diff --git a/.dockerignore b/.dockerignore index ec80feb0..a3ee67b0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,8 +15,9 @@ main_org.js pouchdb-browser.js production/ -# Test coverage and reports +# Test coverage and reports coverage/ +_testdata/ test/bench-network/bench-results/ src/apps/cli/testdeno/bench-results/ diff --git a/.eslintrc b/.eslintrc index f55fe989..7f98b4a6 100644 --- a/.eslintrc +++ b/.eslintrc @@ -20,8 +20,6 @@ "ignorePatterns": [ "**/node_modules/*", "**/jest.config.js", - "src/lib/coverage", - "src/lib/browsertest", "**/test.ts", "**/tests.ts", "**/**test.ts", @@ -56,4 +54,4 @@ } ] } -} \ No newline at end of file +} diff --git a/.github/ISSUE_TEMPLATE/issue-report.md b/.github/ISSUE_TEMPLATE/issue-report.md index 0184d931..e10cd9ba 100644 --- a/.github/ISSUE_TEMPLATE/issue-report.md +++ b/.github/ISSUE_TEMPLATE/issue-report.md @@ -8,7 +8,7 @@ assignees: '' --- Thank you for taking the time to report this issue! -Before filling in this form, please read: [How to report an issue](../docs/to_issue_reporting.md). +Before filling in this form, please read [How to report an issue](https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/to_issue_reporting.md). Issues with sufficient information will be prioritised. @@ -49,13 +49,14 @@ To get it: open the command palette → "Show debug info". ### LiveSync version -The hatch report (below) includes version information. If you cannot provide the report, please fill in the version here. +The full LiveSync report below includes version information. If you cannot provide the report, please fill in the version here. -- Self-hosted LiveSync version: +- Self-hosted LiveSync version: ### Report and Logs from LiveSync -Perform a `Generate full report for opening the issue with debug info` command and provide the generated report. This contains detailed information and recent 1000 log lines, which is very helpful for debugging. **PLEASE AMEND THE REPORT TO REMOVE ANY SENSITIVE INFORMATION BEFORE PASTING.** -If too large to paste here, upload to [Gist](https://gist.github.com/) and share the link. +Run `Generate full report for opening the issue with debug info` and provide the generated report. It contains detailed information and up to 1,000 recent log lines. Review the complete output, and remove credentials, private remote details, Vault names, file paths, file contents, and other private information before sharing it. + +If the report is too large to paste here, upload the redacted report to [Gist](https://gist.github.com/) and share the link.
Report and Logs (primary) diff --git a/.github/workflows/cli-deno-tests.yml b/.github/workflows/cli-deno-tests.yml index afcf60e2..d249fa00 100644 --- a/.github/workflows/cli-deno-tests.yml +++ b/.github/workflows/cli-deno-tests.yml @@ -8,9 +8,6 @@ on: paths: - '.github/workflows/cli-deno-tests.yml' - 'src/apps/cli/**' - - 'src/lib/src/API/processSetting.ts' - - 'src/lib/src/replication/trystero/**' - - 'src/lib/src/rpc/**' - 'test/bench-network/**' - 'package.json' - 'package-lock.json' @@ -18,9 +15,6 @@ on: paths: - '.github/workflows/cli-deno-tests.yml' - 'src/apps/cli/**' - - 'src/lib/src/API/processSetting.ts' - - 'src/lib/src/replication/trystero/**' - - 'src/lib/src/rpc/**' - 'test/bench-network/**' - 'package.json' - 'package-lock.json' @@ -31,8 +25,6 @@ on: type: choice options: - test:ci - - test:p2p - - test:all - test:local - test:e2e-matrix default: test:ci @@ -66,12 +58,6 @@ jobs: test:ci) TASK_MATRIX='["test:setup-put-cat","test:mirror","test:daemon","test:push-pull","test:decoupled-vault","test:sync-two-local","test:sync-locked-remote","test:remote-commands","test:e2e-matrix:couchdb-enc0","test:e2e-matrix:couchdb-enc1","test:e2e-matrix:minio-enc0","test:e2e-matrix:minio-enc1"]' ;; - test:p2p) - TASK_MATRIX='["test:p2p-host","test:p2p-peers","test:p2p-sync","test:p2p-three-nodes","test:p2p-upload-download"]' - ;; - test:all) - TASK_MATRIX='["test:setup-put-cat","test:mirror","test:daemon","test:push-pull","test:decoupled-vault","test:sync-two-local","test:sync-locked-remote","test:remote-commands","test:p2p-host","test:p2p-peers","test:p2p-sync","test:p2p-three-nodes","test:p2p-upload-download","test:e2e-matrix:couchdb-enc0","test:e2e-matrix:couchdb-enc1","test:e2e-matrix:minio-enc0","test:e2e-matrix:minio-enc1"]' - ;; test:local) TASK_MATRIX='["test:setup-put-cat","test:mirror","test:daemon"]' ;; @@ -99,8 +85,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - with: - submodules: recursive - name: Setup Node.js uses: actions/setup-node@v4 @@ -168,8 +152,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - with: - submodules: recursive - name: Show Docker versions run: | @@ -178,7 +160,7 @@ jobs: - name: Run Compose CLI P2P E2E env: - CLI_E2E_TASK: test:p2p-sync + CLI_E2E_TASK: test:p2p:ci RELAY: ws://nostr-relay:7777/ PEERS_TIMEOUT: '20' SYNC_TIMEOUT: '60' diff --git a/.github/workflows/cli-docker.yml b/.github/workflows/cli-docker.yml index f5204400..54eaa5fd 100644 --- a/.github/workflows/cli-docker.yml +++ b/.github/workflows/cli-docker.yml @@ -2,7 +2,8 @@ # Image tag format: --cli # Example: 0.25.56-1743500000-cli # -# The image is also tagged 'latest' for convenience. +# Stable releases are also tagged with their major-minor version and 'latest'. +# Pre-releases receive immutable version and SHA-qualified tags only. # Image name: ghcr.io//livesync-cli name: Build and Push CLI Docker Image @@ -22,7 +23,6 @@ on: - "src/apps/webpeer/**" - ".github/workflows/release.yml" - ".github/workflows/unit-ci.yml" - - ".github/workflows/harness-ci.yml" workflow_dispatch: inputs: dry_run: @@ -47,8 +47,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - with: - submodules: recursive - name: Derive image tag id: meta @@ -61,8 +59,13 @@ jobs: # Build tag list based on the event and git ref TAGS="" if [[ "${{ github.ref }}" == refs/tags/* ]]; then - # Stable release builds - TAGS="${IMAGE}:${VERSION}-cli,${IMAGE}:${MAJOR_MINOR}-cli,${IMAGE}:latest,${IMAGE}:${VERSION}-sha-${SHORT_SHA}-cli" + if [[ "${VERSION}" == *-* ]]; then + # Pre-release builds must not advance stable moving tags. + TAGS="${IMAGE}:${VERSION}-cli,${IMAGE}:${VERSION}-sha-${SHORT_SHA}-cli" + else + # Stable release builds + TAGS="${IMAGE}:${VERSION}-cli,${IMAGE}:${MAJOR_MINOR}-cli,${IMAGE}:latest,${IMAGE}:${VERSION}-sha-${SHORT_SHA}-cli" + fi elif [[ "${{ github.ref }}" == refs/heads/main ]]; then # Bleeding-edge / nightly builds TAGS="${IMAGE}:edge" diff --git a/.github/workflows/cli-e2e.yml b/.github/workflows/cli-e2e.yml index 17388606..6b3fcc4b 100644 --- a/.github/workflows/cli-e2e.yml +++ b/.github/workflows/cli-e2e.yml @@ -23,8 +23,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - with: - submodules: recursive - name: Setup Node.js uses: actions/setup-node@v4 @@ -64,4 +62,4 @@ jobs: working-directory: src/apps/cli run: | bash ./util/couchdb-stop.sh >/dev/null 2>&1 || true - bash ./util/minio-stop.sh >/dev/null 2>&1 || true \ No newline at end of file + bash ./util/minio-stop.sh >/dev/null 2>&1 || true diff --git a/.github/workflows/cli-p2p-compose-smoke.yml b/.github/workflows/cli-p2p-compose-smoke.yml index 882c8592..4b62bbd0 100644 --- a/.github/workflows/cli-p2p-compose-smoke.yml +++ b/.github/workflows/cli-p2p-compose-smoke.yml @@ -39,8 +39,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - with: - submodules: recursive - name: Show Docker versions run: | diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index e96c4c62..9e97b8f3 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -7,10 +7,18 @@ on: - main paths: - 'aggregator.html' + - 'src/**' + - 'test/browser-apps/**' + - 'package.json' + - 'package-lock.json' - '.github/workflows/deploy-pages.yml' pull_request: paths: - 'aggregator.html' + - 'src/**' + - 'test/browser-apps/**' + - 'package.json' + - 'package-lock.json' - '.github/workflows/deploy-pages.yml' permissions: @@ -30,6 +38,29 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24.x' + cache: 'npm' + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.9.2 + + - name: Install dependencies + run: npm ci + + - name: Install Chromium + run: npx playwright@1.62.0 install --with-deps chromium + + - name: Validate browser applications + run: npm run test:browser-apps + + - name: Validate browser application interoperability + run: npm run test:e2e:browser-apps:interop + - name: Validate aggregator run: | test -s aggregator.html @@ -41,10 +72,18 @@ jobs: - name: Prepare Pages site run: | - mkdir -p _site + mkdir -p _site/webapp _site/webpeer cp aggregator.html _site/aggregator.html + cp -R src/apps/webapp/dist/. _site/webapp/ + cp -R src/apps/webpeer/dist/. _site/webpeer/ + test -s _site/webapp/index.html + test -s _site/webapp/webapp.html + test -s _site/webpeer/index.html touch _site/.nojekyll + - name: Validate packaged Pages site + run: npm run test:browser-apps:pages + - name: Configure GitHub Pages uses: actions/configure-pages@v5 diff --git a/.github/workflows/finalise-release.yml b/.github/workflows/finalise-release.yml index 9f07c7bc..697a10d4 100644 --- a/.github/workflows/finalise-release.yml +++ b/.github/workflows/finalise-release.yml @@ -15,6 +15,16 @@ on: description: Full head commit SHA reviewed in the release PR required: true type: string + prerelease: + description: Mark the GitHub Release as a pre-release + required: false + type: boolean + default: false + publish_cli: + description: Create the CLI tag and publish its container image + required: false + type: boolean + default: true jobs: finalise: @@ -41,7 +51,6 @@ jobs: with: ref: ${{ steps.branch.outputs.name }} fetch-depth: 0 - submodules: recursive - name: Use Node.js uses: actions/setup-node@v4 @@ -52,6 +61,8 @@ jobs: env: VERSION: ${{ inputs.version }} EXPECTED_HEAD_SHA: ${{ inputs.expected_head_sha }} + PRERELEASE: ${{ inputs.prerelease }} + PUBLISH_CLI: ${{ inputs.publish_cli }} run: | set -euo pipefail ACTUAL_HEAD_SHA="$(git rev-parse HEAD)" @@ -59,51 +70,78 @@ jobs: echo "Release branch head is ${ACTUAL_HEAD_SHA}, expected ${EXPECTED_HEAD_SHA}." >&2 exit 1 fi + if [[ "${VERSION}" == *-* && "${PRERELEASE}" != "true" ]]; then + echo "Version ${VERSION} is a pre-release version, but prerelease was not enabled." >&2 + exit 1 + fi + if [[ "${VERSION}" != *-* && "${PRERELEASE}" == "true" && "${PUBLISH_CLI}" == "true" ]]; then + echo "A stable version staged as a pre-release must use publish_cli=false so that the CLI latest and major-minor image tags do not advance before BRAT validation." >&2 + exit 1 + fi node utils/release-notes.mjs validate "${VERSION}" - git fetch --tags --force - if git rev-parse --verify --quiet "refs/tags/${VERSION}" >/dev/null; then - echo "Tag already exists: ${VERSION}" >&2 - exit 1 - fi - if git rev-parse --verify --quiet "refs/tags/${VERSION}-cli" >/dev/null; then - echo "Tag already exists: ${VERSION}-cli" >&2 - exit 1 - fi - - name: Create and push release tags + - name: Ensure and push release tags env: VERSION: ${{ inputs.version }} + EXPECTED_HEAD_SHA: ${{ inputs.expected_head_sha }} + PUBLISH_CLI: ${{ inputs.publish_cli }} run: | set -euo pipefail - git tag "${VERSION}" - git tag "${VERSION}-cli" - git push origin "${VERSION}" "${VERSION}-cli" + git fetch --tags --force + if [[ "${PUBLISH_CLI}" == "true" ]]; then + node utils/release-tags.mjs ensure "${VERSION}" "${EXPECTED_HEAD_SHA}" + git push --atomic origin "refs/tags/${VERSION}" "refs/tags/${VERSION}-cli" + else + node utils/release-tags.mjs ensure "${VERSION}" "${EXPECTED_HEAD_SHA}" --plugin-only + git push origin "refs/tags/${VERSION}" + fi - name: Dispatch release workflows env: GH_TOKEN: ${{ github.token }} VERSION: ${{ inputs.version }} + PRERELEASE: ${{ inputs.prerelease }} + PUBLISH_CLI: ${{ inputs.publish_cli }} run: | set -euo pipefail + if [[ "${PUBLISH_CLI}" == "true" ]]; then + gh workflow run cli-docker.yml \ + --ref "${VERSION}-cli" \ + --field dry_run=false \ + --field force=false + fi gh workflow run release.yml \ --ref "${VERSION}" \ --field tag="${VERSION}" \ --field draft=true \ - --field prerelease=false - gh workflow run cli-docker.yml \ - --ref "${VERSION}-cli" \ - --field dry_run=false \ - --field force=false + --field prerelease="${PRERELEASE}" - name: Summarise next steps env: VERSION: ${{ inputs.version }} + PRERELEASE: ${{ inputs.prerelease }} + PUBLISH_CLI: ${{ inputs.publish_cli }} run: | { - echo "Created tags \`${VERSION}\` and \`${VERSION}-cli\`." + echo "Ensured the plug-in tag \`${VERSION}\` points to the reviewed release commit." + if [[ "${PUBLISH_CLI}" == "true" ]]; then + echo "The CLI tag \`${VERSION}-cli\` was also created, and finalisation explicitly dispatched the CLI container workflow." + else + echo "CLI publication was omitted." + fi echo "" echo "Dispatched the plug-in release workflow for \`${VERSION}\`. After approval for the release environment, it creates a draft GitHub Release." - echo "Dispatched the CLI Docker workflow for \`${VERSION}-cli\`. It publishes the version, major-minor, latest, and SHA-qualified image tags." echo "" - echo "Keep the release pull request in draft after publishing the GitHub Release as the latest stable release. Mark it ready and merge it only after BRAT validation succeeds." + if [[ "${VERSION}" == *-* ]]; then + echo "Publish the draft as a pre-release without replacing the latest stable release." + echo "Keep the release pull request in draft and unmerged after BRAT validation; close it only through a separate maintainer action." + elif [[ "${PRERELEASE}" == "true" ]]; then + echo "Publish the draft initially as a pre-release without replacing the latest stable release." + echo "After BRAT validation, remove the pre-release designation and make this exact release the latest stable release before its manifest reaches the default branch." + echo "Confirm the release is no longer a pre-release, then merge the release pull request into its reviewed base branch and integrate the exact release commit into the default branch." + echo "This order prevents Community Directory review from inspecting a stable manifest version while the matching GitHub Release remains a pre-release." + echo "Create the stable CLI tag and publish its latest and major-minor image tags through a separate maintainer gate." + else + echo "Publish the draft as the latest stable release, keep the release pull request in draft, and merge only after BRAT validation succeeds." + fi } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/harness-ci.yml b/.github/workflows/harness-ci.yml deleted file mode 100644 index c9fff4a1..00000000 --- a/.github/workflows/harness-ci.yml +++ /dev/null @@ -1,68 +0,0 @@ -# Run tests by Harnessed CI -name: harness-ci - -on: - workflow_dispatch: - inputs: - testsuite: - description: 'Run specific test suite (leave empty to run all)' - type: choice - options: - - '' - - 'suite/' - - 'suitep2p/' - default: '' - -permissions: - contents: read - -jobs: - test: - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '24.x' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Install test dependencies (Playwright Chromium) - run: npm run test:install-dependencies - - - name: Start test services (CouchDB) - run: npm run test:docker-couchdb:start - if: ${{ inputs.testsuite == '' || inputs.testsuite == 'suite/' }} - - name: Start test services (MinIO) - run: npm run test:docker-s3:start - if: ${{ inputs.testsuite == '' || inputs.testsuite == 'suite/' }} - - name: Start test services (Nostr Relay + WebPeer) - run: npm run test:docker-p2p:start - if: ${{ inputs.testsuite == '' || inputs.testsuite == 'suitep2p/' }} - - name: Run tests suite - if: ${{ inputs.testsuite == '' || inputs.testsuite == 'suite/' }} - env: - CI: true - run: npm run test suite/ - - name: Run P2P tests suite - if: ${{ inputs.testsuite == '' || inputs.testsuite == 'suitep2p/' }} - env: - CI: true - run: npm run test:p2p - - name: Stop test services (CouchDB) - run: npm run test:docker-couchdb:stop - if: ${{ inputs.testsuite == '' || inputs.testsuite == 'suite/' }} - - name: Stop test services (MinIO) - run: npm run test:docker-s3:stop - if: ${{ inputs.testsuite == '' || inputs.testsuite == 'suite/' }} - - name: Stop test services (Nostr Relay + WebPeer) - run: npm run test:docker-p2p:stop - if: ${{ inputs.testsuite == '' || inputs.testsuite == 'suitep2p/' }} \ No newline at end of file diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index ca74d6c7..746530f4 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -37,7 +37,6 @@ jobs: with: ref: ${{ inputs.base_branch }} fetch-depth: 0 - submodules: recursive - name: Use Node.js uses: actions/setup-node@v4 @@ -45,11 +44,6 @@ jobs: node-version: "24.x" cache: npm - - name: Use Deno - uses: denoland/setup-deno@v2 - with: - deno-version: v2.x - - name: Install dependencies run: npm ci @@ -76,12 +70,10 @@ jobs: fi git switch -c "${BRANCH}" - npm version "${VERSION}" --no-git-tag-version + npm version "${VERSION}" --no-git-tag-version --allow-same-version node utils/release-notes.mjs prepare "${VERSION}" - npm run pretty:json - npm run build:lib:types - git add package.json package-lock.json manifest.json versions.json updates.md src/apps/cli/package.json src/apps/webpeer/package.json src/apps/webapp/package.json _types + git add package.json package-lock.json manifest.json versions.json updates.md src/apps/cli/package.json src/apps/webpeer/package.json src/apps/webapp/package.json git diff --cached --check git commit -m "Releasing ${VERSION}" git push --set-upstream origin "${BRANCH}" @@ -95,24 +87,7 @@ jobs: BASE_BRANCH: ${{ inputs.base_branch }} RELEASE_BRANCH: ${{ steps.prepare.outputs.branch }} run: | - cat > /tmp/release-pr-body.md < [!IMPORTANT] - > **Merge intentionally on hold** - > - > Publishing the GitHub Release does not unblock this pull request. Keep this pull request in draft, and leave \`main\` on the previous release, until the published build has passed BRAT validation. - - ## Release checklist - - - [ ] Review and polish \`updates.md\` - - [ ] Confirm the release date - - [ ] Confirm \`manifest.json\`, \`versions.json\`, workspace package versions, and generated \`_types\` - - [ ] Confirm CI has passed - - [ ] Run the finalise release workflow with this PR's fixed head SHA - - [ ] Confirm the draft GitHub Release assets and the published CLI image - - [ ] Publish the GitHub Release as the latest stable release while keeping this pull request in draft - - [ ] Validate the published release with BRAT - - [ ] Mark this pull request ready and merge it with a merge commit - EOF + node utils/release-pr-body.mjs "${VERSION}" "${BASE_BRANCH}" > /tmp/release-pr-body.md gh pr create \ --base "${BASE_BRANCH}" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f92a573b..ce155b7d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,5 @@ name: Release Obsidian Plugin on: - push: - # Sequence of patterns matched against refs/tags - tags: - - '*' # Push events to matching any tag format, i.e. 1.0, 20.15.10 - - '!*-cli' # Exclude command-line interface tags workflow_dispatch: inputs: tag: @@ -33,29 +28,24 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 # otherwise, you will failed to push refs to dest repo - submodules: recursive - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + fetch-depth: 0 + ref: ${{ inputs.tag }} - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: '24.x' # You might need to adjust this value to your own version - # Get the version number and put it in a variable - - name: Get Version - id: version + node-version: '24.x' + - name: Validate release + env: + TAG: ${{ inputs.tag }} run: | - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - TAG="${{ inputs.tag }}" - DRAFT="${{ inputs.draft }}" - PRERELEASE="${{ inputs.prerelease }}" - else - TAG="${GITHUB_REF_NAME}" - DRAFT="true" - PRERELEASE="false" + set -euo pipefail + node utils/release-notes.mjs validate "${TAG}" + HEAD_SHA="$(git rev-parse HEAD)" + TAG_SHA="$(git rev-parse "refs/tags/${TAG}^{commit}")" + if [[ "${HEAD_SHA}" != "${TAG_SHA}" ]]; then + echo "Checked-out commit is ${HEAD_SHA}, but tag ${TAG} points to ${TAG_SHA}." >&2 + exit 1 fi - echo "tag=${TAG}" >> $GITHUB_OUTPUT - echo "draft=${DRAFT}" >> $GITHUB_OUTPUT - echo "prerelease=${PRERELEASE}" >> $GITHUB_OUTPUT # Build the plugin - name: Build id: build @@ -70,21 +60,14 @@ jobs: 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 }} - name: Create Release and Upload Assets uses: softprops/action-gh-release@v2 with: files: | - ${{ github.event.repository.name }}.zip main.js manifest.json styles.css - name: ${{ steps.version.outputs.tag }} - tag_name: ${{ steps.version.outputs.tag }} - draft: ${{ steps.version.outputs.draft }} - prerelease: ${{ steps.version.outputs.prerelease }} + name: ${{ inputs.tag }} + tag_name: ${{ inputs.tag }} + draft: ${{ inputs.draft }} + prerelease: ${{ inputs.prerelease }} diff --git a/.github/workflows/unit-ci.yml b/.github/workflows/unit-ci.yml index 5d173f33..abd8c2b1 100644 --- a/.github/workflows/unit-ci.yml +++ b/.github/workflows/unit-ci.yml @@ -17,10 +17,15 @@ on: - 'vitest.config*.ts' - 'esbuild.config.mjs' - 'eslint.config.mjs' + - 'eslint.config.common.mjs' + - 'eslint.community.config.mjs' - 'update-workspaces.mjs' - 'version-bump.mjs' - 'utils/release-*.mjs' - 'utils/release-*.unit.spec.ts' + - 'utils/couchdb/**' + - 'utils/flyio/**' + - 'utils/setup/**' - '.github/workflows/prepare-release.yml' - '.github/workflows/finalise-release.yml' - '.github/workflows/release.yml' @@ -36,10 +41,15 @@ on: - 'vitest.config*.ts' - 'esbuild.config.mjs' - 'eslint.config.mjs' + - 'eslint.config.common.mjs' + - 'eslint.community.config.mjs' - 'update-workspaces.mjs' - 'version-bump.mjs' - 'utils/release-*.mjs' - 'utils/release-*.unit.spec.ts' + - 'utils/couchdb/**' + - 'utils/flyio/**' + - 'utils/setup/**' - '.github/workflows/prepare-release.yml' - '.github/workflows/finalise-release.yml' - '.github/workflows/release.yml' @@ -49,6 +59,54 @@ permissions: contents: read jobs: + setup-tools: + name: Self-hosted setup tools + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24.x' + cache: 'npm' + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Install dependencies + run: npm ci + + - name: Run setup-tool contract tests + run: npm run test:setup-tools + + - name: Create CouchDB test configuration + run: | + cat < .test.env + hostname=http://127.0.0.1:5989/ + dbname=livesync-test-db + username=admin + password=testpassword + EOF + + - name: Start CouchDB + run: npm run test:docker-couchdb:start + + - name: Provision a versioned LiveSync database + run: npx dotenv-cli -e .test.env -- env database=setup-tools-ci retry_count=1 retry_delay_ms=0 ./utils/couchdb/couchdb-init.sh + + - name: Verify the Commonlib database version + run: | + npx dotenv-cli -e .test.env -- bash -lc 'curl --fail --silent --show-error --user "${username}:${password}" "${hostname}/setup-tools-ci/obsydian_livesync_version" | node --input-type=module -e "import { VER } from \"@vrtmrz/livesync-commonlib/compat/common/types\";let input=\"\";process.stdin.on(\"data\",chunk=>input+=chunk).on(\"end\",()=>{const document=JSON.parse(input);if(document.type!==\"versioninfo\"||document.version!==VER)throw new Error(\"Unexpected LiveSync database version\");})"' + + - name: Stop CouchDB + if: always() + run: npm run test:docker-couchdb:stop || true + unit-test: name: Unit Tests runs-on: ubuntu-latest @@ -56,8 +114,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - with: - submodules: recursive - name: Setup Node.js uses: actions/setup-node@v4 @@ -68,9 +124,15 @@ jobs: - name: Install dependencies run: npm ci + - name: Run source checks + run: npm run check + - name: Run unit tests suite with coverage run: npm run test:unit:coverage + - name: Run real-Obsidian runner contract tests + run: npm run test:e2e:obsidian:runner + - name: Upload coverage report if: always() uses: actions/upload-artifact@v4 @@ -85,19 +147,35 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - with: - submodules: recursive + + - name: Detect LiveSync-owned integration tests + id: integration_tests + shell: bash + run: | + git ls-files -- ':(glob)**/*.integration.spec.ts' ':(glob)**/*.integration.test.ts' > "$RUNNER_TEMP/livesync-integration-tests.txt" + if [[ -s "$RUNNER_TEMP/livesync-integration-tests.txt" ]]; then + echo 'present=true' >> "$GITHUB_OUTPUT" + else + echo 'present=false' >> "$GITHUB_OUTPUT" + fi + + - name: Record delegated integration coverage + if: ${{ steps.integration_tests.outputs.present != 'true' }} + run: echo 'No LiveSync-owned integration tests are present. Commonlib integration tests run in the Commonlib package CI.' >> "$GITHUB_STEP_SUMMARY" - name: Setup Node.js + if: ${{ steps.integration_tests.outputs.present == 'true' }} uses: actions/setup-node@v4 with: node-version: '24.x' cache: 'npm' - name: Install dependencies + if: ${{ steps.integration_tests.outputs.present == 'true' }} run: npm ci - name: Create environment configuration files + if: ${{ steps.integration_tests.outputs.present == 'true' }} run: | cat < .env BUILD_MODE=dev @@ -115,16 +193,19 @@ jobs: EOF - name: Start CouchDB container + if: ${{ steps.integration_tests.outputs.present == 'true' }} run: npm run test:docker-couchdb:start - name: Start MinIO container + if: ${{ steps.integration_tests.outputs.present == 'true' }} run: npm run test:docker-s3:start - name: Run integration tests + if: ${{ steps.integration_tests.outputs.present == 'true' }} run: npm run test:integration - name: Stop containers - if: always() + if: ${{ always() && steps.integration_tests.outputs.present == 'true' }} run: | npm run test:docker-couchdb:stop || true npm run test:docker-s3:stop || true diff --git a/.gitignore b/.gitignore index c443c8ed..f883e1a3 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,8 @@ cov_profile/** coverage src/apps/cli/dist/* +src/apps/webapp/playwright-report/ +src/apps/webapp/test-results/ _testdata/** utils/bench/splitResults.csv -.eslintcache \ No newline at end of file +.eslintcache diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index ea943b62..00000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "src/lib"] - path = src/lib - url = https://github.com/vrtmrz/livesync-commonlib diff --git a/AGENTS.md b/AGENTS.md index 36c9df53..5148dabd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,10 +5,10 @@ When working on this repository (writing code, comments, documentation, or commi ## Required Reference Files Before making changes to documentation, user-facing text, or settings: -1. Read [docs/terms.md](file:///Users/vorotamoroz/dev/js/obsidian-livesync/docs/terms.md) for terminology, vocabulary conventions, and technical definitions. -2. Read [docs/settings.md](file:///Users/vorotamoroz/dev/js/obsidian-livesync/docs/settings.md) (and [docs/settings_ja.md](file:///Users/vorotamoroz/dev/js/obsidian-livesync/docs/settings_ja.md)) for UI settings and setting key mappings. -3. Read [docs/troubleshooting.md](file:///Users/vorotamoroz/dev/js/obsidian-livesync/docs/troubleshooting.md) for troubleshooting guidelines and common recovery steps (such as flag files and SCRAM state). -4. Read [devs.md](file:///Users/vorotamoroz/dev/js/obsidian-livesync/devs.md) for development workflows, module architecture, and testing infrastructure. +1. Read [docs/terms.md](docs/terms.md) for terminology, vocabulary conventions, and technical definitions. +2. Read [docs/settings.md](docs/settings.md) (and [docs/settings_ja.md](docs/settings_ja.md)) for UI settings and setting key mappings. +3. Read [docs/troubleshooting.md](docs/troubleshooting.md) for troubleshooting guidelines and common recovery steps (such as flag files and SCRAM state). +4. Read [devs.md](devs.md) for development workflows, module architecture, and testing infrastructure. --- @@ -45,10 +45,10 @@ Always adhere to the following stylistic and spelling rules: - **Fast Setup (Simple Fetch)** is the preferred flow for initial replication on secondary devices. It utilises stream-based replication for high speed and delays local file reflection to suppress temporary synchronisation warnings. - **Flag files** (such as `redflag.md`, `redflag2.md`, and `redflag3.md`) at the root of the vault control the boot-up sequence and trigger automated fetch/rebuild tasks. 3. **Subrepositories**: - - The directory [src/lib](file:///Users/vorotamoroz/dev/js/obsidian-livesync/src/lib) is a subrepository (Git submodule) pointing to the shared library `livesync-commonlib`. Do not make modifications inside this directory without careful consideration, as changes affect the shared library. + - Treat `@vrtmrz/livesync-commonlib` as an external, authoritative package. Make Commonlib changes in its repository, validate the packed artefact and downstream LiveSync consumer, and update the exact dependency here. Do not recreate a `src/lib` source mirror or generated `_types` fallback. 4. **Application Directories**: - - The directory [src/apps](file:///Users/vorotamoroz/dev/js/obsidian-livesync/src/apps) contains independent application modules: - - `cli`: A Command Line Interface application. Tests specifically for the CLI (both unit and End-to-End tests) are located and executed within [src/apps/cli](file:///Users/vorotamoroz/dev/js/obsidian-livesync/src/apps/cli) using its local `package.json` scripts. + - The directory [src/apps](src/apps) contains independent application modules: + - `cli`: A Command Line Interface application. Tests specifically for the CLI (both unit and End-to-End tests) are located and executed within [src/apps/cli](src/apps/cli) using its local `package.json` scripts. - `webapp`: A Web-based application. - `webpeer`: A Web-based peer utility. @@ -62,7 +62,8 @@ Before submitting code, you should run verification scripts locally to ensure co - Run `npm run check` to perform code verification. This runs type-checking (`tsc-check`), ESLint (`lint`), and Svelte checks (`svelte-check`). 2. **Unit Tests**: - Run `npm run test:unit` to execute fast local unit tests. - - Run `npm run test` or `npm run test:full` for full testing suites (including dockerised services). + - Run `npm run test:unit:coverage` when unit-test coverage is required. + - Run focused integration, CLI E2E, or real Obsidian E2E commands for the boundary being changed. Start only the Docker services required by that command. 3. **Build**: - Run `npm run build` to compile the production bundle (`main.js`). - Run `npm run dev` for the development watch/build task. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 487bc4fc..a97333b6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,13 +6,9 @@ Thank you for your interest in contributing to Self-hosted LiveSync! We welcome To set up the development environment, please follow these steps: -1. Clone the repository recursively to ensure all Git submodules are loaded: +1. Clone the repository: ```bash - git clone --recursive https://github.com/vrtmrz/obsidian-livesync - ``` - If you have already cloned the repository without submodules, run the following command: - ```bash - git submodule update --init --recursive + git clone https://github.com/vrtmrz/obsidian-livesync ``` 2. Install the package dependencies: @@ -25,7 +21,7 @@ To set up the development environment, please follow these steps: npm run build ``` -For a more comprehensive guide on development workflows, testing configurations, and subrepos, please refer to [devs.md](devs.md). +For a more comprehensive guide on development workflows, testing configurations, and the Commonlib dependency, please refer to [devs.md](devs.md). ## Guidelines for Contributions @@ -37,10 +33,16 @@ Before submitting a pull request, you must run verification scripts locally to e ```bash npm run check ``` + This also type-checks the maintained CLI and browser applications, and applies the Community directory blocker rules. Run `npm run lint:community` separately to inspect its non-blocking recommendations. - Run unit tests: ```bash npm run test:unit ``` +- When changing the troubleshooting or recovery guides, inspect their current English UI labels and local references: + ```bash + npm run inspect:troubleshooting + ``` + This read-only Inspector prints JSON containing `ok`, `checkedFiles`, `checkedLocalReferences`, and `errors`, and exits unsuccessfully when a contract is stale. If you have the capability and a suitable environment (such as Linux and Docker), running the CLI End-to-End (E2E) tests is also highly appreciated. Instructions are detailed in [devs.md](devs.md). If you cannot run E2E tests locally, please explicitly ask to run the tests on the CI by stating 'Please run CI tests' in your pull request description. @@ -61,9 +63,9 @@ For a detailed list of vocabulary conventions and terms, please refer to [docs/t To add or update translations, please refer to [docs/adding_translations.md](docs/adding_translations.md) for detailed instructions. -### 4. Git Submodules +### 4. Commonlib changes -The `src/lib` directory is a Git submodule pointing to the shared library `livesync-commonlib`. If you wish to propose changes to the shared library, do not modify `src/lib` directly. Instead, please submit a separate pull request to the [livesync-commonlib repository](https://github.com/vrtmrz/livesync-commonlib). +Shared synchronisation behaviour is provided by the `@vrtmrz/livesync-commonlib` package. If you wish to change that library, submit a separate pull request to the [livesync-commonlib repository](https://github.com/vrtmrz/livesync-commonlib), validate its packed artefact, then update the locked dependency in this repository. Do not add a source mirror or generated fallback declarations to this repository. ## License diff --git a/README.md b/README.md index 57eb828b..c26fc257 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Self-hosted LiveSync is a community-developed synchronisation plug-in available on all Obsidian-compatible platforms. It leverages robust server solutions such as CouchDB or object storage systems (e.g., MinIO, S3, R2, etc.) to ensure reliable data synchronisation. -Additionally, it supports peer-to-peer synchronisation using WebRTC, enabling you to synchronise your notes directly between devices without relying on a server. Documentation is available for [Peer-to-Peer Synchronisation](./docs/p2p_sync_updates_2026.md). +Additionally, it supports peer-to-peer synchronisation using WebRTC, enabling devices to exchange notes without a central data-storage server. A signalling relay is still required for peer discovery. See [How peer-to-peer synchronisation works](./docs/p2p.md). ![obsidian_live_sync_demo](https://user-images.githubusercontent.com/45774780/137355323-f57a8b09-abf2-4501-836c-8cb7d2ff24a3.gif) @@ -18,15 +18,12 @@ Additionally, it supports peer-to-peer synchronisation using WebRTC, enabling yo - Use open-source solutions for the server. - Compatible solutions are supported. - Support end-to-end encryption. -- Synchronise settings, snippets, themes, and plug-ins via [Customisation Sync (Beta)](docs/settings.md#6-customization-sync-advanced) or [Hidden File Sync](docs/settings.md#7-hidden-files-advanced). -- Enable WebRTC peer-to-peer synchronisation without requiring a `host` (Experimental). - - This feature is still in the experimental stage. Please exercise caution when using it. - - WebRTC is a peer-to-peer synchronisation method, so **at least one device must be online to synchronise**. - - Instead of keeping your device online as a stable peer, you can use two pseudo-peers: - - [livesync-serverpeer](https://github.com/vrtmrz/livesync-serverpeer): A pseudo-client running on the server for receiving and sending data between devices. - - [webpeer](https://github.com/vrtmrz/obsidian-livesync/tree/main/src/apps/webpeer): A pseudo-client for receiving and sending data between devices. - - A pre-built instance is available at [fancy-syncing.vrtmrz.net/webpeer](https://fancy-syncing.vrtmrz.net/webpeer/) (hosted on the vrtmrz's blog site). This is also peer-to-peer. Feel free to use it. - - For more information, refer to the [English explanatory article](https://fancy-syncing.vrtmrz.net/blog/0034-p2p-sync-en.html) or the [Japanese explanatory article](https://fancy-syncing.vrtmrz.net/blog/0034-p2p-sync). +- Synchronise settings, snippets, themes, and plug-ins via [Customisation Sync (Beta)](docs/settings.md#6-customisation-sync-advanced) or [Hidden File Sync](docs/tips/hidden-file-sync.md). +- Enable supported, opt-in WebRTC peer-to-peer synchronisation. + - No central data-storage server is required, but a signalling relay is still required for peer discovery. + - At least one device containing the required data must be online while another device synchronises. + - Follow the [Peer-to-Peer Setup](docs/setup_p2p.md) after reviewing the [P2P communication model](docs/p2p.md). + - If you would like to see how P2P setup and connectivity behave on your devices, try the guided connection check in the experimental [WebPeer browser utility](https://vrtmrz.github.io/obsidian-livesync/webpeer/). It uses an empty Vault and does not test note synchronisation. See the [WebPeer documentation](src/apps/webpeer/README.md) for its scope and limitations. This plug-in may be particularly useful for researchers, engineers, and developers who need to keep their notes fully self-hosted for security reasons. It is also suitable for anyone seeking the peace of mind that comes with knowing their notes remain entirely private. @@ -46,15 +43,27 @@ This plug-in may be particularly useful for researchers, engineers, and develope 1. [Set up CouchDB on fly.io](docs/setup_flyio.md) 2. Configure plug-in in [Quick Setup](docs/quick_setup.md) -### Manual Setup +### Setup workflows + +Choose a synchronisation method, prepare its server where required, then follow the corresponding client setup: + +1. CouchDB + 1. Prepare the server: + - [Set up your own CouchDB server](docs/setup_own_server.md). + - [Set up CouchDB on fly.io](docs/setup_flyio.md). + 2. Configure the clients by following [CouchDB Quick Setup](docs/quick_setup.md). +2. Object Storage + 1. Prepare the server. A maintained MinIO server installation guide is not currently available here, so set up an S3-compatible service or server of your choice. + 2. Configure the clients by following [Object Storage Setup](docs/setup_object_storage.md). +3. Peer-to-Peer + 1. No central data-storage server is required. The project's public signalling relay requires no server provisioning; controlled deployments can provide another compatible relay. + 2. Configure the clients by following [Peer-to-Peer Setup](docs/setup_p2p.md). + +Each workflow establishes ordinary note synchronisation on the first device, generates a Setup URI for each additional device from that working device, and verifies synchronisation in both directions. -1. Set up the server - 1. [Set up CouchDB on fly.io](docs/setup_flyio.md) - 2. [Set up your CouchDB](docs/setup_own_server.md) -2. Configure plug-in in [Quick Setup](docs/quick_setup.md) > [!TIP] > Fly.io is no longer free. Fortunately, we can still use IBM Cloudant despite some limitations. Refer to [Set up IBM Cloudant](docs/setup_cloudant.md). -> We can also use peer-to-peer synchronisation without a server. Alternatively, cheap object storage like Cloudflare R2 can be used for free. +> We can also use peer-to-peer synchronisation without a central data-storage server; a signalling relay is still used for peer discovery. Alternatively, cheap object storage like Cloudflare R2 can be used for free. > However, most importantly, we can use a server that we trust. Therefore, please set up your own server. > CouchDB can also be run on a Raspberry Pi (please be mindful of your server's security). @@ -64,7 +73,8 @@ This plug-in may be particularly useful for researchers, engineers, and develope Synchronisation status is shown in the status bar with the following icons. - Activity Indicator - - 📲 Remote activity + - 📲 A finite remote operation is in progress + - 🌐N Approximate remote requests currently in progress - Status - âšī¸ Stopped - 💤 LiveSync enabled. Waiting for changes @@ -88,6 +98,9 @@ To prevent file and database corruption, please avoid closing Obsidian until all ## Tips and Troubleshooting - If you want a faster and simpler initial replication when setting up subsequent devices, see the [Fast Setup Guide](docs/tips/fast-setup.md). +- Configure [Hidden File Sync](docs/tips/hidden-file-sync.md) only after ordinary note synchronisation works. +- If Obsidian or LiveSync cannot start normally, use [Recovery and flag files](docs/recovery.md) before changing or resetting a remote database. +- Self-hosted LiveSync 1.0 requires Obsidian 1.7.2 or later. If you need to use 1.0 on an earlier Obsidian version, please [open an issue](https://github.com/vrtmrz/obsidian-livesync/issues/new?template=issue-report.md) with your version, platform, and reason for remaining on it so that we can assess whether extending support is practical. The standard Community Plugins installer otherwise selects an older compatible plug-in release. - If you are having problems getting the plug-in working, see [Tips and Troubleshooting](docs/troubleshooting.md). ## Acknowledgements diff --git a/_tools/bakei18n.ts b/_tools/bakei18n.ts new file mode 100644 index 00000000..b86cc390 --- /dev/null +++ b/_tools/bakei18n.ts @@ -0,0 +1,13 @@ +import { allMessages } from "../src/common/messages/combinedMessages.dev.ts"; + +const { writeFileSync } = process.getBuiltinModule("node:fs"); +const path = process.getBuiltinModule("node:path"); +const __dirname = import.meta.dirname; +const currentPath = __dirname; +const outDir = path.resolve(currentPath, "../src/common/messages/combinedMessages.prod.ts"); + +process.stdout.write(`Writing to ${outDir}\n`); +writeFileSync( + outDir, + `export const allMessages: Readonly>>> = ${JSON.stringify(allMessages, null, 4)};` +); diff --git a/_tools/checkI18nCoverage.ts b/_tools/checkI18nCoverage.ts new file mode 100644 index 00000000..f8c6d744 --- /dev/null +++ b/_tools/checkI18nCoverage.ts @@ -0,0 +1,63 @@ +import { glob } from "tinyglobby"; +import { parse } from "yaml"; +import { objectToDotted } from "./messagelib.ts"; + +const fsPromises = process.getBuiltinModule("node:fs/promises"); +const path = process.getBuiltinModule("node:path"); +const __dirname = import.meta.dirname; +const targetDir = path.resolve(path.join(__dirname, "../src/common/messagesYAML/")); +const files = (await glob(`*.yaml`, { expandDirectories: false, absolute: true, cwd: targetDir })).sort(); + +function flattenMessages(src: unknown) { + return Object.fromEntries( + Object.entries(objectToDotted(src)) + .map(([key, value]) => [key.endsWith("._value") ? key.slice(0, -7) : key, value] as const) + .filter(([, value]) => typeof value === "string") + .sort(([a], [b]) => a.localeCompare(b)) + ) as Record; +} + +const localeData = new Map>(); +for (const file of files) { + const segments = file.split(/[/\\]/); + const localeFilename = segments[segments.length - 1]; + if (localeFilename === undefined) { + throw new Error(`Could not determine the locale name for ${file}`); + } + const locale = localeFilename.replace(/\.yaml$/, ""); + const content = await fsPromises.readFile(file, "utf-8"); + const parsed: unknown = parse(content); + localeData.set(locale, flattenMessages(parsed ?? {})); +} + +const baseLocale = "en"; +const base = localeData.get(baseLocale); +if (!base) { + throw new Error("en.yaml not found"); +} + +const baseKeys = Object.keys(base); +const report = Object.fromEntries( + [...localeData.entries()].map(([locale, data]) => { + const keys = new Set(Object.keys(data)); + const missing = baseKeys.filter((key) => !keys.has(key)); + const identicalToEnglish = baseKeys.filter( + (key) => keys.has(key) && locale !== baseLocale && data[key] === base[key] + ); + const translated = baseKeys.length - missing.length; + return [ + locale, + { + totalBaseKeys: baseKeys.length, + translatedKeys: translated, + missingKeys: missing.length, + identicalToEnglishCount: identicalToEnglish.length, + coverage: `${translated}/${baseKeys.length}`, + missing, + identicalToEnglish, + }, + ]; + }) +); + +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); diff --git a/_tools/decompileRosetta.ts b/_tools/decompileRosetta.ts new file mode 100644 index 00000000..c2716382 --- /dev/null +++ b/_tools/decompileRosetta.ts @@ -0,0 +1,54 @@ +import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "../src/common/rosetta"; +import { allMessages } from "../src/common/messages/combinedMessages.dev.ts"; + +const { writeFileSync } = process.getBuiltinModule("node:fs"); +const path = process.getBuiltinModule("node:path"); +const thisFileDir = __dirname; +const outDir = path.join(thisFileDir, "i18n"); + +const out = {} as Record; + +for (const [key, value] of Object.entries(allMessages)) { + for (const lang of [...SUPPORTED_I18N_LANGS, "def"]) { + if (!out[lang]) out[lang] = {}; + if (lang in value) { + out[lang][key] = value[lang as I18N_LANGS]; + } else { + if (lang === "def") { + out[lang][key] = key; + } else { + out[lang][key] = undefined; + } + } + } +} + +for (const [lang, value] of Object.entries(out)) { + const filename = `${lang}.ts`; + const escapeString = (prefix: string, key: string, str: string) => { + if (str.indexOf("\n") !== -1) { + const encoded = JSON.stringify(str); + const lineWrapped = encoded.split("\\n").join("\\\n" + prefix); + + return `${prefix}${JSON.stringify(key)}: ${lineWrapped},`; + } + return `${prefix}${JSON.stringify(key)}: ${JSON.stringify(str)},`; + }; + // const z ="a" "b" "c"; + const _stringify = (value: Record) => { + let res = "{\n"; + for (const key of Object.keys(value)) { + const v = value[key]; + if (v) { + res += escapeString("", key, v) + "\n"; + } else { + res += escapeString("// ", key, out["def"]?.[key] ?? "") + "\n"; + } + } + return res + "\n}"; + }; + void writeFileSync( + path.join(outDir, filename), + `export const PartialMessages ={\n "${lang}":${_stringify(value)}\n} as const;` + ); +} diff --git a/_tools/decompileRosettaToJson.ts b/_tools/decompileRosettaToJson.ts new file mode 100644 index 00000000..030c067e --- /dev/null +++ b/_tools/decompileRosettaToJson.ts @@ -0,0 +1,21 @@ +import { allMessages } from "../src/common/messages/combinedMessages.prod.ts"; +const __dirname = import.meta.dirname; + +const { writeFileSync } = process.getBuiltinModule("node:fs"); +const path = process.getBuiltinModule("node:path"); +const thisFileDir = __dirname; +const outDir = path.resolve(thisFileDir, "../src/common/messagesJson"); + +const out = {} as Record; + +for (const [key, value] of Object.entries(allMessages)) { + for (const [lang, langValue] of Object.entries(value)) { + if (!out[lang]) out[lang] = {}; + out[lang][key] = langValue; + } +} + +for (const [lang, value] of Object.entries(out)) { + const filename = `${lang}.json`; + void writeFileSync(path.join(outDir, filename), JSON.stringify(value, null, 4)); +} diff --git a/_tools/inspect-troubleshooting-docs.ts b/_tools/inspect-troubleshooting-docs.ts new file mode 100644 index 00000000..245bf3da --- /dev/null +++ b/_tools/inspect-troubleshooting-docs.ts @@ -0,0 +1,137 @@ +const fsPromises = process.getBuiltinModule("node:fs/promises"); +const path = process.getBuiltinModule("node:path"); +const url = process.getBuiltinModule("node:url"); + +type InspectionError = { + check: "current-label" | "local-reference" | "retired-label"; + file: string; + detail: string; +}; + +export type TroubleshootingDocsInspection = { + ok: boolean; + checkedFiles: string[]; + checkedLocalReferences: number; + errors: InspectionError[]; +}; + +const guidePaths = ["docs/troubleshooting.md", "docs/recovery.md", "docs/tips/p2p-sync-tips.md"] as const; +const messageCataloguePath = "src/common/messagesJson/en.json"; +const markdownLinkPattern = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+["'][^)]*["'])?\)/gu; + +function repositoryRootFromThisFile(): string { + return path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), ".."); +} + +function normaliseReferenceTarget(rawTarget: string): string { + const withoutAngles = rawTarget.startsWith("<") && rawTarget.endsWith(">") ? rawTarget.slice(1, -1) : rawTarget; + return decodeURIComponent(withoutAngles); +} + +function isExternalReference(target: string): boolean { + return /^(?:https?:|mailto:|obsidian:)/u.test(target); +} + +async function inspectLocalReferences( + repositoryRoot: string, + documentPath: string, + document: string, + errors: InspectionError[] +): Promise { + let checked = 0; + for (const match of document.matchAll(markdownLinkPattern)) { + const rawTarget = match[1]; + if (!rawTarget) continue; + const target = normaliseReferenceTarget(rawTarget); + if (isExternalReference(target) || target.startsWith("#")) continue; + + const [pathPart] = target.split("#", 1); + if (!pathPart) continue; + checked++; + const referencedPath = path.resolve(repositoryRoot, path.dirname(documentPath), pathPart); + try { + await fsPromises.access(referencedPath); + } catch { + errors.push({ + check: "local-reference", + file: documentPath, + detail: `Missing local reference: ${path.relative(repositoryRoot, referencedPath)}`, + }); + } + } + return checked; +} + +export async function inspectTroubleshootingDocs( + repositoryRoot = repositoryRootFromThisFile() +): Promise { + const errors: InspectionError[] = []; + const documents = new Map(); + for (const guidePath of guidePaths) { + documents.set(guidePath, await fsPromises.readFile(path.resolve(repositoryRoot, guidePath), "utf8")); + } + + const troubleshooting = documents.get("docs/troubleshooting.md")!; + const catalogue = JSON.parse( + await fsPromises.readFile(path.resolve(repositoryRoot, messageCataloguePath), "utf8") + ) as Record; + const requiredMessageKeys = [ + "TweakMismatchResolve.Action.UseConfigured", + "TweakMismatchResolve.Action.UseMine", + "TweakMismatchResolve.Action.UseRemote", + "TweakMismatchResolve.Action.Dismiss", + "obsidianLiveSyncSettingTab.titleSyncSettingsViaMarkdown", + ] as const; + + for (const messageKey of requiredMessageKeys) { + const label = catalogue[messageKey]; + if (!label) { + errors.push({ + check: "current-label", + file: messageCataloguePath, + detail: `The English message catalogue does not define ${messageKey}.`, + }); + continue; + } + if (!troubleshooting.includes(label)) { + errors.push({ + check: "current-label", + file: "docs/troubleshooting.md", + detail: `The guide does not include the current UI label '${label}'.`, + }); + } + } + + for (const retiredLabel of ["`Update with mine`", "`Use configured`", "`Sync settings via Markdown files`"]) { + if (troubleshooting.includes(retiredLabel)) { + errors.push({ + check: "retired-label", + file: "docs/troubleshooting.md", + detail: `The guide still includes the retired label ${retiredLabel}.`, + }); + } + } + + let checkedLocalReferences = 0; + for (const [guidePath, document] of documents) { + checkedLocalReferences += await inspectLocalReferences(repositoryRoot, guidePath, document, errors); + } + + return { + ok: errors.length === 0, + checkedFiles: [...guidePaths], + checkedLocalReferences, + errors, + }; +} + +async function runCli(): Promise { + const result = await inspectTroubleshootingDocs(); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + if (!result.ok) process.exitCode = 1; +} + +const invokedPath = process.argv[1] ? url.pathToFileURL(path.resolve(process.argv[1])).href : undefined; +if (invokedPath === import.meta.url) { + await runCli(); +} diff --git a/_tools/inspect-troubleshooting-docs.unit.spec.ts b/_tools/inspect-troubleshooting-docs.unit.spec.ts new file mode 100644 index 00000000..f184a162 --- /dev/null +++ b/_tools/inspect-troubleshooting-docs.unit.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { inspectTroubleshootingDocs } from "./inspect-troubleshooting-docs"; + +describe("troubleshooting documentation contract", () => { + it("uses current English UI labels and resolves every local guide reference", async () => { + const result = await inspectTroubleshootingDocs(); + + expect(result.checkedFiles).toEqual([ + "docs/troubleshooting.md", + "docs/recovery.md", + "docs/tips/p2p-sync-tips.md", + ]); + expect(result.checkedLocalReferences).toBeGreaterThan(0); + expect(result.errors).toEqual([]); + expect(result.ok).toBe(true); + }); +}); diff --git a/_tools/json2yaml.ts b/_tools/json2yaml.ts new file mode 100644 index 00000000..299b5d6b --- /dev/null +++ b/_tools/json2yaml.ts @@ -0,0 +1,31 @@ +// Convert Application convenient Message Resources (JSON) to Human-Editable format (YAML) +import { stringify } from "yaml"; +import { glob } from "tinyglobby"; +import { dottedToObject } from "./messagelib"; + +const fsPromises = process.getBuiltinModule("node:fs/promises"); +const path = process.getBuiltinModule("node:path"); +const __dirname = import.meta.dirname; + +const targetDir = path.resolve(path.join(__dirname, "../src/common/messagesJson/")); +process.stdout.write(`Target directory: ${targetDir}\n`); +const files = await glob(`*.json`, { expandDirectories: false, absolute: true, cwd: targetDir }); +for (const file of files) { + const filePath = path.resolve(file); + process.stdout.write(`Processing file: ${filePath}\n`); + const content = await fsPromises.readFile(filePath, "utf-8"); + const jsonDataSrc: unknown = JSON.parse(content); + if (typeof jsonDataSrc !== "object" || jsonDataSrc === null || Array.isArray(jsonDataSrc)) { + throw new TypeError(`Expected ${filePath} to contain a JSON object`); + } + const jsonDataD2 = Object.fromEntries( + Object.entries(jsonDataSrc).sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) + ); + const jsonData = dottedToObject(jsonDataD2); + const yamlData = stringify(jsonData, { indent: 2 }); + const yamlFilePath = filePath.replace(/\.json$/, ".yaml").replace("Json", "YAML"); + await fsPromises.writeFile(yamlFilePath, yamlData, "utf-8"); + process.stdout.write(`Converted ${filePath} to ${yamlFilePath}\n`); +} + +// console.dir(files, { depth: 0 }); diff --git a/_tools/messagelib.ts b/_tools/messagelib.ts new file mode 100644 index 00000000..95a4c664 --- /dev/null +++ b/_tools/messagelib.ts @@ -0,0 +1,49 @@ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function objectToDotted(obj: unknown, prefix = ""): Record { + if (!isRecord(obj)) { + throw new TypeError("Expected a message catalogue object"); + } + const flattened: Record = {}; + for (const [key, value] of Object.entries(obj)) { + const newKey = prefix ? `${prefix}.${key}` : key; + if (isRecord(value)) { + Object.assign(flattened, objectToDotted(value, newKey)); + } else { + flattened[newKey] = value; + } + } + return flattened; +} + +export function dottedToObject(obj: unknown): Record { + if (!isRecord(obj)) { + throw new TypeError("Expected a dotted message catalogue object"); + } + const nestedResult: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (key.includes(" ")) { + nestedResult[key] = value; + continue; + } + const keys = key.split("."); + let nested = nestedResult; + for (const [index, currentKey] of keys.entries()) { + if (index === keys.length - 1) { + nested[currentKey] = value; + continue; + } + const currentValue = nested[currentKey]; + if (isRecord(currentValue)) { + nested = currentValue; + continue; + } + const replacement = currentValue === undefined || currentValue === null ? {} : { _value: currentValue }; + nested[currentKey] = replacement; + nested = replacement; + } + } + return nestedResult; +} diff --git a/_tools/messagelib.unit.spec.ts b/_tools/messagelib.unit.spec.ts new file mode 100644 index 00000000..ae8f612a --- /dev/null +++ b/_tools/messagelib.unit.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { dottedToObject, objectToDotted } from "./messagelib"; + +describe("message catalogue conversion", () => { + it("flattens nested objects while preserving leaf values", () => { + expect( + objectToDotted({ + dialogue: { + title: "Title", + options: ["first", "second"], + }, + "literal key": "Literal", + }) + ).toEqual({ + "dialogue.title": "Title", + "dialogue.options": ["first", "second"], + "literal key": "Literal", + }); + }); + + it("preserves an existing leaf under _value when a dotted child follows it", () => { + expect( + dottedToObject({ + section: "Base value", + "section.child": "Child value", + "literal key": "Literal", + }) + ).toEqual({ + section: { + _value: "Base value", + child: "Child value", + }, + "literal key": "Literal", + }); + }); + + it("rejects non-object catalogue roots", () => { + expect(() => objectToDotted("not an object")).toThrow("Expected a message catalogue object"); + expect(() => dottedToObject(["not", "an", "object"])).toThrow("Expected a dotted message catalogue object"); + }); +}); diff --git a/_tools/yaml2json.ts b/_tools/yaml2json.ts new file mode 100644 index 00000000..d35a39d3 --- /dev/null +++ b/_tools/yaml2json.ts @@ -0,0 +1,30 @@ +// Convert Human-Editable format (YAML) to Application convenient Message Resources (JSON) + +import { parse } from "yaml"; +import { glob } from "tinyglobby"; +import { objectToDotted } from "./messagelib"; + +const fsPromises = process.getBuiltinModule("node:fs/promises"); +const path = process.getBuiltinModule("node:path"); +const __dirname = import.meta.dirname; + +const targetDir = path.resolve(path.join(__dirname, "../src/common/messagesYAML/")); +process.stdout.write(`Target directory: ${targetDir}\n`); +const files = await glob(`*.yaml`, { expandDirectories: false, absolute: true, cwd: targetDir }); +for (const file of files) { + const filePath = path.resolve(file); + const content = await fsPromises.readFile(filePath, "utf-8"); + const jsonDataSrc: unknown = parse(content); + const jsonDataD2 = objectToDotted(jsonDataSrc); + const jsonData = Object.fromEntries( + Object.entries(jsonDataD2) + .map(([key, value]): [string, unknown] => [key.endsWith("._value") ? key.slice(0, -7) : key, value]) + .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) + ); + const yamlData = JSON.stringify(jsonData, null, 4) + "\n"; + const yamlFilePath = filePath.replace(/\.yaml$/, ".json").replace("YAML", "Json"); + await fsPromises.writeFile(yamlFilePath, yamlData, "utf-8"); + process.stdout.write(`Converted ${filePath} to ${yamlFilePath}\n`); +} + +// console.dir(files, { depth: 0 }); diff --git a/_types/src/LiveSyncBaseCore.d.ts b/_types/src/LiveSyncBaseCore.d.ts deleted file mode 100644 index a3f5f93b..00000000 --- a/_types/src/LiveSyncBaseCore.d.ts +++ /dev/null @@ -1,136 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase"; -import type { HasSettings, ObsidianLiveSyncSettings, EntryDoc } from "@lib/common/types"; -import type { Confirm } from "@lib/interfaces/Confirm"; -import type { DatabaseFileAccess } from "@lib/interfaces/DatabaseFileAccess"; -import type { Rebuilder } from "@lib/interfaces/DatabaseRebuilder"; -import type { IFileHandler } from "@lib/interfaces/FileHandler"; -import type { StorageAccess } from "@lib/interfaces/StorageAccess"; -import type { LiveSyncLocalDBEnv } from "@lib/pouchdb/LiveSyncLocalDB"; -import type { LiveSyncCouchDBReplicatorEnv } from "@lib/replication/couchdb/LiveSyncReplicator"; -import type { CheckPointInfo } from "@lib/replication/journal/JournalSyncTypes"; -import type { LiveSyncJournalReplicatorEnv } from "@lib/replication/journal/LiveSyncJournalReplicatorEnv"; -import type { LiveSyncReplicatorEnv } from "@lib/replication/LiveSyncAbstractReplicator"; -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -import type { InjectableServiceHub } from "@lib/services/InjectableServices"; -import { AbstractModule } from "./modules/AbstractModule"; -import type { ServiceModules } from "@lib/interfaces/ServiceModule"; -import type { Constructor } from "@lib/common/utils.type"; -export declare class LiveSyncBaseCore implements LiveSyncLocalDBEnv, LiveSyncReplicatorEnv, LiveSyncJournalReplicatorEnv, LiveSyncCouchDBReplicatorEnv, HasSettings { - addOns: TCommands[]; - /** - * register an add-onn to the plug-in. - * Add-ons are features that are not essential to the core functionality of the plugin, - * @param addOn - */ - private _registerAddOn; - /** - * Get an add-on by its class name. Returns undefined if not found. - * @param cls - * @returns - */ - getAddOn(cls: string): T | undefined; - constructor(serviceHub: InjectableServiceHub, serviceModuleInitialiser: (core: LiveSyncBaseCore, serviceHub: InjectableServiceHub) => ServiceModules, extraModuleInitialiser: (core: LiveSyncBaseCore) => AbstractModule[], addOnsInitialiser: (core: LiveSyncBaseCore) => TCommands[], featuresInitialiser: (core: LiveSyncBaseCore) => void); - /** - * The service hub for managing all services. - */ - _services: InjectableServiceHub | undefined; - get services(): InjectableServiceHub; - /** - * Service Modules - */ - protected _serviceModules: ServiceModules; - get serviceModules(): ServiceModules; - /** - * The modules of the plug-in. Modules are responsible for specific features or functionalities of the plug-in, such as file handling, conflict resolution, replication, etc. - */ - private modules; - /** - * Get a module by its class. Throws an error if not found. - * Mostly used for getting SetupManager. - * @param constructor - * @returns - */ - getModule(constructor: Constructor): T; - /** - * Register a module to the plug-in. - * @param module The module to register. - */ - private _registerModule; - registerModules(extraModules?: AbstractModule[]): void; - /** - * Bind module functions to services. - */ - bindModuleFunctions(): void; - /** - * @obsolete Use services.UI.confirm instead. The confirm function to show a confirmation dialog to the user. - */ - get confirm(): Confirm; - /** - * @obsolete Use services.setting.currentSettings instead. The current settings of the plug-in. - */ - get settings(): ObsidianLiveSyncSettings; - /** - * @obsolete Use services.setting.settings instead. Set the settings of the plug-in. - */ - set settings(value: ObsidianLiveSyncSettings); - /** - * @obsolete Use services.setting.currentSettings instead. Get the settings of the plug-in. - * @returns The current settings of the plug-in. - */ - getSettings(): ObsidianLiveSyncSettings; - /** - * @obsolete Use services.database.localDatabase instead. The local database instance. - */ - get localDatabase(): import("@lib/pouchdb/LiveSyncLocalDB").LiveSyncLocalDB; - /** - * @obsolete Use services.database.localDatabase instead. Get the PouchDB database instance. Note that this is not the same as the local database instance, which is a wrapper around the PouchDB database. - * @returns The PouchDB database instance. - */ - getDatabase(): PouchDB.Database; - /** - * @obsolete Use services.keyValueDB.simpleStore instead. A simple key-value store for storing non-file data, such as checkpoints, sync status, etc. - */ - get simpleStore(): SimpleStore; - /** - * @obsolete Use services.replication.getActiveReplicator instead. Get the active replicator instance. Note that there can be multiple replicators, but only one can be active at a time. - */ - get replicator(): import("@lib/replication/LiveSyncAbstractReplicator").LiveSyncAbstractReplicator; - /** - * @obsolete Use services.keyValueDB.kvDB instead. Get the key-value database instance. This is used for storing large data that cannot be stored in the simple store, such as file metadata, etc. - */ - get kvDB(): import("./lib/src/interfaces/KeyValueDatabase").KeyValueDatabase; - /** - * Storage Accessor for handling file operations. - * @obsolete Use serviceModules.storageAccess instead. - */ - get storageAccess(): StorageAccess; - /** - * Database File Accessor for handling file operations related to the database, such as exporting the database, importing from a file, etc. - * @obsolete Use serviceModules.databaseFileAccess instead. - */ - get databaseFileAccess(): DatabaseFileAccess; - /** - * File Handler for handling file operations related to replication, such as resolving conflicts, applying changes from replication, etc. - * @obsolete Use serviceModules.fileHandler instead. - */ - get fileHandler(): IFileHandler; - /** - * Rebuilder for handling database rebuilding operations. - * @obsolete Use serviceModules.rebuilder instead. - */ - get rebuilder(): Rebuilder; - /** - * Initialise ServiceFeatures. - * (Please refer `serviceFeatures` for more details) - */ - initialiseServiceFeatures(): void; -} -export interface IMinimumLiveSyncCommands { - onunload(): void; - onload(): void | Promise; - constructor: { - name: string; - }; -} diff --git a/_types/src/common/KeyValueDB.d.ts b/_types/src/common/KeyValueDB.d.ts deleted file mode 100644 index 2a91de34..00000000 --- a/_types/src/common/KeyValueDB.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { KeyValueDatabase } from "@lib/interfaces/KeyValueDatabase.ts"; -export { OpenKeyValueDatabase } from "./KeyValueDBv2.ts"; -export declare const _OpenKeyValueDatabase: (dbKey: string) => Promise; diff --git a/_types/src/common/KeyValueDBv2.d.ts b/_types/src/common/KeyValueDBv2.d.ts deleted file mode 100644 index a1e703e5..00000000 --- a/_types/src/common/KeyValueDBv2.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { KeyValueDatabase } from "@lib/interfaces/KeyValueDatabase"; -import { type IDBPDatabase } from "idb"; -export declare function OpenKeyValueDatabase(dbKey: string): Promise; -export declare class IDBKeyValueDatabase implements KeyValueDatabase { - protected _dbPromise: Promise> | null; - protected dbKey: string; - protected storeKey: string; - protected _isDestroyed: boolean; - protected destroyedPromise: Promise | null; - get isDestroyed(): boolean; - get ensuredDestroyed(): Promise; - getIsReady(): Promise; - protected ensureDB(): Promise>; - protected closeDB(setDestroyed?: boolean): Promise; - get DB(): Promise>; - constructor(dbKey: string); - get(key: IDBValidKey): Promise; - set(key: IDBValidKey, value: U): Promise; - del(key: IDBValidKey): Promise; - clear(): Promise; - keys(query?: IDBValidKey | IDBKeyRange, count?: number): Promise; - close(): Promise; - destroy(): Promise; -} diff --git a/_types/src/common/PeriodicProcessor.d.ts b/_types/src/common/PeriodicProcessor.d.ts deleted file mode 100644 index d91584e7..00000000 --- a/_types/src/common/PeriodicProcessor.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; -type PeriodicProcessorHost = NecessaryServices<"API" | "control", never>; -export declare class PeriodicProcessor { - _process: () => Promise; - _timer?: number; - _core: PeriodicProcessorHost; - constructor(core: PeriodicProcessorHost, process: () => Promise); - process(): Promise; - enable(interval: number): void; - disable(): void; -} -export {}; diff --git a/_types/src/common/SvelteItemView.d.ts b/_types/src/common/SvelteItemView.d.ts deleted file mode 100644 index ac65318e..00000000 --- a/_types/src/common/SvelteItemView.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { ItemView } from "@/deps.ts"; -import { type mount } from "svelte"; -export declare abstract class SvelteItemView extends ItemView { - abstract instantiateComponent(target: HTMLElement): ReturnType | Promise>; - component?: ReturnType; - onOpen(): Promise; - _dismountComponent(): Promise; - onClose(): Promise; -} diff --git a/_types/src/common/events.d.ts b/_types/src/common/events.d.ts deleted file mode 100644 index 22a9cb31..00000000 --- a/_types/src/common/events.d.ts +++ /dev/null @@ -1,38 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { eventHub } from "@lib/hub/hub"; -export declare const EVENT_PLUGIN_LOADED = "plugin-loaded"; -export declare const EVENT_PLUGIN_UNLOADED = "plugin-unloaded"; -export declare const EVENT_FILE_SAVED = "file-saved"; -export declare const EVENT_LEAF_ACTIVE_CHANGED = "leaf-active-changed"; -export declare const EVENT_REQUEST_OPEN_SETTINGS = "request-open-settings"; -export declare const EVENT_REQUEST_OPEN_SETTING_WIZARD = "request-open-setting-wizard"; -export declare const EVENT_REQUEST_OPEN_SETUP_URI = "request-open-setup-uri"; -export declare const EVENT_REQUEST_COPY_SETUP_URI = "request-copy-setup-uri"; -export declare const EVENT_REQUEST_SHOW_SETUP_QR = "request-show-setup-qr"; -export declare const EVENT_REQUEST_RELOAD_SETTING_TAB = "reload-setting-tab"; -export declare const EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG = "request-open-plugin-sync-dialog"; -export declare const EVENT_REQUEST_RUN_DOCTOR = "request-run-doctor"; -export declare const EVENT_REQUEST_RUN_FIX_INCOMPLETE = "request-run-fix-incomplete"; -export declare const EVENT_ANALYSE_DB_USAGE = "analyse-db-usage"; -export declare const EVENT_REQUEST_PERFORM_GC_V3 = "request-perform-gc-v3"; -declare global { - interface LSEvents { - [EVENT_PLUGIN_LOADED]: undefined; - [EVENT_PLUGIN_UNLOADED]: undefined; - [EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG]: undefined; - [EVENT_REQUEST_OPEN_SETTINGS]: undefined; - [EVENT_REQUEST_OPEN_SETTING_WIZARD]: undefined; - [EVENT_REQUEST_RELOAD_SETTING_TAB]: undefined; - [EVENT_LEAF_ACTIVE_CHANGED]: undefined; - [EVENT_REQUEST_OPEN_SETUP_URI]: undefined; - [EVENT_REQUEST_COPY_SETUP_URI]: undefined; - [EVENT_REQUEST_SHOW_SETUP_QR]: undefined; - [EVENT_REQUEST_RUN_DOCTOR]: string; - [EVENT_REQUEST_RUN_FIX_INCOMPLETE]: undefined; - [EVENT_ANALYSE_DB_USAGE]: undefined; - [EVENT_REQUEST_PERFORM_GC_V3]: undefined; - } -} -export * from "@lib/events/coreEvents.ts"; -export { eventHub }; diff --git a/_types/src/common/obsidianEvents.d.ts b/_types/src/common/obsidianEvents.d.ts deleted file mode 100644 index 929cd48e..00000000 --- a/_types/src/common/obsidianEvents.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { TFile } from "@/deps"; -import type { FilePathWithPrefix, LoadedEntry } from "@lib/common/types"; -export declare const EVENT_REQUEST_SHOW_HISTORY = "show-history"; -declare global { - interface LSEvents { - [EVENT_REQUEST_SHOW_HISTORY]: { - file: TFile; - fileOnDB: LoadedEntry; - } | { - file: FilePathWithPrefix; - fileOnDB: LoadedEntry; - }; - } -} diff --git a/_types/src/common/reportTool.d.ts b/_types/src/common/reportTool.d.ts deleted file mode 100644 index 0b19bec7..00000000 --- a/_types/src/common/reportTool.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ObsidianLiveSyncSettings } from "@lib/common/models/setting.type"; -import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore"; -export declare function generateReport(settings: ObsidianLiveSyncSettings, core: LiveSyncBaseCore): Promise<{ - obsidianInfo: { - navigator: string; - fileSystem: string; - }; - responseConfig: Record; - pluginConfig: ObsidianLiveSyncSettings; - manifestVersion: string; - packageVersion: string; -}>; diff --git a/_types/src/common/stores.d.ts b/_types/src/common/stores.d.ts deleted file mode 100644 index 58dabeb5..00000000 --- a/_types/src/common/stores.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { PersistentMap } from "octagonal-wheels/dataobject/PersistentMap"; -export declare let sameChangePairs: PersistentMap; -export declare function initializeStores(vaultName: string): void; diff --git a/_types/src/common/types.d.ts b/_types/src/common/types.d.ts deleted file mode 100644 index 59086561..00000000 --- a/_types/src/common/types.d.ts +++ /dev/null @@ -1,48 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type PluginManifest, TFile } from "@/deps.ts"; -import { type DatabaseEntry, type EntryBody, type FilePath } from "@lib/common/types.ts"; -export type { CacheData, FileEventItem } from "@lib/common/types.ts"; -export interface PluginDataEntry extends DatabaseEntry { - deviceVaultName: string; - mtime: number; - manifest: PluginManifest; - mainJs: string; - manifestJson: string; - styleCss?: string; - dataJson?: string; - _conflicts?: string[]; - type: "plugin"; -} -export interface PluginList { - [key: string]: PluginDataEntry[]; -} -export interface DevicePluginList { - [key: string]: PluginDataEntry; -} -export declare const PERIODIC_PLUGIN_SWEEP = 60; -export interface InternalFileInfo { - path: FilePath; - mtime: number; - ctime: number; - size: number; - deleted?: boolean; -} -export interface FileInfo { - path: FilePath; - mtime: number; - ctime: number; - size: number; - deleted?: boolean; - file: TFile; -} -export type queueItem = { - entry: EntryBody; - missingChildren: string[]; - timeout?: number; - done?: boolean; - warned?: boolean; -}; -export declare const FileWatchEventQueueMax = 10; -export { configURIBase, configURIBaseQR } from "@lib/common/types.ts"; -export { CHeader, PSCHeader, PSCHeaderEnd, ICHeader, ICHeaderEnd, ICHeaderLength, ICXHeader, } from "@lib/common/models/fileaccess.const.ts"; diff --git a/_types/src/common/utils.d.ts b/_types/src/common/utils.d.ts deleted file mode 100644 index 35bd53c5..00000000 --- a/_types/src/common/utils.d.ts +++ /dev/null @@ -1,61 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { TAbstractFile } from "@/deps.ts"; -import { type AnyEntry, type CouchDBCredentials, type DocumentID, type EntryHasPath, type FilePath, type FilePathWithPrefix, type UXFileInfo, type UXFileInfoStub } from "@lib/common/types.ts"; -export { ICHeader, ICXHeader } from "./types.ts"; -import type { KeyValueDatabase } from "@lib/interfaces/KeyValueDatabase.ts"; -export { scheduleTask, cancelTask, cancelAllTasks } from "octagonal-wheels/concurrency/task"; -export declare function path2id(filename: FilePathWithPrefix | FilePath, obfuscatePassphrase: string | false, caseInsensitive: boolean): Promise; -export declare function id2path(id: DocumentID, entry?: EntryHasPath): FilePathWithPrefix; -export declare function getPathFromTFile(file: TAbstractFile): FilePath; -import { isInternalFile, getPathFromUXFileInfo, getStoragePathFromUXFileInfo, getDatabasePathFromUXFileInfo } from "@lib/common/typeUtils.ts"; -export { isInternalFile, getPathFromUXFileInfo, getStoragePathFromUXFileInfo, getDatabasePathFromUXFileInfo }; -export declare function memoObject(key: string, obj: T): T; -export declare function memoIfNotExist(key: string, func: () => T | Promise): Promise; -export declare function retrieveMemoObject(key: string): T | false; -export declare function disposeMemoObject(key: string): void; -export declare function isValidPath(filename: string): boolean; -export declare function trimPrefix(target: string, prefix: string): string; -export { isInternalMetadata, id2InternalMetadataId, isChunk, isCustomisationSyncMetadata, isPluginMetadata, stripInternalMetadataPrefix, } from "@lib/common/typeUtils.ts"; -export declare const _requestToCouchDBFetch: (baseUri: string, username: string, password: string, path?: string, body?: unknown, method?: string) => Promise; -export declare const _requestToCouchDB: (baseUri: string, credentials: CouchDBCredentials, origin: string, path?: string, body?: unknown, method?: string, customHeaders?: Record) => Promise; -/** - * @deprecated Use requestToCouchDBWithCredentials instead. - */ -export declare const requestToCouchDB: (baseUri: string, username: string, password: string, origin?: string, key?: string, body?: string, method?: string, customHeaders?: Record) => Promise; -export declare function requestToCouchDBWithCredentials(baseUri: string, credentials: CouchDBCredentials, origin?: string, key?: string, body?: string, method?: string, customHeaders?: Record): Promise; -import { BASE_IS_NEW, EVEN, TARGET_IS_NEW } from "@lib/common/models/shared.const.symbols.ts"; -export { BASE_IS_NEW, EVEN, TARGET_IS_NEW }; -import { compareMTime } from "@lib/common/utils.ts"; -export { compareMTime }; -export declare function markChangesAreSame(file: AnyEntry | string | UXFileInfoStub, mtime1: number, mtime2: number): true | undefined; -export declare function unmarkChanges(file: AnyEntry | string | UXFileInfoStub): void; -export declare function isMarkedAsSameChanges(file: UXFileInfoStub | AnyEntry | string, mtimes: number[]): typeof EVEN | undefined; -export declare function compareFileFreshness(baseFile: UXFileInfoStub | AnyEntry | undefined, checkTarget: UXFileInfo | AnyEntry | undefined): typeof BASE_IS_NEW | typeof TARGET_IS_NEW | typeof EVEN; -export type MemoOption = { - key: string; - forceUpdate?: boolean; - validator?: (context: Map) => boolean; -}; -export declare function useMemo({ key, forceUpdate, validator }: MemoOption, updateFunc: (context: Map, prev: T) => T): T; -export declare function useStatic(key: string): { - value: T | undefined; -}; -export declare function useStatic(key: string, initial: T): { - value: T; -}; -export declare function disposeMemo(key: string): void; -export declare function disposeAllMemo(): void; -export declare function getLogLevel(showNotice: boolean): 32 | 64; -export type MapLike = { - set(key: K, value: V): Map; - clear(): void; - delete(key: K): boolean; - get(key: K): V | undefined; - has(key: K): boolean; - keys: () => IterableIterator; - get size(): number; -}; -export declare function autosaveCache(db: KeyValueDatabase, mapKey: string): Promise>; -export declare function onlyInNTimes(n: number, proc: (progress: number) => unknown): () => void; -export { displayRev } from "@lib/common/utils.ts"; diff --git a/_types/src/deps.d.ts b/_types/src/deps.d.ts deleted file mode 100644 index 340afdcd..00000000 --- a/_types/src/deps.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type FilePath } from "@lib/common/types.ts"; -export { addIcon, App, debounce, Editor, FuzzySuggestModal, MarkdownRenderer, MarkdownView, Modal, Notice, Platform, Plugin, PluginSettingTab, requestUrl, sanitizeHTMLToDom, Setting, stringifyYaml, TAbstractFile, TextAreaComponent, TFile, TFolder, parseYaml, ItemView, WorkspaceLeaf, Menu, request, getLanguage, ButtonComponent, TextComponent, ToggleComponent, DropdownComponent, Component, } from "obsidian"; -export type { DataWriteOptions, PluginManifest, RequestUrlParam, RequestUrlResponse, MarkdownFileInfo, ListedFiles, ValueComponent, Stat, Command, ViewCreator, } from "obsidian"; -declare const normalizePath: (from: T) => T; -export { normalizePath }; -export { type Diff, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch } from "diff-match-patch"; diff --git a/_types/src/features/ConfigSync/CmdConfigSync.d.ts b/_types/src/features/ConfigSync/CmdConfigSync.d.ts deleted file mode 100644 index bb90c1f8..00000000 --- a/_types/src/features/ConfigSync/CmdConfigSync.d.ts +++ /dev/null @@ -1,147 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type PluginManifest } from "@/deps.ts"; -import type { EntryDoc, LoadedEntry, FilePathWithPrefix, FilePath, AnyEntry } from "@lib/common/types.ts"; -import { LiveSyncCommands } from "@/features/LiveSyncCommands.ts"; -import { PeriodicProcessor } from "@/common/PeriodicProcessor.ts"; -import { QueueProcessor } from "octagonal-wheels/concurrency/processor"; -import type ObsidianLiveSyncPlugin from "@/main.ts"; -import { PluginDialogModal } from "./PluginDialogModal.ts"; -import type { InjectableServiceHub } from "@lib/services/InjectableServices.ts"; -import type { LiveSyncCore } from "@/main.ts"; -declare global { - interface OPTIONAL_SYNC_FEATURES { - DISABLE: "DISABLE"; - CUSTOMIZE: "CUSTOMIZE"; - DISABLE_CUSTOM: "DISABLE_CUSTOM"; - } -} -export declare const pluginList: import("svelte/store").Writable; -export declare const pluginIsEnumerating: import("svelte/store").Writable; -export declare const pluginV2Progress: import("svelte/store").Writable; -export type PluginDataExFile = { - filename: string; - data: string[]; - mtime: number; - size: number; - version?: string; - hash?: string; - displayName?: string; -}; -export interface IPluginDataExDisplay { - documentPath: FilePathWithPrefix; - category: string; - name: string; - term: string; - displayName?: string; - files: (LoadedEntryPluginDataExFile | PluginDataExFile)[]; - version?: string; - mtime: number; -} -export type PluginDataExDisplay = { - documentPath: FilePathWithPrefix; - category: string; - name: string; - term: string; - displayName?: string; - files: PluginDataExFile[]; - version?: string; - mtime: number; -}; -type LoadedEntryPluginDataExFile = LoadedEntry & PluginDataExFile; -export declare const pluginManifests: Map; -export declare const pluginManifestStore: import("svelte/store").Writable>; -export declare class PluginDataExDisplayV2 { - documentPath: FilePathWithPrefix; - category: string; - term: string; - files: LoadedEntryPluginDataExFile[]; - name: string; - confKey: string; - constructor(data: IPluginDataExDisplay); - setFile(file: LoadedEntryPluginDataExFile): Promise; - deleteFile(filename: string): void; - _displayName: string | undefined; - _version: string | undefined; - applyLoadedManifest(): void; - get displayName(): string; - get version(): string | undefined; - get mtime(): number; -} -export type PluginDataEx = { - documentPath?: FilePathWithPrefix; - category: string; - name: string; - displayName?: string; - term: string; - files: PluginDataExFile[]; - version?: string; - mtime: number; -}; -export declare class ConfigSync extends LiveSyncCommands { - constructor(plugin: ObsidianLiveSyncPlugin, core: LiveSyncCore); - get configDir(): string; - get kvDB(): import("../../lib/src/interfaces/KeyValueDatabase.ts").KeyValueDatabase; - get useV2(): boolean; - get useSyncPluginEtc(): boolean; - isThisModuleEnabled(): boolean; - pluginDialog?: PluginDialogModal; - periodicPluginSweepProcessor: PeriodicProcessor; - pluginList: IPluginDataExDisplay[]; - showPluginSyncModal(): void; - hidePluginSyncModal(): void; - onunload(): void; - addRibbonIcon: (icon: string, title: string, callback: (evt: MouseEvent) => unknown) => HTMLElement; - onload(): void; - getFileCategory(filePath: string): "CONFIG" | "THEME" | "SNIPPET" | "PLUGIN_MAIN" | "PLUGIN_ETC" | "PLUGIN_DATA" | ""; - isTargetPath(filePath: string): boolean; - private _everyOnDatabaseInitialized; - _everyBeforeReplicate(showNotice: boolean): Promise; - _everyOnResumeProcess(): Promise; - _everyAfterResumeProcess(): Promise; - reloadPluginList(showMessage: boolean): Promise; - loadPluginData(path: FilePathWithPrefix): Promise; - pluginScanProcessor: QueueProcessor; - pluginScanProcessorV2: QueueProcessor; - filenameToUnifiedKey(path: string, termOverRide?: string): FilePathWithPrefix; - filenameWithUnifiedKey(path: string, termOverRide?: string): FilePathWithPrefix; - unifiedKeyPrefixOfTerminal(termOverRide?: string): FilePathWithPrefix; - parseUnifiedPath(unifiedPath: FilePathWithPrefix): { - category: string; - device: string; - key: string; - filename: string; - pathV1: FilePathWithPrefix; - }; - loadedManifest_mTime: Map; - createPluginDataExFileV2(unifiedPathV2: FilePathWithPrefix, loaded?: LoadedEntry): Promise; - createPluginDataFromV2(unifiedPathV2: FilePathWithPrefix): PluginDataExDisplayV2 | undefined; - updatingV2Count: number; - updatePluginListV2(showMessage: boolean, unifiedFilenameWithKey: FilePathWithPrefix): Promise; - migrateV1ToV2(showMessage: boolean, entry: AnyEntry): Promise; - updatePluginList(showMessage: boolean, updatedDocumentPath?: FilePathWithPrefix): Promise; - compareUsingDisplayData(dataA: IPluginDataExDisplay, dataB: IPluginDataExDisplay, compareEach?: boolean): Promise; - applyDataV2(data: PluginDataExDisplayV2, content?: string): Promise; - applyData(data: IPluginDataExDisplay, content?: string): Promise; - deleteData(data: PluginDataEx): Promise; - _anyModuleParsedReplicationResultItem(docs: PouchDB.Core.ExistingDocument): Promise; - _everyRealizeSettingSyncMode(): Promise; - recentProcessedInternalFiles: string[]; - makeEntryFromFile(path: FilePath): Promise; - storeCustomisationFileV2(path: FilePath, term: string, force?: boolean): Promise; - storeCustomizationFiles(path: FilePath, termOverRide?: string): Promise; - _anyProcessOptionalFileEvent(path: FilePath): Promise; - watchVaultRawEventsAsync(path: FilePath): Promise; - scanAllConfigFiles(showMessage: boolean): Promise; - deleteConfigOnDatabase(prefixedFileName: FilePathWithPrefix, forceWrite?: boolean): Promise; - scanInternalFiles(): Promise; - private _allAskUsingOptionalSyncFeature; - private __askHiddenFileConfiguration; - _anyGetOptionalConflictCheckMethod(path: FilePathWithPrefix): Promise; - private _allSuspendExtraSync; - private _allConfigureOptionalSyncFeature; - configureHiddenFileSync(mode: keyof OPTIONAL_SYNC_FEATURES): Promise; - getFiles(path: string, lastDepth: number): Promise; - onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void; -} -export {}; diff --git a/_types/src/features/ConfigSync/PluginDialogModal.d.ts b/_types/src/features/ConfigSync/PluginDialogModal.d.ts deleted file mode 100644 index c885600e..00000000 --- a/_types/src/features/ConfigSync/PluginDialogModal.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { mount } from "svelte"; -import { App, Modal } from "@/deps.ts"; -import ObsidianLiveSyncPlugin from "@/main.ts"; -export declare class PluginDialogModal extends Modal { - plugin: ObsidianLiveSyncPlugin; - component: ReturnType | undefined; - isOpened(): boolean; - constructor(app: App, plugin: ObsidianLiveSyncPlugin); - onOpen(): void; - onClose(): void; -} diff --git a/_types/src/features/HiddenFileCommon/JsonResolveModal.d.ts b/_types/src/features/HiddenFileCommon/JsonResolveModal.d.ts deleted file mode 100644 index b6bc766e..00000000 --- a/_types/src/features/HiddenFileCommon/JsonResolveModal.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { App, Modal } from "@/deps.ts"; -import { type FilePath, type LoadedEntry } from "@lib/common/types.ts"; -import { mount } from "svelte"; -export declare class JsonResolveModal extends Modal { - filename: FilePath; - callback?: (keepRev?: string, mergedStr?: string) => Promise; - docs: LoadedEntry[]; - component?: ReturnType; - nameA: string; - nameB: string; - defaultSelect: string; - keepOrder: boolean; - hideLocal: boolean; - title: string; - constructor(app: App, filename: FilePath, docs: LoadedEntry[], callback: (keepRev?: string, mergedStr?: string) => Promise, nameA?: string, nameB?: string, defaultSelect?: string, keepOrder?: boolean, hideLocal?: boolean, title?: string); - UICallback(keepRev?: string, mergedStr?: string): Promise; - onOpen(): void; - onClose(): void; -} diff --git a/_types/src/features/HiddenFileSync/CmdHiddenFileSync.d.ts b/_types/src/features/HiddenFileSync/CmdHiddenFileSync.d.ts deleted file mode 100644 index 177e7a76..00000000 --- a/_types/src/features/HiddenFileSync/CmdHiddenFileSync.d.ts +++ /dev/null @@ -1,154 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type LoadedEntry, type FilePathWithPrefix, type FilePath, type DocumentID, type UXFileInfo, type UXStat, type MetaEntry, type UXDataWriteOptions } from "@lib/common/types.ts"; -import { type InternalFileInfo } from "@/common/types.ts"; -import { type CustomRegExp } from "@lib/common/utils.ts"; -import { type MapLike } from "@/common/utils.ts"; -import { PeriodicProcessor } from "@/common/PeriodicProcessor.ts"; -import { LiveSyncCommands } from "@/features/LiveSyncCommands.ts"; -import { QueueProcessor } from "octagonal-wheels/concurrency/processor"; -import type { LiveSyncCore } from "@/main.ts"; -type SyncDirection = "push" | "pull" | "safe" | "pullForce" | "pushForce"; -declare global { - interface OPTIONAL_SYNC_FEATURES { - FETCH: "FETCH"; - OVERWRITE: "OVERWRITE"; - MERGE: "MERGE"; - DISABLE: "DISABLE"; - DISABLE_HIDDEN: "DISABLE_HIDDEN"; - } -} -export declare class HiddenFileSync extends LiveSyncCommands { - isThisModuleEnabled(): boolean; - periodicInternalFileScanProcessor: PeriodicProcessor; - get kvDB(): import("../../lib/src/interfaces/KeyValueDatabase.ts").KeyValueDatabase; - getConflictedDoc(path: FilePathWithPrefix, rev: string): Promise; - onunload(): void; - onload(): void; - private _everyOnDatabaseInitialized; - _everyBeforeReplicate(showNotice: boolean): Promise; - private _everyOnloadAfterLoadSettings; - updateSettingCache(): void; - isReady(): boolean; - performStartupScan(showNotice: boolean): Promise; - _everyOnResumeProcess(): Promise; - _everyRealizeSettingSyncMode(): Promise; - _anyProcessOptionalFileEvent(path: FilePath): Promise; - _anyGetOptionalConflictCheckMethod(path: FilePathWithPrefix): Promise; - _anyProcessOptionalSyncFiles(doc: LoadedEntry): Promise; - loadFileWithInfo(path: FilePath): Promise; - _fileInfoLastProcessed: MapLike; - _fileInfoLastKnown: MapLike; - _databaseInfoLastProcessed: MapLike; - statToKey(stat: UXStat | null): string; - docToKey(doc: LoadedEntry | MetaEntry): string; - fileToStatKey(file: FilePath, stat?: UXStat | null): Promise; - updateLastProcessedFile(file: FilePath, keySrc: string | UXStat): void; - updateLastProcessedAsActualFile(file: FilePath, stat?: UXStat | null): Promise; - resetLastProcessedFile(targetFiles: FilePath[] | false): void; - getLastProcessedFileMTime(file: FilePath): number; - getLastProcessedFileKey(file: FilePath): string | undefined; - getLastProcessedDatabaseKey(file: FilePath): string | undefined; - updateLastProcessedDatabase(file: FilePath, keySrc: string | MetaEntry | LoadedEntry): void; - updateLastProcessed(path: FilePath, db: MetaEntry | LoadedEntry, stat: UXStat): void; - updateLastProcessedDeletion(path: FilePath, db: MetaEntry | LoadedEntry | false): void; - ensureDir(path: FilePath): Promise; - writeFile(path: FilePath, data: string | ArrayBuffer, opt?: UXDataWriteOptions): Promise; - __removeFile(path: FilePath): Promise<"OK" | "ALREADY" | false>; - triggerEvent(path: FilePath): Promise; - updateLastProcessedAsActualDatabase(file: FilePath, doc?: MetaEntry | LoadedEntry | null | false): Promise; - resetLastProcessedDatabase(targetFiles: FilePath[] | false): void; - adoptCurrentStorageFilesAsProcessed(targetFiles: FilePath[] | false): Promise; - adoptCurrentDatabaseFilesAsProcessed(targetFiles: FilePath[] | false): Promise; - semaphore: import("octagonal-wheels/concurrency/semaphore_v2").SemaphoreObject; - serializedForEvent(file: FilePath, fn: () => Promise): Promise; - useStorageFiles(files: FilePath[], showNotice?: boolean, onlyNew?: boolean): Promise; - trackScannedStorageChanges(processFiles: FilePath[], showNotice?: boolean, onlyNew?: boolean, forceWriteAll?: boolean, includeDeleted?: boolean): Promise; - scanAllStorageChanges(showNotice?: boolean, onlyNew?: boolean, forceWriteAll?: boolean, includeDeleted?: boolean): Promise; - /** - * check the file is changed or not, and if changed, process it. - */ - trackStorageFileModification(path: FilePath, onlyNew?: boolean, forceWrite?: boolean, includeDeleted?: boolean): Promise; - pendingConflictChecks: Set; - queueConflictCheck(path: FilePathWithPrefix): void; - finishConflictCheck(path: FilePathWithPrefix): void; - requeueConflictCheck(path: FilePathWithPrefix): void; - resolveConflictOnInternalFiles(): Promise; - resolveByNewerEntry(id: DocumentID, path: FilePathWithPrefix, currentDoc: MetaEntry, currentRev: string, conflictedRev: string): Promise; - conflictResolutionProcessor: QueueProcessor; - showJSONMergeDialogAndMerge(docA: LoadedEntry, docB: LoadedEntry): Promise; - getDocProps(doc: LoadedEntry): { - id: DocumentID; - rev: string | undefined; - revDisplay: string; - prefixedPath: FilePathWithPrefix; - path: FilePath; - isDeleted: boolean; - shortenedId: string; - shortenedPath: string; - }; - processReplicationResult(doc: LoadedEntry): Promise; - cacheFileRegExps: Map; - /** - * Parses the regular expression settings for hidden file synchronization. - * @returns An object containing the ignore and target filters. - */ - parseRegExpSettings(): { - ignoreFilter: CustomRegExp[]; - targetFilter: CustomRegExp[]; - }; - /** - * Checks if the target file path matches the defined patterns. - */ - isTargetFileInPatterns(path: string): boolean; - cacheCustomisationSyncIgnoredFiles: Map; - /** - * Gets the list of files ignored for customization synchronization. - * @returns An array of ignored file paths (lowercase). - */ - getCustomisationSynchronizationIgnoredFiles(): string[]; - /** - * Checks if the given path is not ignored by customization synchronization. - * @param path The file path to check. - * @returns True if the path is not ignored; otherwise, false. - */ - isNotIgnoredByCustomisationSync(path: string): boolean; - isHiddenFileSyncHandlingPath(path: FilePath): boolean; - isTargetFile(path: FilePath): Promise; - trackScannedDatabaseChange(processFiles: MetaEntry[], showNotice?: boolean, onlyNew?: boolean, forceWriteAll?: boolean, includeDeletion?: boolean): Promise; - applyOfflineChanges(showNotice: boolean): Promise; - scanAllDatabaseChanges(showNotice?: boolean, onlyNew?: boolean, forceWriteAll?: boolean, includeDeletion?: boolean): Promise; - useDatabaseFiles(files: MetaEntry[], showNotice?: boolean, onlyNew?: boolean): Promise; - trackDatabaseFileModification(path: FilePath, headerLine: string, preventDoubleProcess?: boolean, onlyNew?: boolean, meta?: MetaEntry | false, includeDeletion?: boolean): Promise; - queuedNotificationFiles: Set; - notifyConfigChange(): void; - queueNotification(key: FilePath): void; - rebuildMerging(showNotice: boolean, targetFiles?: FilePath[] | false): Promise; - rebuildFromStorage(showNotice: boolean, targetFiles?: FilePath[] | false, onlyNew?: boolean): Promise; - getAllDatabaseFiles(): Promise; - rebuildFromDatabase(showNotice: boolean, targetFiles?: FilePath[] | false, onlyNew?: boolean): Promise; - initialiseInternalFileSync(direction: SyncDirection, showMessage: boolean, targetFilesSrc?: string[] | false): Promise; - __loadBaseSaveData(file: FilePath, includeContent?: boolean): Promise; - storeInternalFileToDatabase(file: InternalFileInfo | UXFileInfo, forceWrite?: boolean): Promise; - deleteInternalFileOnDatabase(filenameSrc: FilePath, forceWrite?: boolean): Promise; - extractInternalFileFromDatabase(storageFilePath: FilePath, force?: boolean, metaEntry?: MetaEntry | LoadedEntry, preventDoubleProcess?: boolean, onlyNew?: boolean, includeDeletion?: boolean): Promise; - __checkIsNeedToWriteFile(storageFilePath: FilePath, content: string | ArrayBuffer): Promise; - __writeFile(storageFilePath: FilePath, fileOnDB: LoadedEntry, force: boolean): Promise; - __deleteFile(storageFilePath: FilePath): Promise; - private _allAskUsingOptionalSyncFeature; - private __askHiddenFileConfiguration; - private _allSuspendExtraSync; - private _allConfigureOptionalSyncFeature; - configureHiddenFileSync(mode: keyof OPTIONAL_SYNC_FEATURES): Promise; - scanInternalFileNames(): Promise; - scanInternalFiles(): Promise; - getFiles(path: string, checkFunction: (path: FilePath) => Promise | boolean): Promise; - onBindFunction(core: LiveSyncCore, services: typeof core.services): void; -} -export {}; diff --git a/_types/src/features/HiddenFileSync/configureHiddenFileSyncMode.d.ts b/_types/src/features/HiddenFileSync/configureHiddenFileSyncMode.d.ts deleted file mode 100644 index 711c80d6..00000000 --- a/_types/src/features/HiddenFileSync/configureHiddenFileSyncMode.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -type HiddenFileSyncDirection = "pullForce" | "pushForce" | "safe"; -type ConfigureHiddenFileSyncHandlers = { - disable: () => Promise; - enable: () => Promise; - initialise: (direction: HiddenFileSyncDirection) => Promise; -}; -export type ConfigureHiddenFileSyncResult = "ignored" | "disabled" | "enabled"; -export declare function configureHiddenFileSyncMode(mode: keyof OPTIONAL_SYNC_FEATURES, handlers: ConfigureHiddenFileSyncHandlers): Promise; -export {}; diff --git a/_types/src/features/LiveSyncCommands.d.ts b/_types/src/features/LiveSyncCommands.d.ts deleted file mode 100644 index 67837648..00000000 --- a/_types/src/features/LiveSyncCommands.d.ts +++ /dev/null @@ -1,36 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type AnyEntry, type DocumentID, type FilePath, type FilePathWithPrefix, type LOG_LEVEL } from "@lib/common/types.ts"; -import type ObsidianLiveSyncPlugin from "@/main.ts"; -import type { LiveSyncCore } from "@/main.ts"; -import { createInstanceLogFunction } from "@lib/services/lib/logUtils.ts"; -export declare abstract class LiveSyncCommands { - /** - * @deprecated This class is deprecated. Please use core - */ - plugin: ObsidianLiveSyncPlugin; - core: LiveSyncCore; - get app(): import("obsidian").App; - get settings(): import("@lib/common/types.ts").ObsidianLiveSyncSettings; - get localDatabase(): import("../lib/src/pouchdb/LiveSyncLocalDB").LiveSyncLocalDB; - get services(): import("../lib/src/services/InjectableServices").InjectableServiceHub; - path2id(filename: FilePathWithPrefix | FilePath, prefix?: string): Promise; - getPath(entry: AnyEntry): FilePathWithPrefix; - constructor(plugin: ObsidianLiveSyncPlugin, core: LiveSyncCore); - abstract onunload(): void; - abstract onload(): void | Promise; - _isMainReady(): boolean; - _isMainSuspended(): boolean; - _isDatabaseReady(): boolean; - _log: ReturnType; - _verbose: (msg: unknown, key?: string) => void; - _info: (msg: unknown, key?: string) => void; - _notice: (msg: unknown, key?: string) => void; - _progress: (prefix?: string, level?: LOG_LEVEL) => { - log: (msg: string) => void; - once: (msg: string) => void; - done: (msg?: string) => void; - }; - _debug: (msg: unknown, key?: string) => void; - onBindFunction(core: LiveSyncCore, services: typeof core.services): void; -} diff --git a/_types/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.d.ts b/_types/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.d.ts deleted file mode 100644 index ab3c9178..00000000 --- a/_types/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.d.ts +++ /dev/null @@ -1,59 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type DocumentID, type EntryDoc, type EntryLeaf } from "@lib/common/types"; -import { LiveSyncCommands } from "@/features/LiveSyncCommands"; -type ChunkID = DocumentID; -type NoteDocumentID = DocumentID; -type Rev = string; -type ChunkUsageMap = Map>>; -export declare class LocalDatabaseMaintenance extends LiveSyncCommands { - onunload(): void; - onload(): void | Promise; - allChunks(includeDeleted?: boolean): Promise<{ - used: Set; - existing: Map; - }>; - get database(): PouchDB.Database; - clearHash(): void; - confirm(title: string, message: string, affirmative?: string, negative?: string): Promise; - ensureAvailable(operationName: string): Promise; - /** - * Resurrect deleted chunks that are still used in the database. - */ - resurrectChunks(): Promise; - /** - * Commit deletion of files that are marked as deleted. - * This method makes the deletion permanent, and the files will not be recovered. - * After this, chunks that are used in the deleted files become ready for compaction. - */ - commitFileDeletion(): Promise; - /** - * Commit deletion of chunks that are not used in the database. - * This method makes the deletion permanent, and the chunks will not be recovered if the database run compaction. - * After this, the database can shrink the database size by compaction. - * It is recommended to compact the database after this operation (History should be kept once before compaction). - */ - commitChunkDeletion(): Promise; - /** - * Compact the database. - * This method removes all deleted chunks that are not used in the database. - * Make sure all devices are synchronized before running this method. - */ - markUnusedChunks(): Promise; - removeUnusedChunks(): Promise; - scanUnusedChunks(): Promise<{ - chunkSet: Set; - chunkUsageMap: ChunkUsageMap; - unusedSet: Set; - }>; - /** - * Track changes in the database and update the chunk usage map for garbage collection. - * Note that this only able to perform without Fetch chunks on demand. - */ - trackChanges(fromStart?: boolean, showNotice?: boolean): Promise; - performGC(showingNotice?: boolean): Promise; - analyseDatabase(): Promise; - compactDatabase(): Promise; - gcv3(): Promise; -} -export {}; diff --git a/_types/src/features/LocalDatabaseMainte/maintenancePrerequisites.d.ts b/_types/src/features/LocalDatabaseMainte/maintenancePrerequisites.d.ts deleted file mode 100644 index 0cddecf9..00000000 --- a/_types/src/features/LocalDatabaseMainte/maintenancePrerequisites.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ObsidianLiveSyncSettings } from "@lib/common/types"; -type MaintenancePrerequisiteSettings = Pick; -type MaintenancePrerequisiteOptions = { - operationName: string; - settings: MaintenancePrerequisiteSettings; - askSelectStringDialogue: (message: string, buttons: readonly ["Apply and continue", "Cancel"], options: { - title: string; - defaultAction: "Cancel"; - }) => Promise<"Apply and continue" | "Cancel" | false | undefined>; - applyPartial: (settings: Partial, saveImmediately?: boolean) => Promise; -}; -export declare function ensureLocalDatabaseMaintenancePrerequisites({ operationName, settings, askSelectStringDialogue, applyPartial, }: MaintenancePrerequisiteOptions): Promise; -export {}; diff --git a/_types/src/features/P2PSync/P2PReplicator/P2POpenReplicationModal.d.ts b/_types/src/features/P2PSync/P2PReplicator/P2POpenReplicationModal.d.ts deleted file mode 100644 index ac0f5e59..00000000 --- a/_types/src/features/P2PSync/P2PReplicator/P2POpenReplicationModal.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { App, Modal } from "@/deps.ts"; -import { mount } from "svelte"; -import type { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator"; -export type P2POpenReplicationModalCallback = { - onSync: (peerId: string) => Promise; - onSyncAndClose: (peerId: string) => Promise; -}; -export declare class P2POpenReplicationModal extends Modal { - liveSyncReplicator: LiveSyncTrysteroReplicator; - callback?: P2POpenReplicationModalCallback; - component?: ReturnType; - showResult: boolean; - title: string; - onClosed?: () => void; - rebuildMode: boolean; - constructor(app: App, liveSyncReplicator: LiveSyncTrysteroReplicator, callback?: P2POpenReplicationModalCallback, showResult?: boolean, title?: string, onClosed?: () => void, rebuildMode?: boolean); - onSync(peerId: string): Promise; - onSyncAndClose(peerId: string): Promise; - onOpen(): void; - onClose(): void; -} diff --git a/_types/src/features/P2PSync/P2PReplicator/P2PReplicationUI.d.ts b/_types/src/features/P2PSync/P2PReplicator/P2PReplicationUI.d.ts deleted file mode 100644 index d96dace5..00000000 --- a/_types/src/features/P2PSync/P2PReplicator/P2PReplicationUI.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { App } from "@/deps.ts"; -import type { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator"; -/** - * Creates an openReplicationUI factory for Obsidian environments. - * Returns a per-replicator closure that opens the P2P Replication modal - * and performs bidirectional sync (pull then push on success). - * - * Usage: - * const factory = createOpenReplicationUI(app); - * useP2PReplicatorFeature(core, factory); - */ -export declare function createOpenReplicationUI(app: App): (replicator: LiveSyncTrysteroReplicator) => (showResult: boolean) => Promise; -/** - * Creates an openRebuildUI factory for Obsidian environments. - * Opens the P2P Replication modal in "rebuild" mode — one-way pull only, - * with setOnSetup / clearOnSetup bracketing the replicateFrom call. - * - * Usage: - * const factory = createOpenRebuildUI(app); - * useP2PReplicatorFeature(core, createOpenReplicationUI(app), factory); - */ -export declare function createOpenRebuildUI(app: App): (replicator: LiveSyncTrysteroReplicator) => (showResult: boolean) => Promise; diff --git a/_types/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.d.ts b/_types/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.d.ts deleted file mode 100644 index 525f562f..00000000 --- a/_types/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.d.ts +++ /dev/null @@ -1,30 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { Menu, WorkspaceLeaf } from "@/deps.ts"; -import { SvelteItemView } from "@/common/SvelteItemView.ts"; -import { type PeerStatus } from "@lib/replication/trystero/P2PReplicatorPaneCommon.ts"; -import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts"; -import type { P2PPaneParams } from "@lib/replication/trystero/UseP2PReplicatorResult"; -export declare const VIEW_TYPE_P2P = "p2p-replicator"; -export declare class P2PReplicatorPaneView extends SvelteItemView { - core: LiveSyncBaseCore; - private _p2pResult; - icon: string; - title: string; - navigation: boolean; - getIcon(): string; - get replicator(): import("../../../lib/src/replication/trystero/LiveSyncTrysteroReplicator").LiveSyncTrysteroReplicator; - replicateFrom(peer: PeerStatus): Promise; - replicateTo(peer: PeerStatus): Promise; - getRemoteConfig(peer: PeerStatus): Promise; - toggleProp(peer: PeerStatus, prop: "syncOnConnect" | "watchOnConnect" | "syncOnReplicationCommand"): Promise; - m?: Menu; - constructor(leaf: WorkspaceLeaf, core: LiveSyncBaseCore, p2pResult: P2PPaneParams); - getViewType(): string; - getDisplayText(): string; - onClose(): Promise; - instantiateComponent(target: HTMLElement): { - $on?(type: string, callback: (e: any) => void): () => void; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - $set?(props: Partial>): void; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - } & Record; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration -} diff --git a/_types/src/features/P2PSync/P2PReplicator/P2PServerStatusPaneView.d.ts b/_types/src/features/P2PSync/P2PReplicator/P2PServerStatusPaneView.d.ts deleted file mode 100644 index eb9ce808..00000000 --- a/_types/src/features/P2PSync/P2PReplicator/P2PServerStatusPaneView.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { WorkspaceLeaf } from "@/deps.ts"; -import { SvelteItemView } from "@/common/SvelteItemView.ts"; -import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts"; -import type { P2PPaneParams } from "@lib/replication/trystero/UseP2PReplicatorResult"; -export declare const VIEW_TYPE_P2P_SERVER_STATUS = "p2p-server-status"; -export declare class P2PServerStatusPaneView extends SvelteItemView { - core: LiveSyncBaseCore; - private _p2pResult; - icon: string; - navigation: boolean; - constructor(leaf: WorkspaceLeaf, core: LiveSyncBaseCore, p2pResult: P2PPaneParams); - getIcon(): string; - getViewType(): string; - getDisplayText(): string; - instantiateComponent(target: HTMLElement): { - $on?(type: string, callback: (e: any) => void): () => void; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - $set?(props: Partial>): void; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - } & Record; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration -} diff --git a/_types/src/lib/src/API/DirectFileManipulator.d.ts b/_types/src/lib/src/API/DirectFileManipulator.d.ts deleted file mode 100644 index f0df1fcb..00000000 --- a/_types/src/lib/src/API/DirectFileManipulator.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export { DirectFileManipulator } from "./DirectFileManipulatorV2.ts"; -export type { DirectFileManipulatorOptions } from "./DirectFileManipulatorV2.ts"; diff --git a/_types/src/lib/src/API/processSetting.d.ts b/_types/src/lib/src/API/processSetting.d.ts deleted file mode 100644 index d1f85295..00000000 --- a/_types/src/lib/src/API/processSetting.d.ts +++ /dev/null @@ -1,43 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type ObsidianLiveSyncSettings } from "@lib/common/types"; -/** - * Encode settings to a tiny array to encode in QRCode, - * Due to size limitation of QR code, we encode settings as an array instead of object. - * @param settings settings to encode - */ -export declare function encodeSettingsToQRCodeData(settings: ObsidianLiveSyncSettings): string; -/** - * Decode settings from QR code data string - * @param qr data string from QR code - * @returns Decoded settings - */ -export declare function decodeSettingsFromQRCodeData(qr: string): ObsidianLiveSyncSettings; -export declare const enum OutputFormat { - SVG = 0, - ASCII = 1 -} -export interface SplitQRCodeData { - total: number; - parts: string[]; -} -/** - * Encode setting string to QR code in specified format - * @param settingString Setting string to encode - * @param format Output format - */ -export declare function encodeQR(settingString: string, format: OutputFormat): string | SplitQRCodeData; -type ErasureProperties = keyof ObsidianLiveSyncSettings; -/** - * Generate setup URI with encrypted settings - * @param settingString Settings to encode - * @param passphrase Passphrase to encrypt the settings - * @param removeProperties Properties to remove from the settings - * Means these properties will not be included in the generated setup URI, - * See also necessaryErasureProperties for properties that will always be removed. - * @param skipDefaultValue Whether to skip default values - * @returns Generated setup URI - */ -export declare function encodeSettingsToSetupURI(settingString: ObsidianLiveSyncSettings, passphrase: string, removeProperties?: ErasureProperties[], skipDefaultValue?: boolean): Promise; -export declare function decodeSettingsFromSetupURI(uri: string, passphrase: string): Promise; -export {}; diff --git a/_types/src/lib/src/ContentSplitter/ContentSplitter.d.ts b/_types/src/lib/src/ContentSplitter/ContentSplitter.d.ts deleted file mode 100644 index 86296d3c..00000000 --- a/_types/src/lib/src/ContentSplitter/ContentSplitter.d.ts +++ /dev/null @@ -1,30 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -/** - * Content-Splitter for Self-hosted LiveSync. - * Splits content into manageable chunks for efficient storage and synchronisation. - */ -import { type FilePathWithPrefix } from "@lib/common/types.ts"; -import type { ISettingService } from "@lib/services/base/IService.ts"; -/** - * ContentSplitter interface for splitting content into chunks. - */ -export type SplitOptions = { - blob: Blob; - path: FilePathWithPrefix; - pieceSize: number; - plainSplit: boolean; - minimumChunkSize: number; - useWorker: boolean; - useSegmenter: boolean; -}; -/** - * The maximum size, in bytes, of a document to be processed by the content splitter in the foreground. - */ -export declare const MAX_CHUNKS_SIZE_ON_UI = 1024; -/** - * Options for the content splitter. - */ -export type ContentSplitterOptions = { - settingService: ISettingService; -}; diff --git a/_types/src/lib/src/ContentSplitter/ContentSplitterBase.d.ts b/_types/src/lib/src/ContentSplitter/ContentSplitterBase.d.ts deleted file mode 100644 index b4ce905b..00000000 --- a/_types/src/lib/src/ContentSplitter/ContentSplitterBase.d.ts +++ /dev/null @@ -1,53 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type SavingEntry } from "@lib/common/types.ts"; -import { type ContentSplitterOptions, type SplitOptions } from "./ContentSplitter.ts"; -export declare abstract class ContentSplitterCore { - /** - * Options for the content splitter. - * These settings include the chunk splitter version and other configurations. - */ - options: ContentSplitterOptions; - /** - * Task for initialising the content splitter. - * This ensures that the splitter is initialised before any operations are performed. - */ - initialised: Promise | undefined; - /** - * Constructor for the content splitter core. - * @param params Content splitter options - */ - constructor(params: ContentSplitterOptions); - /** - * Initialise the content splitter with the provided options. - * @param options Content splitter options - */ - abstract initialise(options: ContentSplitterOptions): Promise; - /** - * Split the content of the loaded entry into chunks. - * @param entry The loaded entry to be split into chunks - */ - abstract splitContent(entry: SavingEntry): Promise | Generator>; -} -export declare abstract class ContentSplitterBase extends ContentSplitterCore { - initialise(_options: ContentSplitterOptions): Promise; - /** - * Check whether the content splitter is available for the given settings. - * @param setting Content splitter options - * @returns True if the content splitter is available; false otherwise - */ - static isAvailableFor(setting: ContentSplitterOptions): boolean; - /** - * Process the content and split it into chunks. - * @param options Blob content to be split into chunks - */ - abstract processSplit(options: SplitOptions): Promise | Generator>; - getParamsFor(entry: SavingEntry): SplitOptions; - /** - * Split the content of the loaded entry into chunks. - * This method waits for the initialisation task to complete before proceeding. - * @param entry The loaded entry to be split into chunks - * @returns A generator that yields the split chunks - */ - splitContent(entry: SavingEntry): Promise | Generator>; -} diff --git a/_types/src/lib/src/ContentSplitter/ContentSplitterRabinKarp.d.ts b/_types/src/lib/src/ContentSplitter/ContentSplitterRabinKarp.d.ts deleted file mode 100644 index fcc69a25..00000000 --- a/_types/src/lib/src/ContentSplitter/ContentSplitterRabinKarp.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ContentSplitterOptions, SplitOptions } from "./ContentSplitter.ts"; -import { ContentSplitterBase } from "./ContentSplitterBase.ts"; -/** - * Rabin-Karp content splitter for efficient chunking - */ -export declare class ContentSplitterRabinKarp extends ContentSplitterBase { - static isAvailableFor(setting: ContentSplitterOptions): boolean; - processSplit(options: SplitOptions): Promise | Generator>; -} diff --git a/_types/src/lib/src/ContentSplitter/ContentSplitterV1.d.ts b/_types/src/lib/src/ContentSplitter/ContentSplitterV1.d.ts deleted file mode 100644 index 7e5c9083..00000000 --- a/_types/src/lib/src/ContentSplitter/ContentSplitterV1.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ContentSplitterOptions, SplitOptions } from "./ContentSplitter"; -import { ContentSplitterBase } from "./ContentSplitterBase"; -/** - * Legacy content splitter for version 1. - */ -export declare class ContentSplitterV1 extends ContentSplitterBase { - static isAvailableFor(setting: ContentSplitterOptions): boolean; - processSplit(options: SplitOptions): Promise | Generator>; -} diff --git a/_types/src/lib/src/ContentSplitter/ContentSplitterV2.d.ts b/_types/src/lib/src/ContentSplitter/ContentSplitterV2.d.ts deleted file mode 100644 index 29fc9dd3..00000000 --- a/_types/src/lib/src/ContentSplitter/ContentSplitterV2.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ContentSplitterOptions, SplitOptions } from "./ContentSplitter.ts"; -import { ContentSplitterBase } from "./ContentSplitterBase.ts"; -/** - * Content splitter for version 2, which supports segmenter-based splitting. - */ -export declare class ContentSplitterV2 extends ContentSplitterBase { - static isAvailableFor(setting: ContentSplitterOptions): boolean; - processSplit(options: SplitOptions): Promise | Generator>; -} diff --git a/_types/src/lib/src/ContentSplitter/ContentSplitters.d.ts b/_types/src/lib/src/ContentSplitter/ContentSplitters.d.ts deleted file mode 100644 index b7f3b544..00000000 --- a/_types/src/lib/src/ContentSplitter/ContentSplitters.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { SavingEntry } from "@lib/common/types"; -import type { ContentSplitterOptions } from "./ContentSplitter"; -import { ContentSplitterCore, type ContentSplitterBase } from "./ContentSplitterBase"; -/** - * ContentSplitter class that manages the active content splitter based on the provided settings. - */ -export declare class ContentSplitter extends ContentSplitterCore { - _activeSplitter: ContentSplitterBase; - constructor(options: ContentSplitterOptions); - initialise(options: ContentSplitterOptions): Promise; - splitContent(entry: SavingEntry): Promise | Generator>; -} diff --git a/_types/src/lib/src/UI/svelteDialog.d.ts b/_types/src/lib/src/UI/svelteDialog.d.ts deleted file mode 100644 index 5a965ef8..00000000 --- a/_types/src/lib/src/UI/svelteDialog.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export type { HasSetResult, HasGetInitialData, ComponentHasResult, GuestDialogProps, DialogSvelteComponentBaseProps, DialogControlBase, } from "@lib/services/implements/base/SvelteDialog.ts"; -export { CONTEXT_DIALOG_CONTROLS, setupDialogContext, getDialogContext, SvelteDialogManagerBase, } from "@lib/services/implements/base/SvelteDialog.ts"; diff --git a/_types/src/lib/src/bureau/bureau.d.ts b/_types/src/lib/src/bureau/bureau.d.ts deleted file mode 100644 index 801e9b66..00000000 --- a/_types/src/lib/src/bureau/bureau.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type SlipBoard } from "octagonal-wheels/bureau/SlipBoard"; -declare global { - interface Slips extends LSSlips { - _dummy: undefined; - } -} -export declare const globalSlipBoard: SlipBoard; diff --git a/_types/src/lib/src/common/ConnectionString.d.ts b/_types/src/lib/src/common/ConnectionString.d.ts deleted file mode 100644 index c043b2f0..00000000 --- a/_types/src/lib/src/common/ConnectionString.d.ts +++ /dev/null @@ -1,32 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { CouchDBConnection, BucketSyncSetting, P2PConnectionInfo } from "./models/setting.type"; -export type RemoteConfigurationResult = { - type: "couchdb"; - settings: CouchDBConnection; -} | { - type: "s3"; - settings: BucketSyncSetting; -} | { - type: "p2p"; - settings: P2PConnectionInfo; -} | { - type: "webdav"; - settings: never; -}; -export declare class ConnectionStringParser { - /** - * Restore settings from URI - */ - static parse(uriString: string): RemoteConfigurationResult; - /** - * č¨­åŽšã‹ã‚‰URIã‚’į”Ÿæˆã™ã‚‹ - */ - static serialize(config: RemoteConfigurationResult): string; - private static parseCouchDB; - private static serializeCouchDB; - private static parseS3; - private static serializeS3; - private static parseP2P; - private static serializeP2P; -} diff --git a/_types/src/lib/src/common/LSError.d.ts b/_types/src/lib/src/common/LSError.d.ts deleted file mode 100644 index 973ec158..00000000 --- a/_types/src/lib/src/common/LSError.d.ts +++ /dev/null @@ -1,50 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { Constructor } from "@lib/common/utils.type"; -interface ErrorWithCause extends Error { - cause?: unknown; -} -/** - * Error class for Self-hosted LiveSync errors. - * This class extends the base LiveSyncError class and provides additional context for errors related to LiveSync operations. - * It includes a name property and a cause property to capture the original error. - * The status property returns the HTTP status code if available, defaulting to 500 for internal server errors. - * The class also includes static methods to check whether an error is caused by a specific error class. - */ -export declare class LiveSyncError extends Error implements ErrorWithCause { - name: string; - cause?: Error | object | string; - overrideStatus?: number; - /** - * Returns the HTTP status code associated with the error, if available. - * If the error has a status property, it returns that; otherwise, it defaults to 500 (Internal Server Error). - * @returns {number} The HTTP status code. - */ - get status(): number; - /** - * Constructs a new LiveSyncError instance. - * @param message The error message to be displayed. - */ - constructor(message: string, options?: { - cause?: unknown; - status?: number; - }); - /** - * Determines whether an error is caused by a specific error class. - * @param error The error to examine. - * @param errorClass The error class to compare against. - * @returns True if the error is caused by the specified error class; otherwise, false. - * @example - * LiveSyncError.isCausedBy(someSyncParamsFetchError, SyncParamsNotFoundError); // Returns true if the error is caused by SyncParamsNotFoundError; this is usually represented as SyncParamsFetchError at the uppermost layer. - */ - static isCausedBy(error: unknown, errorClass: Constructor): boolean; - /** - * Creates a new instance of the error class from an existing error. - * @param error The error to wrap. - * @returns A new instance of the error class with the original error's message and stack trace. - */ - static fromError(this: T, error: unknown): InstanceType; -} -export declare class LiveSyncFatalError extends LiveSyncError { -} -export {}; diff --git a/_types/src/lib/src/common/configForDoc.d.ts b/_types/src/lib/src/common/configForDoc.d.ts deleted file mode 100644 index 3b6a8e58..00000000 --- a/_types/src/lib/src/common/configForDoc.d.ts +++ /dev/null @@ -1,79 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { Confirm } from "@lib/interfaces/Confirm"; -import { type ObsidianLiveSyncSettings } from "./types"; -declare enum ConditionType { - PLATFORM_CASE_INSENSITIVE = "platform-case-insensitive", - PLATFORM_CASE_SENSITIVE = "platform-case-sensitive", - REMOTE_CASE_SENSITIVE = "remote-case-sensitive" -} -export declare enum RuleLevel { - Must = 0, - Necessary = 1, - Recommended = 2, - Optional = 3 -} -type BaseRule = { - level?: RuleLevel; - requireRebuild?: boolean; - requireRebuildLocal?: boolean; - recommendRebuild?: boolean; - reason?: string; - reasonFunc?: (settings: Partial) => string; - condition?: ConditionType[]; - detectionFunc?: (settings: Partial) => boolean; - value?: TValue; - valueDisplay?: string; - valueDisplayFunc?: (settings: Partial) => string; - obsoleteValues?: TValue[]; -}; -type NumberRuleExact = BaseRule<"number", number> & {}; // eslint-disable-line @typescript-eslint/no-empty-object-type, @typescript-eslint/ban-types -- Empty object type -type NumberRuleRange = BaseRule<"number", number> & { - min?: number; - max?: number; - step?: number; -}; -type StringRangeRule = BaseRule<"string", string> & { - minLength?: number; - maxLength?: number; - regexp?: string; -}; -type StringRule = BaseRule<"string", string> & {}; // eslint-disable-line @typescript-eslint/no-empty-object-type, @typescript-eslint/ban-types -- Empty object type -type BooleanRule = BaseRule<"boolean", boolean> & {}; // eslint-disable-line @typescript-eslint/no-empty-object-type, @typescript-eslint/ban-types -- Empty object type -export type RuleForType = T extends number ? NumberRuleExact | NumberRuleRange : T extends string ? StringRule | StringRangeRule : T extends boolean ? BooleanRule : never; -type DoctorCheckSettings = Omit, "remoteConfigurations" | "pluginSyncExtendedSetting">; -export type DoctorRegulation = { - version: string; - rules: { - [P in keyof DoctorCheckSettings]: RuleForType; - }; -}; -export declare const DoctorRegulationV0_24_16: DoctorRegulation; -export declare const DoctorRegulationV0_24_30: DoctorRegulation; -export declare const DoctorRegulationV0_25_0: DoctorRegulation; -export declare const DoctorRegulationV0_25_27: DoctorRegulation; -export declare const DoctorRegulation: DoctorRegulation; -export declare function checkUnsuitableValues(setting: Partial, regulation?: DoctorRegulation): DoctorRegulation; -export declare const RebuildOptions: { - readonly AutomaticAcceptable: 0; - readonly ConfirmIfRequired: 1; - readonly SkipEvenIfRequired: 2; -}; -export type RebuildOptionsType = (typeof RebuildOptions)[keyof typeof RebuildOptions]; -export type DoctorOptions = { - localRebuild: RebuildOptionsType; - remoteRebuild: RebuildOptionsType; - activateReason?: string; - forceRescan?: boolean; -}; -export type DoctorResult = { - settings: ObsidianLiveSyncSettings; - shouldRebuild: boolean; - shouldRebuildLocal: boolean; - isModified: boolean; -}; -export type HasConfirm = { - confirm: Confirm; -}; -export declare function performDoctorConsultation(env: HasConfirm, settings: ObsidianLiveSyncSettings, { localRebuild, remoteRebuild, activateReason, forceRescan, }: DoctorOptions): Promise; -export {}; diff --git a/_types/src/lib/src/common/coreEnvFunctions.d.ts b/_types/src/lib/src/common/coreEnvFunctions.d.ts deleted file mode 100644 index 693955e7..00000000 --- a/_types/src/lib/src/common/coreEnvFunctions.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { getLanguage as ObsidianGetLanguage } from "obsidian"; -export declare function setGetLanguage(func: typeof ObsidianGetLanguage): void; -export declare function getLanguage(): string; -export declare const compatGlobal: typeof window; -export type CompatTimeoutHandle = ReturnType | number; -export type CompatIntervalHandle = ReturnType | number; -/** - * A wrapper around the global fetch function to ensure compatibility across different environments. - * In Obsidian, they recommend using their own requestUrl for better performance and reliability. - * However, at least for now, requestUrl cannot handle multiple concurrent requests, which causes - * problems for synchronise lively. So we will use the global fetch for now. - * If the situation changes in the future, change this function to use requestUrl. - * @param {RequestInfo} input The resource that you wish to fetch. Can be either a string or a Request object. - * @param {RequestInit} [init] An options object containing any custom settings that you want to apply to the request. - * @returns {Promise} A Promise that resolves to the Response to that request, whether it is successful or not. - */ -export declare const _fetch: { - (input: RequestInfo | URL, init?: RequestInit): Promise; - (input: RequestInfo | URL, init?: RequestInit): Promise; -} & typeof fetch; -export declare const _activeDocument: Document; diff --git a/_types/src/lib/src/common/coreEnvVars.d.ts b/_types/src/lib/src/common/coreEnvVars.d.ts deleted file mode 100644 index 6788310e..00000000 --- a/_types/src/lib/src/common/coreEnvVars.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -declare const manifestVersion: string; -declare const packageVersion: string; -export { manifestVersion, packageVersion }; diff --git a/_types/src/lib/src/common/i18n.d.ts b/_types/src/lib/src/common/i18n.d.ts deleted file mode 100644 index d557488a..00000000 --- a/_types/src/lib/src/common/i18n.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { AllMessageKeys, I18N_LANGS } from "./rosetta"; -import type { TaggedType } from "./types"; -export declare let currentLang: I18N_LANGS; -export declare function getResolvedLang(lang?: I18N_LANGS): I18N_LANGS; -export declare function isAutoDisplayLanguage(lang: I18N_LANGS): boolean; -export declare function __getMissingTranslations(): string[]; -export declare function __onMissingTranslation(callback: (key: string) => void): void; -export declare function setLang(lang: I18N_LANGS): void; -export declare function $t(message: string, lang?: I18N_LANGS): string; -export declare function translateIfAvailable(message: string, lang?: I18N_LANGS): string; -/** - * TagFunction to Automatically translate. - * @param strings - * @param values - * @returns - */ -export declare function $f(strings: TemplateStringsArray, ...values: string[]): string; -export declare function $msg(key: T, params?: Record, lang?: I18N_LANGS): TaggedType; diff --git a/_types/src/lib/src/common/logger.d.ts b/_types/src/lib/src/common/logger.d.ts deleted file mode 100644 index 051b4a73..00000000 --- a/_types/src/lib/src/common/logger.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export * from "octagonal-wheels/common/logger"; -export type * from "octagonal-wheels/common/logger"; diff --git a/_types/src/lib/src/common/messages/combinedMessages.dev.d.ts b/_types/src/lib/src/common/messages/combinedMessages.dev.d.ts deleted file mode 100644 index 821dec6b..00000000 --- a/_types/src/lib/src/common/messages/combinedMessages.dev.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { PartialMessages as def } from "./def.ts"; -import { type MESSAGE } from "@lib/common/rosetta.ts"; -type MessageKeys = keyof typeof def.def; -export declare const allMessages: { - [key: string]: MESSAGE; -}; -export { type MessageKeys }; diff --git a/_types/src/lib/src/common/messages/combinedMessages.prod.d.ts b/_types/src/lib/src/common/messages/combinedMessages.prod.d.ts deleted file mode 100644 index 92957cd6..00000000 --- a/_types/src/lib/src/common/messages/combinedMessages.prod.d.ts +++ /dev/null @@ -1,9266 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare const allMessages: { - readonly "(Active)": { - readonly def: "(Active)"; - readonly es: "(Activo)"; - readonly ja: "īŧˆæœ‰åŠšīŧ‰"; - readonly ko: "(í™œė„ą)"; - readonly ru: "(АĐēŅ‚Đ¸Đ˛ĐŊа)"; - readonly zh: "īŧˆåˇ˛å¯į”¨īŧ‰"; - readonly "zh-tw": "īŧˆåˇ˛å•Ÿį”¨īŧ‰"; - }; - readonly "(BETA) Always overwrite with a newer file": { - readonly def: "(BETA) Always overwrite with a newer file"; - readonly es: "(BETA) Sobrescribir siempre con archivo mÃĄs nuevo"; - readonly fr: "(BÊTA) Toujours Êcraser avec un fichier plus rÊcent"; - readonly he: "(BETA) ×Ēמיד לדרוס ×ĸם קוב×Ĩ חדש יו×Ēר"; - readonly ja: "(ベãƒŧã‚ŋ抟čƒŊ) 常ãĢæ–°ã—ã„ãƒ•ã‚Ąã‚¤ãƒĢで上書きする"; - readonly ko: "(베타) í•­ėƒ ėƒˆëĄœėš´ 파ėŧ로 ëŽė–´ė“°ę¸°"; - readonly ru: "(БЕĐĸА) Đ’ŅĐĩĐŗĐ´Đ° ĐŋĐĩŅ€ĐĩСаĐŋĐ¸ŅŅ‹Đ˛Đ°Ņ‚ŅŒ йОĐģĐĩĐĩ ĐŊĐžĐ˛Ņ‹Đŧ Ņ„Đ°ĐšĐģĐžĐŧ"; - readonly zh: "始įģˆäŊŋį”¨æ›´æ–°įš„æ–‡äģļčφᛖ(æĩ‹č¯•į‰ˆ)"; - }; - readonly "(Beta) Use ignore files": { - readonly def: "(Beta) Use ignore files"; - readonly es: "(Beta) Usar archivos de ignorar"; - readonly fr: "(BÃĒta) Utiliser les fichiers d'exclusion"; - readonly he: "(בטא) שימוש בקב×Ļי ה×Ē×ĸלמו×Ē"; - readonly ja: "(ベãƒŧã‚ŋ抟čƒŊ) é™¤å¤–ãƒ•ã‚Ąã‚¤ãƒĢ(ignore)ぎäŊŋᔍ"; - readonly ko: "(베타) ė œė™¸ ęˇœėš™ 파ėŧ ė‚ŦėšŠ"; - readonly ru: "(БĐĩŅ‚Đ°) Đ˜ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ŅŒ Ņ„Đ°ĐšĐģŅ‹ Đ¸ĐŗĐŊĐžŅ€Đ¸Ņ€ĐžĐ˛Đ°ĐŊĐ¸Ņ"; - readonly zh: "(æĩ‹č¯•į‰ˆ)äŊŋᔍåŋŊį•Ĩ文äģļ"; - }; - readonly "(Days passed, 0 to disable automatic-deletion)": { - readonly def: "(Days passed, 0 to disable automatic-deletion)"; - readonly es: "(Días transcurridos, 0 para desactivar)"; - readonly fr: "(Jours ÊcoulÊs, 0 pour dÊsactiver la suppression automatique)"; - readonly he: "(ימים ׊×ĸברו; 0 לביטול מחיקה אוטומטי×Ē)"; - readonly ja: "(įĩŒéŽæ—Ĩ数、0でč‡Ēå‹•å‰Šé™¤ã‚’į„ĄåŠšåŒ–)"; - readonly ko: "(ė§€ë‚œ ėŧ눘, 0ėœŧ로 ė„¤ė •í•˜ëŠ´ ėžë™ ė‚­ė œ ëš„í™œė„ąí™”)"; - readonly ru: "(ДĐŊĐĩĐš ĐŋŅ€ĐžŅˆĐģĐž, 0 Đ´ĐģŅ ĐžŅ‚ĐēĐģŅŽŅ‡ĐĩĐŊĐ¸Ņ Đ°Đ˛Ņ‚ĐžĐŧĐ°Ņ‚Đ¸Ņ‡ĐĩҁĐēĐžĐŗĐž ŅƒĐ´Đ°ĐģĐĩĐŊĐ¸Ņ)"; - readonly zh: "(厞čŋ‡å¤Šæ•°,0ä¸ēįρᔍč‡Ē动删除)"; - }; - readonly "(ex. Read chunks online) If this option is enabled, LiveSync reads chunks online directly instead of replicating them locally. Increasing Custom chunk size is recommended.": { - readonly def: "(ex. Read chunks online) If this option is enabled, LiveSync reads chunks online directly instead of replicating them locally. Increasing Custom chunk size is recommended."; - readonly es: "(Ej: Leer chunks online) Lee chunks directamente en línea. Aumente tamaÃąo de chunks personalizados"; - readonly fr: "(ex. Lire les fragments en ligne) Si cette option est activÊe, LiveSync lit les fragments directement en ligne au lieu de les rÊpliquer localement. L'augmentation de la taille personnalisÊe des fragments est recommandÊe."; - readonly he: "(לדוגמה: קריא×Ē × ×Ēחים אונליין) אם אפשרו×Ē ×–×• מופ×ĸל×Ē, LiveSync קורא × ×Ēחים ישירו×Ē ×ž×”×Š×¨×Ē ×ž×‘×œ×™ לשכפל או×Ēם מקומי×Ē. מומל×Ĩ להגדיל א×Ē ×’×•×“×œ הנ×Ēח המו×Ēאם אישי×Ē."; - readonly ja: "(例: ãƒãƒŖãƒŗã‚¯ã‚’ã‚ĒãƒŗãƒŠã‚¤ãƒŗã§čĒ­ã‚€) こぎã‚Ēãƒ—ã‚ˇãƒ§ãƒŗã‚’æœ‰åŠšãĢすると、LiveSyncã¯ãƒãƒŖãƒŗã‚¯ã‚’ãƒ­ãƒŧã‚ĢãƒĢãĢ複čŖŊã›ãšã€į›´æŽĨã‚ĒãƒŗãƒŠã‚¤ãƒŗã§čĒ­ãŋčžŧãŋぞす。ã‚Ģ゚ã‚ŋãƒ ãƒãƒŖãƒŗã‚¯ã‚ĩイã‚ēをåĸ—やすことをお勧めしぞす。"; - readonly ko: "(똈: 랭íŦëĨŧ ė›ę˛Šė—ė„œ ėŊėŒ) ė´ ė˜ĩė…˜ė„ í™œė„ąí™”í•˜ëŠ´, LiveSync는 랭íŦëĨŧ 로ėģŦ뗐 ëŗĩė œí•˜ė§€ ė•Šęŗ  ė›ę˛Šė—ė„œ 링렑 ėŊėŠĩ니다. ėģ¤ėŠ¤í…€ 랭íŦ íŦ기ëĨŧ í‚¤ėš°ëŠ” ę˛ƒė„ ęļŒėžĨ합니다."; - readonly ru: "(ex. Read chunks online) If this option is enabled, LiveSync reads chunks online directly instead of replicating them locally. Increasing Custom chunk size is recommended."; - readonly zh: "(例åĻ‚,在įēŋč¯ģ取块)åĻ‚æžœå¯į”¨æ­¤é€‰éĄš,LiveSync 将ᛴæŽĨ在įēŋč¯ģ取块,č€Œä¸æ˜¯åœ¨æœŦ地复åˆļ块。åģē莎åĸžåŠ č‡Ē厚䚉块大小"; - }; - readonly "(MB) If this is set, changes to local and remote files that are larger than this will be skipped. If the file becomes smaller again, a newer one will be used.": { - readonly def: "(MB) If this is set, changes to local and remote files that are larger than this will be skipped. If the file becomes smaller again, a newer one will be used."; - readonly es: "(MB) Saltar cambios en archivos locales/remotos mayores a este tamaÃąo. Si se reduce, se usarÃĄ versiÃŗn nueva"; - readonly fr: "(Mo) Si cette valeur est dÊfinie, les modifications des fichiers locaux et distants plus grands que cette taille seront ignorÊes. Si le fichier redevient plus petit, une version plus rÊcente sera utilisÊe."; - readonly he: "(MB) אם ×ĸרך זה מוגדר, שינויים בקב×Ļים מקומיים ומרוחקים הגדולים מגודל זה יידלגו. אם הקוב×Ĩ יקטן שוב, יי×ĸשה שימוש בגרסה החדשה יו×Ēר."; - readonly ja: "(MB) ã“ãŽå€¤ã‚’č¨­åŽšã™ã‚‹ã¨ã€ã“ã‚Œã‚ˆã‚Šå¤§ãã„ã‚ĩイã‚ēぎロãƒŧã‚ĢãƒĢãƒ•ã‚Ąã‚¤ãƒĢやãƒĒãƒĸãƒŧãƒˆãƒ•ã‚Ąã‚¤ãƒĢãŽå¤‰æ›´ã¯ã‚šã‚­ãƒƒãƒ—ã•ã‚Œãžã™ã€‚ãƒ•ã‚Ąã‚¤ãƒĢãŒå†ãŗå°ã•ããĒãŖãŸå ´åˆã¯ã€æ–°ã—ã„ã‚‚ãŽãŒäŊŋį”¨ã•ã‚Œãžã™ã€‚"; - readonly ko: "(MB) ė´ ę°’ė´ ė„¤ė •ë˜ëŠ´, ė´ëŗ´ë‹¤ 큰 로ėģŦ 및 ė›ę˛Š 파ėŧė˜ ëŗ€ę˛Ŋ ė‚Ŧí•­ė€ 건너뜁니다. 파ėŧė´ ë‹¤ė‹œ ėž‘ė•„ė§€ëŠ´ 더 ėƒˆëĄœėš´ 파ėŧė´ ė‚ŦėšŠëŠë‹ˆë‹¤."; - readonly ru: "(MB) If this is set, changes to local and remote files that are larger than this will be skipped. If the file becomes smaller again, a newer one will be used."; - readonly zh: "(MB)åĻ‚æžœčŽžįŊŽä熿­¤éĄš,大äēŽæ­¤å¤§å°įš„æœŦ地和čŋœį¨‹æ–‡äģļįš„æ›´æ”šå°†čĸĢ莺čŋ‡ã€‚åĻ‚æžœæ–‡äģļ再æŦĄå˜å°,将äŊŋį”¨æ›´æ–°įš„æ–‡äģļ"; - }; - readonly "(Mega chars)": { - readonly def: "(Mega chars)"; - readonly es: "(Millones de caracteres)"; - readonly fr: "(MÊga caractères)"; - readonly he: "(מגה ×Ēווים)"; - readonly ja: "(ãƒĄã‚Ŧ文字)"; - readonly ko: "(메가 ëŦ¸ėž)"; - readonly ru: "(МĐĩĐŗĐ° ŅĐ¸ĐŧвОĐģОв)"; - readonly zh: "(į™žä¸‡å­—įŦĻ)"; - }; - readonly "(Not recommended) If set, credentials will be stored in the file.": { - readonly def: "(Not recommended) If set, credentials will be stored in the file."; - readonly es: "(No recomendado) Almacena credenciales en el archivo"; - readonly fr: "(Non recommandÊ) Si activÊ, les identifiants seront stockÊs dans le fichier."; - readonly he: "(לא מומל×Ĩ) אם מוגדר, פרטי הגישה יישמרו בקוב×Ĩ."; - readonly ja: "(非推åĨ¨) č¨­åŽšã—ãŸå ´åˆã€čĒč¨ŧæƒ…å ąãŒãƒ•ã‚Ąã‚¤ãƒĢãĢäŋå­˜ã•れぞす。"; - readonly ko: "(ęļŒėžĨí•˜ė§€ ė•ŠėŒ) ė„¤ė •í•œ ę˛Ŋ뚰 ėžę˛Š ėĻëĒ…ė´ 파ėŧ뗐 ė €ėžĨ됩니다."; - readonly ru: "(Not recommended) If set, credentials will be stored in the file."; - readonly zh: "(不åģē莎)åĻ‚æžœčŽžįŊŽ,凭捎将存储在文äģļ中"; - }; - readonly "(Obsolete) Use an old adapter for compatibility": { - readonly def: "(Obsolete) Use an old adapter for compatibility"; - readonly es: "(Obsoleto) Usar adaptador antiguo"; - readonly fr: "(Obsolète) Utiliser un ancien adaptateur pour la compatibilitÊ"; - readonly he: "(מיושן) שימוש במ×Ēאם ישן ל×Ēאימו×Ē ×œ××—×•×¨"; - readonly ja: "(å샿­ĸ済ãŋ)古いã‚ĸダプã‚ŋãƒŧをäē’æ›æ€§ãŽãŸã‚ãĢåˆŠį”¨"; - readonly ko: "(ė‚ŦėšŠ ė¤‘ë‹¨) í˜¸í™˜ė„ąė„ ėœ„í•´ ė´ė „ ė–´ëŒ‘í„° ė‚ŦėšŠ"; - readonly ru: "(ĐŖŅŅ‚Đ°Ņ€ĐĩĐģĐž) Đ˜ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ŅŒ ŅŅ‚Đ°Ņ€Ņ‹Đš адаĐŋŅ‚ĐĩŅ€ Đ´ĐģŅ ŅĐžĐ˛ĐŧĐĩŅŅ‚Đ¸ĐŧĐžŅŅ‚Đ¸"; - readonly zh: "(厞åŧƒį”¨)ä¸ēå…ŧ厚性äŊŋį”¨æ—§é€‚é…å™¨"; - }; - readonly "(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": { - readonly def: "(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files."; - readonly es: "(RegExp) DÊjelo vacío para sincronizar todos los archivos. Defina un filtro como expresiÃŗn regular para limitar los archivos que se sincronizan."; - readonly ja: "īŧˆæ­ŖčĻčĄ¨įžīŧ‰įŠēæŦ„ã§å…¨ãƒ•ã‚Ąã‚¤ãƒĢã‚’åŒæœŸã—ãžã™ã€‚æ­ŖčĻčĄ¨įžã‚’æŒ‡åŽšã™ã‚‹ã¨ã€åŒæœŸå¯žčąĄãŽãƒ•ã‚Ąã‚¤ãƒĢをįĩžã‚Ščžŧめぞす。"; - readonly ko: "(ė •ęˇœė‹) ëš„ė›Œ 두면 ëĒ¨ë“  파ėŧė„ 동기화합니다. ė •ęˇœė‹ė„ ė§€ė •í•˜ëŠ´ 동기화할 파ėŧė„ ė œí•œí•  눘 ėžˆėŠĩ니다."; - readonly ru: "(RegExp) ĐžŅŅ‚Đ°Đ˛ŅŒŅ‚Đĩ ĐŋŅƒŅŅ‚Ņ‹Đŧ, Ņ‡Ņ‚ĐžĐąŅ‹ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ Đ˛ŅĐĩ Ņ„Đ°ĐšĐģŅ‹. ĐŖĐēаĐļĐ¸Ņ‚Đĩ Ņ€ĐĩĐŗŅƒĐģŅŅ€ĐŊĐžĐĩ Đ˛Ņ‹Ņ€Đ°ĐļĐĩĐŊиĐĩ, Ņ‡Ņ‚ĐžĐąŅ‹ ĐžĐŗŅ€Đ°ĐŊĐ¸Ņ‡Đ¸Ņ‚ŅŒ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ¸Ņ€ŅƒĐĩĐŧŅ‹Đĩ Ņ„Đ°ĐšĐģŅ‹."; - readonly zh: "īŧˆæ­Ŗåˆ™čĄ¨čžžåŧīŧ‰į•™įŠē襨į¤ē同æ­Ĩ所有文äģļã€‚å¯čŽžįŊŽæ­Ŗåˆ™čĄ¨čžžåŧæĨ限åˆļ需čρ同æ­Ĩįš„æ–‡äģļ。"; - readonly "zh-tw": "īŧˆæ­Ŗå‰‡čĄ¨į¤ēåŧīŧ‰į•™įŠēåŗåŒæ­Ĩ所有æĒ”æĄˆã€‚č¨­åŽšæ­Ŗå‰‡čĄ¨į¤ēåŧå¯é™åˆļčρ同æ­Ĩįš„æĒ”æĄˆã€‚"; - }; - readonly "(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": { - readonly def: "(RegExp) If this is set, any changes to local and remote files that match this will be skipped."; - readonly es: "(RegExp) Si se establece, se omitirÃĄ cualquier cambio en archivos locales y remotos que coincida con este patrÃŗn."; - readonly ja: "īŧˆæ­ŖčĻčĄ¨įžīŧ‰č¨­åŽšã™ã‚‹ã¨ã€ã“ã‚ŒãĢä¸€č‡´ã™ã‚‹ãƒ­ãƒŧã‚ĢãƒĢīŧãƒĒãƒĸãƒŧãƒˆãƒ•ã‚Ąã‚¤ãƒĢぎ変更はすずãĻ゚キップされぞす。"; - readonly ko: "(ė •ęˇœė‹) ė„¤ė •í•˜ëŠ´ ė´ 패턴ęŗŧ ėŧėš˜í•˜ëŠ” 로ėģŦ 및 ė›ę˛Š 파ėŧ ëŗ€ę˛Ŋė€ ëĒ¨ë‘ 건너뜁니다."; - readonly ru: "(RegExp) Đ•ŅĐģи СадаĐŊĐž, ĐģŅŽĐąŅ‹Đĩ иСĐŧĐĩĐŊĐĩĐŊĐ¸Ņ ĐģĐžĐēаĐģҌĐŊҋ҅ и ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊҋ҅ Ņ„Đ°ĐšĐģОв, ŅĐžĐžŅ‚Đ˛ĐĩŅ‚ŅŅ‚Đ˛ŅƒŅŽŅ‰Đ¸Ņ… ŅŅ‚ĐžĐŧ҃ ŅˆĐ°ĐąĐģĐžĐŊ҃, ĐąŅƒĐ´ŅƒŅ‚ ĐŋŅ€ĐžĐŋ҃ҁĐēĐ°Ņ‚ŅŒŅŅ."; - readonly zh: "īŧˆæ­Ŗåˆ™čĄ¨čžžåŧīŧ‰åĻ‚æžœåˇ˛čŽžįŊŽīŧŒåˆ™æ‰€æœ‰åŒšé…æ­¤æ¨Ąåŧįš„æœŦ地和čŋœįĢ¯æ–‡äģļ变更éƒŊäŧščĸĢ莺čŋ‡ã€‚"; - readonly "zh-tw": "īŧˆæ­Ŗå‰‡čĄ¨į¤ēåŧīŧ‰č‹Ĩåˇ˛č¨­åŽšīŧŒæ‰€æœ‰įŦĻåˆæ­¤æ¨Ąåŧįš„æœŦæŠŸčˆ‡é į̝æĒ”æĄˆčŽŠæ›´éƒŊ會čĸĢį•Ĩ過。"; - }; - readonly "(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": { - readonly def: "(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch."; - readonly es: "(Seleccione esto si ya utiliza la sincronizaciÃŗn en otro ordenador o telÊfono). Esta opciÃŗn es adecuada si desea aÃąadir este dispositivo a una configuraciÃŗn de LiveSync existente。"; - readonly ja: "īŧˆåˆĨぎ PC や゚マãƒŧãƒˆãƒ•ã‚Šãƒŗã§ã™ã§ãĢåŒæœŸã‚’åˆŠį”¨ã—ãĻいる場合ãĢ選択しãĻください。īŧ‰ã“ぎį̝æœĢをæ—ĸ存ぎ LiveSync 構成ãĢčŋŊ加する場合ãĢ遊しãĻいぞす。"; - readonly ko: "(다ëĨ¸ ėģ´í“¨í„°ë‚˜ ėŠ¤ë§ˆíŠ¸í°ė—ė„œ ė´ë¯¸ 동기화ëĨŧ ė‚ŦėšŠ ė¤‘ė¸ ę˛Ŋ뚰 ė„ íƒí•˜ė„¸ėš”.) ė´ ėžĨėš˜ëĨŧ ę¸°ėĄ´ LiveSync ęĩŦė„ąė— ėļ”ę°€í•˜ë ¤ëŠ” ę˛Ŋėš°ė— ė í•Ší•Šë‹ˆë‹¤ã€‚"; - readonly ru: "(Đ’Ņ‹ĐąĐĩŅ€Đ¸Ņ‚Đĩ ŅŅ‚ĐžŅ‚ Đ˛Đ°Ņ€Đ¸Đ°ĐŊŅ‚, ĐĩҁĐģи Đ˛Ņ‹ ҃ĐļĐĩ Đ¸ŅĐŋĐžĐģŅŒĐˇŅƒĐĩŅ‚Đĩ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸ŅŽ ĐŊа Đ´Ņ€ŅƒĐŗĐžĐŧ ĐēĐžĐŧĐŋŅŒŅŽŅ‚ĐĩŅ€Đĩ иĐģи ҁĐŧĐ°Ņ€Ņ‚Ņ„ĐžĐŊĐĩ.) ОĐŊ ĐŋĐžĐ´Ņ…ĐžĐ´Đ¸Ņ‚, ĐĩҁĐģи Đ˛Ņ‹ Ņ…ĐžŅ‚Đ¸Ņ‚Đĩ Đ´ĐžĐąĐ°Đ˛Đ¸Ņ‚ŅŒ ŅŅ‚Đž ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đž Đē ҃ĐļĐĩ ŅŅƒŅ‰ĐĩŅŅ‚Đ˛ŅƒŅŽŅ‰ĐĩĐš ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Đ¸ LiveSync。"; - readonly zh: "īŧˆåĻ‚æžœäŊ åˇ˛įģåœ¨åĻ一台į”ĩč„‘æˆ–æ‰‹æœē上äŊŋį”¨åŒæ­ĨīŧŒč¯ˇé€‰æ‹Šæ­¤éĄšã€‚īŧ‰æ­¤é€‰éĄšé€‚合将åŊ“å‰čŽžå¤‡åŠ å…ĨįŽ°æœ‰ LiveSync 配įŊŽįš„į”¨æˆˇã€‚"; - readonly "zh-tw": "īŧˆåĻ‚æžœäŊ åˇ˛įļ“在åĻ一台é›ģč…Ļ或手抟上äŊŋį”¨åŒæ­ĨīŧŒčĢ‹é¸æ“‡æ­¤é …ã€‚īŧ‰æ­¤é¸é …éŠåˆå°‡į›Žå‰čŖįŊŽåŠ å…Ĩæ—ĸ有 LiveSync č¨­åŽšįš„äŊŋį”¨č€…ã€‚"; - }; - readonly "(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": { - readonly def: "(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch."; - readonly es: "(Seleccione esto si estÃĄ configurando este dispositivo como el primer dispositivo de sincronizaciÃŗn). Esta opciÃŗn es adecuada si es nuevo en LiveSync y desea configurarlo desde cero。"; - readonly ja: "īŧˆã“ぎį̝æœĢを最初ぎ同期į̝æœĢとしãĻč¨­åŽšã™ã‚‹å ´åˆãĢ選択しãĻください。īŧ‰LiveSync を初めãĻåˆŠį”¨ã—ã€æœ€åˆã‹ã‚‰č¨­åŽšã—ãŸã„å ´åˆãĢ遊しãĻいぞす。"; - readonly ko: "(ė´ ėžĨėš˜ëĨŧ ė˛Ģ ë˛ˆė§¸ 동기화 ėžĨėš˜ëĄœ ė„¤ė •í•˜ëŠ” ę˛Ŋ뚰 ė„ íƒí•˜ė„¸ėš”.) LiveSyncëĨŧ ė˛˜ėŒ ė‚ŦėšŠí•˜ëŠ° ė˛˜ėŒëļ€í„° ė„¤ė •í•˜ë ¤ëŠ” ę˛Ŋėš°ė— ė í•Ší•Šë‹ˆë‹¤ã€‚"; - readonly ru: "(Đ’Ņ‹ĐąĐĩŅ€Đ¸Ņ‚Đĩ ŅŅ‚ĐžŅ‚ Đ˛Đ°Ņ€Đ¸Đ°ĐŊŅ‚, ĐĩҁĐģи ĐŊĐ°ŅŅ‚Ņ€Đ°Đ¸Đ˛Đ°ĐĩŅ‚Đĩ ŅŅ‚Đž ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đž ĐēаĐē ĐŋĐĩŅ€Đ˛ĐžĐĩ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đž ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸.) ОĐŊ ĐŋĐžĐ´Ņ…ĐžĐ´Đ¸Ņ‚, ĐĩҁĐģи Đ˛Ņ‹ вĐŋĐĩŅ€Đ˛Ņ‹Đĩ Đ¸ŅĐŋĐžĐģŅŒĐˇŅƒĐĩŅ‚Đĩ LiveSync и Ņ…ĐžŅ‚Đ¸Ņ‚Đĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐ¸Ņ‚ŅŒ Đ˛ŅŅ‘ ҁ ĐŊ҃ĐģŅã€‚"; - readonly zh: "īŧˆåĻ‚æžœäŊ æ­Ŗåœ¨å°†æ­¤čŽžå¤‡é…įŊŽä¸ēįŦŦ一台同æ­ĨčŽžå¤‡īŧŒč¯ˇé€‰æ‹Šæ­¤éĄšã€‚īŧ‰æ­¤é€‰éĄšé€‚合初æŦĄäŊŋᔍ LiveSyncīŧŒåšļ希望äģŽå¤´åŧ€å§‹é…įŊŽįš„į”¨æˆˇã€‚"; - readonly "zh-tw": "īŧˆåĻ‚æžœäŊ æ­Ŗåœ¨å°‡æ­¤čŖįŊŽč¨­åޚį‚ēįŦŦ一台同æ­ĨčŖįŊŽīŧŒčĢ‹é¸æ“‡æ­¤é …ã€‚īŧ‰æ­¤é¸é …遊合初æŦĄäŊŋᔍ LiveSyncīŧŒä¸Ļå¸Œæœ›åžžé ­é–‹å§‹č¨­åŽšįš„äŊŋį”¨č€…ã€‚"; - }; - readonly "> [!INFO]- The connected devices have been detected as follows:\n${devices}": { - readonly def: "> [!INFO]- The connected devices have been detected as follows:\n${devices}"; - readonly ja: "> [!INFO]- æŦĄãŽæŽĨį¸ˆãŋデバイ゚が検å‡ēされぞした:\n${devices}"; - readonly ko: "> [!INFO]- ë‹¤ėŒ ė—°ę˛°ëœ 기기가 ę°ė§€ë˜ė—ˆėŠĩ니다:\n${devices}"; - readonly ru: "> [!INFO]- ОбĐŊĐ°Ņ€ŅƒĐļĐĩĐŊŅ‹ ҁĐģĐĩĐ´ŅƒŅŽŅ‰Đ¸Đĩ ĐŋОдĐēĐģŅŽŅ‡Ņ‘ĐŊĐŊŅ‹Đĩ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°:\n${devices}"; - readonly zh: "> [!INFO]- åˇ˛æŖ€æĩ‹åˆ°äģĨ䏋厞čŋžæŽĨčŽžå¤‡īŧš\n${devices}"; - readonly "zh-tw": "> [!INFO]- 厞åĩæ¸Ŧ到äģĨä¸‹åˇ˛é€ŖįˇščŖįŊŽīŧš\n${devices}"; - }; - readonly "A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration.": { - readonly def: "A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration."; - readonly es: "Un URI de configuraciÃŗn es una Ãēnica cadena de texto que contiene la direcciÃŗn del servidor y los datos de autenticaciÃŗn. Si el script de instalaciÃŗn de su servidor generÃŗ un URI, usarlo proporciona una configuraciÃŗn sencilla y segura。"; - readonly ja: "Setup URI は、ã‚ĩãƒŧバãƒŧã‚ĸドãƒŦ゚とčĒč¨ŧæƒ…å ąã‚’åĢむ 1 æœŦぎ文字列です。ã‚ĩãƒŧバãƒŧãŽã‚¤ãƒŗã‚šãƒˆãƒŧãƒĢ゚クãƒĒãƒ—ãƒˆã§į”Ÿæˆã•ã‚ŒãŸ URI がある場合は、それをäŊŋã†ã¨į°Ąå˜ã‹ã¤åŽ‰å…¨ãĢč¨­åŽšã§ããžã™ã€‚"; - readonly ko: "네렕 URI는 ė„œë˛„ ėŖŧė†Œė™€ ė¸ėĻ ė •ëŗ´ëĨŧ íŦ함한 단ėŧ ëŦ¸ėžė—´ėž…니다. ė„œë˛„ ė„¤ėš˜ 늤íŦëĻŊ트가 URIëĨŧ ėƒė„ąí–ˆë‹¤ëŠ´ ė´ëĨŧ ė‚ŦėšŠí•˜ëŠ´ ę°„ë‹¨í•˜ęŗ  ė•ˆė „í•˜ę˛Œ ęĩŦė„ąí•  눘 ėžˆėŠĩ니다。"; - readonly ru: "Setup URI — ŅŅ‚Đž ОдĐŊа ŅŅ‚Ņ€ĐžĐēа Ņ‚ĐĩĐēŅŅ‚Đ°, ŅĐžĐ´ĐĩŅ€ĐļĐ°Ņ‰Đ°Ņ Đ°Đ´Ņ€Đĩҁ ҁĐĩŅ€Đ˛ĐĩŅ€Đ° и даĐŊĐŊŅ‹Đĩ Đ°ŅƒŅ‚ĐĩĐŊŅ‚Đ¸Ņ„Đ¸ĐēĐ°Ņ†Đ¸Đ¸. Đ•ŅĐģи URI ĐąŅ‹Đģ ŅĐžĐˇĐ´Đ°ĐŊ ҁĐēŅ€Đ¸ĐŋŅ‚ĐžĐŧ ŅƒŅŅ‚Đ°ĐŊОвĐēи ҁĐĩŅ€Đ˛ĐĩŅ€Đ°, ĐĩĐŗĐž Đ¸ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°ĐŊиĐĩ ОйĐĩҁĐŋĐĩŅ‡Đ¸Đ˛Đ°ĐĩŅ‚ ĐŋŅ€ĐžŅŅ‚ŅƒŅŽ и ĐąĐĩСОĐŋĐ°ŅĐŊŅƒŅŽ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēŅƒã€‚"; - readonly zh: "Setup URI 是一æŽĩ包åĢæœåŠĄå™¨åœ°å€ä¸ŽčŽ¤č¯äŋĄæ¯įš„æ–‡æœŦ。åĻ‚æžœæœåŠĄå™¨åŽ‰čŖ…č„šæœŦ厞įģį”Ÿæˆäē† URIīŧŒäŊŋį”¨åŽƒå¯äģĨæ›´įŽ€å•ä¸”æ›´åŽ‰å…¨åœ°åŽŒæˆé…įŊŽã€‚"; - readonly "zh-tw": "Setup URI 是一æŽĩ包åĢäŧ翜å™¨äŊå€čˆ‡éŠ—č­‰čŗ‡č¨Šįš„æ–‡å­—ã€‚åĻ‚æžœäŧ翜å™¨åŽ‰čŖč…ŗæœŦ厞įļ“į”ĸį”Ÿ URIīŧŒäŊŋį”¨åŽƒå¯äģĨæ›´į°Ąå–Žä¸”æ›´åŽ‰å…¨åœ°åŽŒæˆč¨­åŽšã€‚"; - }; - readonly "Access Key": { - readonly def: "Access Key"; - readonly es: "Clave de acceso"; - readonly fr: "ClÊ d'accès"; - readonly he: "מפ×Ēח גישה"; - readonly ja: "ã‚ĸクã‚ģ゚キãƒŧ"; - readonly ko: "ė•Ąė„¸ėŠ¤ 키"; - readonly ru: "КĐģŅŽŅ‡ Đ´ĐžŅŅ‚ŅƒĐŋа"; - readonly zh: "čŽŋ闎密é’Ĩ"; - }; - readonly Activate: { - readonly def: "Activate"; - readonly es: "Activar"; - readonly ja: "有劚化"; - readonly ko: "í™œė„ąí™”"; - readonly ru: "АĐēŅ‚Đ¸Đ˛Đ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ"; - readonly zh: "吝ᔍ"; - readonly "zh-tw": "å•Ÿį”¨"; - }; - readonly "Active Remote Configuration": { - readonly def: "Active Remote Configuration"; - readonly fr: "Configuration distante active"; - readonly he: "×Ē×Ļור×Ē ×Š×¨×Ē ×ž×¨×•×—×§ פ×ĸיל"; - readonly ru: "АĐēŅ‚Đ¸Đ˛ĐŊĐ°Ņ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐ°Ņ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Ņ"; - readonly zh: "į”Ÿæ•ˆä¸­įš„čŋœį¨‹é…įŊŽ"; - readonly "zh-tw": "į›Žå‰å•Ÿį”¨įš„é įĢ¯č¨­åŽš"; - }; - readonly "Add default patterns": { - readonly def: "Add default patterns"; - readonly es: "AÃąadir patrones predeterminados"; - readonly ja: "デフりãƒĢトパã‚ŋãƒŧãƒŗã‚’čŋŊ加"; - readonly ko: "ę¸°ëŗ¸ 패턴 ėļ”ę°€"; - readonly ru: "Đ”ĐžĐąĐ°Đ˛Đ¸Ņ‚ŅŒ ŅˆĐ°ĐąĐģĐžĐŊŅ‹ ĐŋĐž ҃ĐŧĐžĐģŅ‡Đ°ĐŊĐ¸ŅŽ"; - readonly zh: "æˇģ加éģ˜čŽ¤æ¨Ąåŧ"; - readonly "zh-tw": "新åĸžé č¨­æ¨Ąåŧ"; - }; - readonly "Add new connection": { - readonly def: "Add new connection"; - readonly es: "AÃąadir conexiÃŗn"; - readonly ja: "æŽĨįļšã‚’čŋŊ加"; - readonly ko: "뗰枰 ėļ”ę°€"; - readonly ru: "Đ”ĐžĐąĐ°Đ˛Đ¸Ņ‚ŅŒ ĐŋОдĐēĐģŅŽŅ‡ĐĩĐŊиĐĩ"; - readonly zh: "新åĸžčŋžæŽĨ"; - readonly "zh-tw": "新åĸžé€Ŗįˇš"; - }; - readonly "All devices have the same progress value (${progress}). Your devices seem to be synchronised. And be able to proceed with Garbage Collection.": { - readonly def: "All devices have the same progress value (${progress}). Your devices seem to be synchronised. And be able to proceed with Garbage Collection."; - readonly ja: "すずãĻãŽãƒ‡ãƒã‚¤ã‚šã§é€˛æ—å€¤ãŒåŒã˜ã§ã™īŧˆ${progress}īŧ‰ã€‚デバイ゚は同期されãĻいるようãĒぎで、Garbage Collection をįļščĄŒã§ããžã™ã€‚"; - readonly ko: "ëĒ¨ë“  ę¸°ę¸°ė˜ ė§„í–‰ ę°’ė´ 동ėŧ합니다(${progress}). ę¸°ę¸°ë“¤ė´ 동기화된 것ėœŧ로 ëŗ´ė´ë¯€ëĄœ Garbage Collectionė„ ė§„í–‰í•  눘 ėžˆėŠĩ니다."; - readonly ru: "ĐŖ Đ˛ŅĐĩŅ… ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛ ОдиĐŊаĐēОвОĐĩ СĐŊĐ°Ņ‡ĐĩĐŊиĐĩ ĐŋŅ€ĐžĐŗŅ€ĐĩŅŅĐ° (${progress}). ĐŸĐžŅ…ĐžĐļĐĩ, Đ˛Đ°ŅˆĐ¸ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ° ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ¸Ņ€ĐžĐ˛Đ°ĐŊŅ‹, и ĐŧĐžĐļĐŊĐž ĐŋŅ€ĐžĐ´ĐžĐģĐļĐ°Ņ‚ŅŒ Garbage Collection."; - readonly zh: "æ‰€æœ‰čŽžå¤‡įš„čŋ›åēĻå€ŧå‡į›¸åŒīŧˆ${progress}īŧ‰ã€‚įœ‹čĩˇæĨäŊ įš„čŽžå¤‡åˇ˛įģåŒæ­ĨīŧŒå¯äģĨįģ§įģ­æ‰§čĄŒåžƒåœžå›žæ”ļ。"; - readonly "zh-tw": "æ‰€æœ‰čŖįŊŽįš„進åēĻå€ŧå‡į›¸åŒīŧˆ${progress}īŧ‰ã€‚įœ‹čĩˇäž†äŊ įš„čŖįŊŽåˇ˛åŒæ­ĨīŧŒå¯äģĨįšŧįēŒåŸˇčĄŒåžƒåœžå›žæ”ļ。"; - }; - readonly "Always prompt merge conflicts": { - readonly def: "Always prompt merge conflicts"; - readonly es: "Siempre preguntar en conflictos"; - readonly fr: "Toujours demander pour les conflits de fusion"; - readonly he: "×Ēמיד לה×Ļיג בקש×Ē ××™×Š×•×¨ לקונפליקטי מיזוג"; - readonly ja: "常ãĢįĢļåˆã¯æ‰‹å‹•ã§č§Ŗæąēする"; - readonly ko: "í•­ėƒ ëŗ‘í•Š ėļŠëŒ ė•ŒëĻŧ"; - readonly ru: "Đ’ŅĐĩĐŗĐ´Đ° СаĐŋŅ€Đ°ŅˆĐ¸Đ˛Đ°Ņ‚ŅŒ Ņ€Đ°ĐˇŅ€Đĩ҈ĐĩĐŊиĐĩ ĐēĐžĐŊŅ„ĐģиĐēŅ‚ĐžĐ˛ ҁĐģĐ¸ŅĐŊĐ¸Ņ"; - readonly zh: "始į숿į¤ē合åšļ冞įǁ"; - readonly "zh-tw": "į¸Ŋ是提į¤ē合äŊĩ衝įǁ"; - }; - readonly Analyse: { - readonly def: "Analyse"; - readonly fr: "Analyser"; - readonly he: "ני×Ēוח"; - readonly ru: "АĐŊаĐģĐ¸ĐˇĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ"; - readonly zh: "įĢ‹åŗåˆ†æž"; - readonly "zh-tw": "分析"; - }; - readonly "Analyse database usage": { - readonly def: "Analyse database usage"; - readonly fr: "Analyser l'utilisation de la base de donnÊes"; - readonly he: "ני×Ēוח שימוש במסד × ×Ēונים"; - readonly ja: "デãƒŧã‚ŋベãƒŧ゚äŊŋᔍįŠļæŗã‚’åˆ†æž"; - readonly ko: "ë°ė´í„°ë˛ ė´ėŠ¤ ė‚ŦėšŠëŸ‰ ëļ„ė„"; - readonly ru: "АĐŊаĐģиС Đ¸ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°ĐŊĐ¸Ņ ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅"; - readonly zh: "分析数捎åē“äŊŋį”¨æƒ…å†ĩ"; - readonly "zh-tw": "åˆ†æžčŗ‡æ–™åēĢäŊŋį”¨æƒ…æŗ"; - }; - readonly "Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like.": { - readonly def: "Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like."; - readonly fr: "Analyser l'utilisation de la base de donnÊes et gÊnÊrer un rapport TSV pour un diagnostic personnel. Vous pouvez coller le rapport gÊnÊrÊ dans le tableur de votre choix."; - readonly he: "× ×Ēח שימוש במסד הנ×Ēונים ו×Ļור דו\"ח TSV לאבחון ×ĸ×Ļמי. ני×Ēן להדביק א×Ē ×”×“×•\"ח שנו×Ļר בכל גיליון אלקטרוני."; - readonly ja: "デãƒŧã‚ŋベãƒŧ゚äŊŋᔍįŠļæŗã‚’åˆ†æžã—ã€č‡Ē分でč¨ē断できるよう TSV ãƒŦポãƒŧãƒˆã‚’į”Ÿæˆã—ãžã™ã€‚į”Ÿæˆã—ãŸãƒŦポãƒŧトはäģģæ„ãŽã‚šãƒ—ãƒŦãƒƒãƒ‰ã‚ˇãƒŧトãĢč˛ŧりäģ˜ã‘ãĻįĸēčĒã§ããžã™ã€‚"; - readonly ko: "ë°ė´í„°ë˛ ė´ėŠ¤ ė‚ŦėšŠëŸ‰ė„ ëļ„ė„í•˜ęŗ  링렑 ė§„ë‹¨í•  눘 ėžˆë„ëĄ TSV ëŗ´ęŗ ė„œëĨŧ ėƒė„ąí•Šë‹ˆë‹¤. ėƒė„ąëœ ëŗ´ęŗ ė„œëŠ” ė›í•˜ëŠ” ėŠ¤í”„ë ˆë“œė‹œíŠ¸ė— ëļ™ė—Ŧ ë„Ŗė–´ í™•ė¸í•  눘 ėžˆėŠĩ니다."; - readonly ru: "ĐŸŅ€ĐžĐ°ĐŊаĐģĐ¸ĐˇĐ¸Ņ€ŅƒĐšŅ‚Đĩ Đ¸ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°ĐŊиĐĩ ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅ и ŅĐžĐˇĐ´Đ°ĐšŅ‚Đĩ TSV-ĐžŅ‚Ņ‡Ņ‘Ņ‚ Đ´ĐģŅ ŅĐ°ĐŧĐžŅŅ‚ĐžŅŅ‚ĐĩĐģҌĐŊОК Đ´Đ¸Đ°ĐŗĐŊĐžŅŅ‚Đ¸Đēи. ПоĐģŅƒŅ‡ĐĩĐŊĐŊŅ‹Đš ĐžŅ‚Ņ‡Ņ‘Ņ‚ ĐŧĐžĐļĐŊĐž Đ˛ŅŅ‚Đ°Đ˛Đ¸Ņ‚ŅŒ в ĐģŅŽĐąŅƒŅŽ ŅƒĐ´ĐžĐąĐŊŅƒŅŽ Đ´ĐģŅ Đ˛Đ°Ņ Ņ‚Đ°ĐąĐģĐ¸Ņ†Ņƒ."; - readonly zh: "分析数捎åē“äŊŋį”¨æƒ…å†ĩåšļį”Ÿæˆ TSV æŠĨ告äģĨ䞛您č‡ĒčĄŒč¯Šæ–­ã€‚æ‚¨å¯äģĨå°†į”Ÿæˆįš„æŠĨå‘Šį˛˜č´´åˆ°æ‚¨å–œæŦĸįš„äģģäŊ•į”ĩå­čĄ¨æ ŧ中。"; - readonly "zh-tw": "åˆ†æžčŗ‡æ–™åēĢäŊŋį”¨æƒ…æŗä¸Ļį”ĸį”Ÿ TSV å ąå‘ŠīŧŒæ–šäžŋäŊ č‡Ē行č¨ēæ–ˇã€‚äŊ å¯äģĨ將į”ĸį”Ÿįš„å ąå‘Šč˛ŧ到äģģäŊ•æ…Ŗį”¨įš„čŠĻįŽ—čĄ¨ä¸­æŸĨįœ‹ã€‚"; - }; - readonly "Apply Latest Change if Conflicting": { - readonly def: "Apply Latest Change if Conflicting"; - readonly es: "Aplicar Ãēltimo cambio en conflictos"; - readonly fr: "Appliquer la dernière modification en cas de conflit"; - readonly he: "החל שינוי אחרון ב×ĸ×Ē ×§×•× ×¤×œ×™×§×˜"; - readonly ja: "įĢļåˆãŒã‚ã‚‹å ´åˆã¯æœ€æ–°ãŽå¤‰æ›´ã‚’éŠį”¨ã™ã‚‹"; - readonly ko: "ėļŠëŒ ė‹œ ėĩœė‹  ëŗ€ę˛Ŋ ė‚Ŧ항 ė ėšŠ"; - readonly ru: "ĐŸŅ€Đ¸ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ ĐŋĐžŅĐģĐĩĐ´ĐŊĐĩĐĩ иСĐŧĐĩĐŊĐĩĐŊиĐĩ ĐŋŅ€Đ¸ ĐēĐžĐŊŅ„ĐģиĐēŅ‚Đĩ"; - readonly zh: "åĻ‚æžœå†˛įĒåˆ™åē”į”¨æœ€æ–°æ›´æ”š"; - readonly "zh-tw": "į™ŧį”ŸčĄįĒæ™‚åĨ—į”¨æœ€æ–°čŽŠæ›´"; - }; - readonly "Apply preset configuration": { - readonly def: "Apply preset configuration"; - readonly es: "Aplicar configuraciÃŗn predefinida"; - readonly fr: "Appliquer une configuration prÊdÊfinie"; - readonly he: "החל ×Ē×Ļורה קבו×ĸה מרא׊"; - readonly ja: "プãƒĒã‚ģãƒƒãƒˆã‚’éŠį”¨ã™ã‚‹"; - readonly ko: "프ëĻŦė…‹ ęĩŦė„ą ė ėšŠ"; - readonly ru: "ĐŸŅ€Đ¸ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ ĐŋŅ€ĐĩĐ´ŅƒŅŅ‚Đ°ĐŊОвĐģĐĩĐŊĐŊŅƒŅŽ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸ŅŽ"; - readonly zh: "åē”ᔍéĸ„čŽžé…įŊŽ"; - readonly "zh-tw": "åĨ—į”¨é č¨­é…įŊŽ"; - }; - readonly "Ask a passphrase at every launch": { - readonly def: "Ask a passphrase at every launch"; - readonly es: "Solicitar la frase de contraseÃąa en cada inicio"; - readonly ja: "čĩˇå‹•ãŽãŸãŗãĢパ゚フãƒŦãƒŧã‚ēをįĸēčĒ"; - readonly ko: "ė‹œėž‘í•  때마다 ė•”í˜¸ëŦ¸ęĩŦ ëŦģ기"; - readonly ru: "ЗаĐŋŅ€Đ°ŅˆĐ¸Đ˛Đ°Ņ‚ŅŒ ĐŋĐ°Ņ€ĐžĐģҌĐŊŅƒŅŽ Ņ„Ņ€Đ°ĐˇŅƒ ĐŋŅ€Đ¸ ĐēаĐļĐ´ĐžĐŧ СаĐŋ҃ҁĐēĐĩ"; - readonly zh: "每æŦĄå¯åЍæ—ļč¯ĸé—Žå¯†į įŸ­č¯­"; - readonly "zh-tw": "每æŦĄå•Ÿå‹•時éƒŊčŠĸ問密čĒž"; - }; - readonly "Automatically Sync all files when opening Obsidian.": { - readonly def: "Automatically Sync all files when opening Obsidian."; - readonly es: "Sincronizar automÃĄticamente todos los archivos al abrir Obsidian"; - readonly fr: "Synchroniser automatiquement tous les fichiers à l'ouverture d'Obsidian."; - readonly he: "סנכרן א×Ē ×›×œ הקב×Ļים אוטומטי×Ē ×ĸם פ×Ēיח×Ē Obsidian."; - readonly ja: "Obsidiančĩˇå‹•時ãĢすずãĻãŽãƒ•ã‚Ąã‚¤ãƒĢをč‡Ē動同期しぞす。"; - readonly ko: "Obsidianė„ ė—´ 때 ëĒ¨ë“  파ėŧė„ ėžë™ėœŧ로 동기화합니다."; - readonly ru: "ĐĐ˛Ņ‚ĐžĐŧĐ°Ņ‚Đ¸Ņ‡ĐĩҁĐēи ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ Đ˛ŅĐĩ Ņ„Đ°ĐšĐģŅ‹ ĐŋŅ€Đ¸ ĐžŅ‚ĐēŅ€Ņ‹Ņ‚Đ¸Đ¸ Obsidian."; - readonly zh: "打åŧ€ Obsidian æ—ļč‡Ē动同æ­Ĩ所有文äģļ"; - readonly "zh-tw": "開啟 Obsidian 時č‡Ē動同æ­Ĩ所有æĒ”æĄˆã€‚"; - }; - readonly Back: { - readonly def: "Back"; - readonly es: "Volver"; - readonly ja: "æˆģる"; - readonly ko: "뒤로"; - readonly ru: "Назад"; - readonly zh: "čŋ”回"; - readonly "zh-tw": "čŋ”回"; - }; - readonly "Back to non-configured": { - readonly def: "Back to non-configured"; - readonly es: "Volver a no configurado"; - readonly ja: "æœĒč¨­åŽšįŠļ態ãĢæˆģす"; - readonly ko: "미ęĩŦė„ą ėƒíƒœëĄœ 되돌ëĻŦ기"; - readonly ru: "ВĐĩŅ€ĐŊŅƒŅ‚ŅŒ в ŅĐžŅŅ‚ĐžŅĐŊиĐĩ ĐąĐĩС ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи"; - readonly zh: "æĸ复ä¸ēæœĒ配įŊŽįŠļ态"; - readonly "zh-tw": "æĸ垊į‚ēæœĒč¨­åŽšį‹€æ…‹"; - }; - readonly "Batch database update": { - readonly def: "Batch database update"; - readonly es: "ActualizaciÃŗn por lotes de BD"; - readonly fr: "Mise à jour groupÊe de la base de donnÊes"; - readonly he: "×ĸדכון א×Ļווה למסד × ×Ēונים"; - readonly ja: "デãƒŧã‚ŋベãƒŧ゚ぎバッチ更新"; - readonly ko: "ėŧ괄 ë°ė´í„°ë˛ ė´ėŠ¤ ė—…ë°ė´íŠ¸"; - readonly ru: "ПаĐēĐĩŅ‚ĐŊĐžĐĩ ОйĐŊОвĐģĐĩĐŊиĐĩ ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅"; - readonly zh: "扚量数捎å瓿›´æ–°"; - readonly "zh-tw": "扚æŦĄæ›´æ–°čŗ‡æ–™åēĢ"; - }; - readonly "Batch limit": { - readonly def: "Batch limit"; - readonly es: "Límite de lotes"; - readonly fr: "Limite de lot"; - readonly he: "מגבל×Ē ××Ļווה"; - readonly ja: "バッチぎ上限"; - readonly ko: "ėŧ괄 ė œí•œ"; - readonly ru: "ПаĐēĐĩŅ‚ĐŊŅ‹Đš ĐģиĐŧĐ¸Ņ‚"; - readonly zh: "扚量限åˆļ"; - readonly "zh-tw": "扚æŦĄä¸Šé™"; - }; - readonly "Batch size": { - readonly def: "Batch size"; - readonly es: "TamaÃąo de lote"; - readonly fr: "Taille de lot"; - readonly he: "גודל א×Ļווה"; - readonly ja: "バッチ厚量"; - readonly ko: "ėŧ괄 íŦ기"; - readonly ru: "РаСĐŧĐĩŅ€ ĐŋаĐēĐĩŅ‚Đ°"; - readonly zh: "扚量大小"; - readonly "zh-tw": "扚æŦĄå¤§å°"; - }; - readonly "Batch size of on-demand fetching": { - readonly def: "Batch size of on-demand fetching"; - readonly es: "TamaÃąo de lote para obtenciÃŗn bajo demanda"; - readonly fr: "Taille de lot pour la rÊcupÊration à la demande"; - readonly he: "גודל א×Ļווה במשיכה לפי דרישה"; - readonly ja: "ã‚Ēãƒŗãƒ‡ãƒžãƒŗãƒ‰å–åž—ãŽãƒãƒƒãƒã‚ĩイã‚ē"; - readonly ko: "í•„ėš” ė‹œ 氀렏ė˜Ŧ 랭íŦ ëŦļėŒ íŦ기"; - readonly ru: "РаСĐŧĐĩŅ€ ĐŋаĐēĐĩŅ‚Đ° ĐŋŅ€Đ¸ СаĐŋŅ€ĐžŅĐĩ ĐŋĐž ҂ҀĐĩйОваĐŊĐ¸ŅŽ"; - readonly zh: "æŒ‰éœ€čŽˇå–įš„æ‰šé‡å¤§å°"; - readonly "zh-tw": "æŒ‰éœ€æŠ“å–įš„æ‰šæŦĄå¤§å°"; - }; - readonly "Before v0.17.16, we used an old adapter for the local database. Now the new adapter is preferred. However, it needs local database rebuilding. Please disable this toggle when you have enough time. If leave it enabled, also while fetching from the remote database, you will be asked to disable this.": { - readonly def: "Before v0.17.16, we used an old adapter for the local database. Now the new adapter is preferred. However, it needs local database rebuilding. Please disable this toggle when you have enough time. If leave it enabled, also while fetching from the remote database, you will be asked to disable this."; - readonly es: "Antes de v0.17.16 usÃĄbamos adaptador antiguo. Nuevo adaptador requiere reconstruir BD local. Desactive cuando pueda"; - readonly fr: "Avant la version v0.17.16, nous utilisions un ancien adaptateur pour la base de donnÊes locale. Le nouvel adaptateur est dÊsormais recommandÊ. Cependant, il nÊcessite une reconstruction de la base locale. Veuillez dÊsactiver cette option lorsque vous aurez suffisamment de temps. Si elle reste activÊe, il vous sera Êgalement demandÊ de la dÊsactiver lors de la rÊcupÊration depuis la base distante."; - readonly he: "לפני גרסה 0.17.16, הש×Ēמשנו במ×Ēאם ישן למסד הנ×Ēונים המקומי. כ×ĸ×Ē ×”×ž×Ēאם החדש מו×ĸדת. ×ĸם זא×Ē, הדבר מ×Ļריך בנייה מחדש של מסד הנ×Ēונים המקומי. אנא כבה אפשרו×Ē ×–×• כשיש לך זמן פנוי. אם ×Ēשאיר או×Ēה מופ×ĸל×Ē, גם ב×ĸ×Ē ×ž×Š×™×›×” ממסד הנ×Ēונים המרוחק, ×Ē×Ēבקש לכבו×Ē ××•×Ēה."; - readonly ja: "v0.17.6ぞでは古いã‚ĸダプã‚ŋãƒŧをロãƒŧã‚ĢãƒĢį”¨ãŽãƒ‡ãƒŧã‚ŋベãƒŧ゚ãĢäŊŋį”¨ã—ãĻã„ãžã—ãŸãŒã€įžåœ¨ã¯æ–°ã—ã„ã‚ĸダプã‚ŋãƒŧを推åĨ¨ã—ãĻいぞす。しかし、新しいã‚ĸダプã‚ŋãƒŧãĢ変更するãĢはロãƒŧã‚ĢãƒĢデãƒŧã‚ŋベãƒŧã‚šãŽå†æ§‹į¯‰ãŒåŋ…čĻã§ã™ã€‚æœ‰åŠšãŽãžãžãĢしãĻおくと、ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚からフェッチする場合ãĢã€ã“ãŽč¨­åŽšã‚’į„ĄåŠšãĢするかぎčŗĒå•ãŒčĄ¨į¤ēされぞす。"; - readonly ko: "v0.17.16 ė´ė „ė—ëŠ” 로ėģŦ ë°ė´í„°ë˛ ė´ėŠ¤ė— ė´ė „ ė–´ëŒ‘í„°ëĨŧ ė‚ŦėšŠí–ˆėŠĩ니다. ė´ė œëŠ” ėƒˆëĄœėš´ ė–´ëŒ‘í„°ëĨŧ ęļŒėžĨ합니다. í•˜ė§€ë§Œ 로ėģŦ ë°ė´í„°ë˛ ė´ėŠ¤ ėžŦęĩŦėļ•ė´ í•„ėš”í•Šë‹ˆë‹¤. ėļŠëļ„한 ė‹œę°„ė´ ėžˆė„ 때 ė´ í† ę¸€ė„ ëš„í™œė„ąí™”í•´ ėŖŧė„¸ėš”. í™œė„ąí™”ëœ ėƒíƒœëĄœ 두면 ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ė—ė„œ 氀렏ė˜Ŧ 때도 ė´ëĨŧ ëš„í™œė„ąí™”í•˜ëŧ는 ëŠ”ė‹œė§€ę°€ 나타납니다."; - readonly ru: "Before v0.17.16, we used an old adapter for the local database. Now the new adapter is preferred. However, it needs local database rebuilding. Please disable this toggle when you have enough time. If leave it enabled, also while fetching from the remote database, you will be asked to disable this."; - readonly zh: "在 v0.17.16 䚋前,我äģŦäŊŋį”¨æ—§é€‚é…å™¨äŊœä¸ēæœŦ地数捎åē“ã€‚įŽ°åœ¨éĻ–é€‰æ–°é€‚é…å™¨ã€‚äŊ†æ˜¯,厃需čĻé‡åģ翜Ŧ地数捎åē“ã€‚č¯ˇåœ¨æœ‰čļŗå¤Ÿæ—ļ间æ—ļįĻį”¨æ­¤åŧ€å…ŗã€‚åĻ‚æžœäŋæŒå¯į”¨įŠļ态,åšļ且在äģŽčŋœį¨‹æ•°æŽåē“čŽˇå–æ—ļ,įŗģįģŸå°†čĻæą‚æ‚¨įĻį”¨æ­¤åŧ€å…ŗã€‚"; - }; - readonly "Bucket Name": { - readonly def: "Bucket Name"; - readonly es: "Nombre del bucket"; - readonly fr: "Nom du bucket"; - readonly he: "׊ם דלי (Bucket)"; - readonly ja: "ãƒã‚ąãƒƒãƒˆå"; - readonly ko: "버킷 ė´ëĻ„"; - readonly ru: "ИĐŧŅ йаĐēĐĩŅ‚Đ°"; - readonly zh: "存储æĄļåį§°"; - readonly "zh-tw": "å„˛å­˜æĄļåį¨ą"; - }; - readonly Cancel: { - readonly def: "Cancel"; - readonly es: "Cancelar"; - readonly ja: "ã‚­ãƒŖãƒŗã‚ģãƒĢ"; - readonly ko: "ėˇ¨ė†Œ"; - readonly ru: "ĐžŅ‚ĐŧĐĩĐŊа"; - readonly zh: "取æļˆ"; - readonly "zh-tw": "取æļˆ"; - }; - readonly "Cancel Garbage Collection": { - readonly def: "Cancel Garbage Collection"; - readonly ja: "Garbage Collection ã‚’ã‚­ãƒŖãƒŗã‚ģãƒĢ"; - readonly ko: "Garbage Collection ėˇ¨ė†Œ"; - readonly ru: "ĐžŅ‚ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ Garbage Collection"; - readonly zh: "取æļˆåžƒåœžå›žæ”ļ"; - readonly "zh-tw": "取æļˆåžƒåœžå›žæ”ļ"; - }; - readonly "Changing this setting requires migrating existing data (a bit time may be taken) and restarting Obsidian. Please make sure to back up your data before proceeding.": { - readonly def: "Changing this setting requires migrating existing data (a bit time may be taken) and restarting Obsidian. Please make sure to back up your data before proceeding."; - }; - readonly Check: { - readonly def: "Check"; - readonly fr: "VÊrifier"; - readonly he: "בדוק"; - readonly ru: "ĐŸŅ€ĐžĐ˛ĐĩŅ€Đ¸Ņ‚ŅŒ"; - readonly zh: "įĢ‹åŗæŖ€æŸĨ"; - readonly "zh-tw": "æĒĸæŸĨ"; - }; - readonly "Check and convert non-path-obfuscated files": { - readonly def: "Check and convert non-path-obfuscated files"; - readonly es: "Comprobar y convertir archivos sin ofuscaciÃŗn de ruta"; - readonly ja: "ãƒ‘ã‚šé›ŖčĒ­åŒ–ã•ã‚ŒãĻいãĒã„ãƒ•ã‚Ąã‚¤ãƒĢをįĸēčĒã—ãĻ変換"; - readonly ko: "ę˛Ŋ로 ë‚œë…í™”ë˜ė§€ ė•Šė€ 파ėŧ 검ė‚Ŧ 및 ëŗ€í™˜"; - readonly ru: "ĐŸŅ€ĐžĐ˛ĐĩŅ€Đ¸Ņ‚ŅŒ и ĐŋŅ€ĐĩĐžĐąŅ€Đ°ĐˇĐžĐ˛Đ°Ņ‚ŅŒ Ņ„Đ°ĐšĐģŅ‹ ĐąĐĩС ĐžĐąŅ„ŅƒŅĐēĐ°Ņ†Đ¸Đ¸ ĐŋŅƒŅ‚Đ¸"; - readonly zh: "æŖ€æŸĨåšļčŊŦæĸæœĒčŋ›čĄŒčˇ¯åž„æˇˇæˇ†įš„æ–‡äģļ"; - readonly "zh-tw": "æĒĸæŸĨä¸ĻčŊ‰æ›æœĒé€˛čĄŒčˇ¯åž‘æˇˇæˇ†įš„æĒ”æĄˆ"; - }; - readonly "Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.": { - readonly def: "Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary."; - readonly es: "Comprueba los documentos que aÃēn no se hayan convertido a identificadores con ruta ofuscada y conviÊrtelos si es necesario."; - readonly ja: "ãžã ãƒ‘ã‚šé›ŖčĒ­åŒ– ID ãĢ変換されãĻいãĒいドキãƒĨãƒĄãƒŗãƒˆã‚’įĸēčĒã—ã€åŋ…čρãĢåŋœã˜ãĻ変換しぞす。"; - readonly ko: "땄링 ę˛Ŋ로 난독화 ID로 ëŗ€í™˜ë˜ė§€ ė•Šė€ ëŦ¸ė„œëĨŧ í™•ė¸í•˜ęŗ  í•„ėš”í•˜ëŠ´ ëŗ€í™˜í•Šë‹ˆë‹¤."; - readonly ru: "ĐŸŅ€ĐžĐ˛ĐĩŅ€ŅĐĩŅ‚ Đ´ĐžĐē҃ĐŧĐĩĐŊ҂ҋ, ĐēĐžŅ‚ĐžŅ€Ņ‹Đĩ Đĩ҉ґ ĐŊĐĩ ĐąŅ‹Đģи ĐŋŅ€ĐĩĐžĐąŅ€Đ°ĐˇĐžĐ˛Đ°ĐŊŅ‹ в path-obfuscated ID, и ĐŋŅ€Đ¸ ĐŊĐĩĐžĐąŅ…ĐžĐ´Đ¸ĐŧĐžŅŅ‚Đ¸ ĐŋŅ€ĐĩĐžĐąŅ€Đ°ĐˇŅƒĐĩŅ‚ Đ¸Ņ…."; - readonly zh: "æŖ€æŸĨ尚æœĒčŊŦæĸä¸ēčˇ¯åž„æˇˇæˇ† ID įš„æ–‡æĄŖīŧŒåšļ在需čρæ—ļ将å…ļčŊŦæĸ。"; - readonly "zh-tw": "æĒĸæŸĨ尚æœĒčŊ‰æ›į‚ēčˇ¯åž‘æˇˇæˇ† ID įš„æ–‡äģļīŧŒä¸Ļ在需čĻæ™‚é€˛čĄŒčŊ‰æ›ã€‚"; - }; - readonly "cmdConfigSync.showCustomizationSync": { - readonly def: "Show Customization sync"; - readonly es: "Mostrar sincronizaciÃŗn de personalizaciÃŗn"; - readonly fr: "Afficher la synchronisation de personnalisation"; - readonly he: "ה×Ļג סנכרון ה×Ēאמה אישי×Ē"; - readonly ja: "ã‚Ģ゚ã‚ŋマイã‚ēåŒæœŸã‚’čĄ¨į¤ē"; - readonly ko: "ė‚ŦėšŠėž 네렕 동기화 í‘œė‹œ"; - readonly ru: "ПоĐēĐ°ĐˇĐ°Ņ‚ŅŒ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸ŅŽ ĐŊĐ°ŅŅ‚Ņ€ĐžĐĩĐē"; - readonly zh: "昞į¤ēč‡Ē厚䚉同æ­Ĩ"; - readonly "zh-tw": "éĄ¯į¤ēč‡Ēč¨‚åŒæ­Ĩ"; - }; - readonly "Comma separated `.gitignore, .dockerignore`": { - readonly def: "Comma separated `.gitignore, .dockerignore`"; - readonly es: "Separados por comas: `.gitignore, .dockerignore`"; - readonly fr: "SÊparÊs par des virgules `.gitignore, .dockerignore`"; - readonly he: "רשימה מופרד×Ē ×‘×¤×Ą×™×§×™× `.gitignore, .dockerignore`"; - readonly ja: "ã‚ĢãƒŗãƒžåŒē切り `.gitignore, .dockerignore`"; - readonly ko: "ė‰ŧ표로 ęĩŦëļ„된 `.gitignore, .dockerignore`"; - readonly ru: "ЧĐĩŅ€ĐĩС СаĐŋŅŅ‚ŅƒŅŽ `.gitignore, .dockerignore`"; - readonly zh: "į”¨é€—åˇåˆ†éš”,例åĻ‚ `.gitignore, .dockerignore`"; - }; - readonly "Compaction in progress on remote database...": { - readonly def: "Compaction in progress on remote database..."; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧã‚šã§ã‚ŗãƒŗãƒ‘ã‚¯ã‚ˇãƒ§ãƒŗã‚’åŽŸčĄŒä¸­ã§ã™..."; - readonly ko: "ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ė—ė„œ ė••ėļ•ė„ ė§„í–‰ ė¤‘ėž…ë‹ˆë‹¤..."; - readonly ru: "Đ’Ņ‹ĐŋĐžĐģĐŊŅĐĩŅ‚ŅŅ ĐēĐžĐŧĐŋаĐēŅ†Đ¸Ņ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊОК ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅..."; - readonly zh: "æ­Ŗåœ¨čŋœį¨‹æ•°æŽåē“ä¸Šæ‰§čĄŒåŽ‹įŧŠ..."; - readonly "zh-tw": "æ­Ŗåœ¨é įĢ¯čŗ‡æ–™åēĢä¸ŠåŸˇčĄŒåŖ“į¸Ž..."; - }; - readonly "Compaction on remote database completed successfully.": { - readonly def: "Compaction on remote database completed successfully."; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧã‚šã§ãŽã‚ŗãƒŗãƒ‘ã‚¯ã‚ˇãƒ§ãƒŗãŒæ­Ŗå¸¸ãĢ厌äē†ã—ぞした。"; - readonly ko: "ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ ė••ėļ•ė´ ė„ąęŗĩ렁ėœŧ로 ė™„ëŖŒë˜ė—ˆėŠĩ니다."; - readonly ru: "КоĐŧĐŋаĐēŅ†Đ¸Ņ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊОК ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅ ҃ҁĐŋĐĩ҈ĐŊĐž СавĐĩŅ€ŅˆĐĩĐŊа."; - readonly zh: "čŋœį¨‹æ•°æŽåē“压įŧŠåˇ˛æˆåŠŸåŽŒæˆã€‚"; - readonly "zh-tw": "遠įĢ¯čŗ‡æ–™åēĢåŖ“į¸Žåˇ˛æˆåŠŸåŽŒæˆã€‚"; - }; - readonly "Compaction on remote database failed.": { - readonly def: "Compaction on remote database failed."; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧã‚šã§ãŽã‚ŗãƒŗãƒ‘ã‚¯ã‚ˇãƒ§ãƒŗãĢå¤ąæ•—ã—ãžã—ãŸã€‚"; - readonly ko: "ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ ė••ėļ•뗐 ė‹¤íŒ¨í–ˆėŠĩ니다."; - readonly ru: "КоĐŧĐŋаĐēŅ†Đ¸Ņ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊОК ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅ СавĐĩŅ€ŅˆĐ¸ĐģĐ°ŅŅŒ ĐžŅˆĐ¸ĐąĐēОК."; - readonly zh: "čŋœį¨‹æ•°æŽåē“压įŧŠå¤ąč´Ĩ。"; - readonly "zh-tw": "遠įĢ¯čŗ‡æ–™åēĢåŖ“į¸Žå¤ąæ•—ã€‚"; - }; - readonly "Compaction on remote database timed out.": { - readonly def: "Compaction on remote database timed out."; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧã‚šã§ãŽã‚ŗãƒŗãƒ‘ã‚¯ã‚ˇãƒ§ãƒŗãŒã‚ŋイムã‚ĸã‚Ļトしぞした。"; - readonly ko: "ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ ė••ėļ• ė‹œę°„ė´ 봈ęŗŧë˜ė—ˆėŠĩ니다."; - readonly ru: "Đ’Ņ€ĐĩĐŧŅ ĐžĐļидаĐŊĐ¸Ņ ĐēĐžĐŧĐŋаĐēŅ†Đ¸Đ¸ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊОК ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅ Đ¸ŅŅ‚ĐĩĐēĐģĐž."; - readonly zh: "čŋœį¨‹æ•°æŽåē“压įŧŠčļ…æ—ļ。"; - readonly "zh-tw": "遠įĢ¯čŗ‡æ–™åēĢåŖ“į¸Žé€žæ™‚ã€‚"; - }; - readonly "Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep.": { - readonly def: "Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep."; - readonly es: "Compara el contenido de los archivos entre la base de datos local y el almacenamiento. Si no coinciden, se te preguntarÃĄ cuÃĄl deseas conservar."; - readonly ja: "ロãƒŧã‚ĢãƒĢデãƒŧã‚ŋベãƒŧ゚と゚トãƒŦãƒŧã‚¸ä¸ŠãŽãƒ•ã‚Ąã‚¤ãƒĢ内厚を比čŧƒã—ãžã™ã€‚ä¸€č‡´ã—ãĒã„å ´åˆã¯ã€ãŠãĄã‚‰ã‚’æŽ‹ã™ã‹é¸æŠžã§ããžã™ã€‚"; - readonly ko: "로ėģŦ ë°ė´í„°ë˛ ė´ėŠ¤ė™€ ė €ėžĨė†Œ ę°„ė˜ 파ėŧ ë‚´ėšŠė„ 비ęĩí•Šë‹ˆë‹¤. ėŧėš˜í•˜ė§€ ė•Šėœŧ늴 ė–´ë–¤ ėĒŊė„ ėœ ė§€í• ė§€ ëŦģ枌 됩니다."; - readonly ru: "ĐĄŅ€Đ°Đ˛ĐŊиваĐĩŅ‚ ŅĐžĐ´ĐĩŅ€ĐļиĐŧĐžĐĩ Ņ„Đ°ĐšĐģОв ĐŧĐĩĐļĐ´Ņƒ ĐģĐžĐēаĐģҌĐŊОК йаСОК даĐŊĐŊҋ҅ и Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰ĐĩĐŧ. Đ•ŅĐģи ĐžĐŊи ĐŊĐĩ ŅĐžĐ˛ĐŋĐ°Đ´ŅƒŅ‚, ваĐŧ ĐŋŅ€ĐĩĐ´ĐģĐžĐļĐ°Ņ‚ Đ˛Ņ‹ĐąŅ€Đ°Ņ‚ŅŒ, ĐēаĐēŅƒŅŽ вĐĩŅ€ŅĐ¸ŅŽ ŅĐžŅ…Ņ€Đ°ĐŊĐ¸Ņ‚ŅŒ."; - readonly zh: "æ¯”čžƒæœŦ地数捎åē“ä¸Žå­˜å‚¨ä¸­įš„æ–‡äģļ内厚īŧ›åĻ‚æžœä¸ä¸€č‡´īŧŒäŊ å°†čĸĢč¯ĸ问čρäŋį•™å“Ē一äģŊ。"; - readonly "zh-tw": "比čŧƒæœŦæŠŸčŗ‡æ–™åēĢčˆ‡å„˛å­˜įŠēé–“ä¸­įš„æĒ”æĄˆå…§åŽšīŧ›č‹Ĩ不一致īŧŒįŗģįĩ࿜ƒčŠĸ問äŊ čρäŋį•™å“Ē一äģŊ。"; - }; - readonly "Compatibility (Conflict Behaviour)": { - readonly def: "Compatibility (Conflict Behaviour)"; - readonly es: "Compatibilidad (comportamiento de conflictos)"; - readonly ja: "äē’æ›æ€§īŧˆįĢļ合時ぎ挙動īŧ‰"; - readonly ko: "í˜¸í™˜ė„ą (ėļŠëŒ ë™ėž‘)"; - readonly ru: "ХОвĐŧĐĩŅŅ‚Đ¸ĐŧĐžŅŅ‚ŅŒ (ĐŋОвĐĩĐ´ĐĩĐŊиĐĩ ĐŋŅ€Đ¸ ĐēĐžĐŊŅ„ĐģиĐēŅ‚Đ°Ņ…)"; - readonly zh: "å…ŧ厚性īŧˆå†˛įĒčĄŒä¸ēīŧ‰"; - readonly "zh-tw": "į›¸åŽšæ€§īŧˆčĄįĒčĄŒį‚ēīŧ‰"; - }; - readonly "Compatibility (Database structure)": { - readonly def: "Compatibility (Database structure)"; - readonly es: "Compatibilidad (estructura de la base de datos)"; - readonly ja: "äē’æ›æ€§īŧˆãƒ‡ãƒŧã‚ŋベãƒŧ゚構造īŧ‰"; - readonly ko: "í˜¸í™˜ė„ą (ë°ė´í„°ë˛ ė´ėŠ¤ ęĩŦėĄ°)"; - readonly ru: "ХОвĐŧĐĩŅŅ‚Đ¸ĐŧĐžŅŅ‚ŅŒ (ŅŅ‚Ņ€ŅƒĐēŅ‚ŅƒŅ€Đ° ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅)"; - readonly zh: "å…ŧ厚性īŧˆæ•°æŽåē“į쓿ž„īŧ‰"; - readonly "zh-tw": "į›¸åŽšæ€§īŧˆčŗ‡æ–™åēĢįĩæ§‹īŧ‰"; - }; - readonly "Compatibility (Internal API Usage)": { - readonly def: "Compatibility (Internal API Usage)"; - readonly es: "Compatibilidad (uso de la API interna)"; - readonly ja: "äē’æ›æ€§īŧˆå†…部 API ãŽåˆŠį”¨īŧ‰"; - readonly ko: "í˜¸í™˜ė„ą (내ëļ€ API ė‚ŦėšŠ)"; - readonly ru: "ХОвĐŧĐĩŅŅ‚Đ¸ĐŧĐžŅŅ‚ŅŒ (Đ¸ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°ĐŊиĐĩ вĐŊŅƒŅ‚Ņ€ĐĩĐŊĐŊĐĩĐŗĐž API)"; - readonly zh: "å…ŧ厚性īŧˆå†…部 API äŊŋᔍīŧ‰"; - readonly "zh-tw": "į›¸åŽšæ€§īŧˆå…§éƒ¨ API äŊŋᔍīŧ‰"; - }; - readonly "Compatibility (Metadata)": { - readonly def: "Compatibility (Metadata)"; - readonly es: "Compatibilidad (metadatos)"; - readonly ja: "äē’æ›æ€§īŧˆãƒĄã‚ŋデãƒŧã‚ŋīŧ‰"; - readonly ko: "í˜¸í™˜ė„ą (ëŠ”íƒ€ë°ė´í„°)"; - readonly ru: "ХОвĐŧĐĩŅŅ‚Đ¸ĐŧĐžŅŅ‚ŅŒ (ĐŧĐĩŅ‚Đ°Đ´Đ°ĐŊĐŊŅ‹Đĩ)"; - readonly zh: "å…ŧ厚性īŧˆå…ƒæ•°æŽīŧ‰"; - readonly "zh-tw": "į›¸åŽšæ€§īŧˆä¸­įšŧčŗ‡æ–™īŧ‰"; - }; - readonly "Compatibility (Remote Database)": { - readonly def: "Compatibility (Remote Database)"; - readonly es: "Compatibilidad (base de datos remota)"; - readonly ja: "äē’æ›æ€§īŧˆãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚īŧ‰"; - readonly ko: "í˜¸í™˜ė„ą (ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤)"; - readonly ru: "ХОвĐŧĐĩŅŅ‚Đ¸ĐŧĐžŅŅ‚ŅŒ (ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐ°Ņ йаСа даĐŊĐŊҋ҅)"; - readonly zh: "å…ŧ厚性īŧˆčŋœįĢ¯æ•°æŽåē“īŧ‰"; - readonly "zh-tw": "į›¸åŽšæ€§īŧˆé įĢ¯čŗ‡æ–™åēĢīŧ‰"; - }; - readonly "Compatibility (Trouble addressed)": { - readonly def: "Compatibility (Trouble addressed)"; - readonly es: "Compatibilidad (problemas corregidos)"; - readonly ja: "äē’æ›æ€§īŧˆå¯žå‡Ļ済ãŋãŽå•éĄŒīŧ‰"; - readonly ko: "í˜¸í™˜ė„ą (ëŦ¸ė œ ëŒ€ė‘)"; - readonly ru: "ХОвĐŧĐĩŅŅ‚Đ¸ĐŧĐžŅŅ‚ŅŒ (Đ¸ŅĐŋŅ€Đ°Đ˛ĐģĐĩĐŊĐŊŅ‹Đĩ ĐŋŅ€ĐžĐąĐģĐĩĐŧŅ‹)"; - readonly zh: "å…ŧ厚性īŧˆé—Žéĸ˜äŋŽå¤īŧ‰"; - readonly "zh-tw": "į›¸åŽšæ€§īŧˆå•éĄŒäŋŽåžŠīŧ‰"; - }; - readonly "Compute revisions for chunks": { - readonly def: "Compute revisions for chunks"; - readonly fr: "Calculer les rÊvisions pour les fragments"; - readonly he: "חשב גרסאו×Ē ×œ× ×Ēחים"; - readonly ja: "ãƒãƒŖãƒŗã‚¯ãŽäŋŽæ­Ŗ(ãƒĒãƒ“ã‚¸ãƒ§ãƒŗ)ã‚’č¨ˆįŽ—"; - readonly ko: "랭íŦ뗐 대한 ëĻŦëš„ė „ ęŗ„ė‚°"; - readonly ru: "Đ’Ņ‹Ņ‡Đ¸ŅĐģŅŅ‚ŅŒ Ņ€ĐĩвиСии Đ´ĐģŅ Ņ‡Đ°ĐŊĐēОв"; - readonly zh: "ä¸ē chunks čŽĄįŽ—äŋŽčŽĸį‰ˆæœŦ(äģĨå‰įš„čĄŒä¸ē)"; - }; - readonly "Configuration Encryption": { - readonly def: "Configuration Encryption"; - readonly es: "Cifrado de configuraciÃŗn"; - readonly ja: "č¨­åŽšãŽæš—åˇåŒ–"; - readonly ko: "ęĩŦė„ą ė•”í˜¸í™”"; - readonly ru: "Đ¨Đ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊиĐĩ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Đ¸"; - readonly zh: "配įŊŽåР坆"; - readonly "zh-tw": "č¨­åŽšåŠ å¯†"; - }; - readonly Configure: { - readonly def: "Configure"; - readonly es: "Configurar"; - readonly ja: "č¨­åŽš"; - readonly ko: "네렕"; - readonly ru: "ĐĐ°ŅŅ‚Ņ€ĐžĐ¸Ņ‚ŅŒ"; - readonly zh: "配įŊŽ"; - readonly "zh-tw": "č¨­åŽš"; - }; - readonly "Configure And Change Remote": { - readonly def: "Configure And Change Remote"; - readonly es: "Configurar y cambiar remoto"; - readonly ja: "ãƒĒãƒĸãƒŧãƒˆã‚’č¨­åŽšã—ãĻ切りæ›ŋえる"; - readonly ko: "ė›ę˛Š ęĩŦė„ą 및 ëŗ€ę˛Ŋ"; - readonly ru: "ĐĐ°ŅŅ‚Ņ€ĐžĐ¸Ņ‚ŅŒ и ĐŋĐĩŅ€ĐĩĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐĩ Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰Đĩ"; - readonly zh: "配įŊŽåšļ切æĸčŋœį̝"; - readonly "zh-tw": "č¨­åŽšä¸Ļ切換遠į̝"; - }; - readonly "Configure E2EE": { - readonly def: "Configure E2EE"; - readonly es: "Configurar E2EE"; - readonly ja: "E2EE ã‚’č¨­åŽš"; - readonly ko: "E2EE ęĩŦė„ą"; - readonly ru: "ĐĐ°ŅŅ‚Ņ€ĐžĐ¸Ņ‚ŅŒ ҁĐēвОСĐŊĐžĐĩ ŅˆĐ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊиĐĩ"; - readonly zh: "配įŊŽ E2EE"; - readonly "zh-tw": "č¨­åŽš E2EE"; - }; - readonly "Configure Remote": { - readonly def: "Configure Remote"; - readonly es: "Configurar remoto"; - readonly ja: "ãƒĒãƒĸãƒŧãƒˆã‚’č¨­åŽš"; - readonly ko: "ė›ę˛Š ęĩŦė„ą"; - readonly ru: "ĐĐ°ŅŅ‚Ņ€ĐžĐ¸Ņ‚ŅŒ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐĩ Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰Đĩ"; - readonly zh: "配įŊŽčŋœį̝"; - readonly "zh-tw": "č¨­åŽšé į̝"; - }; - readonly "Configure the same server information as your other devices again, manually, very advanced users only.": { - readonly def: "Configure the same server information as your other devices again, manually, very advanced users only."; - readonly es: "Configure manualmente la misma informaciÃŗn del servidor que en sus otros dispositivos. Solo para usuarios muy avanzados。"; - readonly ja: "äģ–ぎį̝æœĢと同じã‚ĩãƒŧバãƒŧæƒ…å ąã‚’æ‰‹å‹•ã§å†å…ĨåŠ›ã—ãžã™ã€‚ä¸Šį´šč€…å‘ã‘ãŽæ–šæŗ•ã§ã™ã€‚"; - readonly ko: "다ëĨ¸ ėžĨėš˜ė™€ 동ėŧ한 ė„œë˛„ ė •ëŗ´ëĨŧ ë‹¤ė‹œ ėˆ˜ë™ėœŧ로 ėž…ë Ĩ합니다. 溠揉 ė‚ŦėšŠėž ė „ėšŠėž…ë‹ˆë‹¤ã€‚"; - readonly ru: "ĐĄĐŊОва Đ˛Ņ€ŅƒŅ‡ĐŊŅƒŅŽ ҃ĐēаĐļĐ¸Ņ‚Đĩ Ņ‚Đĩ ĐļĐĩ ĐŋĐ°Ņ€Đ°ĐŧĐĩ҂Ҁҋ ҁĐĩŅ€Đ˛ĐĩŅ€Đ°, Ņ‡Ņ‚Đž и ĐŊа Đ´Ņ€ŅƒĐŗĐ¸Ņ… ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°Ņ…. ĐĸĐžĐģҌĐēĐž Đ´ĐģŅ ĐžŅ‡ĐĩĐŊҌ ĐžĐŋҋ҂ĐŊҋ҅ ĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ĐĩĐģĐĩĐšã€‚"; - readonly zh: "æ‰‹åŠ¨é‡æ–°čž“å…Ĩ与äŊ å…ļäģ–čŽžå¤‡į›¸åŒįš„æœåŠĄå™¨äŋĄæ¯ã€‚äģ…适合é̘įē§į”¨æˆˇã€‚"; - readonly "zh-tw": "手動重新čŧ¸å…Ĩ與å…ļäģ–čŖįŊŽį›¸åŒįš„äŧ翜å™¨čŗ‡č¨Šã€‚åƒ…éŠåˆé€˛éšŽäŊŋį”¨č€…ã€‚"; - }; - readonly "Connection Method": { - readonly def: "Connection Method"; - readonly es: "MÊtodo de conexiÃŗn"; - readonly ja: "æŽĨį–šæŗ•"; - readonly ko: "뗰枰 방법"; - readonly ru: "ĐĄĐŋĐžŅĐžĐą ĐŋОдĐēĐģŅŽŅ‡ĐĩĐŊĐ¸Ņ"; - readonly zh: "čŋžæŽĨæ–šåŧ"; - readonly "zh-tw": "é€Ŗįˇšæ–šåŧ"; - }; - readonly "Continue to CouchDB setup": { - readonly def: "Continue to CouchDB setup"; - readonly es: "Continuar con la configuraciÃŗn de CouchDB"; - readonly ja: "CouchDB č¨­åŽšã¸é€˛ã‚€"; - readonly ko: "CouchDB 네렕ėœŧ로 ęŗ„ė†"; - readonly ru: "ПĐĩŅ€ĐĩĐšŅ‚Đ¸ Đē ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēĐĩ CouchDB"; - readonly zh: "įģ§įģ­čŋ›čĄŒ CouchDB 莞įŊŽ"; - readonly "zh-tw": "įšŧįēŒé€˛čĄŒ CouchDB č¨­åŽš"; - }; - readonly "Continue to Peer-to-Peer only setup": { - readonly def: "Continue to Peer-to-Peer only setup"; - readonly es: "Continuar con la configuraciÃŗn solo Peer-to-Peer"; - readonly ja: "Peer-to-Peer å°‚į”¨č¨­åŽšã¸é€˛ã‚€"; - readonly ko: "Peer-to-Peer ė „ėšŠ 네렕ėœŧ로 ęŗ„ė†"; - readonly ru: "ПĐĩŅ€ĐĩĐšŅ‚Đ¸ Đē ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēĐĩ Ņ‚ĐžĐģҌĐēĐž Peer-to-Peer"; - readonly zh: "įģ§įģ­čŋ›čĄŒäģ… Peer-to-Peer 莞įŊŽ"; - readonly "zh-tw": "įšŧįēŒé€˛čĄŒåƒ… Peer-to-Peer č¨­åŽš"; - }; - readonly "Continue to S3/MinIO/R2 setup": { - readonly def: "Continue to S3/MinIO/R2 setup"; - readonly es: "Continuar con la configuraciÃŗn de S3/MinIO/R2"; - readonly ja: "S3/MinIO/R2 č¨­åŽšã¸é€˛ã‚€"; - readonly ko: "S3/MinIO/R2 네렕ėœŧ로 ęŗ„ė†"; - readonly ru: "ПĐĩŅ€ĐĩĐšŅ‚Đ¸ Đē ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēĐĩ S3/MinIO/R2"; - readonly zh: "įģ§įģ­čŋ›čĄŒ S3/MinIO/R2 莞įŊŽ"; - readonly "zh-tw": "įšŧįēŒé€˛čĄŒ S3/MinIO/R2 č¨­åŽš"; - }; - readonly Copy: { - readonly def: "Copy"; - readonly es: "Copiar"; - readonly ja: "ã‚ŗãƒ”ãƒŧ"; - readonly ko: "ëŗĩė‚Ŧ"; - readonly ru: "КоĐŋĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ"; - readonly zh: "复åˆļ"; - readonly "zh-tw": "複čŖŊ"; - }; - readonly "Copy Report to clipboard": { - readonly def: "Copy Report to clipboard"; - readonly fr: "Copier le rapport dans le presse-papiers"; - readonly he: "ה×ĸ×Ē×§ דו\"ח ללוח"; - readonly ja: "ãƒŦポãƒŧトをクãƒĒップボãƒŧドãĢã‚ŗãƒ”ãƒŧ"; - readonly ko: "ëŗ´ęŗ ė„œëĨŧ 클ëĻŊëŗ´ë“œė— ëŗĩė‚Ŧ"; - readonly ru: "КоĐŋĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ ĐžŅ‚Ņ‡Ņ‘Ņ‚ в ĐąŅƒŅ„ĐĩŅ€ ОйĐŧĐĩĐŊа"; - readonly zh: "将æŠĨ告复åˆļ到å‰Ēč´´æŋ"; - readonly "zh-tw": "å°‡å ąå‘Šč¤‡čŖŊ到å‰Ēč˛ŧį°ŋ"; - }; - readonly "CouchDB Connection Tweak": { - readonly def: "CouchDB Connection Tweak"; - readonly es: "Ajustes de conexiÃŗn de CouchDB"; - readonly ja: "CouchDB æŽĨįļšãŽčĒŋ整"; - readonly ko: "CouchDB 뗰枰 ėĄ°ė •"; - readonly ru: "ĐĐ°ŅŅ‚Ņ€ĐžĐšĐēи ĐŋОдĐēĐģŅŽŅ‡ĐĩĐŊĐ¸Ņ CouchDB"; - readonly zh: "CouchDB čŋžæŽĨ调äŧ˜"; - readonly "zh-tw": "CouchDB é€ŖįˇščĒŋæ Ą"; - }; - readonly "Cross-platform": { - readonly def: "Cross-platform"; - readonly es: "Multiplataforma"; - readonly ja: "クロ゚プナットフりãƒŧム"; - readonly ko: "íŦëĄœėŠ¤ 플ëžĢíŧ"; - readonly ru: "ĐšŅ€ĐžŅŅĐŋĐģĐ°Ņ‚Ņ„ĐžŅ€ĐŧĐĩĐŊĐŊŅ‹Đĩ"; - readonly zh: "čˇ¨åšŗå°"; - readonly "zh-tw": "čˇ¨åšŗå°"; - }; - readonly "Current adapter: {adapter}": { - readonly def: "Current adapter: {adapter}"; - readonly es: "Adaptador actual: {adapter}"; - readonly ja: "įžåœ¨ãŽã‚ĸダプã‚ŋãƒŧ: {adapter}"; - readonly ko: "현ėžŦ ė–´ëŒ‘í„°: {adapter}"; - readonly ru: "ĐĸĐĩĐēŅƒŅ‰Đ¸Đš адаĐŋŅ‚ĐĩŅ€: {adapter}"; - readonly zh: "åŊ“前适配器īŧš{adapter}"; - readonly "zh-tw": "į›Žå‰įš„éŠé…å™¨īŧš{adapter}"; - }; - readonly "Customization Sync": { - readonly def: "Customization Sync"; - readonly es: "SincronizaciÃŗn de personalizaciÃŗn"; - readonly ja: "ã‚Ģ゚ã‚ŋマイã‚ē同期"; - readonly ko: "ė‚ŦėšŠėž 맀렕 동기화"; - readonly ru: "ХиĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Ņ ĐŊĐ°ŅŅ‚Ņ€ĐžĐĩĐē"; - readonly zh: "č‡Ē厚䚉同æ­Ĩ"; - readonly "zh-tw": "č‡Ēč¨‚åŒæ­Ĩ"; - }; - readonly "Customization Sync (Beta3)": { - readonly def: "Customization Sync (Beta3)"; - readonly es: "SincronizaciÃŗn de personalizaciÃŗn (Beta3)"; - readonly ja: "ã‚Ģ゚ã‚ŋマイã‚ē同期 (Beta3)"; - readonly ko: "ė‚ŦėšŠėž 맀렕 동기화 (Beta3)"; - readonly ru: "ХиĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Ņ ĐŊĐ°ŅŅ‚Ņ€ĐžĐĩĐē (Beta3)"; - readonly zh: "č‡Ē厚䚉同æ­ĨīŧˆBeta3īŧ‰"; - readonly "zh-tw": "č‡Ēč¨‚åŒæ­ĨīŧˆBeta3īŧ‰"; - }; - readonly "Data Compression": { - readonly def: "Data Compression"; - readonly es: "CompresiÃŗn de datos"; - readonly fr: "Compression des donnÊes"; - readonly he: "דחיס×Ē × ×Ēונים"; - readonly ja: "デãƒŧã‚ŋåœ§į¸Ž"; - readonly ko: "ë°ė´í„° ė••ėļ•"; - readonly ru: "ĐĄĐļĐ°Ņ‚Đ¸Đĩ даĐŊĐŊҋ҅"; - readonly zh: "数捎压įŧŠ"; - readonly "zh-tw": "čŗ‡æ–™åŖ“į¸Ž"; - }; - readonly "Database -> Storage": { - readonly def: "Database -> Storage"; - readonly "zh-tw": "čŗ‡æ–™åēĢ -> å„˛å­˜įŠē間"; - }; - readonly "Database Adapter": { - readonly def: "Database Adapter"; - readonly es: "Adaptador de base de datos"; - readonly ja: "デãƒŧã‚ŋベãƒŧ゚ã‚ĸダプã‚ŋãƒŧ"; - readonly ko: "ë°ė´í„°ë˛ ė´ėŠ¤ ė–´ëŒ‘í„°"; - readonly ru: "АдаĐŋŅ‚ĐĩŅ€ ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅"; - readonly zh: "数捎åē“适配器"; - readonly "zh-tw": "čŗ‡æ–™åēĢ遊配器"; - }; - readonly "Database Name": { - readonly def: "Database Name"; - readonly es: "Nombre de la base de datos"; - readonly fr: "Nom de la base de donnÊes"; - readonly he: "׊ם מסד × ×Ēונים"; - readonly ja: "デãƒŧã‚ŋベãƒŧ゚名"; - readonly ko: "ë°ė´í„°ë˛ ė´ėŠ¤ ė´ëĻ„"; - readonly ru: "ИĐŧŅ ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅"; - readonly zh: "数捎åē“åį§°"; - readonly "zh-tw": "čŗ‡æ–™åēĢåį¨ą"; - }; - readonly "Database suffix": { - readonly def: "Database suffix"; - readonly es: "Sufijo de base de datos"; - readonly fr: "Suffixe de la base de donnÊes"; - readonly he: "סיומ×Ē ×ž×Ą×“ × ×Ēונים"; - readonly ja: "デãƒŧã‚ŋベãƒŧ゚ぎæŽĨå°žčžž(suffix)"; - readonly ko: "ë°ė´í„°ë˛ ė´ėŠ¤ ė ‘ë¯¸ė‚Ŧ"; - readonly ru: "ĐĄŅƒŅ„Ņ„Đ¸Đēҁ ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅"; - readonly zh: "数捎åē“后įŧ€"; - readonly "zh-tw": "čŗ‡æ–™åēĢ垌įļ´"; - }; - readonly Default: { - readonly def: "Default"; - readonly es: "Predeterminado"; - readonly ja: "デフりãƒĢト"; - readonly ko: "ę¸°ëŗ¸ę°’"; - readonly ru: "По ҃ĐŧĐžĐģŅ‡Đ°ĐŊĐ¸ŅŽ"; - readonly zh: "éģ˜čޤ"; - readonly "zh-tw": "預設"; - }; - readonly "Delay conflict resolution of inactive files": { - readonly def: "Delay conflict resolution of inactive files"; - readonly es: "Retrasar resoluciÃŗn de conflictos en archivos inactivos"; - readonly fr: "DiffÊrer la rÊsolution des conflits pour les fichiers inactifs"; - readonly he: "×ĸכב פ×Ēרון קונפליקטים לקב×Ļים לא פ×ĸילים"; - readonly ja: "非ã‚ĸã‚¯ãƒ†ã‚Ŗãƒ–ãĒãƒ•ã‚Ąã‚¤ãƒĢは、įĢļ合觪æąēを先送りする"; - readonly ko: "ëš„í™œė„ą 파ėŧė˜ ėļŠëŒ 해결 맀뗰"; - readonly ru: "ĐžŅ‚ĐģĐžĐļĐ¸Ņ‚ŅŒ Ņ€Đ°ĐˇŅ€Đĩ҈ĐĩĐŊиĐĩ ĐēĐžĐŊŅ„ĐģиĐēŅ‚ĐžĐ˛ Đ´ĐģŅ ĐŊĐĩаĐēŅ‚Đ¸Đ˛ĐŊҋ҅ Ņ„Đ°ĐšĐģОв"; - readonly zh: "推čŋŸč§Ŗå†ŗä¸æ´ģ动文äģļ"; - readonly "zh-tw": "åģļåžŒč™•į†éžæ´ģ動æĒ”æĄˆįš„čĄįǁ"; - }; - readonly "Delay merge conflict prompt for inactive files.": { - readonly def: "Delay merge conflict prompt for inactive files."; - readonly es: "Retrasar aviso de fusiÃŗn para archivos inactivos"; - readonly fr: "DiffÊrer l'invite de conflit de fusion pour les fichiers inactifs."; - readonly he: "×ĸכב ה×Ļג×Ē ×‘×§×Š×Ē ×ž×™×–×•×’ לקב×Ļים לא פ×ĸילים."; - readonly ja: "非ã‚ĸã‚¯ãƒ†ã‚Ŗãƒ–ãĒãƒ•ã‚Ąã‚¤ãƒĢぎįĢļ合觪æąēãŽãƒ—ãƒ­ãƒŗãƒ—ãƒˆãŽčĄ¨į¤ēを遅åģļさせる"; - readonly ko: "ëš„í™œė„ą 파ėŧė˜ ëŗ‘í•Š ėļŠëŒ 프ëĄŦ프트 맀뗰."; - readonly ru: "ĐžŅ‚ĐģĐžĐļĐ¸Ņ‚ŅŒ СаĐŋŅ€ĐžŅ ĐēĐžĐŊŅ„ĐģиĐēŅ‚Đ° ҁĐģĐ¸ŅĐŊĐ¸Ņ Đ´ĐģŅ ĐŊĐĩаĐēŅ‚Đ¸Đ˛ĐŊҋ҅ Ņ„Đ°ĐšĐģОв."; - readonly zh: "推čŋŸæ‰‹åŠ¨č§Ŗå†ŗä¸æ´ģ动文äģļ"; - readonly "zh-tw": "åģļåžŒéĄ¯į¤ē非æ´ģ動æĒ”æĄˆįš„åˆäŊĩ衝įĒæį¤ē。"; - }; - readonly Delete: { - readonly def: "Delete"; - readonly es: "Eliminar"; - readonly ja: "削除"; - readonly ko: "ė‚­ė œ"; - readonly ru: "ĐŖĐ´Đ°ĐģĐ¸Ņ‚ŅŒ"; - readonly zh: "删除"; - readonly "zh-tw": "åˆĒ除"; - }; - readonly "Delete all customization sync data": { - readonly def: "Delete all customization sync data"; - readonly es: "Eliminar todos los datos de sincronizaciÃŗn de personalizaciÃŗn"; - readonly ja: "ã‚Ģ゚ã‚ŋマイã‚ē同期デãƒŧã‚ŋをすずãĻ削除"; - readonly ko: "ëĒ¨ë“  ė‚ŦėšŠėž ė •ė˜ 동기화 ë°ė´í„° ė‚­ė œ"; - readonly ru: "ĐŖĐ´Đ°ĐģĐ¸Ņ‚ŅŒ Đ˛ŅĐĩ даĐŊĐŊŅ‹Đĩ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸ ĐŊĐ°ŅŅ‚Ņ€ĐžĐĩĐē"; - readonly zh: "删除所有č‡Ē厚䚉同æ­Ĩ数捎"; - readonly "zh-tw": "åˆĒ除所有č‡Ēč¨‚åŒæ­Ĩčŗ‡æ–™"; - }; - readonly "Delete all data on the remote server.": { - readonly def: "Delete all data on the remote server."; - readonly es: "Eliminar todos los datos del servidor remoto."; - readonly ja: "ãƒĒãƒĸãƒŧトã‚ĩãƒŧバãƒŧ上ぎすずãĻぎデãƒŧã‚ŋを削除しぞす。"; - readonly ko: "ė›ę˛Š ė„œë˛„ė˜ ëĒ¨ë“  ë°ė´í„°ëĨŧ ė‚­ė œí•Šë‹ˆë‹¤."; - readonly ru: "ĐŖĐ´Đ°ĐģĐ¸Ņ‚ŅŒ Đ˛ŅĐĩ даĐŊĐŊŅ‹Đĩ ĐŊа ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐŧ ҁĐĩŅ€Đ˛ĐĩŅ€Đĩ."; - readonly zh: "删除čŋœįĢ¯æœåŠĄå™¨ä¸Šįš„æ‰€æœ‰æ•°æŽã€‚"; - readonly "zh-tw": "åˆĒ除遠į̝äŧ翜å™¨ä¸Šįš„æ‰€æœ‰čŗ‡æ–™ã€‚"; - }; - readonly "Delete local database to reset or uninstall Self-hosted LiveSync": { - readonly def: "Delete local database to reset or uninstall Self-hosted LiveSync"; - readonly es: "Eliminar la base de datos local para restablecer o desinstalar Self-hosted LiveSync"; - readonly ja: "Self-hosted LiveSync をãƒĒã‚ģットぞたはã‚ĸãƒŗã‚¤ãƒŗã‚šãƒˆãƒŧãƒĢするため、ロãƒŧã‚ĢãƒĢデãƒŧã‚ŋベãƒŧ゚を削除"; - readonly ko: "Self-hosted LiveSyncëĨŧ ė´ˆę¸°í™”í•˜ęą°ë‚˜ ė œęą°í•˜ę¸° ėœ„í•´ 로ėģŦ ë°ė´í„°ë˛ ė´ėŠ¤ëĨŧ ė‚­ė œ"; - readonly ru: "ĐŖĐ´Đ°ĐģĐ¸Ņ‚ŅŒ ĐģĐžĐēаĐģҌĐŊŅƒŅŽ ĐąĐ°ĐˇŅƒ даĐŊĐŊҋ҅, Ņ‡Ņ‚ĐžĐąŅ‹ ŅĐąŅ€ĐžŅĐ¸Ņ‚ŅŒ иĐģи ŅƒĐ´Đ°ĐģĐ¸Ņ‚ŅŒ Self-hosted LiveSync"; - readonly zh: "删除æœŦ地数捎åē“äģĨ重įŊŽæˆ–卸čŊŊ Self-hosted LiveSync"; - readonly "zh-tw": "åˆĒ除æœŦæŠŸčŗ‡æ–™åēĢäģĨé‡č¨­æˆ–č§Ŗé™¤åŽ‰čŖ Self-hosted LiveSync"; - }; - readonly "Delete old metadata of deleted files on start-up": { - readonly def: "Delete old metadata of deleted files on start-up"; - readonly es: "Borrar metadatos viejos al iniciar"; - readonly fr: "Supprimer les anciennes mÊtadonnÊes des fichiers effacÊs au dÊmarrage"; - readonly he: "מחק מטה-× ×Ēונים ישנים של קב×Ļים שנמחקו ב×ĸ×Ē ×”×¤×ĸלה"; - readonly ja: "削除済ãŋデãƒŧã‚ŋãŽãƒĄã‚ŋデãƒŧã‚ŋをクãƒĒãƒŧãƒŗãƒŠãƒƒãƒ—ã™ã‚‹"; - readonly ko: "ė‹œėž‘ ė‹œ ė‚­ė œëœ 파ėŧė˜ ė˜¤ëž˜ëœ ëŠ”íƒ€ë°ė´í„° ė‚­ė œ"; - readonly ru: "ĐŖĐ´Đ°ĐģŅŅ‚ŅŒ ŅŅ‚Đ°Ņ€Ņ‹Đĩ ĐŧĐĩŅ‚Đ°Đ´Đ°ĐŊĐŊŅ‹Đĩ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊҋ҅ Ņ„Đ°ĐšĐģОв ĐŋŅ€Đ¸ СаĐŋ҃ҁĐēĐĩ"; - readonly zh: "启动æ—ļåˆ é™¤åˇ˛åˆ é™¤æ–‡äģļįš„æ—§å…ƒæ•°æŽ"; - readonly "zh-tw": "啟動時åˆĒ除厞åˆĒ除æĒ”æĄˆįš„čˆŠä¸­įšŧčŗ‡æ–™"; - }; - readonly "Delete Remote Configuration": { - readonly def: "Delete Remote Configuration"; - readonly es: "Eliminar configuraciÃŗn remota"; - readonly ja: "ãƒĒãƒĸãƒŧãƒˆč¨­åŽšã‚’å‰Šé™¤"; - readonly ko: "ė›ę˛Š ęĩŦė„ą ė‚­ė œ"; - readonly ru: "ĐŖĐ´Đ°ĐģĐ¸Ņ‚ŅŒ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊŅƒŅŽ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸ŅŽ"; - readonly zh: "删除čŋœįĢ¯é…įŊŽ"; - readonly "zh-tw": "åˆĒ除遠įĢ¯č¨­åŽš"; - }; - readonly "Delete remote configuration '{name}'?": { - readonly def: "Delete remote configuration '{name}'?"; - readonly es: "ÂŋEliminar la configuraciÃŗn remota '{name}'?"; - readonly ja: "ãƒĒãƒĸãƒŧãƒˆč¨­åŽš '{name}' を削除しぞすかīŧŸ"; - readonly ko: "'{name}' ė›ę˛Š ęĩŦė„ąė„ ė‚­ė œí• ęšŒėš”?"; - readonly ru: "ĐŖĐ´Đ°ĐģĐ¸Ņ‚ŅŒ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊŅƒŅŽ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸ŅŽ '{name}'?"; - readonly zh: "čĻåˆ é™¤čŋœįĢ¯é…įŊŽâ€œ{name}”吗īŧŸ"; - readonly "zh-tw": "čρåˆĒ除遠įĢ¯č¨­åŽšã€Œ{name}」嗎īŧŸ"; - }; - readonly desktop: { - readonly def: "desktop"; - readonly es: "equipo de escritorio"; - readonly ja: "デ゚クトップ"; - readonly ko: "ë°ėŠ¤íŦ톱"; - readonly ru: "Ņ€Đ°ĐąĐžŅ‡Đ¸Đš ŅŅ‚ĐžĐģ"; - readonly zh: "æĄŒéĸčŽžå¤‡"; - readonly "zh-tw": "æĄŒéĸčŖįŊŽ"; - }; - readonly Developer: { - readonly def: "Developer"; - readonly es: "Desarrollador"; - readonly ja: "開į™ē者"; - readonly ko: "ę°œë°œėž"; - readonly ru: "Đ Đ°ĐˇŅ€Đ°ĐąĐžŅ‚Ņ‡Đ¸Đē"; - readonly zh: "åŧ€å‘者"; - readonly "zh-tw": "開į™ŧ者"; - }; - readonly Device: { - readonly def: "Device"; - readonly ja: "デバイ゚"; - readonly ko: "기기"; - readonly ru: "ĐŖŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đž"; - readonly zh: "čŽžå¤‡"; - readonly "zh-tw": "čŖįŊŽ"; - }; - readonly "Device name": { - readonly def: "Device name"; - readonly es: "Nombre del dispositivo"; - readonly fr: "Nom de l'appareil"; - readonly he: "׊ם מכשיר"; - readonly ja: "デバイ゚名"; - readonly ko: "기기 ė´ëĻ„"; - readonly ru: "ИĐŧŅ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°"; - readonly zh: "čŽžå¤‡åį§°"; - readonly "zh-tw": "čŖįŊŽåį¨ą"; - }; - readonly "Device Setup Method": { - readonly def: "Device Setup Method"; - readonly es: "MÊtodo de configuraciÃŗn del dispositivo"; - readonly ja: "į̝æœĢãŽč¨­åŽšæ–šæŗ•"; - readonly ko: "ėžĨėš˜ 네렕 방법"; - readonly ru: "ĐĄĐŋĐžŅĐžĐą ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°"; - readonly zh: "čŽžå¤‡čŽžįŊŽæ–šåŧ"; - readonly "zh-tw": "čŖįŊŽč¨­åŽšæ–šåŧ"; - }; - readonly "dialog.yourLanguageAvailable": { - readonly def: "Self-hosted LiveSync had translations for your language, so the %{Display language} setting was enabled.\n\nNote: Not all messages are translated. We are waiting for your contributions!\nNote 2: If you create an Issue, **please revert to Default** and then take screenshots, messages and logs. This can be done in the setting dialogue.\nMay you find it easy to use!"; - readonly fr: "Self-hosted LiveSync dispose d'une traduction pour votre langue, le paramètre %{Display language} a donc ÊtÊ activÊ.\n\nNote : Tous les messages ne sont pas traduits. Nous attendons vos contributions !\nNote 2 : Si vous crÊez un ticket, **veuillez revenir à Par dÊfaut** puis prendre des captures d'Êcran, messages et journaux. Cela peut ÃĒtre fait dans la boÃŽte de dialogue des paramètres.\nBonne utilisation !"; - readonly he: "ל-Self-hosted LiveSync יש ×Ēרגום לשפ×Ēך, ולכן הגדר×Ē %{Display language} הופ×ĸלה.\n\nה×ĸרה: לא כל ההוד×ĸו×Ē ×ž×Ēורגמו×Ē. אנחנו ממ×Ēינים ל×Ēרומו×Ēיך!\nה×ĸרה 2: אם א×Ēה פו×Ēח Issue, **אנא חזור ל-%{lang-def}** ואז ×Ļלם ×Ļילומי מסך, הוד×ĸו×Ē ×•×™×•×ž× ×™×. ני×Ēן ל×ĸשו×Ē ×–××Ē ×‘×“×™××œ×•×’ ההגדרו×Ē.\nנקווה ׊×Ēמ×Ļא/י א×Ē ×”×¤×œ××’×™×Ÿ נוח לשימוש!"; - readonly ja: "Self-hosted LiveSync ãĢč¨­åŽšã•ã‚ŒãĻã„ã‚‹č¨€čĒžãŽįŋģč¨ŗãŒã‚ã‚Šãžã—ãŸãŽã§ã€ã‚¤ãƒŗã‚ŋãƒŧフェãƒŧã‚šãŽčĄ¨į¤ē言čĒžãŒéŠį”¨ã•ã‚Œãžã—ãŸã€‚\n\næŗ¨æ„īŧš 全ãĻãŽãƒĄãƒƒã‚ģãƒŧジはįŋģč¨ŗã•ã‚ŒãĻいぞせん。あãĒたぎč˛ĸįŒŽã‚’ãŠåž…ãĄã—ãĻいぞすīŧ\nGithubãĢIssueをäŊœæˆã™ã‚‹éš›ãĢは、 ã‚¤ãƒŗã‚ŋãƒŧフェãƒŧã‚šãŽčĄ¨į¤ē言čĒž を一æ—Ļ Default ãĢæˆģしãĻã‹ã‚‰ã€ã‚šã‚¯ã‚ˇãƒ§ã‚„ãƒĄãƒƒã‚ģãƒŧジ、ログを収集しãĻãã ã•ã„ã€‚ã“ã‚Œã¯č¨­åŽšã‹ã‚‰å¤‰æ›´ã§ããžã™ã€‚\n\näžŋ刊ãĢäŊŋį”¨ã§ãã‚Œã°åš¸ã„ã§ã™ã€‚"; - readonly ko: "Self-hosted LiveSyncė—ė„œ ęˇ€í•˜ė˜ ė–¸ė–´ëĄœ ë˛ˆė—­ė„ 렜ęŗĩí•˜ë¯€ëĄœ %{Display language} ė„¤ė •ė´ í™œė„ąí™”ë˜ė—ˆėŠĩ니다.\n\n및溠: ëĒ¨ë“  ëŠ”ė‹œė§€ę°€ ë˛ˆė—­ë˜ė§€ëŠ” ė•ŠėŠĩ니다. ęˇ€í•˜ė˜ 기ė—ŦëĨŧ 기다ëĻŦęŗ  ėžˆėŠĩ니다!\n및溠 2: ė´ėŠˆëĨŧ ėƒė„ąí•˜ëŠ” ę˛Ŋ뚰 **Default로 되돌ëϰ 후** 늤íŦëϰ냎, ëŠ”ė‹œė§€, 로그ëĨŧ 氀렏뙀 ėŖŧė„¸ėš”. ė´ëŠ” 네렕 대화 ėƒėžė—ė„œ 할 눘 ėžˆėŠĩ니다.\n간편하게 ė‚ŦėšŠí•˜ė‹¤ 눘 ėžˆė—ˆėœŧ늴 ėĸ‹ę˛ ėŠĩ니다!"; - readonly ru: "Self-hosted LiveSync иĐŧĐĩĐĩŅ‚ ĐŋĐĩŅ€ĐĩĐ˛ĐžĐ´Ņ‹ Đ´ĐģŅ Đ˛Đ°ŅˆĐĩĐŗĐž ŅĐˇŅ‹Đēа, ĐŋĐžŅŅ‚ĐžĐŧ҃ ĐąŅ‹Đģа вĐēĐģŅŽŅ‡ĐĩĐŊа ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēа ŅĐˇŅ‹Đēа Display language.\n\nĐŸŅ€Đ¸ĐŧĐĩŅ‡Đ°ĐŊиĐĩ: НĐĩ Đ˛ŅĐĩ ŅĐžĐžĐąŅ‰ĐĩĐŊĐ¸Ņ ĐŋĐĩŅ€ĐĩвĐĩĐ´ĐĩĐŊŅ‹. ĐœŅ‹ ĐļĐ´Ņ‘Đŧ Đ˛Đ°ŅˆĐ¸Ņ… ĐŋŅ€ĐĩĐ´ĐģĐžĐļĐĩĐŊиК!\nĐŸŅ€Đ¸ĐŧĐĩŅ‡Đ°ĐŊиĐĩ 2: ĐŸŅ€Đ¸ ŅĐžĐˇĐ´Đ°ĐŊии Issue, ĐŋĐžĐļаĐģŅƒĐšŅŅ‚Đ°, вĐĩŅ€ĐŊĐ¸Ņ‚ĐĩҁҌ Đē lang-def, ĐˇĐ°Ņ‚ĐĩĐŧ ŅĐ´ĐĩĐģĐ°ĐšŅ‚Đĩ ҁĐēŅ€Đ¸ĐŊŅˆĐžŅ‚Ņ‹, ŅĐžĐžĐąŅ‰ĐĩĐŊĐ¸Ņ и ĐģĐžĐŗĐ¸. Đ­Ņ‚Đž ĐŧĐžĐļĐŊĐž ŅĐ´ĐĩĐģĐ°Ņ‚ŅŒ в ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēĐ°Ņ….\nНадĐĩĐĩĐŧŅŅ, ваĐŧ ĐąŅƒĐ´ĐĩŅ‚ ŅƒĐ´ĐžĐąĐŊĐž Đ¸ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ŅŒ!"; - readonly zh: "Self-hosted LiveSyncåˇ˛æäž›æ‚¨č¯­č¨€įš„įŋģ蝑,å› æ­¤å¯į”¨äē†%{Display language}\n\næŗ¨æ„īŧšåšļ非所有æļˆæ¯éƒŊ厞įŋģč¯‘ã€‚æˆ‘äģŦæœŸåž…æ‚¨įš„č´ĄįŒŽīŧ\næŗ¨æ„ 2īŧšč‹Ĩ您创åģē问éĸ˜æŠĨ告īŧŒ **č¯ˇåˆ‡æĸ回Default** īŧŒį„ļ后æˆĒå–åąåš•æˆĒ回、æļˆæ¯å’Œæ—Ĩåŋ—īŧŒæ­¤æ“äŊœå¯åœ¨čŽžįŊŽå¯šč¯æĄ†ä¸­åŽŒæˆ\næ„ŋ您äŊŋᔍéĄēåŋƒīŧ"; - readonly "zh-tw": "Self-hosted LiveSync åˇ˛æäž›äŊ į›Žå‰čĒžč¨€įš„įŋģč­¯īŧŒå› æ­¤åˇ˛å•Ÿį”¨éĄ¯į¤ēčĒžč¨€č¨­åŽšã€‚\n\næŗ¨æ„īŧšä¸Ļéžæ‰€æœ‰č¨Šæ¯éƒŊåˇ˛åŽŒæˆįŋģč­¯īŧŒæ­ĄčŋŽå”åŠŠčŖœå……īŧ\næŗ¨æ„ 2īŧšåĻ‚æžœäŊ čĻå›žå ąå•éĄŒīŧŒčĢ‹å…ˆåˆ‡å›žé č¨­čĒžč¨€īŧŒå†é™„上æˆĒåœ–ã€č¨Šæ¯čˆ‡æ—ĨčĒŒã€‚\n\n希望äŊ äŊŋį”¨æ„‰åŋĢīŧ"; - }; - readonly "dialog.yourLanguageAvailable.btnRevertToDefault": { - readonly def: "Keep Default"; - readonly fr: "Conserver Par dÊfaut"; - readonly he: "השאר %{lang-def}"; - readonly ja: "Keep Default"; - readonly ko: "Default ėœ ė§€"; - readonly ru: "ĐžŅŅ‚Đ°Đ˛Đ¸Ņ‚ŅŒ lang-def"; - readonly zh: "äŋæŒDefault"; - readonly "zh-tw": "æĸ垊į‚ē預設čĒžč¨€"; - }; - readonly "dialog.yourLanguageAvailable.Title": { - readonly def: " Translation is available!"; - readonly fr: " Une traduction est disponible !"; - readonly he: " ×Ēרגום זמין!"; - readonly ja: "įŋģč¨ŗãŒåˆŠį”¨å¯čƒŊですīŧ"; - readonly ko: " ë˛ˆė—­ė„ ė‚ŦėšŠí•  눘 ėžˆėŠĩ니다!"; - readonly ru: "Đ”ĐžŅŅ‚ŅƒĐŋĐĩĐŊ ĐŋĐĩŅ€ĐĩвОд!"; - readonly zh: " įŋģč¯‘å¯į”¨!"; - readonly "zh-tw": "åˇ˛æäž›äŊ įš„čĒžč¨€įŋģč­¯īŧ"; - }; - readonly "Disables all synchronization and restart.": { - readonly def: "Disables all synchronization and restart."; - readonly es: "Desactiva toda la sincronizaciÃŗn y reinicia la aplicaciÃŗn."; - readonly ja: "すずãĻãŽåŒæœŸã‚’į„ĄåŠšãĢしãĻ再čĩˇå‹•しぞす。"; - readonly ko: "ëĒ¨ë“  동기화ëĨŧ ëš„í™œė„ąí™”í•˜ęŗ  ėžŦė‹œėž‘í•Šë‹ˆë‹¤."; - readonly ru: "ĐžŅ‚ĐēĐģŅŽŅ‡Đ°ĐĩŅ‚ Đ˛ŅŅŽ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸ŅŽ и ĐŋĐĩŅ€ĐĩСаĐŋ҃ҁĐēаĐĩŅ‚ ĐŋŅ€Đ¸ĐģĐžĐļĐĩĐŊиĐĩ."; - readonly zh: "įĻį”¨æ‰€æœ‰åŒæ­Ĩåšļ重新启动。"; - readonly "zh-tw": "åœį”¨æ‰€æœ‰åŒæ­Ĩä¸Ļ重新啟動。"; - }; - readonly "Disables logging, only shows notifications. Please disable if you report an issue.": { - readonly def: "Disables logging, only shows notifications. Please disable if you report an issue."; - readonly es: "Desactiva registros, solo muestra notificaciones. Desactívelo si reporta un problema."; - readonly fr: "DÊsactive la journalisation, n'affiche que les notifications. Veuillez dÊsactiver si vous signalez un problème."; - readonly he: "מכבה רישום יומן, מ×Ļיג ה×Ēראו×Ē ×‘×œ×‘×“. אנא כבה אם א×Ēה מדווח ×ĸל ב×ĸיה."; - readonly ja: "ãƒ­ã‚°ã‚’į„ĄåŠšãĢし、通įŸĨぎãŋã‚’čĄ¨į¤ēしぞす。Issueã‚’å ąå‘Šã™ã‚‹å ´åˆã¯į„ĄåŠšãĢしãĻください。"; - readonly ko: "ëĄœęš…ė„ ëš„í™œė„ąí™”í•˜ęŗ  ė•ŒëĻŧ만 í‘œė‹œí•Šë‹ˆë‹¤. ëŦ¸ė œëĨŧ ė‹ ęŗ í•˜ëŠ” ę˛Ŋ뚰 ëš„í™œė„ąí™”í•´ ėŖŧė„¸ėš”."; - readonly ru: "ĐžŅ‚ĐēĐģŅŽŅ‡Đ°ĐĩŅ‚ ĐģĐžĐŗĐ¸Ņ€ĐžĐ˛Đ°ĐŊиĐĩ, ĐŋĐžĐēĐ°ĐˇŅ‹Đ˛Đ°ĐĩŅ‚ Ņ‚ĐžĐģҌĐēĐž ŅƒĐ˛ĐĩĐ´ĐžĐŧĐģĐĩĐŊĐ¸Ņ. ПоĐļаĐģŅƒĐšŅŅ‚Đ°, ĐžŅ‚ĐēĐģŅŽŅ‡Đ¸Ņ‚Đĩ ĐŋŅ€Đ¸ ŅĐžĐžĐąŅ‰ĐĩĐŊии Đž ĐŋŅ€ĐžĐąĐģĐĩĐŧĐĩ."; - readonly zh: "įρᔍæ—Ĩåŋ—čްåŊ•,äģ…æ˜žį¤ē通įŸĨ。åĻ‚æžœæ‚¨æŠĨ告闎éĸ˜,蝎įĻį”¨æ­¤é€‰éĄš"; - readonly "zh-tw": "åœį”¨æ—ĨčĒŒč¨˜éŒ„īŧŒåĒéĄ¯į¤ē通įŸĨ。åĻ‚æžœäŊ čĻå›žå ąå•éĄŒīŧŒčĢ‹é—œé–‰æ­¤é¸é …ã€‚"; - }; - readonly "Display Language": { - readonly def: "Display Language"; - readonly es: "Idioma de visualizaciÃŗn"; - readonly fr: "Langue d'affichage"; - readonly he: "׊פ×Ē ×Ē×Ļוגה"; - readonly ja: "ã‚¤ãƒŗã‚ŋãƒŧフェãƒŧã‚šãŽčĄ¨į¤ē言čĒž"; - readonly ko: "í‘œė‹œ 떏떴"; - readonly ru: "Đ¯ĐˇŅ‹Đē иĐŊŅ‚ĐĩҀ҄ĐĩĐšŅĐ°"; - readonly zh: "昞į¤ē蝭荀"; - readonly "zh-tw": "éĄ¯į¤ēčĒžč¨€"; - }; - readonly "Display name": { - readonly def: "Display name"; - readonly es: "Nombre para mostrar"; - readonly ja: "襨į¤ē名"; - readonly ko: "í‘œė‹œ ė´ëĻ„"; - readonly ru: "ĐžŅ‚ĐžĐąŅ€Đ°ĐļаĐĩĐŧĐžĐĩ иĐŧŅ"; - readonly zh: "昞į¤ēåį§°"; - readonly "zh-tw": "éĄ¯į¤ēåį¨ą"; - }; - readonly "Do not check configuration mismatch before replication": { - readonly def: "Do not check configuration mismatch before replication"; - readonly es: "No verificar incompatibilidades antes de replicar"; - readonly fr: "Ne pas vÊrifier les incohÊrences de configuration avant la rÊplication"; - readonly he: "אל ×Ēבדוק אי-ה×Ēאמה ב×Ē×Ļורה לפני שכפול"; - readonly ja: "ã‚ĩãƒŧバãƒŧから同期する前ãĢč¨­åŽšãŽä¸ä¸€č‡´ã‚’įĸēčĒã—ãĒい"; - readonly ko: "ëŗĩ렜 ė „ ęĩŦė„ą ëļˆėŧėš˜ í™•ė¸ ė•ˆ 함"; - readonly ru: "НĐĩ ĐŋŅ€ĐžĐ˛ĐĩŅ€ŅŅ‚ŅŒ ĐŊĐĩŅĐžĐ˛ĐŋадĐĩĐŊиĐĩ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Đ¸ ĐŋĐĩŅ€ĐĩĐ´ Ņ€ĐĩĐŋĐģиĐēĐ°Ņ†Đ¸ĐĩĐš"; - readonly zh: "在复åˆļå‰ä¸æŖ€æŸĨ配įŊŽä¸åŒšé…"; - readonly "zh-tw": "複å¯Ģ前不æĒĸæŸĨč¨­åŽšæ˜¯åĻ不一致"; - }; - readonly "Do not keep metadata of deleted files.": { - readonly def: "Do not keep metadata of deleted files."; - readonly es: "No conservar metadatos de archivos borrados"; - readonly fr: "Ne pas conserver les mÊtadonnÊes des fichiers supprimÊs."; - readonly he: "אל ×Ēשמור מטה-× ×Ēונים של קב×Ļים שנמחקו."; - readonly ja: "削除済ãŋãƒ•ã‚Ąã‚¤ãƒĢãŽãƒĄã‚ŋデãƒŧã‚ŋをäŋæŒã—ãĒい"; - readonly ko: "ė‚­ė œëœ 파ėŧė˜ ëŠ”íƒ€ë°ė´í„°ëĨŧ ëŗ´ę´€í•˜ė§€ ė•ŠėŠĩ니다."; - readonly ru: "НĐĩ Ņ…Ņ€Đ°ĐŊĐ¸Ņ‚ŅŒ ĐŧĐĩŅ‚Đ°Đ´Đ°ĐŊĐŊŅ‹Đĩ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊҋ҅ Ņ„Đ°ĐšĐģОв."; - readonly zh: "不äŋį•™åˇ˛åˆ é™¤æ–‡äģļįš„å…ƒæ•°æŽ "; - readonly "zh-tw": "不äŋį•™åˇ˛åˆĒ除æĒ”æĄˆįš„ä¸­įšŧčŗ‡æ–™ã€‚"; - }; - readonly "Do not split chunks in the background": { - readonly def: "Do not split chunks in the background"; - readonly es: "No dividir chunks en segundo plano"; - readonly fr: "Ne pas fragmenter en arrière-plan"; - readonly he: "אל ×Ēפ×Ļל × ×Ēחים ברק×ĸ"; - readonly ja: "バックグナã‚Ļãƒŗãƒ‰ã§ãƒãƒŖãƒŗã‚¯ã‚’åˆ†å‰˛ã—ãĒい"; - readonly ko: "밹꡸ëŧėš´ë“œė—ė„œ 랭íŦ ëļ„í•  ė•ˆ 함"; - readonly ru: "НĐĩ Ņ€Đ°ĐˇĐ´ĐĩĐģŅŅ‚ŅŒ Ņ‡Đ°ĐŊĐēи в Ņ„ĐžĐŊОвОĐŧ Ņ€ĐĩĐļиĐŧĐĩ"; - readonly zh: "ä¸åœ¨åŽå°åˆ†å‰˛ chunks"; - readonly "zh-tw": "ä¸åœ¨čƒŒæ™¯åˆ†å‰˛ chunks"; - }; - readonly "Do not use internal API": { - readonly def: "Do not use internal API"; - readonly es: "No usar API interna"; - readonly fr: "Ne pas utiliser l'API interne"; - readonly he: "אל ×Ē׊×Ēמ׊ ב-API פנימי"; - readonly ja: "内部APIをäŊŋį”¨ã—ãĒい"; - readonly ko: "내ëļ€ API ė‚ŦėšŠ ė•ˆ 함"; - readonly ru: "НĐĩ Đ¸ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ŅŒ вĐŊŅƒŅ‚Ņ€ĐĩĐŊĐŊиК API"; - readonly zh: "不äŊŋį”¨å†…éƒ¨ API"; - readonly "zh-tw": "不äŊŋį”¨å…§éƒ¨ API"; - }; - readonly "Doctor.Button.DismissThisVersion": { - readonly def: "No, and do not ask again until the next release"; - readonly fr: "Non, et ne plus demander jusqu'à la prochaine version"; - readonly he: "לא, ואל ×Ēשאל שוב ×ĸד לגרסה הבאה"; - readonly ja: "いいえ、æŦĄãŽãƒĒãƒĒãƒŧ゚ぞで再åēĻįĸēčĒã—ãĒい"; - readonly ko: "ė•„ë‹ˆėš”, ë‹¤ėŒ ëĻ´ëĻŦėŠ¤ęšŒė§€ ë‹¤ė‹œ ëŦģė§€ ė•ŠėŒ"; - readonly ru: "НĐĩŅ‚, и ĐŊĐĩ ҁĐŋŅ€Đ°ŅˆĐ¸Đ˛Đ°Ņ‚ŅŒ Đ´Đž ҁĐģĐĩĐ´ŅƒŅŽŅ‰ĐĩĐŗĐž Đ˛Ņ‹Đŋ҃ҁĐēа"; - readonly zh: "拒įģ,åšļä¸”į›´åˆ°ä¸‹ä¸Ēį‰ˆæœŦ前不再č¯ĸ问"; - }; - readonly "Doctor.Button.Fix": { - readonly def: "Fix it"; - readonly fr: "Corriger"; - readonly he: "×Ēקן"; - readonly ja: "äŋŽæ­Ŗã™ã‚‹"; - readonly ko: "ėˆ˜ė •"; - readonly ru: "Đ˜ŅĐŋŅ€Đ°Đ˛Đ¸Ņ‚ŅŒ"; - readonly zh: "äŋŽå¤"; - }; - readonly "Doctor.Button.FixButNoRebuild": { - readonly def: "Fix it but no rebuild"; - readonly fr: "Corriger mais sans reconstruction"; - readonly he: "×Ēקן ללא בנייה מחדש"; - readonly ja: "äŋŽæ­Ŗã™ã‚‹ãŒå†æ§‹į¯‰ã¯ã—ãĒい"; - readonly ko: "ėˆ˜ė •í•˜ė§€ë§Œ ėžŦęĩŦėļ•í•˜ė§€ ė•ŠėŒ"; - readonly ru: "Đ˜ŅĐŋŅ€Đ°Đ˛Đ¸Ņ‚ŅŒ ĐąĐĩС ĐŋĐĩŅ€ĐĩŅŅ‚Ņ€ĐžĐĩĐŊĐ¸Ņ"; - readonly zh: "äŋŽå¤äŊ†ä¸é‡åģē"; - }; - readonly "Doctor.Button.No": { - readonly def: "No"; - readonly fr: "Non"; - readonly he: "לא"; - readonly ja: "いいえ"; - readonly ko: "ė•„ë‹ˆėš”"; - readonly ru: "НĐĩŅ‚"; - readonly zh: "拒įģ"; - }; - readonly "Doctor.Button.Skip": { - readonly def: "Leave it as is"; - readonly fr: "Laisser tel quel"; - readonly he: "השאר כפי שהוא"; - readonly ja: "そぎぞぞãĢする"; - readonly ko: "그대로 두기"; - readonly ru: "ĐžŅŅ‚Đ°Đ˛Đ¸Ņ‚ŅŒ ĐēаĐē ĐĩŅŅ‚ŅŒ"; - readonly zh: "äŋæŒä¸å˜"; - }; - readonly "Doctor.Button.Yes": { - readonly def: "Yes"; - readonly fr: "Oui"; - readonly he: "כן"; - readonly ja: "はい"; - readonly ko: "똈"; - readonly ru: "Да"; - readonly zh: "įĄŽåŽš"; - }; - readonly "Doctor.Dialogue.Main": { - readonly def: "Hi! Config Doctor has been activated because of ${activateReason}!\nAnd, unfortunately some configurations were detected as potential problems.\nPlease be assured. Let's solve them one by one.\n\nTo let you know ahead of time, we will ask you about the following items.\n\n${issues}\n\nShall we get started?"; - readonly fr: "Bonjour ! Config Doctor a ÊtÊ activÊ en raison de ${activateReason} !\nEt, malheureusement, certaines configurations ont ÊtÊ dÊtectÊes comme des problèmes potentiels.\nPas d'inquiÊtude. RÊsolvons-les un par un.\n\nPour information, nous allons vous interroger sur les ÊlÊments suivants.\n\n${issues}\n\nVoulez-vous commencer ?"; - readonly he: "שלום! רופא ה×Ē×Ļורה הופ×ĸל בגלל ${activateReason}!\nולמרבה ה×Ļ×ĸר, זוהו ×Ē×Ļורו×Ē ×Š×ĸשויו×Ē ×œ×”×™×•×Ē ×‘×ĸיי×Ēיו×Ē.\nהיה רגו×ĸ/ה. בואו נפ×Ēור או×Ēן אח×Ē ××—×Ē.\n\nלידי×ĸ×Ēך מרא׊, נשאל או×Ēך ×ĸל הפריטים הבאים.\n\n${issues}\n\nהאם לה×Ēחיל?"; - readonly ja: "こんãĢãĄã¯īŧ${activateReason}ãŽãŸã‚ã€č¨­åŽšč¨ē断ツãƒŧãƒĢがčĩˇå‹•しぞしたīŧ\n掋åŋĩãĒãŒã‚‰ã€ã„ãã¤ã‹ãŽč¨­åŽšãŒæŊœåœ¨įš„ãĒå•éĄŒã¨ã—ãĻ検å‡ēされぞした。\nご厉åŋƒãã ã•ã„ã€‚ä¸€ã¤ãšã¤č§ŖæąēしãĻいきぞしょう。\n\näē‹å‰ãĢおįŸĨらせしぞすと、äģĨä¸‹ãŽé …į›ŽãĢついãĻお尋ねしぞす。\n\n${issues}\n\n始めãĻいいですかīŧŸ"; - readonly ko: "ė•ˆë…•í•˜ė„¸ėš”! ${activateReason} 로 ė¸í•´ ęĩŦė„ą ė§„ë‹¨ 마법ė‚Ŧ가 í™œė„ąí™”ë˜ė—ˆėŠĩ니다!\n꡸ëĻŦęŗ  ėŧëļ€ ęĩŦė„ąė´ ėž ėžŦė ė¸ ëŦ¸ė œëĄœ ę°ė§€ë˜ė—ˆėŠĩ니다.\nė•ˆė‹Ŧí•˜ė„¸ėš”. í•˜ë‚˜ė”Š 해결해 ë´…ė‹œë‹¤.\n\nëŒ€ėƒ 항ëĒŠė€ ë‹¤ėŒęŗŧ 같ėŠĩ니다.\n\n${issues}\n\nė‹œėž‘í•˜ė‹œę˛ ėŠĩ니까?"; - readonly ru: "ĐŸŅ€Đ¸Đ˛ĐĩŅ‚! Đ”Đ¸Đ°ĐŗĐŊĐžŅŅ‚Đ¸Đēа ĐŊĐ°ŅŅ‚Ņ€ĐžĐĩĐē аĐēŅ‚Đ¸Đ˛Đ¸Ņ€ĐžĐ˛Đ°ĐŊа иС-Са activateReason!\nК ŅĐžĐļаĐģĐĩĐŊĐ¸ŅŽ, ĐŊĐĩĐēĐžŅ‚ĐžŅ€Ņ‹Đĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи ĐąŅ‹Đģи ОйĐŊĐ°Ņ€ŅƒĐļĐĩĐŊŅ‹ ĐēаĐē ĐŋĐžŅ‚ĐĩĐŊŅ†Đ¸Đ°ĐģҌĐŊŅ‹Đĩ ĐŋŅ€ĐžĐąĐģĐĩĐŧŅ‹.\nНĐĩ вОĐģĐŊŅƒĐšŅ‚ĐĩҁҌ. Đ”Đ°Đ˛Đ°ĐšŅ‚Đĩ Ņ€ĐĩŅˆĐ¸Đŧ Đ¸Ņ… ĐŋĐž ĐžŅ‡ĐĩŅ€Đĩди.\n\nĐĄĐžĐžĐąŅ‰Đ°ĐĩĐŧ ваĐŧ ĐˇĐ°Ņ€Đ°ĐŊĐĩĐĩ, ĐŧŅ‹ ҁĐŋŅ€ĐžŅĐ¸Đŧ Đž ҁĐģĐĩĐ´ŅƒŅŽŅ‰Đ¸Ņ… Đŋ҃ĐŊĐēŅ‚Đ°Ņ….\n\nissues\n\nĐĐ°Ņ‡ĐŊŅ‘Đŧ?"; - readonly zh: "您åĨŊīŧé…įŊŽåŒģį”Ÿåˇ˛æ šæŽæ‚¨įš„čĻæą‚å¯åŠ¨(感č°ĸ您)īŧīŧé—æ†žįš„æ˜¯,æŖ€æĩ‹åˆ°éƒ¨åˆ†é…įŊŽå­˜åœ¨æŊœåœ¨é—Žéĸ˜ã€‚č¯ˇæ”žåŋƒ,我äģŦ将逐䏀觪冺čŋ™äē›é—Žéĸ˜ã€‚\n\n提前告įŸĨ您,我äģŦå°†å°ąäģĨ下äē‹éĄščŋ›čĄŒįĄŽčޤīŧš\n\nä¸ēæ•°æŽå—čŽĄįŽ—äŋŽčŽĸį‰ˆæœŦ(æ­¤å‰čĄŒä¸ē)\nåĸžåŧē块大小\n\n我äģŦåŧ€å§‹å¤„ᐆ吗?"; - }; - readonly "Doctor.Dialogue.MainFix": { - readonly def: "\n## ${name}\n\n| Current | Ideal |\n|:---:|:---:|\n| ${current} | ${ideal} |\n\n**Recommendation Level:** ${level}\n\n### Why this has been detected?\n\n${reason}\n\n${note}\n\nFix this to the ideal value?"; - readonly fr: "\n## ${name}\n\n| Actuel | IdÊal |\n|:---:|:---:|\n| ${current} | ${ideal} |\n\n**Niveau de recommandation :** ${level}\n\n### Pourquoi ceci a-t-il ÊtÊ dÊtectÊ ?\n\n${reason}\n\n${note}\n\nCorriger à la valeur idÊale ?"; - readonly he: "\n## ${name}\n\n| נוכחי | אידאלי |\n|:---:|:---:|\n| ${current} | ${ideal} |\n\n**רמ×Ē ×”×ž×œ×Ļה:** ${level}\n\n### מדו×ĸ זה זוהה?\n\n${reason}\n\n${note}\n\nל×Ēקן ל×ĸרך האידאלי?"; - readonly ja: "\n## ${name}\n\n| įžåœ¨ãŽå€¤ | į†æƒŗå€¤ |\n|:---:|:---:|\n| ${current} | ${ideal} |\n\n**推åĨ¨ãƒŦベãƒĢ:** ${level}\n\n### č¨ēæ–­į†į”ąīŧŸ\n\n${reason}\n\n${note}\n\nã“ã‚Œã‚’į†æƒŗå€¤ãĢäŋŽæ­Ŗã—ぞすかīŧŸ"; - readonly ko: "**ęĩŦė„ą ė´ëĻ„:** `${name}`\n**현ėžŦ 값:** `${current}`, **ė´ėƒė ė¸ 값:** `${ideal}`\n**ęļŒėžĨ ėˆ˜ė¤€:** ${level}\n**ė™œ ė´ę˛ƒė´ ę°ė§€ë˜ė—ˆë‚˜ėš”?**\n${reason}\n\n\n${note}\n\nė´ėƒė ė¸ 값ėœŧ로 ėˆ˜ė •í•˜ė‹œę˛ ėŠĩ니까?"; - readonly ru: "name\n\n| ĐĸĐĩĐēŅƒŅ‰ĐĩĐĩ | ИдĐĩаĐģҌĐŊĐžĐĩ |\n|:---:|:---:|\n| current | ideal |\n\n**ĐŖŅ€ĐžĐ˛ĐĩĐŊҌ Ņ€ĐĩĐēĐžĐŧĐĩĐŊĐ´Đ°Ņ†Đ¸Đ¸:** level\n\n### ĐŸĐžŅ‡ĐĩĐŧ҃ ŅŅ‚Đž ĐąŅ‹ĐģĐž ОйĐŊĐ°Ņ€ŅƒĐļĐĩĐŊĐž?\n\nreason\n\nnote\n\nĐ˜ŅĐŋŅ€Đ°Đ˛Đ¸Ņ‚ŅŒ ĐŊа идĐĩаĐģҌĐŊĐžĐĩ СĐŊĐ°Ņ‡ĐĩĐŊиĐĩ?"; - readonly zh: "\n## ${name}\n\n| Current | Ideal |\n|:---:|:---:|\n| ${current} | ${ideal} |\n\n**Recommendation Level:** ${level}\n\n### Why this has been detected?\n\n${reason}\n\n${note}\n\nFix this to the ideal value?"; - }; - readonly "Doctor.Dialogue.Title": { - readonly def: "Self-hosted LiveSync Config Doctor"; - readonly fr: "Docteur Config Self-hosted LiveSync"; - readonly he: "Self-hosted LiveSync Config Doctor"; - readonly ja: "Self-hosted LiveSync č¨­åŽšč¨ē断ツãƒŧãƒĢ"; - readonly ko: "Self-hosted LiveSync ęĩŦė„ą ė§„ë‹¨ 마법ė‚Ŧ"; - readonly ru: "Đ”Đ¸Đ°ĐŗĐŊĐžŅŅ‚Đ¸Đēа Self-hosted LiveSync"; - readonly zh: "Self-hosted LiveSync 配įŊŽč¯Šæ–­"; - }; - readonly "Doctor.Dialogue.TitleAlmostDone": { - readonly def: "Almost done!"; - readonly fr: "Presque terminÊ !"; - readonly he: "כמ×ĸט סיימנו!"; - readonly ja: "あと少しですīŧ"; - readonly ko: "ęą°ė˜ ė™„ëŖŒë˜ė—ˆėŠĩ니다!"; - readonly ru: "ĐŸĐžŅ‡Ņ‚Đ¸ ĐŗĐžŅ‚ĐžĐ˛Đž!"; - readonly zh: "全部厌成!"; - }; - readonly "Doctor.Dialogue.TitleFix": { - readonly def: "Fix issue ${current}/${total}"; - readonly fr: "Corriger le problème ${current}/${total}"; - readonly he: "×Ēקן ב×ĸיה ${current}/${total}"; - readonly ja: "å•éĄŒãŽäŋŽæ­Ŗ ${current}/${total}"; - readonly ko: "ëŦ¸ė œ 해결 ${current}/${total}"; - readonly ru: "Đ˜ŅĐŋŅ€Đ°Đ˛ĐģĐĩĐŊиĐĩ ĐŋŅ€ĐžĐąĐģĐĩĐŧŅ‹ current/total"; - readonly zh: "äŋŽå¤é—Žéĸ˜ ${current}/${total}"; - }; - readonly "Doctor.Level.Must": { - readonly def: "Must"; - readonly fr: "Obligatoire"; - readonly he: "חובה"; - readonly ja: "åŋ…é ˆ"; - readonly ko: "í•„ėˆ˜"; - readonly ru: "ĐžĐąŅĐˇĐ°Ņ‚ĐĩĐģҌĐŊĐž"; - readonly zh: "åŋ…éĄģ"; - }; - readonly "Doctor.Level.Necessary": { - readonly def: "Necessary"; - readonly fr: "NÊcessaire"; - readonly he: "נדרש"; - readonly ja: "åŋ…čρ"; - readonly ko: "í•„ėˆ˜"; - readonly ru: "НĐĩĐžĐąŅ…ĐžĐ´Đ¸ĐŧĐž"; - readonly zh: "åŋ…čρ"; - }; - readonly "Doctor.Level.Optional": { - readonly def: "Optional"; - readonly fr: "Optionnel"; - readonly he: "אופ×Ļיונלי"; - readonly ja: "äģģæ„"; - readonly ko: "ė„ íƒė‚Ŧ항"; - readonly ru: "ОĐŋŅ†Đ¸ĐžĐŊаĐģҌĐŊĐž"; - readonly zh: "可选"; - }; - readonly "Doctor.Level.Recommended": { - readonly def: "Recommended"; - readonly fr: "RecommandÊ"; - readonly he: "מומל×Ĩ"; - readonly ja: "推åĨ¨"; - readonly ko: "ęļŒėžĨ"; - readonly ru: "Đ ĐĩĐēĐžĐŧĐĩĐŊĐ´ŅƒĐĩŅ‚ŅŅ"; - readonly zh: "æŽ¨č"; - }; - readonly "Doctor.Message.NoIssues": { - readonly def: "No issues detected!"; - readonly fr: "Aucun problème dÊtectÊ !"; - readonly he: "לא זוהו ב×ĸיו×Ē!"; - readonly ja: "å•éĄŒã¯æ¤œå‡ēされぞせんでしたīŧ"; - readonly ko: "ëŦ¸ė œę°€ ę°ė§€ë˜ė§€ ė•Šė•˜ėŠĩ니다!"; - readonly ru: "ĐŸŅ€ĐžĐąĐģĐĩĐŧ ĐŊĐĩ ОйĐŊĐ°Ņ€ŅƒĐļĐĩĐŊĐž!"; - readonly zh: "æœĒå‘įŽ°é—Žéĸ˜!"; - }; - readonly "Doctor.Message.RebuildLocalRequired": { - readonly def: "Attention! A local database rebuild is required to apply this!"; - readonly fr: "Attention ! Une reconstruction de la base locale est requise pour appliquer ceci !"; - readonly he: "שים לב! נדרש×Ē ×‘× ×™×™×” מחדש של מסד הנ×Ēונים המקומי כדי להחיל זא×Ē!"; - readonly ja: "æŗ¨æ„īŧã“ã‚Œã‚’éŠį”¨ã™ã‚‹ãĢはロãƒŧã‚ĢãƒĢデãƒŧã‚ŋベãƒŧã‚šãŽå†æ§‹į¯‰ãŒåŋ…čĻã§ã™īŧ"; - readonly ko: "ėŖŧė˜! ė´ëĨŧ ė ėšŠí•˜ë ¤ëŠ´ 로ėģŦ ë°ė´í„°ë˛ ė´ėŠ¤ ėžŦęĩŦėļ•ė´ í•„ėš”í•Šë‹ˆë‹¤!"; - readonly ru: "ВĐŊиĐŧаĐŊиĐĩ! ДĐģŅ ĐŋŅ€Đ¸ĐŧĐĩĐŊĐĩĐŊĐ¸Ņ ҂ҀĐĩĐąŅƒĐĩŅ‚ŅŅ ĐŋĐĩŅ€ĐĩŅŅ‚Ņ€ĐžĐĩĐŊиĐĩ ĐģĐžĐēаĐģҌĐŊОК ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅!"; - readonly zh: "æŗ¨æ„īŧéœ€čĻé‡åģ翜Ŧ地数捎åē“äģĨåē”į”¨æ­¤éĄšīŧ"; - }; - readonly "Doctor.Message.RebuildRequired": { - readonly def: "Attention! A rebuild is required to apply this!"; - readonly fr: "Attention ! Une reconstruction est requise pour appliquer ceci !"; - readonly he: "שים לב! נדרש×Ē ×‘× ×™×™×” מחדש כדי להחיל זא×Ē!"; - readonly ja: "æŗ¨æ„īŧã“ã‚Œã‚’éŠį”¨ã™ã‚‹ãĢã¯å†æ§‹į¯‰ãŒåŋ…čĻã§ã™īŧ"; - readonly ko: "ėŖŧė˜! ė´ëĨŧ ė ėšŠí•˜ë ¤ëŠ´ ėžŦęĩŦėļ•ė´ í•„ėš”í•Šë‹ˆë‹¤!"; - readonly ru: "ВĐŊиĐŧаĐŊиĐĩ! ДĐģŅ ĐŋŅ€Đ¸ĐŧĐĩĐŊĐĩĐŊĐ¸Ņ ҂ҀĐĩĐąŅƒĐĩŅ‚ŅŅ ĐŋĐĩŅ€ĐĩŅŅ‚Ņ€ĐžĐĩĐŊиĐĩ!"; - readonly zh: "æŗ¨æ„īŧéœ€čĻé‡åģ翉čƒŊåē”į”¨æ­¤éĄšīŧ"; - }; - readonly "Doctor.Message.SomeSkipped": { - readonly def: "We left some issues as is. Shall I ask you again on next startup?"; - readonly fr: "Nous avons laissÊ certains problèmes en l'Êtat. Dois-je vous demander à nouveau au prochain dÊmarrage ?"; - readonly he: "השארנו כמה ב×ĸיו×Ē ×›×¤×™ שהן. האם לשאול שוב בהפ×ĸלה הבאה?"; - readonly ja: "ã„ãã¤ã‹ãŽå•éĄŒã‚’ããŽãžãžãĢしぞした。æŦĄå›žčĩˇå‹•時ãĢ再åēĻįĸēčĒã—ãžã™ã‹īŧŸ"; - readonly ko: "ėŧëļ€ ëŦ¸ė œëĨŧ 그대로 ë‘ė—ˆėŠĩ니다. ë‹¤ėŒ ė‹œėž‘ ė‹œ ë‹¤ė‹œ 마ëŦ¸í• ęšŒėš”?"; - readonly ru: "НĐĩĐēĐžŅ‚ĐžŅ€Ņ‹Đĩ ĐŋŅ€ĐžĐąĐģĐĩĐŧŅ‹ ĐžŅŅ‚Đ°Đ˛ĐģĐĩĐŊŅ‹ ĐēаĐē ĐĩŅŅ‚ŅŒ. ĐĄĐŋŅ€ĐžŅĐ¸Ņ‚ŅŒ ҁĐŊОва ĐŋŅ€Đ¸ ҁĐģĐĩĐ´ŅƒŅŽŅ‰ĐĩĐŧ СаĐŋ҃ҁĐēĐĩ?"; - readonly zh: "我äģŦ将某äē›é—Žéĸ˜į•™įģ™äē†äģĨåŽå¤„į†ã€‚æ˜¯åĻčĻåœ¨ä¸‹æŦĄå¯åЍæ—ļ再æŦĄč¯ĸ闎您īŧŸ"; - }; - readonly "Doctor.RULES.E2EE_V02500.REASON": { - readonly def: "The End-to-End Encryption has got now more robust and faster. Also because, the previous E2EE was found to be compromised in a re-conducted code review. It should be applied as soon as possible. Really apologises for your inconvenience. And, this setting is not forward compatible. All synchronised devices must be updated to v0.25.0 or higher. Rebuilds are not required and will be converted from the new transfer to the new format, However, it is recommended to rebuild whenever possible."; - readonly fr: "Le chiffrement de bout en bout est dÊsormais plus robuste et plus rapide. Aussi parce que le prÊcÊdent E2EE s'est rÊvÊlÊ compromis lors d'une nouvelle revue de code. Il doit ÃĒtre appliquÊ dès que possible. Nous nous excusons sincèrement pour la gÃĒne occasionnÊe. Ce paramètre n'est pas compatible avec les versions antÊrieures. Tous les appareils synchronisÊs doivent ÃĒtre mis à jour en v0.25.0 ou supÊrieur. Les reconstructions ne sont pas requises et seront converties du nouveau transfert vers le nouveau format. Il est toutefois recommandÊ de reconstruire dans la mesure du possible."; - readonly he: "הה×Ļפנה מק×Ļה לק×Ļה ×ĸודכנה לגרסה חזקה ומהירה יו×Ēר. ×‘× ×•×Ą×Ŗ, בסקיר×Ē ×§×•×“ ׊נ×ĸרכה מחדש הוסבר שהה×Ļפנה הקודמ×Ē × ×¤×¨×Ļ×Ē. יש להחיל זא×Ē ×‘×”×§×“× האפשרי. מ×Ē× ×Ļלים ×ĸל אי הנוחו×Ē. שים לב שהגדרה זו אינה ×Ēואמ×Ē ×œ××—×•×¨. כל המכשירים המסונכרנים חייבים להיו×Ē ×ž×ĸודכנים לגרסה 0.25.0 ומ×ĸלה. אין ×Ļורך בבנייה מחדש והנ×Ēונים יומרו בהדרגה לפורמט החדש, אך מומל×Ĩ לבנו×Ē ×ž×—×“×Š בהזדמנו×Ē ×”×¨××Š×•× ×”."; - readonly ja: "ã‚¨ãƒŗãƒ‰ãƒ„ãƒŧã‚¨ãƒŗãƒ‰æš—åˇåŒ–ãŒã‚ˆã‚Šå …į‰ĸでéĢ˜é€ŸãĢãĒりぞした。ぞた、äģĨ前ぎE2EEã¯å†ã‚ŗãƒŧドãƒŦビãƒĨãƒŧãĢã‚ˆã‚Šč„†åŧ࿀§ãŒį™ēčĻ‹ã•ã‚Œãžã—ãŸã€‚ã§ãã‚‹ã ã‘æ—ŠãéŠį”¨ã™ã‚‹ã“ã¨ã‚’ãŠå‹§ã‚ã—ãžã™ã€‚ã”ä¸äžŋをおかけしãĻį”ŗã—č¨ŗã‚ã‚Šãžã›ã‚“ã€‚ãžãŸã€ã“ãŽč¨­åŽšã¯ä¸‹äŊäē’æ›æ€§ãŒã‚りぞせん。すずãĻぎ同期デバイ゚をv0.25.0äģĨ降ãĢã‚ĸップデãƒŧトするåŋ…čĻãŒã‚ã‚Šãžã™ã€‚å†æ§‹į¯‰ã¯åŋ…須ではãĒく、新しいčģĸ送から新しいフりãƒŧマットãĢ変換されぞすが、可čƒŊãĒé™ã‚Šå†æ§‹į¯‰ã‚’ãŠå‹§ã‚ã—ãžã™ã€‚"; - readonly ru: "ĐĄĐēвОСĐŊĐžĐĩ ŅˆĐ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊиĐĩ ŅŅ‚Đ°ĐģĐž йОĐģĐĩĐĩ ĐŊĐ°Đ´Ņ‘ĐļĐŊŅ‹Đŧ и ĐąŅ‹ŅŅ‚Ņ€Ņ‹Đŧ. ĐŸŅ€ĐĩĐ´Ņ‹Đ´ŅƒŅ‰ĐĩĐĩ E2EE ĐąŅ‹ĐģĐž ҁĐēĐžĐŧĐŋŅ€ĐžĐŧĐĩŅ‚Đ¸Ņ€ĐžĐ˛Đ°ĐŊĐž. ĐĄĐģĐĩĐ´ŅƒĐĩŅ‚ ĐŋŅ€Đ¸ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ ĐēаĐē ĐŧĐžĐļĐŊĐž ҁĐēĐžŅ€ĐĩĐĩ."; - readonly zh: "The End-to-End Encryption has got now more robust and faster. Also because, the previous E2EE was found to be compromised in a re-conducted code review. It should be applied as soon as possible. Really apologises for your inconvenience. And, this setting is not forward compatible. All synchronised devices must be updated to v0.25.0 or higher. Rebuilds are not required and will be converted from the new transfer to the new format, However, it is recommended to rebuild whenever possible."; - }; - readonly "Document History": { - readonly def: "Document History"; - readonly "zh-tw": "文äģļæ­ˇį¨‹"; - }; - readonly Duplicate: { - readonly def: "Duplicate"; - readonly es: "Duplicar"; - readonly ja: "複čŖŊ"; - readonly ko: "ëŗĩ렜"; - readonly ru: "Đ”ŅƒĐąĐģĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ"; - readonly zh: "复åˆļ"; - readonly "zh-tw": "複čŖŊ"; - }; - readonly "Duplicate remote": { - readonly def: "Duplicate remote"; - readonly es: "Duplicar remoto"; - readonly ja: "ãƒĒãƒĸãƒŧãƒˆč¨­åŽšã‚’č¤‡čŖŊ"; - readonly ko: "ė›ę˛Š ęĩŦė„ą ëŗĩ렜"; - readonly ru: "Đ”ŅƒĐąĐģĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊŅƒŅŽ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸ŅŽ"; - readonly zh: "复åˆļčŋœįĢ¯é…įŊŽ"; - readonly "zh-tw": "複čŖŊ遠įĢ¯č¨­åŽš"; - }; - readonly "E2EE Configuration": { - readonly def: "E2EE Configuration"; - readonly es: "ConfiguraciÃŗn de E2EE"; - readonly ja: "E2EE č¨­åŽš"; - readonly ko: "E2EE ęĩŦė„ą"; - readonly ru: "КоĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Ņ ҁĐēвОСĐŊĐžĐŗĐž ŅˆĐ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊĐ¸Ņ"; - readonly zh: "E2EE 配įŊŽ"; - readonly "zh-tw": "E2EE č¨­åŽš"; - }; - readonly "Edge case addressing (Behaviour)": { - readonly def: "Edge case addressing (Behaviour)"; - readonly es: "Tratamiento de casos límite (comportamiento)"; - readonly ja: "į‰šæŽŠãĒã‚ąãƒŧ゚へぎ寞åŋœīŧˆå‹•äŊœīŧ‰"; - readonly ko: "íŠšėˆ˜ ėƒí™Š 래ëĻŦ (ë™ėž‘)"; - readonly ru: "ĐžĐąŅ€Đ°ĐąĐžŅ‚Đēа ĐžŅĐžĐąŅ‹Ņ… ҁĐģŅƒŅ‡Đ°Đĩв (ĐŋОвĐĩĐ´ĐĩĐŊиĐĩ)"; - readonly zh: "čžšįŧ˜æƒ…å†ĩ处ᐆīŧˆčĄŒä¸ēīŧ‰"; - readonly "zh-tw": "é‚ŠįˇŖæƒ…æŗč™•į†īŧˆčĄŒį‚ēīŧ‰"; - }; - readonly "Edge case addressing (Database)": { - readonly def: "Edge case addressing (Database)"; - readonly es: "Tratamiento de casos límite (base de datos)"; - readonly ja: "į‰šæŽŠãĒã‚ąãƒŧ゚へぎ寞åŋœīŧˆãƒ‡ãƒŧã‚ŋベãƒŧ゚īŧ‰"; - readonly ko: "íŠšėˆ˜ ėƒí™Š 래ëĻŦ (ë°ė´í„°ë˛ ė´ėŠ¤)"; - readonly ru: "ĐžĐąŅ€Đ°ĐąĐžŅ‚Đēа ĐžŅĐžĐąŅ‹Ņ… ҁĐģŅƒŅ‡Đ°Đĩв (йаСа даĐŊĐŊҋ҅)"; - readonly zh: "čžšįŧ˜æƒ…å†ĩ处ᐆīŧˆæ•°æŽåē“īŧ‰"; - readonly "zh-tw": "é‚ŠįˇŖæƒ…æŗč™•į†īŧˆčŗ‡æ–™åēĢīŧ‰"; - }; - readonly "Edge case addressing (Processing)": { - readonly def: "Edge case addressing (Processing)"; - readonly es: "Tratamiento de casos límite (procesamiento)"; - readonly ja: "į‰šæŽŠãĒã‚ąãƒŧ゚へぎ寞åŋœīŧˆå‡Ļᐆīŧ‰"; - readonly ko: "íŠšėˆ˜ ėƒí™Š 래ëĻŦ (래ëĻŦ)"; - readonly ru: "ĐžĐąŅ€Đ°ĐąĐžŅ‚Đēа ĐžŅĐžĐąŅ‹Ņ… ҁĐģŅƒŅ‡Đ°Đĩв (ĐžĐąŅ€Đ°ĐąĐžŅ‚Đēа)"; - readonly zh: "čžšįŧ˜æƒ…å†ĩ处ᐆīŧˆå¤„ᐆīŧ‰"; - readonly "zh-tw": "é‚ŠįˇŖæƒ…æŗč™•į†īŧˆč™•ᐆīŧ‰"; - }; - readonly "Emergency restart": { - readonly def: "Emergency restart"; - readonly es: "Reinicio de emergencia"; - readonly ja: "ᎊæ€Ĩ再čĩˇå‹•"; - readonly ko: "긴급 ėžŦė‹œėž‘"; - readonly ru: "ĐĐ˛Đ°Ņ€Đ¸ĐšĐŊŅ‹Đš ĐŋĐĩŅ€ĐĩСаĐŋ҃ҁĐē"; - readonly zh: "į´§æ€Ĩ重启"; - readonly "zh-tw": "ᎊæ€Ĩ重新啟動"; - }; - readonly "Enable advanced features": { - readonly def: "Enable advanced features"; - readonly es: "Habilitar características avanzadas"; - readonly fr: "Activer les fonctionnalitÊs avancÊes"; - readonly he: "הפ×ĸל ×Ēכונו×Ē ×ž×Ēקדמו×Ē"; - readonly ja: "é̘åēĻãĒ抟čƒŊを有劚ãĢする"; - readonly ko: "溠揉 기ëŠĨ í™œė„ąí™”"; - readonly ru: "ВĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒ Ņ€Đ°ŅŅˆĐ¸Ņ€ĐĩĐŊĐŊŅ‹Đĩ Ņ„ŅƒĐŊĐēŅ†Đ¸Đ¸"; - readonly zh: "吝ᔍé̘įē§åŠŸčƒŊ"; - readonly "zh-tw": "å•Ÿį”¨é€˛éšŽåŠŸčƒŊ"; - }; - readonly "Enable customization sync": { - readonly def: "Enable customization sync"; - readonly es: "Habilitar sincronizaciÃŗn de personalizaciÃŗn"; - readonly fr: "Activer la synchronisation de personnalisation"; - readonly he: "הפ×ĸל סנכרון ה×Ēאמה אישי×Ē"; - readonly ja: "ã‚Ģ゚ã‚ŋマイã‚ē同期を有劚"; - readonly ko: "ė‚ŦėšŠėž 네렕 동기화 í™œė„ąí™”"; - readonly ru: "ВĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸ŅŽ ĐŊĐ°ŅŅ‚Ņ€ĐžĐĩĐē"; - readonly zh: "吝ᔍč‡Ē厚䚉同æ­Ĩ"; - readonly "zh-tw": "å•Ÿį”¨č‡Ēč¨‚åŒæ­Ĩ"; - }; - readonly "Enable Developers' Debug Tools.": { - readonly def: "Enable Developers' Debug Tools."; - readonly es: "Habilitar herramientas de depuraciÃŗn"; - readonly fr: "Activer les outils de dÊbogage pour dÊveloppeurs."; - readonly he: "הפ×ĸל כלי ניפוי באגים למפ×Ēחים."; - readonly ja: "開į™ēč€…į”¨ãƒ‡ãƒãƒƒã‚°ãƒ„ãƒŧãƒĢを有劚ãĢする"; - readonly ko: "ę°œë°œėž 디버그 도ęĩŦ í™œė„ąí™”"; - readonly ru: "ВĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒ иĐŊŅŅ‚Ņ€ŅƒĐŧĐĩĐŊ҂ҋ Ņ€Đ°ĐˇŅ€Đ°ĐąĐžŅ‚Ņ‡Đ¸Đēа."; - readonly zh: "吝ᔍåŧ€å‘č€…č°ƒč¯•åˇĨå…ˇ "; - readonly "zh-tw": "å•Ÿį”¨é–‹į™ŧč€…é™¤éŒ¯åˇĨå…ˇã€‚"; - }; - readonly "Enable edge case treatment features": { - readonly def: "Enable edge case treatment features"; - readonly es: "Habilitar manejo de casos límite"; - readonly fr: "Activer les fonctionnalitÊs pour cas particuliers"; - readonly he: "הפ×ĸל ×Ēכונו×Ē ×œ×˜×™×¤×•×œ במקרי ×§×Ļה"; - readonly ja: "ã‚¨ãƒƒã‚¸ã‚ąãƒŧ゚寞åŋœæŠŸčƒŊを有劚ãĢする"; - readonly ko: "íŠšėˆ˜ ė‚Ŧ례 래ëĻŦ 기ëŠĨ í™œė„ąí™”"; - readonly ru: "ВĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒ Ņ„ŅƒĐŊĐēŅ†Đ¸Đ¸ ĐžĐąŅ€Đ°ĐąĐžŅ‚Đēи ĐŗŅ€Đ°ĐŊĐ¸Ņ‡ĐŊҋ҅ ҁĐģŅƒŅ‡Đ°Đĩв"; - readonly zh: "å¯į”¨čžšįŧ˜æƒ…å†ĩå¤„į†åŠŸčƒŊ"; - readonly "zh-tw": "å•Ÿį”¨é‚ŠįˇŖæƒ…æŗč™•į†åŠŸčƒŊ"; - }; - readonly "Enable poweruser features": { - readonly def: "Enable poweruser features"; - readonly es: "Habilitar funciones para usuarios avanzados"; - readonly fr: "Activer les fonctionnalitÊs pour utilisateurs avancÊs"; - readonly he: "הפ×ĸל ×Ēכונו×Ē ×œ×ž×Š×Ēמ׊ מ×Ēקדם"; - readonly ja: "エキ゚パãƒŧト抟čƒŊを有劚ãĢする"; - readonly ko: "íŒŒė›Œ ėœ ė € 기ëŠĨ í™œė„ąí™”"; - readonly ru: "ВĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒ Ņ„ŅƒĐŊĐēŅ†Đ¸Đ¸ Đ´ĐģŅ ĐžĐŋҋ҂ĐŊҋ҅ ĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ĐĩĐģĐĩĐš"; - readonly zh: "吝ᔍé̘įē§į”¨æˆˇåŠŸčƒŊ"; - readonly "zh-tw": "å•Ÿį”¨é€˛éšŽäŊŋį”¨č€…åŠŸčƒŊ"; - }; - readonly "Enable this if your Object Storage doesn't support CORS": { - readonly def: "Enable this if your Object Storage doesn't support CORS"; - readonly es: "Habilitar si su almacenamiento no soporta CORS"; - readonly fr: "Activer ceci si votre stockage objet ne prend pas en charge CORS"; - readonly he: "הפ×ĸל אם אחסון האובייקטים שלך לא ×Ēומך ב-CORS"; - readonly ja: "ã‚Ēブジェクト゚トãƒŦãƒŧジがCORSをã‚ĩポãƒŧトしãĻいãĒい場合は有劚ãĢしãĻください"; - readonly ko: "ę°ė˛´ ėŠ¤í† ëĻŦ맀氀 CORSëĨŧ ė§€ė›í•˜ė§€ ė•ŠëŠ” ę˛Ŋ뚰 í™œė„ąí™”í•˜ė„¸ėš”"; - readonly ru: "ВĐēĐģŅŽŅ‡Đ¸Ņ‚Đĩ, ĐĩҁĐģи Đ˛Đ°ŅˆĐĩ ĐžĐąŅŠĐĩĐēŅ‚ĐŊĐžĐĩ Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰Đĩ ĐŊĐĩ ĐŋОддĐĩŅ€ĐļиваĐĩŅ‚ CORS"; - readonly zh: "åĻ‚æžœæ‚¨įš„å¯ščąĄå­˜å‚¨ä¸æ”¯æŒ CORS,č¯ˇå¯į”¨æ­¤åŠŸčƒŊ "; - readonly "zh-tw": "åĻ‚æžœäŊ įš„į‰Šäģļå„˛å­˜ä¸æ”¯æ´ CORSīŧŒčĢ‹å•Ÿį”¨æ­¤é¸é …"; - }; - readonly "Enable this option to automatically apply the most recent change to documents even when it conflicts": { - readonly def: "Enable this option to automatically apply the most recent change to documents even when it conflicts"; - readonly es: "Aplicar cambios recientes automÃĄticamente aunque generen conflictos"; - readonly fr: "Activer cette option pour appliquer automatiquement la modification la plus rÊcente aux documents mÃĒme en cas de conflit"; - readonly he: "הפ×ĸל אפשרו×Ē ×–×• כדי להחיל אוטומטי×Ē ××Ē ×”×Š×™× ×•×™ האחרון במסמכים גם כשיש קונפליקט"; - readonly ja: "こぎã‚Ēãƒ—ã‚ˇãƒ§ãƒŗã‚’æœ‰åŠšãĢすると、įĢļåˆãŒã‚ãŖãĻも最新ぎ変更をč‡Ēå‹•įš„ãĢドキãƒĨãƒĄãƒŗãƒˆãĢéŠį”¨ã—ãžã™"; - readonly ko: "ė´ ė˜ĩė…˜ė„ í™œė„ąí™”í•˜ëŠ´ ėļŠëŒė´ ėžˆė–´ë„ ëŦ¸ė„œė— 가ėžĨ ėĩœęˇŧ ëŗ€ę˛Ŋ ė‚Ŧí•­ė„ ėžë™ėœŧ로 ė ėšŠí•Šë‹ˆë‹¤"; - readonly ru: "ВĐēĐģŅŽŅ‡Đ¸Ņ‚Đĩ ŅŅ‚Ņƒ ĐžĐŋŅ†Đ¸ŅŽ Đ´ĐģŅ Đ°Đ˛Ņ‚ĐžĐŧĐ°Ņ‚Đ¸Ņ‡ĐĩҁĐēĐžĐŗĐž ĐŋŅ€Đ¸ĐŧĐĩĐŊĐĩĐŊĐ¸Ņ ĐŋĐžŅĐģĐĩĐ´ĐŊĐ¸Ņ… иСĐŧĐĩĐŊĐĩĐŊиК Đē Đ´ĐžĐē҃ĐŧĐĩĐŊŅ‚Đ°Đŧ даĐļĐĩ ĐŋŅ€Đ¸ ĐēĐžĐŊŅ„ĐģиĐēŅ‚Đĩ"; - readonly zh: "å¯į”¨æ­¤é€‰éĄšå¯åœ¨æ–‡æĄŖå†˛įǁæ—ļč‡Ē动åē”į”¨æœ€æ–°įš„æ›´æ”š"; - readonly "zh-tw": "å•Ÿį”¨æ­¤é¸é …åžŒīŧŒåŗäŊŋį™ŧį”ŸčĄįĒäšŸæœƒč‡Ē動åĨ—į”¨æ–‡äģļįš„æœ€æ–°čŽŠæ›´"; - }; - readonly "Encrypt contents on the remote database. If you use the plugin's synchronization feature, enabling this is recommended.": { - readonly def: "Encrypt contents on the remote database. If you use the plugin's synchronization feature, enabling this is recommended."; - readonly es: "Cifrar contenido en la base de datos remota. Se recomienda habilitar si usa la sincronizaciÃŗn del plugin."; - readonly fr: "Chiffrer le contenu sur la base de donnÊes distante. Si vous utilisez la fonction de synchronisation du plugin, l'activation est recommandÊe."; - readonly he: "ה×Ļפן ×Ēוכן במסד הנ×Ēונים המרוחק. אם א×Ēה מ׊×Ēמ׊ ב×Ēכונ×Ē ×”×Ą× ×›×¨×•×Ÿ של ה×Ē×•×Ą×Ŗ, מומל×Ĩ להפ×ĸיל זא×Ē."; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧã‚šãŽæš—åˇåŒ–īŧˆã‚ĒãƒŗãĢすることを推åĨ¨īŧ‰"; - readonly ko: "ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ė˜ ë‚´ėšŠė„ ė•”í˜¸í™”í•Šë‹ˆë‹¤. 플ëŸŦęˇ¸ė¸ė˜ 동기화 기ëŠĨė„ ė‚ŦėšŠí•˜ëŠ” ę˛Ŋ뚰 í™œė„ąí™”ëĨŧ ęļŒėžĨ합니다."; - readonly ru: "Đ¨Đ¸Ņ„Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ ŅĐžĐ´ĐĩŅ€ĐļиĐŧĐžĐĩ ĐŊа ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊОК йаСĐĩ даĐŊĐŊҋ҅. Đ ĐĩĐēĐžĐŧĐĩĐŊĐ´ŅƒĐĩŅ‚ŅŅ вĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒ ĐŋŅ€Đ¸ Đ¸ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°ĐŊии Ņ„ŅƒĐŊĐēŅ†Đ¸Đ¸ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸ ĐŋĐģĐ°ĐŗĐ¸ĐŊа."; - readonly zh: "加密čŋœį¨‹æ•°æŽåē“ä¸­įš„å†…åŽšã€‚åĻ‚æžœæ‚¨äŊŋį”¨æ’äģļįš„åŒæ­Ĩ功čƒŊ,则åģēčŽŽå¯į”¨æ­¤åŠŸčƒŊ "; - readonly "zh-tw": "加密遠įĢ¯čŗ‡æ–™åēĢä¸­įš„å…§åŽšã€‚åĻ‚æžœäŊ äŊŋį”¨å¤–æŽ›įš„åŒæ­Ĩ功čƒŊīŧŒåģēč­°å•Ÿį”¨æ­¤é¸é …ã€‚"; - }; - readonly "Encrypting sensitive configuration items": { - readonly def: "Encrypting sensitive configuration items"; - readonly es: "Cifrando elementos sensibles"; - readonly fr: "Chiffrement des ÊlÊments de configuration sensibles"; - readonly he: "ה×Ļפן פריטי ×Ē×Ļורה רגישים"; - readonly ja: "抟密性ぎéĢ˜ã„č¨­åŽšé …į›ŽãŽæš—åˇåŒ–"; - readonly ko: "ë¯ŧ감한 ęĩŦė„ą 항ëĒŠ ė•”í˜¸í™”"; - readonly ru: "Đ¨Đ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊиĐĩ ĐēĐžĐŊŅ„Đ¸Đ´ĐĩĐŊŅ†Đ¸Đ°ĐģҌĐŊҋ҅ ĐŊĐ°ŅŅ‚Ņ€ĐžĐĩĐē"; - readonly zh: "加密敏感配įŊŽéĄš"; - readonly "zh-tw": "åŠ å¯†æ•æ„Ÿč¨­åŽšé …į›Ž"; - }; - readonly "Encryption phassphrase. If changed, you should overwrite the server's database with the new (encrypted) files.": { - readonly def: "Encryption phassphrase. If changed, you should overwrite the server's database with the new (encrypted) files."; - readonly es: "Frase de cifrado. Si la cambia, sobrescriba la base del servidor con los nuevos archivos cifrados."; - readonly fr: "Phrase secrète de chiffrement. Si modifiÊe, vous devriez Êcraser la base de donnÊes du serveur avec les nouveaux fichiers (chiffrÊs)."; - readonly he: "ביטוי סיסמה לה×Ļפנה. אם שונה, יש לדרוס א×Ē ×ž×Ą×“ הנ×Ēונים של השר×Ē ×‘×§×‘×Ļים החדשים (המו×Ļפנים)."; - readonly ja: "æš—åˇåŒ–ãƒ‘ã‚šãƒ•ãƒŦãƒŧã‚ē。変更した場合、新しいīŧˆæš—åˇåŒ–ã•ã‚ŒãŸīŧ‰ãƒ•ã‚Ąã‚¤ãƒĢでã‚ĩãƒŧバãƒŧぎデãƒŧã‚ŋベãƒŧ゚を上書きするåŋ…čĻãŒã‚ã‚Šãžã™ã€‚"; - readonly ko: "íŒ¨ėŠ¤í”„ë ˆė´ėĻˆëŠ” ė•”í˜¸í™”ė— ė‚ŦėšŠë˜ëŠ” 긴 ė•”í˜¸ ëŦ¸ęĩŦėž…ë‹ˆë‹¤. ëŗ€ę˛Ŋ한 ę˛Ŋ뚰, ė•”í˜¸í™”ëœ 냈 파ėŧ로 ė„œë˛„ė˜ ë°ė´í„°ë˛ ė´ėŠ¤ëĨŧ ëŽė–´ė¨ė•ŧ 합니다."; - readonly ru: "ĐŸĐ°Ņ€ĐžĐģҌĐŊĐ°Ņ Ņ„Ņ€Đ°ĐˇĐ° ŅˆĐ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊĐ¸Ņ. ĐŸŅ€Đ¸ иСĐŧĐĩĐŊĐĩĐŊии ĐŊ҃ĐļĐŊĐž ĐŋĐĩŅ€ĐĩСаĐŋĐ¸ŅĐ°Ņ‚ŅŒ ĐąĐ°ĐˇŅƒ даĐŊĐŊҋ҅ ҁĐĩŅ€Đ˛ĐĩŅ€Đ° ĐŊĐžĐ˛Ņ‹Đŧи (ĐˇĐ°ŅˆĐ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊĐŊŅ‹Đŧи) Ņ„Đ°ĐšĐģаĐŧи."; - readonly zh: "åŠ å¯†å¯†į ã€‚åĻ‚æžœæ›´æ”š,您åē”č¯Ĩį”¨æ–°įš„(åŠ å¯†įš„)文äģļčĻ†į›–æœåŠĄå™¨įš„æ•°æŽåē“ "; - readonly "zh-tw": "加密密čĒžã€‚åĻ‚æžœčŽŠæ›´äē†åރīŧŒäŊ æ‡‰äģĨæ–°įš„īŧˆåˇ˛åР坆īŧ‰æĒ”æĄˆčφå¯Ģäŧ翜å™¨ä¸Šįš„čŗ‡æ–™åēĢ。"; - }; - readonly "End-to-End Encryption": { - readonly def: "End-to-End Encryption"; - readonly es: "Cifrado de extremo a extremo"; - readonly fr: "Chiffrement de bout en bout"; - readonly he: "ה×Ļפנה מק×Ļה לק×Ļה"; - readonly ja: "E2Eæš—åˇåŒ–"; - readonly ko: "ėĸ…단간 ė•”í˜¸í™”"; - readonly ru: "ĐĄĐēвОСĐŊĐžĐĩ ŅˆĐ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊиĐĩ"; - readonly zh: "į̝到įĢ¯åŠ å¯†"; - readonly "zh-tw": "įĢ¯å°įĢ¯åŠ å¯†"; - }; - readonly "Endpoint URL": { - readonly def: "Endpoint URL"; - readonly es: "URL del endpoint"; - readonly fr: "URL du point de terminaison"; - readonly he: "כ×Ēוב×Ē × ×§×•×“×Ē ×§×Ļה (Endpoint URL)"; - readonly ja: "ã‚¨ãƒŗãƒ‰ãƒã‚¤ãƒŗãƒˆURL"; - readonly ko: "ė—”ë“œíŦė¸íŠ¸ URL"; - readonly ru: "URL ĐēĐžĐŊĐĩ҇ĐŊОК Ņ‚ĐžŅ‡Đēи"; - readonly zh: "įģˆį̝URL"; - readonly "zh-tw": "į̝éģž URL"; - }; - readonly "Enhance chunk size": { - readonly def: "Enhance chunk size"; - readonly es: "Mejorar tamaÃąo de chunks"; - readonly fr: "AmÊliorer la taille des fragments"; - readonly he: "הגדל גודל × ×Ēח"; - readonly ja: "ãƒãƒŖãƒŗã‚¯ã‚ĩイã‚ēを最遊化する"; - readonly ko: "랭íŦ íŦ기 í–Ĩ냁"; - readonly ru: "ĐŖĐģŅƒŅ‡ŅˆĐ¸Ņ‚ŅŒ Ņ€Đ°ĐˇĐŧĐĩŅ€ Ņ‡Đ°ĐŊĐēа"; - readonly zh: "åĸžå¤§å—大小"; - readonly "zh-tw": "擴大 chunk 大小"; - }; - readonly "Enter Server Information": { - readonly def: "Enter Server Information"; - readonly es: "Introducir informaciÃŗn del servidor"; - readonly ja: "ã‚ĩãƒŧバãƒŧæƒ…å ąãŽå…Ĩ力"; - readonly ko: "ė„œë˛„ ė •ëŗ´ ėž…ë Ĩ"; - readonly ru: "ВвĐĩŅŅ‚Đ¸ даĐŊĐŊŅ‹Đĩ ҁĐĩŅ€Đ˛ĐĩŅ€Đ°"; - readonly zh: "输å…ĨæœåŠĄå™¨äŋĄæ¯"; - readonly "zh-tw": "čŧ¸å…Ĩäŧ翜å™¨čŗ‡č¨Š"; - }; - readonly "Enter the server information manually": { - readonly def: "Enter the server information manually"; - readonly es: "Introducir manualmente la informaciÃŗn del servidor"; - readonly ja: "ã‚ĩãƒŧバãƒŧæƒ…å ąã‚’æ‰‹å‹•ã§å…Ĩ力する"; - readonly ko: "ė„œë˛„ ė •ëŗ´ëĨŧ ėˆ˜ë™ėœŧ로 ėž…ë Ĩ"; - readonly ru: "ВвĐĩŅŅ‚Đ¸ даĐŊĐŊŅ‹Đĩ ҁĐĩŅ€Đ˛ĐĩŅ€Đ° Đ˛Ņ€ŅƒŅ‡ĐŊŅƒŅŽ"; - readonly zh: "æ‰‹åŠ¨čž“å…ĨæœåŠĄå™¨äŋĄæ¯"; - readonly "zh-tw": "手動čŧ¸å…Ĩäŧ翜å™¨čŗ‡č¨Š"; - }; - readonly Export: { - readonly def: "Export"; - readonly es: "Exportar"; - readonly ja: "エク゚ポãƒŧト"; - readonly ko: "ë‚´ëŗ´ë‚´ę¸°"; - readonly ru: "Đ­ĐēҁĐŋĐžŅ€Ņ‚"; - readonly zh: "å¯ŧå‡ē"; - readonly "zh-tw": "匯å‡ē"; - }; - readonly "Failed to connect to remote for compaction.": { - readonly def: "Failed to connect to remote for compaction."; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚ãĢæŽĨįļšã§ããšã€ã‚ŗãƒŗãƒ‘ã‚¯ã‚ˇãƒ§ãƒŗã‚’åŽŸčĄŒã§ããžã›ã‚“ã§ã—ãŸã€‚"; - readonly ko: "ė••ėļ•ė„ ėœ„í•´ ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ė— ė—°ę˛°í•˜ė§€ ëĒģ했ėŠĩ니다."; - readonly ru: "НĐĩ ŅƒĐ´Đ°ĐģĐžŅŅŒ ĐŋОдĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒŅŅ Đē ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊОК йаСĐĩ даĐŊĐŊҋ҅ Đ´ĐģŅ ĐēĐžĐŧĐŋаĐēŅ†Đ¸Đ¸."; - readonly zh: "æ— æŗ•čŋžæŽĨ到čŋœį¨‹æ•°æŽåē“äģĨæ‰§čĄŒåŽ‹įŧŠã€‚"; - readonly "zh-tw": "į„Ąæŗ•é€Ŗįˇšåˆ°é įĢ¯čŗ‡æ–™åēĢäģĨåŸˇčĄŒåŖ“į¸Žã€‚"; - }; - readonly "Failed to connect to remote for compaction. ${reason}": { - readonly def: "Failed to connect to remote for compaction. ${reason}"; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚ãĢæŽĨįļšã§ããšã€ã‚ŗãƒŗãƒ‘ã‚¯ã‚ˇãƒ§ãƒŗã‚’åŽŸčĄŒã§ããžã›ã‚“ã§ã—ãŸã€‚${reason}"; - readonly ko: "ė••ėļ•ė„ ėœ„í•´ ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ė— ė—°ę˛°í•˜ė§€ ëĒģ했ėŠĩ니다. ${reason}"; - readonly ru: "НĐĩ ŅƒĐ´Đ°ĐģĐžŅŅŒ ĐŋОдĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒŅŅ Đē ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊОК йаСĐĩ даĐŊĐŊҋ҅ Đ´ĐģŅ ĐēĐžĐŧĐŋаĐēŅ†Đ¸Đ¸. ${reason}"; - readonly zh: "æ— æŗ•čŋžæŽĨ到čŋœį¨‹æ•°æŽåē“äģĨæ‰§čĄŒåŽ‹įŧŠã€‚${reason}"; - readonly "zh-tw": "į„Ąæŗ•é€Ŗįˇšåˆ°é įĢ¯čŗ‡æ–™åēĢäģĨåŸˇčĄŒåŖ“į¸Žã€‚${reason}"; - }; - readonly "Failed to start one-shot replication before Garbage Collection. Garbage Collection Cancelled.": { - readonly def: "Failed to start one-shot replication before Garbage Collection. Garbage Collection Cancelled."; - readonly ja: "Garbage Collection 前ãĢãƒ¯ãƒŗã‚ˇãƒ§ãƒƒãƒˆãƒŦプãƒĒã‚ąãƒŧã‚ˇãƒ§ãƒŗã‚’é–‹å§‹ã§ããžã›ã‚“ã§ã—ãŸã€‚Garbage Collection ã¯ã‚­ãƒŖãƒŗã‚ģãƒĢされぞした。"; - readonly ko: "Garbage Collection 렄뗐 ėŧíšŒė„ą ëŗĩ렜ëĨŧ ė‹œėž‘í•˜ė§€ ëĒģ했ėŠĩ니다. Garbage Collectionė„ ėˇ¨ė†Œí•Šë‹ˆë‹¤."; - readonly ru: "НĐĩ ŅƒĐ´Đ°ĐģĐžŅŅŒ СаĐŋŅƒŅŅ‚Đ¸Ņ‚ŅŒ ОдĐŊĐžŅ€Đ°ĐˇĐžĐ˛ŅƒŅŽ Ņ€ĐĩĐŋĐģиĐēĐ°Ņ†Đ¸ŅŽ ĐŋĐĩŅ€ĐĩĐ´ Garbage Collection. Garbage Collection ĐžŅ‚ĐŧĐĩĐŊĐĩĐŊа."; - readonly zh: "æ— æŗ•åœ¨åžƒåœžå›žæ”ļ前启动一æŦĄæ€§å¤åˆļ。垃圞回æ”ļåˇ˛å–æļˆã€‚"; - readonly "zh-tw": "į„Ąæŗ•åœ¨åžƒåœžå›žæ”ļ前啟動一æŦĄæ€§č¤‡å¯Ģ。垃圞回æ”ļåˇ˛å–æļˆã€‚"; - }; - readonly "Failed to start replication after Garbage Collection.": { - readonly def: "Failed to start replication after Garbage Collection."; - readonly ja: "Garbage Collection 垌ãĢãƒŦプãƒĒã‚ąãƒŧã‚ˇãƒ§ãƒŗã‚’é–‹å§‹ã§ããžã›ã‚“ã§ã—ãŸã€‚"; - readonly ko: "Garbage Collection 후 ëŗĩ렜ëĨŧ ė‹œėž‘í•˜ė§€ ëĒģ했ėŠĩ니다."; - readonly ru: "НĐĩ ŅƒĐ´Đ°ĐģĐžŅŅŒ СаĐŋŅƒŅŅ‚Đ¸Ņ‚ŅŒ Ņ€ĐĩĐŋĐģиĐēĐ°Ņ†Đ¸ŅŽ ĐŋĐžŅĐģĐĩ Garbage Collection."; - readonly zh: "垃圞回æ”ļåŽæ— æŗ•å¯åŠ¨å¤åˆļ。"; - readonly "zh-tw": "垃圞回æ”ļåžŒį„Ąæŗ•å•Ÿå‹•č¤‡å¯Ģ。"; - }; - readonly Fetch: { - readonly def: "Fetch"; - readonly es: "Obtener"; - readonly ja: "取垗"; - readonly ko: "ę°€ė ¸ė˜¤ę¸°"; - readonly ru: "ПоĐģŅƒŅ‡Đ¸Ņ‚ŅŒ"; - readonly zh: "čŽˇå–"; - readonly "zh-tw": "抓取"; - }; - readonly "Fetch chunks on demand": { - readonly def: "Fetch chunks on demand"; - readonly es: "Obtener chunks bajo demanda"; - readonly fr: "RÊcupÊrer les fragments à la demande"; - readonly he: "משוך × ×Ēחים לפי דרישה"; - readonly ja: "ãƒĻãƒŧã‚ļãƒŧぎã‚ŋã‚¤ãƒŸãƒŗã‚°ã§ãƒãƒŖãƒŗã‚¯ãŽæ›´æ–°ã‚’įĸēčĒã™ã‚‹"; - readonly ko: "í•„ėš” ė‹œ 랭íŦ ė›ę˛Š ę°€ė ¸ė˜¤ę¸°"; - readonly ru: "Đ—Đ°ĐŗŅ€ŅƒĐļĐ°Ņ‚ŅŒ Ņ‡Đ°ĐŊĐēи ĐŋĐž ҂ҀĐĩйОваĐŊĐ¸ŅŽ"; - readonly zh: "æŒ‰éœ€čŽˇå–å—"; - readonly "zh-tw": "按需抓取 chunks"; - }; - readonly "Fetch database with previous behaviour": { - readonly def: "Fetch database with previous behaviour"; - readonly es: "Obtener BD con comportamiento anterior"; - readonly fr: "RÊcupÊrer la base de donnÊes avec le comportement prÊcÊdent"; - readonly he: "משוך מסד × ×Ēונים ×ĸם ה×Ēנהגו×Ē ×§×•×“×ž×Ē"; - readonly ja: "äģĨ前ぎ動äŊœã§ãƒ‡ãƒŧã‚ŋベãƒŧ゚を取垗"; - readonly ko: "ė´ė „ ë™ėž‘ėœŧ로 ë°ė´í„°ë˛ ė´ėŠ¤ ę°€ė ¸ė˜¤ę¸°"; - readonly ru: "Đ—Đ°ĐŗŅ€ŅƒĐˇĐ¸Ņ‚ŅŒ ĐąĐ°ĐˇŅƒ даĐŊĐŊҋ҅ ҁ ĐŋŅ€ĐĩĐ´Ņ‹Đ´ŅƒŅ‰Đ¸Đŧ ĐŋОвĐĩĐ´ĐĩĐŊиĐĩĐŧ"; - readonly zh: "äŊŋᔍäģĨå‰įš„čĄŒä¸ēčŽˇå–æ•°æŽåē“"; - readonly "zh-tw": "äģĨå‰ä¸€į¨ŽčĄŒį‚ēæŠ“å–čŗ‡æ–™åēĢ"; - }; - readonly "Fetch remote settings": { - readonly def: "Fetch remote settings"; - readonly es: "Obtener ajustes remotos"; - readonly ja: "ãƒĒãƒĸãƒŧãƒˆč¨­åŽšã‚’å–åž—"; - readonly ko: "ė›ę˛Š 네렕 ę°€ė ¸ė˜¤ę¸°"; - readonly ru: "ПоĐģŅƒŅ‡Đ¸Ņ‚ŅŒ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи ҁ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐŗĐž Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰Đ°"; - readonly zh: "čŽˇå–čŋœįĢ¯čŽžįŊŽ"; - readonly "zh-tw": "抓取遠įĢ¯č¨­åŽš"; - }; - readonly "File to resolve conflict": { - readonly def: "File to resolve conflict"; - readonly es: "Archivo para resolver el conflicto"; - readonly ja: "įĢļåˆã‚’č§Ŗæąēã™ã‚‹ãƒ•ã‚Ąã‚¤ãƒĢ"; - readonly ko: "ėļŠëŒė„ 해결할 파ėŧ"; - readonly ru: "ФаКĐģ Đ´ĐģŅ Ņ€Đ°ĐˇŅ€Đĩ҈ĐĩĐŊĐ¸Ņ ĐēĐžĐŊŅ„ĐģиĐēŅ‚Đ°"; - readonly zh: "选拊čρ觪冺冞įĒįš„æ–‡äģļ"; - readonly "zh-tw": "čρ觪æąē衝įĒįš„æĒ”æĄˆ"; - }; - readonly "File to view History": { - readonly def: "File to view History"; - readonly "zh-tw": "čρæĒĸčĻ–æ­ˇį¨‹įš„æĒ”æĄˆ"; - }; - readonly Filename: { - readonly def: "Filename"; - readonly es: "Nombre de archivo"; - readonly fr: "Nom de fichier"; - readonly he: "׊ם קוב×Ĩ"; - readonly ja: "ãƒ•ã‚Ąã‚¤ãƒĢ名"; - readonly ko: "파ėŧëĒ…"; - readonly ru: "ИĐŧŅ Ņ„Đ°ĐšĐģа"; - readonly zh: "文äģļ名"; - readonly "zh-tw": "æĒ”名"; - }; - readonly "First, please select the option that best describes your current situation.": { - readonly def: "First, please select the option that best describes your current situation."; - readonly es: "Primero, seleccione la opciÃŗn que describa mejor su situaciÃŗn actual。"; - readonly ja: "ãžãšã€įžåœ¨ãŽįŠļæŗãĢ最もčŋ‘ã„é …į›Žã‚’é¸æŠžã—ãĻください。"; - readonly ko: "ë¨ŧė € 현ėžŦ ėƒí™Šė— 가ėžĨ ėž˜ 맞는 항ëĒŠė„ ė„ íƒí•´ ėŖŧė„¸ėš”ã€‚"; - readonly ru: "ĐĄĐŊĐ°Ņ‡Đ°Đģа Đ˛Ņ‹ĐąĐĩŅ€Đ¸Ņ‚Đĩ Đ˛Đ°Ņ€Đ¸Đ°ĐŊŅ‚, ĐēĐžŅ‚ĐžŅ€Ņ‹Đš ĐģŅƒŅ‡ŅˆĐĩ Đ˛ŅĐĩĐŗĐž ĐžĐŋĐ¸ŅŅ‹Đ˛Đ°ĐĩŅ‚ Đ˛Đ°ŅˆŅƒ Ņ‚ĐĩĐēŅƒŅ‰ŅƒŅŽ ŅĐ¸Ņ‚ŅƒĐ°Ņ†Đ¸ŅŽã€‚"; - readonly zh: "éĻ–å…ˆīŧŒč¯ˇé€‰æ‹Šæœ€įŦĻ合äŊ åŊ“前情å†ĩįš„é€‰éĄšã€‚"; - readonly "zh-tw": "éĻ–å…ˆīŧŒčĢ‹é¸æ“‡æœ€įŦĻ合äŊ į›Žå‰æƒ…æŗįš„é¸é …ã€‚"; - }; - readonly "Flag and restart": { - readonly def: "Flag and restart"; - readonly es: "Marcar y reiniciar"; - readonly ja: "フナグをįĢ‹ãĻãĻ再čĩˇå‹•"; - readonly ko: "í‘œė‹œ 후 ėžŦė‹œėž‘"; - readonly ru: "ПоĐŧĐĩŅ‚Đ¸Ņ‚ŅŒ и ĐŋĐĩŅ€ĐĩСаĐŋŅƒŅŅ‚Đ¸Ņ‚ŅŒ"; - readonly zh: "æ ‡čŽ°åŽé‡å¯"; - readonly "zh-tw": "æ¨™č¨˜åžŒé‡æ–°å•Ÿå‹•"; - }; - readonly "Forces the file to be synced when opened.": { - readonly def: "Forces the file to be synced when opened."; - readonly es: "Forzar sincronizaciÃŗn al abrir archivo"; - readonly fr: "Force la synchronisation du fichier à son ouverture."; - readonly he: "מכריח סנכרון הקוב×Ĩ ב×ĸ×Ē ×¤×Ēיח×Ēו."; - readonly ja: "ãƒ•ã‚Ąã‚¤ãƒĢを開いたときãĢåŧˇåˆļįš„ãĢ同期しぞす。"; - readonly ko: "파ėŧė„ ė—´ 때 ę°•ė œëĄœ 동기화합니다."; - readonly ru: "ĐŸŅ€Đ¸ĐŊŅƒĐ´Đ¸Ņ‚ĐĩĐģҌĐŊĐž ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ Ņ„Đ°ĐšĐģ ĐŋŅ€Đ¸ ĐžŅ‚ĐēŅ€Ņ‹Ņ‚Đ¸Đ¸."; - readonly zh: "打åŧ€æ–‡äģļæ—ļåŧēåˆļ同æ­Ĩč¯Ĩ文äģļ "; - readonly "zh-tw": "開啟æĒ”æĄˆæ™‚åŧˇåˆļ同æ­Ĩ芲æĒ”æĄˆã€‚"; - }; - readonly "Fresh Start Wipe": { - readonly def: "Fresh Start Wipe"; - readonly es: "Borrado para reinicio completo"; - readonly ja: "初期化ワイプ"; - readonly ko: "ėƒˆëĄœ ė‹œėž‘ ė§€ėš°ę¸°"; - readonly ru: "ПоĐģĐŊŅ‹Đš ŅĐąŅ€ĐžŅ Đ´ĐģŅ Ņ‡Đ¸ŅŅ‚ĐžĐŗĐž ŅŅ‚Đ°Ņ€Ņ‚Đ°"; - readonly zh: "全新åŧ€å§‹æ¸…除"; - readonly "zh-tw": "全新開始清除"; - }; - readonly "Garbage Collection cancelled by user.": { - readonly def: "Garbage Collection cancelled by user."; - readonly ja: "ãƒĻãƒŧã‚ļãƒŧãĢã‚ˆãŖãĻ Garbage Collection ãŒã‚­ãƒŖãƒŗã‚ģãƒĢされぞした。"; - readonly ko: "ė‚ŦėšŠėžę°€ Garbage Collectionė„ ėˇ¨ė†Œí–ˆėŠĩ니다."; - readonly ru: "ПоĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ĐĩĐģҌ ĐžŅ‚ĐŧĐĩĐŊиĐģ Garbage Collection."; - readonly zh: "į”¨æˆˇåˇ˛å–æļˆåžƒåœžå›žæ”ļ。"; - readonly "zh-tw": "äŊŋį”¨č€…åˇ˛å–æļˆåžƒåœžå›žæ”ļ。"; - }; - readonly "Garbage Collection completed. Deleted chunks: ${deletedChunks} / ${totalChunks}. Time taken: ${seconds} seconds.": { - readonly def: "Garbage Collection completed. Deleted chunks: ${deletedChunks} / ${totalChunks}. Time taken: ${seconds} seconds."; - readonly ja: "Garbage Collection が厌äē†ã—ãžã—ãŸã€‚å‰Šé™¤ã—ãŸãƒãƒŖãƒŗã‚¯: ${deletedChunks} / ${totalChunks}。所čĻæ™‚é–“: ${seconds} į§’ã€‚"; - readonly ko: "Garbage Collectionė´ ė™„ëŖŒë˜ė—ˆėŠĩ니다. ė‚­ė œëœ 랭íŦ: ${deletedChunks} / ${totalChunks}. ė†Œėš” ė‹œę°„: ${seconds}봈."; - readonly ru: "Garbage Collection СавĐĩŅ€ŅˆĐĩĐŊа. ĐŖĐ´Đ°ĐģĐĩĐŊĐž Ņ‡Đ°ĐŊĐēОв: ${deletedChunks} / ${totalChunks}. Đ—Đ°Ņ‚Ņ€Đ°Ņ‡ĐĩĐŊĐŊĐžĐĩ Đ˛Ņ€ĐĩĐŧŅ: ${seconds} ҁĐĩĐē."; - readonly zh: "垃圞回æ”ļåŽŒæˆã€‚åˇ˛åˆ é™¤ chunksīŧš${deletedChunks} / ${totalChunks}ã€‚č€—æ—ļīŧš${seconds} į§’ã€‚"; - readonly "zh-tw": "垃圞回æ”ļåŽŒæˆã€‚åˇ˛åˆĒ除 chunksīŧš${deletedChunks} / ${totalChunks}ã€‚č€—æ™‚īŧš${seconds} į§’ã€‚"; - }; - readonly "Garbage Collection Confirmation": { - readonly def: "Garbage Collection Confirmation"; - readonly ja: "Garbage Collection ぎįĸēčĒ"; - readonly ko: "Garbage Collection í™•ė¸"; - readonly ru: "ĐŸĐžĐ´Ņ‚Đ˛ĐĩŅ€ĐļĐ´ĐĩĐŊиĐĩ Garbage Collection"; - readonly zh: "垃圞回æ”ļįĄŽčŽ¤"; - readonly "zh-tw": "垃圞回æ”ļįĸēčĒ"; - }; - readonly "Garbage Collection V3 (Beta)": { - readonly def: "Garbage Collection V3 (Beta)"; - readonly es: "RecolecciÃŗn de basura V3 (Beta)"; - readonly ja: "ã‚Ŧãƒŧãƒ™ã‚¸ã‚ŗãƒŦã‚¯ã‚ˇãƒ§ãƒŗ V3 (Beta)"; - readonly ko: "ę°€ëš„ė§€ ėģŦë ‰ė…˜ V3 (Beta)"; - readonly ru: "ĐĄĐąĐžŅ€Đēа ĐŧŅƒŅĐžŅ€Đ° V3 (Beta)"; - readonly zh: "垃圞回æ”ļ V3īŧˆBetaīŧ‰"; - readonly "zh-tw": "垃圞回æ”ļ V3īŧˆBetaīŧ‰"; - }; - readonly "Garbage Collection: Found ${unusedChunks} unused chunks to delete.": { - readonly def: "Garbage Collection: Found ${unusedChunks} unused chunks to delete."; - readonly ja: "Garbage Collection: å‰Šé™¤å¯žčąĄãŽæœĒäŊŋį”¨ãƒãƒŖãƒŗã‚¯ãŒ ${unusedChunks} äģļčĻ‹ã¤ã‹ã‚Šãžã—ãŸã€‚"; - readonly ko: "Garbage Collection: ė‚­ė œí•  미ė‚ŦėšŠ 랭íŦ ${unusedChunks}氜ëĨŧ ė°žė•˜ėŠĩ니다."; - readonly ru: "Garbage Collection: ĐŊаКдĐĩĐŊĐž ${unusedChunks} ĐŊĐĩĐ¸ŅĐŋĐžĐģŅŒĐˇŅƒĐĩĐŧҋ҅ Ņ‡Đ°ĐŊĐēОв Đ´ĐģŅ ŅƒĐ´Đ°ĐģĐĩĐŊĐ¸Ņ."; - readonly zh: "垃圞回æ”ļīŧšå‘įŽ° ${unusedChunks} ä¸ĒæœĒäŊŋį”¨įš„ chunks 垅删除。"; - readonly "zh-tw": "垃圞回æ”ļīŧšæ‰žåˆ° ${unusedChunks} 個æœĒäŊŋį”¨įš„ chunks 可åˆĒ除。"; - }; - readonly "Garbage Collection: Scanned ${scanned} / ~${docCount}": { - readonly def: "Garbage Collection: Scanned ${scanned} / ~${docCount}"; - readonly ja: "Garbage Collection: ${scanned} / ~${docCount} ã‚’ã‚šã‚­ãƒŖãƒŗæ¸ˆãŋ"; - readonly ko: "Garbage Collection: ${scanned} / ~${docCount} 늤ėē”됨"; - readonly ru: "Garbage Collection: ĐŋŅ€ĐžŅĐēаĐŊĐ¸Ņ€ĐžĐ˛Đ°ĐŊĐž ${scanned} / ~${docCount}"; - readonly zh: "垃圞回æ”ļīŧšåˇ˛æ‰Ģ描 ${scanned} / ~${docCount}"; - readonly "zh-tw": "垃圞回æ”ļīŧšåˇ˛æŽƒæ ${scanned} / ~${docCount}"; - }; - readonly "Garbage Collection: Scanning completed. Total chunks: ${totalChunks}, Used chunks: ${usedChunks}": { - readonly def: "Garbage Collection: Scanning completed. Total chunks: ${totalChunks}, Used chunks: ${usedChunks}"; - readonly ja: "Garbage Collection: ã‚šã‚­ãƒŖãƒŗåŽŒäē†ã€‚įˇãƒãƒŖãƒŗã‚¯æ•°: ${totalChunks}、äŊŋį”¨ä¸­ãƒãƒŖãƒŗã‚¯æ•°: ${usedChunks}"; - readonly ko: "Garbage Collection: 늤ėē” ė™„ëŖŒ. 렄랴 랭íŦ 눘: ${totalChunks}, ė‚ŦėšŠ ė¤‘ė¸ 랭íŦ 눘: ${usedChunks}"; - readonly ru: "Garbage Collection: ҁĐēаĐŊĐ¸Ņ€ĐžĐ˛Đ°ĐŊиĐĩ СавĐĩŅ€ŅˆĐĩĐŊĐž. Đ’ŅĐĩĐŗĐž Ņ‡Đ°ĐŊĐēОв: ${totalChunks}, Đ¸ŅĐŋĐžĐģŅŒĐˇŅƒĐĩĐŧҋ҅ Ņ‡Đ°ĐŊĐēОв: ${usedChunks}"; - readonly zh: "垃圞回æ”ļīŧšæ‰Ģ描厌成。æ€ģ chunksīŧš${totalChunks}īŧŒåˇ˛äŊŋᔍ chunksīŧš${usedChunks}"; - readonly "zh-tw": "垃圞回æ”ļīŧšæŽƒæåŽŒæˆã€‚į¸Ŋ chunksīŧš${totalChunks}īŧŒåˇ˛äŊŋᔍ chunksīŧš${usedChunks}"; - }; - readonly "Handle files as Case-Sensitive": { - readonly def: "Handle files as Case-Sensitive"; - readonly es: "Manejar archivos como sensibles a mayÃēsculas"; - readonly fr: "GÊrer les fichiers en respectant la casse"; - readonly he: "טפל בקב×Ļים כ×Ēלויי רישיו×Ē"; - readonly ja: "ãƒ•ã‚Ąã‚¤ãƒĢぎ大文字ãƒģ小文字をåŒēåˆĨする"; - readonly ko: "파ėŧė„ ëŒ€ė†ŒëŦ¸ėž ęĩŦëļ„ėœŧ로 래ëĻŦ"; - readonly ru: "ĐžĐąŅ€Đ°ĐąĐ°Ņ‚Ņ‹Đ˛Đ°Ņ‚ŅŒ Ņ„Đ°ĐšĐģŅ‹ ҁ ŅƒŅ‡Ņ‘Ņ‚ĐžĐŧ Ņ€ĐĩĐŗĐ¸ŅŅ‚Ņ€Đ°"; - readonly zh: "将文äģļ视ä¸ēåŒē分大小写"; - readonly "zh-tw": "將æĒ”æĄˆčĻ–į‚ē區分大小å¯Ģ"; - }; - readonly "Hidden Files": { - readonly def: "Hidden Files"; - readonly es: "Archivos ocultos"; - readonly ja: "éš ã—ãƒ•ã‚Ąã‚¤ãƒĢ"; - readonly ko: "ėˆ¨ęš€ 파ėŧ"; - readonly ru: "ĐĄĐēҀҋ҂ҋĐĩ Ņ„Đ°ĐšĐģŅ‹"; - readonly zh: "éšč—æ–‡äģļ"; - readonly "zh-tw": "隱藏æĒ”æĄˆ"; - }; - readonly "Hide completely": { - readonly def: "Hide completely"; - readonly zh: "åŽŒå…¨éšč—"; - readonly "zh-tw": "åŽŒå…¨éšąč—"; - }; - readonly "Highlight diff": { - readonly def: "Highlight diff"; - readonly "zh-tw": "é†’į›ŽéĄ¯į¤ēåˇŽį•°"; - }; - readonly "How to display network errors when the sync server is unreachable.": { - readonly def: "How to display network errors when the sync server is unreachable."; - readonly es: "CÃŗmo mostrar los errores de red cuando el servidor de sincronizaciÃŗn no estÃĄ disponible."; - readonly ja: "同期ã‚ĩãƒŧバãƒŧãĢ到達できãĒい場合ぎネットワãƒŧクエナãƒŧãŽčĄ¨į¤ēæ–šæŗ•ã‚’č¨­åŽšã—ãžã™ã€‚"; - readonly ko: "동기화 ė„œë˛„ė— ė—°ę˛°í•  눘 ė—†ė„ 때 ë„¤íŠ¸ė›ŒíŦ 똤ëĨ˜ëĨŧ ė–´ë–ģ枌 í‘œė‹œí• ė§€ ė„¤ė •í•Šë‹ˆë‹¤."; - readonly ru: "ОĐŋŅ€ĐĩĐ´ĐĩĐģŅĐĩŅ‚, ĐēаĐē ĐžŅ‚ĐžĐąŅ€Đ°ĐļĐ°Ņ‚ŅŒ ҁĐĩŅ‚ĐĩĐ˛Ņ‹Đĩ ĐžŅˆĐ¸ĐąĐēи, ĐĩҁĐģи ҁĐĩŅ€Đ˛ĐĩŅ€ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸ ĐŊĐĩĐ´ĐžŅŅ‚ŅƒĐŋĐĩĐŊ."; - readonly zh: "åŊ“同æ­ĨæœåŠĄå™¨ä¸å¯čžžæ—ļīŧŒåĻ‚äŊ•昞į¤ēįŊ‘įģœé”™č¯¯ã€‚"; - readonly "zh-tw": "į•ļ同æ­Ĩäŧ翜å™¨į„Ąæŗ•é€Ŗįˇšæ™‚īŧŒåĻ‚äŊ•éĄ¯į¤ēįļ˛čˇ¯éŒ¯čĒ¤ã€‚"; - }; - readonly "How would you like to configure the connection to your server?": { - readonly def: "How would you like to configure the connection to your server?"; - readonly es: "ÂŋCÃŗmo desea configurar la conexiÃŗn con su servidor?"; - readonly ja: "ã‚ĩãƒŧバãƒŧæŽĨįļšã‚’おぎようãĢč¨­åŽšã—ãžã™ã‹īŧŸ"; - readonly ko: "ė„œë˛„ ė—°ę˛°ė„ ė–´ë–ģ枌 ęĩŦė„ąí•˜ė‹œę˛ ėŠĩ니까?"; - readonly ru: "КаĐē Đ˛Ņ‹ Ņ…ĐžŅ‚Đ¸Ņ‚Đĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐ¸Ņ‚ŅŒ ĐŋОдĐēĐģŅŽŅ‡ĐĩĐŊиĐĩ Đē ҁĐĩŅ€Đ˛ĐĩŅ€Ņƒ?"; - readonly zh: "äŊ å¸Œæœ›åĻ‚äŊ•配įŊŽä¸ŽæœåŠĄå™¨įš„čŋžæŽĨīŧŸ"; - readonly "zh-tw": "äŊ å¸Œæœ›åĻ‚äŊ•č¨­åŽščˆ‡äŧ翜å™¨įš„é€ŖįˇšīŧŸ"; - }; - readonly "I am adding a device to an existing synchronisation setup": { - readonly def: "I am adding a device to an existing synchronisation setup"; - readonly es: "Estoy agregando un dispositivo a una configuraciÃŗn de sincronizaciÃŗn existente"; - readonly ja: "æ—ĸ存ぎ同期構成ãĢį̝æœĢをčŋŊ加しぞす"; - readonly ko: "ę¸°ėĄ´ 동기화 ęĩŦė„ąė— ėžĨėš˜ëĨŧ ėļ”ę°€í•Šë‹ˆë‹¤"; - readonly ru: "Đ¯ дОйавĐģŅŅŽ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đž Đē ŅŅƒŅ‰ĐĩŅŅ‚Đ˛ŅƒŅŽŅ‰ĐĩĐš ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēĐĩ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸"; - readonly zh: "我čĻå°†čŽžå¤‡åŠ å…ĨįŽ°æœ‰åŒæ­Ĩ配įŊŽ"; - readonly "zh-tw": "我čĻå°‡čŖįŊŽåŠ å…Ĩæ—ĸ有同æ­Ĩč¨­åŽš"; - }; - readonly "I am setting this up for the first time": { - readonly def: "I am setting this up for the first time"; - readonly es: "Estoy configurando esto por primera vez"; - readonly ja: "はじめãĻč¨­åŽšã—ãžã™"; - readonly ko: "ė˛˜ėŒėœŧ로 ė„¤ė •í•Šë‹ˆë‹¤"; - readonly ru: "Đ¯ ĐŊĐ°ŅŅ‚Ņ€Đ°Đ¸Đ˛Đ°ŅŽ ŅŅ‚Đž вĐŋĐĩŅ€Đ˛Ņ‹Đĩ"; - readonly zh: "我是įŦŦ一æŦĄčŋ›čĄŒčŽžįŊŽ"; - readonly "zh-tw": "我是įŦŦ一æŦĄé€˛čĄŒč¨­åޚ"; - }; - readonly "I know my server details, let me enter them": { - readonly def: "I know my server details, let me enter them"; - readonly es: "Conozco los datos de mi servidor; permítame introducirlos"; - readonly ja: "ã‚ĩãƒŧバãƒŧæƒ…å ąã‚’æŠŠæĄã—ãĻいるぎで、č‡Ē分でå…Ĩ力しぞす"; - readonly ko: "ė„œë˛„ ė •ëŗ´ëĨŧ ė•Œęŗ  ėžˆėœŧ니 링렑 ėž…ë Ĩ하겠ėŠĩ니다"; - readonly ru: "Đ¯ СĐŊĐ°ŅŽ ĐŋĐ°Ņ€Đ°ĐŧĐĩ҂Ҁҋ ҁĐĩŅ€Đ˛ĐĩŅ€Đ°, ĐŋОСвОĐģŅŒŅ‚Đĩ ввĐĩŅŅ‚Đ¸ Đ¸Ņ… Đ˛Ņ€ŅƒŅ‡ĐŊŅƒŅŽ"; - readonly zh: "我įŸĨé“æœåŠĄå™¨č¯Ļ情īŧŒčŽŠæˆ‘æ‰‹åŠ¨čž“å…Ĩ"; - readonly "zh-tw": "我įŸĨ道äŧ翜å™¨čŗ‡č¨ŠīŧŒčŽ“æˆ‘æ‰‹å‹•čŧ¸å…Ĩ"; - }; - readonly "If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).": { - readonly def: "If disabled(toggled), chunks will be split on the UI thread (Previous behaviour)."; - readonly es: "Si se desactiva, chunks se dividen en hilo UI (comportamiento anterior)"; - readonly fr: "Si dÊsactivÊ, les fragments seront dÊcoupÊs sur le thread UI (comportement prÊcÊdent)."; - readonly he: "אם מכובה, × ×Ēחים יפו×Ļלו בשרשור ממ׊ק המש×Ēמ׊ (ה×Ēנהגו×Ē ×§×•×“×ž×Ē)."; - readonly ja: "į„ĄåŠšīŧˆãƒˆã‚°ãƒĢīŧ‰ãĢã™ã‚‹ã¨ã€ãƒãƒŖãƒŗã‚¯ã¯UI゚ãƒŦãƒƒãƒ‰ã§åˆ†å‰˛ã•ã‚Œãžã™īŧˆäģĨ前ぎ動äŊœīŧ‰ã€‚"; - readonly ko: "ëš„í™œė„ąí™”(토글)되늴 랭íŦ는 UI ėŠ¤ë ˆë“œė—ė„œ ëļ„할됊니다 (ė´ė „ ë™ėž‘)."; - readonly ru: "Đ•ŅĐģи ĐžŅ‚ĐēĐģŅŽŅ‡ĐĩĐŊĐž, Ņ‡Đ°ĐŊĐēи ĐąŅƒĐ´ŅƒŅ‚ Ņ€Đ°ĐˇĐ´ĐĩĐģŅŅ‚ŅŒŅŅ в ĐžŅĐŊОвĐŊĐžĐŧ ĐŋĐžŅ‚ĐžĐēĐĩ."; - readonly zh: "åĻ‚æžœįρᔍ(切æĸ),chunks 将在 UI įēŋį¨‹ä¸Šåˆ†å‰˛(äģĨå‰įš„čĄŒä¸ē)"; - readonly "zh-tw": "č‹Ĩåœį”¨īŧˆé—œé–‰īŧ‰æ­¤é¸é …īŧŒchunks 會在 UI åŸˇčĄŒįˇ’ä¸Šåˆ†å‰˛īŧˆčˆŠæœ‰čĄŒį‚ēīŧ‰ã€‚"; - }; - readonly "If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.": { - readonly def: "If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions."; - readonly es: "Habilita sincronizaciÃŗn eficiente por archivo. Requiere migraciÃŗn y actualizar todos dispositivos a v0.23.18. Pierde compatibilidad con versiones antiguas"; - readonly fr: "Si activÊe, la synchronisation de personnalisation efficace par fichier sera utilisÊe. Une petite migration est nÊcessaire lors de l'activation. Tous les appareils doivent ÃĒtre à jour en v0.23.18. Une fois cette option activÊe, la compatibilitÊ avec les anciennes versions est perdue."; - readonly he: "אם מופ×ĸל, יי×ĸשה שימוש בסנכרון ה×Ēאמה אישי×Ē ×™×ĸיל לפי קוב×Ĩ. נדרש×Ē ×”×’×™×¨×” קטנה ב×ĸ×Ē ×”×”×¤×ĸלה. כל המכשירים ×Ļריכים להיו×Ē ×ž×ĸודכנים לגרסה 0.23.18. לאחר ההפ×ĸלה, ה×Ēאימו×Ē ×œ×’×¨×Ą××•×Ē ×™×Š× ×•×Ē ×Ēיפג×ĸ."; - readonly ja: "有劚ãĢã™ã‚‹ã¨ã€ãƒ•ã‚Ąã‚¤ãƒĢã”ã¨ãŽåŠšįŽ‡įš„ãĒã‚Ģ゚ã‚ŋマイã‚ē同期がäŊŋį”¨ã•ã‚Œãžã™ã€‚æœ‰åŠšåŒ–æ™‚ãĢ小čĻæ¨ĄãĒį§ģčĄŒãŒåŋ…čĻã§ã™ã€‚ãžãŸã€ã™ãšãĻぎデバイ゚をv0.23.18ãĢã‚ĸップデãƒŧトするåŋ…čĻãŒã‚ã‚Šãžã™ã€‚ä¸€åēĻæœ‰åŠšãĢすると、古いバãƒŧã‚¸ãƒ§ãƒŗã¨ãŽäē’æ›æ€§ãŒãĒくãĒりぞす。"; - readonly ko: "í™œė„ąí™”í•˜ëŠ´ 파ėŧëŗ„ íš¨ėœ¨ė ė¸ ė‚ŦėšŠėž 네렕 동기화가 ė‚ŦėšŠëŠë‹ˆë‹¤. ė´ëĨŧ í™œė„ąí™”í•  때 ė†ŒęˇœëǍ ë°ė´í„° ęĩŦėĄ° ė „í™˜ė´ í•„ėš”í•Šë‹ˆë‹¤. ëĒ¨ë“  기기ëĨŧ v0.23.18로 ė—…ë°ė´íŠ¸í•´ė•ŧ 합니다. ė´ëĨŧ í™œė„ąí™”í•˜ëŠ´ ė´ė „ ë˛„ė „ęŗŧė˜ í˜¸í™˜ė„ąė´ ė‚Ŧëŧė§‘ë‹ˆë‹¤."; - readonly ru: "If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions."; - readonly zh: "åĻ‚æžœå¯į”¨,将äŊŋᔍåŸēäēŽæ–‡äģļįš„ã€éĢ˜æ•ˆįš„č‡Ē厚䚉同æ­Ĩã€‚å¯į”¨æ­¤åŠŸčƒŊ需čρčŋ›čĄŒä¸€æŦĄå°įš„čŋį§ģã€‚æ‰€æœ‰čŽžå¤‡éƒŊå甿›´æ–°åˆ° v0.23.18。一æ—Ļå¯į”¨æ­¤åŠŸčƒŊ,我äģŦå°†å¤ąåŽģä¸Žæ—§į‰ˆæœŦįš„å…ŧ厚性"; - readonly "zh-tw": "å•Ÿį”¨åžŒæœƒäŊŋᔍäģĨ每個æĒ”æĄˆį‚ēå–ŽäŊįš„éĢ˜æ•ˆįŽ‡č‡Ēč¨‚åŒæ­Ĩã€‚å•Ÿį”¨æ™‚éœ€čĻé€˛čĄŒä¸€æŦĄå°åž‹éˇį§ģīŧŒä¸”æ‰€æœ‰čŖįŊŽéƒŊæ‡‰å‡į´šåˆ° v0.23.18ã€‚å•Ÿį”¨åžŒå°‡ä¸å†į›¸åŽščˆŠį‰ˆæœŦ。"; - }; - readonly "If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker.": { - readonly def: "If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker."; - readonly es: "Divide chunks en mÃĄximo 100 ítems. Menos eficiente en deduplicaciÃŗn"; - readonly fr: "Si activÊe, les fragments seront dÊcoupÊs en 100 ÊlÊments au maximum. Cependant, la dÊduplication est lÊgèrement moins efficace."; - readonly he: "אם מופ×ĸל, × ×Ēחים יפו×Ļלו לא ליו×Ēר מ-100 פריטים. ×ĸם זא×Ē, ביטול כפילויו×Ē ×™×”×™×” חלש מ×ĸט יו×Ēר."; - readonly ja: "有劚ãĢã™ã‚‹ã¨ã€ãƒãƒŖãƒŗã‚¯ã¯æœ€å¤§100é …į›ŽãĢåˆ†å‰˛ã•ã‚Œãžã™ã€‚ãŸã ã—ã€é‡č¤‡é™¤åŽģãŽį˛žåēĻはčŊãĄãžã™ã€‚"; - readonly ko: "í™œė„ąí™”í•˜ëŠ´ 랭íŦ는 ėĩœëŒ€ 100氜 항ëĒŠėœŧ로 ëļ„할됊니다. í•˜ė§€ë§Œ 뤑ëŗĩ ė œęą° 기ëŠĨė´ ė•Ŋ간 ė•Ŋí•´ė§‘ë‹ˆë‹¤."; - readonly ru: "Đ•ŅĐģи вĐēĐģŅŽŅ‡ĐĩĐŊĐž, Ņ‡Đ°ĐŊĐēи ĐąŅƒĐ´ŅƒŅ‚ Ņ€Đ°ĐˇĐ´ĐĩĐģĐĩĐŊŅ‹ ĐŊĐĩ йОĐģĐĩĐĩ ҇ĐĩĐŧ ĐŊа 100 ŅĐģĐĩĐŧĐĩĐŊŅ‚ĐžĐ˛."; - readonly zh: "åĻ‚æžœå¯į”¨,数捎块将čĸĢåˆ†å‰˛æˆä¸čļ…čŋ‡ 100 éĄšã€‚äŊ†æ˜¯,åŽģ重效果äŧšį¨åŧą"; - readonly "zh-tw": "å•Ÿį”¨åžŒīŧŒchunks 最多會分成 100 å€‹é …į›ŽīŧŒäŊ†åŽģé‡æ•ˆæžœæœƒį¨åžŽčŽŠåŧąã€‚"; - }; - readonly "If enabled, newly created chunks are temporarily kept within the document, and graduated to become independent chunks once stabilised.": { - readonly def: "If enabled, newly created chunks are temporarily kept within the document, and graduated to become independent chunks once stabilised."; - readonly es: "Chunks nuevos se mantienen temporalmente en el documento hasta estabilizarse"; - readonly fr: "Si activÊe, les fragments nouvellement crÊÊs sont temporairement conservÊs dans le document et promus en fragments indÊpendants une fois stabilisÊs."; - readonly he: "אם מופ×ĸל, × ×Ēחים שנו×Ļרו לאחרונה נשמרים זמני×Ē ×‘×Ēוך המסמך, ומוסמכים לנ×Ēחים ×ĸ×Ļמאיים לאחר י×Ļיבו×Ē."; - readonly ja: "有劚ãĢすると、新しくäŊœæˆã•ã‚ŒãŸãƒãƒŖãƒŗã‚¯ã¯ãƒ‰ã‚­ãƒĨãƒĄãƒŗãƒˆå†…ãĢä¸€æ™‚įš„ãĢäŋæŒã•れ、厉厚したらį‹ŦįĢ‹ã—ãŸãƒãƒŖãƒŗã‚¯ãĢãĒりぞす。"; - readonly ko: "í™œė„ąí™”í•˜ëŠ´ ėƒˆëĄœ ėƒė„ąëœ ëŗ€ę˛Ŋ 기록(랭íŦ)ė€ ëŦ¸ė„œ ė•ˆė— ėž„ė‹œëĄœ ëŗ´ę´€ë˜ëŠ°, ėŧė • ėĄ°ęą´ė„ ë§ŒėĄąí•˜ëŠ´ ėžë™ėœŧ로 ëŦ¸ė„œ 밖ėœŧ로 ëļ„ëĻŦë˜ė–´ ė €ėžĨ됩니다."; - readonly ru: "Đ•ŅĐģи вĐēĐģŅŽŅ‡ĐĩĐŊĐž, вĐŊĐžĐ˛ŅŒ ŅĐžĐˇĐ´Đ°ĐŊĐŊŅ‹Đĩ Ņ‡Đ°ĐŊĐēи Đ˛Ņ€ĐĩĐŧĐĩĐŊĐŊĐž Ņ…Ņ€Đ°ĐŊŅŅ‚ŅŅ в Đ´ĐžĐē҃ĐŧĐĩĐŊŅ‚Đĩ."; - readonly zh: "åĻ‚æžœå¯į”¨,新创åģēįš„æ•°æŽå—å°†æš‚æ—ļäŋį•™åœ¨æ–‡æĄŖä¸­,åšļåœ¨į¨ŗåŽšåŽæˆä¸ēį‹ŦįĢ‹æ•°æŽå—"; - readonly "zh-tw": "å•Ÿį”¨åžŒīŧŒæ–°åģēįĢ‹įš„ chunks 會æšĢ時äŋį•™åœ¨æ–‡äģļ中īŧŒåž…įŠŠåŽšåžŒå†į¨įĢ‹å‡ēåŽģ。"; - }; - readonly "If enabled, the \u26D4 icon will be shown inside the status instead of the file warnings banner. No details will be shown.": { - readonly def: "If enabled, the ⛔ icon will be shown inside the status instead of the file warnings banner. No details will be shown."; - readonly es: "Si se activa, se mostrarÃĄ el icono ⛔ en el estado en lugar del banner de advertencia de archivos. No se mostrarÃĄn detalles."; - readonly fr: "Si activÊe, l'icône ⛔ s'affichera dans le statut à la place de la bannière d'avertissements de fichiers. Aucun dÊtail ne sera affichÊ."; - readonly he: "אם מופ×ĸל, אייקון ⛔ יו×Ļג בסטטוס במקום פץ האזהרו×Ē. לא יו×Ļגו פרטים."; - readonly ja: "有劚ãĢã™ã‚‹ã¨ã€ãƒ•ã‚Ąã‚¤ãƒĢč­Ļ告バナãƒŧぎäģŖã‚ã‚ŠãĢ゚テãƒŧã‚ŋ゚内へ ⛔ ã‚ĸã‚¤ã‚ŗãƒŗãŽãŋã‚’čĄ¨į¤ēã—ãžã™ã€‚čŠŗį´°ã¯čĄ¨į¤ēされぞせん。"; - readonly ko: "í™œė„ąí™”í•˜ëŠ´ 파ėŧ ę˛Ŋęŗ  배너 ëŒ€ė‹  ėƒíƒœ ė˜ė—­ė— ⛔ ė•„ė´ėŊ˜ë§Œ í‘œė‹œëŠë‹ˆë‹¤. ėžė„¸í•œ ë‚´ėšŠė€ í‘œė‹œë˜ė§€ ė•ŠėŠĩ니다."; - readonly ru: "Đ•ŅĐģи вĐēĐģŅŽŅ‡ĐĩĐŊĐž, СĐŊĐ°Ņ‡ĐžĐē ĐąŅƒĐ´ĐĩŅ‚ ĐŋĐžĐēаСаĐŊ вĐŊŅƒŅ‚Ņ€Đ¸ ŅŅ‚Đ°Ņ‚ŅƒŅĐ°."; - readonly zh: "åĻ‚æžœå¯į”¨īŧŒįŠļ态栏内将昞į¤ē ⛔ 回标īŧŒč€Œéžæ–‡äģļč­Ļ告æ¨Ēåš…īŧŒä¸äŧšæ˜žį¤ēäģģäŊ•č¯Ļįģ†äŋĄæ¯ã€‚"; - readonly "zh-tw": "å•Ÿį”¨åžŒīŧŒå°‡åœ¨į‹€æ…‹åˆ—ä¸­éĄ¯į¤ē ⛔ 圖į¤ēīŧŒč€Œä¸æ˜¯æĒ”æĄˆč­Ļ告æŠĢåš…īŧŒä¸”ä¸æœƒéĄ¯į¤ēčŠŗį´°čŗ‡č¨Šã€‚"; - }; - readonly "If enabled, the file under 1kb will be processed in the UI thread.": { - readonly def: "If enabled, the file under 1kb will be processed in the UI thread."; - readonly es: "Archivos <1kb se procesan en hilo UI"; - readonly fr: "Si activÊe, les fichiers de moins de 1 Ko seront traitÊs sur le thread UI."; - readonly he: "אם מופ×ĸל, קב×Ļים קטנים מ-1KB י×ĸובדו בשרשור ממ׊ק המש×Ēמ׊."; - readonly ja: "有劚ãĢすると、1kbæœĒæē€ãŽãƒ•ã‚Ąã‚¤ãƒĢはUI゚ãƒŦッドでå‡Ļį†ã•ã‚Œãžã™ã€‚"; - readonly ko: "í™œė„ąí™”í•˜ëŠ´ 1kb ë¯¸ë§Œė˜ 파ėŧė€ UI ėŠ¤ë ˆë“œė—ė„œ 래ëĻŦ됩니다."; - readonly ru: "Đ•ŅĐģи вĐēĐģŅŽŅ‡ĐĩĐŊĐž, Ņ„Đ°ĐšĐģŅ‹ ĐŧĐĩĐŊҌ҈Đĩ 1КБ ĐąŅƒĐ´ŅƒŅ‚ ĐžĐąŅ€Đ°ĐąĐ°Ņ‚Ņ‹Đ˛Đ°Ņ‚ŅŒŅŅ в ĐžŅĐŊОвĐŊĐžĐŧ ĐŋĐžŅ‚ĐžĐēĐĩ."; - readonly zh: "åĻ‚æžœå¯į”¨,小äēŽ 1kb įš„æ–‡äģļ将在 UI įēŋፋ䏭处ᐆ"; - readonly "zh-tw": "å•Ÿį”¨åžŒīŧŒå°æ–ŧ 1KB įš„æĒ”æĄˆæœƒåœ¨ UI åŸˇčĄŒįˇ’ä¸­č™•į†ã€‚"; - }; - readonly "If enabled, the notification of hidden files change will be suppressed.": { - readonly def: "If enabled, the notification of hidden files change will be suppressed."; - readonly es: "Si se habilita, se suprimirÃĄ la notificaciÃŗn de cambios en archivos ocultos."; - readonly fr: "Si activÊe, les notifications de modifications des fichiers cachÊs seront supprimÊes."; - readonly he: "אם מופ×ĸל, ה×Ēראו×Ē ×ĸל שינוי בקב×Ļים נץ×Ēרים יודחקו."; - readonly ja: "有劚ãĢã™ã‚‹ã¨ã€éš ã—ãƒ•ã‚Ąã‚¤ãƒĢぎ変更通įŸĨが抑åˆļされぞす。"; - readonly ko: "í™œė„ąí™”í•˜ëŠ´ ėˆ¨ę˛¨ė§„ 파ėŧ ëŗ€ę˛Ŋ ė•ŒëĻŧė´ ė–ĩė œëŠë‹ˆë‹¤."; - readonly ru: "Đ•ŅĐģи вĐēĐģŅŽŅ‡ĐĩĐŊĐž, ŅƒĐ˛ĐĩĐ´ĐžĐŧĐģĐĩĐŊиĐĩ Ой иСĐŧĐĩĐŊĐĩĐŊии ҁĐēҀҋ҂ҋ҅ Ņ„Đ°ĐšĐģОв ĐąŅƒĐ´ĐĩŅ‚ ĐŋОдавĐģĐĩĐŊĐž."; - readonly zh: "åĻ‚æžœå¯į”¨,将不再通įŸĨéšč—æ–‡äģļčĸĢæ›´æ”š"; - readonly "zh-tw": "å•Ÿį”¨åžŒīŧŒå°‡ä¸å†é€šįŸĨ隱藏æĒ”æĄˆčŽŠæ›´ã€‚"; - }; - readonly "If this enabled, all chunks will be stored with the revision made from its content. (Previous behaviour)": { - readonly def: "If this enabled, all chunks will be stored with the revision made from its content. (Previous behaviour)"; - readonly es: "Si se habilita, todos los chunks se almacenan con la revisiÃŗn hecha desde su contenido. (comportamiento anterior)"; - readonly fr: "Si activÊe, tous les fragments seront stockÊs avec la rÊvision issue de leur contenu. (Comportement prÊcÊdent)"; - readonly he: "אם מופ×ĸל, כל הנ×Ēחים יישמרו ×ĸם גרסה המבוסס×Ē ×ĸל ×Ēוכנם. (ה×Ēנהגו×Ē ×§×•×“×ž×Ē)"; - readonly ja: "有劚ãĢすると、すずãĻãŽãƒãƒŖãƒŗã‚¯ã¯ã‚ŗãƒŗãƒ†ãƒŗãƒ„ã‹ã‚‰äŊœæˆã•れたãƒĒãƒ“ã‚¸ãƒ§ãƒŗã¨å…ąãĢäŋå­˜ã•れぞすīŧˆäģĨ前ぎ動äŊœīŧ‰ã€‚"; - readonly ko: "ė´ ė˜ĩė…˜ė´ í™œė„ąí™”ë˜ëŠ´ ëĒ¨ë“  랭íŦ는 ėŊ˜í…ė¸ ė—ė„œ ėƒė„ąëœ ëĻŦëš„ė „ęŗŧ 함ęģ˜ ė €ėžĨ됩니다. (ė´ė „ ë™ėž‘)"; - readonly ru: "Đ•ŅĐģи вĐēĐģŅŽŅ‡ĐĩĐŊĐž, Đ˛ŅĐĩ Ņ‡Đ°ĐŊĐēи ĐąŅƒĐ´ŅƒŅ‚ Ņ…Ņ€Đ°ĐŊĐ¸Ņ‚ŅŒŅŅ ҁ Ņ€ĐĩвиСиĐĩĐš иС ŅĐžĐ´ĐĩŅ€ĐļиĐŧĐžĐŗĐž."; - readonly zh: "åĻ‚æžœå¯į”¨,所有 chunks 将äŊŋį”¨æ šæŽå…ļå†…åŽšį”Ÿæˆįš„äŋŽčŽĸį‰ˆæœŦ存储(äģĨå‰įš„čĄŒä¸ē)"; - readonly "zh-tw": "å•Ÿį”¨åžŒīŧŒæ‰€æœ‰ chunks éƒŊ會äģĨå…ļ內厚į”ĸį”Ÿįš„äŋŽč¨‚į‰ˆæœŦå„˛å­˜īŧˆčˆŠæœ‰čĄŒį‚ēīŧ‰ã€‚"; - }; - readonly "If this enabled, All files are handled as case-Sensitive (Previous behaviour).": { - readonly def: "If this enabled, All files are handled as case-Sensitive (Previous behaviour)."; - readonly es: "Si se habilita, todos los archivos se manejan como sensibles a mayÃēsculas (comportamiento anterior)"; - readonly fr: "Si activÊe, tous les fichiers sont gÊrÊs en respectant la casse (comportement prÊcÊdent)."; - readonly he: "אם מופ×ĸל, כל הקב×Ļים מטופלים כ×Ēלויי רישיו×Ē (ה×Ēנהגו×Ē ×§×•×“×ž×Ē)."; - readonly ja: "有劚ãĢすると、すずãĻãŽãƒ•ã‚Ąã‚¤ãƒĢは大文字小文字をåŒēåˆĨしãĻå‡Ļį†ã•ã‚Œãžã™īŧˆäģĨ前ぎ動äŊœīŧ‰ã€‚"; - readonly ko: "ė´ ė˜ĩė…˜ė´ í™œė„ąí™”ë˜ëŠ´ ëĒ¨ë“  파ėŧė´ ëŒ€ė†ŒëŦ¸ėžëĨŧ ęĩŦëļ„하ė—Ŧ 래ëĻŦ됩니다 (ė´ė „ ë™ėž‘)."; - readonly ru: "Đ•ŅĐģи вĐēĐģŅŽŅ‡ĐĩĐŊĐž, Đ˛ŅĐĩ Ņ„Đ°ĐšĐģŅ‹ ĐžĐąŅ€Đ°ĐąĐ°Ņ‚Ņ‹Đ˛Đ°ŅŽŅ‚ŅŅ ҁ ŅƒŅ‡Ņ‘Ņ‚ĐžĐŧ Ņ€ĐĩĐŗĐ¸ŅŅ‚Ņ€Đ°."; - readonly zh: "åĻ‚æžœå¯į”¨,所有文äģļéƒŊ将视ä¸ēåŒē分大小写(äģĨå‰įš„čĄŒä¸ē)"; - readonly "zh-tw": "å•Ÿį”¨åžŒīŧŒæ‰€æœ‰æĒ”æĄˆéƒŊ會äģĨ區分大小å¯Ģæ–šåŧč™•ᐆīŧˆčˆŠæœ‰čĄŒį‚ēīŧ‰ã€‚"; - }; - readonly "If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature.": { - readonly def: "If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature."; - readonly es: "Divide chunks en segmentos semÃĄnticos. No todos los sistemas lo soportan"; - readonly fr: "Si activÊe, les fragments seront dÊcoupÊs en segments sÊmantiquement signifiants. Toutes les plateformes ne prennent pas en charge cette fonctionnalitÊ."; - readonly he: "אם מופ×ĸל, × ×Ēחים יפו×Ļלו לחלקים ×ĸם מ׊מ×ĸו×Ē ×Ą×ž× ×˜×™×Ē. לא כל הפלטפורמו×Ē ×Ēומכו×Ē ×‘×Ēכונה זו."; - readonly ja: "有劚ãĢã™ã‚‹ã¨ã€ãƒãƒŖãƒŗã‚¯ã¯æ„å‘ŗįš„ãĢ有意ãĒã‚ģã‚°ãƒĄãƒŗãƒˆãĢåˆ†å‰˛ã•ã‚Œãžã™ã€‚ã™ãšãĻぎプナットフりãƒŧムがこぎ抟čƒŊをã‚ĩポãƒŧトしãĻいるわけではありぞせん。"; - readonly ko: "ė´ ė˜ĩė…˜ė„ í™œė„ąí™”í•˜ëŠ´ 랭íŦ가 ëŦ¸ë‹¨ė´ë‚˜ ė˜ë¯¸ ë‹¨ėœ„ëĄœ ë‚˜ë‰˜ė–´ ė €ėžĨ됩니다. 단, ė´ 기ëŠĨė€ ėŧëļ€ í”ŒëžĢíŧė—ė„œëŠ” ė§€ė›ë˜ė§€ ė•Šė„ 눘 ėžˆėŠĩ니다."; - readonly ru: "Đ•ŅĐģи вĐēĐģŅŽŅ‡ĐĩĐŊĐž, Ņ‡Đ°ĐŊĐēи ĐąŅƒĐ´ŅƒŅ‚ Ņ€Đ°ĐˇĐ´ĐĩĐģĐĩĐŊŅ‹ ĐŊа ҁĐĩĐŧаĐŊŅ‚Đ¸Ņ‡ĐĩҁĐēи СĐŊĐ°Ņ‡Đ¸ĐŧŅ‹Đĩ ҁĐĩĐŗĐŧĐĩĐŊ҂ҋ."; - readonly zh: "åĻ‚æžœå¯į”¨æ­¤åŠŸčƒŊ,数捎块将čĸĢåˆ†å‰˛æˆå…ˇæœ‰č¯­äš‰æ„äš‰įš„æŽĩčŊ。åšļéžæ‰€æœ‰åšŗå°éƒŊ支持此功čƒŊ"; - readonly "zh-tw": "å•Ÿį”¨åžŒīŧŒchunks 會䞝čĒžæ„åˆ‡åˆ†æˆæœ‰æ„įžŠįš„å€æŽĩīŧŒä¸Ļéžæ‰€æœ‰åšŗå°éƒŊ支援此功čƒŊ。"; - }; - readonly "If this is set, changes to local files which are matched by the ignore files will be skipped. Remote changes are determined using local ignore files.": { - readonly def: "If this is set, changes to local files which are matched by the ignore files will be skipped. Remote changes are determined using local ignore files."; - readonly es: "Saltar cambios en archivos locales que coincidan con ignore files. Cambios remotos usan ignore files locales"; - readonly fr: "Si dÊfini, les modifications des fichiers locaux correspondant aux fichiers d'exclusion seront ignorÊes. Les changements distants sont dÊterminÊs à l'aide des fichiers d'exclusion locaux."; - readonly he: "אם מוגדר, שינויים בקב×Ļים מקומיים ה×Ēואמים לקב×Ļי הה×Ē×ĸלמו×Ē ×™×™×“×œ×’×•. שינויים מרוחקים נקב×ĸים לפי קב×Ļי הה×Ē×ĸלמו×Ē ×”×ž×§×•×ž×™×™×."; - readonly ja: "ã“ã‚Œã‚’č¨­åŽšã™ã‚‹ã¨ã€é™¤å¤–ãƒ•ã‚Ąã‚¤ãƒĢãĢä¸€č‡´ã™ã‚‹ãƒ­ãƒŧã‚ĢãƒĢãƒ•ã‚Ąã‚¤ãƒĢぎ変更ぱキップされぞす。ãƒĒãƒĸãƒŧトぎ変更はロãƒŧã‚ĢãƒĢãŽį„ĄčĻ–ãƒ•ã‚Ąã‚¤ãƒĢをäŊŋį”¨ã—ãĻ判厚されぞす。"; - readonly ko: "ė´ ė˜ĩė…˜ė„ í™œė„ąí™”í•˜ëŠ´, ė œė™¸ ęˇœėš™ 파ėŧ뗐 ėŧėš˜í•˜ëŠ” 로ėģŦ 파ėŧė˜ ëŗ€ę˛Ŋ ė‚Ŧí•­ė€ 건너뜁니다. ė›ę˛Š ëŗ€ę˛Ŋ ė—Ŧëļ€ ë˜í•œ 로ėģŦė˜ ė œė™¸ ęˇœėš™ 파ėŧ뗐 따ëŧ 판단됩니다."; - readonly ru: "If this is set, changes to local files which are matched by the ignore files will be skipped. Remote changes are determined using local ignore files."; - readonly zh: "åĻ‚æžœčŽžįŊŽä熿­¤éĄš,与åŋŊį•Ĩ文äģļåŒšé…įš„æœŦ地文äģļįš„æ›´æ”šå°†čĸĢ莺čŋ‡ã€‚čŋœį¨‹æ›´æ”šäŊŋᔍæœŦ地åŋŊį•Ĩ文äģļįĄŽåŽš"; - }; - readonly "If this option is enabled, PouchDB will hold the connection open for 60 seconds, and if no change arrives in that time, close and reopen the socket, instead of holding it open indefinitely. Useful when a proxy limits request duration but can increase resource usage.": { - readonly def: "If this option is enabled, PouchDB will hold the connection open for 60 seconds, and if no change arrives in that time, close and reopen the socket, instead of holding it open indefinitely. Useful when a proxy limits request duration but can increase resource usage."; - readonly es: "Mantiene conexiÃŗn 60s. Si no hay cambios, reinicia socket. Útil con proxies limitantes"; - readonly fr: "Si cette option est activÊe, PouchDB maintiendra la connexion ouverte pendant 60 secondes, et si aucun changement n'arrive durant cette pÊriode, fermera et rouvrira la socket au lieu de la garder ouverte indÊfiniment. Utile lorsqu'un proxy limite la durÊe des requÃĒtes, mais peut augmenter l'utilisation des ressources."; - readonly he: "אם אפשרו×Ē ×–×• מופ×ĸל×Ē, PouchDB ישמור א×Ē ×”×—×™×‘×•×¨ פ×Ēוח ל-60 שניו×Ē, ואם אין שינוי בפרק זמן זה, יסגור ויפ×Ēח מחדש א×Ē ×”×Š×§×ĸ, במקום להחזיק או×Ēו פ×Ēוח ללא הגבלה. שימושי כשפרוקסי מגביל משך בקשו×Ē, אך ×ĸשוי להגביר שימוש במשאבים."; - readonly ja: "こぎã‚Ēãƒ—ã‚ˇãƒ§ãƒŗã‚’æœ‰åŠšãĢすると、PouchDBはæŽĨįļšã‚’60į§’é–“äŋæŒã—、そぎ間ãĢ通äŋĄãŒãĒい場合、一åēĻæŽĨįļšã‚’閉じãĻ再æŽĨįļšã—ぞす。æŽĨįļšã‚’į„ĄæœŸé™ãĢäŋæŒã™ã‚‹äģŖã‚ã‚ŠãĢこぎ動äŊœã‚’čĄŒã„ãžã™ã€‚ãƒ—ãƒ­ã‚­ã‚ˇ(CloudflareãĒお)がãƒĒクエ゚トぎ持į™‚é–“ã‚’åˆļ限しãĻいる場合ãĢæœ‰į”¨ã§ã™ãŒã€ãƒĒã‚Ŋãƒŧ゚äŊŋį”¨é‡ãŒåĸ—加する可čƒŊ性がありぞす。"; - readonly ko: "ė´ ė˜ĩė…˜ė´ í™œė„ąí™”ë˜ëŠ´ PouchDB는 ė—°ę˛°ė„ ë”ė´ėƒ ëŦ´í•œížˆ ė—´ė–´ë‘ė§€ ė•Šęŗ  60봈 ë™ė•ˆ ėœ ė§€í•Šë‹ˆë‹¤. ꡸ ė‹œę°„ ë‚´ė— ëŗ€ę˛Ŋ ė‚Ŧí•­ė´ ė—†ėœŧ늴 ė†Œėŧ“ė„ ë‹Ģęŗ  ë‹¤ė‹œ ė—Ŋ니다. í”„ëĄė‹œę°€ ėš”ė˛­ ė§€ė† ė‹œę°„ė„ ė œí•œí•  때 ėœ ėšŠí•˜ė§€ë§Œ ëĻŦė†ŒėŠ¤ ė‚ŦėšŠëŸ‰ė´ ėĻę°€í•  눘 ėžˆėŠĩ니다."; - readonly ru: "If this option is enabled, PouchDB will hold the connection open for 60 seconds, and if no change arrives in that time, close and reopen the socket, instead of holding it open indefinitely. Useful when a proxy limits request duration but can increase resource usage."; - readonly zh: "åĻ‚æžœå¯į”¨æ­¤é€‰éĄš,PouchDB 将äŋæŒčŋžæŽĨ打åŧ€ 60 į§’,åĻ‚æžœåœ¨æ­¤æ—ļé—´å†…æ˛Ąæœ‰æ›´æ”šåˆ°čžž,åˆ™å…ŗé—­åšļ重新打åŧ€åĨ—æŽĨ字,č€Œä¸æ˜¯æ— é™æœŸäŋæŒæ‰“åŧ€ã€‚åŊ“äģŖį†é™åˆļč¯ˇæą‚æŒįģ­æ—ļ间æ—ļæœ‰į”¨,äŊ†å¯čƒŊäŧšåĸžåŠ čĩ„æēäŊŋį”¨ÃŸ"; - }; - readonly "If you reached the payload size limit when using IBM Cloudant, please decrease batch size and batch limit to a lower value.": { - readonly def: "If you reached the payload size limit when using IBM Cloudant, please decrease batch size and batch limit to a lower value."; - readonly "zh-tw": "åĻ‚æžœäŊ åœ¨äŊŋᔍ IBM Cloudant æ™‚é‡åˆ°č˛ čŧ‰å¤§å°ä¸Šé™īŧŒčĢ‹čĒŋäŊŽæ‰šæŦĄå¤§å°čˆ‡æ‰šæŦĄä¸Šé™ã€‚"; - }; - readonly "Ignore and Proceed": { - readonly def: "Ignore and Proceed"; - readonly ja: "į„ĄčĻ–ã—ãĻįļščĄŒ"; - readonly ko: "ëŦ´ė‹œí•˜ęŗ  ęŗ„ė†"; - readonly ru: "Đ˜ĐŗĐŊĐžŅ€Đ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ и ĐŋŅ€ĐžĐ´ĐžĐģĐļĐ¸Ņ‚ŅŒ"; - readonly zh: "åŋŊį•Ĩåšļįģ§įģ­"; - readonly "zh-tw": "åŋŊį•Ĩä¸ĻįšŧįēŒ"; - }; - readonly "Ignore files": { - readonly def: "Ignore files"; - readonly es: "Archivos a ignorar"; - readonly fr: "Fichiers d'exclusion"; - readonly he: "קב×Ļי ה×Ē×ĸלמו×Ē"; - readonly ja: "é™¤å¤–ãƒ•ã‚Ąã‚¤ãƒĢ"; - readonly ko: "ė œė™¸ ęˇœėš™ 파ėŧ"; - readonly ru: "ФаКĐģŅ‹ Đ´ĐģŅ Đ¸ĐŗĐŊĐžŅ€Đ¸Ņ€ĐžĐ˛Đ°ĐŊĐ¸Ņ"; - readonly zh: "åŋŊį•Ĩ文äģļ"; - }; - readonly "Ignore patterns": { - readonly def: "Ignore patterns"; - readonly es: "Patrones de exclusiÃŗn"; - readonly ja: "除外パã‚ŋãƒŧãƒŗ"; - readonly ko: "ëŦ´ė‹œ 패턴"; - readonly ru: "ШайĐģĐžĐŊŅ‹ Đ¸ŅĐēĐģŅŽŅ‡ĐĩĐŊĐ¸Ņ"; - readonly zh: "åŋŊį•Ĩæ¨Ąåŧ"; - readonly "zh-tw": "åŋŊį•Ĩæ¨Ąåŧ"; - }; - readonly "Import connection": { - readonly def: "Import connection"; - readonly es: "Importar conexiÃŗn"; - readonly ja: "æŽĨįļšã‚’ã‚¤ãƒŗãƒãƒŧト"; - readonly ko: "뗰枰 ę°€ė ¸ė˜¤ę¸°"; - readonly ru: "ИĐŧĐŋĐžŅ€Ņ‚Đ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ ĐŋОдĐēĐģŅŽŅ‡ĐĩĐŊиĐĩ"; - readonly zh: "å¯ŧå…ĨčŋžæŽĨ"; - readonly "zh-tw": "匯å…Ĩé€Ŗįˇš"; - }; - readonly "Incubate Chunks in Document": { - readonly def: "Incubate Chunks in Document"; - readonly es: "Incubar chunks en documento"; - readonly fr: "Incuber les fragments dans le document"; - readonly he: "בשל × ×Ēחים ב×Ēוך המסמך"; - readonly ja: "ドキãƒĨãƒĄãƒŗãƒˆå†…ã§ãƒãƒŖãƒŗã‚¯ã‚’ä¸€æ™‚äŋįŽĄã™ã‚‹"; - readonly ko: "ëŦ¸ė„œ 내 ëŗ€ę˛Ŋ 기록 ėž„ė‹œ ëŗ´ę´€"; - readonly ru: "ИĐŊĐēŅƒĐąĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ Ņ‡Đ°ĐŊĐēи в Đ´ĐžĐē҃ĐŧĐĩĐŊŅ‚Đĩ"; - readonly zh: "åœ¨æ–‡æĄŖä¸­å­ĩ化块"; - }; - readonly "Initialise all journal history, On the next sync, every item will be received and sent.": { - readonly def: "Initialise all journal history, On the next sync, every item will be received and sent."; - readonly es: "Restablece todo el historial del diario. En la prÃŗxima sincronizaciÃŗn se recibirÃĄn y enviarÃĄn todos los elementos."; - readonly ja: "すずãĻãŽã‚¸ãƒŖãƒŧナãƒĢåąĨ歴を初期化しぞす。æŦĄå›žãŽåŒæœŸæ™‚ãĢ、すずãĻãŽé …į›ŽãŒå†å—äŋĄãƒģ再送äŋĄã•れぞす。"; - readonly ko: "ëĒ¨ë“  ė €ë„ ę¸°ëĄė„ ė´ˆę¸°í™”í•Šë‹ˆë‹¤. ë‹¤ėŒ 동기화 때 ëĒ¨ë“  항ëĒŠė„ ë‹¤ė‹œ ë°›ęŗ  ë‹¤ė‹œ ëŗ´ëƒ…ë‹ˆë‹¤."; - readonly ru: "ИĐŊĐ¸Ņ†Đ¸Đ°ĐģĐ¸ĐˇĐ¸Ņ€ŅƒĐĩŅ‚ Đ˛ŅŅŽ Đ¸ŅŅ‚ĐžŅ€Đ¸ŅŽ ĐļŅƒŅ€ĐŊаĐģа. ĐŸŅ€Đ¸ ҁĐģĐĩĐ´ŅƒŅŽŅ‰ĐĩĐš ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸ ĐēаĐļĐ´Ņ‹Đš ŅĐģĐĩĐŧĐĩĐŊŅ‚ ĐąŅƒĐ´ĐĩŅ‚ СаĐŊОвО ĐŋĐžĐģŅƒŅ‡ĐĩĐŊ и ĐžŅ‚ĐŋŅ€Đ°Đ˛ĐģĐĩĐŊ."; - readonly zh: "初始化所有æ—Ĩåŋ—åŽ†å˛ã€‚ä¸‹æŦĄåŒæ­Ĩæ—ļīŧŒæ‰€æœ‰éĄšį›ŽéƒŊäŧšé‡æ–°æŽĨæ”ļåšļ发送。"; - readonly "zh-tw": "初始化所有æ—ĨčĒŒæ­ˇå˛ã€‚ä¸‹æŦĄåŒæ­Ĩ時īŧŒæ¯å€‹é …į›ŽéƒŊ會重新æŽĨæ”ļčˆ‡å‚ŗé€ã€‚"; - }; - readonly "Initialise journal received history. On the next sync, every item except this device sent will be downloaded again.": { - readonly def: "Initialise journal received history. On the next sync, every item except this device sent will be downloaded again."; - readonly "zh-tw": "初始化æŽĨæ”ļæ—ĨčĒŒæ­ˇį¨‹ã€‚ä¸‹æŦĄåŒæ­Ĩ時īŧŒé™¤ä熿­¤čŖįŊŽåˇ˛é€å‡ēįš„é …į›Žå¤–īŧŒå…ļäģ–é …į›ŽéƒŊ會再æŦĄä¸‹čŧ‰ã€‚"; - }; - readonly "Initialise journal sent history. On the next sync, every item except this device received will be sent again.": { - readonly def: "Initialise journal sent history. On the next sync, every item except this device received will be sent again."; - readonly "zh-tw": "åˆå§‹åŒ–å‚ŗé€æ—ĨčĒŒæ­ˇį¨‹ã€‚ä¸‹æŦĄåŒæ­Ĩ時īŧŒé™¤ä熿­¤čŖįŊŽåˇ˛æŽĨæ”ļįš„é …į›Žå¤–īŧŒå…ļäģ–é …į›ŽéƒŊ會再æŦĄå‚ŗé€ã€‚"; - }; - readonly "Interval (sec)": { - readonly def: "Interval (sec)"; - readonly es: "Intervalo (segundos)"; - readonly fr: "Intervalle (sec)"; - readonly he: "מרווח (שניו×Ē)"; - readonly ja: "į§’"; - readonly ko: "간격 (봈)"; - readonly ru: "ИĐŊŅ‚ĐĩŅ€Đ˛Đ°Đģ (ҁĐĩĐē)"; - readonly zh: "间隔(į§’)"; - }; - readonly "K.exp": { - readonly def: "Experimental"; - readonly fr: "ExpÊrimental"; - readonly he: "ניסיוני"; - readonly ja: "čŠĻ験抟čƒŊ"; - readonly ko: "ė‹¤í—˜ 기ëŠĨ"; - readonly ru: "Đ­ĐēҁĐŋĐĩŅ€Đ¸ĐŧĐĩĐŊŅ‚Đ°ĐģҌĐŊĐ°Ņ"; - readonly zh: "厞éĒŒæ€§"; - }; - readonly "K.long_p2p_sync": { - readonly def: "Peer-to-Peer Sync"; - readonly fr: "Synchronisation pair-à-pair"; - readonly he: "%{title_p2p_sync}"; - readonly ja: "Peer-to-Peer Sync (čŠĻ験抟čƒŊ)"; - readonly ko: "í”ŧė–´ íˆŦ í”ŧė–´(P2P) 동기화 (ė‹¤í—˜ 기ëŠĨ)"; - readonly ru: "title_p2p_sync"; - readonly zh: "Peer-to-Peer同æ­Ĩ (厞éĒŒæ€§)"; - }; - readonly "K.P2P": { - readonly def: "Peer-to-Peer"; - readonly fr: "Pair-à-Pair"; - readonly he: "%{Peer}-ל-%{Peer}"; - readonly ja: "Peer-to-Peer"; - readonly ko: "í”ŧė–´-to-í”ŧė–´"; - readonly ru: "Peer-Đē-Peer"; - readonly zh: "Peer-to-Peer"; - }; - readonly "K.Peer": { - readonly def: "Peer"; - readonly fr: "Pair"; - readonly he: "×ĸמי×Ē"; - readonly ja: "Peer"; - readonly ko: "í”ŧė–´"; - readonly ru: "ĐŖŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đž"; - readonly zh: "Peer"; - }; - readonly "K.ScanCustomization": { - readonly def: "Scan customization"; - readonly fr: "Analyser la personnalisation"; - readonly he: "סרוק ה×Ēאמה אישי×Ē"; - readonly ja: "Scan customization"; - readonly ko: "ė‚ŦėšŠėž 네렕 ę˛€ėƒ‰"; - readonly ru: "Scan customization"; - readonly zh: "æ‰Ģ描č‡Ē厚䚉"; - }; - readonly "K.short_p2p_sync": { - readonly def: "P2P Sync"; - readonly fr: "Sync P2P"; - readonly he: "סנכרון P2P"; - readonly ja: "P2P Sync (čŠĻ験抟čƒŊ)"; - readonly ko: "P2P 동기화 (ė‹¤í—˜ 기ëŠĨ)"; - readonly ru: "P2P ХиĐŊŅ…Ņ€."; - readonly zh: "P2P同æ­Ĩ(厞éĒŒæ€§)"; - }; - readonly "K.title_p2p_sync": { - readonly def: "Peer-to-Peer Sync"; - readonly fr: "Synchronisation pair-à-pair"; - readonly he: "סנכרון ×ĸמי×Ē-ל×ĸמי×Ē"; - readonly ja: "Peer-to-Peer Sync"; - readonly ko: "í”ŧė–´ íˆŦ í”ŧė–´(P2P) 동기화"; - readonly ru: "ХиĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Ņ ĐŧĐĩĐļĐ´Ņƒ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°Đŧи"; - readonly zh: "Peer-to-Peer同æ­Ĩ"; - }; - readonly "Keep empty folder": { - readonly def: "Keep empty folder"; - readonly es: "Mantener carpetas vacías"; - readonly fr: "Conserver les dossiers vides"; - readonly he: "שמור ×Ēיקייה ריקה"; - readonly ja: "įŠēフりãƒĢダぎįļ­æŒ"; - readonly ko: "뚈 폴더 ėœ ė§€"; - readonly ru: "ĐĄĐžŅ…Ņ€Đ°ĐŊŅŅ‚ŅŒ ĐŋŅƒŅŅ‚Ņ‹Đĩ ĐŋаĐŋĐēи"; - readonly zh: "äŋį•™įŠē文äģļ多"; - }; - readonly lang_def: { - readonly def: "Default"; - readonly fr: "Par dÊfaut"; - readonly he: "בריר×Ē ×ž×—×“×œ"; - readonly ja: "Default"; - readonly ko: "Default"; - readonly ru: "По ҃ĐŧĐžĐģŅ‡Đ°ĐŊĐ¸ŅŽ"; - readonly zh: "Default"; - }; - readonly "lang-de": { - readonly def: "Deutsche"; - readonly es: "AlemÃĄn"; - readonly fr: "Deutsche"; - readonly he: "Deutsche"; - readonly ja: "Deutsche"; - readonly ko: "Deutsche"; - readonly ru: "Deutsch"; - readonly zh: "Deutsche"; - }; - readonly "lang-def": { - readonly def: "Default"; - readonly fr: "Par dÊfaut"; - readonly he: "%{lang_def}"; - readonly ja: "Default"; - readonly ko: "Default"; - readonly ru: "lang_def"; - readonly zh: "Default"; - }; - readonly "lang-es": { - readonly def: "EspaÃąol"; - readonly es: "EspaÃąol"; - readonly fr: "EspaÃąol"; - readonly he: "EspaÃąol"; - readonly ja: "EspaÃąol"; - readonly ko: "EspaÃąol"; - readonly ru: "EspaÃąol"; - readonly zh: "EspaÃąol"; - }; - readonly "lang-fr": { - readonly def: "Français"; - readonly es: "Français"; - readonly fr: "Français"; - readonly he: "Français"; - readonly ja: "Français"; - readonly ko: "Français"; - readonly ru: "Français"; - readonly zh: "Français"; - }; - readonly "lang-he": { - readonly def: "Hebrew"; - readonly he: "×ĸברי×Ē"; - }; - readonly "lang-ja": { - readonly def: "æ—ĨæœŦčĒž"; - readonly es: "JaponÊs"; - readonly fr: "æ—ĨæœŦčĒž"; - readonly he: "æ—ĨæœŦčĒž"; - readonly ja: "æ—ĨæœŦčĒž"; - readonly ko: "æ—ĨæœŦčĒž"; - readonly ru: "æ—ĨæœŦčĒž"; - readonly zh: "æ—ĨæœŦčĒž"; - }; - readonly "lang-ko": { - readonly def: "한ęĩ­ė–´"; - readonly fr: "한ęĩ­ė–´"; - readonly he: "한ęĩ­ė–´"; - readonly ja: "한ęĩ­ė–´"; - readonly ko: "한ęĩ­ė–´"; - readonly ru: "한ęĩ­ė–´"; - readonly zh: "한ęĩ­ė–´"; - }; - readonly "lang-ru": { - readonly def: "Đ ŅƒŅŅĐēиК"; - readonly es: "Ruso"; - readonly fr: "Đ ŅƒŅŅĐēиК"; - readonly he: "Đ ŅƒŅŅĐēиК"; - readonly ja: "Đ ŅƒŅŅĐēиК"; - readonly ko: "Đ ŅƒŅŅĐēиК"; - readonly ru: "Đ ŅƒŅŅĐēиК"; - readonly zh: "Đ ŅƒŅŅĐēиК"; - }; - readonly "lang-zh": { - readonly def: "įŽ€äŊ“中文"; - readonly es: "Chino simplificado"; - readonly fr: "įŽ€äŊ“中文"; - readonly he: "įŽ€äŊ“中文"; - readonly ja: "įŽ€äŊ“中文"; - readonly ko: "įŽ€äŊ“中文"; - readonly ru: "įŽ€äŊ“中文"; - readonly zh: "įŽ€äŊ“中文"; - }; - readonly "lang-zh-tw": { - readonly def: "įšéĢ”ä¸­æ–‡"; - readonly es: "Chino tradicional"; - readonly fr: "įšéĢ”ä¸­æ–‡"; - readonly he: "įšéĢ”ä¸­æ–‡"; - readonly ja: "įšéĢ”ä¸­æ–‡"; - readonly ko: "įšéĢ”ä¸­æ–‡"; - readonly ru: "įšéĢ”ä¸­æ–‡"; - readonly zh: "įšéĢ”ä¸­æ–‡"; - }; - readonly Later: { - readonly def: "Later"; - readonly es: "MÃĄs tarde"; - readonly ja: "垌で"; - readonly ko: "ë‚˜ė¤‘ė—"; - readonly ru: "ПозĐļĐĩ"; - readonly zh: "į¨åŽ"; - readonly "zh-tw": "į¨åžŒ"; - }; - readonly "Limit: {datetime} ({timestamp})": { - readonly def: "Limit: {datetime} ({timestamp})"; - readonly es: "Límite: {datetime} ({timestamp})"; - readonly ja: "åˆļ限: {datetime} ({timestamp})"; - readonly ko: "ė œí•œ: {datetime} ({timestamp})"; - readonly ru: "ЛиĐŧĐ¸Ņ‚: {datetime} ({timestamp})"; - readonly zh: "限åˆļīŧš{datetime}īŧˆ{timestamp}īŧ‰"; - readonly "zh-tw": "限åˆļīŧš{datetime}īŧˆ{timestamp}īŧ‰"; - }; - readonly "LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured.": { - readonly def: "LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured."; - readonly es: "LiveSync no puede manejar mÃēltiples bÃŗvedas con mismo nombre sin prefijo. Se configura automÃĄticamente"; - readonly fr: "LiveSync ne peut pas gÊrer plusieurs coffres portant le mÃĒme nom sans prÊfixe distinct. Ceci devrait ÃĒtre configurÊ automatiquement."; - readonly he: "LiveSync אינו יכול לטפל במספר כספו×Ē ×ĸם או×Ēו ׊ם ללא קידומ×Ē ×Š×•× ×”. הדבר אמור להיו×Ē ×ž×•×’×“×¨ אוטומטי×Ē."; - readonly ja: "LiveSyncは、æŽĨ頭辞īŧˆãƒ—ãƒŦãƒ•ã‚Ŗãƒƒã‚¯ã‚šīŧ‰ãŽãĒい同名ぎäŋįŽĄåēĢ(Vault)ã‚’æ‰ąã†ã“ã¨ãŒã§ããžã›ã‚“ã€‚ã“ã‚Œã¯č‡Ēå‹•įš„ãĢč¨­åŽšã•ã‚Œãžã™ã€‚"; - readonly ko: "LiveSync는 ė„œëĄœ 다ëĨ¸ ė ‘ë‘ė‚Ŧ ė—†ė´ 동ėŧ한 ė´ëĻ„ė„ 氀맄 ė—ŦëŸŦ ëŗŧ트ëĨŧ 래ëĻŦ할 눘 ė—†ėŠĩ니다. ė´ëŠ” ėžë™ėœŧ로 ęĩŦė„ąë˜ė–´ė•ŧ 합니다."; - readonly ru: "LiveSync ĐŊĐĩ ĐŧĐžĐļĐĩŅ‚ ĐžĐąŅ€Đ°ĐąĐžŅ‚Đ°Ņ‚ŅŒ ĐŊĐĩҁĐēĐžĐģҌĐēĐž Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰ ҁ ОдиĐŊаĐēĐžĐ˛Ņ‹Đŧ иĐŧĐĩĐŊĐĩĐŧ ĐąĐĩС Ņ€Đ°ĐˇĐŊҋ҅ ĐŋŅ€ĐĩŅ„Đ¸ĐēŅĐžĐ˛."; - readonly zh: "LiveSync æ— æŗ•å¤„į†å…ˇæœ‰į›¸åŒåį§°äŊ†æ˛Ąæœ‰ä¸åŒå‰įŧ€įš„多ä¸Ēåē“。čŋ™åē”č¯Ĩč‡Ē动配įŊŽ"; - }; - readonly "liveSyncReplicator.beforeLiveSync": { - readonly def: "Before LiveSync, start OneShot once..."; - readonly es: "Antes de LiveSync, inicia OneShot..."; - readonly fr: "Avant LiveSync, lancement d'un OneShot initial..."; - readonly he: "לפני LiveSync, ה×Ēחל OneShot פ×ĸם אח×Ē..."; - readonly ja: "LiveSyncぎ前ãĢ、ぞずOneShotを開始しぞす..."; - readonly ko: "LiveSync 렄뗐 OneShotė„ ë¨ŧė € ė‹œėž‘í•Šë‹ˆë‹¤..."; - readonly ru: "ПĐĩŅ€ĐĩĐ´ LiveSync СаĐŋ҃ҁĐēаĐĩĐŧ OneShot..."; - readonly zh: "在LiveSync前,先启动OneShot一æŦĄ..."; - }; - readonly "liveSyncReplicator.cantReplicateLowerValue": { - readonly def: "We can't replicate more lower value."; - readonly es: "No podemos replicar un valor mÃĄs bajo."; - readonly fr: "Impossible de rÊpliquer une valeur infÊrieure."; - readonly he: "לא ני×Ēן לשכפל ×ĸרך נמוך יו×Ēר."; - readonly ja: "これäģĨ上äŊŽã„値ではãƒŦプãƒĒã‚ąãƒŧã‚ˇãƒ§ãƒŗ(複čŖŊ)できぞせん。"; - readonly ko: "더 ë‚Žė€ 값ėœŧ로 ëŗĩė œí•  눘 ė—†ėŠĩ니다."; - readonly ru: "НĐĩĐģŅŒĐˇŅ Ņ€ĐĩĐŋĐģĐ¸Ņ†Đ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ ҁ ĐŧĐĩĐŊŅŒŅˆĐ¸Đŧ СĐŊĐ°Ņ‡ĐĩĐŊиĐĩĐŧ."; - readonly zh: "我äģŦæ— æŗ•å¤åˆļæ›´å°įš„å€ŧ"; - }; - readonly "liveSyncReplicator.checkingLastSyncPoint": { - readonly def: "Looking for the point last synchronized point."; - readonly es: "Buscando el Ãēltimo punto sincronizado."; - readonly fr: "Recherche du dernier point de synchronisation."; - readonly he: "מחפש נקוד×Ē ×”×Ą× ×›×¨×•×Ÿ האחרונה."; - readonly ja: "最垌ãĢåŒæœŸã—ãŸãƒã‚¤ãƒŗãƒˆã‚’æŽĸしãĻいぞす。"; - readonly ko: "ë§ˆė§€ë§‰ėœŧ로 동기화된 ė§€ė ė„ ė°žęŗ  ėžˆėŠĩ니다."; - readonly ru: "ĐŸĐžĐ¸ŅĐē ĐŋĐžŅĐģĐĩĐ´ĐŊĐĩĐš Ņ‚ĐžŅ‡Đēи ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸."; - readonly zh: "æŸĨ扞上æŦĄåŒæ­Ĩį‚š"; - }; - readonly "liveSyncReplicator.couldNotConnectTo": { - readonly def: "Could not connect to ${uri} : ${name}\n(${db})"; - readonly es: "No se pudo conectar a ${uri} : ${name} \n(${db})"; - readonly fr: "Connexion impossible à ${uri} : ${name}\n(${db})"; - readonly he: "לא ני×Ēן לה×Ēחבר אל ${uri} : ${name}\n(${db})"; - readonly ja: "${uri} : ${name}ãĢæŽĨįļšã§ããžã›ã‚“でした\n(${db})"; - readonly ko: "${uri}뗐 ė—°ę˛°í•  눘 ė—†ėŠĩ니다: ${name} \n(${db})"; - readonly ru: "НĐĩ ŅƒĐ´Đ°ĐģĐžŅŅŒ ĐŋОдĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒŅŅ Đē uri : name\n(db)"; - readonly zh: "æ— æŗ•čŋžæŽĨ到 ${uri} : ${name}\n(${db})"; - }; - readonly "liveSyncReplicator.couldNotConnectToRemoteDb": { - readonly def: "Could not connect to remote database: ${d}"; - readonly es: "No se pudo conectar a base de datos remota: ${d}"; - readonly fr: "Connexion à la base distante impossible : ${d}"; - readonly he: "לא ני×Ēן לה×Ēחבר למסד הנ×Ēונים המרוחק: ${d}"; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚ãĢæŽĨįļšã§ããžã›ã‚“でした: ${d}"; - readonly ko: "ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ė— ė—°ę˛°í•  눘 ė—†ėŠĩ니다: ${d}"; - readonly ru: "НĐĩ ŅƒĐ´Đ°ĐģĐžŅŅŒ ĐŋОдĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒŅŅ Đē ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊОК йаСĐĩ даĐŊĐŊҋ҅: d"; - readonly zh: "æ— æŗ•čŋžæŽĨ到čŋœį¨‹æ•°æŽåē“īŧš${d}"; - }; - readonly "liveSyncReplicator.couldNotConnectToServer": { - readonly def: "The connection to the remote has been prevented, or failed."; - readonly es: "No se pudo conectar al servidor."; - readonly fr: "Connexion au serveur impossible."; - readonly he: "לא ני×Ēן לה×Ēחבר לשר×Ē."; - readonly ja: "ã‚ĩãƒŧバãƒŧãĢæŽĨįļšã§ããžã›ã‚“でした。"; - readonly ko: "ė„œë˛„ė— ė—°ę˛°í•  눘 ė—†ėŠĩ니다."; - readonly ru: "НĐĩ ŅƒĐ´Đ°ĐģĐžŅŅŒ ĐŋОдĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒŅŅ Đē ҁĐĩŅ€Đ˛ĐĩŅ€Ņƒ."; - readonly zh: "æ— æŗ•čŋžæŽĨåˆ°æœåŠĄå™¨"; - }; - readonly "liveSyncReplicator.couldNotConnectToURI": { - readonly def: "Could not connect to ${uri}:${dbRet}"; - readonly es: "No se pudo conectar a ${uri}:${dbRet}"; - readonly fr: "Connexion impossible à ${uri}:${dbRet}"; - readonly he: "לא ני×Ēן לה×Ēחבר אל ${uri}:${dbRet}"; - readonly ja: "${uri}ãĢæŽĨįļšã§ããžã›ã‚“でした: ${dbRet}"; - readonly ko: "${uri}뗐 ė—°ę˛°í•  눘 ė—†ėŠĩ니다: ${dbRet}"; - readonly ru: "НĐĩ ŅƒĐ´Đ°ĐģĐžŅŅŒ ĐŋОдĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒŅŅ Đē uri:dbRet"; - readonly zh: "æ— æŗ•čŋžæŽĨ到 ${uri}:${dbRet}"; - }; - readonly "liveSyncReplicator.couldNotMarkResolveRemoteDb": { - readonly def: "Could not mark resolve remote database."; - readonly es: "No se pudo marcar como resuelta la base de datos remota."; - readonly fr: "Impossible de marquer la rÊsolution de la base distante."; - readonly he: "לא ני×Ēן לסמן פ×Ēרון למסד הנ×Ēונים המרוחק."; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧã‚šã‚’č§Ŗæąē済ãŋとしãĻマãƒŧクできぞせんでした。"; - readonly ko: "ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ëĨŧ 해결됨ėœŧ로 í‘œė‹œí•  눘 ė—†ėŠĩ니다."; - readonly ru: "НĐĩ ŅƒĐ´Đ°ĐģĐžŅŅŒ ĐžŅ‚ĐŧĐĩŅ‚Đ¸Ņ‚ŅŒ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊŅƒŅŽ ĐąĐ°ĐˇŅƒ даĐŊĐŊҋ҅ ĐēаĐē Ņ€Đ°ĐˇŅ€ĐĩŅˆŅ‘ĐŊĐŊŅƒŅŽ."; - readonly zh: "æ— æŗ•æ ‡čŽ°åšļ觪冺čŋœį¨‹æ•°æŽåē“"; - }; - readonly "liveSyncReplicator.liveSyncBegin": { - readonly def: "LiveSync begin..."; - readonly es: "Inicio de LiveSync..."; - readonly fr: "DÊmarrage de LiveSync..."; - readonly he: "LiveSync מ×Ēחיל..."; - readonly ja: "LiveSyncを開始..."; - readonly ko: "LiveSync ė‹œėž‘..."; - readonly ru: "ĐĐ°Ņ‡Đ°ĐģĐž LiveSync..."; - readonly zh: "LiveSync åŧ€å§‹..."; - }; - readonly "liveSyncReplicator.lockRemoteDb": { - readonly def: "Lock remote database to prevent data corruption"; - readonly es: "Bloquear base de datos remota para prevenir corrupciÃŗn de datos"; - readonly fr: "Verrouillage de la base distante pour Êviter la corruption des donnÊes"; - readonly he: "נו×ĸל מסד × ×Ēונים מרוחק למני×ĸ×Ē ×¤×’×™×ĸה בנ×Ēונים"; - readonly ja: "デãƒŧã‚ŋį ´æã‚’é˜˛ããŸã‚ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚をロック"; - readonly ko: "ë°ė´í„° ė†ėƒė„ ë°Šė§€í•˜ę¸° ėœ„í•´ ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ëĨŧ ėž ę¸‰ë‹ˆë‹¤"; - readonly ru: "БĐģĐžĐēĐ¸Ņ€ĐžĐ˛Đēа ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊОК ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅ Đ´ĐģŅ ĐŋŅ€ĐĩĐ´ĐžŅ‚Đ˛Ņ€Đ°Ņ‰ĐĩĐŊĐ¸Ņ ĐŋĐžĐ˛Ņ€ĐĩĐļĐ´ĐĩĐŊĐ¸Ņ даĐŊĐŊҋ҅"; - readonly zh: "锁厚čŋœį¨‹æ•°æŽåē“äģĨ防æ­ĸ数捎损坏"; - }; - readonly "liveSyncReplicator.markDeviceResolved": { - readonly def: "Mark this device as 'resolved'."; - readonly es: "Marcar este dispositivo como 'resuelto'."; - readonly fr: "Marquer cet appareil comme ÂĢ rÊsolu Âģ."; - readonly he: "סמן מכשיר זה כ'נפ×Ēר'."; - readonly ja: "ã“ãŽãƒ‡ãƒã‚¤ã‚šã‚’ã€Žč§Ŗæąē済ãŋ』としãĻマãƒŧク。"; - readonly ko: "ė´ 기기ëĨŧ '해결됨'ėœŧ로 í‘œė‹œí•Šë‹ˆë‹¤."; - readonly ru: "ĐžŅ‚ĐŧĐĩŅ‚Đ¸Ņ‚ŅŒ ŅŅ‚Đž ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đž ĐēаĐē ÂĢŅ€Đ°ĐˇŅ€ĐĩŅˆŅ‘ĐŊĐŊĐžĐĩÂģ."; - readonly zh: "å°†æ­¤čŽžå¤‡æ ‡čŽ°ä¸ēâ€œåˇ˛č§Ŗå†ŗâ€"; - }; - readonly "liveSyncReplicator.mismatchedTweakDetected": { - readonly def: "Some mismatches have been detected in the configuration between devices. Running a manual replication will attempt to resolve this issue."; - }; - readonly "liveSyncReplicator.oneShotSyncBegin": { - readonly def: "OneShot Sync begin... (${syncMode})"; - readonly es: "Inicio de sincronizaciÃŗn OneShot... (${syncMode})"; - readonly fr: "DÊmarrage de la synchronisation OneShot... (${syncMode})"; - readonly he: "סנכרון OneShot מ×Ēחיל... (${syncMode})"; - readonly ja: "OneShot同期を開始... (${syncMode})"; - readonly ko: "OneShot 동기화 ė‹œėž‘... (${syncMode})"; - readonly ru: "ĐĐ°Ņ‡Đ°ĐģĐž OneShot ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸... (syncMode)"; - readonly zh: "OneShot同æ­Ĩåŧ€å§‹...(${syncMode})"; - }; - readonly "liveSyncReplicator.remoteDbCorrupted": { - readonly def: "Remote database is newer or corrupted, make sure to latest version of self-hosted-livesync installed"; - readonly es: "La base de datos remota es mÃĄs nueva o estÃĄ daÃąada, asegÃērese de tener la Ãēltima versiÃŗn de self-hosted-livesync instalada"; - readonly fr: "La base distante est plus rÊcente ou corrompue, assurez-vous d'avoir installÊ la dernière version de self-hosted-livesync"; - readonly he: "מסד הנ×Ēונים המרוחק חדש יו×Ēר או פגום, ודא שגרס×Ē self-hosted-livesync המו×Ē×§× ×Ē ×”×™× ה×ĸדכני×Ē ×‘×™×•×Ēר"; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧã‚šãŒæ–°ã—ã„ã‹į ´æã—ãĻいぞす。self-hosted-livesyncぎ最新バãƒŧã‚¸ãƒ§ãƒŗãŒã‚¤ãƒŗã‚šãƒˆãƒŧãƒĢされãĻいることをįĸēčĒã—ãĻください"; - readonly ko: "ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ę°€ 더 ėĩœė‹ ė´ęą°ë‚˜ ė†ėƒë˜ė—ˆėŠĩ니다. ėĩœė‹  ë˛„ė „ė˜ self-hosted-livesync가 ė„¤ėš˜ë˜ė–´ ėžˆëŠ”ė§€ í™•ė¸í•˜ė„¸ėš”"; - readonly ru: "ĐŖĐ´Đ°ĐģŅ‘ĐŊĐŊĐ°Ņ йаСа даĐŊĐŊҋ҅ ĐŊОвĐĩĐĩ иĐģи ĐŋĐžĐ˛Ņ€ĐĩĐļĐ´ĐĩĐŊа, ŅƒĐąĐĩĐ´Đ¸Ņ‚ĐĩҁҌ, Ņ‡Ņ‚Đž ŅƒŅŅ‚Đ°ĐŊОвĐģĐĩĐŊа ĐŋĐžŅĐģĐĩĐ´ĐŊŅŅ вĐĩŅ€ŅĐ¸Ņ self-hosted-livesync"; - readonly zh: "čŋœį¨‹æ•°æŽåē“čžƒæ–°æˆ–åˇ˛æŸå,č¯ˇįĄŽäŋåˇ˛åŽ‰čŖ…æœ€æ–°į‰ˆæœŦįš„self-hosted-livesync"; - }; - readonly "liveSyncReplicator.remoteDbCreatedOrConnected": { - readonly def: "Remote Database Created or Connected"; - readonly es: "Base de datos remota creada o conectada"; - readonly fr: "Base distante crÊÊe ou connectÊe"; - readonly he: "מסד הנ×Ēונים המרוחק נו×Ļר או חובר"; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚がäŊœæˆãžãŸã¯æŽĨįļšã•れぞした"; - readonly ko: "ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ę°€ ėƒė„ąë˜ęą°ë‚˜ ė—°ę˛°ë˜ė—ˆėŠĩ니다"; - readonly ru: "ĐŖĐ´Đ°ĐģŅ‘ĐŊĐŊĐ°Ņ йаСа даĐŊĐŊҋ҅ ŅĐžĐˇĐ´Đ°ĐŊа иĐģи ĐŋОдĐēĐģŅŽŅ‡ĐĩĐŊа"; - readonly zh: "čŋœį¨‹æ•°æŽåē“åˇ˛åˆ›åģ翈–čŋžæŽĨ"; - }; - readonly "liveSyncReplicator.remoteDbDestroyed": { - readonly def: "Remote Database Destroyed"; - readonly es: "Base de datos remota destruida"; - readonly fr: "Base distante dÊtruite"; - readonly he: "מסד הנ×Ēונים המרוחק נהרס"; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚が削除されぞした"; - readonly ko: "ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ę°€ ė‚­ė œë˜ė—ˆėŠĩ니다"; - readonly ru: "ĐŖĐ´Đ°ĐģŅ‘ĐŊĐŊĐ°Ņ йаСа даĐŊĐŊҋ҅ ҃ĐŊĐ¸Ņ‡Ņ‚ĐžĐļĐĩĐŊа"; - readonly zh: "čŋœį¨‹æ•°æŽåē“åˇ˛é”€æ¯"; - }; - readonly "liveSyncReplicator.remoteDbDestroyError": { - readonly def: "Something happened on Remote Database Destroy:"; - readonly es: "Algo ocurriÃŗ al destruir base de datos remota:"; - readonly fr: "Un problème est survenu lors de la destruction de la base distante :"; - readonly he: "איר×ĸה שגיאה בהריס×Ē ×ž×Ą×“ הנ×Ēונים המרוחק:"; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚ぎ削除中ãĢå•éĄŒãŒį™ēį”Ÿã—ãžã—ãŸ:"; - readonly ko: "ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ ė‚­ė œ 뤑 똤ëĨ˜ę°€ ë°œėƒí–ˆėŠĩ니다:"; - readonly ru: "ĐŸŅ€ĐžĐ¸ĐˇĐžŅˆĐģа ĐžŅˆĐ¸ĐąĐēа ĐŋŅ€Đ¸ ҃ĐŊĐ¸Ņ‡Ņ‚ĐžĐļĐĩĐŊии ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊОК ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅:"; - readonly zh: "čŋœį¨‹æ•°æŽåē“销毁æ—ļå‘į”Ÿé”™č¯¯īŧš"; - }; - readonly "liveSyncReplicator.remoteDbMarkedResolved": { - readonly def: "Remote database has been marked resolved."; - readonly es: "Base de datos remota marcada como resuelta."; - readonly fr: "La base distante a ÊtÊ marquÊe comme rÊsolue."; - readonly he: "מסד הנ×Ēונים המרוחק סומן כנפ×Ēר."; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧã‚šãŒč§Ŗæąē済ãŋとしãĻマãƒŧクされぞした。"; - readonly ko: "ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ę°€ 해결됨ėœŧ로 í‘œė‹œë˜ė—ˆėŠĩ니다."; - readonly ru: "ĐŖĐ´Đ°ĐģŅ‘ĐŊĐŊĐ°Ņ йаСа даĐŊĐŊҋ҅ ĐžŅ‚ĐŧĐĩ҇ĐĩĐŊа ĐēаĐē Ņ€Đ°ĐˇŅ€ĐĩŅˆŅ‘ĐŊĐŊĐ°Ņ."; - readonly zh: "čŋœį¨‹æ•°æŽåē“åˇ˛æ ‡čްä¸ē厞觪冺"; - }; - readonly "liveSyncReplicator.replicationClosed": { - readonly def: "Replication closed"; - readonly es: "ReplicaciÃŗn cerrada"; - readonly fr: "RÊplication fermÊe"; - readonly he: "השכפול נסגר"; - readonly ja: "ãƒŦプãƒĒã‚ąãƒŧã‚ˇãƒ§ãƒŗ(複čŖŊ)がįĩ‚äē†ã—ぞした"; - readonly ko: "ëŗĩė œę°€ ėĸ…ëŖŒë˜ė—ˆėŠĩ니다"; - readonly ru: "Đ ĐĩĐŋĐģиĐēĐ°Ņ†Đ¸Ņ СаĐēŅ€Ņ‹Ņ‚Đ°"; - readonly zh: "同æ­Ĩ厞兺闭"; - }; - readonly "liveSyncReplicator.replicationInProgress": { - readonly def: "Replication is already in progress"; - readonly es: "ReplicaciÃŗn en curso"; - readonly fr: "Une rÊplication est dÊjà en cours"; - readonly he: "שכפול כבר מ×Ēב×Ļ×ĸ"; - readonly ja: "ãƒŦプãƒĒã‚ąãƒŧã‚ˇãƒ§ãƒŗ(複čŖŊ)はæ—ĸãĢé€˛čĄŒä¸­ã§ã™"; - readonly ko: "ëŗĩė œę°€ ė´ë¯¸ ė§„í–‰ ė¤‘ėž…ë‹ˆë‹¤"; - readonly ru: "Đ ĐĩĐŋĐģиĐēĐ°Ņ†Đ¸Ņ ҃ĐļĐĩ Đ˛Ņ‹ĐŋĐžĐģĐŊŅĐĩŅ‚ŅŅ"; - readonly zh: "同æ­Ĩæ­Ŗåœ¨čŋ›čĄŒä¸­"; - }; - readonly "liveSyncReplicator.retryLowerBatchSize": { - readonly def: "Retry with lower batch size:${batch_size}/${batches_limit}"; - readonly es: "Reintentar con tamaÃąo de lote mÃĄs bajo:${batch_size}/${batches_limit}"; - readonly fr: "Nouvelle tentative avec une taille de lot rÊduite :${batch_size}/${batches_limit}"; - readonly he: "מנסה שוב ×ĸם גודל א×Ļווה קטן יו×Ēר:${batch_size}/${batches_limit}"; - readonly ja: "より小さいバッチã‚ĩイã‚ēで再čŠĻ行: ${batch_size}/${batches_limit}"; - readonly ko: "더 ë‚Žė€ ėŧ괄 íŦ기로 ėžŦė‹œë„: ${batch_size}/${batches_limit}"; - readonly ru: "ĐŸĐžĐ˛Ņ‚ĐžŅ€ ҁ ĐŧĐĩĐŊŅŒŅˆĐ¸Đŧ Ņ€Đ°ĐˇĐŧĐĩŅ€ĐžĐŧ ĐŋаĐēĐĩŅ‚Đ°: batch_size/batches_limit"; - readonly zh: "äŊŋį”¨æ›´å°įš„æ‰šé‡å¤§å°é‡č¯•īŧš${batch_size}/${batches_limit}"; - }; - readonly "liveSyncReplicator.unlockRemoteDb": { - readonly def: "Unlock remote database to prevent data corruption"; - readonly es: "Desbloquear base de datos remota para prevenir corrupciÃŗn de datos"; - readonly fr: "DÊverrouillage de la base distante pour Êviter la corruption des donnÊes"; - readonly he: "מבטל × ×ĸיל×Ē ×ž×Ą×“ הנ×Ēונים המרוחק למני×ĸ×Ē ×¤×’×™×ĸה בנ×Ēונים"; - readonly ja: "デãƒŧã‚ŋį ´æã‚’é˜˛ããŸã‚ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚をã‚ĸãƒŗãƒ­ãƒƒã‚¯"; - readonly ko: "ë°ė´í„° ė†ėƒė„ ë°Šė§€í•˜ę¸° ėœ„í•´ ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ëĨŧ ėž ę¸ˆ í•´ė œí•Šë‹ˆë‹¤"; - readonly ru: "РаСйĐģĐžĐēĐ¸Ņ€ĐžĐ˛Đēа ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊОК ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅ Đ´ĐģŅ ĐŋŅ€ĐĩĐ´ĐžŅ‚Đ˛Ņ€Đ°Ņ‰ĐĩĐŊĐ¸Ņ ĐŋĐžĐ˛Ņ€ĐĩĐļĐ´ĐĩĐŊĐ¸Ņ даĐŊĐŊҋ҅"; - readonly zh: "觪锁čŋœį¨‹æ•°æŽåē“äģĨ防æ­ĸ数捎损坏"; - }; - readonly "liveSyncSetting.errorNoSuchSettingItem": { - readonly def: "No such setting item: ${key}"; - readonly es: "No existe el ajuste: ${key}"; - readonly fr: "ÉlÊment de paramètre inexistant : ${key}"; - readonly he: "פריט הגדרה לא קיים: ${key}"; - readonly ja: "ããŽč¨­åŽšé …į›Žã¯å­˜åœ¨ã—ãžã›ã‚“: ${key}"; - readonly ko: "해당 네렕 항ëĒŠė´ ė—†ėŠĩ니다: ${key}"; - readonly ru: "ĐĸаĐēĐžĐŗĐž ĐŋĐ°Ņ€Đ°ĐŧĐĩŅ‚Ņ€Đ° ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи ĐŊĐĩ ŅŅƒŅ‰ĐĩŅŅ‚Đ˛ŅƒĐĩŅ‚: key"; - readonly zh: "æ˛Ąæœ‰æ­¤čŽžįŊŽéĄšīŧš${key}"; - }; - readonly "liveSyncSetting.originalValue": { - readonly def: "Original: ${value}"; - readonly es: "Original: ${value}"; - readonly fr: "Original : ${value}"; - readonly he: "×ĸרך מקורי: ${value}"; - readonly ja: "元ぎ値: ${value}"; - readonly ko: "ė›ëŗ¸: ${value}"; - readonly ru: "ĐžŅ€Đ¸ĐŗĐ¸ĐŊаĐģ: value"; - readonly zh: "原始å€ŧīŧš${value}"; - }; - readonly "liveSyncSetting.valueShouldBeInRange": { - readonly def: "The value should ${min} < value < ${max}"; - readonly es: "El valor debe estar entre ${min} y ${max}"; - readonly fr: "La valeur doit ÃĒtre ${min} < valeur < ${max}"; - readonly he: "ה×ĸרך ×Ļריך להיו×Ē ${min} < ×ĸרך < ${max}"; - readonly ja: "値は ${min} < 値 < ${max} ãŽį¯„å›˛ã§ã‚ã‚‹åŋ…čĻãŒã‚ã‚Šãžã™"; - readonly ko: "ę°’ė€ ${min} < 값 < ${max} ë˛”ėœ„ė— ėžˆė–´ė•ŧ 합니다"; - readonly ru: "ЗĐŊĐ°Ņ‡ĐĩĐŊиĐĩ Đ´ĐžĐģĐļĐŊĐž ĐąŅ‹Ņ‚ŅŒ min < СĐŊĐ°Ņ‡ĐĩĐŊиĐĩ < max"; - readonly zh: "å€ŧåē”č¯Ĩ在 ${min} < value < ${max} 之间"; - }; - readonly "liveSyncSettings.btnApply": { - readonly def: "Apply"; - readonly es: "Aplicar"; - readonly fr: "Appliquer"; - readonly he: "החל"; - readonly ja: "éŠį”¨"; - readonly ko: "ė ėšŠ"; - readonly ru: "ĐŸŅ€Đ¸ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ"; - readonly zh: "åē”ᔍ"; - }; - readonly "Local Database Tweak": { - readonly def: "Local Database Tweak"; - readonly es: "Ajustes de la base de datos local"; - readonly ja: "ロãƒŧã‚ĢãƒĢデãƒŧã‚ŋベãƒŧ゚ぎčĒŋ整"; - readonly ko: "로ėģŦ ë°ė´í„°ë˛ ė´ėŠ¤ ėĄ°ė •"; - readonly ru: "ĐĐ°ŅŅ‚Ņ€ĐžĐšĐēи ĐģĐžĐēаĐģҌĐŊОК ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅"; - }; - readonly Lock: { - readonly def: "Lock"; - readonly es: "Bloquear"; - readonly ja: "ロック"; - readonly ko: "ėž ę¸ˆ"; - readonly ru: "ЗабĐģĐžĐēĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ"; - readonly zh: "锁厚"; - readonly "zh-tw": "鎖厚"; - }; - readonly "Lock Server": { - readonly def: "Lock Server"; - readonly es: "Bloquear servidor"; - readonly ja: "ã‚ĩãƒŧバãƒŧをロック"; - readonly ko: "ė„œë˛„ ėž ę¸ˆ"; - readonly ru: "ЗабĐģĐžĐēĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ ҁĐĩŅ€Đ˛ĐĩŅ€"; - readonly zh: "é”åŽšæœåŠĄå™¨"; - readonly "zh-tw": "鎖厚äŧ翜å™¨"; - }; - readonly "Lock the remote server to prevent synchronization with other devices.": { - readonly def: "Lock the remote server to prevent synchronization with other devices."; - readonly es: "Bloquea el servidor remoto para impedir la sincronizaciÃŗn con otros dispositivos."; - readonly ja: "äģ–ãŽãƒ‡ãƒã‚¤ã‚šã¨ãŽåŒæœŸã‚’é˜˛ããŸã‚ã€ãƒĒãƒĸãƒŧトã‚ĩãƒŧバãƒŧをロックしぞす。"; - readonly ko: "다ëĨ¸ ę¸°ę¸°ė™€ė˜ 동기화ëĨŧ ë°Šė§€í•˜ę¸° ėœ„í•´ ė›ę˛Š ė„œë˛„ëĨŧ ėž ę¸‰ë‹ˆë‹¤."; - readonly ru: "БĐģĐžĐēĐ¸Ņ€ŅƒĐĩŅ‚ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊŅ‹Đš ҁĐĩŅ€Đ˛ĐĩŅ€, Ņ‡Ņ‚ĐžĐąŅ‹ СаĐŋŅ€ĐĩŅ‚Đ¸Ņ‚ŅŒ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸ŅŽ ҁ Đ´Ņ€ŅƒĐŗĐ¸Đŧи ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°Đŧи."; - readonly zh: "锁厚čŋœįĢ¯æœåŠĄå™¨īŧŒäģĨé˜ģæ­ĸå…ļäģ–čŽžå¤‡įģ§įģ­åŒæ­Ĩ。"; - readonly "zh-tw": "鎖厚遠į̝äŧ翜å™¨īŧŒäģĨ防æ­ĸå…ļäģ–čŖįŊŽé€˛čĄŒåŒæ­Ĩ。"; - }; - readonly "logPane.autoScroll": { - readonly def: "Auto scroll"; - readonly es: "Autodesplazamiento"; - readonly fr: "DÊfilement automatique"; - readonly he: "גלילה אוטומטי×Ē"; - readonly ja: "č‡Ē動゚クロãƒŧãƒĢ"; - readonly ko: "ėžë™ 늤íŦ륤"; - readonly ru: "ĐĐ˛Ņ‚ĐžĐŋŅ€ĐžĐēŅ€ŅƒŅ‚Đēа"; - readonly zh: "č‡Ē动æģšåЍ"; - }; - readonly "logPane.logWindowOpened": { - readonly def: "Log window opened"; - readonly es: "Ventana de registro abierta"; - readonly fr: "FenÃĒtre des journaux ouverte"; - readonly he: "חלון יומן נפ×Ēח"; - readonly ja: "ログã‚Ļã‚Ŗãƒŗãƒ‰ã‚Ļが開かれぞした"; - readonly ko: "로그 ė°Ŋė´ ė—´ë ¸ėŠĩ니다"; - readonly ru: "ОĐēĐŊĐž ĐģĐžĐŗĐ° ĐžŅ‚ĐēŅ€Ņ‹Ņ‚Đž"; - readonly zh: "æ—Ĩåŋ—įĒ—åŖåˇ˛æ‰“åŧ€"; - }; - readonly "logPane.pause": { - readonly def: "Pause"; - readonly es: "Pausar"; - readonly fr: "Pause"; - readonly he: "השהה"; - readonly ja: "一時停æ­ĸ"; - readonly ko: "ėŧė‹œ ė¤‘ë‹¨"; - readonly ru: "ĐŸĐ°ŅƒĐˇĐ°"; - readonly zh: "暂停"; - }; - readonly "logPane.title": { - readonly def: "Self-hosted LiveSync Log"; - readonly es: "Registro de Self-hosted LiveSync"; - readonly fr: "Journaux Self-hosted LiveSync"; - readonly he: "יומן Self-hosted LiveSync"; - readonly ja: "Self-hosted LiveSync ログ"; - readonly ko: "Self-hosted LiveSync 로그"; - readonly ru: "Đ›ĐžĐŗ Self-hosted LiveSync"; - readonly zh: "Self-hosted LiveSync æ—Ĩåŋ—"; - }; - readonly "logPane.wrap": { - readonly def: "Wrap"; - readonly es: "Ajustar"; - readonly fr: "Retour à la ligne"; - readonly he: "גליש×Ē ×Š×•×¨×•×Ē"; - readonly ja: "折りčŋ”し"; - readonly ko: "뤄 바ęŋˆ"; - readonly ru: "ПĐĩŅ€ĐĩĐŊĐžŅ"; - readonly zh: "č‡Ē动æĸ行"; - }; - readonly "Maximum delay for batch database updating": { - readonly def: "Maximum delay for batch database updating"; - readonly es: "Retraso mÃĄximo para actualizaciÃŗn por lotes"; - readonly fr: "DÊlai maximum pour la mise à jour groupÊe de la base"; - readonly he: "×ĸיכוב מקסימלי ל×ĸדכון א×Ļווה של מסד × ×Ēונים"; - readonly ja: "バッチデãƒŧã‚ŋベãƒŧ゚更新ぎ最大遅åģļ"; - readonly ko: "ėŧ괄 ë°ė´í„°ë˛ ė´ėŠ¤ ė—…ë°ė´íŠ¸ ėĩœëŒ€ 맀뗰"; - readonly ru: "МаĐēŅĐ¸ĐŧаĐģҌĐŊĐ°Ņ СадĐĩŅ€ĐļĐēа ĐŋаĐēĐĩŅ‚ĐŊĐžĐŗĐž ОйĐŊОвĐģĐĩĐŊĐ¸Ņ ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅"; - readonly zh: "扚量数捎å瓿›´æ–°įš„æœ€å¤§åģļčŋŸ"; - }; - readonly "Maximum file size": { - readonly def: "Maximum file size"; - readonly es: "TamaÃąo mÃĄximo de archivo"; - readonly fr: "Taille maximale de fichier"; - readonly he: "גודל קוב×Ĩ מקסימלי"; - readonly ja: "æœ€å¤§ãƒ•ã‚Ąã‚¤ãƒĢ厚量"; - readonly ko: "ėĩœëŒ€ 파ėŧ íŦ기"; - readonly ru: "МаĐēŅĐ¸ĐŧаĐģҌĐŊŅ‹Đš Ņ€Đ°ĐˇĐŧĐĩŅ€ Ņ„Đ°ĐšĐģа"; - readonly zh: "最大文äģļ大小"; - }; - readonly "Maximum Incubating Chunk Size": { - readonly def: "Maximum Incubating Chunk Size"; - readonly es: "TamaÃąo mÃĄximo de chunks incubados"; - readonly fr: "Taille maximale des fragments en incubation"; - readonly he: "גודל מקסימלי לנ×Ēח בבישול"; - readonly ja: "äŋæŒã™ã‚‹ãƒãƒŖãƒŗã‚¯ãŽæœ€å¤§ã‚ĩイã‚ē"; - readonly ko: "ėž„ė‹œ ëŗ´ę´€ ëŗ€ę˛Ŋ ę¸°ëĄė˜ ėĩœëŒ€ íŦ기"; - readonly ru: "МаĐēŅĐ¸ĐŧаĐģҌĐŊŅ‹Đš Ņ€Đ°ĐˇĐŧĐĩŅ€ иĐŊĐēŅƒĐąĐ¸Ņ€ŅƒĐĩĐŧĐžĐŗĐž Ņ‡Đ°ĐŊĐēа"; - readonly zh: "最大å­ĩ化块大小"; - }; - readonly "Maximum Incubating Chunks": { - readonly def: "Maximum Incubating Chunks"; - readonly es: "MÃĄximo de chunks incubados"; - readonly fr: "Nombre maximum de fragments en incubation"; - readonly he: "מספר מקסימלי של × ×Ēחים בבישול"; - readonly ja: "一時äŋįŽĄã™ã‚‹æœ€å¤§ãƒãƒŖãƒŗã‚¯æ•°"; - readonly ko: "ėž„ė‹œ ëŗ´ę´€ ė¤‘ė¸ ëŗ€ę˛Ŋ 기록 ėĩœëŒ€ 눘"; - readonly ru: "МаĐēŅĐ¸ĐŧаĐģҌĐŊĐžĐĩ ĐēĐžĐģĐ¸Ņ‡ĐĩŅŅ‚Đ˛Đž иĐŊĐēŅƒĐąĐ¸Ņ€ŅƒĐĩĐŧҋ҅ Ņ‡Đ°ĐŊĐēОв"; - readonly zh: "最大å­ĩ化块数"; - }; - readonly "Maximum Incubation Period": { - readonly def: "Maximum Incubation Period"; - readonly es: "Periodo mÃĄximo de incubaciÃŗn"; - readonly fr: "PÊriode maximale d'incubation"; - readonly he: "×Ēקופ×Ē ×‘×™×Š×•×œ מקסימלי×Ē"; - readonly ja: "最大äŋæŒæœŸé™"; - readonly ko: "ëŗ€ę˛Ŋ 기록 ėž„ė‹œ ëŗ´ę´€ ėĩœëŒ€ ė‹œę°„"; - readonly ru: "МаĐēŅĐ¸ĐŧаĐģҌĐŊŅ‹Đš ĐŋĐĩŅ€Đ¸ĐžĐ´ иĐŊĐēŅƒĐąĐ°Ņ†Đ¸Đ¸"; - readonly zh: "最大å­ĩ化期"; - }; - readonly "MB (0 to disable).": { - readonly def: "MB (0 to disable)."; - readonly es: "MB (0 para desactivar)"; - readonly fr: "Mo (0 pour dÊsactiver)."; - readonly he: "MB (0 לביטול)."; - readonly ja: "MB (0ã§į„ĄåŠšåŒ–)。"; - readonly ko: "MB (0ėœŧ로 ė„¤ė •í•˜ëŠ´ ëš„í™œė„ąí™”)."; - readonly ru: "МБ (0 Đ´ĐģŅ ĐžŅ‚ĐēĐģŅŽŅ‡ĐĩĐŊĐ¸Ņ)."; - readonly zh: "MB(0ä¸ēįρᔍ)"; - }; - readonly "Memory cache": { - readonly def: "Memory cache"; - readonly es: "CachÊ en memoria"; - readonly ja: "ãƒĄãƒĸãƒĒã‚­ãƒŖãƒƒã‚ˇãƒĨ"; - readonly ko: "메ëǍëĻŦ ėēė‹œ"; - readonly ru: "ĐšŅŅˆ в ĐŋаĐŧŅŅ‚Đ¸"; - }; - readonly "Memory cache size (by total characters)": { - readonly def: "Memory cache size (by total characters)"; - readonly es: "TamaÃąo cachÊ memoria (por caracteres)"; - readonly fr: "Taille du cache mÊmoire (par nombre total de caractères)"; - readonly he: "גודל מטמון זיכרון (לפי סה\"כ ×Ēווים)"; - readonly ja: "全äŊ“ã§ã‚­ãƒŖãƒƒã‚ˇãƒĨする文字数"; - readonly ko: "메ëǍëĻŦ ėēė‹œ íŦ기 (ė´ ëŦ¸ėž 눘)"; - readonly ru: "РаСĐŧĐĩŅ€ ĐēŅŅˆĐ° ĐŋаĐŧŅŅ‚Đ¸ (ĐŋĐž ĐžĐąŅ‰ĐĩĐŧ҃ ĐēĐžĐģĐ¸Ņ‡ĐĩŅŅ‚Đ˛Ņƒ ŅĐ¸ĐŧвОĐģОв)"; - readonly zh: "内存įŧ“存大小(按æ€ģ字įŦĻæ•°)"; - }; - readonly "Memory cache size (by total items)": { - readonly def: "Memory cache size (by total items)"; - readonly es: "TamaÃąo cachÊ memoria (por ítems)"; - readonly fr: "Taille du cache mÊmoire (par nombre total d'ÊlÊments)"; - readonly he: "גודל מטמון זיכרון (לפי סה\"כ פריטים)"; - readonly ja: "全äŊ“ãŽã‚­ãƒŖãƒƒã‚ˇãƒĨã‚ĩイã‚ē"; - readonly ko: "메ëǍëĻŦ ėēė‹œ íŦ기 (ė´ 항ëĒŠ 눘)"; - readonly ru: "РаСĐŧĐĩŅ€ ĐēŅŅˆĐ° ĐŋаĐŧŅŅ‚Đ¸ (ĐŋĐž ĐžĐąŅ‰ĐĩĐŧ҃ ĐēĐžĐģĐ¸Ņ‡ĐĩŅŅ‚Đ˛Ņƒ ŅĐģĐĩĐŧĐĩĐŊŅ‚ĐžĐ˛)"; - readonly zh: "内存įŧ“存大小(按æ€ģéĄšį›Žæ•°)"; - }; - readonly Merge: { - readonly def: "Merge"; - readonly es: "Fusionar"; - readonly ja: "マãƒŧジ"; - readonly ko: "ëŗ‘í•Š"; - readonly ru: "ĐžĐąŅŠĐĩдиĐŊĐ¸Ņ‚ŅŒ"; - readonly zh: "合åšļ"; - }; - readonly "Minimum delay for batch database updating": { - readonly def: "Minimum delay for batch database updating"; - readonly es: "Retraso mínimo para actualizaciÃŗn por lotes"; - readonly fr: "DÊlai minimum pour la mise à jour groupÊe de la base"; - readonly he: "×ĸיכוב מינימלי ל×ĸדכון א×Ļווה של מסד × ×Ēונים"; - readonly ja: "バッチデãƒŧã‚ŋベãƒŧ゚更新ぎ最小遅åģļ"; - readonly ko: "ėŧ괄 ë°ė´í„°ë˛ ė´ėŠ¤ ė—…ë°ė´íŠ¸ ėĩœė†Œ 맀뗰"; - readonly ru: "МиĐŊиĐŧаĐģҌĐŊĐ°Ņ СадĐĩŅ€ĐļĐēа ĐŋаĐēĐĩŅ‚ĐŊĐžĐŗĐž ОйĐŊОвĐģĐĩĐŊĐ¸Ņ ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅"; - readonly zh: "扚量数捎å瓿›´æ–°įš„æœ€å°åģļčŋŸ"; - }; - readonly "Minimum interval for syncing": { - readonly def: "Minimum interval for syncing"; - readonly fr: "Intervalle minimum pour la synchronisation"; - readonly he: "מרווח מינימלי לסנכרון"; - readonly ja: "同期間隔ぎ最小値"; - readonly ko: "동기화 ėĩœė†Œ 간격"; - readonly ru: "МиĐŊиĐŧаĐģҌĐŊŅ‹Đš иĐŊŅ‚ĐĩŅ€Đ˛Đ°Đģ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸"; - readonly zh: "同æ­Ĩ最小间隔"; - readonly "zh-tw": "同æ­Ĩ最小間隔"; - }; - readonly "moduleCheckRemoteSize.logCheckingStorageSizes": { - readonly def: "Checking storage sizes"; - readonly es: "Comprobando tamaÃąos de almacenamiento"; - readonly fr: "VÊrification des tailles de stockage"; - readonly he: "בודק גדלי אחסון"; - readonly ja: "゚トãƒŦãƒŧジã‚ĩイã‚ēをįĸēčĒä¸­"; - readonly ko: "ėŠ¤í† ëĻŦė§€ íŦ기 í™•ė¸ 뤑"; - readonly ru: "ĐŸŅ€ĐžĐ˛ĐĩŅ€Đēа Ņ€Đ°ĐˇĐŧĐĩŅ€ĐžĐ˛ Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰Đ°"; - readonly zh: "æ­Ŗåœ¨æŖ€æŸĨ存储大小"; - }; - readonly "moduleCheckRemoteSize.logCurrentStorageSize": { - readonly def: "Remote storage size: ${measuredSize}"; - readonly es: "TamaÃąo del almacenamiento remoto: ${measuredSize}"; - readonly fr: "Taille du stockage distant : ${measuredSize}"; - readonly he: "גודל אחסון מרוחק: ${measuredSize}"; - readonly ja: "ãƒĒãƒĸãƒŧト゚トãƒŦãƒŧジã‚ĩイã‚ē: ${measuredSize}"; - readonly ko: "ė›ę˛Š ėŠ¤í† ëĻŦė§€ íŦ기: ${measuredSize}"; - readonly ru: "РаСĐŧĐĩŅ€ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐŗĐž Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰Đ°: measuredSize"; - readonly zh: "čŋœį¨‹å­˜å‚¨å¤§å°īŧš${measuredSize}"; - }; - readonly "moduleCheckRemoteSize.logExceededWarning": { - readonly def: "Remote storage size: ${measuredSize} exceeded ${notifySize}"; - readonly es: "TamaÃąo del almacenamiento remoto: ${measuredSize} superÃŗ ${notifySize}"; - readonly fr: "Taille du stockage distant : ${measuredSize} a dÊpassÊ ${notifySize}"; - readonly he: "גודל אחסון מרוחק: ${measuredSize} ×ĸלה ×ĸל ${notifySize}"; - readonly ja: "ãƒĒãƒĸãƒŧト゚トãƒŦãƒŧジã‚ĩイã‚ē: ${measuredSize} が ${notifySize} をčļ…過しぞした"; - readonly ko: "ė›ę˛Š ėŠ¤í† ëĻŦė§€ íŦ기: ${measuredSize}가 ${notifySize}ëĨŧ 봈ęŗŧ했ėŠĩ니다"; - readonly ru: "РаСĐŧĐĩŅ€ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐŗĐž Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰Đ°: measuredSize ĐŋŅ€ĐĩĐ˛Ņ‹ŅĐ¸Đģ notifySize"; - readonly zh: "čŋœį¨‹å­˜å‚¨å¤§å°īŧš${measuredSize} čļ…čŋ‡ ${notifySize}"; - }; - readonly "moduleCheckRemoteSize.logThresholdEnlarged": { - readonly def: "Threshold has been enlarged to ${size}MB"; - readonly es: "El umbral se ha ampliado a ${size}MB"; - readonly fr: "Le seuil a ÊtÊ augmentÊ à ${size} Mo"; - readonly he: "×”×Ą×Ŗ הורחב ל-${size}MB"; - readonly ja: "しきい値が ${size}MB ãĢč¨­åŽšã•ã‚Œãžã—ãŸ"; - readonly ko: "ėž„ęŗ„ę°’ė´ ${size}MB로 ėĻę°€ë˜ė—ˆėŠĩ니다"; - readonly ru: "ĐŸĐžŅ€ĐžĐŗ ŅƒĐ˛ĐĩĐģĐ¸Ņ‡ĐĩĐŊ Đ´Đž sizeМБ"; - readonly zh: "阈å€ŧåˇ˛æ‰Šå¤§åˆ° ${size}MB"; - }; - readonly "moduleCheckRemoteSize.msgConfirmRebuild": { - readonly def: "This may take a bit of a long time. Do you really want to rebuild everything now?"; - readonly es: "Esto puede llevar un poco de tiempo. ÂŋRealmente quieres reconstruir todo ahora?"; - readonly fr: "Cela peut prendre un certain temps. Voulez-vous vraiment tout reconstruire maintenant ?"; - readonly he: "פ×ĸולה זו ×ĸשויה לקח×Ē ×–×ž×Ÿ מה. האם א×Ēה בטוח שבר×Ļונך לבנו×Ē ×ž×—×“×Š ×ĸכשיו?"; - readonly ja: "これは少し時間がかかる場合がありぞす。æœŦåŊ“ãĢäģŠã™ãšãĻã‚’å†æ§‹į¯‰ã—ãžã™ã‹īŧŸ"; - readonly ko: "ė‹œę°„ė´ ęŊ¤ ė˜¤ëž˜ 깸ëĻ´ 눘 ėžˆėŠĩ니다. ė •ë§ ė§€ę¸ˆ ëĒ¨ë“  ę˛ƒė„ ėžŦęĩŦėļ•í•˜ė‹œę˛ ėŠĩ니까?"; - readonly ru: "Đ­Ņ‚Đž ĐŧĐžĐļĐĩŅ‚ СаĐŊŅŅ‚ŅŒ ĐŊĐĩĐēĐžŅ‚ĐžŅ€ĐžĐĩ Đ˛Ņ€ĐĩĐŧŅ. Đ’Ņ‹ Đ´ĐĩĐšŅŅ‚Đ˛Đ¸Ņ‚ĐĩĐģҌĐŊĐž Ņ…ĐžŅ‚Đ¸Ņ‚Đĩ ĐŋĐĩŅ€ĐĩŅŅ‚Ņ€ĐžĐ¸Ņ‚ŅŒ Đ˛ŅŅ‘ ҁĐĩĐšŅ‡Đ°Ņ?"; - readonly zh: "čŋ™å¯čƒŊ需čρ䏀ä盿—ļé—´ã€‚æ‚¨įœŸįš„æƒŗįŽ°åœ¨é‡åģ翉€æœ‰å†…厚吗īŧŸ"; - }; - readonly "moduleCheckRemoteSize.msgDatabaseGrowing": { - readonly def: "**Your database is getting larger!** But do not worry, we can address it now. The time before running out of space on the remote storage.\n\n| Measured size | Configured size |\n| --- | --- |\n| ${estimatedSize} | ${maxSize} |\n\n> [!MORE]-\n> If you have been using it for many years, there may be unreferenced chunks - that is, garbage - accumulating in the database. Therefore, we recommend rebuilding everything. It will probably become much smaller.\n>\n> If the volume of your vault is simply increasing, it is better to rebuild everything after organizing the files. Self-hosted LiveSync does not delete the actual data even if you delete it to speed up the process. It is roughly [documented](https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/tech_info.md).\n>\n> If you don't mind the increase, you can increase the notification limit by 100MB. This is the case if you are running it on your own server. However, it is better to rebuild everything from time to time.\n>\n\n> [!WARNING]\n> If you perform rebuild everything, make sure all devices are synchronised. The plug-in will merge as much as possible, though.\n"; - readonly es: "**ÂĄTu base de datos estÃĄ creciendo!** Pero no te preocupes, podemos abordarlo ahora. El tiempo antes de quedarse sin espacio en el almacenamiento remoto.\n\n| TamaÃąo medido | TamaÃąo configurado |\n| --- | --- |\n| ${estimatedSize} | ${maxSize} |\n\n> [!MORE]-\n> Si lo has estado utilizando durante muchos aÃąos, puede haber fragmentos no referenciados - es decir, basura - acumulÃĄndose en la base de datos. Por lo tanto, recomendamos reconstruir todo. Probablemente se volverÃĄ mucho mÃĄs pequeÃąo.\n>\n> Si el volumen de tu bÃŗveda simplemente estÃĄ aumentando, es mejor reconstruir todo despuÊs de organizar los archivos. Self-hosted LiveSync no elimina los datos reales incluso si los eliminas para acelerar el proceso. EstÃĄ aproximadamente [documentado](https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/tech_info.md).\n>\n> Si no te importa el aumento, puedes aumentar el límite de notificaciÃŗn en 100 MB. Este es el caso si lo estÃĄs ejecutando en tu propio servidor. Sin embargo, es mejor reconstruir todo de vez en cuando.\n>\n\n> [!WARNING]\n> Si realizas la reconstrucciÃŗn completa, asegÃērate de que todos los dispositivos estÊn sincronizados. El complemento fusionarÃĄ tanto como sea posible, sin embargo.\n"; - readonly fr: "**Votre base de donnÊes grossit !** Pas d'inquiÊtude, nous pouvons y remÊdier dès maintenant, avant de manquer d'espace sur le stockage distant.\n\n| Taille mesurÊe | Taille configurÊe |\n| --- | --- |\n| ${estimatedSize} | ${maxSize} |\n\n> [!MORE]-\n> Si vous l'utilisez depuis de nombreuses annÊes, il peut y avoir des fragments non rÊfÊrencÊs — des dÊchets, en somme — accumulÊs dans la base. Nous recommandons donc de tout reconstruire. Cela rÊduira probablement beaucoup la taille.\n>\n> Si le volume de votre coffre augmente simplement, il est prÊfÊrable de tout reconstruire après avoir organisÊ les fichiers. Self-hosted LiveSync ne supprime pas rÊellement les donnÊes mÃĒme si vous les effacez, afin d'accÊlÊrer le processus. Ceci est documentÊ grossièrement [ici](https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/tech_info.md).\n>\n> Si cela ne vous dÊrange pas, vous pouvez augmenter la limite de notification de 100 Mo. C'est le cas si vous l'exÊcutez sur votre propre serveur. Il reste toutefois prÊfÊrable de tout reconstruire de temps en temps.\n>\n\n> [!WARNING]\n> Si vous tout reconstruisez, assurez-vous que tous les appareils sont synchronisÊs. Le plug-in fusionnera autant que possible cependant.\n"; - readonly he: "**מסד הנ×Ēונים שלך הולך וגדל!** אל ×Ēדאג, אנחנו יכולים לטפל בזה ×ĸכשיו. הזמן ׊נ׊אר ×ĸד לאזול המקום באחסון המרוחק.\n\n| גודל נמדד | גודל מוגדר |\n| --- | --- |\n| ${estimatedSize} | ${maxSize} |\n\n> [!MORE]-\n> אם א×Ēה מ׊×Ēמ׊ בפלאגין כבר שנים רבו×Ē, יי×Ēכן ׊נ×Ļברו × ×Ēחים לא מקושרים — כלומר, זבל — במסד הנ×Ēונים. לכן, אנו ממלי×Ļים לבנו×Ē ×”×›×œ מחדש. ככל הנראה מסד הנ×Ēונים יהיה קטן בהרבה לאחר מכן.\n>\n> אם נפח הכספ×Ē ×Š×œ×š פשוט גדל, ×ĸדית לבנו×Ē ×ž×—×“×Š לאחר ארגון הקב×Ļים. Self-hosted LiveSync אינו מוחק × ×Ēונים בפו×ĸל גם כאשר א×Ēה מוחק קב×Ļים כדי להאי×Ĩ א×Ē ×”×Ēהליך. הדבר [מ×Ēו×ĸד בפירוט](https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/tech_info.md).\n>\n> אם אינך מוטרד מהגידול, ני×Ēן להגדיל א×Ē ×Ą×Ŗ הה×Ēראה ב-100MB. הדבר מ×Ēאים אם השר×Ē ×”×•× שלך. ×ĸם זא×Ē, מומל×Ĩ לבנו×Ē ×ž×—×“×Š מ×ĸ×Ē ×œ×ĸ×Ē.\n>\n\n> [!WARNING]\n> אם ×Ēבנה מחדש, ודא שכל המכשירים מסונכרנים. הפלאגין ינסה למזג כמה שני×Ēן.\n"; - readonly ja: "**デãƒŧã‚ŋベãƒŧ゚が大きくãĒãŖãĻいぞすīŧ** でもåŋƒé…ã—ãĒいでください。ãƒĒãƒĸãƒŧト゚トãƒŦãƒŧジぎ厚量が不čļŗã™ã‚‹å‰ãĢ寞åŋœã§ããžã™ã€‚\n\n| æ¸Ŧ厚ã‚ĩイã‚ē | č¨­åŽšã‚ĩイã‚ē |\n| --- | --- |\n| ${estimatedSize} | ${maxSize} |\n\n> [!MORE]-\n> é•ˇåš´äŊŋį”¨ã—ãĻã„ã‚‹å ´åˆã€å‚į…§ã•ã‚ŒãĻいãĒã„ãƒãƒŖãƒŗã‚¯īŧˆã¤ãžã‚Šã‚´ãƒŸīŧ‰ãŒãƒ‡ãƒŧã‚ŋベãƒŧ゚ãĢč“„įŠã—ãĻいる可čƒŊ性がありぞす。そぎため、すずãĻã‚’å†æ§‹į¯‰ã™ã‚‹ã“ã¨ã‚’ãŠå‹§ã‚ã—ãžã™ã€‚ãŠãã‚‰ãã‹ãĒり小さくãĒるでしょう。\n>\n> å˜į´”ãĢäŋįŽĄåēĢぎ厚量がåĸ—えãĻいる場合は、äē‹å‰ãĢãƒ•ã‚Ąã‚¤ãƒĢã‚’æ•´į†ã—ãĻからすずãĻã‚’å†æ§‹į¯‰ã™ã‚‹ãŽãŒč‰¯ã„ã§ã—ã‚‡ã†ã€‚Self-hosted LiveSyncはå‡Ļį†é€ŸåēĻを上げるため、削除しãĻも原際ぎデãƒŧã‚ŋを削除しぞせん。これはおおぞかãĢ[documentation](https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/tech_info.md)ãĢ記čŧ‰ã•れãĻいぞす。\n>\n> åĸ—加を気ãĢしãĒい場合は、通įŸĨåˆļ限を100MB単äŊã§åĸ—やすことができぞす。これはč‡Ē分ぎã‚ĩãƒŧバãƒŧã§åŽŸčĄŒã—ãĻいる場合ãĢ遊しãĻã„ãžã™ã€‚ãŸã ã—ã€åŽšæœŸįš„ãĢすずãĻã‚’å†æ§‹į¯‰ã™ã‚‹æ–šãŒč‰¯ã„ã§ã—ã‚‡ã†ã€‚\n>\n\n> [!WARNING]\n> すずãĻã‚’å†æ§‹į¯‰ã™ã‚‹å ´åˆã¯ã€ã™ãšãĻぎデバイ゚が同期されãĻいることをįĸēčĒã—ãĻãã ã•ã„ã€‚ã‚‚ãĄã‚ã‚“ã€ãƒ—ãƒŠã‚°ã‚¤ãƒŗã¯å¯čƒŊãĒé™ã‚Šč§ŖæąēしようとåŠĒ力はしぞすけお...\n"; - readonly ko: "**ë°ė´í„°ë˛ ė´ėŠ¤ ėšŠëŸ‰ė´ 렐렐 ėģ¤ė§€ęŗ  ėžˆėŠĩ니다!** í•˜ė§€ë§Œ ęąąė •í•˜ė§€ ë§ˆė„¸ėš”. 땄링 ė›ę˛Š ėŠ¤í† ëĻŦė§€ ęŗĩę°„ė´ ė™„ė „ížˆ ëļ€ėĄąí•´ė§„ ęą´ ė•„ë‹™ë‹ˆë‹¤.\n\n| ė¸Ąė •ëœ íŦ기 | ė„¤ė •ëœ 한도 |\n| --- | --- |\n| ${estimatedSize} | ${maxSize} |\n\n> [!MORE]-\n> ė˜¤ëžœ 기간 ė‚ŦėšŠí–ˆë‹¤ëŠ´ ė°¸ėĄ°ë˜ė§€ ė•ŠëŠ” 랭íŦ, ėω 'ė“°ë ˆę¸° ë°ė´í„°'가 ėŒ“ė˜€ė„ 눘 ėžˆėŠĩ니다. ė´ ę˛Ŋ뚰 렄랴 ėžŦęĩŦė„ąė„ ęļŒėžĨ합니다. ėšŠëŸ‰ė´ 훨ė”Ŧ ė¤„ė–´ë“¤ 눘 ėžˆėŠĩ니다.\n> \n> ë‹¨ėˆœížˆ ëŗŧ트 ėžė˛´ ėšŠëŸ‰ė´ ėģ¤ė§€ęŗ  ėžˆëŠ” ę˛ƒė´ëŧ늴, ë¨ŧė € 파ėŧė„ ė •ëĻŦ한 후 렄랴ëĨŧ ėžŦęĩŦė„ąí•˜ëŠ” ę˛ƒė´ ėĸ‹ėŠĩ니다. Self-hosted LiveSync는 래ëĻŦ ė†ë„ëĨŧ ėœ„í•´ ė‚­ė œí•´ë„ ė‹¤ė œ ë°ė´í„°ëĨŧ 바로 ė§€ėš°ė§€ ė•ŠėŠĩ니다. ė´ ë‚´ėšŠė€ [揰눠 ëŦ¸ė„œ](https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/tech_info.md)뗐 간ëžĩ히 ė •ëĻŦë˜ė–´ ėžˆėŠĩ니다.\n> \n> ėšŠëŸ‰ ėĻę°€ę°€ ę´œė°Žë‹¤ëŠ´ ė•ŒëĻŧ ėž„ęŗ„ėš˜ëĨŧ 100MB ë‹¨ėœ„ëĄœ 높ėŧ 눘 ėžˆėŠĩ니다. 링렑 ė„œë˛„ëĨŧ ėš´ė˜í•˜ëŠ” ę˛Ŋėš°ė— ė í•Ší•œ ë°Šë˛•ėž…ë‹ˆë‹¤. 다만, ę°€ë”ė€ 렄랴 ėžŦęĩŦė„ąė„ 해ėŖŧ는 ę˛ƒė´ ë°”ëžŒė§í•Šë‹ˆë‹¤.\n\n> [!WARNING]\n> 렄랴 ėžŦęĩŦė„ąė„ ė‹¤í–‰í•  ę˛Ŋ뚰, ëĒ¨ë“  기기가 ë°˜ë“œė‹œ ë™ę¸°í™”ë˜ė–´ ėžˆė–´ė•ŧ 합니다. 플ëŸŦęˇ¸ė¸ė´ ėĩœëŒ€í•œ ëŗ‘í•Ší•˜ë ¤ęŗ  ė‹œë„í•˜ę¸´ í•˜ė§€ë§Œ ė™„ė „í•˜ė§€ ė•Šė„ 눘 ėžˆėŠĩ니다."; - readonly ru: "Đ’Đ°ŅˆĐ° йаСа даĐŊĐŊҋ҅ ŅƒĐ˛ĐĩĐģĐ¸Ņ‡Đ¸Đ˛Đ°ĐĩŅ‚ŅŅ! Но ĐŊĐĩ вОĐģĐŊŅƒĐšŅ‚ĐĩҁҌ, ĐŧŅ‹ ĐŧĐžĐļĐĩĐŧ Ņ€ĐĩŅˆĐ¸Ņ‚ŅŒ ŅŅ‚Đž ҁĐĩĐšŅ‡Đ°Ņ."; - readonly zh: "**æ‚¨įš„æ•°æŽå瓿­Ŗåœ¨å˜å¤§īŧ** äŊ†åˆĢ担åŋƒ,我äģŦįŽ°åœ¨å¯äģĨč§Ŗå†ŗåŽƒã€‚åœ¨čŋœį¨‹å­˜å‚¨įŠēé—´į”¨åŽŒäš‹å‰čŋ˜æœ‰æ—ļ间。\n\n| æĩ‹é‡å¤§å° | 配įŊŽå¤§å° |\n| --- | --- |\n| ${estimatedSize} | ${maxSize} |\n\n> [!MORE]-\n> åĻ‚æžœæ‚¨åˇ˛įģäŊŋᔍäē†åžˆå¤šåš´,数捎åē“中可čƒŊäŧšį§¯į´¯æœĒåŧ•į”¨įš„ chunksâ€”â€”äšŸå°ąæ˜¯åžƒåœžã€‚å› æ­¤,我äģŦåģē议重åģ翉€æœ‰å†…厚。厃可čƒŊäŧšå˜åž—小垗多。\n> \n> åĻ‚æžœæ‚¨įš„åē“厚量åĒ是在åĸžåŠ ,最åĨŊåœ¨æ•´į†æ–‡äģļ后重åģ翉€æœ‰å†…åŽšã€‚åŗäŊŋ您ä¸ēäē†åŠ é€Ÿčŋ‡į¨‹åˆ é™¤ä熿–‡äģļ,Self-hosted LiveSync 也不äŧšåˆ é™¤åŽžé™…æ•°æŽã€‚čŋ™å¤§č‡´[æœ‰æ–‡æĄŖčŽ°åŊ•](https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/tech_info.md)。\n> \n> åĻ‚æžœæ‚¨ä¸ä싿„åĸžåŠ ,可äģĨ将通įŸĨ限åˆļåĸžåŠ  100MB。åĻ‚æžœæ‚¨åœ¨č‡Ēåˇąįš„æœåŠĄå™¨ä¸ŠčŋčĄŒ,å°ąæ˜¯čŋ™į§æƒ…å†ĩ。äŊ†æ˜¯,最åĨŊčŋ˜æ˜¯ä¸æ—ļ地重åģ翉€æœ‰å†…厚。\n> \n\n> [!WARNING]\n> åĻ‚æžœæ‚¨æ‰§čĄŒé‡åģ翉€æœ‰å†…厚,č¯ˇįĄŽäŋæ‰€æœ‰čŽžå¤‡éƒŊ厞同æ­Ĩ。å°ŊįŽĄåĻ‚æ­¤,插äģļäŧšå°Ŋ可čƒŊ地合åšļ\n"; - }; - readonly "moduleCheckRemoteSize.msgSetDBCapacity": { - readonly def: "We can set a maximum database capacity warning, **to take action before running out of space on the remote storage**.\nDo you want to enable this?\n\n> [!MORE]-\n> - 0: Do not warn about storage size.\n> This is recommended if you have enough space on the remote storage especially you have self-hosted. And you can check the storage size and rebuild manually.\n> - 800: Warn if the remote storage size exceeds 800MB.\n> This is recommended if you are using fly.io with 1GB limit or IBM Cloudant.\n> - 2000: Warn if the remote storage size exceeds 2GB.\n\nIf we have reached the limit, we will be asked to enlarge the limit step by step.\n"; - readonly es: "Podemos configurar una advertencia de capacidad mÃĄxima de base de datos, **para tomar medidas antes de quedarse sin espacio en el almacenamiento remoto**.\nÂŋQuieres habilitar esto?\n\n> [!MORE]-\n> - 0: No advertir sobre el tamaÃąo del almacenamiento.\n> Esto es recomendado si tienes suficiente espacio en el almacenamiento remoto, especialmente si lo tienes autoalojado. Y puedes comprobar el tamaÃąo del almacenamiento y reconstruir manualmente.\n> - 800: Advertir si el tamaÃąo del almacenamiento remoto supera los 800 MB.\n> Esto es recomendado si estÃĄs usando fly.io con un límite de 1 GB o IBM Cloudant.\n> - 2000: Advertir si el tamaÃąo del almacenamiento remoto supera los 2 GB.\n\nSi hemos alcanzado el límite, se nos pedirÃĄ que aumentemos el límite paso a paso.\n"; - readonly fr: "Nous pouvons dÊfinir un avertissement de capacitÊ maximale de la base de donnÊes, **afin d'agir avant de manquer d'espace sur le stockage distant**.\nVoulez-vous activer ceci ?\n\n> [!MORE]-\n> - 0 : Ne pas avertir sur la taille de stockage.\n> RecommandÊ si vous avez suffisamment d'espace sur le stockage distant, surtout en auto-hÊbergement. Vous pouvez vÊrifier la taille et reconstruire manuellement.\n> - 800 : Avertir si la taille du stockage distant dÊpasse 800 Mo.\n> RecommandÊ si vous utilisez fly.io avec une limite de 1 Go ou IBM Cloudant.\n> - 2000 : Avertir si la taille du stockage distant dÊpasse 2 Go.\n\nSi la limite est atteinte, il nous sera proposÊ de l'augmenter Êtape par Êtape.\n"; - readonly he: "ני×Ēן להגדיר אזהר×Ē ×§×™×‘×•×œ×Ē ×ž×§×Ą×™×ž×œ×™×Ē ×Š×œ מסד הנ×Ēונים, **כדי לנקוט פ×ĸולה לפני שנגמר המקום באחסון המרוחד**.\nהאם להפ×ĸיל זא×Ē?\n\n> [!MORE]-\n> - 0: אל ×Ēזהיר ×ĸל גודל האחסון.\n> מומל×Ĩ אם יש לך מספיק מקום באחסון המרוחד, ב×ĸיקר אם השר×Ē ×”×•× שלך. ני×Ēן לבדוק א×Ē ×’×•×“×œ האחסון ולבנו×Ē ×ž×—×“×Š ידני×Ē.\n> - 800: הזהר אם גודל האחסון המרוחד ×ĸולה ×ĸל 800MB.\n> מומל×Ĩ אם א×Ēה מ׊×Ēמ׊ ב-fly.io ×ĸם מגבל×Ē 1GB או ב-IBM Cloudant.\n> - 2000: הזהר אם גודל האחסון המרוחד ×ĸולה ×ĸל 2GB.\n\nאם הג×ĸנו למגבלה, ×Ē×Ēבקש להרחיב א×Ē ×”×Ą×Ŗ בהדרגה.\n"; - readonly ja: "ãƒĒãƒĸãƒŧト゚トãƒŦãƒŧジぎ厚量が不čļŗã™ã‚‹å‰ãĢå¯žį­–ã‚’čŦ›ã˜ã‚‹ãŸã‚ã€**最大デãƒŧã‚ŋベãƒŧ゚厚量ぎč­Ļ告**ã‚’č¨­åŽšã§ããžã™ã€‚\nこれを有劚ãĢしぞすかīŧŸ\n\n> [!MORE]-\n> - 0: ゚トãƒŦãƒŧジã‚ĩイã‚ēãĢついãĻč­Ļ告しãĒい。\n> č‡Ē厅ã‚ĩãƒŧバãƒŧãĒお、ãƒĒãƒĸãƒŧト゚トãƒŦãƒŧジãĢ十分ãĒ厚量がある場合ãĢ推åĨ¨ã•れぞす。゚トãƒŦãƒŧジã‚ĩイã‚ēをįĸēčĒã—ã€æ‰‹å‹•ã§å†æ§‹į¯‰ã§ããžã™ã€‚\n> - 800: ãƒĒãƒĸãƒŧト゚トãƒŦãƒŧジã‚ĩイã‚ēが800MBをčļ…えたらč­Ļ告。\n> 1GBåˆļ限ぎfly.ioやIBM CloudantをäŊŋį”¨ã—ãĻいる場合ãĢ推åĨ¨ã•れぞす。\n> - 2000: ãƒĒãƒĸãƒŧト゚トãƒŦãƒŧジã‚ĩイã‚ēが2GBをčļ…えたらč­Ļ告。\n\nåˆļ限ãĢ達した場合、æŽĩéšŽįš„ãĢåˆļ限をåĸ—ã‚„ã™ã‚ˆã†æą‚ã‚ã‚‰ã‚Œãžã™ã€‚\n"; - readonly ko: "**ė›ę˛Š ėŠ¤í† ëĻŦė§€ ęŗĩę°„ė´ ëļ€ėĄąí•´ė§€ę¸° 렄뗐 미ëĻŦ ėĄ°ėš˜í•  눘 ėžˆë„ëĄ** ë°ė´í„°ë˛ ė´ėŠ¤ ėšŠëŸ‰ ę˛Ŋęŗ ëĨŧ ė„¤ė •í•  눘 ėžˆėŠĩ니다.\nė´ 기ëŠĨė„ í™œė„ąí™”í•˜ė‹œę˛ ėŠĩ니까?\n\n> [!MORE]-\n> - 0: ėŠ¤í† ëĻŦė§€ ėšŠëŸ‰ė— 대한 ę˛Ŋęŗ  ė—†ėŒ\n> ėžė˛´ ė„œë˛„ëĨŧ ė‚ŦėšŠí•˜ëŠ” 등 ė—Ŧ뜠 ęŗĩę°„ė´ ėļŠëļ„한 ę˛Ŋėš°ė— ęļŒėžĨ됩니다. ėŠ¤í† ëĻŦė§€ ėšŠëŸ‰ė„ 링렑 í™•ė¸í•˜ęŗ  ėˆ˜ë™ėœŧ로 ėžŦęĩŦė„ąí•  눘 ėžˆėŠĩ니다.\n> - 800: ė›ę˛Š ėŠ¤í† ëĻŦė§€ ėšŠëŸ‰ė´ 800MBëĨŧ 봈ęŗŧ하면 ę˛Ŋęŗ \n> 1GB ė œí•œė´ ėžˆëŠ” fly.io나 IBM Cloudant ė‚ŦėšŠ ė‹œ ęļŒėžĨ됩니다.\n> - 2000: ė›ę˛Š ėŠ¤í† ëĻŦė§€ ėšŠëŸ‰ė´ 2GBëĨŧ 봈ęŗŧ하면 ę˛Ŋęŗ \n\nė„¤ė •í•œ ėšŠëŸ‰ í•œë„ė— 도ë‹Ŧ하면, ë‹¨ęŗ„ė ėœŧ로 ę˛Ŋęŗ  한도ëĨŧ 늘ëĻ´ė§€ ė—Ŧëļ€ëĨŧ ëŦģ枌 됩니다.\n"; - readonly ru: "МоĐļĐŊĐž ŅƒŅŅ‚Đ°ĐŊĐžĐ˛Đ¸Ņ‚ŅŒ ĐŋŅ€ĐĩĐ´ŅƒĐŋŅ€ĐĩĐļĐ´ĐĩĐŊиĐĩ Đž ĐŧаĐēŅĐ¸ĐŧаĐģҌĐŊОК Ņ‘ĐŧĐēĐžŅŅ‚Đ¸ ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅."; - readonly zh: "我äģŦ可äģĨ莞įŊŽä¸€ä¸Ē最大数捎åē“厚量č­Ļ告,**äģĨäžŋ在čŋœį¨‹å­˜å‚¨įŠē间耗å°Ŋå‰é‡‡å–čĄŒåŠ¨**。\næ‚¨æƒŗå¯į”¨čŋ™ä¸Ē功čƒŊ吗?\n\n> [!MORE]-\n> - 0: 不č­Ļ告存储大小。\n> åĻ‚æžœæ‚¨åœ¨čŋœį¨‹å­˜å‚¨(å°¤å…ļ是č‡Ēæ‰˜įŽĄ)上有čļŗå¤Ÿįš„įŠē间,åˆ™æŽ¨čæ­¤é€‰éĄšã€‚æ‚¨å¯äģĨæ‰‹åŠ¨æŖ€æŸĨ存储大小åšļ重åģē。\n> - 800: åĻ‚æžœčŋœį¨‹å­˜å‚¨å¤§å°čļ…čŋ‡ 800MB 则发å‡ēč­Ļ告。\n> åĻ‚æžœæ‚¨äŊŋį”¨įš„æ˜¯ fly.io(1GB 限åˆļ) 或 IBM Cloudant,åˆ™æŽ¨čæ­¤é€‰éĄšã€‚\n> - 2000: åĻ‚æžœčŋœį¨‹å­˜å‚¨å¤§å°čļ…čŋ‡ 2GB 则发å‡ēč­Ļ告。\n\nåĻ‚æžœčžžåˆ°é™åˆļ,įŗģįģŸäŧščĻæą‚æˆ‘äģŦ逐æ­Ĩåĸžå¤§é™åˆļ\n"; - }; - readonly "moduleCheckRemoteSize.option2GB": { - readonly def: "2GB (Standard)"; - readonly es: "2GB (EstÃĄndar)"; - readonly fr: "2 Go (Standard)"; - readonly he: "2GB (סטנדרטי)"; - readonly ja: "2GB (標æē–)"; - readonly ko: "2GB (í‘œė¤€)"; - readonly ru: "2ГБ (ĐĄŅ‚Đ°ĐŊĐ´Đ°Ņ€Ņ‚)"; - readonly zh: "2GB (标准)"; - }; - readonly "moduleCheckRemoteSize.option800MB": { - readonly def: "800MB (Cloudant, fly.io)"; - readonly es: "800MB (Cloudant, fly.io)"; - readonly fr: "800 Mo (Cloudant, fly.io)"; - readonly he: "800MB (Cloudant, fly.io)"; - readonly ja: "800MB (Cloudant, fly.io)"; - readonly ko: "800MB (Cloudant, fly.io)"; - readonly ru: "800МБ (Cloudant, fly.io)"; - readonly zh: "800MB (Cloudant, fly.io)"; - }; - readonly "moduleCheckRemoteSize.optionAskMeLater": { - readonly def: "Ask me later"; - readonly es: "PregÃēntame mÃĄs tarde"; - readonly fr: "Me demander plus tard"; - readonly he: "שאל מאוחר יו×Ēר"; - readonly ja: "垌でįĸēčĒã™ã‚‹"; - readonly ko: "ë‚˜ė¤‘ė— ëŦŧė–´ëŗ´ę¸°"; - readonly ru: "ĐĄĐŋŅ€ĐžŅĐ¸Ņ‚ŅŒ ĐŋОСĐļĐĩ"; - readonly zh: "į¨åŽé—Žæˆ‘"; - }; - readonly "moduleCheckRemoteSize.optionDismiss": { - readonly def: "Dismiss"; - readonly es: "Descartar"; - readonly fr: "Ignorer"; - readonly he: "דחה"; - readonly ja: "į„ĄčĻ–"; - readonly ko: "ëŦ´ė‹œ"; - readonly ru: "ĐžŅ‚ĐēĐģĐžĐŊĐ¸Ņ‚ŅŒ"; - readonly zh: "åŋŊį•Ĩ"; - }; - readonly "moduleCheckRemoteSize.optionIncreaseLimit": { - readonly def: "increase to ${newMax}MB"; - readonly es: "aumentar a ${newMax}MB"; - readonly fr: "augmenter à ${newMax} Mo"; - readonly he: "הגדל ל-${newMax}MB"; - readonly ja: "${newMax}MBãĢč¨­åŽš"; - readonly ko: "${newMax}MB로 ėĻę°€"; - readonly ru: "ŅƒĐ˛ĐĩĐģĐ¸Ņ‡Đ¸Ņ‚ŅŒ Đ´Đž newMaxМБ"; - readonly zh: "åĸžåŠ åˆ° ${newMax}MB"; - }; - readonly "moduleCheckRemoteSize.optionNoWarn": { - readonly def: "No, never warn please"; - readonly es: "No, nunca advertir por favor"; - readonly fr: "Non, ne jamais avertir"; - readonly he: "לא, אל ×Ēזהיר בכלל"; - readonly ja: "いいえ、č­Ļ告しãĒいでください"; - readonly ko: "ė•„ë‹ˆėš”, ę˛Ŋęŗ í•˜ė§€ ë§ˆė„¸ėš”"; - readonly ru: "НĐĩŅ‚, ĐŊĐĩ ŅƒĐ˛ĐĩĐ´ĐžĐŧĐģŅŅ‚ŅŒ"; - readonly zh: "不,č¯ˇæ°¸čŋœä¸čρč­Ļ告"; - }; - readonly "moduleCheckRemoteSize.optionRebuildAll": { - readonly def: "Rebuild Everything Now"; - readonly es: "Reconstruir todo ahora"; - readonly fr: "Tout reconstruire maintenant"; - readonly he: "בנה הכל מחדש ×ĸכשיו"; - readonly ja: "äģŠã™ãšãĻã‚’å†æ§‹į¯‰"; - readonly ko: "ė§€ę¸ˆ ëĒ¨ë“  것 ėžŦęĩŦėļ•"; - readonly ru: "ПĐĩŅ€ĐĩŅŅ‚Ņ€ĐžĐ¸Ņ‚ŅŒ Đ˛ŅŅ‘ ҁĐĩĐšŅ‡Đ°Ņ"; - readonly zh: "įĢ‹åŗé‡åģ翉€æœ‰å†…厚"; - }; - readonly "moduleCheckRemoteSize.titleDatabaseSizeLimitExceeded": { - readonly def: "Remote storage size exceeded the limit"; - readonly es: "El tamaÃąo del almacenamiento remoto superÃŗ el límite"; - readonly fr: "Taille du stockage distant au-delà de la limite"; - readonly he: "גודל האחסון המרוחד חרג מהמגבלה"; - readonly ja: "ãƒĒãƒĸãƒŧト゚トãƒŦãƒŧジã‚ĩイã‚ēがåˆļ限をčļ…過しぞした"; - readonly ko: "ė›ę˛Š ėŠ¤í† ëĻŦė§€ íŦ기가 ė œí•œė„ 봈ęŗŧ했ėŠĩ니다"; - readonly ru: "РаСĐŧĐĩŅ€ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐŗĐž Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰Đ° ĐŋŅ€ĐĩĐ˛Ņ‹ŅĐ¸Đģ ĐģиĐŧĐ¸Ņ‚"; - readonly zh: "čŋœį¨‹å­˜å‚¨å¤§å°čļ…å‡ē限åˆļ"; - }; - readonly "moduleCheckRemoteSize.titleDatabaseSizeNotify": { - readonly def: "Setting up database size notification"; - readonly es: "ConfiguraciÃŗn de notificaciÃŗn de tamaÃąo de base de datos"; - readonly fr: "Configuration de la notification de taille de base"; - readonly he: "הגדר×Ē ×”×Ēראה ×ĸל גודל מסד × ×Ēונים"; - readonly ja: "デãƒŧã‚ŋベãƒŧ゚ã‚ĩイã‚ē通įŸĨãŽč¨­åŽš"; - readonly ko: "ë°ė´í„°ë˛ ė´ėŠ¤ íŦ기 ė•ŒëĻŧ 네렕"; - readonly ru: "ĐĐ°ŅŅ‚Ņ€ĐžĐšĐēа ŅƒĐ˛ĐĩĐ´ĐžĐŧĐģĐĩĐŊĐ¸Ņ Đž Ņ€Đ°ĐˇĐŧĐĩŅ€Đĩ ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅"; - readonly zh: "莞įŊŽæ•°æŽåē“大小通įŸĨ"; - }; - readonly "moduleInputUIObsidian.defaultTitleConfirmation": { - readonly def: "Confirmation"; - readonly es: "ConfirmaciÃŗn"; - readonly fr: "Confirmation"; - readonly he: "אישור"; - readonly ja: "įĸēčĒ"; - readonly ko: "í™•ė¸"; - readonly ru: "ĐŸĐžĐ´Ņ‚Đ˛ĐĩŅ€ĐļĐ´ĐĩĐŊиĐĩ"; - readonly zh: "įĄŽčŽ¤"; - }; - readonly "moduleInputUIObsidian.defaultTitleSelect": { - readonly def: "Select"; - readonly es: "Seleccionar"; - readonly fr: "SÊlection"; - readonly he: "בחר"; - readonly ja: "選択"; - readonly ko: "ė„ íƒ"; - readonly ru: "Đ’Ņ‹ĐąĐžŅ€"; - readonly zh: "选拊"; - }; - readonly "moduleInputUIObsidian.optionNo": { - readonly def: "No"; - readonly es: "No"; - readonly fr: "Non"; - readonly he: "לא"; - readonly ja: "いいえ"; - readonly ko: "ė•„ë‹ˆėš”"; - readonly ru: "НĐĩŅ‚"; - readonly zh: "åĻ"; - }; - readonly "moduleInputUIObsidian.optionYes": { - readonly def: "Yes"; - readonly es: "Sí"; - readonly fr: "Oui"; - readonly he: "כן"; - readonly ja: "はい"; - readonly ko: "똈"; - readonly ru: "Да"; - readonly zh: "是"; - }; - readonly "moduleLiveSyncMain.logAdditionalSafetyScan": { - readonly def: "Additional safety scan..."; - readonly es: "EscanÊo de seguridad adicional..."; - readonly fr: "Analyse de sÊcuritÊ supplÊmentaire..."; - readonly he: "סריק×Ē ×‘×˜×™×—×•×Ē × ×•×Ą×¤×Ē..."; - readonly ja: "čŋŊåŠ ãŽåŽ‰å…¨ã‚šã‚­ãƒŖãƒŗä¸­..."; - readonly ko: "ėļ”ę°€ ė•ˆė „ 검ė‚Ŧ 뤑..."; - readonly ru: "ДоĐŋĐžĐģĐŊĐ¸Ņ‚ĐĩĐģҌĐŊĐ°Ņ ĐŋŅ€ĐžĐ˛ĐĩŅ€Đēа ĐąĐĩСОĐŋĐ°ŅĐŊĐžŅŅ‚Đ¸..."; - readonly zh: "éĸå¤–įš„åމ免æ‰Ģ描..."; - }; - readonly "moduleLiveSyncMain.logLoadingPlugin": { - readonly def: "Loading plugin..."; - readonly es: "Cargando complemento..."; - readonly fr: "Chargement du plugin..."; - readonly he: "טו×ĸן ×Ē×•×Ą×Ŗ..."; - readonly ja: "ãƒ—ãƒŠã‚°ã‚¤ãƒŗã‚’ãƒ­ãƒŧド中..."; - readonly ko: "플ëŸŦęˇ¸ė¸ 로딩 뤑..."; - readonly ru: "Đ—Đ°ĐŗŅ€ŅƒĐˇĐēа ĐŋĐģĐ°ĐŗĐ¸ĐŊа..."; - readonly zh: "æ­Ŗåœ¨åŠ čŊŊ插äģļ..."; - }; - readonly "moduleLiveSyncMain.logPluginInitCancelled": { - readonly def: "Plugin initialisation was cancelled by a module"; - readonly es: "La inicializaciÃŗn del complemento fue cancelada por un mÃŗdulo"; - readonly fr: "L'initialisation du plugin a ÊtÊ annulÊe par un module"; - readonly he: "א×Ēחול ה×Ē×•×Ą×Ŗ בוטל ×ĸל ידי מודול"; - readonly ja: "ãƒ—ãƒŠã‚°ã‚¤ãƒŗãŽåˆæœŸåŒ–ãŒãƒĸジãƒĨãƒŧãƒĢãĢã‚ˆãŖãĻã‚­ãƒŖãƒŗã‚ģãƒĢされぞした"; - readonly ko: "ëĒ¨ë“ˆė— ė˜í•´ 플ëŸŦęˇ¸ė¸ ė´ˆę¸°í™”ę°€ ėˇ¨ė†Œë˜ė—ˆėŠĩ니다"; - readonly ru: "ИĐŊĐ¸Ņ†Đ¸Đ°ĐģĐ¸ĐˇĐ°Ņ†Đ¸Ņ ĐŋĐģĐ°ĐŗĐ¸ĐŊа ĐžŅ‚ĐŧĐĩĐŊĐĩĐŊа ĐŧĐžĐ´ŅƒĐģĐĩĐŧ"; - readonly zh: "插äģļ初始化čĸĢæŸä¸Ēæ¨Ąå—å–æļˆ"; - }; - readonly "moduleLiveSyncMain.logPluginVersion": { - readonly def: "Self-hosted LiveSync v${manifestVersion} ${packageVersion}"; - readonly es: "Self-hosted LiveSync v${manifestVersion} ${packageVersion}"; - readonly fr: "Self-hosted LiveSync v${manifestVersion} ${packageVersion}"; - readonly he: "Self-hosted LiveSync גרסה ${manifestVersion} ${packageVersion}"; - readonly ja: "Self-hosted LiveSync v${manifestVersion} ${packageVersion}"; - readonly ko: "Self-hosted LiveSync v${manifestVersion} ${packageVersion}"; - readonly ru: "Self-hosted LiveSync vmanifestVersion packageVersion"; - readonly zh: "Self-hosted LiveSync v${manifestVersion} ${packageVersion}"; - }; - readonly "moduleLiveSyncMain.logReadChangelog": { - readonly def: "LiveSync has updated, please read the changelog!"; - readonly es: "LiveSync se ha actualizado, ÂĄpor favor lee el registro de cambios!"; - readonly fr: "LiveSync a ÊtÊ mis à jour, veuillez lire le journal des modifications !"; - readonly he: "LiveSync ×ĸודכן, אנא קרא א×Ē ×™×•×ž×Ÿ השינויים!"; - readonly ja: "LiveSyncが更新されぞした。変更åąĨ歴をおčĒ­ãŋくださいīŧ"; - readonly ko: "LiveSync가 ė—…ë°ė´íŠ¸ë˜ė—ˆėŠĩ니다. ëŗ€ę˛Ŋė‚Ŧí•­ė„ ėŊė–´ëŗ´ė„¸ėš”!"; - readonly ru: "LiveSync ОйĐŊОвĐģŅ‘ĐŊ, ĐŋĐžĐļаĐģŅƒĐšŅŅ‚Đ°, ĐŋŅ€ĐžŅ‡Đ¸Ņ‚Đ°ĐšŅ‚Đĩ ҁĐŋĐ¸ŅĐžĐē иСĐŧĐĩĐŊĐĩĐŊиК!"; - readonly zh: "LiveSync åˇ˛æ›´æ–°,č¯ˇé˜…č¯ģ更新æ—Ĩåŋ—īŧ"; - }; - readonly "moduleLiveSyncMain.logSafetyScanCompleted": { - readonly def: "Additional safety scan completed"; - readonly es: "EscanÊo de seguridad adicional completado"; - readonly fr: "Analyse de sÊcuritÊ supplÊmentaire terminÊe"; - readonly he: "סריק×Ē ×”×‘×˜×™×—×•×Ē ×”× ×•×Ą×¤×Ē ×”×•×Š×œ×ž×”"; - readonly ja: "čŋŊåŠ ãŽåŽ‰å…¨ã‚šã‚­ãƒŖãƒŗãŒåŽŒäē†ã—ぞした"; - readonly ko: "ėļ”ę°€ ė•ˆė „ 검ė‚Ŧ가 ė™„ëŖŒë˜ė—ˆėŠĩ니다"; - readonly ru: "ДоĐŋĐžĐģĐŊĐ¸Ņ‚ĐĩĐģҌĐŊĐ°Ņ ĐŋŅ€ĐžĐ˛ĐĩŅ€Đēа ĐąĐĩСОĐŋĐ°ŅĐŊĐžŅŅ‚Đ¸ СавĐĩŅ€ŅˆĐĩĐŊа"; - readonly zh: "éĸå¤–įš„åމ免æ‰Ģ描厌成"; - }; - readonly "moduleLiveSyncMain.logSafetyScanFailed": { - readonly def: "Additional safety scan has failed on a module"; - readonly es: "El escaneo de seguridad adicional ha fallado en un mÃŗdulo"; - readonly fr: "L'analyse de sÊcuritÊ supplÊmentaire a ÊchouÊ sur un module"; - readonly he: "סריק×Ē ×”×‘×˜×™×—×•×Ē ×”× ×•×Ą×¤×Ē × ×›×Š×œ×” במודול"; - readonly ja: "ãƒĸジãƒĨãƒŧãƒĢでčŋŊåŠ ãŽåŽ‰å…¨ã‚šã‚­ãƒŖãƒŗãŒå¤ąæ•—ã—ãžã—ãŸ"; - readonly ko: "ëĒ¨ë“ˆė—ė„œ ėļ”ę°€ ė•ˆė „ 검ė‚Ŧ가 ė‹¤íŒ¨í–ˆėŠĩ니다"; - readonly ru: "ДоĐŋĐžĐģĐŊĐ¸Ņ‚ĐĩĐģҌĐŊĐ°Ņ ĐŋŅ€ĐžĐ˛ĐĩŅ€Đēа ĐąĐĩСОĐŋĐ°ŅĐŊĐžŅŅ‚Đ¸ ĐŊĐĩ ŅƒĐ´Đ°ĐģĐ°ŅŅŒ в ĐŧĐžĐ´ŅƒĐģĐĩ"; - readonly zh: "éĸå¤–įš„åމ免æ‰Ģ描在某ä¸Ēæ¨Ąå—ä¸Šå¤ąč´Ĩ"; - }; - readonly "moduleLiveSyncMain.logUnloadingPlugin": { - readonly def: "Unloading plugin..."; - readonly es: "Descargando complemento..."; - readonly fr: "DÊchargement du plugin..."; - readonly he: "מסיר ×Ē×•×Ą×Ŗ..."; - readonly ja: "ãƒ—ãƒŠã‚°ã‚¤ãƒŗã‚’ã‚ĸãƒŗãƒ­ãƒŧド中..."; - readonly ko: "플ëŸŦęˇ¸ė¸ ė–¸ëĄœë”Š 뤑..."; - readonly ru: "Đ’Ņ‹ĐŗŅ€ŅƒĐˇĐēа ĐŋĐģĐ°ĐŗĐ¸ĐŊа..."; - readonly zh: "æ­Ŗåœ¨å¸čŊŊ插äģļ..."; - }; - readonly "moduleLiveSyncMain.logVersionUpdate": { - readonly def: "LiveSync has been updated, In case of breaking updates, all automatic synchronization has been temporarily disabled. Ensure that all devices are up to date before enabling."; - readonly es: "LiveSync se ha actualizado, en caso de actualizaciones que rompan, toda la sincronizaciÃŗn automÃĄtica se ha desactivado temporalmente. AsegÃērate de que todos los dispositivos estÊn actualizados antes de habilitar."; - readonly fr: "LiveSync a ÊtÊ mis à jour. En cas de mises à jour non rÊtrocompatibles, toute synchronisation automatique a ÊtÊ temporairement dÊsactivÊe. Assurez-vous que tous les appareils sont à jour avant d'activer."; - readonly he: "LiveSync ×ĸודכן. במקרה של ×ĸדכונים מ׊מ×ĸו×Ēיים, כל הסנכרון האוטומטי הושב×Ē ×–×ž× ×™×Ē. ודא שכל המכשירים מ×ĸודכנים לפני ההפ×ĸלה."; - readonly ja: "LiveSyncが更新されぞした。äē’æ›æ€§ãŽãĒい更新ぎ場合、すずãĻぎč‡Ēå‹•åŒæœŸãŒä¸€æ™‚įš„ãĢį„ĄåŠšåŒ–ã•ã‚ŒãĻいぞす。有劚ãĢする前ãĢ、すずãĻぎデバイ゚が最新ぎįŠļ態であることをįĸēčĒã—ãĻください。"; - readonly ko: "LiveSync가 ė—…ë°ė´íŠ¸ë˜ė—ˆėŠĩ니다. í˜¸í™˜ė„ą ëŦ¸ė œę°€ ėžˆëŠ” ė—…ë°ė´íŠ¸ė˜ ę˛Ŋ뚰 ëĒ¨ë“  ėžë™ 동기화가 ėŧė‹œė ėœŧ로 ëš„í™œė„ąí™”ë˜ė—ˆėŠĩ니다. í™œė„ąí™”í•˜ę¸° 렄뗐 ëĒ¨ë“  기기가 ėĩœė‹  ėƒíƒœė¸ė§€ í™•ė¸í•˜ė„¸ėš”."; - readonly ru: "LiveSync ОйĐŊОвĐģŅ‘ĐŊ. В ҁĐģŅƒŅ‡Đ°Đĩ ĐēŅ€Đ¸Ņ‚Đ¸Ņ‡ĐĩҁĐēĐ¸Ņ… иСĐŧĐĩĐŊĐĩĐŊиК Đ°Đ˛Ņ‚ĐžĐŧĐ°Ņ‚Đ¸Ņ‡ĐĩҁĐēĐ°Ņ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Ņ Đ˛Ņ€ĐĩĐŧĐĩĐŊĐŊĐž ĐžŅ‚ĐēĐģŅŽŅ‡ĐĩĐŊа. ĐŖĐąĐĩĐ´Đ¸Ņ‚ĐĩҁҌ, Ņ‡Ņ‚Đž Đ˛ŅĐĩ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ° ОйĐŊОвĐģĐĩĐŊŅ‹ ĐŋĐĩŅ€ĐĩĐ´ вĐēĐģŅŽŅ‡ĐĩĐŊиĐĩĐŧ."; - readonly zh: "LiveSync åˇ˛æ›´æ–°,åĻ‚æžœå­˜åœ¨į ´åæ€§æ›´æ–°,所有č‡Ē动同æ­Ĩåˇ˛æš‚æ—ļįĻį”¨ã€‚č¯ˇįĄŽäŋæ‰€æœ‰čŽžå¤‡éƒŊæ›´æ–°åˆ°æœ€æ–°į‰ˆæœŦåŽå†å¯į”¨"; - }; - readonly "moduleLiveSyncMain.msgScramEnabled": { - readonly def: "Self-hosted LiveSync has been configured to ignore some events. Is this correct?\n\n| Type | Status | Note |\n|:---:|:---:|---|\n| Storage Events | ${fileWatchingStatus} | Every modification will be ignored |\n| Database Events | ${parseReplicationStatus} | Every synchronised change will be postponed |\n\nDo you want to resume them and restart Obsidian?\n\n> [!DETAILS]-\n> These flags are set by the plug-in while rebuilding, or fetching. If the process ends abnormally, it may be kept unintended.\n> If you are not sure, you can try to rerun these processes. Make sure to back your vault up.\n"; - readonly es: "Self-hosted LiveSync se ha configurado para ignorar algunos eventos. ÂŋEs esto correcto?\n\n| Tipo | Estado | Nota |\n|:---:|:---:|---|\n| Eventos de almacenamiento | ${fileWatchingStatus} | Se ignorarÃĄ cada modificaciÃŗn |\n| Eventos de base de datos | ${parseReplicationStatus} | Cada cambio sincronizado se pospondrÃĄ |\n\nÂŋQuieres reanudarlos y reiniciar Obsidian?\n\n> [!DETAILS]-\n> Estas banderas son establecidas por el complemento mientras se reconstruye o se obtiene. Si el proceso termina de forma anormal, puede mantenerse sin querer.\n> Si no estÃĄs seguro, puedes intentar volver a ejecutar estos procesos. AsegÃērate de hacer una copia de seguridad de tu bÃŗveda.\n"; - readonly fr: "Self-hosted LiveSync a ÊtÊ configurÊ pour ignorer certains ÊvÊnements. Est-ce correct ?\n\n| Type | Statut | Note |\n|:---:|:---:|---|\n| ÉvÊnements de stockage | ${fileWatchingStatus} | Toute modification sera ignorÊe |\n| ÉvÊnements de base | ${parseReplicationStatus} | Tout changement synchronisÊ sera reportÊ |\n\nVoulez-vous les reprendre et redÊmarrer Obsidian ?\n\n> [!DETAILS]-\n> Ces indicateurs sont dÊfinis par le plug-in lors d'une reconstruction ou d'une rÊcupÊration. Si le processus se termine anormalement, ils peuvent rester activÊs involontairement.\n> Si vous n'ÃĒtes pas certain, vous pouvez relancer ces processus. Veillez à sauvegarder votre coffre.\n"; - readonly he: "Self-hosted LiveSync הוגדר לה×Ē×ĸלם מאירו×ĸים מסוימים. האם זה נכון?\n\n| סוג | סטטוס | ה×ĸרה |\n|:---:|:---:|---|\n| אירו×ĸי אחסון | ${fileWatchingStatus} | כל שינוי י×Ē×ĸלם |\n| אירו×ĸי מסד × ×Ēונים | ${parseReplicationStatus} | כל שינוי מסונכרן יידחה |\n\nהאם לחדש או×Ēם ולהפ×ĸיל מחדש א×Ē Obsidian?\n\n> [!DETAILS]-\n> דגלים אלה מוגדרים ×ĸל ידי הפלאגין במהלך בנייה מחדש או משיכה. אם ה×Ēהליך הס×Ēיים ב×Ļורה לא ×Ēקינה, יי×Ēכן שהם נשארו כלא מכוון.\n> אם אינך בטוח, ני×Ēן לנסו×Ē ×œ×”×¨×™×Ĩ מחדש א×Ē ×”×Ēהליכים. ודא שיש לך גיבוי של הכספ×Ē.\n"; - readonly ja: "Self-hosted LiveSyncã¯ä¸€éƒ¨ãŽã‚¤ãƒ™ãƒŗãƒˆã‚’į„ĄčĻ–ã™ã‚‹ã‚ˆã†ãĢč¨­åŽšã•ã‚ŒãĻã„ãžã™ã€‚ã“ã‚Œã¯æ­Ŗã—ã„ã§ã™ã‹īŧŸ\n\n| ã‚ŋイプ | ゚テãƒŧã‚ŋ゚ | ãƒĄãƒĸ |\n|:---:|:---:|---|\n| ゚トãƒŦãƒŧã‚¸ã‚¤ãƒ™ãƒŗãƒˆ | ${fileWatchingStatus} | すずãĻãŽå¤‰æ›´ãŒį„ĄčĻ–ã•ã‚Œãžã™ |\n| デãƒŧã‚ŋベãƒŧã‚šã‚¤ãƒ™ãƒŗãƒˆ | ${parseReplicationStatus} | すずãĻぎ同期された変更がåģᅵŸã•れぞす |\n\nこれらを再開しãĻObsidianを再čĩˇå‹•しぞすかīŧŸ\n\n> [!DETAILS]-\n> ã“ã‚Œã‚‰ãŽãƒ•ãƒŠã‚°ã¯ã€ãƒ—ãƒŠã‚°ã‚¤ãƒŗãŒå†æ§‹į¯‰ãžãŸã¯ãƒ•ã‚§ãƒƒãƒä¸­ãĢč¨­åŽšã•ã‚Œãžã™ã€‚ãƒ—ãƒ­ã‚ģã‚šãŒį•°å¸¸įĩ‚äē†ã—ãŸå ´åˆã€æ„å›ŗã›ãšäŋæŒã•れることがありぞす。\n> 不明ãĒ場合は、これらぎプロã‚ģã‚šã‚’å†åŽŸčĄŒã—ãĻãŋãĻください。åŋ…ずäŋįŽĄåēĢをバックã‚ĸップしãĻください。\n"; - readonly ko: "Self-hosted LiveSync가 ėŧëļ€ ė´ë˛¤íŠ¸ëĨŧ ëŦ´ė‹œí•˜ë„록 ė„¤ė •ë˜ė–´ ėžˆėŠĩ니다. ė´ ė„¤ė •ė´ 맞ėŠĩ니까?\n\n| ėœ í˜• | ėƒíƒœ | 네ëĒ… |\n|:---:|:---:|---|\n| ėŠ¤í† ëĻŦė§€ ė´ë˛¤íŠ¸ | ${fileWatchingStatus} | ëĒ¨ë“  ėˆ˜ė • ė‚Ŧí•­ė´ ëŦ´ė‹œëŠë‹ˆë‹¤ |\n| ë°ė´í„°ë˛ ė´ėŠ¤ ė´ë˛¤íŠ¸ | ${parseReplicationStatus} | ëĒ¨ë“  동기화 ëŗ€ę˛Ŋė´ ė§€ė—°ëŠë‹ˆë‹¤ |\n\nė´ë˛¤íŠ¸ 氐맀ëĨŧ ë‹¤ė‹œ í™œė„ąí™”í•˜ęŗ  Obsidianė„ ėžŦė‹œėž‘í•˜ė‹œę˛ ėŠĩ니까?\n\n> [!DETAILS]-\n> ė´ëŸŦ한 ė„¤ė •ė€ 플ëŸŦęˇ¸ė¸ė´ ėžŦęĩŦė„ą 또는 ë°ė´í„° ę°€ė ¸ė˜¤ę¸° 뤑뗐 ėžë™ėœŧ로 ė„¤ė •í•œ ę˛ƒėž…ë‹ˆë‹¤. í”„ëĄœė„¸ėŠ¤ę°€ ëš„ė •ėƒė ėœŧ로 ėĸ…ëŖŒë˜ëŠ´ ė´ ėƒíƒœę°€ ė˜ë„ėš˜ ė•Šę˛Œ ėœ ė§€ë  눘 ėžˆėŠĩ니다.\n> ėƒíƒœę°€ í™•ė‹¤í•˜ė§€ ė•Šë‹¤ëŠ´ ė´ ęŗŧė •ė„ ë‹¤ė‹œ ė‹¤í–‰í•´ ëŗ´ė„¸ėš”. ėžŦė‹œėž‘ 렄뗐 ë°˜ë“œė‹œ ëŗŧ트ëĨŧ ë°ąė—…í•´ ėŖŧė„¸ėš”."; - readonly ru: "Self-hosted LiveSync has been configured to ignore some events. Is this correct?\n\n| Type | Status | Note |\n|:---:|:---:|---|\n| Storage Events | ${fileWatchingStatus} | Every modification will be ignored |\n| Database Events | ${parseReplicationStatus} | Every synchronised change will be postponed |\n\nDo you want to resume them and restart Obsidian?\n\n> [!DETAILS]-\n> These flags are set by the plug-in while rebuilding, or fetching. If the process ends abnormally, it may be kept unintended.\n> If you are not sure, you can try to rerun these processes. Make sure to back your vault up.\n"; - readonly zh: "Self-hosted LiveSync 厞čĸĢ配įŊŽä¸ēåŋŊį•Ĩ某äē›äē‹äģļ。čŋ™æ ˇå¯šå—īŧŸ\n\n| įąģ型 | įŠļ态 | č¯´æ˜Ž |\n|:---:|:---:|---|\n| 存储äē‹äģļ | ${fileWatchingStatus} | 所有äŋŽæ”šéƒŊ将čĸĢåŋŊį•Ĩ |\n| 数捎åē“äē‹äģļ | ${parseReplicationStatus} | 所有同æ­Ĩįš„æ›´æ”šéƒŊ将čĸ̿ލčŋŸ |\n\næ‚¨æƒŗæĸ复厃äģŦåšļ重启 Obsidian 吗īŧŸ\n\n> [!DETAILS]-\n> čŋ™ä盿 ‡åŋ—是在重åģ翈–čŽˇå–æ—ļį”ąæ’äģļ莞įŊŽįš„。åĻ‚æžœčŋ‡į¨‹åŧ‚常į쓿Ÿ,厃äģŦ可čƒŊäŧščĸĢæ— æ„ä¸­äŋį•™ã€‚\n> åĻ‚æžœæ‚¨ä¸įĄŽåŽš,可äģĨå°č¯•é‡æ–°čŋčĄŒčŋ™äē›čŋ‡į¨‹ã€‚č¯ˇįĄŽäŋå¤‡äģŊæ‚¨įš„åē“。\n"; - }; - readonly "moduleLiveSyncMain.optionKeepLiveSyncDisabled": { - readonly def: "Keep LiveSync disabled"; - readonly es: "Mantener LiveSync desactivado"; - readonly fr: "Garder LiveSync dÊsactivÊ"; - readonly he: "השאר LiveSync מנוטרל"; - readonly ja: "LiveSyncã‚’į„ĄåŠšãŽãžãžãĢする"; - readonly ko: "LiveSync ëš„í™œė„ąí™” ėœ ė§€"; - readonly ru: "ĐžŅŅ‚Đ°Đ˛Đ¸Ņ‚ŅŒ LiveSync ĐžŅ‚ĐēĐģŅŽŅ‡Ņ‘ĐŊĐŊŅ‹Đŧ"; - readonly zh: "äŋæŒ LiveSync įρᔍ"; - }; - readonly "moduleLiveSyncMain.optionResumeAndRestart": { - readonly def: "Resume and restart Obsidian"; - readonly es: "Reanudar y reiniciar Obsidian"; - readonly fr: "Reprendre et redÊmarrer Obsidian"; - readonly he: "חדש והפ×ĸל מחדש א×Ē Obsidian"; - readonly ja: "再開しãĻObsidianを再čĩˇå‹•"; - readonly ko: "ėžŦ氜 후 Obsidian ėžŦė‹œėž‘"; - readonly ru: "ĐŸŅ€ĐžĐ´ĐžĐģĐļĐ¸Ņ‚ŅŒ и ĐŋĐĩŅ€ĐĩСаĐŋŅƒŅŅ‚Đ¸Ņ‚ŅŒ Obsidian"; - readonly zh: "æĸ复åšļ重启 Obsidian"; - }; - readonly "moduleLiveSyncMain.titleScramEnabled": { - readonly def: "Scram Enabled"; - readonly es: "Scram habilitado"; - readonly fr: "Mode Scram activÊ"; - readonly he: "מ×Ļב בלימה פ×ĸיל"; - readonly ja: "ᎊæ€Ĩ停æ­ĸ(Scram)が有劚"; - readonly ko: "Scram í™œė„ąí™”ë¨"; - readonly ru: "Đ­Đēҁ҂ҀĐĩĐŊĐŊĐ°Ņ ĐžŅŅ‚Đ°ĐŊОвĐēа вĐēĐģŅŽŅ‡ĐĩĐŊа"; - readonly zh: "į´§æ€Ĩ停æ­ĸ厞吝ᔍ"; - }; - readonly "moduleLocalDatabase.logWaitingForReady": { - readonly def: "Waiting for ready..."; - readonly es: "Esperando a que la base de datos estÊ lista..."; - readonly fr: "En attente de disponibilitÊ..."; - readonly he: "ממ×Ēין לכשירו×Ē..."; - readonly ja: "ã—ã°ã‚‰ããŠåž…ãĄãã ã•ã„..."; - readonly ko: "ė¤€ëš„ 대기 뤑..."; - readonly ru: "ОĐļидаĐŊиĐĩ ĐŗĐžŅ‚ĐžĐ˛ĐŊĐžŅŅ‚Đ¸..."; - readonly zh: "į­‰åž…å°ąįģĒ..."; - }; - readonly "moduleLog.showLog": { - readonly def: "Show Log"; - readonly es: "Mostrar registro"; - readonly fr: "Afficher le journal"; - readonly he: "ה×Ļג יומן"; - readonly ja: "ãƒ­ã‚°ã‚’čĄ¨į¤ē"; - readonly ko: "로그 í‘œė‹œ"; - readonly ru: "ПоĐēĐ°ĐˇĐ°Ņ‚ŅŒ ĐģĐžĐŗ"; - readonly zh: "昞į¤ēæ—Ĩåŋ—"; - }; - readonly "moduleMigration.docUri": { - readonly def: "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use"; - readonly es: "https://github.com/vrtmrz/obsidian-livesync/blob/main/README_ES.md#how-to-use"; - readonly fr: "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use"; - readonly he: "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use"; - readonly ja: "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use"; - readonly ko: "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use"; - readonly ru: "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use"; - readonly zh: "https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/zh/README_zh.md#%E5%A6%82%E4%BD%95%E4%BD%BF%E7%94%A8"; - }; - readonly "moduleMigration.fix0256.buttons.checkItLater": { - readonly def: "Check it later"; - readonly fr: "VÊrifier plus tard"; - readonly he: "בדוק מאוחר יו×Ēר"; - readonly ja: "垌でįĸēčĒã™ã‚‹"; - readonly ru: "ĐŸŅ€ĐžĐ˛ĐĩŅ€Đ¸Ņ‚ŅŒ ĐŋОСĐļĐĩ"; - readonly zh: "į¨åŽæŖ€æŸĨ"; - }; - readonly "moduleMigration.fix0256.buttons.DismissForever": { - readonly def: "I have fixed it, and do not ask again"; - readonly fr: "J'ai corrigÊ, et ne plus demander"; - readonly he: "×Ēיקנ×Ēי, ואל ×Ēשאל שוב"; - readonly ja: "äŋŽæ­Ŗæ¸ˆãŋ、äģŠåžŒįĸēčĒã—ãĒい"; - readonly ru: "Đ˜ŅĐŋŅ€Đ°Đ˛ĐģĐĩĐŊĐž, йОĐģҌ҈Đĩ ĐŊĐĩ ҁĐŋŅ€Đ°ŅˆĐ¸Đ˛Đ°Ņ‚ŅŒ"; - readonly zh: "æˆ‘åˇ˛įģäŋŽå¤äē†īŧŒä¸å†č¯ĸ问"; - }; - readonly "moduleMigration.fix0256.buttons.fix": { - readonly def: "Fix"; - readonly fr: "Corriger"; - readonly he: "×Ēקן"; - readonly ja: "äŋŽæ­Ŗ"; - readonly ru: "Đ˜ŅĐŋŅ€Đ°Đ˛Đ¸Ņ‚ŅŒ"; - readonly zh: "äŋŽå¤"; - }; - readonly "moduleMigration.fix0256.message": { - readonly def: "Due to a recent bug (in v0.25.6), some files may not have been saved correctly in the sync database.\nWe have scanned our files and found some that need to be fixed.\n\n**Files ready to be fixed:**\n\n${files}\n\nThese files have size-matched original file on the storage, and are likely to be recoverable.\nWe can use them to fix the database, please click the \"Fix\" button below to fix them.\n\n${messageUnrecoverable}\n\nIf you want to run it again, you can do so from Hatch.\n"; - readonly fr: "En raison d'un bug rÊcent (en v0.25.6), certains fichiers peuvent ne pas avoir ÊtÊ enregistrÊs correctement dans la base de synchronisation.\nNous avons analysÊ vos fichiers et trouvÊ ceux à corriger.\n\n**Fichiers prÃĒts à ÃĒtre corrigÊs :**\n\n${files}\n\nCes fichiers ont un original de taille correspondante sur le stockage, et sont probablement rÊcupÊrables.\nNous pouvons les utiliser pour corriger la base, veuillez cliquer sur le bouton ÂĢ Corriger Âģ ci-dessous pour les rÊparer.\n\n${messageUnrecoverable}\n\nSi vous voulez relancer l'opÊration, vous pouvez le faire depuis Hatch.\n"; - readonly he: "בשל באג אחרון (בגרסה 0.25.6), יי×Ēכן שחלק מהקב×Ļים לא נשמרו כהלכה במסד הנ×Ēונים לסנכרון.\nסרקנו א×Ē ×”×§×‘×Ļים ומ×Ļאנו כאלה שיש ל×Ēקן.\n\n**קב×Ļים מוכנים ל×Ēיקון:**\n\n${files}\n\nלקב×Ļים אלה יש קוב×Ĩ מקורי ×Ēואם גודל באחסון, וסביר שני×Ēן לשחזרם.\nני×Ēן להש×Ēמ׊ בהם ל×Ēיקון מסד הנ×Ēונים. לח×Ĩ ×ĸל כפ×Ēור \"×Ēקן\" למטה ל×Ēיקון.\n\n${messageUnrecoverable}\n\nאם בר×Ļונך להרי×Ĩ שוב, ני×Ēן ל×ĸשו×Ē ×–××Ē ×ž-Hatch.\n"; - readonly ja: "最čŋ‘ぎバグīŧˆv0.25.6īŧ‰ãĢã‚ˆã‚Šã€ä¸€éƒ¨ãŽãƒ•ã‚Ąã‚¤ãƒĢが同期デãƒŧã‚ŋベãƒŧ゚ãĢæ­Ŗã—ãäŋå­˜ã•れãĻいãĒい可čƒŊ性がありぞす。\nãƒ•ã‚Ąã‚¤ãƒĢã‚’ã‚šã‚­ãƒŖãƒŗã—ã€äŋŽæ­ŖãŒåŋ…čρãĒもぎがčĻ‹ã¤ã‹ã‚Šãžã—ãŸã€‚\n\n**äŋŽæ­Ŗæē–å‚™ãŒã§ããŸãƒ•ã‚Ąã‚¤ãƒĢ:**\n\n${files}\n\nã“ã‚Œã‚‰ãŽãƒ•ã‚Ąã‚¤ãƒĢぱトãƒŦãƒŧã‚¸ä¸ŠãŽå…ƒãƒ•ã‚Ąã‚¤ãƒĢとã‚ĩイã‚ēãŒä¸€č‡´ã—ãĻおり、垊元可čƒŊです。\n「äŋŽæ­Ŗã€ãƒœã‚ŋãƒŗã‚’ã‚¯ãƒĒックしãĻデãƒŧã‚ŋベãƒŧ゚をäŋŽæ­Ŗã§ããžã™ã€‚\n\n${messageUnrecoverable}\n\nå†åŽŸčĄŒã—ãŸã„å ´åˆã¯ã€Hatchã‹ã‚‰åŽŸčĄŒã§ããžã™ã€‚\n"; - readonly ru: "ИС-Са ĐŊĐĩдавĐŊĐĩĐš ĐžŅˆĐ¸ĐąĐēи ĐŊĐĩĐēĐžŅ‚ĐžŅ€Ņ‹Đĩ Ņ„Đ°ĐšĐģŅ‹ ĐŧĐžĐŗŅƒŅ‚ ĐąŅ‹Ņ‚ŅŒ ĐŊĐĩĐŋŅ€Đ°Đ˛Đ¸ĐģҌĐŊĐž ŅĐžŅ…Ņ€Đ°ĐŊĐĩĐŊŅ‹."; - readonly zh: "į”ąäēŽæœ€čŋ‘įš„ä¸€ä¸Ē bugīŧˆåœ¨ v0.25.6 į‰ˆæœŦ中īŧ‰īŧŒæŸä盿–‡äģļ可čƒŊæœĒæ­ŖįĄŽäŋå­˜åˆ°åŒæ­Ĩ数捎åē“中\n我äģŦ厞įģæ‰Ģ描ä熿–‡äģļīŧŒåšļå‘įŽ°ä¸€äē›éœ€čρäŋŽå¤įš„æ–‡äģļ\n\n**准备äŋŽå¤įš„æ–‡äģļ:**\n\n${files}\n\nčŋ™ä盿–‡äģļ在存储中与原文äģļįš„å¤§å°åŒšé…īŧŒå¯čƒŊ是可æĸå¤įš„\n我äģŦ可äģĨäŊŋį”¨åŽƒäģŦäŋŽå¤æ•°æŽåē“īŧŒč¯ˇį‚šå‡ģä¸‹æ–šįš„â€œäŋŽå¤â€æŒ‰é’Žčŋ›čĄŒäŋŽå¤\n\n${messageUnrecoverable}\n\n\nåĻ‚æžœäŊ å¸Œæœ›å†æŦĄæ‰§čĄŒæ­¤æ“äŊœīŧŒå¯äģĨ前垀 Hatch éĄĩéĸčŋ›čĄŒæ“äŊœ\n"; - }; - readonly "moduleMigration.fix0256.messageUnrecoverable": { - readonly def: "**Files cannot be fixed on this device:**\n\n${filesNotRecoverable}\n\nThese files have inconsistent metadata, and cannot be fixed on this device (mostly we cannot determine which is correct).\nTo restore them, please check your other devices (also by this feature) or restore them manually from a backup.\n"; - readonly fr: "**Fichiers non rÊparables sur cet appareil :**\n\n${filesNotRecoverable}\n\nCes fichiers ont des mÊtadonnÊes incohÊrentes et ne peuvent ÃĒtre corrigÊs sur cet appareil (le plus souvent, nous ne pouvons dÊterminer lequel est correct).\nPour les restaurer, vÊrifiez vos autres appareils (par cette mÃĒme fonction) ou restaurez-les manuellement depuis une sauvegarde.\n"; - readonly he: "**קב×Ļים שלא ני×Ēן ל×Ēקן במכשיר זה:**\n\n${filesNotRecoverable}\n\nלקב×Ļים אלה יש מטה-× ×Ēונים לא ×ĸקביים, ולא ני×Ēן ל×Ēקנם במכשיר זה (לרוב לא ני×Ēן לקבו×ĸ מה נכון). לשחזורם, אנא בדוק מכשירים אחרים שלך (גם ב×Ēכונה זו) או שחזר ידני×Ē ×ž×’×™×‘×•×™.\n"; - readonly ja: "**こぎデバイ゚でäŋŽæ­Ŗã§ããĒã„ãƒ•ã‚Ąã‚¤ãƒĢ:**\n\n${filesNotRecoverable}\n\nã“ã‚Œã‚‰ãŽãƒ•ã‚Ąã‚¤ãƒĢã¯ãƒĄã‚ŋデãƒŧã‚ŋãĢ不整合があり、こぎデバイ゚ではäŋŽæ­Ŗã§ããžã›ã‚“īŧˆãģã¨ã‚“ãŠãŽå ´åˆã€ãŠãĄã‚‰ãŒæ­Ŗã—ã„ã‹åˆ¤åŽšã§ããžã›ã‚“īŧ‰ã€‚\n垊元するãĢは、äģ–ぎデバイ゚でįĸēčĒã™ã‚‹ã‹ã€ãƒãƒƒã‚¯ã‚ĸップから手動で垊元しãĻください。\n"; - readonly ru: "ФаКĐģŅ‹ ĐŊĐĩ ĐŧĐžĐŗŅƒŅ‚ ĐąŅ‹Ņ‚ŅŒ Đ¸ŅĐŋŅ€Đ°Đ˛ĐģĐĩĐŊŅ‹ ĐŊа ŅŅ‚ĐžĐŧ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đĩ:"; - readonly zh: "**æ— æŗ•åœ¨æ­¤čŽžå¤‡ä¸ŠäŋŽå¤įš„æ–‡äģļ:**\n\n${filesNotRecoverable}\n\nčŋ™ä盿–‡äģļįš„å…ƒæ•°æŽä¸ä¸€č‡´īŧŒæ— æŗ•åœ¨æ­¤čŽžå¤‡ä¸ŠäŋŽå¤īŧˆå¤§å¤šæ•°æƒ…å†ĩ下我äģŦæ— æŗ•įĄŽåŽšå“Ē一ä¸Ēæ˜¯æ­ŖįĄŽįš„īŧ‰\nčρæĸ复厃äģŦīŧŒč¯ˇæŖ€æŸĨäŊ įš„å…ļäģ–čŽžå¤‡īŧˆåŒæ ˇäŊŋį”¨æ­¤åŠŸčƒŊīŧ‰īŧŒæˆ–äģŽå¤‡äģŊ中手动æĸ复\n"; - }; - readonly "moduleMigration.fix0256.title": { - readonly def: "Broken files has been detected"; - readonly fr: "Fichiers corrompus dÊtectÊs"; - readonly he: "זוהו קב×Ļים פגומים"; - readonly ja: "į ´æãƒ•ã‚Ąã‚¤ãƒĢが検å‡ēされぞした"; - readonly ru: "ОбĐŊĐ°Ņ€ŅƒĐļĐĩĐŊŅ‹ ĐŋĐžĐ˛Ņ€ĐĩĐļĐ´Ņ‘ĐŊĐŊŅ‹Đĩ Ņ„Đ°ĐšĐģŅ‹"; - readonly zh: "æŖ€æĩ‹åˆ°æŸåįš„æ–‡äģļ"; - }; - readonly "moduleMigration.insecureChunkExist.buttons.fetch": { - readonly def: "I already rebuilt the remote. Fetch from the remote"; - readonly fr: "J'ai dÊjà reconstruit le distant. RÊcupÊrer depuis le distant"; - readonly he: "כבר בני×Ēי מחדש א×Ē ×”×Š×¨×Ē ×”×ž×¨×•×—×§. משוך מהשר×Ē ×”×ž×¨×•×—×“"; - readonly ja: "ãƒĒãƒĸãƒŧトをæ—ĸãĢå†æ§‹į¯‰ã—ãŸã€‚ãƒĒãƒĸãƒŧトからフェッチ"; - readonly ru: "Đ¯ ҃ĐļĐĩ ĐŋĐĩŅ€ĐĩŅŅ‚Ņ€ĐžĐ¸Đģ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊŅƒŅŽ. Đ—Đ°ĐŗŅ€ŅƒĐˇĐ¸Ņ‚ŅŒ ҁ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊОК"; - readonly zh: "æˆ‘åˇ˛įģé‡åģēäē†čŋœį¨‹æ•°æŽåē“īŧŒå°†äģŽčŋœį¨‹čŽˇå–"; - }; - readonly "moduleMigration.insecureChunkExist.buttons.later": { - readonly def: "I will do it later"; - readonly fr: "Je le ferai plus tard"; - readonly he: "אטפל בזה מאוחר יו×Ēר"; - readonly ja: "åžŒã§čĄŒã†"; - readonly ru: "ĐĄĐ´ĐĩĐģĐ°ŅŽ ĐŋОСĐļĐĩ"; - readonly zh: "æˆ‘į¨åŽå†åš"; - }; - readonly "moduleMigration.insecureChunkExist.buttons.rebuild": { - readonly def: "Rebuild Everything"; - readonly fr: "Tout reconstruire"; - readonly he: "בנה הכל מחדש"; - readonly ja: "すずãĻã‚’å†æ§‹į¯‰"; - readonly ru: "ПĐĩŅ€ĐĩŅŅ‚Ņ€ĐžĐ¸Ņ‚ŅŒ Đ˛ŅŅ‘"; - readonly zh: "重åģ翉€æœ‰å†…厚"; - }; - readonly "moduleMigration.insecureChunkExist.laterMessage": { - readonly def: "We strongly recommend to treat this as soon as possible!"; - readonly fr: "Nous recommandons fortement de traiter ceci dès que possible !"; - readonly he: "אנו ממלי×Ļים בחום לטפל בזה בהקדם האפשרי!"; - readonly ja: "できるだけ旊く寞å‡ĻすることをåŧˇããŠå‹§ã‚ã—ぞすīŧ"; - readonly ru: "ĐœŅ‹ ĐŊĐ°ŅŅ‚ĐžŅŅ‚ĐĩĐģҌĐŊĐž Ņ€ĐĩĐēĐžĐŧĐĩĐŊĐ´ŅƒĐĩĐŧ ĐžĐąŅ€Đ°ĐąĐžŅ‚Đ°Ņ‚ŅŒ ŅŅ‚Đž ĐēаĐē ĐŧĐžĐļĐŊĐž ҁĐēĐžŅ€ĐĩĐĩ!"; - readonly zh: "我äģŦåŧē჈åģē莎å°ŊåŋĢå¤„į†æ­¤é—Žéĸ˜īŧ"; - }; - readonly "moduleMigration.insecureChunkExist.message": { - readonly def: "Some chunks are not securely stored and are not encrypted in databases.\n**Please rebuild the database to fix this issue**.\n\nIf your Remote Database is not configured with SSL, or using less-secure credentials, **you are at risk of exposing sensitive data**.\n\nNote: Please upgrade your Self-hosted LiveSync v0.25.6 or higher on all your devices, and back your vault up surely.\nNote2: Rebuild Everything and Fetch consumes a bit of time and traffic, please do it in off-peak hours and ensure a stable network connection.\n"; - readonly fr: "Certains fragments ne sont pas stockÊs de façon sÊcurisÊe et ne sont pas chiffrÊs dans les bases.\n**Veuillez reconstruire la base pour corriger ce problème.**\n\nSi votre base distante n'est pas configurÊe avec SSL, ou utilise des identifiants peu sÃģrs, **vous risquez d'exposer des donnÊes sensibles**.\n\nNote : Veuillez mettre à jour Self-hosted LiveSync en v0.25.6 ou supÊrieur sur tous vos appareils, et sauvegardez votre coffre avec soin.\nNote 2 : Tout reconstruire et RÊcupÊrer consomme un peu de temps et de bande passante, veuillez le faire hors des heures de pointe et avec une connexion rÊseau stable.\n"; - readonly he: "חלק מהנ×Ēחים לא מאוחסנים ב×Ļורה מאובטח×Ē ×•××™× × מו×Ļפנים במסד הנ×Ēונים.\n**אנא בנה מחדש א×Ē ×ž×Ą×“ הנ×Ēונים כדי ל×Ēקן ב×ĸיה זו**.\n\nאם מסד הנ×Ēונים המרוחד אינו מוגדר ×ĸם SSL, או מ׊×Ēמ׊ בפרטי גישה פחו×Ē ×ž××•×‘×˜×—×™×, **א×Ēה בסיכון של חשיפ×Ē ×ž×™×“×ĸ רגיש**.\n\nה×ĸרה: אנא שדרג א×Ē Self-hosted LiveSync לגרסה 0.25.6 ומ×ĸלה ×ĸל כל מכשיריך, וגבה א×Ē ×”×›×Ą×¤×Ē ×Š×œ×š.\nה×ĸרה 2: בנייה מחדש ומשיכה דורשו×Ē ×–×ž×Ÿ ו×Ē×ĸבור×Ē ×¨×Š×Ē. אנא ×ĸשה זא×Ē ×‘×Š×ĸו×Ē ×Š×™× נמוך וודא חיבור ר׊×Ē ×™×Ļיב.\n"; - readonly ja: "ä¸€éƒ¨ãŽãƒãƒŖãƒŗã‚¯ãŒåŽ‰å…¨ãĢäŋå­˜ã•れãĻおらず、デãƒŧã‚ŋベãƒŧã‚šã§æš—åˇåŒ–ã•ã‚ŒãĻいぞせん。\n**ã“ãŽå•éĄŒã‚’äŋŽæ­Ŗã™ã‚‹ãĢはデãƒŧã‚ŋベãƒŧã‚šã‚’å†æ§‹į¯‰ã—ãĻください**。\n\nãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚がSSLã§č¨­åŽšã•ã‚ŒãĻいãĒい、ぞたは厉全性ぎäŊŽã„čĒč¨ŧæƒ…å ąã‚’äŊŋį”¨ã—ãĻいる場合、**抟密デãƒŧã‚ŋがæŧæ´Šã™ã‚‹ãƒĒ゚クがありぞす**。\n\næŗ¨æ„: すずãĻぎデバイ゚でSelf-hosted LiveSync v0.25.6äģĨ降ãĢã‚ĸップグãƒŦãƒŧドし、åŋ…ずäŋįŽĄåēĢをバックã‚ĸップしãĻください。\næŗ¨æ„2: すずãĻã‚’å†æ§‹į¯‰ã¨ãƒ•ã‚§ãƒƒãƒã¯æ™‚é–“ã¨ãƒˆãƒŠãƒ•ã‚Ŗãƒƒã‚¯ã‚’æļˆč˛ģしぞす。ã‚Ēフピãƒŧク時間ãĢ厉厚したネットワãƒŧã‚¯ã§åŽŸčĄŒã—ãĻください。\n"; - readonly ru: "НĐĩĐēĐžŅ‚ĐžŅ€Ņ‹Đĩ Ņ‡Đ°ĐŊĐēи Ņ…Ņ€Đ°ĐŊŅŅ‚ŅŅ ĐŊĐĩĐąĐĩСОĐŋĐ°ŅĐŊĐž. ПоĐļаĐģŅƒĐšŅŅ‚Đ°, ĐŋĐĩŅ€ĐĩŅŅ‚Ņ€ĐžĐšŅ‚Đĩ ĐąĐ°ĐˇŅƒ даĐŊĐŊҋ҅."; - readonly zh: "一äē›å—æœĒ厉全存储īŧŒåšļ且在数捎åē“中æœĒ加密\n**č¯ˇé‡åģēæ•°æŽåē“äģĨäŋŽå¤æ­¤é—Žéĸ˜**.\n\nåĻ‚æžœäŊ įš„čŋœį¨‹æ•°æŽå瓿œĒ配įŊŽ SSLīŧŒæˆ–者äŊŋᔍäē†ä¸åŽ‰å…¨įš„å‡­æŽ **äŊ å¯čƒŊéĸä¸´æš´éœ˛æ•æ„Ÿæ•°æŽįš„éŖŽé™Š**.\n\næŗ¨æ„īŧšč¯ˇåœ¨æ‰€æœ‰čŽžå¤‡ä¸Šå°† Self-hosted LiveSync 升įē§åˆ° v0.25.6 或更éĢ˜į‰ˆæœŦīŧŒåšļįĄŽäŋå¤‡äģŊäŊ įš„äŋé™Šåē“\n\næŗ¨æ„2īŧšé‡åģ翉€æœ‰å†…åŽšå’ŒčŽˇå–æ“äŊœäŧšæļˆč€—一ä盿—ļ间和æĩé‡īŧŒč¯ˇåœ¨éžé̘庰æ—ļæŽĩčŋ›čĄŒīŧŒåšļįĄŽäŋįŊ‘įģœčŋžæŽĨį¨ŗåŽš\n"; - }; - readonly "moduleMigration.insecureChunkExist.title": { - readonly def: "Insecure chunks found!"; - readonly fr: "Fragments non sÊcurisÊs dÊtectÊs !"; - readonly he: "נמ×Ļאו × ×Ēחים לא מאובטחים!"; - readonly ja: "厉全でãĒã„ãƒãƒŖãƒŗã‚¯ãŒčĻ‹ã¤ã‹ã‚Šãžã—ãŸīŧ"; - readonly ru: "ОбĐŊĐ°Ņ€ŅƒĐļĐĩĐŊŅ‹ ĐŊĐĩĐąĐĩСОĐŋĐ°ŅĐŊŅ‹Đĩ Ņ‡Đ°ĐŊĐēи!"; - readonly zh: "å‘įŽ°ä¸åŽ‰å…¨įš„å—īŧ"; - }; - readonly "moduleMigration.logBulkSendCorrupted": { - readonly def: "Send chunks in bulk has been enabled, however, this feature had been corrupted. Sorry for your inconvenience. Automatically disabled."; - readonly es: "El envío de fragmentos en bloque se ha habilitado, sin embargo, esta funciÃŗn se ha corrompido. Disculpe las molestias. Deshabilitado automÃĄticamente."; - readonly fr: "L'envoi groupÊ de fragments a ÊtÊ activÊ, mais cette fonctionnalitÊ Êtait corrompue. DÊsolÊ pour la gÃĒne. DÊsactivÊe automatiquement."; - readonly he: "שליח×Ē × ×Ēחים בא×Ļווה הופ×ĸלה, אך ×Ēכונה זו היי×Ēה פגו×ĸה. מ×Ē× ×Ļלים ×ĸל אי הנוחו×Ē. נוטרלה אוטומטי×Ē."; - readonly ja: "ãƒãƒŖãƒŗã‚¯ãŽä¸€æ‹Ŧ送äŋĄãŒæœ‰åŠšãĢされãĻいぞしたが、こぎ抟čƒŊãĢå•éĄŒãŒã‚ã‚Šãžã—ãŸã€‚ã”ä¸äžŋをおかけしãĻį”ŗã—č¨ŗã‚ã‚Šãžã›ã‚“ã€‚č‡Ēå‹•įš„ãĢį„ĄåŠšåŒ–ã•ã‚Œãžã—ãŸã€‚"; - readonly ko: "랭íŦ ėŧ괄 ė „ė†Ąė´ í™œė„ąí™”ë˜ė—ˆė§€ë§Œ, ė´ 기ëŠĨ뗐 ëŦ¸ė œę°€ ėžˆė—ˆėŠĩ니다. ëļˆíŽ¸ė„ 드려 ėŖ„ė†Ąí•Šë‹ˆë‹¤. ėžë™ėœŧ로 ëš„í™œė„ąí™”ë˜ė—ˆėŠĩ니다."; - readonly ru: "ĐžŅ‚ĐŋŅ€Đ°Đ˛Đēа Ņ‡Đ°ĐŊĐēОв ĐŋаĐēĐĩŅ‚Đ°Đŧи ĐąŅ‹Đģа вĐēĐģŅŽŅ‡ĐĩĐŊа, ĐŊĐž ŅŅ‚Đ° Ņ„ŅƒĐŊĐēŅ†Đ¸Ņ ĐąŅ‹Đģа ĐŋĐžĐ˛Ņ€ĐĩĐļĐ´ĐĩĐŊа. ĐŸŅ€Đ¸ĐŊĐžŅĐ¸Đŧ иСвиĐŊĐĩĐŊĐ¸Ņ. ĐĐ˛Ņ‚ĐžĐŧĐ°Ņ‚Đ¸Ņ‡ĐĩҁĐēи ĐžŅ‚ĐēĐģŅŽŅ‡ĐĩĐŊĐž."; - readonly zh: "åˇ˛å¯į”¨æ‰šé‡å‘é€ chunks,äŊ†æ­¤åŠŸčƒŊåˇ˛æŸåã€‚į왿‚¨å¸ĻæĨ不äžŋ,我äģŦæˇąčĄ¨æ­‰æ„ã€‚åˇ˛č‡Ē动įρᔍ"; - }; - readonly "moduleMigration.logFetchRemoteTweakFailed": { - readonly def: "Failed to fetch remote tweak values"; - readonly es: "Error al obtener los valores de ajuste remoto"; - readonly fr: "Échec de la rÊcupÊration des valeurs d'ajustement distantes"; - readonly he: "נכשל במשיכ×Ē ×ĸרכי כיוונון מרוחקים"; - readonly ja: "ãƒĒãƒĸãƒŧトぎčĒŋ整値ぎ取垗ãĢå¤ąæ•—ã—ãžã—ãŸ"; - readonly ko: "ė›ę˛Š ėĄ°ė • ę°’ė„ ę°€ė ¸ė˜¤ëŠ”ë° ė‹¤íŒ¨í–ˆėŠĩ니다"; - readonly ru: "НĐĩ ŅƒĐ´Đ°ĐģĐžŅŅŒ ĐˇĐ°ĐŗŅ€ŅƒĐˇĐ¸Ņ‚ŅŒ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊŅ‹Đĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи"; - readonly zh: "čŽˇå–čŋœį¨‹č°ƒæ•´å€ŧå¤ąč´Ĩ"; - }; - readonly "moduleMigration.logLocalDatabaseNotReady": { - readonly def: "Something went wrong! The local database is not ready"; - readonly es: "ÂĄAlgo saliÃŗ mal! La base de datos local no estÃĄ lista"; - readonly fr: "Un problème est survenu ! La base locale n'est pas prÃĒte"; - readonly he: "משהו הש×Ēבש! מסד הנ×Ēונים המקומי אינו מוכן"; - readonly ja: "äŊ•ã‹å•éĄŒãŒį™ēį”Ÿã—ãžã—ãŸīŧãƒ­ãƒŧã‚ĢãƒĢデãƒŧã‚ŋベãƒŧ゚がæē–備できãĻいぞせん"; - readonly ko: "ëŦ¸ė œę°€ ë°œėƒí–ˆėŠĩ니다! 로ėģŦ ë°ė´í„°ë˛ ė´ėŠ¤ę°€ ė¤€ëš„ë˜ė§€ ė•Šė•˜ėŠĩ니다"; - readonly ru: "Đ§Ņ‚Đž-Ņ‚Đž ĐŋĐžŅˆĐģĐž ĐŊĐĩ Ņ‚Đ°Đē! ЛоĐēаĐģҌĐŊĐ°Ņ йаСа даĐŊĐŊҋ҅ ĐŊĐĩ ĐŗĐžŅ‚ĐžĐ˛Đ°"; - readonly zh: "å‡ē错äē†īŧæœŦ地数捎åē“å°šæœĒ准备åĨŊ"; - }; - readonly "moduleMigration.logMigratedSameBehaviour": { - readonly def: "Migrated to db:${current} with the same behaviour as before"; - readonly es: "Migrado a db:${current} con el mismo comportamiento que antes"; - readonly fr: "Migration vers db:${current} avec le mÃĒme comportement qu'auparavant"; - readonly he: "הוגר ל-db:${current} ×ĸם או×Ēה ה×Ēנהגו×Ē ×›×ž×§×•×“×"; - readonly ja: "äģĨ前と同じ動äŊœã§db:${current}ãĢį§ģčĄŒã—ãžã—ãŸ"; - readonly ko: "ė´ė „ęŗŧ ę°™ė€ ë°Šė‹ėœŧ로 ë™ėž‘í•˜ë„ëĄ db:${current}로 ë°ė´í„° ęĩŦėĄ° ė „í™˜ė´ ė™„ëŖŒë˜ė—ˆėŠĩ니다"; - readonly ru: "ĐœĐ¸ĐŗŅ€Đ°Ņ†Đ¸Ņ ĐŊа db:current ҁ Ņ‚ĐĩĐŧ ĐļĐĩ ĐŋОвĐĩĐ´ĐĩĐŊиĐĩĐŧ, Ņ‡Ņ‚Đž и Ņ€Đ°ĐŊҌ҈Đĩ"; - readonly zh: "厞čŋį§ģ到 db:${current},行ä¸ēä¸Žäš‹å‰į›¸åŒ"; - }; - readonly "moduleMigration.logMigrationFailed": { - readonly def: "Migration failed or cancelled from ${old} to ${current}"; - readonly es: "La migraciÃŗn fallÃŗ o se cancelÃŗ de ${old} a ${current}"; - readonly fr: "Migration ÊchouÊe ou annulÊe de ${old} vers ${current}"; - readonly he: "הגירה נכשלה או בוטלה מ-${old} ל-${current}"; - readonly ja: "${old}から${current}へぎį§ģčĄŒãŒå¤ąæ•—ãžãŸã¯ã‚­ãƒŖãƒŗã‚ģãƒĢされぞした"; - readonly ko: "${old}ė—ė„œ ${current}ëĄœė˜ ë°ė´í„° ęĩŦėĄ° ė „í™˜ė´ ė‹¤íŒ¨í–ˆęą°ë‚˜ ė¤‘ë‹¨ë˜ė—ˆėŠĩ니다"; - readonly ru: "ĐœĐ¸ĐŗŅ€Đ°Ņ†Đ¸Ņ ĐŊĐĩ ŅƒĐ´Đ°ĐģĐ°ŅŅŒ иĐģи ĐžŅ‚ĐŧĐĩĐŊĐĩĐŊа ҁ old ĐŊа current"; - readonly zh: "äģŽ ${old} 到 ${current} įš„čŋį§ģå¤ąč´Ĩæˆ–åˇ˛å–æļˆ"; - }; - readonly "moduleMigration.logRedflag2CreationFail": { - readonly def: "Failed to create redflag2"; - readonly es: "Error al crear redflag2"; - readonly fr: "Échec de crÊation de redflag2"; - readonly he: "י×Ļיר×Ē redflag2 נכשלה"; - readonly ja: "redflag2ぎäŊœæˆãĢå¤ąæ•—ã—ãžã—ãŸ"; - readonly ko: "redflag2 ėƒė„ąė— ė‹¤íŒ¨í–ˆėŠĩ니다"; - readonly ru: "НĐĩ ŅƒĐ´Đ°ĐģĐžŅŅŒ ŅĐžĐˇĐ´Đ°Ņ‚ŅŒ redflag2"; - readonly zh: "创åģē redflag2 å¤ąč´Ĩ"; - }; - readonly "moduleMigration.logRemoteTweakUnavailable": { - readonly def: "Could not get remote tweak values"; - readonly es: "No se pudieron obtener los valores de ajuste remoto"; - readonly fr: "Impossible d'obtenir les valeurs d'ajustement distantes"; - readonly he: "לא ני×Ēן לקבל ×ĸרכי כיוונון מרוחקים"; - readonly ja: "ãƒĒãƒĸãƒŧトぎčĒŋ整値を取垗できぞせんでした"; - readonly ko: "ė›ę˛Š ėĄ°ė • ę°’ė„ 氀렏ė˜Ŧ 눘 ė—†ėŠĩ니다"; - readonly ru: "НĐĩ ŅƒĐ´Đ°ĐģĐžŅŅŒ ĐŋĐžĐģŅƒŅ‡Đ¸Ņ‚ŅŒ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊŅ‹Đĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи"; - readonly zh: "æ— æŗ•čŽˇå–čŋœį¨‹č°ƒæ•´å€ŧ"; - }; - readonly "moduleMigration.logSetupCancelled": { - readonly def: "The setup has been cancelled, Self-hosted LiveSync waiting for your setup!"; - readonly es: "La configuraciÃŗn ha sido cancelada, ÂĄSelf-hosted LiveSync estÃĄ esperando tu configuraciÃŗn!"; - readonly fr: "La configuration a ÊtÊ annulÊe, Self-hosted LiveSync attend votre configuration !"; - readonly he: "ההגדרה בוטלה, Self-hosted LiveSync ממ×Ēין להגדר×Ēך!"; - readonly ja: "ã‚ģットã‚ĸãƒƒãƒ—ãŒã‚­ãƒŖãƒŗã‚ģãƒĢされぞした。Self-hosted LiveSyncはã‚ģットã‚ĸãƒƒãƒ—ã‚’åž…ãŖãĻいぞすīŧ"; - readonly ko: "ė„¤ė •ė´ ėˇ¨ė†Œë˜ė—ˆėŠĩ니다. Self-hosted LiveSync가 ė„¤ė •ė„ 기다ëĻŦęŗ  ėžˆėŠĩ니다!"; - readonly ru: "ĐĐ°ŅŅ‚Ņ€ĐžĐšĐēа ĐžŅ‚ĐŧĐĩĐŊĐĩĐŊа, Self-hosted LiveSync ĐžĐļидаĐĩŅ‚ Đ˛Đ°ŅˆĐĩĐš ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи!"; - readonly zh: "莞įŊŽåˇ˛å–æļˆ,Self-hosted LiveSync æ­Ŗåœ¨į­‰åž…æ‚¨įš„čŽžįŊŽīŧ"; - }; - readonly "moduleMigration.msgFetchRemoteAgain": { - readonly def: "As you may already know, the self-hosted LiveSync has changed its default behaviour and database structure.\n\nAnd thankfully, with your time and efforts, the remote database appears to have already been migrated. Congratulations!\n\nHowever, we need a bit more. The configuration of this device is not compatible with the remote database. We will need to fetch the remote database again. Should we fetch from the remote again now?\n\n___Note: We cannot synchronise until the configuration has been changed and the database has been fetched again.___\n___Note2: The chunks are completely immutable, we can fetch only the metadata and difference.___"; - readonly es: "Como ya sabrÃĄs, Self-hosted LiveSync ha cambiado su comportamiento predeterminado y la estructura de la base de datos.\n\nAfortunadamente, con tu tiempo y esfuerzo, la base de datos remota parece haber sido ya migrada. ÂĄFelicidades!\n\nSin embargo, necesitamos un poco mÃĄs. La configuraciÃŗn de este dispositivo no es compatible con la base de datos remota. Necesitaremos volver a obtener la base de datos remota. ÂŋDebemos obtenerla nuevamente ahora?\n\n___Nota: No podemos sincronizar hasta que la configuraciÃŗn haya sido cambiada y la base de datos haya sido obtenida nuevamente.___\n___Nota2: Los fragmentos son completamente inmutables, solo podemos obtener los metadatos y diferencias.___"; - readonly fr: "Comme vous le savez peut-ÃĒtre dÊjà, Self-hosted LiveSync a modifiÊ son comportement par dÊfaut et la structure de sa base de donnÊes.\n\nEt, grÃĸce à votre temps et vos efforts, la base distante semble dÊjà avoir ÊtÊ migrÊe. FÊlicitations !\n\nCependant, il faut encore un peu plus. La configuration de cet appareil n'est pas compatible avec la base distante. Nous devrons rÊcupÊrer à nouveau la base distante. Devons-nous rÊcupÊrer depuis le distant maintenant ?\n\n___Note : Nous ne pouvons pas synchroniser tant que la configuration n'a pas ÊtÊ modifiÊe et que la base n'a pas ÊtÊ rÊcupÊrÊe à nouveau.___\n___Note 2 : Les fragments sont complètement immuables, nous ne pouvons rÊcupÊrer que les mÊtadonnÊes et les diffÊrences.___"; - readonly he: "כפי שיי×Ēכן שכבר ידו×ĸ לך, Self-hosted LiveSync שינה א×Ē ×”×Ēנהגו×Ē ×‘×¨×™×¨×Ē ×”×ž×—×“×œ ומבנה מסד הנ×Ēונים.\n\nובזכו×Ē ×–×ž× ×š ומאמ×Ļיך, מסד הנ×Ēונים המרוחד נראה כבר הוגר. ברכו×Ē!\n\n×ĸם זא×Ē, נדרש ×ĸוד ×§×Ļ×Ē. ×Ē×Ļור×Ē ×ž×›×Š×™×¨ זה אינה ×Ēואמ×Ē ×œ×ž×Ą×“ הנ×Ēונים המרוחד. × ×Ļטרך למשוך א×Ē ×ž×Ą×“ הנ×Ēונים המרוחד שוב. האם למשוך מהשר×Ē ×”×ž×¨×•×—×“ ×ĸכשיו?\n\n___ה×ĸרה: לא ני×Ēן לסנכרן ×ĸד שה×Ē×Ļורה ×Ē׊×Ēנה ומסד הנ×Ēונים יימשך שוב.___\n___ה×ĸרה 2: הנ×Ēחים הם בל×Ēי-ני×Ēנים לשינוי לחלוטין, ני×Ēן למשוך רק א×Ē ×”×ž×˜×”-× ×Ēונים וההפרש.___"; - readonly ja: "ご存įŸĨぎとおり、self-hosted LiveSyncはデフりãƒĢトぎ動äŊœã¨ãƒ‡ãƒŧã‚ŋベãƒŧ゚構造を変更しぞした。\n\nご協力ぎおかげで、ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚はすでãĢį§ģčĄŒã•ã‚ŒãĻいるようです。おめでとうございぞすīŧ\n\nしかし、もう少しåŋ…čĻã§ã™ã€‚ã“ãŽãƒ‡ãƒã‚¤ã‚šãŽč¨­åŽšã¯ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚とäē’æ›æ€§ãŒã‚りぞせん。ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚を再åēĻフェッチするåŋ…čĻãŒã‚ã‚Šãžã™ã€‚äģŠã™ããƒĒãƒĸãƒŧトから再フェッチしぞすかīŧŸ\n\n___æŗ¨æ„: č¨­åŽšãŒå¤‰æ›´ã•ã‚Œã€ãƒ‡ãƒŧã‚ŋベãƒŧ゚が再フェッチされるぞで同期できぞせん。___\n___æŗ¨æ„2: ãƒãƒŖãƒŗã‚¯ã¯åŽŒå…¨ãĢ不変ãĒãŽã§ã€ãƒĄã‚ŋデãƒŧã‚ŋã¨åˇŽåˆ†ãŽãŋフェッチできぞす。___"; - readonly ko: "ė´ë¯¸ ė•Œęŗ  ęŗ„ė‹œę˛ ė§€ë§Œ, Self-hosted LiveSyncė˜ ę¸°ëŗ¸ ë™ėž‘ ë°Šė‹ęŗŧ ë°ė´í„°ë˛ ė´ėŠ¤ ęĩŦėĄ°ę°€ ëŗ€ę˛Ŋë˜ė—ˆėŠĩ니다.\n\n다행히도 ė—ŦëŸŦëļ„ė˜ 노ë Ĩ 덕ëļ„뗐 ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ëŠ” ė´ë¯¸ ė„ąęŗĩ렁ėœŧ로 ë°ė´í„° ęĩŦėĄ° ė „í™˜ė´ ė™„ëŖŒëœ 것ėœŧ로 ëŗ´ėž…ë‹ˆë‹¤. ėļ•하드ëĻŊ니다!\n\ní•˜ė§€ë§Œ 땄링 ėŧëļ€ ėļ”ę°€ ėž‘ė—…ė´ í•„ėš”í•Šë‹ˆë‹¤. ė´ ę¸°ę¸°ė˜ ė„¤ė •ė´ ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ė™€ í˜¸í™˜ë˜ė§€ ė•Šėœŧë¯€ëĄœ, ė›ę˛Š ë°ė´í„°ëĨŧ ë‹¤ė‹œ 氀렏뙀ė•ŧ 합니다. ė§€ę¸ˆ ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ëĨŧ ë‹¤ė‹œ ę°€ė ¸ė˜¤ė‹œę˛ ėŠĩ니까?\n\n___및溠: ė„¤ė •ė´ ëŗ€ę˛Ŋë˜ęŗ  ë°ė´í„°ë˛ ė´ėŠ¤ëĨŧ ë‹¤ė‹œ ëļˆëŸŦ똤揰 ė „ęšŒė§€ëŠ” 동기화가 ëļˆę°€ëŠĨ합니다.___\n___및溠2: 랭íŦ는 ëŗ€ę˛Ŋė´ ëļˆę°€ëŠĨ한 ęĩŦėĄ°ė´ë¯€ëĄœ, ëŠ”íƒ€ë°ė´í„°ė™€ ė°¨ė´ė ë§Œ 氀렏ė˜Ŧ 눘 ėžˆėŠĩ니다.___"; - readonly ru: "ĐŖĐ´Đ°ĐģŅ‘ĐŊĐŊĐ°Ņ йаСа даĐŊĐŊҋ҅, ĐŋĐžŅ…ĐžĐļĐĩ, ҃ĐļĐĩ ĐąŅ‹Đģа ĐŧĐ¸ĐŗŅ€Đ¸Ņ€ĐžĐ˛Đ°ĐŊа. КоĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Ņ ŅŅ‚ĐžĐŗĐž ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ° ĐŊĐĩŅĐžĐ˛ĐŧĐĩŅŅ‚Đ¸Đŧа."; - readonly zh: "您可čƒŊ厞įģįŸĨ道,Self-hosted LiveSync 更攚äē†å…ļéģ˜čŽ¤čĄŒä¸ē和数捎åē“į쓿ž„。\n\nå€ŧåž—åē†åš¸įš„æ˜¯,åœ¨æ‚¨įš„æ—ļ间和åŠĒ力下,čŋœį¨‹æ•°æŽåē“äŧŧäšŽåˇ˛įģčŋį§ģ厌成。恭喜īŧ\n\näŊ†æ˜¯,我äģŦčŋ˜éœ€čĻä¸€į‚šį‚šæ“äŊœã€‚æ­¤čŽžå¤‡įš„é…įŊŽä¸Žčŋœį¨‹æ•°æŽåē“不å…ŧ厚。我äģŦ需čĻå†æŦĄäģŽčŋœį¨‹æ•°æŽåē“čŽˇå–ã€‚æˆ‘äģŦįŽ°åœ¨åē”č¯Ĩ再æŦĄäģŽčŋœį¨‹čŽˇå–å—īŧŸ\n\n___æŗ¨æ„:在更攚配įŊŽåšļ再æŦĄčŽˇå–æ•°æŽåē“䚋前,我äģŦæ— æŗ•čŋ›čĄŒåŒæ­Ĩ。___\n___æŗ¨æ„2:chunks æ˜¯åŽŒå…¨ä¸å¯å˜įš„,我äģŦåĒčƒŊčŽˇå–å…ƒæ•°æŽå’ŒåˇŽåŧ‚"; - }; - readonly "moduleMigration.msgInitialSetup": { - readonly def: "Your device has **not been set up yet**. Let me guide you through the setup process.\n\nPlease keep in mind that every dialogue content can be copied to the clipboard. If you need to refer to it later, you can paste it into a note in Obsidian. You can also translate it into your language using a translation tool.\n\nFirst, do you have **Setup URI**?\n\nNote: If you do not know what it is, please refer to the [documentation](${URI_DOC})."; - readonly es: "Tu dispositivo **aÃēn no ha sido configurado**. Permíteme guiarte a travÊs del proceso de configuraciÃŗn.\n\nTen en cuenta que todo el contenido del diÃĄlogo se puede copiar al portapapeles. Si necesitas consultarlo mÃĄs tarde, puedes pegarlo en una nota en Obsidian. TambiÊn puedes traducirlo a tu idioma utilizando una herramienta de traducciÃŗn.\n\nPrimero, Âŋtienes **URI de configuraciÃŗn**?\n\nNota: Si no sabes quÊ es, consulta la [documentaciÃŗn](${URI_DOC})."; - readonly fr: "Votre appareil n'a **pas encore ÊtÊ configurÊ**. Laissez-moi vous guider dans le processus de configuration.\n\nVeuillez noter que chaque contenu de boÃŽte de dialogue peut ÃĒtre copiÊ dans le presse-papiers. Si vous souhaitez vous y rÊfÊrer plus tard, vous pouvez le coller dans une note d'Obsidian. Vous pouvez Êgalement le traduire dans votre langue via un outil de traduction.\n\nTout d'abord, disposez-vous d'une **URI de configuration** ?\n\nNote : Si vous ne savez pas ce que c'est, consultez la [documentation](${URI_DOC})."; - readonly he: "המכשיר שלך **טרם הוגדר**. אנחנו כאן ל×ĸזור לך ב×Ēהליך ההגדרה.\n\nשים לב שני×Ēן לה×ĸ×Ēיק א×Ē ×Ēוכן כל דיאלוג ללוח. אם ×Ļריך לחזור אליו מאוחר יו×Ēר, ני×Ēן להדביק או×Ēו כפ×Ē×§ ב-Obsidian. ני×Ēן גם ל×Ēרגם לשפ×Ēך ב×ĸזר×Ē ×›×œ×™ ×Ēרגום.\n\nראשי×Ē, האם יש לך **Setup URI**?\n\nה×ĸרה: אם אינך יוד×ĸ מהו, אנא ×ĸיין ב[×Ēי×ĸוד](${URI_DOC})."; - readonly ja: "こぎデバイ゚は**ぞだã‚ģットã‚ĸップされãĻいぞせん**。ã‚ģットã‚ĸッププロã‚ģã‚šã‚’ã”æĄˆå†…ã—ãžã™ã€‚\n\nすずãĻぎダイã‚ĸログぎ内厚はクãƒĒップボãƒŧドãĢã‚ŗãƒ”ãƒŧã§ããžã™ã€‚åžŒã§å‚į…§ã™ã‚‹åŋ…čĻãŒã‚ã‚Œã°ã€ObsidianぎノãƒŧトãĢč˛ŧりäģ˜ã‘ãĻください。įŋģč¨ŗãƒ„ãƒŧãƒĢをäŊŋãŖãĻおäŊŋã„ãŽč¨€čĒžãĢįŋģč¨ŗã™ã‚‹ã“ã¨ã‚‚ã§ããžã™ã€‚\n\nぞず、**ã‚ģットã‚ĸップURI**ã‚’ãŠæŒãĄã§ã™ã‹īŧŸ\n\næŗ¨æ„: それがäŊ•か分からãĒい場合は、[documentation](${URI_DOC})ã‚’å‚į…§ã—ãĻください。"; - readonly ko: "ė´ 기기는 **땄링 봈揰 ė„¤ė •ė´ ė™„ëŖŒë˜ė§€ ė•Šė•˜ėŠĩ니다**. ė§€ę¸ˆëļ€í„° 네렕 ęŗŧė •ė„ ė•ˆë‚´í•´ 드ëĻŦ겠ėŠĩ니다.\n\nëĒ¨ë“  대화 ë‚´ėšŠė€ 클ëĻŊëŗ´ë“œė— ëŗĩė‚Ŧ할 눘 ėžˆėŠĩ니다. ë‚˜ė¤‘ė— ė°¸ęŗ í•˜ë ¤ëŠ´ Obsidian ë…¸íŠ¸ė— ëļ™ė—Ŧë„Ŗęą°ë‚˜ ë˛ˆė—­ 도ęĩŦëĨŧ í™œėšŠí•´ ë˛ˆė—­í•˜ė…”ë„ 됩니다.\n\në¨ŧė €, **Setup URI**ëĨŧ 氀맀溠 ęŗ„ė‹ ę°€ėš”?\n\n및溠: Setup URI가 ëŦ´ė—‡ė¸ė§€ ėž˜ ëǍëĨ´ė‹œę˛ ë‹¤ëŠ´ [ëŦ¸ė„œ](${URI_DOC})ëĨŧ ė°¸ęŗ í•´ ėŖŧė„¸ėš”."; - readonly ru: "Đ’Đ°ŅˆĐĩ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đž Đĩ҉ґ ĐŊĐĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐĩĐŊĐž. ĐŖ Đ˛Đ°Ņ ĐĩŅŅ‚ŅŒ Setup URI?"; - readonly zh: "æ‚¨įš„čŽžå¤‡**尚æœĒ莞įŊŽ**ã€‚čŽŠæˆ‘åŧ•å¯ŧæ‚¨åŽŒæˆčŽžįŊŽčŋ‡į¨‹ã€‚\n\nč¯ˇčŽ°äŊ,每ä¸Ēå¯šč¯æĄ†å†…åŽšéƒŊ可äģĨ复åˆļ到å‰Ēč´´æŋ。åĻ‚æžœäģĨ后需čĻå‚č€ƒ,可äģĨ将å…ļម贴到 Obsidian įš„įŦ”čŽ°ä¸­ã€‚æ‚¨äšŸå¯äģĨäŊŋᔍįŋģ蝑åˇĨ兎将å…ļįŋģč¯‘æˆæ‚¨įš„č¯­č¨€ã€‚\n\néĻ–å…ˆ,您有**莞įŊŽ URI** 吗īŧŸ\n\næŗ¨æ„:åĻ‚æžœæ‚¨ä¸įŸĨ道čŋ™æ˜¯äģ€äšˆ,č¯ˇå‚é˜…[æ–‡æĄŖ](${URI_DOC})"; - }; - readonly "moduleMigration.msgRecommendSetupUri": { - readonly def: "We strongly recommend that you generate a set-up URI and use it.\nIf you do not have knowledge about it, please refer to the [documentation](${URI_DOC}) (Sorry again, but it is important).\n\nHow do you want to set it up manually?"; - readonly es: "Te recomendamos encarecidamente que generes una URI de configuraciÃŗn y la utilices.\nSi no tienes conocimientos al respecto, consulta la [documentaciÃŗn](${URI_DOC}) (Lo siento de nuevo, pero es importante).\n\nÂŋCÃŗmo quieres configurarlo manualmente?"; - readonly fr: "Nous recommandons vivement de gÊnÊrer une URI de configuration et de l'utiliser.\nSi vous ne connaissez pas, veuillez consulter la [documentation](${URI_DOC}) (DÊsolÊ encore, mais c'est important).\n\nComment souhaitez-vous effectuer la configuration manuellement ?"; - readonly he: "אנו ממלי×Ļים בחום ליי×Ļר Setup URI ולהש×Ēמ׊ בו.\nאם אין לך יד×ĸ בנושא, אנא ×ĸיין ב[×Ēי×ĸוד](${URI_DOC}) (מ×Ē× ×Ļלים שוב, אך זה חשוב).\n\nכי×Ļד בר×Ļונך להגדיר ידני×Ē?"; - readonly ja: "ã‚ģットã‚ĸップURIã‚’į”Ÿæˆã—ãĻäŊŋį”¨ã™ã‚‹ã“ã¨ã‚’åŧˇããŠå‹§ã‚ã—ぞす。\nこれãĢついãĻįŸĨč­˜ãŒãĒい場合は、[documentation](${URI_DOC})ã‚’å‚į…§ã—ãĻくださいīŧˆé‡čĻã§ã™īŧ‰ã€‚\n\n手動でã‚ģットã‚ĸップしぞすかīŧŸ"; - readonly ko: "Setup URIëĨŧ ėƒė„ąí•´ ė‚ŦėšŠí•˜ëŠ” ę˛ƒė„ 강ë Ĩ히 ęļŒėžĨ합니다.\nSetup URI가 ëŦ´ė—‡ė¸ė§€ ėž˜ ëǍëĨ´ė‹œę˛ ë‹¤ëŠ´ [ëŦ¸ė„œ](${URI_DOC})ëĨŧ ė°¸ęŗ í•´ ėŖŧė„¸ėš”. ė¤‘ėš”í•œ ë‚´ėšŠė´ë‹ˆ ęŧ­ í™•ė¸í•˜ė‹œę¸° 바랍니다.\n\n링렑 ėˆ˜ë™ ė„¤ė •ė„ ė§„í–‰í•˜ė‹œę˛ ėŠĩ니까?"; - readonly ru: "ĐœŅ‹ Ņ€ĐĩĐēĐžĐŧĐĩĐŊĐ´ŅƒĐĩĐŧ ŅĐŗĐĩĐŊĐĩŅ€Đ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ Setup URI."; - readonly zh: "我äģŦåŧē჈åģēčŽŽæ‚¨į”Ÿæˆä¸€ä¸Ē莞įŊŽ URI åšļäŊŋį”¨åŽƒã€‚\nåĻ‚æžœæ‚¨å¯šæ­¤ä¸äē†č§Ŗ,č¯ˇå‚é˜…[æ–‡æĄŖ](${URI_DOC})(再æŦĄæŠąæ­‰,äŊ†čŋ™åžˆé‡čρ)。\n\næ‚¨æƒŗåĻ‚äŊ•æ‰‹åŠ¨čŽžįŊŽ?"; - }; - readonly "moduleMigration.msgSinceV02321": { - readonly def: "Since v0.23.21, the self-hosted LiveSync has changed the default behaviour and database structure. The following changes have been made:\n\n1. **Case sensitivity of filenames**\n The handling of filenames is now case-insensitive. This is a beneficial change for most platforms, other than Linux and iOS, which do not manage filename case sensitivity effectively.\n (On These, a warning will be displayed for files with the same name but different cases).\n\n2. **Revision handling of the chunks**\n Chunks are immutable, which allows their revisions to be fixed. This change will enhance the performance of file saving.\n\n___However, to enable either of these changes, both remote and local databases need to be rebuilt. This process takes a few minutes, and we recommend doing it when you have ample time.___\n\n- If you wish to maintain the previous behaviour, you can skip this process by using `${KEEP}`.\n- If you do not have enough time, please choose `${DISMISS}`. You will be prompted again later.\n- If you have rebuilt the database on another device, please select `${DISMISS}` and try synchronizing again. Since a difference has been detected, you will be prompted again."; - readonly es: "Desde la versiÃŗn v0.23.21, Self-hosted LiveSync ha cambiado el comportamiento predeterminado y la estructura de la base de datos. Se han realizado los siguientes cambios:\n\n1. **Sensibilidad a mayÃēsculas de los nombres de archivo**\n El manejo de los nombres de archivo ahora no distingue entre mayÃēsculas y minÃēsculas. Este cambio es beneficioso para la mayoría de las plataformas, excepto Linux y iOS, que no gestionan efectivamente la sensibilidad a mayÃēsculas de los nombres de archivo.\n (En estos, se mostrarÃĄ una advertencia para archivos con el mismo nombre pero diferentes mayÃēsculas).\n\n2. **Manejo de revisiones de los fragmentos**\n Los fragmentos son inmutables, lo que permite que sus revisiones sean fijas. Este cambio mejorarÃĄ el rendimiento al guardar archivos.\n\n___Sin embargo, para habilitar cualquiera de estos cambios, es necesario reconstruir tanto las bases de datos remota como la local. Este proceso toma unos minutos, y recomendamos hacerlo cuando tengas tiempo suficiente.___\n\n- Si deseas mantener el comportamiento anterior, puedes omitir este proceso usando `${KEEP}`.\n- Si no tienes suficiente tiempo, por favor elige `${DISMISS}`. Se te pedirÃĄ nuevamente mÃĄs tarde.\n- Si has reconstruido la base de datos en otro dispositivo, selecciona `${DISMISS}` e intenta sincronizar nuevamente. Dado que se ha detectado una diferencia, se te solicitarÃĄ nuevamente."; - readonly fr: "Depuis la v0.23.21, Self-hosted LiveSync a modifiÊ son comportement par dÊfaut et la structure de sa base. Les changements suivants ont ÊtÊ effectuÊs :\n\n1. **SensibilitÊ à la casse des noms de fichiers**\n La gestion des noms de fichiers est dÊsormais insensible à la casse. C'est un changement bÊnÊfique pour la plupart des plateformes, hormis Linux et iOS, qui ne gèrent pas efficacement la casse des noms de fichiers.\n (Sur celles-ci, un avertissement s'affichera pour les fichiers portant le mÃĒme nom avec une casse diffÊrente).\n\n2. **Gestion des rÊvisions des fragments**\n Les fragments sont immuables, ce qui permet de fixer leurs rÊvisions. Ce changement amÊliore les performances d'enregistrement des fichiers.\n\n___Cependant, pour activer l'un ou l'autre de ces changements, les bases locale et distante doivent ÃĒtre reconstruites. Ce processus prend quelques minutes, et nous recommandons de le faire quand vous avez le temps.___\n\n- Si vous souhaitez conserver le comportement prÊcÊdent, vous pouvez ignorer ce processus via `${KEEP}`.\n- Si vous n'avez pas le temps, choisissez `${DISMISS}`. Vous serez invitÊ à nouveau plus tard.\n- Si vous avez reconstruit la base sur un autre appareil, sÊlectionnez `${DISMISS}` et rÊessayez la synchronisation. Une diffÊrence Êtant dÊtectÊe, vous serez invitÊ à nouveau."; - readonly he: "מאז גרסה 0.23.21, Self-hosted LiveSync שינה א×Ē ×”×Ēנהגו×Ē ×‘×¨×™×¨×Ē ×”×ž×—×“×œ ומבנה מסד הנ×Ēונים. השינויים הבאים בו×Ļ×ĸו:\n\n1. **×Ēלו×Ē ×¨×™×Š×™×•×Ē ×‘×Š×ž×•×Ē ×§×‘×Ļים**\n הטיפול בשמו×Ē ×§×‘×Ļים הוא כ×ĸ×Ē ×œ×œ× ×Ēלו×Ē ×¨×™×Š×™×•×Ē. זהו שינוי מו×ĸיל לרוב הפלטפורמו×Ē,\n פרט ל-Linux ו-iOS שאינן מנהלו×Ē ×Ēלו×Ē ×¨×™×Š×™×•×Ē ×‘×§×‘×Ļים בי×ĸילו×Ē.\n (בפלטפורמו×Ē ××œ×”, ×Ēו×Ļג אזהרה ×ĸבור קב×Ļים ×ĸם או×Ēו ׊ם אך רישיו×Ē ×Š×•× ×”).\n\n2. **טיפול בגרסאו×Ē ×Š×œ × ×Ēחים**\n × ×Ēחים הם בל×Ēי-ני×Ēנים לשינוי, מה ׊מאפ׊ר גרסאו×Ē ×§×‘×•×ĸו×Ē. שינוי זה ישפר א×Ē\n בי×Ļו×ĸי שמיר×Ē ×”×§×‘×Ļים.\n\n___×ĸם זא×Ē, כדי להפ×ĸיל אחד מהשינויים הללו, יש לבנו×Ē ×ž×—×“×Š גם א×Ē ×ž×Ą×“ הנ×Ēונים המרוחד וגם א×Ē ×”×ž×§×•×ž×™. ×Ēהליך זה לוקח כמה דקו×Ē, ואנו ממלי×Ļים ל×ĸשו×Ē ×–××Ē ×›×Š×™×Š לך זמן פנוי.___\n\n- אם בר×Ļונך לשמור א×Ē ×”×”×Ēנהגו×Ē ×”×§×•×“×ž×Ē, ני×Ēן לדלג ×ĸל ×Ēהליך זה באמ×Ļ×ĸו×Ē `${KEEP}`.\n- אם אין לך מספיק זמן, אנא בחר `${DISMISS}`. ×Ēקבל ×Ēזכור×Ē ×‘×”×ž×Š×š.\n- אם בני×Ē ×ž×—×“×Š א×Ē ×ž×Ą×“ הנ×Ēונים במכשיר אחר, אנא בחר `${DISMISS}` ונסה לסנכרן שוב. מאחר שזוהה הפרש, ×Ēקבל ×Ēזכור×Ē ×Š×•×‘."; - readonly ja: "v0.23.21äģĨ降、self-hosted LiveSyncはデフりãƒĢトぎ動äŊœã¨ãƒ‡ãƒŧã‚ŋベãƒŧ゚構造を変更しぞした。äģĨä¸‹ãŽå¤‰æ›´ãŒčĄŒã‚ã‚Œãžã—ãŸīŧš\n\n1. **ãƒ•ã‚Ąã‚¤ãƒĢ名ぎ大文字小文字ぎåŒēåˆĨ**\n ãƒ•ã‚Ąã‚¤ãƒĢ名ぎå‡Ļį†ãŒå¤§æ–‡å­—å°æ–‡å­—ã‚’åŒēåˆĨしãĒくãĒã‚Šãžã—ãŸã€‚ã“ã‚Œã¯ã€ãƒ•ã‚Ąã‚¤ãƒĢåãŽå¤§æ–‡å­—å°æ–‡å­—ã‚’åŠšæžœįš„ãĢįŽĄį†ã—ãĒいLinuxとiOSäģĨ外ぎãģとんおぎプナットフりãƒŧムãĢã¨ãŖãĻæœ‰į›ŠãĒ変更です。\n īŧˆã“ã‚Œã‚‰ãŽį’°åĸƒã§ã¯ã€åŒã˜åå‰ã§å¤§æ–‡å­—å°æ–‡å­—ãŒį•°ãĒã‚‹ãƒ•ã‚Ąã‚¤ãƒĢãĢ寞しãĻč­Ļå‘ŠãŒčĄ¨į¤ēされぞすīŧ‰ã€‚\n\n2. **ãƒãƒŖãƒŗã‚¯ãŽãƒĒãƒ“ã‚¸ãƒ§ãƒŗå‡Ļᐆ**\n ãƒãƒŖãƒŗã‚¯ã¯ä¸å¤‰ã§ã‚ã‚Šã€ãƒĒãƒ“ã‚¸ãƒ§ãƒŗã‚’å›ē厚できぞす。こぎ変更ãĢã‚ˆã‚Šã€ãƒ•ã‚Ąã‚¤ãƒĢäŋå­˜ãŽãƒ‘フりãƒŧãƒžãƒŗã‚šãŒå‘ä¸Šã—ãžã™ã€‚\n\n___しかし、これらぎ変更を有劚ãĢするãĢは、ãƒĒãƒĸãƒŧトとロãƒŧã‚ĢãƒĢãŽä¸Ąæ–šãŽãƒ‡ãƒŧã‚ŋベãƒŧã‚šã‚’å†æ§‹į¯‰ã™ã‚‹åŋ…čĻãŒã‚ã‚Šãžã™ã€‚ã“ãŽãƒ—ãƒ­ã‚ģ゚は数分かかりぞす。時間ãĢäŊ™čŖ•があるときãĢčĄŒã†ã“ã¨ã‚’ãŠå‹§ã‚ã—ãžã™ã€‚___\n\n- äģĨ前ぎ動äŊœã‚’įļ­æŒã—たい場合は、`${KEEP}`をäŊŋį”¨ã—ãĻこぎプロã‚ģ゚を゚キップできぞす。\n- 時間がãĒい場合は、`${DISMISS}`を選択しãĻください。垌で再åēĻįĸēčĒã•ã‚Œãžã™ã€‚\n- åˆĨぎデバイ゚でデãƒŧã‚ŋベãƒŧã‚šã‚’å†æ§‹į¯‰ã—ãŸå ´åˆã¯ã€`${DISMISS}`を選択しãĻ再åēĻ同期しãĻãŋãĻãã ã•ã„ã€‚åˇŽį•°ãŒæ¤œå‡ēされたため、再åēĻįĸēčĒã•ã‚Œãžã™ã€‚"; - readonly ko: "v0.23.21ëļ€í„° Self-hosted LiveSyncė˜ ę¸°ëŗ¸ ë™ėž‘ ë°Šė‹ęŗŧ ë°ė´í„°ë˛ ė´ėŠ¤ ęĩŦėĄ°ę°€ ëŗ€ę˛Ŋë˜ė—ˆėŠĩ니다. ėŖŧėš” ëŗ€ę˛Ŋė‚Ŧí•­ė€ ë‹¤ėŒęŗŧ 같ėŠĩ니다:\n\n1. **파ėŧëĒ… ëŒ€ė†ŒëŦ¸ėž ęĩŦëļ„ ė˛˜ëĻŦ**\n ė´ė œ 파ėŧëĒ…ė€ ëŒ€ė†ŒëŦ¸ėžëĨŧ ęĩŦëļ„í•˜ė§€ ė•Šęŗ  래ëĻŦ됩니다. ė´ëŠ” 파ėŧëĒ… ęĩŦëļ„ė„ ė œëŒ€ëĄœ ė§€ė›í•˜ė§€ ė•ŠëŠ” Linux 및 iOSëĨŧ ė œė™¸í•œ 대ëļ€ëļ„ė˜ 플ëžĢíŧė—ė„œ 뜠ëĻŦ한 ëŗ€í™”ėž…ë‹ˆë‹¤.\n (Linux나 iOSė—ė„œëŠ” ëŒ€ė†ŒëŦ¸ėžë§Œ 다ëĨ¸ 파ėŧė´ ėĄ´ėžŦ할 ę˛Ŋ뚰 ę˛Ŋęŗ ę°€ í‘œė‹œëŠë‹ˆë‹¤)\n\n2. **랭íŦ ëĻŦëš„ė „ 관ëĻŦ ë°Šė‹ ę°œė„ **\n 랭íŦ는 ëŗ€ę˛Ŋ ëļˆę°€ëŠĨ한(immutable) ęĩŦėĄ°ëĄœ ęŗ ė •ë˜ëŠ°, ė´ëĨŧ í†ĩ해 ëĻŦëš„ė „ 래ëĻŦ가 ė•ˆė •í™”ë˜ęŗ  파ėŧ ė €ėžĨ ė„ąëŠĨė´ í–ĨėƒëŠë‹ˆë‹¤.\n\n___단, ėœ„ 기ëŠĨė„ í™œė„ąí™”í•˜ë ¤ëŠ´ ė›ę˛Š 및 로ėģŦ ë°ė´í„°ë˛ ė´ėŠ¤ëĨŧ ëĒ¨ë‘ ėžŦęĩŦė„ąí•´ė•ŧ 합니다. ė´ ęŗŧė •ė€ 눘 ëļ„ė´ ė†Œėš”ë˜ë¯€ëĄœ ė—Ŧėœ ę°€ ėžˆė„ 때 ė‹¤í–‰í•˜ė‹œëŠ” ę˛ƒė„ ęļŒėžĨ합니다.___\n\n- ę¸°ėĄ´ ë°Šė‹ëŒ€ëĄœ ėœ ė§€í•˜ë ¤ëŠ´ `${KEEP}`ė„ ė„ íƒí•´ ė´ ęŗŧė •ė„ 건너뛸 눘 ėžˆėŠĩ니다.\n- ė‹œę°„ė´ ëļ€ėĄąí•˜ë‹¤ëŠ´ `${DISMISS}`ëĨŧ 눌ëŸŦėŖŧė‹œëŠ´ ë‚˜ė¤‘ė— ë‹¤ė‹œ ė•ˆë‚´ë“œëĻŦ겠ėŠĩ니다.\n- ė´ë¯¸ 다ëĨ¸ ę¸°ę¸°ė—ė„œ ë°ė´í„°ë˛ ė´ėŠ¤ëĨŧ ėžŦęĩŦė„ąí•˜ė…¨ë‹¤ëŠ´ `${DISMISS}`ëĨŧ ė„ íƒí•œ 뒤 ë‹¤ė‹œ 동기화해 ëŗ´ė„¸ėš”. ė°¨ė´ė ė´ ę°ė§€ë˜ëŠ´ ë‹¤ė‹œ ė•ˆë‚´ë“œëĻŦ겠ėŠĩ니다."; - readonly ru: "ĐĐ°Ņ‡Đ¸ĐŊĐ°Ņ ҁ v0.23.21, self-hosted LiveSync иСĐŧĐĩĐŊиĐģ ĐŋОвĐĩĐ´ĐĩĐŊиĐĩ и ŅŅ‚Ņ€ŅƒĐēŅ‚ŅƒŅ€Ņƒ ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅."; - readonly zh: "č‡Ē v0.23.21 čĩˇ,Self-hosted LiveSync 更攚äē†éģ˜čŽ¤čĄŒä¸ē和数捎åē“į쓿ž„。čŋ›čĄŒäē†äģĨ下更攚īŧš\n\n1. **文äģļåįš„åŒē分大小写** \nįŽ°åœ¨å¤„į†æ–‡äģļ名æ—ļ不åŒē分大小写。čŋ™å¯šäēŽå¤§å¤šæ•°åšŗå°æĨč¯´æ˜¯ä¸€ä¸Ēæœ‰į›Šįš„æ›´æ”š,除äē† Linux 和 iOS,厃äģŦ不čƒŊæœ‰æ•ˆåœ°įŽĄį†æ–‡äģļåįš„å¤§å°å†™æ•æ„Ÿæ€§ã€‚\n(在čŋ™äē›åšŗå°ä¸Š,寚äēŽåį§°į›¸åŒäŊ†å¤§å°å†™ä¸åŒįš„æ–‡äģļ将昞į¤ēč­Ļ告)。\n\n2. **chunks įš„į‰ˆæœŦ处ᐆ** \nchunks æ˜¯ä¸å¯å˜įš„,čŋ™äŊŋ垗厃äģŦįš„į‰ˆæœŦ可äģĨå›ē厚。此更攚将提éĢ˜æ–‡äģļäŋå­˜įš„æ€§čƒŊ。\n\n___į„ļ而,čρ吝ᔍčŋ™ä盿›´æ”šä¸­įš„äģģäŊ•一ä¸Ē,éƒŊ需čĻé‡åģēčŋœį¨‹å’ŒæœŦ地数捎åē“。čŋ™ä¸Ēčŋ‡į¨‹éœ€čĻå‡ åˆ†é’Ÿ,我äģŦåģēčŽŽæ‚¨åœ¨æœ‰å……čļŗæ—ļ间æ—ļčŋ›čĄŒã€‚___\n\n- åĻ‚æžœæ‚¨å¸Œæœ›äŋæŒäģĨå‰įš„čĄŒä¸ē,可äģĨäŊŋᔍ `${KEEP}` 莺čŋ‡æ­¤čŋ‡į¨‹ã€‚\n- åĻ‚æžœæ‚¨æ˛Ąæœ‰čļŗå¤Ÿįš„æ—ļ间,č¯ˇé€‰æ‹Š `${DISMISS}`ã€‚į¨åŽäŧšå†æŦĄæį¤ē您。\n- åĻ‚æžœæ‚¨åˇ˛åœ¨åĻä¸€å°čŽžå¤‡ä¸Šé‡åģēä熿•°æŽåē“,č¯ˇé€‰æ‹Š `${DISMISS}` åšļå°č¯•å†æŦĄåŒæ­Ĩã€‚į”ąäēŽæŖ€æĩ‹åˆ°åˇŽåŧ‚,įŗģįģŸäŧšå†æŦĄæį¤ē您"; - }; - readonly "moduleMigration.optionAdjustRemote": { - readonly def: "Adjust to remote"; - readonly es: "Ajustar al remoto"; - readonly fr: "Ajuster au distant"; - readonly he: "ה×Ēאם לשר×Ē ×”×ž×¨×•×—×“"; - readonly ja: "ãƒĒãƒĸãƒŧトãĢ合わせる"; - readonly ko: "ė›ę˛Šė— 맞ėļ”기"; - readonly ru: "ĐĐ°ŅŅ‚Ņ€ĐžĐ¸Ņ‚ŅŒ ĐŋОд ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊŅƒŅŽ"; - readonly zh: "č°ƒæ•´åˆ°čŋœį¨‹čŽžįŊŽ"; - }; - readonly "moduleMigration.optionDecideLater": { - readonly def: "Decide it later"; - readonly es: "Decidirlo mÃĄs tarde"; - readonly fr: "DÊcider plus tard"; - readonly he: "החלט מאוחר יו×Ēר"; - readonly ja: "垌でæąēめる"; - readonly ko: "ë‚˜ė¤‘ė— ę˛°ė •í•˜ę¸°"; - readonly ru: "Đ ĐĩŅˆĐ¸Ņ‚ŅŒ ĐŋОСĐļĐĩ"; - readonly zh: "į¨åŽå†ŗåŽš"; - }; - readonly "moduleMigration.optionEnableBoth": { - readonly def: "Enable both"; - readonly es: "Habilitar ambos"; - readonly fr: "Activer les deux"; - readonly he: "הפ×ĸל א×Ē ×Š× ×™×”×"; - readonly ja: "ä¸Ąæ–šã‚’æœ‰åŠšãĢする"; - readonly ko: "둘 다 í™œė„ąí™”"; - readonly ru: "ВĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒ Ойа"; - readonly zh: "吝ᔍ䏤者"; - }; - readonly "moduleMigration.optionEnableFilenameCaseInsensitive": { - readonly def: "Enable only #1"; - readonly es: "Habilitar solo #1"; - readonly fr: "Activer seulement #1"; - readonly he: "הפ×ĸל רק #1"; - readonly ja: "#1ぎãŋ有劚ãĢする"; - readonly ko: "#1만 í™œė„ąí™”"; - readonly ru: "ВĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒ Ņ‚ĐžĐģҌĐēĐž #1"; - readonly zh: "äģ…吝ᔍ #1"; - }; - readonly "moduleMigration.optionEnableFixedRevisionForChunks": { - readonly def: "Enable only #2"; - readonly es: "Habilitar solo #2"; - readonly fr: "Activer seulement #2"; - readonly he: "הפ×ĸל רק #2"; - readonly ja: "#2ぎãŋ有劚ãĢする"; - readonly ko: "#2만 í™œė„ąí™”"; - readonly ru: "ВĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒ Ņ‚ĐžĐģҌĐēĐž #2"; - readonly zh: "äģ…吝ᔍ #2"; - }; - readonly "moduleMigration.optionHaveSetupUri": { - readonly def: "Yes, I have"; - readonly es: "Sí, tengo"; - readonly fr: "Oui, j'en ai une"; - readonly he: "כן, יש לי"; - readonly ja: "ã¯ã„ã€æŒãŖãĻいぞす"; - readonly ko: "똈, ėžˆėŠĩ니다"; - readonly ru: "Да, ĐĩŅŅ‚ŅŒ"; - readonly zh: "æ˜¯įš„,我有"; - }; - readonly "moduleMigration.optionKeepPreviousBehaviour": { - readonly def: "Keep previous behaviour"; - readonly es: "Mantener comportamiento anterior"; - readonly fr: "Conserver le comportement prÊcÊdent"; - readonly he: "שמור ×ĸל ה×Ēנהגו×Ē ×§×•×“×ž×Ē"; - readonly ja: "äģĨ前ぎ動äŊœã‚’įļ­æŒ"; - readonly ko: "ė´ė „ ë™ėž‘ ėœ ė§€"; - readonly ru: "ĐĄĐžŅ…Ņ€Đ°ĐŊĐ¸Ņ‚ŅŒ ĐŋŅ€ĐĩĐ´Ņ‹Đ´ŅƒŅ‰ĐĩĐĩ ĐŋОвĐĩĐ´ĐĩĐŊиĐĩ"; - readonly zh: "äŋæŒäģĨå‰įš„čĄŒä¸ē"; - }; - readonly "moduleMigration.optionManualSetup": { - readonly def: "Set it up all manually"; - readonly es: "Configurarlo todo manualmente"; - readonly fr: "Tout configurer manuellement"; - readonly he: "הגדר הכל ידני×Ē"; - readonly ja: "すずãĻ手動でã‚ģットã‚ĸップ"; - readonly ko: "ëĒ¨ë“  ę˛ƒė„ ėˆ˜ë™ėœŧ로 네렕"; - readonly ru: "ĐĐ°ŅŅ‚Ņ€ĐžĐ¸Ņ‚ŅŒ Đ˛ŅŅ‘ Đ˛Ņ€ŅƒŅ‡ĐŊŅƒŅŽ"; - readonly zh: "å…¨éƒ¨æ‰‹åŠ¨čŽžįŊŽ"; - }; - readonly "moduleMigration.optionNoAskAgain": { - readonly def: "No, please ask again"; - readonly es: "No, por favor pregÃēntame de nuevo"; - readonly fr: "Non, demandez à nouveau"; - readonly he: "לא, אנא שאל שוב"; - readonly ja: "いいえ、垌でįĸēčĒã™ã‚‹"; - readonly ko: "ė•„ë‹ˆėš” (ë‚˜ė¤‘ė— ë‹¤ė‹œ ëŦŧė–´ëŗ´ę¸°)"; - readonly ru: "НĐĩŅ‚, ҁĐŋŅ€ĐžŅĐ¸Ņ‚ŅŒ ҁĐŊОва"; - readonly zh: "不,č¯ˇį¨åŽå†æŦĄč¯ĸ问"; - }; - readonly "moduleMigration.optionNoSetupUri": { - readonly def: "No, I do not have"; - readonly fr: "Non, je n'en ai pas"; - readonly he: "לא, אין לי"; - readonly ja: "ã„ã„ãˆã€æŒãŖãĻいぞせん"; - readonly ko: "ė•„ë‹ˆėš”, ė—†ėŠĩ니다"; - readonly ru: "НĐĩŅ‚, ĐŊĐĩŅ‚"; - readonly zh: "不,æˆ‘æ˛Ąæœ‰"; - }; - readonly "moduleMigration.optionRemindNextLaunch": { - readonly def: "Remind me at the next launch"; - readonly fr: "Me rappeler au prochain lancement"; - readonly he: "הזכר לי בהפ×ĸלה הבאה"; - readonly ja: "æŦĄå›žčĩˇå‹•時ãĢãƒĒãƒžã‚¤ãƒŗãƒ‰"; - readonly ko: "ë‹¤ėŒ ė‹œėž‘ ė‹œ ė•ŒëĻŧ"; - readonly ru: "НаĐŋĐžĐŧĐŊĐ¸Ņ‚ŅŒ ĐŋŅ€Đ¸ ҁĐģĐĩĐ´ŅƒŅŽŅ‰ĐĩĐŧ СаĐŋ҃ҁĐēĐĩ"; - readonly zh: "下æŦĄå¯åЍæ—ļ提醒我"; - }; - readonly "moduleMigration.optionSetupViaP2P": { - readonly def: "Use P2P Sync to set up"; - readonly fr: "Utiliser Sync P2P pour configurer"; - readonly he: "הש×Ēמ׊ ב-%{short_p2p_sync} להגדרה"; - readonly ja: "P2P Sync (čŠĻ験抟čƒŊ)をäŊŋãŖãĻã‚ģットã‚ĸップ"; - readonly ko: "P2P 동기화 (ė‹¤í—˜ 기ëŠĨ)ëĨŧ ė‚ŦėšŠí•˜ė—Ŧ 네렕"; - readonly ru: "Đ˜ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ŅŒ short_p2p_sync Đ´ĐģŅ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи"; - readonly zh: "Use P2P同æ­Ĩ(厞éĒŒæ€§) to set up"; - }; - readonly "moduleMigration.optionSetupWizard": { - readonly def: "Take me into the setup wizard"; - readonly fr: "Ouvrir l'assistant de configuration"; - readonly he: "קח או×Ēי ×œ××Š×Ŗ ההגדרה"; - readonly ja: "ã‚ģットã‚ĸップã‚Ļã‚Ŗã‚ļãƒŧドへ"; - readonly ko: "네렕 마법ė‚Ŧ로 ė•ˆë‚´"; - readonly ru: "ПĐĩŅ€ĐĩĐšŅ‚Đ¸ в ĐŧĐ°ŅŅ‚ĐĩŅ€ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи"; - readonly zh: "å¸Ļ我čŋ›å…Ĩ莞įŊŽå‘å¯ŧ"; - }; - readonly "moduleMigration.optionYesFetchAgain": { - readonly def: "Yes, fetch again"; - readonly fr: "Oui, rÊcupÊrer à nouveau"; - readonly he: "כן, משוך שוב"; - readonly ja: "はい、再フェッチする"; - readonly ko: "똈 (ë‹¤ė‹œ ę°€ė ¸ė˜¤ę¸°)"; - readonly ru: "Да, ĐˇĐ°ĐŗŅ€ŅƒĐˇĐ¸Ņ‚ŅŒ ҁĐŊОва"; - readonly zh: "æ˜¯įš„,再æŦĄčŽˇå–"; - }; - readonly "moduleMigration.titleCaseSensitivity": { - readonly def: "Case Sensitivity"; - readonly fr: "SensibilitÊ à la casse"; - readonly he: "×Ēלו×Ē ×¨×™×Š×™×•×Ē"; - readonly ja: "大文字小文字ぎåŒēåˆĨ"; - readonly ko: "ëŒ€ė†ŒëŦ¸ėž ęĩŦëļ„"; - readonly ru: "Đ§ŅƒĐ˛ŅŅ‚Đ˛Đ¸Ņ‚ĐĩĐģҌĐŊĐžŅŅ‚ŅŒ Đē Ņ€ĐĩĐŗĐ¸ŅŅ‚Ņ€Ņƒ"; - readonly zh: "大小写敏感性"; - }; - readonly "moduleMigration.titleRecommendSetupUri": { - readonly def: "Recommendation to use Setup URI"; - readonly fr: "Recommandation d'utilisation de l'URI de configuration"; - readonly he: "המל×Ļה לשימוש ב-Setup URI"; - readonly ja: "ã‚ģットã‚ĸップURIぎäŊŋį”¨ã‚’æŽ¨åĨ¨"; - readonly ko: "Setup URI ė‚ŦėšŠ ęļŒėžĨ"; - readonly ru: "Đ ĐĩĐēĐžĐŧĐĩĐŊĐ´Đ°Ņ†Đ¸Ņ Đ¸ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ŅŒ Setup URI"; - readonly zh: "æŽ¨čäŊŋį”¨čŽžįŊŽ URI"; - }; - readonly "moduleMigration.titleWelcome": { - readonly def: "Welcome to Self-hosted LiveSync"; - readonly fr: "Bienvenue dans Self-hosted LiveSync"; - readonly he: "ברוך הבא ל-Self-hosted LiveSync"; - readonly ja: "Self-hosted LiveSyncへようこそ"; - readonly ko: "Self-hosted LiveSync뗐 ė˜¤ė‹  ę˛ƒė„ í™˜ė˜í•Šë‹ˆë‹¤"; - readonly ru: "Đ”ĐžĐąŅ€Đž ĐŋĐžĐļаĐģĐžĐ˛Đ°Ņ‚ŅŒ в Self-hosted LiveSync"; - readonly zh: "æŦĸčŋŽäŊŋᔍ Self-hosted LiveSync"; - }; - readonly "moduleObsidianMenu.replicate": { - readonly def: "Replicate"; - readonly es: "Replicar"; - readonly fr: "RÊpliquer"; - readonly he: "שכפל"; - readonly ja: "ãƒŦプãƒĒã‚ąãƒŧト"; - readonly ko: "ëŗĩ렜"; - readonly ru: "Đ ĐĩĐŋĐģĐ¸Ņ†Đ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ"; - readonly zh: "复åˆļ"; - }; - readonly "More actions": { - readonly def: "More actions"; - readonly es: "MÃĄs acciones"; - readonly ja: "そぎäģ–ぎ操äŊœ"; - readonly ko: "ėļ”ę°€ ėž‘ė—…"; - readonly ru: "Đ”Ņ€ŅƒĐŗĐ¸Đĩ Đ´ĐĩĐšŅŅ‚Đ˛Đ¸Ņ"; - readonly zh: "更多操äŊœ"; - readonly "zh-tw": "更多操äŊœ"; - }; - readonly "Move remotely deleted files to the trash, instead of deleting.": { - readonly def: "Move remotely deleted files to the trash, instead of deleting."; - readonly es: "Mover archivos borrados remotos a papelera en lugar de eliminarlos"; - readonly fr: "DÊplacer les fichiers supprimÊs à distance vers la corbeille, au lieu de les supprimer."; - readonly he: "ה×ĸבר קב×Ļים שנמחקו מרחוק לאשפה, במקום למחוק."; - readonly ja: "ãƒĒãƒĸãƒŧãƒˆã§å‰Šé™¤ã•ã‚ŒãŸãƒ•ã‚Ąã‚¤ãƒĢを削除せずãĢã‚´ãƒŸįŽąãĢį§ģ動する。"; - readonly ko: "ė›ę˛Šė—ė„œ ė‚­ė œëœ 파ėŧė„ ė‚­ė œí•˜ëŠ” ëŒ€ė‹  íœ´ė§€í†ĩėœŧ로 ė´ë™í•Šë‹ˆë‹¤."; - readonly ru: "ПĐĩŅ€ĐĩĐŧĐĩŅ‰Đ°Ņ‚ŅŒ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊŅ‹Đĩ ĐŊа ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐŧ ҁĐĩŅ€Đ˛ĐĩŅ€Đĩ Ņ„Đ°ĐšĐģŅ‹ в ĐēĐžŅ€ĐˇĐ¸ĐŊ҃ вĐŧĐĩŅŅ‚Đž ŅƒĐ´Đ°ĐģĐĩĐŊĐ¸Ņ."; - readonly zh: "将čŋœį¨‹åˆ é™¤įš„æ–‡äģļį§ģč‡ŗå›žæ”ļįĢ™,č€Œä¸æ˜¯į›´æŽĨ删除"; - }; - readonly "Network warning style": { - readonly def: "Network warning style"; - readonly es: "Estilo de advertencia de red"; - readonly ja: "ネットワãƒŧクč­Ļå‘ŠãŽčĄ¨į¤ēæ–šåŧ"; - readonly ko: "ë„¤íŠ¸ė›ŒíŦ ę˛Ŋęŗ  í‘œė‹œ ë°Šė‹"; - readonly ru: "ĐĄŅ‚Đ¸ĐģҌ ҁĐĩŅ‚ĐĩĐ˛ĐžĐŗĐž ĐŋŅ€ĐĩĐ´ŅƒĐŋŅ€ĐĩĐļĐ´ĐĩĐŊĐ¸Ņ"; - readonly zh: "įŊ‘įģœč­Ļå‘Šæ ˇåŧ"; - readonly "zh-tw": "įļ˛čˇ¯č­Ļå‘Šæ¨Ŗåŧ"; - }; - readonly "New Remote": { - readonly def: "New Remote"; - readonly es: "Nuevo remoto"; - readonly ja: "新しいãƒĒãƒĸãƒŧト"; - readonly ko: "냈 ė›ę˛Š"; - readonly ru: "НовоĐĩ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐĩ Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰Đĩ"; - readonly zh: "新åģēčŋœį̝"; - readonly "zh-tw": "新åĸžé į̝"; - }; - readonly "No connected device information found. Cancelling Garbage Collection.": { - readonly def: "No connected device information found. Cancelling Garbage Collection."; - readonly ja: "æŽĨį¸ˆãŋãƒ‡ãƒã‚¤ã‚šãŽæƒ…å ąãŒčĻ‹ã¤ã‹ã‚Šãžã›ã‚“ã§ã—ãŸã€‚Garbage Collection ã‚’ã‚­ãƒŖãƒŗã‚ģãƒĢしぞす。"; - readonly ko: "ė—°ę˛°ëœ 기기 ė •ëŗ´ëĨŧ ė°žė„ 눘 ė—†ėŠĩ니다. Garbage Collectionė„ ėˇ¨ė†Œí•Šë‹ˆë‹¤."; - readonly ru: "НĐĩ ĐŊаКдĐĩĐŊа иĐŊŅ„ĐžŅ€ĐŧĐ°Ņ†Đ¸Ņ Đž ĐŋОдĐēĐģŅŽŅ‡Ņ‘ĐŊĐŊҋ҅ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°Ņ…. Garbage Collection ĐžŅ‚ĐŧĐĩĐŊŅĐĩŅ‚ŅŅ."; - readonly zh: "æœĒæ‰žåˆ°åˇ˛čŋžæŽĨčŽžå¤‡įš„äŋĄæ¯ã€‚æ­Ŗåœ¨å–æļˆåžƒåœžå›žæ”ļ。"; - readonly "zh-tw": "æ‰žä¸åˆ°åˇ˛é€ŖįˇščŖįŊŽįš„čŗ‡č¨Šã€‚æ­Ŗåœ¨å–æļˆåžƒåœžå›žæ”ļ。"; - }; - readonly "No limit configured": { - readonly def: "No limit configured"; - readonly es: "Sin límite configurado"; - readonly ja: "åˆļé™ã¯č¨­åŽšã•ã‚ŒãĻいぞせん"; - readonly ko: "ė œí•œė´ ė„¤ė •ë˜ė§€ ė•ŠėŒ"; - readonly ru: "ЛиĐŧĐ¸Ņ‚ ĐŊĐĩ СадаĐŊ"; - readonly zh: "æœĒ配įŊŽé™åˆļ"; - readonly "zh-tw": "尚æœĒč¨­åŽšé™åˆļ"; - }; - readonly "No, please take me back": { - readonly def: "No, please take me back"; - readonly es: "No, volver atrÃĄs"; - readonly ja: "いいえ、前ãĢæˆģりぞす"; - readonly ko: "ė•„ë‹ˆėš”, ė´ė „ėœŧ로 ëŒė•„ę°€ę˛ ėŠĩ니다"; - readonly ru: "НĐĩŅ‚, вĐĩŅ€ĐŊĐ¸Ņ‚Đĩ ĐŧĐĩĐŊŅ ĐŊаСад"; - readonly zh: "不īŧŒčŋ”回上一æ­Ĩ"; - readonly "zh-tw": "不īŧŒčŋ”回上一æ­Ĩ"; - }; - readonly "Node ID": { - readonly def: "Node ID"; - readonly ja: "ノãƒŧド ID"; - readonly ko: "노드 ID"; - readonly ru: "ID ŅƒĐˇĐģа"; - readonly zh: "čŠ‚į‚š ID"; - readonly "zh-tw": "ᝀéģž ID"; - }; - readonly "Node Information Missing": { - readonly def: "Node Information Missing"; - readonly ja: "ノãƒŧãƒ‰æƒ…å ąãŒã‚ã‚Šãžã›ã‚“"; - readonly ko: "노드 ė •ëŗ´ 누ëŊ"; - readonly ru: "ĐžŅ‚ŅŅƒŅ‚ŅŅ‚Đ˛ŅƒĐĩŅ‚ иĐŊŅ„ĐžŅ€ĐŧĐ°Ņ†Đ¸Ņ Ой ŅƒĐˇĐģĐĩ"; - readonly zh: "čŠ‚į‚šäŋĄæ¯įŧēå¤ą"; - readonly "zh-tw": "ᝀéģžčŗ‡č¨Šįŧēå¤ą"; - }; - readonly "Non-Synchronising files": { - readonly def: "Non-Synchronising files"; - readonly es: "Archivos no sincronizados"; - readonly ja: "同期しãĒã„ãƒ•ã‚Ąã‚¤ãƒĢ"; - readonly ko: "ë™ę¸°í™”í•˜ė§€ ė•ŠëŠ” 파ėŧ"; - readonly ru: "НĐĩŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ¸Ņ€ŅƒĐĩĐŧŅ‹Đĩ Ņ„Đ°ĐšĐģŅ‹"; - readonly zh: "不同æ­Ĩįš„æ–‡äģļ"; - readonly "zh-tw": "不同æ­Ĩįš„æĒ”æĄˆ"; - }; - readonly "Normal Files": { - readonly def: "Normal Files"; - readonly es: "Archivos normales"; - readonly ja: "é€šå¸¸ãƒ•ã‚Ąã‚¤ãƒĢ"; - readonly ko: "ėŧ반 파ėŧ"; - readonly ru: "ĐžĐąŅ‹Ņ‡ĐŊŅ‹Đĩ Ņ„Đ°ĐšĐģŅ‹"; - readonly zh: "晎通文äģļ"; - readonly "zh-tw": "一čˆŦæĒ”æĄˆ"; - }; - readonly 'Not all messages have been translated. And, please revert to "Default" when reporting errors.': { - readonly def: "Not all messages have been translated. And, please revert to \"Default\" when reporting errors."; - readonly es: "No todos los mensajes estÃĄn traducidos. Por favor, vuelva a \"Predeterminado\" al reportar errores."; - readonly fr: "Tous les messages n'ont pas ÊtÊ traduits. Et veuillez revenir à ÂĢ Par dÊfaut Âģ lorsque vous signalez des erreurs."; - readonly he: "לא כל ההוד×ĸו×Ē ×Ēורגמו. ×‘× ×•×Ą×Ŗ, אנא חזור ל\"בריר×Ē ×ž×—×“×œ\" ב×ĸ×Ē ×“×™×•×•×— ×ĸל שגיאו×Ē."; - readonly ja: "すずãĻãŽãƒĄãƒƒã‚ģãƒŧジがįŋģč¨ŗã•ã‚ŒãĻいるわけではありぞせん。ぞた、Issueå ąå‘ŠãŽéš›ãĢã¯ã„ãŖãŸã‚“\"Default\"ãĢæˆģしãĻください"; - readonly ko: "ëĒ¨ë“  ëŠ”ė‹œė§€ę°€ ë˛ˆė—­ë˜ė§€ ė•Šė•˜ėŠĩ니다. 똤ëĨ˜ ė‹ ęŗ  ė‹œ \"ę¸°ëŗ¸ę°’\"ėœŧ로 되돌려 ėŖŧė„¸ėš”."; - readonly ru: "НĐĩ Đ˛ŅĐĩ ŅĐžĐžĐąŅ‰ĐĩĐŊĐ¸Ņ ĐŋĐĩŅ€ĐĩвĐĩĐ´ĐĩĐŊŅ‹. И, ĐŋĐžĐļаĐģŅƒĐšŅŅ‚Đ°, вĐĩŅ€ĐŊĐ¸Ņ‚ĐĩҁҌ Đē ÂĢПо ҃ĐŧĐžĐģŅ‡Đ°ĐŊĐ¸ŅŽÂģ ĐŋŅ€Đ¸ ŅĐžĐžĐąŅ‰ĐĩĐŊии Ой ĐžŅˆĐ¸ĐąĐēĐ°Ņ…."; - readonly zh: "åšļ非所有æļˆæ¯éƒŊ厞įŋģč¯‘ã€‚č¯ˇåœ¨æŠĨå‘Šé”™č¯¯æ—￁ĸ复ä¸ē\"éģ˜čޤ\""; - }; - readonly "Notify all setting files": { - readonly def: "Notify all setting files"; - readonly es: "Notificar todos los archivos de configuraciÃŗn"; - readonly fr: "Notifier tous les fichiers de paramètres"; - readonly he: "הוד×ĸ ×ĸל כל קב×Ļי ההגדרו×Ē"; - readonly ja: "すずãĻãŽč¨­åŽšã‚’é€šįŸĨ"; - readonly ko: "ëĒ¨ë“  네렕 파ėŧ ė•ŒëĻŧ"; - readonly ru: "ĐŖĐ˛ĐĩĐ´ĐžĐŧĐģŅŅ‚ŅŒ ОйО Đ˛ŅĐĩŅ… Ņ„Đ°ĐšĐģĐ°Ņ… ĐŊĐ°ŅŅ‚Ņ€ĐžĐĩĐē"; - readonly zh: "通įŸĨæ‰€æœ‰čŽžįŊŽæ–‡äģļ"; - }; - readonly "Notify customized": { - readonly def: "Notify customized"; - readonly es: "Notificar personalizaciones"; - readonly fr: "Notifier les personnalisations"; - readonly he: "הוד×ĸ ×ĸל ה×Ēאמו×Ē ××™×Š×™×•×Ē"; - readonly ja: "ã‚Ģ゚ã‚ŋマイã‚ēãŒčĄŒã‚ã‚ŒãŸã‚‰é€šįŸĨする"; - readonly ko: "ė‚ŦėšŠėž 네렕 ė•ŒëĻŧ"; - readonly ru: "ĐŖĐ˛ĐĩĐ´ĐžĐŧĐģŅŅ‚ŅŒ Đž ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēĐ°Ņ…"; - readonly zh: "通įŸĨč‡ĒåŽšäš‰čŽžįŊŽ"; - }; - readonly "Notify when other device has newly customized.": { - readonly def: "Notify when other device has newly customized."; - readonly es: "Notificar cuando otro dispositivo personalice"; - readonly fr: "Notifier lorsqu'un autre appareil a une nouvelle personnalisation."; - readonly he: "הוד×ĸ כאשר מכשיר אחר ×”×•×Ą×™×Ŗ ה×Ēאמה אישי×Ē ×—×“×Š×”."; - readonly ja: "åˆĨぎį̝æœĢがã‚Ģ゚ã‚ŋマイã‚ēã‚’čĄŒãĒãŖãŸã‚‰é€šįŸĨする"; - readonly ko: "다ëĨ¸ ę¸°ę¸°ė—ė„œ ėƒˆëĄœėš´ ė‚ŦėšŠėž ė„¤ė •ė´ ėžˆė„ 때 ė•ŒëĻŧė„ 받ėŠĩ니다."; - readonly ru: "ĐŖĐ˛ĐĩĐ´ĐžĐŧĐģŅŅ‚ŅŒ, ĐēĐžĐŗĐ´Đ° Đ´Ņ€ŅƒĐŗĐžĐĩ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đž иСĐŧĐĩĐŊиĐģĐž ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи."; - readonly zh: "åŊ“å…ļäģ–čŽžå¤‡æœ‰æ–°įš„č‡ĒåŽšäš‰čŽžįŊŽæ—ļ通įŸĨ "; - }; - readonly "Notify when the estimated remote storage size exceeds on start up": { - readonly def: "Notify when the estimated remote storage size exceeds on start up"; - readonly es: "Notificar cuando el tamaÃąo estimado del almacenamiento remoto exceda al iniciar"; - readonly fr: "Notifier quand la taille estimÊe du stockage distant est dÊpassÊe au dÊmarrage"; - readonly he: "הוד×ĸ כשגודל האחסון המרוחד המשו×ĸר ×ĸולה ×ĸל ×”×Ą×Ŗ ב×ĸ×Ē ×”×¤×ĸלה"; - readonly ja: "čĩˇå‹•時ãĢä爿ƒŗãƒĒãƒĸãƒŧト゚トãƒŦãƒŧジã‚ĩイã‚ēをčļ…えたら通įŸĨ"; - readonly ko: "ė‹œėž‘ ė‹œ 똈냁 ė›ę˛Š ėŠ¤í† ëĻŦė§€ íŦ기가 봈ęŗŧ되늴 ė•ŒëĻŧ"; - readonly ru: "ĐŖĐ˛ĐĩĐ´ĐžĐŧĐģŅŅ‚ŅŒ, ĐēĐžĐŗĐ´Đ° ĐžŅ†ĐĩĐŊĐžŅ‡ĐŊŅ‹Đš Ņ€Đ°ĐˇĐŧĐĩŅ€ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐŗĐž Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰Đ° ĐŋŅ€ĐĩĐ˛Ņ‹ŅˆĐ°ĐĩŅ‚ ĐŋŅ€Đ¸ СаĐŋ҃ҁĐēĐĩ"; - readonly zh: "启动æ—ļåŊ“äŧ°čŽĄįš„čŋœį¨‹å­˜å‚¨å¤§å°čļ…å‡ēæ—ļ通įŸĨ"; - }; - readonly "Number of batches to process at a time. Defaults to 40. Minimum is 2. This along with batch size controls how many docs are kept in memory at a time.": { - readonly def: "Number of batches to process at a time. Defaults to 40. Minimum is 2. This along with batch size controls how many docs are kept in memory at a time."; - readonly es: "NÃēmero de lotes a procesar. Default 40, mínimo 2. Controla documentos en memoria"; - readonly fr: "Nombre de lots à traiter à la fois. Par dÊfaut 40. Minimum 2. Ceci, avec la taille de lot, contrôle le nombre de documents conservÊs en mÊmoire à la fois."; - readonly he: "מספר הא×Ļוו×Ē ×œ×ĸיבוד בכל פ×ĸם. בריר×Ē ×ž×—×“×œ 40, מינימום 2. יחד ×ĸם גודל הא×Ļווה קוב×ĸ כמה מסמכים נשמרים בזיכרון בו-זמני×Ē."; - readonly ja: "1åēĻãĢå‡Ļį†ã™ã‚‹ãƒãƒƒãƒãŽæ•°ã€‚ãƒ‡ãƒ•ã‚ŠãƒĢトは40、最小は2ã€‚ã“ãŽæ•°å€¤ã¯ã€ãŠã‚Œã ã‘ãŽåŽšé‡ãŽæ›¸éĄžãŒãƒĄãƒĸãƒĒãĢäŋå­˜ã•ã‚Œã‚‹ã‹ã‚‚åŽšįžŠã—ãžã™ã€‚"; - readonly ko: "한 ë˛ˆė— 래ëĻŦ할 ėŧ괄 래ëĻŦ ėˆ˜ėž…ë‹ˆë‹¤. ę¸°ëŗ¸ę°’ė€ 40ėž…ë‹ˆë‹¤. ėĩœė†Œę°’ė€ 2ėž…ë‹ˆë‹¤. ė´ëŠ” ėŧ괄 íŦ揰뙀 함ęģ˜ ëŠ”ëǍëĻŦ뗐 ëŗ´ę´€ë˜ëŠ” ëŦ¸ė„œ 눘ëĨŧ ė œė–´í•Šë‹ˆë‹¤."; - readonly ru: "Number of batches to process at a time. Defaults to 40. Minimum is 2. This along with batch size controls how many docs are kept in memory at a time."; - readonly zh: "一æŦĄå¤„į†įš„æ‰šé‡æ•°é‡ã€‚éģ˜čޤä¸ē 40。最小ä¸ē 2ã€‚æ­¤čŽžįŊŽä¸Žæ‰šé‡å¤§å°ä¸€čĩˇæŽ§åˆļ一æŦĄåœ¨å†…存中äŋį•™å¤šå°‘æ–‡æĄŖ"; - }; - readonly "Number of changes to sync at a time. Defaults to 50. Minimum is 2.": { - readonly def: "Number of changes to sync at a time. Defaults to 50. Minimum is 2."; - readonly es: "NÃēmero de cambios a sincronizar simultÃĄneamente. Default 50, mínimo 2"; - readonly fr: "Nombre de modifications à synchroniser à la fois. Par dÊfaut 50. Minimum 2."; - readonly he: "מספר השינויים לסנכרון בכל פ×ĸם. בריר×Ē ×ž×—×“×œ 50, מינימום 2."; - readonly ja: "一åēĻãĢ同期する変更ぎ数。デフりãƒĢトは50、最小は2。"; - readonly ko: "한 ë˛ˆė— 동기화할 ëŗ€ę˛Ŋ ė‚Ŧí•­ė˜ ėˆ˜ėž…ë‹ˆë‹¤. ę¸°ëŗ¸ę°’ė€ 50ėž…ë‹ˆë‹¤. ėĩœė†Œę°’ė€ 2ėž…ë‹ˆë‹¤."; - readonly ru: "КоĐģĐ¸Ņ‡ĐĩŅŅ‚Đ˛Đž иСĐŧĐĩĐŊĐĩĐŊиК Đ´ĐģŅ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸ Са Ņ€Đ°Đˇ. По ҃ĐŧĐžĐģŅ‡Đ°ĐŊĐ¸ŅŽ 50. МиĐŊиĐŧ҃Đŧ 2."; - readonly zh: "一æŦĄåŒæ­Ĩįš„æ›´æ”šæ•°é‡ã€‚éģ˜čޤä¸ē 50。最小ä¸ē 2。"; - }; - readonly "Obsidian version": { - readonly def: "Obsidian version"; - readonly ja: "Obsidian バãƒŧã‚¸ãƒ§ãƒŗ"; - readonly ko: "Obsidian ë˛„ė „"; - readonly ru: "ВĐĩŅ€ŅĐ¸Ņ Obsidian"; - readonly zh: "Obsidian į‰ˆæœŦ"; - readonly "zh-tw": "Obsidian į‰ˆæœŦ"; - }; - readonly "obsidianLiveSyncSettingTab.btnApply": { - readonly def: "Apply"; - readonly es: "Aplicar"; - readonly fr: "Appliquer"; - readonly he: "החל"; - readonly ja: "éŠį”¨"; - readonly ko: "ė ėšŠ"; - readonly ru: "ĐŸŅ€Đ¸ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ"; - readonly zh: "åē”ᔍ"; - readonly "zh-tw": "åĨ—ᔍ"; - }; - readonly "obsidianLiveSyncSettingTab.btnCheck": { - readonly def: "Check"; - readonly es: "Verificar"; - readonly fr: "VÊrifier"; - readonly he: "בדוק"; - readonly ja: "įĸēčĒ"; - readonly ko: "í™•ė¸"; - readonly ru: "ĐŸŅ€ĐžĐ˛ĐĩŅ€Đ¸Ņ‚ŅŒ"; - readonly zh: "æŖ€æŸĨ"; - }; - readonly "obsidianLiveSyncSettingTab.btnCopy": { - readonly def: "Copy"; - readonly es: "Copiar"; - readonly fr: "Copier"; - readonly he: "ה×ĸ×Ē×§"; - readonly ja: "ã‚ŗãƒ”ãƒŧ"; - readonly ko: "ëŗĩė‚Ŧ"; - readonly ru: "КоĐŋĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ"; - readonly zh: "复åˆļ"; - }; - readonly "obsidianLiveSyncSettingTab.btnDisable": { - readonly def: "Disable"; - readonly es: "Desactivar"; - readonly fr: "DÊsactiver"; - readonly he: "נטרל"; - readonly ja: "į„ĄåŠšåŒ–"; - readonly ko: "ëš„í™œė„ąí™”"; - readonly ru: "ĐžŅ‚ĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒ"; - readonly zh: "įρᔍ"; - readonly "zh-tw": "åœį”¨"; - }; - readonly "obsidianLiveSyncSettingTab.btnDiscard": { - readonly def: "Discard"; - readonly es: "Descartar"; - readonly fr: "Abandonner"; - readonly he: "ביטול שינויים"; - readonly ja: "į ´æŖ„"; - readonly ko: "ė‚­ė œ"; - readonly ru: "ĐžŅ‚ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ"; - readonly zh: "ä¸ĸåŧƒ"; - }; - readonly "obsidianLiveSyncSettingTab.btnEnable": { - readonly def: "Enable"; - readonly es: "Activar"; - readonly fr: "Activer"; - readonly he: "הפ×ĸל"; - readonly ja: "有劚化"; - readonly ko: "í™œė„ąí™”"; - readonly ru: "ВĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒ"; - readonly zh: "吝ᔍ"; - }; - readonly "obsidianLiveSyncSettingTab.btnFix": { - readonly def: "Fix"; - readonly es: "Corregir"; - readonly fr: "Corriger"; - readonly he: "×Ēקן"; - readonly ja: "äŋŽæ­Ŗ"; - readonly ko: "ėˆ˜ė •"; - readonly ru: "Đ˜ŅĐŋŅ€Đ°Đ˛Đ¸Ņ‚ŅŒ"; - readonly zh: "äŋŽå¤"; - }; - readonly "obsidianLiveSyncSettingTab.btnGotItAndUpdated": { - readonly def: "I got it and updated."; - readonly es: "Lo entendí y actualicÊ."; - readonly fr: "J'ai compris et mis à jour."; - readonly he: "הבנ×Ēי ו×ĸדכנ×Ēי."; - readonly ja: "į†č§Ŗã—ãžã—ãŸã€æ›´æ–°ã—ãžã—ãŸã€‚"; - readonly ko: "ė•Œę˛ ėŠĩ니다. ė—…ë°ė´íŠ¸í–ˆėŠĩ니다."; - readonly ru: "ПоĐŊŅĐģ и ОйĐŊОвиĐģ."; - readonly zh: "我明į™Ŋäē†åšļä¸”åˇ˛æ›´æ–°"; - }; - readonly "obsidianLiveSyncSettingTab.btnNext": { - readonly def: "Next"; - readonly es: "Siguiente"; - readonly fr: "Suivant"; - readonly he: "הבא"; - readonly ja: "æŦĄã¸"; - readonly ko: "ë‹¤ėŒ"; - readonly ru: "ДаĐģĐĩĐĩ"; - readonly zh: "下一æ­Ĩ"; - readonly "zh-tw": "下一æ­Ĩ"; - }; - readonly "obsidianLiveSyncSettingTab.btnStart": { - readonly def: "Start"; - readonly es: "Iniciar"; - readonly fr: "DÊmarrer"; - readonly he: "ה×Ēחל"; - readonly ja: "開始"; - readonly ko: "ė‹œėž‘"; - readonly ru: "ĐĄŅ‚Đ°Ņ€Ņ‚"; - readonly zh: "åŧ€å§‹"; - }; - readonly "obsidianLiveSyncSettingTab.btnTest": { - readonly def: "Test"; - readonly es: "Probar"; - readonly fr: "Tester"; - readonly he: "בדוק"; - readonly ja: "テ゚ト"; - readonly ko: "í…ŒėŠ¤íŠ¸"; - readonly ru: "ĐĸĐĩҁ҂"; - readonly zh: "æĩ‹č¯•"; - }; - readonly "obsidianLiveSyncSettingTab.btnUse": { - readonly def: "Use"; - readonly es: "Usar"; - readonly fr: "Utiliser"; - readonly he: "הש×Ēמ׊"; - readonly ja: "äŊŋᔍ"; - readonly ko: "ė‚ŦėšŠ"; - readonly ru: "Đ˜ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ŅŒ"; - readonly zh: "äŊŋᔍ"; - }; - readonly "obsidianLiveSyncSettingTab.buttonFetch": { - readonly def: "Fetch"; - readonly es: "Obtener"; - readonly fr: "RÊcupÊrer"; - readonly he: "משוך"; - readonly ja: "フェッチ"; - readonly ko: "ę°€ė ¸ė˜¤ę¸°"; - readonly ru: "Đ—Đ°ĐŗŅ€ŅƒĐˇĐ¸Ņ‚ŅŒ"; - readonly zh: "čŽˇå–"; - }; - readonly "obsidianLiveSyncSettingTab.buttonNext": { - readonly def: "Next"; - readonly es: "Siguiente"; - readonly fr: "Suivant"; - readonly he: "הבא"; - readonly ja: "æŦĄã¸"; - readonly ko: "ë‹¤ėŒ"; - readonly ru: "ДаĐģĐĩĐĩ"; - readonly zh: "下一æ­Ĩ"; - readonly "zh-tw": "下一æ­Ĩ"; - }; - readonly "obsidianLiveSyncSettingTab.defaultLanguage": { - readonly def: "Default"; - readonly es: "Predeterminado"; - readonly fr: "Par dÊfaut"; - readonly he: "בריר×Ē ×ž×—×“×œ"; - readonly ja: "デフりãƒĢト"; - readonly ko: "ę¸°ëŗ¸ę°’"; - readonly ru: "По ҃ĐŧĐžĐģŅ‡Đ°ĐŊĐ¸ŅŽ"; - readonly zh: "éģ˜čޤ蝭荀"; - readonly "zh-tw": "預設čĒžč¨€"; - }; - readonly "obsidianLiveSyncSettingTab.descConnectSetupURI": { - readonly def: "This is the recommended method to set up Self-hosted LiveSync with a Setup URI."; - readonly es: "Este es el mÊtodo recomendado para configurar Self-hosted LiveSync con una URI de configuraciÃŗn."; - readonly fr: "MÊthode recommandÊe pour configurer Self-hosted LiveSync avec une URI de configuration."; - readonly he: "זוהי השיטה המומל×Ļ×Ē ×œ×”×’×“×¨×Ē Self-hosted LiveSync ×ĸם Setup URI."; - readonly ja: "ã‚ģットã‚ĸップURIをäŊŋį”¨ã—ãĻSelf-hosted LiveSyncをã‚ģットã‚ĸップする推åĨ¨æ–šæŗ•ã§ã™ã€‚"; - readonly ko: "ė´ę˛ƒė€ Setup URI로 Self-hosted LiveSyncëĨŧ ė„¤ė •í•˜ëŠ” ęļŒėžĨ ë°Šë˛•ėž…ë‹ˆë‹¤."; - readonly ru: "Đ­Ņ‚Đž Ņ€ĐĩĐēĐžĐŧĐĩĐŊĐ´ŅƒĐĩĐŧŅ‹Đš ҁĐŋĐžŅĐžĐą ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи Self-hosted LiveSync ҁ ĐŋĐžĐŧĐžŅ‰ŅŒŅŽ Setup URI."; - readonly zh: "čŋ™æ˜¯äŊŋį”¨čŽžįŊŽ URI 莞įŊŽ Self-hosted LiveSync įš„æŽ¨čæ–šæŗ•"; - }; - readonly "obsidianLiveSyncSettingTab.descCopySetupURI": { - readonly def: "Perfect for setting up a new device!"; - readonly es: "ÂĄPerfecto para configurar un nuevo dispositivo!"; - readonly fr: "Parfait pour configurer un nouvel appareil !"; - readonly he: "מושלם להגדר×Ē ×ž×›×Š×™×¨ חדש!"; - readonly ja: "新しいデバイ゚ぎã‚ģットã‚ĸップãĢおすすめīŧ"; - readonly ko: "냈 기기 네렕뗐 뙄ë˛Ŋ합니다!"; - readonly ru: "ИдĐĩаĐģҌĐŊĐž ĐŋĐžĐ´Ņ…ĐžĐ´Đ¸Ņ‚ Đ´ĐģŅ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи ĐŊĐžĐ˛ĐžĐŗĐž ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°!"; - readonly zh: "éžå¸¸é€‚åˆčŽžįŊŽæ–°čŽžå¤‡īŧ"; - }; - readonly "obsidianLiveSyncSettingTab.descEnableLiveSync": { - readonly def: "Only enable this after configuring either of the above two options or completing all configuration manually."; - readonly es: "Solo habilita esto despuÊs de configurar cualquiera de las dos opciones anteriores o completar toda la configuraciÃŗn manualmente."; - readonly fr: "N'activez ceci qu'après avoir configurÊ l'une des deux options ci-dessus ou terminÊ toute la configuration manuellement."; - readonly he: "הפ×ĸל רק לאחר הגדר×Ē ××—×Ē ×ž×Š×Ēי האפשרויו×Ē ×œ×ĸיל, או לאחר השלמ×Ē ×›×œ ההגדרו×Ē ×™×“× ×™×Ē."; - readonly ja: "ä¸Šč¨˜ãŽ2つぎã‚Ēãƒ—ã‚ˇãƒ§ãƒŗãŽã„ãšã‚Œã‹ã‚’č¨­åŽšã™ã‚‹ã‹ã€ã™ãšãĻãŽč¨­åŽšã‚’æ‰‹å‹•ã§åŽŒäē†ã—た垌ãĢぎãŋ有劚ãĢしãĻください。"; - readonly ko: "ėœ„ė˜ 두 ė˜ĩė…˜ 뤑 하나ëĨŧ ęĩŦė„ąí•˜ęą°ë‚˜ ëĒ¨ë“  ęĩŦė„ąė„ ėˆ˜ë™ėœŧ로 ė™„ëŖŒí•œ í›„ė—ë§Œ í™œė„ąí™”í•˜ė„¸ėš”."; - readonly ru: "ВĐēĐģŅŽŅ‡Đ°ĐšŅ‚Đĩ ŅŅ‚Đž Ņ‚ĐžĐģҌĐēĐž ĐŋĐžŅĐģĐĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи ОдĐŊĐžĐŗĐž иС Đ´Đ˛ŅƒŅ… Đ˛Đ°Ņ€Đ¸Đ°ĐŊŅ‚ĐžĐ˛ Đ˛Ņ‹ŅˆĐĩ иĐģи ĐŋĐžŅĐģĐĩ ĐŋĐžĐģĐŊĐžĐŗĐž Ņ€ŅƒŅ‡ĐŊĐžĐŗĐž СавĐĩŅ€ŅˆĐĩĐŊĐ¸Ņ Đ˛ŅĐĩĐš ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Đ¸."; - readonly zh: "äģ…在配įŊŽäē†ä¸Ščŋ°ä¸¤ä¸Ēé€‰éĄšäš‹ä¸€æˆ–æ‰‹åŠ¨åŽŒæˆæ‰€æœ‰é…įŊŽåŽå¯į”¨æ­¤é€‰éĄš"; - }; - readonly "obsidianLiveSyncSettingTab.descFetchConfigFromRemote": { - readonly def: "Fetch necessary settings from already configured remote server."; - readonly es: "Obtener las configuraciones necesarias del servidor remoto ya configurado."; - readonly fr: "RÊcupÊrer les paramètres nÊcessaires depuis un serveur distant dÊjà configurÊ."; - readonly he: "משוך הגדרו×Ē × ×“×¨×Š×•×Ē ×ž×”×Š×¨×Ē ×”×ž×¨×•×—×“ שהוגדר כבר."; - readonly ja: "æ—ĸãĢč¨­åŽšæ¸ˆãŋぎãƒĒãƒĸãƒŧトã‚ĩãƒŧバãƒŧからåŋ…čρãĒč¨­åŽšã‚’å–åž—ã—ãžã™ã€‚"; - readonly ko: "ė´ë¯¸ ęĩŦė„ąëœ ė›ę˛Š ė„œë˛„ė—ė„œ í•„ėš”í•œ ė„¤ė •ė„ 氀렏ė˜ĩ니다."; - readonly ru: "ПоĐģŅƒŅ‡Đ¸Ņ‚ŅŒ ĐŊĐĩĐžĐąŅ…ĐžĐ´Đ¸ĐŧŅ‹Đĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи ҁ ҃ĐļĐĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐĩĐŊĐŊĐžĐŗĐž ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐŗĐž ҁĐĩŅ€Đ˛ĐĩŅ€Đ°."; - readonly zh: "äģŽåˇ˛é…įŊŽįš„čŋœį¨‹æœåŠĄå™¨čŽˇå–åŋ…čĻįš„čŽžįŊŽ"; - }; - readonly "obsidianLiveSyncSettingTab.descManualSetup": { - readonly def: "Not recommended, but useful if you don't have a Setup URI"; - readonly es: "No recomendado, pero Ãētil si no tienes una URI de configuraciÃŗn"; - readonly fr: "Non recommandÊ, mais utile si vous n'avez pas d'URI de configuration"; - readonly he: "לא מומל×Ĩ, אך שימושי אם אין לך Setup URI"; - readonly ja: "推åĨ¨ã—ぞせんが、ã‚ģットã‚ĸップURIがãĒい場合ãĢäžŋ刊です"; - readonly ko: "ęļŒėžĨí•˜ė§€ ė•Šė§€ë§Œ Setup URI가 ė—†ëŠ” ę˛Ŋėš°ė— ėœ ėšŠí•Šë‹ˆë‹¤"; - readonly ru: "НĐĩ Ņ€ĐĩĐēĐžĐŧĐĩĐŊĐ´ŅƒĐĩŅ‚ŅŅ, ĐŊĐž ĐŋĐžĐģĐĩСĐŊĐž, ĐĩҁĐģи ҃ Đ˛Đ°Ņ ĐŊĐĩŅ‚ Setup URI."; - readonly zh: "ä¸æŽ¨č,äŊ†åĻ‚æžœæ‚¨æ˛Ąæœ‰čŽžįŊŽ URI åˆ™åžˆæœ‰į”¨"; - }; - readonly "obsidianLiveSyncSettingTab.descTestDatabaseConnection": { - readonly def: "Open database connection. If the remote database is not found and you have permission to create a database, the database will be created."; - readonly es: "Abrir conexiÃŗn a la base de datos. Si no se encuentra la base de datos remota y tienes permiso para crear una base de datos, se crearÃĄ la base de datos."; - readonly fr: "Ouvrir la connexion à la base de donnÊes. Si la base distante est introuvable et que vous avez l'autorisation de crÊer une base, elle sera crÊÊe."; - readonly he: "פ×Ēח חיבור למסד × ×Ēונים. אם מסד הנ×Ēונים המרוחד לא נמ×Ļא ויש לך הרשאה לי×Ļור אחד, הוא יי×Ļור."; - readonly ja: "デãƒŧã‚ŋベãƒŧ゚æŽĨįļšã‚’開きぞす。ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚がčĻ‹ã¤ã‹ã‚‰ãšã€ãƒ‡ãƒŧã‚ŋベãƒŧ゚をäŊœæˆã™ã‚‹æ¨Šé™ãŒã‚る場合は、デãƒŧã‚ŋベãƒŧ゚がäŊœæˆã•れぞす。"; - readonly ko: "ë°ė´í„°ë˛ ė´ėŠ¤ ė—°ę˛°ė„ ė—Ŋ니다. ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ëĨŧ ė°žė„ 눘 뗆溠 ë°ė´í„°ë˛ ė´ėŠ¤ ėƒė„ą ęļŒí•œė´ ėžˆëŠ” ę˛Ŋ뚰, ë°ė´í„°ë˛ ė´ėŠ¤ę°€ ėƒė„ąëŠë‹ˆë‹¤."; - readonly ru: "ĐžŅ‚ĐēŅ€Ņ‹Ņ‚ŅŒ ĐŋОдĐēĐģŅŽŅ‡ĐĩĐŊиĐĩ Đē йаСĐĩ даĐŊĐŊҋ҅. Đ•ŅĐģи ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐ°Ņ йаСа даĐŊĐŊҋ҅ ĐŊĐĩ ĐŊаКдĐĩĐŊа и ҃ Đ˛Đ°Ņ ĐĩŅŅ‚ŅŒ ĐŋŅ€Đ°Đ˛Đž ĐŊа ĐĩŅ‘ ŅĐžĐˇĐ´Đ°ĐŊиĐĩ, йаСа ĐąŅƒĐ´ĐĩŅ‚ ŅĐžĐˇĐ´Đ°ĐŊа."; - readonly zh: "打åŧ€æ•°æŽåē“čŋžæŽĨ。åĻ‚æžœæœĒ扞到čŋœį¨‹æ•°æŽåē“åšļ且您有创åģēæ•°æŽåē“įš„æƒé™,则将创åģēæ•°æŽåē“"; - }; - readonly "obsidianLiveSyncSettingTab.descValidateDatabaseConfig": { - readonly def: "Checks and fixes any potential issues with the database config."; - readonly es: "Verifica y soluciona cualquier problema potencial con la configuraciÃŗn de la base de datos."; - readonly fr: "VÊrifie et corrige les problèmes potentiels de la configuration de la base."; - readonly he: "בודק ומ×Ēקן ב×ĸיו×Ē ××¤×Š×¨×™×•×Ē ×‘×Ē×Ļור×Ē ×ž×Ą×“ הנ×Ēונים."; - readonly ja: "デãƒŧã‚ŋベãƒŧã‚šč¨­åŽšãŽæŊœåœ¨įš„ãĒå•éĄŒã‚’įĸēčĒã—ã€äŋŽæ­Ŗã—ぞす。"; - readonly ko: "ë°ė´í„°ë˛ ė´ėŠ¤ ęĩŦė„ąė˜ ėž ėžŦ렁 ëŦ¸ė œëĨŧ í™•ė¸í•˜ęŗ  ėˆ˜ė •í•Šë‹ˆë‹¤."; - readonly ru: "ĐŸŅ€ĐžĐ˛ĐĩŅ€ŅĐĩŅ‚ и Đ¸ŅĐŋŅ€Đ°Đ˛ĐģŅĐĩŅ‚ ĐģŅŽĐąŅ‹Đĩ ĐŋĐžŅ‚ĐĩĐŊŅ†Đ¸Đ°ĐģҌĐŊŅ‹Đĩ ĐŋŅ€ĐžĐąĐģĐĩĐŧŅ‹ в ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Đ¸ ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅."; - readonly zh: "æŖ€æŸĨåšļäŋŽå¤æ•°æŽåē“配įŊŽä¸­įš„äģģäŊ•æŊœåœ¨é—Žéĸ˜"; - }; - readonly "obsidianLiveSyncSettingTab.errAccessForbidden": { - readonly def: "❗ Access forbidden."; - readonly es: "Acceso prohibido."; - readonly fr: "❗ Accès interdit."; - readonly he: "❗ גישה נדח×Ēה."; - readonly ja: "❗ ã‚ĸクã‚ģ゚がįρæ­ĸされãĻいぞす。"; - readonly ko: "❗ ė•Ąė„¸ėŠ¤ę°€ ę¸ˆė§€ë˜ė—ˆėŠĩ니다."; - readonly ru: "❗ Đ”ĐžŅŅ‚ŅƒĐŋ СаĐŋŅ€Đĩ҉ґĐŊ."; - readonly zh: "❗ čŽŋ问čĸĢįρæ­ĸ"; - }; - readonly "obsidianLiveSyncSettingTab.errCannotContinueTest": { - readonly def: "We could not continue the test."; - readonly es: "No se pudo continuar con la prueba."; - readonly fr: "Impossible de poursuivre le test."; - readonly he: "לא ני×Ēן להמשיך בבדיקה."; - readonly ja: "テ゚トをįļščĄŒã§ããžã›ã‚“でした。"; - readonly ko: "í…ŒėŠ¤íŠ¸ëĨŧ ęŗ„ė†í•  눘 ė—†ėŠĩ니다."; - readonly ru: "ĐœŅ‹ ĐŊĐĩ ĐŧĐžĐļĐĩĐŧ ĐŋŅ€ĐžĐ´ĐžĐģĐļĐ¸Ņ‚ŅŒ Ņ‚Đĩҁ҂."; - readonly zh: "我äģŦæ— æŗ•įģ§įģ­æĩ‹č¯•。"; - }; - readonly "obsidianLiveSyncSettingTab.errCorsCredentials": { - readonly def: "❗ cors.credentials is wrong"; - readonly es: "❗ cors.credentials es incorrecto"; - readonly fr: "❗ cors.credentials est incorrect"; - readonly he: "❗ cors.credentials שגוי"; - readonly ja: "❗ cors.credentialsãŒä¸æ­Ŗã§ã™"; - readonly ko: "❗ cors.credentials가 ėž˜ëĒģë˜ė—ˆėŠĩ니다"; - readonly ru: "❗ cors.credentials ĐŊĐĩвĐĩŅ€ĐŊĐž"; - readonly zh: "❗ cors.credentials 莞įŊŽé”™č¯¯"; - }; - readonly "obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials": { - readonly def: "❗ CORS is not allowing credentials"; - readonly es: "CORS no permite credenciales"; - readonly fr: "❗ CORS n'autorise pas les identifiants"; - readonly he: "❗ CORS אינו מאפ׊ר פרטי גישה"; - readonly ja: "❗ CORSがčĒč¨ŧæƒ…å ąã‚’č¨ąå¯ã—ãĻいぞせん"; - readonly ko: "❗ CORSė—ė„œ ėžę˛Š ėĻëĒ…ė„ í—ˆėšŠí•˜ė§€ ė•ŠėŠĩ니다"; - readonly ru: "❗ CORS ĐŊĐĩ Ņ€Đ°ĐˇŅ€ĐĩŅˆĐ°ĐĩŅ‚ ŅƒŅ‡Ņ‘Ņ‚ĐŊŅ‹Đĩ даĐŊĐŊŅ‹Đĩ"; - readonly zh: "❗ CORS ä¸å…čŽ¸å‡­æŽ"; - }; - readonly "obsidianLiveSyncSettingTab.errCorsOrigins": { - readonly def: "❗ cors.origins is wrong"; - readonly es: "❗ cors.origins es incorrecto"; - readonly fr: "❗ cors.origins est incorrect"; - readonly he: "❗ cors.origins שגוי"; - readonly ja: "❗ cors.originsãŒä¸æ­Ŗã§ã™"; - readonly ko: "❗ cors.origins가 ėž˜ëĒģë˜ė—ˆėŠĩ니다"; - readonly ru: "❗ cors.origins ĐŊĐĩвĐĩŅ€ĐŊĐž"; - readonly zh: "❗ cors.origins 莞įŊŽé”™č¯¯"; - }; - readonly "obsidianLiveSyncSettingTab.errEnableCors": { - readonly def: "❗ httpd.enable_cors is wrong"; - readonly es: "❗ httpd.enable_cors es incorrecto"; - readonly fr: "❗ httpd.enable_cors est incorrect"; - readonly he: "❗ httpd.enable_cors שגוי"; - readonly ja: "❗ httpd.enable_corsãŒä¸æ­Ŗã§ã™"; - readonly ko: "❗ httpd.enable_cors가 ėž˜ëĒģë˜ė—ˆėŠĩ니다"; - readonly ru: "❗ httpd.enable_cors ĐŊĐĩвĐĩŅ€ĐŊĐž"; - readonly zh: "❗ httpd.enable_cors 莞įŊŽé”™č¯¯"; - }; - readonly "obsidianLiveSyncSettingTab.errEnableCorsChttpd": { - readonly def: "❗ chttpd.enable_cors is wrong"; - readonly fr: "❗ chttpd.enable_cors est incorrect"; - readonly he: "❗ chttpd.enable_cors שגוי"; - readonly ja: "❗ chttpd.enable_corsãŒä¸æ­Ŗã§ã™"; - readonly ru: "❗ chttpd.enable_cors ĐŊĐĩвĐĩŅ€ĐŊĐž"; - readonly zh: "❗ chttpd.enable_cors 莞įŊŽé”™č¯¯"; - }; - readonly "obsidianLiveSyncSettingTab.errMaxDocumentSize": { - readonly def: "❗ couchdb.max_document_size is low)"; - readonly es: "❗ couchdb.max_document_size es bajo)"; - readonly fr: "❗ couchdb.max_document_size est trop bas)"; - readonly he: "❗ couchdb.max_document_size נמוך)"; - readonly ja: "❗ couchdb.max_document_sizeがäŊŽã™ãŽãžã™"; - readonly ko: "❗ couchdb.max_document_size가 낮ėŠĩ니다)"; - readonly ru: "❗ couchdb.max_document_size ĐŊиСĐēĐžĐĩ"; - readonly zh: "❗ couchdb.max_document_size 莞įŊŽčŋ‡äŊŽ)"; - }; - readonly "obsidianLiveSyncSettingTab.errMaxRequestSize": { - readonly def: "❗ chttpd.max_http_request_size is low)"; - readonly es: "❗ chttpd.max_http_request_size es bajo)"; - readonly fr: "❗ chttpd.max_http_request_size est trop bas)"; - readonly he: "❗ chttpd.max_http_request_size נמוך)"; - readonly ja: "❗ chttpd.max_http_request_sizeがäŊŽã™ãŽãžã™"; - readonly ko: "❗ chttpd.max_http_request_size가 낮ėŠĩ니다)"; - readonly ru: "❗ chttpd.max_http_request_size ĐŊиСĐēĐžĐĩ"; - readonly zh: "❗ chttpd.max_http_request_size 莞įŊŽčŋ‡äŊŽ)"; - }; - readonly "obsidianLiveSyncSettingTab.errMissingWwwAuth": { - readonly def: "❗ httpd.WWW-Authenticate is missing"; - readonly es: "❗ httpd.WWW-Authenticate falta"; - readonly fr: "❗ httpd.WWW-Authenticate est manquant"; - readonly he: "❗ httpd.WWW-Authenticate חסר"; - readonly ja: "❗ httpd.WWW-Authenticateが不čļŗã—ãĻいぞす"; - readonly ko: "❗ httpd.WWW-Authenticate가 누ëŊë˜ė—ˆėŠĩ니다"; - readonly ru: "❗ httpd.WWW-Authenticate ĐžŅ‚ŅŅƒŅ‚ŅŅ‚Đ˛ŅƒĐĩŅ‚"; - readonly zh: "❗ įŧē少 httpd.WWW-Authenticate 莞įŊŽ"; - }; - readonly "obsidianLiveSyncSettingTab.errRequireValidUser": { - readonly def: "❗ chttpd.require_valid_user is wrong."; - readonly es: "❗ chttpd.require_valid_user es incorrecto."; - readonly fr: "❗ chttpd.require_valid_user est incorrect."; - readonly he: "❗ chttpd.require_valid_user שגוי."; - readonly ja: "❗ chttpd.require_valid_userãŒä¸æ­Ŗã§ã™ã€‚"; - readonly ko: "❗ chttpd.require_valid_user가 ėž˜ëĒģë˜ė—ˆėŠĩ니다."; - readonly ru: "❗ chttpd.require_valid_user ĐŊĐĩвĐĩŅ€ĐŊĐž."; - readonly zh: "❗ chttpd.require_valid_user 莞įŊŽé”™č¯¯"; - }; - readonly "obsidianLiveSyncSettingTab.errRequireValidUserAuth": { - readonly def: "❗ chttpd_auth.require_valid_user is wrong."; - readonly es: "❗ chttpd_auth.require_valid_user es incorrecto."; - readonly fr: "❗ chttpd_auth.require_valid_user est incorrect."; - readonly he: "❗ chttpd_auth.require_valid_user שגוי."; - readonly ja: "❗ chttpd_auth.require_valid_userãŒä¸æ­Ŗã§ã™ã€‚"; - readonly ko: "❗ chttpd_auth.require_valid_user가 ėž˜ëĒģë˜ė—ˆėŠĩ니다."; - readonly ru: "❗ chttpd_auth.require_valid_user ĐŊĐĩвĐĩŅ€ĐŊĐž."; - readonly zh: "❗ chttpd_auth.require_valid_user 莞įŊŽé”™č¯¯"; - }; - readonly "obsidianLiveSyncSettingTab.labelDisabled": { - readonly def: "âšī¸ : Disabled"; - readonly es: "âšī¸ : Desactivado"; - readonly fr: "âšī¸ : DÊsactivÊ"; - readonly he: "âšī¸ : מנוטרל"; - readonly ja: "âšī¸ : į„ĄåŠš"; - readonly ko: "âšī¸ : ëš„í™œė„ąí™”ë¨"; - readonly ru: "âšī¸ : ĐžŅ‚ĐēĐģŅŽŅ‡ĐĩĐŊĐž"; - readonly zh: "âšī¸īŧšåˇ˛įρᔍ"; - readonly "zh-tw": "âšī¸ : åˇ˛åœį”¨"; - }; - readonly "obsidianLiveSyncSettingTab.labelEnabled": { - readonly def: "🔁 : Enabled"; - readonly es: "🔁 : Activado"; - readonly fr: "🔁 : ActivÊ"; - readonly he: "🔁 : מופ×ĸל"; - readonly ja: "🔁 : 有劚"; - readonly ko: "🔁 : í™œė„ąí™”ë¨"; - readonly ru: "🔁 : ВĐēĐģŅŽŅ‡ĐĩĐŊĐž"; - readonly zh: "🔁īŧšåˇ˛å¯į”¨"; - readonly "zh-tw": "🔁 : åˇ˛å•Ÿį”¨"; - }; - readonly "obsidianLiveSyncSettingTab.levelAdvanced": { - readonly def: " (Advanced)"; - readonly es: " (avanzado)"; - readonly fr: " (AvancÊ)"; - readonly he: " (מ×Ēקדם)"; - readonly ja: " (䏊ᴚ)"; - readonly ko: " (溠揉)"; - readonly ru: " (Đ Đ°ŅŅˆĐ¸Ņ€ĐĩĐŊĐŊŅ‹Đĩ)"; - readonly zh: "(čŋ›é˜ļ)"; - }; - readonly "obsidianLiveSyncSettingTab.levelEdgeCase": { - readonly def: " (Edge Case)"; - readonly es: " (excepciÃŗn)"; - readonly fr: " (Cas particulier)"; - readonly he: " (מקרה ×§×Ļה)"; - readonly ja: " (ã‚¨ãƒƒã‚¸ã‚ąãƒŧ゚)"; - readonly ko: " (íŠšėˆ˜ ė‚Ŧ례)"; - readonly ru: " (Đ“Ņ€Đ°ĐŊĐ¸Ņ‡ĐŊŅ‹Đĩ ҁĐģŅƒŅ‡Đ°Đ¸)"; - readonly zh: "(čžšįŧ˜æƒ…å†ĩ)"; - }; - readonly "obsidianLiveSyncSettingTab.levelPowerUser": { - readonly def: " (Power User)"; - readonly es: " (experto)"; - readonly fr: " (Utilisateur avancÊ)"; - readonly he: " (מ׊×Ēמ׊ מ×Ēקדם)"; - readonly ja: " (エキ゚パãƒŧト)"; - readonly ko: " (íŒŒė›Œ ėœ ė €)"; - readonly ru: " (ОĐŋҋ҂ĐŊŅ‹Đš ĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ĐĩĐģҌ)"; - readonly zh: "(é̘įē§į”¨æˆˇ)"; - }; - readonly "obsidianLiveSyncSettingTab.linkOpenInBrowser": { - readonly def: "Open in browser"; - readonly es: "Abrir en el navegador"; - readonly fr: "Ouvrir dans le navigateur"; - readonly he: "פ×Ēח בדפדפן"; - readonly ja: "ブナã‚Ļã‚ļで開く"; - readonly ko: "브ëŧėš°ė €ė—ė„œ 뗴揰"; - readonly ru: "ĐžŅ‚ĐēŅ€Ņ‹Ņ‚ŅŒ в ĐąŅ€Đ°ŅƒĐˇĐĩŅ€Đĩ"; - readonly zh: "在æĩč§ˆå™¨ä¸­æ‰“åŧ€"; - }; - readonly "obsidianLiveSyncSettingTab.linkPageTop": { - readonly def: "Page Top"; - readonly es: "Ir arriba"; - readonly fr: "Haut de la page"; - readonly he: "רא׊ ה×ĸמוד"; - readonly ja: "ペãƒŧジトップ"; - readonly ko: "íŽ˜ė´ė§€ ėƒë‹¨"; - readonly ru: "В ĐŊĐ°Ņ‡Đ°ĐģĐž ŅŅ‚Ņ€Đ°ĐŊĐ¸Ņ†Ņ‹"; - readonly zh: "éĄĩéĸéĄļ部"; - }; - readonly "obsidianLiveSyncSettingTab.linkTipsAndTroubleshooting": { - readonly def: "Tips and Troubleshooting"; - readonly es: "Consejos y soluciÃŗn de problemas"; - readonly fr: "Conseils et dÊpannage"; - readonly he: "טיפים ופ×Ēרון ב×ĸיו×Ē"; - readonly ja: "ãƒ’ãƒŗãƒˆã¨ãƒˆãƒŠãƒ–ãƒĢã‚ˇãƒĨãƒŧãƒ†ã‚Ŗãƒŗã‚°"; - readonly ko: "팁 및 ëŦ¸ė œ 해결"; - readonly ru: "ХОвĐĩ҂ҋ и ŅƒŅŅ‚Ņ€Đ°ĐŊĐĩĐŊиĐĩ ĐŊĐĩĐŋĐžĐģадОĐē"; - readonly zh: "提į¤ē和故障排除"; - }; - readonly "obsidianLiveSyncSettingTab.linkTroubleshooting": { - readonly def: "/docs/troubleshooting.md"; - readonly es: "/docs/es/troubleshooting.md"; - readonly fr: "/docs/troubleshooting.md"; - readonly he: "/docs/troubleshooting.md"; - readonly ja: "/docs/troubleshooting.md"; - readonly ko: "/docs/troubleshooting.md"; - readonly ru: "/docs/troubleshooting.md"; - readonly zh: "/docs/troubleshooting.md"; - }; - readonly "obsidianLiveSyncSettingTab.logCannotUseCloudant": { - readonly def: "This feature cannot be used with IBM Cloudant."; - readonly es: "Esta funciÃŗn no se puede utilizar con IBM Cloudant."; - readonly fr: "Cette fonctionnalitÊ ne peut pas ÃĒtre utilisÊe avec IBM Cloudant."; - readonly he: "לא ני×Ēן להש×Ēמ׊ ב×Ēכונה זו ×ĸם IBM Cloudant."; - readonly ja: "こぎ抟čƒŊはIBM CloudantではäŊŋį”¨ã§ããžã›ã‚“ã€‚"; - readonly ko: "ė´ 기ëŠĨė€ IBM Cloudant뙀 함ęģ˜ ė‚ŦėšŠí•  눘 ė—†ėŠĩ니다."; - readonly ru: "Đ­Ņ‚Đ° Ņ„ŅƒĐŊĐēŅ†Đ¸Ņ ĐŊĐĩĐ´ĐžŅŅ‚ŅƒĐŋĐŊа Đ´ĐģŅ IBM Cloudant."; - readonly zh: "此功čƒŊ不čƒŊ与 IBM Cloudant 一čĩˇäŊŋᔍ "; - }; - readonly "obsidianLiveSyncSettingTab.logCheckingConfigDone": { - readonly def: "Checking configuration done"; - readonly es: "VerificaciÃŗn de configuraciÃŗn completada"; - readonly fr: "VÊrification de la configuration terminÊe"; - readonly he: "בדיק×Ē ×”×Ē×Ļורה הושלמה"; - readonly ja: "č¨­åŽšãŽįĸēčĒãŒåŽŒäē†ã—ぞした"; - readonly ko: "ęĩŦė„ą í™•ė¸ ė™„ëŖŒ"; - readonly ru: "ĐŸŅ€ĐžĐ˛ĐĩŅ€Đēа ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Đ¸ СавĐĩŅ€ŅˆĐĩĐŊа"; - readonly zh: "配įŊŽæŖ€æŸĨ厌成"; - }; - readonly "obsidianLiveSyncSettingTab.logCheckingConfigFailed": { - readonly def: "Checking configuration failed"; - readonly es: "La verificaciÃŗn de configuraciÃŗn fallÃŗ"; - readonly fr: "Échec de la vÊrification de la configuration"; - readonly he: "בדיק×Ē ×”×Ē×Ļורה נכשלה"; - readonly ja: "č¨­åŽšãŽįĸēčĒãĢå¤ąæ•—ã—ãžã—ãŸ"; - readonly ko: "ęĩŦė„ą í™•ė¸ ė‹¤íŒ¨"; - readonly ru: "ĐŸŅ€ĐžĐ˛ĐĩŅ€Đēа ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Đ¸ ĐŊĐĩ ŅƒĐ´Đ°ĐģĐ°ŅŅŒ"; - readonly zh: "配įŊŽæŖ€æŸĨå¤ąč´Ĩ"; - }; - readonly "obsidianLiveSyncSettingTab.logCheckingDbConfig": { - readonly def: "Checking database configuration"; - readonly es: "Verificando la configuraciÃŗn de la base de datos"; - readonly fr: "VÊrification de la configuration de la base"; - readonly he: "בודק ×Ē×Ļור×Ē ×ž×Ą×“ × ×Ēונים"; - readonly ja: "デãƒŧã‚ŋベãƒŧã‚šč¨­åŽšã‚’įĸēčĒä¸­"; - readonly ko: "ë°ė´í„°ë˛ ė´ėŠ¤ ęĩŦė„ą í™•ė¸ 뤑"; - readonly ru: "ĐŸŅ€ĐžĐ˛ĐĩŅ€Đēа ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Đ¸ ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅"; - readonly zh: "æ­Ŗåœ¨æŖ€æŸĨ数捎åē“配įŊŽ"; - }; - readonly "obsidianLiveSyncSettingTab.logCheckPassphraseFailed": { - readonly def: "ERROR: Failed to check passphrase with the remote server:\n${db}."; - readonly es: "ERROR: Error al comprobar la frase de contraseÃąa con el servidor remoto: \n${db}."; - readonly fr: "ERREUR : Échec de la vÊrification de la phrase secrète avec le serveur distant :\n${db}."; - readonly he: "שגיאה: בדיק×Ē ×‘×™×˜×•×™ הסיסמה ×ĸם השר×Ē ×”×ž×¨×•×—×“ נכשלה:\n${db}."; - readonly ja: "エナãƒŧ: ãƒĒãƒĸãƒŧトã‚ĩãƒŧバãƒŧとぎパ゚フãƒŦãƒŧã‚ēįĸēčĒãĢå¤ąæ•—ã—ãžã—ãŸ:\n${db}。"; - readonly ko: "똤ëĨ˜: ė›ę˛Š ė„œë˛„ė™€ íŒ¨ėŠ¤í”„ë ˆė´ėψ í™•ė¸ė— ė‹¤íŒ¨í–ˆėŠĩ니다: \n${db}."; - readonly ru: "ОШИБКА: НĐĩ ŅƒĐ´Đ°ĐģĐžŅŅŒ ĐŋŅ€ĐžĐ˛ĐĩŅ€Đ¸Ņ‚ŅŒ ĐŋĐ°Ņ€ĐžĐģҌ ҁ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊŅ‹Đŧ ҁĐĩŅ€Đ˛ĐĩŅ€ĐžĐŧ: db."; - readonly zh: "错蝝īŧšæ— æŗ•äŊŋᔍčŋœį¨‹æœåŠĄå™¨æŖ€æŸĨ坆᠁īŧš\n${db} "; - }; - readonly "obsidianLiveSyncSettingTab.logConfiguredDisabled": { - readonly def: "Configured synchronization mode: DISABLED"; - readonly es: "Modo de sincronizaciÃŗn configurado: DESACTIVADO"; - readonly fr: "Mode de synchronisation configurÊ : DÉSACTIVÉ"; - readonly he: "מ×Ļב סנכרון שהוגדר: מנוטרל"; - readonly ja: "č¨­åŽšã•ã‚ŒãŸåŒæœŸãƒĸãƒŧド: į„ĄåŠš"; - readonly ko: "ęĩŦė„ąëœ 동기화 ëĒ¨ë“œ: ëš„í™œė„ąí™”ë¨"; - readonly ru: "ĐĐ°ŅŅ‚Ņ€ĐžĐĩĐŊĐŊŅ‹Đš Ņ€ĐĩĐļиĐŧ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸: ОĐĸКЛЮЧЕН"; - readonly zh: "åˇ˛é…įŊŽįš„同æ­Ĩæ¨Ąåŧīŧšåˇ˛įρᔍ"; - readonly "zh-tw": "åˇ˛č¨­åŽšįš„åŒæ­Ĩæ¨Ąåŧīŧšåˇ˛åœį”¨"; - }; - readonly "obsidianLiveSyncSettingTab.logConfiguredLiveSync": { - readonly def: "Configured synchronization mode: LiveSync"; - readonly es: "Modo de sincronizaciÃŗn configurado: SincronizaciÃŗn en Vivo"; - readonly fr: "Mode de synchronisation configurÊ : LiveSync"; - readonly he: "מ×Ļב סנכרון שהוגדר: LiveSync"; - readonly ja: "č¨­åŽšã•ã‚ŒãŸåŒæœŸãƒĸãƒŧド: LiveSync"; - readonly ko: "ęĩŦė„ąëœ 동기화 ëĒ¨ë“œ: LiveSync"; - readonly ru: "ĐĐ°ŅŅ‚Ņ€ĐžĐĩĐŊĐŊŅ‹Đš Ņ€ĐĩĐļиĐŧ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸: LiveSync"; - readonly zh: "åˇ˛é…įŊŽįš„同æ­Ĩæ¨ĄåŧīŧšLiveSync"; - readonly "zh-tw": "åˇ˛č¨­åŽšįš„åŒæ­Ĩæ¨ĄåŧīŧšLiveSync"; - }; - readonly "obsidianLiveSyncSettingTab.logConfiguredPeriodic": { - readonly def: "Configured synchronization mode: Periodic"; - readonly es: "Modo de sincronizaciÃŗn configurado: PeriÃŗdico"; - readonly fr: "Mode de synchronisation configurÊ : PÊriodique"; - readonly he: "מ×Ļב סנכרון שהוגדר: ×Ēקופ×Ēי"; - readonly ja: "č¨­åŽšã•ã‚ŒãŸåŒæœŸãƒĸãƒŧド: 厚期"; - readonly ko: "ęĩŦė„ąëœ 동기화 ëĒ¨ë“œ: ėŖŧ揰렁"; - readonly ru: "ĐĐ°ŅŅ‚Ņ€ĐžĐĩĐŊĐŊŅ‹Đš Ņ€ĐĩĐļиĐŧ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸: ПĐĩŅ€Đ¸ĐžĐ´Đ¸Ņ‡ĐĩҁĐēиК"; - readonly zh: "åˇ˛é…įŊŽįš„同æ­Ĩæ¨ĄåŧīŧšåŽšæœŸåŒæ­Ĩ"; - readonly "zh-tw": "åˇ˛č¨­åŽšįš„åŒæ­Ĩæ¨ĄåŧīŧšåŽšæœŸåŒæ­Ĩ"; - }; - readonly "obsidianLiveSyncSettingTab.logCouchDbConfigFail": { - readonly def: "CouchDB Configuration: ${title} failed"; - readonly es: "ConfiguraciÃŗn de CouchDB: ${title} fallÃŗ"; - readonly fr: "Configuration CouchDB : Êchec de ${title}"; - readonly he: "×Ē×Ļור×Ē CouchDB: ${title} נכשלה"; - readonly ja: "CouchDBč¨­åŽš: ${title} å¤ąæ•—"; - readonly ko: "CouchDB ęĩŦė„ą: ${title} ė‹¤íŒ¨"; - readonly ru: "КоĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Ņ CouchDB: title ĐŊĐĩ ŅƒĐ´Đ°ĐģĐ°ŅŅŒ"; - readonly zh: "CouchDB 配įŊŽīŧš${title} å¤ąč´Ĩ"; - }; - readonly "obsidianLiveSyncSettingTab.logCouchDbConfigSet": { - readonly def: "CouchDB Configuration: ${title} -> Set ${key} to ${value}"; - readonly es: "ConfiguraciÃŗn de CouchDB: ${title} -> Establecer ${key} en ${value}"; - readonly fr: "Configuration CouchDB : ${title} -> ${key} dÊfini à ${value}"; - readonly he: "×Ē×Ļור×Ē CouchDB: ${title} -> הגדר ${key} ל-${value}"; - readonly ja: "CouchDBč¨­åŽš: ${title} -> ${key}を${value}ãĢč¨­åŽš"; - readonly ko: "CouchDB ęĩŦė„ą: ${title} -> ${key}ëĨŧ ${value}로 네렕"; - readonly ru: "КоĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Ņ CouchDB: title -> ĐŖŅŅ‚Đ°ĐŊĐžĐ˛Đ¸Ņ‚ŅŒ key в value"; - readonly zh: "CouchDB 配įŊŽīŧš${title} -> 莞įŊŽ ${key} ä¸ē ${value}"; - }; - readonly "obsidianLiveSyncSettingTab.logCouchDbConfigUpdated": { - readonly def: "CouchDB Configuration: ${title} successfully updated"; - readonly es: "ConfiguraciÃŗn de CouchDB: ${title} actualizado correctamente"; - readonly fr: "Configuration CouchDB : ${title} mise à jour avec succès"; - readonly he: "×Ē×Ļור×Ē CouchDB: ${title} ×ĸודכנה בה×Ļלחה"; - readonly ja: "CouchDBč¨­åŽš: ${title} æ­Ŗå¸¸ãĢ更新されぞした"; - readonly ko: "CouchDB ęĩŦė„ą: ${title} ė„ąęŗĩ렁ėœŧ로 ė—…ë°ė´íŠ¸ë¨"; - readonly ru: "КоĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Ņ CouchDB: title ҃ҁĐŋĐĩ҈ĐŊĐž ОйĐŊОвĐģĐĩĐŊа"; - readonly zh: "CouchDB 配įŊŽīŧš${title} 成功更新"; - }; - readonly "obsidianLiveSyncSettingTab.logDatabaseConnected": { - readonly def: "Database connected"; - readonly es: "Base de datos conectada"; - readonly fr: "Base de donnÊes connectÊe"; - readonly he: "מסד הנ×Ēונים מחובר"; - readonly ja: "デãƒŧã‚ŋベãƒŧ゚ãĢæŽĨįļšã—ぞした"; - readonly ko: "ë°ė´í„°ë˛ ė´ėŠ¤ ė—°ę˛°ë¨"; - readonly ru: "База даĐŊĐŊҋ҅ ĐŋОдĐēĐģŅŽŅ‡ĐĩĐŊа"; - readonly zh: "数捎åē“厞čŋžæŽĨ"; - }; - readonly "obsidianLiveSyncSettingTab.logEncryptionNoPassphrase": { - readonly def: "You cannot enable encryption without a passphrase"; - readonly es: "No puedes habilitar el cifrado sin una frase de contraseÃąa"; - readonly fr: "Impossible d'activer le chiffrement sans phrase secrète"; - readonly he: "לא ני×Ēן להפ×ĸיל ה×Ļפנה ללא ביטוי סיסמה"; - readonly ja: "パ゚フãƒŦãƒŧã‚ēãĒã—ã§ã¯æš—åˇåŒ–ã‚’æœ‰åŠšãĢできぞせん"; - readonly ko: "íŒ¨ėŠ¤í”„ë ˆė´ėψ ė—†ė´ëŠ” ė•”í˜¸í™”ëĨŧ í™œė„ąí™”í•  눘 ė—†ėŠĩ니다"; - readonly ru: "Đ’Ņ‹ ĐŊĐĩ ĐŧĐžĐļĐĩŅ‚Đĩ вĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒ ŅˆĐ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊиĐĩ ĐąĐĩС ĐŋĐ°Ņ€ĐžĐģҌĐŊОК Ņ„Ņ€Đ°ĐˇŅ‹"; - readonly zh: "æ˛Ąæœ‰å¯†į æ— æŗ•å¯į”¨åŠ å¯†"; - }; - readonly "obsidianLiveSyncSettingTab.logEncryptionNoSupport": { - readonly def: "Your device does not support encryption."; - readonly es: "Tu dispositivo no admite el cifrado."; - readonly fr: "Votre appareil ne prend pas en charge le chiffrement."; - readonly he: "המכשיר שלך אינו ×Ēומך בה×Ļפנה."; - readonly ja: "おäŊŋã„ãŽãƒ‡ãƒã‚¤ã‚šã¯æš—åˇåŒ–ã‚’ã‚ĩポãƒŧトしãĻいぞせん。"; - readonly ko: "기기가 ė•”í˜¸í™”ëĨŧ ė§€ė›í•˜ė§€ ė•ŠėŠĩ니다."; - readonly ru: "Đ’Đ°ŅˆĐĩ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đž ĐŊĐĩ ĐŋОддĐĩŅ€ĐļиваĐĩŅ‚ ŅˆĐ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊиĐĩ."; - readonly zh: "æ‚¨įš„čŽžå¤‡ä¸æ”¯æŒåŠ å¯† "; - }; - readonly "obsidianLiveSyncSettingTab.logErrorOccurred": { - readonly def: "An error occurred!!"; - readonly es: "ÂĄOcurriÃŗ un error!"; - readonly fr: "Une erreur s'est produite !!"; - readonly he: "איר×ĸה שגיאה!!"; - readonly ja: "エナãƒŧがį™ēį”Ÿã—ãžã—ãŸīŧīŧ"; - readonly ko: "똤ëĨ˜ę°€ ë°œėƒí–ˆėŠĩ니다!"; - readonly ru: "ĐŸŅ€ĐžĐ¸ĐˇĐžŅˆĐģа ĐžŅˆĐ¸ĐąĐēа!!"; - readonly zh: "å‘į”Ÿé”™č¯¯īŧīŧ"; - }; - readonly "obsidianLiveSyncSettingTab.logEstimatedSize": { - readonly def: "Estimated size: ${size}"; - readonly es: "TamaÃąo estimado: ${size}"; - readonly fr: "Taille estimÊe : ${size}"; - readonly he: "גודל משו×ĸר: ${size}"; - readonly ja: "推厚ã‚ĩイã‚ē: ${size}"; - readonly ko: "똈냁 íŦ기: ${size}"; - readonly ru: "ĐŸŅ€Đ¸ĐŧĐĩŅ€ĐŊŅ‹Đš Ņ€Đ°ĐˇĐŧĐĩŅ€: size"; - readonly zh: "äŧ°čŽĄå¤§å°īŧš${size}"; - }; - readonly "obsidianLiveSyncSettingTab.logPassphraseInvalid": { - readonly def: "Passphrase is not valid, please fix it."; - readonly es: "La frase de contraseÃąa no es vÃĄlida, por favor corrígela."; - readonly fr: "La phrase secrète est invalide, veuillez la corriger."; - readonly he: "ביטוי הסיסמה אינו ×Ēקין, אנא ×Ēקן או×Ēו."; - readonly ja: "パ゚フãƒŦãƒŧã‚ēãŒį„ĄåŠšã§ã™ã€äŋŽæ­Ŗã—ãĻください。"; - readonly ko: "íŒ¨ėŠ¤í”„ë ˆė´ėĻˆę°€ ėœ íš¨í•˜ė§€ ė•ŠėŠĩ니다. ėˆ˜ė •í•´ ėŖŧė„¸ėš”."; - readonly ru: "ĐŸĐ°Ņ€ĐžĐģҌĐŊĐ°Ņ Ņ„Ņ€Đ°ĐˇĐ° ĐŊĐĩĐ´ĐĩĐšŅŅ‚Đ˛Đ¸Ņ‚ĐĩĐģҌĐŊа, ĐŋĐžĐļаĐģŅƒĐšŅŅ‚Đ°, Đ¸ŅĐŋŅ€Đ°Đ˛ŅŒŅ‚Đĩ."; - readonly zh: "å¯†į æ— æ•ˆ,蝎äŋŽæ­Ŗ"; - }; - readonly "obsidianLiveSyncSettingTab.logPassphraseNotCompatible": { - readonly def: "ERROR: Passphrase is not compatible with the remote server! Please check it again!"; - readonly es: "ERROR: ÂĄLa frase de contraseÃąa no es compatible con el servidor remoto! ÂĄPor favor, revísala de nuevo!"; - readonly fr: "ERREUR : la phrase secrète n'est pas compatible avec le serveur distant ! Veuillez vÊrifier à nouveau !"; - readonly he: "שגיאה: ביטוי הסיסמה אינו ×Ēואם לשר×Ē ×”×ž×¨×•×—×“! אנא בדוק שוב!"; - readonly ja: "エナãƒŧ: パ゚フãƒŦãƒŧã‚ēがãƒĒãƒĸãƒŧトã‚ĩãƒŧバãƒŧと遊合しぞせんīŧå†åēĻįĸēčĒã—ãĻくださいīŧ"; - readonly ko: "똤ëĨ˜: íŒ¨ėŠ¤í”„ë ˆė´ėĻˆę°€ ė›ę˛Š ė„œë˛„ė™€ í˜¸í™˜ë˜ė§€ ė•ŠėŠĩ니다! ë‹¤ė‹œ í™•ė¸í•´ ėŖŧė„¸ėš”!"; - readonly ru: "ОШИБКА: ĐŸĐ°Ņ€ĐžĐģҌĐŊĐ°Ņ Ņ„Ņ€Đ°ĐˇĐ° ĐŊĐĩŅĐžĐ˛ĐŧĐĩŅŅ‚Đ¸Đŧа ҁ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊŅ‹Đŧ ҁĐĩŅ€Đ˛ĐĩŅ€ĐžĐŧ!"; - readonly zh: "错蝝īŧšå¯†į ä¸Žčŋœį¨‹æœåŠĄå™¨ä¸å…ŧ厚īŧč¯ˇå†æŦĄæŖ€æŸĨīŧ"; - }; - readonly "obsidianLiveSyncSettingTab.logRebuildNote": { - readonly def: "Syncing has been disabled, fetch and re-enabled if desired."; - readonly es: "La sincronizaciÃŗn ha sido desactivada, obtÊn y vuelve a activar si lo deseas."; - readonly fr: "La synchronisation a ÊtÊ dÊsactivÊe, rÊcupÊrez et rÊactivez si souhaitÊ."; - readonly he: "הסנכרון הושב×Ē, משוך והפ×ĸל מחדש אם ר×Ļוי."; - readonly ja: "åŒæœŸãŒį„ĄåŠšãĢãĒりぞした。åŋ…čρãĢåŋœã˜ãĻフェッチしãĻ再有劚化しãĻください。"; - readonly ko: "동기화가 ëš„í™œė„ąí™”ë˜ė—ˆėŠĩ니다. ė›í•˜ëŠ” ę˛Ŋ뚰 ę°€ė ¸ė˜¤ę¸° 후 ë‹¤ė‹œ í™œė„ąí™”í•˜ė„¸ėš”."; - readonly ru: "ХиĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Ņ ĐžŅ‚ĐēĐģŅŽŅ‡ĐĩĐŊа, ĐˇĐ°ĐŗŅ€ŅƒĐˇĐ¸Ņ‚Đĩ и вĐēĐģŅŽŅ‡Đ¸Ņ‚Đĩ ҁĐŊОва ĐŋŅ€Đ¸ ĐļĐĩĐģаĐŊии."; - readonly zh: "同æ­Ĩ厞įρᔍ,åĻ‚æžœéœ€čρ,č¯ˇčŽˇå–åšļé‡æ–°å¯į”¨"; - }; - readonly "obsidianLiveSyncSettingTab.logSelectAnyPreset": { - readonly def: "Select any preset."; - readonly es: "Selecciona cualquier preestablecido."; - readonly fr: "SÊlectionnez un prÊrÊglage."; - readonly he: "בחר קבי×ĸה מרא׊ כלשהי."; - readonly ja: "プãƒĒã‚ģットを選択しãĻください。"; - readonly ko: "프ëĻŦė…‹ė„ ė„ íƒí•˜ė„¸ėš”."; - readonly ru: "Đ’Ņ‹ĐąĐĩŅ€Đ¸Ņ‚Đĩ ĐģŅŽĐąĐžĐš ĐŋŅ€ĐĩҁĐĩŅ‚."; - readonly zh: "č¯ˇé€‰æ‹Šäģģ一éĸ„čŽžã€‚"; - readonly "zh-tw": "čĢ‹é¸æ“‡äģģä¸€é č¨­é …į›Žã€‚"; - }; - readonly "obsidianLiveSyncSettingTab.logServerConfigurationCheck": { - readonly def: "obsidianLiveSyncSettingTab.logServerConfigurationCheck"; - }; - readonly "obsidianLiveSyncSettingTab.msgAreYouSureProceed": { - readonly def: "Are you sure to proceed?"; - readonly es: "ÂŋEstÃĄs seguro de proceder?"; - readonly fr: "Êtes-vous sÃģr de vouloir continuer ?"; - readonly he: "האם א×Ēה בטוח שבר×Ļונך להמשיך?"; - readonly ja: "æœŦåŊ“ãĢįļščĄŒã—ぞすかīŧŸ"; - readonly ko: "ė •ë§ëĄœ ė§„í–‰í•˜ė‹œę˛ ėŠĩ니까?"; - readonly ru: "Đ’Ņ‹ ŅƒĐ˛ĐĩŅ€ĐĩĐŊŅ‹, Ņ‡Ņ‚Đž Ņ…ĐžŅ‚Đ¸Ņ‚Đĩ ĐŋŅ€ĐžĐ´ĐžĐģĐļĐ¸Ņ‚ŅŒ?"; - readonly zh: "æ‚¨įĄŽåŽščρįģ§įģ­å—īŧŸ"; - }; - readonly "obsidianLiveSyncSettingTab.msgChangesNeedToBeApplied": { - readonly def: "Changes need to be applied!"; - readonly es: "ÂĄLos cambios deben aplicarse!"; - readonly fr: "Des modifications doivent ÃĒtre appliquÊes !"; - readonly he: "יש להחיל שינויים!"; - readonly ja: "å¤‰æ›´ã‚’éŠį”¨ã™ã‚‹åŋ…čĻãŒã‚ã‚Šãžã™īŧ"; - readonly ko: "ëŗ€ę˛Ŋė‚Ŧí•­ė„ ė ėšŠí•´ė•ŧ 합니다!"; - readonly ru: "ИСĐŧĐĩĐŊĐĩĐŊĐ¸Ņ ĐŊ҃ĐļĐŊĐž ĐŋŅ€Đ¸ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ!"; - readonly zh: "需čρåē”į”¨æ›´æ”šīŧ"; - }; - readonly "obsidianLiveSyncSettingTab.msgConfigCheck": { - readonly def: "--Config check--"; - readonly es: "--VerificaciÃŗn de configuraciÃŗn--"; - readonly fr: "--VÊrification de la configuration--"; - readonly he: "--בדיק×Ē ×Ē×Ļורה--"; - readonly ja: "--č¨­åŽšįĸēčĒ--"; - readonly ko: "--ęĩŦė„ą í™•ė¸--"; - readonly ru: "--ĐŸŅ€ĐžĐ˛ĐĩŅ€Đēа ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Đ¸--"; - readonly zh: "--配įŊŽæŖ€æŸĨ--"; - }; - readonly "obsidianLiveSyncSettingTab.msgConfigCheckFailed": { - readonly def: "The configuration check has failed. Do you want to continue anyway?"; - readonly es: "La verificaciÃŗn de configuraciÃŗn ha fallado. ÂŋQuieres continuar de todos modos?"; - readonly fr: "La vÊrification de la configuration a ÊchouÊ. Voulez-vous continuer malgrÊ tout ?"; - readonly he: "בדיק×Ē ×”×Ē×Ļורה נכשלה. האם בר×Ļונך להמשיך בכל זא×Ē?"; - readonly ja: "č¨­åŽšįĸēčĒãĢå¤ąæ•—ã—ãžã—ãŸã€‚ãã‚Œã§ã‚‚įļščĄŒã—ぞすかīŧŸ"; - readonly ko: "ęĩŦė„ą í™•ė¸ė— ė‹¤íŒ¨í–ˆėŠĩ니다. 그래도 ęŗ„ė†í•˜ė‹œę˛ ėŠĩ니까?"; - readonly ru: "ĐŸŅ€ĐžĐ˛ĐĩŅ€Đēа ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Đ¸ ĐŊĐĩ ŅƒĐ´Đ°ĐģĐ°ŅŅŒ. Đ’Ņ‹ Đ˛ŅŅ‘ Ņ€Đ°Đ˛ĐŊĐž Ņ…ĐžŅ‚Đ¸Ņ‚Đĩ ĐŋŅ€ĐžĐ´ĐžĐģĐļĐ¸Ņ‚ŅŒ?"; - readonly zh: "配įŊŽæŖ€æŸĨå¤ąč´Ĩ。äģčρįģ§įģ­å—īŧŸ"; - readonly "zh-tw": "č¨­åŽšæĒĸæŸĨå¤ąæ•—ã€‚äģčρįšŧįēŒå—ŽīŧŸ"; - }; - readonly "obsidianLiveSyncSettingTab.msgConnectionCheck": { - readonly def: "--Connection check--"; - readonly es: "--VerificaciÃŗn de conexiÃŗn--"; - readonly fr: "--VÊrification de la connexion--"; - readonly he: "--בדיק×Ē ×—×™×‘×•×¨--"; - readonly ja: "--æŽĨįļšįĸēčĒ--"; - readonly ko: "--뗰枰 í™•ė¸--"; - readonly ru: "--ĐŸŅ€ĐžĐ˛ĐĩŅ€Đēа ĐŋОдĐēĐģŅŽŅ‡ĐĩĐŊĐ¸Ņ--"; - readonly zh: "--čŋžæŽĨæŖ€æŸĨ--"; - }; - readonly "obsidianLiveSyncSettingTab.msgConnectionProxyNote": { - readonly def: "If you're having trouble with the Connection-check (even after checking config), please check your reverse proxy configuration."; - readonly es: "Si tienes problemas con la verificaciÃŗn de conexiÃŗn (incluso despuÊs de verificar la configuraciÃŗn), por favor verifica la configuraciÃŗn de tu proxy reverso."; - readonly fr: "Si vous rencontrez des problèmes de vÊrification de connexion (mÃĒme après avoir vÊrifiÊ la configuration), veuillez vÊrifier votre configuration de reverse proxy."; - readonly he: "אם א×Ēה × ×Ēקל בב×ĸיו×Ē ×ĸם בדיק×Ē ×”×—×™×‘×•×¨ (גם לאחר בדיק×Ē ×”×Ē×Ļורה), אנא בדוק א×Ē ×”×’×“×¨×•×Ē ×”-reverse proxy שלך."; - readonly ja: "č¨­åŽšįĸēčĒåžŒã‚‚æŽĨįļšįĸēčĒãĢå•éĄŒãŒã‚ã‚‹å ´åˆã¯ã€ãƒĒバãƒŧã‚šãƒ—ãƒ­ã‚­ã‚ˇãŽč¨­åŽšã‚’įĸēčĒã—ãĻください。"; - readonly ko: "ęĩŦė„ą í™•ė¸ í›„ė—ë„ 뗰枰 í™•ė¸ė— ëŦ¸ė œę°€ ėžˆëŠ” ę˛Ŋ뚰, ëĻŦë˛„ėŠ¤ í”„ëĄė‹œ ęĩŦė„ąė„ í™•ė¸í•´ ėŖŧė„¸ėš”."; - readonly ru: "Đ•ŅĐģи ҃ Đ˛Đ°Ņ ĐŋŅ€ĐžĐąĐģĐĩĐŧŅ‹ ҁ ĐŋŅ€ĐžĐ˛ĐĩŅ€ĐēОК ĐŋОдĐēĐģŅŽŅ‡ĐĩĐŊĐ¸Ņ, ĐŋŅ€ĐžĐ˛ĐĩŅ€ŅŒŅ‚Đĩ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸ŅŽ ĐžĐąŅ€Đ°Ņ‚ĐŊĐžĐŗĐž ĐŋŅ€ĐžĐēŅĐ¸."; - readonly zh: "åĻ‚æžœæ‚¨åœ¨čŋžæŽĨæŖ€æŸĨæ—ļ遇到闎éĸ˜(åŗäŊŋæŖ€æŸĨäē†é…įŊŽåŽ),č¯ˇæŖ€æŸĨæ‚¨įš„åå‘äģŖį†é…įŊŽ"; - }; - readonly "obsidianLiveSyncSettingTab.msgCurrentOrigin": { - readonly def: "Current origin: ${origin}"; - readonly es: "Origen actual: {origin}"; - readonly fr: "Origine actuelle : ${origin}"; - readonly he: "מקור נוכחי: ${origin}"; - readonly ja: "įžåœ¨ãŽã‚ĒãƒĒã‚¸ãƒŗ: ${origin}"; - readonly ko: "현ėžŦ 뛐렐: {origin}"; - readonly ru: "ĐĸĐĩĐēŅƒŅ‰Đ¸Đš origin: origin"; - readonly zh: "åŊ“前æē: {origin}"; - }; - readonly "obsidianLiveSyncSettingTab.msgDiscardConfirmation": { - readonly def: "Do you really want to discard existing settings and databases?"; - readonly es: "ÂŋRealmente deseas descartar las configuraciones y bases de datos existentes?"; - readonly fr: "Voulez-vous vraiment abandonner les paramètres et bases existants ?"; - readonly he: "האם א×Ēה בטוח שבר×Ļונך לבטל הגדרו×Ē ×•×ž×Ą×“×™ × ×Ēונים קיימים?"; - readonly ja: "æœŦåŊ“ãĢæ—ĸå­˜ãŽč¨­åŽšã¨ãƒ‡ãƒŧã‚ŋベãƒŧã‚šã‚’į ´æŖ„ã—ãžã™ã‹īŧŸ"; - readonly ko: "ė •ë§ëĄœ ę¸°ėĄ´ 네렕ęŗŧ ë°ė´í„°ë˛ ė´ėŠ¤ëĨŧ ė‚­ė œí•˜ė‹œę˛ ėŠĩ니까?"; - readonly ru: "Đ’Ņ‹ Đ´ĐĩĐšŅŅ‚Đ˛Đ¸Ņ‚ĐĩĐģҌĐŊĐž Ņ…ĐžŅ‚Đ¸Ņ‚Đĩ ĐžŅ‚ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ ŅŅƒŅ‰ĐĩŅŅ‚Đ˛ŅƒŅŽŅ‰Đ¸Đĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи и ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅?"; - readonly zh: "æ‚¨įœŸįš„čρä¸ĸåŧƒįŽ°æœ‰įš„čŽžįŊŽå’Œæ•°æŽåē“吗īŧŸ"; - }; - readonly "obsidianLiveSyncSettingTab.msgDone": { - readonly def: "--Done--"; - readonly es: "--Hecho--"; - readonly fr: "--TerminÊ--"; - readonly he: "--הס×Ēיים--"; - readonly ja: "--厌äē†--"; - readonly ko: "--ė™„ëŖŒ--"; - readonly ru: "--Đ“ĐžŅ‚ĐžĐ˛Đž--"; - readonly zh: "--厌成--"; - }; - readonly "obsidianLiveSyncSettingTab.msgEnableCors": { - readonly def: "Set httpd.enable_cors"; - readonly es: "Configurar httpd.enable_cors"; - readonly fr: "DÊfinir httpd.enable_cors"; - readonly he: "הגדר httpd.enable_cors"; - readonly ja: "httpd.enable_corsã‚’č¨­åŽš"; - readonly ko: "httpd.enable_cors 네렕"; - readonly ru: "ĐŖŅŅ‚Đ°ĐŊĐžĐ˛Đ¸Ņ‚ŅŒ httpd.enable_cors"; - readonly zh: "莞įŊŽ httpd.enable_cors"; - }; - readonly "obsidianLiveSyncSettingTab.msgEnableCorsChttpd": { - readonly def: "Set chttpd.enable_cors"; - readonly fr: "DÊfinir chttpd.enable_cors"; - readonly he: "הגדר chttpd.enable_cors"; - readonly ja: "chttpd.enable_corsã‚’č¨­åŽš"; - readonly ru: "ĐŖŅŅ‚Đ°ĐŊĐžĐ˛Đ¸Ņ‚ŅŒ chttpd.enable_cors"; - readonly zh: "莞įŊŽ chttpd.enable_cors"; - }; - readonly "obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": { - readonly def: "We recommend enabling End-To-End Encryption, and Path Obfuscation. Are you sure you want to continue without encryption?"; - readonly es: "Recomendamos habilitar el cifrado de extremo a extremo y la obfuscaciÃŗn de ruta. ÂŋEstÃĄs seguro de querer continuar sin cifrado?"; - readonly fr: "Nous recommandons d'activer le chiffrement de bout en bout et l'obfuscation des chemins. Êtes-vous sÃģr de vouloir continuer sans chiffrement ?"; - readonly he: "אנו ממלי×Ļים להפ×ĸיל ה×Ļפנה מק×Ļה לק×Ļה וא×Ē ×ĸרפול הנ×Ēיב. האם א×Ēה בטוח שבר×Ļונך להמשיך ללא ה×Ļפנה?"; - readonly ja: "ã‚¨ãƒŗãƒ‰ãƒ„ãƒŧã‚¨ãƒŗãƒ‰æš—åˇåŒ–ã¨ãƒ‘ã‚šé›ŖčĒ­åŒ–ã‚’æœ‰åŠšãĢã™ã‚‹ã“ã¨ã‚’ãŠå‹§ã‚ã—ãžã™ã€‚æš—åˇåŒ–ãĒしでįļščĄŒã—ãĻもよろしいですかīŧŸ"; - readonly ko: "ėĸ…단간 ė•”í˜¸í™”ė™€ ę˛Ŋ로 난독화ëĨŧ í™œė„ąí™”í•˜ëŠ” ę˛ƒė„ ęļŒėžĨ합니다. ė •ë§ëĄœ ė•”í˜¸í™” ė—†ė´ ęŗ„ė†í•˜ė‹œę˛ ėŠĩ니까?"; - readonly ru: "ĐœŅ‹ Ņ€ĐĩĐēĐžĐŧĐĩĐŊĐ´ŅƒĐĩĐŧ вĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒ ҁĐēвОСĐŊĐžĐĩ ŅˆĐ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊиĐĩ. Đ’Ņ‹ ŅƒĐ˛ĐĩŅ€ĐĩĐŊŅ‹, Ņ‡Ņ‚Đž Ņ…ĐžŅ‚Đ¸Ņ‚Đĩ ĐŋŅ€ĐžĐ´ĐžĐģĐļĐ¸Ņ‚ŅŒ ĐąĐĩС ŅˆĐ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊĐ¸Ņ?"; - readonly zh: "åģēčŽŽå¯į”¨į̝到įĢ¯åŠ å¯†å’Œčˇ¯åž„æˇˇæˇ†ã€‚äŊ įĄŽåޚčρ圍æœĒåŠ å¯†įš„æƒ…å†ĩ下įģ§įģ­å—īŧŸ"; - readonly "zh-tw": "我們åģēč­°å•Ÿį”¨įĢ¯å°įĢ¯åŠ å¯†čˆ‡čˇ¯åž‘æˇˇæˇ†ã€‚äŊ įĸē厚čρ圍æœĒåŠ å¯†įš„æƒ…æŗä¸‹įšŧįēŒå—ŽīŧŸ"; - }; - readonly "obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": { - readonly def: "Do you want to fetch the config from the remote server?"; - readonly es: "ÂŋQuieres obtener la configuraciÃŗn del servidor remoto?"; - readonly fr: "Voulez-vous rÊcupÊrer la configuration depuis le serveur distant ?"; - readonly he: "האם בר×Ļונך למשוך א×Ē ×”×Ē×Ļורה מהשר×Ē ×”×ž×¨×•×—×“?"; - readonly ja: "ãƒĒãƒĸãƒŧトã‚ĩãƒŧバãƒŧã‹ã‚‰č¨­åŽšã‚’å–åž—ã—ãžã™ã‹īŧŸ"; - readonly ko: "ė›ę˛Š ė„œë˛„ė—ė„œ ęĩŦė„ąė„ ę°€ė ¸ė˜¤ė‹œę˛ ėŠĩ니까?"; - readonly ru: "Đ’Ņ‹ Ņ…ĐžŅ‚Đ¸Ņ‚Đĩ ĐˇĐ°ĐŗŅ€ŅƒĐˇĐ¸Ņ‚ŅŒ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸ŅŽ ҁ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐŗĐž ҁĐĩŅ€Đ˛ĐĩŅ€Đ°?"; - readonly zh: "čρäģŽčŋœįĢ¯æœåŠĄå™¨čŽˇå–é…įŊŽå—īŧŸ"; - readonly "zh-tw": "čĻåžžé į̝äŧ翜å™¨æŠ“å–č¨­åŽšå—ŽīŧŸ"; - }; - readonly "obsidianLiveSyncSettingTab.msgGenerateSetupURI": { - readonly def: "All done! Do you want to generate a setup URI to set up other devices?"; - readonly es: "ÂĄTodo listo! ÂŋQuieres generar un URI de configuraciÃŗn para configurar otros dispositivos?"; - readonly fr: "Tout est prÃĒt ! Voulez-vous gÊnÊrer une URI de configuration pour configurer d'autres appareils ?"; - readonly he: "הכל מוכן! האם בר×Ļונך ליי×Ļר Setup URI להגדר×Ē ×ž×›×Š×™×¨×™× אחרים?"; - readonly ja: "厌äē†īŧäģ–ぎデバイ゚をã‚ģットã‚ĸップするためぎã‚ģットã‚ĸップURIã‚’į”Ÿæˆã—ãžã™ã‹īŧŸ"; - readonly ko: "ëĒ¨ë“  ėž‘ė—…ė´ ė™„ëŖŒë˜ė—ˆėŠĩ니다! 다ëĨ¸ 기기ëĨŧ ė„¤ė •í•˜ę¸° ėœ„í•´ Setup URIëĨŧ ėƒė„ąí•˜ė‹œę˛ ėŠĩ니까?"; - readonly ru: "Đ’ŅŅ‘ ĐŗĐžŅ‚ĐžĐ˛Đž! Đ’Ņ‹ Ņ…ĐžŅ‚Đ¸Ņ‚Đĩ ŅĐŗĐĩĐŊĐĩŅ€Đ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ Setup URI Đ´ĐģŅ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи Đ´Ņ€ŅƒĐŗĐ¸Ņ… ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛?"; - readonly zh: "全部厌成īŧčĻį”ŸæˆčŽžįŊŽ URI äģĨäžŋ配įŊŽå…ļäģ–čŽžå¤‡å—īŧŸ"; - readonly "zh-tw": "全部厌成īŧčρį”ĸį”Ÿ Setup URI äģĨäžŋč¨­åŽšå…ļäģ–čŖįŊŽå—ŽīŧŸ"; - }; - readonly "obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": { - readonly def: "If the server configuration is not persistent (e.g., running on docker), the values here may change. Once you are able to connect, please update the settings in the server's local.ini."; - readonly es: "Si la configuraciÃŗn del servidor no es persistente (por ejemplo, ejecutÃĄndose en docker), los valores aquí pueden cambiar. Una vez que puedas conectarte, por favor actualiza las configuraciones en el local.ini del servidor."; - readonly fr: "Si la configuration du serveur n'est pas persistante (par ex. fonctionnant sur Docker), les valeurs peuvent changer. Une fois la connexion Êtablie, mettez à jour les paramètres dans le local.ini du serveur."; - readonly he: "אם ×Ē×Ļור×Ē ×”×Š×¨×Ē ××™× ×” קבו×ĸה (למשל, פו×ĸל×Ē ×‘-docker), ה×ĸרכים כאן ×ĸשויים להש×Ēנו×Ē. לאחר ׊×Ē×Ļליח לה×Ēחבר, אנא ×ĸדכן א×Ē ×”×”×’×“×¨×•×Ē ×‘-local.ini של השר×Ē."; - readonly ja: "ã‚ĩãƒŧバãƒŧč¨­åŽšãŒæ°¸įļšįš„でãĒい場合īŧˆäž‹: Dockerã§åŽŸčĄŒä¸­īŧ‰ã€ã“こぎ値は変更される可čƒŊ性がありぞす。æŽĨįļšã§ãã‚‹ã‚ˆã†ãĢãĒãŖãŸã‚‰ã€ã‚ĩãƒŧバãƒŧぎlocal.iniãŽč¨­åŽšã‚’æ›´æ–°ã—ãĻください。"; - readonly ko: "ė„œë˛„ ė„¤ė •ė´ 똁ęĩŦ렁ėœŧ로 ė €ėžĨë˜ė§€ ė•ŠëŠ” 환ę˛Ŋ(똈: Dockerė—ė„œ ė‹¤í–‰ 뤑)ė—ė„œëŠ” ė´ęŗŗė˜ ę°’ë“¤ė´ ëŗ€ę˛Ŋ될 눘 ėžˆėŠĩ니다. ė—°ę˛°ė´ 가ëŠĨí•´ė§€ëŠ´ ė„œë˛„ė˜ local.ini 파ėŧė—ė„œ ė„¤ė •ė„ ėˆ˜ë™ėœŧ로 ė—…ë°ė´íŠ¸í•´ ėŖŧė„¸ėš”."; - readonly ru: "Đ•ŅĐģи ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Ņ ҁĐĩŅ€Đ˛ĐĩŅ€Đ° ĐŊĐĩĐŋĐžŅŅ‚ĐžŅĐŊĐŊа, СĐŊĐ°Ņ‡ĐĩĐŊĐ¸Ņ СдĐĩҁҌ ĐŧĐžĐŗŅƒŅ‚ иСĐŧĐĩĐŊĐ¸Ņ‚ŅŒŅŅ."; - readonly zh: "åĻ‚æžœæœåŠĄå™¨é…įŊŽä¸æ˜¯æŒäš…įš„(例åĻ‚,在 docker 上čŋčĄŒ),æ­¤å¤„įš„å€ŧ可čƒŊäŧšæ›´æ”šã€‚ä¸€æ—ĻčƒŊ够čŋžæŽĨ,č¯ˇæ›´æ–°æœåŠĄå™¨ local.ini ä¸­įš„čŽžįŊŽ"; - }; - readonly "obsidianLiveSyncSettingTab.msgInvalidPassphrase": { - readonly def: "Your encryption passphrase might be invalid. Are you sure you want to continue?"; - readonly es: "Tu frase de contraseÃąa de cifrado podría ser invÃĄlida. ÂŋEstÃĄs seguro de querer continuar?"; - readonly fr: "Votre phrase secrète de chiffrement peut ÃĒtre invalide. Êtes-vous sÃģr de vouloir continuer ?"; - readonly he: "ביטוי הסיסמה לה×Ļפנה שלך ×ĸשוי להיו×Ē ×œ× ×Ēקין. האם א×Ēה בטוח שבר×Ļונך להמשיך?"; - readonly ja: "æš—åˇåŒ–ãƒ‘ã‚šãƒ•ãƒŦãƒŧã‚ēãŒį„ĄåŠšã‹ã‚‚ã—ã‚Œãžã›ã‚“ã€‚įļščĄŒã—ãĻもよろしいですかīŧŸ"; - readonly ko: "ė•”í˜¸í™” íŒ¨ėŠ¤í”„ë ˆė´ėĻˆę°€ ėœ íš¨í•˜ė§€ ė•Šė„ 눘 ėžˆėŠĩ니다. ė •ë§ëĄœ ęŗ„ė†í•˜ė‹œę˛ ėŠĩ니까?"; - readonly ru: "Đ’Đ°ŅˆĐ° ĐŋĐ°Ņ€ĐžĐģҌĐŊĐ°Ņ Ņ„Ņ€Đ°ĐˇĐ° ŅˆĐ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊĐ¸Ņ ĐŧĐžĐļĐĩŅ‚ ĐąŅ‹Ņ‚ŅŒ ĐŊĐĩĐ´ĐĩĐšŅŅ‚Đ˛Đ¸Ņ‚ĐĩĐģҌĐŊа."; - readonly zh: "äŊ įš„åŠ å¯†å¯†į įŸ­č¯­å¯čƒŊ无效。äŊ įĄŽåޚčρįģ§įģ­å—īŧŸ"; - readonly "zh-tw": "äŊ įš„加密密čĒžå¯čƒŊį„Ąæ•ˆã€‚äŊ įĸē厚čρįšŧįēŒå—ŽīŧŸ"; - }; - readonly "obsidianLiveSyncSettingTab.msgNewVersionNote": { - readonly def: "Here due to an upgrade notification? Please review the version history. If you're satisfied, click the button. A new update will prompt this again."; - readonly es: "ÂŋAquí debido a una notificaciÃŗn de actualizaciÃŗn? Por favor, revise el historial de versiones. Si estÃĄ satisfecho, haga clic en el botÃŗn. Una nueva actualizaciÃŗn volverÃĄ a mostrar esto."; - readonly fr: "ArrivÊ ici suite à une notification de mise à jour ? Consultez l'historique des versions. Si vous ÃĒtes satisfait, cliquez sur le bouton. Une nouvelle mise à jour reproposera ceci."; - readonly he: "הג×ĸ×Ē ×›××Ÿ בשל הוד×ĸ×Ē ×Š×“×¨×•×’? אנא ×ĸיין בהיסטוריי×Ē ×”×’×¨×Ą××•×Ē. אם א×Ēה מרו×Ļה, לח×Ĩ ×ĸל הכפ×Ēור. ×ĸדכון חדש י×Ļיג זא×Ē ×Š×•×‘."; - readonly ja: "ã‚ĸップグãƒŦãƒŧド通įŸĨでここãĢæĨぞしたかīŧŸãƒãƒŧã‚¸ãƒ§ãƒŗåąĨ歴をįĸēčĒã—ãĻãã ã•ã„ã€‚į´åž—ã—ãŸã‚‰ãƒœã‚ŋãƒŗã‚’ã‚¯ãƒĒックしãĻください。新しい更新があると再åēĻįĸēčĒã•ã‚Œãžã™ã€‚"; - readonly ko: "ė—…ęˇ¸ë ˆė´ë“œ ė•ŒëĻŧėœŧ로 ė—Ŧ揰뗐 ė˜¤ė…¨ë‚˜ėš”? ë˛„ė „ ę¸°ëĄė„ 검토해 ėŖŧė„¸ėš”. ë§ŒėĄąí•˜ė‹ ë‹¤ëŠ´ 버íŠŧė„ 클ëĻ­í•˜ė„¸ėš”. ėƒˆëĄœėš´ ė—…ë°ė´íŠ¸ ė‹œ ë‹¤ė‹œ ė•ˆë‚´ëŠë‹ˆë‹¤."; - readonly ru: "Đ’Ņ‹ ĐŋŅ€Đ¸ŅˆĐģи иС-Са ŅƒĐ˛ĐĩĐ´ĐžĐŧĐģĐĩĐŊĐ¸Ņ Ой ОйĐŊОвĐģĐĩĐŊии? ĐŸŅ€ĐžŅĐŧĐžŅ‚Ņ€Đ¸Ņ‚Đĩ Đ¸ŅŅ‚ĐžŅ€Đ¸ŅŽ вĐĩŅ€ŅĐ¸Đš."; - readonly zh: "因ä¸ē升įē§é€šįŸĨæĨ到čŋ™é‡ŒīŧŸč¯ˇæŸĨįœ‹į‰ˆæœŦåŽ†å˛ã€‚åĻ‚æžœæ‚¨æģĄæ„,č¯ˇį‚šå‡ģæŒ‰é’Žã€‚æ–°įš„æ›´æ–°å°†å†æŦĄæį¤ēæ­¤äŋĄæ¯"; - }; - readonly "obsidianLiveSyncSettingTab.msgNonHTTPSInfo": { - readonly def: "Configured as non-HTTPS URI. Be warned that this may not work on mobile devices."; - readonly es: "Configurado como URI que no es HTTPS. Ten en cuenta que esto puede no funcionar en dispositivos mÃŗviles."; - readonly fr: "ConfigurÊ avec une URI non HTTPS. Attention, ceci peut ne pas fonctionner sur les appareils mobiles."; - readonly he: "מוגדר כ-URI שאינו HTTPS. שים לב שהדבר ×ĸשוי שלא לפ×ĸול ×ĸל מכשירים ניידים."; - readonly ja: "非HTTPS URIとしãĻč¨­åŽšã•ã‚ŒãĻいぞす。ãƒĸバイãƒĢデバイ゚では動äŊœã—ãĒい可čƒŊ性がありぞす。"; - readonly ko: "비 HTTPS URI로 ęĩŦė„ąë˜ė—ˆėŠĩ니다. ëĒ¨ë°”ėŧ ę¸°ę¸°ė—ė„œëŠ” ėž‘ë™í•˜ė§€ ė•Šė„ 눘 ėžˆėœŧ니 ėŖŧė˜í•˜ė„¸ėš”."; - readonly ru: "ĐĐ°ŅŅ‚Ņ€ĐžĐĩĐŊĐž ĐēаĐē ĐŊĐĩ-HTTPS URI. Đ­Ņ‚Đž ĐŧĐžĐļĐĩŅ‚ ĐŊĐĩ Ņ€Đ°ĐąĐžŅ‚Đ°Ņ‚ŅŒ ĐŊа ĐŧОйиĐģҌĐŊҋ҅ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°Ņ…."; - readonly zh: "配įŊŽä¸ē非 HTTPS URIã€‚č¯ˇæŗ¨æ„,čŋ™å¯čƒŊ在į§ģåŠ¨čŽžå¤‡ä¸Šæ— æŗ•åˇĨäŊœ"; - }; - readonly "obsidianLiveSyncSettingTab.msgNonHTTPSWarning": { - readonly def: "Cannot connect to non-HTTPS URI. Please update your config and try again."; - readonly es: "No se puede conectar a URI que no sean HTTPS. Por favor, actualiza tu configuraciÃŗn y vuelve a intentarlo."; - readonly fr: "Connexion impossible à une URI non HTTPS. Mettez à jour votre configuration et rÊessayez."; - readonly he: "לא ני×Ēן לה×Ēחבר ל-URI שאינו HTTPS. אנא ×ĸדכן א×Ē ×”×Ē×Ļורה ונסה שוב."; - readonly ja: "非HTTPS URIãĢæŽĨįļšã§ããžã›ã‚“ã€‚č¨­åŽšã‚’æ›´æ–°ã—ãĻ再čŠĻčĄŒã—ãĻください。"; - readonly ko: "비 HTTPS URI뗐 ė—°ę˛°í•  눘 ė—†ėŠĩ니다. ęĩŦė„ąė„ ė—…ë°ė´íŠ¸í•˜ęŗ  ë‹¤ė‹œ ė‹œë„í•´ ėŖŧė„¸ėš”."; - readonly ru: "НĐĩ ŅƒĐ´Đ°Ņ‘Ņ‚ŅŅ ĐŋОдĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒŅŅ Đē ĐŊĐĩ-HTTPS URI. ОбĐŊĐžĐ˛Đ¸Ņ‚Đĩ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸ŅŽ."; - readonly zh: "æ— æŗ•čŋžæŽĨ到非 HTTPS URIã€‚č¯ˇæ›´æ–°æ‚¨įš„é…įŊŽåšļé‡č¯•"; - }; - readonly "obsidianLiveSyncSettingTab.msgNotice": { - readonly def: "---Notice---"; - readonly es: "---Aviso---"; - readonly fr: "---Avis---"; - readonly he: "---הוד×ĸה---"; - readonly ja: "---おįŸĨらせ---"; - readonly ko: "---ęŗĩė§€ė‚Ŧ항---"; - readonly ru: "---ĐŖĐ˛ĐĩĐ´ĐžĐŧĐģĐĩĐŊиĐĩ---"; - readonly zh: "---æŗ¨æ„---"; - }; - readonly "obsidianLiveSyncSettingTab.msgObjectStorageWarning": { - readonly def: "WARNING: This feature is a Work In Progress, so please keep in mind the following:\n- Append only architecture. A rebuild is required to shrink the storage.\n- A bit fragile.\n- When first syncing, all history will be transferred from the remote. Be mindful of data caps and slow speeds.\n- Only differences are synced live.\n\nIf you run into any issues, or have ideas about this feature, please create a issue on GitHub.\nI appreciate you for your great dedication."; - readonly es: "ADVERTENCIA: Esta característica estÃĄ en desarrollo, así que por favor ten en cuenta lo siguiente:\n- Arquitectura de solo anexado. Se requiere una reconstrucciÃŗn para reducir el almacenamiento.\n- Un poco frÃĄgil.\n- Al sincronizar por primera vez, todo el historial serÃĄ transferido desde el remoto. Ten en cuenta los límites de datos y las velocidades lentas.\n- Solo las diferencias se sincronizan en vivo.\n\nSi encuentras algÃēn problema o tienes ideas sobre esta característica, por favor crea un issue en GitHub.\nAprecio mucho tu gran dedicaciÃŗn."; - readonly fr: "AVERTISSEMENT : cette fonctionnalitÊ est en cours de dÊveloppement, gardez à l'esprit ce qui suit :\n- Architecture en ajout seul. Une reconstruction est nÊcessaire pour rÊduire le stockage.\n- Un peu fragile.\n- Lors de la première synchronisation, tout l'historique sera transfÊrÊ depuis le distant. Attention aux limites de donnÊes et aux dÊbits lents.\n- Seules les diffÊrences sont synchronisÊes en direct.\n\nSi vous rencontrez des problèmes ou avez des idÊes sur cette fonctionnalitÊ, merci d'ouvrir un ticket sur GitHub.\nMerci pour votre grand dÊvouement."; - readonly he: "אזהרה: ×Ēכונה זו בשלב פי×Ēוח, לכן שים לב לנקודו×Ē ×”×‘××•×Ē:\n- ארכיטקטור×Ē ×”×•×Ą×¤×” בלבד. נדרש×Ē ×‘× ×™×™×” מחדש ל×Ļמ×Ļום האחסון.\n- ×§×Ļ×Ē ×¨×’×™×Š.\n- בסנכרון הראשון, כל ההיסטוריה ×Ēו×ĸבר מהשר×Ē ×”×ž×¨×•×—×“. שים לב למגבלו×Ē × ×Ēונים ומהירו×Ē.\n- רק הפרשים מסונכרנים בזמן אמ×Ē.\n\nאם × ×Ēקל×Ē ×‘×‘×ĸיו×Ē, או שיש לך ר×ĸיונו×Ē ×œ×’×‘×™ ×Ēכונה זו, אנא פ×Ēח Issue ב-GitHub.\nאנחנו מ×ĸריכים א×Ē ×”×”×§×“×Š×” הגדולה שלך."; - readonly ja: "č­Ļ告: こぎ抟čƒŊは開į™ē中です。äģĨä¸‹ãŽį‚šãĢã”æŗ¨æ„ãã ã•ã„īŧš\n- čŋŊč¨˜å°‚į”¨ã‚ĸãƒŧã‚­ãƒ†ã‚¯ãƒãƒŖã€‚ã‚šãƒˆãƒŦãƒŧã‚¸ã‚’į¸Žå°ã™ã‚‹ãĢã¯å†æ§‹į¯‰ãŒåŋ…čĻã§ã™ã€‚\n- やや不厉厚です。\n- 初回同期時、すずãĻぎåąĨ歴がãƒĒãƒĸãƒŧトからčģĸ送されぞす。デãƒŧã‚ŋåˆļ限と速åēĻãĢæŗ¨æ„ã—ãĻください。\n- ãƒŠã‚¤ãƒ–åŒæœŸã¯åˇŽåˆ†ãŽãŋです。\n\nå•éĄŒãŒã‚ã‚Œã°ã€ãžãŸã¯ã“ãŽæŠŸčƒŊãĢついãĻã‚ĸイデã‚ĸがあれば、GitHubãĢIssueをäŊœæˆã—ãĻください。\nご協力ãĢ感čŦã—ぞす。"; - readonly ko: "âš ī¸ ėŖŧė˜: ė´ 기ëŠĨė€ 땄링 개발 뤑(WIP)ėž…ë‹ˆë‹¤. ë‹¤ėŒ ė‚Ŧí•­ė„ ėœ ė˜í•´ ėŖŧė„¸ėš”:\n- ėļ”ę°€ ė „ėšŠ ęĩŦėĄ°(append-only)로 ë™ėž‘í•Šë‹ˆë‹¤. ė €ėžĨ ėšŠëŸ‰ė„ ė¤„ė´ë ¤ëŠ´ ë°ė´í„° ėžŦęĩŦė„ąė´ í•„ėš”í•Šë‹ˆë‹¤.\n- 기ëŠĨė´ ë‹¤ė†Œ ëļˆė•ˆė •í•  눘 ėžˆėŠĩ니다.\n- ėĩœė´ˆ 동기화 ė‹œ, 렄랴 ížˆėŠ¤í† ëĻŦ가 ė›ę˛Š ė„œë˛„ė—ė„œ ė „ė†ĄëŠë‹ˆë‹¤. ë°ė´í„° ėšŠëŸ‰ ė œí•œ 및 느ëϰ ė†ë„ė— ėœ ė˜í•´ ėŖŧė„¸ėš”.\n- ė‹¤ė‹œę°„ 동기화는 ëŗ€ę˛Ŋ된 ëļ€ëļ„ë§Œ 래ëĻŦ됩니다.\n\nëŦ¸ė œę°€ ë°œėƒí–ˆęą°ë‚˜ ę°œė„  ė•„ė´ë””ė–´ę°€ ėžˆėœŧė‹œëŠ´ GitHub뗐 ė´ėŠˆëĨŧ 등록해 ėŖŧė„¸ėš”.\n기ė—Ŧ뗐 ęšŠė´ 감ė‚Ŧ드ëĻŊ니다."; - readonly ru: "ĐŸĐ Đ•Đ”ĐŖĐŸĐ Đ•Đ–Đ”Đ•ĐĐ˜Đ•: Đ­Ņ‚Đ° Ņ„ŅƒĐŊĐēŅ†Đ¸Ņ в Ņ€Đ°ĐˇŅ€Đ°ĐąĐžŅ‚ĐēĐĩ."; - readonly zh: "č­Ļ告īŧšæ­¤åŠŸčƒŊäģåœ¨åŧ€å‘中,č¯ˇæŗ¨æ„äģĨä¸‹å‡ į‚šīŧš\n- äģ…čŋŊ加æžļ构。需čĻé‡åģ翉čƒŊįŧŠå°å­˜å‚¨įŠē间。\n- æœ‰į‚šč„†åŧąã€‚\n- éĻ–æŦĄåŒæ­Ĩæ—ļ,æ‰€æœ‰åŽ†å˛čŽ°åŊ•å°†äģŽčŋœį¨‹äŧ čž“ã€‚æŗ¨æ„æ•°æŽä¸Šé™å’Œæ…ĸ速。\n- åĒæœ‰åˇŽåŧ‚äŧšåŽžæ—ļ同æ­Ĩ。\n\nåĻ‚æžœæ‚¨é‡åˆ°äģģäŊ•é—Žéĸ˜,或寚此功čƒŊ有äģģäŊ•æƒŗæŗ•,蝎圍 GitHub 上创åģē issue。\n感č°ĸæ‚¨įš„åˇ¨å¤§č´ĄįŒŽ"; - }; - readonly "obsidianLiveSyncSettingTab.msgOriginCheck": { - readonly def: "Origin check: ${org}"; - readonly es: "VerificaciÃŗn de origen: {org}"; - readonly fr: "VÊrification d'origine : ${org}"; - readonly he: "בדיק×Ē ×ž×§×•×¨: ${org}"; - readonly ja: "ã‚ĒãƒĒã‚¸ãƒŗįĸēčĒ: ${org}"; - readonly ko: "뛐렐 í™•ė¸: {org}"; - readonly ru: "ĐŸŅ€ĐžĐ˛ĐĩŅ€Đēа origin: org"; - readonly zh: "æēæŖ€æŸĨ: {org}"; - }; - readonly "obsidianLiveSyncSettingTab.msgRebuildRequired": { - readonly def: "Rebuilding Databases are required to apply the changes.. Please select the method to apply the changes.\n\n
\nLegends\n\n| Symbol | Meaning |\n|: ------ :| ------- |\n| ⇔ | Up to Date |\n| ⇄ | Synchronise to balance |\n| ⇐,⇒ | Transfer to overwrite |\n| ⇠,â‡ĸ | Transfer to overwrite from other side |\n\n
\n\n## ${OPTION_REBUILD_BOTH}\nAt a glance: 📄 ⇒¹ đŸ’ģ ⇒² đŸ›°ī¸ â‡ĸâŋ đŸ’ģ ⇄âŋâēš 📄\nReconstruct both the local and remote databases using existing files from this device.\nThis causes a lockout other devices, and they need to perform fetching.\n## ${OPTION_FETCH}\nAt a glance: 📄 ⇄² đŸ’ģ ⇐¹ đŸ›°ī¸ ⇔ đŸ’ģ ⇔ 📄\nInitialise the local database and reconstruct it using data fetched from the remote database.\nThis case includes the case which you have rebuilt the remote database.\n## ${OPTION_ONLY_SETTING}\nStore only the settings. **Caution: This may lead to data corruption**; database reconstruction is generally necessary."; - readonly es: "Es necesario reconstruir las bases de datos para aplicar los cambios. Por favor selecciona el mÊtodo para aplicar los cambios.\n\n
\nLegendas\n\n| Símbolo | Significado |\n|: ------ :| ------- |\n| ⇔ | Actualizado |\n| ⇄ | Sincronizar para equilibrar |\n| ⇐,⇒ | Transferir para sobrescribir |\n| ⇠,â‡ĸ | Transferir para sobrescribir desde otro lado |\n\n
\n\n## ${OPTION_REBUILD_BOTH}\nA simple vista: 📄 ⇒¹ đŸ’ģ ⇒² đŸ›°ī¸ â‡ĸâŋ đŸ’ģ ⇄âŋâēš 📄\nReconstruir tanto la base de datos local como la remota utilizando los archivos existentes de este dispositivo.\nEsto bloquea a otros dispositivos, y necesitan realizar la obtenciÃŗn.\n## ${OPTION_FETCH}\nA simple vista: 📄 ⇄² đŸ’ģ ⇐¹ đŸ›°ī¸ ⇔ đŸ’ģ ⇔ 📄\nInicializa la base de datos local y la reconstruye utilizando los datos obtenidos de la base de datos remota.\nEste caso incluye el caso en el que has reconstruido la base de datos remota.\n## ${OPTION_ONLY_SETTING}\nAlmacena solo la configuraciÃŗn. **PrecauciÃŗn: esto puede provocar corrupciÃŗn de datos**; generalmente es necesario reconstruir la base de datos."; - readonly fr: "La reconstruction des bases de donnÊes est nÊcessaire pour appliquer les changements. Veuillez sÊlectionner la mÊthode d'application.\n\n
\nLÊgende\n\n| Symbole | Signification |\n|: ------ :| ------- |\n| ⇔ | À jour |\n| ⇄ | Synchroniser pour Êquilibrer |\n| ⇐,⇒ | TransfÊrer pour Êcraser |\n| ⇠,â‡ĸ | TransfÊrer pour Êcraser depuis l'autre côtÊ |\n\n
\n\n## ${OPTION_REBUILD_BOTH}\nEn bref : 📄 ⇒¹ đŸ’ģ ⇒² đŸ›°ī¸ â‡ĸâŋ đŸ’ģ ⇄âŋâēš 📄\nReconstruit les bases locale et distante à partir des fichiers existants de cet appareil.\nCeci provoque un verrouillage des autres appareils, qui devront effectuer une rÊcupÊration.\n## ${OPTION_FETCH}\nEn bref : 📄 ⇄² đŸ’ģ ⇐¹ đŸ›°ī¸ ⇔ đŸ’ģ ⇔ 📄\nInitialise la base locale et la reconstruit à partir des donnÊes rÊcupÊrÊes depuis la base distante.\nCe cas inclut Êgalement celui oÚ vous avez reconstruit la base distante.\n## ${OPTION_ONLY_SETTING}\nNe stocker que les paramètres. **Attention : cela peut entraÃŽner une corruption des donnÊes** ; une reconstruction de la base est gÊnÊralement nÊcessaire."; - readonly he: "נדרש×Ē ×‘× ×™×™×” מחדש של מסדי הנ×Ēונים כדי להחיל א×Ē ×”×Š×™× ×•×™×™×. אנא בחר א×Ē ×”×Š×™×˜×”.\n\n
\nמקרא\n\n| סמל | מ׊מ×ĸו×Ē |\n|: ------ :| ------- |\n| ⇔ | מ×ĸודכן |\n| ⇄ | סנכרן לאיזון |\n| ⇐,⇒ | ה×ĸבר לדריסה |\n| ⇠,â‡ĸ | ה×ĸבר לדריסה מה×Ļד השני |\n\n
\n\n## ${OPTION_REBUILD_BOTH}\nבמבט: 📄 ⇒¹ đŸ’ģ ⇒² đŸ›°ī¸ â‡ĸâŋ đŸ’ģ ⇄âŋâēš 📄\nבנה מחדש גם א×Ē ×ž×Ą×“ הנ×Ēונים המקומי וגם המרוחד ×Ēוך שימוש בקב×Ļים קיימים ממכשיר זה.\nפ×ĸולה זו ×Ē× ×ĸל מכשירים אחרים שי×Ļטרכו לב×Ļ×ĸ משיכה.\n## ${OPTION_FETCH}\nבמבט: 📄 ⇄² đŸ’ģ ⇐¹ đŸ›°ī¸ ⇔ đŸ’ģ ⇔ 📄\nא×Ēחל א×Ē ×ž×Ą×“ הנ×Ēונים המקומי ובנה או×Ēו מחדש ×Ēוך שימוש בנ×Ēונים שנמשכו ממסד הנ×Ēונים המרוחד.\nכולל א×Ē ×”×ž×§×¨×” שבו בני×Ē ×ž×—×“×Š א×Ē ×ž×Ą×“ הנ×Ēונים המרוחד.\n## ${OPTION_ONLY_SETTING}\nשמור רק א×Ē ×”×”×’×“×¨×•×Ē. **זהירו×Ē: ×ĸלול לגרום לפגי×ĸה בנ×Ēונים**; בנייה מחדש של מסד הנ×Ēונים נדרש×Ē ×‘×“×¨×š כלל."; - readonly ja: "å¤‰æ›´ã‚’éŠį”¨ã™ã‚‹ãĢはデãƒŧã‚ŋベãƒŧã‚šãŽå†æ§‹į¯‰ãŒåŋ…čĻã§ã™ã€‚å¤‰æ›´ã‚’éŠį”¨ã™ã‚‹æ–šæŗ•ã‚’é¸æŠžã—ãĻください。\n\n
\nå‡Ąäž‹\n\n| č¨˜åˇ | æ„å‘ŗ |\n|: ------ :| ------- |\n| ⇔ | 最新 |\n| ⇄ | 同期しãĻãƒãƒŠãƒŗã‚šã‚’å–ã‚‹ |\n| ⇐,⇒ | 上書きするためčģĸ送 |\n| ⇠,â‡ĸ | 反寞側から上書きするためčģĸ送 |\n\n
\n\n## ${OPTION_REBUILD_BOTH}\næĻ‚čρ: 📄 ⇒¹ đŸ’ģ ⇒² đŸ›°ī¸ â‡ĸâŋ đŸ’ģ ⇄âŋâēš 📄\nこぎデバイ゚ぎæ—ĸå­˜ãƒ•ã‚Ąã‚¤ãƒĢをäŊŋį”¨ã—ãĻロãƒŧã‚ĢãƒĢとãƒĒãƒĸãƒŧãƒˆãŽä¸Ąæ–šãŽãƒ‡ãƒŧã‚ŋベãƒŧã‚šã‚’å†æ§‹į¯‰ã—ãžã™ã€‚\näģ–ぎデバイ゚はロックã‚ĸã‚Ļトされ、フェッチがåŋ…čĻã§ã™ã€‚\n## ${OPTION_FETCH}\næĻ‚čρ: 📄 ⇄² đŸ’ģ ⇐¹ đŸ›°ī¸ ⇔ đŸ’ģ ⇔ 📄\nロãƒŧã‚ĢãƒĢデãƒŧã‚ŋベãƒŧ゚を初期化し、ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚から取垗したデãƒŧã‚ŋをäŊŋį”¨ã—ãĻå†æ§‹į¯‰ã—ãžã™ã€‚\nãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧã‚šã‚’å†æ§‹į¯‰ã—ãŸå ´åˆã‚‚åĢぞれぞす。\n## ${OPTION_ONLY_SETTING}\nč¨­åŽšãŽãŋをäŋå­˜ã—ぞす。**æŗ¨æ„: デãƒŧã‚ŋį ´æãĢつãĒがる可čƒŊ性がありぞす**。通常、デãƒŧã‚ŋベãƒŧã‚šãŽå†æ§‹į¯‰ãŒåŋ…čĻã§ã™ã€‚"; - readonly ko: "ëŗ€ę˛Ŋė‚Ŧí•­ė„ ė ėšŠí•˜ë ¤ëŠ´ ë°ė´í„°ë˛ ė´ėŠ¤ëĨŧ ėžŦęĩŦėļ•í•´ė•ŧ 합니다. ė•„ëž˜ 뤑 한 氀맀 ë°Šë˛•ė„ ė„ íƒí•´ ėŖŧė„¸ėš”.\n\n
\n범례\n\n| 기호 | ė˜ë¯¸ |\n|: ------ :| ------- |\n| ⇔ | ėĩœė‹  ėƒíƒœ |\n| ⇄ | 동기화 균형 ėœ ė§€ |\n| ⇐,⇒ | ëŽė–´ė“°ę¸° ë°Šė‹ė˜ ė „ė†Ą |\n| ⇠,â‡ĸ | ėƒëŒ€íŽ¸ė—ė„œ 氀렏뙀 ëŽė–´ė“°ę¸° |\n\n
\n\n## ${OPTION_REBUILD_BOTH}\nę°œėš”: 📄 ⇒¹ đŸ’ģ ⇒² đŸ›°ī¸ â‡ĸâŋ đŸ’ģ ⇄âŋâēš 📄\nė´ ę¸°ę¸°ė˜ ę¸°ėĄ´ 파ėŧė„ 기반ėœŧ로 로ėģŦęŗŧ ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ëĨŧ ëĒ¨ë‘ ėžŦęĩŦėļ•핊니다.\nė´ ęŗŧė •ė—ė„œ 다ëĨ¸ 기기는 ėŧė‹œė ėœŧ로 ė ‘ęˇŧė´ ė œí•œë˜ëŠ°, ę°€ė ¸ė˜¤ę¸° ėž‘ė—…ė„ ëŗ„ë„ëĄœ ėˆ˜í–‰í•´ė•ŧ 합니다.\n\n## ${OPTION_FETCH}\nę°œėš”: 📄 ⇄² đŸ’ģ ⇐¹ đŸ›°ī¸ ⇔ đŸ’ģ ⇔ 📄\n로ėģŦ ë°ė´í„°ë˛ ė´ėŠ¤ëĨŧ ė´ˆę¸°í™”í•œ 후, ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ė—ė„œ ë°ė´í„°ëĨŧ 氀렏뙀 ėžŦęĩŦėļ•핊니다.\nė´ëŠ” ė›ę˛Š ė¸Ąė—ė„œ ë°ė´í„°ë˛ ė´ėŠ¤ëĨŧ ë¨ŧė € ėžŦęĩŦėļ•한 ę˛Ŋėš°ė—ë„ 해당됩니다.\n\n## ${OPTION_ONLY_SETTING}\nė„¤ė •ë§Œ ė €ėžĨ합니다. **âš ī¸ ėŖŧė˜: ė´ ë°Šë˛•ė€ ë°ė´í„° ė†ėƒė„ ėŧėœŧí‚Ŧ 눘 ėžˆėŠĩ니다.** ėŧë°˜ė ėœŧ로는 렄랴 ë°ė´í„°ë˛ ė´ėŠ¤ ėžŦęĩŦėļ•ė´ í•„ėš”í•Šë‹ˆë‹¤."; - readonly ru: "ĐĸŅ€ĐĩĐąŅƒĐĩŅ‚ŅŅ ĐŋĐĩŅ€ĐĩŅŅ‚Ņ€ĐžĐĩĐŊиĐĩ йаС даĐŊĐŊҋ҅ Đ´ĐģŅ ĐŋŅ€Đ¸ĐŧĐĩĐŊĐĩĐŊĐ¸Ņ иСĐŧĐĩĐŊĐĩĐŊиК."; - readonly zh: "需čĻé‡åģēæ•°æŽåē“äģĨåē”į”¨æ›´æ”šã€‚č¯ˇé€‰æ‹Šåē”į”¨æ›´æ”šįš„æ–šæŗ•ã€‚\n\n
\n回䞋\n\n| įŦĻåˇ | åĢ义 |\n|: ------ :| ------- |\n| ⇔ | 最新 |\n| ⇄ | 同æ­ĨäģĨåšŗčĄĄ |\n| ⇐,⇒ | äŧ čž“äģĨčφᛖ |\n| ⇠,â‡ĸ | äģŽåĻ一侧äŧ čž“äģĨčφᛖ |\n\n
\n\n## ${OPTION_REBUILD_BOTH}\næĻ‚č§ˆ:📄 ⇒¹ đŸ’ģ ⇒² đŸ›°ī¸ â‡ĸâŋ đŸ’ģ ⇄âŋâēš 📄\näŊŋį”¨æ­¤čŽžå¤‡įš„įŽ°æœ‰æ–‡äģļ重åģ翜Ŧ地和čŋœį¨‹æ•°æŽåē“。\nčŋ™å°†å¯ŧ致å…ļäģ–čŽžå¤‡čĸĢ锁厚,åšļ且厃äģŦ需čĻæ‰§čĄŒčŽˇå–æ“äŊœã€‚\n## ${OPTION_FETCH}\næĻ‚č§ˆ:📄 ⇄² đŸ’ģ ⇐¹ đŸ›°ī¸ ⇔ đŸ’ģ ⇔ 📄\n初始化æœŦ地数捎åē“åšļäŊŋᔍäģŽčŋœį¨‹æ•°æŽåē“čŽˇå–įš„æ•°æŽé‡åģē厃。\nčŋ™į§æƒ…å†ĩ包æ‹Ŧæ‚¨åˇ˛įģé‡åģēäē†čŋœį¨‹æ•°æŽåē“įš„æƒ…å†ĩ。\n## ${OPTION_ONLY_SETTING}\näģ…å­˜å‚¨čŽžįŊŽã€‚**æŗ¨æ„īŧščŋ™å¯čƒŊå¯ŧč‡´æ•°æŽæŸå**īŧ›é€šå¸¸éœ€čĻé‡åģēæ•°æŽåē“"; - }; - readonly "obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": { - readonly def: "Please select and apply any preset item to complete the wizard."; - readonly es: "Por favor, selecciona y aplica cualquier elemento preestablecido para completar el asistente."; - readonly fr: "Veuillez sÊlectionner et appliquer un prÊrÊglage pour terminer l'assistant."; - readonly he: "אנא בחר והחל פריט קבו×ĸ מרא׊ כלשהו להשלמ×Ē ×”××Š×Ŗ."; - readonly ja: "ã‚Ļã‚Ŗã‚ļãƒŧドを厌äē†ã™ã‚‹ãĢは、プãƒĒã‚ģãƒƒãƒˆé …į›Žã‚’é¸æŠžã—ãĻéŠį”¨ã—ãĻください。"; - readonly ko: "마법ė‚ŦëĨŧ ė™„ëŖŒí•˜ë ¤ëŠ´ 프ëĻŦė…‹ 항ëĒŠė„ ė„ íƒí•˜ęŗ  ė ėšŠí•´ ėŖŧė„¸ėš”."; - readonly ru: "Đ’Ņ‹ĐąĐĩŅ€Đ¸Ņ‚Đĩ и ĐŋŅ€Đ¸ĐŧĐĩĐŊĐ¸Ņ‚Đĩ ĐģŅŽĐąĐžĐš ĐŋŅ€ĐĩҁĐĩŅ‚ Đ´ĐģŅ СавĐĩŅ€ŅˆĐĩĐŊĐ¸Ņ ĐŧĐ°ŅŅ‚ĐĩŅ€Đ°."; - readonly zh: "č¯ˇé€‰æ‹Šåšļåē”ᔍäģģ一éĸ„莞饚äģĨ厌成向å¯ŧ。"; - readonly "zh-tw": "čĢ‹é¸æ“‡ä¸ĻåĨ—ᔍäģģä¸€é č¨­é …į›ŽäģĨåŽŒæˆį˛žéˆã€‚"; - }; - readonly "obsidianLiveSyncSettingTab.msgSetCorsCredentials": { - readonly def: "Set cors.credentials"; - readonly es: "Configurar cors.credentials"; - readonly fr: "DÊfinir cors.credentials"; - readonly he: "הגדר cors.credentials"; - readonly ja: "cors.credentialsã‚’č¨­åŽš"; - readonly ko: "cors.credentials 네렕"; - readonly ru: "ĐŖŅŅ‚Đ°ĐŊĐžĐ˛Đ¸Ņ‚ŅŒ cors.credentials"; - readonly zh: "莞įŊŽ cors.credentials"; - }; - readonly "obsidianLiveSyncSettingTab.msgSetCorsOrigins": { - readonly def: "Set cors.origins"; - readonly es: "Configurar cors.origins"; - readonly fr: "DÊfinir cors.origins"; - readonly he: "הגדר cors.origins"; - readonly ja: "cors.originsã‚’č¨­åŽš"; - readonly ko: "cors.origins 네렕"; - readonly ru: "ĐŖŅŅ‚Đ°ĐŊĐžĐ˛Đ¸Ņ‚ŅŒ cors.origins"; - readonly zh: "莞įŊŽ cors.origins"; - }; - readonly "obsidianLiveSyncSettingTab.msgSetMaxDocSize": { - readonly def: "Set couchdb.max_document_size"; - readonly es: "Configurar couchdb.max_document_size"; - readonly fr: "DÊfinir couchdb.max_document_size"; - readonly he: "הגדר couchdb.max_document_size"; - readonly ja: "couchdb.max_document_sizeã‚’č¨­åŽš"; - readonly ko: "couchdb.max_document_size 네렕"; - readonly ru: "ĐŖŅŅ‚Đ°ĐŊĐžĐ˛Đ¸Ņ‚ŅŒ couchdb.max_document_size"; - readonly zh: "莞įŊŽ couchdb.max_document_size"; - }; - readonly "obsidianLiveSyncSettingTab.msgSetMaxRequestSize": { - readonly def: "Set chttpd.max_http_request_size"; - readonly es: "Configurar chttpd.max_http_request_size"; - readonly fr: "DÊfinir chttpd.max_http_request_size"; - readonly he: "הגדר chttpd.max_http_request_size"; - readonly ja: "chttpd.max_http_request_sizeã‚’č¨­åŽš"; - readonly ko: "chttpd.max_http_request_size 네렕"; - readonly ru: "ĐŖŅŅ‚Đ°ĐŊĐžĐ˛Đ¸Ņ‚ŅŒ chttpd.max_http_request_size"; - readonly zh: "莞įŊŽ chttpd.max_http_request_size"; - }; - readonly "obsidianLiveSyncSettingTab.msgSetRequireValidUser": { - readonly def: "Set chttpd.require_valid_user = true"; - readonly es: "Configurar chttpd.require_valid_user = true"; - readonly fr: "DÊfinir chttpd.require_valid_user = true"; - readonly he: "הגדר chttpd.require_valid_user = true"; - readonly ja: "chttpd.require_valid_user = trueã‚’č¨­åŽš"; - readonly ko: "chttpd.require_valid_user = true로 네렕"; - readonly ru: "ĐŖŅŅ‚Đ°ĐŊĐžĐ˛Đ¸Ņ‚ŅŒ chttpd.require_valid_user = true"; - readonly zh: "莞įŊŽ chttpd.require_valid_user = true"; - }; - readonly "obsidianLiveSyncSettingTab.msgSetRequireValidUserAuth": { - readonly def: "Set chttpd_auth.require_valid_user = true"; - readonly es: "Configurar chttpd_auth.require_valid_user = true"; - readonly fr: "DÊfinir chttpd_auth.require_valid_user = true"; - readonly he: "הגדר chttpd_auth.require_valid_user = true"; - readonly ja: "chttpd_auth.require_valid_user = trueã‚’č¨­åŽš"; - readonly ko: "chttpd_auth.require_valid_user = true로 네렕"; - readonly ru: "ĐŖŅŅ‚Đ°ĐŊĐžĐ˛Đ¸Ņ‚ŅŒ chttpd_auth.require_valid_user = true"; - readonly zh: "莞įŊŽ chttpd_auth.require_valid_user = true"; - }; - readonly "obsidianLiveSyncSettingTab.msgSettingModified": { - readonly def: "The setting \"${setting}\" was modified from another device. Click {HERE} to reload settings. Click elsewhere to ignore changes."; - readonly es: "La configuraciÃŗn \"${setting}\" fue modificada desde otro dispositivo. Haz clic {HERE} para recargar la configuraciÃŗn. Haz clic en otro lugar para ignorar los cambios."; - readonly fr: "Le paramètre ÂĢ ${setting} Âģ a ÊtÊ modifiÊ depuis un autre appareil. Cliquez sur {HERE} pour recharger les paramètres. Cliquez ailleurs pour ignorer les modifications."; - readonly he: "ההגדרה \"${setting}\" שונ×Ēה ממכשיר אחר. לח×Ĩ ×ĸל {HERE} לט×ĸינה מחדש של ההגדרו×Ē. לח×Ĩ במקום אחר לה×Ē×ĸלמו×Ē ×ž×”×Š×™× ×•×™×™×."; - readonly ja: "č¨­åŽš\"${setting}\"がåˆĨぎデバイ゚から変更されぞした。{HERE}をクãƒĒックしãĻč¨­åŽšã‚’å†čĒ­ãŋčžŧãŋしãĻãã ã•ã„ã€‚å¤‰æ›´ã‚’į„ĄčĻ–ã™ã‚‹ãĢはäģ–ぎ場所をクãƒĒックしãĻください。"; - readonly ko: "\"${setting}\" ė„¤ė •ė´ 다ëĨ¸ ę¸°ę¸°ė—ė„œ ėˆ˜ė •ë˜ė—ˆėŠĩ니다. ė„¤ė •ė„ ë‹¤ė‹œ 로드하려면 {HERE}ëĨŧ 클ëĻ­í•˜ė„¸ėš”. ëŗ€ę˛Ŋė‚Ŧí•­ė„ ëŦ´ė‹œí•˜ë ¤ëŠ´ 다ëĨ¸ ęŗŗė„ 클ëĻ­í•˜ė„¸ėš”."; - readonly ru: "ĐĐ°ŅŅ‚Ņ€ĐžĐšĐēа setting ĐąŅ‹Đģа иСĐŧĐĩĐŊĐĩĐŊа ҁ Đ´Ņ€ŅƒĐŗĐžĐŗĐž ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°."; - readonly zh: "莞įŊŽ \"${setting}\" 厞äģŽåĻä¸€å°čŽžå¤‡äŋŽæ”šã€‚į‚šå‡ģ {HERE} 重新加čŊŊ莞įŊŽã€‚į‚šå‡ģå…ļäģ–地斚åŋŊį•Ĩ更攚"; - }; - readonly "obsidianLiveSyncSettingTab.msgSettingsUnchangeableDuringSync": { - readonly def: "These settings are unable to be changed during synchronization. Please disable all syncing in the \"Sync Settings\" to unlock."; - readonly es: "Estas configuraciones no se pueden cambiar durante la sincronizaciÃŗn. Por favor, deshabilita toda la sincronizaciÃŗn en las \"Configuraciones de SincronizaciÃŗn\" para desbloquear."; - readonly fr: "Ces paramètres ne peuvent pas ÃĒtre modifiÊs durant la synchronisation. DÊsactivez toute synchronisation dans ÂĢ Paramètres de synchronisation Âģ pour dÊverrouiller."; - readonly he: "הגדרו×Ē ××œ×” אינן ני×Ēנו×Ē ×œ×Š×™× ×•×™ במהלך סנכרון. אנא נטרל א×Ē ×›×œ הסנכרון ב\"הגדרו×Ē ×Ą× ×›×¨×•×Ÿ\" כדי לבטל × ×ĸילה."; - readonly ja: "ã“ã‚Œã‚‰ãŽč¨­åŽšã¯åŒæœŸä¸­ãĢå¤‰æ›´ã§ããžã›ã‚“ã€‚ãƒ­ãƒƒã‚¯ã‚’č§Ŗé™¤ã™ã‚‹ãĢは、\"åŒæœŸč¨­åŽš\"ですずãĻãŽåŒæœŸã‚’į„ĄåŠšãĢしãĻください。"; - readonly ko: "동기화 ė¤‘ė—ëŠ” ė´ ė„¤ė •ë“¤ė„ ëŗ€ę˛Ŋ할 눘 ė—†ėŠĩ니다. ėž ę¸ˆė„ í•´ė œí•˜ë ¤ëŠ´ \"동기화 네렕\"ė—ė„œ ëĒ¨ë“  동기화ëĨŧ ëš„í™œė„ąí™”í•´ ėŖŧė„¸ėš”."; - readonly ru: "Đ­Ņ‚Đ¸ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи ĐŊĐĩĐģŅŒĐˇŅ иСĐŧĐĩĐŊĐ¸Ņ‚ŅŒ вО Đ˛Ņ€ĐĩĐŧŅ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸."; - readonly zh: "čŋ™äē›čŽžįŊŽåœ¨åŒæ­ĨæœŸé—´æ— æŗ•æ›´æ”šã€‚č¯ˇåœ¨â€œåŒæ­Ĩ莞įŊŽâ€ä¸­įĻį”¨æ‰€æœ‰åŒæ­ĨäģĨ觪锁"; - }; - readonly "obsidianLiveSyncSettingTab.msgSetWwwAuth": { - readonly def: "Set httpd.WWW-Authenticate"; - readonly es: "Configurar httpd.WWW-Authenticate"; - readonly fr: "DÊfinir httpd.WWW-Authenticate"; - readonly he: "הגדר httpd.WWW-Authenticate"; - readonly ja: "httpd.WWW-Authenticateã‚’č¨­åŽš"; - readonly ko: "httpd.WWW-Authenticate 네렕"; - readonly ru: "ĐŖŅŅ‚Đ°ĐŊĐžĐ˛Đ¸Ņ‚ŅŒ httpd.WWW-Authenticate"; - readonly zh: "莞įŊŽ httpd.WWW-Authenticate"; - }; - readonly "obsidianLiveSyncSettingTab.nameApplySettings": { - readonly def: "Apply Settings"; - readonly es: "Aplicar configuraciones"; - readonly fr: "Appliquer les paramètres"; - readonly he: "החל הגדרו×Ē"; - readonly ja: "č¨­åŽšã‚’éŠį”¨"; - readonly ko: "네렕 ė ėšŠ"; - readonly ru: "ĐŸŅ€Đ¸ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи"; - readonly zh: "åē”į”¨čŽžįŊŽ"; - }; - readonly "obsidianLiveSyncSettingTab.nameConnectSetupURI": { - readonly def: "Connect with Setup URI"; - readonly es: "Conectar con URI de configuraciÃŗn"; - readonly fr: "Se connecter avec une URI de configuration"; - readonly he: "ה×Ēחבר ×ĸם Setup URI"; - readonly ja: "ã‚ģットã‚ĸップURIでæŽĨįļš"; - readonly ko: "Setup URI로 뗰枰"; - readonly ru: "ПодĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒŅŅ ҇ĐĩŅ€ĐĩС Setup URI"; - readonly zh: "äŊŋį”¨čŽžįŊŽ URI čŋžæŽĨ"; - }; - readonly "obsidianLiveSyncSettingTab.nameCopySetupURI": { - readonly def: "Copy the current settings to a Setup URI"; - readonly es: "Copiar la configuraciÃŗn actual a una URI de configuraciÃŗn"; - readonly fr: "Copier les paramètres actuels vers une URI de configuration"; - readonly he: "ה×ĸ×Ē×§ הגדרו×Ē × ×•×›×—×™×•×Ē ×œ-Setup URI"; - readonly ja: "įžåœ¨ãŽč¨­åŽšã‚’ã‚ģットã‚ĸップURIãĢã‚ŗãƒ”ãƒŧ"; - readonly ko: "현ėžŦ ė„¤ė •ė„ Setup URI로 ëŗĩė‚Ŧ"; - readonly ru: "КоĐŋĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ Ņ‚ĐĩĐēŅƒŅ‰Đ¸Đĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи в Setup URI"; - readonly zh: "将åŊ“å‰čŽžįŊŽå¤åˆļä¸ē莞įŊŽ URI"; - }; - readonly "obsidianLiveSyncSettingTab.nameDisableHiddenFileSync": { - readonly def: "Disable Hidden files sync"; - readonly es: "Desactivar sincronizaciÃŗn de archivos ocultos"; - readonly fr: "DÊsactiver la synchronisation des fichiers cachÊs"; - readonly he: "נטרל סנכרון קב×Ļים נץ×Ēרים"; - readonly ja: "éš ã—ãƒ•ã‚Ąã‚¤ãƒĢåŒæœŸã‚’į„ĄåŠšåŒ–"; - readonly ko: "ėˆ¨ęš€ 파ėŧ 동기화 ëš„í™œė„ąí™”"; - readonly ru: "ĐžŅ‚ĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸ŅŽ ҁĐēҀҋ҂ҋ҅ Ņ„Đ°ĐšĐģОв"; - readonly zh: "įĻį”¨éšč—æ–‡äģļ同æ­Ĩ"; - readonly "zh-tw": "åœį”¨éšąč—æĒ”æĄˆåŒæ­Ĩ"; - }; - readonly "obsidianLiveSyncSettingTab.nameDiscardSettings": { - readonly def: "Discard existing settings and databases"; - readonly es: "Descartar configuraciones y bases de datos existentes"; - readonly fr: "Abandonner les paramètres et bases existants"; - readonly he: "בטל הגדרו×Ē ×•×ž×Ą×“×™ × ×Ēונים קיימים"; - readonly ja: "æ—ĸå­˜ãŽč¨­åŽšã¨ãƒ‡ãƒŧã‚ŋベãƒŧã‚šã‚’į ´æŖ„"; - readonly ko: "ę¸°ėĄ´ 네렕 및 ë°ė´í„°ë˛ ė´ėŠ¤ ė‚­ė œ"; - readonly ru: "ĐžŅ‚ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ ŅŅƒŅ‰ĐĩŅŅ‚Đ˛ŅƒŅŽŅ‰Đ¸Đĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи и ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅"; - readonly zh: "ä¸ĸåŧƒįŽ°æœ‰čŽžįŊŽå’Œæ•°æŽåē“"; - }; - readonly "obsidianLiveSyncSettingTab.nameEnableHiddenFileSync": { - readonly def: "Enable Hidden files sync"; - readonly es: "Activar sincronizaciÃŗn de archivos ocultos"; - readonly fr: "Activer la synchronisation des fichiers cachÊs"; - readonly he: "הפ×ĸל סנכרון קב×Ļים נץ×Ēרים"; - readonly ja: "éš ã—ãƒ•ã‚Ąã‚¤ãƒĢ同期を有劚化"; - readonly ko: "ėˆ¨ęš€ 파ėŧ 동기화 í™œė„ąí™”"; - readonly ru: "ВĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸ŅŽ ҁĐēҀҋ҂ҋ҅ Ņ„Đ°ĐšĐģОв"; - readonly zh: "å¯į”¨éšč—æ–‡äģļ同æ­Ĩ"; - readonly "zh-tw": "å•Ÿį”¨éšąč—æĒ”æĄˆåŒæ­Ĩ"; - }; - readonly "obsidianLiveSyncSettingTab.nameEnableLiveSync": { - readonly def: "Enable LiveSync"; - readonly es: "Activar LiveSync"; - readonly fr: "Activer LiveSync"; - readonly he: "הפ×ĸל LiveSync"; - readonly ja: "LiveSyncを有劚化"; - readonly ko: "LiveSync í™œė„ąí™”"; - readonly ru: "ВĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒ LiveSync"; - readonly zh: "吝ᔍ LiveSync"; - }; - readonly "obsidianLiveSyncSettingTab.nameHiddenFileSynchronization": { - readonly def: "Hidden file synchronization"; - readonly es: "SincronizaciÃŗn de archivos ocultos"; - readonly fr: "Synchronisation des fichiers cachÊs"; - readonly he: "סנכרון קב×Ļים נץ×Ēרים"; - readonly ja: "éš ã—ãƒ•ã‚Ąã‚¤ãƒĢ同期"; - readonly ko: "ėˆ¨ęš€ 파ėŧ 동기화"; - readonly ru: "ХиĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Ņ ҁĐēҀҋ҂ҋ҅ Ņ„Đ°ĐšĐģОв"; - readonly zh: "éšč—æ–‡äģļ同æ­Ĩ"; - readonly "zh-tw": "隱藏æĒ”æĄˆåŒæ­Ĩ"; - }; - readonly "obsidianLiveSyncSettingTab.nameManualSetup": { - readonly def: "Manual Setup"; - readonly es: "ConfiguraciÃŗn manual"; - readonly fr: "Configuration manuelle"; - readonly he: "הגדרה ידני×Ē"; - readonly ja: "手動ã‚ģットã‚ĸップ"; - readonly ko: "ėˆ˜ë™ 네렕"; - readonly ru: "Đ ŅƒŅ‡ĐŊĐ°Ņ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēа"; - readonly zh: "æ‰‹åŠ¨čŽžįŊŽ"; - }; - readonly "obsidianLiveSyncSettingTab.nameTestConnection": { - readonly def: "Test Connection"; - readonly es: "Probar conexiÃŗn"; - readonly fr: "Tester la connexion"; - readonly he: "בדוק חיבור"; - readonly ja: "æŽĨįļšãƒ†ã‚šãƒˆ"; - readonly ko: "뗰枰 í…ŒėŠ¤íŠ¸"; - readonly ru: "ĐĸĐĩҁ҂ ĐŋОдĐēĐģŅŽŅ‡ĐĩĐŊĐ¸Ņ"; - readonly zh: "æĩ‹č¯•čŋžæŽĨ"; - }; - readonly "obsidianLiveSyncSettingTab.nameTestDatabaseConnection": { - readonly def: "Test Database Connection"; - readonly es: "Probar ConexiÃŗn de Base de Datos"; - readonly fr: "Tester la connexion à la base de donnÊes"; - readonly he: "בדוק חיבור למסד × ×Ēונים"; - readonly ja: "デãƒŧã‚ŋベãƒŧ゚æŽĨįļšãƒ†ã‚šãƒˆ"; - readonly ko: "ë°ė´í„°ë˛ ė´ėŠ¤ 뗰枰 í…ŒėŠ¤íŠ¸"; - readonly ru: "ĐĸĐĩҁ҂ ĐŋОдĐēĐģŅŽŅ‡ĐĩĐŊĐ¸Ņ Đē йаСĐĩ даĐŊĐŊҋ҅"; - readonly zh: "æĩ‹č¯•数捎åē“čŋžæŽĨ"; - }; - readonly "obsidianLiveSyncSettingTab.nameValidateDatabaseConfig": { - readonly def: "Validate Database Configuration"; - readonly es: "Validar ConfiguraciÃŗn de la Base de Datos"; - readonly fr: "Valider la configuration de la base de donnÊes"; - readonly he: "אמ×Ē ×Ē×Ļור×Ē ×ž×Ą×“ × ×Ēונים"; - readonly ja: "デãƒŧã‚ŋベãƒŧã‚šč¨­åŽšã‚’æ¤œč¨ŧ"; - readonly ko: "ë°ė´í„°ë˛ ė´ėŠ¤ ęĩŦė„ą 검ėĻ"; - readonly ru: "ĐŸŅ€ĐžĐ˛ĐĩŅ€Đ¸Ņ‚ŅŒ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸ŅŽ ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅"; - readonly zh: "éĒŒč¯æ•°æŽåē“配įŊŽ"; - }; - readonly "obsidianLiveSyncSettingTab.okAdminPrivileges": { - readonly def: "✔ You have administrator privileges."; - readonly es: "✔ Tienes privilegios de administrador."; - readonly fr: "✔ Vous disposez des privilèges administrateur."; - readonly he: "✔ יש לך הרשאו×Ē ×ž× ×”×œ."; - readonly ja: "✔ įŽĄį†č€…æ¨Šé™ãŒã‚ã‚Šãžã™ã€‚"; - readonly ko: "✔ 관ëĻŦėž ęļŒí•œė´ ėžˆėŠĩ니다."; - readonly ru: "✔ ĐŖ Đ˛Đ°Ņ ĐĩŅŅ‚ŅŒ ĐŋŅ€Đ°Đ˛Đ° адĐŧиĐŊĐ¸ŅŅ‚Ņ€Đ°Ņ‚ĐžŅ€Đ°."; - readonly zh: "✔ 您æ‹Ĩæœ‰įŽĄį†å‘˜æƒé™"; - }; - readonly "obsidianLiveSyncSettingTab.okCorsCredentials": { - readonly def: "✔ cors.credentials is ok."; - readonly es: "✔ cors.credentials estÃĄ correcto."; - readonly fr: "✔ cors.credentials est correct."; - readonly he: "✔ cors.credentials ×Ēקין."; - readonly ja: "✔ cors.credentialsã¯æ­Ŗå¸¸ã§ã™ã€‚"; - readonly ko: "✔ cors.credentials가 ė •ėƒėž…ë‹ˆë‹¤."; - readonly ru: "✔ cors.credentials в ĐŋĐžŅ€ŅĐ´ĐēĐĩ."; - readonly zh: "✔ cors.credentials 莞įŊŽæ­ŖįĄŽ"; - }; - readonly "obsidianLiveSyncSettingTab.okCorsCredentialsForOrigin": { - readonly def: "CORS credentials OK"; - readonly es: "CORS credenciales OK"; - readonly fr: "Identifiants CORS OK"; - readonly he: "CORS credentials ×Ēקין"; - readonly ja: "CORSčĒč¨ŧæƒ…å ąOK"; - readonly ko: "CORS ėžę˛Š ėĻëĒ… ė •ėƒ"; - readonly ru: "CORS ŅƒŅ‡Ņ‘Ņ‚ĐŊŅ‹Đĩ даĐŊĐŊŅ‹Đĩ в ĐŋĐžŅ€ŅĐ´ĐēĐĩ"; - readonly zh: "CORS å‡­æŽæ­Ŗå¸¸"; - }; - readonly "obsidianLiveSyncSettingTab.okCorsOriginMatched": { - readonly def: "✔ CORS origin OK"; - readonly es: "✔ Origen de CORS correcto"; - readonly fr: "✔ Origine CORS OK"; - readonly he: "✔ CORS origin ×Ēקין"; - readonly ja: "✔ CORSã‚ĒãƒĒã‚¸ãƒŗOK"; - readonly ko: "✔ CORS 뛐렐 ė •ėƒ"; - readonly ru: "✔ CORS origin в ĐŋĐžŅ€ŅĐ´ĐēĐĩ"; - readonly zh: "✔ CORS æēæ­Ŗå¸¸"; - }; - readonly "obsidianLiveSyncSettingTab.okCorsOrigins": { - readonly def: "✔ cors.origins is ok."; - readonly es: "✔ cors.origins estÃĄ correcto."; - readonly fr: "✔ cors.origins est correct."; - readonly he: "✔ cors.origins ×Ēקין."; - readonly ja: "✔ cors.originsã¯æ­Ŗå¸¸ã§ã™ã€‚"; - readonly ko: "✔ cors.origins가 ė •ėƒėž…ë‹ˆë‹¤."; - readonly ru: "✔ cors.origins в ĐŋĐžŅ€ŅĐ´ĐēĐĩ."; - readonly zh: "✔ cors.origins 莞įŊŽæ­ŖįĄŽ"; - }; - readonly "obsidianLiveSyncSettingTab.okEnableCors": { - readonly def: "✔ httpd.enable_cors is ok."; - readonly es: "✔ httpd.enable_cors estÃĄ correcto."; - readonly fr: "✔ httpd.enable_cors est correct."; - readonly he: "✔ httpd.enable_cors ×Ēקין."; - readonly ja: "✔ httpd.enable_corsã¯æ­Ŗå¸¸ã§ã™ã€‚"; - readonly ko: "✔ httpd.enable_cors가 ė •ėƒėž…ë‹ˆë‹¤."; - readonly ru: "✔ httpd.enable_cors в ĐŋĐžŅ€ŅĐ´ĐēĐĩ."; - readonly zh: "✔ httpd.enable_cors 莞įŊŽæ­ŖįĄŽ"; - }; - readonly "obsidianLiveSyncSettingTab.okEnableCorsChttpd": { - readonly def: "✔ chttpd.enable_cors is ok."; - readonly fr: "✔ chttpd.enable_cors est correct."; - readonly he: "✔ chttpd.enable_cors ×Ēקין."; - readonly ja: "✔ chttpd.enable_corsã¯æ­Ŗå¸¸ã§ã™ã€‚"; - readonly ru: "✔ chttpd.enable_cors в ĐŋĐžŅ€ŅĐ´ĐēĐĩ."; - readonly zh: "✔ chttpd.enable_cors is ok."; - }; - readonly "obsidianLiveSyncSettingTab.okMaxDocumentSize": { - readonly def: "✔ couchdb.max_document_size is ok."; - readonly es: "✔ couchdb.max_document_size estÃĄ correcto."; - readonly fr: "✔ couchdb.max_document_size est correct."; - readonly he: "✔ couchdb.max_document_size ×Ēקין."; - readonly ja: "✔ couchdb.max_document_sizeã¯æ­Ŗå¸¸ã§ã™ã€‚"; - readonly ko: "✔ couchdb.max_document_size가 ė •ėƒėž…ë‹ˆë‹¤."; - readonly ru: "✔ couchdb.max_document_size в ĐŋĐžŅ€ŅĐ´ĐēĐĩ."; - readonly zh: "✔ couchdb.max_document_size 莞įŊŽæ­ŖįĄŽ"; - }; - readonly "obsidianLiveSyncSettingTab.okMaxRequestSize": { - readonly def: "✔ chttpd.max_http_request_size is ok."; - readonly es: "✔ chttpd.max_http_request_size estÃĄ correcto."; - readonly fr: "✔ chttpd.max_http_request_size est correct."; - readonly he: "✔ chttpd.max_http_request_size ×Ēקין."; - readonly ja: "✔ chttpd.max_http_request_sizeã¯æ­Ŗå¸¸ã§ã™ã€‚"; - readonly ko: "✔ chttpd.max_http_request_size가 ė •ėƒėž…ë‹ˆë‹¤."; - readonly ru: "✔ chttpd.max_http_request_size в ĐŋĐžŅ€ŅĐ´ĐēĐĩ."; - readonly zh: "✔ chttpd.max_http_request_size 莞įŊŽæ­ŖįĄŽ"; - }; - readonly "obsidianLiveSyncSettingTab.okRequireValidUser": { - readonly def: "✔ chttpd.require_valid_user is ok."; - readonly es: "✔ chttpd.require_valid_user estÃĄ correcto."; - readonly fr: "✔ chttpd.require_valid_user est correct."; - readonly he: "✔ chttpd.require_valid_user ×Ēקין."; - readonly ja: "✔ chttpd.require_valid_userã¯æ­Ŗå¸¸ã§ã™ã€‚"; - readonly ko: "✔ chttpd.require_valid_user가 ė •ėƒėž…ë‹ˆë‹¤."; - readonly ru: "✔ chttpd.require_valid_user в ĐŋĐžŅ€ŅĐ´ĐēĐĩ."; - readonly zh: "✔ chttpd.require_valid_user 莞įŊŽæ­ŖįĄŽ"; - }; - readonly "obsidianLiveSyncSettingTab.okRequireValidUserAuth": { - readonly def: "✔ chttpd_auth.require_valid_user is ok."; - readonly es: "✔ chttpd_auth.require_valid_user estÃĄ correcto."; - readonly fr: "✔ chttpd_auth.require_valid_user est correct."; - readonly he: "✔ chttpd_auth.require_valid_user ×Ēקין."; - readonly ja: "✔ chttpd_auth.require_valid_userã¯æ­Ŗå¸¸ã§ã™ã€‚"; - readonly ko: "✔ chttpd_auth.require_valid_user가 ė •ėƒėž…ë‹ˆë‹¤."; - readonly ru: "✔ chttpd_auth.require_valid_user в ĐŋĐžŅ€ŅĐ´ĐēĐĩ."; - readonly zh: "✔ chttpd_auth.require_valid_user 莞įŊŽæ­ŖįĄŽ"; - }; - readonly "obsidianLiveSyncSettingTab.okWwwAuth": { - readonly def: "✔ httpd.WWW-Authenticate is ok."; - readonly es: "✔ httpd.WWW-Authenticate estÃĄ correcto."; - readonly fr: "✔ httpd.WWW-Authenticate est correct."; - readonly he: "✔ httpd.WWW-Authenticate ×Ēקין."; - readonly ja: "✔ httpd.WWW-Authenticateã¯æ­Ŗå¸¸ã§ã™ã€‚"; - readonly ko: "✔ httpd.WWW-Authenticate가 ė •ėƒėž…ë‹ˆë‹¤."; - readonly ru: "✔ httpd.WWW-Authenticate в ĐŋĐžŅ€ŅĐ´ĐēĐĩ."; - readonly zh: "✔ httpd.WWW-Authenticate 莞įŊŽæ­ŖįĄŽ"; - }; - readonly "obsidianLiveSyncSettingTab.optionApply": { - readonly def: "Apply"; - readonly es: "Aplicar"; - readonly fr: "Appliquer"; - readonly he: "החל"; - readonly ja: "éŠį”¨"; - readonly ko: "ė ėšŠ"; - readonly ru: "ĐŸŅ€Đ¸ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ"; - readonly zh: "åē”ᔍ"; - }; - readonly "obsidianLiveSyncSettingTab.optionCancel": { - readonly def: "Cancel"; - readonly es: "Cancelar"; - readonly fr: "Annuler"; - readonly he: "ביטול"; - readonly ja: "ã‚­ãƒŖãƒŗã‚ģãƒĢ"; - readonly ko: "ėˇ¨ė†Œ"; - readonly ru: "ĐžŅ‚ĐŧĐĩĐŊа"; - readonly zh: "取æļˆ"; - }; - readonly "obsidianLiveSyncSettingTab.optionCouchDB": { - readonly def: "CouchDB"; - readonly es: "CouchDB"; - readonly fr: "CouchDB"; - readonly he: "CouchDB"; - readonly ja: "CouchDB"; - readonly ko: "CouchDB"; - readonly ru: "CouchDB"; - readonly zh: "CouchDB"; - }; - readonly "obsidianLiveSyncSettingTab.optionDisableAllAutomatic": { - readonly def: "Disable all automatic"; - readonly es: "Desactivar lo automÃĄtico"; - readonly fr: "DÊsactiver toute automatisation"; - readonly he: "נטרל א×Ē ×›×œ האוטומטי"; - readonly ja: "すずãĻぎč‡Ēå‹•ã‚’į„ĄåŠšåŒ–"; - readonly ko: "ëĒ¨ë“  ėžë™ ëš„í™œė„ąí™”"; - readonly ru: "ĐžŅ‚ĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒ Đ˛ŅŅ‘ Đ°Đ˛Ņ‚ĐžĐŧĐ°Ņ‚Đ¸Ņ‡ĐĩҁĐēĐžĐĩ"; - readonly zh: "įĻį”¨æ‰€æœ‰č‡Ē动同æ­Ĩ"; - readonly "zh-tw": "åœį”¨æ‰€æœ‰č‡Ē動同æ­Ĩ"; - }; - readonly "obsidianLiveSyncSettingTab.optionFetchFromRemote": { - readonly def: "Fetch from Remote"; - readonly es: "Obtener del remoto"; - readonly fr: "RÊcupÊrer depuis le distant"; - readonly he: "משוך מהשר×Ē ×”×ž×¨×•×—×“"; - readonly ja: "ãƒĒãƒĸãƒŧトからフェッチ"; - readonly ko: "ė›ę˛Šė—ė„œ ę°€ė ¸ė˜¤ę¸°"; - readonly ru: "Đ—Đ°ĐŗŅ€ŅƒĐˇĐ¸Ņ‚ŅŒ ҁ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐŗĐž"; - readonly zh: "äģŽčŋœį¨‹čŽˇå–"; - }; - readonly "obsidianLiveSyncSettingTab.optionHere": { - readonly def: "HERE"; - readonly es: "AQUÍ"; - readonly fr: "ICI"; - readonly he: "כאן"; - readonly ja: "ここ"; - readonly ko: "ė—Ŧ기"; - readonly ru: "ЗДЕСĐŦ"; - readonly zh: "čŋ™é‡Œ"; - }; - readonly "obsidianLiveSyncSettingTab.optionLiveSync": { - readonly def: "LiveSync"; - readonly es: "SincronizaciÃŗn LiveSync"; - readonly fr: "LiveSync"; - readonly he: "LiveSync"; - readonly ja: "LiveSync 同期"; - readonly ko: "LiveSync 동기화"; - readonly ru: "ХиĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Ņ LiveSync"; - readonly zh: "LiveSync 同æ­Ĩ"; - readonly "zh-tw": "LiveSync 同æ­Ĩ"; - }; - readonly "obsidianLiveSyncSettingTab.optionMinioS3R2": { - readonly def: "Minio,S3,R2"; - readonly es: "Minio,S3,R2"; - readonly fr: "Minio, S3, R2"; - readonly he: "Minio,S3,R2"; - readonly ja: "Minio,S3,R2"; - readonly ko: "Minio,S3,R2"; - readonly ru: "Minio,S3,R2"; - readonly zh: "Minio, S3, R2"; - }; - readonly "obsidianLiveSyncSettingTab.optionOkReadEverything": { - readonly def: "OK, I have read everything."; - readonly es: "OK, he leído todo."; - readonly fr: "OK, j'ai tout lu."; - readonly he: "בסדר, קרא×Ēי הכל."; - readonly ja: "OK、すずãĻčĒ­ãŋぞした。"; - readonly ko: "네, ëĒ¨ë“  ę˛ƒė„ ėŊė—ˆėŠĩ니다."; - readonly ru: "ОК, Ņ Đ˛ŅŅ‘ ĐŋŅ€ĐžŅ‡Đ¸Ņ‚Đ°Đģ."; - readonly zh: "åĨŊįš„,æˆ‘åˇ˛įģé˜…č¯ģä熿‰€æœ‰å†…厚 "; - }; - readonly "obsidianLiveSyncSettingTab.optionOnEvents": { - readonly def: "On events"; - readonly es: "En eventos"; - readonly fr: "Sur ÊvÊnements"; - readonly he: "×ĸל אירו×ĸים"; - readonly ja: "ã‚¤ãƒ™ãƒŗãƒˆæ™‚"; - readonly ko: "ė´ë˛¤íŠ¸ ė‹œ"; - readonly ru: "По ŅĐžĐąŅ‹Ņ‚Đ¸ŅĐŧ"; - readonly zh: "äē‹äģļč§Ļ发æ—ļ"; - readonly "zh-tw": "äē‹äģļ觸į™ŧ時"; - }; - readonly "obsidianLiveSyncSettingTab.optionPeriodicAndEvents": { - readonly def: "Periodic and on events"; - readonly es: "PeriÃŗdico y en eventos"; - readonly fr: "PÊriodique et sur ÊvÊnements"; - readonly he: "×Ēקופ×Ēי ו×ĸל אירו×ĸים"; - readonly ja: "åŽšæœŸãŠã‚ˆãŗã‚¤ãƒ™ãƒŗãƒˆæ™‚"; - readonly ko: "ėŖŧ揰렁 및 ė´ë˛¤íŠ¸ ė‹œ"; - readonly ru: "ПĐĩŅ€Đ¸ĐžĐ´Đ¸Ņ‡ĐĩҁĐēи и ĐŋĐž ŅĐžĐąŅ‹Ņ‚Đ¸ŅĐŧ"; - readonly zh: "厚期与äē‹äģļč§Ļ发"; - readonly "zh-tw": "åŽšæœŸčˆ‡äē‹äģļ觸į™ŧ"; - }; - readonly "obsidianLiveSyncSettingTab.optionPeriodicWithBatch": { - readonly def: "Periodic w/ batch"; - readonly es: "PeriÃŗdico con lote"; - readonly fr: "PÊriodique avec lot"; - readonly he: "×Ēקופ×Ēי ×ĸם א×Ļווה"; - readonly ja: "バッチäģ˜ãåŽšæœŸ"; - readonly ko: "ėŖŧ揰렁 w/ ėŧ괄"; - readonly ru: "ПĐĩŅ€Đ¸ĐžĐ´Đ¸Ņ‡ĐĩҁĐēи ҁ ĐŋаĐēĐĩŅ‚Đ°Đŧи"; - readonly zh: "厚期īŧˆæ‰šå¤„ᐆīŧ‰"; - readonly "zh-tw": "厚期īŧˆæ‰šæŦĄīŧ‰"; - }; - readonly "obsidianLiveSyncSettingTab.optionRebuildBoth": { - readonly def: "Rebuild Both from This Device"; - readonly es: "Reconstructuir ambos desde este dispositivo"; - readonly fr: "Tout reconstruire depuis cet appareil"; - readonly he: "בנה שניהם מחדש ממכשיר זה"; - readonly ja: "ã“ãŽãƒ‡ãƒã‚¤ã‚šã‹ã‚‰ä¸Ąæ–šã‚’å†æ§‹į¯‰"; - readonly ko: "ė´ ę¸°ę¸°ė—ė„œ 둘 다 ėžŦęĩŦėļ•"; - readonly ru: "ПĐĩŅ€ĐĩŅŅ‚Ņ€ĐžĐ¸Ņ‚ŅŒ Ойа ҁ ŅŅ‚ĐžĐŗĐž ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°"; - readonly zh: "äģŽæ­¤čŽžå¤‡é‡åģē两者"; - }; - readonly "obsidianLiveSyncSettingTab.optionSaveOnlySettings": { - readonly def: "(Danger) Save Only Settings"; - readonly es: "(Peligro) Guardar solo configuraciÃŗn"; - readonly fr: "(Danger) N'enregistrer que les paramètres"; - readonly he: "(סכנה) שמור הגדרו×Ē ×‘×œ×‘×“"; - readonly ja: "(åąé™ē) č¨­åŽšãŽãŋäŋå­˜"; - readonly ko: "(ėœ„í—˜) ė„¤ė •ë§Œ ė €ėžĨ"; - readonly ru: "(ОĐŋĐ°ŅĐŊĐž) ĐĄĐžŅ…Ņ€Đ°ĐŊĐ¸Ņ‚ŅŒ Ņ‚ĐžĐģҌĐēĐž ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи"; - readonly zh: "(åąé™Š)äģ…äŋå­˜čŽžįŊŽ"; - }; - readonly "obsidianLiveSyncSettingTab.panelChangeLog": { - readonly def: "Change Log"; - readonly es: "Registro de cambios"; - readonly fr: "Journal des modifications"; - readonly he: "יומן שינויים"; - readonly ja: "変更åąĨæ­´"; - readonly ko: "ëŗ€ę˛Ŋ 로그"; - readonly ru: "Đ˜ŅŅ‚ĐžŅ€Đ¸Ņ иСĐŧĐĩĐŊĐĩĐŊиК"; - readonly zh: "更新æ—Ĩåŋ—"; - }; - readonly "obsidianLiveSyncSettingTab.panelGeneralSettings": { - readonly def: "General Settings"; - readonly es: "Configuraciones Generales"; - readonly fr: "Paramètres gÊnÊraux"; - readonly he: "הגדרו×Ē ×›×œ×œ×™×•×Ē"; - readonly ja: "一čˆŦč¨­åŽš"; - readonly ko: "ėŧ반 네렕"; - readonly ru: "ĐžŅĐŊОвĐŊŅ‹Đĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи"; - readonly zh: "å¸¸č§„čŽžįŊŽ"; - }; - readonly "obsidianLiveSyncSettingTab.panelPrivacyEncryption": { - readonly def: "Privacy & Encryption"; - readonly es: "Privacidad y Cifrado"; - readonly fr: "ConfidentialitÊ et chiffrement"; - readonly he: "פרטיו×Ē ×•×”×Ļפנה"; - readonly ja: "ãƒ—ãƒŠã‚¤ãƒã‚ˇãƒŧã¨æš—åˇåŒ–"; - readonly ko: "ę°œė¸ė •ëŗ´ ëŗ´í˜¸ 및 ė•”í˜¸í™”"; - readonly ru: "КоĐŊŅ„Đ¸Đ´ĐĩĐŊŅ†Đ¸Đ°ĐģҌĐŊĐžŅŅ‚ŅŒ и ŅˆĐ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊиĐĩ"; - readonly zh: "éšį§ä¸ŽåŠ å¯†"; - }; - readonly "obsidianLiveSyncSettingTab.panelRemoteConfiguration": { - readonly def: "Remote Configuration"; - readonly es: "ConfiguraciÃŗn remota"; - readonly fr: "Configuration distante"; - readonly he: "×Ē×Ļור×Ē ×Š×¨×Ē ×ž×¨×•×—×“"; - readonly ja: "ãƒĒãƒĸãƒŧãƒˆč¨­åŽš"; - readonly ko: "ė›ę˛Š ęĩŦė„ą"; - readonly ru: "ĐŖĐ´Đ°ĐģŅ‘ĐŊĐŊĐ°Ņ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Ņ"; - readonly zh: "čŋœį¨‹é…įŊŽ"; - }; - readonly "obsidianLiveSyncSettingTab.panelSetup": { - readonly def: "Setup"; - readonly es: "ConfiguraciÃŗn"; - readonly fr: "Configuration"; - readonly he: "הגדרה"; - readonly ja: "ã‚ģットã‚ĸップ"; - readonly ko: "네렕"; - readonly ru: "ĐĐ°ŅŅ‚Ņ€ĐžĐšĐēа"; - readonly zh: "莞įŊŽ"; - }; - readonly "obsidianLiveSyncSettingTab.serverVersion": { - readonly def: "Server info: ${info}"; - readonly fr: "Infos serveur : ${info}"; - readonly he: "פרטי ׊ר×Ē: ${info}"; - readonly ja: "ã‚ĩãƒŧバãƒŧæƒ…å ą: ${info}"; - readonly ru: "ИĐŊŅ„ĐžŅ€ĐŧĐ°Ņ†Đ¸Ņ Đž ҁĐĩŅ€Đ˛ĐĩŅ€Đĩ: info"; - readonly zh: "æœåŠĄå™¨äŋĄæ¯: ${info}"; - }; - readonly "obsidianLiveSyncSettingTab.titleActiveRemoteServer": { - readonly def: "Active Remote Server"; - readonly fr: "Serveur distant actif"; - readonly he: "׊ר×Ē ×ž×¨×•×—×“ פ×ĸיל"; - readonly ja: "ã‚ĸã‚¯ãƒ†ã‚Ŗãƒ–ãĒãƒĒãƒĸãƒŧトã‚ĩãƒŧバãƒŧ"; - readonly ru: "АĐēŅ‚Đ¸Đ˛ĐŊŅ‹Đš ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊŅ‹Đš ҁĐĩŅ€Đ˛ĐĩŅ€"; - readonly zh: "æ´ģ动čŋœį¨‹æœåŠĄå™¨"; - }; - readonly "obsidianLiveSyncSettingTab.titleAppearance": { - readonly def: "Appearance"; - readonly es: "Apariencia"; - readonly fr: "Apparence"; - readonly he: "מראה"; - readonly ja: "外čĻŗ"; - readonly ko: "뙏洀"; - readonly ru: "ВĐŊĐĩ҈ĐŊиК вид"; - readonly zh: "外观"; - readonly "zh-tw": "外觀"; - }; - readonly "obsidianLiveSyncSettingTab.titleConflictResolution": { - readonly def: "Conflict resolution"; - readonly es: "ResoluciÃŗn de conflictos"; - readonly fr: "RÊsolution des conflits"; - readonly he: "פ×Ēרון קונפליקטים"; - readonly ja: "įĢļ合觪æąē"; - readonly ko: "ėļŠëŒ 해결"; - readonly ru: "Đ Đ°ĐˇŅ€Đĩ҈ĐĩĐŊиĐĩ ĐēĐžĐŊŅ„ĐģиĐēŅ‚ĐžĐ˛"; - readonly zh: "冞įǁ处ᐆ"; - readonly "zh-tw": "衝įǁ處ᐆ"; - }; - readonly "obsidianLiveSyncSettingTab.titleCongratulations": { - readonly def: "Congratulations!"; - readonly es: "ÂĄFelicidades!"; - readonly fr: "FÊlicitations !"; - readonly he: "מזל טוב!"; - readonly ja: "おめでとうございぞすīŧ"; - readonly ko: "ėļ•하핊니다!"; - readonly ru: "ĐŸĐžĐˇĐ´Ņ€Đ°Đ˛ĐģŅĐĩĐŧ!"; - readonly zh: "恭喜īŧ"; - readonly "zh-tw": "恭喜īŧ"; - }; - readonly "obsidianLiveSyncSettingTab.titleCouchDB": { - readonly def: "CouchDB"; - readonly es: "Servidor CouchDB"; - readonly fr: "CouchDB"; - readonly he: "CouchDB"; - readonly ja: "CouchDB ã‚ĩãƒŧバãƒŧ"; - readonly ko: "CouchDB ė„œë˛„"; - readonly ru: "ĐĄĐĩŅ€Đ˛ĐĩŅ€ CouchDB"; - readonly zh: "CouchDB æœåŠĄå™¨"; - readonly "zh-tw": "CouchDB äŧ翜å™¨"; - }; - readonly "obsidianLiveSyncSettingTab.titleDeletionPropagation": { - readonly def: "Deletion Propagation"; - readonly es: "PropagaciÃŗn de eliminaciÃŗn"; - readonly fr: "Propagation des suppressions"; - readonly he: "הפ×Ļ×Ē ×ž×—×™×§×•×Ē"; - readonly ja: "削除ぎäŧæ’­"; - readonly ko: "ė‚­ė œ ė „íŒŒ"; - readonly ru: "Đ Đ°ŅĐŋŅ€ĐžŅŅ‚Ņ€Đ°ĐŊĐĩĐŊиĐĩ ŅƒĐ´Đ°ĐģĐĩĐŊиК"; - readonly zh: "删除äŧ æ’­"; - readonly "zh-tw": "åˆĒé™¤å‚ŗæ’­"; - }; - readonly "obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": { - readonly def: "Encryption is not enabled"; - readonly es: "El cifrado no estÃĄ habilitado"; - readonly fr: "Le chiffrement n'est pas activÊ"; - readonly he: "הה×Ļפנה אינה מופ×ĸל×Ē"; - readonly ja: "æš—åˇåŒ–ãŒæœ‰åŠšãĢãĒãŖãĻいぞせん"; - readonly ko: "ė•”í˜¸í™”ę°€ í™œė„ąí™”ë˜ė§€ ė•ŠėŒ"; - readonly ru: "Đ¨Đ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊиĐĩ ĐŊĐĩ вĐēĐģŅŽŅ‡ĐĩĐŊĐž"; - readonly zh: "尚æœĒå¯į”¨åŠ å¯†"; - readonly "zh-tw": "尚æœĒå•Ÿį”¨åŠ å¯†"; - }; - readonly "obsidianLiveSyncSettingTab.titleEncryptionPassphraseInvalid": { - readonly def: "Encryption Passphrase Invalid"; - readonly es: "La frase de contraseÃąa de cifrado es invÃĄlida"; - readonly fr: "Phrase secrète de chiffrement invalide"; - readonly he: "ביטוי סיסמה לה×Ļפנה לא ×Ēקין"; - readonly ja: "æš—åˇåŒ–ãƒ‘ã‚šãƒ•ãƒŦãƒŧã‚ēãŒį„ĄåŠšã§ã™"; - readonly ko: "ė•”í˜¸í™” íŒ¨ėŠ¤í”„ë ˆė´ėψ ėœ íš¨í•˜ė§€ ė•ŠėŒ"; - readonly ru: "ĐŸĐ°Ņ€ĐžĐģҌĐŊĐ°Ņ Ņ„Ņ€Đ°ĐˇĐ° ŅˆĐ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊĐ¸Ņ ĐŊĐĩĐ´ĐĩĐšŅŅ‚Đ˛Đ¸Ņ‚ĐĩĐģҌĐŊа"; - readonly zh: "åŠ å¯†å¯†į įŸ­č¯­æ— æ•ˆ"; - readonly "zh-tw": "加密密čĒžį„Ąæ•ˆ"; - }; - readonly "obsidianLiveSyncSettingTab.titleExtraFeatures": { - readonly def: "Enable extra and advanced features"; - readonly es: "Habilitar funciones extras y avanzadas"; - readonly fr: "Activer les fonctionnalitÊs supplÊmentaires et avancÊes"; - readonly he: "הפ×ĸל ×Ēכונו×Ē × ×•×Ą×¤×•×Ē ×•×ž×Ēקדמו×Ē"; - readonly ja: "čŋŊåŠ ãŠã‚ˆãŗä¸Šį´šæŠŸčƒŊを有劚化"; - readonly ko: "ėļ”ę°€ 및 溠揉 기ëŠĨ í™œė„ąí™”"; - readonly ru: "ВĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒ Đ´ĐžĐŋĐžĐģĐŊĐ¸Ņ‚ĐĩĐģҌĐŊŅ‹Đĩ и Ņ€Đ°ŅŅˆĐ¸Ņ€ĐĩĐŊĐŊŅ‹Đĩ Ņ„ŅƒĐŊĐēŅ†Đ¸Đ¸"; - readonly zh: "吝ᔍéĸå¤–å’Œé̘įē§åŠŸčƒŊ"; - }; - readonly "obsidianLiveSyncSettingTab.titleFetchConfig": { - readonly def: "Fetch Config"; - readonly es: "Obtener configuraciÃŗn"; - readonly fr: "RÊcupÊrer la configuration"; - readonly he: "משוך ×Ē×Ļורה"; - readonly ja: "č¨­åŽšã‚’å–åž—"; - readonly ko: "ęĩŦė„ą ę°€ė ¸ė˜¤ę¸°"; - readonly ru: "Đ—Đ°ĐŗŅ€ŅƒĐˇĐ¸Ņ‚ŅŒ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸ŅŽ"; - readonly zh: "čŽˇå–é…įŊŽ"; - readonly "zh-tw": "æŠ“å–č¨­åŽš"; - }; - readonly "obsidianLiveSyncSettingTab.titleFetchConfigFromRemote": { - readonly def: "Fetch config from remote server"; - readonly es: "Obtener configuraciÃŗn del servidor remoto"; - readonly fr: "RÊcupÊrer la configuration depuis le serveur distant"; - readonly he: "משוך ×Ē×Ļורה מהשר×Ē ×”×ž×¨×•×—×“"; - readonly ja: "ãƒĒãƒĸãƒŧトã‚ĩãƒŧバãƒŧã‹ã‚‰č¨­åŽšã‚’å–åž—"; - readonly ko: "ė›ę˛Š ė„œë˛„ė—ė„œ ęĩŦė„ą ę°€ė ¸ė˜¤ę¸°"; - readonly ru: "Đ—Đ°ĐŗŅ€ŅƒĐˇĐ¸Ņ‚ŅŒ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸ŅŽ ҁ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐŗĐž ҁĐĩŅ€Đ˛ĐĩŅ€Đ°"; - readonly zh: "äģŽčŋœį¨‹æœåŠĄå™¨čŽˇå–é…įŊŽ"; - }; - readonly "obsidianLiveSyncSettingTab.titleFetchSettings": { - readonly def: "Fetch Settings"; - readonly es: "Obtener configuraciones"; - readonly fr: "RÊcupÊrer les paramètres"; - readonly he: "משוך הגדרו×Ē"; - readonly ja: "č¨­åŽšãŽå–åž—"; - readonly ko: "네렕 ę°€ė ¸ė˜¤ę¸°"; - readonly ru: "Đ—Đ°ĐŗŅ€ŅƒĐˇĐ¸Ņ‚ŅŒ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи"; - readonly zh: "čŽˇå–čŽžįŊŽ"; - }; - readonly "obsidianLiveSyncSettingTab.titleHiddenFiles": { - readonly def: "Hidden Files"; - readonly es: "Archivos ocultos"; - readonly fr: "Fichiers cachÊs"; - readonly he: "קב×Ļים נץ×Ēרים"; - readonly ja: "éš ã—ãƒ•ã‚Ąã‚¤ãƒĢ"; - readonly ko: "ėˆ¨ęš€ 파ėŧ"; - readonly ru: "ĐĄĐēҀҋ҂ҋĐĩ Ņ„Đ°ĐšĐģŅ‹"; - readonly zh: "éšč—æ–‡äģļ"; - readonly "zh-tw": "隱藏æĒ”æĄˆ"; - }; - readonly "obsidianLiveSyncSettingTab.titleLogging": { - readonly def: "Logging"; - readonly es: "Registro"; - readonly fr: "Journalisation"; - readonly he: "רישום יומן"; - readonly ja: "ログ"; - readonly ko: "로깅"; - readonly ru: "Đ›ĐžĐŗĐ¸Ņ€ĐžĐ˛Đ°ĐŊиĐĩ"; - readonly zh: "æ—Ĩåŋ—"; - readonly "zh-tw": "記錄"; - }; - readonly "obsidianLiveSyncSettingTab.titleMinioS3R2": { - readonly def: "Minio,S3,R2"; - readonly es: "MinIO, S3, R2"; - readonly fr: "Minio, S3, R2"; - readonly he: "Minio,S3,R2"; - readonly ja: "MinIO、S3、R2"; - readonly ko: "MinIO, S3, R2"; - readonly ru: "MinIO, S3, R2"; - readonly zh: "MinIO、S3、R2"; - readonly "zh-tw": "MinIO、S3、R2"; - }; - readonly "obsidianLiveSyncSettingTab.titleNotification": { - readonly def: "Notification"; - readonly es: "NotificaciÃŗn"; - readonly fr: "Notification"; - readonly he: "ה×Ēראה"; - readonly ja: "通įŸĨ"; - readonly ko: "ė•ŒëĻŧ"; - readonly ru: "ĐŖĐ˛ĐĩĐ´ĐžĐŧĐģĐĩĐŊĐ¸Ņ"; - readonly zh: "通įŸĨ"; - readonly "zh-tw": "通įŸĨ"; - }; - readonly "obsidianLiveSyncSettingTab.titleOnlineTips": { - readonly def: "Online Tips"; - readonly es: "Consejos en línea"; - readonly fr: "Conseils en ligne"; - readonly he: "טיפים אונליין"; - readonly ja: "ã‚ĒãƒŗãƒŠã‚¤ãƒŗãƒ’ãƒŗãƒˆ"; - readonly ko: "똍ëŧė¸ 팁"; - readonly ru: "ОĐŊĐģаКĐŊ ŅĐžĐ˛Đĩ҂ҋ"; - readonly zh: "在įēŋ提į¤ē"; - }; - readonly "obsidianLiveSyncSettingTab.titleQuickSetup": { - readonly def: "Quick Setup"; - readonly es: "ConfiguraciÃŗn rÃĄpida"; - readonly fr: "Configuration rapide"; - readonly he: "הגדרה מהירה"; - readonly ja: "クイックã‚ģットã‚ĸップ"; - readonly ko: "ëš ëĨ¸ 네렕"; - readonly ru: "Đ‘Ņ‹ŅŅ‚Ņ€Đ°Ņ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēа"; - readonly zh: "åŋĢ速设įŊŽ"; - }; - readonly "obsidianLiveSyncSettingTab.titleRebuildRequired": { - readonly def: "Rebuild Required"; - readonly es: "ReconstrucciÃŗn necesaria"; - readonly fr: "Reconstruction requise"; - readonly he: "נדרש×Ē ×‘× ×™×™×” מחדש"; - readonly ja: "å†æ§‹į¯‰ãŒåŋ…čρ"; - readonly ko: "ėžŦęĩŦėļ• í•„ėš”"; - readonly ru: "ĐĸŅ€ĐĩĐąŅƒĐĩŅ‚ŅŅ ĐŋĐĩŅ€ĐĩŅŅ‚Ņ€ĐžĐĩĐŊиĐĩ"; - readonly zh: "需čĻé‡åģē"; - }; - readonly "obsidianLiveSyncSettingTab.titleRemoteConfigCheckFailed": { - readonly def: "Remote Configuration Check Failed"; - readonly es: "La verificaciÃŗn de configuraciÃŗn remota fallÃŗ"; - readonly fr: "Échec de la vÊrification de la configuration distante"; - readonly he: "בדיק×Ē ×Ē×Ļור×Ē ×Š×¨×Ē ×ž×¨×•×—×“ נכשלה"; - readonly ja: "ãƒĒãƒĸãƒŧãƒˆč¨­åŽšãŽįĸēčĒãĢå¤ąæ•—"; - readonly ko: "ė›ę˛Š ęĩŦė„ą í™•ė¸ ė‹¤íŒ¨"; - readonly ru: "ĐŸŅ€ĐžĐ˛ĐĩŅ€Đēа ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊОК ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Đ¸ ĐŊĐĩ ŅƒĐ´Đ°ĐģĐ°ŅŅŒ"; - readonly zh: "čŋœįĢ¯é…įŊŽæŖ€æŸĨå¤ąč´Ĩ"; - readonly "zh-tw": "遠įĢ¯č¨­åŽšæĒĸæŸĨå¤ąæ•—"; - }; - readonly "obsidianLiveSyncSettingTab.titleRemoteServer": { - readonly def: "Remote Server"; - readonly es: "Servidor remoto"; - readonly fr: "Serveur distant"; - readonly he: "׊ר×Ē ×ž×¨×•×—×“"; - readonly ja: "ãƒĒãƒĸãƒŧトã‚ĩãƒŧバãƒŧ"; - readonly ko: "ė›ę˛Š ė„œë˛„"; - readonly ru: "ĐŖĐ´Đ°ĐģŅ‘ĐŊĐŊŅ‹Đš ҁĐĩŅ€Đ˛ĐĩŅ€"; - readonly zh: "čŋœįĢ¯æœåŠĄå™¨"; - readonly "zh-tw": "遠į̝äŧ翜å™¨"; - }; - readonly "obsidianLiveSyncSettingTab.titleReset": { - readonly def: "Reset"; - readonly es: "Reiniciar"; - readonly fr: "RÊinitialiser"; - readonly he: "א×Ēחול"; - readonly ja: "ãƒĒã‚ģット"; - readonly ko: "ëĻŦė…‹"; - readonly ru: "ĐĄĐąŅ€ĐžŅ"; - readonly zh: "重įŊŽ"; - }; - readonly "obsidianLiveSyncSettingTab.titleSetupOtherDevices": { - readonly def: "To setup other devices"; - readonly es: "Para configurar otros dispositivos"; - readonly fr: "Pour configurer d'autres appareils"; - readonly he: "להגדר×Ē ×ž×›×Š×™×¨×™× אחרים"; - readonly ja: "äģ–ぎデバイ゚ぎã‚ģットã‚ĸップ"; - readonly ko: "다ëĨ¸ 기기 네렕"; - readonly ru: "ДĐģŅ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи Đ´Ņ€ŅƒĐŗĐ¸Ņ… ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛"; - readonly zh: "莞įŊŽå…ļäģ–čŽžå¤‡"; - }; - readonly "obsidianLiveSyncSettingTab.titleSynchronizationMethod": { - readonly def: "Synchronization Method"; - readonly es: "MÊtodo de sincronizaciÃŗn"; - readonly fr: "MÊthode de synchronisation"; - readonly he: "שיט×Ē ×Ą× ×›×¨×•×Ÿ"; - readonly ja: "åŒæœŸæ–šæŗ•"; - readonly ko: "동기화 방법"; - readonly ru: "МĐĩŅ‚ĐžĐ´ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸"; - readonly zh: "同æ­Ĩæ–šåŧ"; - readonly "zh-tw": "同æ­Ĩæ–šåŧ"; - }; - readonly "obsidianLiveSyncSettingTab.titleSynchronizationPreset": { - readonly def: "Synchronization Preset"; - readonly es: "Preestablecimiento de sincronizaciÃŗn"; - readonly fr: "PrÊrÊglage de synchronisation"; - readonly he: "קבו×ĸ מרא׊ לסנכרון"; - readonly ja: "同期プãƒĒã‚ģット"; - readonly ko: "동기화 프ëĻŦė…‹"; - readonly ru: "ĐŸŅ€ĐĩҁĐĩŅ‚ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸"; - readonly zh: "同æ­Ĩéĸ„莞"; - readonly "zh-tw": "同æ­Ĩ預設"; - }; - readonly "obsidianLiveSyncSettingTab.titleSyncSettings": { - readonly def: "Sync Settings"; - readonly es: "Configuraciones de SincronizaciÃŗn"; - readonly fr: "Paramètres de synchronisation"; - readonly he: "הגדרו×Ē ×Ą× ×›×¨×•×Ÿ"; - readonly ja: "åŒæœŸč¨­åŽš"; - readonly ko: "동기화 네렕"; - readonly ru: "ĐĐ°ŅŅ‚Ņ€ĐžĐšĐēи ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸"; - readonly zh: "同æ­Ĩ莞įŊŽ"; - }; - readonly "obsidianLiveSyncSettingTab.titleSyncSettingsViaMarkdown": { - readonly def: "Sync Settings via Markdown"; - readonly es: "ConfiguraciÃŗn de sincronizaciÃŗn a travÊs de Markdown"; - readonly fr: "Synchroniser les paramètres via Markdown"; - readonly he: "סנכרון הגדרו×Ē ×“×¨×š Markdown"; - readonly ja: "MarkdownįĩŒį”ąã§č¨­åŽšã‚’åŒæœŸ"; - readonly ko: "마íŦë‹¤ėš´ė„ í†ĩ한 동기화 네렕"; - readonly ru: "ХиĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Ņ ĐŊĐ°ŅŅ‚Ņ€ĐžĐĩĐē ҇ĐĩŅ€ĐĩС Markdown"; - readonly zh: "通čŋ‡ Markdown 同æ­Ĩ莞įŊŽ"; - readonly "zh-tw": "透過 Markdown 同æ­Ĩč¨­åŽš"; - }; - readonly "obsidianLiveSyncSettingTab.titleUpdateThinning": { - readonly def: "Update Thinning"; - readonly es: "ActualizaciÃŗn de adelgazamiento"; - readonly fr: "Lissage des mises à jour"; - readonly he: "דילול ×ĸדכונים"; - readonly ja: "更新ぎ間åŧ•き"; - readonly ko: "ė—…ë°ė´íŠ¸ ė†Žė•„ë‚´ę¸°"; - readonly ru: "ОĐŋŅ‚Đ¸ĐŧĐ¸ĐˇĐ°Ņ†Đ¸Ņ ОйĐŊОвĐģĐĩĐŊиК"; - readonly zh: "æ›´æ–°į˛žįŽ€"; - readonly "zh-tw": "æ›´æ–°į˛žį°Ą"; - }; - readonly "obsidianLiveSyncSettingTab.warnCorsOriginUnmatched": { - readonly def: "⚠ CORS Origin is unmatched ${from}->${to}"; - readonly es: "⚠ El origen de CORS no coincide: {from}->{to}"; - readonly fr: "⚠ L'origine CORS ne correspond pas ${from}->${to}"; - readonly he: "⚠ CORS Origin אינו ×Ēואם ${from}->${to}"; - readonly ja: "⚠ CORS OriginãŒä¸€č‡´ã—ãžã›ã‚“ ${from}->${to}"; - readonly ko: "⚠ CORS ė›ė ė´ ėŧėš˜í•˜ė§€ ė•ŠėŠĩ니다 {from}->{to}"; - readonly ru: "⚠ CORS Origin ĐŊĐĩ ŅĐžĐ˛ĐŋадаĐĩŅ‚ from->to"; - readonly zh: "⚠ CORS æēä¸åŒšé… {from}->{to}"; - }; - readonly "obsidianLiveSyncSettingTab.warnNoAdmin": { - readonly def: "⚠ You do not have administrator privileges."; - readonly es: "⚠ No tienes privilegios de administrador."; - readonly fr: "⚠ Vous n'avez pas les privilèges administrateur."; - readonly he: "⚠ אין לך הרשאו×Ē ×ž× ×”×œ."; - readonly ja: "⚠ įŽĄį†č€…æ¨Šé™ãŒã‚ã‚Šãžã›ã‚“ã€‚"; - readonly ko: "⚠ 관ëĻŦėž ęļŒí•œė´ ė—†ėŠĩ니다."; - readonly ru: "⚠ ĐŖ Đ˛Đ°Ņ ĐŊĐĩŅ‚ ĐŋŅ€Đ°Đ˛ адĐŧиĐŊĐ¸ŅŅ‚Ņ€Đ°Ņ‚ĐžŅ€Đ°."; - readonly zh: "⚠ æ‚¨æ˛Ąæœ‰įŽĄį†å‘˜æƒé™"; - }; - readonly Ok: { - readonly def: "Ok"; - readonly es: "Aceptar"; - readonly ja: "OK"; - readonly ko: "í™•ė¸"; - readonly ru: "ОК"; - readonly zh: "įĄŽåŽš"; - readonly "zh-tw": "įĸē厚"; - }; - readonly "Old Algorithm": { - readonly def: "Old Algorithm"; - readonly es: "Algoritmo antiguo"; - readonly ja: "旧ã‚ĸãƒĢゴãƒĒã‚ēム"; - readonly ko: "ė´ė „ ė•Œęŗ ëĻŦėϘ"; - readonly ru: "ĐĄŅ‚Đ°Ņ€Ņ‹Đš аĐģĐŗĐžŅ€Đ¸Ņ‚Đŧ"; - readonly zh: "æ—§įŽ—æŗ•"; - readonly "zh-tw": "舊æŧ”įŽ—æŗ•"; - }; - readonly "Older fallback (Slow, W/O WebAssembly)": { - readonly def: "Older fallback (Slow, W/O WebAssembly)"; - readonly es: "Alternativa anterior (lenta, sin WebAssembly)"; - readonly ja: "旧フりãƒŧãƒĢバック (äŊŽé€Ÿã€WebAssembly ãĒし)"; - readonly ko: "ė´ė „ ëŒ€ė˛´ ë°Šė‹ (느ëĻŧ, WebAssembly ė—†ėŒ)"; - readonly ru: "ĐĄŅ‚Đ°Ņ€Ņ‹Đš Đ˛Đ°Ņ€Đ¸Đ°ĐŊŅ‚ fallback (ĐŧĐĩĐ´ĐģĐĩĐŊĐŊŅ‹Đš, ĐąĐĩС WebAssembly)"; - readonly zh: "æ—§į‰ˆå›žé€€īŧˆčžƒæ…ĸīŧŒæ—  WebAssemblyīŧ‰"; - readonly "zh-tw": "čˆŠį‰ˆå›žé€€īŧˆčŧƒæ…ĸīŧŒį„Ą WebAssemblyīŧ‰"; - }; - readonly Open: { - readonly def: "Open"; - readonly es: "Abrir"; - readonly ja: "開く"; - readonly ko: "뗴揰"; - readonly ru: "ĐžŅ‚ĐēŅ€Ņ‹Ņ‚ŅŒ"; - readonly zh: "打åŧ€"; - }; - readonly "Open the dialog": { - readonly def: "Open the dialog"; - readonly es: "Abrir el diÃĄlogo"; - readonly ja: "ダイã‚ĸログを開く"; - readonly ko: "ëŒ€í™”ėƒėž 뗴揰"; - readonly ru: "ĐžŅ‚ĐēŅ€Ņ‹Ņ‚ŅŒ диаĐģĐžĐŗ"; - readonly zh: "打åŧ€å¯šč¯æĄ†"; - }; - readonly Overwrite: { - readonly def: "Overwrite"; - readonly es: "Sobrescribir"; - readonly ja: "上書き"; - readonly ko: "ëŽė–´ė“°ę¸°"; - readonly ru: "ПĐĩŅ€ĐĩСаĐŋĐ¸ŅĐ°Ņ‚ŅŒ"; - readonly zh: "čφᛖ"; - }; - readonly "Overwrite patterns": { - readonly def: "Overwrite patterns"; - readonly es: "Patrones de sobrescritura"; - readonly ja: "上書きパã‚ŋãƒŧãƒŗ"; - readonly ko: "ëŽė–´ė“°ę¸° 패턴"; - readonly ru: "ШайĐģĐžĐŊŅ‹ ĐŋĐĩŅ€ĐĩСаĐŋĐ¸ŅĐ¸"; - readonly zh: "čĻ†į›–æ¨Ąåŧ"; - readonly "zh-tw": "čφå¯Ģæ¨Ąåŧ"; - }; - readonly "Overwrite remote": { - readonly def: "Overwrite remote"; - readonly es: "Sobrescribir remoto"; - readonly ja: "ãƒĒãƒĸãƒŧトを上書き"; - readonly ko: "ė›ę˛Š ëŽė–´ė“°ę¸°"; - readonly ru: "ПĐĩŅ€ĐĩСаĐŋĐ¸ŅĐ°Ņ‚ŅŒ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐĩ Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰Đĩ"; - readonly zh: "čφᛖčŋœį̝"; - readonly "zh-tw": "čφå¯Ģ遠į̝"; - }; - readonly "Overwrite remote with local DB and passphrase.": { - readonly def: "Overwrite remote with local DB and passphrase."; - readonly es: "Sobrescribe el remoto con la base de datos local y la frase de contraseÃąa."; - readonly ja: "ロãƒŧã‚ĢãƒĢ DB とパ゚フãƒŦãƒŧã‚ēでãƒĒãƒĸãƒŧトを上書きしぞす。"; - readonly ko: "로ėģŦ DB뙀 ė•”í˜¸ëŦ¸ęĩŦ로 ė›ę˛Šė„ ëŽė–´ė”ë‹ˆë‹¤."; - readonly ru: "ПĐĩŅ€ĐĩСаĐŋĐ¸ŅĐ°Ņ‚ŅŒ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐĩ Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰Đĩ ĐģĐžĐēаĐģҌĐŊОК БД и ĐŋĐ°Ņ€ĐžĐģҌĐŊОК Ņ„Ņ€Đ°ĐˇĐžĐš."; - readonly zh: "äŊŋᔍæœŦ地数捎åē“å’Œå¯†į įŸ­č¯­čφᛖčŋœįĢ¯ã€‚"; - readonly "zh-tw": "äŊŋᔍæœŦæŠŸčŗ‡æ–™åēĢčˆ‡å¯†čĒžčφå¯Ģ遠įĢ¯ã€‚"; - }; - readonly "Overwrite Server Data with This Device's Files": { - readonly def: "Overwrite Server Data with This Device's Files"; - readonly es: "Sobrescribir los datos del servidor con los archivos de este dispositivo"; - readonly ja: "ã“ãŽãƒ‡ãƒã‚¤ã‚šãŽãƒ•ã‚Ąã‚¤ãƒĢでã‚ĩãƒŧバãƒŧデãƒŧã‚ŋを上書き"; - readonly ko: "ė´ ę¸°ę¸°ė˜ 파ėŧ로 ė„œë˛„ ë°ė´í„°ëĨŧ ëŽė–´ė“°ę¸°"; - readonly ru: "ПĐĩŅ€ĐĩСаĐŋĐ¸ŅĐ°Ņ‚ŅŒ даĐŊĐŊŅ‹Đĩ ҁĐĩŅ€Đ˛ĐĩŅ€Đ° Ņ„Đ°ĐšĐģаĐŧи ҁ ŅŅ‚ĐžĐŗĐž ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°"; - readonly zh: "ᔍæœŦčŽžå¤‡æ–‡äģļčĻ†į›–æœåŠĄå™¨æ•°æŽ"; - readonly "zh-tw": "äģĨæ­¤čŖįŊŽįš„æĒ”æĄˆčφå¯Ģäŧ翜å™¨čŗ‡æ–™"; - }; - readonly "P2P.AskPassphraseForDecrypt": { - readonly def: "The remote peer shared the configuration. Please input the passphrase to decrypt the configuration."; - readonly fr: "Le pair distant a partagÊ la configuration. Veuillez saisir la phrase secrète pour dÊchiffrer la configuration."; - readonly he: "ה×ĸמי×Ē ×”×ž×¨×•×—×“ שי×Ē×Ŗ א×Ē ×”×Ē×Ļורה. אנא הזן א×Ē ×‘×™×˜×•×™ הסיסמה לפ×ĸנוח ה×Ē×Ļורה."; - readonly ja: "ãƒĒãƒĸãƒŧトピã‚ĸã‹ã‚‰č¨­åŽšãŒå…ąæœ‰ã•ã‚Œãžã—ãŸã€‚č¨­åŽšã‚’åžŠåˇã™ã‚‹ãŸã‚ãŽãƒ‘ã‚šãƒ•ãƒŦãƒŧã‚ēをå…Ĩ力しãĻください。"; - readonly ko: "ė›ę˛Š í”ŧė–´ę°€ ęĩŦė„ąė„ ęŗĩėœ í–ˆėŠĩ니다. ęĩŦė„ąė„ ëŗĩ호화하려면 íŒ¨ėŠ¤í”„ë ˆė´ėψëĨŧ ėž…ë Ĩ해 ėŖŧė„¸ėš”."; - readonly ru: "ĐŖĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐĩ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đž ĐŋŅ€ĐĩĐ´ĐžŅŅ‚Đ°Đ˛Đ¸ĐģĐž ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸ŅŽ. ВвĐĩĐ´Đ¸Ņ‚Đĩ ĐŋĐ°Ņ€ĐžĐģҌ Đ´ĐģŅ Ņ€Đ°ŅŅˆĐ¸Ņ„Ņ€ĐžĐ˛Đēи."; - readonly zh: "čŋœį¨‹å¯šį­‰æ–šå…ąäēĢäē†é…įŊŽīŧŒč¯ˇčž“å…Ĩå¯†į įŸ­č¯­äģĨč§Ŗå¯†é…įŊŽ"; - }; - readonly "P2P.AskPassphraseForShare": { - readonly def: "The remote peer requested this device configuration. Please input the passphrase to share the configuration. You can ignore the request by cancelling this dialogue."; - readonly fr: "Le pair distant a demandÊ la configuration de cet appareil. Veuillez saisir la phrase secrète pour partager la configuration. Vous pouvez ignorer la demande en annulant cette boÃŽte de dialogue."; - readonly he: "ה×ĸמי×Ē ×”×ž×¨×•×—×“ ביקש א×Ē ×Ē×Ļור×Ē ×ž×›×Š×™×¨ זה. אנא הזן א×Ē ×‘×™×˜×•×™ הסיסמה לשי×Ēות ה×Ē×Ļורה. ני×Ēן לה×Ē×ĸלם מהבקשה ×ĸל ידי ביטול הדיאלוג."; - readonly ja: "ãƒĒãƒĸãƒŧトピã‚ĸã‹ã‚‰ã“ãŽãƒ‡ãƒã‚¤ã‚šãŽč¨­åŽšãŒčĻæą‚ã•ã‚Œãžã—ãŸã€‚č¨­åŽšã‚’å…ąæœ‰ã™ã‚‹ãŸã‚ãŽãƒ‘ã‚šãƒ•ãƒŦãƒŧã‚ēをå…Ĩ力しãĻください。こぎダイã‚ĸãƒ­ã‚°ã‚’ã‚­ãƒŖãƒŗã‚ģãƒĢすることでãƒĒã‚¯ã‚¨ã‚šãƒˆã‚’į„ĄčĻ–ã§ããžã™ã€‚"; - readonly ko: "ė›ę˛Š í”ŧė–´ę°€ ė´ ę¸°ę¸°ė˜ ęĩŦė„ąė„ ėš”ė˛­í–ˆėŠĩ니다. ęĩŦė„ąė„ ęŗĩėœ í•˜ë ¤ëŠ´ íŒ¨ėŠ¤í”„ë ˆė´ėψëĨŧ ėž…ë Ĩ해 ėŖŧė„¸ėš”. ė´ ëŒ€í™”ėƒėžëĨŧ ėˇ¨ė†Œí•˜ė—Ŧ ėš”ė˛­ė„ ëŦ´ė‹œí•  눘 ėžˆėŠĩ니다."; - readonly ru: "ĐŖĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐĩ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đž СаĐŋŅ€Đ°ŅˆĐ¸Đ˛Đ°ĐĩŅ‚ ŅŅ‚Ņƒ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸ŅŽ. ВвĐĩĐ´Đ¸Ņ‚Đĩ ĐŋĐ°Ņ€ĐžĐģҌ Đ´ĐģŅ ĐŋĐĩŅ€ĐĩĐ´Đ°Ņ‡Đ¸."; - readonly zh: "čŋœį¨‹å¯šį­‰æ–šč¯ˇæą‚æ­¤čŽžå¤‡é…įŊŽīŧŒč¯ˇčž“å…Ĩå¯†į įŸ­č¯­äģĨå…ąäēĢ配įŊŽã€‚äŊ å¯äģĨ通čŋ‡å–æļˆæ­¤å¯šč¯æĄ†æĨåŋŊį•Ĩæ­¤č¯ˇæą‚"; - }; - readonly "P2P.DisabledButNeed": { - readonly def: "Peer-to-Peer Sync is disabled. Do you really want to enable it?"; - readonly fr: "Synchronisation pair-à-pair est dÊsactivÊ. Voulez-vous vraiment l'activer ?"; - readonly he: "%{title_p2p_sync} מנוטרל. האם א×Ēה בטוח שבר×Ļונך להפ×ĸיל?"; - readonly ja: "Peer-to-Peer Syncã¯į„ĄåŠšãĢãĒãŖãĻいぞす。æœŦåŊ“ãĢ有劚ãĢしぞすかīŧŸ"; - readonly ko: "í”ŧė–´ íˆŦ í”ŧė–´(P2P) 동기화가 ëš„í™œė„ąí™”ë˜ė–´ ėžˆėŠĩ니다. ė •ë§ëĄœ í™œė„ąí™”í•˜ė‹œę˛ ėŠĩ니까?"; - readonly ru: "title_p2p_sync ĐžŅ‚ĐēĐģŅŽŅ‡Ņ‘ĐŊ. Đ’Ņ‹ Đ´ĐĩĐšŅŅ‚Đ˛Đ¸Ņ‚ĐĩĐģҌĐŊĐž Ņ…ĐžŅ‚Đ¸Ņ‚Đĩ вĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒ?"; - readonly zh: "Peer-to-Peer同æ­Ĩ 厞įĻį”¨ã€‚äŊ įĄŽåޚčĻå¯į”¨åŽƒå—īŧŸ"; - }; - readonly "P2P.FailedToOpen": { - readonly def: "Failed to open P2P connection to the signalling server."; - readonly fr: "Échec d'ouverture de la connexion P2P vers le serveur de signalisation."; - readonly he: "לא ני×Ēן לפ×Ēוח חיבור P2P לשר×Ē ×”××•×Ēו×Ē."; - readonly ja: "ã‚ˇã‚°ãƒŠãƒĒãƒŗã‚°ã‚ĩãƒŧバãƒŧへぎP2PæŽĨįļšã‚’開けぞせんでした。"; - readonly ko: "ė‹œęˇ¸ë„ë§ ė„œë˛„ė— P2P ė—°ę˛°ė„ ė—´ 눘 ė—†ėŠĩ니다."; - readonly ru: "НĐĩ ŅƒĐ´Đ°ĐģĐžŅŅŒ ĐžŅ‚ĐēŅ€Ņ‹Ņ‚ŅŒ P2P ĐŋОдĐēĐģŅŽŅ‡ĐĩĐŊиĐĩ Đē ҁĐĩŅ€Đ˛ĐĩŅ€Ņƒ ŅĐ¸ĐŗĐŊаĐģĐ¸ĐˇĐ°Ņ†Đ¸Đ¸."; - readonly zh: "æ— æŗ•æ‰“åŧ€ P2P čŋžæŽĨ到äŋĄä줿œåŠĄå™¨"; - }; - readonly "P2P.NoAutoSyncPeers": { - readonly def: "No auto-sync peers found. Please set peers on the Peer-to-Peer Sync pane."; - readonly fr: "Aucun pair de synchronisation automatique trouvÊ. Veuillez dÊfinir des pairs dans le panneau Synchronisation pair-à-pair."; - readonly he: "לא נמ×Ļאו ×ĸמי×Ēים לסנכרון אוטומטי. אנא הגדר ×ĸמי×Ēים בלוח %{long_p2p_sync}."; - readonly ja: "č‡Ē動同期ピã‚ĸがčĻ‹ã¤ã‹ã‚Šãžã›ã‚“ã€‚Peer-to-Peer Sync (čŠĻ験抟čƒŊ)ãƒšã‚¤ãƒŗã§ãƒ”ã‚ĸã‚’č¨­åŽšã—ãĻください。"; - readonly ko: "ėžë™ 동기화 í”ŧė–´ëĨŧ ė°žė„ 눘 ė—†ėŠĩ니다. í”ŧė–´ íˆŦ í”ŧė–´(P2P) 동기화 (ė‹¤í—˜ 기ëŠĨ) ė°Ŋė—ė„œ í”ŧė–´ëĨŧ ė„¤ė •í•´ ėŖŧė„¸ėš”."; - readonly ru: "ĐĐ˛Ņ‚ĐžŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ¸Ņ€ŅƒĐĩĐŧŅ‹Đĩ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ° ĐŊĐĩ ĐŊаКдĐĩĐŊŅ‹."; - readonly zh: "æœĒ扞到č‡Ē动同æ­Ĩįš„å¯šį­‰æ–šīŧŒč¯ˇåœ¨ Peer-to-Peer同æ­Ĩ (厞éĒŒæ€§) éĸæŋ中莞įŊŽå¯šį­‰æ–š"; - }; - readonly "P2P.NoKnownPeers": { - readonly def: "No peers has been detected, waiting incoming other peers..."; - readonly fr: "Aucun pair dÊtectÊ, en attente d'autres pairs entrants..."; - readonly he: "לא זוהו ×ĸמי×Ēים, ממ×Ēין ל×ĸמי×Ēים נכנסים..."; - readonly ja: "ピã‚ĸが検å‡ēされãĻいぞせん。äģ–ぎピã‚ĸからぎæŽĨįļšã‚’垅抟中..."; - readonly ko: "í”ŧė–´ę°€ ę°ė§€ë˜ė§€ ė•Šė•˜ėŠĩ니다. 다ëĨ¸ í”ŧė–´ė˜ ė ‘ė†ė„ 기다ëĻŦęŗ  ėžˆėŠĩ니다..."; - readonly ru: "ĐŖŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ° ĐŊĐĩ ОйĐŊĐ°Ņ€ŅƒĐļĐĩĐŊŅ‹, ĐžĐļидаĐĩĐŧ Đ´Ņ€ŅƒĐŗĐ¸Đĩ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°..."; - readonly zh: "æœĒæŖ€æĩ‹åˆ°å¯šį­‰æ–šīŧŒæ­Ŗåœ¨į­‰åž…å…ļäģ–å¯šį­‰æ–šįš„čŋžæŽĨ..."; - }; - readonly "P2P.Note.description": { - readonly def: " This replicator allows us to synchronise our vault with other devices\nusing a peer-to-peer connection. We can use this to synchronise our vault with our other devices without using a cloud service.\nThis replicator is based on Trystero. It also uses a signalling server to establish a connection between devices. The signalling server is used to exchange connection information between devices. It does (or,should) not know or store any of our data.\n\nThe signalling server can be hosted by anyone. This is just a Nostr relay. For the sake of simplicity and checking the behaviour of the replicator, an instance of the signalling server is hosted by vrtmrz. You can use the experimental server provided by vrtmrz, or you can use any other server.\n\nBy the way, even if the signalling server does not store our data, it can see the connection information of some of our devices. Please be aware of this. Also, be cautious when using the server provided by someone else."; - readonly fr: " Ce rÊplicateur permet de synchroniser notre coffre avec d'autres\nappareils via une connexion pair-à-pair. Nous pouvons l'utiliser pour synchroniser notre coffre avec nos autres appareils sans recourir à un service cloud.\nCe rÊplicateur est basÊ sur Trystero. Il utilise Êgalement un serveur de signalisation pour Êtablir une connexion entre les appareils. Le serveur de signalisation sert à Êchanger les informations de connexion entre appareils. Il ne connaÃŽt (ou ne devrait connaÃŽtre) ni ne stocke aucune de nos donnÊes.\n\nLe serveur de signalisation peut ÃĒtre hÊbergÊ par n'importe qui. Il s'agit simplement d'un relais Nostr. Par souci de simplicitÊ et pour vÊrifier le comportement du rÊplicateur, une instance du serveur de signalisation est hÊbergÊe par vrtmrz. Vous pouvez utiliser le serveur expÊrimental fourni par vrtmrz, ou tout autre serveur.\n\nAu passage, mÃĒme si le serveur de signalisation ne stocke pas nos donnÊes, il peut voir les informations de connexion de certains de nos appareils. Soyez-en conscient. Soyez Êgalement prudent avec un serveur fourni par quelqu'un d'autre."; - readonly he: " רפליקטור זה מאפ׊ר לסנכרן א×Ē ×”×›×Ą×¤×Ē ×ĸם מכשירים אחרים באמ×Ļ×ĸו×Ē ×—×™×‘×•×¨ ×ĸמי×Ē-ל×ĸמי×Ē.\nני×Ēן להש×Ēמ׊ בזה לסנכרון הכספ×Ē ×ĸם מכשירים אחרים ללא שירו×Ē ×ĸנן.\nרפליקטור זה מבוסס ×ĸל Trystero. הוא מ׊×Ēמ׊ גם בשר×Ē ××•×Ēו×Ē ×œ×‘×™×Ą×•×Ą חיבור בין מכשירים. ׊ר×Ē ×”××•×Ēו×Ē ×ž×Š×ž×Š להחלפ×Ē ×ž×™×“×ĸ חיבור בין מכשירים. הוא אינו (ולא אמור) לד×ĸ×Ē ××• לאחסן א×Ē ×”× ×Ēונים שלנו.\n\n׊ר×Ē ×”××•×Ēו×Ē ×™×›×•×œ להיו×Ē ×ž××•×—×Ą×Ÿ ×ĸל ידי כל אחד. זהו ממסר Nostr בלבד. ל×Ļורך פשטו×Ē ×•×‘×“×™×§×Ē ×”×Ēנהגו×Ē ×”×¨×¤×œ×™×§×˜×•×¨, vrtmrz מאחסן ×ĸו×Ē×§ של ׊ר×Ē ×”××•×Ēו×Ē. ני×Ēן להש×Ēמ׊ בשר×Ē ×”× ×™×Ą×™×•× ×™ של vrtmrz, או בכל ׊ר×Ē ××—×¨.\n\nאגב, גם אם ׊ר×Ē ×”××•×Ēו×Ē ××™× ×• מאחסן × ×Ēונים, הוא יכול לראו×Ē ×ž×™×“×ĸ חיבור של חלק ממכשיריך. אנא שים לב לכך. כמו כן, היה זהיר בשימוש בשר×Ē ×Š×œ מישהו אחר."; - readonly ja: "こぎãƒŦプãƒĒã‚ąãƒŧã‚ŋãƒŧは、ピã‚ĸツãƒŧピã‚ĸæŽĨįļšã‚’äŊŋį”¨ã—ãĻ、Vaultをäģ–ぎデバイ゚と同期することができぞす。クナã‚Ļドã‚ĩãƒŧビ゚をäŊŋį”¨ã›ãšãĢ、äģ–ぎデバイ゚とVaultを同期することができぞす。\nこぎãƒŦプãƒĒã‚ąãƒŧã‚ŋãƒŧはTrysteroをベãƒŧ゚ãĢしãĻいぞす。デバイ゚間ぎæŽĨįļšã‚’įĸēįĢ‹ã™ã‚‹ãŸã‚ãĢã‚ˇã‚°ãƒŠãƒĒãƒŗã‚°ã‚ĩãƒŧバãƒŧをäŊŋį”¨ã—ãžã™ã€‚ã‚ˇã‚°ãƒŠãƒĒãƒŗã‚°ã‚ĩãƒŧバãƒŧはデバイ゚間でæŽĨįƒ…å ąã‚’ä礿›ã™ã‚‹ãŸã‚ãĢäŊŋį”¨ã•ã‚Œãžã™ã€‚į§ãŸãĄãŽãƒ‡ãƒŧã‚ŋをįŸĨãŖãŸã‚Šäŋå­˜ã—たりすることはありぞせんīŧˆãžãŸã¯ã€ãã†ã‚るずきではありぞせんīŧ‰ã€‚\n\nã‚ˇã‚°ãƒŠãƒĒãƒŗã‚°ã‚ĩãƒŧバãƒŧはčĒ°ã§ã‚‚ãƒ›ã‚šãƒˆã§ããžã™ã€‚ã“ã‚Œã¯å˜ãĒるNostrãƒĒãƒŦãƒŧã§ã™ã€‚į°ĄäžŋさとãƒŦプãƒĒã‚ąãƒŧã‚ŋãƒŧぎ動äŊœįĸēčĒãŽãŸã‚ãĢ、vrtmrzãŒã‚ˇã‚°ãƒŠãƒĒãƒŗã‚°ã‚ĩãƒŧバãƒŧãŽã‚¤ãƒŗã‚šã‚ŋãƒŗã‚šã‚’ãƒ›ã‚šãƒˆã—ãĻいぞす。vrtmrzãŒæäž›ã™ã‚‹åŽŸé¨“į”¨ã‚ĩãƒŧバãƒŧをäŊŋį”¨ã™ã‚‹ã“ã¨ã‚‚ã€äģ–ぎã‚ĩãƒŧバãƒŧをäŊŋį”¨ã™ã‚‹ã“ã¨ã‚‚ã§ããžã™ã€‚\n\nãĒãŠã€ã‚ˇã‚°ãƒŠãƒĒãƒŗã‚°ã‚ĩãƒŧバãƒŧãŒį§ãŸãĄãŽãƒ‡ãƒŧã‚ŋをäŋå­˜ã—ãĒくãĻも、一部ぎデバイ゚ぎæŽĨįƒ…å ąã‚’čĻ‹ã‚‹ã“ã¨ãŒã§ããžã™ã€‚ã“ã‚ŒãĢã”æŗ¨æ„ãã ã•ã„ã€‚ãžãŸã€äģ–ぎäēēが提䞛するã‚ĩãƒŧバãƒŧをäŊŋį”¨ã™ã‚‹å ´åˆã¯æŗ¨æ„ã—ãĻください。"; - readonly ko: "ė´ ëŗĩė œę¸°ëŠ” í”ŧė–´ íˆŦ í”ŧė–´(P2P) ė—°ę˛°ė„ í†ĩ해 다ëĨ¸ 기기들ęŗŧ ëŗŧ트ëĨŧ 동기화할 눘 ėžˆë„ëĄ 합니다. 클ëŧėš°ë“œ ė„œëš„ėŠ¤ëĨŧ ęą°ėš˜ė§€ ė•Šęŗ ë„ 기기간 동기화ëĨŧ ęĩŦ현할 눘 ėžˆėŠĩ니다.\n\nė´ ëŗĩė œę¸°ëŠ” TrysteroëĨŧ 기반ėœŧ로 하며, 기기 간 ė—°ę˛°ė„ ė„¤ė •í•˜ę¸° ėœ„í•´ ė‹œęˇ¸ë„ë§ ė„œë˛„ëĨŧ ė‚ŦėšŠí•Šë‹ˆë‹¤. ė‹œęˇ¸ë„ë§ ė„œë˛„ëŠ” ë‹¨ėˆœížˆ 뗰枰 ė •ëŗ´ëĨŧ ęĩí™˜í•˜ëŠ” ėšŠë„ëĄœë§Œ ė‚ŦėšŠë˜ëŠ°, ė‚ŦėšŠėž ë°ė´í„°ëĨŧ ė €ėžĨ하거나 ė ‘ęˇŧí•˜ė§€ ė•ŠėŠĩ니다 (또는 그래ė•ŧ만 합니다).\n\nė‹œęˇ¸ë„ë§ ė„œë˛„ëŠ” 누ęĩŦ나 ėš´ė˜í•  눘 ėžˆėœŧ늰, ė´ëŠ” ë‹¨ėˆœí•œ Nostr ëĻ´ë ˆė´ėž…ë‹ˆë‹¤. íŽ¸ė˜ė„ąęŗŧ ëŗĩė œę¸°ė˜ ėž‘ë™ í™•ė¸ė„ ėœ„í•´ `vrtmrz`가 ėžė˛´ė ėœŧ로 ė‹œęˇ¸ë„ë§ ė„œë˛„ ė¸ėŠ¤í„´ėŠ¤ëĨŧ 뚴똁 ė¤‘ėž…ë‹ˆë‹¤. ė‚ŦėšŠėžëŠ” `vrtmrz`가 렜ęŗĩ하는 ė‹¤í—˜ėšŠ ė„œë˛„ëĨŧ ė‚ŦėšŠí•  ėˆ˜ë„ ėžˆęŗ , ëŗ„ë„ëĄœ ėžė‹ ë§Œė˜ ė„œë˛„ëĨŧ ė„¤ė •í•  ėˆ˜ë„ ėžˆėŠĩ니다.\n\nė°¸ęŗ ëĄœ, ė‹œęˇ¸ë„ë§ ė„œë˛„ëŠ” ė‚ŦėšŠėž ë°ė´í„°ëĨŧ ė €ėžĨí•˜ė§€ ė•Šë”ëŧ도 ėŧëļ€ ę¸°ę¸°ė˜ 뗰枰 ė •ëŗ´ëŠ” ëŗŧ 눘 ėžˆėŠĩ니다. ė´ ė ė„ ėœ ė˜í•´ ėŖŧė„¸ėš”. 특히 íƒ€ė¸ė´ ėš´ė˜í•˜ëŠ” ė„œë˛„ëĨŧ ė‚ŦėšŠí•  ę˛Ŋ뚰 ėŖŧė˜ę°€ í•„ėš”í•Šë‹ˆë‹¤."; - readonly ru: "Đ­Ņ‚ĐžŅ‚ Ņ€ĐĩĐŋĐģиĐēĐ°Ņ‚ĐžŅ€ ĐŋОСвОĐģŅĐĩŅ‚ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰Đĩ ҁ Đ´Ņ€ŅƒĐŗĐ¸Đŧи ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°Đŧи ҁ Đ¸ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°ĐŊиĐĩĐŧ ОдĐŊĐžŅ€Đ°ĐŊĐŗĐžĐ˛ĐžĐŗĐž ŅĐžĐĩдиĐŊĐĩĐŊĐ¸Ņ."; - readonly zh: " This replicator allows us to synchronise our vault with other devices\nusing a peer-to-peer connection. We can use this to synchronise our vault with our other devices without using a cloud service.\nThis replicator is based on Trystero. It also uses a signaling server to establish a connection between devices. The signaling server is used to exchange connection information between devices. It does (or,should) not know or store any of our data.\n\nThe signaling server can be hosted by anyone. This is just a Nostr relay. For the sake of simplicity and checking the behaviour of the replicator, an instance of the signaling server is hosted by vrtmrz. You can use the experimental server provided by vrtmrz, or you can use any other server.\n\nBy the way, even if the signaling server does not store our data, it can see the connection information of some of our devices. Please be aware of this. Also, be cautious when using the server provided by someone else."; - }; - readonly "P2P.Note.important_note": { - readonly def: "Peer-to-Peer Replicator."; - readonly fr: "RÊplicateur pair-à-pair."; - readonly he: "רפליקטור ×ĸמי×Ē-ל×ĸמי×Ē."; - readonly ja: "ピã‚ĸツãƒŧピã‚ĸãƒŦプãƒĒã‚ąãƒŧã‚ŋãƒŧãŽåŽŸé¨“įš„åŽŸčŖ…"; - readonly ko: "í”ŧė–´ íˆŦ í”ŧė–´(P2P) ëŗĩė œę¸°ė˜ ė‹¤í—˜ė  ęĩŦí˜„ėž…ë‹ˆë‹¤."; - readonly ru: "P2P Ņ€ĐĩĐŋĐģиĐēĐ°Ņ‚ĐžŅ€."; - readonly zh: "The Experimental Implementation of the Peer-to-Peer Replicator."; - }; - readonly "P2P.Note.important_note_sub": { - readonly def: "This feature is still on the bleeding edge. Please be aware that ensure your data is backed up before using this feature. And, we would be so happy if you could contribute to the development of this feature."; - readonly fr: "Cette fonctionnalitÊ est encore en tout dÊbut de dÊveloppement. Veillez à sauvegarder vos donnÊes avant de l'utiliser. Nous serions très heureux si vous contribuiez au dÊveloppement de cette fonctionnalitÊ."; - readonly he: "×Ēכונה זו ×ĸדיין בשלב מ×Ēקדם. ודא שהנ×Ēונים שלך מגובים לפני השימוש. ונשמח אם ×Ēוכל ל×Ēרום לפי×Ēוח ×Ēכונה זו."; - readonly ja: "こぎ抟čƒŊはぞだ原験æŽĩ階です。期垅通りãĢ動äŊœã—ãĒい可čƒŊ性があることãĢã”æŗ¨æ„ãã ã•ã„ã€‚ã•ã‚‰ãĢ、バグ、ã‚ģキãƒĨãƒĒãƒ†ã‚ŖãŽå•éĄŒã€ããŽäģ–ãŽå•éĄŒãŒã‚ã‚‹å¯čƒŊ性がありぞす。こぎ抟čƒŊはč‡Ēåˇąč˛ŦäģģでごäŊŋį”¨ãã ã•ã„ã€‚ã“ãŽæŠŸčƒŊぎ開į™ēãĢご協力ください。"; - readonly ko: "ė´ 기ëŠĨė€ 땄링 ė‹¤í—˜ ë‹¨ęŗ„ė— ėžˆėŠĩ니다. ė´ 기ëŠĨė´ ė˜ˆėƒëŒ€ëĄœ ėž‘ë™í•˜ė§€ ė•Šė„ 눘 ėžˆėŒė„ ė•Œė•„ėŖŧė„¸ėš”. 또한 버그, ëŗ´ė•ˆ ëŦ¸ė œ 및 기타 ëŦ¸ė œę°€ ėžˆė„ 눘 ėžˆėŠĩ니다. ė´ 기ëŠĨė„ ė‚ŦėšŠí•  때는 ëŗ¸ė¸ė˜ ėą…ėž„ í•˜ė— ė‚ŦėšŠí•˜ė„¸ėš”. ė´ 기ëŠĨė˜ ę°œë°œė— 기ė—Ŧ해 ėŖŧė„¸ėš”."; - readonly ru: "Đ­Ņ‚Đ° Ņ„ŅƒĐŊĐēŅ†Đ¸Ņ Đ˛ŅŅ‘ Đĩ҉ґ ĐŊа ŅŅ‚Đ°Đ´Đ¸Đ¸ Ņ€Đ°ĐˇŅ€Đ°ĐąĐžŅ‚Đēи. ПоĐļаĐģŅƒĐšŅŅ‚Đ°, ŅƒĐąĐĩĐ´Đ¸Ņ‚ĐĩҁҌ, Ņ‡Ņ‚Đž Đ˛Đ°ŅˆĐ¸ даĐŊĐŊŅ‹Đĩ ĐˇĐ°Ņ€ĐĩСĐĩŅ€Đ˛Đ¸Ņ€ĐžĐ˛Đ°ĐŊŅ‹."; - readonly zh: "This feature is still in the experimental stage. Please be aware that this feature may not work as expected. Furthermore, it may have some bugs, security issues, and other issues. Please use this feature at your own risk. Please contribute to the development of this feature."; - }; - readonly "P2P.Note.Summary": { - readonly def: "What is this feature? (and some important notes, please read once)"; - readonly fr: "Qu'est-ce que cette fonctionnalitÊ ? (et quelques notes importantes, à lire)"; - readonly he: "מהי ×Ēכונה זו? (ו×ĸוד ה×ĸרו×Ē ×—×Š×•×‘×•×Ē, נא לקרוא פ×ĸם אח×Ē)"; - readonly ja: "こぎ抟čƒŊãĢついãĻīŧˆé‡čρãĒæŗ¨æ„äē‹é …ã‚’åĢむ、一åēĻおčĒ­ãŋくださいīŧ‰"; - readonly ko: "ė´ 기ëŠĨė€ ëŦ´ė—‡ė¸ę°€ėš”? (네ëĒ…ęŗŧ 및溠ė‚Ŧí•­ė´ ė í˜€ėžˆėŠĩ니다. 한 번 ėŊė–´ëŗ´ė„¸ėš”!)"; - readonly ru: "Đ§Ņ‚Đž ŅŅ‚Đž Са Ņ„ŅƒĐŊĐēŅ†Đ¸Ņ? (ваĐļĐŊŅ‹Đĩ СаĐŧĐĩŅ‡Đ°ĐŊĐ¸Ņ)"; - readonly zh: "What is this feature? (and some important notes, please read once)"; - }; - readonly "P2P.NotEnabled": { - readonly def: "Peer-to-Peer Sync is not enabled. We cannot open a new connection."; - readonly fr: "Synchronisation pair-à-pair n'est pas activÊ. Nous ne pouvons pas ouvrir de nouvelle connexion."; - readonly he: "%{title_p2p_sync} אינו מופ×ĸל. לא ני×Ēן לפ×Ēוח חיבור חדש."; - readonly ja: "Peer-to-Peer Syncが有劚ãĢãĒãŖãĻいぞせん。新しいæŽĨįļšã‚’開くことができぞせん。"; - readonly ko: "í”ŧė–´ íˆŦ í”ŧė–´(P2P) 동기화가 í™œė„ąí™”ë˜ė§€ ė•Šė•˜ėŠĩ니다. ėƒˆëĄœėš´ ė—°ę˛°ė„ ė—´ 눘 ė—†ėŠĩ니다."; - readonly ru: "title_p2p_sync ĐŊĐĩ вĐēĐģŅŽŅ‡Ņ‘ĐŊ. ĐœŅ‹ ĐŊĐĩ ĐŧĐžĐļĐĩĐŧ ĐžŅ‚ĐēŅ€Ņ‹Ņ‚ŅŒ ĐŊОвОĐĩ ĐŋОдĐēĐģŅŽŅ‡ĐĩĐŊиĐĩ."; - readonly zh: "Peer-to-Peer同æ­Ĩ is not enabled. We cannot open a new connection."; - }; - readonly "P2P.P2PReplication": { - readonly def: "Peer-to-Peer Replication"; - readonly fr: "RÊplication Pair-à-Pair"; - readonly he: "שכפול %{P2P}"; - readonly ja: "Peer-to-PeerãƒŦプãƒĒã‚ąãƒŧã‚ˇãƒ§ãƒŗ(複čŖŊ)"; - readonly ko: "í”ŧė–´-to-í”ŧė–´ ëŗĩ렜"; - readonly ru: "P2P Đ ĐĩĐŋĐģиĐēĐ°Ņ†Đ¸Ņ"; - readonly zh: "Peer-to-Peer Replication"; - }; - readonly "P2P.PaneTitle": { - readonly def: "Peer-to-Peer Sync"; - readonly fr: "Synchronisation pair-à-pair"; - readonly he: "%{long_p2p_sync}"; - readonly ja: "Peer-to-Peer Sync (čŠĻ験抟čƒŊ)"; - readonly ko: "í”ŧė–´ íˆŦ í”ŧė–´(P2P) 동기화 (ė‹¤í—˜ 기ëŠĨ)"; - readonly ru: "long_p2p_sync"; - readonly zh: "Peer-to-Peer同æ­Ĩ (厞éĒŒæ€§)"; - }; - readonly "P2P.ReplicatorInstanceMissing": { - readonly def: "P2P Sync replicator is not found, possibly not have been configured or enabled."; - readonly fr: "Le rÊplicateur Sync P2P est introuvable, peut-ÃĒtre non configurÊ ou non activÊ."; - readonly he: "רפליקטור סנכרון P2P לא נמ×Ļא, יי×Ēכן שלא הוגדר או הופ×ĸל."; - readonly ja: "P2P同期ãƒŦプãƒĒã‚ąãƒŧã‚ŋãƒŧがčĻ‹ã¤ã‹ã‚Šãžã›ã‚“ã€‚č¨­åŽšãžãŸã¯æœ‰åŠšåŒ–ã•ã‚ŒãĻいãĒい可čƒŊ性がありぞす。"; - readonly ko: "P2P 동기화 ëŗĩ렜揰ëĨŧ ė°žė„ 눘 ė—†ėŠĩ니다. ęĩŦė„ąë˜ė§€ ė•Šė•˜ęą°ë‚˜ í™œė„ąí™”ë˜ė§€ ė•Šė•˜ė„ 눘 ėžˆėŠĩ니다."; - readonly ru: "P2P Sync Ņ€ĐĩĐŋĐģиĐēĐ°Ņ‚ĐžŅ€ ĐŊĐĩ ĐŊаКдĐĩĐŊ, вОСĐŧĐžĐļĐŊĐž, ĐŊĐĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐĩĐŊ."; - readonly zh: "P2P Sync replicator is not found, possibly not have been configured or enabled."; - }; - readonly "P2P.SeemsOffline": { - readonly def: "Peer ${name} seems offline, skipped."; - readonly fr: "Le pair ${name} semble hors ligne, ignorÊ."; - readonly he: "ה×ĸמי×Ē ${name} נראה לא מחובר, מדלג."; - readonly ja: "ピã‚ĸ${name}はã‚Ēãƒ•ãƒŠã‚¤ãƒŗãŽã‚ˆã†ã§ã™ã€‚ã‚šã‚­ãƒƒãƒ—ã—ãžã—ãŸã€‚"; - readonly ko: "í”ŧė–´ ${name}ė´(가) ė˜¤í”„ëŧė¸ė¸ 것 같ėŠĩ니다. 건너뜁니다."; - readonly ru: "ĐŖŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đž name ĐžŅ„ĐģаКĐŊ, ĐŋŅ€ĐžĐŋŅƒŅ‰ĐĩĐŊĐž."; - readonly zh: "Peer ${name} seems offline, skipped."; - }; - readonly "P2P.SyncAlreadyRunning": { - readonly def: "P2P Sync is already running."; - readonly fr: "La Sync P2P est dÊjà en cours."; - readonly he: "סנכרון P2P כבר פו×ĸל."; - readonly ja: "P2P同期はすでãĢåŽŸčĄŒä¸­ã§ã™ã€‚"; - readonly ko: "P2P 동기화가 ė´ë¯¸ ė‹¤í–‰ ė¤‘ėž…ë‹ˆë‹¤."; - readonly ru: "P2P Sync ҃ĐļĐĩ СаĐŋŅƒŅ‰ĐĩĐŊ."; - readonly zh: "P2P Sync is already running."; - }; - readonly "P2P.SyncCompleted": { - readonly def: "P2P Sync completed."; - readonly fr: "Sync P2P terminÊe."; - readonly he: "סנכרון P2P הושלם."; - readonly ja: "P2P同期が厌äē†ã—ぞした。"; - readonly ko: "P2P 동기화가 ė™„ëŖŒë˜ė—ˆėŠĩ니다."; - readonly ru: "P2P Sync СавĐĩŅ€ŅˆŅ‘ĐŊ."; - readonly zh: "P2P Sync completed."; - }; - readonly "P2P.SyncStartedWith": { - readonly def: "P2P Sync with ${name} have been started."; - readonly fr: "La Sync P2P avec ${name} a dÊmarrÊ."; - readonly he: "סנכרון P2P ×ĸם ${name} ה×Ēחיל."; - readonly ja: "${name}とぎP2P同期を開始しぞした。"; - readonly ko: "${name}ęŗŧė˜ P2P 동기화가 ė‹œėž‘ë˜ė—ˆėŠĩ니다."; - readonly ru: "P2P Sync ҁ name ĐŊĐ°Ņ‡Đ°Ņ‚."; - readonly zh: "P2P Sync with ${name} have been started."; - }; - readonly "paneMaintenance.markDeviceResolvedAfterBackup": { - readonly def: "paneMaintenance.markDeviceResolvedAfterBackup"; - readonly es: "Marcar el dispositivo como resuelto despuÊs de hacer una copia de seguridad"; - readonly ja: "バックã‚ĸップ垌ãĢã“ãŽãƒ‡ãƒã‚¤ã‚šã‚’č§Ŗæąē済ãŋãĢする"; - readonly ko: "ë°ąė—… 후 ėžĨėš˜ëĨŧ 해결됨ėœŧ로 í‘œė‹œ"; - readonly ru: "ПоĐŧĐĩŅ‚Đ¸Ņ‚ŅŒ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đž ĐēаĐē ĐžĐąŅ€Đ°ĐąĐžŅ‚Đ°ĐŊĐŊĐžĐĩ ĐŋĐžŅĐģĐĩ Ņ€ĐĩСĐĩŅ€Đ˛ĐŊĐžĐŗĐž ĐēĐžĐŋĐ¸Ņ€ĐžĐ˛Đ°ĐŊĐ¸Ņ"; - readonly zh: "č¯ˇåœ¨åŽŒæˆå¤‡äģŊåŽå°†æ­¤čŽžå¤‡æ ‡čŽ°ä¸ēåˇ˛å¤„į†ã€‚"; - readonly "zh-tw": "čĢ‹åœ¨åŽŒæˆå‚™äģŊåžŒå°‡æ­¤čŖįŊŽæ¨™č¨˜į‚ēåˇ˛č™•į†ã€‚"; - }; - readonly "paneMaintenance.remoteLockedAndDeviceNotAccepted": { - readonly def: "paneMaintenance.remoteLockedAndDeviceNotAccepted"; - readonly es: "La base de datos remota estÃĄ bloqueada y este dispositivo aÃēn no ha sido aceptado."; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚はロックされãĻおり、こぎデバイ゚はぞだæ‰ŋčĒã•ã‚ŒãĻいぞせん。"; - readonly ko: "ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ę°€ ėž ę˛¨ ėžˆėœŧ늰 ė´ ėžĨėš˜ëŠ” 땄링 ėŠšė¸ë˜ė§€ ė•Šė•˜ėŠĩ니다."; - readonly ru: "ĐŖĐ´Đ°ĐģŅ‘ĐŊĐŊĐ°Ņ йаСа даĐŊĐŊҋ҅ СайĐģĐžĐēĐ¸Ņ€ĐžĐ˛Đ°ĐŊа, и ŅŅ‚Đž ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đž Đĩ҉ґ ĐŊĐĩ ĐžĐ´ĐžĐąŅ€ĐĩĐŊĐž."; - readonly zh: "čŋœįĢ¯æ•°æŽåē“厞锁åޚīŧŒä¸”æ­¤čŽžå¤‡å°šæœĒčĸĢæŽĨ受。"; - readonly "zh-tw": "遠įĢ¯čŗ‡æ–™åēĢåˇ˛éŽ–åŽšīŧŒä¸”æ­¤čŖįŊŽå°šæœĒčĸĢæŽĨ受。"; - }; - readonly "paneMaintenance.remoteLockedResolvedDevice": { - readonly def: "paneMaintenance.remoteLockedResolvedDevice"; - readonly es: "La base de datos remota estÃĄ bloqueada, pero este dispositivo ya fue aceptado."; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚はロックされãĻいぞすが、こぎデバイ゚はすでãĢæ‰ŋčĒã•ã‚ŒãĻいぞす。"; - readonly ko: "ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ę°€ ėž ę˛¨ ėžˆė§€ë§Œ ė´ ėžĨėš˜ëŠ” ė´ë¯¸ ėŠšė¸ë˜ė—ˆėŠĩ니다."; - readonly ru: "ĐŖĐ´Đ°ĐģŅ‘ĐŊĐŊĐ°Ņ йаСа даĐŊĐŊҋ҅ СайĐģĐžĐēĐ¸Ņ€ĐžĐ˛Đ°ĐŊа, ĐŊĐž ŅŅ‚Đž ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đž ҃ĐļĐĩ ĐžĐ´ĐžĐąŅ€ĐĩĐŊĐž."; - readonly zh: "čŋœįĢ¯æ•°æŽåē“厞锁åޚīŧŒäŊ†æ­¤čŽžå¤‡åˇ˛čĸĢæŽĨ受。"; - readonly "zh-tw": "遠įĢ¯čŗ‡æ–™åēĢåˇ˛éŽ–åŽšīŧŒäŊ†æ­¤čŖįŊŽåˇ˛čĸĢæŽĨ受。"; - }; - readonly "paneMaintenance.unlockDatabaseReady": { - readonly def: "paneMaintenance.unlockDatabaseReady"; - readonly es: "Desbloquear la base de datos"; - readonly ja: "デãƒŧã‚ŋベãƒŧã‚šãŽãƒ­ãƒƒã‚¯ã‚’č§Ŗé™¤"; - readonly ko: "ë°ė´í„°ë˛ ė´ėŠ¤ ėž ę¸ˆ í•´ė œ"; - readonly ru: "РаСйĐģĐžĐēĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ ĐąĐ°ĐˇŅƒ даĐŊĐŊҋ҅"; - readonly zh: "įŽ°åœ¨å¯äģĨč§Ŗé”æ•°æŽåē“。"; - readonly "zh-tw": "įžåœ¨å¯äģĨč§ŖéŽ–čŗ‡æ–™åēĢ。"; - }; - readonly Passphrase: { - readonly def: "Passphrase"; - readonly es: "Frase de contraseÃąa"; - readonly fr: "Phrase secrète"; - readonly he: "ביטוי סיסמה"; - readonly ja: "パ゚フãƒŦãƒŧã‚ē"; - readonly ko: "íŒ¨ėŠ¤í”„ë ˆė´ėψ"; - readonly ru: "ĐŸĐ°Ņ€ĐžĐģҌĐŊĐ°Ņ Ņ„Ņ€Đ°ĐˇĐ°"; - readonly zh: "坆᠁"; - }; - readonly "Passphrase of sensitive configuration items": { - readonly def: "Passphrase of sensitive configuration items"; - readonly es: "Frase para elementos sensibles"; - readonly fr: "Phrase secrète des ÊlÊments de configuration sensibles"; - readonly he: "ביטוי סיסמה לפריטי ×Ē×Ļורה רגישים"; - readonly ja: "抟密性ぎéĢ˜ã„č¨­åŽšé …į›ŽãĢパ゚フãƒŦãƒŧã‚ēをäŊŋᔍ"; - readonly ko: "ë¯ŧ감한 ęĩŦė„ą 항ëĒŠė˜ íŒ¨ėŠ¤í”„ë ˆė´ėψ"; - readonly ru: "ĐŸĐ°Ņ€ĐžĐģҌĐŊĐ°Ņ Ņ„Ņ€Đ°ĐˇĐ° Đ´ĐģŅ ĐēĐžĐŊŅ„Đ¸Đ´ĐĩĐŊŅ†Đ¸Đ°ĐģҌĐŊҋ҅ ĐŊĐ°ŅŅ‚Ņ€ĐžĐĩĐē"; - readonly zh: "敏感配įŊŽéĄšįš„坆᠁"; - }; - readonly password: { - readonly def: "password"; - readonly es: "contraseÃąa"; - readonly fr: "mot de passe"; - readonly he: "סיסמה"; - readonly ja: "パ゚ワãƒŧド"; - readonly ko: "비밀번호"; - readonly ru: "ĐŋĐ°Ņ€ĐžĐģҌ"; - readonly zh: "坆᠁"; - }; - readonly Password: { - readonly def: "Password"; - readonly es: "ContraseÃąa"; - readonly fr: "Mot de passe"; - readonly he: "סיסמה"; - readonly ja: "パ゚ワãƒŧド"; - readonly ko: "비밀번호"; - readonly ru: "ĐŸĐ°Ņ€ĐžĐģҌ"; - readonly zh: "坆᠁"; - }; - readonly "Paste a connection string": { - readonly def: "Paste a connection string"; - readonly es: "Pegar cadena de conexiÃŗn"; - readonly ja: "æŽĨį–‡å­—列をč˛ŧりäģ˜ã‘ã‚‹"; - readonly ko: "뗰枰 ëŦ¸ėžė—´ ëļ™ė—Ŧë„Ŗę¸°"; - readonly ru: "Đ’ŅŅ‚Đ°Đ˛Đ¸Ņ‚ŅŒ ŅŅ‚Ņ€ĐžĐē҃ ĐŋОдĐēĐģŅŽŅ‡ĐĩĐŊĐ¸Ņ"; - readonly zh: "ម贴čŋžæŽĨ字įŦĻ串"; - readonly "zh-tw": "č˛ŧä¸Šé€Ŗįˇšå­—ä¸˛"; - }; - readonly "Paste the Setup URI generated from one of your active devices.": { - readonly def: "Paste the Setup URI generated from one of your active devices."; - readonly es: "Pegue el URI de configuraciÃŗn generado desde uno de sus dispositivos activos。"; - readonly ja: "į¨ŧ働中ぎį̝æœĢã§į”Ÿæˆã—ãŸ Setup URI をč˛ŧりäģ˜ã‘ãĻください。"; - readonly ko: "현ėžŦ ė‚ŦėšŠ ė¤‘ė¸ ėžĨėš˜ 뤑 í•˜ë‚˜ė—ė„œ ėƒė„ąí•œ 네렕 URIëĨŧ ëļ™ė—Ŧ ë„Ŗėœŧė„¸ėš”ã€‚"; - readonly ru: "Đ’ŅŅ‚Đ°Đ˛ŅŒŅ‚Đĩ Setup URI, ŅĐžĐˇĐ´Đ°ĐŊĐŊŅ‹Đš ĐŊа ОдĐŊĐžĐŧ иС Đ˛Đ°ŅˆĐ¸Ņ… аĐēŅ‚Đ¸Đ˛ĐŊҋ҅ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛ã€‚"; - readonly zh: "ម贴äģŽä¸€å°åˇ˛åœ¨äŊŋį”¨įš„čŽžå¤‡ä¸Šį”Ÿæˆįš„ Setup URI。"; - readonly "zh-tw": "č˛ŧä¸Šåžžä¸€å°åˇ˛åœ¨äŊŋį”¨čŖįŊŽä¸Šį”ĸį”Ÿįš„ Setup URI。"; - }; - readonly "Path Obfuscation": { - readonly def: "Path Obfuscation"; - readonly es: "OfuscaciÃŗn de rutas"; - readonly fr: "Obfuscation des chemins"; - readonly he: "×ĸרפול × ×Ēיב"; - readonly ja: "ãƒ‘ã‚šãŽé›ŖčĒ­åŒ–"; - readonly ko: "ę˛Ŋ로 난독화"; - readonly ru: "ĐžĐąŅ„ŅƒŅĐēĐ°Ņ†Đ¸Ņ ĐŋŅƒŅ‚ĐĩĐš"; - readonly zh: "čˇ¯åž„æˇˇæˇ†"; - }; - readonly "Patterns to match files for overwriting instead of merging": { - readonly def: "Patterns to match files for overwriting instead of merging"; - readonly es: "Patrones para identificar archivos que se sobrescribirÃĄn en lugar de fusionarse"; - readonly ja: "マãƒŧジではãĒãä¸Šæ›¸ãã™ã‚‹ãƒ•ã‚Ąã‚¤ãƒĢを判厚するパã‚ŋãƒŧãƒŗ"; - readonly ko: "ëŗ‘í•Š ëŒ€ė‹  ëŽė–´ė“¸ 파ėŧė„ íŒëŗ„í•˜ëŠ” 패턴"; - readonly ru: "ШайĐģĐžĐŊŅ‹ Đ´ĐģŅ Ņ„Đ°ĐšĐģОв, ĐēĐžŅ‚ĐžŅ€Ņ‹Đĩ ĐŊ҃ĐļĐŊĐž ĐŋĐĩŅ€ĐĩСаĐŋĐ¸ŅŅ‹Đ˛Đ°Ņ‚ŅŒ вĐŧĐĩŅŅ‚Đž ĐžĐąŅŠĐĩдиĐŊĐĩĐŊĐ¸Ņ"; - readonly zh: "ᔍäēŽåŒšé…éœ€čĻ†į›–č€Œéžåˆåšļ文äģļįš„æ¨Ąåŧ"; - readonly "zh-tw": "ᔍæ–ŧ匚配需čφå¯Ģč€ŒéžåˆäŊĩæĒ”æĄˆįš„æ¨Ąåŧ"; - }; - readonly "Patterns to match files for syncing": { - readonly def: "Patterns to match files for syncing"; - readonly es: "Patrones para identificar archivos que se sincronizarÃĄn"; - readonly ja: "åŒæœŸå¯žčąĄãƒ•ã‚Ąã‚¤ãƒĢを判厚するパã‚ŋãƒŧãƒŗ"; - readonly ko: "동기화할 파ėŧė„ íŒëŗ„í•˜ëŠ” 패턴"; - readonly ru: "ШайĐģĐžĐŊŅ‹ Đ´ĐģŅ Ņ„Đ°ĐšĐģОв, ĐēĐžŅ‚ĐžŅ€Ņ‹Đĩ ĐŊ҃ĐļĐŊĐž ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ"; - readonly zh: "ᔍäēŽåŒšé…åŒæ­Ĩ文äģļįš„æ¨Ąåŧ"; - readonly "zh-tw": "ᔍæ–ŧ匚配同æ­ĨæĒ”æĄˆįš„æ¨Ąåŧ"; - }; - readonly "Peer-to-Peer only": { - readonly def: "Peer-to-Peer only"; - readonly es: "Solo Peer-to-Peer"; - readonly ja: "Peer-to-Peer ぎãŋ"; - readonly ko: "Peer-to-Peer ė „ėšŠ"; - readonly ru: "ĐĸĐžĐģҌĐēĐž Peer-to-Peer"; - readonly zh: "äģ… Peer-to-Peer"; - readonly "zh-tw": "僅 Peer-to-Peer"; - }; - readonly "Peer-to-Peer Synchronisation": { - readonly def: "Peer-to-Peer Synchronisation"; - readonly es: "SincronizaciÃŗn entre pares"; - readonly ja: "ピã‚ĸツãƒŧピã‚ĸ同期"; - readonly ko: "í”ŧė–´ íˆŦ í”ŧė–´ 동기화"; - readonly ru: "ОдĐŊĐžŅ€Đ°ĐŊĐŗĐžĐ˛Đ°Ņ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Ņ"; - readonly zh: "į‚šå¯šį‚šåŒæ­Ĩ"; - readonly "zh-tw": "éģžå°éģžåŒæ­Ĩ"; - }; - readonly "Per-file-saved customization sync": { - readonly def: "Per-file-saved customization sync"; - readonly es: "SincronizaciÃŗn de personalizaciÃŗn por archivo"; - readonly fr: "Synchronisation de personnalisation enregistrÊe par fichier"; - readonly he: "סנכרון ה×Ēאמה אישי×Ē ×Š× ×Š×ž×¨ לפי קוב×Ĩ"; - readonly ja: "ãƒ•ã‚Ąã‚¤ãƒĢごとぎã‚Ģ゚ã‚ŋマイã‚ē同期"; - readonly ko: "파ėŧëŗ„ ė €ėžĨ ė‚ŦėšŠėž 네렕 동기화"; - readonly ru: "ХиĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Ņ ĐŊĐ°ŅŅ‚Ņ€ĐžĐĩĐē Đ´ĐģŅ ĐēаĐļĐ´ĐžĐŗĐž Ņ„Đ°ĐšĐģа"; - readonly zh: "按文äģļäŋå­˜įš„č‡Ē厚䚉同æ­Ĩ"; - }; - readonly Perform: { - readonly def: "Perform"; - readonly es: "Ejecutar"; - readonly ja: "åŽŸčĄŒ"; - readonly ko: "ė‹¤í–‰"; - readonly ru: "Đ’Ņ‹ĐŋĐžĐģĐŊĐ¸Ņ‚ŅŒ"; - readonly zh: "æ‰§čĄŒ"; - readonly "zh-tw": "åŸˇčĄŒ"; - }; - readonly "Perform cleanup": { - readonly def: "Perform cleanup"; - readonly es: "Ejecutar limpieza"; - readonly ja: "クãƒĒãƒŧãƒŗã‚ĸãƒƒãƒ—ã‚’åŽŸčĄŒ"; - readonly ko: "ė •ëĻŦ ė‹¤í–‰"; - readonly ru: "Đ’Ņ‹ĐŋĐžĐģĐŊĐ¸Ņ‚ŅŒ ĐžŅ‡Đ¸ŅŅ‚Đē҃"; - readonly zh: "æ‰§čĄŒæ¸…į†"; - readonly "zh-tw": "åŸˇčĄŒæ¸…į†"; - }; - readonly "Perform Garbage Collection": { - readonly def: "Perform Garbage Collection"; - readonly es: "Ejecutar recolecciÃŗn de basura"; - readonly ja: "ã‚Ŧãƒŧãƒ™ã‚¸ã‚ŗãƒŦã‚¯ã‚ˇãƒ§ãƒŗã‚’åŽŸčĄŒ"; - readonly ko: "ę°€ëš„ė§€ ėģŦë ‰ė…˜ ė‹¤í–‰"; - readonly ru: "Đ’Ņ‹ĐŋĐžĐģĐŊĐ¸Ņ‚ŅŒ ŅĐąĐžŅ€Đē҃ ĐŧŅƒŅĐžŅ€Đ°"; - readonly zh: "æ‰§čĄŒåžƒåœžå›žæ”ļ"; - readonly "zh-tw": "åŸˇčĄŒåžƒåœžå›žæ”ļ"; - }; - readonly "Perform Garbage Collection to remove unused chunks and reduce database size.": { - readonly def: "Perform Garbage Collection to remove unused chunks and reduce database size."; - readonly es: "Ejecuta la recolecciÃŗn de basura para eliminar chunks no usados y reducir el tamaÃąo de la base de datos."; - readonly ja: "æœĒäŊŋį”¨ãŽãƒãƒŖãƒŗã‚¯ã‚’å‰Šé™¤ã—ã€ãƒ‡ãƒŧã‚ŋベãƒŧ゚ã‚ĩイã‚ēを削減するためãĢã‚Ŧãƒŧãƒ™ã‚¸ã‚ŗãƒŦã‚¯ã‚ˇãƒ§ãƒŗã‚’åŽŸčĄŒã—ãžã™ã€‚"; - readonly ko: "ė‚ŦėšŠí•˜ė§€ ė•ŠëŠ” 랭íŦëĨŧ ė œęą°í•˜ęŗ  ë°ė´í„°ë˛ ė´ėŠ¤ íŦ기ëĨŧ ė¤„ė´ę¸° ėœ„í•´ ę°€ëš„ė§€ ėģŦë ‰ė…˜ė„ ė‹¤í–‰í•Šë‹ˆë‹¤."; - readonly ru: "Đ’Ņ‹ĐŋĐžĐģĐŊŅĐĩŅ‚ ŅĐąĐžŅ€Đē҃ ĐŧŅƒŅĐžŅ€Đ°, Ņ‡Ņ‚ĐžĐąŅ‹ ŅƒĐ´Đ°ĐģĐ¸Ņ‚ŅŒ ĐŊĐĩĐ¸ŅĐŋĐžĐģŅŒĐˇŅƒĐĩĐŧŅ‹Đĩ Ņ‡Đ°ĐŊĐēи и ҃ĐŧĐĩĐŊŅŒŅˆĐ¸Ņ‚ŅŒ Ņ€Đ°ĐˇĐŧĐĩŅ€ ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅."; - readonly zh: "æ‰§čĄŒåžƒåœžå›žæ”ļäģĨæ¸…į†æœĒäŊŋį”¨įš„ chunks åšļ减小数捎åē“äŊ“į§¯ã€‚"; - readonly "zh-tw": "åŸˇčĄŒåžƒåœžå›žæ”ļäģĨį§ģ除æœĒäŊŋį”¨įš„ chunks ä¸Ļæ¸›å°‘čŗ‡æ–™åēĢ大小。"; - }; - readonly "Periodic Sync interval": { - readonly def: "Periodic Sync interval"; - readonly es: "Intervalo de sincronizaciÃŗn periÃŗdica"; - readonly fr: "Intervalle de synchronisation pÊriodique"; - readonly he: "מרווח סנכרון ×Ēקופ×Ēי"; - readonly ja: "厚時同期ぎ感čϚ"; - readonly ko: "ėŖŧ揰렁 동기화 간격"; - readonly ru: "ИĐŊŅ‚ĐĩŅ€Đ˛Đ°Đģ ĐŋĐĩŅ€Đ¸ĐžĐ´Đ¸Ņ‡ĐĩҁĐēОК ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸"; - readonly zh: "厚期同æ­Ĩ间隔"; - }; - readonly "Pick a file to resolve conflict": { - readonly def: "Pick a file to resolve conflict"; - readonly es: "Elegir un archivo para resolver el conflicto"; - readonly ja: "įĢļåˆã‚’č§Ŗæąēã™ã‚‹ãƒ•ã‚Ąã‚¤ãƒĢを選択"; - readonly ko: "ėļŠëŒė„ 해결할 파ėŧ ė„ íƒ"; - readonly ru: "Đ’Ņ‹ĐąŅ€Đ°Ņ‚ŅŒ Ņ„Đ°ĐšĐģ Đ´ĐģŅ Ņ€Đ°ĐˇŅ€Đĩ҈ĐĩĐŊĐ¸Ņ ĐēĐžĐŊŅ„ĐģиĐēŅ‚Đ°"; - readonly zh: "选拊čρ觪冺冞įĒįš„æ–‡äģļ"; - readonly "zh-tw": "選擇čρ觪æąē衝įĒįš„æĒ”æĄˆ"; - }; - readonly "Pick a file to show history": { - readonly def: "Pick a file to show history"; - readonly "zh-tw": "選擇čĻéĄ¯į¤ēæ­ˇį¨‹įš„æĒ”æĄˆ"; - }; - readonly "Please disable 'Read chunks online' in settings to use Garbage Collection.": { - readonly def: "Please disable 'Read chunks online' in settings to use Garbage Collection."; - readonly ja: "Garbage Collection をäŊŋうãĢã¯ã€č¨­åŽšã§ã€ŒRead chunks onlineã€ã‚’į„ĄåŠšãĢしãĻください。"; - readonly ko: "Garbage Collectionė„ ė‚ŦėšŠí•˜ë ¤ëŠ´ ė„¤ė •ė—ė„œ \"Read chunks online\"ė„ ëš„í™œė„ąí™”í•´ ėŖŧė„¸ėš”."; - readonly ru: "Đ§Ņ‚ĐžĐąŅ‹ Đ¸ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ŅŒ Garbage Collection, ĐžŅ‚ĐēĐģŅŽŅ‡Đ¸Ņ‚Đĩ в ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēĐ°Ņ… ÂĢRead chunks onlineÂģ."; - readonly zh: "čρäŊŋį”¨åžƒåœžå›žæ”ļīŧŒč¯ˇåœ¨čŽžįŊŽä¸­įĻį”¨â€œRead chunks online”。"; - readonly "zh-tw": "č‹ĨčρäŊŋį”¨åžƒåœžå›žæ”ļīŧŒčĢ‹åœ¨č¨­åŽšä¸­åœį”¨ã€ŒRead chunks online」。"; - }; - readonly "Please enable 'Compute revisions for chunks' in settings to use Garbage Collection.": { - readonly def: "Please enable 'Compute revisions for chunks' in settings to use Garbage Collection."; - readonly ja: "Garbage Collection をäŊŋうãĢã¯ã€č¨­åŽšã§ã€ŒCompute revisions for chunks」を有劚ãĢしãĻください。"; - readonly ko: "Garbage Collectionė„ ė‚ŦėšŠí•˜ë ¤ëŠ´ ė„¤ė •ė—ė„œ \"Compute revisions for chunks\"ëĨŧ í™œė„ąí™”í•´ ėŖŧė„¸ėš”."; - readonly ru: "Đ§Ņ‚ĐžĐąŅ‹ Đ¸ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ŅŒ Garbage Collection, вĐēĐģŅŽŅ‡Đ¸Ņ‚Đĩ в ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēĐ°Ņ… ÂĢCompute revisions for chunksÂģ."; - readonly zh: "čρäŊŋį”¨åžƒåœžå›žæ”ļīŧŒč¯ˇåœ¨čŽžįŊŽä¸­å¯į”¨â€œCompute revisions for chunks”。"; - readonly "zh-tw": "č‹ĨčρäŊŋį”¨åžƒåœžå›žæ”ļīŧŒčĢ‹åœ¨č¨­åŽšä¸­å•Ÿį”¨ã€ŒCompute revisions for chunks」。"; - }; - readonly "Please select 'Cancel' explicitly to cancel this operation.": { - readonly def: "Please select 'Cancel' explicitly to cancel this operation."; - readonly ja: "こぎ操äŊœã‚’中æ­ĸするãĢは、明į¤ēįš„ãĢã€Œã‚­ãƒŖãƒŗã‚ģãƒĢ」を選択しãĻください。"; - readonly ko: "ė´ ėž‘ė—…ė„ ėˇ¨ė†Œí•˜ë ¤ëŠ´ ë°˜ë“œė‹œ \"ėˇ¨ė†Œ\"ëĨŧ ëĒ…ė‹œė ėœŧ로 ė„ íƒí•´ ėŖŧė„¸ėš”."; - readonly ru: "Đ§Ņ‚ĐžĐąŅ‹ ĐžŅ‚ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ ŅŅ‚Ņƒ ĐžĐŋĐĩŅ€Đ°Ņ†Đ¸ŅŽ, ŅĐ˛ĐŊĐž Đ˛Ņ‹ĐąĐĩŅ€Đ¸Ņ‚Đĩ ÂĢĐžŅ‚ĐŧĐĩĐŊаÂģ."; - readonly zh: "åĻ‚éœ€å–æļˆæ­¤æ“äŊœīŧŒč¯ˇæ˜ŽįĄŽé€‰æ‹Šâ€œå–æļˆâ€ã€‚"; - readonly "zh-tw": "č‹ĨčĻå–æļˆæ­¤æ“äŊœīŧŒčĢ‹æ˜Žįĸē選擇「取æļˆã€ã€‚"; - }; - readonly "Please select a method to import the settings from another device.": { - readonly def: "Please select a method to import the settings from another device."; - readonly es: "Seleccione un mÊtodo para importar la configuraciÃŗn desde otro dispositivo。"; - readonly ja: "åˆĨぎį̝æœĢã‹ã‚‰č¨­åŽšã‚’å–ã‚Ščžŧã‚€æ–šæŗ•ã‚’é¸æŠžã—ãĻください。"; - readonly ko: "다ëĨ¸ ėžĨėš˜ė—ė„œ ė„¤ė •ė„ 氀렏ė˜Ŧ ë°Šë˛•ė„ ė„ íƒí•´ ėŖŧė„¸ėš”ã€‚"; - readonly ru: "Đ’Ņ‹ĐąĐĩŅ€Đ¸Ņ‚Đĩ ҁĐŋĐžŅĐžĐą иĐŧĐŋĐžŅ€Ņ‚Đ° ĐŊĐ°ŅŅ‚Ņ€ĐžĐĩĐē ҁ Đ´Ņ€ŅƒĐŗĐžĐŗĐž ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°ã€‚"; - readonly zh: "č¯ˇé€‰æ‹Šä¸€į§äģŽå…ļäģ–čŽžå¤‡å¯ŧå…Ĩ莞įŊŽįš„æ–šæŗ•ã€‚"; - readonly "zh-tw": "čĢ‹é¸æ“‡ä¸€į¨Žåžžå…ļäģ–čŖįŊŽåŒ¯å…Ĩč¨­åŽšįš„æ–šæŗ•ã€‚"; - }; - readonly "Please select an option to proceed": { - readonly def: "Please select an option to proceed"; - readonly es: "Seleccione una opciÃŗn para continuar"; - readonly ja: "įļščĄŒã™ã‚‹ãĢã¯é …į›Žã‚’é¸æŠžã—ãĻください"; - readonly ko: "ęŗ„ė†í•˜ë ¤ëŠ´ 항ëĒŠė„ ė„ íƒí•´ ėŖŧė„¸ėš”"; - readonly ru: "Đ§Ņ‚ĐžĐąŅ‹ ĐŋŅ€ĐžĐ´ĐžĐģĐļĐ¸Ņ‚ŅŒ, Đ˛Ņ‹ĐąĐĩŅ€Đ¸Ņ‚Đĩ Đ˛Đ°Ņ€Đ¸Đ°ĐŊŅ‚"; - readonly zh: "č¯ˇé€‰æ‹Šä¸€ä¸Ē选项äģĨįģ§įģ­"; - readonly "zh-tw": "čĢ‹é¸æ“‡ä¸€å€‹é¸é …äģĨįšŧįēŒ"; - }; - readonly "Please select the type of server to which you are connecting.": { - readonly def: "Please select the type of server to which you are connecting."; - readonly es: "Seleccione el tipo de servidor al que se estÃĄ conectando。"; - readonly ja: "æŽĨįļšã™ã‚‹ã‚ĩãƒŧバãƒŧãŽį¨ŽéĄžã‚’é¸æŠžã—ãĻください。"; - readonly ko: "ė—°ę˛°í•  ė„œë˛„ ėœ í˜•ė„ ė„ íƒí•´ ėŖŧė„¸ėš”ã€‚"; - readonly ru: "Đ’Ņ‹ĐąĐĩŅ€Đ¸Ņ‚Đĩ Ņ‚Đ¸Đŋ ҁĐĩŅ€Đ˛ĐĩŅ€Đ°, Đē ĐēĐžŅ‚ĐžŅ€ĐžĐŧ҃ Đ˛Ņ‹ ĐŋОдĐēĐģŅŽŅ‡Đ°ĐĩŅ‚ĐĩŅŅŒã€‚"; - readonly zh: "č¯ˇé€‰æ‹ŠäŊ čρčŋžæŽĨįš„æœåŠĄå™¨įąģ型。"; - readonly "zh-tw": "čĢ‹é¸æ“‡äŊ čĻé€Ŗįˇšįš„äŧ翜å™¨éĄžåž‹ã€‚"; - }; - readonly "Please set device name to identify this device. This name should be unique among your devices. While not configured, we cannot enable this feature.": { - readonly def: "Please set device name to identify this device. This name should be unique among your devices. While not configured, we cannot enable this feature."; - readonly es: "Define un nombre para identificar este dispositivo. Debe ser Ãēnico entre tus dispositivos. Mientras no estÊ configurado, no podremos habilitar esta funciÃŗn."; - readonly ja: "ã“ãŽãƒ‡ãƒã‚¤ã‚šã‚’č­˜åˆĨã™ã‚‹ãŸã‚ãŽãƒ‡ãƒã‚¤ã‚šåã‚’č¨­åŽšã—ãĻください。こぎ名前は各デバイ゚で一意であるåŋ…čĻãŒã‚ã‚Šãžã™ã€‚č¨­åŽšã•ã‚Œã‚‹ãžã§ã€ã“ãŽæŠŸčƒŊは有劚ãĢできぞせん。"; - readonly ko: "ė´ ėžĨėš˜ëĨŧ ė‹ëŗ„í•  ėžĨėš˜ ė´ëĻ„ė„ ė„¤ė •í•´ ėŖŧė„¸ėš”. ė´ ė´ëĻ„ė€ ėžĨėš˜ 氄뗐 ęŗ ėœ í•´ė•ŧ 합니다. ė„¤ė •ë˜ę¸° ė „ęšŒė§€ëŠ” ė´ 기ëŠĨė„ í™œė„ąí™”í•  눘 ė—†ėŠĩ니다."; - readonly ru: "ĐŖĐēаĐļĐ¸Ņ‚Đĩ иĐŧŅ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ° Đ´ĐģŅ идĐĩĐŊŅ‚Đ¸Ņ„Đ¸ĐēĐ°Ņ†Đ¸Đ¸ ŅŅ‚ĐžĐŗĐž ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°. ИĐŧŅ Đ´ĐžĐģĐļĐŊĐž ĐąŅ‹Ņ‚ŅŒ ҃ĐŊиĐēаĐģҌĐŊŅ‹Đŧ ҁҀĐĩди Đ˛Đ°ŅˆĐ¸Ņ… ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛. ПоĐēа ĐžĐŊĐž ĐŊĐĩ СадаĐŊĐž, ĐŧŅ‹ ĐŊĐĩ ĐŧĐžĐļĐĩĐŧ вĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒ ŅŅ‚Ņƒ Ņ„ŅƒĐŊĐēŅ†Đ¸ŅŽ."; - readonly zh: "č¯ˇčŽžįŊŽčŽžå¤‡åį§°äģĨæ ‡č¯†æ­¤čŽžå¤‡ã€‚č¯Ĩåį§°åœ¨äŊ įš„æ‰€æœ‰čŽžå¤‡äš‹é—´åē”äŋæŒå”¯ä¸€īŧ›æœĒ配įŊŽå‰æ— æŗ•å¯į”¨æ­¤åŠŸčƒŊ。"; - }; - readonly "Please set this device name": { - readonly def: "Please set this device name"; - readonly es: "Define el nombre de este dispositivo"; - readonly ja: "ã“ãŽãƒ‡ãƒã‚¤ã‚šåã‚’č¨­åŽšã—ãĻください"; - readonly ko: "ė´ ėžĨėš˜ ė´ëĻ„ė„ ė„¤ė •í•´ ėŖŧė„¸ėš”"; - readonly ru: "ĐŖĐēаĐļĐ¸Ņ‚Đĩ иĐŧŅ ŅŅ‚ĐžĐŗĐž ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°"; - readonly zh: "č¯ˇčŽžįŊŽæ­¤čŽžå¤‡åį§°"; - readonly "zh-tw": "čĢ‹č¨­åŽšæ­¤čŖįŊŽåį¨ą"; - }; - readonly "Plug-in version": { - readonly def: "Plug-in version"; - readonly ja: "ãƒ—ãƒŠã‚°ã‚¤ãƒŗãƒãƒŧã‚¸ãƒ§ãƒŗ"; - readonly ko: "플ëŸŦęˇ¸ė¸ ë˛„ė „"; - readonly ru: "ВĐĩŅ€ŅĐ¸Ņ ĐŋĐģĐ°ĐŗĐ¸ĐŊа"; - readonly zh: "插äģļį‰ˆæœŦ"; - readonly "zh-tw": "å¤–æŽ›į‰ˆæœŦ"; - }; - readonly "Prepare the 'report' to create an issue": { - readonly def: "Prepare the 'report' to create an issue"; - readonly fr: "PrÊparer le ÂĢ rapport Âģ pour crÊer un ticket"; - readonly he: "הכן 'דו\"ח' לי×Ļיר×Ē Issue"; - readonly ja: "Issue äŊœæˆį”¨ãŽã€ŒãƒŦポãƒŧト」をæē–å‚™"; - readonly ko: "ė´ėŠˆ ėƒė„ąė„ ėœ„í•œ 'ëŗ´ęŗ ė„œ' ė¤€ëš„"; - readonly ru: "ĐŸĐžĐ´ĐŗĐžŅ‚ĐžĐ˛Đ¸Ņ‚ŅŒ ÂĢĐžŅ‚Ņ‡Ņ‘Ņ‚Âģ Đ´ĐģŅ ŅĐžĐˇĐ´Đ°ĐŊĐ¸Ņ Issue"; - readonly zh: "准备 'æŠĨ告' äģĨ创åģē问éĸ˜å•"; - readonly "zh-tw": "æē–å‚™åģēįĢ‹ Issue į”¨įš„ã€Œå ąå‘Šã€"; - }; - readonly Presets: { - readonly def: "Presets"; - readonly es: "Preconfiguraciones"; - readonly fr: "PrÊrÊglages"; - readonly he: "קבי×ĸו×Ē ×ž×¨××Š"; - readonly ja: "プãƒĒã‚ģット"; - readonly ko: "프ëĻŦė…‹"; - readonly ru: "ĐŸŅ€ĐĩҁĐĩ҂ҋ"; - readonly zh: "éĸ„莞"; - }; - readonly "Proceed Garbage Collection": { - readonly def: "Proceed Garbage Collection"; - readonly ja: "Garbage Collection をįļščĄŒ"; - readonly ko: "Garbage Collection ęŗ„ė†"; - readonly ru: "ĐŸŅ€ĐžĐ´ĐžĐģĐļĐ¸Ņ‚ŅŒ Garbage Collection"; - readonly zh: "įģ§įģ­æ‰§čĄŒåžƒåœžå›žæ”ļ"; - readonly "zh-tw": "įšŧįēŒåŸˇčĄŒåžƒåœžå›žæ”ļ"; - }; - readonly "Proceed with Setup URI": { - readonly def: "Proceed with Setup URI"; - readonly es: "Continuar con el URI de configuraciÃŗn"; - readonly ja: "Setup URI でįļščĄŒ"; - readonly ko: "네렕 URI로 ęŗ„ė†"; - readonly ru: "ĐŸŅ€ĐžĐ´ĐžĐģĐļĐ¸Ņ‚ŅŒ ҁ Setup URI"; - readonly zh: "įģ§įģ­äŊŋᔍ Setup URI"; - readonly "zh-tw": "įšŧįēŒäŊŋᔍ Setup URI"; - }; - readonly "Proceeding with Garbage Collection, ignoring missing nodes.": { - readonly def: "Proceeding with Garbage Collection, ignoring missing nodes."; - readonly ja: "不čļŗã—ãĻいるノãƒŧãƒ‰ã‚’į„ĄčĻ–ã—ãĻ Garbage Collection をįļščĄŒã—ぞす。"; - readonly ko: "누ëŊ된 노드ëĨŧ ëŦ´ė‹œí•˜ęŗ  Garbage Collectionė„ ęŗ„ė† ė§„í–‰í•Šë‹ˆë‹¤."; - readonly ru: "ĐŸŅ€ĐžĐ´ĐžĐģĐļаĐĩĐŧ Garbage Collection, Đ¸ĐŗĐŊĐžŅ€Đ¸Ņ€ŅƒŅ ĐžŅ‚ŅŅƒŅ‚ŅŅ‚Đ˛ŅƒŅŽŅ‰Đ¸Đĩ ŅƒĐˇĐģŅ‹."; - readonly zh: "æ­Ŗåœ¨įģ§įģ­æ‰§čĄŒåžƒåœžå›žæ”ļīŧŒåšļåŋŊį•Ĩįŧēå¤ąčŠ‚į‚šã€‚"; - readonly "zh-tw": "æ­Ŗåœ¨įšŧįēŒåŸˇčĄŒåžƒåœžå›žæ”ļīŧŒä¸ĻåŋŊį•Ĩįŧēå¤ąį¯€éģžã€‚"; - }; - readonly "Proceeding with Garbage Collection.": { - readonly def: "Proceeding with Garbage Collection."; - readonly ja: "Garbage Collection ã‚’åŽŸčĄŒã—ãžã™ã€‚"; - readonly ko: "Garbage Collectionė„ ė§„í–‰í•Šë‹ˆë‹¤."; - readonly ru: "ЗаĐŋ҃ҁĐēаĐĩĐŧ Garbage Collection."; - readonly zh: "æ­Ŗåœ¨æ‰§čĄŒåžƒåœžå›žæ”ļ。"; - readonly "zh-tw": "æ­Ŗåœ¨åŸˇčĄŒåžƒåœžå›žæ”ļ。"; - }; - readonly "Process small files in the foreground": { - readonly def: "Process small files in the foreground"; - readonly es: "Procesar archivos pequeÃąos en primer plano"; - readonly fr: "Traiter les petits fichiers au premier plan"; - readonly he: "×ĸבד קב×Ļים קטנים בחזי×Ē"; - readonly ja: "å°ã•ã„ãƒ•ã‚Ąã‚¤ãƒĢを最前éĸでå‡Ļᐆ"; - readonly ko: "íŦ꡸ëŧėš´ë“œė—ė„œ ėž‘ė€ 파ėŧ 래ëĻŦ"; - readonly ru: "ĐžĐąŅ€Đ°ĐąĐ°Ņ‚Ņ‹Đ˛Đ°Ņ‚ŅŒ ĐŧаĐģĐĩĐŊҌĐēиĐĩ Ņ„Đ°ĐšĐģŅ‹ в ĐžŅĐŊОвĐŊĐžĐŧ ĐŋĐžŅ‚ĐžĐēĐĩ"; - readonly zh: "åœ¨å‰å°å¤„į†å°æ–‡äģļ"; - }; - readonly Progress: { - readonly def: "Progress"; - readonly ja: "é€˛æ—"; - readonly ko: "ė§„í–‰ ėƒíƒœ"; - readonly ru: "ĐŸŅ€ĐžĐŗŅ€Đĩҁҁ"; - readonly zh: "čŋ›åēĻ"; - readonly "zh-tw": "進åēĻ"; - }; - readonly "Property Encryption": { - readonly def: "Property Encryption"; - readonly fr: "Chiffrement des propriÊtÊs"; - readonly he: "ה×Ļפנ×Ē ×ž××¤×™×™× ×™×"; - readonly ru: "Đ¨Đ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊиĐĩ ŅĐ˛ĐžĐšŅŅ‚Đ˛"; - readonly zh: "åąžæ€§åŠ å¯†"; - }; - readonly "PureJS fallback (Fast, W/O WebAssembly)": { - readonly def: "PureJS fallback (Fast, W/O WebAssembly)"; - readonly es: "Alternativa PureJS (rÃĄpida, sin WebAssembly)"; - readonly ja: "PureJS フりãƒŧãƒĢバック (éĢ˜é€Ÿã€WebAssembly ãĒし)"; - readonly ko: "PureJS ëŒ€ė˛´ ë°Šė‹ (ëš ëĻ„, WebAssembly ė—†ėŒ)"; - readonly ru: "Đ’Đ°Ņ€Đ¸Đ°ĐŊŅ‚ PureJS (ĐąŅ‹ŅŅ‚Ņ€Ņ‹Đš, ĐąĐĩС WebAssembly)"; - readonly zh: "PureJS 回退īŧˆåŋĢ速īŧŒæ—  WebAssemblyīŧ‰"; - readonly "zh-tw": "PureJS 回退īŧˆåŋĢ速īŧŒį„Ą WebAssemblyīŧ‰"; - }; - readonly "Purge all download/upload cache.": { - readonly def: "Purge all download/upload cache."; - readonly es: "Purga toda la cachÊ de descarga y carga."; - readonly ja: "ダã‚Ļãƒŗãƒ­ãƒŧドīŧã‚ĸップロãƒŧãƒ‰ã‚­ãƒŖãƒƒã‚ˇãƒĨをすずãĻ削除しぞす。"; - readonly ko: "ëĒ¨ë“  ë‹¤ėš´ëĄœë“œ/ė—…ëĄœë“œ ėēė‹œëĨŧ ė œęą°í•Šë‹ˆë‹¤."; - readonly ru: "ĐžŅ‡Đ¸ŅŅ‚Đ¸Ņ‚ŅŒ вĐĩҁҌ ĐēŅŅˆ ĐˇĐ°ĐŗŅ€ŅƒĐˇĐēи/Đ˛Ņ‹ĐŗŅ€ŅƒĐˇĐēи."; - readonly zh: "清除所有下čŊŊīŧä¸Šäŧ įŧ“存。"; - readonly "zh-tw": "清除所有下čŧ‰īŧä¸Šå‚ŗåŋĢ取。"; - }; - readonly "Purge all journal counter": { - readonly def: "Purge all journal counter"; - readonly es: "Purgar todos los contadores del diario"; - readonly ja: "すずãĻãŽã‚¸ãƒŖãƒŧナãƒĢã‚Ģã‚Ļãƒŗã‚ŋãƒŧを削除"; - readonly ko: "ëĒ¨ë“  ė €ë„ ėš´ėš´í„° ė‚­ė œ"; - readonly ru: "ĐžŅ‡Đ¸ŅŅ‚Đ¸Ņ‚ŅŒ Đ˛ŅĐĩ ŅŅ‡Ņ‘Ņ‚Ņ‡Đ¸Đēи ĐļŅƒŅ€ĐŊаĐģа"; - readonly zh: "清除所有æ—Ĩåŋ—čŽĄæ•°å™¨"; - readonly "zh-tw": "清除所有æ—ĨčĒŒč¨ˆæ•¸å™¨"; - }; - readonly "Rebuild local and remote database with local files.": { - readonly def: "Rebuild local and remote database with local files."; - readonly es: "Reconstruye la base de datos local y remota usando los archivos locales."; - readonly ja: "ロãƒŧã‚ĢãƒĢãƒ•ã‚Ąã‚¤ãƒĢをäŊŋãŖãĻロãƒŧã‚ĢãƒĢとãƒĒãƒĸãƒŧトぎデãƒŧã‚ŋベãƒŧã‚šã‚’å†æ§‹į¯‰ã—ãžã™ã€‚"; - readonly ko: "로ėģŦ 파ėŧ로 로ėģŦ 및 ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ëĨŧ ë‹¤ė‹œ ęĩŦėļ•핊니다."; - readonly ru: "ПĐĩŅ€ĐĩŅŅ‚Ņ€ĐžĐ¸Ņ‚ŅŒ ĐģĐžĐēаĐģҌĐŊŅƒŅŽ и ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊŅƒŅŽ ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅ ĐŊа ĐžŅĐŊОвĐĩ ĐģĐžĐēаĐģҌĐŊҋ҅ Ņ„Đ°ĐšĐģОв."; - readonly zh: "äŊŋᔍæœŦ地文äģļ重åģ翜Ŧ地和čŋœįĢ¯æ•°æŽåē“。"; - readonly "zh-tw": "äģĨæœŦ抟æĒ”æĄˆé‡åģ翜ŦæŠŸčˆ‡é įĢ¯čŗ‡æ–™åēĢ。"; - }; - readonly "Rebuilding Operations (Remote Only)": { - readonly def: "Rebuilding Operations (Remote Only)"; - readonly es: "Operaciones de reconstrucciÃŗn (solo remoto)"; - readonly ja: "å†æ§‹į¯‰æ“äŊœ (ãƒĒãƒĸãƒŧトぎãŋ)"; - readonly ko: "ėžŦęĩŦėļ• ėž‘ė—… (ė›ę˛Š ė „ėšŠ)"; - readonly ru: "ОĐŋĐĩŅ€Đ°Ņ†Đ¸Đ¸ ĐŋĐĩŅ€ĐĩŅŅ‚Ņ€ĐžĐĩĐŊĐ¸Ņ (Ņ‚ĐžĐģҌĐēĐž ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐĩ Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰Đĩ)"; - readonly zh: "重åģēæ“äŊœīŧˆäģ…čŋœį̝īŧ‰"; - readonly "zh-tw": "重åģēäŊœæĨ­īŧˆåƒ…遠į̝īŧ‰"; - }; - readonly "Recovery and Repair": { - readonly def: "Recovery and Repair"; - readonly "zh-tw": "äŋŽåžŠčˆ‡äŋŽčŖœ"; - }; - readonly "Recreate all": { - readonly def: "Recreate all"; - readonly es: "Recrear todo"; - readonly ja: "すずãĻ再äŊœæˆ"; - readonly ko: "ëĒ¨ë‘ ë‹¤ė‹œ ėƒė„ą"; - readonly ru: "ПĐĩŅ€ĐĩŅĐžĐˇĐ´Đ°Ņ‚ŅŒ Đ˛ŅŅ‘"; - readonly zh: "全部重åģē"; - readonly "zh-tw": "全部重åģē"; - }; - readonly "Recreate missing chunks for all files": { - readonly def: "Recreate missing chunks for all files"; - readonly es: "Recrear fragmentos faltantes para todos los archivos"; - readonly ja: "すずãĻãŽãƒ•ã‚Ąã‚¤ãƒĢぎ不čļŗãƒãƒŖãƒŗã‚¯ã‚’再äŊœæˆ"; - readonly ko: "ëĒ¨ë“  파ėŧė˜ 누ëŊ된 랭íŦ ë‹¤ė‹œ ėƒė„ą"; - readonly ru: "ПĐĩŅ€ĐĩŅĐžĐˇĐ´Đ°Ņ‚ŅŒ ĐžŅ‚ŅŅƒŅ‚ŅŅ‚Đ˛ŅƒŅŽŅ‰Đ¸Đĩ Ņ‡Đ°ĐŊĐēи Đ´ĐģŅ Đ˛ŅĐĩŅ… Ņ„Đ°ĐšĐģОв"; - readonly zh: "ä¸ē所有文äģļ重åģēįŧēå¤ąįš„ chunks"; - readonly "zh-tw": "į‚ē所有æĒ”æĄˆé‡åģēéēå¤ąįš„ chunks"; - }; - readonly "RedFlag.Fetch.Method.Desc": { - readonly def: "How do you want to fetch?\n- Create a local database once before fetching.\n **Low Traffic**, **High CPU**, **Low Risk**\n Recommended if ...\n - Files possibly inconsistent\n - Files were not so much\n- Create local file chunks before fetching.\n **Low Traffic**, **Moderate CPU**, **Low to Moderate Risk**\n Recommended if ...\n - Files probably consistent\n - You have a lot of files.\n- Fetch everything from the remote.\n **High Traffic**, **Low CPU**, **Low to Moderate Risk**\n\n>[!INFO]- Details\n> ## Create a local database once before fetching.\n> **Low Traffic**, **High CPU**, **Low Risk**\n> This option first creates a local database using existing local files before fetching data from the remote source.\n> If matching files exist both locally and remotely, only the differences between them will be transferred.\n> However, files present in both locations will initially be handled as conflicted files. They will be resolved automatically if they are not actually conflicted, but this process may take time.\n> This is generally the safest method, minimizing data loss risk.\n> ## Create local file chunks before fetching.\n> **Low Traffic**, **Moderate CPU**, **Low to Moderate Risk** (depending operation)\n> This option first creates chunks from local files for the database, then fetches data. Consequently, only chunks missing locally are transferred. However, all metadata is taken from the remote source.\n> Local files are then compared against this metadata at launch. The content considered newer will overwrite the older one (by modified time). This outcome is then synchronised back to the remote database.\n> This is generally safe if local files are genuinely the latest timestamp. However, it can cause problems if a file has a newer timestamp but older content (like the initial `welcome.md`).\n> This uses less CPU and faster than \"Create a local database once before fetching\", but it may lead to data loss if not used carefully.\n> ## Fetch everything from the remote.\n> **High Traffic**, **Low CPU**, **Low to Moderate Risk** (depending operation)\n> All things will be fetched from the remote.\n> Similar to the Create local file chunks before fetching, but all chunks are fetched from the remote source.\n> This is the most traditional way to fetch, typically consuming the most network traffic and time. It also carries a similar risk of overwriting remote files to the 'Create local file chunks before fetching' option.\n> However, it is often considered the most stable method because it is the longest-established and most straightforward approach."; - readonly fr: "Comment voulez-vous rÊcupÊrer ?\n- CrÊer une base locale avant de rÊcupÊrer.\n **Trafic faible**, **CPU ÊlevÊ**, **Risque faible**\n RecommandÊ si ...\n - Fichiers possiblement incohÊrents\n - Fichiers peu nombreux\n- CrÊer des fragments de fichiers locaux avant de rÊcupÊrer.\n **Trafic faible**, **CPU modÊrÊ**, **Risque faible à modÊrÊ**\n RecommandÊ si ...\n - Fichiers probablement cohÊrents\n - Vous avez beaucoup de fichiers.\n- Tout rÊcupÊrer depuis le distant.\n **Trafic ÊlevÊ**, **CPU faible**, **Risque faible à modÊrÊ**\n\n>[!INFO]- DÊtails\n> ## CrÊer une base locale avant de rÊcupÊrer.\n> **Trafic faible**, **CPU ÊlevÊ**, **Risque faible**\n> Cette option crÊe d'abord une base locale à partir des fichiers locaux existants avant de rÊcupÊrer les donnÊes depuis la source distante.\n> Si des fichiers correspondants existent à la fois localement et à distance, seules les diffÊrences entre eux seront transfÊrÊes.\n> Toutefois, les fichiers prÊsents aux deux emplacements seront initialement traitÊs comme en conflit. Ils seront rÊsolus automatiquement s'ils ne le sont pas rÊellement, mais ce processus peut prendre du temps.\n> C'est gÊnÊralement la mÊthode la plus sÃģre, minimisant le risque de perte de donnÊes.\n> ## CrÊer des fragments de fichiers locaux avant de rÊcupÊrer.\n> **Trafic faible**, **CPU modÊrÊ**, **Risque faible à modÊrÊ** (selon l'opÊration)\n> Cette option crÊe d'abord des fragments à partir des fichiers locaux pour la base, puis rÊcupère les donnÊes. Par consÊquent, seuls les fragments manquants localement sont transfÊrÊs. Cependant, toutes les mÊtadonnÊes sont prises de la source distante.\n> Les fichiers locaux sont ensuite comparÊs à ces mÊtadonnÊes au lancement. Le contenu considÊrÊ comme plus rÊcent Êcrasera le plus ancien (selon la date de modification). Le rÊsultat est ensuite synchronisÊ vers la base distante.\n> C'est gÊnÊralement sÃģr si les fichiers locaux ont bien l'horodatage le plus rÊcent. Cela peut toutefois poser problème si un fichier a un horodatage plus rÊcent mais un contenu plus ancien (comme le `welcome.md` initial).\n> Cette mÊthode utilise moins de CPU et est plus rapide que ÂĢ CrÊer une base locale avant de rÊcupÊrer Âģ, mais peut entraÃŽner une perte de donnÊes si elle n'est pas utilisÊe avec prÊcaution.\n> ## Tout rÊcupÊrer depuis le distant.\n> **Trafic ÊlevÊ**, **CPU faible**, **Risque faible à modÊrÊ** (selon l'opÊration)\n> Tout sera rÊcupÊrÊ depuis le distant.\n> Similaire à CrÊer des fragments de fichiers locaux avant de rÊcupÊrer, mais tous les fragments sont rÊcupÊrÊs depuis la source distante.\n> C'est la façon la plus traditionnelle de rÊcupÊrer, consommant gÊnÊralement le plus de trafic rÊseau et de temps. Elle comporte Êgalement un risque similaire d'Êcraser les fichiers distants à l'option ÂĢ CrÊer des fragments de fichiers locaux avant de rÊcupÊrer Âģ.\n> Elle est toutefois souvent considÊrÊe comme la mÊthode la plus stable car c'est la plus ancienne et la plus directe."; - readonly he: "כי×Ļד בר×Ļונך למשוך?\n- %{RedFlag.Fetch.Method.FetchSafer}.\n **×Ē×ĸבורה נמוכה**, **מ×ĸבד גבוה**, **סיכון נמוך**\n מומל×Ĩ אם...\n - קב×Ļים ×ĸשויים להיו×Ē ×œ× ×ĸקביים\n - אין הרבה קב×Ļים\n- %{RedFlag.Fetch.Method.FetchSmoother}.\n **×Ē×ĸבורה נמוכה**, **מ×ĸבד בינוני**, **סיכון נמוך ×ĸד בינוני**\n מומל×Ĩ אם...\n - הקב×Ļים ככל הנראה ×ĸקביים\n - יש לך הרבה קב×Ļים.\n- %{RedFlag.Fetch.Method.FetchTraditional}.\n **×Ē×ĸבורה גבוהה**, **מ×ĸבד נמוך**, **סיכון נמוך ×ĸד בינוני**\n\n>[!INFO]- פרטים\n> ## %{RedFlag.Fetch.Method.FetchSafer}.\n> **×Ē×ĸבורה נמוכה**, **מ×ĸבד גבוה**, **סיכון נמוך**\n> אפשרו×Ē ×–×• יו×Ļר×Ē ×Ēחילה מסד × ×Ēונים מקומי ×Ēוך שימוש בקב×Ļים מקומיים קיימים לפני משיכ×Ē × ×Ēונים מהמקור המרוחד.\n> אם קיימים קב×Ļים ×Ēואמים גם מקומי×Ē ×•×’× מרחוק, רק ההפרשים ביניהם יו×ĸברו.\n> ×ĸם זא×Ē, קב×Ļים הקיימים בשני המקומו×Ē ×™×˜×•×¤×œ×• ×Ēחילה כקב×Ļים מ×Ēנגשים. הם ייפ×Ēרו אוטומטי×Ē ×× לא מ×Ēנגשים בפו×ĸל, אך ×Ēהליך זה ×ĸשוי לקח×Ē ×–×ž×Ÿ.\n> זוהי בדרך כלל השיטה הבטוחה ביו×Ēר, ממז×ĸר×Ē ×Ą×™×›×•×Ÿ לאובדן × ×Ēונים.\n> ## %{RedFlag.Fetch.Method.FetchSmoother}.\n> **×Ē×ĸבורה נמוכה**, **מ×ĸבד בינוני**, **סיכון נמוך ×ĸד בינוני** (×Ēלוי בפ×ĸולה)\n> אפשרו×Ē ×–×• יו×Ļר×Ē ×Ēחילה × ×Ēחים מקב×Ļים מקומיים למסד הנ×Ēונים, ואז מושכ×Ē × ×Ēונים. כ×Ēו×Ļאה מכך, רק × ×Ēחים חסרים מקומי×Ē ×ž×•×ĸברים. ×ĸם זא×Ē, כל המטה-× ×Ēונים נלקחים מהמקור המרוחד.\n> קב×Ļים מקומיים נבדקים לאחר מכן מול מטה-× ×Ēונים אלה ב×ĸ×Ē ×”×”×¤×ĸלה. ה×Ēוכן שנחשב חדש יו×Ēר ידרוס א×Ē ×”×™×Š×Ÿ יו×Ēר (לפי זמן שינוי).\n> בדרך כלל בטוח אם הקב×Ļים המקומיים הם אכן חדשים ביו×Ēר. ×ĸם זא×Ē, ×ĸלול לגרום לב×ĸיו×Ē ×× לקוב×Ĩ יש חו×Ēמ×Ē ×–×ž×Ÿ חדשה יו×Ēר אך ×Ēוכן ישן יו×Ēר (כמו `welcome.md` ראשוני).\n> שיטה זו מ׊×Ēמ׊×Ē ×‘×¤×—×•×Ē ×ž×ĸבד ומהירה יו×Ēר מ-\"%{RedFlag.Fetch.Method.FetchSafer}\", אך ×ĸלולה להוביל לאובדן × ×Ēונים אם לא מ׊×Ēמשים בה בזהירו×Ē.\n> ## %{RedFlag.Fetch.Method.FetchTraditional}.\n> **×Ē×ĸבורה גבוהה**, **מ×ĸבד נמוך**, **סיכון נמוך ×ĸד בינוני** (×Ēלוי בפ×ĸולה)\n> הכל יימשך מהשר×Ē ×”×ž×¨×•×—×“.\n> דומה ל-%{RedFlag.Fetch.Method.FetchSmoother}, אך כל הנ×Ēחים נמשכים מהמקור המרוחד.\n> זוהי הדרך המסור×Ēי×Ē ×‘×™×•×Ēר למשיכה, ×Ļורכ×Ē ×‘×“×¨×š כלל א×Ē ×¨×•×‘ ×Ē×ĸבור×Ē ×”×¨×Š×Ē ×•×”×–×ž×Ÿ.\n> ×ĸם זא×Ē, היא נחשב×Ē ×œ×ĸ×Ēים קרובו×Ē ×œ×Š×™×˜×” הי×Ļיבה ביו×Ēר מכיוון שהיא הוו×Ēיקה והישירה ביו×Ēר."; - readonly ja: "おぎようãĢフェッチしぞすかīŧŸ\n- フェッチ前ãĢロãƒŧã‚ĢãƒĢデãƒŧã‚ŋベãƒŧ゚をäŊœæˆ\n **äŊŽãƒˆãƒŠãƒ•ã‚Ŗãƒƒã‚¯**, **é̘CPU負荷**, **äŊŽãƒĒ゚ク**\n 推åĨ¨æĄäģļ...\n - ãƒ•ã‚Ąã‚¤ãƒĢぎ整合性ãĢ不厉がある\n - ãƒ•ã‚Ąã‚¤ãƒĢ数がそれãģお多くãĒい\n- フェッチ前ãĢロãƒŧã‚ĢãƒĢãƒ•ã‚Ąã‚¤ãƒĢãƒãƒŖãƒŗã‚¯ã‚’äŊœæˆ\n **äŊŽãƒˆãƒŠãƒ•ã‚Ŗãƒƒã‚¯**, **䏭ፋCPU負荷**, **äŊŽīŊžä¸­ãƒĒ゚ク**\n 推åĨ¨æĄäģļ...\n - ãƒ•ã‚Ąã‚¤ãƒĢがおそらく整合しãĻいる\n - ãƒ•ã‚Ąã‚¤ãƒĢ数が多い\n- ãƒĒãƒĸãƒŧトからすずãĻをフェッチ\n **éĢ˜ãƒˆãƒŠãƒ•ã‚Ŗãƒƒã‚¯**, **äŊŽCPU負荷**, **äŊŽīŊžä¸­ãƒĒ゚ク**\n\n>[!INFO]- čŠŗį´°\n> ## フェッチ前ãĢロãƒŧã‚ĢãƒĢデãƒŧã‚ŋベãƒŧ゚をäŊœæˆ\n> **äŊŽãƒˆãƒŠãƒ•ã‚Ŗãƒƒã‚¯**, **é̘CPU負荷**, **äŊŽãƒĒ゚ク**\n> こぎã‚Ēãƒ—ã‚ˇãƒ§ãƒŗã¯ã€ãƒĒãƒĸãƒŧトからデãƒŧã‚ŋをフェッチする前ãĢ、æ—ĸ存ぎロãƒŧã‚ĢãƒĢãƒ•ã‚Ąã‚¤ãƒĢをäŊŋį”¨ã—ãĻロãƒŧã‚ĢãƒĢデãƒŧã‚ŋベãƒŧ゚をäŊœæˆã—ぞす。\n> ロãƒŧã‚ĢãƒĢとãƒĒãƒĸãƒŧãƒˆãŽä¸Ąæ–šãĢä¸€č‡´ã™ã‚‹ãƒ•ã‚Ąã‚¤ãƒĢãŒã‚ã‚‹å ´åˆã€åˇŽåˆ†ãŽãŋがčģĸ送されぞす。\n> ãŸã ã—ã€ä¸Ąæ–šãŽå ´æ‰€ãĢå­˜åœ¨ã™ã‚‹ãƒ•ã‚Ąã‚¤ãƒĢは最初はįĢļåˆãƒ•ã‚Ąã‚¤ãƒĢとしãĻå‡Ļį†ã•ã‚Œãžã™ã€‚åŽŸéš›ãĢįĢļ合しãĻいãĒければč‡Ēå‹•įš„ãĢč§Ŗæąēされぞすが、こぎå‡ĻᐆãĢは時間がかかる場合がありぞす。\n> これは一čˆŦįš„ãĢ最も厉全ãĒæ–šæŗ•ã§ã€ãƒ‡ãƒŧã‚ŋæå¤ąãŽãƒĒ゚クを最小限ãĢ抑えぞす。\n> ## フェッチ前ãĢロãƒŧã‚ĢãƒĢãƒ•ã‚Ąã‚¤ãƒĢãƒãƒŖãƒŗã‚¯ã‚’äŊœæˆ\n> **äŊŽãƒˆãƒŠãƒ•ã‚Ŗãƒƒã‚¯**, **䏭ፋCPU負荷**, **äŊŽīŊžä¸­ãƒĒ゚ク**īŧˆæ“äŊœãĢよるīŧ‰\n> こぎã‚Ēãƒ—ã‚ˇãƒ§ãƒŗã¯ã€æœ€åˆãĢロãƒŧã‚ĢãƒĢãƒ•ã‚Ąã‚¤ãƒĢからデãƒŧã‚ŋベãƒŧã‚šį”¨ãŽãƒãƒŖãƒŗã‚¯ã‚’äŊœæˆã—、そぎ垌デãƒŧã‚ŋをフェッチしぞす。そぎため、ロãƒŧã‚ĢãƒĢãĢãĒã„ãƒãƒŖãƒŗã‚¯ãŽãŋがčģĸ送されぞす。ただし、すずãĻãŽãƒĄã‚ŋデãƒŧã‚ŋはãƒĒãƒĸãƒŧトから取垗されぞす。\n> ロãƒŧã‚ĢãƒĢãƒ•ã‚Ąã‚¤ãƒĢはčĩˇå‹•時ãĢã“ãŽãƒĄã‚ŋデãƒŧã‚ŋと比čŧƒã•ã‚Œãžã™ã€‚æ–°ã—ã„ã¨åˆ¤æ–­ã•ã‚ŒãŸã‚ŗãƒŗãƒ†ãƒŗãƒ„īŧˆæ›´æ–°æ—Ĩ時ãĢよるīŧ‰ãŒå¤ã„もぎを上書きしぞす。こぎįĩæžœã¯ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚ãĢ同期されぞす。\n> ロãƒŧã‚ĢãƒĢãƒ•ã‚Ąã‚¤ãƒĢがæœŦåŊ“ãĢ最新ぎã‚ŋイム゚ã‚ŋãƒŗãƒ—ã§ã‚ã‚Œã°ä¸€čˆŦįš„ãĢåŽ‰å…¨ã§ã™ã€‚ãŸã ã—ã€ãƒ•ã‚Ąã‚¤ãƒĢぎã‚ŋイム゚ã‚ŋãƒŗãƒ—ãŒæ–°ã—ããĻã‚‚ã‚ŗãƒŗãƒ†ãƒŗãƒ„ãŒå¤ã„å ´åˆīŧˆåˆæœŸãŽ`welcome.md`ãĒおīŧ‰ã¯å•éĄŒãŒį™ēį”Ÿã™ã‚‹å¯čƒŊ性がありぞす。\n> これは\"フェッチ前ãĢロãƒŧã‚ĢãƒĢデãƒŧã‚ŋベãƒŧ゚をäŊœæˆ\"よりCPUäŊŋį”¨é‡ãŒå°‘ãĒくéĢ˜é€Ÿã§ã™ãŒã€æŗ¨æ„ã—ãĒいとデãƒŧã‚ŋæå¤ąãĢつãĒがる可čƒŊ性がありぞす。\n> ## ãƒĒãƒĸãƒŧトからすずãĻをフェッチ\n> **éĢ˜ãƒˆãƒŠãƒ•ã‚Ŗãƒƒã‚¯**, **äŊŽCPU負荷**, **äŊŽīŊžä¸­ãƒĒ゚ク**īŧˆæ“äŊœãĢよるīŧ‰\n> すずãĻぎデãƒŧã‚ŋがãƒĒãƒĸãƒŧトからフェッチされぞす。\n> フェッチ前ãĢロãƒŧã‚ĢãƒĢãƒ•ã‚Ąã‚¤ãƒĢãƒãƒŖãƒŗã‚¯ã‚’äŊœæˆã¨äŧŧãĻいぞすが、すずãĻãŽãƒãƒŖãƒŗã‚¯ãŒãƒĒãƒĸãƒŧトからフェッチされぞす。\n> これは最も垓æĨãŽãƒ•ã‚§ãƒƒãƒæ–šæŗ•ã§ã€é€šå¸¸æœ€ã‚‚ãƒãƒƒãƒˆãƒ¯ãƒŧã‚¯ãƒˆãƒŠãƒ•ã‚Ŗãƒƒã‚¯ã¨æ™‚é–“ã‚’æļˆč˛ģしぞす。'フェッチ前ãĢロãƒŧã‚ĢãƒĢãƒ•ã‚Ąã‚¤ãƒĢãƒãƒŖãƒŗã‚¯ã‚’äŊœæˆ'ã‚Ēãƒ—ã‚ˇãƒ§ãƒŗã¨åŒæ§˜ãŽãƒĒãƒĸãƒŧãƒˆãƒ•ã‚Ąã‚¤ãƒĢ上書きぎãƒĒ゚クがありぞす。\n> ãŸã ã—ã€æœ€ã‚‚æ­´å˛ãŒã‚ã‚Šį°Ąå˜ãĒã‚ĸプロãƒŧãƒã§ã‚ã‚‹ãŸã‚ã€æœ€ã‚‚åŽ‰åŽšã—ãŸæ–šæŗ•ã¨čĻ‹ãĒされることが多いです。"; - readonly ko: "ė–´ë–ģ枌 ę°€ė ¸ė˜¤ė‹œę˛ ėŠĩ니까?\n- ę°€ė ¸ė˜¤ę¸° 렄뗐 로ėģŦ ë°ė´í„°ë˛ ė´ėŠ¤ëĨŧ 한 번 ėƒė„ą. (ęļŒėžĨ)\n **ë‚Žė€ 트래í”Ŋ**, **ë†’ė€ CPU**, **ë‚Žė€ ėœ„í—˜**\n- ę°€ė ¸ė˜¤ę¸° 렄뗐 로ėģŦ 파ėŧ 랭íŦ ėƒė„ą.\n **ë‚Žė€ 트래í”Ŋ**, **ëŗ´í†ĩ CPU**, **ë‚ŽėŒė—ė„œ ëŗ´í†ĩ ėœ„í—˜**\n- ė›ę˛Šė—ė„œ ëĒ¨ë“  것 ę°€ė ¸ė˜¤ę¸°.\n **ë†’ė€ 트래í”Ŋ**, **ë‚Žė€ CPU**, **ë‚ŽėŒė—ė„œ ëŗ´í†ĩ ėœ„í—˜**\n\n>[!INFO]- 넏ëļ€ ė‚Ŧ항\n> ## ę°€ė ¸ė˜¤ę¸° 렄뗐 로ėģŦ ë°ė´í„°ë˛ ė´ėŠ¤ëĨŧ 한 번 ėƒė„ą. (ęļŒėžĨ)\n> **ë‚Žė€ 트래í”Ŋ**, **ë†’ė€ CPU**, **ë‚Žė€ ėœ„í—˜**\n> ė´ ė˜ĩė…˜ė€ ė›ę˛Š ė†ŒėŠ¤ė—ė„œ ë°ė´í„°ëĨŧ ę°€ė ¸ė˜¤ę¸° 렄뗐 ę¸°ėĄ´ 로ėģŦ 파ėŧė„ ė‚ŦėšŠí•˜ė—Ŧ 로ėģŦ ë°ė´í„°ë˛ ė´ėŠ¤ëĨŧ ë¨ŧė € ėƒė„ąí•Šë‹ˆë‹¤.\n> 로ėģŦęŗŧ ė›ę˛Š ëĒ¨ë‘ė— ėŧėš˜í•˜ëŠ” 파ėŧė´ ėžˆėœŧ늴 둘 ė‚Ŧė´ė˜ ė°¨ė´ė ë§Œ ė „ė†ĄëŠë‹ˆë‹¤.\n> í•˜ė§€ë§Œ 두 ėœ„ėš˜ ëĒ¨ë‘ė— ėžˆëŠ” 파ėŧė€ ė´ˆę¸°ė— ėļŠëŒ 파ėŧ로 래ëĻŦ됩니다. ė‹¤ė œëĄœ ėļŠëŒí•˜ė§€ ė•ŠëŠ”ë‹¤ëŠ´ ėžë™ėœŧ로 í•´ę˛°ë˜ė§€ë§Œ ė´ ęŗŧė •ė€ ė‹œę°„ė´ 깸ëĻ´ 눘 ėžˆėŠĩ니다.\n> ė´ëŠ” ėŧë°˜ė ėœŧ로 가ėžĨ ė•ˆė „í•œ 방법ėœŧ로 ë°ė´í„° 놐다 ėœ„í—˜ė„ ėĩœė†Œí™”핊니다.\n> ## ę°€ė ¸ė˜¤ę¸° 렄뗐 로ėģŦ 파ėŧ 랭íŦ ėƒė„ą.\n> **ë‚Žė€ 트래í”Ŋ**, **ëŗ´í†ĩ CPU**, **ë‚ŽėŒė—ė„œ ëŗ´í†ĩ ėœ„í—˜** (ėž‘ė—…ė— 따ëŧ)\n> ė´ ė˜ĩė…˜ė€ ë¨ŧė € 로ėģŦ 파ėŧė—ė„œ ë°ė´í„°ë˛ ė´ėŠ¤ėšŠ 랭íŦëĨŧ ėƒė„ąí•œ ë‹¤ėŒ ë°ė´í„°ëĨŧ 氀렏ė˜ĩ니다. 따ëŧė„œ 로ėģŦ뗐 ė—†ëŠ” 랭íŦ만 ė „ė†ĄëŠë‹ˆë‹¤. í•˜ė§€ë§Œ ëĒ¨ë“  ëŠ”íƒ€ë°ė´í„°ëŠ” ė›ę˛Š ė†ŒėŠ¤ė—ė„œ 氀렏ė˜ĩ니다.\n> 그런 ë‹¤ėŒ 로ėģŦ 파ėŧė´ ė‹œėž‘ ė‹œ ė´ ëŠ”íƒ€ë°ė´í„°ė™€ 비ęĩëŠë‹ˆë‹¤. 더 ėƒˆëĄœėš´ 것ėœŧ로 간ėŖŧ되는 ėŊ˜í…ė¸ ę°€ ė˜¤ëž˜ëœ ę˛ƒė„ ëŽė–´ė”ë‹ˆë‹¤(ėˆ˜ė • ė‹œę°„ 揰뤀). ė´ 결ęŗŧ는 ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ė— ë‹¤ė‹œ 동기화됩니다.\n> 로ėģŦ 파ėŧė´ ė‹¤ė œëĄœ ėĩœė‹  íƒ€ėž„ėŠ¤íƒŦ프ëŧ늴 ėŧë°˜ė ėœŧ로 ė•ˆė „í•Šë‹ˆë‹¤. í•˜ė§€ë§Œ 파ėŧė´ 더 ėƒˆëĄœėš´ íƒ€ėž„ėŠ¤íƒŦ프ëĨŧ 氀맀溠 ėžˆė§€ë§Œ 더 ė˜¤ëž˜ëœ ėŊ˜í…ė¸ ëĨŧ 氀맀溠 ėžˆë‹¤ëŠ´(봈揰 `welcome.md`래ëŸŧ) ëŦ¸ė œę°€ ë°œėƒí•  눘 ėžˆėŠĩ니다.\n> ė´ëŠ” \"ę°€ė ¸ė˜¤ę¸° 렄뗐 로ėģŦ ë°ė´í„°ë˛ ė´ėŠ¤ëĨŧ 한 번 ėƒė„ą\"ëŗ´ë‹¤ CPUëĨŧ 덜 ė‚ŦėšŠí•˜ęŗ  더 ëš ëĨ´ė§€ë§Œ ėŖŧė˜ 깊게 ė‚ŦėšŠí•˜ė§€ ė•Šėœŧ늴 ë°ė´í„° ė†ė‹¤ëĄœ ė´ė–´ė§ˆ 눘 ėžˆėŠĩ니다.\n> ## ė›ę˛Šė—ė„œ ëĒ¨ë“  것 ę°€ė ¸ė˜¤ę¸°.\n> **ë†’ė€ 트래í”Ŋ**, **ë‚Žė€ CPU**, **ë‚ŽėŒė—ė„œ ëŗ´í†ĩ ėœ„í—˜** (ėž‘ė—…ė— 따ëŧ)\n> ëĒ¨ë“  ę˛ƒė´ ė›ę˛Šė—ė„œ ę°€ė ¸ė™€ė§‘ë‹ˆë‹¤.\n> ę°€ė ¸ė˜¤ę¸° 렄뗐 로ėģŦ 파ėŧ 랭íŦ ėƒė„ąė™€ 뜠ė‚Ŧí•˜ė§€ë§Œ ëĒ¨ë“  랭íŦ가 ė›ę˛Š ė†ŒėŠ¤ė—ė„œ ę°€ė ¸ė™€ė§‘ë‹ˆë‹¤.\n> ė´ëŠ” 가ėžĨ ė „í†ĩė ė¸ ę°€ė ¸ė˜¤ę¸° 방법ėœŧ로 ėŧë°˜ė ėœŧ로 가ėžĨ ë§Žė€ ë„¤íŠ¸ė›ŒíŦ 트래í”Ŋęŗŧ ė‹œę°„ė„ ė†ŒëĒ¨í•Šë‹ˆë‹¤. 또한 'ę°€ė ¸ė˜¤ę¸° 렄뗐 로ėģŦ 파ėŧ 랭íŦ ėƒė„ą' ė˜ĩė…˜ęŗŧ 뜠ė‚Ŧ하게 ė›ę˛Š 파ėŧė„ ëŽė–´ė“¸ ėœ„í—˜ė´ ėžˆėŠĩ니다.\n> í•˜ė§€ë§Œ 가ėžĨ ė˜¤ëž˜ë˜ęŗ  가ėžĨ ė§ė ‘ė ė¸ ė ‘ęˇŧ ë°Šė‹ė´ę¸° 때ëŦ¸ė— ėĸ…ėĸ… 가ėžĨ ė•ˆė •ė ė¸ 방법ėœŧ로 간ėŖŧ됩니다."; - readonly ru: "КаĐē Đ˛Ņ‹ Ņ…ĐžŅ‚Đ¸Ņ‚Đĩ ĐˇĐ°ĐŗŅ€ŅƒĐˇĐ¸Ņ‚ŅŒ?"; - readonly zh: "How do you want to fetch?\n- Create a local database once before fetching.\n **Low Traffic**, **High CPU**, **Low Risk**\n Recommended if ...\n - Files possibly inconsistent\n - Files were not so much\n- Create local file chunks before fetching.\n **Low Traffic**, **Moderate CPU**, **Low to Moderate Risk**\n Recommended if ...\n - Files probably consistent\n - You have a lot of files.\n- Fetch everything from the remote.\n **High Traffic**, **Low CPU**, **Low to Moderate Risk**\n\n>[!INFO]- Details\n> ## Create a local database once before fetching.\n> **Low Traffic**, **High CPU**, **Low Risk**\n> This option first creates a local database using existing local files before fetching data from the remote source.\n> If matching files exist both locally and remotely, only the differences between them will be transferred.\n> However, files present in both locations will initially be handled as conflicted files. They will be resolved automatically if they are not actually conflicted, but this process may take time.\n> This is generally the safest method, minimizing data loss risk.\n> ## Create local file chunks before fetching.\n> **Low Traffic**, **Moderate CPU**, **Low to Moderate Risk** (depending operation)\n> This option first creates chunks from local files for the database, then fetches data. Consequently, only chunks missing locally are transferred. However, all metadata is taken from the remote source.\n> Local files are then compared against this metadata at launch. The content considered newer will overwrite the older one (by modified time). This outcome is then synchronised back to the remote database.\n> This is generally safe if local files are genuinely the latest timestamp. However, it can cause problems if a file has a newer timestamp but older content (like the initial `welcome.md`).\n> This uses less CPU and faster than \"Create a local database once before fetching\", but it may lead to data loss if not used carefully.\n> ## Fetch everything from the remote.\n> **High Traffic**, **Low CPU**, **Low to Moderate Risk** (depending operation)\n> All things will be fetched from the remote.\n> Similar to the Create local file chunks before fetching, but all chunks are fetched from the remote source.\n> This is the most traditional way to fetch, typically consuming the most network traffic and time. It also carries a similar risk of overwriting remote files to the 'Create local file chunks before fetching' option.\n> However, it is often considered the most stable method because it is the longest-established and most straightforward approach."; - }; - readonly "RedFlag.Fetch.Method.FetchSafer": { - readonly def: "Create a local database once before fetching"; - readonly fr: "CrÊer une base locale avant de rÊcupÊrer"; - readonly he: "×Ļור מסד × ×Ēונים מקומי לפני המשיכה"; - readonly ja: "フェッチ前ãĢロãƒŧã‚ĢãƒĢデãƒŧã‚ŋベãƒŧ゚をäŊœæˆ"; - readonly ko: "ę°€ė ¸ė˜¤ę¸° 렄뗐 로ėģŦ ë°ė´í„°ë˛ ė´ėŠ¤ëĨŧ 한 번 ėƒė„ą"; - readonly ru: "ĐĄĐžĐˇĐ´Đ°Ņ‚ŅŒ ĐģĐžĐēаĐģҌĐŊŅƒŅŽ ĐąĐ°ĐˇŅƒ даĐŊĐŊҋ҅ ĐŋĐĩŅ€ĐĩĐ´ ĐˇĐ°ĐŗŅ€ŅƒĐˇĐēОК"; - readonly zh: "Create a local database once before fetching"; - }; - readonly "RedFlag.Fetch.Method.FetchSmoother": { - readonly def: "Create local file chunks before fetching"; - readonly fr: "CrÊer des fragments de fichiers locaux avant de rÊcupÊrer"; - readonly he: "×Ļור × ×Ēחי קב×Ļים מקומיים לפני המשיכה"; - readonly ja: "フェッチ前ãĢロãƒŧã‚ĢãƒĢãƒ•ã‚Ąã‚¤ãƒĢãƒãƒŖãƒŗã‚¯ã‚’äŊœæˆ"; - readonly ko: "ę°€ė ¸ė˜¤ę¸° 렄뗐 로ėģŦ 파ėŧ 랭íŦ ėƒė„ą"; - readonly ru: "ĐĄĐžĐˇĐ´Đ°Ņ‚ŅŒ ĐģĐžĐēаĐģҌĐŊŅ‹Đĩ Ņ‡Đ°ĐŊĐēи ĐŋĐĩŅ€ĐĩĐ´ ĐˇĐ°ĐŗŅ€ŅƒĐˇĐēОК"; - readonly zh: "Create local file chunks before fetching"; - }; - readonly "RedFlag.Fetch.Method.FetchTraditional": { - readonly def: "Fetch everything from the remote"; - readonly fr: "Tout rÊcupÊrer depuis le distant"; - readonly he: "משוך הכל מהשר×Ē ×”×ž×¨×•×—×“"; - readonly ja: "ãƒĒãƒĸãƒŧトからすずãĻをフェッチ"; - readonly ko: "ė›ę˛Šė—ė„œ ëĒ¨ë“  것 ę°€ė ¸ė˜¤ę¸°"; - readonly ru: "Đ—Đ°ĐŗŅ€ŅƒĐˇĐ¸Ņ‚ŅŒ Đ˛ŅŅ‘ ҁ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐŗĐž"; - readonly zh: "Fetch everything from the remote"; - }; - readonly "RedFlag.Fetch.Method.Title": { - readonly def: "How do you want to fetch?"; - readonly fr: "Comment voulez-vous rÊcupÊrer ?"; - readonly he: "כי×Ļד בר×Ļונך למשוך?"; - readonly ja: "おぎようãĢフェッチしぞすかīŧŸ"; - readonly ko: "ė–´ë–ģ枌 ę°€ė ¸ė˜¤ė‹œę˛ ėŠĩ니까?"; - readonly ru: "КаĐē Đ˛Ņ‹ Ņ…ĐžŅ‚Đ¸Ņ‚Đĩ ĐˇĐ°ĐŗŅ€ŅƒĐˇĐ¸Ņ‚ŅŒ?"; - readonly zh: "How do you want to fetch?"; - }; - readonly "RedFlag.FetchRemoteConfig.Buttons.Cancel": { - readonly def: "No, use local settings"; - readonly fr: "Non, utiliser les paramètres locaux"; - readonly he: "לא, הש×Ēמ׊ בהגדרו×Ē ×”×ž×§×•×ž×™×•×Ē"; - readonly ja: "いいえ、ロãƒŧã‚ĢãƒĢč¨­åŽšã‚’äŊŋᔍ"; - readonly ru: "НĐĩŅ‚, Đ¸ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ŅŒ ĐģĐžĐēаĐģҌĐŊŅ‹Đĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи"; - readonly zh: "No, use local settings"; - }; - readonly "RedFlag.FetchRemoteConfig.Buttons.Fetch": { - readonly def: "Yes, fetch and apply remote settings"; - readonly fr: "Oui, rÊcupÊrer et appliquer les paramètres distants"; - readonly he: "כן, משוך והחל הגדרו×Ē ×ž×¨×•×—×§×•×Ē"; - readonly ja: "はい、ãƒĒãƒĸãƒŧãƒˆč¨­åŽšã‚’å–åž—ã—ãĻéŠį”¨"; - readonly ru: "Да, ĐˇĐ°ĐŗŅ€ŅƒĐˇĐ¸Ņ‚ŅŒ и ĐŋŅ€Đ¸ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊŅ‹Đĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи"; - readonly zh: "Yes, fetch and apply remote settings"; - }; - readonly "RedFlag.FetchRemoteConfig.Message": { - readonly def: "Do you want to fetch and apply remotely stored preference settings to the device?"; - readonly fr: "Voulez-vous rÊcupÊrer et appliquer les prÊfÊrences stockÊes à distance sur cet appareil ?"; - readonly he: "האם בר×Ļונך למשוך ולהחיל הגדרו×Ē ×Š×ž×•×¨×•×Ē ×ž×¨×—×•×§ ×ĸל מכשיר זה?"; - readonly ja: "ãƒĒãƒĸãƒŧトãĢäŋå­˜ã•ã‚ŒãŸč¨­åŽšã‚’å–åž—ã—ãĻ、こぎデバイ゚ãĢéŠį”¨ã—ãžã™ã‹īŧŸ"; - readonly ru: "Đ’Ņ‹ Ņ…ĐžŅ‚Đ¸Ņ‚Đĩ ĐˇĐ°ĐŗŅ€ŅƒĐˇĐ¸Ņ‚ŅŒ и ĐŋŅ€Đ¸ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊŅ‹Đĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи?"; - readonly zh: "Do you want to fetch and apply remotely stored preference settings to the device?"; - }; - readonly "RedFlag.FetchRemoteConfig.Title": { - readonly def: "Fetch Remote Configuration"; - readonly fr: "RÊcupÊrer la configuration distante"; - readonly he: "משוך ×Ē×Ļורה מרוחק×Ē"; - readonly ja: "ãƒĒãƒĸãƒŧãƒˆč¨­åŽšãŽå–åž—"; - readonly ru: "Đ—Đ°ĐŗŅ€ŅƒĐˇĐ¸Ņ‚ŅŒ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊŅƒŅŽ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸ŅŽ"; - readonly zh: "Fetch Remote Configuration"; - }; - readonly "Reduces storage space by discarding all non-latest revisions. This requires the same amount of free space on the remote server and the local client.": { - readonly def: "Reduces storage space by discarding all non-latest revisions. This requires the same amount of free space on the remote server and the local client."; - readonly ja: "æœ€æ–°į‰ˆäģĨ外ぎすずãĻぎãƒĒãƒ“ã‚¸ãƒ§ãƒŗã‚’į ´æŖ„ã—ãĻ、äŊŋį”¨åŽšé‡ã‚’å‰Šæ¸›ã—ãžã™ã€‚åŽŸčĄŒãĢは、ãƒĒãƒĸãƒŧトã‚ĩãƒŧバãƒŧとロãƒŧã‚ĢãƒĢクナイã‚ĸãƒŗãƒˆãŽä¸Ąæ–šãĢåŒį¨‹åēĻぎįŠēき厚量がåŋ…čĻã§ã™ã€‚"; - readonly ko: "ėĩœė‹  ë˛„ė „ė´ ė•„ë‹Œ ëĒ¨ë“  ëĻŦëš„ė „ė„ ė œęą°í•˜ė—Ŧ ė €ėžĨ ęŗĩę°„ė„ ė¤„ėž…ë‹ˆë‹¤. ė´ ėž‘ė—…ė„ ėˆ˜í–‰í•˜ë ¤ëŠ´ ė›ę˛Š ė„œë˛„ė™€ 로ėģŦ 클ëŧė´ė–¸íŠ¸ė— 동ėŧ한 ė–‘ė˜ ė—Ŧ뜠 ęŗĩę°„ė´ í•„ėš”í•Šë‹ˆë‹¤."; - readonly zh: "通čŋ‡ä¸ĸåŧƒæ‰€æœ‰éžæœ€æ–°į‰ˆæœŦæĨ减少存储įŠē间。čŋ™éœ€čρčŋœį¨‹æœåŠĄå™¨å’ŒæœŦ地åŽĸæˆˇįĢ¯å…ˇå¤‡į›¸åŒæ•°é‡įš„å¯į”¨įŠē间。"; - readonly "zh-tw": "é€éŽæ¨æŖ„æ‰€æœ‰éžæœ€æ–°į‰ˆæœŦäž†æ¸›å°‘å„˛å­˜įŠēé–“å į”¨ã€‚åŸˇčĄŒæ­¤æ“äŊœæ™‚īŧŒé į̝äŧ翜å™¨čˆ‡æœŦæŠŸį”¨æˆļį̝éƒŊ需čĻå…ˇå‚™į›¸åŒæ•¸é‡įš„å¯į”¨įŠē間。"; - }; - readonly "Reducing the frequency with which on-disk changes are reflected into the DB": { - readonly def: "Reducing the frequency with which on-disk changes are reflected into the DB"; - readonly es: "Reducir frecuencia de actualizaciones de disco a BD"; - readonly fr: "RÊduire la frÊquence à laquelle les modifications sur disque sont reflÊtÊes dans la base"; - readonly he: "הפח×Ē ××Ē ×Ēדירו×Ē ×”×Š×Ēקפו×Ē ×Š×™× ×•×™×™× בדיסק למסד הנ×Ēונים"; - readonly ja: "ロãƒŧã‚ĢãƒĢでぎ変更がデãƒŧã‚ŋベãƒŧ゚ãĢ反映されるé ģåēĻを下げる(所厚ぎ回数ぞとめãĻ同期する、逐一反映しãĒい)"; - readonly ko: "ë””ėŠ¤íŦ ëŗ€ę˛Ŋ ė‚Ŧí•­ė´ ë°ė´í„°ë˛ ė´ėŠ¤ė— ë°˜ė˜ë˜ëŠ” 빈도ëĨŧ ė¤„ėž…ë‹ˆë‹¤"; - readonly ru: "ĐŖĐŧĐĩĐŊҌ҈ĐĩĐŊиĐĩ Ņ‡Đ°ŅŅ‚ĐžŅ‚Ņ‹ ĐžŅ‚Ņ€Đ°ĐļĐĩĐŊĐ¸Ņ иСĐŧĐĩĐŊĐĩĐŊиК ҁ Đ´Đ¸ŅĐēа в БД"; - readonly zh: "降äŊŽå°†įŖį›˜ä¸Šįš„æ›´æ”šåæ˜ åˆ°æ•°æŽåē“ä¸­įš„éĸ‘įŽ‡"; - }; - readonly Region: { - readonly def: "Region"; - readonly es: "RegiÃŗn"; - readonly fr: "RÊgion"; - readonly he: "אזור"; - readonly ja: "ãƒĒãƒŧã‚¸ãƒ§ãƒŗ"; - readonly ko: "맀뗭"; - readonly ru: "Đ ĐĩĐŗĐ¸ĐžĐŊ"; - readonly zh: "åŒē域"; - }; - readonly Remediation: { - readonly def: "Remediation"; - readonly es: "RemediaciÃŗn"; - readonly ja: "æ˜¯æ­Ŗ"; - readonly ko: "ëŗĩęĩŦ ėĄ°ėš˜"; - readonly ru: "Đ˜ŅĐŋŅ€Đ°Đ˛ĐģĐĩĐŊиĐĩ"; - readonly zh: "äŋŽå¤čŽžįŊŽ"; - readonly "zh-tw": "äŋŽåžŠč¨­åޚ"; - }; - readonly "Remediation Setting Changed": { - readonly def: "Remediation Setting Changed"; - readonly es: "La configuraciÃŗn de remediaciÃŗn cambiÃŗ"; - readonly ja: "æ˜¯æ­Ŗč¨­åŽšãŒå¤‰æ›´ã•ã‚Œãžã—ãŸ"; - readonly ko: "ëŗĩęĩŦ ė„¤ė •ė´ ëŗ€ę˛Ŋ됨"; - readonly ru: "ĐĐ°ŅŅ‚Ņ€ĐžĐšĐēи Đ¸ŅĐŋŅ€Đ°Đ˛ĐģĐĩĐŊĐ¸Ņ иСĐŧĐĩĐŊĐĩĐŊŅ‹"; - readonly zh: "äŋŽå¤čŽžįŊŽåˇ˛æ›´æ”š"; - readonly "zh-tw": "äŋŽåžŠč¨­åŽšåˇ˛čŽŠæ›´"; - }; - readonly "Remote Database Tweak (In sunset)": { - readonly def: "Remote Database Tweak (In sunset)"; - readonly es: "Ajustes de base de datos remota (en retirada)"; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚ぎčĒŋ整 (å샿­ĸäēˆåޚ)"; - readonly ko: "ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ ėĄ°ė • (폐기 ė˜ˆė •)"; - readonly ru: "ĐĐ°ŅŅ‚Ņ€ĐžĐšĐēи ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊОК ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅ (ŅƒŅŅ‚Đ°Ņ€ĐĩваĐĩŅ‚)"; - readonly zh: "čŋœįĢ¯æ•°æŽåē“č°ƒäŧ˜īŧˆé€æ­Ĩæˇ˜æą°īŧ‰"; - readonly "zh-tw": "遠įĢ¯čŗ‡æ–™åēĢčĒŋæ Ąīŧˆåŗå°‡æˇ˜æą°īŧ‰"; - }; - readonly "Remote Databases": { - readonly def: "Remote Databases"; - readonly es: "Bases de datos remotas"; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚"; - readonly ko: "ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤"; - readonly ru: "ĐŖĐ´Đ°ĐģŅ‘ĐŊĐŊŅ‹Đĩ ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅"; - readonly zh: "čŋœįĢ¯æ•°æŽåē“"; - readonly "zh-tw": "遠įĢ¯čŗ‡æ–™åēĢ"; - }; - readonly "Remote name": { - readonly def: "Remote name"; - readonly es: "Nombre del remoto"; - readonly ja: "ãƒĒãƒĸãƒŧト名"; - readonly ko: "ė›ę˛Š ė´ëĻ„"; - readonly ru: "ИĐŧŅ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐŗĐž Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰Đ°"; - readonly zh: "čŋœįĢ¯åį§°"; - readonly "zh-tw": "遠įĢ¯åį¨ą"; - }; - readonly "Remote server type": { - readonly def: "Remote server type"; - readonly es: "Tipo de servidor remoto"; - readonly fr: "Type de serveur distant"; - readonly he: "סוג ׊ר×Ē ×ž×¨×•×—×“"; - readonly ja: "ãƒĒãƒĸãƒŧãƒˆãŽį¨ŽåˆĨ"; - readonly ko: "ė›ę˛Š ė„œë˛„ ėœ í˜•"; - readonly ru: "ĐĸиĐŋ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐŗĐž ҁĐĩŅ€Đ˛ĐĩŅ€Đ°"; - readonly zh: "čŋœį¨‹æœåŠĄå™¨įąģ型"; - }; - readonly "Remote Type": { - readonly def: "Remote Type"; - readonly es: "Tipo de remoto"; - readonly fr: "Type de distant"; - readonly he: "סוג מרוחד"; - readonly ja: "同期斚åŧ"; - readonly ko: "ė›ę˛Š ėœ í˜•"; - readonly ru: "ĐŖĐ´Đ°ĐģŅ‘ĐŊĐŊŅ‹Đš Ņ‚Đ¸Đŋ"; - readonly zh: "čŋœį¨‹įąģ型"; - }; - readonly Rename: { - readonly def: "Rename"; - readonly es: "Renombrar"; - readonly ja: "名前を変更"; - readonly ko: "ė´ëĻ„ 바꾸기"; - readonly ru: "ПĐĩŅ€ĐĩиĐŧĐĩĐŊĐžĐ˛Đ°Ņ‚ŅŒ"; - readonly zh: "重å‘Ŋ名"; - readonly "zh-tw": "重新å‘Ŋ名"; - }; - readonly "Replicator.Dialogue.Locked.Action.Dismiss": { - readonly def: "Cancel for reconfirmation"; - readonly fr: "Annuler pour reconfirmer"; - readonly he: "ביטול לאישור מחדש"; - readonly ja: "再įĸēčĒãŽãŸã‚ã‚­ãƒŖãƒŗã‚ģãƒĢ"; - readonly ko: "ėžŦí™•ė¸ė„ ėœ„í•´ ėˇ¨ė†Œ"; - readonly ru: "ĐžŅ‚ĐŧĐĩĐŊа Đ´ĐģŅ ĐŋĐžĐ´Ņ‚Đ˛ĐĩŅ€ĐļĐ´ĐĩĐŊĐ¸Ņ"; - readonly zh: "Cancel for reconfirmation"; - }; - readonly "Replicator.Dialogue.Locked.Action.Fetch": { - readonly def: "Reset Synchronisation on This Device"; - readonly fr: "RÊinitialiser la synchronisation sur cet appareil"; - readonly he: "אפץ סנכרון במכשיר זה"; - readonly ja: "こぎデバイ゚ぎ同期をãƒĒã‚ģット"; - readonly ko: "ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ė—ė„œ ëĒ¨ë“  ę˛ƒė„ ë‹¤ė‹œ ę°€ė ¸ė˜¤ę¸°"; - readonly ru: "ĐĄĐąŅ€ĐžŅĐ¸Ņ‚ŅŒ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸ŅŽ ĐŊа ŅŅ‚ĐžĐŧ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đĩ"; - readonly zh: "Reset Synchronisation on This Device"; - }; - readonly "Replicator.Dialogue.Locked.Action.Unlock": { - readonly def: "Unlock the remote database"; - readonly fr: "DÊverrouiller la base distante"; - readonly he: "בטל × ×ĸיל×Ē ×ž×Ą×“ הנ×Ēונים המרוחד"; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧã‚šãŽãƒ­ãƒƒã‚¯ã‚’č§Ŗé™¤"; - readonly ko: "ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ ėž ę¸ˆ í•´ė œ"; - readonly ru: "РаСйĐģĐžĐēĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊŅƒŅŽ ĐąĐ°ĐˇŅƒ даĐŊĐŊҋ҅"; - readonly zh: "Unlock the remote database"; - }; - readonly "Replicator.Dialogue.Locked.Message": { - readonly def: "Remote database is locked. This is due to a rebuild on one of the terminals.\nThe device is therefore asked to withhold the connection to avoid database corruption.\n\nThere are three options that we can do:\n\n- Reset Synchronisation on This Device\n The most preferred and reliable way. This will dispose the local database once, and reset all synchronisation information from the remote database again, In most case, we can perform this safely. However, it takes some time and should be done in stable network.\n- Unlock the remote database\n This method can only be used if we are already reliably synchronised by other replication methods. This does not simply mean that we have the same files. If you are not sure, you should avoid it.\n- Cancel for reconfirmation\n This will cancel the operation. And we will asked again on next request.\n"; - readonly fr: "La base distante est verrouillÊe. Ceci est dÃģ à une reconstruction sur l'un des terminaux.\nL'appareil est donc priÊ de suspendre la connexion pour Êviter la corruption de la base.\n\nTrois options sont possibles :\n\n- RÊinitialiser la synchronisation sur cet appareil\n La mÊthode la plus recommandÊe et fiable. Elle supprime la base locale puis rÊinitialise toutes les informations de synchronisation depuis la base distante. Dans la plupart des cas, c'est sÃģr. Cela prend cependant du temps et devrait se faire sur un rÊseau stable.\n- DÊverrouiller la base distante\n Cette mÊthode ne peut ÃĒtre utilisÊe que si nous sommes dÊjà synchronisÊs de manière fiable par d'autres mÊthodes de rÊplication. Cela ne signifie pas simplement que nous avons les mÃĒmes fichiers. Dans le doute, Êvitez.\n- Annuler pour reconfirmer\n Ceci annule l'opÊration. Vous serez à nouveau interrogÊ à la prochaine requÃĒte.\n"; - readonly he: "מסד הנ×Ēונים המרוחד × ×ĸול. הסיבה היא בנייה מחדש באחד הטרמינלים.\nלכן המכשיר מ×Ēבקש להמנ×ĸ מחיבור כדי למנו×ĸ פגי×ĸה במסד הנ×Ēונים.\n\nקיימו×Ē ×Š×œ×•×Š אפשרויו×Ē:\n\n- %{Replicator.Dialogue.Locked.Action.Fetch}\n הדרך המו×ĸדפ×Ē ×•×”××ž×™× ×” ביו×Ēר. פ×ĸולה זו ×Ēמחק א×Ē ×ž×Ą×“ הנ×Ēונים המקומי פ×ĸם,\n ו×Ēאפץ א×Ē ×›×œ מיד×ĸ הסנכרון ממסד הנ×Ēונים המרוחד מחדש. ברוב המקרים ני×Ēן\n ל×ĸשו×Ē ×–××Ē ×‘×‘×˜×—×”. ×ĸם זא×Ē, דורש×Ē ×–×ž×Ÿ ויש לב×Ļ×ĸ ברש×Ē ×™×Ļיבה.\n- %{Replicator.Dialogue.Locked.Action.Unlock}\n ני×Ēן להש×Ēמ׊ בשיטה זו רק אם כבר מסונכרנים באופן אמין בשיטו×Ē ×Š×›×¤×•×œ\n אחרו×Ē. פשוט לא מספיק שיש או×Ēם קב×Ļים. אם אינך בטוח, הימנ×ĸ מכך.\n- %{Replicator.Dialogue.Locked.Action.Dismiss}\n פ×ĸולה זו ×Ēבטל א×Ē ×”×¤×ĸולה. ×Ē×Ēבקש שוב בבקשה הבאה.\n"; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚がロックされãĻいぞす。これはいずれかぎį̝æœĢã§ãŽå†æ§‹į¯‰ãŒåŽŸå› ã§ã™ã€‚\nデãƒŧã‚ŋベãƒŧã‚šãŽį ´æã‚’éŋけるため、こぎデバイ゚はæŽĨįļšã‚’äŋį•™ã™ã‚‹ã‚ˆã†æą‚められãĻいぞす。\n\n3つぎã‚Ēãƒ—ã‚ˇãƒ§ãƒŗãŒã‚ã‚Šãžã™īŧš\n\n- こぎデバイ゚ぎ同期をãƒĒã‚ģット\n 最も推åĨ¨ã•れるäŋĄé ŧ性ぎéĢ˜ã„æ–šæŗ•ã§ã™ã€‚ãƒ­ãƒŧã‚ĢãƒĢデãƒŧã‚ŋベãƒŧ゚を一åēĻį ´æŖ„ã—ã€ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚からすずãĻãŽåŒæœŸæƒ…å ąã‚’å†å–åž—ã—ãžã™ã€‚ãģとんおぎ場合、これは厉全ãĢåŽŸčĄŒã§ããžã™ã€‚ãŸã ã—ã€æ™‚é–“ãŒã‹ã‹ã‚Šã€åŽ‰åŽšã—ãŸãƒãƒƒãƒˆãƒ¯ãƒŧã‚¯ã§åŽŸčĄŒã™ã‚‹åŋ…čĻãŒã‚ã‚Šãžã™ã€‚\n- ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧã‚šãŽãƒ­ãƒƒã‚¯ã‚’č§Ŗé™¤\n ã“ãŽæ–šæŗ•ã¯ã€äģ–ぎãƒŦプãƒĒã‚ąãƒŧã‚ˇãƒ§ãƒŗ(複čŖŊ)æ–šæŗ•ã§ã™ã§ãĢįĸē原ãĢ同期されãĻいる場合ぎãŋäŊŋį”¨ã§ããžã™ã€‚å˜ãĢåŒã˜ãƒ•ã‚Ąã‚¤ãƒĢãŒã‚ã‚‹ã¨ã„ã†æ„å‘ŗã§ã¯ã‚ã‚Šãžã›ã‚“ã€‚įĸēäŋĄãŒãĒい場合はéŋけãĻください。\n- 再įĸēčĒãŽãŸã‚ã‚­ãƒŖãƒŗã‚ģãƒĢ\n 操äŊœã‚’ã‚­ãƒŖãƒŗã‚ģãƒĢしぞす。æŦĄå›žãŽãƒĒクエ゚ト時ãĢ再åēĻįĸēčĒã•ã‚Œãžã™ã€‚\n"; - readonly ko: "ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ę°€ ėž ę˛¨ ėžˆėŠĩ니다. ė´ëŠ” ėŧëļ€ í„°ë¯¸ë„ė—ė„œ ë°ė´í„°ë˛ ė´ėŠ¤ëĨŧ ėžŦęĩŦėļ•í–ˆę¸° 때ëŦ¸ėž…니다.\n따ëŧė„œ 현ėžŦ 기기는 ë°ė´í„°ë˛ ė´ėŠ¤ ė†ėƒė„ ë°Šė§€í•˜ę¸° ėœ„í•´ ė—°ę˛°ė„ ėŧė‹œė ėœŧ로 ëŗ´ëĨ˜í•´ė•ŧ 합니다.\n\nė„ íƒí•  눘 ėžˆëŠ” 넏 氀맀 ë°Šë˛•ė´ ėžˆėŠĩ니다:\n\n- ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ė—ė„œ ëĒ¨ë“  ę˛ƒė„ ë‹¤ė‹œ ę°€ė ¸ė˜¤ę¸°\n 가ėžĨ ęļŒėžĨë˜ęŗ  ė‹ ëĸ°í•  눘 ėžˆëŠ” ë°Šë˛•ėž…ë‹ˆë‹¤. 로ėģŦ ë°ė´í„°ë˛ ė´ėŠ¤ëĨŧ ė´ˆę¸°í™”í•œ 뒤, ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ė˜ 렄랴 ë°ė´í„°ëĨŧ ë‹¤ė‹œ 氀렏ė˜ĩ니다. 대ëļ€ëļ„ė˜ ę˛Ŋ뚰 ė•ˆė „í•˜ę˛Œ ėˆ˜í–‰í•  눘 ėžˆėœŧ나, ė‹œę°„ė´ ë‹¤ė†Œ 깸ëĻŦ늰 ė•ˆė •ė ė¸ ë„¤íŠ¸ė›ŒíŦ 환ę˛Ŋė—ė„œ ė§„í–‰í•´ė•ŧ 합니다.\n- ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ ėž ę¸ˆ í•´ė œ\n ė´ ë°Šë˛•ė€ 다ëĨ¸ 동기화 ë°Šė‹ėœŧ로 ė´ë¯¸ ė™„ė „í•˜ęŗ  ė•ˆė •ė ėœŧ로 동기화된 ę˛Ŋėš°ė—ë§Œ ė‚ŦėšŠí•  눘 ėžˆėŠĩ니다. ë‹¨ėˆœížˆ 파ėŧė´ 같다는 ė˜ë¯¸ę°€ ė•„ë‹ˆë¯€ëĄœ, í™•ė‹ ė´ ė—†ë‹¤ëŠ´ ė‚ŦėšŠė„ í”ŧ하는 ę˛ƒė´ ėĸ‹ėŠĩ니다.\n- ėžŦí™•ė¸ė„ ėœ„í•´ ėˇ¨ė†Œ\n ė´ë˛ˆ ėž‘ė—…ė„ ėˇ¨ė†Œí•˜ęŗ , ë‹¤ėŒ ėš”ė˛­ ė‹œ ë‹¤ė‹œ ė•ˆë‚´ë°›ėŠĩ니다.\n"; - readonly ru: "ĐŖĐ´Đ°ĐģŅ‘ĐŊĐŊĐ°Ņ йаСа даĐŊĐŊҋ҅ СайĐģĐžĐēĐ¸Ņ€ĐžĐ˛Đ°ĐŊа. Đ­Ņ‚Đž ŅĐ˛ŅĐˇĐ°ĐŊĐž ҁ ĐŋĐĩŅ€ĐĩŅŅ‚Ņ€ĐžĐĩĐŊиĐĩĐŧ ĐŊа ОдĐŊĐžĐŧ иС ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛."; - readonly zh: "Remote database is locked. This is due to a rebuild on one of the terminals.\nThe device is therefore asked to withhold the connection to avoid database corruption.\n\nThere are three options that we can do:\n\n- Reset Synchronisation on This Device\n The most preferred and reliable way. This will dispose the local database once, and fetch all from the remote database again, In most case, we can perform this safely. However, it takes some time and should be done in stable network.\n- Unlock the remote database\n This method can only be used if we are already reliably synchronised by other replication methods. This does not simply mean that we have the same files. If you are not sure, you should avoid it.\n- Cancel for reconfirmation\n This will cancel the operation. And we will asked again on next request.\n"; - }; - readonly "Replicator.Dialogue.Locked.Message.Fetch": { - readonly def: "Fetch all has been scheduled. Plug-in will be restarted to perform it."; - readonly fr: "Tout rÊcupÊrer a ÊtÊ planifiÊ. Le plug-in sera redÊmarrÊ pour l'exÊcuter."; - readonly he: "משיכה מלאה ×Ēוזמנה. הפלאגין יופ×ĸל מחדש לבי×Ļו×ĸה."; - readonly ja: "å…¨ãƒ•ã‚§ãƒƒãƒãŒã‚šã‚ąã‚¸ãƒĨãƒŧãƒĢã•ã‚Œãžã—ãŸã€‚ãƒ—ãƒŠã‚°ã‚¤ãƒŗã¯åŽŸčĄŒãŽãŸã‚ãĢ再čĩˇå‹•されぞす。"; - readonly ko: "ëĒ¨ë“  것 ę°€ė ¸ė˜¤ę¸°ę°€ 똈ė•Ŋë˜ė—ˆėŠĩ니다. ė´ëĨŧ ėˆ˜í–‰í•˜ę¸° ėœ„í•´ 플ëŸŦęˇ¸ė¸ė´ ėžŦė‹œėž‘ëŠë‹ˆë‹¤."; - readonly ru: "Đ—Đ°ĐŗŅ€ŅƒĐˇĐēа Đ˛ŅĐĩĐŗĐž СаĐŋĐģаĐŊĐ¸Ņ€ĐžĐ˛Đ°ĐŊа. ПĐģĐ°ĐŗĐ¸ĐŊ ĐąŅƒĐ´ĐĩŅ‚ ĐŋĐĩŅ€ĐĩСаĐŋŅƒŅ‰ĐĩĐŊ."; - readonly zh: "Fetch all has been scheduled. Plug-in will be restarted to perform it."; - }; - readonly "Replicator.Dialogue.Locked.Message.Unlocked": { - readonly def: "The remote database has been unlocked. Please retry the operation."; - readonly fr: "La base distante a ÊtÊ dÊverrouillÊe. Veuillez rÊessayer l'opÊration."; - readonly he: "מסד הנ×Ēונים המרוחד בוטל × ×ĸיל×Ēו. אנא נסה שוב א×Ē ×”×¤×ĸולה."; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧã‚šãŽãƒ­ãƒƒã‚¯ãŒč§Ŗé™¤ã•ã‚Œãžã—ãŸã€‚æ“äŊœã‚’再čŠĻčĄŒã—ãĻください。"; - readonly ko: "ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ ėž ę¸ˆė´ í•´ė œë˜ė—ˆėŠĩ니다. ėž‘ė—…ė„ ë‹¤ė‹œ ė‹œë„í•´ ėŖŧė„¸ėš”."; - readonly ru: "ĐŖĐ´Đ°ĐģŅ‘ĐŊĐŊĐ°Ņ йаСа даĐŊĐŊҋ҅ Ņ€Đ°ĐˇĐąĐģĐžĐēĐ¸Ņ€ĐžĐ˛Đ°ĐŊа. ĐŸĐžĐ˛Ņ‚ĐžŅ€Đ¸Ņ‚Đĩ ĐžĐŋĐĩŅ€Đ°Ņ†Đ¸ŅŽ."; - readonly zh: "The remote database has been unlocked. Please retry the operation."; - }; - readonly "Replicator.Dialogue.Locked.Title": { - readonly def: "Locked"; - readonly fr: "VerrouillÊe"; - readonly he: "× ×ĸול"; - readonly ja: "ロック中"; - readonly ko: "ėž ęš€"; - readonly ru: "ЗабĐģĐžĐēĐ¸Ņ€ĐžĐ˛Đ°ĐŊĐž"; - readonly zh: "Locked"; - }; - readonly "Replicator.Message.Cleaned": { - readonly def: "Database cleaning up is in process. replication has been cancelled"; - readonly fr: "Nettoyage de la base en cours. La rÊplication a ÊtÊ annulÊe"; - readonly he: "ניקוי מסד הנ×Ēונים ב×Ēהליך. השכפול בוטל"; - readonly ja: "デãƒŧã‚ŋベãƒŧ゚ぎクãƒĒãƒŧナップ中です。ãƒŦプãƒĒã‚ąãƒŧã‚ˇãƒ§ãƒŗ(複čŖŊ)ã¯ã‚­ãƒŖãƒŗã‚ģãƒĢされぞした。"; - readonly ko: "ë°ė´í„°ë˛ ė´ėŠ¤ ė •ëĻŦ가 ė§„í–‰ ė¤‘ėž…ë‹ˆë‹¤. ëŗĩė œę°€ ėˇ¨ė†Œë˜ė—ˆėŠĩ니다"; - readonly ru: "ĐžŅ‡Đ¸ŅŅ‚Đēа ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅ в ĐŋŅ€ĐžŅ†ĐĩҁҁĐĩ. Đ ĐĩĐŋĐģиĐēĐ°Ņ†Đ¸Ņ ĐžŅ‚ĐŧĐĩĐŊĐĩĐŊа"; - readonly zh: "Database cleaning up is in process. replication has been cancelled"; - }; - readonly "Replicator.Message.InitialiseFatalError": { - readonly def: "No replicator is available, this is the fatal error."; - readonly fr: "Aucun rÊplicateur disponible, il s'agit d'une erreur fatale."; - readonly he: "אין רפליקטור זמין, זוהי שגיאה קריטי×Ē."; - readonly ja: "ãƒŦプãƒĒã‚ąãƒŧã‚ŋãƒŧãŒåˆŠį”¨ã§ããžã›ã‚“ã€‚ã“ã‚Œã¯č‡´å‘Ŋįš„ãĒエナãƒŧです。"; - readonly ko: "ė‚ŦėšŠ 가ëŠĨ한 ëŗĩė œę¸°ę°€ ė—†ėŠĩ니다. ėš˜ëĒ…ė ė¸ 똤ëĨ˜ėž…니다."; - readonly ru: "Đ ĐĩĐŋĐģиĐēĐ°Ņ‚ĐžŅ€ ĐŊĐĩĐ´ĐžŅŅ‚ŅƒĐŋĐĩĐŊ, ŅŅ‚Đž Ņ„Đ°Ņ‚Đ°ĐģҌĐŊĐ°Ņ ĐžŅˆĐ¸ĐąĐēа."; - readonly zh: "No replicator is available, this is the fatal error."; - }; - readonly "Replicator.Message.Pending": { - readonly def: "Some file events are pending. Replication has been cancelled."; - readonly fr: "Des ÊvÊnements de fichier sont en attente. La rÊplication a ÊtÊ annulÊe."; - readonly he: "חלק מאירו×ĸי הקב×Ļים ממ×Ēינים. השכפול בוטל."; - readonly ja: "ãƒ•ã‚Ąã‚¤ãƒĢã‚¤ãƒ™ãƒŗãƒˆãŒäŋį•™ä¸­ã§ã™ã€‚ãƒŦプãƒĒã‚ąãƒŧã‚ˇãƒ§ãƒŗ(複čŖŊ)ã¯ã‚­ãƒŖãƒŗã‚ģãƒĢされぞした。"; - readonly ko: "ėŧëļ€ íŒŒėŧ ė´ë˛¤íŠ¸ę°€ 대기 ė¤‘ėž…ë‹ˆë‹¤. ëŗĩė œę°€ ėˇ¨ė†Œë˜ė—ˆėŠĩ니다."; - readonly ru: "НĐĩĐēĐžŅ‚ĐžŅ€Ņ‹Đĩ ŅĐžĐąŅ‹Ņ‚Đ¸Ņ Ņ„Đ°ĐšĐģОв ĐžĐļĐ¸Đ´Đ°ŅŽŅ‚. Đ ĐĩĐŋĐģиĐēĐ°Ņ†Đ¸Ņ ĐžŅ‚ĐŧĐĩĐŊĐĩĐŊа."; - readonly zh: "Some file events are pending. Replication has been cancelled."; - }; - readonly "Replicator.Message.SomeModuleFailed": { - readonly def: "Replication has been cancelled by some module failure"; - readonly fr: "La rÊplication a ÊtÊ annulÊe suite à l'Êchec d'un module"; - readonly he: "השכפול בוטל בשל כשל במודול"; - readonly ja: "一部ぎãƒĸジãƒĨãƒŧãƒĢãŽå¤ąæ•—ãĢよりãƒŦプãƒĒã‚ąãƒŧã‚ˇãƒ§ãƒŗ(複čŖŊ)ãŒã‚­ãƒŖãƒŗã‚ģãƒĢされぞした。"; - readonly ko: "ėŧëļ€ ëĒ¨ë“ˆ ė‹¤íŒ¨ëĄœ ëŗĩė œę°€ ėˇ¨ė†Œë˜ė—ˆėŠĩ니다"; - readonly ru: "Đ ĐĩĐŋĐģиĐēĐ°Ņ†Đ¸Ņ ĐžŅ‚ĐŧĐĩĐŊĐĩĐŊа иС-Са ŅĐąĐžŅ ĐŧĐžĐ´ŅƒĐģŅ"; - readonly zh: "Replication has been cancelled by some module failure"; - }; - readonly "Replicator.Message.VersionUpFlash": { - readonly def: "An update has been detected. Please open the Settings dialogue and check the Change Log. Replication has been cancelled."; - readonly fr: "Une mise à jour a ÊtÊ dÊtectÊe. Veuillez ouvrir la boÃŽte de dialogue des paramètres et consulter le journal des modifications. La rÊplication a ÊtÊ annulÊe."; - readonly he: "זוהה ×ĸדכון. אנא פ×Ēח א×Ē ×“×™××œ×•×’ ההגדרו×Ē ×•×‘×“×•×§ א×Ē ×™×•×ž×Ÿ השינויים. השכפול בוטל."; - readonly ja: "更新が検å‡ēã•ã‚Œãžã—ãŸã€‚č¨­åŽšãƒ€ã‚¤ã‚ĸログを開いãĻ変更ログをįĸēčĒã—ãĻください。ãƒŦプãƒĒã‚ąãƒŧã‚ˇãƒ§ãƒŗ(複čŖŊ)ã¯ã‚­ãƒŖãƒŗã‚ģãƒĢされぞした。"; - readonly ko: "ė„¤ė •ė„ ė—´ęŗ  ëŠ”ė‹œė§€ëĨŧ í™•ė¸í•´ ėŖŧė„¸ėš”. ëŗĩė œę°€ ėˇ¨ė†Œë˜ė—ˆėŠĩ니다."; - readonly ru: "ОбĐŊОвĐģĐĩĐŊиĐĩ ОйĐŊĐ°Ņ€ŅƒĐļĐĩĐŊĐž. ĐžŅ‚ĐēŅ€ĐžĐšŅ‚Đĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи и ĐŋŅ€ĐžĐ˛ĐĩŅ€ŅŒŅ‚Đĩ Đ¸ŅŅ‚ĐžŅ€Đ¸ŅŽ иСĐŧĐĩĐŊĐĩĐŊиК."; - readonly zh: "An update has been detected. Please open the Settings dialogue and check the Change Log. Replication has been cancelled."; - }; - readonly "Requires restart of Obsidian": { - readonly def: "Requires restart of Obsidian"; - readonly es: "Requiere reiniciar Obsidian"; - readonly fr: "NÊcessite un redÊmarrage d'Obsidian"; - readonly he: "דורש הפ×ĸלה מחדש של Obsidian"; - readonly ja: "Obsidianぎ再čĩˇå‹•がåŋ…čĻã§ã™"; - readonly ko: "Obsidian ėžŦė‹œėž‘ í•„ėš”"; - readonly ru: "ĐĸŅ€ĐĩĐąŅƒĐĩŅ‚ŅŅ ĐŋĐĩŅ€ĐĩСаĐŋ҃ҁĐē Obsidian"; - readonly zh: "需čĻé‡å¯ Obsidian"; - }; - readonly "Requires restart of Obsidian.": { - readonly def: "Requires restart of Obsidian."; - readonly es: "Requiere reiniciar Obsidian"; - readonly fr: "NÊcessite un redÊmarrage d'Obsidian."; - readonly he: "דורש הפ×ĸלה מחדש של Obsidian."; - readonly ja: "Obsidianぎ再čĩˇå‹•がåŋ…čĻã§ã™ã€‚"; - readonly ko: "Obsidian ėžŦė‹œėž‘ė´ í•„ėš”í•Šë‹ˆë‹¤."; - readonly ru: "ĐĸŅ€ĐĩĐąŅƒĐĩŅ‚ŅŅ ĐŋĐĩŅ€ĐĩСаĐŋ҃ҁĐē Obsidian."; - readonly zh: "需čĻé‡å¯ Obsidian "; - }; - readonly "Rerun Onboarding Wizard": { - readonly def: "Rerun Onboarding Wizard"; - readonly fr: "Relancer l'assistant d'intÊgration"; - readonly he: "הר×Ĩ שוב א×Ē ××Š×Ŗ ההכוונה"; - readonly ja: "ã‚Ēãƒŗãƒœãƒŧãƒ‡ã‚Ŗãƒŗã‚°ã‚Ļã‚Ŗã‚ļãƒŧãƒ‰ã‚’å†åŽŸčĄŒ"; - readonly ko: "ė˜¨ëŗ´ë”Š 마법ė‚Ŧ ë‹¤ė‹œ ė‹¤í–‰"; - readonly ru: "ПĐĩŅ€ĐĩСаĐŋŅƒŅŅ‚Đ¸Ņ‚ŅŒ ĐŧĐ°ŅŅ‚ĐĩŅ€ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи"; - readonly zh: "重新čŋčĄŒåŧ•å¯ŧ向å¯ŧ"; - readonly "zh-tw": "é‡æ–°åŸˇčĄŒå°ŽčĻŊį˛žéˆ"; - }; - readonly "Rerun the onboarding wizard to set up Self-hosted LiveSync again.": { - readonly def: "Rerun the onboarding wizard to set up Self-hosted LiveSync again."; - readonly fr: "Relancer l'assistant d'intÊgration pour reconfigurer Self-hosted LiveSync."; - readonly he: "הר×Ĩ שוב א×Ē ××Š×Ŗ ההכוונה להגדר×Ē Self-hosted LiveSync מחדש."; - readonly ja: "ã‚Ēãƒŗãƒœãƒŧãƒ‡ã‚Ŗãƒŗã‚°ã‚Ļã‚Ŗã‚ļãƒŧãƒ‰ã‚’å†åŽŸčĄŒã—ãĻ、Self-hosted LiveSync をもう一åēĻč¨­åŽšã—ãžã™ã€‚"; - readonly ko: "ė˜¨ëŗ´ë”Š 마법ė‚ŦëĨŧ ë‹¤ė‹œ ė‹¤í–‰í•˜ė—Ŧ Self-hosted LiveSyncëĨŧ ë‹¤ė‹œ ė„¤ė •í•Šë‹ˆë‹¤."; - readonly ru: "ПĐĩŅ€ĐĩСаĐŋŅƒŅŅ‚Đ¸Ņ‚ŅŒ ĐŧĐ°ŅŅ‚ĐĩŅ€ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи Đ´ĐģŅ ĐŋĐžĐ˛Ņ‚ĐžŅ€ĐŊОК ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи Self-hosted LiveSync."; - readonly zh: "重新čŋčĄŒåŧ•å¯ŧ向å¯ŧäģĨ再æŦĄčŽžįŊŽ Self-hosted LiveSync。"; - readonly "zh-tw": "é‡æ–°åŸˇčĄŒå°ŽčĻŊį˛žéˆäģĨ再æŦĄč¨­åޚ Self-hosted LiveSync。"; - }; - readonly "Rerun Wizard": { - readonly def: "Rerun Wizard"; - readonly fr: "Relancer l'assistant"; - readonly he: "הר×Ĩ שוב א×Ē ×”××Š×Ŗ"; - readonly ja: "ã‚Ļã‚Ŗã‚ļãƒŧãƒ‰ã‚’å†åŽŸčĄŒ"; - readonly ko: "마법ė‚Ŧ ë‹¤ė‹œ ė‹¤í–‰"; - readonly ru: "ПĐĩŅ€ĐĩСаĐŋŅƒŅŅ‚Đ¸Ņ‚ŅŒ ĐŧĐ°ŅŅ‚ĐĩŅ€"; - readonly zh: "重新čŋčĄŒå‘å¯ŧ"; - readonly "zh-tw": "é‡æ–°åŸˇčĄŒį˛žéˆ"; - }; - readonly Resend: { - readonly def: "Resend"; - readonly es: "Reenviar"; - readonly ja: "再送äŋĄ"; - readonly ko: "ë‹¤ė‹œ ëŗ´ë‚´ę¸°"; - readonly ru: "ĐŸĐžĐ˛Ņ‚ĐžŅ€ĐŊĐž ĐžŅ‚ĐŋŅ€Đ°Đ˛Đ¸Ņ‚ŅŒ"; - readonly zh: "重新发送"; - readonly "zh-tw": "é‡æ–°å‚ŗé€"; - }; - readonly "Resend all chunks to the remote.": { - readonly def: "Resend all chunks to the remote."; - readonly es: "Reenvía todos los chunks al remoto."; - readonly ja: "すずãĻãŽãƒãƒŖãƒŗã‚¯ã‚’ãƒĒãƒĸãƒŧトへ再送äŋĄã—ぞす。"; - readonly ko: "ëĒ¨ë“  랭íŦëĨŧ ė›ę˛Šėœŧ로 ë‹¤ė‹œ ëŗ´ëƒ…ë‹ˆë‹¤."; - readonly ru: "ĐŸĐžĐ˛Ņ‚ĐžŅ€ĐŊĐž ĐžŅ‚ĐŋŅ€Đ°Đ˛Đ¸Ņ‚ŅŒ Đ˛ŅĐĩ Ņ‡Đ°ĐŊĐēи в ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐĩ Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰Đĩ."; - readonly zh: "将所有 chunks 重新发送到čŋœįĢ¯ã€‚"; - readonly "zh-tw": "將所有 chunks é‡æ–°å‚ŗé€åˆ°é įĢ¯ã€‚"; - }; - readonly Reset: { - readonly def: "Reset"; - readonly es: "Restablecer"; - readonly ja: "ãƒĒã‚ģット"; - readonly ko: "ėžŦ네렕"; - readonly ru: "ĐĄĐąŅ€ĐžŅ"; - readonly zh: "重įŊŽ"; - readonly "zh-tw": "重設"; - }; - readonly "Reset all": { - readonly def: "Reset all"; - readonly es: "Restablecer todo"; - readonly ja: "すずãĻãƒĒã‚ģット"; - readonly ko: "ëĒ¨ë‘ ėžŦ네렕"; - readonly ru: "ĐĄĐąŅ€ĐžŅĐ¸Ņ‚ŅŒ Đ˛ŅŅ‘"; - readonly zh: "全部重įŊŽ"; - readonly "zh-tw": "å…¨éƒ¨é‡č¨­"; - }; - readonly "Reset all journal counter": { - readonly def: "Reset all journal counter"; - readonly es: "Restablecer todos los contadores del diario"; - readonly ja: "すずãĻãŽã‚¸ãƒŖãƒŧナãƒĢã‚Ģã‚Ļãƒŗã‚ŋãƒŧをãƒĒã‚ģット"; - readonly ko: "ëĒ¨ë“  ė €ë„ ėš´ėš´í„° ėžŦ네렕"; - readonly ru: "ĐĄĐąŅ€ĐžŅĐ¸Ņ‚ŅŒ Đ˛ŅĐĩ ŅŅ‡Ņ‘Ņ‚Ņ‡Đ¸Đēи ĐļŅƒŅ€ĐŊаĐģа"; - readonly zh: "重įŊŽæ‰€æœ‰æ—Ĩåŋ—čŽĄæ•°å™¨"; - readonly "zh-tw": "é‡č¨­æ‰€æœ‰æ—ĨčĒŒč¨ˆæ•¸å™¨"; - }; - readonly "Reset journal received history": { - readonly def: "Reset journal received history"; - readonly es: "Restablecer historial de recepciÃŗn del diario"; - readonly ja: "ã‚¸ãƒŖãƒŧナãƒĢ受äŋĄåąĨ歴をãƒĒã‚ģット"; - readonly ko: "ė €ë„ ėˆ˜ė‹  기록 ėžŦ네렕"; - readonly ru: "ĐĄĐąŅ€ĐžŅĐ¸Ņ‚ŅŒ Đ¸ŅŅ‚ĐžŅ€Đ¸ŅŽ ĐŋĐžĐģŅƒŅ‡ĐĩĐŊĐŊҋ҅ СаĐŋĐ¸ŅĐĩĐš ĐļŅƒŅ€ĐŊаĐģа"; - readonly zh: "重įŊŽæ—Ĩåŋ—æŽĨæ”ļåŽ†å˛"; - readonly "zh-tw": "重設æ—ĨčnjæŽĨæ”ļæ­ˇå˛"; - }; - readonly "Reset journal sent history": { - readonly def: "Reset journal sent history"; - readonly es: "Restablecer historial de envío del diario"; - readonly ja: "ã‚¸ãƒŖãƒŧナãƒĢ送äŋĄåąĨ歴をãƒĒã‚ģット"; - readonly ko: "ė €ë„ ė†Ąė‹  기록 ėžŦ네렕"; - readonly ru: "ĐĄĐąŅ€ĐžŅĐ¸Ņ‚ŅŒ Đ¸ŅŅ‚ĐžŅ€Đ¸ŅŽ ĐžŅ‚ĐŋŅ€Đ°Đ˛ĐģĐĩĐŊĐŊҋ҅ СаĐŋĐ¸ŅĐĩĐš ĐļŅƒŅ€ĐŊаĐģа"; - readonly zh: "重įŊŽæ—Ĩåŋ—å‘é€åŽ†å˛"; - readonly "zh-tw": "重設æ—ĨčĒŒå‚ŗé€æ­ˇå˛"; - }; - readonly "Reset notification threshold and check the remote database usage": { - readonly def: "Reset notification threshold and check the remote database usage"; - readonly fr: "RÊinitialiser le seuil de notification et vÊrifier l'utilisation de la base distante"; - readonly he: "אפץ ×Ą×Ŗ ה×Ēראה ובדוק שימוש במסד הנ×Ēונים המרוחד"; - readonly ja: "通įŸĨしきい値をãƒĒã‚ģットしãĻãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚äŊŋį”¨é‡ã‚’įĸēčĒ"; - readonly ko: "ė•ŒëĻŧ ėž„ęŗ„ę°’ė„ ė´ˆę¸°í™”í•˜ęŗ  ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ ė‚ŦėšŠëŸ‰ í™•ė¸"; - readonly ru: "ĐĄĐąŅ€ĐžŅĐ¸Ņ‚ŅŒ ĐŋĐžŅ€ĐžĐŗ ŅƒĐ˛ĐĩĐ´ĐžĐŧĐģĐĩĐŊĐ¸Ņ и ĐŋŅ€ĐžĐ˛ĐĩŅ€Đ¸Ņ‚ŅŒ Đ¸ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°ĐŊиĐĩ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊОК ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅"; - readonly zh: "重įŊŽé€šįŸĨ阈å€ŧåšļæŖ€æŸĨčŋœį¨‹æ•°æŽåē“äŊŋį”¨æƒ…å†ĩ"; - readonly "zh-tw": "重設通įŸĨ閾å€ŧä¸ĻæĒĸæŸĨ遠įĢ¯čŗ‡æ–™åēĢäŊŋį”¨æƒ…æŗ"; - }; - readonly "Reset received": { - readonly def: "Reset received"; - readonly es: "Restablecer recepciÃŗn"; - readonly ja: "受äŋĄåąĨ歴をãƒĒã‚ģット"; - readonly ko: "ėˆ˜ė‹  기록 ėžŦ네렕"; - readonly ru: "ĐĄĐąŅ€ĐžŅĐ¸Ņ‚ŅŒ ĐŋĐžĐģŅƒŅ‡ĐĩĐŊĐŊŅ‹Đĩ"; - readonly zh: "重įŊŽæŽĨæ”ļ莰åŊ•"; - readonly "zh-tw": "重設æŽĨæ”ļį´€éŒ„"; - }; - readonly "Reset sent history": { - readonly def: "Reset sent history"; - readonly es: "Restablecer historial de envío"; - readonly ja: "送äŋĄåąĨ歴をãƒĒã‚ģット"; - readonly ko: "ė†Ąė‹  기록 ėžŦ네렕"; - readonly ru: "ĐĄĐąŅ€ĐžŅĐ¸Ņ‚ŅŒ Đ¸ŅŅ‚ĐžŅ€Đ¸ŅŽ ĐžŅ‚ĐŋŅ€Đ°Đ˛Đēи"; - readonly zh: "重įŊŽå‘é€åŽ†å˛"; - readonly "zh-tw": "é‡č¨­å‚ŗé€æ­ˇå˛"; - }; - readonly "Reset Synchronisation information": { - readonly def: "Reset Synchronisation information"; - readonly es: "Restablecer informaciÃŗn de sincronizaciÃŗn"; - readonly ja: "åŒæœŸæƒ…å ąã‚’ãƒĒã‚ģット"; - readonly ko: "동기화 ė •ëŗ´ ėžŦ네렕"; - readonly ru: "ĐĄĐąŅ€ĐžŅĐ¸Ņ‚ŅŒ иĐŊŅ„ĐžŅ€ĐŧĐ°Ņ†Đ¸ŅŽ Đž ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸"; - readonly zh: "重įŊŽåŒæ­ĨäŋĄæ¯"; - readonly "zh-tw": "é‡č¨­åŒæ­Ĩčŗ‡č¨Š"; - }; - readonly "Reset Synchronisation on This Device": { - readonly def: "Reset Synchronisation on This Device"; - readonly es: "Restablecer sincronizaciÃŗn en este dispositivo"; - readonly ja: "こぎデバイ゚ぎ同期įŠļ態をãƒĒã‚ģット"; - readonly ko: "ė´ ėžĨėš˜ė˜ 동기화 ėƒíƒœ ėžŦ네렕"; - readonly ru: "ĐĄĐąŅ€ĐžŅĐ¸Ņ‚ŅŒ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸ŅŽ ĐŊа ŅŅ‚ĐžĐŧ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đĩ"; - readonly zh: "重įŊŽæ­¤čŽžå¤‡ä¸Šįš„åŒæ­Ĩ"; - readonly "zh-tw": "é‡č¨­æ­¤čŖįŊŽä¸Šįš„同æ­Ĩ"; - }; - readonly "Reset the remote storage size threshold and check the remote storage size again.": { - readonly def: "Reset the remote storage size threshold and check the remote storage size again."; - readonly fr: "RÊinitialiser le seuil de taille du stockage distant et vÊrifier à nouveau la taille du stockage distant."; - readonly he: "אפץ א×Ē ×Ą×Ŗ גודל האחסון המרוחד ובדוק שוב א×Ē ×’×•×“×œ האחסון המרוחד."; - readonly ja: "ãƒĒãƒĸãƒŧト゚トãƒŦãƒŧジ厚量ぎしきい値をãƒĒã‚ģットし、ãƒĒãƒĸãƒŧト゚トãƒŦãƒŧジ厚量を再įĸēčĒã—ãžã™ã€‚"; - readonly ko: "ė›ę˛Š ė €ėžĨė†Œ íŦ기 ėž„ęŗ„ę°’ė„ ė´ˆę¸°í™”í•˜ęŗ  ė›ę˛Š ė €ėžĨė†Œ íŦ기ëĨŧ ë‹¤ė‹œ í™•ė¸í•Šë‹ˆë‹¤."; - readonly ru: "ĐĄĐąŅ€ĐžŅĐ¸Ņ‚ŅŒ ĐŋĐžŅ€ĐžĐŗ Ņ€Đ°ĐˇĐŧĐĩŅ€Đ° ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐŗĐž Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰Đ° и ĐŋŅ€ĐžĐ˛ĐĩŅ€Đ¸Ņ‚ŅŒ Ņ€Đ°ĐˇĐŧĐĩŅ€ Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰Đ° ҁĐŊОва."; - readonly zh: "重įŊŽčŋœį¨‹å­˜å‚¨å¤§å°é˜ˆå€ŧåšļ再æŦĄæŖ€æŸĨčŋœį¨‹å­˜å‚¨å¤§å°ã€‚"; - readonly "zh-tw": "重設遠įĢ¯å„˛å­˜įŠē間大小閞å€ŧīŧŒä¸Ļ再æŦĄæĒĸæŸĨ遠įĢ¯å„˛å­˜įŠē間大小。"; - }; - readonly "Resolve All": { - readonly def: "Resolve All"; - readonly es: "Resolver todo"; - readonly ja: "すずãĻč§Ŗæąē"; - readonly ko: "ëĒ¨ë‘ 해결"; - readonly ru: "Đ Đ°ĐˇŅ€ĐĩŅˆĐ¸Ņ‚ŅŒ Đ˛ŅŅ‘"; - readonly zh: "å…¨éƒ¨å¤„į†"; - readonly "zh-tw": "å…¨éƒ¨č™•į†"; - }; - readonly "Resolve all conflicted files": { - readonly def: "Resolve all conflicted files"; - readonly es: "Resolver todos los archivos en conflicto"; - readonly ja: "įĢļ合しãĻいるすずãĻãŽãƒ•ã‚Ąã‚¤ãƒĢã‚’č§Ŗæąē"; - readonly ko: "ėļŠëŒí•œ ëĒ¨ë“  파ėŧ 해결"; - readonly ru: "Đ Đ°ĐˇŅ€ĐĩŅˆĐ¸Ņ‚ŅŒ Đ˛ŅĐĩ ĐēĐžĐŊŅ„ĐģиĐēŅ‚ŅƒŅŽŅ‰Đ¸Đĩ Ņ„Đ°ĐšĐģŅ‹"; - readonly zh: "č§Ŗå†ŗæ‰€æœ‰å†˛įĒæ–‡äģļ"; - readonly "zh-tw": "č§Ŗæąēæ‰€æœ‰čĄįǁæĒ”æĄˆ"; - }; - readonly "Resolve All conflicted files by the newer one": { - readonly def: "Resolve All conflicted files by the newer one"; - readonly es: "Resolver todos los archivos en conflicto con la versiÃŗn mÃĄs reciente"; - readonly ja: "įĢļ合したすずãĻãŽãƒ•ã‚Ąã‚¤ãƒĢã‚’æ–°ã—ã„æ–šã§č§Ŗæąē"; - readonly ko: "ėļŠëŒí•œ ëĒ¨ë“  파ėŧė„ ėĩœė‹  ë˛„ė „ėœŧ로 해결"; - readonly ru: "Đ Đ°ĐˇŅ€ĐĩŅˆĐ¸Ņ‚ŅŒ Đ˛ŅĐĩ ĐēĐžĐŊŅ„ĐģиĐēŅ‚ŅƒŅŽŅ‰Đ¸Đĩ Ņ„Đ°ĐšĐģŅ‹ в ĐŋĐžĐģŅŒĐˇŅƒ йОĐģĐĩĐĩ ĐŊОвОК вĐĩŅ€ŅĐ¸Đ¸"; - readonly zh: "å°†æ‰€æœ‰å†˛įĒæ–‡äģļč§Ŗæžä¸ēčžƒæ–°įš„į‰ˆæœŦ"; - readonly "zh-tw": "å°‡æ‰€æœ‰čĄįǁæĒ”æĄˆįĩąä¸€į‚ēčŧƒæ–°įš„į‰ˆæœŦ"; - }; - readonly "Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one.": { - readonly def: "Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one."; - readonly es: "Resuelve todos los archivos en conflicto conservando la versiÃŗn mÃĄs reciente. PrecauciÃŗn: esto sobrescribirÃĄ la versiÃŗn anterior y no podrÃĄ recuperarse."; - readonly ja: "įĢļ合しãĻいるすずãĻãŽãƒ•ã‚Ąã‚¤ãƒĢã‚’æ–°ã—ã„æ–šãŽå†…åŽšã§č§Ŗæąēã—ãžã™ã€‚æŗ¨æ„īŧšå¤ã„斚は上書きされ、垊元できぞせん。"; - readonly ko: "ėļŠëŒí•œ ëĒ¨ë“  파ėŧė„ 더 ėĩœė‹  ë˛„ė „ėœŧ로 해결합니다. ėŖŧė˜: ė´ė „ ë˛„ė „ė€ ëŽė–´ė¨ė§€ëŠ° ëŗĩė›í•  눘 ė—†ėŠĩ니다."; - readonly ru: "Đ Đ°ĐˇŅ€ĐĩŅˆĐ°ĐĩŅ‚ Đ˛ŅĐĩ ĐēĐžĐŊŅ„ĐģиĐēŅ‚ŅƒŅŽŅ‰Đ¸Đĩ Ņ„Đ°ĐšĐģŅ‹ в ĐŋĐžĐģŅŒĐˇŅƒ йОĐģĐĩĐĩ ĐŊОвОК вĐĩŅ€ŅĐ¸Đ¸. ВĐŊиĐŧаĐŊиĐĩ: ŅŅ‚Đ°Ņ€Đ°Ņ вĐĩŅ€ŅĐ¸Ņ ĐąŅƒĐ´ĐĩŅ‚ ĐŋĐĩŅ€ĐĩСаĐŋĐ¸ŅĐ°ĐŊа и ĐĩŅ‘ ĐŊĐĩĐģŅŒĐˇŅ ĐąŅƒĐ´ĐĩŅ‚ Đ˛ĐžŅŅŅ‚Đ°ĐŊĐžĐ˛Đ¸Ņ‚ŅŒ."; - readonly zh: "å°†æ‰€æœ‰å†˛įĒæ–‡äģļįģŸä¸€äŋį•™čžƒæ–°įš„į‰ˆæœŦã€‚æŗ¨æ„īŧščŋ™äŧščĻ†į›–čžƒæ—§įš„į‰ˆæœŦīŧŒä¸”čĸĢčĻ†į›–įš„å†…åŽšæ— æŗ•æĸ复。"; - readonly "zh-tw": "å°‡æ‰€æœ‰čĄįǁæĒ”æĄˆįĩąä¸€äŋį•™čŧƒæ–°įš„į‰ˆæœŦã€‚æŗ¨æ„īŧšé€™æœƒčφå¯ĢčŧƒčˆŠįš„į‰ˆæœŦīŧŒä¸”čĸĢčφå¯Ģįš„å…§åŽšį„Ąæŗ•åžŠåŽŸã€‚"; - }; - readonly "Restart Now": { - readonly def: "Restart Now"; - readonly es: "Reiniciar ahora"; - readonly ja: "äģŠã™ãå†čĩˇå‹•"; - readonly ko: "ė§€ę¸ˆ ėžŦė‹œėž‘"; - readonly ru: "ПĐĩŅ€ĐĩСаĐŋŅƒŅŅ‚Đ¸Ņ‚ŅŒ ҁĐĩĐšŅ‡Đ°Ņ"; - readonly zh: "įĢ‹åŗé‡å¯"; - readonly "zh-tw": "įĢ‹åŗé‡æ–°å•Ÿå‹•"; - }; - readonly "Restarting Obsidian is strongly recommended. Until restart, some changes may not take effect, and display may be inconsistent. Are you sure to restart now?": { - readonly def: "Restarting Obsidian is strongly recommended. Until restart, some changes may not take effect, and display may be inconsistent. Are you sure to restart now?"; - readonly "zh-tw": "åŧˇįƒˆåģēč­°é‡æ–°å•Ÿå‹• Obsidian。在重新啟動䚋前īŧŒéƒ¨åˆ†čŽŠæ›´å¯čƒŊ尚æœĒį”Ÿæ•ˆīŧŒéĄ¯į¤ē䚟可čƒŊä¸ä¸€č‡´ã€‚äŊ įĸē厚čĻįžåœ¨é‡æ–°å•Ÿå‹•å—ŽīŧŸ"; - }; - readonly "Restore or reconstruct local database from remote.": { - readonly def: "Restore or reconstruct local database from remote."; - readonly es: "Restaura o reconstruye la base de datos local desde el remoto."; - readonly ja: "ãƒĒãƒĸãƒŧトからロãƒŧã‚ĢãƒĢデãƒŧã‚ŋベãƒŧã‚šã‚’åžŠå…ƒãžãŸã¯å†æ§‹į¯‰ã—ãžã™ã€‚"; - readonly ko: "ė›ę˛Šė—ė„œ 로ėģŦ ë°ė´í„°ë˛ ė´ėŠ¤ëĨŧ ëŗĩė›í•˜ęą°ë‚˜ ėžŦęĩŦėļ•핊니다."; - readonly ru: "Đ’ĐžŅŅŅ‚Đ°ĐŊĐžĐ˛Đ¸Ņ‚ŅŒ иĐģи ĐŋĐĩŅ€ĐĩŅŅ‚Ņ€ĐžĐ¸Ņ‚ŅŒ ĐģĐžĐēаĐģҌĐŊŅƒŅŽ ĐąĐ°ĐˇŅƒ даĐŊĐŊҋ҅ иС ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊОК."; - readonly zh: "äģŽčŋœį̝æĸ复或重åģ翜Ŧ地数捎åē“。"; - readonly "zh-tw": "垞遠įĢ¯é‚„åŽŸæˆ–é‡åģ翜ŦæŠŸčŗ‡æ–™åēĢ。"; - }; - readonly "Run Doctor": { - readonly def: "Run Doctor"; - readonly fr: "Lancer le Docteur"; - readonly he: "הפ×ĸל Doctor"; - readonly ja: "č¨ēæ–­ã‚’åŽŸčĄŒ"; - readonly ko: "ė§„ë‹¨ ė‹¤í–‰"; - readonly ru: "ЗаĐŋŅƒŅŅ‚Đ¸Ņ‚ŅŒ Đ´Đ¸Đ°ĐŗĐŊĐžŅŅ‚Đ¸Đē҃"; - readonly zh: "įĢ‹åŗč¯Šæ–­"; - readonly "zh-tw": "åŸˇčĄŒč¨ēæ–ˇ"; - }; - readonly "S3/MinIO/R2 Object Storage": { - readonly def: "S3/MinIO/R2 Object Storage"; - readonly es: "Almacenamiento de objetos S3/MinIO/R2"; - readonly ja: "S3/MinIO/R2 ã‚Ēブジェクト゚トãƒŦãƒŧジ"; - readonly ko: "S3/MinIO/R2 ę°ė˛´ ėŠ¤í† ëĻŦė§€"; - readonly ru: "ĐžĐąŅŠĐĩĐēŅ‚ĐŊĐžĐĩ Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰Đĩ S3/MinIO/R2"; - readonly zh: "S3/MinIO/R2 å¯ščąĄå­˜å‚¨"; - readonly "zh-tw": "S3/MinIO/R2 į‰Šäģļå„˛å­˜"; - }; - readonly "Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.": { - readonly def: "Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform."; - readonly es: "Guardar configuraciÃŗn en archivo markdown. Se notificarÃĄn nuevos ajustes. Puede definir diferentes archivos por plataforma"; - readonly fr: "Enregistrer les paramètres dans un fichier markdown. Vous serez notifiÊ à l'arrivÊe de nouveaux paramètres. Vous pouvez dÊfinir des fichiers diffÊrents selon la plateforme."; - readonly he: "שמור הגדרו×Ē ×œ×§×•×‘×Ĩ Markdown. ×Ēיוד×ĸ כשהגדרו×Ē ×—×“×Š×•×Ē ×™×’×™×ĸו. ני×Ēן להגדיר קב×Ļים שונים לפי פלטפורמה."; - readonly ja: "Markdownãƒ•ã‚Ąã‚¤ãƒĢãĢč¨­åŽšã‚’äŋå­˜ã—ãžã™ã€‚æ–°ã—ã„č¨­åŽšãŒåˆ°į€ã™ã‚‹ã¨é€šįŸĨされぞす。プナットフりãƒŧムごとãĢį•°ãĒã‚‹ãƒ•ã‚Ąã‚¤ãƒĢã‚’č¨­åŽšã§ããžã™ã€‚"; - readonly ko: "ė„¤ė •ė„ 마íŦë‹¤ėš´ 파ėŧ뗐 ė €ėžĨ합니다. ėƒˆëĄœėš´ ė„¤ė •ė´ ë„ė°Ší•˜ëŠ´ ė•ŒëĻŧė„ 받게 됩니다. 플ëžĢíŧëŗ„ëĄœ 다ëĨ¸ 파ėŧė„ ė„¤ė •í•  눘 ėžˆėŠĩ니다."; - readonly ru: "Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform."; - readonly zh: "å°†čŽžįŊŽäŋå­˜åˆ°ä¸€ä¸Ē Markdown 文äģļ中。åŊ“æ–°čŽžįŊŽåˆ°čžžæ—ļ,您将æ”ļ到通įŸĨ。您可äģĨæ šæŽåšŗå°čŽžįŊŽä¸åŒįš„æ–‡äģļ "; - }; - readonly "Saving will be performed forcefully after this number of seconds.": { - readonly def: "Saving will be performed forcefully after this number of seconds."; - readonly es: "Guardado forzado tras esta cantidad de segundos"; - readonly fr: "L'enregistrement sera forcÊ au bout de ce nombre de secondes."; - readonly he: "השמירה ×Ē×Ēב×Ļ×ĸ בכפייה לאחר מספר שניו×Ē ×–×”."; - readonly ja: "ã“ãŽį§’æ•°åžŒãĢåŧˇåˆļįš„ãĢäŋå­˜ã•れぞす。"; - readonly ko: "ė´ ė‹œę°„(봈) í›„ė— ę°•ė œëĄœ ė €ėžĨė´ ėˆ˜í–‰ëŠë‹ˆë‹¤."; - readonly ru: "ĐĄĐžŅ…Ņ€Đ°ĐŊĐĩĐŊиĐĩ ĐąŅƒĐ´ĐĩŅ‚ ĐŋŅ€Đ¸ĐŊŅƒĐ´Đ¸Ņ‚ĐĩĐģҌĐŊĐž Đ˛Ņ‹ĐŋĐžĐģĐŊĐĩĐŊĐž ĐŋĐžŅĐģĐĩ ŅŅ‚ĐžĐŗĐž ĐēĐžĐģĐ¸Ņ‡ĐĩŅŅ‚Đ˛Đ° ҁĐĩĐē҃ĐŊĐ´."; - readonly zh: "åœ¨æ­¤į§’æ•°åŽå°†åŧēåˆļæ‰§čĄŒäŋå­˜ "; - }; - readonly "Scan a QR Code (Recommended for mobile)": { - readonly def: "Scan a QR Code (Recommended for mobile)"; - readonly es: "Escanear un cÃŗdigo QR (recomendado para mÃŗviles)"; - readonly ja: "QR ã‚ŗãƒŧãƒ‰ã‚’ã‚šã‚­ãƒŖãƒŗã™ã‚‹īŧˆãƒĸバイãƒĢ推åĨ¨īŧ‰"; - readonly ko: "QR ėŊ”드 늤ėē”(ëĒ¨ë°”ėŧ ęļŒėžĨ)"; - readonly ru: "ĐĄĐēаĐŊĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ QR-ĐēОд (Ņ€ĐĩĐēĐžĐŧĐĩĐŊĐ´ŅƒĐĩŅ‚ŅŅ Đ´ĐģŅ ĐŧОйиĐģҌĐŊҋ҅ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛)"; - readonly zh: "æ‰Ģ描äēŒįģ´į īŧˆį§ģ动įĢ¯æŽ¨čīŧ‰"; - readonly "zh-tw": "掃描 QR CodeīŧˆčĄŒå‹•čŖįŊ޿ލč–Ļīŧ‰"; - }; - readonly "Scan changes on customization sync": { - readonly def: "Scan changes on customization sync"; - readonly es: "Escanear cambios en sincronizaciÃŗn de personalizaciÃŗn"; - readonly fr: "Analyser les modifications de synchronisation de personnalisation"; - readonly he: "סרוק שינויים בסנכרון ה×Ēאמה אישי×Ē"; - readonly ja: "ã‚Ģ゚ã‚ŋマイã‚ēされた同期時ãĢã€å¤‰æ›´ã‚’ã‚šã‚­ãƒŖãƒŗã™ã‚‹"; - readonly ko: "ė‚ŦėšŠėž 네렕 동기화 ė‹œ ëŗ€ę˛Ŋ ė‚Ŧ항 ę˛€ėƒ‰"; - readonly ru: "ĐĄĐēаĐŊĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ иСĐŧĐĩĐŊĐĩĐŊĐ¸Ņ ĐŋŅ€Đ¸ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸ ĐŊĐ°ŅŅ‚Ņ€ĐžĐĩĐē"; - readonly zh: "在č‡Ē厚䚉同æ­Ĩæ—￉Ģ描更攚"; - }; - readonly "Scan customization automatically": { - readonly def: "Scan customization automatically"; - readonly es: "Escanear personalizaciÃŗn automÃĄticamente"; - readonly fr: "Analyser automatiquement la personnalisation"; - readonly he: "סרוק ה×Ēאמה אישי×Ē ××•×˜×•×ž×˜×™×Ē"; - readonly ja: "č‡Ēå‹•įš„ãĢã‚Ģ゚ã‚ŋマイã‚ēã‚’ã‚šã‚­ãƒŖãƒŗ"; - readonly ko: "ė‚ŦėšŠėž 네렕 ėžë™ ę˛€ėƒ‰"; - readonly ru: "ĐĄĐēаĐŊĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи Đ°Đ˛Ņ‚ĐžĐŧĐ°Ņ‚Đ¸Ņ‡ĐĩҁĐēи"; - readonly zh: "č‡Ē动æ‰Ģ描č‡ĒåŽšäš‰čŽžįŊŽ"; - }; - readonly "Scan customization before replicating.": { - readonly def: "Scan customization before replicating."; - readonly es: "Escanear personalizaciÃŗn antes de replicar"; - readonly fr: "Analyser la personnalisation avant de rÊpliquer."; - readonly he: "סרוק ה×Ēאמה אישי×Ē ×œ×¤× ×™ שכפול."; - readonly ja: "ãƒŦプãƒĒã‚ąãƒŧã‚ˇãƒ§ãƒŗ(複čŖŊ)前ãĢ、ã‚Ģ゚ã‚ŋマイã‚ēã‚’ã‚šã‚­ãƒŖãƒŗ"; - readonly ko: "ëŗĩė œí•˜ę¸° 렄뗐 ė‚ŦėšŠėž ė„¤ė •ė„ ę˛€ėƒ‰í•Šë‹ˆë‹¤."; - readonly ru: "ĐĄĐēаĐŊĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи ĐŋĐĩŅ€ĐĩĐ´ Ņ€ĐĩĐŋĐģиĐēĐ°Ņ†Đ¸ĐĩĐš."; - readonly zh: "在复åˆļ前æ‰Ģ描č‡ĒåŽšäš‰čŽžįŊŽ "; - }; - readonly "Scan customization every 1 minute.": { - readonly def: "Scan customization every 1 minute."; - readonly es: "Escanear personalizaciÃŗn cada 1 minuto"; - readonly fr: "Analyser la personnalisation toutes les 1 minute."; - readonly he: "סרוק ה×Ēאמה אישי×Ē ×›×œ דקה."; - readonly ja: "ã‚Ģ゚ã‚ŋマイã‚ēãŽã‚šã‚­ãƒŖãƒŗã‚’1分ごとãĢčĄŒã†"; - readonly ko: "1ëļ„마다 ė‚ŦėšŠėž ė„¤ė •ė„ ę˛€ėƒ‰í•Šë‹ˆë‹¤."; - readonly ru: "ĐĄĐēаĐŊĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи ĐēаĐļĐ´ŅƒŅŽ ĐŧиĐŊŅƒŅ‚Ņƒ."; - readonly zh: "每1分钟æ‰Ģ描č‡ĒåŽšäš‰čŽžįŊŽ "; - }; - readonly "Scan customization periodically": { - readonly def: "Scan customization periodically"; - readonly es: "Escanear personalizaciÃŗn periÃŗdicamente"; - readonly fr: "Analyser la personnalisation pÊriodiquement"; - readonly he: "סרוק ה×Ēאמה אישי×Ē ×Ēקופ×Ēי×Ē"; - readonly ja: "åŽšæœŸįš„ãĢã‚Ģ゚ã‚ŋマイã‚ēã‚’ã‚šã‚­ãƒŖãƒŗ"; - readonly ko: "ėŖŧ揰렁ėœŧ로 ė‚ŦėšŠėž 네렕 ę˛€ėƒ‰"; - readonly ru: "ĐĄĐēаĐŊĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи ĐŋĐĩŅ€Đ¸ĐžĐ´Đ¸Ņ‡ĐĩҁĐēи"; - readonly zh: "厚期æ‰Ģ描č‡ĒåŽšäš‰čŽžįŊŽ"; - }; - readonly "Scan for Broken files": { - readonly def: "Scan for Broken files"; - readonly ja: "į ´æãƒ•ã‚Ąã‚¤ãƒĢã‚’ã‚šã‚­ãƒŖãƒŗ"; - readonly ko: "ė†ėƒëœ 파ėŧ 검ė‚Ŧ"; - readonly zh: "æ‰Ģ描损坏文äģļ"; - readonly "zh-tw": "æŽƒææåŖžæĒ”æĄˆ"; - }; - readonly "Scan for hidden files before replication": { - readonly def: "Scan for hidden files before replication"; - readonly es: "Escanear archivos ocultos antes de replicar"; - readonly fr: "Analyser les fichiers cachÊs avant rÊplication"; - readonly he: "סרוק קב×Ļים נץ×Ēרים לפני שכפול"; - readonly ja: "ãƒŦプãƒĒã‚ąãƒŧã‚ˇãƒ§ãƒŗ(複čŖŊ)開始前ãĢã€éš ã—ãƒ•ã‚Ąã‚¤ãƒĢãŽã‚šã‚­ãƒŖãƒŗã‚’čĄŒã†"; - readonly ko: "ëŗĩ렜 ė „ ėˆ¨ę˛¨ė§„ 파ėŧ ę˛€ėƒ‰"; - readonly ru: "ĐĄĐēаĐŊĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ ҁĐēҀҋ҂ҋĐĩ Ņ„Đ°ĐšĐģŅ‹ ĐŋĐĩŅ€ĐĩĐ´ Ņ€ĐĩĐŋĐģиĐēĐ°Ņ†Đ¸ĐĩĐš"; - readonly zh: "复åˆļ前æ‰Ģæéšč—æ–‡äģļ"; - }; - readonly "Scan hidden files periodically": { - readonly def: "Scan hidden files periodically"; - readonly es: "Escanear archivos ocultos periÃŗdicamente"; - readonly fr: "Analyser les fichiers cachÊs pÊriodiquement"; - readonly he: "סרוק קב×Ļים נץ×Ēרים ×Ēקופ×Ēי×Ē"; - readonly ja: "åŽšæœŸįš„ãĢéš ã—ãƒ•ã‚Ąã‚¤ãƒĢãŽã‚šã‚­ãƒŖãƒŗã‚’čĄŒã†"; - readonly ko: "ėŖŧ揰렁ėœŧ로 ėˆ¨ę˛¨ė§„ 파ėŧ ę˛€ėƒ‰"; - readonly ru: "ĐĄĐēаĐŊĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ ҁĐēҀҋ҂ҋĐĩ Ņ„Đ°ĐšĐģŅ‹ ĐŋĐĩŅ€Đ¸ĐžĐ´Đ¸Ņ‡ĐĩҁĐēи"; - readonly zh: "厚期æ‰Ģæéšč—æ–‡äģļ"; - }; - readonly "Scan the QR code displayed on an active device using this device's camera.": { - readonly def: "Scan the QR code displayed on an active device using this device's camera."; - readonly es: "Escanee con la cÃĄmara de este dispositivo el cÃŗdigo QR mostrado en un dispositivo activo。"; - readonly ja: "į¨ŧ働中ぎį̝æœĢãĢ襨į¤ēされた QR ã‚ŗãƒŧドを、こぎį̝æœĢぎã‚ĢãƒĄãƒŠã§čĒ­ãŋå–ãŖãĻください。"; - readonly ko: "ė´ ėžĨėš˜ė˜ ėš´ëŠ”ëŧ로 í™œė„ą ėžĨėš˜ė— í‘œė‹œëœ QR ėŊ”드ëĨŧ 늤ėē”í•˜ė„¸ėš”ã€‚"; - readonly ru: "ĐžŅ‚ŅĐēаĐŊĐ¸Ņ€ŅƒĐšŅ‚Đĩ QR-ĐēОд, ĐŋĐžĐēаСаĐŊĐŊŅ‹Đš ĐŊа аĐēŅ‚Đ¸Đ˛ĐŊĐžĐŧ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đĩ, ҁ ĐŋĐžĐŧĐžŅ‰ŅŒŅŽ ĐēаĐŧĐĩҀҋ ŅŅ‚ĐžĐŗĐž ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°ã€‚"; - readonly zh: "äŊŋᔍåŊ“å‰čŽžå¤‡įš„æ‘„åƒå¤´æ‰Ģ描åĻä¸€å°åˇ˛åœ¨äŊŋį”¨čŽžå¤‡ä¸Šæ˜žį¤ēįš„äēŒįģ´į ã€‚"; - readonly "zh-tw": "äŊŋį”¨į›Žå‰čŖįŊŽįš„į›¸æŠŸæŽƒæåĻä¸€å°åˇ˛åœ¨äŊŋį”¨čŖįŊŽä¸ŠéĄ¯į¤ēįš„ QR Code。"; - }; - readonly "Schedule and Restart": { - readonly def: "Schedule and Restart"; - readonly es: "Programar y reiniciar"; - readonly ja: "äēˆį´„しãĻ再čĩˇå‹•"; - readonly ko: "똈ė•Ŋ 후 ėžŦė‹œėž‘"; - readonly ru: "ЗаĐŋĐģаĐŊĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ и ĐŋĐĩŅ€ĐĩСаĐŋŅƒŅŅ‚Đ¸Ņ‚ŅŒ"; - readonly zh: "厉排åšļ重启"; - readonly "zh-tw": "æŽ’į¨‹åžŒé‡æ–°å•Ÿå‹•"; - }; - readonly "Scram Switches": { - readonly def: "Scram Switches"; - readonly ja: "ᎊæ€Ĩ寞åŋœã‚šã‚¤ãƒƒãƒ"; - readonly ko: "긴급 ė „í™˜ ėŠ¤ėœ„ėš˜"; - readonly zh: "į´§æ€Ĩåŧ€å…ŗ"; - readonly "zh-tw": "ᎊæ€Ĩ處įŊŽé–‹é—œ"; - }; - readonly "Scram!": { - readonly def: "Scram!"; - readonly es: "Medidas de emergencia"; - readonly ja: "ᎊæ€Ĩ停æ­ĸ"; - readonly ko: "긴급 ėĄ°ėš˜"; - readonly ru: "Đ­Đēҁ҂ҀĐĩĐŊĐŊŅ‹Đĩ ĐŧĐĩҀҋ"; - readonly zh: "į´§æ€Ĩ处įŊŽ"; - readonly "zh-tw": "ᎊæ€Ĩ處įŊŽ"; - }; - readonly "Seconds, 0 to disable": { - readonly def: "Seconds, 0 to disable"; - readonly es: "Segundos, 0 para desactivar"; - readonly fr: "Secondes, 0 pour dÊsactiver"; - readonly he: "שניו×Ē, 0 לביטול"; - readonly ja: "į§’æ•°ã€0ã§į„ĄåŠš"; - readonly ko: "봈 ë‹¨ėœ„, 0ėœŧ로 ė„¤ė •í•˜ëŠ´ ëš„í™œė„ąí™”"; - readonly ru: "ĐĄĐĩĐē҃ĐŊĐ´, 0 Đ´ĐģŅ ĐžŅ‚ĐēĐģŅŽŅ‡ĐĩĐŊĐ¸Ņ"; - readonly zh: "į§’,0ä¸ēįρᔍ"; - }; - readonly "Seconds. Saving to the local database will be delayed until this value after we stop typing or saving.": { - readonly def: "Seconds. Saving to the local database will be delayed until this value after we stop typing or saving."; - readonly es: "Segundos. Guardado en BD local se retrasarÃĄ hasta este valor tras dejar de escribir/guardar"; - readonly fr: "Secondes. L'enregistrement dans la base locale sera diffÊrÊ de cette valeur après l'arrÃĒt de la frappe ou de l'enregistrement."; - readonly he: "שניו×Ē. השמירה למסד הנ×Ēונים המקומי ×Ē×Ē×ĸכב ב×ĸרך זה לאחר הפסק×Ē ×”×§×œ×“×” או שמירה."; - readonly ja: "į§’ã€‚å…Ĩ力やäŋå­˜ã‚’停æ­ĸしãĻからこぎ値ぎ間、ロãƒŧã‚ĢãƒĢデãƒŧã‚ŋベãƒŧ゚へぎäŋå­˜ãŒé…åģļされぞす。"; - readonly ko: "봈 ë‹¨ėœ„ėž…ë‹ˆë‹¤. íƒ€ė´í•‘ė´ë‚˜ ė €ėžĨė„ ė¤‘ë‹¨í•œ 후 ė´ ė‹œę°„ë™ė•ˆ 로ėģŦ ë°ė´í„°ë˛ ė´ėŠ¤ ė €ėžĨė´ ė§€ė—°ëŠë‹ˆë‹¤."; - readonly ru: "ĐĄĐĩĐē҃ĐŊĐ´Ņ‹. ĐĄĐžŅ…Ņ€Đ°ĐŊĐĩĐŊиĐĩ в ĐģĐžĐēаĐģҌĐŊŅƒŅŽ ĐąĐ°ĐˇŅƒ даĐŊĐŊҋ҅ ĐąŅƒĐ´ĐĩŅ‚ ĐžŅ‚ĐģĐžĐļĐĩĐŊĐž."; - readonly zh: "į§’ã€‚åœ¨æˆ‘äģŦ停æ­ĸ输å…Ĩ或äŋå­˜åŽ,äŋå­˜åˆ°æœŦ地数捎åē“å°†åģļčŋŸæ­¤å€ŧ "; - }; - readonly "Secret Key": { - readonly def: "Secret Key"; - readonly es: "Clave secreta"; - readonly fr: "ClÊ secrète"; - readonly he: "מפ×Ēח סודי"; - readonly ja: "ã‚ˇãƒŧクãƒŦットキãƒŧ"; - readonly ko: "ė‹œíŦëĻŋ 키"; - readonly ru: "ĐĄĐĩĐēŅ€ĐĩŅ‚ĐŊŅ‹Đš ĐēĐģŅŽŅ‡"; - readonly zh: "Secret Key"; - }; - readonly "Select the database adapter to use.": { - readonly def: "Select the database adapter to use."; - readonly es: "Selecciona el adaptador de base de datos que se usarÃĄ."; - readonly ja: "äŊŋį”¨ã™ã‚‹ãƒ‡ãƒŧã‚ŋベãƒŧ゚ã‚ĸダプã‚ŋãƒŧを選択しぞす。"; - readonly ko: "ė‚ŦėšŠí•  ë°ė´í„°ë˛ ė´ėŠ¤ ė–´ëŒ‘í„°ëĨŧ ė„ íƒí•Šë‹ˆë‹¤."; - readonly ru: "Đ’Ņ‹ĐąĐĩŅ€Đ¸Ņ‚Đĩ Đ¸ŅĐŋĐžĐģŅŒĐˇŅƒĐĩĐŧŅ‹Đš адаĐŋŅ‚ĐĩŅ€ ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅."; - readonly zh: "选拊čρäŊŋį”¨įš„æ•°æŽåē“适配器。"; - readonly "zh-tw": "選擇čρäŊŋį”¨įš„čŗ‡æ–™åēĢ遊配器。"; - }; - readonly Send: { - readonly def: "Send"; - readonly es: "Enviar"; - readonly ja: "送äŋĄ"; - readonly ko: "ëŗ´ë‚´ę¸°"; - readonly ru: "ĐžŅ‚ĐŋŅ€Đ°Đ˛Đ¸Ņ‚ŅŒ"; - readonly zh: "发送"; - readonly "zh-tw": "傺送"; - }; - readonly "Send chunks": { - readonly def: "Send chunks"; - readonly es: "Enviar chunks"; - readonly ja: "ãƒãƒŖãƒŗã‚¯ã‚’é€äŋĄ"; - readonly ko: "랭íŦ ëŗ´ë‚´ę¸°"; - readonly ru: "ĐžŅ‚ĐŋŅ€Đ°Đ˛Đ¸Ņ‚ŅŒ Ņ‡Đ°ĐŊĐēи"; - readonly zh: "发送 chunks"; - readonly "zh-tw": "傺送 chunks"; - }; - readonly "Server URI": { - readonly def: "Server URI"; - readonly es: "URI del servidor"; - readonly fr: "URI du serveur"; - readonly he: "כ×Ēוב×Ē ×Š×¨×Ē (URI)"; - readonly ja: "URI"; - readonly ko: "ė„œë˛„ URI"; - readonly ru: "URI ҁĐĩŅ€Đ˛ĐĩŅ€Đ°"; - readonly zh: "æœåŠĄå™¨ URI"; - }; - readonly "Setting.GenerateKeyPair.Desc": { - readonly def: "We have generated a key pair!\n\nNote: This key pair will never be shown again. Please save it in a safe place. If you have lost it, you need to generate a new key pair.\nNote 2: The public key is in spki format, and the Private key is in pkcs8 format. For the sake of convenience, newlines are converted to `\\n` in public key.\nNote 3: The public key should be configured in the remote database, and the private key should be configured in local devices.\n\n>[!FOR YOUR EYES ONLY]-\n>
\n>\n> ### Public Key\n> ```\n${public_key}\n> ```\n>\n> ### Private Key\n> ```\n${private_key}\n> ```\n>\n>
\n\n>[!Both for copying]-\n>\n>
\n>\n> ```\n${public_key}\n${private_key}\n> ```\n>\n>
\n\n"; - readonly es: "Hemos generado un par de claves.\n\nNota: Este par de claves no volverÃĄ a mostrarse. GuÃĄrdalo en un lugar seguro. Si lo pierdes, tendrÃĄs que generar uno nuevo.\nNota 2: La clave pÃēblica estÃĄ en formato spki y la clave privada en formato pkcs8. Para mayor comodidad, los saltos de línea de la clave pÃēblica se convierten en `\\n`.\nNota 3: La clave pÃēblica debe configurarse en la base de datos remota y la clave privada en los dispositivos locales.\n\n>[!SOLO PARA TUS OJOS]-\n>
\n>\n> ### Clave pÃēblica\n> ```\n${public_key}\n> ```\n>\n> ### Clave privada\n> ```\n${private_key}\n> ```\n>\n>
\n\n>[!Ambas para copiar]-\n>\n>
\n>\n> ```\n${public_key}\n${private_key}\n> ```\n>\n>
"; - readonly fr: "Nous avons gÊnÊrÊ une paire de clÊs !\n\nNote : cette paire de clÊs ne sera plus jamais affichÊe. Veuillez la conserver dans un endroit sÃģr. Si vous la perdez, vous devrez gÊnÊrer une nouvelle paire.\nNote 2 : la clÊ publique est au format spki, et la clÊ privÊe au format pkcs8. Pour plus de commoditÊ, les retours à la ligne sont convertis en `\\n` dans la clÊ publique.\nNote 3 : la clÊ publique doit ÃĒtre configurÊe dans la base distante, et la clÊ privÊe sur les appareils locaux.\n\n>[!POUR VOS YEUX SEULEMENT]-\n>
\n>\n> ### ClÊ publique\n> ```\n${public_key}\n> ```\n>\n> ### ClÊ privÊe\n> ```\n${private_key}\n> ```\n>\n>
\n\n>[!Les deux pour copier]-\n>\n>
\n>\n> ```\n${public_key}\n${private_key}\n> ```\n>\n>
\n\n"; - readonly he: "י×Ļרנו זוג מפ×Ēחו×Ē!\n\nה×ĸרה: זוג מפ×Ēחו×Ē ×–×” לא יו×Ļג שוב. אנא שמור או×Ēו במקום בטוח. אם אבד לך, יהיה ×Ļורך ליי×Ļר זוג מפ×Ēחו×Ē ×—×“×Š.\nה×ĸרה 2: המפ×Ēח ה×Ļיבורי הוא בפורמט spki, והמפ×Ēח הפרטי הוא בפורמט pkcs8. לנוחו×Ē, שורו×Ē ×—×“×Š×•×Ē ×ž×ž×•×™×¨×•×Ē ×œ-`\\n` במפ×Ēח ה×Ļיבורי.\nה×ĸרה 3: יש להגדיר א×Ē ×”×ž×¤×Ēח ה×Ļיבורי במסד הנ×Ēונים המרוחד, וא×Ē ×”×ž×¤×Ēח הפרטי במכשירים המקומיים.\n\n>[!FOR YOUR EYES ONLY]-\n>
\n>\n> ### מפ×Ēח ×Ļיבורי\n> ```\n${public_key}\n> ```\n>\n> ### מפ×Ēח פרטי\n> ```\n${private_key}\n> ```\n>\n>
\n\n>[!Both for copying]-\n>\n>
\n>\n> ```\n${public_key}\n${private_key}\n> ```\n>\n>
\n\n"; - readonly ja: "キãƒŧペã‚ĸã‚’į”Ÿæˆã—ãžã—ãŸīŧ\n\næŗ¨æ„: こぎキãƒŧペã‚ĸは再åēĻ襨į¤ēされぞせん。厉全ãĒ場所ãĢäŋå­˜ã—ãĻãã ã•ã„ã€‚į´›å¤ąã—ãŸå ´åˆã¯ã€æ–°ã—ã„ã‚­ãƒŧペã‚ĸã‚’į”Ÿæˆã™ã‚‹åŋ…čĻãŒã‚ã‚Šãžã™ã€‚\næŗ¨æ„2: å…Ŧ開éĩはspkiåŊĸåŧã€į§˜å¯†éĩはpkcs8åŊĸåŧã§ã™ã€‚刊äžŋ性ぎため、å…Ŧ開éĩãŽæ”ščĄŒã¯`\\n`ãĢ変換されãĻいぞす。\næŗ¨æ„3: å…Ŧ開éĩはãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚ãĢã€į§˜å¯†éĩはロãƒŧã‚ĢãƒĢデバイ゚ãĢč¨­åŽšã—ãĻください。\n\n>[!FOR YOUR EYES ONLY]-\n>
\n>\n> ### å…Ŧ開éĩ\n> ```\n${public_key}\n> ```\n>\n> ### į§˜å¯†éĩ\n> ```\n${private_key}\n> ```\n>\n>
\n\n>[!Both for copying]-\n>\n>
\n>\n> ```\n${public_key}\n${private_key}\n> ```\n>\n>
\n\n"; - readonly ko: "키 íŽ˜ė–´ëĨŧ ėƒė„ąí–ˆėŠĩ니다!\n\n및溠: ė´ 키 íŽ˜ė–´ëŠ” ë‹¤ė‹œ í‘œė‹œë˜ė§€ ė•ŠėŠĩ니다. ė•ˆė „í•œ 溺뗐 ė €ėžĨ해 ėŖŧė„¸ėš”. ëļ„ė‹¤í•˜ëŠ´ 냈 키 íŽ˜ė–´ëĨŧ ėƒė„ąí•´ė•ŧ 합니다.\n및溠 2: ęŗĩ氜 키는 spki í˜•ė‹ė´ęŗ , ę°œė¸ 키는 pkcs8 í˜•ė‹ėž…ë‹ˆë‹¤. íŽ¸ė˜ėƒ ęŗĩ氜 í‚¤ė˜ 뤄 바ęŋˆė€ `\\n`ėœŧ로 ëŗ€í™˜ëŠë‹ˆë‹¤.\n및溠 3: ęŗĩ氜 키는 ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ė—ė„œ ęĩŦė„ąë˜ė–´ė•ŧ í•˜ęŗ , ę°œė¸ 키는 로ėģŦ ę¸°ę¸°ė—ė„œ ęĩŦė„ąë˜ė–´ė•ŧ 합니다.\n\n>[!FOR YOUR EYES ONLY]-\n>
\n>\n> ### ęŗĩ氜 키\n> ```\n${public_key}\n> ```\n>\n> ### ę°œė¸ 키\n> ```\n${private_key}\n> ```\n>\n>
\n\n>[!Both for copying]-\n>\n>
\n>\n> ```\n${public_key}\n${private_key}\n> ```\n>\n>
\n\n\n"; - readonly ru: "ĐœŅ‹ ŅĐŗĐĩĐŊĐĩŅ€Đ¸Ņ€ĐžĐ˛Đ°Đģи ĐŋĐ°Ņ€Ņƒ ĐēĐģŅŽŅ‡ĐĩĐš!"; - readonly zh: "我äģŦ厞įģį”Ÿæˆäē†ä¸€įģ„密é’Ĩ寚īŧ\n\næŗ¨æ„īŧščŋ™įģ„密é’Ĩ寚䚋后将不äŧšå†æŦĄæ˜žį¤ēã€‚č¯ˇåŠĄåŋ…åĻĨ善äŋįŽĄīŧ›åĻ‚æžœä¸ĸå¤ąīŧŒäŊ éœ€čĻé‡æ–°į”Ÿæˆæ–°įš„å¯†é’Ĩ寚。\næŗ¨æ„ 2īŧšå…Ŧé’Ĩ采ᔍ spki æ ŧåŧīŧŒį§é’Ĩ采ᔍ pkcs8 æ ŧåŧã€‚ä¸ēæ–šäžŋ复åˆļīŧŒå…Ŧé’Ĩä¸­įš„æĸ行äŧščĸĢčŊŦæĸä¸ē `\\n`。\næŗ¨æ„ 3īŧšå…Ŧé’Ĩåē”配įŊŽåœ¨čŋœįĢ¯æ•°æŽåē“中īŧŒį§é’Ĩåē”配įŊŽåœ¨æœŦåœ°čŽžå¤‡ä¸Šã€‚\n\n>[!äģ…限æœŦäē翟Ĩįœ‹]-\n>
\n>\n> ### å…Ŧé’Ĩ\n> ```\n${public_key}\n> ```\n>\n> ### ᧁé’Ĩ\n> ```\n${private_key}\n> ```\n>\n>
\n\n>[!整æŽĩ复åˆļ]-\n>\n>
\n>\n> ```\n${public_key}\n${private_key}\n> ```\n>\n>
"; - readonly "zh-tw": "æˆ‘å€‘åˇ˛į”ĸį”Ÿæ–°įš„é‡‘é‘°å°īŧ\n\næŗ¨æ„īŧšæ­¤é‡‘é‘°å°äš‹åžŒå°‡ä¸æœƒå†æŦĄéĄ¯į¤ē。č̋務åŋ…åĻĨ善äŋå­˜īŧ›č‹Ĩéēå¤ąīŧŒåŋ…須重新į”ĸį”Ÿæ–°įš„é‡‘é‘°å°ã€‚\næŗ¨æ„ 2īŧšå…Ŧé‘°æŽĄį”¨ spki æ ŧåŧīŧŒį§é‘°æŽĄį”¨ pkcs8 æ ŧåŧã€‚į‚ēä熿–šäžŋ複čŖŊīŧŒå…Ŧé‘°ä¸­įš„æ›čĄŒæœƒčŊ‰æ›į‚ē `\\n`。\næŗ¨æ„ 3īŧšå…Ŧé‘°æ‡‰č¨­åŽšåœ¨é įĢ¯čŗ‡æ–™åēĢ中īŧŒį§é‘°å‰‡æ‡‰č¨­åޚ圍æœŦæŠŸčŖįŊŽä¸Šã€‚\n\n>[!僅䞛æœŦäē翟Ĩįœ‹]-\n>
\n>\n> ### å…Ŧ鑰\n> ```\n${public_key}\n> ```\n>\n> ### ᧁ鑰\n> ```\n${private_key}\n> ```\n>\n>
\n\n>[!äžŋæ–ŧ整æŽĩ複čŖŊ]-\n>\n>
\n>\n> ```\n${public_key}\n${private_key}\n> ```\n>\n>
"; - }; - readonly "Setting.GenerateKeyPair.Title": { - readonly def: "New key pair has been generated!"; - readonly es: "ÂĄSe ha generado un nuevo par de claves!"; - readonly fr: "Une nouvelle paire de clÊs a ÊtÊ gÊnÊrÊe !"; - readonly he: "זוג מפ×Ēחו×Ē ×—×“×Š נו×Ļר!"; - readonly ja: "新しいキãƒŧペã‚ĸãŒį”Ÿæˆã•ã‚Œãžã—ãŸīŧ"; - readonly ko: "냈 키 íŽ˜ė–´ę°€ ėƒė„ąë˜ė—ˆėŠĩ니다!"; - readonly ru: "ĐĐžĐ˛Đ°Ņ ĐŋĐ°Ņ€Đ° ĐēĐģŅŽŅ‡ĐĩĐš ŅĐŗĐĩĐŊĐĩŅ€Đ¸Ņ€ĐžĐ˛Đ°ĐŊа!"; - readonly zh: "åˇ˛į”Ÿæˆæ–°įš„å¯†é’Ĩ寚īŧ"; - readonly "zh-tw": "厞į”ĸį”Ÿæ–°įš„é‡‘é‘°å°īŧ"; - }; - readonly "Setting.TroubleShooting": { - readonly def: "TroubleShooting"; - readonly fr: "DÊpannage"; - readonly he: "פ×Ēרון ב×ĸיו×Ē"; - readonly ja: "トナブãƒĢã‚ˇãƒĨãƒŧãƒ†ã‚Ŗãƒŗã‚°"; - readonly ko: "ëŦ¸ė œ 해결"; - readonly ru: "ĐŖŅŅ‚Ņ€Đ°ĐŊĐĩĐŊиĐĩ ĐŊĐĩĐŋĐžĐģадОĐē"; - readonly zh: "故障排除"; - }; - readonly "Setting.TroubleShooting.Doctor": { - readonly def: "Setting Doctor"; - readonly fr: "Docteur des paramètres"; - readonly he: "Doctor הגדרו×Ē"; - readonly ja: "č¨­åŽšč¨ē断ツãƒŧãƒĢ"; - readonly ko: "네렕 ė§„ë‹¨ 마법ė‚Ŧ"; - readonly ru: "Đ”Đ¸Đ°ĐŗĐŊĐžŅŅ‚Đ¸Đēа ĐŊĐ°ŅŅ‚Ņ€ĐžĐĩĐē"; - readonly zh: "莞įŊŽč¯Šæ–­"; - }; - readonly "Setting.TroubleShooting.Doctor.Desc": { - readonly def: "Detects non optimal settings. (Same as during migration)"; - readonly fr: "DÊtecte les paramètres non optimaux. (Identique à la migration)"; - readonly he: "מזהה הגדרו×Ē ×œ× אופטימליו×Ē. (זהה לפ×ĸולה במהלך הגירה)"; - readonly ja: "最遊でãĒã„č¨­åŽšã‚’æ¤œå‡ēしぞす。īŧˆãƒžã‚¤ã‚°ãƒŦãƒŧã‚ˇãƒ§ãƒŗæ™‚ã¨åŒã˜īŧ‰"; - readonly ko: "ėĩœė í™”ë˜ė§€ ė•Šė€ ė„¤ė •ė„ ę°ė§€í•Šë‹ˆë‹¤. (ë°ė´í„° ęĩŦėĄ° ė „í™˜ ė‹œė™€ 동ėŧ)"; - readonly ru: "ОбĐŊĐ°Ņ€ŅƒĐļиваĐĩŅ‚ ĐŊĐĩĐžĐŋŅ‚Đ¸ĐŧаĐģҌĐŊŅ‹Đĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи."; - readonly zh: "æŖ€æĩ‹įŗģįģŸä¸­ä¸åˆį†įš„莞įŊŽã€‚īŧˆä¸Žčŋį§ģ期间é€ģčž‘į›¸åŒīŧ‰"; - }; - readonly "Setting.TroubleShooting.ScanBrokenFiles": { - readonly def: "Scan for broken files"; - readonly fr: "Analyser les fichiers corrompus"; - readonly he: "סרוק קב×Ļים פגומים"; - readonly ja: "į ´æãƒ•ã‚Ąã‚¤ãƒĢãŽã‚šã‚­ãƒŖãƒŗ"; - readonly ko: "ė†ėƒëœ 파ėŧ 검ė‚Ŧ"; - readonly ru: "ĐĄĐēаĐŊĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ ĐŋĐžĐ˛Ņ€ĐĩĐļĐ´Ņ‘ĐŊĐŊŅ‹Đĩ Ņ„Đ°ĐšĐģŅ‹"; - readonly zh: "æ‰Ģ描损坏或åŧ‚å¸¸įš„æ–‡äģļ"; - }; - readonly "Setting.TroubleShooting.ScanBrokenFiles.Desc": { - readonly def: "Scans for files that are not stored correctly in the database."; - readonly fr: "Analyse les fichiers qui ne sont pas stockÊs correctement dans la base."; - readonly he: "סורק קב×Ļים שלא נשמרו כהלכה במסד הנ×Ēונים."; - readonly ja: "デãƒŧã‚ŋベãƒŧ゚ãĢæ­Ŗã—ãäŋå­˜ã•れãĻいãĒã„ãƒ•ã‚Ąã‚¤ãƒĢã‚’ã‚šã‚­ãƒŖãƒŗã—ãžã™ã€‚"; - readonly ko: "ë°ė´í„°ë˛ ė´ėŠ¤ė— ė˜Ŧ바ëĨ´ę˛Œ ė €ėžĨë˜ė§€ ė•Šė€ 파ėŧė„ 검ė‚Ŧ합니다."; - readonly ru: "ĐĄĐēаĐŊĐ¸Ņ€ŅƒĐĩŅ‚ Ņ„Đ°ĐšĐģŅ‹, ĐēĐžŅ‚ĐžŅ€Ņ‹Đĩ ĐŊĐĩĐŋŅ€Đ°Đ˛Đ¸ĐģҌĐŊĐž Ņ…Ņ€Đ°ĐŊŅŅ‚ŅŅ в йаСĐĩ даĐŊĐŊҋ҅."; - readonly zh: "æ‰Ģ描数捎åē“中æœĒæ­ŖįĄŽå­˜å‚¨įš„æ–‡äģļ。"; - }; - readonly "SettingTab.Message.AskRebuild": { - readonly def: "Your changes require fetching from the remote database. Do you want to proceed?"; - readonly fr: "Vos modifications nÊcessitent une rÊcupÊration depuis la base distante. Voulez-vous continuer ?"; - readonly he: "השינויים שלך מ×Ļריכים משיכה ממסד הנ×Ēונים המרוחד. האם להמשיך?"; - readonly ja: "変更ãĢはãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚からぎフェッチがåŋ…čĻã§ã™ã€‚įļščĄŒã—ぞすかīŧŸ"; - readonly ko: "ëŗ€ę˛Ŋ ė‚Ŧí•­ė„ ė ėšŠí•˜ë ¤ëŠ´ ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ė—ė„œ 氀렏뙀ė•ŧ 합니다. ęŗ„ė† ė§„í–‰í•˜ė‹œę˛ ėŠĩ니까?"; - readonly ru: "Đ’Đ°ŅˆĐ¸ иСĐŧĐĩĐŊĐĩĐŊĐ¸Ņ ҂ҀĐĩĐąŅƒŅŽŅ‚ ĐˇĐ°ĐŗŅ€ŅƒĐˇĐēи иС ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊОК ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅. ĐĨĐžŅ‚Đ¸Ņ‚Đĩ ĐŋŅ€ĐžĐ´ĐžĐģĐļĐ¸Ņ‚ŅŒ?"; - readonly zh: "Your changes require fetching from the remote database. Do you want to proceed?"; - }; - readonly "Setup URI dialog cancelled.": { - readonly def: "Setup URI dialog cancelled."; - readonly ja: "Setup URI ダイã‚ĸãƒ­ã‚°ã¯ã‚­ãƒŖãƒŗã‚ģãƒĢされぞした。"; - readonly ko: "Setup URI 대화 ėƒėžę°€ ėˇ¨ė†Œë˜ė—ˆėŠĩ니다."; - readonly ru: "ДиаĐģĐžĐŗ Setup URI ĐąŅ‹Đģ ĐžŅ‚ĐŧĐĩĐŊŅ‘ĐŊ."; - readonly zh: "Setup URI å¯šč¯æĄ†åˇ˛å–æļˆã€‚"; - readonly "zh-tw": "Setup URI å°čŠąæĄ†åˇ˛å–æļˆã€‚"; - }; - readonly "Setup.Apply.Buttons.ApplyAndFetch": { - readonly def: "Apply and Fetch"; - readonly fr: "Appliquer et rÊcupÊrer"; - readonly he: "החל ומשוך"; - readonly ja: "éŠį”¨ã—ãĻフェッチ"; - readonly ru: "ĐŸŅ€Đ¸ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ и ĐˇĐ°ĐŗŅ€ŅƒĐˇĐ¸Ņ‚ŅŒ"; - readonly zh: "Apply and Fetch"; - }; - readonly "Setup.Apply.Buttons.ApplyAndMerge": { - readonly def: "Apply and Merge"; - readonly fr: "Appliquer et fusionner"; - readonly he: "החל ומזג"; - readonly ja: "éŠį”¨ã—ãĻマãƒŧジ"; - readonly ru: "ĐŸŅ€Đ¸ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ и ĐžĐąŅŠĐĩдиĐŊĐ¸Ņ‚ŅŒ"; - readonly zh: "Apply and Merge"; - }; - readonly "Setup.Apply.Buttons.ApplyAndRebuild": { - readonly def: "Apply and Rebuild"; - readonly fr: "Appliquer et reconstruire"; - readonly he: "החל ובנה מחדש"; - readonly ja: "éŠį”¨ã—ãĻå†æ§‹į¯‰"; - readonly ru: "ĐŸŅ€Đ¸ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ и ĐŋĐĩŅ€ĐĩŅŅ‚Ņ€ĐžĐ¸Ņ‚ŅŒ"; - readonly zh: "Apply and Rebuild"; - }; - readonly "Setup.Apply.Buttons.Cancel": { - readonly def: "Discard and Cancel"; - readonly fr: "Abandonner et annuler"; - readonly he: "בטל ובטל"; - readonly ja: "į ´æŖ„ã—ãĻã‚­ãƒŖãƒŗã‚ģãƒĢ"; - readonly ru: "ĐžŅ‚ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ и ĐžŅ‚ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ"; - readonly zh: "Discard and Cancel"; - }; - readonly "Setup.Apply.Buttons.OnlyApply": { - readonly def: "Only Apply"; - readonly fr: "Appliquer seulement"; - readonly he: "החל בלבד"; - readonly ja: "éŠį”¨ãŽãŋ"; - readonly ru: "ĐĸĐžĐģҌĐēĐž ĐŋŅ€Đ¸ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ"; - readonly zh: "Only Apply"; - }; - readonly "Setup.Apply.Message": { - readonly def: "The new configuration is ready. Let us proceed to apply it.\nThere are several ways to apply this:\n\n- Apply and Fetch\n Configure this device as a new client. After applying, synchronise from the remote server.\n- Apply and Merge\n Configure on a device that already has the file. It processes the local files and transfers the differences. Conflicts may arise.\n- Apply and Rebuild\n Rebuild the remote using local files. This is typically done if the server becomes corrupted or we wish to start from scratch.\n Other devices will be locked and required to re-fetch.\n- Only Apply\n Apply only. Conflicts may arise if a rebuild is required."; - readonly fr: "La nouvelle configuration est prÃĒte. ProcÊdons à son application.\nPlusieurs manières de l'appliquer :\n\n- Appliquer et rÊcupÊrer\n Configurer cet appareil comme nouveau client. Après application, synchroniser depuis le serveur distant.\n- Appliquer et fusionner\n Configurer sur un appareil qui possède dÊjà les fichiers. Traite les fichiers locaux et transfère les diffÊrences. Des conflits peuvent apparaÃŽtre.\n- Appliquer et reconstruire\n Reconstruire le distant à partir des fichiers locaux. Typiquement effectuÊ si le serveur est corrompu ou si l'on souhaite repartir de zÊro.\n Les autres appareils seront verrouillÊs et devront refaire une rÊcupÊration.\n- Appliquer seulement\n Appliquer uniquement. Des conflits peuvent apparaÃŽtre si une reconstruction est nÊcessaire."; - readonly he: "ה×Ē×Ļורה החדשה מוכנה. בואו נמשיך להחיל או×Ēה.\nישנן מספר דרכים להחיל זא×Ē:\n\n- החל ומשוך\n הגדר מכשיר זה כלקוח חדש. לאחר ההחלה, סנכרן מהשר×Ē ×”×ž×¨×•×—×“.\n- החל ומזג\n הגדר ×ĸל מכשיר שכבר יש בו קב×Ļים. מ×ĸבד קב×Ļים מקומיים ומ×ĸביר הפרשים. ×ĸלולים\n לקום קונפליקטים.\n- החל ובנה מחדש\n בנה א×Ē ×”×Š×¨×Ē ×”×ž×¨×•×—×“ מחדש ×Ēוך שימוש בקב×Ļים מקומיים. × ×ĸשה בדרך כלל אם השר×Ē\n מושח×Ē ××• אם רו×Ļים לה×Ēחיל מאפס. מכשירים אחרים יינ×ĸלו וי×Ļטרכו למשוך מחדש.\n- החל בלבד\n החל בלבד. ×ĸלולים לקום קונפליקטים אם נדרש×Ē ×‘× ×™×™×” מחדש."; - readonly ja: "æ–°ã—ã„č¨­åŽšãŽæē–å‚™ãŒã§ããžã—ãŸã€‚éŠį”¨ãĢ進ãŋぞしょう。\néŠį”¨æ–šæŗ•ã¯ã„ãã¤ã‹ã‚ã‚Šãžã™īŧš\n\n- éŠį”¨ã—ãĻフェッチ\n こぎデバイ゚を新しいクナイã‚ĸãƒŗãƒˆã¨ã—ãĻč¨­åŽšã—ãžã™ã€‚éŠį”¨åžŒã€ãƒĒãƒĸãƒŧトã‚ĩãƒŧバãƒŧから同期しぞす。\n- éŠį”¨ã—ãĻマãƒŧジ\n æ—ĸãĢãƒ•ã‚Ąã‚¤ãƒĢãŒã‚ã‚‹ãƒ‡ãƒã‚¤ã‚šã§č¨­åŽšã—ãžã™ã€‚ãƒ­ãƒŧã‚ĢãƒĢãƒ•ã‚Ąã‚¤ãƒĢをå‡Ļį†ã—ã€åˇŽåˆ†ã‚’čģĸ送しぞす。įĢļ合がį™ēį”Ÿã™ã‚‹å ´åˆãŒã‚ã‚Šãžã™ã€‚\n- éŠį”¨ã—ãĻå†æ§‹į¯‰\n ロãƒŧã‚ĢãƒĢãƒ•ã‚Ąã‚¤ãƒĢをäŊŋį”¨ã—ãĻãƒĒãƒĸãƒŧãƒˆã‚’å†æ§‹į¯‰ã—ãžã™ã€‚ã“ã‚Œã¯é€šå¸¸ã€ã‚ĩãƒŧバãƒŧãŒį ´æã—ãŸå ´åˆã‚„æœ€åˆã‹ã‚‰ã‚„ã‚Šį›´ã—ãŸã„å ´åˆãĢčĄŒã„ãžã™ã€‚\n äģ–ぎデバイ゚はロックされ、再フェッチがåŋ…čρãĢãĒりぞす。\n- éŠį”¨ãŽãŋ\n éŠį”¨ãŽãŋã‚’čĄŒã„ãžã™ã€‚å†æ§‹į¯‰ãŒåŋ…čρãĒ場合、įĢļ合がį™ēį”Ÿã™ã‚‹å¯čƒŊ性がありぞす。"; - readonly ru: "ĐĐžĐ˛Đ°Ņ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Ņ ĐŗĐžŅ‚ĐžĐ˛Đ°. Đ•ŅŅ‚ŅŒ ĐŊĐĩҁĐēĐžĐģҌĐēĐž ҁĐŋĐžŅĐžĐąĐžĐ˛ ĐŋŅ€Đ¸ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ ĐĩŅ‘."; - readonly zh: "The new configuration is ready. Let us proceed to apply it.\nThere are several ways to apply this:\n\n- Apply and Fetch\n Configure this device as a new client. After applying, synchronise from the remote server.\n- Apply and Merge\n Configure on a device that already has the file. It processes the local files and transfers the differences. Conflicts may arise.\n- Apply and Rebuild\n Rebuild the remote using local files. This is typically done if the server becomes corrupted or we wish to start from scratch.\n Other devices will be locked and required to re-fetch.\n- Only Apply\n Apply only. Conflicts may arise if a rebuild is required."; - }; - readonly "Setup.Apply.Title": { - readonly def: "Apply new configuration from the ${method}"; - readonly fr: "Appliquer la nouvelle configuration depuis ${method}"; - readonly he: "החל ×Ē×Ļורה חדשה מה-${method}"; - readonly ja: "${method}ã‹ã‚‰ãŽæ–°ã—ã„č¨­åŽšã‚’éŠį”¨"; - readonly ru: "ĐŸŅ€Đ¸ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ ĐŊĐžĐ˛ŅƒŅŽ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸ŅŽ иС method"; - readonly zh: "Apply new configuration from the ${method}"; - }; - readonly "Setup.Apply.WarningRebuildRecommended": { - readonly def: "NOTE: after adjusting the settings, it has been determined that a rebuild is required; Just Import is not recommended."; - readonly fr: "NOTE : après ajustement des paramètres, il a ÊtÊ dÊterminÊ qu'une reconstruction est requise ; un simple import n'est pas recommandÊ."; - readonly he: "שים לב: לאחר כוונון ההגדרו×Ē, נקב×ĸ שנדרש×Ē ×‘× ×™×™×” מחדש; ייבוא בלבד אינו מומל×Ĩ."; - readonly ja: "æŗ¨æ„: č¨­åŽšãŽčĒŋæ•´åžŒã€å†æ§‹į¯‰ãŒåŋ…čĻã¨åˆ¤æ–­ã•ã‚Œãžã—ãŸã€‚ã‚¤ãƒŗãƒãƒŧトぎãŋは推åĨ¨ã•れぞせん。"; - readonly ru: "ПРИМЕЧАНИЕ: ĐŋĐžŅĐģĐĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи иСĐŧĐĩĐŊĐĩĐŊиК ĐžĐŋŅ€ĐĩĐ´ĐĩĐģĐĩĐŊĐž, Ņ‡Ņ‚Đž ҂ҀĐĩĐąŅƒĐĩŅ‚ŅŅ ĐŋĐĩŅ€ĐĩŅŅ‚Ņ€ĐžĐĩĐŊиĐĩ."; - readonly zh: "NOTE: after adjusting the settings, it has been determined that a rebuild is required; Just Import is not recommended."; - }; - readonly "Setup.Doctor.Buttons.No": { - readonly def: "No, please use the settings in the URI as is"; - readonly fr: "Non, utiliser les paramètres de l'URI tels quels"; - readonly he: "לא, אנא הש×Ēמ׊ בהגדרו×Ē ×”-URI כפי שהן"; - readonly ja: "いいえ、URIãŽč¨­åŽšã‚’ããŽãžãžäŊŋᔍ"; - readonly ru: "НĐĩŅ‚, Đ¸ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ŅŒ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи иС URI ĐēаĐē ĐĩŅŅ‚ŅŒ"; - readonly zh: "No, please use the settings in the URI as is"; - }; - readonly "Setup.Doctor.Buttons.Yes": { - readonly def: "Yes, please consult the doctor"; - readonly fr: "Oui, consulter le docteur"; - readonly he: "כן, אנא י×ĸ×Ĩ ל-Doctor"; - readonly ja: "はい、č¨ē断ツãƒŧãƒĢãĢᛏčĢ‡ã™ã‚‹"; - readonly ru: "Да, ĐŋĐžĐļаĐģŅƒĐšŅŅ‚Đ°, СаĐŋŅƒŅŅ‚Đ¸Ņ‚ŅŒ Đ´Đ¸Đ°ĐŗĐŊĐžŅŅ‚Đ¸Đē҃"; - readonly zh: "Yes, please consult the doctor"; - }; - readonly "Setup.Doctor.Message": { - readonly def: "Self-hosted LiveSync has gradually become longer in history and some recommended settings have changed.\n\nNow, setup is a very good time to do this.\n\nDo you want to run Doctor to check if the imported settings are optimal compared to the latest state?"; - readonly fr: "Self-hosted LiveSync s'est progressivement ÊtoffÊ et certains paramètres recommandÊs ont ÊvoluÊ.\n\nLa configuration est un bon moment pour le faire.\n\nVoulez-vous lancer le Docteur pour vÊrifier si les paramètres importÊs sont optimaux par rapport au dernier Êtat ?"; - readonly he: "Self-hosted LiveSync הפך ארוך יו×Ēר בהיסטוריה שלו וחלק מההגדרו×Ē ×”×ž×•×ž×œ×Ļו×Ē ×”×Š×Ēנו.\n\n×ĸכשיו, הגדרה היא זמן מ×Ļוין לכך.\n\nהאם בר×Ļונך להפ×ĸיל א×Ē Doctor כדי לבדוק אם ההגדרו×Ē ×”×ž×™×•×‘××•×Ē ××•×¤×˜×™×ž×œ×™×•×Ē ×‘×”×Š×•×•××” למ×Ļב הנוכחי?"; - readonly ja: "Self-hosted LiveSyncは垐々ãĢæ­´å˛ãŒé•ˇããĒり、一部ぎ推åĨ¨č¨­åŽšãŒå¤‰æ›´ã•ã‚ŒãĻいぞす。\n\nã‚ģットã‚ĸãƒƒãƒ—ã¯ã€ã“ã‚Œã‚’čĄŒã†éžå¸¸ãĢč‰¯ã„æŠŸäŧšã§ã™ã€‚\n\nã‚¤ãƒŗãƒãƒŧãƒˆã•ã‚ŒãŸč¨­åŽšãŒæœ€æ–°ãŽįŠļ態と比čŧƒã—ãĻ最遊かおうかをįĸēčĒã™ã‚‹ãŸã‚ãĢ、č¨ē断ツãƒŧãƒĢã‚’åŽŸčĄŒã—ãžã™ã‹īŧŸ"; - readonly ru: "Self-hosted LiveSync ĐŋĐžŅŅ‚ĐĩĐŋĐĩĐŊĐŊĐž ĐŊĐ°ĐąŅ€Đ°Đģ Đ¸ŅŅ‚ĐžŅ€Đ¸ŅŽ и ĐŊĐĩĐēĐžŅ‚ĐžŅ€Ņ‹Đĩ Ņ€ĐĩĐēĐžĐŧĐĩĐŊĐ´ŅƒĐĩĐŧŅ‹Đĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи иСĐŧĐĩĐŊиĐģĐ¸ŅŅŒ."; - readonly zh: "Self-hosted LiveSync has gradually become longer in history and some recommended settings have changed.\n\nNow, setup is a very good time to do this.\n\nDo you want to run Doctor to check if the imported settings are optimal compared to the latest state?"; - }; - readonly "Setup.Doctor.Title": { - readonly def: "Do you want to consult the doctor?"; - readonly fr: "Voulez-vous consulter le docteur ?"; - readonly he: "האם בר×Ļונך לה×Ēיי×ĸ×Ĩ ×ĸם ה-Doctor?"; - readonly ja: "č¨ē断ツãƒŧãƒĢãĢᛏčĢ‡ã—ãžã™ã‹īŧŸ"; - readonly ru: "ĐĨĐžŅ‚Đ¸Ņ‚Đĩ СаĐŋŅƒŅŅ‚Đ¸Ņ‚ŅŒ Đ´Đ¸Đ°ĐŗĐŊĐžŅŅ‚Đ¸Đē҃?"; - readonly zh: "Do you want to consult the doctor?"; - }; - readonly "Setup.FetchRemoteConf.Buttons.Fetch": { - readonly def: "Yes, please fetch the configuration"; - readonly fr: "Oui, rÊcupÊrer la configuration"; - readonly he: "כן, אנא משוך א×Ē ×”×Ē×Ļורה"; - readonly ja: "ã¯ã„ã€č¨­åŽšã‚’å–åž—"; - readonly ru: "Да, ĐˇĐ°ĐŗŅ€ŅƒĐˇĐ¸Ņ‚ŅŒ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸ŅŽ"; - readonly zh: "Yes, please fetch the configuration"; - }; - readonly "Setup.FetchRemoteConf.Buttons.Skip": { - readonly def: "No, please use the settings in the URI"; - readonly fr: "Non, utiliser les paramètres de l'URI"; - readonly he: "לא, אנא הש×Ēמ׊ בהגדרו×Ē ×‘-URI"; - readonly ja: "いいえ、URIãŽč¨­åŽšã‚’äŊŋᔍ"; - readonly ru: "НĐĩŅ‚, Đ¸ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ŅŒ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи иС URI"; - readonly zh: "No, please use the settings in the URI"; - }; - readonly "Setup.FetchRemoteConf.Message": { - readonly def: "If we have already synchronised once with another device, the remote database stores the suitable configuration values between the synchronised devices. The plug-in would like to retrieve them for robust configuration.\n\nHowever, we have to make sure the one thing. Are we currently in a situation where we can access the network safely and retrieve the settings?\n\nNote: Mostly, you are safe to do this, that your remote database is hosted with a SSL certificate, and your network is not compromised."; - readonly fr: "Si nous avons dÊjà synchronisÊ une fois avec un autre appareil, la base distante stocke les valeurs de configuration adaptÊes entre les appareils synchronisÊs. Le plug-in souhaiterait les rÊcupÊrer pour une configuration robuste.\n\nMais il faut s'assurer d'une chose. Sommes-nous actuellement dans une situation oÚ nous pouvons accÊder au rÊseau en toute sÊcuritÊ et rÊcupÊrer les paramètres ?\n\nNote : le plus souvent, c'est sÃģr si votre base distante est hÊbergÊe avec un certificat SSL et si votre rÊseau n'est pas compromis."; - readonly he: "אם סנכרנו כבר פ×ĸם ×ĸם מכשיר אחר, מסד הנ×Ēונים המרוחד מאחסן ×ĸרכי ×Ē×Ļורה מ×Ēאימים בין המכשירים המסונכרנים. הפלאגין יר×Ļה לאחזר או×Ēם ל×Ē×Ļורה חזקה יו×Ēר.\n\n×ĸם זא×Ē, ×ĸלינו לוודא דבר אחד. האם אנחנו כרג×ĸ במ×Ļב שבו ני×Ēן לגש×Ē ×œ×¨×Š×Ē ×‘×‘×˜×—×” ולאחזר א×Ē ×”×”×’×“×¨×•×Ē?\nה×ĸרה: ברוב המקרים, א×Ēה בטוח ל×ĸשו×Ē ×–××Ē, כל ×ĸוד מסד הנ×Ēונים המרוחד שלך מאוחסן ×ĸם ×Ē×ĸוד×Ē SSL, ורש×Ēך אינה פגומה."; - readonly ja: "æ—ĸãĢäģ–ぎデバイ゚と同期したことがある場合、ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚ãĢは同期されたデバイ゚間ぎ遊切ãĒč¨­åŽšå€¤ãŒäŋå­˜ã•れãĻã„ãžã™ã€‚ãƒ—ãƒŠã‚°ã‚¤ãƒŗã¯å …į‰ĸãĒč¨­åŽšãŽãŸã‚ãĢãã‚Œã‚‰ã‚’å–åž—ã—ãŸã„ã¨č€ƒãˆãĻいぞす。\n\nただし、1つįĸēčĒãŒåŋ…čĻã§ã™ã€‚įžåœ¨ã€ãƒãƒƒãƒˆãƒ¯ãƒŧクãĢ厉全ãĢã‚ĸクã‚ģ゚しãĻč¨­åŽšã‚’å–åž—ã§ãã‚‹įŠļæŗã§ã™ã‹īŧŸ\n\næŗ¨æ„: ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚がSSLč¨ŧ明書でポトされãĻおり、ネットワãƒŧクがäžĩåŽŗã•ã‚ŒãĻいãĒければ、ãģとんおぎ場合厉全ãĢåŽŸčĄŒã§ããžã™ã€‚"; - readonly ru: "Đ•ŅĐģи ĐŧŅ‹ ҃ĐļĐĩ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ¸Ņ€ĐžĐ˛Đ°ĐģĐ¸ŅŅŒ ҁ Đ´Ņ€ŅƒĐŗĐ¸Đŧ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛ĐžĐŧ, ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐ°Ņ йаСа даĐŊĐŊҋ҅ Ņ…Ņ€Đ°ĐŊĐ¸Ņ‚ ĐŋĐžĐ´Ņ…ĐžĐ´ŅŅ‰Đ¸Đĩ СĐŊĐ°Ņ‡ĐĩĐŊĐ¸Ņ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Đ¸."; - readonly zh: "If we have already synchronised once with another device, the remote database stores the suitable configuration values between the synchronised devices. The plug-in would like to retrieve them for robust configuration.\n\nHowever, we have to make sure the one thing. Are we currently in a situation where we can access the network safely and retrieve the settings?\n\nNote: Mostly, you are safe to do this, that your remote database is hosted with a SSL certificate, and your network is not compromised."; - }; - readonly "Setup.FetchRemoteConf.Title": { - readonly def: "Fetch configuration from remote database?"; - readonly fr: "RÊcupÊrer la configuration depuis la base distante ?"; - readonly he: "אחזר ×Ē×Ļורה ממסד הנ×Ēונים המרוחד?"; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧã‚šã‹ã‚‰č¨­åŽšã‚’å–åž—ã—ãžã™ã‹īŧŸ"; - readonly ru: "Đ—Đ°ĐŗŅ€ŅƒĐˇĐ¸Ņ‚ŅŒ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸ŅŽ ҁ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊОК ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅?"; - readonly zh: "Fetch configuration from remote database?"; - }; - readonly "Setup.QRCode": { - readonly def: "We have generated a QR code to transfer the settings. Please scan the QR code with your phone or other device.\nNote: The QR code is not encrypted, so be careful to open this.\n\n>[!FOR YOUR EYES ONLY]-\n>
${qr_image}
"; - readonly fr: "Nous avons gÊnÊrÊ un QR code pour transfÊrer les paramètres. Scannez-le avec votre tÊlÊphone ou un autre appareil.\nNote : le QR code n'est pas chiffrÊ, soyez prudent en l'affichant.\n\n>[!POUR VOS YEUX SEULEMENT]-\n>
${qr_image}
"; - readonly he: "י×Ļרנו קוד QR לה×ĸבר×Ē ×”×”×’×“×¨×•×Ē. אנא סרוק א×Ē ×§×•×“ ה-QR ×ĸם הטלפון או מכשיר אחר.\nה×ĸרה: קוד ה-QR אינו מו×Ļפן, אז היה זהיר בפ×Ēיח×Ēו.\n\n>[!FOR YOUR EYES ONLY]-\n>
${qr_image}
"; - readonly ja: "č¨­åŽšã‚’čģĸ送するためぎQRã‚ŗãƒŧãƒ‰ã‚’į”Ÿæˆã—ãžã—ãŸã€‚ã‚šãƒžãƒŧãƒˆãƒ•ã‚Šãƒŗã‚„äģ–ぎデバイ゚でQRã‚ŗãƒŧãƒ‰ã‚’ã‚šã‚­ãƒŖãƒŗã—ãĻください。\næŗ¨æ„: QRã‚ŗãƒŧãƒ‰ã¯æš—åˇåŒ–ã•ã‚ŒãĻいãĒã„ãŸã‚ã€é–‹ãéš›ã¯æŗ¨æ„ã—ãĻください。\n\n>[!FOR YOUR EYES ONLY]-\n>
${qr_image}
"; - readonly ko: "ė„¤ė •ė„ ė „ė†Ąí•˜ę¸° ėœ„í•œ QR ėŊ”드ëĨŧ ėƒė„ąí–ˆėŠĩ니다. íœ´ëŒ€í°ė´ë‚˜ 다ëĨ¸ 기기로 QR ėŊ”드ëĨŧ 늤ėē”í•´ ėŖŧė„¸ėš”.\n및溠: QR ėŊ”드는 ė•”í˜¸í™”ë˜ė§€ ė•Šė•˜ėœŧë¯€ëĄœ ė—´ 때 ėŖŧė˜í•˜ė„¸ėš”.\n\n>[!FOR YOUR EYES ONLY]-\n>
${qr_image}
"; - readonly ru: "ĐœŅ‹ ŅĐŗĐĩĐŊĐĩŅ€Đ¸Ņ€ĐžĐ˛Đ°Đģи QR-ĐēОд Đ´ĐģŅ ĐŋĐĩŅ€ĐĩĐ´Đ°Ņ‡Đ¸ ĐŊĐ°ŅŅ‚Ņ€ĐžĐĩĐē. ĐžŅ‚ŅĐēаĐŊĐ¸Ņ€ŅƒĐšŅ‚Đĩ QR-ĐēОд Ņ‚ĐĩĐģĐĩŅ„ĐžĐŊĐžĐŧ."; - readonly zh: "We have generated a QR code to transfer the settings. Please scan the QR code with your phone or other device.\nNote: The QR code is not encrypted, so be careful to open this.\n\n>[!FOR YOUR EYES ONLY]-\n>
${qr_image}
"; - }; - readonly "Setup.RemoteE2EE.AdvancedTitle": { - readonly def: "Advanced"; - readonly es: "Avanzado"; - readonly ja: "čŠŗį´°č¨­åŽš"; - readonly ko: "溠揉"; - readonly ru: "ДоĐŋĐžĐģĐŊĐ¸Ņ‚ĐĩĐģҌĐŊĐž"; - readonly zh: "é̘įē§"; - readonly "zh-tw": "進階"; - }; - readonly "Setup.RemoteE2EE.AlgorithmWarning": { - readonly def: "Changing the encryption algorithm will prevent access to any data previously encrypted with a different algorithm. Ensure that all your devices are configured to use the same algorithm to maintain access to your data."; - readonly es: "Cambiar el algoritmo de cifrado impedirÃĄ el acceso a cualquier dato cifrado anteriormente con otro algoritmo. AsegÃērate de que todos tus dispositivos estÊn configurados para usar el mismo algoritmo y así mantener el acceso a tus datos."; - readonly ja: "æš—åˇåŒ–ã‚ĸãƒĢゴãƒĒã‚ēムを変更すると、åˆĨぎã‚ĸãƒĢゴãƒĒã‚ēãƒ ã§æš—åˇåŒ–ã•ã‚ŒãŸæ—ĸ存デãƒŧã‚ŋãĢはã‚ĸクã‚ģ゚できãĒくãĒりぞす。すずãĻぎį̝æœĢで同じã‚ĸãƒĢゴãƒĒã‚ēムをäŊŋã†ã‚ˆã†č¨­åŽšã—ã€ãƒ‡ãƒŧã‚ŋãĢã‚ĸクã‚ģ゚できるįŠļ態をįļ­æŒã—ãĻください。"; - readonly ko: "ė•”í˜¸í™” ė•Œęŗ ëĻŦėĻ˜ė„ ëŗ€ę˛Ŋ하면 다ëĨ¸ ė•Œęŗ ëĻŦėϘėœŧ로 ė•”í˜¸í™”ëœ ę¸°ėĄ´ ë°ė´í„°ė— ė ‘ęˇŧ할 눘 ė—†ę˛Œ 됩니다. ëĒ¨ë“  ę¸°ę¸°ė—ė„œ 동ėŧ한 ė•Œęŗ ëĻŦėĻ˜ė„ ė‚ŦėšŠí•˜ë„ëĄ ė„¤ė •í•´ ë°ė´í„° ė ‘ęˇŧė„ąė„ ėœ ė§€í•˜ė„¸ėš”."; - readonly ru: "ИСĐŧĐĩĐŊĐĩĐŊиĐĩ аĐģĐŗĐžŅ€Đ¸Ņ‚Đŧа ŅˆĐ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊĐ¸Ņ ĐģĐ¸ŅˆĐ¸Ņ‚ Đ´ĐžŅŅ‚ŅƒĐŋа Đē даĐŊĐŊŅ‹Đŧ, ĐēĐžŅ‚ĐžŅ€Ņ‹Đĩ Ņ€Đ°ĐŊĐĩĐĩ ĐąŅ‹Đģи ĐˇĐ°ŅˆĐ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊŅ‹ Đ´Ņ€ŅƒĐŗĐ¸Đŧ аĐģĐŗĐžŅ€Đ¸Ņ‚ĐŧĐžĐŧ. ĐŖĐąĐĩĐ´Đ¸Ņ‚ĐĩҁҌ, Ņ‡Ņ‚Đž Đ˛ŅĐĩ Đ˛Đ°ŅˆĐ¸ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ° ĐŊĐ°ŅŅ‚Ņ€ĐžĐĩĐŊŅ‹ ĐŊа Đ¸ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°ĐŊиĐĩ ОдĐŊĐžĐŗĐž и Ņ‚ĐžĐŗĐž ĐļĐĩ аĐģĐŗĐžŅ€Đ¸Ņ‚Đŧа, Ņ‡Ņ‚ĐžĐąŅ‹ ŅĐžŅ…Ņ€Đ°ĐŊĐ¸Ņ‚ŅŒ Đ´ĐžŅŅ‚ŅƒĐŋ Đē даĐŊĐŊŅ‹Đŧ."; - readonly zh: "æ›´æ”šåŠ å¯†įŽ—æŗ•äŧšå¯ŧč‡´äš‹å‰äŊŋᔍå…ļäģ–įŽ—æŗ•åŠ å¯†įš„æ•°æŽæ— æŗ•čŽŋé—Žã€‚č¯ˇįĄŽäŋæ‰€æœ‰čŽžå¤‡éƒŊ配įŊŽä¸ēäŊŋį”¨åŒä¸€įŽ—æŗ•īŧŒäģĨäŋæŒå¯šæ•°æŽįš„čŽŋ问čƒŊ力。"; - readonly "zh-tw": "čŽŠæ›´åŠ å¯†æŧ”įŽ—æŗ•åžŒīŧŒå…ˆå‰äģĨå…ļäģ–æŧ”įŽ—æŗ•åŠ å¯†įš„čŗ‡æ–™å°‡į„Ąæŗ•å†å­˜å–ã€‚čĢ‹įĸēčĒæ‰€æœ‰čŖįŊŽéƒŊč¨­åŽšį‚ēäŊŋį”¨į›¸åŒæŧ”įŽ—æŗ•īŧŒäģĨįļ­æŒå°čŗ‡æ–™įš„存取čƒŊ力。"; - }; - readonly "Setup.RemoteE2EE.ButtonCancel": { - readonly def: "Cancel"; - readonly es: "Cancelar"; - readonly ja: "ã‚­ãƒŖãƒŗã‚ģãƒĢ"; - readonly ko: "ėˇ¨ė†Œ"; - readonly ru: "ĐžŅ‚ĐŧĐĩĐŊа"; - readonly zh: "取æļˆ"; - readonly "zh-tw": "取æļˆ"; - }; - readonly "Setup.RemoteE2EE.ButtonProceed": { - readonly def: "Proceed"; - readonly es: "Continuar"; - readonly ja: "é€˛ã‚€"; - readonly ko: "ė§„í–‰"; - readonly ru: "ĐŸŅ€ĐžĐ´ĐžĐģĐļĐ¸Ņ‚ŅŒ"; - readonly zh: "įģ§įģ­"; - readonly "zh-tw": "įšŧįēŒ"; - }; - readonly "Setup.RemoteE2EE.DefaultAlgorithmDesc": { - readonly def: "In most cases, you should stick with the default algorithm (${algorithm}). This setting is only required if you have an existing Vault encrypted in a different format."; - readonly es: "En la mayoría de los casos, debes mantener el algoritmo predeterminado (${algorithm}). Este ajuste solo es necesario si ya tienes un Vault cifrado con un formato diferente."; - readonly ja: "ãģとんおぎ場合は、æ—ĸ厚ぎã‚ĸãƒĢゴãƒĒã‚ēムīŧˆ${algorithm}īŧ‰ã‚’そぎぞぞäŊŋį”¨ã—ãĻãã ã•ã„ã€‚ã“ãŽč¨­åŽšãŒåŋ…čρãĢãĒるぎは、æ—ĸ存ぎ Vault がåˆĨぎåŊĸåŧã§æš—åˇåŒ–ã•ã‚ŒãĻいる場合ぎãŋです。"; - readonly ko: "대ëļ€ëļ„ė˜ ę˛Ŋ뚰 ę¸°ëŗ¸ ė•Œęŗ ëĻŦėϘ(${algorithm})ė„ 그대로 ė‚ŦėšŠí•˜ëŠ” ę˛ƒė´ ėĸ‹ėŠĩ니다. ė´ ė„¤ė •ė€ ę¸°ėĄ´ Vault가 다ëĨ¸ í˜•ė‹ėœŧ로 ė•”í˜¸í™”ë˜ė–´ ėžˆëŠ” ę˛Ŋėš°ė—ë§Œ í•„ėš”í•Šë‹ˆë‹¤."; - readonly ru: "В йОĐģŅŒŅˆĐ¸ĐŊŅŅ‚Đ˛Đĩ ҁĐģŅƒŅ‡Đ°Đĩв ҁĐģĐĩĐ´ŅƒĐĩŅ‚ ĐžŅŅ‚Đ°Đ˛Đ¸Ņ‚ŅŒ аĐģĐŗĐžŅ€Đ¸Ņ‚Đŧ ĐŋĐž ҃ĐŧĐžĐģŅ‡Đ°ĐŊĐ¸ŅŽ (${algorithm}). Đ­Ņ‚ĐžŅ‚ ĐŋĐ°Ņ€Đ°ĐŧĐĩ҂Ҁ ĐŊ҃ĐļĐĩĐŊ Ņ‚ĐžĐģҌĐēĐž в Ņ‚ĐžĐŧ ҁĐģŅƒŅ‡Đ°Đĩ, ĐĩҁĐģи ҃ Đ˛Đ°Ņ ҃ĐļĐĩ ĐĩŅŅ‚ŅŒ Vault, ĐˇĐ°ŅˆĐ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊĐŊŅ‹Đš в Đ´Ņ€ŅƒĐŗĐžĐŧ Ņ„ĐžŅ€ĐŧĐ°Ņ‚Đĩ."; - readonly zh: "在大多数情å†ĩ下īŧŒäŊ åē”įģ§įģ­äŊŋᔍéģ˜čŽ¤įŽ—æŗ•īŧˆ${algorithm}īŧ‰ã€‚åĒ有åŊ“äŊ åˇ˛æœ‰ä¸€ä¸Ēé‡‡į”¨ä¸åŒæ ŧåŧåŠ å¯†įš„ Vault æ—ļīŧŒæ‰éœ€čĻč°ƒæ•´æ­¤čŽžįŊŽã€‚"; - readonly "zh-tw": "åœ¨å¤§å¤šæ•¸æƒ…æŗä¸‹īŧŒåģēč­°įļ­æŒäŊŋᔍ預荭æŧ”įŽ—æŗ•īŧˆ${algorithm}īŧ‰ã€‚åĒ有į•ļäŊ įžæœ‰įš„ Vault 是äģĨ不同æ ŧåŧåŠ å¯†æ™‚īŧŒæ‰éœ€čρčĒŋæ•´é€™é …č¨­åŽšã€‚"; - }; - readonly "Setup.RemoteE2EE.Guidance": { - readonly def: "Please configure your end-to-end encryption settings."; - readonly es: "Configura tus ajustes de cifrado de extremo a extremo."; - readonly ja: "ã‚¨ãƒŗãƒ‰ãƒ„ãƒŧã‚¨ãƒŗãƒ‰æš—åˇåŒ–ãŽč¨­åŽšã‚’čĄŒãŖãĻください。"; - readonly ko: "ė—”ë“œíˆŦė—”ë“œ ė•”í˜¸í™” ė„¤ė •ė„ ęĩŦė„ąí•´ ėŖŧė„¸ėš”."; - readonly ru: "ПоĐļаĐģŅƒĐšŅŅ‚Đ°, ĐŊĐ°ŅŅ‚Ņ€ĐžĐšŅ‚Đĩ ĐŋĐ°Ņ€Đ°ĐŧĐĩ҂Ҁҋ ҁĐēвОСĐŊĐžĐŗĐž ŅˆĐ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊĐ¸Ņ."; - readonly zh: "č¯ˇé…įŊŽäŊ įš„į̝到įĢ¯åŠ å¯†čŽžįŊŽã€‚"; - readonly "zh-tw": "čĢ‹č¨­åŽšäŊ įš„įĢ¯å°įĢ¯åŠ å¯†é¸é …ã€‚"; - }; - readonly "Setup.RemoteE2EE.LabelEncrypt": { - readonly def: "End-to-End Encryption"; - readonly es: "Cifrado de extremo a extremo"; - readonly ja: "ã‚¨ãƒŗãƒ‰ãƒ„ãƒŧã‚¨ãƒŗãƒ‰æš—åˇåŒ–"; - readonly ko: "ė—”ë“œíˆŦė—”ë“œ ė•”í˜¸í™”"; - readonly ru: "ĐĄĐēвОСĐŊĐžĐĩ ŅˆĐ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊиĐĩ"; - readonly zh: "į̝到įĢ¯åŠ å¯†"; - readonly "zh-tw": "įĢ¯å°įĢ¯åŠ å¯†"; - }; - readonly "Setup.RemoteE2EE.LabelEncryptionAlgorithm": { - readonly def: "Encryption Algorithm"; - readonly es: "Algoritmo de cifrado"; - readonly ja: "æš—åˇåŒ–ã‚ĸãƒĢゴãƒĒã‚ēム"; - readonly ko: "ė•”í˜¸í™” ė•Œęŗ ëĻŦėϘ"; - readonly ru: "АĐģĐŗĐžŅ€Đ¸Ņ‚Đŧ ŅˆĐ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊĐ¸Ņ"; - readonly zh: "åŠ å¯†įŽ—æŗ•"; - readonly "zh-tw": "加密æŧ”įŽ—æŗ•"; - }; - readonly "Setup.RemoteE2EE.LabelObfuscateProperties": { - readonly def: "Obfuscate Properties"; - readonly es: "Ofuscar propiedades"; - readonly ja: "ãƒ—ãƒ­ãƒ‘ãƒ†ã‚Ŗã‚’é›ŖčĒ­åŒ–"; - readonly ko: "ė†ė„ą 난독화"; - readonly ru: "ĐžĐąŅ„ŅƒŅŅ†Đ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ ŅĐ˛ĐžĐšŅŅ‚Đ˛Đ°"; - readonly zh: "æˇˇæˇ†åąžæ€§"; - readonly "zh-tw": "æˇˇæˇ†åąŦ性"; - }; - readonly "Setup.RemoteE2EE.MultiDestinationWarning": { - readonly def: "This setting must be the same even when connecting to multiple synchronisation destinations."; - readonly es: "Este ajuste debe ser el mismo incluso cuando te conectes a varios destinos de sincronizaciÃŗn."; - readonly ja: "č¤‡æ•°ãŽåŒæœŸå…ˆã¸æŽĨįļšã™ã‚‹å ´åˆã§ã‚‚ã€ã“ãŽč¨­åŽšã¯åŒä¸€ã§ã‚ã‚‹åŋ…čĻãŒã‚ã‚Šãžã™ã€‚"; - readonly ko: "ė—ŦëŸŦ 동기화 ëŒ€ėƒė— ė—°ę˛°í•˜ëŠ” ę˛Ŋėš°ė—ë„ ė´ ė„¤ė •ė€ 동ėŧ해ė•ŧ 합니다."; - readonly ru: "Đ­Ņ‚ĐžŅ‚ ĐŋĐ°Ņ€Đ°ĐŧĐĩ҂Ҁ Đ´ĐžĐģĐļĐĩĐŊ ĐąŅ‹Ņ‚ŅŒ ОдиĐŊаĐēĐžĐ˛Ņ‹Đŧ даĐļĐĩ ĐŋŅ€Đ¸ ĐŋОдĐēĐģŅŽŅ‡ĐĩĐŊии Đē ĐŊĐĩҁĐēĐžĐģҌĐēиĐŧ ĐŊаĐŋŅ€Đ°Đ˛ĐģĐĩĐŊĐ¸ŅĐŧ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸."; - readonly zh: "åŗäŊŋčŋžæŽĨ到多ä¸Ē同æ­Ĩį›Žæ ‡īŧŒæ­¤čŽžįŊŽäšŸåŋ…éĄģäŋæŒä¸€č‡´ã€‚"; - readonly "zh-tw": "åŗäŊŋé€Ŗįˇšåˆ°å¤šå€‹åŒæ­Ĩį›Žæ¨™īŧŒé€™é …č¨­åŽšäšŸåŋ…é ˆäŋæŒä¸€č‡´ã€‚"; - }; - readonly "Setup.RemoteE2EE.ObfuscatePropertiesDesc": { - readonly def: "Obfuscating properties (e.g., path of file, size, creation and modification dates) adds an additional layer of security by making it harder to identify the structure and names of your files and folders on the remote server. This helps protect your privacy and makes it more difficult for unauthorized users to infer information about your data."; - readonly es: "Ofuscar propiedades (por ejemplo, la ruta del archivo, el tamaÃąo y las fechas de creaciÃŗn y modificaciÃŗn) aÃąade una capa adicional de seguridad al dificultar la identificaciÃŗn de la estructura y los nombres de tus archivos y carpetas en el servidor remoto. Esto ayuda a proteger tu privacidad y dificulta que usuarios no autorizados deduzcan informaciÃŗn sobre tus datos."; - readonly ja: "ãƒ—ãƒ­ãƒ‘ãƒ†ã‚Ŗīŧˆãƒ•ã‚Ąã‚¤ãƒĢパ゚、ã‚ĩイã‚ē、äŊœæˆæ—Ĩ時、更新æ—Ĩ時ãĒおīŧ‰ã‚’雪čĒ­åŒ–ã™ã‚‹ã¨ã€ãƒĒãƒĸãƒŧトã‚ĩãƒŧバãƒŧä¸ŠãŽãƒ•ã‚Ąã‚¤ãƒĢやフりãƒĢダãƒŧãŽæ§‹é€ ã‚„åå‰ã‚’į‰šåŽšã—ãĢくくできるため、čŋŊ加ぎäŋč­ˇåą¤ãĢãĒりぞす。これãĢã‚ˆã‚Šãƒ—ãƒŠã‚¤ãƒã‚ˇãƒŧが厈られ、樊限ぎãĒいįŦŦä¸‰č€…ãŒãƒ‡ãƒŧã‚ŋãĢé–ĸã™ã‚‹æƒ…å ąã‚’æŽ¨æ¸ŦしãĢくくãĒりぞす。"; - readonly ko: "ė†ė„ą(똈: 파ėŧ ę˛Ŋ로, íŦ기, ėƒė„ąėŧ 및 ėˆ˜ė •ėŧ)ė„ 난독화하면 ė›ę˛Š ė„œë˛„ė—ė„œ 파ėŧęŗŧ í´ë”ė˜ ęĩŦėĄ° 및 ė´ëĻ„ė„ ė‹ëŗ„í•˜ę¸° ė–´ë ĩ枌 ë§Œë“¤ė–´ ëŗ´ė•ˆė„ 한ė¸ĩ 강화할 눘 ėžˆėŠĩ니다. ė´ëŠ” ę°œė¸ ė •ëŗ´ëĨŧ ëŗ´í˜¸í•˜ęŗ  ęļŒí•œ ė—†ëŠ” ė‚ŦėšŠėžę°€ ë°ė´í„°ė— 관한 ė •ëŗ´ëĨŧ ėļ”ëĄ í•˜ę¸° ė–´ë ĩ枌 만듭니다."; - readonly ru: "ĐžĐąŅ„ŅƒŅĐēĐ°Ņ†Đ¸Ņ ŅĐ˛ĐžĐšŅŅ‚Đ˛ (ĐŊаĐŋŅ€Đ¸ĐŧĐĩŅ€, ĐŋŅƒŅ‚Đ¸ Đē Ņ„Đ°ĐšĐģ҃, Ņ€Đ°ĐˇĐŧĐĩŅ€Đ°, Đ´Đ°Ņ‚ ŅĐžĐˇĐ´Đ°ĐŊĐ¸Ņ и иСĐŧĐĩĐŊĐĩĐŊĐ¸Ņ) дОйавĐģŅĐĩŅ‚ Đ´ĐžĐŋĐžĐģĐŊĐ¸Ņ‚ĐĩĐģҌĐŊŅ‹Đš ŅƒŅ€ĐžĐ˛ĐĩĐŊҌ ĐˇĐ°Ņ‰Đ¸Ņ‚Ņ‹, ĐˇĐ°Ņ‚Ņ€ŅƒĐ´ĐŊŅŅ ĐžĐŋŅ€ĐĩĐ´ĐĩĐģĐĩĐŊиĐĩ ŅŅ‚Ņ€ŅƒĐēŅ‚ŅƒŅ€Ņ‹ и ĐŊаСваĐŊиК Đ˛Đ°ŅˆĐ¸Ņ… Ņ„Đ°ĐšĐģОв и ĐŋаĐŋĐžĐē ĐŊа ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐŧ ҁĐĩŅ€Đ˛ĐĩŅ€Đĩ. Đ­Ņ‚Đž ĐŋĐžĐŧĐžĐŗĐ°ĐĩŅ‚ ĐˇĐ°Ņ‰Đ¸Ņ‚Đ¸Ņ‚ŅŒ Đ˛Đ°ŅˆŅƒ ĐēĐžĐŊŅ„Đ¸Đ´ĐĩĐŊŅ†Đ¸Đ°ĐģҌĐŊĐžŅŅ‚ŅŒ и ҃ҁĐģĐžĐļĐŊŅĐĩŅ‚ Đ´ĐģŅ ĐŋĐžŅŅ‚ĐžŅ€ĐžĐŊĐŊĐ¸Ņ… Đ˛Ņ‹Đ˛ĐžĐ´ иĐŊŅ„ĐžŅ€ĐŧĐ°Ņ†Đ¸Đ¸ Đž Đ˛Đ°ŅˆĐ¸Ņ… даĐŊĐŊҋ҅."; - readonly zh: "æˇˇæˇ†åąžæ€§īŧˆäž‹åĻ‚æ–‡äģļčˇ¯åž„ã€å¤§å°ã€åˆ›åģē和äŋŽæ”šæ—Ĩ期īŧ‰å¯äģĨåĸžåŠ ä¸€åą‚éĸå¤–äŋæŠ¤īŧŒäŊŋčŋœį¨‹æœåŠĄå™¨ä¸Šįš„æ–‡äģļ与文äģļ多į쓿ž„åŠåį§°æ›´éšžčĸĢ蝆åˆĢ。čŋ™æœ‰åŠŠäēŽäŋæŠ¤äŊ įš„éšį§īŧŒäšŸčŽŠæœĒæŽˆæƒį”¨æˆˇæ›´éšžæŽ¨æ–­äŊ įš„æ•°æŽį›¸å…ŗäŋĄæ¯ã€‚"; - readonly "zh-tw": "æˇˇæˇ†åąŦ性īŧˆäž‹åĻ‚æĒ”æĄˆčˇ¯åž‘ã€å¤§å°ã€åģēįĢ‹æ™‚é–“čˆ‡äŋŽæ”𿙂間īŧ‰å¯äģĨéĄå¤–åĸžåŠ ä¸€åą¤åމ免äŋč­ˇīŧŒčޓ遠į̝äŧ翜å™¨ä¸Šįš„æĒ”æĄˆčˆ‡čŗ‡æ–™å¤žįĩæ§‹åŠåį¨ąæ›´é›ŖčĸĢčž¨č­˜ã€‚é€™æœ‰åŠŠæ–ŧäŋč­ˇäŊ įš„éšąį§īŧŒäšŸčŽ“æœĒ授æŦŠäŊŋį”¨č€…æ›´é›ŖæŽ¨æ¸ŦäŊ įš„čŗ‡æ–™čŗ‡č¨Šã€‚"; - }; - readonly "Setup.RemoteE2EE.PassphraseValidationLine1": { - readonly def: "Please be aware that the End-to-End Encryption passphrase is not validated until the synchronisation process actually commences. This is a security measure designed to protect your data."; - readonly es: "Ten en cuenta que la frase de contraseÃąa del cifrado de extremo a extremo no se valida hasta que el proceso de sincronizaciÃŗn comienza realmente. Esta es una medida de seguridad diseÃąada para proteger tus datos."; - readonly ja: "ã‚¨ãƒŗãƒ‰ãƒ„ãƒŧã‚¨ãƒŗãƒ‰æš—åˇåŒ–ãŽãƒ‘ã‚šãƒ•ãƒŦãƒŧã‚ēは、原際ãĢ同期å‡Ļį†ãŒé–‹å§‹ã•ã‚Œã‚‹ãžã§æ¤œč¨ŧされãĒã„į‚šãĢã”æŗ¨æ„ãã ã•ã„ã€‚ã“ã‚Œã¯ãƒ‡ãƒŧã‚ŋをäŋč­ˇã™ã‚‹ãŸã‚ãŽã‚ģキãƒĨãƒĒãƒ†ã‚Ŗå¯žį­–ã§ã™ã€‚"; - readonly ko: "ė—”ë“œíˆŦė—”ë“œ ė•”í˜¸í™” íŒ¨ėŠ¤í”„ë ˆė´ėĻˆëŠ” ė‹¤ė œ 동기화가 ė‹œėž‘ë˜ę¸° ė „ęšŒė§€ 검ėĻë˜ė§€ ė•ŠëŠ”ë‹¤ëŠ” 렐뗐 ėœ ė˜í•˜ė„¸ėš”. ė´ę˛ƒė€ ë°ė´í„°ëĨŧ ëŗ´í˜¸í•˜ę¸° ėœ„í•œ ëŗ´ė•ˆ ėĄ°ėš˜ėž…ë‹ˆë‹¤."; - readonly ru: "ĐžĐąŅ€Đ°Ņ‚Đ¸Ņ‚Đĩ вĐŊиĐŧаĐŊиĐĩ: ĐŋĐ°Ņ€ĐžĐģҌĐŊĐ°Ņ Ņ„Ņ€Đ°ĐˇĐ° Đ´ĐģŅ ҁĐēвОСĐŊĐžĐŗĐž ŅˆĐ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊĐ¸Ņ ĐŊĐĩ ĐŋŅ€ĐžĐ˛ĐĩŅ€ŅĐĩŅ‚ŅŅ Đ´Đž Ņ„Đ°ĐēŅ‚Đ¸Ņ‡ĐĩҁĐēĐžĐŗĐž ĐŊĐ°Ņ‡Đ°Đģа ĐŋŅ€ĐžŅ†ĐĩŅŅĐ° ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸. Đ­Ņ‚Đž ŅĐ´ĐĩĐģаĐŊĐž в ҆ĐĩĐģŅŅ… ĐąĐĩСОĐŋĐ°ŅĐŊĐžŅŅ‚Đ¸ Đ˛Đ°ŅˆĐ¸Ņ… даĐŊĐŊҋ҅."; - readonly zh: "č¯ˇæŗ¨æ„īŧŒåœ¨åŒæ­Ĩčŋ‡į¨‹įœŸæ­Ŗåŧ€å§‹äš‹å‰īŧŒį̝到įĢ¯åŠ å¯†å¯†į įŸ­č¯­ä¸äŧščĸĢæ ĄéĒŒã€‚čŋ™æ˜¯ä¸€éĄšį”¨äēŽäŋæŠ¤äŊ æ•°æŽįš„åމ免æŽĒæ–Ŋ。"; - readonly "zh-tw": "čĢ‹æŗ¨æ„īŧŒįĢ¯å°įĢ¯åŠ å¯†įš„å¯†čĒžčρ到同æ­Ĩፋåēå¯Ļéš›é–‹å§‹æ™‚æ‰æœƒé€˛čĄŒéŠ—č­‰ã€‚é€™æ˜¯į‚ēäē†äŋč­ˇäŊ čŗ‡æ–™č€Œč¨­č¨ˆįš„åމ免æŽĒæ–Ŋ。"; - }; - readonly "Setup.RemoteE2EE.PassphraseValidationLine2": { - readonly def: "Therefore, we ask that you exercise extreme caution when configuring server information manually. If an incorrect passphrase is entered, the data on the server will become corrupted. Please understand that this is intended behaviour."; - readonly es: "Por lo tanto, te pedimos que tengas muchísimo cuidado al configurar manualmente la informaciÃŗn del servidor. Si introduces una frase de contraseÃąa incorrecta, los datos del servidor se corromperÃĄn. Ten en cuenta que este comportamiento es intencionado."; - readonly ja: "そぎため、ã‚ĩãƒŧバãƒŧæƒ…å ąã‚’æ‰‹å‹•ã§č¨­åŽšã™ã‚‹éš›ã¯į´°åŋƒãŽæŗ¨æ„ã‚’æ‰•ãŖãĻください。čĒ¤ãŖãŸãƒ‘ã‚šãƒ•ãƒŦãƒŧã‚ēをå…Ĩ力すると、ã‚ĩãƒŧバãƒŧ上ぎデãƒŧã‚ŋãŒį ´æã—ãžã™ã€‚ã“ã‚Œã¯æ„å›ŗã•ã‚ŒãŸå‹•äŊœã§ã™ãŽã§ã€ã‚ã‚‰ã‹ã˜ã‚ã”į†č§Ŗãã ã•ã„ã€‚"; - readonly ko: "따ëŧė„œ ė„œë˛„ ė •ëŗ´ëĨŧ ėˆ˜ë™ėœŧ로 ęĩŦė„ąí•  때는 ę°ëŗ„ížˆ ėŖŧė˜í•´ ėŖŧė„¸ėš”. ėž˜ëĒģ된 íŒ¨ėŠ¤í”„ë ˆė´ėψëĨŧ ėž…ë Ĩ하면 ė„œë˛„ė˜ ë°ė´í„°ę°€ ė†ėƒëŠë‹ˆë‹¤. ė´ëŠ” ė˜ë„ëœ ë™ėž‘ė´ë‹ˆ ë°˜ë“œė‹œ ė´í•´í•˜ęŗ  ė§„í–‰í•´ ėŖŧė„¸ėš”."; - readonly ru: "ĐŸĐžŅŅ‚ĐžĐŧ҃ ĐŋŅ€Đ¸ Ņ€ŅƒŅ‡ĐŊОК ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēĐĩ иĐŊŅ„ĐžŅ€ĐŧĐ°Ņ†Đ¸Đ¸ Đž ҁĐĩŅ€Đ˛ĐĩŅ€Đĩ ҂ҀĐĩĐąŅƒĐĩŅ‚ŅŅ ĐŋŅ€ĐĩĐ´ĐĩĐģҌĐŊĐ°Ņ ĐžŅŅ‚ĐžŅ€ĐžĐļĐŊĐžŅŅ‚ŅŒ. Đ•ŅĐģи ĐąŅƒĐ´ĐĩŅ‚ ввĐĩĐ´ĐĩĐŊа ĐŊĐĩвĐĩŅ€ĐŊĐ°Ņ ĐŋĐ°Ņ€ĐžĐģҌĐŊĐ°Ņ Ņ„Ņ€Đ°ĐˇĐ°, даĐŊĐŊŅ‹Đĩ ĐŊа ҁĐĩŅ€Đ˛ĐĩŅ€Đĩ ĐąŅƒĐ´ŅƒŅ‚ ĐŋĐžĐ˛Ņ€ĐĩĐļĐ´ĐĩĐŊŅ‹. ПоĐļаĐģŅƒĐšŅŅ‚Đ°, ŅƒŅ‡Ņ‚Đ¸Ņ‚Đĩ, Ņ‡Ņ‚Đž ŅŅ‚Đž ĐžĐļидаĐĩĐŧĐžĐĩ ĐŋОвĐĩĐ´ĐĩĐŊиĐĩ."; - readonly zh: "因此īŧŒåœ¨æ‰‹åŠ¨é…įŊŽæœåŠĄå™¨äŋĄæ¯æ—ļč¯ˇåŠĄåŋ…æ ŧ外小åŋƒã€‚åĻ‚æžœčž“å…Ĩäē†é”™č¯¯įš„å¯†į įŸ­č¯­īŧŒæœåŠĄå™¨ä¸Šįš„æ•°æŽå°†äŧšæŸåã€‚č¯ˇį†č§Ŗčŋ™åąžäēŽéĸ„æœŸčĄŒä¸ē。"; - readonly "zh-tw": "因此īŧŒåœ¨æ‰‹å‹•設åޚäŧ翜å™¨čŗ‡č¨Šæ™‚č̋務åŋ…æ ŧ外小åŋƒã€‚åĻ‚æžœčŧ¸å…Ĩäē†éŒ¯čĒ¤įš„å¯†čĒžīŧŒäŧ翜å™¨ä¸Šįš„čŗ‡æ–™å°‡æœƒææ¯€ã€‚čĢ‹į†č§Ŗé€™æ˜¯įŗģįĩąįš„é æœŸčĄŒį‚ē。"; - }; - readonly "Setup.RemoteE2EE.PlaceholderPassphrase": { - readonly def: "Enter your passphrase"; - readonly es: "Introduce tu frase de contraseÃąa"; - readonly ja: "パ゚フãƒŦãƒŧã‚ēをå…Ĩ力しãĻください"; - readonly ko: "íŒ¨ėŠ¤í”„ë ˆė´ėψëĨŧ ėž…ë Ĩí•˜ė„¸ėš”"; - readonly ru: "ВвĐĩĐ´Đ¸Ņ‚Đĩ ĐŋĐ°Ņ€ĐžĐģҌĐŊŅƒŅŽ Ņ„Ņ€Đ°ĐˇŅƒ"; - readonly zh: "输å…ĨäŊ įš„å¯†į įŸ­č¯­"; - readonly "zh-tw": "čŧ¸å…ĨäŊ įš„密čĒž"; - }; - readonly "Setup.RemoteE2EE.StronglyRecommendedLine1": { - readonly def: "Enabling end-to-end encryption ensures that your data is encrypted on your device before being sent to the remote server. This means that even if someone gains access to the server, they won't be able to read your data without the passphrase. Make sure to remember your passphrase, as it will be required to decrypt your data on other devices."; - readonly es: "Al habilitar el cifrado de extremo a extremo, tus datos se cifran en tu dispositivo antes de enviarse al servidor remoto. Esto significa que, incluso si alguien obtiene acceso al servidor, no podrÃĄ leer tus datos sin la frase de contraseÃąa. AsegÃērate de recordarla, ya que tambiÊn serÃĄ necesaria para descifrar tus datos en otros dispositivos."; - readonly ja: "ã‚¨ãƒŗãƒ‰ãƒ„ãƒŧã‚¨ãƒŗãƒ‰æš—åˇåŒ–ã‚’æœ‰åŠšãĢすると、デãƒŧã‚ŋはãƒĒãƒĸãƒŧトã‚ĩãƒŧバãƒŧへ送äŋĄã•れる前ãĢこぎį̝æœĢä¸Šã§æš—åˇåŒ–ã•ã‚Œãžã™ã€‚ã¤ãžã‚Šã€ãŸã¨ãˆčĒ°ã‹ãŒã‚ĩãƒŧバãƒŧへã‚ĸクã‚ģ゚できãĻも、パ゚フãƒŦãƒŧã‚ēがãĒければデãƒŧã‚ŋをčĒ­ã‚€ã“ã¨ã¯ã§ããžã›ã‚“ã€‚äģ–ぎį̝æœĢでデãƒŧã‚ŋã‚’åžŠåˇã™ã‚‹éš›ãĢもåŋ…čρãĢãĒるため、パ゚フãƒŦãƒŧã‚ēはåŋ…ずčĻšãˆãĻおいãĻください。"; - readonly ko: "ė—”ë“œíˆŦė—”ë“œ ė•”í˜¸í™”ëĨŧ í™œė„ąí™”í•˜ëŠ´ ë°ė´í„°ę°€ ė›ę˛Š ė„œë˛„ëĄœ ė „ė†Ąë˜ę¸° 렄뗐 ė´ ę¸°ę¸°ė—ė„œ ė•”í˜¸í™”ëŠë‹ˆë‹¤. ėω, 누ęĩ°ę°€ ė„œë˛„ė— ė ‘ęˇŧ하더ëŧ도 íŒ¨ėŠ¤í”„ë ˆė´ėψ ė—†ė´ëŠ” ë°ė´í„°ëĨŧ ėŊė„ 눘 ė—†ėŠĩ니다. 다ëĨ¸ ę¸°ę¸°ė—ė„œ ë°ė´í„°ëĨŧ ëŗĩ호화할 때도 í•„ėš”í•˜ë¯€ëĄœ íŒ¨ėŠ¤í”„ë ˆė´ėψëĨŧ ë°˜ë“œė‹œ 기ė–ĩ해 ë‘ė„¸ėš”."; - readonly ru: "ĐŸŅ€Đ¸ вĐēĐģŅŽŅ‡ĐĩĐŊии ҁĐēвОСĐŊĐžĐŗĐž ŅˆĐ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊĐ¸Ņ Đ˛Đ°ŅˆĐ¸ даĐŊĐŊŅ‹Đĩ ŅˆĐ¸Ņ„Ņ€ŅƒŅŽŅ‚ŅŅ ĐŊа ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đĩ Đ´Đž ĐžŅ‚ĐŋŅ€Đ°Đ˛Đēи ĐŊа ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊŅ‹Đš ҁĐĩŅ€Đ˛ĐĩŅ€. Đ­Ņ‚Đž ОСĐŊĐ°Ņ‡Đ°ĐĩŅ‚, Ņ‡Ņ‚Đž даĐļĐĩ ĐĩҁĐģи ĐēŅ‚Đž-Ņ‚Đž ĐŋĐžĐģŅƒŅ‡Đ¸Ņ‚ Đ´ĐžŅŅ‚ŅƒĐŋ Đē ҁĐĩŅ€Đ˛ĐĩŅ€Ņƒ, ĐžĐŊ ĐŊĐĩ ҁĐŧĐžĐļĐĩŅ‚ ĐŋŅ€ĐžŅ‡Đ¸Ņ‚Đ°Ņ‚ŅŒ Đ˛Đ°ŅˆĐ¸ даĐŊĐŊŅ‹Đĩ ĐąĐĩС ĐŋĐ°Ņ€ĐžĐģҌĐŊОК Ņ„Ņ€Đ°ĐˇŅ‹. ĐžĐąŅĐˇĐ°Ņ‚ĐĩĐģҌĐŊĐž СаĐŋĐžĐŧĐŊĐ¸Ņ‚Đĩ ĐŋĐ°Ņ€ĐžĐģҌĐŊŅƒŅŽ Ņ„Ņ€Đ°ĐˇŅƒ, Ņ‚Đ°Đē ĐēаĐē ĐžĐŊа ĐŋĐžŅ‚Ņ€ĐĩĐąŅƒĐĩŅ‚ŅŅ Đ´ĐģŅ Ņ€Đ°ŅŅˆĐ¸Ņ„Ņ€ĐžĐ˛Đēи даĐŊĐŊҋ҅ ĐŊа Đ´Ņ€ŅƒĐŗĐ¸Ņ… ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°Ņ…."; - readonly zh: "吝ᔍį̝到įĢ¯åŠ å¯†åŽīŧŒæ•°æŽäŧšå…ˆåœ¨äŊ įš„čŽžå¤‡ä¸ŠåŠ å¯†īŧŒå†å‘送到čŋœį¨‹æœåŠĄå™¨ã€‚čŋ™æ„å‘ŗį€åŗäŊŋ有äēēčŽˇåž—ä熿œåŠĄå™¨čŽŋ闎权限īŧŒæ˛Ąæœ‰å¯†į įŸ­č¯­äšŸæ— æŗ•č¯ģ取äŊ įš„æ•°æŽã€‚č¯ˇåŠĄåŋ…čްäŊäŊ įš„å¯†į įŸ­č¯­īŧŒå› ä¸ē在å…ļäģ–čŽžå¤‡ä¸Šč§Ŗå¯†æ•°æŽæ—ļ也需čĻåŽƒã€‚"; - readonly "zh-tw": "å•Ÿį”¨įĢ¯å°įĢ¯åŠ å¯†åžŒīŧŒčŗ‡æ–™æœƒå…ˆåœ¨äŊ įš„čŖįŊŽä¸ŠåŽŒæˆåŠ å¯†īŧŒå†å‚ŗé€åˆ°é į̝äŧ翜å™¨ã€‚這襨į¤ēåŗäŊŋ有äēē取垗äŧ翜å™¨å­˜å–æŦŠīŧŒæ˛’有密čĒžäšŸį„Ąæŗ•čŽ€å–äŊ įš„čŗ‡æ–™ã€‚č̋務åŋ…č¨˜äŊäŊ įš„密čĒžīŧŒå› į‚ēå…ļäģ–čŖįŊŽåœ¨č§Ŗå¯†čŗ‡æ–™æ™‚䚟需čĻåŽƒã€‚"; - }; - readonly "Setup.RemoteE2EE.StronglyRecommendedLine2": { - readonly def: "Also, please note that if you are using Peer-to-Peer synchronization, this configuration will be used when you switch to other methods and connect to a remote server in the future."; - readonly es: "AdemÃĄs, ten en cuenta que si estÃĄs usando sincronizaciÃŗn Peer-to-Peer, esta configuraciÃŗn se utilizarÃĄ cuando mÃĄs adelante cambies a otros mÊtodos y te conectes a un servidor remoto."; - readonly ja: "ぞた、Peer-to-Peer 同期をäŊŋį”¨ã—ãĻいる場合でも、将æĨãģかぎ斚åŧã¸åˆ‡ã‚Šæ›ŋえãĻãƒĒãƒĸãƒŧトã‚ĩãƒŧバãƒŧへæŽĨįļšã™ã‚‹ã¨ããĢã¯ã€ã“ãŽč¨­åŽšãŒäŊŋわれぞす。"; - readonly ko: "또한 Peer-to-Peer 동기화ëĨŧ ė‚ŦėšŠ ė¤‘ė´ë”ëŧ도, ë‚˜ė¤‘ė— 다ëĨ¸ ë°Šė‹ėœŧ로 ė „í™˜í•˜ė—Ŧ ė›ę˛Š ė„œë˛„ė— ė—°ę˛°í•˜ëŠ´ ė´ ė„¤ė •ė´ 그대로 ė‚ŦėšŠëŠë‹ˆë‹¤."; - readonly ru: "ĐĸаĐēĐļĐĩ ĐžĐąŅ€Đ°Ņ‚Đ¸Ņ‚Đĩ вĐŊиĐŧаĐŊиĐĩ: ĐĩҁĐģи Đ˛Ņ‹ Đ¸ŅĐŋĐžĐģŅŒĐˇŅƒĐĩŅ‚Đĩ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸ŅŽ Peer-to-Peer, ŅŅ‚Đ° ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Ņ ĐąŅƒĐ´ĐĩŅ‚ Đ¸ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°ĐŊа, ĐēĐžĐŗĐ´Đ° Đ˛Ņ‹ ĐŋОСĐļĐĩ ĐŋĐĩŅ€ĐĩĐēĐģŅŽŅ‡Đ¸Ņ‚ĐĩҁҌ ĐŊа Đ´Ņ€ŅƒĐŗĐ¸Đĩ ĐŧĐĩŅ‚ĐžĐ´Ņ‹ и ĐŋОдĐēĐģŅŽŅ‡Đ¸Ņ‚ĐĩҁҌ Đē ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐŧ҃ ҁĐĩŅ€Đ˛ĐĩŅ€Ņƒ."; - readonly zh: "åĻå¤–č¯ˇæŗ¨æ„īŧŒåĻ‚æžœäŊ æ­Ŗåœ¨äŊŋᔍ Peer-to-Peer 同æ­ĨīŧŒåŊ“äŊ äģĨ后切æĸ到å…ļäģ–同æ­Ĩæ–šåŧåšļčŋžæŽĨčŋœį¨‹æœåŠĄå™¨æ—ļīŧŒäšŸäŧšäŊŋᔍčŋ™įģ„配įŊŽã€‚"; - readonly "zh-tw": "åĻ外čĢ‹æŗ¨æ„īŧŒåŗäŊŋäŊ į›Žå‰äŊŋį”¨įš„æ˜¯ Peer-to-Peer 同æ­ĨīŧŒæ—Ĩ垌č‹Ĩ切換到å…ļäģ–æ–šåŧä¸Ļé€Ŗįˇšåˆ°é į̝äŧ翜å™¨æ™‚īŧŒä🿜ƒæ˛ŋᔍ這įĩ„č¨­åŽšã€‚"; - }; - readonly "Setup.RemoteE2EE.StronglyRecommendedTitle": { - readonly def: "Strongly Recommended"; - readonly es: "Muy recomendable"; - readonly ja: "åŧˇãæŽ¨åĨ¨"; - readonly ko: "강ë Ĩ ęļŒėžĨ"; - readonly ru: "ĐĐ°ŅŅ‚ĐžŅŅ‚ĐĩĐģҌĐŊĐž Ņ€ĐĩĐēĐžĐŧĐĩĐŊĐ´ŅƒĐĩŅ‚ŅŅ"; - readonly zh: "åŧēįƒˆæŽ¨č"; - readonly "zh-tw": "åŧˇįƒˆåģēč­°"; - }; - readonly "Setup.RemoteE2EE.Title": { - readonly def: "End-to-End Encryption"; - readonly es: "Cifrado de extremo a extremo"; - readonly ja: "ã‚¨ãƒŗãƒ‰ãƒ„ãƒŧã‚¨ãƒŗãƒ‰æš—åˇåŒ–"; - readonly ko: "ė—”ë“œíˆŦė—”ë“œ ė•”í˜¸í™”"; - readonly ru: "ĐĄĐēвОСĐŊĐžĐĩ ŅˆĐ¸Ņ„Ņ€ĐžĐ˛Đ°ĐŊиĐĩ"; - readonly zh: "į̝到įĢ¯åŠ å¯†"; - readonly "zh-tw": "įĢ¯å°įĢ¯åŠ å¯†"; - }; - readonly "Setup.ScanQRCode.ButtonClose": { - readonly def: "Close this dialog"; - readonly es: "Cerrar este diÃĄlogo"; - readonly ja: "こぎダイã‚ĸログを閉じる"; - readonly ko: "ė´ 대화 ėƒėž ë‹Ģ기"; - readonly ru: "ЗаĐēŅ€Ņ‹Ņ‚ŅŒ ŅŅ‚Đž ĐžĐēĐŊĐž"; - readonly zh: "å…ŗé—­æ­¤å¯šč¯æĄ†"; - readonly "zh-tw": "é—œé–‰æ­¤å°čŠąæĄ†"; - }; - readonly "Setup.ScanQRCode.Guidance": { - readonly def: "Please follow the steps below to import settings from your existing device."; - readonly es: "Sigue los pasos de abajo para importar los ajustes desde tu dispositivo actual."; - readonly ja: "æ—ĸ存ぎį̝æœĢã‹ã‚‰č¨­åŽšã‚’å–ã‚ŠčžŧむãĢは、äģĨ下ぎ手順ãĢåž“ãŖãĻください。"; - readonly ko: "ę¸°ėĄ´ ę¸°ę¸°ė—ė„œ ė„¤ė •ė„ ę°€ė ¸ė˜¤ë ¤ëŠ´ ė•„ëž˜ ë‹¨ęŗ„ëĨŧ 따ëŧ ėŖŧė„¸ėš”."; - readonly ru: "Đ§Ņ‚ĐžĐąŅ‹ иĐŧĐŋĐžŅ€Ņ‚Đ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи ҁ ŅŅƒŅ‰ĐĩŅŅ‚Đ˛ŅƒŅŽŅ‰ĐĩĐŗĐž ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°, Đ˛Ņ‹ĐŋĐžĐģĐŊĐ¸Ņ‚Đĩ ҁĐģĐĩĐ´ŅƒŅŽŅ‰Đ¸Đĩ ŅˆĐ°ĐŗĐ¸."; - readonly zh: "č¯ˇæŒ‰į…§äģĨ下æ­ĨéǤäģŽįŽ°æœ‰čŽžå¤‡å¯ŧå…Ĩ莞įŊŽã€‚"; - readonly "zh-tw": "čĢ‹äžį…§äģĨ下æ­Ĩ驟īŧŒåžžįžæœ‰čŖįŊŽåŒ¯å…Ĩč¨­åŽšã€‚"; - }; - readonly "Setup.ScanQRCode.Step1": { - readonly def: "On this device, please keep this Vault open."; - readonly es: "En este dispositivo, mantÊn este Vault abierto."; - readonly ja: "こぎį̝æœĢでは、こぎ Vault を開いたぞぞãĢしãĻください。"; - readonly ko: "ė´ ę¸°ę¸°ė—ė„œëŠ” ė´ VaultëĨŧ ęŗ„ė† ė—´ė–´ ë‘ė„¸ėš”."; - readonly ru: "На ŅŅ‚ĐžĐŧ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đĩ ĐžŅŅ‚Đ°Đ˛ŅŒŅ‚Đĩ даĐŊĐŊŅ‹Đš Vault ĐžŅ‚ĐēҀҋ҂ҋĐŧ."; - readonly zh: "在čŋ™å°čŽžå¤‡ä¸ŠīŧŒč¯ˇäŋæŒæ­¤ Vault 处äēŽæ‰“åŧ€įŠļ态。"; - readonly "zh-tw": "åœ¨é€™å°čŖįŊŽä¸ŠīŧŒčĢ‹äŋæŒæ­¤ Vault 開啟。"; - }; - readonly "Setup.ScanQRCode.Step2": { - readonly def: "On the source device, open Obsidian."; - readonly es: "En el dispositivo de origen, abre Obsidian."; - readonly ja: "元ぎį̝æœĢで Obsidian を開きぞす。"; - readonly ko: "ė›ëŗ¸ ę¸°ę¸°ė—ė„œ Obsidianė„ ė—Ŋ니다."; - readonly ru: "На Đ¸ŅŅ…ĐžĐ´ĐŊĐžĐŧ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đĩ ĐžŅ‚ĐēŅ€ĐžĐšŅ‚Đĩ Obsidian."; - readonly zh: "在æēčŽžå¤‡ä¸Šæ‰“åŧ€ Obsidian。"; - readonly "zh-tw": "在䞆æēčŖįŊŽä¸Šé–‹å•Ÿ Obsidian。"; - }; - readonly "Setup.ScanQRCode.Step3": { - readonly def: "On the source device, from the command palette, run the 'Show settings as a QR code' command."; - readonly es: "En el dispositivo de origen, ejecuta desde la paleta de comandos la orden \"Mostrar ajustes como cÃŗdigo QR\"."; - readonly ja: "元ぎį̝æœĢã§ã‚ŗãƒžãƒŗãƒ‰ãƒ‘ãƒŦãƒƒãƒˆã‹ã‚‰ã€Œč¨­åŽšã‚’ QR ã‚ŗãƒŧドとしãĻ襨į¤ēã€ã‚’åŽŸčĄŒã—ãžã™ã€‚"; - readonly ko: "ė›ëŗ¸ ę¸°ę¸°ė—ė„œ ëĒ…ë š 팔레트ëĨŧ ė—´ęŗ  \"ė„¤ė •ė„ QR ėŊ”ë“œëĄœ í‘œė‹œ\" ëĒ…ë šė„ ė‹¤í–‰í•Šë‹ˆë‹¤."; - readonly ru: "На Đ¸ŅŅ…ĐžĐ´ĐŊĐžĐŧ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đĩ в ĐŋаĐģĐ¸Ņ‚Ņ€Đĩ ĐēĐžĐŧаĐŊĐ´ Đ˛Ņ‹ĐŋĐžĐģĐŊĐ¸Ņ‚Đĩ ĐēĐžĐŧаĐŊĐ´Ņƒ ÂĢПоĐēĐ°ĐˇĐ°Ņ‚ŅŒ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи ĐēаĐē QR-ĐēОдÂģ."; - readonly zh: "在æēčŽžå¤‡ä¸ŠīŧŒäģŽå‘Ŋäģ¤éĸæŋčŋčĄŒâ€œå°†čŽžįŊŽæ˜žį¤ēä¸ēäēŒįģ´į â€å‘Ŋäģ¤ã€‚"; - readonly "zh-tw": "在䞆æēčŖįŊŽä¸ŠīŧŒåžžå‘Ŋäģ¤éĸæŋåŸˇčĄŒã€Œå°‡č¨­åŽšéĄ¯į¤ēį‚ē QR įĸŧ」å‘Ŋäģ¤ã€‚"; - }; - readonly "Setup.ScanQRCode.Step4": { - readonly def: "On this device, switch to the camera app or use a QR code scanner to scan the displayed QR code."; - readonly es: "En este dispositivo, cambia a la cÃĄmara o usa un escÃĄner QR para escanear el cÃŗdigo mostrado."; - readonly ja: "こぎį̝æœĢでã‚ĢãƒĄãƒŠã‚ĸプãƒĒãĢ切りæ›ŋえるか QR ã‚ŗãƒŧãƒ‰ã‚šã‚­ãƒŖãƒŠãƒŧをäŊŋãŖãĻã€čĄ¨į¤ēされた QR ã‚ŗãƒŧドをčĒ­ãŋå–ãŖãĻください。"; - readonly ko: "ė´ ę¸°ę¸°ė—ė„œ ėš´ëŠ”ëŧ ė•ąėœŧ로 ė „í™˜í•˜ęą°ë‚˜ QR ėŊ”드 늤ėēë„ˆëĨŧ ė‚ŦėšŠí•´ í‘œė‹œëœ QR ėŊ”드ëĨŧ 늤ėē”í•˜ė„¸ėš”."; - readonly ru: "На ŅŅ‚ĐžĐŧ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đĩ ĐžŅ‚ĐēŅ€ĐžĐšŅ‚Đĩ ĐŋŅ€Đ¸ĐģĐžĐļĐĩĐŊиĐĩ ĐēаĐŧĐĩҀҋ иĐģи Đ¸ŅĐŋĐžĐģŅŒĐˇŅƒĐšŅ‚Đĩ ҁĐēаĐŊĐĩŅ€ QR-ĐēОдОв, Ņ‡Ņ‚ĐžĐąŅ‹ ŅŅ‡Đ¸Ņ‚Đ°Ņ‚ŅŒ ĐŋĐžĐēаСаĐŊĐŊŅ‹Đš QR-ĐēОд."; - readonly zh: "在čŋ™å°čŽžå¤‡ä¸ŠīŧŒåˆ‡æĸåˆ°į›¸æœēåē”į”¨æˆ–äŊŋᔍäēŒįģ´į æ‰Ģ描器æ‰Ģ描昞į¤ēå‡ēįš„äēŒįģ´į ã€‚"; - readonly "zh-tw": "åœ¨é€™å°čŖįŊŽä¸Šåˆ‡æ›åˆ°į›¸æŠŸ AppīŧŒæˆ–äŊŋᔍ QR įĸŧæŽƒæå™¨æŽƒæéĄ¯į¤ēå‡ēįš„ QR įĸŧ。"; - }; - readonly "Setup.ScanQRCode.Title": { - readonly def: "Scan QR Code"; - readonly es: "Escanear cÃŗdigo QR"; - readonly ja: "QRã‚ŗãƒŧãƒ‰ã‚’ã‚šã‚­ãƒŖãƒŗ"; - readonly ko: "QR ėŊ”드 늤ėē”"; - readonly ru: "ĐĄĐēаĐŊĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ QR-ĐēОд"; - readonly zh: "æ‰Ģ描äēŒįģ´į "; - readonly "zh-tw": "掃描 QR įĸŧ"; - }; - readonly "Setup.ShowQRCode": { - readonly def: "Show QR code"; - readonly fr: "Afficher le QR code"; - readonly he: "ה×Ļג קוד QR"; - readonly ja: "QRã‚ŗãƒŧãƒ‰ã‚’čĄ¨į¤ē"; - readonly ko: "QR ėŊ”드 í‘œė‹œ"; - readonly ru: "ПоĐēĐ°ĐˇĐ°Ņ‚ŅŒ QR ĐēОд"; - readonly zh: "äŊŋᔍQR᠁"; - }; - readonly "Setup.ShowQRCode.Desc": { - readonly def: "Show QR code to transfer the settings."; - readonly fr: "Afficher le QR code pour transfÊrer les paramètres."; - readonly he: "ה×Ļג קוד QR לה×ĸבר×Ē ×”×”×’×“×¨×•×Ē."; - readonly ja: "č¨­åŽšã‚’čģĸ送するためぎQRã‚ŗãƒŧãƒ‰ã‚’čĄ¨į¤ēしぞす。"; - readonly ko: "ė„¤ė •ė„ ė „ė†Ąí•˜ę¸° ėœ„í•œ QR ėŊ”드ëĨŧ í‘œė‹œí•Šë‹ˆë‹¤."; - readonly ru: "ПоĐēĐ°ĐˇĐ°Ņ‚ŅŒ QR ĐēОд Đ´ĐģŅ ĐŋĐĩŅ€ĐĩĐ´Đ°Ņ‡Đ¸ ĐŊĐ°ŅŅ‚Ņ€ĐžĐĩĐē."; - readonly zh: "äŊŋᔍQR᠁æĨäŧ é€’配įŊŽ"; - }; - readonly "Setup.UseSetupURI.ButtonCancel": { - readonly def: "Cancel"; - readonly es: "Cancelar"; - readonly ja: "ã‚­ãƒŖãƒŗã‚ģãƒĢ"; - readonly ko: "ėˇ¨ė†Œ"; - readonly ru: "ĐžŅ‚ĐŧĐĩĐŊа"; - readonly zh: "取æļˆ"; - readonly "zh-tw": "取æļˆ"; - }; - readonly "Setup.UseSetupURI.ButtonProceed": { - readonly def: "Test Settings and Continue"; - readonly es: "Probar ajustes y continuar"; - readonly ja: "č¨­åŽšã‚’ãƒ†ã‚šãƒˆã—ãĻįļščĄŒ"; - readonly ko: "네렕 í…ŒėŠ¤íŠ¸ 후 ęŗ„ė†"; - readonly ru: "ĐŸŅ€ĐžĐ˛ĐĩŅ€Đ¸Ņ‚ŅŒ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи и ĐŋŅ€ĐžĐ´ĐžĐģĐļĐ¸Ņ‚ŅŒ"; - readonly zh: "æĩ‹č¯•莞įŊŽåšļįģ§įģ­"; - readonly "zh-tw": "æ¸ŦčŠĻč¨­åŽšä¸ĻįšŧįēŒ"; - }; - readonly "Setup.UseSetupURI.ErrorFailedToParse": { - readonly def: "Failed to parse the Setup URI. Please check the URI and passphrase."; - readonly es: "No se pudo procesar la URI de configuraciÃŗn. Revisa la URI y la frase de contraseÃąa."; - readonly ja: "Setup URI ã‚’č§Ŗæžã§ããžã›ã‚“ã§ã—ãŸã€‚URI とパ゚フãƒŦãƒŧã‚ēをįĸēčĒã—ãĻください。"; - readonly ko: "Setup URIëĨŧ í•´ė„í•˜ė§€ ëĒģ했ėŠĩ니다. URI뙀 íŒ¨ėŠ¤í”„ë ˆė´ėψëĨŧ í™•ė¸í•´ ėŖŧė„¸ėš”."; - readonly ru: "НĐĩ ŅƒĐ´Đ°ĐģĐžŅŅŒ ĐžĐąŅ€Đ°ĐąĐžŅ‚Đ°Ņ‚ŅŒ Setup URI. ĐŸŅ€ĐžĐ˛ĐĩŅ€ŅŒŅ‚Đĩ URI и ĐŋĐ°Ņ€ĐžĐģҌĐŊŅƒŅŽ Ņ„Ņ€Đ°ĐˇŅƒ."; - readonly zh: "æ— æŗ•č§Ŗæž Setup URIīŧŒč¯ˇæŖ€æŸĨ URI å’Œå¯†į įŸ­č¯­ã€‚"; - readonly "zh-tw": "į„Ąæŗ•č§Ŗæž Setup URIīŧŒčĢ‹æĒĸæŸĨ URI čˆ‡å¯†čĒžã€‚"; - }; - readonly "Setup.UseSetupURI.ErrorPassphraseRequired": { - readonly def: "Please enter the vault passphrase."; - readonly es: "Introduce la frase de contraseÃąa del Vault."; - readonly ja: "Vault ぎパ゚フãƒŦãƒŧã‚ēをå…Ĩ力しãĻください。"; - readonly ko: "Vault íŒ¨ėŠ¤í”„ë ˆė´ėψëĨŧ ėž…ë Ĩ해 ėŖŧė„¸ėš”."; - readonly ru: "ПоĐļаĐģŅƒĐšŅŅ‚Đ°, ввĐĩĐ´Đ¸Ņ‚Đĩ ĐŋĐ°Ņ€ĐžĐģҌĐŊŅƒŅŽ Ņ„Ņ€Đ°ĐˇŅƒ Vault."; - readonly zh: "č¯ˇčž“å…Ĩ Vault å¯†į įŸ­č¯­ã€‚"; - readonly "zh-tw": "čĢ‹čŧ¸å…Ĩ Vault įš„å¯†čĒžã€‚"; - }; - readonly "Setup.UseSetupURI.GuidanceLine1": { - readonly def: "Please enter the Setup URI that was generated during server installation or on another device, along with the vault passphrase."; - readonly es: "Introduce la URI de configuraciÃŗn que se generÃŗ durante la instalaciÃŗn del servidor o en otro dispositivo, junto con la frase de contraseÃąa del Vault."; - readonly ja: "ã‚ĩãƒŧバãƒŧぎã‚ģットã‚ĸップ時ぞたはåˆĨぎį̝æœĢã§į”Ÿæˆã•ã‚ŒãŸ Setup URI と、Vault ぎパ゚フãƒŦãƒŧã‚ēをå…Ĩ力しãĻください。"; - readonly ko: "ė„œë˛„ ė„¤ėš˜ 뤑 또는 다ëĨ¸ ę¸°ę¸°ė—ė„œ ėƒė„ąëœ Setup URI뙀 Vault íŒ¨ėŠ¤í”„ë ˆė´ėψëĨŧ ėž…ë Ĩ해 ėŖŧė„¸ėš”."; - readonly ru: "ВвĐĩĐ´Đ¸Ņ‚Đĩ Setup URI, ŅĐžĐˇĐ´Đ°ĐŊĐŊŅ‹Đš вО Đ˛Ņ€ĐĩĐŧŅ ŅƒŅŅ‚Đ°ĐŊОвĐēи ҁĐĩŅ€Đ˛ĐĩŅ€Đ° иĐģи ĐŊа Đ´Ņ€ŅƒĐŗĐžĐŧ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đĩ, а Ņ‚Đ°ĐēĐļĐĩ ĐŋĐ°Ņ€ĐžĐģҌĐŊŅƒŅŽ Ņ„Ņ€Đ°ĐˇŅƒ Vault."; - readonly zh: "č¯ˇčž“å…Ĩåœ¨æœåŠĄå™¨åŽ‰čŖ…æœŸé—´æˆ–åĻä¸€å°čŽžå¤‡ä¸Šį”Ÿæˆįš„ Setup URIīŧŒäģĨ及 Vault å¯†į įŸ­č¯­ã€‚"; - readonly "zh-tw": "čĢ‹čŧ¸å…Ĩ在äŧ翜å™¨åŽ‰čŖæœŸé–“æˆ–å…ļäģ–čŖįŊŽä¸Šį”ĸį”Ÿįš„ Setup URIīŧŒäģĨ及 Vault įš„å¯†čĒžã€‚"; - }; - readonly "Setup.UseSetupURI.GuidanceLine2": { - readonly def: "Note that you can generate a new Setup URI by running the \"Copy settings as a new Setup URI\" command in the command palette."; - readonly es: "Ten en cuenta que puedes generar una nueva URI de configuraciÃŗn ejecutando el comando \"Copiar ajustes como nueva URI de configuraciÃŗn\" desde la paleta de comandos."; - readonly ja: "ã‚ŗãƒžãƒŗãƒ‰ãƒ‘ãƒŦãƒƒãƒˆã§ã€Œč¨­åŽšã‚’æ–°ã—ã„ Setup URI としãĻã‚ŗãƒ”ãƒŧã€ã‚’åŽŸčĄŒã™ã‚‹ã¨ã€æ–°ã—ã„ Setup URI ã‚’į”Ÿæˆã§ããžã™ã€‚"; - readonly ko: "ëĒ…ë š íŒ”ë ˆíŠ¸ė—ė„œ \"ė„¤ė •ė„ 냈 Setup URI로 ëŗĩė‚Ŧ\" ëĒ…ë šė„ ė‹¤í–‰í•˜ëŠ´ 냈 Setup URIëĨŧ ėƒė„ąí•  눘 ėžˆėŠĩ니다."; - readonly ru: "ĐĐžĐ˛Ņ‹Đš Setup URI ĐŧĐžĐļĐŊĐž ŅĐžĐˇĐ´Đ°Ņ‚ŅŒ, Đ˛Ņ‹ĐŋĐžĐģĐŊив в ĐŋаĐģĐ¸Ņ‚Ņ€Đĩ ĐēĐžĐŧаĐŊĐ´ ĐēĐžĐŧаĐŊĐ´Ņƒ ÂĢĐĄĐēĐžĐŋĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи ĐēаĐē ĐŊĐžĐ˛Ņ‹Đš Setup URIÂģ."; - readonly zh: "äŊ å¯äģĨ在å‘Ŋäģ¤éĸæŋ中čŋčĄŒâ€œå°†čŽžįŊŽå¤åˆļä¸ēæ–°įš„ Setup URI”å‘Ŋä줿Ĩį”Ÿæˆæ–°įš„ Setup URI。"; - readonly "zh-tw": "äŊ å¯äģĨ在å‘Ŋäģ¤éĸæŋä¸­åŸˇčĄŒã€Œå°‡č¨­åŽšč¤‡čŖŊį‚ēæ–°įš„ Setup URI」å‘Ŋäģ¤äž†į”ĸį”Ÿæ–°įš„ Setup URI。"; - }; - readonly "Setup.UseSetupURI.InvalidInfo": { - readonly def: "The Setup URI is invalid. Please check it and try again."; - readonly es: "La URI de configuraciÃŗn no es vÃĄlida. Revísala e intÊntalo de nuevo."; - readonly ja: "Setup URI ãŒį„ĄåŠšã§ã™ã€‚å†…åŽšã‚’įĸēčĒã—ãĻ再čŠĻčĄŒã—ãĻください。"; - readonly ko: "Setup URI가 ė˜Ŧ바ëĨ´ė§€ ė•ŠėŠĩ니다. í™•ė¸í•œ 뒤 ë‹¤ė‹œ ė‹œë„í•´ ėŖŧė„¸ėš”."; - readonly ru: "Setup URI ĐŊĐĩĐēĐžŅ€Ņ€ĐĩĐēŅ‚ĐĩĐŊ. ĐŸŅ€ĐžĐ˛ĐĩŅ€ŅŒŅ‚Đĩ ĐĩĐŗĐž и ĐŋĐžĐŋŅ€ĐžĐąŅƒĐšŅ‚Đĩ ҁĐŊОва."; - readonly zh: "Setup URI 无效īŧŒč¯ˇæŖ€æŸĨåŽé‡č¯•ã€‚"; - readonly "zh-tw": "Setup URI į„Ąæ•ˆīŧŒčĢ‹æĒĸæŸĨ垌再čŠĻ一æŦĄã€‚"; - }; - readonly "Setup.UseSetupURI.LabelPassphrase": { - readonly def: "Vault passphrase"; - readonly es: "Frase de contraseÃąa del Vault"; - readonly ja: "Vault ぎパ゚フãƒŦãƒŧã‚ē"; - readonly ko: "Vault íŒ¨ėŠ¤í”„ë ˆė´ėψ"; - readonly ru: "ĐŸĐ°Ņ€ĐžĐģҌĐŊĐ°Ņ Ņ„Ņ€Đ°ĐˇĐ° Vault"; - readonly zh: "Vault å¯†į įŸ­č¯­"; - readonly "zh-tw": "Vault 密čĒž"; - }; - readonly "Setup.UseSetupURI.LabelSetupURI": { - readonly def: "Setup URI"; - readonly es: "URI de configuraciÃŗn"; - readonly ja: "Setup URI"; - readonly ko: "Setup URI"; - readonly ru: "Setup URI"; - readonly zh: "Setup URI"; - readonly "zh-tw": "Setup URI"; - }; - readonly "Setup.UseSetupURI.PlaceholderPassphrase": { - readonly def: "Enter your vault passphrase"; - readonly es: "Introduce la frase de contraseÃąa del Vault"; - readonly ja: "Vault ぎパ゚フãƒŦãƒŧã‚ēをå…Ĩ力しãĻください"; - readonly ko: "Vault íŒ¨ėŠ¤í”„ë ˆė´ėψëĨŧ ėž…ë Ĩí•˜ė„¸ėš”"; - readonly ru: "ВвĐĩĐ´Đ¸Ņ‚Đĩ ĐŋĐ°Ņ€ĐžĐģҌĐŊŅƒŅŽ Ņ„Ņ€Đ°ĐˇŅƒ Vault"; - readonly zh: "输å…Ĩ Vault å¯†į įŸ­č¯­"; - readonly "zh-tw": "čŧ¸å…Ĩ Vault 密čĒž"; - }; - readonly "Setup.UseSetupURI.Title": { - readonly def: "Enter Setup URI"; - readonly es: "Introducir URI de configuraciÃŗn"; - readonly ja: "Setup URI をå…Ĩ力"; - readonly ko: "Setup URI ėž…ë Ĩ"; - readonly ru: "ВвĐĩŅŅ‚Đ¸ Setup URI"; - readonly zh: "输å…Ĩ Setup URI"; - readonly "zh-tw": "čŧ¸å…Ĩ Setup URI"; - }; - readonly "Setup.UseSetupURI.ValidInfo": { - readonly def: "The Setup URI is valid and ready to use."; - readonly es: "La URI de configuraciÃŗn es vÃĄlida y estÃĄ lista para usarse."; - readonly ja: "Setup URI は有劚で、äŊŋį”¨ã§ããžã™ã€‚"; - readonly ko: "Setup URI가 ėœ íš¨í•˜ëŠ° ė‚ŦėšŠí•  ė¤€ëš„ę°€ ë˜ė—ˆėŠĩ니다."; - readonly ru: "Setup URI ĐēĐžŅ€Ņ€ĐĩĐēŅ‚ĐĩĐŊ и ĐŗĐžŅ‚ĐžĐ˛ Đē Đ¸ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°ĐŊĐ¸ŅŽ."; - readonly zh: "Setup URI 有效īŧŒå¯äģĨäŊŋį”¨ã€‚"; - readonly "zh-tw": "Setup URI 有效īŧŒå¯äģĨäŊŋį”¨ã€‚"; - }; - readonly "Should we keep folders that don't have any files inside?": { - readonly def: "Should we keep folders that don't have any files inside?"; - readonly es: "ÂŋMantener carpetas vacías?"; - readonly fr: "Conserver les dossiers ne contenant aucun fichier ?"; - readonly he: "האם לשמור ×Ēיקיו×Ē ×Š××™×Ÿ בהן קב×Ļים?"; - readonly ja: "中ãĢãƒ•ã‚Ąã‚¤ãƒĢがãĒいフりãƒĢダãƒŧをäŋæŒã—ぞすかīŧŸ"; - readonly ko: "내ëļ€ė— 파ėŧė´ ė—†ëŠ” 폴더ëĨŧ ėœ ė§€í•˜ė‹œę˛ ėŠĩ니까?"; - readonly ru: "ĐĄĐžŅ…Ņ€Đ°ĐŊŅŅ‚ŅŒ ĐŋаĐŋĐēи ĐąĐĩС Ņ„Đ°ĐšĐģОв?"; - readonly zh: "我äģŦ是åĻåē”č¯Ĩäŋį•™å†…éƒ¨æ˛Ąæœ‰äģģäŊ•æ–‡äģļįš„æ–‡äģļ多īŧŸ"; - }; - readonly "Should we only check for conflicts when a file is opened?": { - readonly def: "Should we only check for conflicts when a file is opened?"; - readonly es: "ÂŋSolo comprobar conflictos al abrir archivo?"; - readonly fr: "Ne vÊrifier les conflits qu'à l'ouverture d'un fichier ?"; - readonly he: "האם לבדוק קונפליקטים רק ב×ĸ×Ē ×¤×Ēיח×Ē ×§×•×‘×Ĩ?"; - readonly ja: "ãƒ•ã‚Ąã‚¤ãƒĢを開いたときぎãŋįĢļ合をチェックしぞすかīŧŸ"; - readonly ko: "파ėŧė„ ė—´ 때만 ėļŠëŒė„ í™•ė¸í•˜ė‹œę˛ ėŠĩ니까?"; - readonly ru: "ĐŸŅ€ĐžĐ˛ĐĩŅ€ŅŅ‚ŅŒ ĐēĐžĐŊŅ„ĐģиĐē҂ҋ Ņ‚ĐžĐģҌĐēĐž ĐŋŅ€Đ¸ ĐžŅ‚ĐēŅ€Ņ‹Ņ‚Đ¸Đ¸ Ņ„Đ°ĐšĐģа?"; - readonly zh: "我äģŦ是åĻåē”č¯Ĩäģ…在文äģ￉“åŧ€æ—ļæŖ€æŸĨ冞įǁīŧŸ"; - }; - readonly "Should we prompt you about conflicting files when a file is opened?": { - readonly def: "Should we prompt you about conflicting files when a file is opened?"; - readonly es: "ÂŋNotificar sobre conflictos al abrir archivo?"; - readonly fr: "Vous demander au sujet des fichiers en conflit à l'ouverture d'un fichier ?"; - readonly he: "האם לה×Ļיג בקשה לגבי קב×Ļים מ×Ēנגשים ב×ĸ×Ē ×¤×Ēיח×Ē ×§×•×‘×Ĩ?"; - readonly ja: "ãƒ•ã‚Ąã‚¤ãƒĢを開いたときãĢįĢļåˆãƒ•ã‚Ąã‚¤ãƒĢãĢついãĻįĸēčĒã‚’æą‚ã‚ãžã™ã‹īŧŸ"; - readonly ko: "파ėŧė„ ė—´ 때 ėļŠëŒí•˜ëŠ” 파ėŧ뗐 대해 ė•ŒëĻŧė„ í‘œė‹œí•˜ė‹œę˛ ėŠĩ니까?"; - readonly ru: "ĐĄĐŋŅ€Đ°ŅˆĐ¸Đ˛Đ°Ņ‚ŅŒ Đž ĐēĐžĐŊŅ„ĐģиĐēŅ‚ŅƒŅŽŅ‰Đ¸Ņ… Ņ„Đ°ĐšĐģĐ°Ņ… ĐŋŅ€Đ¸ ĐžŅ‚ĐēŅ€Ņ‹Ņ‚Đ¸Đ¸ Ņ„Đ°ĐšĐģа?"; - readonly zh: "åŊ“æ–‡äģ￉“åŧ€æ—ļ,是åĻ提į¤ē冞įĒæ–‡äģļīŧŸ"; - }; - readonly "Should we prompt you for every single merge, even if we can safely merge automatcially?": { - readonly def: "Should we prompt you for every single merge, even if we can safely merge automatcially?"; - readonly es: "ÂŋPreguntar en cada fusiÃŗn aunque sea automÃĄtica?"; - readonly fr: "Vous demander pour chaque fusion, mÃĒme si nous pouvons fusionner automatiquement en toute sÊcuritÊ ?"; - readonly he: "האם לה×Ļיג בקש×Ē ××™×Š×•×¨ לכל מיזוג יחיד, גם אם ני×Ēן למזג בבטחה אוטומטי×Ē?"; - readonly ja: "č‡Ēå‹•įš„ãĢ厉全ãĢマãƒŧジできる場合でも、すずãĻぎマãƒŧジãĢついãĻįĸēčĒã‚’æą‚ã‚ãžã™ã‹īŧŸ"; - readonly ko: "ė•ˆė „í•˜ę˛Œ ėžë™ ëŗ‘í•Ší•  눘 ėžˆëŠ” ę˛Ŋėš°ė—ë„ ëĒ¨ë“  ëŗ‘í•Šė— 대해 ė•ŒëĻŧė„ 받ėœŧė‹œę˛ ėŠĩ니까?"; - readonly ru: "ĐĄĐŋŅ€Đ°ŅˆĐ¸Đ˛Đ°Ņ‚ŅŒ Đž ĐēаĐļĐ´ĐžĐŧ ҁĐģĐ¸ŅĐŊии, даĐļĐĩ ĐĩҁĐģи ĐŧŅ‹ ĐŧĐžĐļĐĩĐŧ ĐąĐĩСОĐŋĐ°ŅĐŊĐž ҁĐģĐ¸Ņ‚ŅŒ Đ°Đ˛Ņ‚ĐžĐŧĐ°Ņ‚Đ¸Ņ‡ĐĩҁĐēи?"; - readonly zh: "åŗäŊŋ我äģŦ可äģĨ厉全地č‡Ē动合åšļ,是åĻ也åē”č¯Ĩä¸ē每一æŦĄåˆåšļ提į¤ē您īŧŸ"; - }; - readonly "Show full banner": { - readonly def: "Show full banner"; - readonly es: "Mostrar banner completo"; - readonly ja: "厌全ãĒバナãƒŧã‚’čĄ¨į¤ē"; - readonly ko: "렄랴 배너 í‘œė‹œ"; - readonly ru: "ПоĐēĐ°ĐˇŅ‹Đ˛Đ°Ņ‚ŅŒ ĐŋĐžĐģĐŊŅ‹Đš йаĐŊĐŊĐĩŅ€"; - readonly zh: "昞į¤ē厌整æ¨Ēåš…"; - readonly "zh-tw": "éĄ¯į¤ē厌整æŠĢåš…"; - }; - readonly "Show history": { - readonly def: "Show history"; - readonly "zh-tw": "éĄ¯į¤ēæ­ˇį¨‹"; - }; - readonly "Show icon only": { - readonly def: "Show icon only"; - readonly zh: "äģ…æ˜žį¤ē回标"; - readonly "zh-tw": "åƒ…éĄ¯į¤ē圖į¤ē"; - }; - readonly "Show only notifications": { - readonly def: "Show only notifications"; - readonly es: "Mostrar solo notificaciones"; - readonly fr: "N'afficher que les notifications"; - readonly he: "ה×Ļג ה×Ēראו×Ē ×‘×œ×‘×“"; - readonly ja: "通įŸĨぎãŋ襨į¤ē"; - readonly ko: "ė•ŒëĻŧ만 í‘œė‹œ"; - readonly ru: "ПоĐēĐ°ĐˇŅ‹Đ˛Đ°Ņ‚ŅŒ Ņ‚ĐžĐģҌĐēĐž ŅƒĐ˛ĐĩĐ´ĐžĐŧĐģĐĩĐŊĐ¸Ņ"; - readonly zh: "äģ…æ˜žį¤ē通įŸĨ"; - }; - readonly "Show status as icons only": { - readonly def: "Show status as icons only"; - readonly es: "Mostrar estado solo con íconos"; - readonly fr: "N'afficher le statut que sous forme d'icônes"; - readonly he: "ה×Ļג סטטוס כאייקונים בלבד"; - readonly ja: "゚テãƒŧã‚ŋã‚ščĄ¨į¤ēをã‚ĸã‚¤ã‚ŗãƒŗãŽãŋãĢする"; - readonly ko: "ė•„ė´ėŊ˜ėœŧ로만 ėƒíƒœ í‘œė‹œ"; - readonly ru: "ПоĐēĐ°ĐˇŅ‹Đ˛Đ°Ņ‚ŅŒ ŅŅ‚Đ°Ņ‚ŅƒŅ Ņ‚ĐžĐģҌĐēĐž иĐēĐžĐŊĐēаĐŧи"; - readonly zh: "äģ…äģĨ回标昞į¤ēįŠļ态"; - }; - readonly "Show status icon instead of file warnings banner": { - readonly def: "Show status icon instead of file warnings banner"; - readonly es: "Mostrar icono de estado en lugar del banner de advertencia de archivos"; - readonly fr: "Afficher l'icône de statut au lieu de la bannière d'avertissements"; - readonly he: "ה×Ļג אייקון סטטוס במקום פץ אזהרו×Ē ×”×§×•×‘×Ĩ"; - readonly ja: "ãƒ•ã‚Ąã‚¤ãƒĢč­Ļ告バナãƒŧぎäģŖã‚ã‚ŠãĢ゚テãƒŧã‚ŋ゚ã‚ĸã‚¤ã‚ŗãƒŗã‚’čĄ¨į¤ē"; - readonly ko: "파ėŧ ę˛Ŋęŗ  배너 ëŒ€ė‹  ėƒíƒœ ė•„ė´ėŊ˜ í‘œė‹œ"; - readonly ru: "ПоĐēĐ°ĐˇŅ‹Đ˛Đ°Ņ‚ŅŒ иĐēĐžĐŊĐē҃ ŅŅ‚Đ°Ņ‚ŅƒŅĐ° вĐŧĐĩŅŅ‚Đž ĐŋŅ€ĐĩĐ´ŅƒĐŋŅ€ĐĩĐļĐ´ĐĩĐŊĐ¸Ņ Đž Ņ„Đ°ĐšĐģĐ°Ņ…"; - readonly zh: "昞į¤ēįŠļ态回标īŧŒč€Œéžæ–‡äģļč­Ļ告æ¨Ēåš…"; - readonly "zh-tw": "äģĨį‹€æ…‹åœ–į¤ē取äģŖæĒ”æĄˆč­Ļ告æŠĢåš…"; - }; - readonly "Show status inside the editor": { - readonly def: "Show status inside the editor"; - readonly es: "Mostrar estado dentro del editor"; - readonly fr: "Afficher le statut dans l'Êditeur"; - readonly he: "ה×Ļג סטטוס ב×Ēוך ה×ĸורך"; - readonly ja: "゚テãƒŧã‚ŋã‚šã‚’ã‚¨ãƒ‡ã‚Ŗã‚ŋ内ãĢ襨į¤ē"; - readonly ko: "íŽ¸ė§‘ę¸° 내ëļ€ė— ėƒíƒœ í‘œė‹œ"; - readonly ru: "ПоĐēĐ°ĐˇŅ‹Đ˛Đ°Ņ‚ŅŒ ŅŅ‚Đ°Ņ‚ŅƒŅ вĐŊŅƒŅ‚Ņ€Đ¸ Ņ€ĐĩдаĐēŅ‚ĐžŅ€Đ°"; - readonly zh: "在įŧ–čž‘å™¨å†…æ˜žį¤ēįŠļ态"; - }; - readonly "Show status on the status bar": { - readonly def: "Show status on the status bar"; - readonly es: "Mostrar estado en la barra de estado"; - readonly fr: "Afficher le statut dans la barre d'Êtat"; - readonly he: "ה×Ļג סטטוס בשור×Ē ×”×ž×Ļב"; - readonly ja: "゚テãƒŧã‚ŋ゚バãƒŧãĢ、゚テãƒŧã‚ŋã‚šã‚’čĄ¨į¤ē"; - readonly ko: "ėƒíƒœ ë°”ė— ėƒíƒœ í‘œė‹œ"; - readonly ru: "ПоĐēĐ°ĐˇŅ‹Đ˛Đ°Ņ‚ŅŒ ŅŅ‚Đ°Ņ‚ŅƒŅ в ŅŅ‚Ņ€ĐžĐēĐĩ ŅĐžŅŅ‚ĐžŅĐŊĐ¸Ņ"; - readonly zh: "在įŠļ态栏上昞į¤ēįŠļ态"; - }; - readonly "Show verbose log. Please enable if you report an issue.": { - readonly def: "Show verbose log. Please enable if you report an issue."; - readonly es: "Mostrar registro detallado. Actívelo si reporta un problema."; - readonly fr: "Afficher un journal verbeux. À activer si vous signalez un problème."; - readonly he: "ה×Ļג יומן מפורט. אנא הפ×ĸל אם א×Ēה מדווח ×ĸל ב×ĸיה."; - readonly ja: "エナãƒŧäģĨå¤–ãŽčŠŗį´°ãƒ­ã‚°é …į›Žã‚‚čĄ¨į¤ēã™ã‚‹ã€‚å•éĄŒãŒį™ēį”Ÿã—ãŸå ´åˆã¯æœ‰åŠšãĢしãĻください。"; - readonly ko: "ėžė„¸í•œ 로그ëĨŧ í‘œė‹œí•Šë‹ˆë‹¤. ëŦ¸ė œëĨŧ ė‹ ęŗ í•˜ëŠ” ę˛Ŋ뚰 í™œė„ąí™”í•´ ėŖŧė„¸ėš”."; - readonly ru: "ПоĐēĐ°ĐˇŅ‹Đ˛Đ°Ņ‚ŅŒ ĐŋĐžĐ´Ņ€ĐžĐąĐŊŅ‹Đš ĐģĐžĐŗ. ПоĐļаĐģŅƒĐšŅŅ‚Đ°, вĐēĐģŅŽŅ‡Đ¸Ņ‚Đĩ ĐŋŅ€Đ¸ ŅĐžĐžĐąŅ‰ĐĩĐŊии Đž ĐŋŅ€ĐžĐąĐģĐĩĐŧĐĩ."; - readonly zh: "昞į¤ēč¯Ļį솿—Ĩåŋ—。åĻ‚æžœæ‚¨æŠĨ告闎éĸ˜,č¯ˇå¯į”¨æ­¤é€‰éĄš "; - }; - readonly "Some devices have differing progress values (max: ${maxProgress}, min: ${minProgress}).\nThis may indicate that some devices have not completed synchronisation, which could lead to conflicts. Strongly recommend confirming that all devices are synchronised before proceeding.": { - readonly def: "Some devices have differing progress values (max: ${maxProgress}, min: ${minProgress}).\nThis may indicate that some devices have not completed synchronisation, which could lead to conflicts. Strongly recommend confirming that all devices are synchronised before proceeding."; - readonly ja: "ä¸€éƒ¨ãŽãƒ‡ãƒã‚¤ã‚šã§é€˛æ—å€¤ãŒį•°ãĒãŖãĻいぞすīŧˆæœ€å¤§: ${maxProgress}、最小: ${minProgress}īŧ‰ã€‚\nこれは一部ぎデバイ゚で同期が厌äē†ã—ãĻいãĒい可čƒŊ性をį¤ēしãĻおり、įĢļ合ぎ原因ãĢãĒることがありぞす。įļščĄŒã™ã‚‹å‰ãĢ、すずãĻぎデバイ゚が同期済ãŋであることをįĸēčĒã™ã‚‹ã“ã¨ã‚’åŧˇããŠã™ã™ã‚ã—ぞす。"; - readonly ko: "ėŧëļ€ ę¸°ę¸°ė˜ ė§„í–‰ ę°’ė´ 다ëĻ…ë‹ˆë‹¤(ėĩœëŒ€: ${maxProgress}, ėĩœė†Œ: ${minProgress}).\nė´ëŠ” ėŧëļ€ ę¸°ę¸°ę°€ 동기화ëĨŧ ė™„ëŖŒí•˜ė§€ ė•Šė•˜ėŒė„ ė˜ë¯¸í•  눘 ėžˆėœŧ늰, ėļŠëŒëĄœ ė´ė–´ė§ˆ 눘 ėžˆėŠĩ니다. ęŗ„ė† ė§„í–‰í•˜ę¸° 렄뗐 ëĒ¨ë“  기기가 ë™ę¸°í™”ë˜ė—ˆëŠ”ė§€ ë°˜ë“œė‹œ í™•ė¸í•˜ëŠ” ę˛ƒė„ 강ë Ĩ히 ęļŒėžĨ합니다."; - readonly ru: "ĐŖ ĐŊĐĩĐēĐžŅ‚ĐžŅ€Ņ‹Ņ… ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛ Ņ€Đ°ĐˇĐģĐ¸Ņ‡Đ°ŅŽŅ‚ŅŅ СĐŊĐ°Ņ‡ĐĩĐŊĐ¸Ņ ĐŋŅ€ĐžĐŗŅ€ĐĩŅŅĐ° (ĐŧаĐēҁ.: ${maxProgress}, ĐŧиĐŊ.: ${minProgress}).\nĐ­Ņ‚Đž ĐŧĐžĐļĐĩŅ‚ ОСĐŊĐ°Ņ‡Đ°Ņ‚ŅŒ, Ņ‡Ņ‚Đž ĐŊĐĩĐēĐžŅ‚ĐžŅ€Ņ‹Đĩ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ° Đĩ҉ґ ĐŊĐĩ СавĐĩŅ€ŅˆĐ¸Đģи ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸ŅŽ, Ņ‡Ņ‚Đž ĐŧĐžĐļĐĩŅ‚ ĐŋŅ€Đ¸Đ˛ĐĩŅŅ‚Đ¸ Đē ĐēĐžĐŊŅ„ĐģиĐēŅ‚Đ°Đŧ. ĐĐ°ŅŅ‚ĐžŅŅ‚ĐĩĐģҌĐŊĐž Ņ€ĐĩĐēĐžĐŧĐĩĐŊĐ´ŅƒĐĩŅ‚ŅŅ ĐŋĐĩŅ€ĐĩĐ´ ĐŋŅ€ĐžĐ´ĐžĐģĐļĐĩĐŊиĐĩĐŧ ŅƒĐąĐĩĐ´Đ¸Ņ‚ŅŒŅŅ, Ņ‡Ņ‚Đž Đ˛ŅĐĩ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ° ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ¸Ņ€ĐžĐ˛Đ°ĐŊŅ‹."; - readonly zh: "某äē›čŽžå¤‡įš„čŋ›åēĻå€ŧ不同īŧˆæœ€å¤§īŧš${maxProgress}īŧŒæœ€å°īŧš${minProgress}īŧ‰ã€‚\nčŋ™å¯čƒŊ襨į¤ē某äē›čŽžå¤‡å°šæœĒ厌成同æ­ĨīŧŒäģŽč€Œå¯čƒŊåŧ•å‘å†˛įĒã€‚åŧē჈åģēčŽŽåœ¨įģ§įģ­äš‹å‰å…ˆįĄŽčŽ¤æ‰€æœ‰čŽžå¤‡éƒŊ厞同æ­Ĩ。"; - readonly "zh-tw": "某äē›čŖįŊŽįš„進åēĻå€ŧ不同īŧˆæœ€å¤§īŧš${maxProgress}īŧŒæœ€å°īŧš${minProgress}īŧ‰ã€‚\n這可čƒŊ襨į¤ē某äē›čŖįŊŽå°šæœĒ厌成同æ­ĨīŧŒé€˛č€Œå¯čƒŊå°Žč‡´čĄįĒã€‚åŧˇįƒˆåģē議圍įšŧįēŒäš‹å‰å…ˆįĸēčĒæ‰€æœ‰čŖįŊŽéƒŊ厞同æ­Ĩ。"; - }; - readonly "Starts synchronisation when a file is saved.": { - readonly def: "Starts synchronisation when a file is saved."; - readonly es: "Inicia sincronizaciÃŗn al guardar un archivo"; - readonly fr: "DÊmarre la synchronisation à l'enregistrement d'un fichier."; - readonly he: "מ×Ēחיל סנכרון כאשר קוב×Ĩ נ׊מר."; - readonly ja: "ãƒ•ã‚Ąã‚¤ãƒĢがäŋå­˜ã•れたときãĢ同期を開始しぞす。"; - readonly ko: "파ėŧė´ ė €ėžĨ될 때 동기화ëĨŧ ė‹œėž‘í•Šë‹ˆë‹¤."; - readonly ru: "ЗаĐŋ҃ҁĐēĐ°Ņ‚ŅŒ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸ŅŽ ĐŋŅ€Đ¸ ŅĐžŅ…Ņ€Đ°ĐŊĐĩĐŊии Ņ„Đ°ĐšĐģа."; - readonly zh: "åŊ“æ–‡äģļäŋå­˜æ—ļ启动同æ­Ĩ "; - }; - readonly "Stop reflecting database changes to storage files.": { - readonly def: "Stop reflecting database changes to storage files."; - readonly es: "Dejar de reflejar cambios de BD en archivos"; - readonly fr: "ArrÃĒter de rÊpercuter les modifications de la base vers les fichiers de stockage."; - readonly he: "הפסק ×œ×Š×§×Ŗ שינויי מסד × ×Ēונים לקב×Ļי אחסון."; - readonly ja: "デãƒŧã‚ŋベãƒŧ゚ぎ変更を゚トãƒŦãƒŧã‚¸ãƒ•ã‚Ąã‚¤ãƒĢãĢ反映させãĒい"; - readonly ko: "ë°ė´í„°ë˛ ė´ėŠ¤ ëŗ€ę˛Ŋ ė‚Ŧí•­ė„ ėŠ¤í† ëĻŦė§€ 파ėŧ뗐 ë°˜ė˜í•˜ëŠ” ę˛ƒė„ ė¤‘ë‹¨í•Šë‹ˆë‹¤."; - readonly ru: "ĐžŅŅ‚Đ°ĐŊĐžĐ˛Đ¸Ņ‚ŅŒ ĐžŅ‚Ņ€Đ°ĐļĐĩĐŊиĐĩ иСĐŧĐĩĐŊĐĩĐŊиК ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅ в Ņ„Đ°ĐšĐģŅ‹ Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰Đ°."; - readonly zh: "停æ­ĸ将数捎å瓿›´æ”šåæ˜ åˆ°å­˜å‚¨æ–‡äģļ "; - }; - readonly "Stop watching for file changes.": { - readonly def: "Stop watching for file changes."; - readonly es: "Dejar de monitorear cambios en archivos"; - readonly fr: "ArrÃĒter la surveillance des modifications de fichiers."; - readonly he: "הפסק ל×ĸקוב אחר שינויי קב×Ļים."; - readonly ja: "ᛪčĻ–ãŽåœæ­ĸ"; - readonly ko: "파ėŧ ëŗ€ę˛Ŋ ė‚Ŧ항 ę°ė‹œëĨŧ ė¤‘ë‹¨í•Šë‹ˆë‹¤."; - readonly ru: "ĐžŅŅ‚Đ°ĐŊĐžĐ˛Đ¸Ņ‚ŅŒ ĐžŅ‚ŅĐģĐĩĐļиваĐŊиĐĩ иСĐŧĐĩĐŊĐĩĐŊиК Ņ„Đ°ĐšĐģОв."; - readonly zh: "停æ­ĸį›‘č§†æ–‡äģļæ›´æ”š "; - }; - readonly "Storage -> Database": { - readonly def: "Storage -> Database"; - readonly "zh-tw": "å„˛å­˜įŠē間 -> čŗ‡æ–™åēĢ"; - }; - readonly "Suppress notification of hidden files change": { - readonly def: "Suppress notification of hidden files change"; - readonly es: "Suprimir notificaciones de cambios en archivos ocultos"; - readonly fr: "Supprimer les notifications de modification des fichiers cachÊs"; - readonly he: "דחוק ה×Ēרא×Ē ×Š×™× ×•×™ קב×Ļים נץ×Ēרים"; - readonly ja: "éš ã—ãƒ•ã‚Ąã‚¤ãƒĢぎ変更通įŸĨを抑åˆļ"; - readonly ko: "ėˆ¨ę˛¨ė§„ 파ėŧ ëŗ€ę˛Ŋ ė•ŒëĻŧ ė–ĩ렜"; - readonly ru: "ПодавĐģŅŅ‚ŅŒ ŅƒĐ˛ĐĩĐ´ĐžĐŧĐģĐĩĐŊĐ¸Ņ Ой иСĐŧĐĩĐŊĐĩĐŊии ҁĐēҀҋ҂ҋ҅ Ņ„Đ°ĐšĐģОв"; - readonly zh: "æš‚åœéšč—æ–‡äģļæ›´æ”šįš„通įŸĨ"; - }; - readonly "Suspend database reflecting": { - readonly def: "Suspend database reflecting"; - readonly es: "Suspender reflejo de base de datos"; - readonly fr: "Suspendre la rÊpercussion dans la base"; - readonly he: "השהה ×Š×™×§×•×Ŗ מסד × ×Ēונים"; - readonly ja: "デãƒŧã‚ŋベãƒŧ゚反映ぎ一時停æ­ĸ"; - readonly ko: "ë°ė´í„°ë˛ ė´ėŠ¤ ë°˜ė˜ ėŧė‹œ ė¤‘ë‹¨"; - readonly ru: "ĐŸŅ€Đ¸ĐžŅŅ‚Đ°ĐŊĐžĐ˛Đ¸Ņ‚ŅŒ ĐžŅ‚Ņ€Đ°ĐļĐĩĐŊиĐĩ ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅"; - readonly zh: "暂停数捎åē“反映"; - }; - readonly "Suspend file watching": { - readonly def: "Suspend file watching"; - readonly es: "Suspender monitorizaciÃŗn de archivos"; - readonly fr: "Suspendre la surveillance des fichiers"; - readonly he: "השהה מ×ĸקב קב×Ļים"; - readonly ja: "ᛪčĻ–ãŽä¸€æ™‚åœæ­ĸ"; - readonly ko: "파ėŧ ę°ė‹œ ėŧė‹œ ė¤‘ë‹¨"; - readonly ru: "ĐŸŅ€Đ¸ĐžŅŅ‚Đ°ĐŊĐžĐ˛Đ¸Ņ‚ŅŒ ĐžŅ‚ŅĐģĐĩĐļиваĐŊиĐĩ Ņ„Đ°ĐšĐģОв"; - readonly zh: "暂停文äģļᛑ视"; - }; - readonly "Switch to IDB": { - readonly def: "Switch to IDB"; - readonly es: "Cambiar a IDB"; - readonly ja: "IDB ãĢ切りæ›ŋえる"; - readonly ko: "IDB로 ė „í™˜"; - readonly ru: "ПĐĩŅ€ĐĩĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒŅŅ ĐŊа IDB"; - readonly zh: "切æĸ到 IDB"; - readonly "zh-tw": "åˆ‡æ›č‡ŗ IDB"; - }; - readonly "Switch to IndexedDB": { - readonly def: "Switch to IndexedDB"; - readonly es: "Cambiar a IndexedDB"; - readonly ja: "IndexedDB ãĢ切りæ›ŋえる"; - readonly ko: "IndexedDB로 ė „í™˜"; - readonly ru: "ПĐĩŅ€ĐĩĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒŅŅ ĐŊа IndexedDB"; - readonly zh: "切æĸ到 IndexedDB"; - readonly "zh-tw": "åˆ‡æ›č‡ŗ IndexedDB"; - }; - readonly "Sync after merging file": { - readonly def: "Sync after merging file"; - readonly es: "Sincronizar tras fusionar archivo"; - readonly fr: "Synchroniser après fusion d'un fichier"; - readonly he: "סנכרן לאחר מיזוג קוב×Ĩ"; - readonly ja: "ãƒ•ã‚Ąã‚¤ãƒĢがマãƒŧジ(įĩąåˆ)された時ãĢ同期"; - readonly ko: "파ėŧ ëŗ‘í•Š 후 동기화"; - readonly ru: "ХиĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ ĐŋĐžŅĐģĐĩ ҁĐģĐ¸ŅĐŊĐ¸Ņ Ņ„Đ°ĐšĐģа"; - readonly zh: "合åšļ文äģļ后同æ­Ĩ"; - }; - readonly "Sync automatically after merging files": { - readonly def: "Sync automatically after merging files"; - readonly es: "Sincronizar automÃĄticamente tras fusionar archivos"; - readonly fr: "Synchroniser automatiquement après fusion des fichiers"; - readonly he: "סנכרן אוטומטי×Ē ×œ××—×¨ מיזוג קב×Ļים"; - readonly ja: "ãƒ•ã‚Ąã‚¤ãƒĢぎマãƒŧジ垌ãĢč‡Ēå‹•įš„ãĢ同期"; - readonly ko: "파ėŧ ëŗ‘í•Š 후 ėžë™ėœŧ로 동기화"; - readonly ru: "ХиĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ Đ°Đ˛Ņ‚ĐžĐŧĐ°Ņ‚Đ¸Ņ‡ĐĩҁĐēи ĐŋĐžŅĐģĐĩ ҁĐģĐ¸ŅĐŊĐ¸Ņ Ņ„Đ°ĐšĐģОв"; - readonly zh: "合åšļ文äģļ后č‡Ē动同æ­Ĩ"; - }; - readonly "Sync Mode": { - readonly def: "Sync Mode"; - readonly es: "Modo de sincronizaciÃŗn"; - readonly fr: "Mode de synchronisation"; - readonly he: "מ×Ļב סנכרון"; - readonly ja: "同期ãƒĸãƒŧド"; - readonly ko: "동기화 ëĒ¨ë“œ"; - readonly ru: "Đ ĐĩĐļиĐŧ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸"; - readonly zh: "同æ­Ĩæ¨Ąåŧ"; - }; - readonly "Sync on Editor Save": { - readonly def: "Sync on Editor Save"; - readonly es: "Sincronizar al guardar en editor"; - readonly fr: "Synchroniser à l'enregistrement dans l'Êditeur"; - readonly he: "סנכרן בשמיר×Ē ×ĸורך"; - readonly ja: "ã‚¨ãƒ‡ã‚Ŗã‚ŋでぎäŋå­˜æ™‚ãĢ、同期されぞす"; - readonly ko: "íŽ¸ė§‘ę¸° ė €ėžĨ ė‹œ 동기화"; - readonly ru: "ХиĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Ņ ĐŋŅ€Đ¸ ŅĐžŅ…Ņ€Đ°ĐŊĐĩĐŊии в Ņ€ĐĩдаĐēŅ‚ĐžŅ€Đĩ"; - readonly zh: "įŧ–čž‘å™¨äŋå­˜æ—ļ同æ­Ĩ"; - }; - readonly "Sync on File Open": { - readonly def: "Sync on File Open"; - readonly es: "Sincronizar al abrir archivo"; - readonly fr: "Synchroniser à l'ouverture d'un fichier"; - readonly he: "סנכרן בפ×Ēיח×Ē ×§×•×‘×Ĩ"; - readonly ja: "ãƒ•ã‚Ąã‚¤ãƒĢを開いた時ãĢ同期"; - readonly ko: "파ėŧ 뗴揰 ė‹œ 동기화"; - readonly ru: "ХиĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Ņ ĐŋŅ€Đ¸ ĐžŅ‚ĐēŅ€Ņ‹Ņ‚Đ¸Đ¸ Ņ„Đ°ĐšĐģа"; - readonly zh: "打åŧ€æ–‡äģļæ—ļ同æ­Ĩ"; - }; - readonly "Sync on Save": { - readonly def: "Sync on Save"; - readonly es: "Sincronizar al guardar"; - readonly fr: "Synchroniser à l'enregistrement"; - readonly he: "סנכרן בשמירה"; - readonly ja: "äŋå­˜æ™‚ãĢ同期"; - readonly ko: "ė €ėžĨ ė‹œ 동기화"; - readonly ru: "ХиĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Ņ ĐŋŅ€Đ¸ ŅĐžŅ…Ņ€Đ°ĐŊĐĩĐŊии"; - readonly zh: "äŋå­˜æ—ļ同æ­Ĩ"; - }; - readonly "Sync on Startup": { - readonly def: "Sync on Startup"; - readonly es: "Sincronizar al iniciar"; - readonly fr: "Synchroniser au dÊmarrage"; - readonly he: "סנכרן בהפ×ĸלה"; - readonly ja: "čĩˇå‹•時同期"; - readonly ko: "ė‹œėž‘ ė‹œ 동기화"; - readonly ru: "ХиĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Ņ ĐŋŅ€Đ¸ СаĐŋ҃ҁĐēĐĩ"; - readonly zh: "启动æ—ļ同æ­Ĩ"; - }; - readonly "Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage.": { - readonly def: "Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage."; - readonly es: "SincronizaciÃŗn mediante archivos de registro. Debe haber configurado un almacenamiento de objetos compatible con S3/MinIO/R2。"; - readonly ja: "ã‚¸ãƒŖãƒŧナãƒĢãƒ•ã‚Ąã‚¤ãƒĢã‚’åˆŠį”¨ã™ã‚‹åŒæœŸæ–šåŧã§ã™ã€‚S3/MinIO/R2 äē’æ›ãŽã‚Ēブジェクト゚トãƒŦãƒŧジをäē‹å‰ãĢ構成しãĻおくåŋ…čĻãŒã‚ã‚Šãžã™ã€‚"; - readonly ko: "ė €ë„ 파ėŧė„ í™œėšŠí•˜ëŠ” 동기화 ë°Šė‹ėž…ë‹ˆë‹¤. S3/MinIO/R2 호환 ę°ė˛´ ėŠ¤í† ëĻŦė§€ëĨŧ 미ëĻŦ ęĩŦė„ąí•´ė•ŧ 핊니다。"; - readonly ru: "ХиĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Ņ ҁ Đ¸ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°ĐŊиĐĩĐŧ Ņ„Đ°ĐšĐģОв ĐļŅƒŅ€ĐŊаĐģа. НĐĩĐžĐąŅ…ĐžĐ´Đ¸ĐŧĐž ĐˇĐ°Ņ€Đ°ĐŊĐĩĐĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐ¸Ņ‚ŅŒ ĐžĐąŅŠĐĩĐēŅ‚ĐŊĐžĐĩ Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰Đĩ, ŅĐžĐ˛ĐŧĐĩŅŅ‚Đ¸ĐŧĐžĐĩ ҁ S3/MinIO/R2。"; - readonly zh: "通čŋ‡æ—Ĩåŋ—æ–‡äģļčŋ›čĄŒåŒæ­Ĩ。äŊ éœ€čρäē‹å…ˆéƒ¨įŊ˛åĨŊå…ŧ厚 S3/MinIO/R2 įš„å¯ščąĄå­˜å‚¨æœåŠĄã€‚"; - readonly "zh-tw": "透過æ—ĨčnjæĒ”é€˛čĄŒåŒæ­Ĩ。äŊ éœ€čρäē‹å…ˆéƒ¨įŊ˛åĨŊį›¸åŽš S3/MinIO/R2 įš„į‰Šäģļå„˛å­˜æœå‹™ã€‚"; - }; - readonly "Synchronising files": { - readonly def: "Synchronising files"; - readonly es: "Archivos sincronizados"; - readonly ja: "åŒæœŸã™ã‚‹ãƒ•ã‚Ąã‚¤ãƒĢ"; - readonly ko: "동기화할 파ėŧ"; - readonly ru: "ХиĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ¸Ņ€ŅƒĐĩĐŧŅ‹Đĩ Ņ„Đ°ĐšĐģŅ‹"; - readonly zh: "同æ­Ĩ文äģļ"; - readonly "zh-tw": "同æ­Ĩä¸­įš„æĒ”æĄˆ"; - }; - readonly Syncing: { - readonly def: "Syncing"; - readonly es: "SincronizaciÃŗn"; - readonly ja: "同期"; - readonly ko: "동기화"; - readonly ru: "ХиĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Ņ"; - readonly zh: "同æ­Ĩ中"; - readonly "zh-tw": "同æ­Ĩ中"; - }; - readonly "Target patterns": { - readonly def: "Target patterns"; - readonly es: "Patrones objetivo"; - readonly ja: "å¯žčąĄãƒ‘ã‚ŋãƒŧãƒŗ"; - readonly ko: "ëŒ€ėƒ 패턴"; - readonly ru: "ĐĻĐĩĐģĐĩĐ˛Ņ‹Đĩ ŅˆĐ°ĐąĐģĐžĐŊŅ‹"; - readonly zh: "į›Žæ ‡æ¨Ąåŧ"; - readonly "zh-tw": "į›Žæ¨™æ¨Ąåŧ"; - }; - readonly "Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.": { - readonly def: "Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned."; - readonly es: "Solo pruebas - Resolver conflictos sincronizando copias nuevas (puede sobrescribir modificaciones)"; - readonly fr: "Test uniquement - RÊsout les conflits de fichiers en synchronisant les copies plus rÊcentes, ce qui peut Êcraser des fichiers modifiÊs. Prudence."; - readonly he: "לבדיקה בלבד - פ×Ēור קונפליקטי קב×Ļים ×ĸל ידי סנכרון ×ĸו×Ēקים חדשים יו×Ēר של הקוב×Ĩ, פ×ĸולה זו ×ĸלולה לדרוס קב×Ļים שונו. היה מוזהר."; - readonly ja: "ãƒ†ã‚šãƒˆį”¨ - ãƒ•ã‚Ąã‚¤ãƒĢãŽæ–°ã—ã„ã‚ŗãƒ”ãƒŧを同期しãĻãƒ•ã‚Ąã‚¤ãƒĢįĢļåˆã‚’č§Ŗæąēしぞす。これãĢã‚ˆã‚Šå¤‰æ›´ã•ã‚ŒãŸãƒ•ã‚Ąã‚¤ãƒĢが上書きされる可čƒŊæ€§ãŒã‚ã‚Šãžã™ã€‚æŗ¨æ„ã—ãĻください。"; - readonly ko: "í…ŒėŠ¤íŠ¸ ė „ėšŠ - 파ėŧė˜ ėƒˆëĄœėš´ ė‚Ŧëŗ¸ė„ 동기화하ė—Ŧ 파ėŧ ėļŠëŒė„ 해결하며, ėˆ˜ė •ëœ 파ėŧė„ ëŽė–´ė“¸ 눘 ėžˆėŠĩ니다. ėŖŧė˜í•˜ė„¸ėš”."; - readonly ru: "ĐĸĐžĐģҌĐēĐž Đ´ĐģŅ Ņ‚ĐĩŅŅ‚Đ¸Ņ€ĐžĐ˛Đ°ĐŊĐ¸Ņ - Ņ€Đ°ĐˇŅ€ĐĩŅˆĐ°Ņ‚ŅŒ ĐēĐžĐŊŅ„ĐģиĐē҂ҋ Ņ„Đ°ĐšĐģОв ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸ĐĩĐš ĐŊĐžĐ˛Ņ‹Ņ… ĐēĐžĐŋиК."; - readonly zh: "äģ…äž›æĩ‹č¯• - 通čŋ‡åŒæ­Ĩ文äģļįš„čžƒæ–°å‰¯æœŦæĨč§Ŗå†ŗæ–‡äģļ冞įǁ,čŋ™å¯čƒŊäŧščφᛖäŋŽæ”ščŋ‡įš„æ–‡äģļã€‚č¯ˇæŗ¨æ„ "; - }; - readonly "The delay for consecutive on-demand fetches": { - readonly def: "The delay for consecutive on-demand fetches"; - readonly es: "Retraso entre obtenciones consecutivas"; - readonly fr: "Le dÊlai entre rÊcupÊrations consÊcutives à la demande"; - readonly he: "ה×ĸיכוב ×ĸבור משיכו×Ē ×œ×¤×™ דרישה ×ĸוקבו×Ē"; - readonly ja: "逪įļšã—たã‚Ēãƒŗãƒ‡ãƒžãƒŗãƒ‰ãƒ•ã‚§ãƒƒãƒãŽé…åģļ"; - readonly ko: "ė—°ė† 랭íŦ ėš”ė˛­ 간 대기 ė‹œę°„"; - readonly ru: "ЗадĐĩŅ€ĐļĐēа Đ´ĐģŅ ĐŋĐžŅĐģĐĩĐ´ĐžĐ˛Đ°Ņ‚ĐĩĐģҌĐŊҋ҅ СаĐŋŅ€ĐžŅĐžĐ˛ ĐŋĐž ҂ҀĐĩйОваĐŊĐ¸ŅŽ"; - readonly zh: "čŋžįģ­æŒ‰éœ€čŽˇå–įš„åģļčŋŸ"; - }; - readonly "The following accepted nodes are missing its node information:\n- ${missingNodes}\n\nThis indicates that they have not been connected for some time or have been left on an older version.\nIt is preferable to update all devices if possible. If you have any devices that are no longer in use, you can clear all accepted nodes by locking the remote once.": { - readonly def: "The following accepted nodes are missing its node information:\n- ${missingNodes}\n\nThis indicates that they have not been connected for some time or have been left on an older version.\nIt is preferable to update all devices if possible. If you have any devices that are no longer in use, you can clear all accepted nodes by locking the remote once."; - readonly ja: "æŦĄãŽæ‰ŋčĒæ¸ˆãŋノãƒŧドãĢはノãƒŧãƒ‰æƒ…å ąãŒã‚ã‚Šãžã›ã‚“:\n- ${missingNodes}\n\nこれは、それらがしばらくæŽĨįļšã•れãĻいãĒいか、古いバãƒŧã‚¸ãƒ§ãƒŗãŽãžãžãĢãĒãŖãĻいることをį¤ēしãĻいぞす。\n可čƒŊであれば、ぞずすずãĻぎデバイ゚を更新することをおすすめしぞす。すでãĢäŊŋį”¨ã—ãĻいãĒいデバイ゚がある場合は、ãƒĒãƒĸãƒŧトを一åēĻロックすることでæ‰ŋčĒæ¸ˆãŋノãƒŧドをすずãĻクãƒĒã‚ĸできぞす。"; - readonly ko: "ë‹¤ėŒ ėŠšė¸ëœ ë…¸ë“œė—ëŠ” 노드 ė •ëŗ´ę°€ ė—†ėŠĩ니다:\n- ${missingNodes}\n\nė´ëŠ” 해당 노드가 í•œë™ė•ˆ ė—°ę˛°ë˜ė§€ ė•Šė•˜ęą°ë‚˜ ė´ė „ ë˛„ė „ė— 머ëŦŧëŸŦ ėžˆėŒė„ ė˜ë¯¸í•Šë‹ˆë‹¤.\n가ëŠĨ하다면 ë¨ŧė € ëĒ¨ë“  기기ëĨŧ ė—…ë°ė´íŠ¸í•˜ëŠ” ę˛ƒė´ ėĸ‹ėŠĩ니다. 더 ė´ėƒ ė‚ŦėšŠí•˜ė§€ ė•ŠëŠ” 기기가 ėžˆë‹¤ëŠ´ ė›ę˛Šė„ 한 번 ėž ę°€ ėŠšė¸ëœ 노드ëĨŧ ëĒ¨ë‘ ė •ëĻŦ할 눘 ėžˆėŠĩ니다."; - readonly ru: "ДĐģŅ ҁĐģĐĩĐ´ŅƒŅŽŅ‰Đ¸Ņ… ĐŋŅ€Đ¸ĐŊŅŅ‚Ņ‹Ņ… ŅƒĐˇĐģОв ĐžŅ‚ŅŅƒŅ‚ŅŅ‚Đ˛ŅƒĐĩŅ‚ иĐŊŅ„ĐžŅ€ĐŧĐ°Ņ†Đ¸Ņ Ой ŅƒĐˇĐģĐĩ:\n- ${missingNodes}\n\nĐ­Ņ‚Đž ОСĐŊĐ°Ņ‡Đ°ĐĩŅ‚, Ņ‡Ņ‚Đž ĐžĐŊи давĐŊĐž ĐŊĐĩ ĐŋОдĐēĐģŅŽŅ‡Đ°ĐģĐ¸ŅŅŒ иĐģи ĐžŅŅ‚Đ°ĐģĐ¸ŅŅŒ ĐŊа ŅŅ‚Đ°Ņ€ĐžĐš вĐĩŅ€ŅĐ¸Đ¸.\nПо вОСĐŧĐžĐļĐŊĐžŅŅ‚Đ¸ Ņ€ĐĩĐēĐžĐŧĐĩĐŊĐ´ŅƒĐĩŅ‚ŅŅ ҁĐŊĐ°Ņ‡Đ°Đģа ОйĐŊĐžĐ˛Đ¸Ņ‚ŅŒ Đ˛ŅĐĩ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°. Đ•ŅĐģи ҃ Đ˛Đ°Ņ ĐĩŅŅ‚ŅŒ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°, ĐēĐžŅ‚ĐžŅ€Ņ‹Đĩ йОĐģҌ҈Đĩ ĐŊĐĩ Đ¸ŅĐŋĐžĐģŅŒĐˇŅƒŅŽŅ‚ŅŅ, Đ˛Ņ‹ ĐŧĐžĐļĐĩŅ‚Đĩ ĐžŅ‡Đ¸ŅŅ‚Đ¸Ņ‚ŅŒ ҁĐŋĐ¸ŅĐžĐē Đ˛ŅĐĩŅ… ĐŋŅ€Đ¸ĐŊŅŅ‚Ņ‹Ņ… ŅƒĐˇĐģОв, ОдиĐŊ Ņ€Đ°Đˇ СайĐģĐžĐēĐ¸Ņ€ĐžĐ˛Đ°Đ˛ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊŅƒŅŽ ĐąĐ°ĐˇŅƒ."; - readonly zh: "äģĨ䏋厞æŽĨå—čŠ‚į‚šįŧēå°‘čŠ‚į‚šäŋĄæ¯īŧš\n- ${missingNodes}\n\nčŋ™čĄ¨į¤ē厃äģŦåˇ˛æœ‰ä¸€æŽĩæ—ļ间æœĒčŋžæŽĨīŧŒæˆ–äģåœį•™åœ¨čžƒæ—§į‰ˆæœŦ。\nåĻ‚æœ‰å¯čƒŊīŧŒåģēčŽŽå…ˆæ›´æ–°æ‰€æœ‰čŽžå¤‡ã€‚åĻ‚æžœæœ‰åˇ˛ä¸å†äŊŋį”¨įš„čŽžå¤‡īŧŒå¯äģĨ先锁厚一æŦĄčŋœį¨‹į̝äģĨæ¸…é™¤å…¨éƒ¨åˇ˛æŽĨå—čŠ‚į‚šã€‚"; - readonly "zh-tw": "äģĨ䏋厞æŽĨå—į¯€éģžįŧē少ᝀéģžčŗ‡č¨Šīŧš\n- ${missingNodes}\n\n這表į¤ēåŽƒå€‘åˇ˛æœ‰ä¸€æŽĩ時間æœĒé€ŖįˇšīŧŒæˆ–äģåœį•™åœ¨čŧƒčˆŠį‰ˆæœŦ。\nåĻ‚æœ‰å¯čƒŊīŧŒåģēč­°å…ˆæ›´æ–°æ‰€æœ‰čŖįŊŽã€‚åĻ‚æžœæœ‰åˇ˛ä¸å†äŊŋį”¨įš„čŖįŊŽīŧŒå¯äģĨ先鎖厚一æŦĄé į̝į̝äģĨæ¸…é™¤å…¨éƒ¨åˇ˛æŽĨå—į¯€éģžã€‚"; - }; - readonly "The Hash algorithm for chunk IDs": { - readonly def: "The Hash algorithm for chunk IDs"; - readonly es: "Algoritmo hash para IDs de chunks"; - readonly fr: "L'algorithme de hachage pour les identifiants de fragments"; - readonly he: "אלגורי×Ēם Hash ×ĸבור מזהי × ×Ēחים"; - readonly ja: "ãƒãƒŖãƒŗã‚¯IDãŽãƒãƒƒã‚ˇãƒĨã‚ĸãƒĢゴãƒĒã‚ēム"; - readonly ko: "랭íŦ IDėšŠ í•´ė‹œ ė•Œęŗ ëĻŦėϘ"; - readonly ru: "ĐĨŅŅˆ-аĐģĐŗĐžŅ€Đ¸Ņ‚Đŧ Đ´ĐģŅ ID Ņ‡Đ°ĐŊĐēОв"; - readonly zh: "块 ID įš„å“ˆå¸ŒįŽ—æŗ•(厞éĒŒæ€§)"; - }; - readonly "The IndexedDB adapter often offers superior performance in certain scenarios, but it has been found to cause memory leaks when used with LiveSync mode. When using LiveSync mode, please use IDB adapter instead.": { - readonly def: "The IndexedDB adapter often offers superior performance in certain scenarios, but it has been found to cause memory leaks when used with LiveSync mode. When using LiveSync mode, please use IDB adapter instead."; - readonly "zh-tw": "IndexedDB 遊配器在某ä盿ƒ…æŗä¸‹é€šå¸¸čƒŊ提䞛čŧƒäŊŗæ•ˆčƒŊīŧŒäŊ†åœ¨ LiveSync æ¨Ąåŧä¸‹åˇ˛į™ŧįžå¯čƒŊå°Žč‡´č¨˜æ†ļéĢ”æ´Šæŧã€‚äŊŋᔍ LiveSync æ¨Ąåŧæ™‚īŧŒčĢ‹æ”šį”¨ IDB 遊配器。"; - }; - readonly "The maximum duration for which chunks can be incubated within the document. Chunks exceeding this period will graduate to independent chunks.": { - readonly def: "The maximum duration for which chunks can be incubated within the document. Chunks exceeding this period will graduate to independent chunks."; - readonly es: "DuraciÃŗn mÃĄxima para incubar chunks. Excedentes se independizan"; - readonly fr: "La durÊe maximale pendant laquelle les fragments peuvent ÃĒtre incubÊs dans le document. Les fragments dÊpassant cette pÊriode seront promus en fragments indÊpendants."; - readonly he: "משך הזמן המקסימלי ׊נ×Ēחים יכולים להישמר זמני×Ē ×‘×Ēוך המסמך. × ×Ēחים שחורגים מ×Ēקופה זו יהפכו לנ×Ēחים ×ĸ×Ļמאיים."; - readonly ja: "ドキãƒĨãƒĄãƒŗãƒˆå†…ã§ãƒãƒŖãƒŗã‚¯ã‚’äŋæŒã§ãã‚‹æœ€å¤§æœŸé–“。こぎ期間をčļ…ãˆãŸãƒãƒŖãƒŗã‚¯ã¯į‹ŦįĢ‹ã—ãŸãƒãƒŖãƒŗã‚¯ãĢ昇æ ŧしぞす。"; - readonly ko: "ëŗ€ę˛Ŋ ę¸°ëĄė´ ëŦ¸ė„œė— 함ęģ˜ ëŗ´ę´€ë  눘 ėžˆëŠ” ėĩœëŒ€ ė‹œę°„ėž…ë‹ˆë‹¤. 봈ęŗŧ ė‹œ ëŦ¸ė„œė—ė„œ ëļ„ëĻŦë˜ė–´ ę°œëŗ„ëĄœ ė €ėžĨ됩니다."; - readonly ru: "The maximum duration for which chunks can be incubated within the document. Chunks exceeding this period will graduate to independent chunks."; - readonly zh: "æ–‡æĄŖä¸­å¯äģĨå­ĩåŒ–įš„æ•°æŽå—įš„æœ€å¤§æŒįģ­æ—ļ间。čļ…čŋ‡æ­¤æ—ļé—´įš„æ•°æŽå—å°†æˆä¸ēį‹ŦįĢ‹æ•°æŽå— "; - }; - readonly "The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks.": { - readonly def: "The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks."; - readonly es: "NÃēmero mÃĄximo de chunks que pueden incubarse en el documento. Excedentes se independizan"; - readonly fr: "Le nombre maximum de fragments pouvant ÃĒtre incubÊs dans le document. Les fragments dÊpassant ce nombre seront immÊdiatement promus en fragments indÊpendants."; - readonly he: "המספר המקסימלי של × ×Ēחים שיכולים להישמר זמני×Ē ×‘×Ēוך המסמך. × ×Ēחים שחורגים ממספר זה יהפכו מיד לנ×Ēחים ×ĸ×Ļמאיים."; - readonly ja: "ドキãƒĨãƒĄãƒŗãƒˆå†…ã§äŋæŒã§ãã‚‹ãƒãƒŖãƒŗã‚¯ãŽæœ€å¤§æ•°ã€‚こぎ数をčļ…ãˆãŸãƒãƒŖãƒŗã‚¯ã¯åŗåē§ãĢį‹ŦįĢ‹ã—ãŸãƒãƒŖãƒŗã‚¯ãĢ昇æ ŧしぞす。"; - readonly ko: "ëŦ¸ė„œ ė•ˆė— ėž„ė‹œëĄœ ëŗ´ę´€í•  눘 ėžˆëŠ” ëŗ€ę˛Ŋ ę¸°ëĄė˜ ėĩœëŒ€ ę°œėˆ˜ėž…ë‹ˆë‹¤. ė´ 눘ëĨŧ 봈ęŗŧ하면 ėĻ‰ė‹œ 독ëĻŊ된 랭íŦ로 ëļ„ëĻŦë˜ė–´ ė €ėžĨ됩니다."; - readonly ru: "The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks."; - readonly zh: "æ–‡æĄŖä¸­å¯äģĨå­ĩåŒ–įš„æ•°æŽå—įš„æœ€å¤§æ•°é‡ã€‚čļ…čŋ‡æ­¤æ•°é‡įš„æ•°æŽå—å°†įĢ‹åŗæˆä¸ēį‹ŦįĢ‹æ•°æŽå— "; - }; - readonly "The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks.": { - readonly def: "The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks."; - readonly es: "TamaÃąo total mÃĄximo de chunks incubados. Excedentes se independizan"; - readonly fr: "La taille totale maximale des fragments pouvant ÃĒtre incubÊs dans le document. Les fragments dÊpassant cette taille seront immÊdiatement promus en fragments indÊpendants."; - readonly he: "הגודל הכולל המקסימלי של × ×Ēחים שיכולים להישמר זמני×Ē ×‘×Ēוך המסמך. × ×Ēחים שחורגים מגודל זה יהפכו מיד לנ×Ēחים ×ĸ×Ļמאיים."; - readonly ja: "ドキãƒĨãƒĄãƒŗãƒˆå†…ã§äŋæŒã§ãã‚‹ãƒãƒŖãƒŗã‚¯ãŽæœ€å¤§åˆč¨ˆã‚ĩイã‚ē。こぎã‚ĩイã‚ēをčļ…ãˆãŸãƒãƒŖãƒŗã‚¯ã¯åŗåē§ãĢį‹ŦįĢ‹ã—ãŸãƒãƒŖãƒŗã‚¯ãĢ昇æ ŧしぞす。"; - readonly ko: "ëŦ¸ė„œ ė•ˆė— ėž„ė‹œëĄœ ëŗ´ę´€í•  눘 ėžˆëŠ” ëŗ€ę˛Ŋ ę¸°ëĄė˜ 렄랴 íŦ기 ė œí•œėž…ë‹ˆë‹¤. 봈ęŗŧ ė‹œ ėžë™ėœŧ로 ëļ„ëĻŦ됩니다."; - readonly ru: "The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks."; - readonly zh: "æ–‡æĄŖä¸­å¯äģĨå­ĩåŒ–įš„æ•°æŽå—įš„æœ€å¤§æ€ģ大小。čļ…čŋ‡æ­¤å¤§å°įš„æ•°æŽå—å°†įĢ‹åŗæˆä¸ēį‹ŦįĢ‹æ•°æŽå— "; - }; - readonly "The minimum interval for automatic synchronisation on event.": { - readonly def: "The minimum interval for automatic synchronisation on event."; - readonly fr: "L'intervalle minimum pour la synchronisation automatique sur ÊvÊnement."; - readonly he: "מרווח הזמן המינימלי לסנכרון אוטומטי ×ĸל אירו×ĸ."; - readonly ja: "ã‚¤ãƒ™ãƒŗãƒˆį™ēį”Ÿæ™‚ãŽč‡Ē動同期ãĢおける最小間隔です。"; - readonly ko: "ė´ë˛¤íŠ¸ ë°œėƒ ė‹œ ėžë™ ë™ę¸°í™”ė˜ ėĩœė†Œ ę°„ę˛Šėž…ë‹ˆë‹¤."; - readonly ru: "МиĐŊиĐŧаĐģҌĐŊŅ‹Đš иĐŊŅ‚ĐĩŅ€Đ˛Đ°Đģ Đ°Đ˛Ņ‚ĐžĐŧĐ°Ņ‚Đ¸Ņ‡ĐĩҁĐēОК ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸ ĐŋĐž ŅĐžĐąŅ‹Ņ‚Đ¸ŅŽ."; - readonly zh: "åŸēäēŽäē‹äģļč‡Ē动同æ­Ĩįš„æœ€å°é—´éš”ã€‚"; - readonly "zh-tw": "äē‹äģļ觸į™ŧč‡Ē動同æ­Ĩįš„æœ€å°é–“éš”ã€‚"; - }; - readonly "This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.": { - readonly def: "This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer."; - readonly es: "Esta funciÃŗn permite la sincronizaciÃŗn directa entre dispositivos. No requiere servidor, pero ambos dispositivos deben estar en línea al mismo tiempo para que la sincronizaciÃŗn se produzca, y algunas funciones pueden ser limitadas. La conexiÃŗn a Internet solo se necesita para la seÃąalizaciÃŗn (detecciÃŗn de pares), no para la transferencia de datos。"; - readonly ja: "こぎ抟čƒŊではį̝æœĢ同åŖĢã‚’į›´æŽĨ同期できぞす。ã‚ĩãƒŧバãƒŧは不čĻã§ã™ãŒã€åŒæœŸã‚’čĄŒã†ãĢã¯ä¸Ąį̝æœĢが同時ãĢã‚ĒãƒŗãƒŠã‚¤ãƒŗã§ã‚ã‚‹åŋ…čĻãŒã‚ã‚Šã€ä¸€éƒ¨æŠŸčƒŊはåˆļé™ã•ã‚Œãžã™ã€‚ã‚¤ãƒŗã‚ŋãƒŧネットæŽĨįļšã¯ã‚ˇã‚°ãƒŠãƒĒãƒŗã‚°īŧˆãƒ”ã‚ĸ検å‡ēīŧ‰ãĢぎãŋåŋ…čĻã§ã€ãƒ‡ãƒŧã‚ŋčģĸ送č‡ĒäŊ“ãĢはäŊŋわれぞせん。"; - readonly ko: "ė´ 기ëŠĨė€ ėžĨėš˜ 간 링렑 동기화ëĨŧ 렜ęŗĩ합니다. ė„œë˛„ëŠ” í•„ėš” ė—†ė§€ë§Œ 동기화가 ė´ëŖ¨ė–´ė§€ë ¤ëŠ´ 두 ėžĨėš˜ę°€ ë™ė‹œė— 똍ëŧė¸ ėƒíƒœė—Ŧė•ŧ 하며 ėŧëļ€ ę¸°ëŠĨė€ ė œí•œë  눘 ėžˆėŠĩ니다. ė¸í„°ë„ˇ ė—°ę˛°ė€ ė‹œęˇ¸ë„ë§(í”ŧė–´ 氐맀)ė—ë§Œ í•„ėš”í•˜ëŠ° ë°ė´í„° ė „ė†Ą ėžė˛´ė—ëŠ” í•„ėš”í•˜ė§€ ė•ŠėŠĩ니다。"; - readonly ru: "Đ­Ņ‚Đ° Ņ„ŅƒĐŊĐēŅ†Đ¸Ņ ОйĐĩҁĐŋĐĩŅ‡Đ¸Đ˛Đ°ĐĩŅ‚ ĐŋŅ€ŅĐŧŅƒŅŽ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸ŅŽ ĐŧĐĩĐļĐ´Ņƒ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°Đŧи. ĐĄĐĩŅ€Đ˛ĐĩŅ€ ĐŊĐĩ ҂ҀĐĩĐąŅƒĐĩŅ‚ŅŅ, ĐŊĐž Đ´ĐģŅ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸ Ойа ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ° Đ´ĐžĐģĐļĐŊŅ‹ ĐąŅ‹Ņ‚ŅŒ ОдĐŊĐžĐ˛Ņ€ĐĩĐŧĐĩĐŊĐŊĐž в ҁĐĩŅ‚Đ¸, а ĐŊĐĩĐēĐžŅ‚ĐžŅ€Ņ‹Đĩ Ņ„ŅƒĐŊĐēŅ†Đ¸Đ¸ ĐŧĐžĐŗŅƒŅ‚ ĐąŅ‹Ņ‚ŅŒ ĐžĐŗŅ€Đ°ĐŊĐ¸Ņ‡ĐĩĐŊŅ‹. ПодĐēĐģŅŽŅ‡ĐĩĐŊиĐĩ Đē ИĐŊŅ‚ĐĩŅ€ĐŊĐĩŅ‚Ņƒ ĐŊ҃ĐļĐŊĐž Ņ‚ĐžĐģҌĐēĐž Đ´ĐģŅ ŅĐ¸ĐŗĐŊаĐģĐ¸ĐˇĐ°Ņ†Đ¸Đ¸ (ОйĐŊĐ°Ņ€ŅƒĐļĐĩĐŊĐ¸Ņ ĐŋĐ¸Ņ€ĐžĐ˛), а ĐŊĐĩ Đ´ĐģŅ ĐŋĐĩŅ€ĐĩĐ´Đ°Ņ‡Đ¸ даĐŊĐŊŅ‹Ņ…ã€‚"; - readonly zh: "此功čƒŊå¯åœ¨čŽžå¤‡äš‹é—´į›´æŽĨ同æ­ĨīŧŒæ— éœ€æœåŠĄå™¨īŧ›äŊ†åŒæ­Ĩæ—ļä¸¤å°čŽžå¤‡åŋ…éĄģ同æ—ļ在įēŋīŧŒä¸”部分功čƒŊ可čƒŊ受限。äē’联įŊ‘čŋžæŽĨäģ…ᔍäēŽäŋĄäģ¤īŧˆå‘įŽ°å¯šį̝īŧ‰īŧŒä¸į”¨äēŽæ•°æŽäŧ čž“。"; - readonly "zh-tw": "此功čƒŊå¯åœ¨čŖįŊŽäš‹é–“į›´æŽĨ同æ­ĨīŧŒį„Ąéœ€äŧ翜å™¨īŧ›äŊ†åŒæ­Ĩæ™‚å…Šå°čŖįŊŽåŋ…é ˆåŒæ™‚åœ¨įˇšīŧŒä¸”部分功čƒŊ可čƒŊ受限。įļ˛éš›įļ˛čˇ¯é€Ŗįˇšåƒ…ᔍæ–ŧ訊號ä礿›īŧˆåĩæ¸Ŧ對į̝īŧ‰īŧŒä¸į”¨æ–ŧčŗ‡æ–™å‚ŗčŧ¸ã€‚"; - }; - readonly "This is an advanced option for users who do not have a URI or who wish to configure detailed settings.": { - readonly def: "This is an advanced option for users who do not have a URI or who wish to configure detailed settings."; - readonly es: "Esta es una opciÃŗn avanzada para usuarios que no disponen de un URI o que desean configurar parÃĄmetros detallados。"; - readonly ja: "URI ã‚’æŒãŖãĻいãĒã„å ´åˆã‚„ã€čŠŗį´°č¨­åŽšã‚’æ‰‹å‹•ã§čĄŒã„ãŸã„ãƒĻãƒŧã‚ļãƒŧå‘ã‘ãŽä¸Šį´šč€…ã‚Ēãƒ—ã‚ˇãƒ§ãƒŗã§ã™ã€‚"; - readonly ko: "URI가 ė—†ęą°ë‚˜ 넏ëļ€ ė„¤ė •ė„ 링렑 ęĩŦė„ąí•˜ë ¤ëŠ” ė‚ŦėšŠėžëĨŧ ėœ„í•œ 溠揉 ė˜ĩė…˜ėž…ë‹ˆë‹¤ã€‚"; - readonly ru: "Đ­Ņ‚Đž Ņ€Đ°ŅŅˆĐ¸Ņ€ĐĩĐŊĐŊŅ‹Đš Đ˛Đ°Ņ€Đ¸Đ°ĐŊŅ‚ Đ´ĐģŅ ĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ĐĩĐģĐĩĐš, ҃ ĐēĐžŅ‚ĐžŅ€Ņ‹Ņ… ĐŊĐĩŅ‚ URI иĐģи ĐēĐžŅ‚ĐžŅ€Ņ‹Đĩ Ņ…ĐžŅ‚ŅŅ‚ Đ˛Ņ€ŅƒŅ‡ĐŊŅƒŅŽ ĐˇĐ°Đ´Đ°Ņ‚ŅŒ ĐŋĐžĐ´Ņ€ĐžĐąĐŊŅ‹Đĩ ĐŋĐ°Ņ€Đ°ĐŧĐĩŅ‚Ņ€Ņ‹ã€‚"; - readonly zh: "čŋ™æ˜¯éĸå‘æ˛Ąæœ‰ URI 或希望手动配įŊŽč¯Ļįģ†å‚æ•°įš„é̘įē§é€‰éĄšã€‚"; - readonly "zh-tw": "這是éĸå‘æ˛’æœ‰ URI æˆ–å¸Œæœ›æ‰‹å‹•č¨­åŽščŠŗį´°åƒæ•¸įš„é€˛éšŽé¸é …ã€‚"; - }; - readonly "This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.": { - readonly def: "This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance."; - readonly es: "Este es el mÊtodo de sincronizaciÃŗn mÃĄs adecuado para el diseÃąo. Todas las funciones estÃĄn disponibles. Debe tener configurada una instancia de CouchDB。"; - readonly ja: "ã“ãŽč¨­č¨ˆãĢ最も遊した同期斚åŧã§ã™ã€‚すずãĻぎ抟čƒŊãŒåˆŠį”¨ã§ããžã™ã€‚CouchDB ã‚¤ãƒŗã‚šã‚ŋãƒŗã‚šã‚’äē‹å‰ãĢ構成しãĻおくåŋ…čĻãŒã‚ã‚Šãžã™ã€‚"; - readonly ko: "ė´ 네溄뗐 가ėžĨ ė í•Ší•œ 동기화 ë°Šė‹ėž…ë‹ˆë‹¤. ëĒ¨ë“  기ëŠĨė„ ė‚ŦėšŠí•  눘 ėžˆėŠĩ니다. CouchDB ė¸ėŠ¤í„´ėŠ¤ëĨŧ 미ëĻŦ ęĩŦė„ąí•´ė•ŧ 핊니다。"; - readonly ru: "Đ­Ņ‚Đž ĐŊаийОĐģĐĩĐĩ ĐŋĐžĐ´Ņ…ĐžĐ´ŅŅ‰Đ¸Đš Đ´ĐģŅ даĐŊĐŊОК Đ°Ņ€Ņ…Đ¸Ņ‚ĐĩĐēŅ‚ŅƒŅ€Ņ‹ ҁĐŋĐžŅĐžĐą ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸. Đ”ĐžŅŅ‚ŅƒĐŋĐŊŅ‹ Đ˛ŅĐĩ Ņ„ŅƒĐŊĐēŅ†Đ¸Đ¸. НĐĩĐžĐąŅ…ĐžĐ´Đ¸ĐŧĐž ĐˇĐ°Ņ€Đ°ĐŊĐĩĐĩ Ņ€Đ°ĐˇĐ˛ĐĩŅ€ĐŊŅƒŅ‚ŅŒ ŅĐēСĐĩĐŧĐŋĐģŅŅ€ CouchDB。"; - readonly zh: "čŋ™æ˜¯æœ€įŦĻ合åŊ“å‰čŽžčŽĄįš„åŒæ­Ĩæ–šåŧīŧŒæ‰€æœ‰åŠŸčƒŊå‡å¯į”¨ã€‚äŊ éœ€čρäē‹å…ˆéƒ¨įŊ˛åĨŊ CouchDB 厞䞋。"; - readonly "zh-tw": "這是最įŦĻåˆį›Žå‰č¨­č¨ˆįš„åŒæ­Ĩæ–šåŧīŧŒæ‰€æœ‰åŠŸčƒŊįš†å¯äŊŋį”¨ã€‚äŊ éœ€čρäē‹å…ˆéƒ¨įŊ˛åĨŊ CouchDB å¯Ļ䞋。"; - }; - readonly "This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.": { - readonly def: "This passphrase will not be copied to another device. It will be set to `Default` until you configure it again."; - readonly es: "Esta frase no se copia a otros dispositivos. UsarÃĄ `Default` hasta reconfigurar"; - readonly fr: "Cette phrase secrète ne sera pas copiÊe vers un autre appareil. Elle sera dÊfinie à `Default` jusqu'à ce que vous la configuriez à nouveau."; - readonly he: "ביטוי סיסמה זה לא יו×ĸ×Ē×§ למכשיר אחר. הוא יוגדר ל-`Default` ×ĸד ׊×Ēגדיר או×Ēו שוב."; - readonly ja: "こぎパ゚フãƒŦãƒŧã‚ēはäģ–ぎデバイ゚ãĢã‚ŗãƒ”ãƒŧされぞせん。再åēĻč¨­åŽšã™ã‚‹ãžã§`Default`ãĢč¨­åŽšã•ã‚Œãžã™ã€‚"; - readonly ko: "ė´ íŒ¨ėŠ¤í”„ë ˆė´ėĻˆëŠ” 다ëĨ¸ 기기로 ëŗĩė‚Ŧë˜ė§€ ė•ŠėŠĩ니다. ë‹¤ė‹œ ęĩŦė„ąí•  ë•ŒęšŒė§€ `ę¸°ëŗ¸ę°’`ėœŧ로 ė„¤ė •ëŠë‹ˆë‹¤."; - readonly ru: "This passphrase will not be copied to another device. It will be set to `Default` until you configure it again."; - readonly zh: "æ­¤å¯†į ä¸äŧšå¤åˆļ到åĻä¸€å°čŽžå¤‡ã€‚åœ¨æ‚¨å†æŦĄé…įŊŽäš‹å‰,åŽƒå°†čŽžįŊŽä¸ē `Default` "; - }; - readonly "This will recreate chunks for all files. If there were missing chunks, this may fix the errors.": { - readonly def: "This will recreate chunks for all files. If there were missing chunks, this may fix the errors."; - readonly es: "Esto recrearÃĄ los fragmentos de todos los archivos. Si faltaban fragmentos, esto puede corregir los errores."; - readonly ja: "すずãĻãŽãƒ•ã‚Ąã‚¤ãƒĢãĢついãĻãƒãƒŖãƒŗã‚¯ã‚’å†äŊœæˆã—ぞす。æŦ æã—ãĻã„ã‚‹ãƒãƒŖãƒŗã‚¯ãŒã‚ãŖãŸå ´åˆã€ã‚¨ãƒŠãƒŧãŒč§Ŗæļˆã•れる可čƒŊ性がありぞす。"; - readonly ko: "ëĒ¨ë“  파ėŧė˜ 랭íŦëĨŧ ë‹¤ė‹œ ėƒė„ąí•Šë‹ˆë‹¤. 누ëŊ된 랭íŦ가 ėžˆė—ˆë‹¤ëŠ´ ė´ ėž‘ė—…ėœŧ로 똤ëĨ˜ę°€ 해결될 눘 ėžˆėŠĩ니다."; - readonly ru: "Đ­Ņ‚Đž ĐŋĐĩŅ€ĐĩŅĐžĐˇĐ´Đ°ŅŅ‚ Ņ‡Đ°ĐŊĐēи Đ´ĐģŅ Đ˛ŅĐĩŅ… Ņ„Đ°ĐšĐģОв. Đ•ŅĐģи ĐēаĐēиĐĩ-Ņ‚Đž Ņ‡Đ°ĐŊĐēи ĐžŅ‚ŅŅƒŅ‚ŅŅ‚Đ˛ĐžĐ˛Đ°Đģи, ŅŅ‚Đž ĐŧĐžĐļĐĩŅ‚ Đ¸ŅĐŋŅ€Đ°Đ˛Đ¸Ņ‚ŅŒ ĐžŅˆĐ¸ĐąĐēи."; - readonly zh: "čŋ™äŧšä¸ē所有文äģļé‡æ–°į”Ÿæˆ chunks。åĻ‚æžœäš‹å‰å­˜åœ¨įŧēå¤ąįš„ chunksīŧŒčŋ™å¯čƒŊäŋŽå¤į›¸å…ŗé”™č¯¯ã€‚"; - readonly "zh-tw": "這會į‚ē所有æĒ”æĄˆé‡æ–°åģēįĢ‹ chunks。č‹Ĩ先前有éēå¤ąįš„ chunksīŧŒé€™å¯čƒŊäŋŽåžŠį›¸é—œéŒ¯čĒ¤ã€‚"; - }; - readonly "Transfer Tweak": { - readonly def: "Transfer Tweak"; - readonly es: "Ajustes de transferencia"; - readonly ja: "čģĸ送ぎčĒŋ整"; - readonly ko: "ė „ė†Ą ėĄ°ė •"; - readonly ru: "ĐĐ°ŅŅ‚Ņ€ĐžĐšĐēи ĐŋĐĩŅ€ĐĩĐ´Đ°Ņ‡Đ¸"; - }; - readonly "TweakMismatchResolve.Action.DisableAutoAcceptCompatible": { - readonly def: "Disable auto-accept"; - }; - readonly "TweakMismatchResolve.Action.Dismiss": { - readonly def: "Dismiss"; - readonly fr: "Ignorer"; - readonly he: "דחה"; - readonly ja: "į„ĄčĻ–"; - readonly ko: "ëŦ´ė‹œ"; - readonly ru: "ĐžŅ‚ĐŧĐĩĐŊа"; - readonly zh: "Dismiss"; - }; - readonly "TweakMismatchResolve.Action.EnableAutoAcceptCompatible": { - readonly def: "Enable auto-accept"; - }; - readonly "TweakMismatchResolve.Action.UseConfigured": { - readonly def: "Use configured settings"; - readonly fr: "Utiliser les paramètres configurÊs"; - readonly he: "הש×Ēמ׊ בהגדרו×Ē ×”×ž×•×’×“×¨×•×Ē"; - readonly ja: "č¨­åŽšæ¸ˆãŋãŽč¨­åŽšã‚’äŊŋᔍ"; - readonly ko: "ęĩŦė„ąëœ 네렕 ė‚ŦėšŠ"; - readonly ru: "Đ˜ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ŅŒ ĐŊĐ°ŅŅ‚Ņ€ĐžĐĩĐŊĐŊŅ‹Đĩ ĐŋĐ°Ņ€Đ°ĐŧĐĩ҂Ҁҋ"; - readonly zh: "Use configured settings"; - }; - readonly "TweakMismatchResolve.Action.UseMine": { - readonly def: "Update remote database settings"; - readonly fr: "Mettre à jour les paramètres de la base distante"; - readonly he: "×ĸדכן הגדרו×Ē ×ž×Ą×“ הנ×Ēונים המרוחד"; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧã‚šãŽč¨­åŽšã‚’æ›´æ–°"; - readonly ko: "ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ 네렕 ė—…ë°ė´íŠ¸"; - readonly ru: "ОбĐŊĐžĐ˛Đ¸Ņ‚ŅŒ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊОК ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅"; - readonly zh: "Update remote database settings"; - }; - readonly "TweakMismatchResolve.Action.UseMineAcceptIncompatible": { - readonly def: "Update remote database settings but keep as is"; - readonly fr: "Mettre à jour la base distante mais garder en l'Êtat"; - readonly he: "×ĸדכן הגדרו×Ē ×ž×Ą×“ הנ×Ēונים המרוחד אך השאר כפי שהוא"; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧã‚šãŽč¨­åŽšã‚’æ›´æ–°ã™ã‚‹ãŒããŽãžãžįļ­æŒ"; - readonly ko: "ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ 네렕 ė—…ë°ė´íŠ¸í•˜ė§€ë§Œ 그대로 ėœ ė§€"; - readonly ru: "ОбĐŊĐžĐ˛Đ¸Ņ‚ŅŒ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи, ĐŊĐž ĐžŅŅ‚Đ°Đ˛Đ¸Ņ‚ŅŒ ĐēаĐē ĐĩŅŅ‚ŅŒ"; - readonly zh: "Update remote database settings but keep as is"; - }; - readonly "TweakMismatchResolve.Action.UseMineWithRebuild": { - readonly def: "Update remote database settings and rebuild again"; - readonly fr: "Mettre à jour la base distante et reconstruire"; - readonly he: "×ĸדכן הגדרו×Ē ×ž×Ą×“ הנ×Ēונים המרוחד ובנה מחדש"; - readonly ja: "ãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧã‚šãŽč¨­åŽšã‚’æ›´æ–°ã—ãĻå†æ§‹į¯‰"; - readonly ko: "ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ 네렕 ė—…ë°ė´íŠ¸í•˜ęŗ  ë‹¤ė‹œ ėžŦęĩŦėļ•"; - readonly ru: "ОбĐŊĐžĐ˛Đ¸Ņ‚ŅŒ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи и ĐŋĐĩŅ€ĐĩŅŅ‚Ņ€ĐžĐ¸Ņ‚ŅŒ ҁĐŊОва"; - readonly zh: "Update remote database settings and rebuild again"; - }; - readonly "TweakMismatchResolve.Action.UseRemote": { - readonly def: "Apply settings to this device"; - readonly fr: "Appliquer les paramètres à cet appareil"; - readonly he: "החל הגדרו×Ē ×ĸל מכשיר זה"; - readonly ja: "こぎデバイ゚ãĢč¨­åŽšã‚’éŠį”¨"; - readonly ko: "ė´ 揰揰뗐 네렕 ė ėšŠ"; - readonly ru: "ĐŸŅ€Đ¸ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи Đē ŅŅ‚ĐžĐŧ҃ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Ņƒ"; - readonly zh: "Apply settings to this device"; - }; - readonly "TweakMismatchResolve.Action.UseRemoteAcceptIncompatible": { - readonly def: "Apply settings to this device, but and ignore incompatibility"; - readonly fr: "Appliquer à cet appareil, mais ignorer l'incompatibilitÊ"; - readonly he: "החל הגדרו×Ē ×ĸל מכשיר זה, אך ה×Ē×ĸלם מאי-×Ēאימו×Ē"; - readonly ja: "こぎデバイ゚ãĢč¨­åŽšã‚’éŠį”¨ã—ã€éžäē’æ›æ€§ã‚’į„ĄčĻ–"; - readonly ko: "ė´ 揰揰뗐 네렕 ė ėšŠí•˜ė§€ë§Œ í˜¸í™˜ė„ą ëŦ¸ė œ ëŦ´ė‹œ"; - readonly ru: "ĐŸŅ€Đ¸ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи, ĐŊĐž Đ¸ĐŗĐŊĐžŅ€Đ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ ĐŊĐĩŅĐžĐ˛ĐŧĐĩŅŅ‚Đ¸ĐŧĐžŅŅ‚ŅŒ"; - readonly zh: "Apply settings to this device, but and ignore incompatibility"; - }; - readonly "TweakMismatchResolve.Action.UseRemoteWithRebuild": { - readonly def: "Apply settings to this device, and fetch again"; - readonly fr: "Appliquer à cet appareil et rÊcupÊrer à nouveau"; - readonly he: "החל הגדרו×Ē ×ĸל מכשיר זה ומשוך שוב"; - readonly ja: "こぎデバイ゚ãĢč¨­åŽšã‚’éŠį”¨ã—ã€å†ãƒ•ã‚§ãƒƒãƒ"; - readonly ko: "ė´ 揰揰뗐 네렕 ė ėšŠí•˜ęŗ  ë‹¤ė‹œ ę°€ė ¸ė˜¤ę¸°"; - readonly ru: "ĐŸŅ€Đ¸ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи и ĐˇĐ°ĐŗŅ€ŅƒĐˇĐ¸Ņ‚ŅŒ ҁĐŊОва"; - readonly zh: "Apply settings to this device, and fetch again"; - }; - readonly "TweakMismatchResolve.Message.AutoAcceptCompatibleUndefined": { - readonly def: "\nIt appears that the settings differ for each device. You can now automatically apply compatible changes to these configurations.\nWould you like to enable this `auto-accept` setting?"; - }; - readonly "TweakMismatchResolve.Message.Main": { - readonly def: "\nThe settings in the remote database are as follows. These values are configured by other devices, which are synchronised with this device at least once.\n\nIf you want to use these settings, please select Use configured settings.\nIf you want to keep the settings of this device, please select Dismiss.\n\n${table}\n\n>[!TIP]\n> If you want to synchronise all settings, please use `Sync settings via markdown` after applying minimal configuration with this feature.\n\n${additionalMessage}"; - readonly fr: "\nLes paramètres de la base distante sont les suivants. Ces valeurs sont configurÊes par d'autres appareils, synchronisÊs au moins une fois avec celui-ci.\n\nPour utiliser ces paramètres, sÊlectionnez Utiliser les paramètres configurÊs.\nPour conserver les paramètres de cet appareil, sÊlectionnez Ignorer.\n\n${table}\n\n>[!ASTUCE]\n> Pour synchroniser tous les paramètres, utilisez ÂĢ Synchroniser les paramètres via markdown Âģ après application de la configuration minimale avec cette fonctionnalitÊ.\n\n${additionalMessage}"; - readonly he: "\nההגדרו×Ē ×‘×ž×Ą×“ הנ×Ēונים המרוחד הן כדלקמן. ×ĸרכים אלה הוגדרו ×ĸל ידי מכשירים אחרים, א׊ר סונכרנו ×ĸם מכשיר זה לפחו×Ē ×¤×ĸם אח×Ē.\n\nאם בר×Ļונך להש×Ēמ׊ בהגדרו×Ē ××œ×”, אנא בחר %{TweakMismatchResolve.Action.UseConfigured}.\nאם בר×Ļונך לשמור א×Ē ×”×’×“×¨×•×Ē ×ž×›×Š×™×¨ זה, אנא בחר %{TweakMismatchResolve.Action.Dismiss}.\n\n${table}\n\n>[!TIP]\n> אם בר×Ļונך לסנכרן א×Ē ×›×œ ההגדרו×Ē, אנא הש×Ēמ׊ ב-`סנכרון הגדרו×Ē ×“×¨×š Markdown` לאחר החל×Ē ×Ē×Ļורה מינימלי×Ē ×ĸם ×Ēכונה זו.\n\n${additionalMessage}"; - readonly ja: "\nãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧã‚šãŽč¨­åŽšã¯äģĨ下ぎ通りです。これらぎ値は、こぎデバイ゚と少ãĒくとも1回同期されたäģ–ぎデバイ゚ãĢã‚ˆãŖãĻč¨­åŽšã•ã‚ŒãĻいぞす。\n\nã“ã‚Œã‚‰ãŽč¨­åŽšã‚’äŊŋį”¨ã™ã‚‹å ´åˆã¯ã€č¨­åŽšæ¸ˆãŋãŽč¨­åŽšã‚’äŊŋį”¨ã‚’é¸æŠžã—ãĻください。\nã“ãŽãƒ‡ãƒã‚¤ã‚šãŽč¨­åŽšã‚’įļ­æŒã™ã‚‹å ´åˆã¯ã€į„ĄčĻ–ã‚’é¸æŠžã—ãĻください。\n\n${table}\n\n>[!TIP]\n> すずãĻãŽč¨­åŽšã‚’åŒæœŸã—ãŸã„å ´åˆã¯ã€ã“ãŽæŠŸčƒŊã§æœ€å°é™ãŽč¨­åŽšã‚’éŠį”¨ã—ãŸåžŒã€`Sync settings via markdown`をäŊŋį”¨ã—ãĻください。\n\n${additionalMessage}"; - readonly ko: "\nė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ė˜ ė„¤ė •ė€ ë‹¤ėŒęŗŧ 같ėŠĩ니다. ė´ ę°’ë“¤ė€ ė´ 揰揰뙀 ėĩœė†Œ 한 번 동기화된 다ëĨ¸ ę¸°ę¸°ė—ė„œ ęĩŦė„ąëœ ę˛ƒėž…ë‹ˆë‹¤.\n\nė´ ė„¤ė •ė„ ė‚ŦėšŠí•˜ë ¤ëŠ´ ęĩŦė„ąëœ 네렕 ė‚ŦėšŠëĨŧ ė„ íƒí•´ ėŖŧė„¸ėš”.\nė´ ę¸°ę¸°ė˜ ė„¤ė •ė„ ėœ ė§€í•˜ë ¤ëŠ´ ëŦ´ė‹œëĨŧ ė„ íƒí•´ ėŖŧė„¸ėš”.\n\n${table}\n\n>[!TIP]\n> ëĒ¨ë“  ė„¤ė •ė„ 동기화하려면 ė´ 기ëŠĨėœŧ로 ėĩœė†Œ ęĩŦė„ąė„ ė ėšŠí•œ 후 `마íŦë‹¤ėš´ė„ í†ĩ한 네렕 동기화`ëĨŧ ė‚ŦėšŠí•´ ėŖŧė„¸ėš”.\n\n${additionalMessage}"; - readonly ru: "ĐĐ°ŅŅ‚Ņ€ĐžĐšĐēи в ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊОК йаСĐĩ даĐŊĐŊҋ҅ ҁĐģĐĩĐ´ŅƒŅŽŅ‰Đ¸Đĩ. Đ­Ņ‚Đ¸ СĐŊĐ°Ņ‡ĐĩĐŊĐ¸Ņ ĐŊĐ°ŅŅ‚Ņ€ĐžĐĩĐŊŅ‹ Đ´Ņ€ŅƒĐŗĐ¸Đŧи ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°Đŧи."; - readonly zh: "\nThe settings in the remote database are as follows. These values are configured by other devices, which are synchronised with this device at least once.\n\nIf you want to use these settings, please select Use configured settings.\nIf you want to keep the settings of this device, please select Dismiss.\n\n${table}\n\n>[!TIP]\n> If you want to synchronise all settings, please use `Sync settings via markdown` after applying minimal configuration with this feature.\n\n${additionalMessage}"; - }; - readonly "TweakMismatchResolve.Message.MainTweakResolving": { - readonly def: "Your configuration has not been matched with the one on the remote server.\n\nFollowing configuration should be matched:\n\n${table}\n\nLet us know your decision.\n\n${additionalMessage}"; - readonly fr: "Votre configuration ne correspond pas à celle du serveur distant.\n\nLa configuration suivante devrait correspondre :\n\n${table}\n\nFaites-nous part de votre dÊcision.\n\n${additionalMessage}"; - readonly he: "ה×Ē×Ļורה שלך אינה ×Ēואמ×Ē ×œ×–×• שבשר×Ē ×”×ž×¨×•×—×“.\n\nיש לה×Ēאים א×Ē ×”×Ē×Ļורו×Ē ×”×‘××•×Ē:\n\n${table}\n\nאנא הוד×ĸ לנו ×ĸל החלט×Ēך.\n\n${additionalMessage}"; - readonly ja: "č¨­åŽšãŒãƒĒãƒĸãƒŧトã‚ĩãƒŧバãƒŧãŽč¨­åŽšã¨ä¸€č‡´ã—ãžã›ã‚“ã€‚\n\näģĨä¸‹ãŽč¨­åŽšãŒä¸€č‡´ã—ãĻいるåŋ…čĻãŒã‚ã‚Šãžã™īŧš\n\n${table}\n\n判断をおįŸĨらせください。\n\n${additionalMessage}"; - readonly ko: "ęĩŦė„ąė´ ė›ę˛Š ė„œë˛„ė˜ 것ęŗŧ ėŧėš˜í•˜ė§€ ė•ŠėŠĩ니다.\n\në‹¤ėŒ ęĩŦė„ąė´ ėŧėš˜í•´ė•ŧ 합니다:\n\n${table}\n\nę˛°ė •ė„ ė•Œë ¤ėŖŧė„¸ėš”.\n\n${additionalMessage}"; - readonly ru: "Đ’Đ°ŅˆĐ° ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Ņ ĐŊĐĩ ŅĐžĐ˛ĐŋадаĐĩŅ‚ ҁ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊŅ‹Đŧ ҁĐĩŅ€Đ˛ĐĩŅ€ĐžĐŧ."; - readonly zh: "Your configuration has not been matched with the one on the remote server.\n\nFollowing configuration should be matched:\n\n${table}\n\nLet us know your decision.\n\n${additionalMessage}"; - }; - readonly "TweakMismatchResolve.Message.mineUpdated": { - readonly def: "The device configuration have been adjusted."; - }; - readonly "TweakMismatchResolve.Message.remoteUpdated": { - readonly def: "The configuration stored remotely has been updated."; - }; - readonly "TweakMismatchResolve.Message.UseRemote.WarningRebuildRecommended": { - readonly def: "\n>[!NOTICE]\n> Some changes are compatible but may consume extra storage and transfer volumes. A rebuild is recommended. However, a rebuild may not be performed at present, but may be implemented in future maintenance.\n> ***Please ensure that you have time and are connected to a stable network to apply!***"; - readonly fr: "\n>[!AVIS]\n> Certains changements sont compatibles mais peuvent consommer du stockage et du trafic supplÊmentaires. Une reconstruction est recommandÊe. Cependant, elle peut ne pas ÃĒtre effectuÊe maintenant, mais pourra l'ÃĒtre lors d'une maintenance future.\n> ***Assurez-vous d'avoir du temps et une connexion stable pour appliquer !***"; - readonly he: "\n>[!NOTICE]\n> חלק מהשינויים ×Ēואמים אך ×ĸלולים ל×Ļרוך אחסון ו×Ē×ĸבורה נוספים. מומל×Ļ×Ē ×‘× ×™×™×” מחדש. ×ĸם זא×Ē, יי×Ēכן שבנייה מחדש לא ×Ē×Ēב×Ļ×ĸ כ×ĸ×Ē, אך ×Ēיושם ב×Ēחזוקה ×ĸ×Ēידי×Ē.\n> ***ודא שיש לך זמן ושא×Ēה מחובר לרש×Ē ×™×Ļיבה לפני ההחלה!***"; - readonly ja: "\n>[!NOTICE]\n> 一部ぎ変更はäē’æ›æ€§ãŒã‚りぞすが、čŋŊ加ぎ゚トãƒŦãƒŧジとčģĸ送量をæļˆč˛ģする可čƒŊæ€§ãŒã‚ã‚Šãžã™ã€‚å†æ§‹į¯‰ã‚’ãŠå‹§ã‚ã—ãžã™ã€‚ãŸã ã—ã€å†æ§‹į¯‰ã¯įžæ™‚į‚šã§ã¯åŽŸčĄŒã•ã‚ŒãĒい場合がありぞすが、将æĨãŽãƒĄãƒŗãƒ†ãƒŠãƒŗã‚šã§åŽŸčŖ…ã•ã‚Œã‚‹å¯čƒŊ性がありぞす。\n> ***éŠį”¨ãĢは時間と厉厚したネットワãƒŧクæŽĨįļšãŒåŋ…čĻã§ã™īŧ***"; - readonly ko: "\n>[!NOTICE]\n> ėŧëļ€ ëŗ€ę˛Ŋė‚Ŧí•­ė€ 호환 가ëŠĨí•˜ė§€ë§Œ ėļ”ę°€ ėŠ¤í† ëĻŦė§€ 및 ė „ė†ĄëŸ‰ė„ ė†ŒëĒ¨í•  눘 ėžˆėŠĩ니다. ėžŦęĩŦėļ•ė„ ęļŒėžĨ합니다. í•˜ė§€ë§Œ 현ėžŦ ėžŦęĩŦėļ•ė„ ėˆ˜í–‰í•˜ė§€ ė•Šë”ëŧ도 í–Ĩ후 ėœ ė§€ëŗ´ėˆ˜ė—ė„œ ęĩŦ현될 눘 ėžˆėŠĩ니다.\n> ***ė‹œę°„ė  ė—Ŧėœ ę°€ ėžˆęŗ  ė•ˆė •ė ė¸ ë„¤íŠ¸ė›ŒíŦ뗐 ė—°ę˛°ëœ ėƒíƒœė—ė„œ ė ėšŠí•´ ėŖŧė„¸ėš”!***"; - readonly ru: "НĐĩĐēĐžŅ‚ĐžŅ€Ņ‹Đĩ иСĐŧĐĩĐŊĐĩĐŊĐ¸Ņ ŅĐžĐ˛ĐŧĐĩŅŅ‚Đ¸ĐŧŅ‹, ĐŊĐž ĐŧĐžĐŗŅƒŅ‚ ĐŋĐžŅ‚Ņ€ĐĩĐąĐžĐ˛Đ°Ņ‚ŅŒ Đ´ĐžĐŋĐžĐģĐŊĐ¸Ņ‚ĐĩĐģҌĐŊĐžĐŗĐž Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰Đ°. Đ ĐĩĐēĐžĐŧĐĩĐŊĐ´ŅƒĐĩŅ‚ŅŅ ĐŋĐĩŅ€ĐĩŅŅ‚Ņ€ĐžĐĩĐŊиĐĩ."; - readonly zh: "\n>[!NOTICE]\n> Some changes are compatible but may consume extra storage and transfer volumes. A rebuild is recommended. However, a rebuild may not be performed at present, but may be implemented in future maintenance.\n> ***Please ensure that you have time and are connected to a stable network to apply!***"; - }; - readonly "TweakMismatchResolve.Message.UseRemote.WarningRebuildRequired": { - readonly def: "\n>[!WARNING]\n> Some remote configurations are not compatible with the local database of this device. Rebuilding the local database will be required.\n> ***Please ensure that you have time and are connected to a stable network to apply!***"; - readonly fr: "\n>[!AVERTISSEMENT]\n> Certaines configurations distantes ne sont pas compatibles avec la base locale de cet appareil. Une reconstruction de la base locale sera requise.\n> ***Assurez-vous d'avoir du temps et une connexion stable pour appliquer !***"; - readonly he: "\n>[!WARNING]\n> חלק מה×Ē×Ļורו×Ē ×”×ž×¨×•×—×§×•×Ē ××™× ×Ÿ ×Ēואמו×Ē ×œ×ž×Ą×“ הנ×Ēונים המקומי של מכשיר זה. נדרש×Ē ×‘× ×™×™×” מחדש של מסד הנ×Ēונים המקומי.\n> ***ודא שיש לך זמן ושא×Ēה מחובר לרש×Ē ×™×Ļיבה לפני ההחלה!***"; - readonly ja: "\n>[!WARNING]\n> 一部ぎãƒĒãƒĸãƒŧãƒˆč¨­åŽšã¯ã“ãŽãƒ‡ãƒã‚¤ã‚šãŽãƒ­ãƒŧã‚ĢãƒĢデãƒŧã‚ŋベãƒŧ゚とäē’æ›æ€§ãŒã‚りぞせん。ロãƒŧã‚ĢãƒĢデãƒŧã‚ŋベãƒŧã‚šãŽå†æ§‹į¯‰ãŒåŋ…čĻã§ã™ã€‚\n> ***éŠį”¨ãĢは時間と厉厚したネットワãƒŧクæŽĨįļšãŒåŋ…čĻã§ã™īŧ***"; - readonly ko: "\n>[!WARNING]\n> ėŧëļ€ ė›ę˛Š ęĩŦė„ąė´ ė´ ę¸°ę¸°ė˜ 로ėģŦ ë°ė´í„°ë˛ ė´ėŠ¤ė™€ í˜¸í™˜ë˜ė§€ ė•ŠėŠĩ니다. 로ėģŦ ë°ė´í„°ë˛ ė´ėŠ¤ ėžŦęĩŦėļ•ė´ í•„ėš”í•Šë‹ˆë‹¤.\n> ***ė‹œę°„ė  ė—Ŧėœ ę°€ ėžˆęŗ  ė•ˆė •ė ė¸ ë„¤íŠ¸ė›ŒíŦ뗐 ė—°ę˛°ëœ ėƒíƒœė—ė„œ ė ėšŠí•´ ėŖŧė„¸ėš”!***"; - readonly ru: "НĐĩĐēĐžŅ‚ĐžŅ€Ņ‹Đĩ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊŅ‹Đĩ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Đ¸ ĐŊĐĩŅĐžĐ˛ĐŧĐĩŅŅ‚Đ¸ĐŧŅ‹ ҁ ĐģĐžĐēаĐģҌĐŊОК йаСОК даĐŊĐŊҋ҅. ĐĸŅ€ĐĩĐąŅƒĐĩŅ‚ŅŅ ĐŋĐĩŅ€ĐĩŅŅ‚Ņ€ĐžĐĩĐŊиĐĩ."; - readonly zh: "\n>[!WARNING]\n> Some remote configurations are not compatible with the local database of this device. Rebuilding the local database will be required.\n> ***Please ensure that you have time and are connected to a stable network to apply!***"; - }; - readonly "TweakMismatchResolve.Message.WarningIncompatibleRebuildRecommended": { - readonly def: "\n>[!NOTICE]\n> We have detected that some of the values are different to make incompatible the local database with the remote database.\n> Some changes are compatible but may consume extra storage and transfer volumes. A rebuild is recommended. However, a rebuild may not be performed at present, but may be implemented in future maintenance.\n> If you want to rebuild, it takes a few minutes or more. **Make sure it is safe to perform it now.**"; - readonly fr: "\n>[!AVIS]\n> Nous avons dÊtectÊ que certaines valeurs diffèrent et rendent la base locale incompatible avec la base distante.\n> Certains changements sont compatibles mais peuvent consommer du stockage et du trafic supplÊmentaires. Une reconstruction est recommandÊe. Cependant, elle peut ne pas ÃĒtre effectuÊe maintenant, mais pourra l'ÃĒtre lors d'une maintenance future.\n> Si vous souhaitez reconstruire, cela prend quelques minutes ou plus. **Assurez-vous qu'il est sÃģr de le faire maintenant.**"; - readonly he: "\n>[!NOTICE]\n> זיהינו שחלק מה×ĸרכים שונים, מה שגורם לאי-×Ēאימו×Ē ×‘×™×Ÿ מסד הנ×Ēונים המקומי למרוחד.\n> חלק מהשינויים ×Ēואמים אך ×ĸלולים ל×Ļרוך אחסון ו×Ē×ĸבורה נוספים. מומל×Ļ×Ē ×‘× ×™×™×” מחדש. ×ĸם זא×Ē, יי×Ēכן שבנייה מחדש לא ×Ē×Ēב×Ļ×ĸ כ×ĸ×Ē, אך ×Ēיושם ב×Ēחזוקה ×ĸ×Ēידי×Ē.\n> אם בר×Ļונך לבנו×Ē ×ž×—×“×Š, הדבר ייקח כמה דקו×Ē ××• יו×Ēר. **ודא שבטוח לב×Ļ×ĸ זא×Ē ×ĸכשיו.**"; - readonly ja: "\n>[!NOTICE]\n> ロãƒŧã‚ĢãƒĢデãƒŧã‚ŋベãƒŧ゚とãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚ぎ非äē’æ›æ€§ã‚’åŧ•きčĩˇã“す値ぎ違いが検å‡ēされぞした。\n> 一部ぎ変更はäē’æ›æ€§ãŒã‚りぞすが、čŋŊ加ぎ゚トãƒŦãƒŧジとčģĸ送量をæļˆč˛ģする可čƒŊæ€§ãŒã‚ã‚Šãžã™ã€‚å†æ§‹į¯‰ã‚’ãŠå‹§ã‚ã—ãžã™ã€‚ãŸã ã—ã€å†æ§‹į¯‰ã¯įžæ™‚į‚šã§ã¯åŽŸčĄŒã•ã‚ŒãĒい場合がありぞすが、将æĨãŽãƒĄãƒŗãƒ†ãƒŠãƒŗã‚šã§åŽŸčŖ…ã•ã‚Œã‚‹å¯čƒŊ性がありぞす。\n> å†æ§‹į¯‰ã‚’čĄŒã†å ´åˆã¯æ•°åˆ†äģĨ上かかりぞす。**äģŠåŽŸčĄŒã—ãĻも厉全かįĸēčĒã—ãĻください。**"; - readonly ko: "\n>[!NOTICE]\n> 로ėģŦ ë°ė´í„°ë˛ ė´ėŠ¤ė™€ ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ę°€ í˜¸í™˜ë˜ė§€ ė•Šë„ëĄ 만드는 ę°’ë“¤ė´ 다ëĨ¸ ę˛ƒė„ ę°ė§€í–ˆėŠĩ니다.\n> ėŧëļ€ ëŗ€ę˛Ŋė‚Ŧí•­ė€ 호환 가ëŠĨí•˜ė§€ë§Œ ėļ”ę°€ ėŠ¤í† ëĻŦė§€ 및 ė „ė†ĄëŸ‰ė„ ė†ŒëĒ¨í•  눘 ėžˆėŠĩ니다. ėžŦęĩŦėļ•ė„ ęļŒėžĨ합니다. í•˜ė§€ë§Œ 현ėžŦ ėžŦęĩŦėļ•ė„ ėˆ˜í–‰í•˜ė§€ ė•Šë”ëŧ도 í–Ĩ후 ėœ ė§€ëŗ´ėˆ˜ė—ė„œ ęĩŦ현될 눘 ėžˆėŠĩ니다.\n> ėžŦęĩŦėļ•ė„ ė›í•œë‹¤ëŠ´ ëLJ ëļ„ ė´ėƒ ė†Œėš”ëŠë‹ˆë‹¤. **ė§€ę¸ˆ ėˆ˜í–‰í•´ë„ ė•ˆė „í•œė§€ í™•ė¸í•´ ėŖŧė„¸ėš”.**"; - readonly ru: "ОбĐŊĐ°Ņ€ŅƒĐļĐĩĐŊŅ‹ СĐŊĐ°Ņ‡ĐĩĐŊĐ¸Ņ, ĐŊĐĩŅĐžĐ˛ĐŧĐĩŅŅ‚Đ¸ĐŧŅ‹Đĩ ҁ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊОК йаСОК даĐŊĐŊҋ҅. Đ ĐĩĐēĐžĐŧĐĩĐŊĐ´ŅƒĐĩŅ‚ŅŅ ĐŋĐĩŅ€ĐĩŅŅ‚Ņ€ĐžĐĩĐŊиĐĩ."; - readonly zh: "\n>[!NOTICE]\n> We have detected that some of the values are different to make incompatible the local database with the remote database.\n> Some changes are compatible but may consume extra storage and transfer volumes. A rebuild is recommended. However, a rebuild may not be performed at present, but may be implemented in future maintenance.\n> If you want to rebuild, it takes a few minutes or more. **Make sure it is safe to perform it now.**"; - }; - readonly "TweakMismatchResolve.Message.WarningIncompatibleRebuildRequired": { - readonly def: "\n>[!WARNING]\n> We have detected that some of the values are different to make incompatible the local database with the remote database.\n> Either local or remote rebuilds are required. Both of them takes a few minutes or more. **Make sure it is safe to perform it now.**"; - readonly fr: "\n>[!AVERTISSEMENT]\n> Nous avons dÊtectÊ que certaines valeurs diffèrent et rendent la base locale incompatible avec la base distante.\n> Une reconstruction locale ou distante est nÊcessaire. L'une comme l'autre prend quelques minutes ou plus. **Assurez-vous qu'il est sÃģr de le faire maintenant.**"; - readonly he: "\n>[!WARNING]\n> זיהינו שחלק מה×ĸרכים שונים, מה שגורם לאי-×Ēאימו×Ē ×‘×™×Ÿ מסד הנ×Ēונים המקומי למרוחד.\n> נדרש×Ē ×‘× ×™×™×” מחדש של המסד המקומי או המרוחד. שניהם ייקחו כמה דקו×Ē ××• יו×Ēר. **ודא שבטוח לב×Ļ×ĸ זא×Ē ×ĸכשיו.**"; - readonly ja: "\n>[!WARNING]\n> ロãƒŧã‚ĢãƒĢデãƒŧã‚ŋベãƒŧ゚とãƒĒãƒĸãƒŧトデãƒŧã‚ŋベãƒŧ゚ぎ非äē’æ›æ€§ã‚’åŧ•きčĩˇã“す値ぎ違いが検å‡ēされぞした。\n> ロãƒŧã‚ĢãƒĢぞたはãƒĒãƒĸãƒŧãƒˆãŽå†æ§‹į¯‰ãŒåŋ…čĻã§ã™ã€‚ãŠãĄã‚‰ã‚‚æ•°åˆ†äģĨ上かかりぞす。**äģŠåŽŸčĄŒã—ãĻも厉全かįĸēčĒã—ãĻください。**"; - readonly ko: "\n>[!WARNING]\n> 로ėģŦ ë°ė´í„°ë˛ ė´ėŠ¤ė™€ ė›ę˛Š ë°ė´í„°ë˛ ė´ėŠ¤ę°€ í˜¸í™˜ë˜ė§€ ė•Šë„ëĄ 만드는 ę°’ë“¤ė´ 다ëĨ¸ ę˛ƒė„ ę°ė§€í–ˆėŠĩ니다.\n> 로ėģŦ 또는 ė›ę˛Š ėžŦęĩŦėļ•ė´ í•„ėš”í•Šë‹ˆë‹¤. 둘 다 ëLJ ëļ„ ė´ėƒ ė†Œėš”ëŠë‹ˆë‹¤. **ė§€ę¸ˆ ėˆ˜í–‰í•´ë„ ė•ˆė „í•œė§€ í™•ė¸í•´ ėŖŧė„¸ėš”.**"; - readonly ru: "ОбĐŊĐ°Ņ€ŅƒĐļĐĩĐŊŅ‹ СĐŊĐ°Ņ‡ĐĩĐŊĐ¸Ņ, ĐŊĐĩŅĐžĐ˛ĐŧĐĩŅŅ‚Đ¸ĐŧŅ‹Đĩ ҁ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊОК йаСОК даĐŊĐŊҋ҅. ĐĸŅ€ĐĩĐąŅƒĐĩŅ‚ŅŅ ĐŋĐĩŅ€ĐĩŅŅ‚Ņ€ĐžĐĩĐŊиĐĩ."; - readonly zh: "\n>[!WARNING]\n> We have detected that some of the values are different to make incompatible the local database with the remote database.\n> Either local or remote rebuilds are required. Both of them takes a few minutes or more. **Make sure it is safe to perform it now.**"; - }; - readonly "TweakMismatchResolve.Table": { - readonly def: "| Value name | This device | On Remote |\n|: --- |: ---- :|: ---- :|\n${rows}\n\n"; - readonly fr: "| Nom de la valeur | Cet appareil | Sur le distant |\n|: --- |: ---- :|: ---- :|\n${rows}\n\n"; - readonly he: "| ׊ם ×ĸרך | מכשיר זה | מרוחד |\n|: --- |: ---- :|: ---- :|\n${rows}\n\n"; - readonly ja: "| 値ぎ名前 | こぎデバイ゚ | ãƒĒãƒĸãƒŧト |\n|: --- |: ---- :|: ---- :|\n${rows}\n\n"; - readonly ko: "| 값 ė´ëĻ„ | ė´ 기기 | ė›ę˛Š |\n|: --- |: ---- :|: ---- :|\n${rows}\n\n"; - readonly ru: "| ИĐŧŅ СĐŊĐ°Ņ‡ĐĩĐŊĐ¸Ņ | Đ­Ņ‚Đž ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đž | На ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐŧ |\n|: --- |: ---- :|: ---- :|"; - readonly zh: "| Value name | This device | On Remote |\n|: --- |: ---- :|: ---- :|\n${rows}\n\n"; - }; - readonly "TweakMismatchResolve.Table.Row": { - readonly def: "| ${name} | ${self} | ${remote} |"; - readonly fr: "| ${name} | ${self} | ${remote} |"; - readonly he: "| ${name} | ${self} | ${remote} |"; - readonly ja: "| ${name} | ${self} | ${remote} |"; - readonly ko: "| ${name} | ${self} | ${remote} |"; - readonly ru: "| name | self | remote |"; - readonly zh: "| ${name} | ${self} | ${remote} |"; - }; - readonly "TweakMismatchResolve.Title": { - readonly def: "Configuration Mismatch Detected"; - readonly fr: "IncohÊrence de configuration dÊtectÊe"; - readonly he: "זוה×Ēה אי-ה×Ēאמה ב×Ē×Ļורה"; - readonly ja: "č¨­åŽšãŽä¸ä¸€č‡´ãŒæ¤œå‡ēされぞした"; - readonly ko: "ęĩŦė„ą ëļˆėŧėš˜ 氐맀"; - readonly ru: "ОбĐŊĐ°Ņ€ŅƒĐļĐĩĐŊĐž ĐŊĐĩŅĐžĐžŅ‚Đ˛ĐĩŅ‚ŅŅ‚Đ˛Đ¸Đĩ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Đ¸"; - readonly zh: "Configuration Mismatch Detected"; - }; - readonly "TweakMismatchResolve.Title.AutoAcceptCompatible": { - readonly def: "Auto-Accept Available"; - }; - readonly "TweakMismatchResolve.Title.TweakResolving": { - readonly def: "Configuration Mismatch Detected"; - readonly fr: "IncohÊrence de configuration dÊtectÊe"; - readonly he: "זוה×Ēה אי-ה×Ēאמה ב×Ē×Ļורה"; - readonly ja: "č¨­åŽšãŽä¸ä¸€č‡´ãŒæ¤œå‡ēされぞした"; - readonly ko: "ęĩŦė„ą ëļˆėŧėš˜ 氐맀"; - readonly ru: "ОбĐŊĐ°Ņ€ŅƒĐļĐĩĐŊĐž ĐŊĐĩŅĐžĐžŅ‚Đ˛ĐĩŅ‚ŅŅ‚Đ˛Đ¸Đĩ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸Đ¸"; - readonly zh: "Configuration Mismatch Detected"; - }; - readonly "TweakMismatchResolve.Title.UseRemoteConfig": { - readonly def: "Use Remote Configuration"; - readonly fr: "Utiliser la configuration distante"; - readonly he: "הש×Ēמ׊ ב×Ē×Ļורה המרוחק×Ē"; - readonly ja: "ãƒĒãƒĸãƒŧãƒˆč¨­åŽšã‚’äŊŋᔍ"; - readonly ko: "ė›ę˛Š ęĩŦė„ą ė‚ŦėšŠ"; - readonly ru: "Đ˜ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ŅŒ ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊŅƒŅŽ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸ŅŽ"; - readonly zh: "Use Remote Configuration"; - }; - readonly "Ui.Common.Signal.Caution": { - readonly def: "CAUTION"; - readonly zh: "æŗ¨æ„"; - }; - readonly "Ui.Common.Signal.Danger": { - readonly def: "DANGER"; - readonly zh: "åąé™Š"; - }; - readonly "Ui.Common.Signal.Notice": { - readonly def: "NOTICE"; - readonly zh: "提į¤ē"; - }; - readonly "Ui.Common.Signal.Warning": { - readonly def: "WARNING"; - readonly zh: "č­Ļ告"; - }; - readonly "Ui.Settings.Advanced.LocalDatabaseTweak": { - readonly def: "Local Database Tweak"; - readonly zh: "æœŦ地数捎åē“č°ƒæ•´"; - }; - readonly "Ui.Settings.Advanced.MemoryCache": { - readonly def: "Memory Cache"; - readonly zh: "内存įŧ“å­˜"; - }; - readonly "Ui.Settings.Advanced.TransferTweak": { - readonly def: "Transfer Tweak"; - readonly zh: "äŧ čž“č°ƒæ•´"; - }; - readonly "Ui.Settings.Common.Analyse": { - readonly def: "Analyse"; - readonly zh: "分析"; - }; - readonly "Ui.Settings.Common.Back": { - readonly def: "Back"; - readonly zh: "čŋ”回"; - }; - readonly "Ui.Settings.Common.Check": { - readonly def: "Check"; - readonly zh: "æŖ€æŸĨ"; - }; - readonly "Ui.Settings.Common.Configure": { - readonly def: "Configure"; - readonly zh: "配įŊŽ"; - }; - readonly "Ui.Settings.Common.Continue": { - readonly def: "Continue"; - readonly zh: "įģ§įģ­"; - }; - readonly "Ui.Settings.Common.Delete": { - readonly def: "Delete"; - readonly zh: "删除"; - }; - readonly "Ui.Settings.Common.Fetch": { - readonly def: "Fetch"; - readonly zh: "čŽˇå–"; - }; - readonly "Ui.Settings.Common.Lock": { - readonly def: "Lock"; - readonly zh: "锁厚"; - }; - readonly "Ui.Settings.Common.Merge": { - readonly def: "Merge"; - readonly zh: "合åšļ"; - }; - readonly "Ui.Settings.Common.Open": { - readonly def: "Open"; - readonly zh: "打åŧ€"; - }; - readonly "Ui.Settings.Common.Overwrite": { - readonly def: "Overwrite"; - readonly zh: "čφᛖ"; - }; - readonly "Ui.Settings.Common.Perform": { - readonly def: "Perform"; - readonly zh: "æ‰§čĄŒ"; - }; - readonly "Ui.Settings.Common.ResetAll": { - readonly def: "Reset all"; - readonly zh: "全部重įŊŽ"; - }; - readonly "Ui.Settings.Common.ResolveAll": { - readonly def: "Resolve All"; - readonly zh: "å…¨éƒ¨č§Ŗå†ŗ"; - }; - readonly "Ui.Settings.Common.Scan": { - readonly def: "Scan"; - readonly zh: "æ‰Ģ描"; - }; - readonly "Ui.Settings.Common.Send": { - readonly def: "Send"; - readonly zh: "发送"; - }; - readonly "Ui.Settings.Common.Use": { - readonly def: "Use"; - readonly zh: "äŊŋᔍ"; - }; - readonly "Ui.Settings.Common.VerifyAll": { - readonly def: "Verify all"; - readonly zh: "å…¨éƒ¨æ Ąénj"; - }; - readonly "Ui.Settings.CustomizationSync.OpenDesc": { - readonly def: "Open the dialog"; - readonly zh: "打åŧ€æ­¤å¯šč¯æĄ†"; - }; - readonly "Ui.Settings.CustomizationSync.Panel": { - readonly def: "Customization Sync"; - readonly zh: "č‡Ē厚䚉同æ­Ĩ"; - }; - readonly "Ui.Settings.CustomizationSync.WarnChangeDeviceName": { - readonly def: "We cannot change the device name while this feature is enabled. Please disable this feature to change the device name."; - readonly zh: "å¯į”¨æ­¤åŠŸčƒŊæ—ļæ— æŗ•äŋŽæ”ščŽžå¤‡åį§°ã€‚č¯ˇå…ˆå…ŗé—­æ­¤åŠŸčƒŊīŧŒå†äŋŽæ”ščŽžå¤‡åį§°ã€‚"; - }; - readonly "Ui.Settings.CustomizationSync.WarnSetDeviceName": { - readonly def: "Please set device name to identify this device. This name should be unique among your devices. While not configured, we cannot enable this feature."; - readonly zh: "č¯ˇå…ˆčŽžįŊŽį”¨äēŽæ ‡č¯†æ­¤čŽžå¤‡įš„čŽžå¤‡åį§°ã€‚č¯Ĩåį§°åē”在äŊ įš„čŽžå¤‡äš‹é—´äŋæŒå”¯ä¸€ã€‚æœĒ莞įŊŽå‰æ— æŗ•å¯į”¨æ­¤åŠŸčƒŊ。"; - }; - readonly "Ui.Settings.Hatch.AnalyseDatabaseUsage": { - readonly def: "Analyse database usage"; - readonly zh: "分析数捎åē“äŊŋį”¨æƒ…å†ĩ"; - }; - readonly "Ui.Settings.Hatch.AnalyseDatabaseUsageDesc": { - readonly def: "Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like."; - readonly zh: "分析数捎åē“äŊŋį”¨æƒ…å†ĩīŧŒåšļį”Ÿæˆ TSV æŠĨ告䞛äŊ č‡ĒčĄŒč¯Šæ–­ã€‚äŊ å¯äģĨå°†į”Ÿæˆįš„æŠĨå‘Šį˛˜č´´åˆ°äģģæ„į”ĩå­čĄ¨æ ŧåˇĨ兎䏭æŸĨįœ‹ã€‚"; - }; - readonly "Ui.Settings.Hatch.BackToNonConfigured": { - readonly def: "Back to non-configured"; - readonly zh: "čŋ”回æœĒ配įŊŽįŠļ态"; - }; - readonly "Ui.Settings.Hatch.ConvertNonObfuscated": { - readonly def: "Check and convert non-path-obfuscated files"; - readonly zh: "æŖ€æŸĨåšļčŊŦæĸæœĒčŋ›čĄŒčˇ¯åž„æˇˇæˇ†įš„æ–‡äģļ"; - }; - readonly "Ui.Settings.Hatch.ConvertNonObfuscatedDesc": { - readonly def: "Check the local database for files that were stored without path obfuscation and convert them when needed."; - readonly zh: "æŖ€æŸĨæœŦ地数捎åē“中æœĒæŒ‰čˇ¯åž„æˇˇæˇ†æ–šåŧå­˜å‚¨įš„æ–‡äģļīŧŒåšļ在需čρæ—ļ将å…ļčŊŦæĸä¸ēæ­ŖįĄŽæ ŧåŧã€‚"; - }; - readonly "Ui.Settings.Hatch.CopyIssueReport": { - readonly def: "Copy Report to clipboard"; - readonly zh: "复åˆļæŠĨ告到å‰Ēč´´æŋ"; - }; - readonly "Ui.Settings.Hatch.DatabaseLabel": { - readonly def: "Database: ${details}"; - readonly zh: "数捎åē“īŧš${details}"; - }; - readonly "Ui.Settings.Hatch.DatabaseToStorage": { - readonly def: "Database -> Storage"; - readonly zh: "数捎åē“ -> 存储"; - }; - readonly "Ui.Settings.Hatch.DeleteCustomizationSyncData": { - readonly def: "Delete all customization sync data"; - readonly zh: "删除所有č‡Ē厚䚉同æ­Ĩ数捎"; - }; - readonly "Ui.Settings.Hatch.GeneratedReport": { - readonly def: "Generated report"; - readonly zh: "åˇ˛į”Ÿæˆįš„æŠĨ告"; - }; - readonly "Ui.Settings.Hatch.Missing": { - readonly def: "Missing"; - readonly zh: "įŧēå¤ą"; - }; - readonly "Ui.Settings.Hatch.ModifiedSize": { - readonly def: "Modified: ${modified}, Size: ${size}"; - readonly zh: "äŋŽæ”šæ—ļ间īŧš${modified}īŧŒå¤§å°īŧš${size}"; - }; - readonly "Ui.Settings.Hatch.ModifiedSizeActual": { - readonly def: "Modified: ${modified}, Size: ${size} (actual size: ${actualSize})"; - readonly zh: "äŋŽæ”šæ—ļ间īŧš${modified}īŧŒå¤§å°īŧš${size}īŧˆåŽžé™…å¤§å°īŧš${actualSize}īŧ‰"; - }; - readonly "Ui.Settings.Hatch.PrepareIssueReport": { - readonly def: "Prepare the 'report' to create an issue"; - readonly zh: "准备ᔍäēŽæäē¤é—Žéĸ˜įš„æŠĨ告"; - }; - readonly "Ui.Settings.Hatch.RecoveryAndRepair": { - readonly def: "Recovery and Repair"; - readonly zh: "æĸ复与äŋŽå¤"; - }; - readonly "Ui.Settings.Hatch.RecreateAll": { - readonly def: "Recreate all"; - readonly zh: "全部重åģē"; - }; - readonly "Ui.Settings.Hatch.RecreateMissingChunks": { - readonly def: "Recreate missing chunks for all files"; - readonly zh: "ä¸ē所有文äģļ重新创åģēįŧēå¤ąįš„æ•°æŽå—"; - }; - readonly "Ui.Settings.Hatch.RecreateMissingChunksDesc": { - readonly def: "This will recreate chunks for all files. If there were missing chunks, this may fix the errors."; - readonly zh: "此操äŊœäŧšä¸ē所有文äģļ重新创åģēæ•°æŽå—。åĻ‚æžœå­˜åœ¨įŧēå¤ąįš„æ•°æŽå—īŧŒå¯čƒŊäŧšäŋŽå¤į›¸å…ŗé”™č¯¯ã€‚"; - }; - readonly "Ui.Settings.Hatch.ResetPanel": { - readonly def: "Reset"; - readonly zh: "重įŊŽ"; - }; - readonly "Ui.Settings.Hatch.ResetRemoteUsage": { - readonly def: "Reset notification threshold and check the remote database usage"; - readonly zh: "重įŊŽé€šįŸĨ阈å€ŧåšļæŖ€æŸĨčŋœį¨‹æ•°æŽåē“äŊŋį”¨æƒ…å†ĩ"; - }; - readonly "Ui.Settings.Hatch.ResetRemoteUsageDesc": { - readonly def: "Reset the remote storage size threshold and check the remote storage size again."; - readonly zh: "重įŊŽčŋœį¨‹å­˜å‚¨å¤§å°é˜ˆå€ŧīŧŒåšļ再æŦĄæŖ€æŸĨčŋœį¨‹å­˜å‚¨å¤§å°ã€‚"; - }; - readonly "Ui.Settings.Hatch.ResolveAllConflictedFiles": { - readonly def: "Resolve all conflicted files by the newer one"; - readonly zh: "äŊŋį”¨čžƒæ–°įš„į‰ˆæœŦč§Ŗå†ŗæ‰€æœ‰å†˛įĒæ–‡äģļ"; - }; - readonly "Ui.Settings.Hatch.ResolveAllConflictedFilesDesc": { - readonly def: "Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one."; - readonly zh: "äŊŋį”¨čžƒæ–°įš„į‰ˆæœŦč§Ŗå†ŗæ‰€æœ‰å†˛įĒæ–‡äģļã€‚æŗ¨æ„īŧšæ­¤æ“äŊœäŧščĻ†į›–čžƒæ—§į‰ˆæœŦīŧŒä¸”æ— æŗ•æĸ复čĸĢčĻ†į›–įš„å†…åŽšã€‚"; - }; - readonly "Ui.Settings.Hatch.RunDoctor": { - readonly def: "Run Doctor"; - readonly zh: "čŋčĄŒč¯Šæ–­"; - }; - readonly "Ui.Settings.Hatch.ScanBrokenFiles": { - readonly def: "Scan for broken files"; - readonly zh: "æ‰Ģ描损坏文äģļ"; - }; - readonly "Ui.Settings.Hatch.ScramSwitches": { - readonly def: "Scram Switches"; - readonly zh: "į´§æ€Ĩåŧ€å…ŗ"; - }; - readonly "Ui.Settings.Hatch.ShowHistory": { - readonly def: "Show history"; - readonly zh: "æŸĨįœ‹åŽ†å˛"; - }; - readonly "Ui.Settings.Hatch.StorageLabel": { - readonly def: "Storage: ${details}"; - readonly zh: "存储īŧš${details}"; - }; - readonly "Ui.Settings.Hatch.StorageToDatabase": { - readonly def: "Storage -> Database"; - readonly zh: "存储 -> 数捎åē“"; - }; - readonly "Ui.Settings.Hatch.VerifyAndRepairAllFiles": { - readonly def: "Verify and repair all files"; - readonly zh: "æ ĄénjåšļäŋŽå¤æ‰€æœ‰æ–‡äģļ"; - }; - readonly "Ui.Settings.Hatch.VerifyAndRepairAllFilesDesc": { - readonly def: "Compare the content of files between the local database and storage. If they do not match, you will be asked which one to keep."; - readonly zh: "æ¯”čžƒæœŦ地数捎åē“ä¸Žå­˜å‚¨ä¸­įš„æ–‡äģļ内厚。åĻ‚æžœå†…åŽšä¸ä¸€č‡´īŧŒįŗģįģŸäŧšč¯ĸ问äŊ äŋį•™å“Ē一äģŊ。"; - }; - readonly "Ui.Settings.Maintenance.Cleanup": { - readonly def: "Perform cleanup"; - readonly zh: "æ‰§čĄŒæ¸…į†"; - }; - readonly "Ui.Settings.Maintenance.CleanupDesc": { - readonly def: "Reduces storage space by discarding all non-latest revisions. This requires the same amount of free space on the remote server and the local client."; - readonly zh: "ä¸ĸåŧƒæ‰€æœ‰éžæœ€æ–°äŋŽčŽĸį‰ˆæœŦīŧŒäģĨ减少存储įŠēé—´å į”¨ã€‚æ­¤æ“äŊœčĻæą‚čŋœį¨‹æœåŠĄå™¨å’ŒæœŦ地åŽĸæˆˇį̝éƒŊå…ˇå¤‡åŒį­‰å¤§å°įš„å¯į”¨įŠē间。"; - }; - readonly "Ui.Settings.Maintenance.DeleteLocalDatabase": { - readonly def: "Delete local database to reset or uninstall Self-hosted LiveSync"; - readonly zh: "删除æœŦ地数捎åē“äģĨ重įŊŽæˆ–卸čŊŊ Self-hosted LiveSync"; - }; - readonly "Ui.Settings.Maintenance.EmergencyRestart": { - readonly def: "Emergency restart"; - readonly zh: "į´§æ€Ĩ重启"; - }; - readonly "Ui.Settings.Maintenance.EmergencyRestartDesc": { - readonly def: "Disable all synchronisation and restart."; - readonly zh: "įĻį”¨æ‰€æœ‰åŒæ­Ĩåšļ重新启动。"; - }; - readonly "Ui.Settings.Maintenance.FreshStartWipe": { - readonly def: "Fresh Start Wipe"; - readonly zh: "全新åŧ€å§‹æ¸…įŠē"; - }; - readonly "Ui.Settings.Maintenance.FreshStartWipeDesc": { - readonly def: "Delete all data on the remote server."; - readonly zh: "删除čŋœį¨‹æœåŠĄå™¨ä¸Šįš„æ‰€æœ‰æ•°æŽã€‚"; - }; - readonly "Ui.Settings.Maintenance.GarbageCollection": { - readonly def: "Garbage Collection V3 (Beta)"; - readonly zh: "垃圞回æ”ļ V3īŧˆæĩ‹č¯•į‰ˆīŧ‰"; - }; - readonly "Ui.Settings.Maintenance.GarbageCollectionAction": { - readonly def: "Perform Garbage Collection"; - readonly zh: "æ‰§čĄŒåžƒåœžå›žæ”ļ"; - }; - readonly "Ui.Settings.Maintenance.GarbageCollectionDesc": { - readonly def: "Perform Garbage Collection to remove unused chunks and reduce database size."; - readonly zh: "æ‰§čĄŒåžƒåœžå›žæ”ļäģĨį§ģ除æœĒäŊŋį”¨įš„æ•°æŽå—åšļ减少数捎åē“大小。"; - }; - readonly "Ui.Settings.Maintenance.LockServer": { - readonly def: "Lock Server"; - readonly zh: "é”åŽšæœåŠĄå™¨"; - }; - readonly "Ui.Settings.Maintenance.LockServerDesc": { - readonly def: "Lock the remote server to prevent synchronisation with other devices."; - readonly zh: "锁厚čŋœį¨‹æœåŠĄå™¨īŧŒé˜˛æ­ĸ与å…ļäģ–čŽžå¤‡įģ§įģ­åŒæ­Ĩ。"; - }; - readonly "Ui.Settings.Maintenance.OverwriteRemote": { - readonly def: "Overwrite remote"; - readonly zh: "čφᛖčŋœį¨‹į̝"; - }; - readonly "Ui.Settings.Maintenance.OverwriteRemoteDesc": { - readonly def: "Overwrite remote with local DB and passphrase."; - readonly zh: "äŊŋᔍæœŦ地数捎åē“å’Œå¯†į įŸ­č¯­čφᛖčŋœį¨‹įĢ¯æ•°æŽã€‚"; - }; - readonly "Ui.Settings.Maintenance.OverwriteServerData": { - readonly def: "Overwrite Server Data with This Device's Files"; - readonly zh: "į”¨æ­¤čŽžå¤‡įš„æ–‡äģļčĻ†į›–æœåŠĄå™¨æ•°æŽ"; - }; - readonly "Ui.Settings.Maintenance.OverwriteServerDataDesc": { - readonly def: "Rebuild the local and remote database with files from this device."; - readonly zh: "äŊŋį”¨æ­¤čŽžå¤‡ä¸Šįš„æ–‡äģļ重åģ翜Ŧ地和čŋœį¨‹æ•°æŽåē“。"; - }; - readonly "Ui.Settings.Maintenance.PurgeAllJournalCounter": { - readonly def: "Purge all journal counter"; - readonly zh: "清įŠē全部æ—Ĩåŋ—čŽĄæ•°å™¨"; - }; - readonly "Ui.Settings.Maintenance.PurgeAllJournalCounterDesc": { - readonly def: "Purge all download and upload caches."; - readonly zh: "清įŠē所有下čŊŊ与上äŧ įŧ“存。"; - }; - readonly "Ui.Settings.Maintenance.RebuildingOperations": { - readonly def: "Rebuilding Operations (Remote Only)"; - readonly zh: "重åģēæ“äŊœīŧˆäģ…čŋœį¨‹į̝īŧ‰"; - }; - readonly "Ui.Settings.Maintenance.Resend": { - readonly def: "Resend"; - readonly zh: "重新发送"; - }; - readonly "Ui.Settings.Maintenance.ResendDesc": { - readonly def: "Resend all chunks to the remote."; - readonly zh: "将所有数捎块重新发送到čŋœį¨‹įĢ¯ã€‚"; - }; - readonly "Ui.Settings.Maintenance.Reset": { - readonly def: "Reset"; - readonly zh: "重įŊŽ"; - }; - readonly "Ui.Settings.Maintenance.ResetAllJournalCounter": { - readonly def: "Reset all journal counter"; - readonly zh: "重įŊŽå…¨éƒ¨æ—Ĩåŋ—čŽĄæ•°å™¨"; - }; - readonly "Ui.Settings.Maintenance.ResetAllJournalCounterDesc": { - readonly def: "Initialise all journal history. On the next sync, every item will be received and sent again."; - readonly zh: "初始化全部æ—Ĩåŋ—åŽ†å˛ã€‚ä¸‹æŦĄåŒæ­Ĩæ—ļīŧŒæ‰€æœ‰éĄšį›ŽéƒŊäŧšé‡æ–°æŽĨæ”ļåšļ重新发送。"; - }; - readonly "Ui.Settings.Maintenance.ResetJournalReceived": { - readonly def: "Reset journal received history"; - readonly zh: "重įŊŽæ—Ĩåŋ—æŽĨæ”ļåŽ†å˛"; - }; - readonly "Ui.Settings.Maintenance.ResetJournalReceivedDesc": { - readonly def: "Initialise journal received history. On the next sync, every item except those sent by this device will be downloaded again."; - readonly zh: "初始化æ—Ĩåŋ—æŽĨæ”ļåŽ†å˛ã€‚ä¸‹æŦĄåŒæ­Ĩæ—ļīŧŒé™¤åŊ“å‰čŽžå¤‡å‘é€įš„éĄšį›Žå¤–īŧŒå…ļäŊ™éĄšį›ŽéƒŊäŧšé‡æ–°ä¸‹čŊŊ。"; - }; - readonly "Ui.Settings.Maintenance.ResetJournalSent": { - readonly def: "Reset journal sent history"; - readonly zh: "重įŊŽæ—Ĩåŋ—å‘é€åŽ†å˛"; - }; - readonly "Ui.Settings.Maintenance.ResetJournalSentDesc": { - readonly def: "Initialise journal sent history. On the next sync, every item except those received by this device will be sent again."; - readonly zh: "初始化æ—Ĩåŋ—å‘é€åŽ†å˛ã€‚ä¸‹æŦĄåŒæ­Ĩæ—ļīŧŒé™¤åŊ“å‰čŽžå¤‡åˇ˛æŽĨæ”ļįš„éĄšį›Žå¤–īŧŒå…ļäŊ™éĄšį›ŽéƒŊäŧšé‡æ–°å‘送。"; - }; - readonly "Ui.Settings.Maintenance.ResetLocalSyncInfo": { - readonly def: "Reset Synchronisation information"; - readonly zh: "重įŊŽåŒæ­ĨäŋĄæ¯"; - }; - readonly "Ui.Settings.Maintenance.ResetLocalSyncInfoDesc": { - readonly def: "Restore or reconstruct local database from remote."; - readonly zh: "äģŽčŋœį¨‹į̝æĸ复或重åģ翜Ŧ地数捎åē“。"; - }; - readonly "Ui.Settings.Maintenance.ResetReceived": { - readonly def: "Reset received"; - readonly zh: "重įŊŽæŽĨæ”ļ莰åŊ•"; - }; - readonly "Ui.Settings.Maintenance.ResetSentHistory": { - readonly def: "Reset sent history"; - readonly zh: "重įŊŽå‘送čްåŊ•"; - }; - readonly "Ui.Settings.Maintenance.ResetThisDevice": { - readonly def: "Reset Synchronisation on This Device"; - readonly zh: "重įŊŽæ­¤čŽžå¤‡ä¸Šįš„åŒæ­ĨįŠļ态"; - }; - readonly "Ui.Settings.Maintenance.ScheduleAndRestart": { - readonly def: "Schedule and Restart"; - readonly zh: "čŽĄåˆ’æ‰§čĄŒåšļ重启"; - }; - readonly "Ui.Settings.Maintenance.Scram": { - readonly def: "Scram!"; - readonly zh: "į´§æ€Ĩ处ᐆ"; - }; - readonly "Ui.Settings.Maintenance.SendChunks": { - readonly def: "Send chunks"; - readonly zh: "发送数捎块"; - }; - readonly "Ui.Settings.Maintenance.Syncing": { - readonly def: "Syncing"; - readonly zh: "同æ­Ĩ"; - }; - readonly "Ui.Settings.Maintenance.WarningLockedReadyAction": { - readonly def: "I am ready, unlock the database"; - readonly zh: "æˆ‘åˇ˛å‡†å¤‡åĨŊīŧŒįĢ‹åŗč§Ŗé”æ•°æŽåē“"; - }; - readonly "Ui.Settings.Maintenance.WarningLockedReadyText": { - readonly def: "To prevent unwanted vault corruption, the remote database has been locked for synchronisation. (This device is marked as 'resolved'.) When all your devices are marked as 'resolved', unlock the database. This warning will continue to appear until replication confirms the device is resolved."; - readonly zh: "ä¸ē防æ­ĸæ„å¤–įš„æ•°æŽäģ“å瓿ŸåīŧŒčŋœį¨‹æ•°æŽåē“厞čĸĢ锁厚īŧŒæš‚停同æ­Ĩ。īŧˆæ­¤čŽžå¤‡åˇ˛čĸĢæ ‡čްä¸ēâ€œåˇ˛įĄŽčŽ¤â€īŧ‰åŊ“äŊ įš„æ‰€æœ‰čŽžå¤‡éƒŊæ ‡čŽ°ä¸ēâ€œåˇ˛įĄŽčŽ¤â€åŽīŧŒå†č§Ŗé”æ•°æŽåē“。在复åˆļčŋ‡į¨‹įĄŽčŽ¤æ­¤čŽžå¤‡åˇ˛åŽŒæˆįĄŽčŽ¤äš‹å‰īŧŒæ­¤č­Ļ告äŧšæŒįģ­æ˜žį¤ē。"; - }; - readonly "Ui.Settings.Maintenance.WarningLockedResolveAction": { - readonly def: "I have made a backup, mark this device as resolved"; - readonly zh: "æˆ‘åˇ˛åŽŒæˆå¤‡äģŊīŧŒå°†æ­¤čŽžå¤‡æ ‡čŽ°ä¸ēâ€œåˇ˛įĄŽčŽ¤â€"; - }; - readonly "Ui.Settings.Maintenance.WarningLockedResolveText": { - readonly def: "The remote database is locked for synchronisation to prevent vault corruption because this device is not marked as 'resolved'. Please back up your vault, reset the local database, and select 'Mark this device as resolved'. This warning will persist until replication confirms the device is resolved."; - readonly zh: "ä¸ē防æ­ĸ数捎äģ“å瓿ŸåīŧŒį”ąäēŽæ­¤čŽžå¤‡å°šæœĒæ ‡čŽ°ä¸ēâ€œåˇ˛įĄŽčŽ¤â€īŧŒčŋœį¨‹æ•°æŽåē“厞čĸĢ锁厚īŧŒæš‚停同æ­Ĩã€‚č¯ˇå…ˆå¤‡äģŊäŊ įš„äģ“åē“、重įŊŽæœŦ地数捎åē“īŧŒį„ļåŽé€‰æ‹Šâ€œå°†æ­¤čŽžå¤‡æ ‡čŽ°ä¸ēåˇ˛įĄŽčŽ¤â€ã€‚åœ¨å¤åˆļčŋ‡į¨‹įĄŽčŽ¤æ­¤čŽžå¤‡åˇ˛åŽŒæˆįĄŽčŽ¤äš‹å‰īŧŒæ­¤č­Ļ告äŧšæŒįģ­æ˜žį¤ē。"; - }; - readonly "Ui.Settings.Maintenance.WriteRedFlagAndRestart": { - readonly def: "Flag and restart"; - readonly zh: "æ ‡čŽ°åšļ重启"; - }; - readonly "Ui.Settings.Patches.CompatibilityConflict": { - readonly def: "Compatibility (Conflict Behaviour)"; - readonly zh: "å…ŧ厚性īŧˆå†˛įĒčĄŒä¸ēīŧ‰"; - }; - readonly "Ui.Settings.Patches.CompatibilityDatabase": { - readonly def: "Compatibility (Database structure)"; - readonly zh: "å…ŧ厚性īŧˆæ•°æŽåē“į쓿ž„īŧ‰"; - }; - readonly "Ui.Settings.Patches.CompatibilityInternalApi": { - readonly def: "Compatibility (Internal API Usage)"; - readonly zh: "å…ŧ厚性īŧˆå†…部 API äŊŋᔍīŧ‰"; - }; - readonly "Ui.Settings.Patches.CompatibilityMetadata": { - readonly def: "Compatibility (Metadata)"; - readonly zh: "å…ŧ厚性īŧˆå…ƒæ•°æŽīŧ‰"; - }; - readonly "Ui.Settings.Patches.CompatibilityRemote": { - readonly def: "Compatibility (Remote Database)"; - readonly zh: "å…ŧ厚性īŧˆčŋœį¨‹æ•°æŽåē“īŧ‰"; - }; - readonly "Ui.Settings.Patches.CompatibilityTrouble": { - readonly def: "Compatibility (Trouble addressed)"; - readonly zh: "å…ŧ厚性īŧˆåˇ˛å¤„į†é—Žéĸ˜īŧ‰"; - }; - readonly "Ui.Settings.Patches.CurrentAdapter": { - readonly def: "Current adapter: ${adapter}"; - readonly zh: "åŊ“前适配器īŧš${adapter}"; - }; - readonly "Ui.Settings.Patches.DatabaseAdapter": { - readonly def: "Database Adapter"; - readonly zh: "数捎åē“适配器"; - }; - readonly "Ui.Settings.Patches.DatabaseAdapterDesc": { - readonly def: "Select the database adapter to use."; - readonly zh: "选拊čρäŊŋį”¨įš„æ•°æŽåē“适配器。"; - }; - readonly "Ui.Settings.Patches.EdgeCaseBehaviour": { - readonly def: "Edge case addressing (Behaviour)"; - readonly zh: "čžšį•Œæƒ…å†ĩ处ᐆīŧˆčĄŒä¸ēīŧ‰"; - }; - readonly "Ui.Settings.Patches.EdgeCaseDatabase": { - readonly def: "Edge case addressing (Database)"; - readonly zh: "čžšį•Œæƒ…å†ĩ处ᐆīŧˆæ•°æŽåē“īŧ‰"; - }; - readonly "Ui.Settings.Patches.EdgeCaseProcessing": { - readonly def: "Edge case addressing (Processing)"; - readonly zh: "čžšį•Œæƒ…å†ĩ处ᐆīŧˆå¤„ᐆæĩį¨‹īŧ‰"; - }; - readonly "Ui.Settings.Patches.IndexedDbWarning": { - readonly def: "The IndexedDB adapter often offers superior performance in certain scenarios, but it has been found to cause memory leaks when used with LiveSync mode. When using LiveSync mode, please use the IDB adapter instead."; - readonly zh: "IndexedDB 适配器在某äē›åœēæ™¯ä¸‹é€šå¸¸å…ˇæœ‰æ›´åĨŊįš„æ€§čƒŊīŧŒäŊ†åœ¨ LiveSync æ¨Ąåŧä¸‹åˇ˛å‘įŽ°å¯čƒŊå¯ŧč‡´å†…å­˜æŗ„æŧã€‚äŊŋᔍ LiveSync æ¨Ąåŧæ—ļīŧŒč¯ˇæ”šį”¨ IDB 适配器。"; - }; - readonly "Ui.Settings.Patches.MigratingToIdb": { - readonly def: "Migrating all data to IDB..."; - readonly zh: "æ­Ŗåœ¨å°†æ‰€æœ‰æ•°æŽčŋį§ģ到 IDB..."; - }; - readonly "Ui.Settings.Patches.MigratingToIndexedDb": { - readonly def: "Migrating all data to IndexedDB..."; - readonly zh: "æ­Ŗåœ¨å°†æ‰€æœ‰æ•°æŽčŋį§ģ到 IndexedDB..."; - }; - readonly "Ui.Settings.Patches.MigrationIdbCompleted": { - readonly def: "Migration to IDB completed. Obsidian will be restarted with the new configuration immediately."; - readonly zh: "åˇ˛åŽŒæˆčŋį§ģ到 IDB。Obsidian 将įĢ‹åŗäŊŋį”¨æ–°é…įŊŽé‡æ–°å¯åŠ¨ã€‚"; - }; - readonly "Ui.Settings.Patches.MigrationIdbCompletedFollowUp": { - readonly def: "Migration to IDB completed. Please switch the adapter and restart Obsidian."; - readonly zh: "åˇ˛åŽŒæˆčŋį§ģ到 IDBã€‚č¯ˇåˆ‡æĸ适配器åšļ重新启动 Obsidian。"; - }; - readonly "Ui.Settings.Patches.MigrationIndexedDbCompleted": { - readonly def: "Migration to IndexedDB completed. Obsidian will be restarted with the new configuration immediately."; - readonly zh: "åˇ˛åŽŒæˆčŋį§ģ到 IndexedDB。Obsidian 将įĢ‹åŗäŊŋį”¨æ–°é…įŊŽé‡æ–°å¯åŠ¨ã€‚"; - }; - readonly "Ui.Settings.Patches.MigrationIndexedDbCompletedFollowUp": { - readonly def: "Migration to IndexedDB completed. Please switch the adapter and restart Obsidian."; - readonly zh: "åˇ˛åŽŒæˆčŋį§ģ到 IndexedDBã€‚č¯ˇåˆ‡æĸ适配器åšļ重新启动 Obsidian。"; - }; - readonly "Ui.Settings.Patches.MigrationWarning": { - readonly def: "Changing this setting requires migrating existing data, which may take some time, and restarting Obsidian. Please make sure to back up your data before proceeding."; - readonly zh: "äŋŽæ”šæ­¤čŽžįŊŽéœ€čρčŋį§ģįŽ°æœ‰æ•°æŽīŧˆå¯čƒŊ需čρ䏀ä盿—ļ间īŧ‰åšļ重新启动 Obsidianã€‚č¯ˇå…ˆå¤‡äģŊäŊ įš„æ•°æŽåŽå†įģ§įģ­ã€‚"; - }; - readonly "Ui.Settings.Patches.OperationToIdb": { - readonly def: "to IDB"; - readonly zh: "čŋį§ģ到 IDB"; - }; - readonly "Ui.Settings.Patches.OperationToIndexedDb": { - readonly def: "to IndexedDB"; - readonly zh: "čŋį§ģ到 IndexedDB"; - }; - readonly "Ui.Settings.Patches.Remediation": { - readonly def: "Remediation"; - readonly zh: "äŋŽæ­Ŗ"; - }; - readonly "Ui.Settings.Patches.RemediationChanged": { - readonly def: "Remediation Setting Changed"; - readonly zh: "äŋŽæ­ŖčŽžįŊŽåˇ˛æ›´æ”š"; - }; - readonly "Ui.Settings.Patches.RemediationNoLimit": { - readonly def: "No limit configured"; - readonly zh: "æœĒ莞įŊŽé™åˆļ"; - }; - readonly "Ui.Settings.Patches.RemediationRestarting": { - readonly def: "Remediation setting changed. Restarting Obsidian..."; - readonly zh: "äŋŽæ­ŖčŽžįŊŽåˇ˛æ›´æ”šīŧŒæ­Ŗåœ¨é‡æ–°å¯åЍ Obsidian..."; - }; - readonly "Ui.Settings.Patches.RemediationRestartLater": { - readonly def: "Later"; - readonly zh: "į¨åŽ"; - }; - readonly "Ui.Settings.Patches.RemediationRestartMessage": { - readonly def: "Restarting Obsidian is strongly recommended. Until restart, some changes may not take effect, and the display may be inconsistent. Are you sure you want to restart now?"; - readonly zh: "åŧē჈åģēčŽŽé‡æ–°å¯åŠ¨ Obsidian。在重启䚋前īŧŒéƒ¨åˆ†æ›´æ”šå¯čƒŊ不äŧšį”Ÿæ•ˆīŧŒį•Œéĸ昞į¤ē䚟可čƒŊä¸ä¸€č‡´ã€‚įĄŽåŽščĻįŽ°åœ¨é‡å¯å—īŧŸ"; - }; - readonly "Ui.Settings.Patches.RemediationRestartNow": { - readonly def: "Restart Now"; - readonly zh: "įĢ‹åŗé‡å¯"; - }; - readonly "Ui.Settings.Patches.RemediationSuffixChanged": { - readonly def: "Suffix has been changed. Reopening database..."; - readonly zh: "后įŧ€åˇ˛æ›´æ”šīŧŒæ­Ŗåœ¨é‡æ–°æ‰“åŧ€æ•°æŽåē“..."; - }; - readonly "Ui.Settings.Patches.RemediationWithValue": { - readonly def: "Limit: ${date} (${timestamp})"; - readonly zh: "限åˆļīŧš${date}īŧˆ${timestamp}īŧ‰"; - }; - readonly "Ui.Settings.Patches.RemoteDatabaseSunset": { - readonly def: "Remote Database Tweak (In sunset)"; - readonly zh: "čŋœį¨‹æ•°æŽåē“č°ƒæ•´īŧˆåŗå°†åŧƒį”¨īŧ‰"; - }; - readonly "Ui.Settings.Patches.SwitchToIDB": { - readonly def: "Switch to IDB"; - readonly zh: "切æĸ到 IDB"; - }; - readonly "Ui.Settings.Patches.SwitchToIndexedDb": { - readonly def: "Switch to IndexedDB"; - readonly zh: "切æĸ到 IndexedDB"; - }; - readonly "Ui.Settings.PowerUsers.ConfigurationEncryption": { - readonly def: "Configuration Encryption"; - readonly zh: "配įŊŽåР坆"; - }; - readonly "Ui.Settings.PowerUsers.ConnectionTweak": { - readonly def: "CouchDB Connection Tweak"; - readonly zh: "CouchDB čŋžæŽĨč°ƒæ•´"; - }; - readonly "Ui.Settings.PowerUsers.ConnectionTweakDesc": { - readonly def: "If you reached the payload size limit when using IBM Cloudant, please decrease batch size and batch limit to a lower value."; - readonly zh: "åĻ‚æžœäŊ åœ¨äŊŋᔍ IBM Cloudant æ—ļé‡åˆ°č´ŸčŊŊ大小限åˆļīŧŒč¯ˇå°† batch size 和 batch limit 调äŊŽã€‚"; - }; - readonly "Ui.Settings.PowerUsers.Default": { - readonly def: "Default"; - readonly zh: "éģ˜čޤ"; - }; - readonly "Ui.Settings.PowerUsers.Developer": { - readonly def: "Developer"; - readonly zh: "åŧ€å‘者"; - }; - readonly "Ui.Settings.PowerUsers.EncryptSensitiveConfig": { - readonly def: "Encrypt sensitive configuration items"; - readonly zh: "加密敏感配įŊŽéĄš"; - }; - readonly "Ui.Settings.PowerUsers.PromptPassphraseEveryLaunch": { - readonly def: "Ask for a passphrase at every launch"; - readonly zh: "每æŦĄå¯åЍæ—ļč¯ĸé—Žå¯†į įŸ­č¯­"; - }; - readonly "Ui.Settings.PowerUsers.UseCustomPassphrase": { - readonly def: "Use a custom passphrase"; - readonly zh: "äŊŋᔍč‡ĒåŽšäš‰å¯†į įŸ­č¯­"; - }; - readonly "Ui.Settings.Remote.Activate": { - readonly def: "Activate"; - readonly zh: "吝ᔍ"; - }; - readonly "Ui.Settings.Remote.ActiveSuffix": { - readonly def: " (Active)"; - readonly zh: "īŧˆåŊ“å‰å¯į”¨īŧ‰"; - }; - readonly "Ui.Settings.Remote.AddConnection": { - readonly def: "Add new connection"; - readonly zh: "新åĸžčŋžæŽĨ"; - }; - readonly "Ui.Settings.Remote.AddRemoteDefaultName": { - readonly def: "New Remote"; - readonly zh: "新čŋœį¨‹į̝"; - }; - readonly "Ui.Settings.Remote.ConfigureAndChangeRemote": { - readonly def: "Configure and change remote"; - readonly zh: "配įŊŽåšļ切æĸčŋœį¨‹į̝"; - }; - readonly "Ui.Settings.Remote.ConfigureE2EE": { - readonly def: "Configure E2EE"; - readonly zh: "配įŊŽį̝到įĢ¯åŠ å¯†"; - }; - readonly "Ui.Settings.Remote.ConfigureRemote": { - readonly def: "Configure Remote"; - readonly zh: "配įŊŽčŋœį¨‹į̝"; - }; - readonly "Ui.Settings.Remote.DeleteRemoteConfirm": { - readonly def: "Delete remote configuration '${name}'?"; - readonly zh: "įĄŽåŽščĻåˆ é™¤čŋœį¨‹é…įŊŽâ€œ${name}”吗īŧŸ"; - }; - readonly "Ui.Settings.Remote.DeleteRemoteTitle": { - readonly def: "Delete Remote Configuration"; - readonly zh: "删除čŋœį¨‹é…įŊŽ"; - }; - readonly "Ui.Settings.Remote.DisplayName": { - readonly def: "Display name"; - readonly zh: "昞į¤ēåį§°"; - }; - readonly "Ui.Settings.Remote.DuplicateRemote": { - readonly def: "Duplicate remote"; - readonly zh: "复åˆļčŋœį¨‹é…įŊŽ"; - }; - readonly "Ui.Settings.Remote.DuplicateRemoteSuffix": { - readonly def: "${name} (Copy)"; - readonly zh: "${name}īŧˆå‰¯æœŦīŧ‰"; - }; - readonly "Ui.Settings.Remote.E2EEConfiguration": { - readonly def: "E2EE Configuration"; - readonly zh: "į̝到įĢ¯åŠ å¯†é…įŊŽ"; - }; - readonly "Ui.Settings.Remote.Export": { - readonly def: "Export"; - readonly zh: "å¯ŧå‡ē"; - }; - readonly "Ui.Settings.Remote.FetchRemoteSettings": { - readonly def: "Fetch remote settings"; - readonly zh: "čŽˇå–čŋœį¨‹čŽžįŊŽ"; - }; - readonly "Ui.Settings.Remote.ImportConnection": { - readonly def: "Import connection"; - readonly zh: "å¯ŧå…ĨčŋžæŽĨ"; - }; - readonly "Ui.Settings.Remote.ImportConnectionPrompt": { - readonly def: "Paste a connection string"; - readonly zh: "ម贴čŋžæŽĨ字įŦĻ串"; - }; - readonly "Ui.Settings.Remote.ImportedCouchDb": { - readonly def: "Imported CouchDB"; - readonly zh: "厞å¯ŧå…Ĩįš„ CouchDB"; - }; - readonly "Ui.Settings.Remote.ImportedRemote": { - readonly def: "Remote"; - readonly zh: "čŋœį¨‹į̝"; - }; - readonly "Ui.Settings.Remote.MoreActions": { - readonly def: "More actions"; - readonly zh: "更多操äŊœ"; - }; - readonly "Ui.Settings.Remote.PeerToPeerPanel": { - readonly def: "Peer-to-Peer Synchronisation"; - readonly zh: "į‚šå¯šį‚šåŒæ­Ĩ"; - }; - readonly "Ui.Settings.Remote.RemoteConfigurationPrefix": { - readonly def: "Remote configuration"; - readonly zh: "čŋœį¨‹é…įŊŽ"; - }; - readonly "Ui.Settings.Remote.RemoteDatabases": { - readonly def: "Remote Databases"; - readonly zh: "čŋœį¨‹æ•°æŽåē“"; - }; - readonly "Ui.Settings.Remote.RemoteName": { - readonly def: "Remote name"; - readonly zh: "čŋœį¨‹åį§°"; - }; - readonly "Ui.Settings.Remote.RemoteNameCouchDb": { - readonly def: "CouchDB ${host}"; - readonly zh: "CouchDB ${host}"; - }; - readonly "Ui.Settings.Remote.RemoteNameP2P": { - readonly def: "P2P ${room}"; - readonly zh: "P2P ${room}"; - }; - readonly "Ui.Settings.Remote.RemoteNameS3": { - readonly def: "S3 ${bucket}"; - readonly zh: "S3 ${bucket}"; - }; - readonly "Ui.Settings.Remote.Rename": { - readonly def: "Rename"; - readonly zh: "重å‘Ŋ名"; - }; - readonly "Ui.Settings.Selector.AddDefaultPatterns": { - readonly def: "Add default patterns"; - readonly zh: "æˇģ加éģ˜čŽ¤æ¨Ąåŧ"; - }; - readonly "Ui.Settings.Selector.CrossPlatform": { - readonly def: "Cross-platform"; - readonly zh: "čˇ¨åšŗå°"; - }; - readonly "Ui.Settings.Selector.Default": { - readonly def: "Default"; - readonly zh: "éģ˜čޤ"; - }; - readonly "Ui.Settings.Selector.HiddenFiles": { - readonly def: "Hidden Files"; - readonly zh: "éšč—æ–‡äģļ"; - }; - readonly "Ui.Settings.Selector.IgnorePatterns": { - readonly def: "Ignore patterns"; - readonly zh: "åŋŊį•Ĩæ¨Ąåŧ"; - }; - readonly "Ui.Settings.Selector.NonSynchronisingFiles": { - readonly def: "Non-Synchronising files"; - readonly zh: "不同æ­Ĩ文äģļ"; - }; - readonly "Ui.Settings.Selector.NonSynchronisingFilesDesc": { - readonly def: "(RegExp) If this is set, any changes to local and remote files that match this will be skipped."; - readonly zh: "īŧˆRegExpīŧ‰åĻ‚æžœčŽžįŊŽäē†č¯Ĩ饚īŧŒåˆ™æœŦ地和čŋœį¨‹ä¸­åŒšé…čŋ™äē›č§„åˆ™įš„æ–‡äģļ变更将čĸĢ莺čŋ‡ã€‚"; - }; - readonly "Ui.Settings.Selector.NormalFiles": { - readonly def: "Normal Files"; - readonly zh: "晎通文äģļ"; - }; - readonly "Ui.Settings.Selector.OverwritePatterns": { - readonly def: "Overwrite patterns"; - readonly zh: "čĻ†į›–æ¨Ąåŧ"; - }; - readonly "Ui.Settings.Selector.OverwritePatternsDesc": { - readonly def: "Patterns to match files for overwriting instead of merging"; - readonly zh: "åŒšé…åŽå°†æ‰§čĄŒčĻ†į›–č€Œéžåˆåšļįš„æ–‡äģᅪĄåŧ"; - }; - readonly "Ui.Settings.Selector.SynchronisingFiles": { - readonly def: "Synchronising files"; - readonly zh: "同æ­Ĩ文äģļ"; - }; - readonly "Ui.Settings.Selector.SynchronisingFilesDesc": { - readonly def: "(RegExp) Empty to sync all files. Set a regular expression filter to limit synchronised files."; - readonly zh: "īŧˆRegExpīŧ‰į•™įŠē则同æ­Ĩ所有文äģļã€‚å¯čŽžįŊŽæ­Ŗåˆ™čĄ¨čžžåŧäģĨ限åˆļ需čρ同æ­Ĩįš„æ–‡äģļ。"; - }; - readonly "Ui.Settings.Selector.TargetPatterns": { - readonly def: "Target patterns"; - readonly zh: "į›Žæ ‡æ¨Ąåŧ"; - }; - readonly "Ui.Settings.Selector.TargetPatternsDesc": { - readonly def: "Patterns to match files for syncing"; - readonly zh: "ᔍäēŽåŒšé…éœ€čρ同æ­Ĩ文äģļįš„æ¨Ąåŧ"; - }; - readonly "Ui.Settings.Setup.RerunWizardButton": { - readonly def: "Rerun Wizard"; - readonly zh: "重新čŋčĄŒå‘å¯ŧ"; - }; - readonly "Ui.Settings.Setup.RerunWizardDesc": { - readonly def: "Rerun the onboarding wizard to set up Self-hosted LiveSync again."; - readonly zh: "重新čŋčĄŒåŧ•å¯ŧ向å¯ŧīŧŒå†æŦĄčŽžįŊŽ Self-hosted LiveSync。"; - }; - readonly "Ui.Settings.Setup.RerunWizardName": { - readonly def: "Rerun Onboarding Wizard"; - readonly zh: "重新čŋčĄŒåŧ•å¯ŧ向å¯ŧ"; - }; - readonly "Ui.Settings.SyncSettings.Fetch": { - readonly def: "Fetch"; - readonly zh: "čŽˇå–"; - }; - readonly "Ui.Settings.SyncSettings.Merge": { - readonly def: "Merge"; - readonly zh: "合åšļ"; - }; - readonly "Ui.Settings.SyncSettings.Overwrite": { - readonly def: "Overwrite"; - readonly zh: "čφᛖ"; - }; - readonly "Ui.SetupWizard.Common.Back": { - readonly def: "No, please take me back"; - readonly zh: "不īŧŒå¸Ļ我čŋ”回"; - }; - readonly "Ui.SetupWizard.Common.Cancel": { - readonly def: "Cancel"; - readonly zh: "取æļˆ"; - }; - readonly "Ui.SetupWizard.Common.ProceedSelectOption": { - readonly def: "Please select an option to proceed"; - readonly zh: "č¯ˇé€‰æ‹Šä¸€ä¸Ēé€‰éĄšåŽįģ§įģ­"; - }; - readonly "Ui.SetupWizard.Intro.ExistingOption": { - readonly def: "I am adding a device to an existing synchronisation setup"; - readonly zh: "å°†æ­¤čŽžå¤‡åŠ å…Ĩåˇ˛æœ‰åŒæ­Ĩ配įŊŽ"; - }; - readonly "Ui.SetupWizard.Intro.ExistingOptionDesc": { - readonly def: "Select this if you are already using synchronisation on another computer or smartphone. Use this option to connect this device to that existing setup."; - readonly zh: "åĻ‚æžœäŊ åˇ˛įģåœ¨åĻ一台į”ĩč„‘æˆ–æ‰‹æœē上äŊŋį”¨åŒæ­ĨīŧŒč¯ˇé€‰æ‹Šæ­¤éĄšã€‚æ­¤é€‰éĄšį”¨äēŽå°†åŊ“å‰čŽžå¤‡čŋžæŽĨ到æ—ĸ有同æ­Ĩ配įŊŽã€‚"; - }; - readonly "Ui.SetupWizard.Intro.Guidance": { - readonly def: "We will now guide you through a few questions to simplify the synchronisation setup."; - readonly zh: "æŽĨ下æĨ我äģŦäŧšé€ščŋ‡å‡ ä¸Ē问éĸ˜īŧŒå¸ŽåŠŠäŊ æ›´čŊģæžåœ°åŽŒæˆåŒæ­Ĩ配įŊŽã€‚"; - }; - readonly "Ui.SetupWizard.Intro.NewOption": { - readonly def: "I am setting this up for the first time"; - readonly zh: "éĻ–æŦĄčŽžįŊŽåŒæ­Ĩ"; - }; - readonly "Ui.SetupWizard.Intro.NewOptionDesc": { - readonly def: "Select this if you are configuring this device as the first synchronisation device."; - readonly zh: "åĻ‚æžœäŊ æ­ŖæŠŠčŋ™å°čŽžå¤‡äŊœä¸ēįŦŦ一台同æ­ĨčŽžå¤‡čŋ›čĄŒé…įŊŽīŧŒč¯ˇé€‰æ‹Šæ­¤éĄšã€‚"; - }; - readonly "Ui.SetupWizard.Intro.ProceedExisting": { - readonly def: "Yes, I want to add this device to my existing synchronisation"; - readonly zh: "æ˜¯įš„īŧŒæˆ‘čĻå°†æ­¤čŽžå¤‡åŠ å…ĨįŽ°æœ‰åŒæ­Ĩ"; - }; - readonly "Ui.SetupWizard.Intro.ProceedNew": { - readonly def: "Yes, I want to set up a new synchronisation"; - readonly zh: "æ˜¯įš„īŧŒæˆ‘čρåŧ€å§‹æ–°įš„同æ­Ĩ配įŊŽ"; - }; - readonly "Ui.SetupWizard.Intro.Question": { - readonly def: "First, please select the option that best describes your current situation."; - readonly zh: "éĻ–å…ˆīŧŒč¯ˇé€‰æ‹Šæœ€įŦĻ合äŊ åŊ“前情å†ĩįš„é€‰éĄšã€‚"; - }; - readonly "Ui.SetupWizard.Intro.Title": { - readonly def: "Welcome to Self-hosted LiveSync"; - readonly zh: "æŦĸčŋŽäŊŋᔍ Self-hosted LiveSync"; - }; - readonly "Ui.SetupWizard.OutroAskUserMode.CompatibleOption": { - readonly def: "The remote is already set up, and the configuration is compatible (or became compatible through this operation)."; - readonly zh: "čŋœį¨‹įĢ¯åˇ˛é…įŊŽåŽŒæˆīŧŒä¸”åŊ“前配įŊŽå…ŧ厚īŧˆæˆ–åˇ˛é€ščŋ‡æœŦæŦĄæ“äŊœå˜ä¸ēå…ŧ厚īŧ‰ã€‚"; - }; - readonly "Ui.SetupWizard.OutroAskUserMode.CompatibleOptionDesc": { - readonly def: "Unless you are certain, selecting this option is risky. It assumes the server configuration is compatible with this device. If that is not the case, data loss may occur. Please make sure you understand the consequences."; - readonly zh: "除非äŊ éžå¸¸įĄŽåޚīŧŒåĻåˆ™é€‰æ‹Šæ­¤éĄšå­˜åœ¨éŖŽé™Šã€‚åŽƒå‡åŽšæœåŠĄå™¨é…įŊŽä¸ŽåŊ“å‰čŽžå¤‡å…ŧ厚。åĻ‚æžœäē‹åŽžåšļ非åĻ‚æ­¤īŧŒå¯čƒŊäŧšå¯ŧč‡´æ•°æŽä¸ĸå¤ąã€‚č¯ˇįĄŽčŽ¤äŊ äē†č§ŖåŽæžœã€‚"; - }; - readonly "Ui.SetupWizard.OutroAskUserMode.ExistingOption": { - readonly def: "My remote server is already set up. I want to join this device."; - readonly zh: "čŋœį¨‹æœåŠĄå™¨åˇ˛įģé…įŊŽåŽŒæˆīŧŒæˆ‘æƒŗčŽŠæ­¤čŽžå¤‡åŠ å…Ĩ同æ­Ĩ。"; - }; - readonly "Ui.SetupWizard.OutroAskUserMode.ExistingOptionDesc": { - readonly def: "Selecting this option will make this device join the existing server. You need to fetch the existing synchronisation data from the server to this device."; - readonly zh: "é€‰æ‹Šæ­¤éĄšåŽīŧŒæ­¤čŽžå¤‡äŧšåŠ å…Ĩåˇ˛æœ‰æœåŠĄå™¨ã€‚äŊ éœ€čĻå°†æœåŠĄå™¨ä¸Šįš„įŽ°æœ‰åŒæ­Ĩæ•°æŽčŽˇå–åˆ°æ­¤čŽžå¤‡ã€‚"; - }; - readonly "Ui.SetupWizard.OutroAskUserMode.Guidance": { - readonly def: "The connection to the server has been configured successfully. As the next step, the local database, in other words the synchronisation information, must be rebuilt."; - readonly zh: "æœåŠĄå™¨čŋžæŽĨåˇ˛æˆåŠŸé…įŊŽã€‚下一æ­Ĩ需čĻé‡åģ翜Ŧ地数捎åē“īŧŒäšŸå°ąæ˜¯åŒæ­ĨįŠļ态äŋĄæ¯ã€‚"; - }; - readonly "Ui.SetupWizard.OutroAskUserMode.NewOption": { - readonly def: "I am setting up a new server for the first time / I want to reset my existing server."; - readonly zh: "我是įŦŦ一æŦĄé…įŊŽæ–°æœåŠĄå™¨ / æˆ‘æƒŗé‡įŊŽįŽ°æœ‰æœåŠĄå™¨ã€‚"; - }; - readonly "Ui.SetupWizard.OutroAskUserMode.NewOptionDesc": { - readonly def: "Selecting this option will initialise the server using the current data on this device. Any existing data on the server will be completely overwritten."; - readonly zh: "é€‰æ‹Šæ­¤éĄšåŽīŧŒæœåŠĄå™¨äŧšäŊŋᔍåŊ“å‰čŽžå¤‡ä¸Šįš„æ•°æŽčŋ›čĄŒåˆå§‹åŒ–ã€‚æœåŠĄå™¨ä¸Šįš„įŽ°æœ‰æ•°æŽå°†čĸĢ厌全čĻ†į›–ã€‚"; - }; - readonly "Ui.SetupWizard.OutroAskUserMode.ProceedApplySettings": { - readonly def: "Apply the settings"; - readonly zh: "åē”ᔍčŋ™äē›čŽžįŊŽ"; - }; - readonly "Ui.SetupWizard.OutroAskUserMode.ProceedNext": { - readonly def: "Proceed to the next step."; - readonly zh: "įģ§įģ­ä¸‹ä¸€æ­Ĩ"; - }; - readonly "Ui.SetupWizard.OutroAskUserMode.Question": { - readonly def: "Please select your situation."; - readonly zh: "č¯ˇé€‰æ‹ŠäŊ įš„åŊ“前情å†ĩ。"; - }; - readonly "Ui.SetupWizard.OutroAskUserMode.Title": { - readonly def: "Mostly Complete: Decision Required"; - readonly zh: "åŗå°†åŽŒæˆīŧščŋ˜éœ€čρ做å‡ē选拊"; - }; - readonly "Ui.SetupWizard.OutroNewUser.GuidancePrimary": { - readonly def: "The connection to the server has been configured successfully. As the next step, the synchronisation data on the server will be built from the current data on this device."; - readonly zh: "æœåŠĄå™¨čŋžæŽĨåˇ˛æˆåŠŸé…įŊŽã€‚下一æ­Ĩ将栚捎åŊ“å‰čŽžå¤‡ä¸Šįš„æ•°æŽīŧŒåœ¨æœåŠĄå™¨į̝åģēįĢ‹åŒæ­Ĩ数捎。"; - }; - readonly "Ui.SetupWizard.OutroNewUser.GuidanceWarning": { - readonly def: "After restarting, the data on this device will be uploaded to the server as the master copy. Please note that any unintended data currently on the server will be completely overwritten."; - readonly zh: "重启后īŧŒåŊ“å‰čŽžå¤‡ä¸Šįš„æ•°æŽäŧšäŊœä¸ēä¸ģ副æœŦ上äŧ åˆ°æœåŠĄå™¨ã€‚č¯ˇæŗ¨æ„īŧŒæœåŠĄå™¨ä¸ŠįŽ°æœ‰įš„éžéĸ„期数捎将čĸĢ厌全čĻ†į›–ã€‚"; - }; - readonly "Ui.SetupWizard.OutroNewUser.Important": { - readonly def: "IMPORTANT"; - readonly zh: "重čρ"; - }; - readonly "Ui.SetupWizard.OutroNewUser.Proceed": { - readonly def: "Restart and Initialise Server"; - readonly zh: "重启åšļåˆå§‹åŒ–æœåŠĄå™¨"; - }; - readonly "Ui.SetupWizard.OutroNewUser.Question": { - readonly def: "Please select the button below to restart and proceed to the final confirmation."; - readonly zh: "č¯ˇé€‰æ‹Šä¸‹æ–šæŒ‰é’ŽīŧŒé‡å¯åšļčŋ›å…Ĩ最įģˆįĄŽčޤæ­ĨéĒ¤ã€‚"; - }; - readonly "Ui.SetupWizard.OutroNewUser.Title": { - readonly def: "Setup Complete: Preparing to Initialise Server"; - readonly zh: "莞įŊŽåŽŒæˆīŧšå‡†å¤‡åˆå§‹åŒ–æœåŠĄå™¨"; - }; - readonly "Ui.SetupWizard.SelectExisting.Guidance": { - readonly def: "You are adding this device to an existing synchronisation setup."; - readonly zh: "äŊ æ­Ŗåœ¨å°†æ­¤čŽžå¤‡åŠ å…Ĩåˇ˛æœ‰åŒæ­Ĩ配įŊŽã€‚"; - }; - readonly "Ui.SetupWizard.SelectExisting.ManualOption": { - readonly def: "Enter the server information manually"; - readonly zh: "æ‰‹åŠ¨čž“å…ĨæœåŠĄå™¨äŋĄæ¯"; - }; - readonly "Ui.SetupWizard.SelectExisting.ManualOptionDesc": { - readonly def: "Configure the same server information as your other devices again manually. This is intended only for advanced users."; - readonly zh: "手动重新配įŊŽä¸ŽäŊ å…ļäģ–čŽžå¤‡į›¸åŒįš„æœåŠĄå™¨äŋĄæ¯ã€‚此斚åŧäģ…适ᔍäēŽé̘įē§į”¨æˆˇã€‚"; - }; - readonly "Ui.SetupWizard.SelectExisting.ProceedManual": { - readonly def: "I know my server details, let me enter them"; - readonly zh: "我įŸĨé“æœåŠĄå™¨äŋĄæ¯īŧŒčŽŠæˆ‘æ‰‹åŠ¨čž“å…Ĩ"; - }; - readonly "Ui.SetupWizard.SelectExisting.ProceedQr": { - readonly def: "Scan the QR code displayed on an active device using this device's camera."; - readonly zh: "äŊŋᔍæœŦčŽžå¤‡æ‘„åƒå¤´æ‰Ģ描æ´ģåŠ¨čŽžå¤‡ä¸Šæ˜žį¤ēįš„äēŒįģ´į "; - }; - readonly "Ui.SetupWizard.SelectExisting.ProceedSetupUri": { - readonly def: "Proceed with Setup URI"; - readonly zh: "äŊŋᔍ Setup URI įģ§įģ­"; - }; - readonly "Ui.SetupWizard.SelectExisting.QrOption": { - readonly def: "Scan a QR Code (Recommended for mobile)"; - readonly zh: "æ‰Ģ描äēŒįģ´į īŧˆį§ģ动įĢ¯æŽ¨čīŧ‰"; - }; - readonly "Ui.SetupWizard.SelectExisting.QrOptionDesc": { - readonly def: "Scan the QR code displayed on an active device using this device's camera."; - readonly zh: "äŊŋᔍæœŦčŽžå¤‡æ‘„åƒå¤´æ‰Ģ描æ´ģåŠ¨čŽžå¤‡ä¸Šæ˜žį¤ēįš„äēŒįģ´į ã€‚"; - }; - readonly "Ui.SetupWizard.SelectExisting.Question": { - readonly def: "Please select a method to import the settings from another device."; - readonly zh: "č¯ˇé€‰æ‹Šä¸€į§äģŽå…ļäģ–čŽžå¤‡å¯ŧå…Ĩ莞įŊŽįš„æ–šæŗ•ã€‚"; - }; - readonly "Ui.SetupWizard.SelectExisting.SetupUriOption": { - readonly def: "Use a Setup URI (Recommended)"; - readonly zh: "äŊŋᔍ Setup URIīŧˆæŽ¨čīŧ‰"; - }; - readonly "Ui.SetupWizard.SelectExisting.SetupUriOptionDesc": { - readonly def: "Paste the Setup URI generated from one of your active devices."; - readonly zh: "ម贴äģŽæŸå°åˇ˛å¯į”¨čŽžå¤‡į”Ÿæˆįš„ Setup URI。"; - }; - readonly "Ui.SetupWizard.SelectExisting.Title": { - readonly def: "Device Setup Method"; - readonly zh: "čŽžå¤‡čŽžįŊŽæ–šåŧ"; - }; - readonly "Ui.SetupWizard.SelectNew.Guidance": { - readonly def: "We will now proceed with the server configuration."; - readonly zh: "æŽĨ下æĨ将įģ§įģ­é…įŊŽæœåŠĄå™¨čŋžæŽĨäŋĄæ¯ã€‚"; - }; - readonly "Ui.SetupWizard.SelectNew.ManualOption": { - readonly def: "Enter the server information manually"; - readonly zh: "æ‰‹åŠ¨čž“å…ĨæœåŠĄå™¨äŋĄæ¯"; - }; - readonly "Ui.SetupWizard.SelectNew.ManualOptionDesc": { - readonly def: "This is an advanced option for users who do not have a Setup URI or who want to configure detailed settings."; - readonly zh: "åĻ‚æžœäŊ æ˛Ąæœ‰ Setup URIīŧŒæˆ–希望č‡Ē行配įŊŽæ›´č¯Ļįģ†įš„参数īŧŒå¯é€‰æ‹Šæ­¤é̘įē§é€‰éĄšã€‚"; - }; - readonly "Ui.SetupWizard.SelectNew.ProceedManual": { - readonly def: "I know my server details, let me enter them"; - readonly zh: "我įŸĨé“æœåŠĄå™¨äŋĄæ¯īŧŒčŽŠæˆ‘æ‰‹åŠ¨čž“å…Ĩ"; - }; - readonly "Ui.SetupWizard.SelectNew.ProceedSetupUri": { - readonly def: "Proceed with Setup URI"; - readonly zh: "äŊŋᔍ Setup URI įģ§įģ­"; - }; - readonly "Ui.SetupWizard.SelectNew.Question": { - readonly def: "How would you like to configure the connection to your server?"; - readonly zh: "äŊ å¸Œæœ›åĻ‚äŊ•配įŊŽæœåŠĄå™¨čŋžæŽĨīŧŸ"; - }; - readonly "Ui.SetupWizard.SelectNew.SetupUriOption": { - readonly def: "Use a Setup URI (Recommended)"; - readonly zh: "äŊŋᔍ Setup URIīŧˆæŽ¨čīŧ‰"; - }; - readonly "Ui.SetupWizard.SelectNew.SetupUriOptionDesc": { - readonly def: "A Setup URI is a single string containing your server address and authentication details. If one was generated by your server installation script, it provides a simple and secure configuration method."; - readonly zh: "Setup URI 是一æŽĩ包åĢæœåŠĄå™¨åœ°å€å’ŒčŽ¤č¯äŋĄæ¯įš„æ–‡æœŦ。åĻ‚æžœäŊ įš„æœåŠĄå™¨åŽ‰čŖ…č„šæœŦ厞įģį”Ÿæˆäē†åރīŧŒčŋ™æ˜¯æœ€įŽ€å•ä¸”åŽ‰å…¨įš„é…įŊŽæ–šåŧã€‚"; - }; - readonly "Ui.SetupWizard.SelectNew.Title": { - readonly def: "Connection Method"; - readonly zh: "čŋžæŽĨæ–šåŧ"; - }; - readonly "Ui.SetupWizard.SetupRemote.BucketOption": { - readonly def: "S3/MinIO/R2 Object Storage"; - readonly zh: "S3/MinIO/R2 å¯ščąĄå­˜å‚¨"; - }; - readonly "Ui.SetupWizard.SetupRemote.BucketOptionDesc": { - readonly def: "Synchronisation using journal files. You must already have an S3/MinIO/R2 compatible object storage service set up."; - readonly zh: "äŊŋᔍæ—Ĩåŋ—æ–‡äģļčŋ›čĄŒåŒæ­Ĩ。äŊ éœ€čĻå…ˆå‡†å¤‡åĨŊå…ŧ厚 S3/MinIO/R2 įš„å¯ščąĄå­˜å‚¨æœåŠĄã€‚"; - }; - readonly "Ui.SetupWizard.SetupRemote.CouchDbOptionDesc": { - readonly def: "This is the most suitable synchronisation method for the current design. All features are available. You must already have a CouchDB instance set up."; - readonly zh: "čŋ™æ˜¯åŊ“å‰čŽžčŽĄä¸‹æœ€é€‚åˆįš„åŒæ­Ĩæ–šåŧīŧŒæ‰€æœ‰åŠŸčƒŊéƒŊå¯į”¨ã€‚äŊ éœ€čĻå…ˆå‡†å¤‡åĨŊ CouchDB 厞䞋。"; - }; - readonly "Ui.SetupWizard.SetupRemote.Guidance": { - readonly def: "Please select the type of server you are connecting to."; - readonly zh: "č¯ˇé€‰æ‹ŠäŊ čρčŋžæŽĨįš„æœåŠĄå™¨įąģ型。"; - }; - readonly "Ui.SetupWizard.SetupRemote.P2POption": { - readonly def: "Peer-to-Peer only"; - readonly zh: "äģ…į‚šå¯šį‚š"; - }; - readonly "Ui.SetupWizard.SetupRemote.P2POptionDesc": { - readonly def: "This enables direct synchronisation between devices. No server is required, but both devices must be online at the same time and some features may be limited. Internet connectivity is required only for signalling, not for data transfer."; - readonly zh: "å¯į”¨čŽžå¤‡äš‹é—´įš„į›´æŽĨ同æ­Ĩã€‚æ— éœ€æœåŠĄå™¨īŧŒäŊ†ä¸¤å°čŽžå¤‡åŋ…éĄģ同æ—ļ在įēŋīŧŒä¸”部分功čƒŊ可čƒŊ受限。äē’联įŊ‘čŋžæŽĨäģ…ᔍäēŽäŋĄäģ¤īŧŒä¸į”¨äēŽäŧ čž“数捎。"; - }; - readonly "Ui.SetupWizard.SetupRemote.ProceedBucket": { - readonly def: "Continue to S3/MinIO/R2 setup"; - readonly zh: "įģ§įģ­é…įŊŽ S3/MinIO/R2"; - }; - readonly "Ui.SetupWizard.SetupRemote.ProceedCouchDb": { - readonly def: "Continue to CouchDB setup"; - readonly zh: "įģ§įģ­é…įŊŽ CouchDB"; - }; - readonly "Ui.SetupWizard.SetupRemote.ProceedP2P": { - readonly def: "Continue to Peer-to-Peer only setup"; - readonly zh: "įģ§įģ­é…įŊŽäģ…į‚šå¯šį‚šæ¨Ąåŧ"; - }; - readonly "Ui.SetupWizard.SetupRemote.Title": { - readonly def: "Enter Server Information"; - readonly zh: "输å…ĨæœåŠĄå™¨äŋĄæ¯"; - }; - readonly "Unique name between all synchronized devices. To edit this setting, please disable customization sync once.": { - readonly def: "Unique name between all synchronized devices. To edit this setting, please disable customization sync once."; - readonly es: "Nombre Ãēnico entre dispositivos sincronizados. Para editarlo, desactive sincronizaciÃŗn de personalizaciÃŗn"; - readonly fr: "Nom unique parmi tous les appareils synchronisÊs. Pour modifier ce paramètre, dÊsactivez d'abord la synchronisation de personnalisation."; - readonly he: "׊ם ייחודי בין כל המכשירים המסונכרנים. כדי ל×ĸרוך הגדרה זו, אנא נטרל א×Ē ×Ą× ×›×¨×•×Ÿ הה×Ēאמה האישי×Ē ×¤×ĸם אח×Ē."; - readonly ja: "同期するすずãĻぎį̝æœĢé–“ã§é‡č¤‡ã—ãĒい(一意ぎ)åå‰ã€‚ã“ãŽč¨­åŽšã‚’å¤‰æ›´ã™ã‚‹å ´åˆã€ã‚Ģ゚ã‚ŋマイã‚ēåŒæœŸã‚’į„ĄåŠšãĢしãĻください。"; - readonly ko: "ëĒ¨ë“  동기화된 기기 간 溠뜠 ė´ëĻ„ėž…ë‹ˆë‹¤. ė´ ė„¤ė •ė„ íŽ¸ė§‘í•˜ë ¤ëŠ´ ė‚ŦėšŠėž 네렕 동기화ëĨŧ 한 번 ëš„í™œė„ąí™”í•´ ėŖŧė„¸ėš”."; - readonly ru: "ĐŖĐŊиĐēаĐģҌĐŊĐžĐĩ иĐŧŅ ĐŧĐĩĐļĐ´Ņƒ Đ˛ŅĐĩĐŧи ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ¸Ņ€ŅƒĐĩĐŧŅ‹Đŧи ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°Đŧи."; - readonly zh: "所有同æ­ĨčŽžå¤‡äš‹é—´įš„å”¯ä¸€åį§°ã€‚čρįŧ–čž‘æ­¤čŽžįŊŽ,蝎éĻ–å…ˆįρᔍč‡Ē厚䚉同æ­Ĩ"; - }; - readonly "Use a custom passphrase": { - readonly def: "Use a custom passphrase"; - readonly es: "Usar una frase de contraseÃąa personalizada"; - readonly ja: "ã‚Ģ゚ã‚ŋムパ゚フãƒŦãƒŧã‚ēをäŊŋう"; - readonly ko: "ė‚ŦėšŠėž 맀렕 ė•”í˜¸ëŦ¸ęĩŦ ė‚ŦėšŠ"; - readonly ru: "Đ˜ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ŅŒ ĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ĐĩĐģҌҁĐēŅƒŅŽ ĐŋĐ°Ņ€ĐžĐģҌĐŊŅƒŅŽ Ņ„Ņ€Đ°ĐˇŅƒ"; - readonly zh: "äŊŋᔍč‡ĒåŽšäš‰å¯†į įŸ­č¯­"; - }; - readonly "Use a Setup URI (Recommended)": { - readonly def: "Use a Setup URI (Recommended)"; - readonly es: "Usar un URI de configuraciÃŗn (recomendado)"; - readonly ja: "Setup URI をäŊŋうīŧˆæŽ¨åĨ¨īŧ‰"; - readonly ko: "네렕 URI ė‚ŦėšŠ(ęļŒėžĨ)"; - readonly ru: "Đ˜ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ŅŒ Setup URI (Ņ€ĐĩĐēĐžĐŧĐĩĐŊĐ´ŅƒĐĩŅ‚ŅŅ)"; - readonly zh: "äŊŋᔍ Setup URIīŧˆæŽ¨čīŧ‰"; - readonly "zh-tw": "äŊŋᔍ Setup URIīŧˆæŽ¨č–Ļīŧ‰"; - }; - readonly "Use Custom HTTP Handler": { - readonly def: "Use Custom HTTP Handler"; - readonly es: "Usar manejador HTTP personalizado"; - readonly fr: "Utiliser un gestionnaire HTTP personnalisÊ"; - readonly he: "הש×Ēמ׊ ב-HTTP Handler מו×Ēאם אישי×Ē"; - readonly ja: "ã‚Ģ゚ã‚ŋムHTTPãƒãƒŗãƒ‰ãƒŠãƒŧãŽåˆŠį”¨"; - readonly ko: "ėģ¤ėŠ¤í…€ HTTP 핸들ëŸŦ ė‚ŦėšŠ"; - readonly ru: "Đ˜ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ŅŒ ĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ĐĩĐģҌҁĐēиК HTTP ĐžĐąŅ€Đ°ĐąĐžŅ‚Ņ‡Đ¸Đē"; - readonly zh: "äŊŋᔍč‡Ē厚䚉 HTTP 处ᐆፋåē"; - }; - readonly "Use dynamic iteration count": { - readonly def: "Use dynamic iteration count"; - readonly es: "Usar conteo de iteraciones dinÃĄmico"; - readonly fr: "Utiliser un compteur d'itÊrations dynamique"; - readonly he: "הש×Ēמ׊ בספיר×Ē ××™×˜×¨×Ļיו×Ē ×“×™× ×ž×™×Ē"; - readonly ja: "å‹•įš„ãĒįš°ã‚Ščŋ”し回数"; - readonly ko: "ë™ė  반ëŗĩ íšŸėˆ˜ ė‚ŦėšŠ"; - readonly ru: "Đ˜ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ŅŒ диĐŊаĐŧĐ¸Ņ‡ĐĩҁĐēĐžĐĩ ĐēĐžĐģĐ¸Ņ‡ĐĩŅŅ‚Đ˛Đž Đ¸Ņ‚ĐĩŅ€Đ°Ņ†Đ¸Đš"; - readonly zh: "äŊŋį”¨åŠ¨æ€čŋ­äģŖæŦĄæ•°"; - }; - readonly "Use Segmented-splitter": { - readonly def: "Use Segmented-splitter"; - readonly es: "Usar divisor segmentado"; - readonly fr: "Utiliser le dÊcoupeur segmentÊ"; - readonly he: "הש×Ēמ׊ ב-Segmented-splitter"; - readonly ja: "ã‚ģã‚°ãƒĄãƒŗãƒˆåˆ†å‰˛ã‚’äŊŋᔍ"; - readonly ko: "ė˜ë¯¸ 기반 ëļ„í•  ė‚ŦėšŠ"; - readonly ru: "Đ˜ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ŅŒ ҁĐĩĐŗĐŧĐĩĐŊŅ‚ĐŊŅ‹Đš Ņ€Đ°ĐˇĐ´ĐĩĐģĐ¸Ņ‚ĐĩĐģҌ"; - readonly zh: "äŊŋį”¨åˆ†æŽĩåˆ†å‰˛å™¨"; - }; - readonly "Use splitting-limit-capped chunk splitter": { - readonly def: "Use splitting-limit-capped chunk splitter"; - readonly es: "Usar divisor de chunks con límite"; - readonly fr: "Utiliser le dÊcoupeur de fragments plafonnÊ"; - readonly he: "הש×Ēמ׊ ב-chunk splitter ×ĸם מגבל×Ē ×¤×™×Ļול"; - readonly ja: "åˆ†å‰˛åˆļ限äģ˜ããƒãƒŖãƒŗã‚¯åˆ†å‰˛ã‚’äŊŋᔍ"; - readonly ko: "ëļ„í•  ė œí•œ ėƒí•œ 랭íŦ ëļ„할기 ė‚ŦėšŠ"; - readonly ru: "Đ˜ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ŅŒ Ņ€Đ°ĐˇĐ´ĐĩĐģĐ¸Ņ‚ĐĩĐģҌ Ņ‡Đ°ĐŊĐēОв ҁ ĐžĐŗŅ€Đ°ĐŊĐ¸Ņ‡ĐĩĐŊиĐĩĐŧ"; - readonly zh: "äŊŋį”¨åˆ†å‰˛é™åˆļä¸Šé™įš„å—åˆ†å‰˛å™¨"; - }; - readonly "Use the trash bin": { - readonly def: "Use the trash bin"; - readonly es: "Usar papelera"; - readonly fr: "Utiliser la corbeille"; - readonly he: "הש×Ēמ׊ בסל האשפה"; - readonly ja: "ã‚´ãƒŸįŽąã‚’äŊŋᔍ"; - readonly ko: "íœ´ė§€í†ĩ ė‚ŦėšŠ"; - readonly ru: "Đ˜ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ŅŒ ĐēĐžŅ€ĐˇĐ¸ĐŊ҃"; - readonly zh: "äŊŋį”¨å›žæ”ļįĢ™"; - }; - readonly "Use timeouts instead of heartbeats": { - readonly def: "Use timeouts instead of heartbeats"; - readonly es: "Usar timeouts en lugar de latidos"; - readonly fr: "Utiliser des dÊlais d'attente au lieu de battements"; - readonly he: "הש×Ēמ׊ בפסק זמן במקום פ×ĸימו×Ē ×œ×‘"; - readonly ja: "ハãƒŧトビãƒŧトぎäģŖã‚ã‚ŠãĢã‚ŋイムã‚ĸã‚ĻトをäŊŋᔍ"; - readonly ko: "하트비트 ëŒ€ė‹  íƒ€ėž„ė•„ė›ƒ ė‚ŦėšŠ"; - readonly ru: "Đ˜ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ŅŒ Ņ‚Đ°ĐšĐŧĐ°ŅƒŅ‚Ņ‹ вĐŧĐĩŅŅ‚Đž Đŋ҃ĐģŅŒŅĐ°"; - readonly zh: "äŊŋᔍčļ…æ—ļč€Œä¸æ˜¯åŋƒčˇŗ"; - }; - readonly username: { - readonly def: "username"; - readonly es: "nombre de usuario"; - readonly fr: "nom d'utilisateur"; - readonly he: "׊ם מ׊×Ēמ׊"; - readonly ja: "ãƒĻãƒŧã‚ļãƒŧ名"; - readonly ko: "ė‚ŦėšŠėžëĒ…"; - readonly ru: "иĐŧŅ ĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ĐĩĐģŅ"; - readonly zh: "į”¨æˆˇå"; - }; - readonly Username: { - readonly def: "Username"; - readonly es: "Usuario"; - readonly fr: "Nom d'utilisateur"; - readonly he: "׊ם מ׊×Ēמ׊"; - readonly ja: "ãƒĻãƒŧã‚ļãƒŧ名"; - readonly ko: "ė‚ŦėšŠėžëĒ…"; - readonly ru: "ИĐŧŅ ĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ĐĩĐģŅ"; - readonly zh: "į”¨æˆˇå"; - }; - readonly "Verbose Log": { - readonly def: "Verbose Log"; - readonly es: "Registro detallado"; - readonly fr: "Journal verbeux"; - readonly he: "יומן מפורט"; - readonly ja: "エナãƒŧäģĨå¤–ãŽãƒ­ã‚°é …į›Ž"; - readonly ko: "ėžė„¸í•œ 로그"; - readonly ru: "ĐŸĐžĐ´Ņ€ĐžĐąĐŊŅ‹Đš ĐģĐžĐŗ"; - readonly zh: "č¯Ļį솿—Ĩåŋ—"; - }; - readonly "Verify all": { - readonly def: "Verify all"; - readonly es: "Verificar todo"; - readonly ja: "すずãĻ検č¨ŧ"; - readonly ko: "ëĒ¨ë‘ 검ėĻ"; - readonly ru: "ĐŸŅ€ĐžĐ˛ĐĩŅ€Đ¸Ņ‚ŅŒ Đ˛ŅŅ‘"; - readonly zh: "å…¨éƒ¨æ Ąénj"; - readonly "zh-tw": "å…¨éƒ¨éŠ—č­‰"; - }; - readonly "Verify and repair all files": { - readonly def: "Verify and repair all files"; - readonly es: "Verificar y reparar todos los archivos"; - readonly ja: "すずãĻãŽãƒ•ã‚Ąã‚¤ãƒĢを検č¨ŧしãĻäŋŽåžŠ"; - readonly ko: "ëĒ¨ë“  파ėŧ 검ėĻ 및 ëŗĩęĩŦ"; - readonly ru: "ĐŸŅ€ĐžĐ˛ĐĩŅ€Đ¸Ņ‚ŅŒ и Đ˛ĐžŅŅŅ‚Đ°ĐŊĐžĐ˛Đ¸Ņ‚ŅŒ Đ˛ŅĐĩ Ņ„Đ°ĐšĐģŅ‹"; - readonly zh: "æ ĄénjåšļäŋŽå¤æ‰€æœ‰æ–‡äģļ"; - readonly "zh-tw": "驗證ä¸ĻäŋŽåžŠæ‰€æœ‰æĒ”æĄˆ"; - }; - readonly "Warning! This will have a serious impact on performance. And the logs will not be synchronised under the default name. Please be careful with logs; they often contain your confidential information.": { - readonly def: "Warning! This will have a serious impact on performance. And the logs will not be synchronised under the default name. Please be careful with logs; they often contain your confidential information."; - readonly es: "ÂĄAdvertencia! Impacta rendimiento. Los logs no se sincronizan con nombre predeterminado. Contienen informaciÃŗn confidencial"; - readonly fr: "Attention ! Ceci aura un impact important sur les performances. De plus, les journaux ne seront pas synchronisÊs sous le nom par dÊfaut. Soyez prudent avec les journaux ; ils contiennent souvent des informations confidentielles."; - readonly he: "אזהרה! לכך ×Ēהיה השפ×ĸה ר×Ļיני×Ē ×ĸל הבי×Ļו×ĸים. ×‘× ×•×Ą×Ŗ, היומנים לא יסונכרנו ×Ēח×Ē ×”×Š× בריר×Ē ×”×ž×—×“×œ. אנא היה זהיר ×ĸם יומנים; הם לרוב מכילים מיד×ĸ סודי שלך."; - readonly ja: "č­Ļ告īŧã“れはパフりãƒŧãƒžãƒŗã‚šãĢ重大ãĒåŊąéŸŋを与えぞす。ぞた、ログはデフりãƒĢト名では同期されぞせん。ログãĢã¯æŠŸå¯†æƒ…å ąãŒåĢãžã‚Œã‚‹ã“ã¨ãŒå¤šã„ãŸã‚ã€æŗ¨æ„ã—ãĻください。"; - readonly ko: "ę˛Ŋęŗ ! ė´ëŠ” ė„ąëŠĨ뗐 ė‹Ŧ각한 똁í–Ĩė„ ë¯¸ėšŠë‹ˆë‹¤. 로그는 ę¸°ëŗ¸ ė´ëĻ„ėœŧ로 ë™ę¸°í™”ë˜ė§€ ė•ŠėŠĩ니다. ëĄœęˇ¸ė—ëŠ” ėĸ…ėĸ… 기밀 ė •ëŗ´ę°€ íŦí•¨ë˜ė–´ ėžˆėœŧë¯€ëĄœ ėŖŧė˜í•´ ėŖŧė„¸ėš”."; - readonly ru: "Warning! This will have a serious impact on performance. And the logs will not be synchronised under the default name. Please be careful with logs; they often contain your confidential information."; - readonly zh: "č­Ļ告īŧčŋ™å°†ä¸Ĩ重åŊąå“æ€§čƒŊ。åšļ且æ—Ĩåŋ—不äŧšäģĨéģ˜čŽ¤åį§°åŒæ­Ĩã€‚č¯ˇå°åŋƒå¤„ᐆæ—Ĩåŋ—īŧ›åރäģŦ通常包åĢæ‚¨įš„æ•æ„ŸäŋĄæ¯ "; - }; - readonly "We cannot change the device name while this feature is enabled. Please disable this feature to change the device name.": { - readonly def: "We cannot change the device name while this feature is enabled. Please disable this feature to change the device name."; - readonly es: "No podemos cambiar el nombre del dispositivo mientras esta funciÃŗn estÊ habilitada. Deshabilita la funciÃŗn para cambiarlo."; - readonly ja: "こぎ抟čƒŊが有劚ãĒ間はデバイ゚名を変更できぞせん。変更するãĢはこぎ抟čƒŊã‚’į„ĄåŠšãĢしãĻください。"; - readonly ko: "ė´ 기ëŠĨė´ í™œė„ąí™”ë˜ė–´ ėžˆëŠ” ë™ė•ˆė—ëŠ” ėžĨėš˜ ė´ëĻ„ė„ ëŗ€ę˛Ŋ할 눘 ė—†ėŠĩ니다. ėžĨėš˜ ė´ëĻ„ė„ ëŗ€ę˛Ŋ하려면 ė´ 기ëŠĨė„ ëš„í™œė„ąí™”í•˜ė„¸ėš”."; - readonly ru: "НĐĩвОСĐŧĐžĐļĐŊĐž иСĐŧĐĩĐŊĐ¸Ņ‚ŅŒ иĐŧŅ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°, ĐŋĐžĐēа ŅŅ‚Đ° Ņ„ŅƒĐŊĐēŅ†Đ¸Ņ вĐēĐģŅŽŅ‡ĐĩĐŊа. ĐžŅ‚ĐēĐģŅŽŅ‡Đ¸Ņ‚Đĩ ĐĩŅ‘, Ņ‡Ņ‚ĐžĐąŅ‹ иСĐŧĐĩĐŊĐ¸Ņ‚ŅŒ иĐŧŅ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°."; - readonly zh: "å¯į”¨æ­¤åŠŸčƒŊæ—ļæ— æŗ•æ›´æ”ščŽžå¤‡åį§°ã€‚åĻ‚éœ€äŋŽæ”ščŽžå¤‡åį§°īŧŒč¯ˇå…ˆįĻį”¨æ­¤åŠŸčƒŊ。"; - }; - readonly "We will now guide you through a few questions to simplify the synchronisation setup.": { - readonly def: "We will now guide you through a few questions to simplify the synchronisation setup."; - readonly es: "Ahora le guiaremos con unas pocas preguntas para simplificar la configuraciÃŗn de la sincronizaciÃŗn。"; - readonly ja: "これからいくつかぎčŗĒ問ãĢæ˛ŋãŖãĻã€åŒæœŸč¨­åŽšã‚’į°Ąå˜ãĢé€˛ã‚ãžã™ã€‚"; - readonly ko: "동기화 ė„¤ė •ė„ 더 ė‰Ŋ枌 ė§„í–‰í•  눘 ėžˆë„ëĄ ëLJ 氀맀 마ëŦ¸ėœŧ로 ė•ˆë‚´í•´ 드ëĻŦ겠ėŠĩ니다。"; - readonly ru: "ĐĄĐĩĐšŅ‡Đ°Ņ ĐŧŅ‹ СададиĐŧ ĐŊĐĩҁĐēĐžĐģҌĐēĐž вОĐŋŅ€ĐžŅĐžĐ˛, Ņ‡Ņ‚ĐžĐąŅ‹ ҃ĐŋŅ€ĐžŅŅ‚Đ¸Ņ‚ŅŒ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐē҃ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸ã€‚"; - readonly zh: "æŽĨ下æĨ我äģŦäŧšé€ščŋ‡å‡ ä¸Ē问éĸ˜īŧŒåŧ•å¯ŧäŊ æ›´čŊģæžåœ°åŽŒæˆåŒæ­Ĩ莞įŊŽã€‚"; - readonly "zh-tw": "æŽĨä¸‹äž†æˆ‘å€‘æœƒé€éŽåšžå€‹å•éĄŒīŧŒåŧ•å°ŽäŊ æ›´čŧ•éŦ†åœ°åŽŒæˆåŒæ­Ĩč¨­åŽšã€‚"; - }; - readonly "We will now proceed with the server configuration.": { - readonly def: "We will now proceed with the server configuration."; - readonly es: "Ahora continuaremos con la configuraciÃŗn del servidor。"; - readonly ja: "æŦĄãĢã‚ĩãƒŧバãƒŧč¨­åŽšã‚’é€˛ã‚ãžã™ã€‚"; - readonly ko: "ė´ė œ ė„œë˛„ ęĩŦė„ąė„ ė§„í–‰í•˜ę˛ ėŠĩ니다。"; - readonly ru: "ĐĸĐĩĐŋĐĩŅ€ŅŒ ĐŋĐĩŅ€ĐĩĐšĐ´Ņ‘Đŧ Đē ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēĐĩ ҁĐĩŅ€Đ˛ĐĩŅ€Đ°ã€‚"; - readonly zh: "æŽĨ下æĨ将įģ§įģ­čŋ›čĄŒæœåŠĄå™¨é…įŊŽã€‚"; - readonly "zh-tw": "æŽĨ下䞆將įšŧįēŒé€˛čĄŒäŧ翜å™¨č¨­åŽšã€‚"; - }; - readonly "Welcome to Self-hosted LiveSync": { - readonly def: "Welcome to Self-hosted LiveSync"; - readonly es: "Bienvenido a Self-hosted LiveSync"; - readonly ja: "Self-hosted LiveSync へようこそ"; - readonly ko: "Self-hosted LiveSync뗐 ė˜¤ė‹  ę˛ƒė„ í™˜ė˜í•Šë‹ˆë‹¤"; - readonly ru: "Đ”ĐžĐąŅ€Đž ĐŋĐžĐļаĐģĐžĐ˛Đ°Ņ‚ŅŒ в Self-hosted LiveSync"; - readonly zh: "æŦĸčŋŽäŊŋᔍ Self-hosted LiveSync"; - readonly "zh-tw": "æ­ĄčŋŽäŊŋᔍ Self-hosted LiveSync"; - }; - readonly "When you save a file in the editor, start a sync automatically": { - readonly def: "When you save a file in the editor, start a sync automatically"; - readonly es: "Iniciar sincronizaciÃŗn automÃĄtica al guardar en editor"; - readonly fr: "À l'enregistrement d'un fichier dans l'Êditeur, dÊmarrer automatiquement une synchronisation"; - readonly he: "כאשר א×Ēה שומר קוב×Ĩ ב×ĸורך, ה×Ēחל סנכרון אוטומטי×Ē"; - readonly ja: "ã‚¨ãƒ‡ã‚Ŗã‚ŋã§ãƒ•ã‚Ąã‚¤ãƒĢをäŋå­˜ã™ã‚‹ã¨ã€č‡Ēå‹•įš„ãĢ同期を開始しぞす"; - readonly ko: "íŽ¸ė§‘ę¸°ė—ė„œ 파ėŧė„ ė €ėžĨ할 때 ėžë™ėœŧ로 동기화ëĨŧ ė‹œėž‘í•Šë‹ˆë‹¤"; - readonly ru: "ĐšĐžĐŗĐ´Đ° Đ˛Ņ‹ ŅĐžŅ…Ņ€Đ°ĐŊŅĐĩŅ‚Đĩ Ņ„Đ°ĐšĐģ в Ņ€ĐĩдаĐēŅ‚ĐžŅ€Đĩ, Đ°Đ˛Ņ‚ĐžĐŧĐ°Ņ‚Đ¸Ņ‡ĐĩҁĐēи СаĐŋ҃ҁĐēĐ°Ņ‚ŅŒ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸ŅŽ"; - readonly zh: "åŊ“您在įŧ–čž‘å™¨ä¸­äŋå­˜æ–‡äģļæ—ļ,č‡Ē动åŧ€å§‹åŒæ­Ĩ"; - }; - readonly "Write credentials in the file": { - readonly def: "Write credentials in the file"; - readonly es: "Escribir credenciales en archivo"; - readonly fr: "Écrire les identifiants dans le fichier"; - readonly he: "כ×Ēוב פרטי גישה בקוב×Ĩ"; - readonly ja: "čĒč¨ŧæƒ…å ąãŽãƒ•ã‚Ąã‚¤ãƒĢ内äŋå­˜"; - readonly ko: "파ėŧ뗐 ėžę˛Š ėĻëĒ… ė €ėžĨ"; - readonly ru: "ЗаĐŋĐ¸ŅŅ‹Đ˛Đ°Ņ‚ŅŒ ŅƒŅ‡Ņ‘Ņ‚ĐŊŅ‹Đĩ даĐŊĐŊŅ‹Đĩ в Ņ„Đ°ĐšĐģ"; - readonly zh: "将凭捎写å…Ĩ文äģļ"; - }; - readonly "Write logs into the file": { - readonly def: "Write logs into the file"; - readonly es: "Escribir logs en archivo"; - readonly fr: "Écrire les journaux dans le fichier"; - readonly he: "כ×Ēוב יומנים לקוב×Ĩ"; - readonly ja: "ãƒ•ã‚Ąã‚¤ãƒĢãĢãƒ­ã‚°ã‚’č¨˜éŒ˛"; - readonly ko: "파ėŧ뗐 로그 기록"; - readonly ru: "ЗаĐŋĐ¸ŅŅ‹Đ˛Đ°Ņ‚ŅŒ ĐģĐžĐŗĐ¸ в Ņ„Đ°ĐšĐģ"; - readonly zh: "将æ—Ĩåŋ—写å…Ĩ文äģļ"; - }; - readonly "xxhash32 (Fast but less collision resistance)": { - readonly def: "xxhash32 (Fast but less collision resistance)"; - readonly es: "xxhash32 (rÃĄpido, pero con menor resistencia a colisiones)"; - readonly ja: "xxhash32 (éĢ˜é€Ÿã§ã™ãŒčĄįĒč€æ€§ã¯äŊŽã‚)"; - readonly ko: "xxhash32 (ëš ëĨ´ė§€ë§Œ ėļŠëŒ ė €í•­ė„ąė€ ë‚ŽėŒ)"; - readonly ru: "xxhash32 (ĐąŅ‹ŅŅ‚Ņ€Ņ‹Đš, ĐŊĐž ҁ ĐŧĐĩĐŊҌ҈ĐĩĐš ŅƒŅŅ‚ĐžĐšŅ‡Đ¸Đ˛ĐžŅŅ‚ŅŒŅŽ Đē ĐēĐžĐģĐģĐ¸ĐˇĐ¸ŅĐŧ)"; - readonly zh: "xxhash32īŧˆé€ŸåēĻåŋĢīŧŒäŊ†æŠ—įĸ°æ’žčƒŊåŠ›čžƒåŧąīŧ‰"; - readonly "zh-tw": "xxhash32īŧˆé€ŸåēĻåŋĢīŧŒäŊ†æŠ—įĸ°æ’žčƒŊ力čŧƒåŧąīŧ‰"; - }; - readonly "xxhash64 (Fastest)": { - readonly def: "xxhash64 (Fastest)"; - readonly es: "xxhash64 (el mÃĄs rÃĄpido)"; - readonly ja: "xxhash64 (最速)"; - readonly ko: "xxhash64 (가ėžĨ ëš ëĻ„)"; - readonly ru: "xxhash64 (ŅĐ°ĐŧŅ‹Đš ĐąŅ‹ŅŅ‚Ņ€Ņ‹Đš)"; - readonly zh: "xxhash64īŧˆæœ€åŋĢīŧ‰"; - readonly "zh-tw": "xxhash64īŧˆæœ€åŋĢīŧ‰"; - }; - readonly "Yes, I want to add this device to my existing synchronisation": { - readonly def: "Yes, I want to add this device to my existing synchronisation"; - readonly es: "Sí, quiero aÃąadir este dispositivo a mi sincronizaciÃŗn existente"; - readonly ja: "はい、こぎį̝æœĢをæ—ĸ存ぎ同期ãĢčŋŊ加しぞす"; - readonly ko: "똈, ė´ ėžĨėš˜ëĨŧ ę¸°ėĄ´ ë™ę¸°í™”ė— ėļ”ę°€í•˜ę˛ ėŠĩ니다"; - readonly ru: "Да, Ņ Ņ…ĐžŅ‡Ņƒ Đ´ĐžĐąĐ°Đ˛Đ¸Ņ‚ŅŒ ŅŅ‚Đž ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đž Đē ŅŅƒŅ‰ĐĩŅŅ‚Đ˛ŅƒŅŽŅ‰ĐĩĐš ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸"; - readonly zh: "æ˜¯įš„īŧŒæˆ‘čĻæŠŠčŋ™å°čŽžå¤‡åŠ å…ĨįŽ°æœ‰åŒæ­Ĩ"; - readonly "zh-tw": "æ˜¯įš„īŧŒæˆ‘čĻæŠŠé€™å°čŖįŊŽåŠ å…Ĩæ—ĸ有同æ­Ĩ"; - }; - readonly "Yes, I want to set up a new synchronisation": { - readonly def: "Yes, I want to set up a new synchronisation"; - readonly es: "Sí, quiero configurar una nueva sincronizaciÃŗn"; - readonly ja: "ã¯ã„ã€æ–°ã—ã„åŒæœŸã‚’č¨­åŽšã—ãžã™"; - readonly ko: "똈, 냈 동기화ëĨŧ ė„¤ė •í•˜ę˛ ėŠĩ니다"; - readonly ru: "Да, Ņ Ņ…ĐžŅ‡Ņƒ ĐŊĐ°ŅŅ‚Ņ€ĐžĐ¸Ņ‚ŅŒ ĐŊĐžĐ˛ŅƒŅŽ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸ŅŽ"; - readonly zh: "æ˜¯įš„īŧŒæˆ‘čĻé…įŊŽæ–°įš„同æ­Ĩ"; - readonly "zh-tw": "æ˜¯įš„īŧŒæˆ‘čĻč¨­åŽšæ–°įš„åŒæ­Ĩ"; - }; - readonly "You are adding this device to an existing synchronisation setup.": { - readonly def: "You are adding this device to an existing synchronisation setup."; - readonly es: "EstÃĄ aÃąadiendo este dispositivo a una configuraciÃŗn de sincronizaciÃŗn existente。"; - readonly ja: "こぎį̝æœĢをæ—ĸ存ぎ同期構成ãĢčŋŊ加しようとしãĻいぞす。"; - readonly ko: "ė´ ėžĨėš˜ëĨŧ ę¸°ėĄ´ 동기화 ęĩŦė„ąė— ėļ”ę°€í•˜ë ¤ęŗ  핊니다。"; - readonly ru: "Đ’Ņ‹ дОйавĐģŅĐĩŅ‚Đĩ ŅŅ‚Đž ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đž Đē ŅŅƒŅ‰ĐĩŅŅ‚Đ˛ŅƒŅŽŅ‰ĐĩĐš ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēĐĩ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Đ¸ã€‚"; - readonly zh: "äŊ æ­Ŗåœ¨å°†æ­¤čŽžå¤‡åŠ å…Ĩåˆ°įŽ°æœ‰åŒæ­Ĩ配įŊŽä¸­ã€‚"; - readonly "zh-tw": "äŊ æ­Ŗåœ¨å°‡æ­¤čŖįŊŽåŠ å…Ĩæ—ĸ有同æ­Ĩč¨­åŽšä¸­ã€‚"; - }; - readonly "Compute revisions for chunks (Previous behaviour)": { - readonly es: "Calcular revisiones para chunks (comportamiento anterior)"; - }; - readonly "Setup.> [!INFO]- The connected devices have been detected as follows:\n${devices}": { - readonly es: "> [!INFO]- Se detectaron los siguientes dispositivos conectados:\n${devices}"; - }; - readonly "Setup.All devices have the same progress value (${progress}). Your devices seem to be synchronised. And be able to proceed with Garbage Collection.": { - readonly es: "Todos los dispositivos tienen el mismo valor de progreso (${progress}). Parece que tus dispositivos estÃĄn sincronizados y se puede continuar con la recolecciÃŗn de basura."; - }; - readonly "Setup.Cancel Garbage Collection": { - readonly es: "Cancelar la recolecciÃŗn de basura"; - }; - readonly "Setup.Compaction in progress on remote database...": { - readonly es: "La compactaciÃŗn estÃĄ en curso en la base de datos remota..."; - }; - readonly "Setup.Compaction on remote database completed successfully.": { - readonly es: "La compactaciÃŗn en la base de datos remota se completÃŗ correctamente."; - }; - readonly "Setup.Compaction on remote database failed.": { - readonly es: "La compactaciÃŗn en la base de datos remota fallÃŗ."; - }; - readonly "Setup.Compaction on remote database timed out.": { - readonly es: "La compactaciÃŗn en la base de datos remota agotÃŗ el tiempo de espera."; - }; - readonly "Setup.Device": { - readonly es: "Dispositivo"; - }; - readonly "Setup.Failed to connect to remote for compaction.": { - readonly es: "No se pudo conectar a la base de datos remota para la compactaciÃŗn."; - }; - readonly "Setup.Failed to connect to remote for compaction. ${reason}": { - readonly es: "No se pudo conectar a la base de datos remota para la compactaciÃŗn. ${reason}"; - }; - readonly "Setup.Failed to start one-shot replication before Garbage Collection. Garbage Collection Cancelled.": { - readonly es: "No se pudo iniciar la replicaciÃŗn de una sola vez antes de la recolecciÃŗn de basura. La recolecciÃŗn de basura se cancelÃŗ."; - }; - readonly "Setup.Failed to start replication after Garbage Collection.": { - readonly es: "No se pudo iniciar la replicaciÃŗn despuÊs de la recolecciÃŗn de basura."; - }; - readonly "Setup.Garbage Collection cancelled by user.": { - readonly es: "El usuario cancelÃŗ la recolecciÃŗn de basura."; - }; - readonly "Setup.Garbage Collection completed. Deleted chunks: ${deletedChunks} / ${totalChunks}. Time taken: ${seconds} seconds.": { - readonly es: "RecolecciÃŗn de basura completada. Chunks eliminados: ${deletedChunks} / ${totalChunks}. Tiempo empleado: ${seconds} segundos."; - }; - readonly "Setup.Garbage Collection Confirmation": { - readonly es: "ConfirmaciÃŗn de recolecciÃŗn de basura"; - }; - readonly "Setup.Garbage Collection: Found ${unusedChunks} unused chunks to delete.": { - readonly es: "RecolecciÃŗn de basura: se encontraron ${unusedChunks} chunks no usados para eliminar."; - }; - readonly "Setup.Garbage Collection: Scanned ${scanned} / ~${docCount}": { - readonly es: "RecolecciÃŗn de basura: escaneados ${scanned} / ~${docCount}"; - }; - readonly "Setup.Garbage Collection: Scanning completed. Total chunks: ${totalChunks}, Used chunks: ${usedChunks}": { - readonly es: "RecolecciÃŗn de basura: escaneo completado. Chunks totales: ${totalChunks}, chunks usados: ${usedChunks}"; - }; - readonly "Setup.Ignore and Proceed": { - readonly es: "Ignorar y continuar"; - }; - readonly "Setup.No connected device information found. Cancelling Garbage Collection.": { - readonly es: "No se encontrÃŗ informaciÃŗn de dispositivos conectados. Cancelando la recolecciÃŗn de basura."; - }; - readonly "Setup.Node ID": { - readonly es: "ID del nodo"; - }; - readonly "Setup.Node Information Missing": { - readonly es: "Falta informaciÃŗn del nodo"; - }; - readonly "Setup.Obsidian version": { - readonly es: "VersiÃŗn de Obsidian"; - }; - readonly "Setup.optionNoSetupUri": { - readonly es: "No, no tengo"; - }; - readonly "Setup.optionRemindNextLaunch": { - readonly es: "RecordÃĄrmelo en el prÃŗximo inicio"; - }; - readonly "Setup.optionSetupWizard": { - readonly es: "LlÊvame al asistente de configuraciÃŗn"; - }; - readonly "Setup.optionYesFetchAgain": { - readonly es: "Sí, obtener nuevamente"; - }; - readonly "Setup.Please disable 'Read chunks online' in settings to use Garbage Collection.": { - readonly es: "Desactiva \"Read chunks online\" en los ajustes para usar la recolecciÃŗn de basura."; - }; - readonly "Setup.Please enable 'Compute revisions for chunks' in settings to use Garbage Collection.": { - readonly es: "Activa \"Compute revisions for chunks\" en los ajustes para usar la recolecciÃŗn de basura."; - }; - readonly "Setup.Please select 'Cancel' explicitly to cancel this operation.": { - readonly es: "Selecciona explícitamente \"Cancelar\" para cancelar esta operaciÃŗn."; - }; - readonly "Setup.Plug-in version": { - readonly es: "VersiÃŗn del complemento"; - }; - readonly "Setup.Proceed Garbage Collection": { - readonly es: "Continuar con la recolecciÃŗn de basura"; - }; - readonly "Setup.Proceeding with Garbage Collection, ignoring missing nodes.": { - readonly es: "Continuando con la recolecciÃŗn de basura e ignorando los nodos faltantes."; - }; - readonly "Setup.Proceeding with Garbage Collection.": { - readonly es: "Continuando con la recolecciÃŗn de basura."; - }; - readonly "Setup.Progress": { - readonly es: "Progreso"; - }; - readonly "Setup.Setup URI dialog cancelled.": { - readonly es: "Se cancelÃŗ el diÃĄlogo de Setup URI."; - }; - readonly "Setup.Some devices have differing progress values (max: ${maxProgress}, min: ${minProgress}).\nThis may indicate that some devices have not completed synchronisation, which could lead to conflicts. Strongly recommend confirming that all devices are synchronised before proceeding.": { - readonly es: "Algunos dispositivos tienen valores de progreso diferentes (mÃĄx.: ${maxProgress}, mín.: ${minProgress}).\nEsto puede indicar que algunos dispositivos no han completado la sincronizaciÃŗn, lo que podría causar conflictos. Se recomienda encarecidamente confirmar que todos los dispositivos estÊn sincronizados antes de continuar."; - }; - readonly "Setup.The following accepted nodes are missing its node information:\n- ${missingNodes}\n\nThis indicates that they have not been connected for some time or have been left on an older version.\nIt is preferable to update all devices if possible. If you have any devices that are no longer in use, you can clear all accepted nodes by locking the remote once.": { - readonly es: "Los siguientes nodos aceptados no tienen informaciÃŗn del nodo:\n- ${missingNodes}\n\nEsto indica que no se han conectado desde hace algÃēn tiempo o que se han quedado en una versiÃŗn anterior.\nSi es posible, es preferible actualizar todos los dispositivos. Si tienes dispositivos que ya no se usan, puedes borrar todos los nodos aceptados bloqueando el remoto una vez."; - }; - readonly "Setup.titleCaseSensitivity": { - readonly es: "Sensibilidad a mayÃēsculas"; - }; - readonly "Setup.titleRecommendSetupUri": { - readonly es: "RecomendaciÃŗn de uso de URI de configuraciÃŗn"; - }; - readonly "Setup.titleWelcome": { - readonly es: "Bienvenido a Self-hosted LiveSync"; - }; - readonly "(Not recommended) If set, credentials will be stored in the file": { - readonly ru: "(НĐĩ Ņ€ĐĩĐēĐžĐŧĐĩĐŊĐ´ŅƒĐĩŅ‚ŅŅ) Đ•ŅĐģи ŅƒŅŅ‚Đ°ĐŊОвĐģĐĩĐŊĐž, ŅƒŅ‡Ņ‘Ņ‚ĐŊŅ‹Đĩ даĐŊĐŊŅ‹Đĩ ĐąŅƒĐ´ŅƒŅ‚ ŅĐžŅ…Ņ€Đ°ĐŊĐĩĐŊŅ‹ в Ņ„Đ°ĐšĐģĐĩ"; - }; - readonly "Before v0.17.16, we used an old adapter for the local database. Now the new adapter is preferred. However, it needs local database rebuilding.": { - readonly ru: "До v0.17.16 ĐŧŅ‹ Đ¸ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Đģи ŅŅ‚Đ°Ņ€Ņ‹Đš адаĐŋŅ‚ĐĩŅ€ Đ´ĐģŅ ĐģĐžĐēаĐģҌĐŊОК ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅. ĐĸĐĩĐŋĐĩŅ€ŅŒ ĐŋŅ€ĐĩĐ´ĐŋĐžŅ‡Ņ‚Đ¸Ņ‚ĐĩĐģĐĩĐŊ ĐŊĐžĐ˛Ņ‹Đš адаĐŋŅ‚ĐĩŅ€. ОдĐŊаĐēĐž ҂ҀĐĩĐąŅƒĐĩŅ‚ŅŅ ĐŋĐĩŅ€ĐĩŅŅ‚Ņ€ĐžĐĩĐŊиĐĩ ĐģĐžĐēаĐģҌĐŊОК ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅."; - }; - readonly descConnectSetupURI: { - readonly ru: "Đ­Ņ‚Đž Ņ€ĐĩĐēĐžĐŧĐĩĐŊĐ´ŅƒĐĩĐŧŅ‹Đš ҁĐŋĐžŅĐžĐą ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи Self-hosted LiveSync ҁ ĐŋĐžĐŧĐžŅ‰ŅŒŅŽ Setup URI."; - }; - readonly descCopySetupURI: { - readonly ru: "ИдĐĩаĐģҌĐŊĐž Đ´ĐģŅ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи ĐŊĐžĐ˛ĐžĐŗĐž ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đ°!"; - }; - readonly descEnableLiveSync: { - readonly ru: "ВĐēĐģŅŽŅ‡Đ°ĐšŅ‚Đĩ ŅŅ‚Đž Ņ‚ĐžĐģҌĐēĐž ĐŋĐžŅĐģĐĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи ОдĐŊĐžĐŗĐž иС Đ´Đ˛ŅƒŅ… Đ˛Đ°Ņ€Đ¸Đ°ĐŊŅ‚ĐžĐ˛ Đ˛Ņ‹ŅˆĐĩ."; - }; - readonly descFetchConfigFromRemote: { - readonly ru: "Đ—Đ°ĐŗŅ€ŅƒĐˇĐ¸Ņ‚ŅŒ ĐŊĐĩĐžĐąŅ…ĐžĐ´Đ¸ĐŧŅ‹Đĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи ҁ ҃ĐļĐĩ ĐŊĐ°ŅŅ‚Ņ€ĐžĐĩĐŊĐŊĐžĐŗĐž ŅƒĐ´Đ°ĐģŅ‘ĐŊĐŊĐžĐŗĐž ҁĐĩŅ€Đ˛ĐĩŅ€Đ°."; - }; - readonly descManualSetup: { - readonly ru: "НĐĩ Ņ€ĐĩĐēĐžĐŧĐĩĐŊĐ´ŅƒĐĩŅ‚ŅŅ, ĐŊĐž ĐŋĐžĐģĐĩСĐŊĐž, ĐĩҁĐģи ҃ Đ˛Đ°Ņ ĐŊĐĩŅ‚ Setup URI"; - }; - readonly descTestDatabaseConnection: { - readonly ru: "ĐžŅ‚ĐēŅ€Ņ‹Ņ‚ŅŒ ĐŋОдĐēĐģŅŽŅ‡ĐĩĐŊиĐĩ Đē йаСĐĩ даĐŊĐŊҋ҅."; - }; - readonly descValidateDatabaseConfig: { - readonly ru: "ĐŸŅ€ĐžĐ˛ĐĩŅ€ŅĐĩŅ‚ и Đ¸ŅĐŋŅ€Đ°Đ˛ĐģŅĐĩŅ‚ ĐŋĐžŅ‚ĐĩĐŊŅ†Đ¸Đ°ĐģҌĐŊŅ‹Đĩ ĐŋŅ€ĐžĐąĐģĐĩĐŧŅ‹ ҁ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸ĐĩĐš ĐąĐ°ĐˇŅ‹ даĐŊĐŊҋ҅."; - }; - readonly "If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this.": { - readonly ru: "Đ•ŅĐģи вĐēĐģŅŽŅ‡ĐĩĐŊĐž, ĐąŅƒĐ´ĐĩŅ‚ Đ¸ŅĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ŅŒŅŅ ŅŅ„Ņ„ĐĩĐēŅ‚Đ¸Đ˛ĐŊĐ°Ņ ŅĐ¸ĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ°Ņ†Đ¸Ņ ĐŊĐ°ŅŅ‚Ņ€ĐžĐĩĐē Đ´ĐģŅ ĐēаĐļĐ´ĐžĐŗĐž Ņ„Đ°ĐšĐģа."; - }; - readonly "If this is set, changes to local files which are matched by the ignore files will be skipped.": { - readonly ru: "Đ•ŅĐģи ŅƒŅŅ‚Đ°ĐŊОвĐģĐĩĐŊĐž, иСĐŧĐĩĐŊĐĩĐŊĐ¸Ņ Ņ„Đ°ĐšĐģОв иС ҁĐŋĐ¸ŅĐēа Đ¸ĐŗĐŊĐžŅ€Đ¸Ņ€ĐžĐ˛Đ°ĐŊĐ¸Ņ ĐąŅƒĐ´ŅƒŅ‚ ĐŋŅ€ĐžĐŋŅƒŅ‰ĐĩĐŊŅ‹."; - }; - readonly "If this option is enabled, PouchDB will hold the connection open for 60 seconds, and if no change arrives in that time, close and reopen the socket, instead of holding it open indefinitely.": { - readonly ru: "Đ•ŅĐģи ŅŅ‚Đ° ĐžĐŋŅ†Đ¸Ņ вĐēĐģŅŽŅ‡ĐĩĐŊа, PouchDB ĐąŅƒĐ´ĐĩŅ‚ Đ´ĐĩŅ€ĐļĐ°Ņ‚ŅŒ ŅĐžĐĩдиĐŊĐĩĐŊиĐĩ ĐžŅ‚ĐēҀҋ҂ҋĐŧ 60 ҁĐĩĐē҃ĐŊĐ´."; - }; - readonly "Number of batches to process at a time. Defaults to 40. Minimum is 2.": { - readonly ru: "КоĐģĐ¸Ņ‡ĐĩŅŅ‚Đ˛Đž ĐŋаĐēĐĩŅ‚ĐžĐ˛ Đ´ĐģŅ ĐžĐąŅ€Đ°ĐąĐžŅ‚Đēи Са Ņ€Đ°Đˇ. По ҃ĐŧĐžĐģŅ‡Đ°ĐŊĐ¸ŅŽ 40. МиĐŊиĐŧ҃Đŧ 2."; - }; - readonly "Save settings to a markdown file.": { - readonly ru: "ĐĄĐžŅ…Ņ€Đ°ĐŊĐ¸Ņ‚ŅŒ ĐŊĐ°ŅŅ‚Ņ€ĐžĐšĐēи в Ņ„Đ°ĐšĐģ markdown."; - }; - readonly "The maximum duration for which chunks can be incubated within the document.": { - readonly ru: "МаĐēŅĐ¸ĐŧаĐģҌĐŊĐ°Ņ ĐŋŅ€ĐžĐ´ĐžĐģĐļĐ¸Ņ‚ĐĩĐģҌĐŊĐžŅŅ‚ŅŒ иĐŊĐēŅƒĐąĐ°Ņ†Đ¸Đ¸ Ņ‡Đ°ĐŊĐēОв в Đ´ĐžĐē҃ĐŧĐĩĐŊŅ‚Đĩ."; - }; - readonly "The maximum number of chunks that can be incubated within the document.": { - readonly ru: "МаĐēŅĐ¸ĐŧаĐģҌĐŊĐžĐĩ ĐēĐžĐģĐ¸Ņ‡ĐĩŅŅ‚Đ˛Đž иĐŊĐēŅƒĐąĐ¸Ņ€ŅƒĐĩĐŧҋ҅ Ņ‡Đ°ĐŊĐēОв в Đ´ĐžĐē҃ĐŧĐĩĐŊŅ‚Đĩ."; - }; - readonly "The maximum total size of chunks that can be incubated within the document.": { - readonly ru: "МаĐēŅĐ¸ĐŧаĐģҌĐŊŅ‹Đš ĐžĐąŅ‰Đ¸Đš Ņ€Đ°ĐˇĐŧĐĩŅ€ иĐŊĐēŅƒĐąĐ¸Ņ€ŅƒĐĩĐŧҋ҅ Ņ‡Đ°ĐŊĐēОв в Đ´ĐžĐē҃ĐŧĐĩĐŊŅ‚Đĩ."; - }; - readonly "This passphrase will not be copied to another device. It will be set to until you configure it again.": { - readonly ru: "Đ­Ņ‚Đ° ĐŋĐ°Ņ€ĐžĐģҌĐŊĐ°Ņ Ņ„Ņ€Đ°ĐˇĐ° ĐŊĐĩ ĐąŅƒĐ´ĐĩŅ‚ ҁĐēĐžĐŋĐ¸Ņ€ĐžĐ˛Đ°ĐŊа ĐŊа Đ´Ņ€ŅƒĐŗĐžĐĩ ŅƒŅŅ‚Ņ€ĐžĐšŅŅ‚Đ˛Đž."; - }; - readonly "Warning! This will have a serious impact on performance. And the logs will not be synchronised under the default name.": { - readonly ru: "ВĐŊиĐŧаĐŊиĐĩ! Đ­Ņ‚Đž ҁĐĩŅ€ŅŒŅ‘ĐˇĐŊĐž ĐŋОвĐģĐ¸ŅĐĩŅ‚ ĐŊа ĐŋŅ€ĐžĐ¸ĐˇĐ˛ĐžĐ´Đ¸Ņ‚ĐĩĐģҌĐŊĐžŅŅ‚ŅŒ."; - }; -}; diff --git a/_types/src/lib/src/common/messages/de.d.ts b/_types/src/lib/src/common/messages/de.d.ts deleted file mode 100644 index 4171aba3..00000000 --- a/_types/src/lib/src/common/messages/de.d.ts +++ /dev/null @@ -1,299 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare const PartialMessages: { - readonly de: { - "(Active)": string; - "(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": string; - "(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": string; - "(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": string; - "(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": string; - "> [!INFO]- The connected devices have been detected as follows:\n${devices}": string; - "A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration.": string; - Activate: string; - "Add default patterns": string; - "Add new connection": string; - "All devices have the same progress value (${progress}). Your devices seem to be synchronised. And be able to proceed with Garbage Collection.": string; - Back: string; - "Back to non-configured": string; - Cancel: string; - "Cancel Garbage Collection": string; - "Check and convert non-path-obfuscated files": string; - "Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.": string; - "cmdConfigSync.showCustomizationSync": string; - "Compaction in progress on remote database...": string; - "Compaction on remote database completed successfully.": string; - "Compaction on remote database failed.": string; - "Compaction on remote database timed out.": string; - "Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep.": string; - "Compatibility (Conflict Behaviour)": string; - "Compatibility (Database structure)": string; - "Compatibility (Internal API Usage)": string; - "Compatibility (Metadata)": string; - "Compatibility (Remote Database)": string; - "Compatibility (Trouble addressed)": string; - Configure: string; - "Configure And Change Remote": string; - "Configure E2EE": string; - "Configure Remote": string; - "Configure the same server information as your other devices again, manually, very advanced users only.": string; - "Connection Method": string; - "Continue to CouchDB setup": string; - "Continue to Peer-to-Peer only setup": string; - "Continue to S3/MinIO/R2 setup": string; - Copy: string; - "Cross-platform": string; - "Current adapter: {adapter}": string; - "Customization Sync (Beta3)": string; - "Database Adapter": string; - Default: string; - Delete: string; - "Delete all customization sync data": string; - "Delete all data on the remote server.": string; - "Delete local database to reset or uninstall Self-hosted LiveSync": string; - "Delete Remote Configuration": string; - "Delete remote configuration '{name}'?": string; - desktop: string; - Device: string; - "Device name": string; - "Device Setup Method": string; - "Disables all synchronization and restart.": string; - "Display name": string; - Duplicate: string; - "Duplicate remote": string; - "E2EE Configuration": string; - "Edge case addressing (Behaviour)": string; - "Edge case addressing (Database)": string; - "Edge case addressing (Processing)": string; - "Emergency restart": string; - "Encrypting sensitive configuration items": string; - "Enter Server Information": string; - "Enter the server information manually": string; - Export: string; - "Failed to connect to remote for compaction.": string; - "Failed to connect to remote for compaction. ${reason}": string; - "Failed to start one-shot replication before Garbage Collection. Garbage Collection Cancelled.": string; - "Failed to start replication after Garbage Collection.": string; - "Fetch remote settings": string; - "File to resolve conflict": string; - "First, please select the option that best describes your current situation.": string; - "Flag and restart": string; - "Fresh Start Wipe": string; - "Garbage Collection cancelled by user.": string; - "Garbage Collection completed. Deleted chunks: ${deletedChunks} / ${totalChunks}. Time taken: ${seconds} seconds.": string; - "Garbage Collection Confirmation": string; - "Garbage Collection V3 (Beta)": string; - "Garbage Collection: Found ${unusedChunks} unused chunks to delete.": string; - "Garbage Collection: Scanned ${scanned} / ~${docCount}": string; - "Garbage Collection: Scanning completed. Total chunks: ${totalChunks}, Used chunks: ${usedChunks}": string; - "Hidden Files": string; - "Hide completely": string; - "How to display network errors when the sync server is unreachable.": string; - "How would you like to configure the connection to your server?": string; - "I am adding a device to an existing synchronisation setup": string; - "I am setting this up for the first time": string; - "I know my server details, let me enter them": string; - "If enabled, the \u26D4 icon will be shown inside the status instead of the file warnings banner. No details will be shown.": string; - "Ignore and Proceed": string; - "Ignore patterns": string; - "Import connection": string; - "Initialise all journal history, On the next sync, every item will be received and sent.": string; - Later: string; - "Limit: {datetime} ({timestamp})": string; - Lock: string; - "Lock Server": string; - "Lock the remote server to prevent synchronization with other devices.": string; - "More actions": string; - "Network warning style": string; - "New Remote": string; - "No connected device information found. Cancelling Garbage Collection.": string; - "No limit configured": string; - "No, please take me back": string; - "Node ID": string; - "Node Information Missing": string; - "Non-Synchronising files": string; - "Normal Files": string; - "Obsidian version": string; - "obsidianLiveSyncSettingTab.btnApply": string; - "obsidianLiveSyncSettingTab.btnDisable": string; - "obsidianLiveSyncSettingTab.btnNext": string; - "obsidianLiveSyncSettingTab.buttonNext": string; - "obsidianLiveSyncSettingTab.defaultLanguage": string; - "obsidianLiveSyncSettingTab.labelDisabled": string; - "obsidianLiveSyncSettingTab.labelEnabled": string; - "obsidianLiveSyncSettingTab.logConfiguredDisabled": string; - "obsidianLiveSyncSettingTab.logConfiguredLiveSync": string; - "obsidianLiveSyncSettingTab.logConfiguredPeriodic": string; - "obsidianLiveSyncSettingTab.logSelectAnyPreset": string; - "obsidianLiveSyncSettingTab.msgConfigCheckFailed": string; - "obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": string; - "obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.msgGenerateSetupURI": string; - "obsidianLiveSyncSettingTab.msgInvalidPassphrase": string; - "obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": string; - "obsidianLiveSyncSettingTab.nameDisableHiddenFileSync": string; - "obsidianLiveSyncSettingTab.nameEnableHiddenFileSync": string; - "obsidianLiveSyncSettingTab.nameHiddenFileSynchronization": string; - "obsidianLiveSyncSettingTab.optionDisableAllAutomatic": string; - "obsidianLiveSyncSettingTab.optionLiveSync": string; - "obsidianLiveSyncSettingTab.optionOnEvents": string; - "obsidianLiveSyncSettingTab.optionPeriodicAndEvents": string; - "obsidianLiveSyncSettingTab.optionPeriodicWithBatch": string; - "obsidianLiveSyncSettingTab.titleAppearance": string; - "obsidianLiveSyncSettingTab.titleConflictResolution": string; - "obsidianLiveSyncSettingTab.titleCongratulations": string; - "obsidianLiveSyncSettingTab.titleCouchDB": string; - "obsidianLiveSyncSettingTab.titleDeletionPropagation": string; - "obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": string; - "obsidianLiveSyncSettingTab.titleEncryptionPassphraseInvalid": string; - "obsidianLiveSyncSettingTab.titleFetchConfig": string; - "obsidianLiveSyncSettingTab.titleHiddenFiles": string; - "obsidianLiveSyncSettingTab.titleLogging": string; - "obsidianLiveSyncSettingTab.titleMinioS3R2": string; - "obsidianLiveSyncSettingTab.titleNotification": string; - "obsidianLiveSyncSettingTab.titleRemoteConfigCheckFailed": string; - "obsidianLiveSyncSettingTab.titleRemoteServer": string; - "obsidianLiveSyncSettingTab.titleSynchronizationMethod": string; - "obsidianLiveSyncSettingTab.titleSynchronizationPreset": string; - "obsidianLiveSyncSettingTab.titleSyncSettingsViaMarkdown": string; - "obsidianLiveSyncSettingTab.titleUpdateThinning": string; - Ok: string; - "Old Algorithm": string; - "Older fallback (Slow, W/O WebAssembly)": string; - "Overwrite patterns": string; - "Overwrite remote": string; - "Overwrite remote with local DB and passphrase.": string; - "Overwrite Server Data with This Device's Files": string; - "paneMaintenance.markDeviceResolvedAfterBackup": string; - "paneMaintenance.remoteLockedAndDeviceNotAccepted": string; - "paneMaintenance.remoteLockedResolvedDevice": string; - "paneMaintenance.unlockDatabaseReady": string; - "Paste a connection string": string; - "Paste the Setup URI generated from one of your active devices.": string; - "Patterns to match files for overwriting instead of merging": string; - "Patterns to match files for syncing": string; - "Peer-to-Peer only": string; - "Peer-to-Peer Synchronisation": string; - Perform: string; - "Perform cleanup": string; - "Perform Garbage Collection": string; - "Perform Garbage Collection to remove unused chunks and reduce database size.": string; - "Pick a file to resolve conflict": string; - "Please disable 'Read chunks online' in settings to use Garbage Collection.": string; - "Please enable 'Compute revisions for chunks' in settings to use Garbage Collection.": string; - "Please select 'Cancel' explicitly to cancel this operation.": string; - "Please select a method to import the settings from another device.": string; - "Please select an option to proceed": string; - "Please select the type of server to which you are connecting.": string; - "Please set this device name": string; - "Plug-in version": string; - "Proceed Garbage Collection": string; - "Proceed with Setup URI": string; - "Proceeding with Garbage Collection, ignoring missing nodes.": string; - "Proceeding with Garbage Collection.": string; - Progress: string; - "PureJS fallback (Fast, W/O WebAssembly)": string; - "Purge all download/upload cache.": string; - "Purge all journal counter": string; - "Rebuild local and remote database with local files.": string; - "Rebuilding Operations (Remote Only)": string; - "Recreate all": string; - "Recreate missing chunks for all files": string; - Remediation: string; - "Remediation Setting Changed": string; - "Remote Database Tweak (In sunset)": string; - "Remote Databases": string; - "Remote name": string; - Rename: string; - Resend: string; - "Resend all chunks to the remote.": string; - Reset: string; - "Reset all": string; - "Reset all journal counter": string; - "Reset journal received history": string; - "Reset journal sent history": string; - "Reset received": string; - "Reset sent history": string; - "Reset Synchronisation information": string; - "Reset Synchronisation on This Device": string; - "Resolve All": string; - "Resolve all conflicted files": string; - "Resolve All conflicted files by the newer one": string; - "Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one.": string; - "Restart Now": string; - "Restore or reconstruct local database from remote.": string; - "S3/MinIO/R2 Object Storage": string; - "Scan a QR Code (Recommended for mobile)": string; - "Scan the QR code displayed on an active device using this device's camera.": string; - "Schedule and Restart": string; - "Scram!": string; - "Select the database adapter to use.": string; - Send: string; - "Send chunks": string; - "Setting.GenerateKeyPair.Desc": string; - "Setting.GenerateKeyPair.Title": string; - "Setup URI dialog cancelled.": string; - "Setup.RemoteE2EE.AdvancedTitle": string; - "Setup.RemoteE2EE.AlgorithmWarning": string; - "Setup.RemoteE2EE.ButtonCancel": string; - "Setup.RemoteE2EE.ButtonProceed": string; - "Setup.RemoteE2EE.DefaultAlgorithmDesc": string; - "Setup.RemoteE2EE.Guidance": string; - "Setup.RemoteE2EE.LabelEncrypt": string; - "Setup.RemoteE2EE.LabelEncryptionAlgorithm": string; - "Setup.RemoteE2EE.LabelObfuscateProperties": string; - "Setup.RemoteE2EE.MultiDestinationWarning": string; - "Setup.RemoteE2EE.ObfuscatePropertiesDesc": string; - "Setup.RemoteE2EE.PassphraseValidationLine1": string; - "Setup.RemoteE2EE.PassphraseValidationLine2": string; - "Setup.RemoteE2EE.PlaceholderPassphrase": string; - "Setup.RemoteE2EE.StronglyRecommendedLine1": string; - "Setup.RemoteE2EE.StronglyRecommendedLine2": string; - "Setup.RemoteE2EE.StronglyRecommendedTitle": string; - "Setup.RemoteE2EE.Title": string; - "Setup.ScanQRCode.ButtonClose": string; - "Setup.ScanQRCode.Guidance": string; - "Setup.ScanQRCode.Step1": string; - "Setup.ScanQRCode.Step2": string; - "Setup.ScanQRCode.Step3": string; - "Setup.ScanQRCode.Step4": string; - "Setup.ScanQRCode.Title": string; - "Setup.UseSetupURI.ButtonCancel": string; - "Setup.UseSetupURI.ButtonProceed": string; - "Setup.UseSetupURI.ErrorFailedToParse": string; - "Setup.UseSetupURI.ErrorPassphraseRequired": string; - "Setup.UseSetupURI.GuidanceLine1": string; - "Setup.UseSetupURI.GuidanceLine2": string; - "Setup.UseSetupURI.InvalidInfo": string; - "Setup.UseSetupURI.LabelPassphrase": string; - "Setup.UseSetupURI.LabelSetupURI": string; - "Setup.UseSetupURI.PlaceholderPassphrase": string; - "Setup.UseSetupURI.Title": string; - "Setup.UseSetupURI.ValidInfo": string; - "Show full banner": string; - "Show icon only": string; - "Show status icon instead of file warnings banner": string; - "Some devices have differing progress values (max: ${maxProgress}, min: ${minProgress}).\nThis may indicate that some devices have not completed synchronisation, which could lead to conflicts. Strongly recommend confirming that all devices are synchronised before proceeding.": string; - "Switch to IDB": string; - "Switch to IndexedDB": string; - "Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage.": string; - "Synchronising files": string; - Syncing: string; - "Target patterns": string; - "The following accepted nodes are missing its node information:\n- ${missingNodes}\n\nThis indicates that they have not been connected for some time or have been left on an older version.\nIt is preferable to update all devices if possible. If you have any devices that are no longer in use, you can clear all accepted nodes by locking the remote once.": string; - "This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.": string; - "This is an advanced option for users who do not have a URI or who wish to configure detailed settings.": string; - "This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.": string; - "This will recreate chunks for all files. If there were missing chunks, this may fix the errors.": string; - "Use a Setup URI (Recommended)": string; - "Verify all": string; - "Verify and repair all files": string; - "We will now guide you through a few questions to simplify the synchronisation setup.": string; - "We will now proceed with the server configuration.": string; - "Welcome to Self-hosted LiveSync": string; - "xxhash32 (Fast but less collision resistance)": string; - "xxhash64 (Fastest)": string; - "Yes, I want to add this device to my existing synchronisation": string; - "Yes, I want to set up a new synchronisation": string; - "You are adding this device to an existing synchronisation setup.": string; - }; -}; diff --git a/_types/src/lib/src/common/messages/def.d.ts b/_types/src/lib/src/common/messages/def.d.ts deleted file mode 100644 index d2a7e239..00000000 --- a/_types/src/lib/src/common/messages/def.d.ts +++ /dev/null @@ -1,1121 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare const PartialMessages: { - readonly def: { - "(Active)": string; - "(BETA) Always overwrite with a newer file": string; - "(Beta) Use ignore files": string; - "(Days passed, 0 to disable automatic-deletion)": string; - "(ex. Read chunks online) If this option is enabled, LiveSync reads chunks online directly instead of replicating them locally. Increasing Custom chunk size is recommended.": string; - "(MB) If this is set, changes to local and remote files that are larger than this will be skipped. If the file becomes smaller again, a newer one will be used.": string; - "(Mega chars)": string; - "(Not recommended) If set, credentials will be stored in the file.": string; - "(Obsolete) Use an old adapter for compatibility": string; - "(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": string; - "(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": string; - "(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": string; - "(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": string; - "> [!INFO]- The connected devices have been detected as follows:\n${devices}": string; - "A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration.": string; - "Access Key": string; - Activate: string; - "Active Remote Configuration": string; - "Add default patterns": string; - "Add new connection": string; - "All devices have the same progress value (${progress}). Your devices seem to be synchronised. And be able to proceed with Garbage Collection.": string; - "Always prompt merge conflicts": string; - Analyse: string; - "Analyse database usage": string; - "Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like.": string; - "Apply Latest Change if Conflicting": string; - "Apply preset configuration": string; - "Ask a passphrase at every launch": string; - "Automatically Sync all files when opening Obsidian.": string; - Back: string; - "Back to non-configured": string; - "Batch database update": string; - "Batch limit": string; - "Batch size": string; - "Batch size of on-demand fetching": string; - "Before v0.17.16, we used an old adapter for the local database. Now the new adapter is preferred. However, it needs local database rebuilding. Please disable this toggle when you have enough time. If leave it enabled, also while fetching from the remote database, you will be asked to disable this.": string; - "Bucket Name": string; - Cancel: string; - "Cancel Garbage Collection": string; - "Changing this setting requires migrating existing data (a bit time may be taken) and restarting Obsidian. Please make sure to back up your data before proceeding.": string; - Check: string; - "Check and convert non-path-obfuscated files": string; - "Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.": string; - "cmdConfigSync.showCustomizationSync": string; - "Comma separated `.gitignore, .dockerignore`": string; - "Compaction in progress on remote database...": string; - "Compaction on remote database completed successfully.": string; - "Compaction on remote database failed.": string; - "Compaction on remote database timed out.": string; - "Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep.": string; - "Compatibility (Conflict Behaviour)": string; - "Compatibility (Database structure)": string; - "Compatibility (Internal API Usage)": string; - "Compatibility (Metadata)": string; - "Compatibility (Remote Database)": string; - "Compatibility (Trouble addressed)": string; - "Compute revisions for chunks": string; - "Configuration Encryption": string; - Configure: string; - "Configure And Change Remote": string; - "Configure E2EE": string; - "Configure Remote": string; - "Configure the same server information as your other devices again, manually, very advanced users only.": string; - "Connection Method": string; - "Continue to CouchDB setup": string; - "Continue to Peer-to-Peer only setup": string; - "Continue to S3/MinIO/R2 setup": string; - Copy: string; - "Copy Report to clipboard": string; - "CouchDB Connection Tweak": string; - "Cross-platform": string; - "Current adapter: {adapter}": string; - "Customization Sync": string; - "Customization Sync (Beta3)": string; - "Data Compression": string; - "Database -> Storage": string; - "Database Adapter": string; - "Database Name": string; - "Database suffix": string; - Default: string; - "Delay conflict resolution of inactive files": string; - "Delay merge conflict prompt for inactive files.": string; - Delete: string; - "Delete all customization sync data": string; - "Delete all data on the remote server.": string; - "Delete local database to reset or uninstall Self-hosted LiveSync": string; - "Delete old metadata of deleted files on start-up": string; - "Delete Remote Configuration": string; - "Delete remote configuration '{name}'?": string; - desktop: string; - Developer: string; - Device: string; - "Device name": string; - "Device Setup Method": string; - "dialog.yourLanguageAvailable": string; - "dialog.yourLanguageAvailable.btnRevertToDefault": string; - "dialog.yourLanguageAvailable.Title": string; - "Disables all synchronization and restart.": string; - "Disables logging, only shows notifications. Please disable if you report an issue.": string; - "Display Language": string; - "Display name": string; - "Do not check configuration mismatch before replication": string; - "Do not keep metadata of deleted files.": string; - "Do not split chunks in the background": string; - "Do not use internal API": string; - "Doctor.Button.DismissThisVersion": string; - "Doctor.Button.Fix": string; - "Doctor.Button.FixButNoRebuild": string; - "Doctor.Button.No": string; - "Doctor.Button.Skip": string; - "Doctor.Button.Yes": string; - "Doctor.Dialogue.Main": string; - "Doctor.Dialogue.MainFix": string; - "Doctor.Dialogue.Title": string; - "Doctor.Dialogue.TitleAlmostDone": string; - "Doctor.Dialogue.TitleFix": string; - "Doctor.Level.Must": string; - "Doctor.Level.Necessary": string; - "Doctor.Level.Optional": string; - "Doctor.Level.Recommended": string; - "Doctor.Message.NoIssues": string; - "Doctor.Message.RebuildLocalRequired": string; - "Doctor.Message.RebuildRequired": string; - "Doctor.Message.SomeSkipped": string; - "Doctor.RULES.E2EE_V02500.REASON": string; - "Document History": string; - Duplicate: string; - "Duplicate remote": string; - "E2EE Configuration": string; - "Edge case addressing (Behaviour)": string; - "Edge case addressing (Database)": string; - "Edge case addressing (Processing)": string; - "Emergency restart": string; - "Enable advanced features": string; - "Enable customization sync": string; - "Enable Developers' Debug Tools.": string; - "Enable edge case treatment features": string; - "Enable poweruser features": string; - "Enable this if your Object Storage doesn't support CORS": string; - "Enable this option to automatically apply the most recent change to documents even when it conflicts": string; - "Encrypt contents on the remote database. If you use the plugin's synchronization feature, enabling this is recommended.": string; - "Encrypting sensitive configuration items": string; - "Encryption phassphrase. If changed, you should overwrite the server's database with the new (encrypted) files.": string; - "End-to-End Encryption": string; - "Endpoint URL": string; - "Enhance chunk size": string; - "Enter Server Information": string; - "Enter the server information manually": string; - Export: string; - "Failed to connect to remote for compaction.": string; - "Failed to connect to remote for compaction. ${reason}": string; - "Failed to start one-shot replication before Garbage Collection. Garbage Collection Cancelled.": string; - "Failed to start replication after Garbage Collection.": string; - Fetch: string; - "Fetch chunks on demand": string; - "Fetch database with previous behaviour": string; - "Fetch remote settings": string; - "File to resolve conflict": string; - "File to view History": string; - Filename: string; - "First, please select the option that best describes your current situation.": string; - "Flag and restart": string; - "Forces the file to be synced when opened.": string; - "Fresh Start Wipe": string; - "Garbage Collection cancelled by user.": string; - "Garbage Collection completed. Deleted chunks: ${deletedChunks} / ${totalChunks}. Time taken: ${seconds} seconds.": string; - "Garbage Collection Confirmation": string; - "Garbage Collection V3 (Beta)": string; - "Garbage Collection: Found ${unusedChunks} unused chunks to delete.": string; - "Garbage Collection: Scanned ${scanned} / ~${docCount}": string; - "Garbage Collection: Scanning completed. Total chunks: ${totalChunks}, Used chunks: ${usedChunks}": string; - "Handle files as Case-Sensitive": string; - "Hidden Files": string; - "Hide completely": string; - "Highlight diff": string; - "How to display network errors when the sync server is unreachable.": string; - "How would you like to configure the connection to your server?": string; - "I am adding a device to an existing synchronisation setup": string; - "I am setting this up for the first time": string; - "I know my server details, let me enter them": string; - "If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).": string; - "If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.": string; - "If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker.": string; - "If enabled, newly created chunks are temporarily kept within the document, and graduated to become independent chunks once stabilised.": string; - "If enabled, the \u26D4 icon will be shown inside the status instead of the file warnings banner. No details will be shown.": string; - "If enabled, the file under 1kb will be processed in the UI thread.": string; - "If enabled, the notification of hidden files change will be suppressed.": string; - "If this enabled, all chunks will be stored with the revision made from its content. (Previous behaviour)": string; - "If this enabled, All files are handled as case-Sensitive (Previous behaviour).": string; - "If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature.": string; - "If this is set, changes to local files which are matched by the ignore files will be skipped. Remote changes are determined using local ignore files.": string; - "If this option is enabled, PouchDB will hold the connection open for 60 seconds, and if no change arrives in that time, close and reopen the socket, instead of holding it open indefinitely. Useful when a proxy limits request duration but can increase resource usage.": string; - "If you reached the payload size limit when using IBM Cloudant, please decrease batch size and batch limit to a lower value.": string; - "Ignore and Proceed": string; - "Ignore files": string; - "Ignore patterns": string; - "Import connection": string; - "Incubate Chunks in Document": string; - "Initialise all journal history, On the next sync, every item will be received and sent.": string; - "Initialise journal received history. On the next sync, every item except this device sent will be downloaded again.": string; - "Initialise journal sent history. On the next sync, every item except this device received will be sent again.": string; - "Interval (sec)": string; - "K.exp": string; - "K.long_p2p_sync": string; - "K.P2P": string; - "K.Peer": string; - "K.ScanCustomization": string; - "K.short_p2p_sync": string; - "K.title_p2p_sync": string; - "Keep empty folder": string; - lang_def: string; - "lang-de": string; - "lang-def": string; - "lang-es": string; - "lang-fr": string; - "lang-he": string; - "lang-ja": string; - "lang-ko": string; - "lang-ru": string; - "lang-zh": string; - "lang-zh-tw": string; - Later: string; - "Limit: {datetime} ({timestamp})": string; - "LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured.": string; - "liveSyncReplicator.beforeLiveSync": string; - "liveSyncReplicator.cantReplicateLowerValue": string; - "liveSyncReplicator.checkingLastSyncPoint": string; - "liveSyncReplicator.couldNotConnectTo": string; - "liveSyncReplicator.couldNotConnectToRemoteDb": string; - "liveSyncReplicator.couldNotConnectToServer": string; - "liveSyncReplicator.couldNotConnectToURI": string; - "liveSyncReplicator.couldNotMarkResolveRemoteDb": string; - "liveSyncReplicator.liveSyncBegin": string; - "liveSyncReplicator.lockRemoteDb": string; - "liveSyncReplicator.markDeviceResolved": string; - "liveSyncReplicator.mismatchedTweakDetected": string; - "liveSyncReplicator.oneShotSyncBegin": string; - "liveSyncReplicator.remoteDbCorrupted": string; - "liveSyncReplicator.remoteDbCreatedOrConnected": string; - "liveSyncReplicator.remoteDbDestroyed": string; - "liveSyncReplicator.remoteDbDestroyError": string; - "liveSyncReplicator.remoteDbMarkedResolved": string; - "liveSyncReplicator.replicationClosed": string; - "liveSyncReplicator.replicationInProgress": string; - "liveSyncReplicator.retryLowerBatchSize": string; - "liveSyncReplicator.unlockRemoteDb": string; - "liveSyncSetting.errorNoSuchSettingItem": string; - "liveSyncSetting.originalValue": string; - "liveSyncSetting.valueShouldBeInRange": string; - "liveSyncSettings.btnApply": string; - "Local Database Tweak": string; - Lock: string; - "Lock Server": string; - "Lock the remote server to prevent synchronization with other devices.": string; - "logPane.autoScroll": string; - "logPane.logWindowOpened": string; - "logPane.pause": string; - "logPane.title": string; - "logPane.wrap": string; - "Maximum delay for batch database updating": string; - "Maximum file size": string; - "Maximum Incubating Chunk Size": string; - "Maximum Incubating Chunks": string; - "Maximum Incubation Period": string; - "MB (0 to disable).": string; - "Memory cache": string; - "Memory cache size (by total characters)": string; - "Memory cache size (by total items)": string; - Merge: string; - "Minimum delay for batch database updating": string; - "Minimum interval for syncing": string; - "moduleCheckRemoteSize.logCheckingStorageSizes": string; - "moduleCheckRemoteSize.logCurrentStorageSize": string; - "moduleCheckRemoteSize.logExceededWarning": string; - "moduleCheckRemoteSize.logThresholdEnlarged": string; - "moduleCheckRemoteSize.msgConfirmRebuild": string; - "moduleCheckRemoteSize.msgDatabaseGrowing": string; - "moduleCheckRemoteSize.msgSetDBCapacity": string; - "moduleCheckRemoteSize.option2GB": string; - "moduleCheckRemoteSize.option800MB": string; - "moduleCheckRemoteSize.optionAskMeLater": string; - "moduleCheckRemoteSize.optionDismiss": string; - "moduleCheckRemoteSize.optionIncreaseLimit": string; - "moduleCheckRemoteSize.optionNoWarn": string; - "moduleCheckRemoteSize.optionRebuildAll": string; - "moduleCheckRemoteSize.titleDatabaseSizeLimitExceeded": string; - "moduleCheckRemoteSize.titleDatabaseSizeNotify": string; - "moduleInputUIObsidian.defaultTitleConfirmation": string; - "moduleInputUIObsidian.defaultTitleSelect": string; - "moduleInputUIObsidian.optionNo": string; - "moduleInputUIObsidian.optionYes": string; - "moduleLiveSyncMain.logAdditionalSafetyScan": string; - "moduleLiveSyncMain.logLoadingPlugin": string; - "moduleLiveSyncMain.logPluginInitCancelled": string; - "moduleLiveSyncMain.logPluginVersion": string; - "moduleLiveSyncMain.logReadChangelog": string; - "moduleLiveSyncMain.logSafetyScanCompleted": string; - "moduleLiveSyncMain.logSafetyScanFailed": string; - "moduleLiveSyncMain.logUnloadingPlugin": string; - "moduleLiveSyncMain.logVersionUpdate": string; - "moduleLiveSyncMain.msgScramEnabled": string; - "moduleLiveSyncMain.optionKeepLiveSyncDisabled": string; - "moduleLiveSyncMain.optionResumeAndRestart": string; - "moduleLiveSyncMain.titleScramEnabled": string; - "moduleLocalDatabase.logWaitingForReady": string; - "moduleLog.showLog": string; - "moduleMigration.docUri": string; - "moduleMigration.fix0256.buttons.checkItLater": string; - "moduleMigration.fix0256.buttons.DismissForever": string; - "moduleMigration.fix0256.buttons.fix": string; - "moduleMigration.fix0256.message": string; - "moduleMigration.fix0256.messageUnrecoverable": string; - "moduleMigration.fix0256.title": string; - "moduleMigration.insecureChunkExist.buttons.fetch": string; - "moduleMigration.insecureChunkExist.buttons.later": string; - "moduleMigration.insecureChunkExist.buttons.rebuild": string; - "moduleMigration.insecureChunkExist.laterMessage": string; - "moduleMigration.insecureChunkExist.message": string; - "moduleMigration.insecureChunkExist.title": string; - "moduleMigration.logBulkSendCorrupted": string; - "moduleMigration.logFetchRemoteTweakFailed": string; - "moduleMigration.logLocalDatabaseNotReady": string; - "moduleMigration.logMigratedSameBehaviour": string; - "moduleMigration.logMigrationFailed": string; - "moduleMigration.logRedflag2CreationFail": string; - "moduleMigration.logRemoteTweakUnavailable": string; - "moduleMigration.logSetupCancelled": string; - "moduleMigration.msgFetchRemoteAgain": string; - "moduleMigration.msgInitialSetup": string; - "moduleMigration.msgRecommendSetupUri": string; - "moduleMigration.msgSinceV02321": string; - "moduleMigration.optionAdjustRemote": string; - "moduleMigration.optionDecideLater": string; - "moduleMigration.optionEnableBoth": string; - "moduleMigration.optionEnableFilenameCaseInsensitive": string; - "moduleMigration.optionEnableFixedRevisionForChunks": string; - "moduleMigration.optionHaveSetupUri": string; - "moduleMigration.optionKeepPreviousBehaviour": string; - "moduleMigration.optionManualSetup": string; - "moduleMigration.optionNoAskAgain": string; - "moduleMigration.optionNoSetupUri": string; - "moduleMigration.optionRemindNextLaunch": string; - "moduleMigration.optionSetupViaP2P": string; - "moduleMigration.optionSetupWizard": string; - "moduleMigration.optionYesFetchAgain": string; - "moduleMigration.titleCaseSensitivity": string; - "moduleMigration.titleRecommendSetupUri": string; - "moduleMigration.titleWelcome": string; - "moduleObsidianMenu.replicate": string; - "More actions": string; - "Move remotely deleted files to the trash, instead of deleting.": string; - "Network warning style": string; - "New Remote": string; - "No connected device information found. Cancelling Garbage Collection.": string; - "No limit configured": string; - "No, please take me back": string; - "Node ID": string; - "Node Information Missing": string; - "Non-Synchronising files": string; - "Normal Files": string; - "Not all messages have been translated. And, please revert to \"Default\" when reporting errors.": string; - "Notify all setting files": string; - "Notify customized": string; - "Notify when other device has newly customized.": string; - "Notify when the estimated remote storage size exceeds on start up": string; - "Number of batches to process at a time. Defaults to 40. Minimum is 2. This along with batch size controls how many docs are kept in memory at a time.": string; - "Number of changes to sync at a time. Defaults to 50. Minimum is 2.": string; - "Obsidian version": string; - "obsidianLiveSyncSettingTab.btnApply": string; - "obsidianLiveSyncSettingTab.btnCheck": string; - "obsidianLiveSyncSettingTab.btnCopy": string; - "obsidianLiveSyncSettingTab.btnDisable": string; - "obsidianLiveSyncSettingTab.btnDiscard": string; - "obsidianLiveSyncSettingTab.btnEnable": string; - "obsidianLiveSyncSettingTab.btnFix": string; - "obsidianLiveSyncSettingTab.btnGotItAndUpdated": string; - "obsidianLiveSyncSettingTab.btnNext": string; - "obsidianLiveSyncSettingTab.btnStart": string; - "obsidianLiveSyncSettingTab.btnTest": string; - "obsidianLiveSyncSettingTab.btnUse": string; - "obsidianLiveSyncSettingTab.buttonFetch": string; - "obsidianLiveSyncSettingTab.buttonNext": string; - "obsidianLiveSyncSettingTab.defaultLanguage": string; - "obsidianLiveSyncSettingTab.descConnectSetupURI": string; - "obsidianLiveSyncSettingTab.descCopySetupURI": string; - "obsidianLiveSyncSettingTab.descEnableLiveSync": string; - "obsidianLiveSyncSettingTab.descFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.descManualSetup": string; - "obsidianLiveSyncSettingTab.descTestDatabaseConnection": string; - "obsidianLiveSyncSettingTab.descValidateDatabaseConfig": string; - "obsidianLiveSyncSettingTab.errAccessForbidden": string; - "obsidianLiveSyncSettingTab.errCannotContinueTest": string; - "obsidianLiveSyncSettingTab.errCorsCredentials": string; - "obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials": string; - "obsidianLiveSyncSettingTab.errCorsOrigins": string; - "obsidianLiveSyncSettingTab.errEnableCors": string; - "obsidianLiveSyncSettingTab.errEnableCorsChttpd": string; - "obsidianLiveSyncSettingTab.errMaxDocumentSize": string; - "obsidianLiveSyncSettingTab.errMaxRequestSize": string; - "obsidianLiveSyncSettingTab.errMissingWwwAuth": string; - "obsidianLiveSyncSettingTab.errRequireValidUser": string; - "obsidianLiveSyncSettingTab.errRequireValidUserAuth": string; - "obsidianLiveSyncSettingTab.labelDisabled": string; - "obsidianLiveSyncSettingTab.labelEnabled": string; - "obsidianLiveSyncSettingTab.levelAdvanced": string; - "obsidianLiveSyncSettingTab.levelEdgeCase": string; - "obsidianLiveSyncSettingTab.levelPowerUser": string; - "obsidianLiveSyncSettingTab.linkOpenInBrowser": string; - "obsidianLiveSyncSettingTab.linkPageTop": string; - "obsidianLiveSyncSettingTab.linkTipsAndTroubleshooting": string; - "obsidianLiveSyncSettingTab.linkTroubleshooting": string; - "obsidianLiveSyncSettingTab.logCannotUseCloudant": string; - "obsidianLiveSyncSettingTab.logCheckingConfigDone": string; - "obsidianLiveSyncSettingTab.logCheckingConfigFailed": string; - "obsidianLiveSyncSettingTab.logCheckingDbConfig": string; - "obsidianLiveSyncSettingTab.logCheckPassphraseFailed": string; - "obsidianLiveSyncSettingTab.logConfiguredDisabled": string; - "obsidianLiveSyncSettingTab.logConfiguredLiveSync": string; - "obsidianLiveSyncSettingTab.logConfiguredPeriodic": string; - "obsidianLiveSyncSettingTab.logCouchDbConfigFail": string; - "obsidianLiveSyncSettingTab.logCouchDbConfigSet": string; - "obsidianLiveSyncSettingTab.logCouchDbConfigUpdated": string; - "obsidianLiveSyncSettingTab.logDatabaseConnected": string; - "obsidianLiveSyncSettingTab.logEncryptionNoPassphrase": string; - "obsidianLiveSyncSettingTab.logEncryptionNoSupport": string; - "obsidianLiveSyncSettingTab.logErrorOccurred": string; - "obsidianLiveSyncSettingTab.logEstimatedSize": string; - "obsidianLiveSyncSettingTab.logPassphraseInvalid": string; - "obsidianLiveSyncSettingTab.logPassphraseNotCompatible": string; - "obsidianLiveSyncSettingTab.logRebuildNote": string; - "obsidianLiveSyncSettingTab.logSelectAnyPreset": string; - "obsidianLiveSyncSettingTab.logServerConfigurationCheck": string; - "obsidianLiveSyncSettingTab.msgAreYouSureProceed": string; - "obsidianLiveSyncSettingTab.msgChangesNeedToBeApplied": string; - "obsidianLiveSyncSettingTab.msgConfigCheck": string; - "obsidianLiveSyncSettingTab.msgConfigCheckFailed": string; - "obsidianLiveSyncSettingTab.msgConnectionCheck": string; - "obsidianLiveSyncSettingTab.msgConnectionProxyNote": string; - "obsidianLiveSyncSettingTab.msgCurrentOrigin": string; - "obsidianLiveSyncSettingTab.msgDiscardConfirmation": string; - "obsidianLiveSyncSettingTab.msgDone": string; - "obsidianLiveSyncSettingTab.msgEnableCors": string; - "obsidianLiveSyncSettingTab.msgEnableCorsChttpd": string; - "obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": string; - "obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.msgGenerateSetupURI": string; - "obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": string; - "obsidianLiveSyncSettingTab.msgInvalidPassphrase": string; - "obsidianLiveSyncSettingTab.msgNewVersionNote": string; - "obsidianLiveSyncSettingTab.msgNonHTTPSInfo": string; - "obsidianLiveSyncSettingTab.msgNonHTTPSWarning": string; - "obsidianLiveSyncSettingTab.msgNotice": string; - "obsidianLiveSyncSettingTab.msgObjectStorageWarning": string; - "obsidianLiveSyncSettingTab.msgOriginCheck": string; - "obsidianLiveSyncSettingTab.msgRebuildRequired": string; - "obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": string; - "obsidianLiveSyncSettingTab.msgSetCorsCredentials": string; - "obsidianLiveSyncSettingTab.msgSetCorsOrigins": string; - "obsidianLiveSyncSettingTab.msgSetMaxDocSize": string; - "obsidianLiveSyncSettingTab.msgSetMaxRequestSize": string; - "obsidianLiveSyncSettingTab.msgSetRequireValidUser": string; - "obsidianLiveSyncSettingTab.msgSetRequireValidUserAuth": string; - "obsidianLiveSyncSettingTab.msgSettingModified": string; - "obsidianLiveSyncSettingTab.msgSettingsUnchangeableDuringSync": string; - "obsidianLiveSyncSettingTab.msgSetWwwAuth": string; - "obsidianLiveSyncSettingTab.nameApplySettings": string; - "obsidianLiveSyncSettingTab.nameConnectSetupURI": string; - "obsidianLiveSyncSettingTab.nameCopySetupURI": string; - "obsidianLiveSyncSettingTab.nameDisableHiddenFileSync": string; - "obsidianLiveSyncSettingTab.nameDiscardSettings": string; - "obsidianLiveSyncSettingTab.nameEnableHiddenFileSync": string; - "obsidianLiveSyncSettingTab.nameEnableLiveSync": string; - "obsidianLiveSyncSettingTab.nameHiddenFileSynchronization": string; - "obsidianLiveSyncSettingTab.nameManualSetup": string; - "obsidianLiveSyncSettingTab.nameTestConnection": string; - "obsidianLiveSyncSettingTab.nameTestDatabaseConnection": string; - "obsidianLiveSyncSettingTab.nameValidateDatabaseConfig": string; - "obsidianLiveSyncSettingTab.okAdminPrivileges": string; - "obsidianLiveSyncSettingTab.okCorsCredentials": string; - "obsidianLiveSyncSettingTab.okCorsCredentialsForOrigin": string; - "obsidianLiveSyncSettingTab.okCorsOriginMatched": string; - "obsidianLiveSyncSettingTab.okCorsOrigins": string; - "obsidianLiveSyncSettingTab.okEnableCors": string; - "obsidianLiveSyncSettingTab.okEnableCorsChttpd": string; - "obsidianLiveSyncSettingTab.okMaxDocumentSize": string; - "obsidianLiveSyncSettingTab.okMaxRequestSize": string; - "obsidianLiveSyncSettingTab.okRequireValidUser": string; - "obsidianLiveSyncSettingTab.okRequireValidUserAuth": string; - "obsidianLiveSyncSettingTab.okWwwAuth": string; - "obsidianLiveSyncSettingTab.optionApply": string; - "obsidianLiveSyncSettingTab.optionCancel": string; - "obsidianLiveSyncSettingTab.optionCouchDB": string; - "obsidianLiveSyncSettingTab.optionDisableAllAutomatic": string; - "obsidianLiveSyncSettingTab.optionFetchFromRemote": string; - "obsidianLiveSyncSettingTab.optionHere": string; - "obsidianLiveSyncSettingTab.optionLiveSync": string; - "obsidianLiveSyncSettingTab.optionMinioS3R2": string; - "obsidianLiveSyncSettingTab.optionOkReadEverything": string; - "obsidianLiveSyncSettingTab.optionOnEvents": string; - "obsidianLiveSyncSettingTab.optionPeriodicAndEvents": string; - "obsidianLiveSyncSettingTab.optionPeriodicWithBatch": string; - "obsidianLiveSyncSettingTab.optionRebuildBoth": string; - "obsidianLiveSyncSettingTab.optionSaveOnlySettings": string; - "obsidianLiveSyncSettingTab.panelChangeLog": string; - "obsidianLiveSyncSettingTab.panelGeneralSettings": string; - "obsidianLiveSyncSettingTab.panelPrivacyEncryption": string; - "obsidianLiveSyncSettingTab.panelRemoteConfiguration": string; - "obsidianLiveSyncSettingTab.panelSetup": string; - "obsidianLiveSyncSettingTab.serverVersion": string; - "obsidianLiveSyncSettingTab.titleActiveRemoteServer": string; - "obsidianLiveSyncSettingTab.titleAppearance": string; - "obsidianLiveSyncSettingTab.titleConflictResolution": string; - "obsidianLiveSyncSettingTab.titleCongratulations": string; - "obsidianLiveSyncSettingTab.titleCouchDB": string; - "obsidianLiveSyncSettingTab.titleDeletionPropagation": string; - "obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": string; - "obsidianLiveSyncSettingTab.titleEncryptionPassphraseInvalid": string; - "obsidianLiveSyncSettingTab.titleExtraFeatures": string; - "obsidianLiveSyncSettingTab.titleFetchConfig": string; - "obsidianLiveSyncSettingTab.titleFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.titleFetchSettings": string; - "obsidianLiveSyncSettingTab.titleHiddenFiles": string; - "obsidianLiveSyncSettingTab.titleLogging": string; - "obsidianLiveSyncSettingTab.titleMinioS3R2": string; - "obsidianLiveSyncSettingTab.titleNotification": string; - "obsidianLiveSyncSettingTab.titleOnlineTips": string; - "obsidianLiveSyncSettingTab.titleQuickSetup": string; - "obsidianLiveSyncSettingTab.titleRebuildRequired": string; - "obsidianLiveSyncSettingTab.titleRemoteConfigCheckFailed": string; - "obsidianLiveSyncSettingTab.titleRemoteServer": string; - "obsidianLiveSyncSettingTab.titleReset": string; - "obsidianLiveSyncSettingTab.titleSetupOtherDevices": string; - "obsidianLiveSyncSettingTab.titleSynchronizationMethod": string; - "obsidianLiveSyncSettingTab.titleSynchronizationPreset": string; - "obsidianLiveSyncSettingTab.titleSyncSettings": string; - "obsidianLiveSyncSettingTab.titleSyncSettingsViaMarkdown": string; - "obsidianLiveSyncSettingTab.titleUpdateThinning": string; - "obsidianLiveSyncSettingTab.warnCorsOriginUnmatched": string; - "obsidianLiveSyncSettingTab.warnNoAdmin": string; - Ok: string; - "Old Algorithm": string; - "Older fallback (Slow, W/O WebAssembly)": string; - Open: string; - "Open the dialog": string; - Overwrite: string; - "Overwrite patterns": string; - "Overwrite remote": string; - "Overwrite remote with local DB and passphrase.": string; - "Overwrite Server Data with This Device's Files": string; - "P2P.AskPassphraseForDecrypt": string; - "P2P.AskPassphraseForShare": string; - "P2P.DisabledButNeed": string; - "P2P.FailedToOpen": string; - "P2P.NoAutoSyncPeers": string; - "P2P.NoKnownPeers": string; - "P2P.Note.description": string; - "P2P.Note.important_note": string; - "P2P.Note.important_note_sub": string; - "P2P.Note.Summary": string; - "P2P.NotEnabled": string; - "P2P.P2PReplication": string; - "P2P.PaneTitle": string; - "P2P.ReplicatorInstanceMissing": string; - "P2P.SeemsOffline": string; - "P2P.SyncAlreadyRunning": string; - "P2P.SyncCompleted": string; - "P2P.SyncStartedWith": string; - "paneMaintenance.markDeviceResolvedAfterBackup": string; - "paneMaintenance.remoteLockedAndDeviceNotAccepted": string; - "paneMaintenance.remoteLockedResolvedDevice": string; - "paneMaintenance.unlockDatabaseReady": string; - Passphrase: string; - "Passphrase of sensitive configuration items": string; - password: string; - Password: string; - "Paste a connection string": string; - "Paste the Setup URI generated from one of your active devices.": string; - "Path Obfuscation": string; - "Patterns to match files for overwriting instead of merging": string; - "Patterns to match files for syncing": string; - "Peer-to-Peer only": string; - "Peer-to-Peer Synchronisation": string; - "Per-file-saved customization sync": string; - Perform: string; - "Perform cleanup": string; - "Perform Garbage Collection": string; - "Perform Garbage Collection to remove unused chunks and reduce database size.": string; - "Periodic Sync interval": string; - "Pick a file to resolve conflict": string; - "Pick a file to show history": string; - "Please disable 'Read chunks online' in settings to use Garbage Collection.": string; - "Please enable 'Compute revisions for chunks' in settings to use Garbage Collection.": string; - "Please select 'Cancel' explicitly to cancel this operation.": string; - "Please select a method to import the settings from another device.": string; - "Please select an option to proceed": string; - "Please select the type of server to which you are connecting.": string; - "Please set device name to identify this device. This name should be unique among your devices. While not configured, we cannot enable this feature.": string; - "Please set this device name": string; - "Plug-in version": string; - "Prepare the 'report' to create an issue": string; - Presets: string; - "Proceed Garbage Collection": string; - "Proceed with Setup URI": string; - "Proceeding with Garbage Collection, ignoring missing nodes.": string; - "Proceeding with Garbage Collection.": string; - "Process small files in the foreground": string; - Progress: string; - "Property Encryption": string; - "PureJS fallback (Fast, W/O WebAssembly)": string; - "Purge all download/upload cache.": string; - "Purge all journal counter": string; - "Rebuild local and remote database with local files.": string; - "Rebuilding Operations (Remote Only)": string; - "Recovery and Repair": string; - "Recreate all": string; - "Recreate missing chunks for all files": string; - "RedFlag.Fetch.Method.Desc": string; - "RedFlag.Fetch.Method.FetchSafer": string; - "RedFlag.Fetch.Method.FetchSmoother": string; - "RedFlag.Fetch.Method.FetchTraditional": string; - "RedFlag.Fetch.Method.Title": string; - "RedFlag.FetchRemoteConfig.Buttons.Cancel": string; - "RedFlag.FetchRemoteConfig.Buttons.Fetch": string; - "RedFlag.FetchRemoteConfig.Message": string; - "RedFlag.FetchRemoteConfig.Title": string; - "Reduces storage space by discarding all non-latest revisions. This requires the same amount of free space on the remote server and the local client.": string; - "Reducing the frequency with which on-disk changes are reflected into the DB": string; - Region: string; - Remediation: string; - "Remediation Setting Changed": string; - "Remote Database Tweak (In sunset)": string; - "Remote Databases": string; - "Remote name": string; - "Remote server type": string; - "Remote Type": string; - Rename: string; - "Replicator.Dialogue.Locked.Action.Dismiss": string; - "Replicator.Dialogue.Locked.Action.Fetch": string; - "Replicator.Dialogue.Locked.Action.Unlock": string; - "Replicator.Dialogue.Locked.Message": string; - "Replicator.Dialogue.Locked.Message.Fetch": string; - "Replicator.Dialogue.Locked.Message.Unlocked": string; - "Replicator.Dialogue.Locked.Title": string; - "Replicator.Message.Cleaned": string; - "Replicator.Message.InitialiseFatalError": string; - "Replicator.Message.Pending": string; - "Replicator.Message.SomeModuleFailed": string; - "Replicator.Message.VersionUpFlash": string; - "Requires restart of Obsidian": string; - "Requires restart of Obsidian.": string; - "Rerun Onboarding Wizard": string; - "Rerun the onboarding wizard to set up Self-hosted LiveSync again.": string; - "Rerun Wizard": string; - Resend: string; - "Resend all chunks to the remote.": string; - Reset: string; - "Reset all": string; - "Reset all journal counter": string; - "Reset journal received history": string; - "Reset journal sent history": string; - "Reset notification threshold and check the remote database usage": string; - "Reset received": string; - "Reset sent history": string; - "Reset Synchronisation information": string; - "Reset Synchronisation on This Device": string; - "Reset the remote storage size threshold and check the remote storage size again.": string; - "Resolve All": string; - "Resolve all conflicted files": string; - "Resolve All conflicted files by the newer one": string; - "Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one.": string; - "Restart Now": string; - "Restarting Obsidian is strongly recommended. Until restart, some changes may not take effect, and display may be inconsistent. Are you sure to restart now?": string; - "Restore or reconstruct local database from remote.": string; - "Run Doctor": string; - "S3/MinIO/R2 Object Storage": string; - "Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.": string; - "Saving will be performed forcefully after this number of seconds.": string; - "Scan a QR Code (Recommended for mobile)": string; - "Scan changes on customization sync": string; - "Scan customization automatically": string; - "Scan customization before replicating.": string; - "Scan customization every 1 minute.": string; - "Scan customization periodically": string; - "Scan for Broken files": string; - "Scan for hidden files before replication": string; - "Scan hidden files periodically": string; - "Scan the QR code displayed on an active device using this device's camera.": string; - "Schedule and Restart": string; - "Scram Switches": string; - "Scram!": string; - "Seconds, 0 to disable": string; - "Seconds. Saving to the local database will be delayed until this value after we stop typing or saving.": string; - "Secret Key": string; - "Select the database adapter to use.": string; - Send: string; - "Send chunks": string; - "Server URI": string; - "Setting.GenerateKeyPair.Desc": string; - "Setting.GenerateKeyPair.Title": string; - "Setting.TroubleShooting": string; - "Setting.TroubleShooting.Doctor": string; - "Setting.TroubleShooting.Doctor.Desc": string; - "Setting.TroubleShooting.ScanBrokenFiles": string; - "Setting.TroubleShooting.ScanBrokenFiles.Desc": string; - "SettingTab.Message.AskRebuild": string; - "Setup URI dialog cancelled.": string; - "Setup.Apply.Buttons.ApplyAndFetch": string; - "Setup.Apply.Buttons.ApplyAndMerge": string; - "Setup.Apply.Buttons.ApplyAndRebuild": string; - "Setup.Apply.Buttons.Cancel": string; - "Setup.Apply.Buttons.OnlyApply": string; - "Setup.Apply.Message": string; - "Setup.Apply.Title": string; - "Setup.Apply.WarningRebuildRecommended": string; - "Setup.Doctor.Buttons.No": string; - "Setup.Doctor.Buttons.Yes": string; - "Setup.Doctor.Message": string; - "Setup.Doctor.Title": string; - "Setup.FetchRemoteConf.Buttons.Fetch": string; - "Setup.FetchRemoteConf.Buttons.Skip": string; - "Setup.FetchRemoteConf.Message": string; - "Setup.FetchRemoteConf.Title": string; - "Setup.QRCode": string; - "Setup.RemoteE2EE.AdvancedTitle": string; - "Setup.RemoteE2EE.AlgorithmWarning": string; - "Setup.RemoteE2EE.ButtonCancel": string; - "Setup.RemoteE2EE.ButtonProceed": string; - "Setup.RemoteE2EE.DefaultAlgorithmDesc": string; - "Setup.RemoteE2EE.Guidance": string; - "Setup.RemoteE2EE.LabelEncrypt": string; - "Setup.RemoteE2EE.LabelEncryptionAlgorithm": string; - "Setup.RemoteE2EE.LabelObfuscateProperties": string; - "Setup.RemoteE2EE.MultiDestinationWarning": string; - "Setup.RemoteE2EE.ObfuscatePropertiesDesc": string; - "Setup.RemoteE2EE.PassphraseValidationLine1": string; - "Setup.RemoteE2EE.PassphraseValidationLine2": string; - "Setup.RemoteE2EE.PlaceholderPassphrase": string; - "Setup.RemoteE2EE.StronglyRecommendedLine1": string; - "Setup.RemoteE2EE.StronglyRecommendedLine2": string; - "Setup.RemoteE2EE.StronglyRecommendedTitle": string; - "Setup.RemoteE2EE.Title": string; - "Setup.ScanQRCode.ButtonClose": string; - "Setup.ScanQRCode.Guidance": string; - "Setup.ScanQRCode.Step1": string; - "Setup.ScanQRCode.Step2": string; - "Setup.ScanQRCode.Step3": string; - "Setup.ScanQRCode.Step4": string; - "Setup.ScanQRCode.Title": string; - "Setup.ShowQRCode": string; - "Setup.ShowQRCode.Desc": string; - "Setup.UseSetupURI.ButtonCancel": string; - "Setup.UseSetupURI.ButtonProceed": string; - "Setup.UseSetupURI.ErrorFailedToParse": string; - "Setup.UseSetupURI.ErrorPassphraseRequired": string; - "Setup.UseSetupURI.GuidanceLine1": string; - "Setup.UseSetupURI.GuidanceLine2": string; - "Setup.UseSetupURI.InvalidInfo": string; - "Setup.UseSetupURI.LabelPassphrase": string; - "Setup.UseSetupURI.LabelSetupURI": string; - "Setup.UseSetupURI.PlaceholderPassphrase": string; - "Setup.UseSetupURI.Title": string; - "Setup.UseSetupURI.ValidInfo": string; - "Should we keep folders that don't have any files inside?": string; - "Should we only check for conflicts when a file is opened?": string; - "Should we prompt you about conflicting files when a file is opened?": string; - "Should we prompt you for every single merge, even if we can safely merge automatcially?": string; - "Show full banner": string; - "Show history": string; - "Show icon only": string; - "Show only notifications": string; - "Show status as icons only": string; - "Show status icon instead of file warnings banner": string; - "Show status inside the editor": string; - "Show status on the status bar": string; - "Show verbose log. Please enable if you report an issue.": string; - "Some devices have differing progress values (max: ${maxProgress}, min: ${minProgress}).\nThis may indicate that some devices have not completed synchronisation, which could lead to conflicts. Strongly recommend confirming that all devices are synchronised before proceeding.": string; - "Starts synchronisation when a file is saved.": string; - "Stop reflecting database changes to storage files.": string; - "Stop watching for file changes.": string; - "Storage -> Database": string; - "Suppress notification of hidden files change": string; - "Suspend database reflecting": string; - "Suspend file watching": string; - "Switch to IDB": string; - "Switch to IndexedDB": string; - "Sync after merging file": string; - "Sync automatically after merging files": string; - "Sync Mode": string; - "Sync on Editor Save": string; - "Sync on File Open": string; - "Sync on Save": string; - "Sync on Startup": string; - "Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage.": string; - "Synchronising files": string; - Syncing: string; - "Target patterns": string; - "Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.": string; - "The delay for consecutive on-demand fetches": string; - "The following accepted nodes are missing its node information:\n- ${missingNodes}\n\nThis indicates that they have not been connected for some time or have been left on an older version.\nIt is preferable to update all devices if possible. If you have any devices that are no longer in use, you can clear all accepted nodes by locking the remote once.": string; - "The Hash algorithm for chunk IDs": string; - "The IndexedDB adapter often offers superior performance in certain scenarios, but it has been found to cause memory leaks when used with LiveSync mode. When using LiveSync mode, please use IDB adapter instead.": string; - "The maximum duration for which chunks can be incubated within the document. Chunks exceeding this period will graduate to independent chunks.": string; - "The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks.": string; - "The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks.": string; - "The minimum interval for automatic synchronisation on event.": string; - "This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.": string; - "This is an advanced option for users who do not have a URI or who wish to configure detailed settings.": string; - "This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.": string; - "This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.": string; - "This will recreate chunks for all files. If there were missing chunks, this may fix the errors.": string; - "Transfer Tweak": string; - "TweakMismatchResolve.Action.DisableAutoAcceptCompatible": string; - "TweakMismatchResolve.Action.Dismiss": string; - "TweakMismatchResolve.Action.EnableAutoAcceptCompatible": string; - "TweakMismatchResolve.Action.UseConfigured": string; - "TweakMismatchResolve.Action.UseMine": string; - "TweakMismatchResolve.Action.UseMineAcceptIncompatible": string; - "TweakMismatchResolve.Action.UseMineWithRebuild": string; - "TweakMismatchResolve.Action.UseRemote": string; - "TweakMismatchResolve.Action.UseRemoteAcceptIncompatible": string; - "TweakMismatchResolve.Action.UseRemoteWithRebuild": string; - "TweakMismatchResolve.Message.AutoAcceptCompatibleUndefined": string; - "TweakMismatchResolve.Message.Main": string; - "TweakMismatchResolve.Message.MainTweakResolving": string; - "TweakMismatchResolve.Message.mineUpdated": string; - "TweakMismatchResolve.Message.remoteUpdated": string; - "TweakMismatchResolve.Message.UseRemote.WarningRebuildRecommended": string; - "TweakMismatchResolve.Message.UseRemote.WarningRebuildRequired": string; - "TweakMismatchResolve.Message.WarningIncompatibleRebuildRecommended": string; - "TweakMismatchResolve.Message.WarningIncompatibleRebuildRequired": string; - "TweakMismatchResolve.Table": string; - "TweakMismatchResolve.Table.Row": string; - "TweakMismatchResolve.Title": string; - "TweakMismatchResolve.Title.AutoAcceptCompatible": string; - "TweakMismatchResolve.Title.TweakResolving": string; - "TweakMismatchResolve.Title.UseRemoteConfig": string; - "Ui.Common.Signal.Caution": string; - "Ui.Common.Signal.Danger": string; - "Ui.Common.Signal.Notice": string; - "Ui.Common.Signal.Warning": string; - "Ui.Settings.Advanced.LocalDatabaseTweak": string; - "Ui.Settings.Advanced.MemoryCache": string; - "Ui.Settings.Advanced.TransferTweak": string; - "Ui.Settings.Common.Analyse": string; - "Ui.Settings.Common.Back": string; - "Ui.Settings.Common.Check": string; - "Ui.Settings.Common.Configure": string; - "Ui.Settings.Common.Continue": string; - "Ui.Settings.Common.Delete": string; - "Ui.Settings.Common.Fetch": string; - "Ui.Settings.Common.Lock": string; - "Ui.Settings.Common.Merge": string; - "Ui.Settings.Common.Open": string; - "Ui.Settings.Common.Overwrite": string; - "Ui.Settings.Common.Perform": string; - "Ui.Settings.Common.ResetAll": string; - "Ui.Settings.Common.ResolveAll": string; - "Ui.Settings.Common.Scan": string; - "Ui.Settings.Common.Send": string; - "Ui.Settings.Common.Use": string; - "Ui.Settings.Common.VerifyAll": string; - "Ui.Settings.CustomizationSync.OpenDesc": string; - "Ui.Settings.CustomizationSync.Panel": string; - "Ui.Settings.CustomizationSync.WarnChangeDeviceName": string; - "Ui.Settings.CustomizationSync.WarnSetDeviceName": string; - "Ui.Settings.Hatch.AnalyseDatabaseUsage": string; - "Ui.Settings.Hatch.AnalyseDatabaseUsageDesc": string; - "Ui.Settings.Hatch.BackToNonConfigured": string; - "Ui.Settings.Hatch.ConvertNonObfuscated": string; - "Ui.Settings.Hatch.ConvertNonObfuscatedDesc": string; - "Ui.Settings.Hatch.CopyIssueReport": string; - "Ui.Settings.Hatch.DatabaseLabel": string; - "Ui.Settings.Hatch.DatabaseToStorage": string; - "Ui.Settings.Hatch.DeleteCustomizationSyncData": string; - "Ui.Settings.Hatch.GeneratedReport": string; - "Ui.Settings.Hatch.Missing": string; - "Ui.Settings.Hatch.ModifiedSize": string; - "Ui.Settings.Hatch.ModifiedSizeActual": string; - "Ui.Settings.Hatch.PrepareIssueReport": string; - "Ui.Settings.Hatch.RecoveryAndRepair": string; - "Ui.Settings.Hatch.RecreateAll": string; - "Ui.Settings.Hatch.RecreateMissingChunks": string; - "Ui.Settings.Hatch.RecreateMissingChunksDesc": string; - "Ui.Settings.Hatch.ResetPanel": string; - "Ui.Settings.Hatch.ResetRemoteUsage": string; - "Ui.Settings.Hatch.ResetRemoteUsageDesc": string; - "Ui.Settings.Hatch.ResolveAllConflictedFiles": string; - "Ui.Settings.Hatch.ResolveAllConflictedFilesDesc": string; - "Ui.Settings.Hatch.RunDoctor": string; - "Ui.Settings.Hatch.ScanBrokenFiles": string; - "Ui.Settings.Hatch.ScramSwitches": string; - "Ui.Settings.Hatch.ShowHistory": string; - "Ui.Settings.Hatch.StorageLabel": string; - "Ui.Settings.Hatch.StorageToDatabase": string; - "Ui.Settings.Hatch.VerifyAndRepairAllFiles": string; - "Ui.Settings.Hatch.VerifyAndRepairAllFilesDesc": string; - "Ui.Settings.Maintenance.Cleanup": string; - "Ui.Settings.Maintenance.CleanupDesc": string; - "Ui.Settings.Maintenance.DeleteLocalDatabase": string; - "Ui.Settings.Maintenance.EmergencyRestart": string; - "Ui.Settings.Maintenance.EmergencyRestartDesc": string; - "Ui.Settings.Maintenance.FreshStartWipe": string; - "Ui.Settings.Maintenance.FreshStartWipeDesc": string; - "Ui.Settings.Maintenance.GarbageCollection": string; - "Ui.Settings.Maintenance.GarbageCollectionAction": string; - "Ui.Settings.Maintenance.GarbageCollectionDesc": string; - "Ui.Settings.Maintenance.LockServer": string; - "Ui.Settings.Maintenance.LockServerDesc": string; - "Ui.Settings.Maintenance.OverwriteRemote": string; - "Ui.Settings.Maintenance.OverwriteRemoteDesc": string; - "Ui.Settings.Maintenance.OverwriteServerData": string; - "Ui.Settings.Maintenance.OverwriteServerDataDesc": string; - "Ui.Settings.Maintenance.PurgeAllJournalCounter": string; - "Ui.Settings.Maintenance.PurgeAllJournalCounterDesc": string; - "Ui.Settings.Maintenance.RebuildingOperations": string; - "Ui.Settings.Maintenance.Resend": string; - "Ui.Settings.Maintenance.ResendDesc": string; - "Ui.Settings.Maintenance.Reset": string; - "Ui.Settings.Maintenance.ResetAllJournalCounter": string; - "Ui.Settings.Maintenance.ResetAllJournalCounterDesc": string; - "Ui.Settings.Maintenance.ResetJournalReceived": string; - "Ui.Settings.Maintenance.ResetJournalReceivedDesc": string; - "Ui.Settings.Maintenance.ResetJournalSent": string; - "Ui.Settings.Maintenance.ResetJournalSentDesc": string; - "Ui.Settings.Maintenance.ResetLocalSyncInfo": string; - "Ui.Settings.Maintenance.ResetLocalSyncInfoDesc": string; - "Ui.Settings.Maintenance.ResetReceived": string; - "Ui.Settings.Maintenance.ResetSentHistory": string; - "Ui.Settings.Maintenance.ResetThisDevice": string; - "Ui.Settings.Maintenance.ScheduleAndRestart": string; - "Ui.Settings.Maintenance.Scram": string; - "Ui.Settings.Maintenance.SendChunks": string; - "Ui.Settings.Maintenance.Syncing": string; - "Ui.Settings.Maintenance.WarningLockedReadyAction": string; - "Ui.Settings.Maintenance.WarningLockedReadyText": string; - "Ui.Settings.Maintenance.WarningLockedResolveAction": string; - "Ui.Settings.Maintenance.WarningLockedResolveText": string; - "Ui.Settings.Maintenance.WriteRedFlagAndRestart": string; - "Ui.Settings.Patches.CompatibilityConflict": string; - "Ui.Settings.Patches.CompatibilityDatabase": string; - "Ui.Settings.Patches.CompatibilityInternalApi": string; - "Ui.Settings.Patches.CompatibilityMetadata": string; - "Ui.Settings.Patches.CompatibilityRemote": string; - "Ui.Settings.Patches.CompatibilityTrouble": string; - "Ui.Settings.Patches.CurrentAdapter": string; - "Ui.Settings.Patches.DatabaseAdapter": string; - "Ui.Settings.Patches.DatabaseAdapterDesc": string; - "Ui.Settings.Patches.EdgeCaseBehaviour": string; - "Ui.Settings.Patches.EdgeCaseDatabase": string; - "Ui.Settings.Patches.EdgeCaseProcessing": string; - "Ui.Settings.Patches.IndexedDbWarning": string; - "Ui.Settings.Patches.MigratingToIdb": string; - "Ui.Settings.Patches.MigratingToIndexedDb": string; - "Ui.Settings.Patches.MigrationIdbCompleted": string; - "Ui.Settings.Patches.MigrationIdbCompletedFollowUp": string; - "Ui.Settings.Patches.MigrationIndexedDbCompleted": string; - "Ui.Settings.Patches.MigrationIndexedDbCompletedFollowUp": string; - "Ui.Settings.Patches.MigrationWarning": string; - "Ui.Settings.Patches.OperationToIdb": string; - "Ui.Settings.Patches.OperationToIndexedDb": string; - "Ui.Settings.Patches.Remediation": string; - "Ui.Settings.Patches.RemediationChanged": string; - "Ui.Settings.Patches.RemediationNoLimit": string; - "Ui.Settings.Patches.RemediationRestarting": string; - "Ui.Settings.Patches.RemediationRestartLater": string; - "Ui.Settings.Patches.RemediationRestartMessage": string; - "Ui.Settings.Patches.RemediationRestartNow": string; - "Ui.Settings.Patches.RemediationSuffixChanged": string; - "Ui.Settings.Patches.RemediationWithValue": string; - "Ui.Settings.Patches.RemoteDatabaseSunset": string; - "Ui.Settings.Patches.SwitchToIDB": string; - "Ui.Settings.Patches.SwitchToIndexedDb": string; - "Ui.Settings.PowerUsers.ConfigurationEncryption": string; - "Ui.Settings.PowerUsers.ConnectionTweak": string; - "Ui.Settings.PowerUsers.ConnectionTweakDesc": string; - "Ui.Settings.PowerUsers.Default": string; - "Ui.Settings.PowerUsers.Developer": string; - "Ui.Settings.PowerUsers.EncryptSensitiveConfig": string; - "Ui.Settings.PowerUsers.PromptPassphraseEveryLaunch": string; - "Ui.Settings.PowerUsers.UseCustomPassphrase": string; - "Ui.Settings.Remote.Activate": string; - "Ui.Settings.Remote.ActiveSuffix": string; - "Ui.Settings.Remote.AddConnection": string; - "Ui.Settings.Remote.AddRemoteDefaultName": string; - "Ui.Settings.Remote.ConfigureAndChangeRemote": string; - "Ui.Settings.Remote.ConfigureE2EE": string; - "Ui.Settings.Remote.ConfigureRemote": string; - "Ui.Settings.Remote.DeleteRemoteConfirm": string; - "Ui.Settings.Remote.DeleteRemoteTitle": string; - "Ui.Settings.Remote.DisplayName": string; - "Ui.Settings.Remote.DuplicateRemote": string; - "Ui.Settings.Remote.DuplicateRemoteSuffix": string; - "Ui.Settings.Remote.E2EEConfiguration": string; - "Ui.Settings.Remote.Export": string; - "Ui.Settings.Remote.FetchRemoteSettings": string; - "Ui.Settings.Remote.ImportConnection": string; - "Ui.Settings.Remote.ImportConnectionPrompt": string; - "Ui.Settings.Remote.ImportedCouchDb": string; - "Ui.Settings.Remote.ImportedRemote": string; - "Ui.Settings.Remote.MoreActions": string; - "Ui.Settings.Remote.PeerToPeerPanel": string; - "Ui.Settings.Remote.RemoteConfigurationPrefix": string; - "Ui.Settings.Remote.RemoteDatabases": string; - "Ui.Settings.Remote.RemoteName": string; - "Ui.Settings.Remote.RemoteNameCouchDb": string; - "Ui.Settings.Remote.RemoteNameP2P": string; - "Ui.Settings.Remote.RemoteNameS3": string; - "Ui.Settings.Remote.Rename": string; - "Ui.Settings.Selector.AddDefaultPatterns": string; - "Ui.Settings.Selector.CrossPlatform": string; - "Ui.Settings.Selector.Default": string; - "Ui.Settings.Selector.HiddenFiles": string; - "Ui.Settings.Selector.IgnorePatterns": string; - "Ui.Settings.Selector.NonSynchronisingFiles": string; - "Ui.Settings.Selector.NonSynchronisingFilesDesc": string; - "Ui.Settings.Selector.NormalFiles": string; - "Ui.Settings.Selector.OverwritePatterns": string; - "Ui.Settings.Selector.OverwritePatternsDesc": string; - "Ui.Settings.Selector.SynchronisingFiles": string; - "Ui.Settings.Selector.SynchronisingFilesDesc": string; - "Ui.Settings.Selector.TargetPatterns": string; - "Ui.Settings.Selector.TargetPatternsDesc": string; - "Ui.Settings.Setup.RerunWizardButton": string; - "Ui.Settings.Setup.RerunWizardDesc": string; - "Ui.Settings.Setup.RerunWizardName": string; - "Ui.Settings.SyncSettings.Fetch": string; - "Ui.Settings.SyncSettings.Merge": string; - "Ui.Settings.SyncSettings.Overwrite": string; - "Ui.SetupWizard.Common.Back": string; - "Ui.SetupWizard.Common.Cancel": string; - "Ui.SetupWizard.Common.ProceedSelectOption": string; - "Ui.SetupWizard.Intro.ExistingOption": string; - "Ui.SetupWizard.Intro.ExistingOptionDesc": string; - "Ui.SetupWizard.Intro.Guidance": string; - "Ui.SetupWizard.Intro.NewOption": string; - "Ui.SetupWizard.Intro.NewOptionDesc": string; - "Ui.SetupWizard.Intro.ProceedExisting": string; - "Ui.SetupWizard.Intro.ProceedNew": string; - "Ui.SetupWizard.Intro.Question": string; - "Ui.SetupWizard.Intro.Title": string; - "Ui.SetupWizard.OutroAskUserMode.CompatibleOption": string; - "Ui.SetupWizard.OutroAskUserMode.CompatibleOptionDesc": string; - "Ui.SetupWizard.OutroAskUserMode.ExistingOption": string; - "Ui.SetupWizard.OutroAskUserMode.ExistingOptionDesc": string; - "Ui.SetupWizard.OutroAskUserMode.Guidance": string; - "Ui.SetupWizard.OutroAskUserMode.NewOption": string; - "Ui.SetupWizard.OutroAskUserMode.NewOptionDesc": string; - "Ui.SetupWizard.OutroAskUserMode.ProceedApplySettings": string; - "Ui.SetupWizard.OutroAskUserMode.ProceedNext": string; - "Ui.SetupWizard.OutroAskUserMode.Question": string; - "Ui.SetupWizard.OutroAskUserMode.Title": string; - "Ui.SetupWizard.OutroNewUser.GuidancePrimary": string; - "Ui.SetupWizard.OutroNewUser.GuidanceWarning": string; - "Ui.SetupWizard.OutroNewUser.Important": string; - "Ui.SetupWizard.OutroNewUser.Proceed": string; - "Ui.SetupWizard.OutroNewUser.Question": string; - "Ui.SetupWizard.OutroNewUser.Title": string; - "Ui.SetupWizard.SelectExisting.Guidance": string; - "Ui.SetupWizard.SelectExisting.ManualOption": string; - "Ui.SetupWizard.SelectExisting.ManualOptionDesc": string; - "Ui.SetupWizard.SelectExisting.ProceedManual": string; - "Ui.SetupWizard.SelectExisting.ProceedQr": string; - "Ui.SetupWizard.SelectExisting.ProceedSetupUri": string; - "Ui.SetupWizard.SelectExisting.QrOption": string; - "Ui.SetupWizard.SelectExisting.QrOptionDesc": string; - "Ui.SetupWizard.SelectExisting.Question": string; - "Ui.SetupWizard.SelectExisting.SetupUriOption": string; - "Ui.SetupWizard.SelectExisting.SetupUriOptionDesc": string; - "Ui.SetupWizard.SelectExisting.Title": string; - "Ui.SetupWizard.SelectNew.Guidance": string; - "Ui.SetupWizard.SelectNew.ManualOption": string; - "Ui.SetupWizard.SelectNew.ManualOptionDesc": string; - "Ui.SetupWizard.SelectNew.ProceedManual": string; - "Ui.SetupWizard.SelectNew.ProceedSetupUri": string; - "Ui.SetupWizard.SelectNew.Question": string; - "Ui.SetupWizard.SelectNew.SetupUriOption": string; - "Ui.SetupWizard.SelectNew.SetupUriOptionDesc": string; - "Ui.SetupWizard.SelectNew.Title": string; - "Ui.SetupWizard.SetupRemote.BucketOption": string; - "Ui.SetupWizard.SetupRemote.BucketOptionDesc": string; - "Ui.SetupWizard.SetupRemote.CouchDbOptionDesc": string; - "Ui.SetupWizard.SetupRemote.Guidance": string; - "Ui.SetupWizard.SetupRemote.P2POption": string; - "Ui.SetupWizard.SetupRemote.P2POptionDesc": string; - "Ui.SetupWizard.SetupRemote.ProceedBucket": string; - "Ui.SetupWizard.SetupRemote.ProceedCouchDb": string; - "Ui.SetupWizard.SetupRemote.ProceedP2P": string; - "Ui.SetupWizard.SetupRemote.Title": string; - "Unique name between all synchronized devices. To edit this setting, please disable customization sync once.": string; - "Use a custom passphrase": string; - "Use a Setup URI (Recommended)": string; - "Use Custom HTTP Handler": string; - "Use dynamic iteration count": string; - "Use Segmented-splitter": string; - "Use splitting-limit-capped chunk splitter": string; - "Use the trash bin": string; - "Use timeouts instead of heartbeats": string; - username: string; - Username: string; - "Verbose Log": string; - "Verify all": string; - "Verify and repair all files": string; - "Warning! This will have a serious impact on performance. And the logs will not be synchronised under the default name. Please be careful with logs; they often contain your confidential information.": string; - "We cannot change the device name while this feature is enabled. Please disable this feature to change the device name.": string; - "We will now guide you through a few questions to simplify the synchronisation setup.": string; - "We will now proceed with the server configuration.": string; - "Welcome to Self-hosted LiveSync": string; - "When you save a file in the editor, start a sync automatically": string; - "Write credentials in the file": string; - "Write logs into the file": string; - "xxhash32 (Fast but less collision resistance)": string; - "xxhash64 (Fastest)": string; - "Yes, I want to add this device to my existing synchronisation": string; - "Yes, I want to set up a new synchronisation": string; - "You are adding this device to an existing synchronisation setup.": string; - }; -}; diff --git a/_types/src/lib/src/common/messages/es.d.ts b/_types/src/lib/src/common/messages/es.d.ts deleted file mode 100644 index 414a60e2..00000000 --- a/_types/src/lib/src/common/messages/es.d.ts +++ /dev/null @@ -1,691 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare const PartialMessages: { - readonly es: { - "(Active)": string; - "(BETA) Always overwrite with a newer file": string; - "(Beta) Use ignore files": string; - "(Days passed, 0 to disable automatic-deletion)": string; - "(ex. Read chunks online) If this option is enabled, LiveSync reads chunks online directly instead of replicating them locally. Increasing Custom chunk size is recommended.": string; - "(MB) If this is set, changes to local and remote files that are larger than this will be skipped. If the file becomes smaller again, a newer one will be used.": string; - "(Mega chars)": string; - "(Not recommended) If set, credentials will be stored in the file.": string; - "(Obsolete) Use an old adapter for compatibility": string; - "(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": string; - "(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": string; - "(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": string; - "(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": string; - "A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration.": string; - "Access Key": string; - Activate: string; - "Add default patterns": string; - "Add new connection": string; - "Always prompt merge conflicts": string; - "Apply Latest Change if Conflicting": string; - "Apply preset configuration": string; - "Ask a passphrase at every launch": string; - "Automatically Sync all files when opening Obsidian.": string; - Back: string; - "Back to non-configured": string; - "Batch database update": string; - "Batch limit": string; - "Batch size": string; - "Batch size of on-demand fetching": string; - "Before v0.17.16, we used an old adapter for the local database. Now the new adapter is preferred. However, it needs local database rebuilding. Please disable this toggle when you have enough time. If leave it enabled, also while fetching from the remote database, you will be asked to disable this.": string; - "Bucket Name": string; - Cancel: string; - "Check and convert non-path-obfuscated files": string; - "Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.": string; - "cmdConfigSync.showCustomizationSync": string; - "Comma separated `.gitignore, .dockerignore`": string; - "Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep.": string; - "Compatibility (Conflict Behaviour)": string; - "Compatibility (Database structure)": string; - "Compatibility (Internal API Usage)": string; - "Compatibility (Metadata)": string; - "Compatibility (Remote Database)": string; - "Compatibility (Trouble addressed)": string; - "Compute revisions for chunks (Previous behaviour)": string; - "Configuration Encryption": string; - Configure: string; - "Configure And Change Remote": string; - "Configure E2EE": string; - "Configure Remote": string; - "Configure the same server information as your other devices again, manually, very advanced users only.": string; - "Connection Method": string; - "Continue to CouchDB setup": string; - "Continue to Peer-to-Peer only setup": string; - "Continue to S3/MinIO/R2 setup": string; - Copy: string; - "CouchDB Connection Tweak": string; - "Cross-platform": string; - "Current adapter: {adapter}": string; - "Customization Sync": string; - "Customization Sync (Beta3)": string; - "Data Compression": string; - "Database Adapter": string; - "Database Name": string; - "Database suffix": string; - Default: string; - "Delay conflict resolution of inactive files": string; - "Delay merge conflict prompt for inactive files.": string; - Delete: string; - "Delete all customization sync data": string; - "Delete all data on the remote server.": string; - "Delete local database to reset or uninstall Self-hosted LiveSync": string; - "Delete old metadata of deleted files on start-up": string; - "Delete Remote Configuration": string; - "Delete remote configuration '{name}'?": string; - desktop: string; - Developer: string; - "Device name": string; - "Device Setup Method": string; - "Disables all synchronization and restart.": string; - "Disables logging, only shows notifications. Please disable if you report an issue.": string; - "Display Language": string; - "Display name": string; - "Do not check configuration mismatch before replication": string; - "Do not keep metadata of deleted files.": string; - "Do not split chunks in the background": string; - "Do not use internal API": string; - Duplicate: string; - "Duplicate remote": string; - "E2EE Configuration": string; - "Edge case addressing (Behaviour)": string; - "Edge case addressing (Database)": string; - "Edge case addressing (Processing)": string; - "Emergency restart": string; - "Enable advanced features": string; - "Enable customization sync": string; - "Enable Developers' Debug Tools.": string; - "Enable edge case treatment features": string; - "Enable poweruser features": string; - "Enable this if your Object Storage doesn't support CORS": string; - "Enable this option to automatically apply the most recent change to documents even when it conflicts": string; - "Encrypt contents on the remote database. If you use the plugin's synchronization feature, enabling this is recommended.": string; - "Encrypting sensitive configuration items": string; - "Encryption phassphrase. If changed, you should overwrite the server's database with the new (encrypted) files.": string; - "End-to-End Encryption": string; - "Endpoint URL": string; - "Enhance chunk size": string; - "Enter Server Information": string; - "Enter the server information manually": string; - Export: string; - Fetch: string; - "Fetch chunks on demand": string; - "Fetch database with previous behaviour": string; - "Fetch remote settings": string; - "File to resolve conflict": string; - Filename: string; - "First, please select the option that best describes your current situation.": string; - "Flag and restart": string; - "Forces the file to be synced when opened.": string; - "Fresh Start Wipe": string; - "Garbage Collection V3 (Beta)": string; - "Handle files as Case-Sensitive": string; - "Hidden Files": string; - "How to display network errors when the sync server is unreachable.": string; - "How would you like to configure the connection to your server?": string; - "I am adding a device to an existing synchronisation setup": string; - "I am setting this up for the first time": string; - "I know my server details, let me enter them": string; - "If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).": string; - "If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.": string; - "If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker.": string; - "If enabled, newly created chunks are temporarily kept within the document, and graduated to become independent chunks once stabilised.": string; - "If enabled, the \u26D4 icon will be shown inside the status instead of the file warnings banner. No details will be shown.": string; - "If enabled, the file under 1kb will be processed in the UI thread.": string; - "If enabled, the notification of hidden files change will be suppressed.": string; - "If this enabled, all chunks will be stored with the revision made from its content. (Previous behaviour)": string; - "If this enabled, All files are handled as case-Sensitive (Previous behaviour).": string; - "If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature.": string; - "If this is set, changes to local files which are matched by the ignore files will be skipped. Remote changes are determined using local ignore files.": string; - "If this option is enabled, PouchDB will hold the connection open for 60 seconds, and if no change arrives in that time, close and reopen the socket, instead of holding it open indefinitely. Useful when a proxy limits request duration but can increase resource usage.": string; - "Ignore files": string; - "Ignore patterns": string; - "Import connection": string; - "Incubate Chunks in Document": string; - "Initialise all journal history, On the next sync, every item will be received and sent.": string; - "Interval (sec)": string; - "Keep empty folder": string; - "lang-de": string; - "lang-es": string; - "lang-fr": string; - "lang-ja": string; - "lang-ru": string; - "lang-zh": string; - "lang-zh-tw": string; - Later: string; - "Limit: {datetime} ({timestamp})": string; - "LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured.": string; - "liveSyncReplicator.beforeLiveSync": string; - "liveSyncReplicator.cantReplicateLowerValue": string; - "liveSyncReplicator.checkingLastSyncPoint": string; - "liveSyncReplicator.couldNotConnectTo": string; - "liveSyncReplicator.couldNotConnectToRemoteDb": string; - "liveSyncReplicator.couldNotConnectToServer": string; - "liveSyncReplicator.couldNotConnectToURI": string; - "liveSyncReplicator.couldNotMarkResolveRemoteDb": string; - "liveSyncReplicator.liveSyncBegin": string; - "liveSyncReplicator.lockRemoteDb": string; - "liveSyncReplicator.markDeviceResolved": string; - "liveSyncReplicator.oneShotSyncBegin": string; - "liveSyncReplicator.remoteDbCorrupted": string; - "liveSyncReplicator.remoteDbCreatedOrConnected": string; - "liveSyncReplicator.remoteDbDestroyed": string; - "liveSyncReplicator.remoteDbDestroyError": string; - "liveSyncReplicator.remoteDbMarkedResolved": string; - "liveSyncReplicator.replicationClosed": string; - "liveSyncReplicator.replicationInProgress": string; - "liveSyncReplicator.retryLowerBatchSize": string; - "liveSyncReplicator.unlockRemoteDb": string; - "liveSyncSetting.errorNoSuchSettingItem": string; - "liveSyncSetting.originalValue": string; - "liveSyncSetting.valueShouldBeInRange": string; - "liveSyncSettings.btnApply": string; - "Local Database Tweak": string; - Lock: string; - "Lock Server": string; - "Lock the remote server to prevent synchronization with other devices.": string; - "logPane.autoScroll": string; - "logPane.logWindowOpened": string; - "logPane.pause": string; - "logPane.title": string; - "logPane.wrap": string; - "Maximum delay for batch database updating": string; - "Maximum file size": string; - "Maximum Incubating Chunk Size": string; - "Maximum Incubating Chunks": string; - "Maximum Incubation Period": string; - "MB (0 to disable).": string; - "Memory cache": string; - "Memory cache size (by total characters)": string; - "Memory cache size (by total items)": string; - Merge: string; - "Minimum delay for batch database updating": string; - "moduleCheckRemoteSize.logCheckingStorageSizes": string; - "moduleCheckRemoteSize.logCurrentStorageSize": string; - "moduleCheckRemoteSize.logExceededWarning": string; - "moduleCheckRemoteSize.logThresholdEnlarged": string; - "moduleCheckRemoteSize.msgConfirmRebuild": string; - "moduleCheckRemoteSize.msgDatabaseGrowing": string; - "moduleCheckRemoteSize.msgSetDBCapacity": string; - "moduleCheckRemoteSize.option2GB": string; - "moduleCheckRemoteSize.option800MB": string; - "moduleCheckRemoteSize.optionAskMeLater": string; - "moduleCheckRemoteSize.optionDismiss": string; - "moduleCheckRemoteSize.optionIncreaseLimit": string; - "moduleCheckRemoteSize.optionNoWarn": string; - "moduleCheckRemoteSize.optionRebuildAll": string; - "moduleCheckRemoteSize.titleDatabaseSizeLimitExceeded": string; - "moduleCheckRemoteSize.titleDatabaseSizeNotify": string; - "moduleInputUIObsidian.defaultTitleConfirmation": string; - "moduleInputUIObsidian.defaultTitleSelect": string; - "moduleInputUIObsidian.optionNo": string; - "moduleInputUIObsidian.optionYes": string; - "moduleLiveSyncMain.logAdditionalSafetyScan": string; - "moduleLiveSyncMain.logLoadingPlugin": string; - "moduleLiveSyncMain.logPluginInitCancelled": string; - "moduleLiveSyncMain.logPluginVersion": string; - "moduleLiveSyncMain.logReadChangelog": string; - "moduleLiveSyncMain.logSafetyScanCompleted": string; - "moduleLiveSyncMain.logSafetyScanFailed": string; - "moduleLiveSyncMain.logUnloadingPlugin": string; - "moduleLiveSyncMain.logVersionUpdate": string; - "moduleLiveSyncMain.msgScramEnabled": string; - "moduleLiveSyncMain.optionKeepLiveSyncDisabled": string; - "moduleLiveSyncMain.optionResumeAndRestart": string; - "moduleLiveSyncMain.titleScramEnabled": string; - "moduleLocalDatabase.logWaitingForReady": string; - "moduleLog.showLog": string; - "moduleMigration.docUri": string; - "moduleMigration.logBulkSendCorrupted": string; - "moduleMigration.logFetchRemoteTweakFailed": string; - "moduleMigration.logLocalDatabaseNotReady": string; - "moduleMigration.logMigratedSameBehaviour": string; - "moduleMigration.logMigrationFailed": string; - "moduleMigration.logRedflag2CreationFail": string; - "moduleMigration.logRemoteTweakUnavailable": string; - "moduleMigration.logSetupCancelled": string; - "moduleMigration.msgFetchRemoteAgain": string; - "moduleMigration.msgInitialSetup": string; - "moduleMigration.msgRecommendSetupUri": string; - "moduleMigration.msgSinceV02321": string; - "moduleMigration.optionAdjustRemote": string; - "moduleMigration.optionDecideLater": string; - "moduleMigration.optionEnableBoth": string; - "moduleMigration.optionEnableFilenameCaseInsensitive": string; - "moduleMigration.optionEnableFixedRevisionForChunks": string; - "moduleMigration.optionHaveSetupUri": string; - "moduleMigration.optionKeepPreviousBehaviour": string; - "moduleMigration.optionManualSetup": string; - "moduleMigration.optionNoAskAgain": string; - "moduleObsidianMenu.replicate": string; - "More actions": string; - "Move remotely deleted files to the trash, instead of deleting.": string; - "Network warning style": string; - "New Remote": string; - "No limit configured": string; - "No, please take me back": string; - "Non-Synchronising files": string; - "Normal Files": string; - "Not all messages have been translated. And, please revert to \"Default\" when reporting errors.": string; - "Notify all setting files": string; - "Notify customized": string; - "Notify when other device has newly customized.": string; - "Notify when the estimated remote storage size exceeds on start up": string; - "Number of batches to process at a time. Defaults to 40. Minimum is 2. This along with batch size controls how many docs are kept in memory at a time.": string; - "Number of changes to sync at a time. Defaults to 50. Minimum is 2.": string; - "obsidianLiveSyncSettingTab.btnApply": string; - "obsidianLiveSyncSettingTab.btnCheck": string; - "obsidianLiveSyncSettingTab.btnCopy": string; - "obsidianLiveSyncSettingTab.btnDisable": string; - "obsidianLiveSyncSettingTab.btnDiscard": string; - "obsidianLiveSyncSettingTab.btnEnable": string; - "obsidianLiveSyncSettingTab.btnFix": string; - "obsidianLiveSyncSettingTab.btnGotItAndUpdated": string; - "obsidianLiveSyncSettingTab.btnNext": string; - "obsidianLiveSyncSettingTab.btnStart": string; - "obsidianLiveSyncSettingTab.btnTest": string; - "obsidianLiveSyncSettingTab.btnUse": string; - "obsidianLiveSyncSettingTab.buttonFetch": string; - "obsidianLiveSyncSettingTab.buttonNext": string; - "obsidianLiveSyncSettingTab.defaultLanguage": string; - "obsidianLiveSyncSettingTab.descConnectSetupURI": string; - "obsidianLiveSyncSettingTab.descCopySetupURI": string; - "obsidianLiveSyncSettingTab.descEnableLiveSync": string; - "obsidianLiveSyncSettingTab.descFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.descManualSetup": string; - "obsidianLiveSyncSettingTab.descTestDatabaseConnection": string; - "obsidianLiveSyncSettingTab.descValidateDatabaseConfig": string; - "obsidianLiveSyncSettingTab.errAccessForbidden": string; - "obsidianLiveSyncSettingTab.errCannotContinueTest": string; - "obsidianLiveSyncSettingTab.errCorsCredentials": string; - "obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials": string; - "obsidianLiveSyncSettingTab.errCorsOrigins": string; - "obsidianLiveSyncSettingTab.errEnableCors": string; - "obsidianLiveSyncSettingTab.errMaxDocumentSize": string; - "obsidianLiveSyncSettingTab.errMaxRequestSize": string; - "obsidianLiveSyncSettingTab.errMissingWwwAuth": string; - "obsidianLiveSyncSettingTab.errRequireValidUser": string; - "obsidianLiveSyncSettingTab.errRequireValidUserAuth": string; - "obsidianLiveSyncSettingTab.labelDisabled": string; - "obsidianLiveSyncSettingTab.labelEnabled": string; - "obsidianLiveSyncSettingTab.levelAdvanced": string; - "obsidianLiveSyncSettingTab.levelEdgeCase": string; - "obsidianLiveSyncSettingTab.levelPowerUser": string; - "obsidianLiveSyncSettingTab.linkOpenInBrowser": string; - "obsidianLiveSyncSettingTab.linkPageTop": string; - "obsidianLiveSyncSettingTab.linkTipsAndTroubleshooting": string; - "obsidianLiveSyncSettingTab.linkTroubleshooting": string; - "obsidianLiveSyncSettingTab.logCannotUseCloudant": string; - "obsidianLiveSyncSettingTab.logCheckingConfigDone": string; - "obsidianLiveSyncSettingTab.logCheckingConfigFailed": string; - "obsidianLiveSyncSettingTab.logCheckingDbConfig": string; - "obsidianLiveSyncSettingTab.logCheckPassphraseFailed": string; - "obsidianLiveSyncSettingTab.logConfiguredDisabled": string; - "obsidianLiveSyncSettingTab.logConfiguredLiveSync": string; - "obsidianLiveSyncSettingTab.logConfiguredPeriodic": string; - "obsidianLiveSyncSettingTab.logCouchDbConfigFail": string; - "obsidianLiveSyncSettingTab.logCouchDbConfigSet": string; - "obsidianLiveSyncSettingTab.logCouchDbConfigUpdated": string; - "obsidianLiveSyncSettingTab.logDatabaseConnected": string; - "obsidianLiveSyncSettingTab.logEncryptionNoPassphrase": string; - "obsidianLiveSyncSettingTab.logEncryptionNoSupport": string; - "obsidianLiveSyncSettingTab.logErrorOccurred": string; - "obsidianLiveSyncSettingTab.logEstimatedSize": string; - "obsidianLiveSyncSettingTab.logPassphraseInvalid": string; - "obsidianLiveSyncSettingTab.logPassphraseNotCompatible": string; - "obsidianLiveSyncSettingTab.logRebuildNote": string; - "obsidianLiveSyncSettingTab.logSelectAnyPreset": string; - "obsidianLiveSyncSettingTab.msgAreYouSureProceed": string; - "obsidianLiveSyncSettingTab.msgChangesNeedToBeApplied": string; - "obsidianLiveSyncSettingTab.msgConfigCheck": string; - "obsidianLiveSyncSettingTab.msgConfigCheckFailed": string; - "obsidianLiveSyncSettingTab.msgConnectionCheck": string; - "obsidianLiveSyncSettingTab.msgConnectionProxyNote": string; - "obsidianLiveSyncSettingTab.msgCurrentOrigin": string; - "obsidianLiveSyncSettingTab.msgDiscardConfirmation": string; - "obsidianLiveSyncSettingTab.msgDone": string; - "obsidianLiveSyncSettingTab.msgEnableCors": string; - "obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": string; - "obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.msgGenerateSetupURI": string; - "obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": string; - "obsidianLiveSyncSettingTab.msgInvalidPassphrase": string; - "obsidianLiveSyncSettingTab.msgNewVersionNote": string; - "obsidianLiveSyncSettingTab.msgNonHTTPSInfo": string; - "obsidianLiveSyncSettingTab.msgNonHTTPSWarning": string; - "obsidianLiveSyncSettingTab.msgNotice": string; - "obsidianLiveSyncSettingTab.msgObjectStorageWarning": string; - "obsidianLiveSyncSettingTab.msgOriginCheck": string; - "obsidianLiveSyncSettingTab.msgRebuildRequired": string; - "obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": string; - "obsidianLiveSyncSettingTab.msgSetCorsCredentials": string; - "obsidianLiveSyncSettingTab.msgSetCorsOrigins": string; - "obsidianLiveSyncSettingTab.msgSetMaxDocSize": string; - "obsidianLiveSyncSettingTab.msgSetMaxRequestSize": string; - "obsidianLiveSyncSettingTab.msgSetRequireValidUser": string; - "obsidianLiveSyncSettingTab.msgSetRequireValidUserAuth": string; - "obsidianLiveSyncSettingTab.msgSettingModified": string; - "obsidianLiveSyncSettingTab.msgSettingsUnchangeableDuringSync": string; - "obsidianLiveSyncSettingTab.msgSetWwwAuth": string; - "obsidianLiveSyncSettingTab.nameApplySettings": string; - "obsidianLiveSyncSettingTab.nameConnectSetupURI": string; - "obsidianLiveSyncSettingTab.nameCopySetupURI": string; - "obsidianLiveSyncSettingTab.nameDisableHiddenFileSync": string; - "obsidianLiveSyncSettingTab.nameDiscardSettings": string; - "obsidianLiveSyncSettingTab.nameEnableHiddenFileSync": string; - "obsidianLiveSyncSettingTab.nameEnableLiveSync": string; - "obsidianLiveSyncSettingTab.nameHiddenFileSynchronization": string; - "obsidianLiveSyncSettingTab.nameManualSetup": string; - "obsidianLiveSyncSettingTab.nameTestConnection": string; - "obsidianLiveSyncSettingTab.nameTestDatabaseConnection": string; - "obsidianLiveSyncSettingTab.nameValidateDatabaseConfig": string; - "obsidianLiveSyncSettingTab.okAdminPrivileges": string; - "obsidianLiveSyncSettingTab.okCorsCredentials": string; - "obsidianLiveSyncSettingTab.okCorsCredentialsForOrigin": string; - "obsidianLiveSyncSettingTab.okCorsOriginMatched": string; - "obsidianLiveSyncSettingTab.okCorsOrigins": string; - "obsidianLiveSyncSettingTab.okEnableCors": string; - "obsidianLiveSyncSettingTab.okMaxDocumentSize": string; - "obsidianLiveSyncSettingTab.okMaxRequestSize": string; - "obsidianLiveSyncSettingTab.okRequireValidUser": string; - "obsidianLiveSyncSettingTab.okRequireValidUserAuth": string; - "obsidianLiveSyncSettingTab.okWwwAuth": string; - "obsidianLiveSyncSettingTab.optionApply": string; - "obsidianLiveSyncSettingTab.optionCancel": string; - "obsidianLiveSyncSettingTab.optionCouchDB": string; - "obsidianLiveSyncSettingTab.optionDisableAllAutomatic": string; - "obsidianLiveSyncSettingTab.optionFetchFromRemote": string; - "obsidianLiveSyncSettingTab.optionHere": string; - "obsidianLiveSyncSettingTab.optionLiveSync": string; - "obsidianLiveSyncSettingTab.optionMinioS3R2": string; - "obsidianLiveSyncSettingTab.optionOkReadEverything": string; - "obsidianLiveSyncSettingTab.optionOnEvents": string; - "obsidianLiveSyncSettingTab.optionPeriodicAndEvents": string; - "obsidianLiveSyncSettingTab.optionPeriodicWithBatch": string; - "obsidianLiveSyncSettingTab.optionRebuildBoth": string; - "obsidianLiveSyncSettingTab.optionSaveOnlySettings": string; - "obsidianLiveSyncSettingTab.panelChangeLog": string; - "obsidianLiveSyncSettingTab.panelGeneralSettings": string; - "obsidianLiveSyncSettingTab.panelPrivacyEncryption": string; - "obsidianLiveSyncSettingTab.panelRemoteConfiguration": string; - "obsidianLiveSyncSettingTab.panelSetup": string; - "obsidianLiveSyncSettingTab.titleAppearance": string; - "obsidianLiveSyncSettingTab.titleConflictResolution": string; - "obsidianLiveSyncSettingTab.titleCongratulations": string; - "obsidianLiveSyncSettingTab.titleCouchDB": string; - "obsidianLiveSyncSettingTab.titleDeletionPropagation": string; - "obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": string; - "obsidianLiveSyncSettingTab.titleEncryptionPassphraseInvalid": string; - "obsidianLiveSyncSettingTab.titleExtraFeatures": string; - "obsidianLiveSyncSettingTab.titleFetchConfig": string; - "obsidianLiveSyncSettingTab.titleFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.titleFetchSettings": string; - "obsidianLiveSyncSettingTab.titleHiddenFiles": string; - "obsidianLiveSyncSettingTab.titleLogging": string; - "obsidianLiveSyncSettingTab.titleMinioS3R2": string; - "obsidianLiveSyncSettingTab.titleNotification": string; - "obsidianLiveSyncSettingTab.titleOnlineTips": string; - "obsidianLiveSyncSettingTab.titleQuickSetup": string; - "obsidianLiveSyncSettingTab.titleRebuildRequired": string; - "obsidianLiveSyncSettingTab.titleRemoteConfigCheckFailed": string; - "obsidianLiveSyncSettingTab.titleRemoteServer": string; - "obsidianLiveSyncSettingTab.titleReset": string; - "obsidianLiveSyncSettingTab.titleSetupOtherDevices": string; - "obsidianLiveSyncSettingTab.titleSynchronizationMethod": string; - "obsidianLiveSyncSettingTab.titleSynchronizationPreset": string; - "obsidianLiveSyncSettingTab.titleSyncSettings": string; - "obsidianLiveSyncSettingTab.titleSyncSettingsViaMarkdown": string; - "obsidianLiveSyncSettingTab.titleUpdateThinning": string; - "obsidianLiveSyncSettingTab.warnCorsOriginUnmatched": string; - "obsidianLiveSyncSettingTab.warnNoAdmin": string; - Ok: string; - "Old Algorithm": string; - "Older fallback (Slow, W/O WebAssembly)": string; - Open: string; - "Open the dialog": string; - Overwrite: string; - "Overwrite patterns": string; - "Overwrite remote": string; - "Overwrite remote with local DB and passphrase.": string; - "Overwrite Server Data with This Device's Files": string; - "paneMaintenance.markDeviceResolvedAfterBackup": string; - "paneMaintenance.remoteLockedAndDeviceNotAccepted": string; - "paneMaintenance.remoteLockedResolvedDevice": string; - "paneMaintenance.unlockDatabaseReady": string; - Passphrase: string; - "Passphrase of sensitive configuration items": string; - password: string; - Password: string; - "Paste a connection string": string; - "Paste the Setup URI generated from one of your active devices.": string; - "Path Obfuscation": string; - "Patterns to match files for overwriting instead of merging": string; - "Patterns to match files for syncing": string; - "Peer-to-Peer only": string; - "Peer-to-Peer Synchronisation": string; - "Per-file-saved customization sync": string; - Perform: string; - "Perform cleanup": string; - "Perform Garbage Collection": string; - "Perform Garbage Collection to remove unused chunks and reduce database size.": string; - "Periodic Sync interval": string; - "Pick a file to resolve conflict": string; - "Please select a method to import the settings from another device.": string; - "Please select an option to proceed": string; - "Please select the type of server to which you are connecting.": string; - "Please set device name to identify this device. This name should be unique among your devices. While not configured, we cannot enable this feature.": string; - "Please set this device name": string; - Presets: string; - "Proceed with Setup URI": string; - "Process small files in the foreground": string; - "PureJS fallback (Fast, W/O WebAssembly)": string; - "Purge all download/upload cache.": string; - "Purge all journal counter": string; - "Rebuild local and remote database with local files.": string; - "Rebuilding Operations (Remote Only)": string; - "Recreate all": string; - "Recreate missing chunks for all files": string; - "Reducing the frequency with which on-disk changes are reflected into the DB": string; - Region: string; - Remediation: string; - "Remediation Setting Changed": string; - "Remote Database Tweak (In sunset)": string; - "Remote Databases": string; - "Remote name": string; - "Remote server type": string; - "Remote Type": string; - Rename: string; - "Requires restart of Obsidian": string; - "Requires restart of Obsidian.": string; - Resend: string; - "Resend all chunks to the remote.": string; - Reset: string; - "Reset all": string; - "Reset all journal counter": string; - "Reset journal received history": string; - "Reset journal sent history": string; - "Reset received": string; - "Reset sent history": string; - "Reset Synchronisation information": string; - "Reset Synchronisation on This Device": string; - "Resolve All": string; - "Resolve all conflicted files": string; - "Resolve All conflicted files by the newer one": string; - "Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one.": string; - "Restart Now": string; - "Restore or reconstruct local database from remote.": string; - "S3/MinIO/R2 Object Storage": string; - "Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.": string; - "Saving will be performed forcefully after this number of seconds.": string; - "Scan a QR Code (Recommended for mobile)": string; - "Scan changes on customization sync": string; - "Scan customization automatically": string; - "Scan customization before replicating.": string; - "Scan customization every 1 minute.": string; - "Scan customization periodically": string; - "Scan for hidden files before replication": string; - "Scan hidden files periodically": string; - "Scan the QR code displayed on an active device using this device's camera.": string; - "Schedule and Restart": string; - "Scram!": string; - "Seconds, 0 to disable": string; - "Seconds. Saving to the local database will be delayed until this value after we stop typing or saving.": string; - "Secret Key": string; - "Select the database adapter to use.": string; - Send: string; - "Send chunks": string; - "Server URI": string; - "Setting.GenerateKeyPair.Desc": string; - "Setting.GenerateKeyPair.Title": string; - "Setup.> [!INFO]- The connected devices have been detected as follows:\n${devices}": string; - "Setup.All devices have the same progress value (${progress}). Your devices seem to be synchronised. And be able to proceed with Garbage Collection.": string; - "Setup.Cancel Garbage Collection": string; - "Setup.Compaction in progress on remote database...": string; - "Setup.Compaction on remote database completed successfully.": string; - "Setup.Compaction on remote database failed.": string; - "Setup.Compaction on remote database timed out.": string; - "Setup.Device": string; - "Setup.Failed to connect to remote for compaction.": string; - "Setup.Failed to connect to remote for compaction. ${reason}": string; - "Setup.Failed to start one-shot replication before Garbage Collection. Garbage Collection Cancelled.": string; - "Setup.Failed to start replication after Garbage Collection.": string; - "Setup.Garbage Collection cancelled by user.": string; - "Setup.Garbage Collection completed. Deleted chunks: ${deletedChunks} / ${totalChunks}. Time taken: ${seconds} seconds.": string; - "Setup.Garbage Collection Confirmation": string; - "Setup.Garbage Collection: Found ${unusedChunks} unused chunks to delete.": string; - "Setup.Garbage Collection: Scanned ${scanned} / ~${docCount}": string; - "Setup.Garbage Collection: Scanning completed. Total chunks: ${totalChunks}, Used chunks: ${usedChunks}": string; - "Setup.Ignore and Proceed": string; - "Setup.No connected device information found. Cancelling Garbage Collection.": string; - "Setup.Node ID": string; - "Setup.Node Information Missing": string; - "Setup.Obsidian version": string; - "Setup.optionNoSetupUri": string; - "Setup.optionRemindNextLaunch": string; - "Setup.optionSetupWizard": string; - "Setup.optionYesFetchAgain": string; - "Setup.Please disable 'Read chunks online' in settings to use Garbage Collection.": string; - "Setup.Please enable 'Compute revisions for chunks' in settings to use Garbage Collection.": string; - "Setup.Please select 'Cancel' explicitly to cancel this operation.": string; - "Setup.Plug-in version": string; - "Setup.Proceed Garbage Collection": string; - "Setup.Proceeding with Garbage Collection, ignoring missing nodes.": string; - "Setup.Proceeding with Garbage Collection.": string; - "Setup.Progress": string; - "Setup.RemoteE2EE.AdvancedTitle": string; - "Setup.RemoteE2EE.AlgorithmWarning": string; - "Setup.RemoteE2EE.ButtonCancel": string; - "Setup.RemoteE2EE.ButtonProceed": string; - "Setup.RemoteE2EE.DefaultAlgorithmDesc": string; - "Setup.RemoteE2EE.Guidance": string; - "Setup.RemoteE2EE.LabelEncrypt": string; - "Setup.RemoteE2EE.LabelEncryptionAlgorithm": string; - "Setup.RemoteE2EE.LabelObfuscateProperties": string; - "Setup.RemoteE2EE.MultiDestinationWarning": string; - "Setup.RemoteE2EE.ObfuscatePropertiesDesc": string; - "Setup.RemoteE2EE.PassphraseValidationLine1": string; - "Setup.RemoteE2EE.PassphraseValidationLine2": string; - "Setup.RemoteE2EE.PlaceholderPassphrase": string; - "Setup.RemoteE2EE.StronglyRecommendedLine1": string; - "Setup.RemoteE2EE.StronglyRecommendedLine2": string; - "Setup.RemoteE2EE.StronglyRecommendedTitle": string; - "Setup.RemoteE2EE.Title": string; - "Setup.ScanQRCode.ButtonClose": string; - "Setup.ScanQRCode.Guidance": string; - "Setup.ScanQRCode.Step1": string; - "Setup.ScanQRCode.Step2": string; - "Setup.ScanQRCode.Step3": string; - "Setup.ScanQRCode.Step4": string; - "Setup.ScanQRCode.Title": string; - "Setup.Setup URI dialog cancelled.": string; - "Setup.Some devices have differing progress values (max: ${maxProgress}, min: ${minProgress}).\nThis may indicate that some devices have not completed synchronisation, which could lead to conflicts. Strongly recommend confirming that all devices are synchronised before proceeding.": string; - "Setup.The following accepted nodes are missing its node information:\n- ${missingNodes}\n\nThis indicates that they have not been connected for some time or have been left on an older version.\nIt is preferable to update all devices if possible. If you have any devices that are no longer in use, you can clear all accepted nodes by locking the remote once.": string; - "Setup.titleCaseSensitivity": string; - "Setup.titleRecommendSetupUri": string; - "Setup.titleWelcome": string; - "Setup.UseSetupURI.ButtonCancel": string; - "Setup.UseSetupURI.ButtonProceed": string; - "Setup.UseSetupURI.ErrorFailedToParse": string; - "Setup.UseSetupURI.ErrorPassphraseRequired": string; - "Setup.UseSetupURI.GuidanceLine1": string; - "Setup.UseSetupURI.GuidanceLine2": string; - "Setup.UseSetupURI.InvalidInfo": string; - "Setup.UseSetupURI.LabelPassphrase": string; - "Setup.UseSetupURI.LabelSetupURI": string; - "Setup.UseSetupURI.PlaceholderPassphrase": string; - "Setup.UseSetupURI.Title": string; - "Setup.UseSetupURI.ValidInfo": string; - "Should we keep folders that don't have any files inside?": string; - "Should we only check for conflicts when a file is opened?": string; - "Should we prompt you about conflicting files when a file is opened?": string; - "Should we prompt you for every single merge, even if we can safely merge automatcially?": string; - "Show full banner": string; - "Show only notifications": string; - "Show status as icons only": string; - "Show status icon instead of file warnings banner": string; - "Show status inside the editor": string; - "Show status on the status bar": string; - "Show verbose log. Please enable if you report an issue.": string; - "Starts synchronisation when a file is saved.": string; - "Stop reflecting database changes to storage files.": string; - "Stop watching for file changes.": string; - "Suppress notification of hidden files change": string; - "Suspend database reflecting": string; - "Suspend file watching": string; - "Switch to IDB": string; - "Switch to IndexedDB": string; - "Sync after merging file": string; - "Sync automatically after merging files": string; - "Sync Mode": string; - "Sync on Editor Save": string; - "Sync on File Open": string; - "Sync on Save": string; - "Sync on Startup": string; - "Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage.": string; - "Synchronising files": string; - Syncing: string; - "Target patterns": string; - "Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.": string; - "The delay for consecutive on-demand fetches": string; - "The Hash algorithm for chunk IDs": string; - "The maximum duration for which chunks can be incubated within the document. Chunks exceeding this period will graduate to independent chunks.": string; - "The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks.": string; - "The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks.": string; - "This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.": string; - "This is an advanced option for users who do not have a URI or who wish to configure detailed settings.": string; - "This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.": string; - "This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.": string; - "This will recreate chunks for all files. If there were missing chunks, this may fix the errors.": string; - "Transfer Tweak": string; - "Unique name between all synchronized devices. To edit this setting, please disable customization sync once.": string; - "Use a custom passphrase": string; - "Use a Setup URI (Recommended)": string; - "Use Custom HTTP Handler": string; - "Use dynamic iteration count": string; - "Use Segmented-splitter": string; - "Use splitting-limit-capped chunk splitter": string; - "Use the trash bin": string; - "Use timeouts instead of heartbeats": string; - username: string; - Username: string; - "Verbose Log": string; - "Verify all": string; - "Verify and repair all files": string; - "Warning! This will have a serious impact on performance. And the logs will not be synchronised under the default name. Please be careful with logs; they often contain your confidential information.": string; - "We cannot change the device name while this feature is enabled. Please disable this feature to change the device name.": string; - "We will now guide you through a few questions to simplify the synchronisation setup.": string; - "We will now proceed with the server configuration.": string; - "Welcome to Self-hosted LiveSync": string; - "When you save a file in the editor, start a sync automatically": string; - "Write credentials in the file": string; - "Write logs into the file": string; - "xxhash32 (Fast but less collision resistance)": string; - "xxhash64 (Fastest)": string; - "Yes, I want to add this device to my existing synchronisation": string; - "Yes, I want to set up a new synchronisation": string; - "You are adding this device to an existing synchronisation setup.": string; - }; -}; diff --git a/_types/src/lib/src/common/messages/fr.d.ts b/_types/src/lib/src/common/messages/fr.d.ts deleted file mode 100644 index 7444bba5..00000000 --- a/_types/src/lib/src/common/messages/fr.d.ts +++ /dev/null @@ -1,584 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare const PartialMessages: { - readonly fr: { - "(BETA) Always overwrite with a newer file": string; - "(Beta) Use ignore files": string; - "(Days passed, 0 to disable automatic-deletion)": string; - "(ex. Read chunks online) If this option is enabled, LiveSync reads chunks online directly instead of replicating them locally. Increasing Custom chunk size is recommended.": string; - "(MB) If this is set, changes to local and remote files that are larger than this will be skipped. If the file becomes smaller again, a newer one will be used.": string; - "(Mega chars)": string; - "(Not recommended) If set, credentials will be stored in the file.": string; - "(Obsolete) Use an old adapter for compatibility": string; - "Access Key": string; - "Active Remote Configuration": string; - "Always prompt merge conflicts": string; - Analyse: string; - "Analyse database usage": string; - "Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like.": string; - "Apply Latest Change if Conflicting": string; - "Apply preset configuration": string; - "Automatically Sync all files when opening Obsidian.": string; - "Batch database update": string; - "Batch limit": string; - "Batch size": string; - "Batch size of on-demand fetching": string; - "Before v0.17.16, we used an old adapter for the local database. Now the new adapter is preferred. However, it needs local database rebuilding. Please disable this toggle when you have enough time. If leave it enabled, also while fetching from the remote database, you will be asked to disable this.": string; - "Bucket Name": string; - Check: string; - "cmdConfigSync.showCustomizationSync": string; - "Comma separated `.gitignore, .dockerignore`": string; - "Compute revisions for chunks": string; - "Copy Report to clipboard": string; - "Data Compression": string; - "Database Name": string; - "Database suffix": string; - "Delay conflict resolution of inactive files": string; - "Delay merge conflict prompt for inactive files.": string; - "Delete old metadata of deleted files on start-up": string; - "Device name": string; - "dialog.yourLanguageAvailable": string; - "dialog.yourLanguageAvailable.btnRevertToDefault": string; - "dialog.yourLanguageAvailable.Title": string; - "Disables logging, only shows notifications. Please disable if you report an issue.": string; - "Display Language": string; - "Do not check configuration mismatch before replication": string; - "Do not keep metadata of deleted files.": string; - "Do not split chunks in the background": string; - "Do not use internal API": string; - "Doctor.Button.DismissThisVersion": string; - "Doctor.Button.Fix": string; - "Doctor.Button.FixButNoRebuild": string; - "Doctor.Button.No": string; - "Doctor.Button.Skip": string; - "Doctor.Button.Yes": string; - "Doctor.Dialogue.Main": string; - "Doctor.Dialogue.MainFix": string; - "Doctor.Dialogue.Title": string; - "Doctor.Dialogue.TitleAlmostDone": string; - "Doctor.Dialogue.TitleFix": string; - "Doctor.Level.Must": string; - "Doctor.Level.Necessary": string; - "Doctor.Level.Optional": string; - "Doctor.Level.Recommended": string; - "Doctor.Message.NoIssues": string; - "Doctor.Message.RebuildLocalRequired": string; - "Doctor.Message.RebuildRequired": string; - "Doctor.Message.SomeSkipped": string; - "Doctor.RULES.E2EE_V02500.REASON": string; - "Enable advanced features": string; - "Enable customization sync": string; - "Enable Developers' Debug Tools.": string; - "Enable edge case treatment features": string; - "Enable poweruser features": string; - "Enable this if your Object Storage doesn't support CORS": string; - "Enable this option to automatically apply the most recent change to documents even when it conflicts": string; - "Encrypt contents on the remote database. If you use the plugin's synchronization feature, enabling this is recommended.": string; - "Encrypting sensitive configuration items": string; - "Encryption phassphrase. If changed, you should overwrite the server's database with the new (encrypted) files.": string; - "End-to-End Encryption": string; - "Endpoint URL": string; - "Enhance chunk size": string; - "Fetch chunks on demand": string; - "Fetch database with previous behaviour": string; - Filename: string; - "Forces the file to be synced when opened.": string; - "Handle files as Case-Sensitive": string; - "If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).": string; - "If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.": string; - "If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker.": string; - "If enabled, newly created chunks are temporarily kept within the document, and graduated to become independent chunks once stabilised.": string; - "If enabled, the \u26D4 icon will be shown inside the status instead of the file warnings banner. No details will be shown.": string; - "If enabled, the file under 1kb will be processed in the UI thread.": string; - "If enabled, the notification of hidden files change will be suppressed.": string; - "If this enabled, all chunks will be stored with the revision made from its content. (Previous behaviour)": string; - "If this enabled, All files are handled as case-Sensitive (Previous behaviour).": string; - "If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature.": string; - "If this is set, changes to local files which are matched by the ignore files will be skipped. Remote changes are determined using local ignore files.": string; - "If this option is enabled, PouchDB will hold the connection open for 60 seconds, and if no change arrives in that time, close and reopen the socket, instead of holding it open indefinitely. Useful when a proxy limits request duration but can increase resource usage.": string; - "Ignore files": string; - "Incubate Chunks in Document": string; - "Interval (sec)": string; - "K.exp": string; - "K.long_p2p_sync": string; - "K.P2P": string; - "K.Peer": string; - "K.ScanCustomization": string; - "K.short_p2p_sync": string; - "K.title_p2p_sync": string; - "Keep empty folder": string; - lang_def: string; - "lang-de": string; - "lang-def": string; - "lang-es": string; - "lang-fr": string; - "lang-ja": string; - "lang-ko": string; - "lang-ru": string; - "lang-zh": string; - "lang-zh-tw": string; - "LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured.": string; - "liveSyncReplicator.beforeLiveSync": string; - "liveSyncReplicator.cantReplicateLowerValue": string; - "liveSyncReplicator.checkingLastSyncPoint": string; - "liveSyncReplicator.couldNotConnectTo": string; - "liveSyncReplicator.couldNotConnectToRemoteDb": string; - "liveSyncReplicator.couldNotConnectToServer": string; - "liveSyncReplicator.couldNotConnectToURI": string; - "liveSyncReplicator.couldNotMarkResolveRemoteDb": string; - "liveSyncReplicator.liveSyncBegin": string; - "liveSyncReplicator.lockRemoteDb": string; - "liveSyncReplicator.markDeviceResolved": string; - "liveSyncReplicator.oneShotSyncBegin": string; - "liveSyncReplicator.remoteDbCorrupted": string; - "liveSyncReplicator.remoteDbCreatedOrConnected": string; - "liveSyncReplicator.remoteDbDestroyed": string; - "liveSyncReplicator.remoteDbDestroyError": string; - "liveSyncReplicator.remoteDbMarkedResolved": string; - "liveSyncReplicator.replicationClosed": string; - "liveSyncReplicator.replicationInProgress": string; - "liveSyncReplicator.retryLowerBatchSize": string; - "liveSyncReplicator.unlockRemoteDb": string; - "liveSyncSetting.errorNoSuchSettingItem": string; - "liveSyncSetting.originalValue": string; - "liveSyncSetting.valueShouldBeInRange": string; - "liveSyncSettings.btnApply": string; - "logPane.autoScroll": string; - "logPane.logWindowOpened": string; - "logPane.pause": string; - "logPane.title": string; - "logPane.wrap": string; - "Maximum delay for batch database updating": string; - "Maximum file size": string; - "Maximum Incubating Chunk Size": string; - "Maximum Incubating Chunks": string; - "Maximum Incubation Period": string; - "MB (0 to disable).": string; - "Memory cache size (by total characters)": string; - "Memory cache size (by total items)": string; - "Minimum delay for batch database updating": string; - "Minimum interval for syncing": string; - "moduleCheckRemoteSize.logCheckingStorageSizes": string; - "moduleCheckRemoteSize.logCurrentStorageSize": string; - "moduleCheckRemoteSize.logExceededWarning": string; - "moduleCheckRemoteSize.logThresholdEnlarged": string; - "moduleCheckRemoteSize.msgConfirmRebuild": string; - "moduleCheckRemoteSize.msgDatabaseGrowing": string; - "moduleCheckRemoteSize.msgSetDBCapacity": string; - "moduleCheckRemoteSize.option2GB": string; - "moduleCheckRemoteSize.option800MB": string; - "moduleCheckRemoteSize.optionAskMeLater": string; - "moduleCheckRemoteSize.optionDismiss": string; - "moduleCheckRemoteSize.optionIncreaseLimit": string; - "moduleCheckRemoteSize.optionNoWarn": string; - "moduleCheckRemoteSize.optionRebuildAll": string; - "moduleCheckRemoteSize.titleDatabaseSizeLimitExceeded": string; - "moduleCheckRemoteSize.titleDatabaseSizeNotify": string; - "moduleInputUIObsidian.defaultTitleConfirmation": string; - "moduleInputUIObsidian.defaultTitleSelect": string; - "moduleInputUIObsidian.optionNo": string; - "moduleInputUIObsidian.optionYes": string; - "moduleLiveSyncMain.logAdditionalSafetyScan": string; - "moduleLiveSyncMain.logLoadingPlugin": string; - "moduleLiveSyncMain.logPluginInitCancelled": string; - "moduleLiveSyncMain.logPluginVersion": string; - "moduleLiveSyncMain.logReadChangelog": string; - "moduleLiveSyncMain.logSafetyScanCompleted": string; - "moduleLiveSyncMain.logSafetyScanFailed": string; - "moduleLiveSyncMain.logUnloadingPlugin": string; - "moduleLiveSyncMain.logVersionUpdate": string; - "moduleLiveSyncMain.msgScramEnabled": string; - "moduleLiveSyncMain.optionKeepLiveSyncDisabled": string; - "moduleLiveSyncMain.optionResumeAndRestart": string; - "moduleLiveSyncMain.titleScramEnabled": string; - "moduleLocalDatabase.logWaitingForReady": string; - "moduleLog.showLog": string; - "moduleMigration.docUri": string; - "moduleMigration.fix0256.buttons.checkItLater": string; - "moduleMigration.fix0256.buttons.DismissForever": string; - "moduleMigration.fix0256.buttons.fix": string; - "moduleMigration.fix0256.message": string; - "moduleMigration.fix0256.messageUnrecoverable": string; - "moduleMigration.fix0256.title": string; - "moduleMigration.insecureChunkExist.buttons.fetch": string; - "moduleMigration.insecureChunkExist.buttons.later": string; - "moduleMigration.insecureChunkExist.buttons.rebuild": string; - "moduleMigration.insecureChunkExist.laterMessage": string; - "moduleMigration.insecureChunkExist.message": string; - "moduleMigration.insecureChunkExist.title": string; - "moduleMigration.logBulkSendCorrupted": string; - "moduleMigration.logFetchRemoteTweakFailed": string; - "moduleMigration.logLocalDatabaseNotReady": string; - "moduleMigration.logMigratedSameBehaviour": string; - "moduleMigration.logMigrationFailed": string; - "moduleMigration.logRedflag2CreationFail": string; - "moduleMigration.logRemoteTweakUnavailable": string; - "moduleMigration.logSetupCancelled": string; - "moduleMigration.msgFetchRemoteAgain": string; - "moduleMigration.msgInitialSetup": string; - "moduleMigration.msgRecommendSetupUri": string; - "moduleMigration.msgSinceV02321": string; - "moduleMigration.optionAdjustRemote": string; - "moduleMigration.optionDecideLater": string; - "moduleMigration.optionEnableBoth": string; - "moduleMigration.optionEnableFilenameCaseInsensitive": string; - "moduleMigration.optionEnableFixedRevisionForChunks": string; - "moduleMigration.optionHaveSetupUri": string; - "moduleMigration.optionKeepPreviousBehaviour": string; - "moduleMigration.optionManualSetup": string; - "moduleMigration.optionNoAskAgain": string; - "moduleMigration.optionNoSetupUri": string; - "moduleMigration.optionRemindNextLaunch": string; - "moduleMigration.optionSetupViaP2P": string; - "moduleMigration.optionSetupWizard": string; - "moduleMigration.optionYesFetchAgain": string; - "moduleMigration.titleCaseSensitivity": string; - "moduleMigration.titleRecommendSetupUri": string; - "moduleMigration.titleWelcome": string; - "moduleObsidianMenu.replicate": string; - "Move remotely deleted files to the trash, instead of deleting.": string; - "Not all messages have been translated. And, please revert to \"Default\" when reporting errors.": string; - "Notify all setting files": string; - "Notify customized": string; - "Notify when other device has newly customized.": string; - "Notify when the estimated remote storage size exceeds on start up": string; - "Number of batches to process at a time. Defaults to 40. Minimum is 2. This along with batch size controls how many docs are kept in memory at a time.": string; - "Number of changes to sync at a time. Defaults to 50. Minimum is 2.": string; - "obsidianLiveSyncSettingTab.btnApply": string; - "obsidianLiveSyncSettingTab.btnCheck": string; - "obsidianLiveSyncSettingTab.btnCopy": string; - "obsidianLiveSyncSettingTab.btnDisable": string; - "obsidianLiveSyncSettingTab.btnDiscard": string; - "obsidianLiveSyncSettingTab.btnEnable": string; - "obsidianLiveSyncSettingTab.btnFix": string; - "obsidianLiveSyncSettingTab.btnGotItAndUpdated": string; - "obsidianLiveSyncSettingTab.btnNext": string; - "obsidianLiveSyncSettingTab.btnStart": string; - "obsidianLiveSyncSettingTab.btnTest": string; - "obsidianLiveSyncSettingTab.btnUse": string; - "obsidianLiveSyncSettingTab.buttonFetch": string; - "obsidianLiveSyncSettingTab.buttonNext": string; - "obsidianLiveSyncSettingTab.defaultLanguage": string; - "obsidianLiveSyncSettingTab.descConnectSetupURI": string; - "obsidianLiveSyncSettingTab.descCopySetupURI": string; - "obsidianLiveSyncSettingTab.descEnableLiveSync": string; - "obsidianLiveSyncSettingTab.descFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.descManualSetup": string; - "obsidianLiveSyncSettingTab.descTestDatabaseConnection": string; - "obsidianLiveSyncSettingTab.descValidateDatabaseConfig": string; - "obsidianLiveSyncSettingTab.errAccessForbidden": string; - "obsidianLiveSyncSettingTab.errCannotContinueTest": string; - "obsidianLiveSyncSettingTab.errCorsCredentials": string; - "obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials": string; - "obsidianLiveSyncSettingTab.errCorsOrigins": string; - "obsidianLiveSyncSettingTab.errEnableCors": string; - "obsidianLiveSyncSettingTab.errEnableCorsChttpd": string; - "obsidianLiveSyncSettingTab.errMaxDocumentSize": string; - "obsidianLiveSyncSettingTab.errMaxRequestSize": string; - "obsidianLiveSyncSettingTab.errMissingWwwAuth": string; - "obsidianLiveSyncSettingTab.errRequireValidUser": string; - "obsidianLiveSyncSettingTab.errRequireValidUserAuth": string; - "obsidianLiveSyncSettingTab.labelDisabled": string; - "obsidianLiveSyncSettingTab.labelEnabled": string; - "obsidianLiveSyncSettingTab.levelAdvanced": string; - "obsidianLiveSyncSettingTab.levelEdgeCase": string; - "obsidianLiveSyncSettingTab.levelPowerUser": string; - "obsidianLiveSyncSettingTab.linkOpenInBrowser": string; - "obsidianLiveSyncSettingTab.linkPageTop": string; - "obsidianLiveSyncSettingTab.linkTipsAndTroubleshooting": string; - "obsidianLiveSyncSettingTab.linkTroubleshooting": string; - "obsidianLiveSyncSettingTab.logCannotUseCloudant": string; - "obsidianLiveSyncSettingTab.logCheckingConfigDone": string; - "obsidianLiveSyncSettingTab.logCheckingConfigFailed": string; - "obsidianLiveSyncSettingTab.logCheckingDbConfig": string; - "obsidianLiveSyncSettingTab.logCheckPassphraseFailed": string; - "obsidianLiveSyncSettingTab.logConfiguredDisabled": string; - "obsidianLiveSyncSettingTab.logConfiguredLiveSync": string; - "obsidianLiveSyncSettingTab.logConfiguredPeriodic": string; - "obsidianLiveSyncSettingTab.logCouchDbConfigFail": string; - "obsidianLiveSyncSettingTab.logCouchDbConfigSet": string; - "obsidianLiveSyncSettingTab.logCouchDbConfigUpdated": string; - "obsidianLiveSyncSettingTab.logDatabaseConnected": string; - "obsidianLiveSyncSettingTab.logEncryptionNoPassphrase": string; - "obsidianLiveSyncSettingTab.logEncryptionNoSupport": string; - "obsidianLiveSyncSettingTab.logErrorOccurred": string; - "obsidianLiveSyncSettingTab.logEstimatedSize": string; - "obsidianLiveSyncSettingTab.logPassphraseInvalid": string; - "obsidianLiveSyncSettingTab.logPassphraseNotCompatible": string; - "obsidianLiveSyncSettingTab.logRebuildNote": string; - "obsidianLiveSyncSettingTab.logSelectAnyPreset": string; - "obsidianLiveSyncSettingTab.msgAreYouSureProceed": string; - "obsidianLiveSyncSettingTab.msgChangesNeedToBeApplied": string; - "obsidianLiveSyncSettingTab.msgConfigCheck": string; - "obsidianLiveSyncSettingTab.msgConfigCheckFailed": string; - "obsidianLiveSyncSettingTab.msgConnectionCheck": string; - "obsidianLiveSyncSettingTab.msgConnectionProxyNote": string; - "obsidianLiveSyncSettingTab.msgCurrentOrigin": string; - "obsidianLiveSyncSettingTab.msgDiscardConfirmation": string; - "obsidianLiveSyncSettingTab.msgDone": string; - "obsidianLiveSyncSettingTab.msgEnableCors": string; - "obsidianLiveSyncSettingTab.msgEnableCorsChttpd": string; - "obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": string; - "obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.msgGenerateSetupURI": string; - "obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": string; - "obsidianLiveSyncSettingTab.msgInvalidPassphrase": string; - "obsidianLiveSyncSettingTab.msgNewVersionNote": string; - "obsidianLiveSyncSettingTab.msgNonHTTPSInfo": string; - "obsidianLiveSyncSettingTab.msgNonHTTPSWarning": string; - "obsidianLiveSyncSettingTab.msgNotice": string; - "obsidianLiveSyncSettingTab.msgObjectStorageWarning": string; - "obsidianLiveSyncSettingTab.msgOriginCheck": string; - "obsidianLiveSyncSettingTab.msgRebuildRequired": string; - "obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": string; - "obsidianLiveSyncSettingTab.msgSetCorsCredentials": string; - "obsidianLiveSyncSettingTab.msgSetCorsOrigins": string; - "obsidianLiveSyncSettingTab.msgSetMaxDocSize": string; - "obsidianLiveSyncSettingTab.msgSetMaxRequestSize": string; - "obsidianLiveSyncSettingTab.msgSetRequireValidUser": string; - "obsidianLiveSyncSettingTab.msgSetRequireValidUserAuth": string; - "obsidianLiveSyncSettingTab.msgSettingModified": string; - "obsidianLiveSyncSettingTab.msgSettingsUnchangeableDuringSync": string; - "obsidianLiveSyncSettingTab.msgSetWwwAuth": string; - "obsidianLiveSyncSettingTab.nameApplySettings": string; - "obsidianLiveSyncSettingTab.nameConnectSetupURI": string; - "obsidianLiveSyncSettingTab.nameCopySetupURI": string; - "obsidianLiveSyncSettingTab.nameDisableHiddenFileSync": string; - "obsidianLiveSyncSettingTab.nameDiscardSettings": string; - "obsidianLiveSyncSettingTab.nameEnableHiddenFileSync": string; - "obsidianLiveSyncSettingTab.nameEnableLiveSync": string; - "obsidianLiveSyncSettingTab.nameHiddenFileSynchronization": string; - "obsidianLiveSyncSettingTab.nameManualSetup": string; - "obsidianLiveSyncSettingTab.nameTestConnection": string; - "obsidianLiveSyncSettingTab.nameTestDatabaseConnection": string; - "obsidianLiveSyncSettingTab.nameValidateDatabaseConfig": string; - "obsidianLiveSyncSettingTab.okAdminPrivileges": string; - "obsidianLiveSyncSettingTab.okCorsCredentials": string; - "obsidianLiveSyncSettingTab.okCorsCredentialsForOrigin": string; - "obsidianLiveSyncSettingTab.okCorsOriginMatched": string; - "obsidianLiveSyncSettingTab.okCorsOrigins": string; - "obsidianLiveSyncSettingTab.okEnableCors": string; - "obsidianLiveSyncSettingTab.okEnableCorsChttpd": string; - "obsidianLiveSyncSettingTab.okMaxDocumentSize": string; - "obsidianLiveSyncSettingTab.okMaxRequestSize": string; - "obsidianLiveSyncSettingTab.okRequireValidUser": string; - "obsidianLiveSyncSettingTab.okRequireValidUserAuth": string; - "obsidianLiveSyncSettingTab.okWwwAuth": string; - "obsidianLiveSyncSettingTab.optionApply": string; - "obsidianLiveSyncSettingTab.optionCancel": string; - "obsidianLiveSyncSettingTab.optionCouchDB": string; - "obsidianLiveSyncSettingTab.optionDisableAllAutomatic": string; - "obsidianLiveSyncSettingTab.optionFetchFromRemote": string; - "obsidianLiveSyncSettingTab.optionHere": string; - "obsidianLiveSyncSettingTab.optionLiveSync": string; - "obsidianLiveSyncSettingTab.optionMinioS3R2": string; - "obsidianLiveSyncSettingTab.optionOkReadEverything": string; - "obsidianLiveSyncSettingTab.optionOnEvents": string; - "obsidianLiveSyncSettingTab.optionPeriodicAndEvents": string; - "obsidianLiveSyncSettingTab.optionPeriodicWithBatch": string; - "obsidianLiveSyncSettingTab.optionRebuildBoth": string; - "obsidianLiveSyncSettingTab.optionSaveOnlySettings": string; - "obsidianLiveSyncSettingTab.panelChangeLog": string; - "obsidianLiveSyncSettingTab.panelGeneralSettings": string; - "obsidianLiveSyncSettingTab.panelPrivacyEncryption": string; - "obsidianLiveSyncSettingTab.panelRemoteConfiguration": string; - "obsidianLiveSyncSettingTab.panelSetup": string; - "obsidianLiveSyncSettingTab.serverVersion": string; - "obsidianLiveSyncSettingTab.titleActiveRemoteServer": string; - "obsidianLiveSyncSettingTab.titleAppearance": string; - "obsidianLiveSyncSettingTab.titleConflictResolution": string; - "obsidianLiveSyncSettingTab.titleCongratulations": string; - "obsidianLiveSyncSettingTab.titleCouchDB": string; - "obsidianLiveSyncSettingTab.titleDeletionPropagation": string; - "obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": string; - "obsidianLiveSyncSettingTab.titleEncryptionPassphraseInvalid": string; - "obsidianLiveSyncSettingTab.titleExtraFeatures": string; - "obsidianLiveSyncSettingTab.titleFetchConfig": string; - "obsidianLiveSyncSettingTab.titleFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.titleFetchSettings": string; - "obsidianLiveSyncSettingTab.titleHiddenFiles": string; - "obsidianLiveSyncSettingTab.titleLogging": string; - "obsidianLiveSyncSettingTab.titleMinioS3R2": string; - "obsidianLiveSyncSettingTab.titleNotification": string; - "obsidianLiveSyncSettingTab.titleOnlineTips": string; - "obsidianLiveSyncSettingTab.titleQuickSetup": string; - "obsidianLiveSyncSettingTab.titleRebuildRequired": string; - "obsidianLiveSyncSettingTab.titleRemoteConfigCheckFailed": string; - "obsidianLiveSyncSettingTab.titleRemoteServer": string; - "obsidianLiveSyncSettingTab.titleReset": string; - "obsidianLiveSyncSettingTab.titleSetupOtherDevices": string; - "obsidianLiveSyncSettingTab.titleSynchronizationMethod": string; - "obsidianLiveSyncSettingTab.titleSynchronizationPreset": string; - "obsidianLiveSyncSettingTab.titleSyncSettings": string; - "obsidianLiveSyncSettingTab.titleSyncSettingsViaMarkdown": string; - "obsidianLiveSyncSettingTab.titleUpdateThinning": string; - "obsidianLiveSyncSettingTab.warnCorsOriginUnmatched": string; - "obsidianLiveSyncSettingTab.warnNoAdmin": string; - "P2P.AskPassphraseForDecrypt": string; - "P2P.AskPassphraseForShare": string; - "P2P.DisabledButNeed": string; - "P2P.FailedToOpen": string; - "P2P.NoAutoSyncPeers": string; - "P2P.NoKnownPeers": string; - "P2P.Note.description": string; - "P2P.Note.important_note": string; - "P2P.Note.important_note_sub": string; - "P2P.Note.Summary": string; - "P2P.NotEnabled": string; - "P2P.P2PReplication": string; - "P2P.PaneTitle": string; - "P2P.ReplicatorInstanceMissing": string; - "P2P.SeemsOffline": string; - "P2P.SyncAlreadyRunning": string; - "P2P.SyncCompleted": string; - "P2P.SyncStartedWith": string; - Passphrase: string; - "Passphrase of sensitive configuration items": string; - password: string; - Password: string; - "Path Obfuscation": string; - "Per-file-saved customization sync": string; - "Periodic Sync interval": string; - "Prepare the 'report' to create an issue": string; - Presets: string; - "Process small files in the foreground": string; - "Property Encryption": string; - "RedFlag.Fetch.Method.Desc": string; - "RedFlag.Fetch.Method.FetchSafer": string; - "RedFlag.Fetch.Method.FetchSmoother": string; - "RedFlag.Fetch.Method.FetchTraditional": string; - "RedFlag.Fetch.Method.Title": string; - "RedFlag.FetchRemoteConfig.Buttons.Cancel": string; - "RedFlag.FetchRemoteConfig.Buttons.Fetch": string; - "RedFlag.FetchRemoteConfig.Message": string; - "RedFlag.FetchRemoteConfig.Title": string; - "Reducing the frequency with which on-disk changes are reflected into the DB": string; - Region: string; - "Remote server type": string; - "Remote Type": string; - "Replicator.Dialogue.Locked.Action.Dismiss": string; - "Replicator.Dialogue.Locked.Action.Fetch": string; - "Replicator.Dialogue.Locked.Action.Unlock": string; - "Replicator.Dialogue.Locked.Message": string; - "Replicator.Dialogue.Locked.Message.Fetch": string; - "Replicator.Dialogue.Locked.Message.Unlocked": string; - "Replicator.Dialogue.Locked.Title": string; - "Replicator.Message.Cleaned": string; - "Replicator.Message.InitialiseFatalError": string; - "Replicator.Message.Pending": string; - "Replicator.Message.SomeModuleFailed": string; - "Replicator.Message.VersionUpFlash": string; - "Requires restart of Obsidian": string; - "Requires restart of Obsidian.": string; - "Rerun Onboarding Wizard": string; - "Rerun the onboarding wizard to set up Self-hosted LiveSync again.": string; - "Rerun Wizard": string; - "Reset notification threshold and check the remote database usage": string; - "Reset the remote storage size threshold and check the remote storage size again.": string; - "Run Doctor": string; - "Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.": string; - "Saving will be performed forcefully after this number of seconds.": string; - "Scan changes on customization sync": string; - "Scan customization automatically": string; - "Scan customization before replicating.": string; - "Scan customization every 1 minute.": string; - "Scan customization periodically": string; - "Scan for hidden files before replication": string; - "Scan hidden files periodically": string; - "Seconds, 0 to disable": string; - "Seconds. Saving to the local database will be delayed until this value after we stop typing or saving.": string; - "Secret Key": string; - "Server URI": string; - "Setting.GenerateKeyPair.Desc": string; - "Setting.GenerateKeyPair.Title": string; - "Setting.TroubleShooting": string; - "Setting.TroubleShooting.Doctor": string; - "Setting.TroubleShooting.Doctor.Desc": string; - "Setting.TroubleShooting.ScanBrokenFiles": string; - "Setting.TroubleShooting.ScanBrokenFiles.Desc": string; - "SettingTab.Message.AskRebuild": string; - "Setup.Apply.Buttons.ApplyAndFetch": string; - "Setup.Apply.Buttons.ApplyAndMerge": string; - "Setup.Apply.Buttons.ApplyAndRebuild": string; - "Setup.Apply.Buttons.Cancel": string; - "Setup.Apply.Buttons.OnlyApply": string; - "Setup.Apply.Message": string; - "Setup.Apply.Title": string; - "Setup.Apply.WarningRebuildRecommended": string; - "Setup.Doctor.Buttons.No": string; - "Setup.Doctor.Buttons.Yes": string; - "Setup.Doctor.Message": string; - "Setup.Doctor.Title": string; - "Setup.FetchRemoteConf.Buttons.Fetch": string; - "Setup.FetchRemoteConf.Buttons.Skip": string; - "Setup.FetchRemoteConf.Message": string; - "Setup.FetchRemoteConf.Title": string; - "Setup.QRCode": string; - "Setup.ShowQRCode": string; - "Setup.ShowQRCode.Desc": string; - "Should we keep folders that don't have any files inside?": string; - "Should we only check for conflicts when a file is opened?": string; - "Should we prompt you about conflicting files when a file is opened?": string; - "Should we prompt you for every single merge, even if we can safely merge automatcially?": string; - "Show only notifications": string; - "Show status as icons only": string; - "Show status icon instead of file warnings banner": string; - "Show status inside the editor": string; - "Show status on the status bar": string; - "Show verbose log. Please enable if you report an issue.": string; - "Starts synchronisation when a file is saved.": string; - "Stop reflecting database changes to storage files.": string; - "Stop watching for file changes.": string; - "Suppress notification of hidden files change": string; - "Suspend database reflecting": string; - "Suspend file watching": string; - "Sync after merging file": string; - "Sync automatically after merging files": string; - "Sync Mode": string; - "Sync on Editor Save": string; - "Sync on File Open": string; - "Sync on Save": string; - "Sync on Startup": string; - "Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.": string; - "The delay for consecutive on-demand fetches": string; - "The Hash algorithm for chunk IDs": string; - "The maximum duration for which chunks can be incubated within the document. Chunks exceeding this period will graduate to independent chunks.": string; - "The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks.": string; - "The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks.": string; - "The minimum interval for automatic synchronisation on event.": string; - "This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.": string; - "TweakMismatchResolve.Action.Dismiss": string; - "TweakMismatchResolve.Action.UseConfigured": string; - "TweakMismatchResolve.Action.UseMine": string; - "TweakMismatchResolve.Action.UseMineAcceptIncompatible": string; - "TweakMismatchResolve.Action.UseMineWithRebuild": string; - "TweakMismatchResolve.Action.UseRemote": string; - "TweakMismatchResolve.Action.UseRemoteAcceptIncompatible": string; - "TweakMismatchResolve.Action.UseRemoteWithRebuild": string; - "TweakMismatchResolve.Message.Main": string; - "TweakMismatchResolve.Message.MainTweakResolving": string; - "TweakMismatchResolve.Message.UseRemote.WarningRebuildRecommended": string; - "TweakMismatchResolve.Message.UseRemote.WarningRebuildRequired": string; - "TweakMismatchResolve.Message.WarningIncompatibleRebuildRecommended": string; - "TweakMismatchResolve.Message.WarningIncompatibleRebuildRequired": string; - "TweakMismatchResolve.Table": string; - "TweakMismatchResolve.Table.Row": string; - "TweakMismatchResolve.Title": string; - "TweakMismatchResolve.Title.TweakResolving": string; - "TweakMismatchResolve.Title.UseRemoteConfig": string; - "Unique name between all synchronized devices. To edit this setting, please disable customization sync once.": string; - "Use Custom HTTP Handler": string; - "Use dynamic iteration count": string; - "Use Segmented-splitter": string; - "Use splitting-limit-capped chunk splitter": string; - "Use the trash bin": string; - "Use timeouts instead of heartbeats": string; - username: string; - Username: string; - "Verbose Log": string; - "Warning! This will have a serious impact on performance. And the logs will not be synchronised under the default name. Please be careful with logs; they often contain your confidential information.": string; - "When you save a file in the editor, start a sync automatically": string; - "Write credentials in the file": string; - "Write logs into the file": string; - }; -}; diff --git a/_types/src/lib/src/common/messages/he.d.ts b/_types/src/lib/src/common/messages/he.d.ts deleted file mode 100644 index cdd6cf1f..00000000 --- a/_types/src/lib/src/common/messages/he.d.ts +++ /dev/null @@ -1,585 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare const PartialMessages: { - readonly he: { - "(BETA) Always overwrite with a newer file": string; - "(Beta) Use ignore files": string; - "(Days passed, 0 to disable automatic-deletion)": string; - "(ex. Read chunks online) If this option is enabled, LiveSync reads chunks online directly instead of replicating them locally. Increasing Custom chunk size is recommended.": string; - "(MB) If this is set, changes to local and remote files that are larger than this will be skipped. If the file becomes smaller again, a newer one will be used.": string; - "(Mega chars)": string; - "(Not recommended) If set, credentials will be stored in the file.": string; - "(Obsolete) Use an old adapter for compatibility": string; - "Access Key": string; - "Active Remote Configuration": string; - "Always prompt merge conflicts": string; - Analyse: string; - "Analyse database usage": string; - "Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like.": string; - "Apply Latest Change if Conflicting": string; - "Apply preset configuration": string; - "Automatically Sync all files when opening Obsidian.": string; - "Batch database update": string; - "Batch limit": string; - "Batch size": string; - "Batch size of on-demand fetching": string; - "Before v0.17.16, we used an old adapter for the local database. Now the new adapter is preferred. However, it needs local database rebuilding. Please disable this toggle when you have enough time. If leave it enabled, also while fetching from the remote database, you will be asked to disable this.": string; - "Bucket Name": string; - Check: string; - "cmdConfigSync.showCustomizationSync": string; - "Comma separated `.gitignore, .dockerignore`": string; - "Compute revisions for chunks": string; - "Copy Report to clipboard": string; - "Data Compression": string; - "Database Name": string; - "Database suffix": string; - "Delay conflict resolution of inactive files": string; - "Delay merge conflict prompt for inactive files.": string; - "Delete old metadata of deleted files on start-up": string; - "Device name": string; - "dialog.yourLanguageAvailable": string; - "dialog.yourLanguageAvailable.btnRevertToDefault": string; - "dialog.yourLanguageAvailable.Title": string; - "Disables logging, only shows notifications. Please disable if you report an issue.": string; - "Display Language": string; - "Do not check configuration mismatch before replication": string; - "Do not keep metadata of deleted files.": string; - "Do not split chunks in the background": string; - "Do not use internal API": string; - "Doctor.Button.DismissThisVersion": string; - "Doctor.Button.Fix": string; - "Doctor.Button.FixButNoRebuild": string; - "Doctor.Button.No": string; - "Doctor.Button.Skip": string; - "Doctor.Button.Yes": string; - "Doctor.Dialogue.Main": string; - "Doctor.Dialogue.MainFix": string; - "Doctor.Dialogue.Title": string; - "Doctor.Dialogue.TitleAlmostDone": string; - "Doctor.Dialogue.TitleFix": string; - "Doctor.Level.Must": string; - "Doctor.Level.Necessary": string; - "Doctor.Level.Optional": string; - "Doctor.Level.Recommended": string; - "Doctor.Message.NoIssues": string; - "Doctor.Message.RebuildLocalRequired": string; - "Doctor.Message.RebuildRequired": string; - "Doctor.Message.SomeSkipped": string; - "Doctor.RULES.E2EE_V02500.REASON": string; - "Enable advanced features": string; - "Enable customization sync": string; - "Enable Developers' Debug Tools.": string; - "Enable edge case treatment features": string; - "Enable poweruser features": string; - "Enable this if your Object Storage doesn't support CORS": string; - "Enable this option to automatically apply the most recent change to documents even when it conflicts": string; - "Encrypt contents on the remote database. If you use the plugin's synchronization feature, enabling this is recommended.": string; - "Encrypting sensitive configuration items": string; - "Encryption phassphrase. If changed, you should overwrite the server's database with the new (encrypted) files.": string; - "End-to-End Encryption": string; - "Endpoint URL": string; - "Enhance chunk size": string; - "Fetch chunks on demand": string; - "Fetch database with previous behaviour": string; - Filename: string; - "Forces the file to be synced when opened.": string; - "Handle files as Case-Sensitive": string; - "If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).": string; - "If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.": string; - "If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker.": string; - "If enabled, newly created chunks are temporarily kept within the document, and graduated to become independent chunks once stabilised.": string; - "If enabled, the \u26D4 icon will be shown inside the status instead of the file warnings banner. No details will be shown.": string; - "If enabled, the file under 1kb will be processed in the UI thread.": string; - "If enabled, the notification of hidden files change will be suppressed.": string; - "If this enabled, all chunks will be stored with the revision made from its content. (Previous behaviour)": string; - "If this enabled, All files are handled as case-Sensitive (Previous behaviour).": string; - "If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature.": string; - "If this is set, changes to local files which are matched by the ignore files will be skipped. Remote changes are determined using local ignore files.": string; - "If this option is enabled, PouchDB will hold the connection open for 60 seconds, and if no change arrives in that time, close and reopen the socket, instead of holding it open indefinitely. Useful when a proxy limits request duration but can increase resource usage.": string; - "Ignore files": string; - "Incubate Chunks in Document": string; - "Interval (sec)": string; - "K.exp": string; - "K.long_p2p_sync": string; - "K.P2P": string; - "K.Peer": string; - "K.ScanCustomization": string; - "K.short_p2p_sync": string; - "K.title_p2p_sync": string; - "Keep empty folder": string; - lang_def: string; - "lang-de": string; - "lang-def": string; - "lang-es": string; - "lang-fr": string; - "lang-he": string; - "lang-ja": string; - "lang-ko": string; - "lang-ru": string; - "lang-zh": string; - "lang-zh-tw": string; - "LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured.": string; - "liveSyncReplicator.beforeLiveSync": string; - "liveSyncReplicator.cantReplicateLowerValue": string; - "liveSyncReplicator.checkingLastSyncPoint": string; - "liveSyncReplicator.couldNotConnectTo": string; - "liveSyncReplicator.couldNotConnectToRemoteDb": string; - "liveSyncReplicator.couldNotConnectToServer": string; - "liveSyncReplicator.couldNotConnectToURI": string; - "liveSyncReplicator.couldNotMarkResolveRemoteDb": string; - "liveSyncReplicator.liveSyncBegin": string; - "liveSyncReplicator.lockRemoteDb": string; - "liveSyncReplicator.markDeviceResolved": string; - "liveSyncReplicator.oneShotSyncBegin": string; - "liveSyncReplicator.remoteDbCorrupted": string; - "liveSyncReplicator.remoteDbCreatedOrConnected": string; - "liveSyncReplicator.remoteDbDestroyed": string; - "liveSyncReplicator.remoteDbDestroyError": string; - "liveSyncReplicator.remoteDbMarkedResolved": string; - "liveSyncReplicator.replicationClosed": string; - "liveSyncReplicator.replicationInProgress": string; - "liveSyncReplicator.retryLowerBatchSize": string; - "liveSyncReplicator.unlockRemoteDb": string; - "liveSyncSetting.errorNoSuchSettingItem": string; - "liveSyncSetting.originalValue": string; - "liveSyncSetting.valueShouldBeInRange": string; - "liveSyncSettings.btnApply": string; - "logPane.autoScroll": string; - "logPane.logWindowOpened": string; - "logPane.pause": string; - "logPane.title": string; - "logPane.wrap": string; - "Maximum delay for batch database updating": string; - "Maximum file size": string; - "Maximum Incubating Chunk Size": string; - "Maximum Incubating Chunks": string; - "Maximum Incubation Period": string; - "MB (0 to disable).": string; - "Memory cache size (by total characters)": string; - "Memory cache size (by total items)": string; - "Minimum delay for batch database updating": string; - "Minimum interval for syncing": string; - "moduleCheckRemoteSize.logCheckingStorageSizes": string; - "moduleCheckRemoteSize.logCurrentStorageSize": string; - "moduleCheckRemoteSize.logExceededWarning": string; - "moduleCheckRemoteSize.logThresholdEnlarged": string; - "moduleCheckRemoteSize.msgConfirmRebuild": string; - "moduleCheckRemoteSize.msgDatabaseGrowing": string; - "moduleCheckRemoteSize.msgSetDBCapacity": string; - "moduleCheckRemoteSize.option2GB": string; - "moduleCheckRemoteSize.option800MB": string; - "moduleCheckRemoteSize.optionAskMeLater": string; - "moduleCheckRemoteSize.optionDismiss": string; - "moduleCheckRemoteSize.optionIncreaseLimit": string; - "moduleCheckRemoteSize.optionNoWarn": string; - "moduleCheckRemoteSize.optionRebuildAll": string; - "moduleCheckRemoteSize.titleDatabaseSizeLimitExceeded": string; - "moduleCheckRemoteSize.titleDatabaseSizeNotify": string; - "moduleInputUIObsidian.defaultTitleConfirmation": string; - "moduleInputUIObsidian.defaultTitleSelect": string; - "moduleInputUIObsidian.optionNo": string; - "moduleInputUIObsidian.optionYes": string; - "moduleLiveSyncMain.logAdditionalSafetyScan": string; - "moduleLiveSyncMain.logLoadingPlugin": string; - "moduleLiveSyncMain.logPluginInitCancelled": string; - "moduleLiveSyncMain.logPluginVersion": string; - "moduleLiveSyncMain.logReadChangelog": string; - "moduleLiveSyncMain.logSafetyScanCompleted": string; - "moduleLiveSyncMain.logSafetyScanFailed": string; - "moduleLiveSyncMain.logUnloadingPlugin": string; - "moduleLiveSyncMain.logVersionUpdate": string; - "moduleLiveSyncMain.msgScramEnabled": string; - "moduleLiveSyncMain.optionKeepLiveSyncDisabled": string; - "moduleLiveSyncMain.optionResumeAndRestart": string; - "moduleLiveSyncMain.titleScramEnabled": string; - "moduleLocalDatabase.logWaitingForReady": string; - "moduleLog.showLog": string; - "moduleMigration.docUri": string; - "moduleMigration.fix0256.buttons.checkItLater": string; - "moduleMigration.fix0256.buttons.DismissForever": string; - "moduleMigration.fix0256.buttons.fix": string; - "moduleMigration.fix0256.message": string; - "moduleMigration.fix0256.messageUnrecoverable": string; - "moduleMigration.fix0256.title": string; - "moduleMigration.insecureChunkExist.buttons.fetch": string; - "moduleMigration.insecureChunkExist.buttons.later": string; - "moduleMigration.insecureChunkExist.buttons.rebuild": string; - "moduleMigration.insecureChunkExist.laterMessage": string; - "moduleMigration.insecureChunkExist.message": string; - "moduleMigration.insecureChunkExist.title": string; - "moduleMigration.logBulkSendCorrupted": string; - "moduleMigration.logFetchRemoteTweakFailed": string; - "moduleMigration.logLocalDatabaseNotReady": string; - "moduleMigration.logMigratedSameBehaviour": string; - "moduleMigration.logMigrationFailed": string; - "moduleMigration.logRedflag2CreationFail": string; - "moduleMigration.logRemoteTweakUnavailable": string; - "moduleMigration.logSetupCancelled": string; - "moduleMigration.msgFetchRemoteAgain": string; - "moduleMigration.msgInitialSetup": string; - "moduleMigration.msgRecommendSetupUri": string; - "moduleMigration.msgSinceV02321": string; - "moduleMigration.optionAdjustRemote": string; - "moduleMigration.optionDecideLater": string; - "moduleMigration.optionEnableBoth": string; - "moduleMigration.optionEnableFilenameCaseInsensitive": string; - "moduleMigration.optionEnableFixedRevisionForChunks": string; - "moduleMigration.optionHaveSetupUri": string; - "moduleMigration.optionKeepPreviousBehaviour": string; - "moduleMigration.optionManualSetup": string; - "moduleMigration.optionNoAskAgain": string; - "moduleMigration.optionNoSetupUri": string; - "moduleMigration.optionRemindNextLaunch": string; - "moduleMigration.optionSetupViaP2P": string; - "moduleMigration.optionSetupWizard": string; - "moduleMigration.optionYesFetchAgain": string; - "moduleMigration.titleCaseSensitivity": string; - "moduleMigration.titleRecommendSetupUri": string; - "moduleMigration.titleWelcome": string; - "moduleObsidianMenu.replicate": string; - "Move remotely deleted files to the trash, instead of deleting.": string; - "Not all messages have been translated. And, please revert to \"Default\" when reporting errors.": string; - "Notify all setting files": string; - "Notify customized": string; - "Notify when other device has newly customized.": string; - "Notify when the estimated remote storage size exceeds on start up": string; - "Number of batches to process at a time. Defaults to 40. Minimum is 2. This along with batch size controls how many docs are kept in memory at a time.": string; - "Number of changes to sync at a time. Defaults to 50. Minimum is 2.": string; - "obsidianLiveSyncSettingTab.btnApply": string; - "obsidianLiveSyncSettingTab.btnCheck": string; - "obsidianLiveSyncSettingTab.btnCopy": string; - "obsidianLiveSyncSettingTab.btnDisable": string; - "obsidianLiveSyncSettingTab.btnDiscard": string; - "obsidianLiveSyncSettingTab.btnEnable": string; - "obsidianLiveSyncSettingTab.btnFix": string; - "obsidianLiveSyncSettingTab.btnGotItAndUpdated": string; - "obsidianLiveSyncSettingTab.btnNext": string; - "obsidianLiveSyncSettingTab.btnStart": string; - "obsidianLiveSyncSettingTab.btnTest": string; - "obsidianLiveSyncSettingTab.btnUse": string; - "obsidianLiveSyncSettingTab.buttonFetch": string; - "obsidianLiveSyncSettingTab.buttonNext": string; - "obsidianLiveSyncSettingTab.defaultLanguage": string; - "obsidianLiveSyncSettingTab.descConnectSetupURI": string; - "obsidianLiveSyncSettingTab.descCopySetupURI": string; - "obsidianLiveSyncSettingTab.descEnableLiveSync": string; - "obsidianLiveSyncSettingTab.descFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.descManualSetup": string; - "obsidianLiveSyncSettingTab.descTestDatabaseConnection": string; - "obsidianLiveSyncSettingTab.descValidateDatabaseConfig": string; - "obsidianLiveSyncSettingTab.errAccessForbidden": string; - "obsidianLiveSyncSettingTab.errCannotContinueTest": string; - "obsidianLiveSyncSettingTab.errCorsCredentials": string; - "obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials": string; - "obsidianLiveSyncSettingTab.errCorsOrigins": string; - "obsidianLiveSyncSettingTab.errEnableCors": string; - "obsidianLiveSyncSettingTab.errEnableCorsChttpd": string; - "obsidianLiveSyncSettingTab.errMaxDocumentSize": string; - "obsidianLiveSyncSettingTab.errMaxRequestSize": string; - "obsidianLiveSyncSettingTab.errMissingWwwAuth": string; - "obsidianLiveSyncSettingTab.errRequireValidUser": string; - "obsidianLiveSyncSettingTab.errRequireValidUserAuth": string; - "obsidianLiveSyncSettingTab.labelDisabled": string; - "obsidianLiveSyncSettingTab.labelEnabled": string; - "obsidianLiveSyncSettingTab.levelAdvanced": string; - "obsidianLiveSyncSettingTab.levelEdgeCase": string; - "obsidianLiveSyncSettingTab.levelPowerUser": string; - "obsidianLiveSyncSettingTab.linkOpenInBrowser": string; - "obsidianLiveSyncSettingTab.linkPageTop": string; - "obsidianLiveSyncSettingTab.linkTipsAndTroubleshooting": string; - "obsidianLiveSyncSettingTab.linkTroubleshooting": string; - "obsidianLiveSyncSettingTab.logCannotUseCloudant": string; - "obsidianLiveSyncSettingTab.logCheckingConfigDone": string; - "obsidianLiveSyncSettingTab.logCheckingConfigFailed": string; - "obsidianLiveSyncSettingTab.logCheckingDbConfig": string; - "obsidianLiveSyncSettingTab.logCheckPassphraseFailed": string; - "obsidianLiveSyncSettingTab.logConfiguredDisabled": string; - "obsidianLiveSyncSettingTab.logConfiguredLiveSync": string; - "obsidianLiveSyncSettingTab.logConfiguredPeriodic": string; - "obsidianLiveSyncSettingTab.logCouchDbConfigFail": string; - "obsidianLiveSyncSettingTab.logCouchDbConfigSet": string; - "obsidianLiveSyncSettingTab.logCouchDbConfigUpdated": string; - "obsidianLiveSyncSettingTab.logDatabaseConnected": string; - "obsidianLiveSyncSettingTab.logEncryptionNoPassphrase": string; - "obsidianLiveSyncSettingTab.logEncryptionNoSupport": string; - "obsidianLiveSyncSettingTab.logErrorOccurred": string; - "obsidianLiveSyncSettingTab.logEstimatedSize": string; - "obsidianLiveSyncSettingTab.logPassphraseInvalid": string; - "obsidianLiveSyncSettingTab.logPassphraseNotCompatible": string; - "obsidianLiveSyncSettingTab.logRebuildNote": string; - "obsidianLiveSyncSettingTab.logSelectAnyPreset": string; - "obsidianLiveSyncSettingTab.msgAreYouSureProceed": string; - "obsidianLiveSyncSettingTab.msgChangesNeedToBeApplied": string; - "obsidianLiveSyncSettingTab.msgConfigCheck": string; - "obsidianLiveSyncSettingTab.msgConfigCheckFailed": string; - "obsidianLiveSyncSettingTab.msgConnectionCheck": string; - "obsidianLiveSyncSettingTab.msgConnectionProxyNote": string; - "obsidianLiveSyncSettingTab.msgCurrentOrigin": string; - "obsidianLiveSyncSettingTab.msgDiscardConfirmation": string; - "obsidianLiveSyncSettingTab.msgDone": string; - "obsidianLiveSyncSettingTab.msgEnableCors": string; - "obsidianLiveSyncSettingTab.msgEnableCorsChttpd": string; - "obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": string; - "obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.msgGenerateSetupURI": string; - "obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": string; - "obsidianLiveSyncSettingTab.msgInvalidPassphrase": string; - "obsidianLiveSyncSettingTab.msgNewVersionNote": string; - "obsidianLiveSyncSettingTab.msgNonHTTPSInfo": string; - "obsidianLiveSyncSettingTab.msgNonHTTPSWarning": string; - "obsidianLiveSyncSettingTab.msgNotice": string; - "obsidianLiveSyncSettingTab.msgObjectStorageWarning": string; - "obsidianLiveSyncSettingTab.msgOriginCheck": string; - "obsidianLiveSyncSettingTab.msgRebuildRequired": string; - "obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": string; - "obsidianLiveSyncSettingTab.msgSetCorsCredentials": string; - "obsidianLiveSyncSettingTab.msgSetCorsOrigins": string; - "obsidianLiveSyncSettingTab.msgSetMaxDocSize": string; - "obsidianLiveSyncSettingTab.msgSetMaxRequestSize": string; - "obsidianLiveSyncSettingTab.msgSetRequireValidUser": string; - "obsidianLiveSyncSettingTab.msgSetRequireValidUserAuth": string; - "obsidianLiveSyncSettingTab.msgSettingModified": string; - "obsidianLiveSyncSettingTab.msgSettingsUnchangeableDuringSync": string; - "obsidianLiveSyncSettingTab.msgSetWwwAuth": string; - "obsidianLiveSyncSettingTab.nameApplySettings": string; - "obsidianLiveSyncSettingTab.nameConnectSetupURI": string; - "obsidianLiveSyncSettingTab.nameCopySetupURI": string; - "obsidianLiveSyncSettingTab.nameDisableHiddenFileSync": string; - "obsidianLiveSyncSettingTab.nameDiscardSettings": string; - "obsidianLiveSyncSettingTab.nameEnableHiddenFileSync": string; - "obsidianLiveSyncSettingTab.nameEnableLiveSync": string; - "obsidianLiveSyncSettingTab.nameHiddenFileSynchronization": string; - "obsidianLiveSyncSettingTab.nameManualSetup": string; - "obsidianLiveSyncSettingTab.nameTestConnection": string; - "obsidianLiveSyncSettingTab.nameTestDatabaseConnection": string; - "obsidianLiveSyncSettingTab.nameValidateDatabaseConfig": string; - "obsidianLiveSyncSettingTab.okAdminPrivileges": string; - "obsidianLiveSyncSettingTab.okCorsCredentials": string; - "obsidianLiveSyncSettingTab.okCorsCredentialsForOrigin": string; - "obsidianLiveSyncSettingTab.okCorsOriginMatched": string; - "obsidianLiveSyncSettingTab.okCorsOrigins": string; - "obsidianLiveSyncSettingTab.okEnableCors": string; - "obsidianLiveSyncSettingTab.okEnableCorsChttpd": string; - "obsidianLiveSyncSettingTab.okMaxDocumentSize": string; - "obsidianLiveSyncSettingTab.okMaxRequestSize": string; - "obsidianLiveSyncSettingTab.okRequireValidUser": string; - "obsidianLiveSyncSettingTab.okRequireValidUserAuth": string; - "obsidianLiveSyncSettingTab.okWwwAuth": string; - "obsidianLiveSyncSettingTab.optionApply": string; - "obsidianLiveSyncSettingTab.optionCancel": string; - "obsidianLiveSyncSettingTab.optionCouchDB": string; - "obsidianLiveSyncSettingTab.optionDisableAllAutomatic": string; - "obsidianLiveSyncSettingTab.optionFetchFromRemote": string; - "obsidianLiveSyncSettingTab.optionHere": string; - "obsidianLiveSyncSettingTab.optionLiveSync": string; - "obsidianLiveSyncSettingTab.optionMinioS3R2": string; - "obsidianLiveSyncSettingTab.optionOkReadEverything": string; - "obsidianLiveSyncSettingTab.optionOnEvents": string; - "obsidianLiveSyncSettingTab.optionPeriodicAndEvents": string; - "obsidianLiveSyncSettingTab.optionPeriodicWithBatch": string; - "obsidianLiveSyncSettingTab.optionRebuildBoth": string; - "obsidianLiveSyncSettingTab.optionSaveOnlySettings": string; - "obsidianLiveSyncSettingTab.panelChangeLog": string; - "obsidianLiveSyncSettingTab.panelGeneralSettings": string; - "obsidianLiveSyncSettingTab.panelPrivacyEncryption": string; - "obsidianLiveSyncSettingTab.panelRemoteConfiguration": string; - "obsidianLiveSyncSettingTab.panelSetup": string; - "obsidianLiveSyncSettingTab.serverVersion": string; - "obsidianLiveSyncSettingTab.titleActiveRemoteServer": string; - "obsidianLiveSyncSettingTab.titleAppearance": string; - "obsidianLiveSyncSettingTab.titleConflictResolution": string; - "obsidianLiveSyncSettingTab.titleCongratulations": string; - "obsidianLiveSyncSettingTab.titleCouchDB": string; - "obsidianLiveSyncSettingTab.titleDeletionPropagation": string; - "obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": string; - "obsidianLiveSyncSettingTab.titleEncryptionPassphraseInvalid": string; - "obsidianLiveSyncSettingTab.titleExtraFeatures": string; - "obsidianLiveSyncSettingTab.titleFetchConfig": string; - "obsidianLiveSyncSettingTab.titleFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.titleFetchSettings": string; - "obsidianLiveSyncSettingTab.titleHiddenFiles": string; - "obsidianLiveSyncSettingTab.titleLogging": string; - "obsidianLiveSyncSettingTab.titleMinioS3R2": string; - "obsidianLiveSyncSettingTab.titleNotification": string; - "obsidianLiveSyncSettingTab.titleOnlineTips": string; - "obsidianLiveSyncSettingTab.titleQuickSetup": string; - "obsidianLiveSyncSettingTab.titleRebuildRequired": string; - "obsidianLiveSyncSettingTab.titleRemoteConfigCheckFailed": string; - "obsidianLiveSyncSettingTab.titleRemoteServer": string; - "obsidianLiveSyncSettingTab.titleReset": string; - "obsidianLiveSyncSettingTab.titleSetupOtherDevices": string; - "obsidianLiveSyncSettingTab.titleSynchronizationMethod": string; - "obsidianLiveSyncSettingTab.titleSynchronizationPreset": string; - "obsidianLiveSyncSettingTab.titleSyncSettings": string; - "obsidianLiveSyncSettingTab.titleSyncSettingsViaMarkdown": string; - "obsidianLiveSyncSettingTab.titleUpdateThinning": string; - "obsidianLiveSyncSettingTab.warnCorsOriginUnmatched": string; - "obsidianLiveSyncSettingTab.warnNoAdmin": string; - "P2P.AskPassphraseForDecrypt": string; - "P2P.AskPassphraseForShare": string; - "P2P.DisabledButNeed": string; - "P2P.FailedToOpen": string; - "P2P.NoAutoSyncPeers": string; - "P2P.NoKnownPeers": string; - "P2P.Note.description": string; - "P2P.Note.important_note": string; - "P2P.Note.important_note_sub": string; - "P2P.Note.Summary": string; - "P2P.NotEnabled": string; - "P2P.P2PReplication": string; - "P2P.PaneTitle": string; - "P2P.ReplicatorInstanceMissing": string; - "P2P.SeemsOffline": string; - "P2P.SyncAlreadyRunning": string; - "P2P.SyncCompleted": string; - "P2P.SyncStartedWith": string; - Passphrase: string; - "Passphrase of sensitive configuration items": string; - password: string; - Password: string; - "Path Obfuscation": string; - "Per-file-saved customization sync": string; - "Periodic Sync interval": string; - "Prepare the 'report' to create an issue": string; - Presets: string; - "Process small files in the foreground": string; - "Property Encryption": string; - "RedFlag.Fetch.Method.Desc": string; - "RedFlag.Fetch.Method.FetchSafer": string; - "RedFlag.Fetch.Method.FetchSmoother": string; - "RedFlag.Fetch.Method.FetchTraditional": string; - "RedFlag.Fetch.Method.Title": string; - "RedFlag.FetchRemoteConfig.Buttons.Cancel": string; - "RedFlag.FetchRemoteConfig.Buttons.Fetch": string; - "RedFlag.FetchRemoteConfig.Message": string; - "RedFlag.FetchRemoteConfig.Title": string; - "Reducing the frequency with which on-disk changes are reflected into the DB": string; - Region: string; - "Remote server type": string; - "Remote Type": string; - "Replicator.Dialogue.Locked.Action.Dismiss": string; - "Replicator.Dialogue.Locked.Action.Fetch": string; - "Replicator.Dialogue.Locked.Action.Unlock": string; - "Replicator.Dialogue.Locked.Message": string; - "Replicator.Dialogue.Locked.Message.Fetch": string; - "Replicator.Dialogue.Locked.Message.Unlocked": string; - "Replicator.Dialogue.Locked.Title": string; - "Replicator.Message.Cleaned": string; - "Replicator.Message.InitialiseFatalError": string; - "Replicator.Message.Pending": string; - "Replicator.Message.SomeModuleFailed": string; - "Replicator.Message.VersionUpFlash": string; - "Requires restart of Obsidian": string; - "Requires restart of Obsidian.": string; - "Rerun Onboarding Wizard": string; - "Rerun the onboarding wizard to set up Self-hosted LiveSync again.": string; - "Rerun Wizard": string; - "Reset notification threshold and check the remote database usage": string; - "Reset the remote storage size threshold and check the remote storage size again.": string; - "Run Doctor": string; - "Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.": string; - "Saving will be performed forcefully after this number of seconds.": string; - "Scan changes on customization sync": string; - "Scan customization automatically": string; - "Scan customization before replicating.": string; - "Scan customization every 1 minute.": string; - "Scan customization periodically": string; - "Scan for hidden files before replication": string; - "Scan hidden files periodically": string; - "Seconds, 0 to disable": string; - "Seconds. Saving to the local database will be delayed until this value after we stop typing or saving.": string; - "Secret Key": string; - "Server URI": string; - "Setting.GenerateKeyPair.Desc": string; - "Setting.GenerateKeyPair.Title": string; - "Setting.TroubleShooting": string; - "Setting.TroubleShooting.Doctor": string; - "Setting.TroubleShooting.Doctor.Desc": string; - "Setting.TroubleShooting.ScanBrokenFiles": string; - "Setting.TroubleShooting.ScanBrokenFiles.Desc": string; - "SettingTab.Message.AskRebuild": string; - "Setup.Apply.Buttons.ApplyAndFetch": string; - "Setup.Apply.Buttons.ApplyAndMerge": string; - "Setup.Apply.Buttons.ApplyAndRebuild": string; - "Setup.Apply.Buttons.Cancel": string; - "Setup.Apply.Buttons.OnlyApply": string; - "Setup.Apply.Message": string; - "Setup.Apply.Title": string; - "Setup.Apply.WarningRebuildRecommended": string; - "Setup.Doctor.Buttons.No": string; - "Setup.Doctor.Buttons.Yes": string; - "Setup.Doctor.Message": string; - "Setup.Doctor.Title": string; - "Setup.FetchRemoteConf.Buttons.Fetch": string; - "Setup.FetchRemoteConf.Buttons.Skip": string; - "Setup.FetchRemoteConf.Message": string; - "Setup.FetchRemoteConf.Title": string; - "Setup.QRCode": string; - "Setup.ShowQRCode": string; - "Setup.ShowQRCode.Desc": string; - "Should we keep folders that don't have any files inside?": string; - "Should we only check for conflicts when a file is opened?": string; - "Should we prompt you about conflicting files when a file is opened?": string; - "Should we prompt you for every single merge, even if we can safely merge automatcially?": string; - "Show only notifications": string; - "Show status as icons only": string; - "Show status icon instead of file warnings banner": string; - "Show status inside the editor": string; - "Show status on the status bar": string; - "Show verbose log. Please enable if you report an issue.": string; - "Starts synchronisation when a file is saved.": string; - "Stop reflecting database changes to storage files.": string; - "Stop watching for file changes.": string; - "Suppress notification of hidden files change": string; - "Suspend database reflecting": string; - "Suspend file watching": string; - "Sync after merging file": string; - "Sync automatically after merging files": string; - "Sync Mode": string; - "Sync on Editor Save": string; - "Sync on File Open": string; - "Sync on Save": string; - "Sync on Startup": string; - "Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.": string; - "The delay for consecutive on-demand fetches": string; - "The Hash algorithm for chunk IDs": string; - "The maximum duration for which chunks can be incubated within the document. Chunks exceeding this period will graduate to independent chunks.": string; - "The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks.": string; - "The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks.": string; - "The minimum interval for automatic synchronisation on event.": string; - "This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.": string; - "TweakMismatchResolve.Action.Dismiss": string; - "TweakMismatchResolve.Action.UseConfigured": string; - "TweakMismatchResolve.Action.UseMine": string; - "TweakMismatchResolve.Action.UseMineAcceptIncompatible": string; - "TweakMismatchResolve.Action.UseMineWithRebuild": string; - "TweakMismatchResolve.Action.UseRemote": string; - "TweakMismatchResolve.Action.UseRemoteAcceptIncompatible": string; - "TweakMismatchResolve.Action.UseRemoteWithRebuild": string; - "TweakMismatchResolve.Message.Main": string; - "TweakMismatchResolve.Message.MainTweakResolving": string; - "TweakMismatchResolve.Message.UseRemote.WarningRebuildRecommended": string; - "TweakMismatchResolve.Message.UseRemote.WarningRebuildRequired": string; - "TweakMismatchResolve.Message.WarningIncompatibleRebuildRecommended": string; - "TweakMismatchResolve.Message.WarningIncompatibleRebuildRequired": string; - "TweakMismatchResolve.Table": string; - "TweakMismatchResolve.Table.Row": string; - "TweakMismatchResolve.Title": string; - "TweakMismatchResolve.Title.TweakResolving": string; - "TweakMismatchResolve.Title.UseRemoteConfig": string; - "Unique name between all synchronized devices. To edit this setting, please disable customization sync once.": string; - "Use Custom HTTP Handler": string; - "Use dynamic iteration count": string; - "Use Segmented-splitter": string; - "Use splitting-limit-capped chunk splitter": string; - "Use the trash bin": string; - "Use timeouts instead of heartbeats": string; - username: string; - Username: string; - "Verbose Log": string; - "Warning! This will have a serious impact on performance. And the logs will not be synchronised under the default name. Please be careful with logs; they often contain your confidential information.": string; - "When you save a file in the editor, start a sync automatically": string; - "Write credentials in the file": string; - "Write logs into the file": string; - }; -}; diff --git a/_types/src/lib/src/common/messages/ja.d.ts b/_types/src/lib/src/common/messages/ja.d.ts deleted file mode 100644 index 0a0560a6..00000000 --- a/_types/src/lib/src/common/messages/ja.d.ts +++ /dev/null @@ -1,840 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare const PartialMessages: { - readonly ja: { - "(Active)": string; - "(BETA) Always overwrite with a newer file": string; - "(Beta) Use ignore files": string; - "(Days passed, 0 to disable automatic-deletion)": string; - "(ex. Read chunks online) If this option is enabled, LiveSync reads chunks online directly instead of replicating them locally. Increasing Custom chunk size is recommended.": string; - "(MB) If this is set, changes to local and remote files that are larger than this will be skipped. If the file becomes smaller again, a newer one will be used.": string; - "(Mega chars)": string; - "(Not recommended) If set, credentials will be stored in the file.": string; - "(Obsolete) Use an old adapter for compatibility": string; - "(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": string; - "(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": string; - "(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": string; - "(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": string; - "> [!INFO]- The connected devices have been detected as follows:\n${devices}": string; - "A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration.": string; - "Access Key": string; - Activate: string; - "Add default patterns": string; - "Add new connection": string; - "All devices have the same progress value (${progress}). Your devices seem to be synchronised. And be able to proceed with Garbage Collection.": string; - "Always prompt merge conflicts": string; - "Analyse database usage": string; - "Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like.": string; - "Apply Latest Change if Conflicting": string; - "Apply preset configuration": string; - "Ask a passphrase at every launch": string; - "Automatically Sync all files when opening Obsidian.": string; - Back: string; - "Back to non-configured": string; - "Batch database update": string; - "Batch limit": string; - "Batch size": string; - "Batch size of on-demand fetching": string; - "Before v0.17.16, we used an old adapter for the local database. Now the new adapter is preferred. However, it needs local database rebuilding. Please disable this toggle when you have enough time. If leave it enabled, also while fetching from the remote database, you will be asked to disable this.": string; - "Bucket Name": string; - Cancel: string; - "Cancel Garbage Collection": string; - "Check and convert non-path-obfuscated files": string; - "Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.": string; - "cmdConfigSync.showCustomizationSync": string; - "Comma separated `.gitignore, .dockerignore`": string; - "Compaction in progress on remote database...": string; - "Compaction on remote database completed successfully.": string; - "Compaction on remote database failed.": string; - "Compaction on remote database timed out.": string; - "Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep.": string; - "Compatibility (Conflict Behaviour)": string; - "Compatibility (Database structure)": string; - "Compatibility (Internal API Usage)": string; - "Compatibility (Metadata)": string; - "Compatibility (Remote Database)": string; - "Compatibility (Trouble addressed)": string; - "Compute revisions for chunks": string; - "Configuration Encryption": string; - Configure: string; - "Configure And Change Remote": string; - "Configure E2EE": string; - "Configure Remote": string; - "Configure the same server information as your other devices again, manually, very advanced users only.": string; - "Connection Method": string; - "Continue to CouchDB setup": string; - "Continue to Peer-to-Peer only setup": string; - "Continue to S3/MinIO/R2 setup": string; - Copy: string; - "Copy Report to clipboard": string; - "CouchDB Connection Tweak": string; - "Cross-platform": string; - "Current adapter: {adapter}": string; - "Customization Sync": string; - "Customization Sync (Beta3)": string; - "Data Compression": string; - "Database Adapter": string; - "Database Name": string; - "Database suffix": string; - Default: string; - "Delay conflict resolution of inactive files": string; - "Delay merge conflict prompt for inactive files.": string; - Delete: string; - "Delete all customization sync data": string; - "Delete all data on the remote server.": string; - "Delete local database to reset or uninstall Self-hosted LiveSync": string; - "Delete old metadata of deleted files on start-up": string; - "Delete Remote Configuration": string; - "Delete remote configuration '{name}'?": string; - desktop: string; - Developer: string; - Device: string; - "Device name": string; - "Device Setup Method": string; - "dialog.yourLanguageAvailable": string; - "dialog.yourLanguageAvailable.btnRevertToDefault": string; - "dialog.yourLanguageAvailable.Title": string; - "Disables all synchronization and restart.": string; - "Disables logging, only shows notifications. Please disable if you report an issue.": string; - "Display Language": string; - "Display name": string; - "Do not check configuration mismatch before replication": string; - "Do not keep metadata of deleted files.": string; - "Do not split chunks in the background": string; - "Do not use internal API": string; - "Doctor.Button.DismissThisVersion": string; - "Doctor.Button.Fix": string; - "Doctor.Button.FixButNoRebuild": string; - "Doctor.Button.No": string; - "Doctor.Button.Skip": string; - "Doctor.Button.Yes": string; - "Doctor.Dialogue.Main": string; - "Doctor.Dialogue.MainFix": string; - "Doctor.Dialogue.Title": string; - "Doctor.Dialogue.TitleAlmostDone": string; - "Doctor.Dialogue.TitleFix": string; - "Doctor.Level.Must": string; - "Doctor.Level.Necessary": string; - "Doctor.Level.Optional": string; - "Doctor.Level.Recommended": string; - "Doctor.Message.NoIssues": string; - "Doctor.Message.RebuildLocalRequired": string; - "Doctor.Message.RebuildRequired": string; - "Doctor.Message.SomeSkipped": string; - "Doctor.RULES.E2EE_V02500.REASON": string; - Duplicate: string; - "Duplicate remote": string; - "E2EE Configuration": string; - "Edge case addressing (Behaviour)": string; - "Edge case addressing (Database)": string; - "Edge case addressing (Processing)": string; - "Emergency restart": string; - "Enable advanced features": string; - "Enable customization sync": string; - "Enable Developers' Debug Tools.": string; - "Enable edge case treatment features": string; - "Enable poweruser features": string; - "Enable this if your Object Storage doesn't support CORS": string; - "Enable this option to automatically apply the most recent change to documents even when it conflicts": string; - "Encrypt contents on the remote database. If you use the plugin's synchronization feature, enabling this is recommended.": string; - "Encrypting sensitive configuration items": string; - "Encryption phassphrase. If changed, you should overwrite the server's database with the new (encrypted) files.": string; - "End-to-End Encryption": string; - "Endpoint URL": string; - "Enhance chunk size": string; - "Enter Server Information": string; - "Enter the server information manually": string; - Export: string; - "Failed to connect to remote for compaction.": string; - "Failed to connect to remote for compaction. ${reason}": string; - "Failed to start one-shot replication before Garbage Collection. Garbage Collection Cancelled.": string; - "Failed to start replication after Garbage Collection.": string; - Fetch: string; - "Fetch chunks on demand": string; - "Fetch database with previous behaviour": string; - "Fetch remote settings": string; - "File to resolve conflict": string; - Filename: string; - "First, please select the option that best describes your current situation.": string; - "Flag and restart": string; - "Forces the file to be synced when opened.": string; - "Fresh Start Wipe": string; - "Garbage Collection cancelled by user.": string; - "Garbage Collection completed. Deleted chunks: ${deletedChunks} / ${totalChunks}. Time taken: ${seconds} seconds.": string; - "Garbage Collection Confirmation": string; - "Garbage Collection V3 (Beta)": string; - "Garbage Collection: Found ${unusedChunks} unused chunks to delete.": string; - "Garbage Collection: Scanned ${scanned} / ~${docCount}": string; - "Garbage Collection: Scanning completed. Total chunks: ${totalChunks}, Used chunks: ${usedChunks}": string; - "Handle files as Case-Sensitive": string; - "Hidden Files": string; - "How to display network errors when the sync server is unreachable.": string; - "How would you like to configure the connection to your server?": string; - "I am adding a device to an existing synchronisation setup": string; - "I am setting this up for the first time": string; - "I know my server details, let me enter them": string; - "If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).": string; - "If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.": string; - "If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker.": string; - "If enabled, newly created chunks are temporarily kept within the document, and graduated to become independent chunks once stabilised.": string; - "If enabled, the \u26D4 icon will be shown inside the status instead of the file warnings banner. No details will be shown.": string; - "If enabled, the file under 1kb will be processed in the UI thread.": string; - "If enabled, the notification of hidden files change will be suppressed.": string; - "If this enabled, all chunks will be stored with the revision made from its content. (Previous behaviour)": string; - "If this enabled, All files are handled as case-Sensitive (Previous behaviour).": string; - "If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature.": string; - "If this is set, changes to local files which are matched by the ignore files will be skipped. Remote changes are determined using local ignore files.": string; - "If this option is enabled, PouchDB will hold the connection open for 60 seconds, and if no change arrives in that time, close and reopen the socket, instead of holding it open indefinitely. Useful when a proxy limits request duration but can increase resource usage.": string; - "Ignore and Proceed": string; - "Ignore files": string; - "Ignore patterns": string; - "Import connection": string; - "Incubate Chunks in Document": string; - "Initialise all journal history, On the next sync, every item will be received and sent.": string; - "Interval (sec)": string; - "K.exp": string; - "K.long_p2p_sync": string; - "K.P2P": string; - "K.Peer": string; - "K.ScanCustomization": string; - "K.short_p2p_sync": string; - "K.title_p2p_sync": string; - "Keep empty folder": string; - lang_def: string; - "lang-de": string; - "lang-def": string; - "lang-es": string; - "lang-fr": string; - "lang-ja": string; - "lang-ko": string; - "lang-ru": string; - "lang-zh": string; - "lang-zh-tw": string; - Later: string; - "Limit: {datetime} ({timestamp})": string; - "LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured.": string; - "liveSyncReplicator.beforeLiveSync": string; - "liveSyncReplicator.cantReplicateLowerValue": string; - "liveSyncReplicator.checkingLastSyncPoint": string; - "liveSyncReplicator.couldNotConnectTo": string; - "liveSyncReplicator.couldNotConnectToRemoteDb": string; - "liveSyncReplicator.couldNotConnectToServer": string; - "liveSyncReplicator.couldNotConnectToURI": string; - "liveSyncReplicator.couldNotMarkResolveRemoteDb": string; - "liveSyncReplicator.liveSyncBegin": string; - "liveSyncReplicator.lockRemoteDb": string; - "liveSyncReplicator.markDeviceResolved": string; - "liveSyncReplicator.oneShotSyncBegin": string; - "liveSyncReplicator.remoteDbCorrupted": string; - "liveSyncReplicator.remoteDbCreatedOrConnected": string; - "liveSyncReplicator.remoteDbDestroyed": string; - "liveSyncReplicator.remoteDbDestroyError": string; - "liveSyncReplicator.remoteDbMarkedResolved": string; - "liveSyncReplicator.replicationClosed": string; - "liveSyncReplicator.replicationInProgress": string; - "liveSyncReplicator.retryLowerBatchSize": string; - "liveSyncReplicator.unlockRemoteDb": string; - "liveSyncSetting.errorNoSuchSettingItem": string; - "liveSyncSetting.originalValue": string; - "liveSyncSetting.valueShouldBeInRange": string; - "liveSyncSettings.btnApply": string; - "Local Database Tweak": string; - Lock: string; - "Lock Server": string; - "Lock the remote server to prevent synchronization with other devices.": string; - "logPane.autoScroll": string; - "logPane.logWindowOpened": string; - "logPane.pause": string; - "logPane.title": string; - "logPane.wrap": string; - "Maximum delay for batch database updating": string; - "Maximum file size": string; - "Maximum Incubating Chunk Size": string; - "Maximum Incubating Chunks": string; - "Maximum Incubation Period": string; - "MB (0 to disable).": string; - "Memory cache": string; - "Memory cache size (by total characters)": string; - "Memory cache size (by total items)": string; - Merge: string; - "Minimum delay for batch database updating": string; - "Minimum interval for syncing": string; - "moduleCheckRemoteSize.logCheckingStorageSizes": string; - "moduleCheckRemoteSize.logCurrentStorageSize": string; - "moduleCheckRemoteSize.logExceededWarning": string; - "moduleCheckRemoteSize.logThresholdEnlarged": string; - "moduleCheckRemoteSize.msgConfirmRebuild": string; - "moduleCheckRemoteSize.msgDatabaseGrowing": string; - "moduleCheckRemoteSize.msgSetDBCapacity": string; - "moduleCheckRemoteSize.option2GB": string; - "moduleCheckRemoteSize.option800MB": string; - "moduleCheckRemoteSize.optionAskMeLater": string; - "moduleCheckRemoteSize.optionDismiss": string; - "moduleCheckRemoteSize.optionIncreaseLimit": string; - "moduleCheckRemoteSize.optionNoWarn": string; - "moduleCheckRemoteSize.optionRebuildAll": string; - "moduleCheckRemoteSize.titleDatabaseSizeLimitExceeded": string; - "moduleCheckRemoteSize.titleDatabaseSizeNotify": string; - "moduleInputUIObsidian.defaultTitleConfirmation": string; - "moduleInputUIObsidian.defaultTitleSelect": string; - "moduleInputUIObsidian.optionNo": string; - "moduleInputUIObsidian.optionYes": string; - "moduleLiveSyncMain.logAdditionalSafetyScan": string; - "moduleLiveSyncMain.logLoadingPlugin": string; - "moduleLiveSyncMain.logPluginInitCancelled": string; - "moduleLiveSyncMain.logPluginVersion": string; - "moduleLiveSyncMain.logReadChangelog": string; - "moduleLiveSyncMain.logSafetyScanCompleted": string; - "moduleLiveSyncMain.logSafetyScanFailed": string; - "moduleLiveSyncMain.logUnloadingPlugin": string; - "moduleLiveSyncMain.logVersionUpdate": string; - "moduleLiveSyncMain.msgScramEnabled": string; - "moduleLiveSyncMain.optionKeepLiveSyncDisabled": string; - "moduleLiveSyncMain.optionResumeAndRestart": string; - "moduleLiveSyncMain.titleScramEnabled": string; - "moduleLocalDatabase.logWaitingForReady": string; - "moduleLog.showLog": string; - "moduleMigration.docUri": string; - "moduleMigration.fix0256.buttons.checkItLater": string; - "moduleMigration.fix0256.buttons.DismissForever": string; - "moduleMigration.fix0256.buttons.fix": string; - "moduleMigration.fix0256.message": string; - "moduleMigration.fix0256.messageUnrecoverable": string; - "moduleMigration.fix0256.title": string; - "moduleMigration.insecureChunkExist.buttons.fetch": string; - "moduleMigration.insecureChunkExist.buttons.later": string; - "moduleMigration.insecureChunkExist.buttons.rebuild": string; - "moduleMigration.insecureChunkExist.laterMessage": string; - "moduleMigration.insecureChunkExist.message": string; - "moduleMigration.insecureChunkExist.title": string; - "moduleMigration.logBulkSendCorrupted": string; - "moduleMigration.logFetchRemoteTweakFailed": string; - "moduleMigration.logLocalDatabaseNotReady": string; - "moduleMigration.logMigratedSameBehaviour": string; - "moduleMigration.logMigrationFailed": string; - "moduleMigration.logRedflag2CreationFail": string; - "moduleMigration.logRemoteTweakUnavailable": string; - "moduleMigration.logSetupCancelled": string; - "moduleMigration.msgFetchRemoteAgain": string; - "moduleMigration.msgInitialSetup": string; - "moduleMigration.msgRecommendSetupUri": string; - "moduleMigration.msgSinceV02321": string; - "moduleMigration.optionAdjustRemote": string; - "moduleMigration.optionDecideLater": string; - "moduleMigration.optionEnableBoth": string; - "moduleMigration.optionEnableFilenameCaseInsensitive": string; - "moduleMigration.optionEnableFixedRevisionForChunks": string; - "moduleMigration.optionHaveSetupUri": string; - "moduleMigration.optionKeepPreviousBehaviour": string; - "moduleMigration.optionManualSetup": string; - "moduleMigration.optionNoAskAgain": string; - "moduleMigration.optionNoSetupUri": string; - "moduleMigration.optionRemindNextLaunch": string; - "moduleMigration.optionSetupViaP2P": string; - "moduleMigration.optionSetupWizard": string; - "moduleMigration.optionYesFetchAgain": string; - "moduleMigration.titleCaseSensitivity": string; - "moduleMigration.titleRecommendSetupUri": string; - "moduleMigration.titleWelcome": string; - "moduleObsidianMenu.replicate": string; - "More actions": string; - "Move remotely deleted files to the trash, instead of deleting.": string; - "Network warning style": string; - "New Remote": string; - "No connected device information found. Cancelling Garbage Collection.": string; - "No limit configured": string; - "No, please take me back": string; - "Node ID": string; - "Node Information Missing": string; - "Non-Synchronising files": string; - "Normal Files": string; - "Not all messages have been translated. And, please revert to \"Default\" when reporting errors.": string; - "Notify all setting files": string; - "Notify customized": string; - "Notify when other device has newly customized.": string; - "Notify when the estimated remote storage size exceeds on start up": string; - "Number of batches to process at a time. Defaults to 40. Minimum is 2. This along with batch size controls how many docs are kept in memory at a time.": string; - "Number of changes to sync at a time. Defaults to 50. Minimum is 2.": string; - "Obsidian version": string; - "obsidianLiveSyncSettingTab.btnApply": string; - "obsidianLiveSyncSettingTab.btnCheck": string; - "obsidianLiveSyncSettingTab.btnCopy": string; - "obsidianLiveSyncSettingTab.btnDisable": string; - "obsidianLiveSyncSettingTab.btnDiscard": string; - "obsidianLiveSyncSettingTab.btnEnable": string; - "obsidianLiveSyncSettingTab.btnFix": string; - "obsidianLiveSyncSettingTab.btnGotItAndUpdated": string; - "obsidianLiveSyncSettingTab.btnNext": string; - "obsidianLiveSyncSettingTab.btnStart": string; - "obsidianLiveSyncSettingTab.btnTest": string; - "obsidianLiveSyncSettingTab.btnUse": string; - "obsidianLiveSyncSettingTab.buttonFetch": string; - "obsidianLiveSyncSettingTab.buttonNext": string; - "obsidianLiveSyncSettingTab.defaultLanguage": string; - "obsidianLiveSyncSettingTab.descConnectSetupURI": string; - "obsidianLiveSyncSettingTab.descCopySetupURI": string; - "obsidianLiveSyncSettingTab.descEnableLiveSync": string; - "obsidianLiveSyncSettingTab.descFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.descManualSetup": string; - "obsidianLiveSyncSettingTab.descTestDatabaseConnection": string; - "obsidianLiveSyncSettingTab.descValidateDatabaseConfig": string; - "obsidianLiveSyncSettingTab.errAccessForbidden": string; - "obsidianLiveSyncSettingTab.errCannotContinueTest": string; - "obsidianLiveSyncSettingTab.errCorsCredentials": string; - "obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials": string; - "obsidianLiveSyncSettingTab.errCorsOrigins": string; - "obsidianLiveSyncSettingTab.errEnableCors": string; - "obsidianLiveSyncSettingTab.errEnableCorsChttpd": string; - "obsidianLiveSyncSettingTab.errMaxDocumentSize": string; - "obsidianLiveSyncSettingTab.errMaxRequestSize": string; - "obsidianLiveSyncSettingTab.errMissingWwwAuth": string; - "obsidianLiveSyncSettingTab.errRequireValidUser": string; - "obsidianLiveSyncSettingTab.errRequireValidUserAuth": string; - "obsidianLiveSyncSettingTab.labelDisabled": string; - "obsidianLiveSyncSettingTab.labelEnabled": string; - "obsidianLiveSyncSettingTab.levelAdvanced": string; - "obsidianLiveSyncSettingTab.levelEdgeCase": string; - "obsidianLiveSyncSettingTab.levelPowerUser": string; - "obsidianLiveSyncSettingTab.linkOpenInBrowser": string; - "obsidianLiveSyncSettingTab.linkPageTop": string; - "obsidianLiveSyncSettingTab.linkTipsAndTroubleshooting": string; - "obsidianLiveSyncSettingTab.linkTroubleshooting": string; - "obsidianLiveSyncSettingTab.logCannotUseCloudant": string; - "obsidianLiveSyncSettingTab.logCheckingConfigDone": string; - "obsidianLiveSyncSettingTab.logCheckingConfigFailed": string; - "obsidianLiveSyncSettingTab.logCheckingDbConfig": string; - "obsidianLiveSyncSettingTab.logCheckPassphraseFailed": string; - "obsidianLiveSyncSettingTab.logConfiguredDisabled": string; - "obsidianLiveSyncSettingTab.logConfiguredLiveSync": string; - "obsidianLiveSyncSettingTab.logConfiguredPeriodic": string; - "obsidianLiveSyncSettingTab.logCouchDbConfigFail": string; - "obsidianLiveSyncSettingTab.logCouchDbConfigSet": string; - "obsidianLiveSyncSettingTab.logCouchDbConfigUpdated": string; - "obsidianLiveSyncSettingTab.logDatabaseConnected": string; - "obsidianLiveSyncSettingTab.logEncryptionNoPassphrase": string; - "obsidianLiveSyncSettingTab.logEncryptionNoSupport": string; - "obsidianLiveSyncSettingTab.logErrorOccurred": string; - "obsidianLiveSyncSettingTab.logEstimatedSize": string; - "obsidianLiveSyncSettingTab.logPassphraseInvalid": string; - "obsidianLiveSyncSettingTab.logPassphraseNotCompatible": string; - "obsidianLiveSyncSettingTab.logRebuildNote": string; - "obsidianLiveSyncSettingTab.logSelectAnyPreset": string; - "obsidianLiveSyncSettingTab.msgAreYouSureProceed": string; - "obsidianLiveSyncSettingTab.msgChangesNeedToBeApplied": string; - "obsidianLiveSyncSettingTab.msgConfigCheck": string; - "obsidianLiveSyncSettingTab.msgConfigCheckFailed": string; - "obsidianLiveSyncSettingTab.msgConnectionCheck": string; - "obsidianLiveSyncSettingTab.msgConnectionProxyNote": string; - "obsidianLiveSyncSettingTab.msgCurrentOrigin": string; - "obsidianLiveSyncSettingTab.msgDiscardConfirmation": string; - "obsidianLiveSyncSettingTab.msgDone": string; - "obsidianLiveSyncSettingTab.msgEnableCors": string; - "obsidianLiveSyncSettingTab.msgEnableCorsChttpd": string; - "obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": string; - "obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.msgGenerateSetupURI": string; - "obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": string; - "obsidianLiveSyncSettingTab.msgInvalidPassphrase": string; - "obsidianLiveSyncSettingTab.msgNewVersionNote": string; - "obsidianLiveSyncSettingTab.msgNonHTTPSInfo": string; - "obsidianLiveSyncSettingTab.msgNonHTTPSWarning": string; - "obsidianLiveSyncSettingTab.msgNotice": string; - "obsidianLiveSyncSettingTab.msgObjectStorageWarning": string; - "obsidianLiveSyncSettingTab.msgOriginCheck": string; - "obsidianLiveSyncSettingTab.msgRebuildRequired": string; - "obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": string; - "obsidianLiveSyncSettingTab.msgSetCorsCredentials": string; - "obsidianLiveSyncSettingTab.msgSetCorsOrigins": string; - "obsidianLiveSyncSettingTab.msgSetMaxDocSize": string; - "obsidianLiveSyncSettingTab.msgSetMaxRequestSize": string; - "obsidianLiveSyncSettingTab.msgSetRequireValidUser": string; - "obsidianLiveSyncSettingTab.msgSetRequireValidUserAuth": string; - "obsidianLiveSyncSettingTab.msgSettingModified": string; - "obsidianLiveSyncSettingTab.msgSettingsUnchangeableDuringSync": string; - "obsidianLiveSyncSettingTab.msgSetWwwAuth": string; - "obsidianLiveSyncSettingTab.nameApplySettings": string; - "obsidianLiveSyncSettingTab.nameConnectSetupURI": string; - "obsidianLiveSyncSettingTab.nameCopySetupURI": string; - "obsidianLiveSyncSettingTab.nameDisableHiddenFileSync": string; - "obsidianLiveSyncSettingTab.nameDiscardSettings": string; - "obsidianLiveSyncSettingTab.nameEnableHiddenFileSync": string; - "obsidianLiveSyncSettingTab.nameEnableLiveSync": string; - "obsidianLiveSyncSettingTab.nameHiddenFileSynchronization": string; - "obsidianLiveSyncSettingTab.nameManualSetup": string; - "obsidianLiveSyncSettingTab.nameTestConnection": string; - "obsidianLiveSyncSettingTab.nameTestDatabaseConnection": string; - "obsidianLiveSyncSettingTab.nameValidateDatabaseConfig": string; - "obsidianLiveSyncSettingTab.okAdminPrivileges": string; - "obsidianLiveSyncSettingTab.okCorsCredentials": string; - "obsidianLiveSyncSettingTab.okCorsCredentialsForOrigin": string; - "obsidianLiveSyncSettingTab.okCorsOriginMatched": string; - "obsidianLiveSyncSettingTab.okCorsOrigins": string; - "obsidianLiveSyncSettingTab.okEnableCors": string; - "obsidianLiveSyncSettingTab.okEnableCorsChttpd": string; - "obsidianLiveSyncSettingTab.okMaxDocumentSize": string; - "obsidianLiveSyncSettingTab.okMaxRequestSize": string; - "obsidianLiveSyncSettingTab.okRequireValidUser": string; - "obsidianLiveSyncSettingTab.okRequireValidUserAuth": string; - "obsidianLiveSyncSettingTab.okWwwAuth": string; - "obsidianLiveSyncSettingTab.optionApply": string; - "obsidianLiveSyncSettingTab.optionCancel": string; - "obsidianLiveSyncSettingTab.optionCouchDB": string; - "obsidianLiveSyncSettingTab.optionDisableAllAutomatic": string; - "obsidianLiveSyncSettingTab.optionFetchFromRemote": string; - "obsidianLiveSyncSettingTab.optionHere": string; - "obsidianLiveSyncSettingTab.optionLiveSync": string; - "obsidianLiveSyncSettingTab.optionMinioS3R2": string; - "obsidianLiveSyncSettingTab.optionOkReadEverything": string; - "obsidianLiveSyncSettingTab.optionOnEvents": string; - "obsidianLiveSyncSettingTab.optionPeriodicAndEvents": string; - "obsidianLiveSyncSettingTab.optionPeriodicWithBatch": string; - "obsidianLiveSyncSettingTab.optionRebuildBoth": string; - "obsidianLiveSyncSettingTab.optionSaveOnlySettings": string; - "obsidianLiveSyncSettingTab.panelChangeLog": string; - "obsidianLiveSyncSettingTab.panelGeneralSettings": string; - "obsidianLiveSyncSettingTab.panelPrivacyEncryption": string; - "obsidianLiveSyncSettingTab.panelRemoteConfiguration": string; - "obsidianLiveSyncSettingTab.panelSetup": string; - "obsidianLiveSyncSettingTab.serverVersion": string; - "obsidianLiveSyncSettingTab.titleActiveRemoteServer": string; - "obsidianLiveSyncSettingTab.titleAppearance": string; - "obsidianLiveSyncSettingTab.titleConflictResolution": string; - "obsidianLiveSyncSettingTab.titleCongratulations": string; - "obsidianLiveSyncSettingTab.titleCouchDB": string; - "obsidianLiveSyncSettingTab.titleDeletionPropagation": string; - "obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": string; - "obsidianLiveSyncSettingTab.titleEncryptionPassphraseInvalid": string; - "obsidianLiveSyncSettingTab.titleExtraFeatures": string; - "obsidianLiveSyncSettingTab.titleFetchConfig": string; - "obsidianLiveSyncSettingTab.titleFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.titleFetchSettings": string; - "obsidianLiveSyncSettingTab.titleHiddenFiles": string; - "obsidianLiveSyncSettingTab.titleLogging": string; - "obsidianLiveSyncSettingTab.titleMinioS3R2": string; - "obsidianLiveSyncSettingTab.titleNotification": string; - "obsidianLiveSyncSettingTab.titleOnlineTips": string; - "obsidianLiveSyncSettingTab.titleQuickSetup": string; - "obsidianLiveSyncSettingTab.titleRebuildRequired": string; - "obsidianLiveSyncSettingTab.titleRemoteConfigCheckFailed": string; - "obsidianLiveSyncSettingTab.titleRemoteServer": string; - "obsidianLiveSyncSettingTab.titleReset": string; - "obsidianLiveSyncSettingTab.titleSetupOtherDevices": string; - "obsidianLiveSyncSettingTab.titleSynchronizationMethod": string; - "obsidianLiveSyncSettingTab.titleSynchronizationPreset": string; - "obsidianLiveSyncSettingTab.titleSyncSettings": string; - "obsidianLiveSyncSettingTab.titleSyncSettingsViaMarkdown": string; - "obsidianLiveSyncSettingTab.titleUpdateThinning": string; - "obsidianLiveSyncSettingTab.warnCorsOriginUnmatched": string; - "obsidianLiveSyncSettingTab.warnNoAdmin": string; - Ok: string; - "Old Algorithm": string; - "Older fallback (Slow, W/O WebAssembly)": string; - Open: string; - "Open the dialog": string; - Overwrite: string; - "Overwrite patterns": string; - "Overwrite remote": string; - "Overwrite remote with local DB and passphrase.": string; - "Overwrite Server Data with This Device's Files": string; - "P2P.AskPassphraseForDecrypt": string; - "P2P.AskPassphraseForShare": string; - "P2P.DisabledButNeed": string; - "P2P.FailedToOpen": string; - "P2P.NoAutoSyncPeers": string; - "P2P.NoKnownPeers": string; - "P2P.Note.description": string; - "P2P.Note.important_note": string; - "P2P.Note.important_note_sub": string; - "P2P.Note.Summary": string; - "P2P.NotEnabled": string; - "P2P.P2PReplication": string; - "P2P.PaneTitle": string; - "P2P.ReplicatorInstanceMissing": string; - "P2P.SeemsOffline": string; - "P2P.SyncAlreadyRunning": string; - "P2P.SyncCompleted": string; - "P2P.SyncStartedWith": string; - "paneMaintenance.markDeviceResolvedAfterBackup": string; - "paneMaintenance.remoteLockedAndDeviceNotAccepted": string; - "paneMaintenance.remoteLockedResolvedDevice": string; - "paneMaintenance.unlockDatabaseReady": string; - Passphrase: string; - "Passphrase of sensitive configuration items": string; - password: string; - Password: string; - "Paste a connection string": string; - "Paste the Setup URI generated from one of your active devices.": string; - "Path Obfuscation": string; - "Patterns to match files for overwriting instead of merging": string; - "Patterns to match files for syncing": string; - "Peer-to-Peer only": string; - "Peer-to-Peer Synchronisation": string; - "Per-file-saved customization sync": string; - Perform: string; - "Perform cleanup": string; - "Perform Garbage Collection": string; - "Perform Garbage Collection to remove unused chunks and reduce database size.": string; - "Periodic Sync interval": string; - "Pick a file to resolve conflict": string; - "Please disable 'Read chunks online' in settings to use Garbage Collection.": string; - "Please enable 'Compute revisions for chunks' in settings to use Garbage Collection.": string; - "Please select 'Cancel' explicitly to cancel this operation.": string; - "Please select a method to import the settings from another device.": string; - "Please select an option to proceed": string; - "Please select the type of server to which you are connecting.": string; - "Please set device name to identify this device. This name should be unique among your devices. While not configured, we cannot enable this feature.": string; - "Please set this device name": string; - "Plug-in version": string; - "Prepare the 'report' to create an issue": string; - Presets: string; - "Proceed Garbage Collection": string; - "Proceed with Setup URI": string; - "Proceeding with Garbage Collection, ignoring missing nodes.": string; - "Proceeding with Garbage Collection.": string; - "Process small files in the foreground": string; - Progress: string; - "PureJS fallback (Fast, W/O WebAssembly)": string; - "Purge all download/upload cache.": string; - "Purge all journal counter": string; - "Rebuild local and remote database with local files.": string; - "Rebuilding Operations (Remote Only)": string; - "Recreate all": string; - "Recreate missing chunks for all files": string; - "RedFlag.Fetch.Method.Desc": string; - "RedFlag.Fetch.Method.FetchSafer": string; - "RedFlag.Fetch.Method.FetchSmoother": string; - "RedFlag.Fetch.Method.FetchTraditional": string; - "RedFlag.Fetch.Method.Title": string; - "RedFlag.FetchRemoteConfig.Buttons.Cancel": string; - "RedFlag.FetchRemoteConfig.Buttons.Fetch": string; - "RedFlag.FetchRemoteConfig.Message": string; - "RedFlag.FetchRemoteConfig.Title": string; - "Reduces storage space by discarding all non-latest revisions. This requires the same amount of free space on the remote server and the local client.": string; - "Reducing the frequency with which on-disk changes are reflected into the DB": string; - Region: string; - Remediation: string; - "Remediation Setting Changed": string; - "Remote Database Tweak (In sunset)": string; - "Remote Databases": string; - "Remote name": string; - "Remote server type": string; - "Remote Type": string; - Rename: string; - "Replicator.Dialogue.Locked.Action.Dismiss": string; - "Replicator.Dialogue.Locked.Action.Fetch": string; - "Replicator.Dialogue.Locked.Action.Unlock": string; - "Replicator.Dialogue.Locked.Message": string; - "Replicator.Dialogue.Locked.Message.Fetch": string; - "Replicator.Dialogue.Locked.Message.Unlocked": string; - "Replicator.Dialogue.Locked.Title": string; - "Replicator.Message.Cleaned": string; - "Replicator.Message.InitialiseFatalError": string; - "Replicator.Message.Pending": string; - "Replicator.Message.SomeModuleFailed": string; - "Replicator.Message.VersionUpFlash": string; - "Requires restart of Obsidian": string; - "Requires restart of Obsidian.": string; - "Rerun Onboarding Wizard": string; - "Rerun the onboarding wizard to set up Self-hosted LiveSync again.": string; - "Rerun Wizard": string; - Resend: string; - "Resend all chunks to the remote.": string; - Reset: string; - "Reset all": string; - "Reset all journal counter": string; - "Reset journal received history": string; - "Reset journal sent history": string; - "Reset notification threshold and check the remote database usage": string; - "Reset received": string; - "Reset sent history": string; - "Reset Synchronisation information": string; - "Reset Synchronisation on This Device": string; - "Reset the remote storage size threshold and check the remote storage size again.": string; - "Resolve All": string; - "Resolve all conflicted files": string; - "Resolve All conflicted files by the newer one": string; - "Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one.": string; - "Restart Now": string; - "Restore or reconstruct local database from remote.": string; - "Run Doctor": string; - "S3/MinIO/R2 Object Storage": string; - "Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.": string; - "Saving will be performed forcefully after this number of seconds.": string; - "Scan a QR Code (Recommended for mobile)": string; - "Scan changes on customization sync": string; - "Scan customization automatically": string; - "Scan customization before replicating.": string; - "Scan customization every 1 minute.": string; - "Scan customization periodically": string; - "Scan for Broken files": string; - "Scan for hidden files before replication": string; - "Scan hidden files periodically": string; - "Scan the QR code displayed on an active device using this device's camera.": string; - "Schedule and Restart": string; - "Scram Switches": string; - "Scram!": string; - "Seconds, 0 to disable": string; - "Seconds. Saving to the local database will be delayed until this value after we stop typing or saving.": string; - "Secret Key": string; - "Select the database adapter to use.": string; - Send: string; - "Send chunks": string; - "Server URI": string; - "Setting.GenerateKeyPair.Desc": string; - "Setting.GenerateKeyPair.Title": string; - "Setting.TroubleShooting": string; - "Setting.TroubleShooting.Doctor": string; - "Setting.TroubleShooting.Doctor.Desc": string; - "Setting.TroubleShooting.ScanBrokenFiles": string; - "Setting.TroubleShooting.ScanBrokenFiles.Desc": string; - "SettingTab.Message.AskRebuild": string; - "Setup URI dialog cancelled.": string; - "Setup.Apply.Buttons.ApplyAndFetch": string; - "Setup.Apply.Buttons.ApplyAndMerge": string; - "Setup.Apply.Buttons.ApplyAndRebuild": string; - "Setup.Apply.Buttons.Cancel": string; - "Setup.Apply.Buttons.OnlyApply": string; - "Setup.Apply.Message": string; - "Setup.Apply.Title": string; - "Setup.Apply.WarningRebuildRecommended": string; - "Setup.Doctor.Buttons.No": string; - "Setup.Doctor.Buttons.Yes": string; - "Setup.Doctor.Message": string; - "Setup.Doctor.Title": string; - "Setup.FetchRemoteConf.Buttons.Fetch": string; - "Setup.FetchRemoteConf.Buttons.Skip": string; - "Setup.FetchRemoteConf.Message": string; - "Setup.FetchRemoteConf.Title": string; - "Setup.QRCode": string; - "Setup.RemoteE2EE.AdvancedTitle": string; - "Setup.RemoteE2EE.AlgorithmWarning": string; - "Setup.RemoteE2EE.ButtonCancel": string; - "Setup.RemoteE2EE.ButtonProceed": string; - "Setup.RemoteE2EE.DefaultAlgorithmDesc": string; - "Setup.RemoteE2EE.Guidance": string; - "Setup.RemoteE2EE.LabelEncrypt": string; - "Setup.RemoteE2EE.LabelEncryptionAlgorithm": string; - "Setup.RemoteE2EE.LabelObfuscateProperties": string; - "Setup.RemoteE2EE.MultiDestinationWarning": string; - "Setup.RemoteE2EE.ObfuscatePropertiesDesc": string; - "Setup.RemoteE2EE.PassphraseValidationLine1": string; - "Setup.RemoteE2EE.PassphraseValidationLine2": string; - "Setup.RemoteE2EE.PlaceholderPassphrase": string; - "Setup.RemoteE2EE.StronglyRecommendedLine1": string; - "Setup.RemoteE2EE.StronglyRecommendedLine2": string; - "Setup.RemoteE2EE.StronglyRecommendedTitle": string; - "Setup.RemoteE2EE.Title": string; - "Setup.ScanQRCode.ButtonClose": string; - "Setup.ScanQRCode.Guidance": string; - "Setup.ScanQRCode.Step1": string; - "Setup.ScanQRCode.Step2": string; - "Setup.ScanQRCode.Step3": string; - "Setup.ScanQRCode.Step4": string; - "Setup.ScanQRCode.Title": string; - "Setup.ShowQRCode": string; - "Setup.ShowQRCode.Desc": string; - "Setup.UseSetupURI.ButtonCancel": string; - "Setup.UseSetupURI.ButtonProceed": string; - "Setup.UseSetupURI.ErrorFailedToParse": string; - "Setup.UseSetupURI.ErrorPassphraseRequired": string; - "Setup.UseSetupURI.GuidanceLine1": string; - "Setup.UseSetupURI.GuidanceLine2": string; - "Setup.UseSetupURI.InvalidInfo": string; - "Setup.UseSetupURI.LabelPassphrase": string; - "Setup.UseSetupURI.LabelSetupURI": string; - "Setup.UseSetupURI.PlaceholderPassphrase": string; - "Setup.UseSetupURI.Title": string; - "Setup.UseSetupURI.ValidInfo": string; - "Should we keep folders that don't have any files inside?": string; - "Should we only check for conflicts when a file is opened?": string; - "Should we prompt you about conflicting files when a file is opened?": string; - "Should we prompt you for every single merge, even if we can safely merge automatcially?": string; - "Show full banner": string; - "Show only notifications": string; - "Show status as icons only": string; - "Show status icon instead of file warnings banner": string; - "Show status inside the editor": string; - "Show status on the status bar": string; - "Show verbose log. Please enable if you report an issue.": string; - "Some devices have differing progress values (max: ${maxProgress}, min: ${minProgress}).\nThis may indicate that some devices have not completed synchronisation, which could lead to conflicts. Strongly recommend confirming that all devices are synchronised before proceeding.": string; - "Starts synchronisation when a file is saved.": string; - "Stop reflecting database changes to storage files.": string; - "Stop watching for file changes.": string; - "Suppress notification of hidden files change": string; - "Suspend database reflecting": string; - "Suspend file watching": string; - "Switch to IDB": string; - "Switch to IndexedDB": string; - "Sync after merging file": string; - "Sync automatically after merging files": string; - "Sync Mode": string; - "Sync on Editor Save": string; - "Sync on File Open": string; - "Sync on Save": string; - "Sync on Startup": string; - "Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage.": string; - "Synchronising files": string; - Syncing: string; - "Target patterns": string; - "Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.": string; - "The delay for consecutive on-demand fetches": string; - "The following accepted nodes are missing its node information:\n- ${missingNodes}\n\nThis indicates that they have not been connected for some time or have been left on an older version.\nIt is preferable to update all devices if possible. If you have any devices that are no longer in use, you can clear all accepted nodes by locking the remote once.": string; - "The Hash algorithm for chunk IDs": string; - "The maximum duration for which chunks can be incubated within the document. Chunks exceeding this period will graduate to independent chunks.": string; - "The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks.": string; - "The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks.": string; - "The minimum interval for automatic synchronisation on event.": string; - "This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.": string; - "This is an advanced option for users who do not have a URI or who wish to configure detailed settings.": string; - "This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.": string; - "This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.": string; - "This will recreate chunks for all files. If there were missing chunks, this may fix the errors.": string; - "Transfer Tweak": string; - "TweakMismatchResolve.Action.Dismiss": string; - "TweakMismatchResolve.Action.UseConfigured": string; - "TweakMismatchResolve.Action.UseMine": string; - "TweakMismatchResolve.Action.UseMineAcceptIncompatible": string; - "TweakMismatchResolve.Action.UseMineWithRebuild": string; - "TweakMismatchResolve.Action.UseRemote": string; - "TweakMismatchResolve.Action.UseRemoteAcceptIncompatible": string; - "TweakMismatchResolve.Action.UseRemoteWithRebuild": string; - "TweakMismatchResolve.Message.Main": string; - "TweakMismatchResolve.Message.MainTweakResolving": string; - "TweakMismatchResolve.Message.UseRemote.WarningRebuildRecommended": string; - "TweakMismatchResolve.Message.UseRemote.WarningRebuildRequired": string; - "TweakMismatchResolve.Message.WarningIncompatibleRebuildRecommended": string; - "TweakMismatchResolve.Message.WarningIncompatibleRebuildRequired": string; - "TweakMismatchResolve.Table": string; - "TweakMismatchResolve.Table.Row": string; - "TweakMismatchResolve.Title": string; - "TweakMismatchResolve.Title.TweakResolving": string; - "TweakMismatchResolve.Title.UseRemoteConfig": string; - "Unique name between all synchronized devices. To edit this setting, please disable customization sync once.": string; - "Use a custom passphrase": string; - "Use a Setup URI (Recommended)": string; - "Use Custom HTTP Handler": string; - "Use dynamic iteration count": string; - "Use Segmented-splitter": string; - "Use splitting-limit-capped chunk splitter": string; - "Use the trash bin": string; - "Use timeouts instead of heartbeats": string; - username: string; - Username: string; - "Verbose Log": string; - "Verify all": string; - "Verify and repair all files": string; - "Warning! This will have a serious impact on performance. And the logs will not be synchronised under the default name. Please be careful with logs; they often contain your confidential information.": string; - "We cannot change the device name while this feature is enabled. Please disable this feature to change the device name.": string; - "We will now guide you through a few questions to simplify the synchronisation setup.": string; - "We will now proceed with the server configuration.": string; - "Welcome to Self-hosted LiveSync": string; - "When you save a file in the editor, start a sync automatically": string; - "Write credentials in the file": string; - "Write logs into the file": string; - "xxhash32 (Fast but less collision resistance)": string; - "xxhash64 (Fastest)": string; - "Yes, I want to add this device to my existing synchronisation": string; - "Yes, I want to set up a new synchronisation": string; - "You are adding this device to an existing synchronisation setup.": string; - }; -}; diff --git a/_types/src/lib/src/common/messages/ko.d.ts b/_types/src/lib/src/common/messages/ko.d.ts deleted file mode 100644 index cc611158..00000000 --- a/_types/src/lib/src/common/messages/ko.d.ts +++ /dev/null @@ -1,802 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare const PartialMessages: { - readonly ko: { - "(Active)": string; - "(BETA) Always overwrite with a newer file": string; - "(Beta) Use ignore files": string; - "(Days passed, 0 to disable automatic-deletion)": string; - "(ex. Read chunks online) If this option is enabled, LiveSync reads chunks online directly instead of replicating them locally. Increasing Custom chunk size is recommended.": string; - "(MB) If this is set, changes to local and remote files that are larger than this will be skipped. If the file becomes smaller again, a newer one will be used.": string; - "(Mega chars)": string; - "(Not recommended) If set, credentials will be stored in the file.": string; - "(Obsolete) Use an old adapter for compatibility": string; - "(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": string; - "(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": string; - "(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": string; - "(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": string; - "> [!INFO]- The connected devices have been detected as follows:\n${devices}": string; - "A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration.": string; - "Access Key": string; - Activate: string; - "Add default patterns": string; - "Add new connection": string; - "All devices have the same progress value (${progress}). Your devices seem to be synchronised. And be able to proceed with Garbage Collection.": string; - "Always prompt merge conflicts": string; - "Analyse database usage": string; - "Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like.": string; - "Apply Latest Change if Conflicting": string; - "Apply preset configuration": string; - "Ask a passphrase at every launch": string; - "Automatically Sync all files when opening Obsidian.": string; - Back: string; - "Back to non-configured": string; - "Batch database update": string; - "Batch limit": string; - "Batch size": string; - "Batch size of on-demand fetching": string; - "Before v0.17.16, we used an old adapter for the local database. Now the new adapter is preferred. However, it needs local database rebuilding. Please disable this toggle when you have enough time. If leave it enabled, also while fetching from the remote database, you will be asked to disable this.": string; - "Bucket Name": string; - Cancel: string; - "Cancel Garbage Collection": string; - "Check and convert non-path-obfuscated files": string; - "Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.": string; - "cmdConfigSync.showCustomizationSync": string; - "Comma separated `.gitignore, .dockerignore`": string; - "Compaction in progress on remote database...": string; - "Compaction on remote database completed successfully.": string; - "Compaction on remote database failed.": string; - "Compaction on remote database timed out.": string; - "Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep.": string; - "Compatibility (Conflict Behaviour)": string; - "Compatibility (Database structure)": string; - "Compatibility (Internal API Usage)": string; - "Compatibility (Metadata)": string; - "Compatibility (Remote Database)": string; - "Compatibility (Trouble addressed)": string; - "Compute revisions for chunks": string; - "Configuration Encryption": string; - Configure: string; - "Configure And Change Remote": string; - "Configure E2EE": string; - "Configure Remote": string; - "Configure the same server information as your other devices again, manually, very advanced users only.": string; - "Connection Method": string; - "Continue to CouchDB setup": string; - "Continue to Peer-to-Peer only setup": string; - "Continue to S3/MinIO/R2 setup": string; - Copy: string; - "Copy Report to clipboard": string; - "CouchDB Connection Tweak": string; - "Cross-platform": string; - "Current adapter: {adapter}": string; - "Customization Sync": string; - "Customization Sync (Beta3)": string; - "Data Compression": string; - "Database Adapter": string; - "Database Name": string; - "Database suffix": string; - Default: string; - "Delay conflict resolution of inactive files": string; - "Delay merge conflict prompt for inactive files.": string; - Delete: string; - "Delete all customization sync data": string; - "Delete all data on the remote server.": string; - "Delete local database to reset or uninstall Self-hosted LiveSync": string; - "Delete old metadata of deleted files on start-up": string; - "Delete Remote Configuration": string; - "Delete remote configuration '{name}'?": string; - desktop: string; - Developer: string; - Device: string; - "Device name": string; - "Device Setup Method": string; - "dialog.yourLanguageAvailable": string; - "dialog.yourLanguageAvailable.btnRevertToDefault": string; - "dialog.yourLanguageAvailable.Title": string; - "Disables all synchronization and restart.": string; - "Disables logging, only shows notifications. Please disable if you report an issue.": string; - "Display Language": string; - "Display name": string; - "Do not check configuration mismatch before replication": string; - "Do not keep metadata of deleted files.": string; - "Do not split chunks in the background": string; - "Do not use internal API": string; - "Doctor.Button.DismissThisVersion": string; - "Doctor.Button.Fix": string; - "Doctor.Button.FixButNoRebuild": string; - "Doctor.Button.No": string; - "Doctor.Button.Skip": string; - "Doctor.Button.Yes": string; - "Doctor.Dialogue.Main": string; - "Doctor.Dialogue.MainFix": string; - "Doctor.Dialogue.Title": string; - "Doctor.Dialogue.TitleAlmostDone": string; - "Doctor.Dialogue.TitleFix": string; - "Doctor.Level.Must": string; - "Doctor.Level.Necessary": string; - "Doctor.Level.Optional": string; - "Doctor.Level.Recommended": string; - "Doctor.Message.NoIssues": string; - "Doctor.Message.RebuildLocalRequired": string; - "Doctor.Message.RebuildRequired": string; - "Doctor.Message.SomeSkipped": string; - Duplicate: string; - "Duplicate remote": string; - "E2EE Configuration": string; - "Edge case addressing (Behaviour)": string; - "Edge case addressing (Database)": string; - "Edge case addressing (Processing)": string; - "Emergency restart": string; - "Enable advanced features": string; - "Enable customization sync": string; - "Enable Developers' Debug Tools.": string; - "Enable edge case treatment features": string; - "Enable poweruser features": string; - "Enable this if your Object Storage doesn't support CORS": string; - "Enable this option to automatically apply the most recent change to documents even when it conflicts": string; - "Encrypt contents on the remote database. If you use the plugin's synchronization feature, enabling this is recommended.": string; - "Encrypting sensitive configuration items": string; - "Encryption phassphrase. If changed, you should overwrite the server's database with the new (encrypted) files.": string; - "End-to-End Encryption": string; - "Endpoint URL": string; - "Enhance chunk size": string; - "Enter Server Information": string; - "Enter the server information manually": string; - Export: string; - "Failed to connect to remote for compaction.": string; - "Failed to connect to remote for compaction. ${reason}": string; - "Failed to start one-shot replication before Garbage Collection. Garbage Collection Cancelled.": string; - "Failed to start replication after Garbage Collection.": string; - Fetch: string; - "Fetch chunks on demand": string; - "Fetch database with previous behaviour": string; - "Fetch remote settings": string; - "File to resolve conflict": string; - Filename: string; - "First, please select the option that best describes your current situation.": string; - "Flag and restart": string; - "Forces the file to be synced when opened.": string; - "Fresh Start Wipe": string; - "Garbage Collection cancelled by user.": string; - "Garbage Collection completed. Deleted chunks: ${deletedChunks} / ${totalChunks}. Time taken: ${seconds} seconds.": string; - "Garbage Collection Confirmation": string; - "Garbage Collection V3 (Beta)": string; - "Garbage Collection: Found ${unusedChunks} unused chunks to delete.": string; - "Garbage Collection: Scanned ${scanned} / ~${docCount}": string; - "Garbage Collection: Scanning completed. Total chunks: ${totalChunks}, Used chunks: ${usedChunks}": string; - "Handle files as Case-Sensitive": string; - "Hidden Files": string; - "How to display network errors when the sync server is unreachable.": string; - "How would you like to configure the connection to your server?": string; - "I am adding a device to an existing synchronisation setup": string; - "I am setting this up for the first time": string; - "I know my server details, let me enter them": string; - "If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).": string; - "If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.": string; - "If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker.": string; - "If enabled, newly created chunks are temporarily kept within the document, and graduated to become independent chunks once stabilised.": string; - "If enabled, the \u26D4 icon will be shown inside the status instead of the file warnings banner. No details will be shown.": string; - "If enabled, the file under 1kb will be processed in the UI thread.": string; - "If enabled, the notification of hidden files change will be suppressed.": string; - "If this enabled, all chunks will be stored with the revision made from its content. (Previous behaviour)": string; - "If this enabled, All files are handled as case-Sensitive (Previous behaviour).": string; - "If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature.": string; - "If this is set, changes to local files which are matched by the ignore files will be skipped. Remote changes are determined using local ignore files.": string; - "If this option is enabled, PouchDB will hold the connection open for 60 seconds, and if no change arrives in that time, close and reopen the socket, instead of holding it open indefinitely. Useful when a proxy limits request duration but can increase resource usage.": string; - "Ignore and Proceed": string; - "Ignore files": string; - "Ignore patterns": string; - "Import connection": string; - "Incubate Chunks in Document": string; - "Initialise all journal history, On the next sync, every item will be received and sent.": string; - "Interval (sec)": string; - "K.exp": string; - "K.long_p2p_sync": string; - "K.P2P": string; - "K.Peer": string; - "K.ScanCustomization": string; - "K.short_p2p_sync": string; - "K.title_p2p_sync": string; - "Keep empty folder": string; - lang_def: string; - "lang-de": string; - "lang-def": string; - "lang-es": string; - "lang-fr": string; - "lang-ja": string; - "lang-ko": string; - "lang-ru": string; - "lang-zh": string; - "lang-zh-tw": string; - Later: string; - "Limit: {datetime} ({timestamp})": string; - "LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured.": string; - "liveSyncReplicator.beforeLiveSync": string; - "liveSyncReplicator.cantReplicateLowerValue": string; - "liveSyncReplicator.checkingLastSyncPoint": string; - "liveSyncReplicator.couldNotConnectTo": string; - "liveSyncReplicator.couldNotConnectToRemoteDb": string; - "liveSyncReplicator.couldNotConnectToServer": string; - "liveSyncReplicator.couldNotConnectToURI": string; - "liveSyncReplicator.couldNotMarkResolveRemoteDb": string; - "liveSyncReplicator.liveSyncBegin": string; - "liveSyncReplicator.lockRemoteDb": string; - "liveSyncReplicator.markDeviceResolved": string; - "liveSyncReplicator.oneShotSyncBegin": string; - "liveSyncReplicator.remoteDbCorrupted": string; - "liveSyncReplicator.remoteDbCreatedOrConnected": string; - "liveSyncReplicator.remoteDbDestroyed": string; - "liveSyncReplicator.remoteDbDestroyError": string; - "liveSyncReplicator.remoteDbMarkedResolved": string; - "liveSyncReplicator.replicationClosed": string; - "liveSyncReplicator.replicationInProgress": string; - "liveSyncReplicator.retryLowerBatchSize": string; - "liveSyncReplicator.unlockRemoteDb": string; - "liveSyncSetting.errorNoSuchSettingItem": string; - "liveSyncSetting.originalValue": string; - "liveSyncSetting.valueShouldBeInRange": string; - "liveSyncSettings.btnApply": string; - "Local Database Tweak": string; - Lock: string; - "Lock Server": string; - "Lock the remote server to prevent synchronization with other devices.": string; - "logPane.autoScroll": string; - "logPane.logWindowOpened": string; - "logPane.pause": string; - "logPane.title": string; - "logPane.wrap": string; - "Maximum delay for batch database updating": string; - "Maximum file size": string; - "Maximum Incubating Chunk Size": string; - "Maximum Incubating Chunks": string; - "Maximum Incubation Period": string; - "MB (0 to disable).": string; - "Memory cache": string; - "Memory cache size (by total characters)": string; - "Memory cache size (by total items)": string; - Merge: string; - "Minimum delay for batch database updating": string; - "Minimum interval for syncing": string; - "moduleCheckRemoteSize.logCheckingStorageSizes": string; - "moduleCheckRemoteSize.logCurrentStorageSize": string; - "moduleCheckRemoteSize.logExceededWarning": string; - "moduleCheckRemoteSize.logThresholdEnlarged": string; - "moduleCheckRemoteSize.msgConfirmRebuild": string; - "moduleCheckRemoteSize.msgDatabaseGrowing": string; - "moduleCheckRemoteSize.msgSetDBCapacity": string; - "moduleCheckRemoteSize.option2GB": string; - "moduleCheckRemoteSize.option800MB": string; - "moduleCheckRemoteSize.optionAskMeLater": string; - "moduleCheckRemoteSize.optionDismiss": string; - "moduleCheckRemoteSize.optionIncreaseLimit": string; - "moduleCheckRemoteSize.optionNoWarn": string; - "moduleCheckRemoteSize.optionRebuildAll": string; - "moduleCheckRemoteSize.titleDatabaseSizeLimitExceeded": string; - "moduleCheckRemoteSize.titleDatabaseSizeNotify": string; - "moduleInputUIObsidian.defaultTitleConfirmation": string; - "moduleInputUIObsidian.defaultTitleSelect": string; - "moduleInputUIObsidian.optionNo": string; - "moduleInputUIObsidian.optionYes": string; - "moduleLiveSyncMain.logAdditionalSafetyScan": string; - "moduleLiveSyncMain.logLoadingPlugin": string; - "moduleLiveSyncMain.logPluginInitCancelled": string; - "moduleLiveSyncMain.logPluginVersion": string; - "moduleLiveSyncMain.logReadChangelog": string; - "moduleLiveSyncMain.logSafetyScanCompleted": string; - "moduleLiveSyncMain.logSafetyScanFailed": string; - "moduleLiveSyncMain.logUnloadingPlugin": string; - "moduleLiveSyncMain.logVersionUpdate": string; - "moduleLiveSyncMain.msgScramEnabled": string; - "moduleLiveSyncMain.optionKeepLiveSyncDisabled": string; - "moduleLiveSyncMain.optionResumeAndRestart": string; - "moduleLiveSyncMain.titleScramEnabled": string; - "moduleLocalDatabase.logWaitingForReady": string; - "moduleLog.showLog": string; - "moduleMigration.docUri": string; - "moduleMigration.logBulkSendCorrupted": string; - "moduleMigration.logFetchRemoteTweakFailed": string; - "moduleMigration.logLocalDatabaseNotReady": string; - "moduleMigration.logMigratedSameBehaviour": string; - "moduleMigration.logMigrationFailed": string; - "moduleMigration.logRedflag2CreationFail": string; - "moduleMigration.logRemoteTweakUnavailable": string; - "moduleMigration.logSetupCancelled": string; - "moduleMigration.msgFetchRemoteAgain": string; - "moduleMigration.msgInitialSetup": string; - "moduleMigration.msgRecommendSetupUri": string; - "moduleMigration.msgSinceV02321": string; - "moduleMigration.optionAdjustRemote": string; - "moduleMigration.optionDecideLater": string; - "moduleMigration.optionEnableBoth": string; - "moduleMigration.optionEnableFilenameCaseInsensitive": string; - "moduleMigration.optionEnableFixedRevisionForChunks": string; - "moduleMigration.optionHaveSetupUri": string; - "moduleMigration.optionKeepPreviousBehaviour": string; - "moduleMigration.optionManualSetup": string; - "moduleMigration.optionNoAskAgain": string; - "moduleMigration.optionNoSetupUri": string; - "moduleMigration.optionRemindNextLaunch": string; - "moduleMigration.optionSetupViaP2P": string; - "moduleMigration.optionSetupWizard": string; - "moduleMigration.optionYesFetchAgain": string; - "moduleMigration.titleCaseSensitivity": string; - "moduleMigration.titleRecommendSetupUri": string; - "moduleMigration.titleWelcome": string; - "moduleObsidianMenu.replicate": string; - "More actions": string; - "Move remotely deleted files to the trash, instead of deleting.": string; - "Network warning style": string; - "New Remote": string; - "No connected device information found. Cancelling Garbage Collection.": string; - "No limit configured": string; - "No, please take me back": string; - "Node ID": string; - "Node Information Missing": string; - "Non-Synchronising files": string; - "Normal Files": string; - "Not all messages have been translated. And, please revert to \"Default\" when reporting errors.": string; - "Notify all setting files": string; - "Notify customized": string; - "Notify when other device has newly customized.": string; - "Notify when the estimated remote storage size exceeds on start up": string; - "Number of batches to process at a time. Defaults to 40. Minimum is 2. This along with batch size controls how many docs are kept in memory at a time.": string; - "Number of changes to sync at a time. Defaults to 50. Minimum is 2.": string; - "Obsidian version": string; - "obsidianLiveSyncSettingTab.btnApply": string; - "obsidianLiveSyncSettingTab.btnCheck": string; - "obsidianLiveSyncSettingTab.btnCopy": string; - "obsidianLiveSyncSettingTab.btnDisable": string; - "obsidianLiveSyncSettingTab.btnDiscard": string; - "obsidianLiveSyncSettingTab.btnEnable": string; - "obsidianLiveSyncSettingTab.btnFix": string; - "obsidianLiveSyncSettingTab.btnGotItAndUpdated": string; - "obsidianLiveSyncSettingTab.btnNext": string; - "obsidianLiveSyncSettingTab.btnStart": string; - "obsidianLiveSyncSettingTab.btnTest": string; - "obsidianLiveSyncSettingTab.btnUse": string; - "obsidianLiveSyncSettingTab.buttonFetch": string; - "obsidianLiveSyncSettingTab.buttonNext": string; - "obsidianLiveSyncSettingTab.defaultLanguage": string; - "obsidianLiveSyncSettingTab.descConnectSetupURI": string; - "obsidianLiveSyncSettingTab.descCopySetupURI": string; - "obsidianLiveSyncSettingTab.descEnableLiveSync": string; - "obsidianLiveSyncSettingTab.descFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.descManualSetup": string; - "obsidianLiveSyncSettingTab.descTestDatabaseConnection": string; - "obsidianLiveSyncSettingTab.descValidateDatabaseConfig": string; - "obsidianLiveSyncSettingTab.errAccessForbidden": string; - "obsidianLiveSyncSettingTab.errCannotContinueTest": string; - "obsidianLiveSyncSettingTab.errCorsCredentials": string; - "obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials": string; - "obsidianLiveSyncSettingTab.errCorsOrigins": string; - "obsidianLiveSyncSettingTab.errEnableCors": string; - "obsidianLiveSyncSettingTab.errMaxDocumentSize": string; - "obsidianLiveSyncSettingTab.errMaxRequestSize": string; - "obsidianLiveSyncSettingTab.errMissingWwwAuth": string; - "obsidianLiveSyncSettingTab.errRequireValidUser": string; - "obsidianLiveSyncSettingTab.errRequireValidUserAuth": string; - "obsidianLiveSyncSettingTab.labelDisabled": string; - "obsidianLiveSyncSettingTab.labelEnabled": string; - "obsidianLiveSyncSettingTab.levelAdvanced": string; - "obsidianLiveSyncSettingTab.levelEdgeCase": string; - "obsidianLiveSyncSettingTab.levelPowerUser": string; - "obsidianLiveSyncSettingTab.linkOpenInBrowser": string; - "obsidianLiveSyncSettingTab.linkPageTop": string; - "obsidianLiveSyncSettingTab.linkTipsAndTroubleshooting": string; - "obsidianLiveSyncSettingTab.linkTroubleshooting": string; - "obsidianLiveSyncSettingTab.logCannotUseCloudant": string; - "obsidianLiveSyncSettingTab.logCheckingConfigDone": string; - "obsidianLiveSyncSettingTab.logCheckingConfigFailed": string; - "obsidianLiveSyncSettingTab.logCheckingDbConfig": string; - "obsidianLiveSyncSettingTab.logCheckPassphraseFailed": string; - "obsidianLiveSyncSettingTab.logConfiguredDisabled": string; - "obsidianLiveSyncSettingTab.logConfiguredLiveSync": string; - "obsidianLiveSyncSettingTab.logConfiguredPeriodic": string; - "obsidianLiveSyncSettingTab.logCouchDbConfigFail": string; - "obsidianLiveSyncSettingTab.logCouchDbConfigSet": string; - "obsidianLiveSyncSettingTab.logCouchDbConfigUpdated": string; - "obsidianLiveSyncSettingTab.logDatabaseConnected": string; - "obsidianLiveSyncSettingTab.logEncryptionNoPassphrase": string; - "obsidianLiveSyncSettingTab.logEncryptionNoSupport": string; - "obsidianLiveSyncSettingTab.logErrorOccurred": string; - "obsidianLiveSyncSettingTab.logEstimatedSize": string; - "obsidianLiveSyncSettingTab.logPassphraseInvalid": string; - "obsidianLiveSyncSettingTab.logPassphraseNotCompatible": string; - "obsidianLiveSyncSettingTab.logRebuildNote": string; - "obsidianLiveSyncSettingTab.logSelectAnyPreset": string; - "obsidianLiveSyncSettingTab.msgAreYouSureProceed": string; - "obsidianLiveSyncSettingTab.msgChangesNeedToBeApplied": string; - "obsidianLiveSyncSettingTab.msgConfigCheck": string; - "obsidianLiveSyncSettingTab.msgConfigCheckFailed": string; - "obsidianLiveSyncSettingTab.msgConnectionCheck": string; - "obsidianLiveSyncSettingTab.msgConnectionProxyNote": string; - "obsidianLiveSyncSettingTab.msgCurrentOrigin": string; - "obsidianLiveSyncSettingTab.msgDiscardConfirmation": string; - "obsidianLiveSyncSettingTab.msgDone": string; - "obsidianLiveSyncSettingTab.msgEnableCors": string; - "obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": string; - "obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.msgGenerateSetupURI": string; - "obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": string; - "obsidianLiveSyncSettingTab.msgInvalidPassphrase": string; - "obsidianLiveSyncSettingTab.msgNewVersionNote": string; - "obsidianLiveSyncSettingTab.msgNonHTTPSInfo": string; - "obsidianLiveSyncSettingTab.msgNonHTTPSWarning": string; - "obsidianLiveSyncSettingTab.msgNotice": string; - "obsidianLiveSyncSettingTab.msgObjectStorageWarning": string; - "obsidianLiveSyncSettingTab.msgOriginCheck": string; - "obsidianLiveSyncSettingTab.msgRebuildRequired": string; - "obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": string; - "obsidianLiveSyncSettingTab.msgSetCorsCredentials": string; - "obsidianLiveSyncSettingTab.msgSetCorsOrigins": string; - "obsidianLiveSyncSettingTab.msgSetMaxDocSize": string; - "obsidianLiveSyncSettingTab.msgSetMaxRequestSize": string; - "obsidianLiveSyncSettingTab.msgSetRequireValidUser": string; - "obsidianLiveSyncSettingTab.msgSetRequireValidUserAuth": string; - "obsidianLiveSyncSettingTab.msgSettingModified": string; - "obsidianLiveSyncSettingTab.msgSettingsUnchangeableDuringSync": string; - "obsidianLiveSyncSettingTab.msgSetWwwAuth": string; - "obsidianLiveSyncSettingTab.nameApplySettings": string; - "obsidianLiveSyncSettingTab.nameConnectSetupURI": string; - "obsidianLiveSyncSettingTab.nameCopySetupURI": string; - "obsidianLiveSyncSettingTab.nameDisableHiddenFileSync": string; - "obsidianLiveSyncSettingTab.nameDiscardSettings": string; - "obsidianLiveSyncSettingTab.nameEnableHiddenFileSync": string; - "obsidianLiveSyncSettingTab.nameEnableLiveSync": string; - "obsidianLiveSyncSettingTab.nameHiddenFileSynchronization": string; - "obsidianLiveSyncSettingTab.nameManualSetup": string; - "obsidianLiveSyncSettingTab.nameTestConnection": string; - "obsidianLiveSyncSettingTab.nameTestDatabaseConnection": string; - "obsidianLiveSyncSettingTab.nameValidateDatabaseConfig": string; - "obsidianLiveSyncSettingTab.okAdminPrivileges": string; - "obsidianLiveSyncSettingTab.okCorsCredentials": string; - "obsidianLiveSyncSettingTab.okCorsCredentialsForOrigin": string; - "obsidianLiveSyncSettingTab.okCorsOriginMatched": string; - "obsidianLiveSyncSettingTab.okCorsOrigins": string; - "obsidianLiveSyncSettingTab.okEnableCors": string; - "obsidianLiveSyncSettingTab.okMaxDocumentSize": string; - "obsidianLiveSyncSettingTab.okMaxRequestSize": string; - "obsidianLiveSyncSettingTab.okRequireValidUser": string; - "obsidianLiveSyncSettingTab.okRequireValidUserAuth": string; - "obsidianLiveSyncSettingTab.okWwwAuth": string; - "obsidianLiveSyncSettingTab.optionApply": string; - "obsidianLiveSyncSettingTab.optionCancel": string; - "obsidianLiveSyncSettingTab.optionCouchDB": string; - "obsidianLiveSyncSettingTab.optionDisableAllAutomatic": string; - "obsidianLiveSyncSettingTab.optionFetchFromRemote": string; - "obsidianLiveSyncSettingTab.optionHere": string; - "obsidianLiveSyncSettingTab.optionLiveSync": string; - "obsidianLiveSyncSettingTab.optionMinioS3R2": string; - "obsidianLiveSyncSettingTab.optionOkReadEverything": string; - "obsidianLiveSyncSettingTab.optionOnEvents": string; - "obsidianLiveSyncSettingTab.optionPeriodicAndEvents": string; - "obsidianLiveSyncSettingTab.optionPeriodicWithBatch": string; - "obsidianLiveSyncSettingTab.optionRebuildBoth": string; - "obsidianLiveSyncSettingTab.optionSaveOnlySettings": string; - "obsidianLiveSyncSettingTab.panelChangeLog": string; - "obsidianLiveSyncSettingTab.panelGeneralSettings": string; - "obsidianLiveSyncSettingTab.panelPrivacyEncryption": string; - "obsidianLiveSyncSettingTab.panelRemoteConfiguration": string; - "obsidianLiveSyncSettingTab.panelSetup": string; - "obsidianLiveSyncSettingTab.titleAppearance": string; - "obsidianLiveSyncSettingTab.titleConflictResolution": string; - "obsidianLiveSyncSettingTab.titleCongratulations": string; - "obsidianLiveSyncSettingTab.titleCouchDB": string; - "obsidianLiveSyncSettingTab.titleDeletionPropagation": string; - "obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": string; - "obsidianLiveSyncSettingTab.titleEncryptionPassphraseInvalid": string; - "obsidianLiveSyncSettingTab.titleExtraFeatures": string; - "obsidianLiveSyncSettingTab.titleFetchConfig": string; - "obsidianLiveSyncSettingTab.titleFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.titleFetchSettings": string; - "obsidianLiveSyncSettingTab.titleHiddenFiles": string; - "obsidianLiveSyncSettingTab.titleLogging": string; - "obsidianLiveSyncSettingTab.titleMinioS3R2": string; - "obsidianLiveSyncSettingTab.titleNotification": string; - "obsidianLiveSyncSettingTab.titleOnlineTips": string; - "obsidianLiveSyncSettingTab.titleQuickSetup": string; - "obsidianLiveSyncSettingTab.titleRebuildRequired": string; - "obsidianLiveSyncSettingTab.titleRemoteConfigCheckFailed": string; - "obsidianLiveSyncSettingTab.titleRemoteServer": string; - "obsidianLiveSyncSettingTab.titleReset": string; - "obsidianLiveSyncSettingTab.titleSetupOtherDevices": string; - "obsidianLiveSyncSettingTab.titleSynchronizationMethod": string; - "obsidianLiveSyncSettingTab.titleSynchronizationPreset": string; - "obsidianLiveSyncSettingTab.titleSyncSettings": string; - "obsidianLiveSyncSettingTab.titleSyncSettingsViaMarkdown": string; - "obsidianLiveSyncSettingTab.titleUpdateThinning": string; - "obsidianLiveSyncSettingTab.warnCorsOriginUnmatched": string; - "obsidianLiveSyncSettingTab.warnNoAdmin": string; - Ok: string; - "Old Algorithm": string; - "Older fallback (Slow, W/O WebAssembly)": string; - Open: string; - "Open the dialog": string; - Overwrite: string; - "Overwrite patterns": string; - "Overwrite remote": string; - "Overwrite remote with local DB and passphrase.": string; - "Overwrite Server Data with This Device's Files": string; - "P2P.AskPassphraseForDecrypt": string; - "P2P.AskPassphraseForShare": string; - "P2P.DisabledButNeed": string; - "P2P.FailedToOpen": string; - "P2P.NoAutoSyncPeers": string; - "P2P.NoKnownPeers": string; - "P2P.Note.description": string; - "P2P.Note.important_note": string; - "P2P.Note.important_note_sub": string; - "P2P.Note.Summary": string; - "P2P.NotEnabled": string; - "P2P.P2PReplication": string; - "P2P.PaneTitle": string; - "P2P.ReplicatorInstanceMissing": string; - "P2P.SeemsOffline": string; - "P2P.SyncAlreadyRunning": string; - "P2P.SyncCompleted": string; - "P2P.SyncStartedWith": string; - "paneMaintenance.markDeviceResolvedAfterBackup": string; - "paneMaintenance.remoteLockedAndDeviceNotAccepted": string; - "paneMaintenance.remoteLockedResolvedDevice": string; - "paneMaintenance.unlockDatabaseReady": string; - Passphrase: string; - "Passphrase of sensitive configuration items": string; - password: string; - Password: string; - "Paste a connection string": string; - "Paste the Setup URI generated from one of your active devices.": string; - "Path Obfuscation": string; - "Patterns to match files for overwriting instead of merging": string; - "Patterns to match files for syncing": string; - "Peer-to-Peer only": string; - "Peer-to-Peer Synchronisation": string; - "Per-file-saved customization sync": string; - Perform: string; - "Perform cleanup": string; - "Perform Garbage Collection": string; - "Perform Garbage Collection to remove unused chunks and reduce database size.": string; - "Periodic Sync interval": string; - "Pick a file to resolve conflict": string; - "Please disable 'Read chunks online' in settings to use Garbage Collection.": string; - "Please enable 'Compute revisions for chunks' in settings to use Garbage Collection.": string; - "Please select 'Cancel' explicitly to cancel this operation.": string; - "Please select a method to import the settings from another device.": string; - "Please select an option to proceed": string; - "Please select the type of server to which you are connecting.": string; - "Please set device name to identify this device. This name should be unique among your devices. While not configured, we cannot enable this feature.": string; - "Please set this device name": string; - "Plug-in version": string; - "Prepare the 'report' to create an issue": string; - Presets: string; - "Proceed Garbage Collection": string; - "Proceed with Setup URI": string; - "Proceeding with Garbage Collection, ignoring missing nodes.": string; - "Proceeding with Garbage Collection.": string; - "Process small files in the foreground": string; - Progress: string; - "PureJS fallback (Fast, W/O WebAssembly)": string; - "Purge all download/upload cache.": string; - "Purge all journal counter": string; - "Rebuild local and remote database with local files.": string; - "Rebuilding Operations (Remote Only)": string; - "Recreate all": string; - "Recreate missing chunks for all files": string; - "RedFlag.Fetch.Method.Desc": string; - "RedFlag.Fetch.Method.FetchSafer": string; - "RedFlag.Fetch.Method.FetchSmoother": string; - "RedFlag.Fetch.Method.FetchTraditional": string; - "RedFlag.Fetch.Method.Title": string; - "Reduces storage space by discarding all non-latest revisions. This requires the same amount of free space on the remote server and the local client.": string; - "Reducing the frequency with which on-disk changes are reflected into the DB": string; - Region: string; - Remediation: string; - "Remediation Setting Changed": string; - "Remote Database Tweak (In sunset)": string; - "Remote Databases": string; - "Remote name": string; - "Remote server type": string; - "Remote Type": string; - Rename: string; - "Replicator.Dialogue.Locked.Action.Dismiss": string; - "Replicator.Dialogue.Locked.Action.Fetch": string; - "Replicator.Dialogue.Locked.Action.Unlock": string; - "Replicator.Dialogue.Locked.Message": string; - "Replicator.Dialogue.Locked.Message.Fetch": string; - "Replicator.Dialogue.Locked.Message.Unlocked": string; - "Replicator.Dialogue.Locked.Title": string; - "Replicator.Message.Cleaned": string; - "Replicator.Message.InitialiseFatalError": string; - "Replicator.Message.Pending": string; - "Replicator.Message.SomeModuleFailed": string; - "Replicator.Message.VersionUpFlash": string; - "Requires restart of Obsidian": string; - "Requires restart of Obsidian.": string; - "Rerun Onboarding Wizard": string; - "Rerun the onboarding wizard to set up Self-hosted LiveSync again.": string; - "Rerun Wizard": string; - Resend: string; - "Resend all chunks to the remote.": string; - Reset: string; - "Reset all": string; - "Reset all journal counter": string; - "Reset journal received history": string; - "Reset journal sent history": string; - "Reset notification threshold and check the remote database usage": string; - "Reset received": string; - "Reset sent history": string; - "Reset Synchronisation information": string; - "Reset Synchronisation on This Device": string; - "Reset the remote storage size threshold and check the remote storage size again.": string; - "Resolve All": string; - "Resolve all conflicted files": string; - "Resolve All conflicted files by the newer one": string; - "Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one.": string; - "Restart Now": string; - "Restore or reconstruct local database from remote.": string; - "Run Doctor": string; - "S3/MinIO/R2 Object Storage": string; - "Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.": string; - "Saving will be performed forcefully after this number of seconds.": string; - "Scan a QR Code (Recommended for mobile)": string; - "Scan changes on customization sync": string; - "Scan customization automatically": string; - "Scan customization before replicating.": string; - "Scan customization every 1 minute.": string; - "Scan customization periodically": string; - "Scan for Broken files": string; - "Scan for hidden files before replication": string; - "Scan hidden files periodically": string; - "Scan the QR code displayed on an active device using this device's camera.": string; - "Schedule and Restart": string; - "Scram Switches": string; - "Scram!": string; - "Seconds, 0 to disable": string; - "Seconds. Saving to the local database will be delayed until this value after we stop typing or saving.": string; - "Secret Key": string; - "Select the database adapter to use.": string; - Send: string; - "Send chunks": string; - "Server URI": string; - "Setting.GenerateKeyPair.Desc": string; - "Setting.GenerateKeyPair.Title": string; - "Setting.TroubleShooting": string; - "Setting.TroubleShooting.Doctor": string; - "Setting.TroubleShooting.Doctor.Desc": string; - "Setting.TroubleShooting.ScanBrokenFiles": string; - "Setting.TroubleShooting.ScanBrokenFiles.Desc": string; - "SettingTab.Message.AskRebuild": string; - "Setup URI dialog cancelled.": string; - "Setup.QRCode": string; - "Setup.RemoteE2EE.AdvancedTitle": string; - "Setup.RemoteE2EE.AlgorithmWarning": string; - "Setup.RemoteE2EE.ButtonCancel": string; - "Setup.RemoteE2EE.ButtonProceed": string; - "Setup.RemoteE2EE.DefaultAlgorithmDesc": string; - "Setup.RemoteE2EE.Guidance": string; - "Setup.RemoteE2EE.LabelEncrypt": string; - "Setup.RemoteE2EE.LabelEncryptionAlgorithm": string; - "Setup.RemoteE2EE.LabelObfuscateProperties": string; - "Setup.RemoteE2EE.MultiDestinationWarning": string; - "Setup.RemoteE2EE.ObfuscatePropertiesDesc": string; - "Setup.RemoteE2EE.PassphraseValidationLine1": string; - "Setup.RemoteE2EE.PassphraseValidationLine2": string; - "Setup.RemoteE2EE.PlaceholderPassphrase": string; - "Setup.RemoteE2EE.StronglyRecommendedLine1": string; - "Setup.RemoteE2EE.StronglyRecommendedLine2": string; - "Setup.RemoteE2EE.StronglyRecommendedTitle": string; - "Setup.RemoteE2EE.Title": string; - "Setup.ScanQRCode.ButtonClose": string; - "Setup.ScanQRCode.Guidance": string; - "Setup.ScanQRCode.Step1": string; - "Setup.ScanQRCode.Step2": string; - "Setup.ScanQRCode.Step3": string; - "Setup.ScanQRCode.Step4": string; - "Setup.ScanQRCode.Title": string; - "Setup.ShowQRCode": string; - "Setup.ShowQRCode.Desc": string; - "Setup.UseSetupURI.ButtonCancel": string; - "Setup.UseSetupURI.ButtonProceed": string; - "Setup.UseSetupURI.ErrorFailedToParse": string; - "Setup.UseSetupURI.ErrorPassphraseRequired": string; - "Setup.UseSetupURI.GuidanceLine1": string; - "Setup.UseSetupURI.GuidanceLine2": string; - "Setup.UseSetupURI.InvalidInfo": string; - "Setup.UseSetupURI.LabelPassphrase": string; - "Setup.UseSetupURI.LabelSetupURI": string; - "Setup.UseSetupURI.PlaceholderPassphrase": string; - "Setup.UseSetupURI.Title": string; - "Setup.UseSetupURI.ValidInfo": string; - "Should we keep folders that don't have any files inside?": string; - "Should we only check for conflicts when a file is opened?": string; - "Should we prompt you about conflicting files when a file is opened?": string; - "Should we prompt you for every single merge, even if we can safely merge automatcially?": string; - "Show full banner": string; - "Show only notifications": string; - "Show status as icons only": string; - "Show status icon instead of file warnings banner": string; - "Show status inside the editor": string; - "Show status on the status bar": string; - "Show verbose log. Please enable if you report an issue.": string; - "Some devices have differing progress values (max: ${maxProgress}, min: ${minProgress}).\nThis may indicate that some devices have not completed synchronisation, which could lead to conflicts. Strongly recommend confirming that all devices are synchronised before proceeding.": string; - "Starts synchronisation when a file is saved.": string; - "Stop reflecting database changes to storage files.": string; - "Stop watching for file changes.": string; - "Suppress notification of hidden files change": string; - "Suspend database reflecting": string; - "Suspend file watching": string; - "Switch to IDB": string; - "Switch to IndexedDB": string; - "Sync after merging file": string; - "Sync automatically after merging files": string; - "Sync Mode": string; - "Sync on Editor Save": string; - "Sync on File Open": string; - "Sync on Save": string; - "Sync on Startup": string; - "Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage.": string; - "Synchronising files": string; - Syncing: string; - "Target patterns": string; - "Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.": string; - "The delay for consecutive on-demand fetches": string; - "The following accepted nodes are missing its node information:\n- ${missingNodes}\n\nThis indicates that they have not been connected for some time or have been left on an older version.\nIt is preferable to update all devices if possible. If you have any devices that are no longer in use, you can clear all accepted nodes by locking the remote once.": string; - "The Hash algorithm for chunk IDs": string; - "The maximum duration for which chunks can be incubated within the document. Chunks exceeding this period will graduate to independent chunks.": string; - "The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks.": string; - "The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks.": string; - "The minimum interval for automatic synchronisation on event.": string; - "This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.": string; - "This is an advanced option for users who do not have a URI or who wish to configure detailed settings.": string; - "This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.": string; - "This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.": string; - "This will recreate chunks for all files. If there were missing chunks, this may fix the errors.": string; - "Transfer Tweak": string; - "TweakMismatchResolve.Action.Dismiss": string; - "TweakMismatchResolve.Action.UseConfigured": string; - "TweakMismatchResolve.Action.UseMine": string; - "TweakMismatchResolve.Action.UseMineAcceptIncompatible": string; - "TweakMismatchResolve.Action.UseMineWithRebuild": string; - "TweakMismatchResolve.Action.UseRemote": string; - "TweakMismatchResolve.Action.UseRemoteAcceptIncompatible": string; - "TweakMismatchResolve.Action.UseRemoteWithRebuild": string; - "TweakMismatchResolve.Message.Main": string; - "TweakMismatchResolve.Message.MainTweakResolving": string; - "TweakMismatchResolve.Message.UseRemote.WarningRebuildRecommended": string; - "TweakMismatchResolve.Message.UseRemote.WarningRebuildRequired": string; - "TweakMismatchResolve.Message.WarningIncompatibleRebuildRecommended": string; - "TweakMismatchResolve.Message.WarningIncompatibleRebuildRequired": string; - "TweakMismatchResolve.Table": string; - "TweakMismatchResolve.Table.Row": string; - "TweakMismatchResolve.Title": string; - "TweakMismatchResolve.Title.TweakResolving": string; - "TweakMismatchResolve.Title.UseRemoteConfig": string; - "Unique name between all synchronized devices. To edit this setting, please disable customization sync once.": string; - "Use a custom passphrase": string; - "Use a Setup URI (Recommended)": string; - "Use Custom HTTP Handler": string; - "Use dynamic iteration count": string; - "Use Segmented-splitter": string; - "Use splitting-limit-capped chunk splitter": string; - "Use the trash bin": string; - "Use timeouts instead of heartbeats": string; - username: string; - Username: string; - "Verbose Log": string; - "Verify all": string; - "Verify and repair all files": string; - "Warning! This will have a serious impact on performance. And the logs will not be synchronised under the default name. Please be careful with logs; they often contain your confidential information.": string; - "We cannot change the device name while this feature is enabled. Please disable this feature to change the device name.": string; - "We will now guide you through a few questions to simplify the synchronisation setup.": string; - "We will now proceed with the server configuration.": string; - "Welcome to Self-hosted LiveSync": string; - "When you save a file in the editor, start a sync automatically": string; - "Write credentials in the file": string; - "Write logs into the file": string; - "xxhash32 (Fast but less collision resistance)": string; - "xxhash64 (Fastest)": string; - "Yes, I want to add this device to my existing synchronisation": string; - "Yes, I want to set up a new synchronisation": string; - "You are adding this device to an existing synchronisation setup.": string; - }; -}; diff --git a/_types/src/lib/src/common/messages/ru.d.ts b/_types/src/lib/src/common/messages/ru.d.ts deleted file mode 100644 index 213a8f0f..00000000 --- a/_types/src/lib/src/common/messages/ru.d.ts +++ /dev/null @@ -1,860 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare const PartialMessages: { - readonly ru: { - "(Active)": string; - "(BETA) Always overwrite with a newer file": string; - "(Beta) Use ignore files": string; - "(Days passed, 0 to disable automatic-deletion)": string; - "(ex. Read chunks online) If this option is enabled, LiveSync reads chunks online directly instead of replicating them locally. Increasing Custom chunk size is recommended.": string; - "(MB) If this is set, changes to local and remote files that are larger than this will be skipped. If the file becomes smaller again, a newer one will be used.": string; - "(Mega chars)": string; - "(Not recommended) If set, credentials will be stored in the file": string; - "(Not recommended) If set, credentials will be stored in the file.": string; - "(Obsolete) Use an old adapter for compatibility": string; - "(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": string; - "(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": string; - "(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": string; - "(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": string; - "> [!INFO]- The connected devices have been detected as follows:\n${devices}": string; - "A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration.": string; - "Access Key": string; - Activate: string; - "Active Remote Configuration": string; - "Add default patterns": string; - "Add new connection": string; - "All devices have the same progress value (${progress}). Your devices seem to be synchronised. And be able to proceed with Garbage Collection.": string; - "Always prompt merge conflicts": string; - Analyse: string; - "Analyse database usage": string; - "Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like.": string; - "Apply Latest Change if Conflicting": string; - "Apply preset configuration": string; - "Ask a passphrase at every launch": string; - "Automatically Sync all files when opening Obsidian.": string; - Back: string; - "Back to non-configured": string; - "Batch database update": string; - "Batch limit": string; - "Batch size": string; - "Batch size of on-demand fetching": string; - "Before v0.17.16, we used an old adapter for the local database. Now the new adapter is preferred. However, it needs local database rebuilding.": string; - "Before v0.17.16, we used an old adapter for the local database. Now the new adapter is preferred. However, it needs local database rebuilding. Please disable this toggle when you have enough time. If leave it enabled, also while fetching from the remote database, you will be asked to disable this.": string; - "Bucket Name": string; - Cancel: string; - "Cancel Garbage Collection": string; - Check: string; - "Check and convert non-path-obfuscated files": string; - "Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.": string; - "cmdConfigSync.showCustomizationSync": string; - "Comma separated `.gitignore, .dockerignore`": string; - "Compaction in progress on remote database...": string; - "Compaction on remote database completed successfully.": string; - "Compaction on remote database failed.": string; - "Compaction on remote database timed out.": string; - "Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep.": string; - "Compatibility (Conflict Behaviour)": string; - "Compatibility (Database structure)": string; - "Compatibility (Internal API Usage)": string; - "Compatibility (Metadata)": string; - "Compatibility (Remote Database)": string; - "Compatibility (Trouble addressed)": string; - "Compute revisions for chunks": string; - "Configuration Encryption": string; - Configure: string; - "Configure And Change Remote": string; - "Configure E2EE": string; - "Configure Remote": string; - "Configure the same server information as your other devices again, manually, very advanced users only.": string; - "Connection Method": string; - "Continue to CouchDB setup": string; - "Continue to Peer-to-Peer only setup": string; - "Continue to S3/MinIO/R2 setup": string; - Copy: string; - "Copy Report to clipboard": string; - "CouchDB Connection Tweak": string; - "Cross-platform": string; - "Current adapter: {adapter}": string; - "Customization Sync": string; - "Customization Sync (Beta3)": string; - "Data Compression": string; - "Database Adapter": string; - "Database Name": string; - "Database suffix": string; - Default: string; - "Delay conflict resolution of inactive files": string; - "Delay merge conflict prompt for inactive files.": string; - Delete: string; - "Delete all customization sync data": string; - "Delete all data on the remote server.": string; - "Delete local database to reset or uninstall Self-hosted LiveSync": string; - "Delete old metadata of deleted files on start-up": string; - "Delete Remote Configuration": string; - "Delete remote configuration '{name}'?": string; - descConnectSetupURI: string; - descCopySetupURI: string; - descEnableLiveSync: string; - descFetchConfigFromRemote: string; - descManualSetup: string; - descTestDatabaseConnection: string; - descValidateDatabaseConfig: string; - desktop: string; - Developer: string; - Device: string; - "Device name": string; - "Device Setup Method": string; - "dialog.yourLanguageAvailable": string; - "dialog.yourLanguageAvailable.btnRevertToDefault": string; - "dialog.yourLanguageAvailable.Title": string; - "Disables all synchronization and restart.": string; - "Disables logging, only shows notifications. Please disable if you report an issue.": string; - "Display Language": string; - "Display name": string; - "Do not check configuration mismatch before replication": string; - "Do not keep metadata of deleted files.": string; - "Do not split chunks in the background": string; - "Do not use internal API": string; - "Doctor.Button.DismissThisVersion": string; - "Doctor.Button.Fix": string; - "Doctor.Button.FixButNoRebuild": string; - "Doctor.Button.No": string; - "Doctor.Button.Skip": string; - "Doctor.Button.Yes": string; - "Doctor.Dialogue.Main": string; - "Doctor.Dialogue.MainFix": string; - "Doctor.Dialogue.Title": string; - "Doctor.Dialogue.TitleAlmostDone": string; - "Doctor.Dialogue.TitleFix": string; - "Doctor.Level.Must": string; - "Doctor.Level.Necessary": string; - "Doctor.Level.Optional": string; - "Doctor.Level.Recommended": string; - "Doctor.Message.NoIssues": string; - "Doctor.Message.RebuildLocalRequired": string; - "Doctor.Message.RebuildRequired": string; - "Doctor.Message.SomeSkipped": string; - "Doctor.RULES.E2EE_V02500.REASON": string; - Duplicate: string; - "Duplicate remote": string; - "E2EE Configuration": string; - "Edge case addressing (Behaviour)": string; - "Edge case addressing (Database)": string; - "Edge case addressing (Processing)": string; - "Emergency restart": string; - "Enable advanced features": string; - "Enable customization sync": string; - "Enable Developers' Debug Tools.": string; - "Enable edge case treatment features": string; - "Enable poweruser features": string; - "Enable this if your Object Storage doesn't support CORS": string; - "Enable this option to automatically apply the most recent change to documents even when it conflicts": string; - "Encrypt contents on the remote database. If you use the plugin's synchronization feature, enabling this is recommended.": string; - "Encrypting sensitive configuration items": string; - "Encryption phassphrase. If changed, you should overwrite the server's database with the new (encrypted) files.": string; - "End-to-End Encryption": string; - "Endpoint URL": string; - "Enhance chunk size": string; - "Enter Server Information": string; - "Enter the server information manually": string; - Export: string; - "Failed to connect to remote for compaction.": string; - "Failed to connect to remote for compaction. ${reason}": string; - "Failed to start one-shot replication before Garbage Collection. Garbage Collection Cancelled.": string; - "Failed to start replication after Garbage Collection.": string; - Fetch: string; - "Fetch chunks on demand": string; - "Fetch database with previous behaviour": string; - "Fetch remote settings": string; - "File to resolve conflict": string; - Filename: string; - "First, please select the option that best describes your current situation.": string; - "Flag and restart": string; - "Forces the file to be synced when opened.": string; - "Fresh Start Wipe": string; - "Garbage Collection cancelled by user.": string; - "Garbage Collection completed. Deleted chunks: ${deletedChunks} / ${totalChunks}. Time taken: ${seconds} seconds.": string; - "Garbage Collection Confirmation": string; - "Garbage Collection V3 (Beta)": string; - "Garbage Collection: Found ${unusedChunks} unused chunks to delete.": string; - "Garbage Collection: Scanned ${scanned} / ~${docCount}": string; - "Garbage Collection: Scanning completed. Total chunks: ${totalChunks}, Used chunks: ${usedChunks}": string; - "Handle files as Case-Sensitive": string; - "Hidden Files": string; - "How to display network errors when the sync server is unreachable.": string; - "How would you like to configure the connection to your server?": string; - "I am adding a device to an existing synchronisation setup": string; - "I am setting this up for the first time": string; - "I know my server details, let me enter them": string; - "If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).": string; - "If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this.": string; - "If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.": string; - "If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker.": string; - "If enabled, newly created chunks are temporarily kept within the document, and graduated to become independent chunks once stabilised.": string; - "If enabled, the \u26D4 icon will be shown inside the status instead of the file warnings banner. No details will be shown.": string; - "If enabled, the file under 1kb will be processed in the UI thread.": string; - "If enabled, the notification of hidden files change will be suppressed.": string; - "If this enabled, all chunks will be stored with the revision made from its content. (Previous behaviour)": string; - "If this enabled, All files are handled as case-Sensitive (Previous behaviour).": string; - "If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature.": string; - "If this is set, changes to local files which are matched by the ignore files will be skipped.": string; - "If this is set, changes to local files which are matched by the ignore files will be skipped. Remote changes are determined using local ignore files.": string; - "If this option is enabled, PouchDB will hold the connection open for 60 seconds, and if no change arrives in that time, close and reopen the socket, instead of holding it open indefinitely.": string; - "If this option is enabled, PouchDB will hold the connection open for 60 seconds, and if no change arrives in that time, close and reopen the socket, instead of holding it open indefinitely. Useful when a proxy limits request duration but can increase resource usage.": string; - "Ignore and Proceed": string; - "Ignore files": string; - "Ignore patterns": string; - "Import connection": string; - "Incubate Chunks in Document": string; - "Initialise all journal history, On the next sync, every item will be received and sent.": string; - "Interval (sec)": string; - "K.exp": string; - "K.long_p2p_sync": string; - "K.P2P": string; - "K.Peer": string; - "K.ScanCustomization": string; - "K.short_p2p_sync": string; - "K.title_p2p_sync": string; - "Keep empty folder": string; - lang_def: string; - "lang-de": string; - "lang-def": string; - "lang-es": string; - "lang-fr": string; - "lang-ja": string; - "lang-ko": string; - "lang-ru": string; - "lang-zh": string; - "lang-zh-tw": string; - Later: string; - "Limit: {datetime} ({timestamp})": string; - "LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured.": string; - "liveSyncReplicator.beforeLiveSync": string; - "liveSyncReplicator.cantReplicateLowerValue": string; - "liveSyncReplicator.checkingLastSyncPoint": string; - "liveSyncReplicator.couldNotConnectTo": string; - "liveSyncReplicator.couldNotConnectToRemoteDb": string; - "liveSyncReplicator.couldNotConnectToServer": string; - "liveSyncReplicator.couldNotConnectToURI": string; - "liveSyncReplicator.couldNotMarkResolveRemoteDb": string; - "liveSyncReplicator.liveSyncBegin": string; - "liveSyncReplicator.lockRemoteDb": string; - "liveSyncReplicator.markDeviceResolved": string; - "liveSyncReplicator.oneShotSyncBegin": string; - "liveSyncReplicator.remoteDbCorrupted": string; - "liveSyncReplicator.remoteDbCreatedOrConnected": string; - "liveSyncReplicator.remoteDbDestroyed": string; - "liveSyncReplicator.remoteDbDestroyError": string; - "liveSyncReplicator.remoteDbMarkedResolved": string; - "liveSyncReplicator.replicationClosed": string; - "liveSyncReplicator.replicationInProgress": string; - "liveSyncReplicator.retryLowerBatchSize": string; - "liveSyncReplicator.unlockRemoteDb": string; - "liveSyncSetting.errorNoSuchSettingItem": string; - "liveSyncSetting.originalValue": string; - "liveSyncSetting.valueShouldBeInRange": string; - "liveSyncSettings.btnApply": string; - "Local Database Tweak": string; - Lock: string; - "Lock Server": string; - "Lock the remote server to prevent synchronization with other devices.": string; - "logPane.autoScroll": string; - "logPane.logWindowOpened": string; - "logPane.pause": string; - "logPane.title": string; - "logPane.wrap": string; - "Maximum delay for batch database updating": string; - "Maximum file size": string; - "Maximum Incubating Chunk Size": string; - "Maximum Incubating Chunks": string; - "Maximum Incubation Period": string; - "MB (0 to disable).": string; - "Memory cache": string; - "Memory cache size (by total characters)": string; - "Memory cache size (by total items)": string; - Merge: string; - "Minimum delay for batch database updating": string; - "Minimum interval for syncing": string; - "moduleCheckRemoteSize.logCheckingStorageSizes": string; - "moduleCheckRemoteSize.logCurrentStorageSize": string; - "moduleCheckRemoteSize.logExceededWarning": string; - "moduleCheckRemoteSize.logThresholdEnlarged": string; - "moduleCheckRemoteSize.msgConfirmRebuild": string; - "moduleCheckRemoteSize.msgDatabaseGrowing": string; - "moduleCheckRemoteSize.msgSetDBCapacity": string; - "moduleCheckRemoteSize.option2GB": string; - "moduleCheckRemoteSize.option800MB": string; - "moduleCheckRemoteSize.optionAskMeLater": string; - "moduleCheckRemoteSize.optionDismiss": string; - "moduleCheckRemoteSize.optionIncreaseLimit": string; - "moduleCheckRemoteSize.optionNoWarn": string; - "moduleCheckRemoteSize.optionRebuildAll": string; - "moduleCheckRemoteSize.titleDatabaseSizeLimitExceeded": string; - "moduleCheckRemoteSize.titleDatabaseSizeNotify": string; - "moduleInputUIObsidian.defaultTitleConfirmation": string; - "moduleInputUIObsidian.defaultTitleSelect": string; - "moduleInputUIObsidian.optionNo": string; - "moduleInputUIObsidian.optionYes": string; - "moduleLiveSyncMain.logAdditionalSafetyScan": string; - "moduleLiveSyncMain.logLoadingPlugin": string; - "moduleLiveSyncMain.logPluginInitCancelled": string; - "moduleLiveSyncMain.logPluginVersion": string; - "moduleLiveSyncMain.logReadChangelog": string; - "moduleLiveSyncMain.logSafetyScanCompleted": string; - "moduleLiveSyncMain.logSafetyScanFailed": string; - "moduleLiveSyncMain.logUnloadingPlugin": string; - "moduleLiveSyncMain.logVersionUpdate": string; - "moduleLiveSyncMain.msgScramEnabled": string; - "moduleLiveSyncMain.optionKeepLiveSyncDisabled": string; - "moduleLiveSyncMain.optionResumeAndRestart": string; - "moduleLiveSyncMain.titleScramEnabled": string; - "moduleLocalDatabase.logWaitingForReady": string; - "moduleLog.showLog": string; - "moduleMigration.docUri": string; - "moduleMigration.fix0256.buttons.checkItLater": string; - "moduleMigration.fix0256.buttons.DismissForever": string; - "moduleMigration.fix0256.buttons.fix": string; - "moduleMigration.fix0256.message": string; - "moduleMigration.fix0256.messageUnrecoverable": string; - "moduleMigration.fix0256.title": string; - "moduleMigration.insecureChunkExist.buttons.fetch": string; - "moduleMigration.insecureChunkExist.buttons.later": string; - "moduleMigration.insecureChunkExist.buttons.rebuild": string; - "moduleMigration.insecureChunkExist.laterMessage": string; - "moduleMigration.insecureChunkExist.message": string; - "moduleMigration.insecureChunkExist.title": string; - "moduleMigration.logBulkSendCorrupted": string; - "moduleMigration.logFetchRemoteTweakFailed": string; - "moduleMigration.logLocalDatabaseNotReady": string; - "moduleMigration.logMigratedSameBehaviour": string; - "moduleMigration.logMigrationFailed": string; - "moduleMigration.logRedflag2CreationFail": string; - "moduleMigration.logRemoteTweakUnavailable": string; - "moduleMigration.logSetupCancelled": string; - "moduleMigration.msgFetchRemoteAgain": string; - "moduleMigration.msgInitialSetup": string; - "moduleMigration.msgRecommendSetupUri": string; - "moduleMigration.msgSinceV02321": string; - "moduleMigration.optionAdjustRemote": string; - "moduleMigration.optionDecideLater": string; - "moduleMigration.optionEnableBoth": string; - "moduleMigration.optionEnableFilenameCaseInsensitive": string; - "moduleMigration.optionEnableFixedRevisionForChunks": string; - "moduleMigration.optionHaveSetupUri": string; - "moduleMigration.optionKeepPreviousBehaviour": string; - "moduleMigration.optionManualSetup": string; - "moduleMigration.optionNoAskAgain": string; - "moduleMigration.optionNoSetupUri": string; - "moduleMigration.optionRemindNextLaunch": string; - "moduleMigration.optionSetupViaP2P": string; - "moduleMigration.optionSetupWizard": string; - "moduleMigration.optionYesFetchAgain": string; - "moduleMigration.titleCaseSensitivity": string; - "moduleMigration.titleRecommendSetupUri": string; - "moduleMigration.titleWelcome": string; - "moduleObsidianMenu.replicate": string; - "More actions": string; - "Move remotely deleted files to the trash, instead of deleting.": string; - "Network warning style": string; - "New Remote": string; - "No connected device information found. Cancelling Garbage Collection.": string; - "No limit configured": string; - "No, please take me back": string; - "Node ID": string; - "Node Information Missing": string; - "Non-Synchronising files": string; - "Normal Files": string; - "Not all messages have been translated. And, please revert to \"Default\" when reporting errors.": string; - "Notify all setting files": string; - "Notify customized": string; - "Notify when other device has newly customized.": string; - "Notify when the estimated remote storage size exceeds on start up": string; - "Number of batches to process at a time. Defaults to 40. Minimum is 2.": string; - "Number of batches to process at a time. Defaults to 40. Minimum is 2. This along with batch size controls how many docs are kept in memory at a time.": string; - "Number of changes to sync at a time. Defaults to 50. Minimum is 2.": string; - "Obsidian version": string; - "obsidianLiveSyncSettingTab.btnApply": string; - "obsidianLiveSyncSettingTab.btnCheck": string; - "obsidianLiveSyncSettingTab.btnCopy": string; - "obsidianLiveSyncSettingTab.btnDisable": string; - "obsidianLiveSyncSettingTab.btnDiscard": string; - "obsidianLiveSyncSettingTab.btnEnable": string; - "obsidianLiveSyncSettingTab.btnFix": string; - "obsidianLiveSyncSettingTab.btnGotItAndUpdated": string; - "obsidianLiveSyncSettingTab.btnNext": string; - "obsidianLiveSyncSettingTab.btnStart": string; - "obsidianLiveSyncSettingTab.btnTest": string; - "obsidianLiveSyncSettingTab.btnUse": string; - "obsidianLiveSyncSettingTab.buttonFetch": string; - "obsidianLiveSyncSettingTab.buttonNext": string; - "obsidianLiveSyncSettingTab.defaultLanguage": string; - "obsidianLiveSyncSettingTab.descConnectSetupURI": string; - "obsidianLiveSyncSettingTab.descCopySetupURI": string; - "obsidianLiveSyncSettingTab.descEnableLiveSync": string; - "obsidianLiveSyncSettingTab.descFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.descManualSetup": string; - "obsidianLiveSyncSettingTab.descTestDatabaseConnection": string; - "obsidianLiveSyncSettingTab.descValidateDatabaseConfig": string; - "obsidianLiveSyncSettingTab.errAccessForbidden": string; - "obsidianLiveSyncSettingTab.errCannotContinueTest": string; - "obsidianLiveSyncSettingTab.errCorsCredentials": string; - "obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials": string; - "obsidianLiveSyncSettingTab.errCorsOrigins": string; - "obsidianLiveSyncSettingTab.errEnableCors": string; - "obsidianLiveSyncSettingTab.errEnableCorsChttpd": string; - "obsidianLiveSyncSettingTab.errMaxDocumentSize": string; - "obsidianLiveSyncSettingTab.errMaxRequestSize": string; - "obsidianLiveSyncSettingTab.errMissingWwwAuth": string; - "obsidianLiveSyncSettingTab.errRequireValidUser": string; - "obsidianLiveSyncSettingTab.errRequireValidUserAuth": string; - "obsidianLiveSyncSettingTab.labelDisabled": string; - "obsidianLiveSyncSettingTab.labelEnabled": string; - "obsidianLiveSyncSettingTab.levelAdvanced": string; - "obsidianLiveSyncSettingTab.levelEdgeCase": string; - "obsidianLiveSyncSettingTab.levelPowerUser": string; - "obsidianLiveSyncSettingTab.linkOpenInBrowser": string; - "obsidianLiveSyncSettingTab.linkPageTop": string; - "obsidianLiveSyncSettingTab.linkTipsAndTroubleshooting": string; - "obsidianLiveSyncSettingTab.linkTroubleshooting": string; - "obsidianLiveSyncSettingTab.logCannotUseCloudant": string; - "obsidianLiveSyncSettingTab.logCheckingConfigDone": string; - "obsidianLiveSyncSettingTab.logCheckingConfigFailed": string; - "obsidianLiveSyncSettingTab.logCheckingDbConfig": string; - "obsidianLiveSyncSettingTab.logCheckPassphraseFailed": string; - "obsidianLiveSyncSettingTab.logConfiguredDisabled": string; - "obsidianLiveSyncSettingTab.logConfiguredLiveSync": string; - "obsidianLiveSyncSettingTab.logConfiguredPeriodic": string; - "obsidianLiveSyncSettingTab.logCouchDbConfigFail": string; - "obsidianLiveSyncSettingTab.logCouchDbConfigSet": string; - "obsidianLiveSyncSettingTab.logCouchDbConfigUpdated": string; - "obsidianLiveSyncSettingTab.logDatabaseConnected": string; - "obsidianLiveSyncSettingTab.logEncryptionNoPassphrase": string; - "obsidianLiveSyncSettingTab.logEncryptionNoSupport": string; - "obsidianLiveSyncSettingTab.logErrorOccurred": string; - "obsidianLiveSyncSettingTab.logEstimatedSize": string; - "obsidianLiveSyncSettingTab.logPassphraseInvalid": string; - "obsidianLiveSyncSettingTab.logPassphraseNotCompatible": string; - "obsidianLiveSyncSettingTab.logRebuildNote": string; - "obsidianLiveSyncSettingTab.logSelectAnyPreset": string; - "obsidianLiveSyncSettingTab.msgAreYouSureProceed": string; - "obsidianLiveSyncSettingTab.msgChangesNeedToBeApplied": string; - "obsidianLiveSyncSettingTab.msgConfigCheck": string; - "obsidianLiveSyncSettingTab.msgConfigCheckFailed": string; - "obsidianLiveSyncSettingTab.msgConnectionCheck": string; - "obsidianLiveSyncSettingTab.msgConnectionProxyNote": string; - "obsidianLiveSyncSettingTab.msgCurrentOrigin": string; - "obsidianLiveSyncSettingTab.msgDiscardConfirmation": string; - "obsidianLiveSyncSettingTab.msgDone": string; - "obsidianLiveSyncSettingTab.msgEnableCors": string; - "obsidianLiveSyncSettingTab.msgEnableCorsChttpd": string; - "obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": string; - "obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.msgGenerateSetupURI": string; - "obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": string; - "obsidianLiveSyncSettingTab.msgInvalidPassphrase": string; - "obsidianLiveSyncSettingTab.msgNewVersionNote": string; - "obsidianLiveSyncSettingTab.msgNonHTTPSInfo": string; - "obsidianLiveSyncSettingTab.msgNonHTTPSWarning": string; - "obsidianLiveSyncSettingTab.msgNotice": string; - "obsidianLiveSyncSettingTab.msgObjectStorageWarning": string; - "obsidianLiveSyncSettingTab.msgOriginCheck": string; - "obsidianLiveSyncSettingTab.msgRebuildRequired": string; - "obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": string; - "obsidianLiveSyncSettingTab.msgSetCorsCredentials": string; - "obsidianLiveSyncSettingTab.msgSetCorsOrigins": string; - "obsidianLiveSyncSettingTab.msgSetMaxDocSize": string; - "obsidianLiveSyncSettingTab.msgSetMaxRequestSize": string; - "obsidianLiveSyncSettingTab.msgSetRequireValidUser": string; - "obsidianLiveSyncSettingTab.msgSetRequireValidUserAuth": string; - "obsidianLiveSyncSettingTab.msgSettingModified": string; - "obsidianLiveSyncSettingTab.msgSettingsUnchangeableDuringSync": string; - "obsidianLiveSyncSettingTab.msgSetWwwAuth": string; - "obsidianLiveSyncSettingTab.nameApplySettings": string; - "obsidianLiveSyncSettingTab.nameConnectSetupURI": string; - "obsidianLiveSyncSettingTab.nameCopySetupURI": string; - "obsidianLiveSyncSettingTab.nameDisableHiddenFileSync": string; - "obsidianLiveSyncSettingTab.nameDiscardSettings": string; - "obsidianLiveSyncSettingTab.nameEnableHiddenFileSync": string; - "obsidianLiveSyncSettingTab.nameEnableLiveSync": string; - "obsidianLiveSyncSettingTab.nameHiddenFileSynchronization": string; - "obsidianLiveSyncSettingTab.nameManualSetup": string; - "obsidianLiveSyncSettingTab.nameTestConnection": string; - "obsidianLiveSyncSettingTab.nameTestDatabaseConnection": string; - "obsidianLiveSyncSettingTab.nameValidateDatabaseConfig": string; - "obsidianLiveSyncSettingTab.okAdminPrivileges": string; - "obsidianLiveSyncSettingTab.okCorsCredentials": string; - "obsidianLiveSyncSettingTab.okCorsCredentialsForOrigin": string; - "obsidianLiveSyncSettingTab.okCorsOriginMatched": string; - "obsidianLiveSyncSettingTab.okCorsOrigins": string; - "obsidianLiveSyncSettingTab.okEnableCors": string; - "obsidianLiveSyncSettingTab.okEnableCorsChttpd": string; - "obsidianLiveSyncSettingTab.okMaxDocumentSize": string; - "obsidianLiveSyncSettingTab.okMaxRequestSize": string; - "obsidianLiveSyncSettingTab.okRequireValidUser": string; - "obsidianLiveSyncSettingTab.okRequireValidUserAuth": string; - "obsidianLiveSyncSettingTab.okWwwAuth": string; - "obsidianLiveSyncSettingTab.optionApply": string; - "obsidianLiveSyncSettingTab.optionCancel": string; - "obsidianLiveSyncSettingTab.optionCouchDB": string; - "obsidianLiveSyncSettingTab.optionDisableAllAutomatic": string; - "obsidianLiveSyncSettingTab.optionFetchFromRemote": string; - "obsidianLiveSyncSettingTab.optionHere": string; - "obsidianLiveSyncSettingTab.optionLiveSync": string; - "obsidianLiveSyncSettingTab.optionMinioS3R2": string; - "obsidianLiveSyncSettingTab.optionOkReadEverything": string; - "obsidianLiveSyncSettingTab.optionOnEvents": string; - "obsidianLiveSyncSettingTab.optionPeriodicAndEvents": string; - "obsidianLiveSyncSettingTab.optionPeriodicWithBatch": string; - "obsidianLiveSyncSettingTab.optionRebuildBoth": string; - "obsidianLiveSyncSettingTab.optionSaveOnlySettings": string; - "obsidianLiveSyncSettingTab.panelChangeLog": string; - "obsidianLiveSyncSettingTab.panelGeneralSettings": string; - "obsidianLiveSyncSettingTab.panelPrivacyEncryption": string; - "obsidianLiveSyncSettingTab.panelRemoteConfiguration": string; - "obsidianLiveSyncSettingTab.panelSetup": string; - "obsidianLiveSyncSettingTab.serverVersion": string; - "obsidianLiveSyncSettingTab.titleActiveRemoteServer": string; - "obsidianLiveSyncSettingTab.titleAppearance": string; - "obsidianLiveSyncSettingTab.titleConflictResolution": string; - "obsidianLiveSyncSettingTab.titleCongratulations": string; - "obsidianLiveSyncSettingTab.titleCouchDB": string; - "obsidianLiveSyncSettingTab.titleDeletionPropagation": string; - "obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": string; - "obsidianLiveSyncSettingTab.titleEncryptionPassphraseInvalid": string; - "obsidianLiveSyncSettingTab.titleExtraFeatures": string; - "obsidianLiveSyncSettingTab.titleFetchConfig": string; - "obsidianLiveSyncSettingTab.titleFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.titleFetchSettings": string; - "obsidianLiveSyncSettingTab.titleHiddenFiles": string; - "obsidianLiveSyncSettingTab.titleLogging": string; - "obsidianLiveSyncSettingTab.titleMinioS3R2": string; - "obsidianLiveSyncSettingTab.titleNotification": string; - "obsidianLiveSyncSettingTab.titleOnlineTips": string; - "obsidianLiveSyncSettingTab.titleQuickSetup": string; - "obsidianLiveSyncSettingTab.titleRebuildRequired": string; - "obsidianLiveSyncSettingTab.titleRemoteConfigCheckFailed": string; - "obsidianLiveSyncSettingTab.titleRemoteServer": string; - "obsidianLiveSyncSettingTab.titleReset": string; - "obsidianLiveSyncSettingTab.titleSetupOtherDevices": string; - "obsidianLiveSyncSettingTab.titleSynchronizationMethod": string; - "obsidianLiveSyncSettingTab.titleSynchronizationPreset": string; - "obsidianLiveSyncSettingTab.titleSyncSettings": string; - "obsidianLiveSyncSettingTab.titleSyncSettingsViaMarkdown": string; - "obsidianLiveSyncSettingTab.titleUpdateThinning": string; - "obsidianLiveSyncSettingTab.warnCorsOriginUnmatched": string; - "obsidianLiveSyncSettingTab.warnNoAdmin": string; - Ok: string; - "Old Algorithm": string; - "Older fallback (Slow, W/O WebAssembly)": string; - Open: string; - "Open the dialog": string; - Overwrite: string; - "Overwrite patterns": string; - "Overwrite remote": string; - "Overwrite remote with local DB and passphrase.": string; - "Overwrite Server Data with This Device's Files": string; - "P2P.AskPassphraseForDecrypt": string; - "P2P.AskPassphraseForShare": string; - "P2P.DisabledButNeed": string; - "P2P.FailedToOpen": string; - "P2P.NoAutoSyncPeers": string; - "P2P.NoKnownPeers": string; - "P2P.Note.description": string; - "P2P.Note.important_note": string; - "P2P.Note.important_note_sub": string; - "P2P.Note.Summary": string; - "P2P.NotEnabled": string; - "P2P.P2PReplication": string; - "P2P.PaneTitle": string; - "P2P.ReplicatorInstanceMissing": string; - "P2P.SeemsOffline": string; - "P2P.SyncAlreadyRunning": string; - "P2P.SyncCompleted": string; - "P2P.SyncStartedWith": string; - "paneMaintenance.markDeviceResolvedAfterBackup": string; - "paneMaintenance.remoteLockedAndDeviceNotAccepted": string; - "paneMaintenance.remoteLockedResolvedDevice": string; - "paneMaintenance.unlockDatabaseReady": string; - Passphrase: string; - "Passphrase of sensitive configuration items": string; - password: string; - Password: string; - "Paste a connection string": string; - "Paste the Setup URI generated from one of your active devices.": string; - "Path Obfuscation": string; - "Patterns to match files for overwriting instead of merging": string; - "Patterns to match files for syncing": string; - "Peer-to-Peer only": string; - "Peer-to-Peer Synchronisation": string; - "Per-file-saved customization sync": string; - Perform: string; - "Perform cleanup": string; - "Perform Garbage Collection": string; - "Perform Garbage Collection to remove unused chunks and reduce database size.": string; - "Periodic Sync interval": string; - "Pick a file to resolve conflict": string; - "Please disable 'Read chunks online' in settings to use Garbage Collection.": string; - "Please enable 'Compute revisions for chunks' in settings to use Garbage Collection.": string; - "Please select 'Cancel' explicitly to cancel this operation.": string; - "Please select a method to import the settings from another device.": string; - "Please select an option to proceed": string; - "Please select the type of server to which you are connecting.": string; - "Please set device name to identify this device. This name should be unique among your devices. While not configured, we cannot enable this feature.": string; - "Please set this device name": string; - "Plug-in version": string; - "Prepare the 'report' to create an issue": string; - Presets: string; - "Proceed Garbage Collection": string; - "Proceed with Setup URI": string; - "Proceeding with Garbage Collection, ignoring missing nodes.": string; - "Proceeding with Garbage Collection.": string; - "Process small files in the foreground": string; - Progress: string; - "Property Encryption": string; - "PureJS fallback (Fast, W/O WebAssembly)": string; - "Purge all download/upload cache.": string; - "Purge all journal counter": string; - "Rebuild local and remote database with local files.": string; - "Rebuilding Operations (Remote Only)": string; - "Recreate all": string; - "Recreate missing chunks for all files": string; - "RedFlag.Fetch.Method.Desc": string; - "RedFlag.Fetch.Method.FetchSafer": string; - "RedFlag.Fetch.Method.FetchSmoother": string; - "RedFlag.Fetch.Method.FetchTraditional": string; - "RedFlag.Fetch.Method.Title": string; - "RedFlag.FetchRemoteConfig.Buttons.Cancel": string; - "RedFlag.FetchRemoteConfig.Buttons.Fetch": string; - "RedFlag.FetchRemoteConfig.Message": string; - "RedFlag.FetchRemoteConfig.Title": string; - "Reducing the frequency with which on-disk changes are reflected into the DB": string; - Region: string; - Remediation: string; - "Remediation Setting Changed": string; - "Remote Database Tweak (In sunset)": string; - "Remote Databases": string; - "Remote name": string; - "Remote server type": string; - "Remote Type": string; - Rename: string; - "Replicator.Dialogue.Locked.Action.Dismiss": string; - "Replicator.Dialogue.Locked.Action.Fetch": string; - "Replicator.Dialogue.Locked.Action.Unlock": string; - "Replicator.Dialogue.Locked.Message": string; - "Replicator.Dialogue.Locked.Message.Fetch": string; - "Replicator.Dialogue.Locked.Message.Unlocked": string; - "Replicator.Dialogue.Locked.Title": string; - "Replicator.Message.Cleaned": string; - "Replicator.Message.InitialiseFatalError": string; - "Replicator.Message.Pending": string; - "Replicator.Message.SomeModuleFailed": string; - "Replicator.Message.VersionUpFlash": string; - "Requires restart of Obsidian": string; - "Requires restart of Obsidian.": string; - "Rerun Onboarding Wizard": string; - "Rerun the onboarding wizard to set up Self-hosted LiveSync again.": string; - "Rerun Wizard": string; - Resend: string; - "Resend all chunks to the remote.": string; - Reset: string; - "Reset all": string; - "Reset all journal counter": string; - "Reset journal received history": string; - "Reset journal sent history": string; - "Reset notification threshold and check the remote database usage": string; - "Reset received": string; - "Reset sent history": string; - "Reset Synchronisation information": string; - "Reset Synchronisation on This Device": string; - "Reset the remote storage size threshold and check the remote storage size again.": string; - "Resolve All": string; - "Resolve all conflicted files": string; - "Resolve All conflicted files by the newer one": string; - "Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one.": string; - "Restart Now": string; - "Restore or reconstruct local database from remote.": string; - "Run Doctor": string; - "S3/MinIO/R2 Object Storage": string; - "Save settings to a markdown file.": string; - "Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.": string; - "Saving will be performed forcefully after this number of seconds.": string; - "Scan a QR Code (Recommended for mobile)": string; - "Scan changes on customization sync": string; - "Scan customization automatically": string; - "Scan customization before replicating.": string; - "Scan customization every 1 minute.": string; - "Scan customization periodically": string; - "Scan for hidden files before replication": string; - "Scan hidden files periodically": string; - "Scan the QR code displayed on an active device using this device's camera.": string; - "Schedule and Restart": string; - "Scram!": string; - "Seconds, 0 to disable": string; - "Seconds. Saving to the local database will be delayed until this value after we stop typing or saving.": string; - "Secret Key": string; - "Select the database adapter to use.": string; - Send: string; - "Send chunks": string; - "Server URI": string; - "Setting.GenerateKeyPair.Desc": string; - "Setting.GenerateKeyPair.Title": string; - "Setting.TroubleShooting": string; - "Setting.TroubleShooting.Doctor": string; - "Setting.TroubleShooting.Doctor.Desc": string; - "Setting.TroubleShooting.ScanBrokenFiles": string; - "Setting.TroubleShooting.ScanBrokenFiles.Desc": string; - "SettingTab.Message.AskRebuild": string; - "Setup URI dialog cancelled.": string; - "Setup.Apply.Buttons.ApplyAndFetch": string; - "Setup.Apply.Buttons.ApplyAndMerge": string; - "Setup.Apply.Buttons.ApplyAndRebuild": string; - "Setup.Apply.Buttons.Cancel": string; - "Setup.Apply.Buttons.OnlyApply": string; - "Setup.Apply.Message": string; - "Setup.Apply.Title": string; - "Setup.Apply.WarningRebuildRecommended": string; - "Setup.Doctor.Buttons.No": string; - "Setup.Doctor.Buttons.Yes": string; - "Setup.Doctor.Message": string; - "Setup.Doctor.Title": string; - "Setup.FetchRemoteConf.Buttons.Fetch": string; - "Setup.FetchRemoteConf.Buttons.Skip": string; - "Setup.FetchRemoteConf.Message": string; - "Setup.FetchRemoteConf.Title": string; - "Setup.QRCode": string; - "Setup.RemoteE2EE.AdvancedTitle": string; - "Setup.RemoteE2EE.AlgorithmWarning": string; - "Setup.RemoteE2EE.ButtonCancel": string; - "Setup.RemoteE2EE.ButtonProceed": string; - "Setup.RemoteE2EE.DefaultAlgorithmDesc": string; - "Setup.RemoteE2EE.Guidance": string; - "Setup.RemoteE2EE.LabelEncrypt": string; - "Setup.RemoteE2EE.LabelEncryptionAlgorithm": string; - "Setup.RemoteE2EE.LabelObfuscateProperties": string; - "Setup.RemoteE2EE.MultiDestinationWarning": string; - "Setup.RemoteE2EE.ObfuscatePropertiesDesc": string; - "Setup.RemoteE2EE.PassphraseValidationLine1": string; - "Setup.RemoteE2EE.PassphraseValidationLine2": string; - "Setup.RemoteE2EE.PlaceholderPassphrase": string; - "Setup.RemoteE2EE.StronglyRecommendedLine1": string; - "Setup.RemoteE2EE.StronglyRecommendedLine2": string; - "Setup.RemoteE2EE.StronglyRecommendedTitle": string; - "Setup.RemoteE2EE.Title": string; - "Setup.ScanQRCode.ButtonClose": string; - "Setup.ScanQRCode.Guidance": string; - "Setup.ScanQRCode.Step1": string; - "Setup.ScanQRCode.Step2": string; - "Setup.ScanQRCode.Step3": string; - "Setup.ScanQRCode.Step4": string; - "Setup.ScanQRCode.Title": string; - "Setup.ShowQRCode": string; - "Setup.ShowQRCode.Desc": string; - "Setup.UseSetupURI.ButtonCancel": string; - "Setup.UseSetupURI.ButtonProceed": string; - "Setup.UseSetupURI.ErrorFailedToParse": string; - "Setup.UseSetupURI.ErrorPassphraseRequired": string; - "Setup.UseSetupURI.GuidanceLine1": string; - "Setup.UseSetupURI.GuidanceLine2": string; - "Setup.UseSetupURI.InvalidInfo": string; - "Setup.UseSetupURI.LabelPassphrase": string; - "Setup.UseSetupURI.LabelSetupURI": string; - "Setup.UseSetupURI.PlaceholderPassphrase": string; - "Setup.UseSetupURI.Title": string; - "Setup.UseSetupURI.ValidInfo": string; - "Should we keep folders that don't have any files inside?": string; - "Should we only check for conflicts when a file is opened?": string; - "Should we prompt you about conflicting files when a file is opened?": string; - "Should we prompt you for every single merge, even if we can safely merge automatcially?": string; - "Show full banner": string; - "Show only notifications": string; - "Show status as icons only": string; - "Show status icon instead of file warnings banner": string; - "Show status inside the editor": string; - "Show status on the status bar": string; - "Show verbose log. Please enable if you report an issue.": string; - "Some devices have differing progress values (max: ${maxProgress}, min: ${minProgress}).\nThis may indicate that some devices have not completed synchronisation, which could lead to conflicts. Strongly recommend confirming that all devices are synchronised before proceeding.": string; - "Starts synchronisation when a file is saved.": string; - "Stop reflecting database changes to storage files.": string; - "Stop watching for file changes.": string; - "Suppress notification of hidden files change": string; - "Suspend database reflecting": string; - "Suspend file watching": string; - "Switch to IDB": string; - "Switch to IndexedDB": string; - "Sync after merging file": string; - "Sync automatically after merging files": string; - "Sync Mode": string; - "Sync on Editor Save": string; - "Sync on File Open": string; - "Sync on Save": string; - "Sync on Startup": string; - "Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage.": string; - "Synchronising files": string; - Syncing: string; - "Target patterns": string; - "Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.": string; - "The delay for consecutive on-demand fetches": string; - "The following accepted nodes are missing its node information:\n- ${missingNodes}\n\nThis indicates that they have not been connected for some time or have been left on an older version.\nIt is preferable to update all devices if possible. If you have any devices that are no longer in use, you can clear all accepted nodes by locking the remote once.": string; - "The Hash algorithm for chunk IDs": string; - "The maximum duration for which chunks can be incubated within the document.": string; - "The maximum duration for which chunks can be incubated within the document. Chunks exceeding this period will graduate to independent chunks.": string; - "The maximum number of chunks that can be incubated within the document.": string; - "The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks.": string; - "The maximum total size of chunks that can be incubated within the document.": string; - "The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks.": string; - "The minimum interval for automatic synchronisation on event.": string; - "This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.": string; - "This is an advanced option for users who do not have a URI or who wish to configure detailed settings.": string; - "This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.": string; - "This passphrase will not be copied to another device. It will be set to until you configure it again.": string; - "This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.": string; - "This will recreate chunks for all files. If there were missing chunks, this may fix the errors.": string; - "Transfer Tweak": string; - "TweakMismatchResolve.Action.Dismiss": string; - "TweakMismatchResolve.Action.UseConfigured": string; - "TweakMismatchResolve.Action.UseMine": string; - "TweakMismatchResolve.Action.UseMineAcceptIncompatible": string; - "TweakMismatchResolve.Action.UseMineWithRebuild": string; - "TweakMismatchResolve.Action.UseRemote": string; - "TweakMismatchResolve.Action.UseRemoteAcceptIncompatible": string; - "TweakMismatchResolve.Action.UseRemoteWithRebuild": string; - "TweakMismatchResolve.Message.Main": string; - "TweakMismatchResolve.Message.MainTweakResolving": string; - "TweakMismatchResolve.Message.UseRemote.WarningRebuildRecommended": string; - "TweakMismatchResolve.Message.UseRemote.WarningRebuildRequired": string; - "TweakMismatchResolve.Message.WarningIncompatibleRebuildRecommended": string; - "TweakMismatchResolve.Message.WarningIncompatibleRebuildRequired": string; - "TweakMismatchResolve.Table": string; - "TweakMismatchResolve.Table.Row": string; - "TweakMismatchResolve.Title": string; - "TweakMismatchResolve.Title.TweakResolving": string; - "TweakMismatchResolve.Title.UseRemoteConfig": string; - "Unique name between all synchronized devices. To edit this setting, please disable customization sync once.": string; - "Use a custom passphrase": string; - "Use a Setup URI (Recommended)": string; - "Use Custom HTTP Handler": string; - "Use dynamic iteration count": string; - "Use Segmented-splitter": string; - "Use splitting-limit-capped chunk splitter": string; - "Use the trash bin": string; - "Use timeouts instead of heartbeats": string; - username: string; - Username: string; - "Verbose Log": string; - "Verify all": string; - "Verify and repair all files": string; - "Warning! This will have a serious impact on performance. And the logs will not be synchronised under the default name.": string; - "Warning! This will have a serious impact on performance. And the logs will not be synchronised under the default name. Please be careful with logs; they often contain your confidential information.": string; - "We cannot change the device name while this feature is enabled. Please disable this feature to change the device name.": string; - "We will now guide you through a few questions to simplify the synchronisation setup.": string; - "We will now proceed with the server configuration.": string; - "Welcome to Self-hosted LiveSync": string; - "When you save a file in the editor, start a sync automatically": string; - "Write credentials in the file": string; - "Write logs into the file": string; - "xxhash32 (Fast but less collision resistance)": string; - "xxhash64 (Fastest)": string; - "Yes, I want to add this device to my existing synchronisation": string; - "Yes, I want to set up a new synchronisation": string; - "You are adding this device to an existing synchronisation setup.": string; - }; -}; diff --git a/_types/src/lib/src/common/messages/zh-tw.d.ts b/_types/src/lib/src/common/messages/zh-tw.d.ts deleted file mode 100644 index e619634d..00000000 --- a/_types/src/lib/src/common/messages/zh-tw.d.ts +++ /dev/null @@ -1,386 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare const PartialMessages: { - readonly "zh-tw": { - "(Active)": string; - "(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": string; - "(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": string; - "(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": string; - "(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": string; - "> [!INFO]- The connected devices have been detected as follows:\n${devices}": string; - "A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration.": string; - Activate: string; - "Active Remote Configuration": string; - "Add default patterns": string; - "Add new connection": string; - "All devices have the same progress value (${progress}). Your devices seem to be synchronised. And be able to proceed with Garbage Collection.": string; - "Always prompt merge conflicts": string; - Analyse: string; - "Analyse database usage": string; - "Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like.": string; - "Apply Latest Change if Conflicting": string; - "Apply preset configuration": string; - "Ask a passphrase at every launch": string; - "Automatically Sync all files when opening Obsidian.": string; - Back: string; - "Back to non-configured": string; - "Batch database update": string; - "Batch limit": string; - "Batch size": string; - "Batch size of on-demand fetching": string; - "Bucket Name": string; - Cancel: string; - "Cancel Garbage Collection": string; - Check: string; - "Check and convert non-path-obfuscated files": string; - "Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.": string; - "cmdConfigSync.showCustomizationSync": string; - "Compaction in progress on remote database...": string; - "Compaction on remote database completed successfully.": string; - "Compaction on remote database failed.": string; - "Compaction on remote database timed out.": string; - "Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep.": string; - "Compatibility (Conflict Behaviour)": string; - "Compatibility (Database structure)": string; - "Compatibility (Internal API Usage)": string; - "Compatibility (Metadata)": string; - "Compatibility (Remote Database)": string; - "Compatibility (Trouble addressed)": string; - "Configuration Encryption": string; - Configure: string; - "Configure And Change Remote": string; - "Configure E2EE": string; - "Configure Remote": string; - "Configure the same server information as your other devices again, manually, very advanced users only.": string; - "Connection Method": string; - "Continue to CouchDB setup": string; - "Continue to Peer-to-Peer only setup": string; - "Continue to S3/MinIO/R2 setup": string; - Copy: string; - "Copy Report to clipboard": string; - "CouchDB Connection Tweak": string; - "Cross-platform": string; - "Current adapter: {adapter}": string; - "Customization Sync": string; - "Customization Sync (Beta3)": string; - "Data Compression": string; - "Database -> Storage": string; - "Database Adapter": string; - "Database Name": string; - "Database suffix": string; - Default: string; - "Delay conflict resolution of inactive files": string; - "Delay merge conflict prompt for inactive files.": string; - Delete: string; - "Delete all customization sync data": string; - "Delete all data on the remote server.": string; - "Delete local database to reset or uninstall Self-hosted LiveSync": string; - "Delete old metadata of deleted files on start-up": string; - "Delete Remote Configuration": string; - "Delete remote configuration '{name}'?": string; - desktop: string; - Developer: string; - Device: string; - "Device name": string; - "Device Setup Method": string; - "dialog.yourLanguageAvailable": string; - "dialog.yourLanguageAvailable.btnRevertToDefault": string; - "dialog.yourLanguageAvailable.Title": string; - "Disables all synchronization and restart.": string; - "Disables logging, only shows notifications. Please disable if you report an issue.": string; - "Display Language": string; - "Display name": string; - "Do not check configuration mismatch before replication": string; - "Do not keep metadata of deleted files.": string; - "Do not split chunks in the background": string; - "Do not use internal API": string; - "Document History": string; - Duplicate: string; - "Duplicate remote": string; - "E2EE Configuration": string; - "Edge case addressing (Behaviour)": string; - "Edge case addressing (Database)": string; - "Edge case addressing (Processing)": string; - "Emergency restart": string; - "Enable advanced features": string; - "Enable customization sync": string; - "Enable Developers' Debug Tools.": string; - "Enable edge case treatment features": string; - "Enable poweruser features": string; - "Enable this if your Object Storage doesn't support CORS": string; - "Enable this option to automatically apply the most recent change to documents even when it conflicts": string; - "Encrypt contents on the remote database. If you use the plugin's synchronization feature, enabling this is recommended.": string; - "Encrypting sensitive configuration items": string; - "Encryption phassphrase. If changed, you should overwrite the server's database with the new (encrypted) files.": string; - "End-to-End Encryption": string; - "Endpoint URL": string; - "Enhance chunk size": string; - "Enter Server Information": string; - "Enter the server information manually": string; - Export: string; - "Failed to connect to remote for compaction.": string; - "Failed to connect to remote for compaction. ${reason}": string; - "Failed to start one-shot replication before Garbage Collection. Garbage Collection Cancelled.": string; - "Failed to start replication after Garbage Collection.": string; - Fetch: string; - "Fetch chunks on demand": string; - "Fetch database with previous behaviour": string; - "Fetch remote settings": string; - "File to resolve conflict": string; - "File to view History": string; - Filename: string; - "First, please select the option that best describes your current situation.": string; - "Flag and restart": string; - "Forces the file to be synced when opened.": string; - "Fresh Start Wipe": string; - "Garbage Collection cancelled by user.": string; - "Garbage Collection completed. Deleted chunks: ${deletedChunks} / ${totalChunks}. Time taken: ${seconds} seconds.": string; - "Garbage Collection Confirmation": string; - "Garbage Collection V3 (Beta)": string; - "Garbage Collection: Found ${unusedChunks} unused chunks to delete.": string; - "Garbage Collection: Scanned ${scanned} / ~${docCount}": string; - "Garbage Collection: Scanning completed. Total chunks: ${totalChunks}, Used chunks: ${usedChunks}": string; - "Handle files as Case-Sensitive": string; - "Hidden Files": string; - "Hide completely": string; - "Highlight diff": string; - "How to display network errors when the sync server is unreachable.": string; - "How would you like to configure the connection to your server?": string; - "I am adding a device to an existing synchronisation setup": string; - "I am setting this up for the first time": string; - "I know my server details, let me enter them": string; - "If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).": string; - "If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.": string; - "If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker.": string; - "If enabled, newly created chunks are temporarily kept within the document, and graduated to become independent chunks once stabilised.": string; - "If enabled, the \u26D4 icon will be shown inside the status instead of the file warnings banner. No details will be shown.": string; - "If enabled, the file under 1kb will be processed in the UI thread.": string; - "If enabled, the notification of hidden files change will be suppressed.": string; - "If this enabled, all chunks will be stored with the revision made from its content. (Previous behaviour)": string; - "If this enabled, All files are handled as case-Sensitive (Previous behaviour).": string; - "If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature.": string; - "If you reached the payload size limit when using IBM Cloudant, please decrease batch size and batch limit to a lower value.": string; - "Ignore and Proceed": string; - "Ignore patterns": string; - "Import connection": string; - "Initialise all journal history, On the next sync, every item will be received and sent.": string; - "Initialise journal received history. On the next sync, every item except this device sent will be downloaded again.": string; - "Initialise journal sent history. On the next sync, every item except this device received will be sent again.": string; - Later: string; - "Limit: {datetime} ({timestamp})": string; - Lock: string; - "Lock Server": string; - "Lock the remote server to prevent synchronization with other devices.": string; - "Minimum interval for syncing": string; - "More actions": string; - "Network warning style": string; - "New Remote": string; - "No connected device information found. Cancelling Garbage Collection.": string; - "No limit configured": string; - "No, please take me back": string; - "Node ID": string; - "Node Information Missing": string; - "Non-Synchronising files": string; - "Normal Files": string; - "Obsidian version": string; - "obsidianLiveSyncSettingTab.btnApply": string; - "obsidianLiveSyncSettingTab.btnDisable": string; - "obsidianLiveSyncSettingTab.btnNext": string; - "obsidianLiveSyncSettingTab.buttonNext": string; - "obsidianLiveSyncSettingTab.defaultLanguage": string; - "obsidianLiveSyncSettingTab.labelDisabled": string; - "obsidianLiveSyncSettingTab.labelEnabled": string; - "obsidianLiveSyncSettingTab.logConfiguredDisabled": string; - "obsidianLiveSyncSettingTab.logConfiguredLiveSync": string; - "obsidianLiveSyncSettingTab.logConfiguredPeriodic": string; - "obsidianLiveSyncSettingTab.logSelectAnyPreset": string; - "obsidianLiveSyncSettingTab.msgConfigCheckFailed": string; - "obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": string; - "obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.msgGenerateSetupURI": string; - "obsidianLiveSyncSettingTab.msgInvalidPassphrase": string; - "obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": string; - "obsidianLiveSyncSettingTab.nameDisableHiddenFileSync": string; - "obsidianLiveSyncSettingTab.nameEnableHiddenFileSync": string; - "obsidianLiveSyncSettingTab.nameHiddenFileSynchronization": string; - "obsidianLiveSyncSettingTab.optionDisableAllAutomatic": string; - "obsidianLiveSyncSettingTab.optionLiveSync": string; - "obsidianLiveSyncSettingTab.optionOnEvents": string; - "obsidianLiveSyncSettingTab.optionPeriodicAndEvents": string; - "obsidianLiveSyncSettingTab.optionPeriodicWithBatch": string; - "obsidianLiveSyncSettingTab.titleAppearance": string; - "obsidianLiveSyncSettingTab.titleConflictResolution": string; - "obsidianLiveSyncSettingTab.titleCongratulations": string; - "obsidianLiveSyncSettingTab.titleCouchDB": string; - "obsidianLiveSyncSettingTab.titleDeletionPropagation": string; - "obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": string; - "obsidianLiveSyncSettingTab.titleEncryptionPassphraseInvalid": string; - "obsidianLiveSyncSettingTab.titleFetchConfig": string; - "obsidianLiveSyncSettingTab.titleHiddenFiles": string; - "obsidianLiveSyncSettingTab.titleLogging": string; - "obsidianLiveSyncSettingTab.titleMinioS3R2": string; - "obsidianLiveSyncSettingTab.titleNotification": string; - "obsidianLiveSyncSettingTab.titleRemoteConfigCheckFailed": string; - "obsidianLiveSyncSettingTab.titleRemoteServer": string; - "obsidianLiveSyncSettingTab.titleSynchronizationMethod": string; - "obsidianLiveSyncSettingTab.titleSynchronizationPreset": string; - "obsidianLiveSyncSettingTab.titleSyncSettingsViaMarkdown": string; - "obsidianLiveSyncSettingTab.titleUpdateThinning": string; - Ok: string; - "Old Algorithm": string; - "Older fallback (Slow, W/O WebAssembly)": string; - "Overwrite patterns": string; - "Overwrite remote": string; - "Overwrite remote with local DB and passphrase.": string; - "Overwrite Server Data with This Device's Files": string; - "paneMaintenance.markDeviceResolvedAfterBackup": string; - "paneMaintenance.remoteLockedAndDeviceNotAccepted": string; - "paneMaintenance.remoteLockedResolvedDevice": string; - "paneMaintenance.unlockDatabaseReady": string; - "Paste a connection string": string; - "Paste the Setup URI generated from one of your active devices.": string; - "Patterns to match files for overwriting instead of merging": string; - "Patterns to match files for syncing": string; - "Peer-to-Peer only": string; - "Peer-to-Peer Synchronisation": string; - Perform: string; - "Perform cleanup": string; - "Perform Garbage Collection": string; - "Perform Garbage Collection to remove unused chunks and reduce database size.": string; - "Pick a file to resolve conflict": string; - "Pick a file to show history": string; - "Please disable 'Read chunks online' in settings to use Garbage Collection.": string; - "Please enable 'Compute revisions for chunks' in settings to use Garbage Collection.": string; - "Please select 'Cancel' explicitly to cancel this operation.": string; - "Please select a method to import the settings from another device.": string; - "Please select an option to proceed": string; - "Please select the type of server to which you are connecting.": string; - "Please set this device name": string; - "Plug-in version": string; - "Prepare the 'report' to create an issue": string; - "Proceed Garbage Collection": string; - "Proceed with Setup URI": string; - "Proceeding with Garbage Collection, ignoring missing nodes.": string; - "Proceeding with Garbage Collection.": string; - Progress: string; - "PureJS fallback (Fast, W/O WebAssembly)": string; - "Purge all download/upload cache.": string; - "Purge all journal counter": string; - "Rebuild local and remote database with local files.": string; - "Rebuilding Operations (Remote Only)": string; - "Recovery and Repair": string; - "Recreate all": string; - "Recreate missing chunks for all files": string; - "Reduces storage space by discarding all non-latest revisions. This requires the same amount of free space on the remote server and the local client.": string; - Remediation: string; - "Remediation Setting Changed": string; - "Remote Database Tweak (In sunset)": string; - "Remote Databases": string; - "Remote name": string; - Rename: string; - "Rerun Onboarding Wizard": string; - "Rerun the onboarding wizard to set up Self-hosted LiveSync again.": string; - "Rerun Wizard": string; - Resend: string; - "Resend all chunks to the remote.": string; - Reset: string; - "Reset all": string; - "Reset all journal counter": string; - "Reset journal received history": string; - "Reset journal sent history": string; - "Reset notification threshold and check the remote database usage": string; - "Reset received": string; - "Reset sent history": string; - "Reset Synchronisation information": string; - "Reset Synchronisation on This Device": string; - "Reset the remote storage size threshold and check the remote storage size again.": string; - "Resolve All": string; - "Resolve all conflicted files": string; - "Resolve All conflicted files by the newer one": string; - "Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one.": string; - "Restart Now": string; - "Restarting Obsidian is strongly recommended. Until restart, some changes may not take effect, and display may be inconsistent. Are you sure to restart now?": string; - "Restore or reconstruct local database from remote.": string; - "Run Doctor": string; - "S3/MinIO/R2 Object Storage": string; - "Scan a QR Code (Recommended for mobile)": string; - "Scan for Broken files": string; - "Scan the QR code displayed on an active device using this device's camera.": string; - "Schedule and Restart": string; - "Scram Switches": string; - "Scram!": string; - "Select the database adapter to use.": string; - Send: string; - "Send chunks": string; - "Setting.GenerateKeyPair.Desc": string; - "Setting.GenerateKeyPair.Title": string; - "Setup URI dialog cancelled.": string; - "Setup.RemoteE2EE.AdvancedTitle": string; - "Setup.RemoteE2EE.AlgorithmWarning": string; - "Setup.RemoteE2EE.ButtonCancel": string; - "Setup.RemoteE2EE.ButtonProceed": string; - "Setup.RemoteE2EE.DefaultAlgorithmDesc": string; - "Setup.RemoteE2EE.Guidance": string; - "Setup.RemoteE2EE.LabelEncrypt": string; - "Setup.RemoteE2EE.LabelEncryptionAlgorithm": string; - "Setup.RemoteE2EE.LabelObfuscateProperties": string; - "Setup.RemoteE2EE.MultiDestinationWarning": string; - "Setup.RemoteE2EE.ObfuscatePropertiesDesc": string; - "Setup.RemoteE2EE.PassphraseValidationLine1": string; - "Setup.RemoteE2EE.PassphraseValidationLine2": string; - "Setup.RemoteE2EE.PlaceholderPassphrase": string; - "Setup.RemoteE2EE.StronglyRecommendedLine1": string; - "Setup.RemoteE2EE.StronglyRecommendedLine2": string; - "Setup.RemoteE2EE.StronglyRecommendedTitle": string; - "Setup.RemoteE2EE.Title": string; - "Setup.ScanQRCode.ButtonClose": string; - "Setup.ScanQRCode.Guidance": string; - "Setup.ScanQRCode.Step1": string; - "Setup.ScanQRCode.Step2": string; - "Setup.ScanQRCode.Step3": string; - "Setup.ScanQRCode.Step4": string; - "Setup.ScanQRCode.Title": string; - "Setup.UseSetupURI.ButtonCancel": string; - "Setup.UseSetupURI.ButtonProceed": string; - "Setup.UseSetupURI.ErrorFailedToParse": string; - "Setup.UseSetupURI.ErrorPassphraseRequired": string; - "Setup.UseSetupURI.GuidanceLine1": string; - "Setup.UseSetupURI.GuidanceLine2": string; - "Setup.UseSetupURI.InvalidInfo": string; - "Setup.UseSetupURI.LabelPassphrase": string; - "Setup.UseSetupURI.LabelSetupURI": string; - "Setup.UseSetupURI.PlaceholderPassphrase": string; - "Setup.UseSetupURI.Title": string; - "Setup.UseSetupURI.ValidInfo": string; - "Show full banner": string; - "Show history": string; - "Show icon only": string; - "Show status icon instead of file warnings banner": string; - "Some devices have differing progress values (max: ${maxProgress}, min: ${minProgress}).\nThis may indicate that some devices have not completed synchronisation, which could lead to conflicts. Strongly recommend confirming that all devices are synchronised before proceeding.": string; - "Storage -> Database": string; - "Switch to IDB": string; - "Switch to IndexedDB": string; - "Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage.": string; - "Synchronising files": string; - Syncing: string; - "Target patterns": string; - "The following accepted nodes are missing its node information:\n- ${missingNodes}\n\nThis indicates that they have not been connected for some time or have been left on an older version.\nIt is preferable to update all devices if possible. If you have any devices that are no longer in use, you can clear all accepted nodes by locking the remote once.": string; - "The IndexedDB adapter often offers superior performance in certain scenarios, but it has been found to cause memory leaks when used with LiveSync mode. When using LiveSync mode, please use IDB adapter instead.": string; - "The minimum interval for automatic synchronisation on event.": string; - "This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.": string; - "This is an advanced option for users who do not have a URI or who wish to configure detailed settings.": string; - "This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.": string; - "This will recreate chunks for all files. If there were missing chunks, this may fix the errors.": string; - "Use a Setup URI (Recommended)": string; - "Verify all": string; - "Verify and repair all files": string; - "We will now guide you through a few questions to simplify the synchronisation setup.": string; - "We will now proceed with the server configuration.": string; - "Welcome to Self-hosted LiveSync": string; - "xxhash32 (Fast but less collision resistance)": string; - "xxhash64 (Fastest)": string; - "Yes, I want to add this device to my existing synchronisation": string; - "Yes, I want to set up a new synchronisation": string; - "You are adding this device to an existing synchronisation setup.": string; - }; -}; diff --git a/_types/src/lib/src/common/messages/zh.d.ts b/_types/src/lib/src/common/messages/zh.d.ts deleted file mode 100644 index 630c2898..00000000 --- a/_types/src/lib/src/common/messages/zh.d.ts +++ /dev/null @@ -1,1095 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare const PartialMessages: { - readonly zh: { - "(Active)": string; - "(BETA) Always overwrite with a newer file": string; - "(Beta) Use ignore files": string; - "(Days passed, 0 to disable automatic-deletion)": string; - "(ex. Read chunks online) If this option is enabled, LiveSync reads chunks online directly instead of replicating them locally. Increasing Custom chunk size is recommended.": string; - "(MB) If this is set, changes to local and remote files that are larger than this will be skipped. If the file becomes smaller again, a newer one will be used.": string; - "(Mega chars)": string; - "(Not recommended) If set, credentials will be stored in the file.": string; - "(Obsolete) Use an old adapter for compatibility": string; - "(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": string; - "(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": string; - "(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": string; - "(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": string; - "> [!INFO]- The connected devices have been detected as follows:\n${devices}": string; - "A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration.": string; - "Access Key": string; - Activate: string; - "Active Remote Configuration": string; - "Add default patterns": string; - "Add new connection": string; - "All devices have the same progress value (${progress}). Your devices seem to be synchronised. And be able to proceed with Garbage Collection.": string; - "Always prompt merge conflicts": string; - Analyse: string; - "Analyse database usage": string; - "Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like.": string; - "Apply Latest Change if Conflicting": string; - "Apply preset configuration": string; - "Ask a passphrase at every launch": string; - "Automatically Sync all files when opening Obsidian.": string; - Back: string; - "Back to non-configured": string; - "Batch database update": string; - "Batch limit": string; - "Batch size": string; - "Batch size of on-demand fetching": string; - "Before v0.17.16, we used an old adapter for the local database. Now the new adapter is preferred. However, it needs local database rebuilding. Please disable this toggle when you have enough time. If leave it enabled, also while fetching from the remote database, you will be asked to disable this.": string; - "Bucket Name": string; - Cancel: string; - "Cancel Garbage Collection": string; - Check: string; - "Check and convert non-path-obfuscated files": string; - "Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.": string; - "cmdConfigSync.showCustomizationSync": string; - "Comma separated `.gitignore, .dockerignore`": string; - "Compaction in progress on remote database...": string; - "Compaction on remote database completed successfully.": string; - "Compaction on remote database failed.": string; - "Compaction on remote database timed out.": string; - "Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep.": string; - "Compatibility (Conflict Behaviour)": string; - "Compatibility (Database structure)": string; - "Compatibility (Internal API Usage)": string; - "Compatibility (Metadata)": string; - "Compatibility (Remote Database)": string; - "Compatibility (Trouble addressed)": string; - "Compute revisions for chunks": string; - "Configuration Encryption": string; - Configure: string; - "Configure And Change Remote": string; - "Configure E2EE": string; - "Configure Remote": string; - "Configure the same server information as your other devices again, manually, very advanced users only.": string; - "Connection Method": string; - "Continue to CouchDB setup": string; - "Continue to Peer-to-Peer only setup": string; - "Continue to S3/MinIO/R2 setup": string; - Copy: string; - "Copy Report to clipboard": string; - "CouchDB Connection Tweak": string; - "Cross-platform": string; - "Current adapter: {adapter}": string; - "Customization Sync": string; - "Customization Sync (Beta3)": string; - "Data Compression": string; - "Database Adapter": string; - "Database Name": string; - "Database suffix": string; - Default: string; - "Delay conflict resolution of inactive files": string; - "Delay merge conflict prompt for inactive files.": string; - Delete: string; - "Delete all customization sync data": string; - "Delete all data on the remote server.": string; - "Delete local database to reset or uninstall Self-hosted LiveSync": string; - "Delete old metadata of deleted files on start-up": string; - "Delete Remote Configuration": string; - "Delete remote configuration '{name}'?": string; - desktop: string; - Developer: string; - Device: string; - "Device name": string; - "Device Setup Method": string; - "dialog.yourLanguageAvailable": string; - "dialog.yourLanguageAvailable.btnRevertToDefault": string; - "dialog.yourLanguageAvailable.Title": string; - "Disables all synchronization and restart.": string; - "Disables logging, only shows notifications. Please disable if you report an issue.": string; - "Display Language": string; - "Display name": string; - "Do not check configuration mismatch before replication": string; - "Do not keep metadata of deleted files.": string; - "Do not split chunks in the background": string; - "Do not use internal API": string; - "Doctor.Button.DismissThisVersion": string; - "Doctor.Button.Fix": string; - "Doctor.Button.FixButNoRebuild": string; - "Doctor.Button.No": string; - "Doctor.Button.Skip": string; - "Doctor.Button.Yes": string; - "Doctor.Dialogue.Main": string; - "Doctor.Dialogue.MainFix": string; - "Doctor.Dialogue.Title": string; - "Doctor.Dialogue.TitleAlmostDone": string; - "Doctor.Dialogue.TitleFix": string; - "Doctor.Level.Must": string; - "Doctor.Level.Necessary": string; - "Doctor.Level.Optional": string; - "Doctor.Level.Recommended": string; - "Doctor.Message.NoIssues": string; - "Doctor.Message.RebuildLocalRequired": string; - "Doctor.Message.RebuildRequired": string; - "Doctor.Message.SomeSkipped": string; - "Doctor.RULES.E2EE_V02500.REASON": string; - Duplicate: string; - "Duplicate remote": string; - "E2EE Configuration": string; - "Edge case addressing (Behaviour)": string; - "Edge case addressing (Database)": string; - "Edge case addressing (Processing)": string; - "Emergency restart": string; - "Enable advanced features": string; - "Enable customization sync": string; - "Enable Developers' Debug Tools.": string; - "Enable edge case treatment features": string; - "Enable poweruser features": string; - "Enable this if your Object Storage doesn't support CORS": string; - "Enable this option to automatically apply the most recent change to documents even when it conflicts": string; - "Encrypt contents on the remote database. If you use the plugin's synchronization feature, enabling this is recommended.": string; - "Encrypting sensitive configuration items": string; - "Encryption phassphrase. If changed, you should overwrite the server's database with the new (encrypted) files.": string; - "End-to-End Encryption": string; - "Endpoint URL": string; - "Enhance chunk size": string; - "Enter Server Information": string; - "Enter the server information manually": string; - Export: string; - "Failed to connect to remote for compaction.": string; - "Failed to connect to remote for compaction. ${reason}": string; - "Failed to start one-shot replication before Garbage Collection. Garbage Collection Cancelled.": string; - "Failed to start replication after Garbage Collection.": string; - Fetch: string; - "Fetch chunks on demand": string; - "Fetch database with previous behaviour": string; - "Fetch remote settings": string; - "File to resolve conflict": string; - Filename: string; - "First, please select the option that best describes your current situation.": string; - "Flag and restart": string; - "Forces the file to be synced when opened.": string; - "Fresh Start Wipe": string; - "Garbage Collection cancelled by user.": string; - "Garbage Collection completed. Deleted chunks: ${deletedChunks} / ${totalChunks}. Time taken: ${seconds} seconds.": string; - "Garbage Collection Confirmation": string; - "Garbage Collection V3 (Beta)": string; - "Garbage Collection: Found ${unusedChunks} unused chunks to delete.": string; - "Garbage Collection: Scanned ${scanned} / ~${docCount}": string; - "Garbage Collection: Scanning completed. Total chunks: ${totalChunks}, Used chunks: ${usedChunks}": string; - "Handle files as Case-Sensitive": string; - "Hidden Files": string; - "Hide completely": string; - "How to display network errors when the sync server is unreachable.": string; - "How would you like to configure the connection to your server?": string; - "I am adding a device to an existing synchronisation setup": string; - "I am setting this up for the first time": string; - "I know my server details, let me enter them": string; - "If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).": string; - "If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.": string; - "If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker.": string; - "If enabled, newly created chunks are temporarily kept within the document, and graduated to become independent chunks once stabilised.": string; - "If enabled, the \u26D4 icon will be shown inside the status instead of the file warnings banner. No details will be shown.": string; - "If enabled, the file under 1kb will be processed in the UI thread.": string; - "If enabled, the notification of hidden files change will be suppressed.": string; - "If this enabled, all chunks will be stored with the revision made from its content. (Previous behaviour)": string; - "If this enabled, All files are handled as case-Sensitive (Previous behaviour).": string; - "If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature.": string; - "If this is set, changes to local files which are matched by the ignore files will be skipped. Remote changes are determined using local ignore files.": string; - "If this option is enabled, PouchDB will hold the connection open for 60 seconds, and if no change arrives in that time, close and reopen the socket, instead of holding it open indefinitely. Useful when a proxy limits request duration but can increase resource usage.": string; - "Ignore and Proceed": string; - "Ignore files": string; - "Ignore patterns": string; - "Import connection": string; - "Incubate Chunks in Document": string; - "Initialise all journal history, On the next sync, every item will be received and sent.": string; - "Interval (sec)": string; - "K.exp": string; - "K.long_p2p_sync": string; - "K.P2P": string; - "K.Peer": string; - "K.ScanCustomization": string; - "K.short_p2p_sync": string; - "K.title_p2p_sync": string; - "Keep empty folder": string; - lang_def: string; - "lang-de": string; - "lang-def": string; - "lang-es": string; - "lang-fr": string; - "lang-ja": string; - "lang-ko": string; - "lang-ru": string; - "lang-zh": string; - "lang-zh-tw": string; - Later: string; - "Limit: {datetime} ({timestamp})": string; - "LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured.": string; - "liveSyncReplicator.beforeLiveSync": string; - "liveSyncReplicator.cantReplicateLowerValue": string; - "liveSyncReplicator.checkingLastSyncPoint": string; - "liveSyncReplicator.couldNotConnectTo": string; - "liveSyncReplicator.couldNotConnectToRemoteDb": string; - "liveSyncReplicator.couldNotConnectToServer": string; - "liveSyncReplicator.couldNotConnectToURI": string; - "liveSyncReplicator.couldNotMarkResolveRemoteDb": string; - "liveSyncReplicator.liveSyncBegin": string; - "liveSyncReplicator.lockRemoteDb": string; - "liveSyncReplicator.markDeviceResolved": string; - "liveSyncReplicator.oneShotSyncBegin": string; - "liveSyncReplicator.remoteDbCorrupted": string; - "liveSyncReplicator.remoteDbCreatedOrConnected": string; - "liveSyncReplicator.remoteDbDestroyed": string; - "liveSyncReplicator.remoteDbDestroyError": string; - "liveSyncReplicator.remoteDbMarkedResolved": string; - "liveSyncReplicator.replicationClosed": string; - "liveSyncReplicator.replicationInProgress": string; - "liveSyncReplicator.retryLowerBatchSize": string; - "liveSyncReplicator.unlockRemoteDb": string; - "liveSyncSetting.errorNoSuchSettingItem": string; - "liveSyncSetting.originalValue": string; - "liveSyncSetting.valueShouldBeInRange": string; - "liveSyncSettings.btnApply": string; - Lock: string; - "Lock Server": string; - "Lock the remote server to prevent synchronization with other devices.": string; - "logPane.autoScroll": string; - "logPane.logWindowOpened": string; - "logPane.pause": string; - "logPane.title": string; - "logPane.wrap": string; - "Maximum delay for batch database updating": string; - "Maximum file size": string; - "Maximum Incubating Chunk Size": string; - "Maximum Incubating Chunks": string; - "Maximum Incubation Period": string; - "MB (0 to disable).": string; - "Memory cache size (by total characters)": string; - "Memory cache size (by total items)": string; - Merge: string; - "Minimum delay for batch database updating": string; - "Minimum interval for syncing": string; - "moduleCheckRemoteSize.logCheckingStorageSizes": string; - "moduleCheckRemoteSize.logCurrentStorageSize": string; - "moduleCheckRemoteSize.logExceededWarning": string; - "moduleCheckRemoteSize.logThresholdEnlarged": string; - "moduleCheckRemoteSize.msgConfirmRebuild": string; - "moduleCheckRemoteSize.msgDatabaseGrowing": string; - "moduleCheckRemoteSize.msgSetDBCapacity": string; - "moduleCheckRemoteSize.option2GB": string; - "moduleCheckRemoteSize.option800MB": string; - "moduleCheckRemoteSize.optionAskMeLater": string; - "moduleCheckRemoteSize.optionDismiss": string; - "moduleCheckRemoteSize.optionIncreaseLimit": string; - "moduleCheckRemoteSize.optionNoWarn": string; - "moduleCheckRemoteSize.optionRebuildAll": string; - "moduleCheckRemoteSize.titleDatabaseSizeLimitExceeded": string; - "moduleCheckRemoteSize.titleDatabaseSizeNotify": string; - "moduleInputUIObsidian.defaultTitleConfirmation": string; - "moduleInputUIObsidian.defaultTitleSelect": string; - "moduleInputUIObsidian.optionNo": string; - "moduleInputUIObsidian.optionYes": string; - "moduleLiveSyncMain.logAdditionalSafetyScan": string; - "moduleLiveSyncMain.logLoadingPlugin": string; - "moduleLiveSyncMain.logPluginInitCancelled": string; - "moduleLiveSyncMain.logPluginVersion": string; - "moduleLiveSyncMain.logReadChangelog": string; - "moduleLiveSyncMain.logSafetyScanCompleted": string; - "moduleLiveSyncMain.logSafetyScanFailed": string; - "moduleLiveSyncMain.logUnloadingPlugin": string; - "moduleLiveSyncMain.logVersionUpdate": string; - "moduleLiveSyncMain.msgScramEnabled": string; - "moduleLiveSyncMain.optionKeepLiveSyncDisabled": string; - "moduleLiveSyncMain.optionResumeAndRestart": string; - "moduleLiveSyncMain.titleScramEnabled": string; - "moduleLocalDatabase.logWaitingForReady": string; - "moduleLog.showLog": string; - "moduleMigration.docUri": string; - "moduleMigration.fix0256.buttons.checkItLater": string; - "moduleMigration.fix0256.buttons.DismissForever": string; - "moduleMigration.fix0256.buttons.fix": string; - "moduleMigration.fix0256.message": string; - "moduleMigration.fix0256.messageUnrecoverable": string; - "moduleMigration.fix0256.title": string; - "moduleMigration.insecureChunkExist.buttons.fetch": string; - "moduleMigration.insecureChunkExist.buttons.later": string; - "moduleMigration.insecureChunkExist.buttons.rebuild": string; - "moduleMigration.insecureChunkExist.laterMessage": string; - "moduleMigration.insecureChunkExist.message": string; - "moduleMigration.insecureChunkExist.title": string; - "moduleMigration.logBulkSendCorrupted": string; - "moduleMigration.logFetchRemoteTweakFailed": string; - "moduleMigration.logLocalDatabaseNotReady": string; - "moduleMigration.logMigratedSameBehaviour": string; - "moduleMigration.logMigrationFailed": string; - "moduleMigration.logRedflag2CreationFail": string; - "moduleMigration.logRemoteTweakUnavailable": string; - "moduleMigration.logSetupCancelled": string; - "moduleMigration.msgFetchRemoteAgain": string; - "moduleMigration.msgInitialSetup": string; - "moduleMigration.msgRecommendSetupUri": string; - "moduleMigration.msgSinceV02321": string; - "moduleMigration.optionAdjustRemote": string; - "moduleMigration.optionDecideLater": string; - "moduleMigration.optionEnableBoth": string; - "moduleMigration.optionEnableFilenameCaseInsensitive": string; - "moduleMigration.optionEnableFixedRevisionForChunks": string; - "moduleMigration.optionHaveSetupUri": string; - "moduleMigration.optionKeepPreviousBehaviour": string; - "moduleMigration.optionManualSetup": string; - "moduleMigration.optionNoAskAgain": string; - "moduleMigration.optionNoSetupUri": string; - "moduleMigration.optionRemindNextLaunch": string; - "moduleMigration.optionSetupViaP2P": string; - "moduleMigration.optionSetupWizard": string; - "moduleMigration.optionYesFetchAgain": string; - "moduleMigration.titleCaseSensitivity": string; - "moduleMigration.titleRecommendSetupUri": string; - "moduleMigration.titleWelcome": string; - "moduleObsidianMenu.replicate": string; - "More actions": string; - "Move remotely deleted files to the trash, instead of deleting.": string; - "Network warning style": string; - "New Remote": string; - "No connected device information found. Cancelling Garbage Collection.": string; - "No limit configured": string; - "No, please take me back": string; - "Node ID": string; - "Node Information Missing": string; - "Non-Synchronising files": string; - "Normal Files": string; - "Not all messages have been translated. And, please revert to \"Default\" when reporting errors.": string; - "Notify all setting files": string; - "Notify customized": string; - "Notify when other device has newly customized.": string; - "Notify when the estimated remote storage size exceeds on start up": string; - "Number of batches to process at a time. Defaults to 40. Minimum is 2. This along with batch size controls how many docs are kept in memory at a time.": string; - "Number of changes to sync at a time. Defaults to 50. Minimum is 2.": string; - "Obsidian version": string; - "obsidianLiveSyncSettingTab.btnApply": string; - "obsidianLiveSyncSettingTab.btnCheck": string; - "obsidianLiveSyncSettingTab.btnCopy": string; - "obsidianLiveSyncSettingTab.btnDisable": string; - "obsidianLiveSyncSettingTab.btnDiscard": string; - "obsidianLiveSyncSettingTab.btnEnable": string; - "obsidianLiveSyncSettingTab.btnFix": string; - "obsidianLiveSyncSettingTab.btnGotItAndUpdated": string; - "obsidianLiveSyncSettingTab.btnNext": string; - "obsidianLiveSyncSettingTab.btnStart": string; - "obsidianLiveSyncSettingTab.btnTest": string; - "obsidianLiveSyncSettingTab.btnUse": string; - "obsidianLiveSyncSettingTab.buttonFetch": string; - "obsidianLiveSyncSettingTab.buttonNext": string; - "obsidianLiveSyncSettingTab.defaultLanguage": string; - "obsidianLiveSyncSettingTab.descConnectSetupURI": string; - "obsidianLiveSyncSettingTab.descCopySetupURI": string; - "obsidianLiveSyncSettingTab.descEnableLiveSync": string; - "obsidianLiveSyncSettingTab.descFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.descManualSetup": string; - "obsidianLiveSyncSettingTab.descTestDatabaseConnection": string; - "obsidianLiveSyncSettingTab.descValidateDatabaseConfig": string; - "obsidianLiveSyncSettingTab.errAccessForbidden": string; - "obsidianLiveSyncSettingTab.errCannotContinueTest": string; - "obsidianLiveSyncSettingTab.errCorsCredentials": string; - "obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials": string; - "obsidianLiveSyncSettingTab.errCorsOrigins": string; - "obsidianLiveSyncSettingTab.errEnableCors": string; - "obsidianLiveSyncSettingTab.errEnableCorsChttpd": string; - "obsidianLiveSyncSettingTab.errMaxDocumentSize": string; - "obsidianLiveSyncSettingTab.errMaxRequestSize": string; - "obsidianLiveSyncSettingTab.errMissingWwwAuth": string; - "obsidianLiveSyncSettingTab.errRequireValidUser": string; - "obsidianLiveSyncSettingTab.errRequireValidUserAuth": string; - "obsidianLiveSyncSettingTab.labelDisabled": string; - "obsidianLiveSyncSettingTab.labelEnabled": string; - "obsidianLiveSyncSettingTab.levelAdvanced": string; - "obsidianLiveSyncSettingTab.levelEdgeCase": string; - "obsidianLiveSyncSettingTab.levelPowerUser": string; - "obsidianLiveSyncSettingTab.linkOpenInBrowser": string; - "obsidianLiveSyncSettingTab.linkPageTop": string; - "obsidianLiveSyncSettingTab.linkTipsAndTroubleshooting": string; - "obsidianLiveSyncSettingTab.linkTroubleshooting": string; - "obsidianLiveSyncSettingTab.logCannotUseCloudant": string; - "obsidianLiveSyncSettingTab.logCheckingConfigDone": string; - "obsidianLiveSyncSettingTab.logCheckingConfigFailed": string; - "obsidianLiveSyncSettingTab.logCheckingDbConfig": string; - "obsidianLiveSyncSettingTab.logCheckPassphraseFailed": string; - "obsidianLiveSyncSettingTab.logConfiguredDisabled": string; - "obsidianLiveSyncSettingTab.logConfiguredLiveSync": string; - "obsidianLiveSyncSettingTab.logConfiguredPeriodic": string; - "obsidianLiveSyncSettingTab.logCouchDbConfigFail": string; - "obsidianLiveSyncSettingTab.logCouchDbConfigSet": string; - "obsidianLiveSyncSettingTab.logCouchDbConfigUpdated": string; - "obsidianLiveSyncSettingTab.logDatabaseConnected": string; - "obsidianLiveSyncSettingTab.logEncryptionNoPassphrase": string; - "obsidianLiveSyncSettingTab.logEncryptionNoSupport": string; - "obsidianLiveSyncSettingTab.logErrorOccurred": string; - "obsidianLiveSyncSettingTab.logEstimatedSize": string; - "obsidianLiveSyncSettingTab.logPassphraseInvalid": string; - "obsidianLiveSyncSettingTab.logPassphraseNotCompatible": string; - "obsidianLiveSyncSettingTab.logRebuildNote": string; - "obsidianLiveSyncSettingTab.logSelectAnyPreset": string; - "obsidianLiveSyncSettingTab.msgAreYouSureProceed": string; - "obsidianLiveSyncSettingTab.msgChangesNeedToBeApplied": string; - "obsidianLiveSyncSettingTab.msgConfigCheck": string; - "obsidianLiveSyncSettingTab.msgConfigCheckFailed": string; - "obsidianLiveSyncSettingTab.msgConnectionCheck": string; - "obsidianLiveSyncSettingTab.msgConnectionProxyNote": string; - "obsidianLiveSyncSettingTab.msgCurrentOrigin": string; - "obsidianLiveSyncSettingTab.msgDiscardConfirmation": string; - "obsidianLiveSyncSettingTab.msgDone": string; - "obsidianLiveSyncSettingTab.msgEnableCors": string; - "obsidianLiveSyncSettingTab.msgEnableCorsChttpd": string; - "obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": string; - "obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.msgGenerateSetupURI": string; - "obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": string; - "obsidianLiveSyncSettingTab.msgInvalidPassphrase": string; - "obsidianLiveSyncSettingTab.msgNewVersionNote": string; - "obsidianLiveSyncSettingTab.msgNonHTTPSInfo": string; - "obsidianLiveSyncSettingTab.msgNonHTTPSWarning": string; - "obsidianLiveSyncSettingTab.msgNotice": string; - "obsidianLiveSyncSettingTab.msgObjectStorageWarning": string; - "obsidianLiveSyncSettingTab.msgOriginCheck": string; - "obsidianLiveSyncSettingTab.msgRebuildRequired": string; - "obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": string; - "obsidianLiveSyncSettingTab.msgSetCorsCredentials": string; - "obsidianLiveSyncSettingTab.msgSetCorsOrigins": string; - "obsidianLiveSyncSettingTab.msgSetMaxDocSize": string; - "obsidianLiveSyncSettingTab.msgSetMaxRequestSize": string; - "obsidianLiveSyncSettingTab.msgSetRequireValidUser": string; - "obsidianLiveSyncSettingTab.msgSetRequireValidUserAuth": string; - "obsidianLiveSyncSettingTab.msgSettingModified": string; - "obsidianLiveSyncSettingTab.msgSettingsUnchangeableDuringSync": string; - "obsidianLiveSyncSettingTab.msgSetWwwAuth": string; - "obsidianLiveSyncSettingTab.nameApplySettings": string; - "obsidianLiveSyncSettingTab.nameConnectSetupURI": string; - "obsidianLiveSyncSettingTab.nameCopySetupURI": string; - "obsidianLiveSyncSettingTab.nameDisableHiddenFileSync": string; - "obsidianLiveSyncSettingTab.nameDiscardSettings": string; - "obsidianLiveSyncSettingTab.nameEnableHiddenFileSync": string; - "obsidianLiveSyncSettingTab.nameEnableLiveSync": string; - "obsidianLiveSyncSettingTab.nameHiddenFileSynchronization": string; - "obsidianLiveSyncSettingTab.nameManualSetup": string; - "obsidianLiveSyncSettingTab.nameTestConnection": string; - "obsidianLiveSyncSettingTab.nameTestDatabaseConnection": string; - "obsidianLiveSyncSettingTab.nameValidateDatabaseConfig": string; - "obsidianLiveSyncSettingTab.okAdminPrivileges": string; - "obsidianLiveSyncSettingTab.okCorsCredentials": string; - "obsidianLiveSyncSettingTab.okCorsCredentialsForOrigin": string; - "obsidianLiveSyncSettingTab.okCorsOriginMatched": string; - "obsidianLiveSyncSettingTab.okCorsOrigins": string; - "obsidianLiveSyncSettingTab.okEnableCors": string; - "obsidianLiveSyncSettingTab.okEnableCorsChttpd": string; - "obsidianLiveSyncSettingTab.okMaxDocumentSize": string; - "obsidianLiveSyncSettingTab.okMaxRequestSize": string; - "obsidianLiveSyncSettingTab.okRequireValidUser": string; - "obsidianLiveSyncSettingTab.okRequireValidUserAuth": string; - "obsidianLiveSyncSettingTab.okWwwAuth": string; - "obsidianLiveSyncSettingTab.optionApply": string; - "obsidianLiveSyncSettingTab.optionCancel": string; - "obsidianLiveSyncSettingTab.optionCouchDB": string; - "obsidianLiveSyncSettingTab.optionDisableAllAutomatic": string; - "obsidianLiveSyncSettingTab.optionFetchFromRemote": string; - "obsidianLiveSyncSettingTab.optionHere": string; - "obsidianLiveSyncSettingTab.optionLiveSync": string; - "obsidianLiveSyncSettingTab.optionMinioS3R2": string; - "obsidianLiveSyncSettingTab.optionOkReadEverything": string; - "obsidianLiveSyncSettingTab.optionOnEvents": string; - "obsidianLiveSyncSettingTab.optionPeriodicAndEvents": string; - "obsidianLiveSyncSettingTab.optionPeriodicWithBatch": string; - "obsidianLiveSyncSettingTab.optionRebuildBoth": string; - "obsidianLiveSyncSettingTab.optionSaveOnlySettings": string; - "obsidianLiveSyncSettingTab.panelChangeLog": string; - "obsidianLiveSyncSettingTab.panelGeneralSettings": string; - "obsidianLiveSyncSettingTab.panelPrivacyEncryption": string; - "obsidianLiveSyncSettingTab.panelRemoteConfiguration": string; - "obsidianLiveSyncSettingTab.panelSetup": string; - "obsidianLiveSyncSettingTab.serverVersion": string; - "obsidianLiveSyncSettingTab.titleActiveRemoteServer": string; - "obsidianLiveSyncSettingTab.titleAppearance": string; - "obsidianLiveSyncSettingTab.titleConflictResolution": string; - "obsidianLiveSyncSettingTab.titleCongratulations": string; - "obsidianLiveSyncSettingTab.titleCouchDB": string; - "obsidianLiveSyncSettingTab.titleDeletionPropagation": string; - "obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": string; - "obsidianLiveSyncSettingTab.titleEncryptionPassphraseInvalid": string; - "obsidianLiveSyncSettingTab.titleExtraFeatures": string; - "obsidianLiveSyncSettingTab.titleFetchConfig": string; - "obsidianLiveSyncSettingTab.titleFetchConfigFromRemote": string; - "obsidianLiveSyncSettingTab.titleFetchSettings": string; - "obsidianLiveSyncSettingTab.titleHiddenFiles": string; - "obsidianLiveSyncSettingTab.titleLogging": string; - "obsidianLiveSyncSettingTab.titleMinioS3R2": string; - "obsidianLiveSyncSettingTab.titleNotification": string; - "obsidianLiveSyncSettingTab.titleOnlineTips": string; - "obsidianLiveSyncSettingTab.titleQuickSetup": string; - "obsidianLiveSyncSettingTab.titleRebuildRequired": string; - "obsidianLiveSyncSettingTab.titleRemoteConfigCheckFailed": string; - "obsidianLiveSyncSettingTab.titleRemoteServer": string; - "obsidianLiveSyncSettingTab.titleReset": string; - "obsidianLiveSyncSettingTab.titleSetupOtherDevices": string; - "obsidianLiveSyncSettingTab.titleSynchronizationMethod": string; - "obsidianLiveSyncSettingTab.titleSynchronizationPreset": string; - "obsidianLiveSyncSettingTab.titleSyncSettings": string; - "obsidianLiveSyncSettingTab.titleSyncSettingsViaMarkdown": string; - "obsidianLiveSyncSettingTab.titleUpdateThinning": string; - "obsidianLiveSyncSettingTab.warnCorsOriginUnmatched": string; - "obsidianLiveSyncSettingTab.warnNoAdmin": string; - Ok: string; - "Old Algorithm": string; - "Older fallback (Slow, W/O WebAssembly)": string; - Open: string; - "Open the dialog": string; - Overwrite: string; - "Overwrite patterns": string; - "Overwrite remote": string; - "Overwrite remote with local DB and passphrase.": string; - "Overwrite Server Data with This Device's Files": string; - "P2P.AskPassphraseForDecrypt": string; - "P2P.AskPassphraseForShare": string; - "P2P.DisabledButNeed": string; - "P2P.FailedToOpen": string; - "P2P.NoAutoSyncPeers": string; - "P2P.NoKnownPeers": string; - "P2P.Note.description": string; - "P2P.Note.important_note": string; - "P2P.Note.important_note_sub": string; - "P2P.Note.Summary": string; - "P2P.NotEnabled": string; - "P2P.P2PReplication": string; - "P2P.PaneTitle": string; - "P2P.ReplicatorInstanceMissing": string; - "P2P.SeemsOffline": string; - "P2P.SyncAlreadyRunning": string; - "P2P.SyncCompleted": string; - "P2P.SyncStartedWith": string; - "paneMaintenance.markDeviceResolvedAfterBackup": string; - "paneMaintenance.remoteLockedAndDeviceNotAccepted": string; - "paneMaintenance.remoteLockedResolvedDevice": string; - "paneMaintenance.unlockDatabaseReady": string; - Passphrase: string; - "Passphrase of sensitive configuration items": string; - password: string; - Password: string; - "Paste a connection string": string; - "Paste the Setup URI generated from one of your active devices.": string; - "Path Obfuscation": string; - "Patterns to match files for overwriting instead of merging": string; - "Patterns to match files for syncing": string; - "Peer-to-Peer only": string; - "Peer-to-Peer Synchronisation": string; - "Per-file-saved customization sync": string; - Perform: string; - "Perform cleanup": string; - "Perform Garbage Collection": string; - "Perform Garbage Collection to remove unused chunks and reduce database size.": string; - "Periodic Sync interval": string; - "Pick a file to resolve conflict": string; - "Please disable 'Read chunks online' in settings to use Garbage Collection.": string; - "Please enable 'Compute revisions for chunks' in settings to use Garbage Collection.": string; - "Please select 'Cancel' explicitly to cancel this operation.": string; - "Please select a method to import the settings from another device.": string; - "Please select an option to proceed": string; - "Please select the type of server to which you are connecting.": string; - "Please set device name to identify this device. This name should be unique among your devices. While not configured, we cannot enable this feature.": string; - "Please set this device name": string; - "Plug-in version": string; - "Prepare the 'report' to create an issue": string; - Presets: string; - "Proceed Garbage Collection": string; - "Proceed with Setup URI": string; - "Proceeding with Garbage Collection, ignoring missing nodes.": string; - "Proceeding with Garbage Collection.": string; - "Process small files in the foreground": string; - Progress: string; - "Property Encryption": string; - "PureJS fallback (Fast, W/O WebAssembly)": string; - "Purge all download/upload cache.": string; - "Purge all journal counter": string; - "Rebuild local and remote database with local files.": string; - "Rebuilding Operations (Remote Only)": string; - "Recreate all": string; - "Recreate missing chunks for all files": string; - "RedFlag.Fetch.Method.Desc": string; - "RedFlag.Fetch.Method.FetchSafer": string; - "RedFlag.Fetch.Method.FetchSmoother": string; - "RedFlag.Fetch.Method.FetchTraditional": string; - "RedFlag.Fetch.Method.Title": string; - "RedFlag.FetchRemoteConfig.Buttons.Cancel": string; - "RedFlag.FetchRemoteConfig.Buttons.Fetch": string; - "RedFlag.FetchRemoteConfig.Message": string; - "RedFlag.FetchRemoteConfig.Title": string; - "Reduces storage space by discarding all non-latest revisions. This requires the same amount of free space on the remote server and the local client.": string; - "Reducing the frequency with which on-disk changes are reflected into the DB": string; - Region: string; - Remediation: string; - "Remediation Setting Changed": string; - "Remote Database Tweak (In sunset)": string; - "Remote Databases": string; - "Remote name": string; - "Remote server type": string; - "Remote Type": string; - Rename: string; - "Replicator.Dialogue.Locked.Action.Dismiss": string; - "Replicator.Dialogue.Locked.Action.Fetch": string; - "Replicator.Dialogue.Locked.Action.Unlock": string; - "Replicator.Dialogue.Locked.Message": string; - "Replicator.Dialogue.Locked.Message.Fetch": string; - "Replicator.Dialogue.Locked.Message.Unlocked": string; - "Replicator.Dialogue.Locked.Title": string; - "Replicator.Message.Cleaned": string; - "Replicator.Message.InitialiseFatalError": string; - "Replicator.Message.Pending": string; - "Replicator.Message.SomeModuleFailed": string; - "Replicator.Message.VersionUpFlash": string; - "Requires restart of Obsidian": string; - "Requires restart of Obsidian.": string; - "Rerun Onboarding Wizard": string; - "Rerun the onboarding wizard to set up Self-hosted LiveSync again.": string; - "Rerun Wizard": string; - Resend: string; - "Resend all chunks to the remote.": string; - Reset: string; - "Reset all": string; - "Reset all journal counter": string; - "Reset journal received history": string; - "Reset journal sent history": string; - "Reset notification threshold and check the remote database usage": string; - "Reset received": string; - "Reset sent history": string; - "Reset Synchronisation information": string; - "Reset Synchronisation on This Device": string; - "Reset the remote storage size threshold and check the remote storage size again.": string; - "Resolve All": string; - "Resolve all conflicted files": string; - "Resolve All conflicted files by the newer one": string; - "Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one.": string; - "Restart Now": string; - "Restore or reconstruct local database from remote.": string; - "Run Doctor": string; - "S3/MinIO/R2 Object Storage": string; - "Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.": string; - "Saving will be performed forcefully after this number of seconds.": string; - "Scan a QR Code (Recommended for mobile)": string; - "Scan changes on customization sync": string; - "Scan customization automatically": string; - "Scan customization before replicating.": string; - "Scan customization every 1 minute.": string; - "Scan customization periodically": string; - "Scan for Broken files": string; - "Scan for hidden files before replication": string; - "Scan hidden files periodically": string; - "Scan the QR code displayed on an active device using this device's camera.": string; - "Schedule and Restart": string; - "Scram Switches": string; - "Scram!": string; - "Seconds, 0 to disable": string; - "Seconds. Saving to the local database will be delayed until this value after we stop typing or saving.": string; - "Secret Key": string; - "Select the database adapter to use.": string; - Send: string; - "Send chunks": string; - "Server URI": string; - "Setting.GenerateKeyPair.Desc": string; - "Setting.GenerateKeyPair.Title": string; - "Setting.TroubleShooting": string; - "Setting.TroubleShooting.Doctor": string; - "Setting.TroubleShooting.Doctor.Desc": string; - "Setting.TroubleShooting.ScanBrokenFiles": string; - "Setting.TroubleShooting.ScanBrokenFiles.Desc": string; - "SettingTab.Message.AskRebuild": string; - "Setup URI dialog cancelled.": string; - "Setup.Apply.Buttons.ApplyAndFetch": string; - "Setup.Apply.Buttons.ApplyAndMerge": string; - "Setup.Apply.Buttons.ApplyAndRebuild": string; - "Setup.Apply.Buttons.Cancel": string; - "Setup.Apply.Buttons.OnlyApply": string; - "Setup.Apply.Message": string; - "Setup.Apply.Title": string; - "Setup.Apply.WarningRebuildRecommended": string; - "Setup.Doctor.Buttons.No": string; - "Setup.Doctor.Buttons.Yes": string; - "Setup.Doctor.Message": string; - "Setup.Doctor.Title": string; - "Setup.FetchRemoteConf.Buttons.Fetch": string; - "Setup.FetchRemoteConf.Buttons.Skip": string; - "Setup.FetchRemoteConf.Message": string; - "Setup.FetchRemoteConf.Title": string; - "Setup.QRCode": string; - "Setup.RemoteE2EE.AdvancedTitle": string; - "Setup.RemoteE2EE.AlgorithmWarning": string; - "Setup.RemoteE2EE.ButtonCancel": string; - "Setup.RemoteE2EE.ButtonProceed": string; - "Setup.RemoteE2EE.DefaultAlgorithmDesc": string; - "Setup.RemoteE2EE.Guidance": string; - "Setup.RemoteE2EE.LabelEncrypt": string; - "Setup.RemoteE2EE.LabelEncryptionAlgorithm": string; - "Setup.RemoteE2EE.LabelObfuscateProperties": string; - "Setup.RemoteE2EE.MultiDestinationWarning": string; - "Setup.RemoteE2EE.ObfuscatePropertiesDesc": string; - "Setup.RemoteE2EE.PassphraseValidationLine1": string; - "Setup.RemoteE2EE.PassphraseValidationLine2": string; - "Setup.RemoteE2EE.PlaceholderPassphrase": string; - "Setup.RemoteE2EE.StronglyRecommendedLine1": string; - "Setup.RemoteE2EE.StronglyRecommendedLine2": string; - "Setup.RemoteE2EE.StronglyRecommendedTitle": string; - "Setup.RemoteE2EE.Title": string; - "Setup.ScanQRCode.ButtonClose": string; - "Setup.ScanQRCode.Guidance": string; - "Setup.ScanQRCode.Step1": string; - "Setup.ScanQRCode.Step2": string; - "Setup.ScanQRCode.Step3": string; - "Setup.ScanQRCode.Step4": string; - "Setup.ScanQRCode.Title": string; - "Setup.ShowQRCode": string; - "Setup.ShowQRCode.Desc": string; - "Setup.UseSetupURI.ButtonCancel": string; - "Setup.UseSetupURI.ButtonProceed": string; - "Setup.UseSetupURI.ErrorFailedToParse": string; - "Setup.UseSetupURI.ErrorPassphraseRequired": string; - "Setup.UseSetupURI.GuidanceLine1": string; - "Setup.UseSetupURI.GuidanceLine2": string; - "Setup.UseSetupURI.InvalidInfo": string; - "Setup.UseSetupURI.LabelPassphrase": string; - "Setup.UseSetupURI.LabelSetupURI": string; - "Setup.UseSetupURI.PlaceholderPassphrase": string; - "Setup.UseSetupURI.Title": string; - "Setup.UseSetupURI.ValidInfo": string; - "Should we keep folders that don't have any files inside?": string; - "Should we only check for conflicts when a file is opened?": string; - "Should we prompt you about conflicting files when a file is opened?": string; - "Should we prompt you for every single merge, even if we can safely merge automatcially?": string; - "Show full banner": string; - "Show icon only": string; - "Show only notifications": string; - "Show status as icons only": string; - "Show status icon instead of file warnings banner": string; - "Show status inside the editor": string; - "Show status on the status bar": string; - "Show verbose log. Please enable if you report an issue.": string; - "Some devices have differing progress values (max: ${maxProgress}, min: ${minProgress}).\nThis may indicate that some devices have not completed synchronisation, which could lead to conflicts. Strongly recommend confirming that all devices are synchronised before proceeding.": string; - "Starts synchronisation when a file is saved.": string; - "Stop reflecting database changes to storage files.": string; - "Stop watching for file changes.": string; - "Suppress notification of hidden files change": string; - "Suspend database reflecting": string; - "Suspend file watching": string; - "Switch to IDB": string; - "Switch to IndexedDB": string; - "Sync after merging file": string; - "Sync automatically after merging files": string; - "Sync Mode": string; - "Sync on Editor Save": string; - "Sync on File Open": string; - "Sync on Save": string; - "Sync on Startup": string; - "Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage.": string; - "Synchronising files": string; - Syncing: string; - "Target patterns": string; - "Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.": string; - "The delay for consecutive on-demand fetches": string; - "The following accepted nodes are missing its node information:\n- ${missingNodes}\n\nThis indicates that they have not been connected for some time or have been left on an older version.\nIt is preferable to update all devices if possible. If you have any devices that are no longer in use, you can clear all accepted nodes by locking the remote once.": string; - "The Hash algorithm for chunk IDs": string; - "The maximum duration for which chunks can be incubated within the document. Chunks exceeding this period will graduate to independent chunks.": string; - "The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks.": string; - "The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks.": string; - "The minimum interval for automatic synchronisation on event.": string; - "This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.": string; - "This is an advanced option for users who do not have a URI or who wish to configure detailed settings.": string; - "This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.": string; - "This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.": string; - "This will recreate chunks for all files. If there were missing chunks, this may fix the errors.": string; - "TweakMismatchResolve.Action.Dismiss": string; - "TweakMismatchResolve.Action.UseConfigured": string; - "TweakMismatchResolve.Action.UseMine": string; - "TweakMismatchResolve.Action.UseMineAcceptIncompatible": string; - "TweakMismatchResolve.Action.UseMineWithRebuild": string; - "TweakMismatchResolve.Action.UseRemote": string; - "TweakMismatchResolve.Action.UseRemoteAcceptIncompatible": string; - "TweakMismatchResolve.Action.UseRemoteWithRebuild": string; - "TweakMismatchResolve.Message.Main": string; - "TweakMismatchResolve.Message.MainTweakResolving": string; - "TweakMismatchResolve.Message.UseRemote.WarningRebuildRecommended": string; - "TweakMismatchResolve.Message.UseRemote.WarningRebuildRequired": string; - "TweakMismatchResolve.Message.WarningIncompatibleRebuildRecommended": string; - "TweakMismatchResolve.Message.WarningIncompatibleRebuildRequired": string; - "TweakMismatchResolve.Table": string; - "TweakMismatchResolve.Table.Row": string; - "TweakMismatchResolve.Title": string; - "TweakMismatchResolve.Title.TweakResolving": string; - "TweakMismatchResolve.Title.UseRemoteConfig": string; - "Ui.Common.Signal.Caution": string; - "Ui.Common.Signal.Danger": string; - "Ui.Common.Signal.Notice": string; - "Ui.Common.Signal.Warning": string; - "Ui.Settings.Advanced.LocalDatabaseTweak": string; - "Ui.Settings.Advanced.MemoryCache": string; - "Ui.Settings.Advanced.TransferTweak": string; - "Ui.Settings.Common.Analyse": string; - "Ui.Settings.Common.Back": string; - "Ui.Settings.Common.Check": string; - "Ui.Settings.Common.Configure": string; - "Ui.Settings.Common.Continue": string; - "Ui.Settings.Common.Delete": string; - "Ui.Settings.Common.Fetch": string; - "Ui.Settings.Common.Lock": string; - "Ui.Settings.Common.Merge": string; - "Ui.Settings.Common.Open": string; - "Ui.Settings.Common.Overwrite": string; - "Ui.Settings.Common.Perform": string; - "Ui.Settings.Common.ResetAll": string; - "Ui.Settings.Common.ResolveAll": string; - "Ui.Settings.Common.Scan": string; - "Ui.Settings.Common.Send": string; - "Ui.Settings.Common.Use": string; - "Ui.Settings.Common.VerifyAll": string; - "Ui.Settings.CustomizationSync.OpenDesc": string; - "Ui.Settings.CustomizationSync.Panel": string; - "Ui.Settings.CustomizationSync.WarnChangeDeviceName": string; - "Ui.Settings.CustomizationSync.WarnSetDeviceName": string; - "Ui.Settings.Hatch.AnalyseDatabaseUsage": string; - "Ui.Settings.Hatch.AnalyseDatabaseUsageDesc": string; - "Ui.Settings.Hatch.BackToNonConfigured": string; - "Ui.Settings.Hatch.ConvertNonObfuscated": string; - "Ui.Settings.Hatch.ConvertNonObfuscatedDesc": string; - "Ui.Settings.Hatch.CopyIssueReport": string; - "Ui.Settings.Hatch.DatabaseLabel": string; - "Ui.Settings.Hatch.DatabaseToStorage": string; - "Ui.Settings.Hatch.DeleteCustomizationSyncData": string; - "Ui.Settings.Hatch.GeneratedReport": string; - "Ui.Settings.Hatch.Missing": string; - "Ui.Settings.Hatch.ModifiedSize": string; - "Ui.Settings.Hatch.ModifiedSizeActual": string; - "Ui.Settings.Hatch.PrepareIssueReport": string; - "Ui.Settings.Hatch.RecoveryAndRepair": string; - "Ui.Settings.Hatch.RecreateAll": string; - "Ui.Settings.Hatch.RecreateMissingChunks": string; - "Ui.Settings.Hatch.RecreateMissingChunksDesc": string; - "Ui.Settings.Hatch.ResetPanel": string; - "Ui.Settings.Hatch.ResetRemoteUsage": string; - "Ui.Settings.Hatch.ResetRemoteUsageDesc": string; - "Ui.Settings.Hatch.ResolveAllConflictedFiles": string; - "Ui.Settings.Hatch.ResolveAllConflictedFilesDesc": string; - "Ui.Settings.Hatch.RunDoctor": string; - "Ui.Settings.Hatch.ScanBrokenFiles": string; - "Ui.Settings.Hatch.ScramSwitches": string; - "Ui.Settings.Hatch.ShowHistory": string; - "Ui.Settings.Hatch.StorageLabel": string; - "Ui.Settings.Hatch.StorageToDatabase": string; - "Ui.Settings.Hatch.VerifyAndRepairAllFiles": string; - "Ui.Settings.Hatch.VerifyAndRepairAllFilesDesc": string; - "Ui.Settings.Maintenance.Cleanup": string; - "Ui.Settings.Maintenance.CleanupDesc": string; - "Ui.Settings.Maintenance.DeleteLocalDatabase": string; - "Ui.Settings.Maintenance.EmergencyRestart": string; - "Ui.Settings.Maintenance.EmergencyRestartDesc": string; - "Ui.Settings.Maintenance.FreshStartWipe": string; - "Ui.Settings.Maintenance.FreshStartWipeDesc": string; - "Ui.Settings.Maintenance.GarbageCollection": string; - "Ui.Settings.Maintenance.GarbageCollectionAction": string; - "Ui.Settings.Maintenance.GarbageCollectionDesc": string; - "Ui.Settings.Maintenance.LockServer": string; - "Ui.Settings.Maintenance.LockServerDesc": string; - "Ui.Settings.Maintenance.OverwriteRemote": string; - "Ui.Settings.Maintenance.OverwriteRemoteDesc": string; - "Ui.Settings.Maintenance.OverwriteServerData": string; - "Ui.Settings.Maintenance.OverwriteServerDataDesc": string; - "Ui.Settings.Maintenance.PurgeAllJournalCounter": string; - "Ui.Settings.Maintenance.PurgeAllJournalCounterDesc": string; - "Ui.Settings.Maintenance.RebuildingOperations": string; - "Ui.Settings.Maintenance.Resend": string; - "Ui.Settings.Maintenance.ResendDesc": string; - "Ui.Settings.Maintenance.Reset": string; - "Ui.Settings.Maintenance.ResetAllJournalCounter": string; - "Ui.Settings.Maintenance.ResetAllJournalCounterDesc": string; - "Ui.Settings.Maintenance.ResetJournalReceived": string; - "Ui.Settings.Maintenance.ResetJournalReceivedDesc": string; - "Ui.Settings.Maintenance.ResetJournalSent": string; - "Ui.Settings.Maintenance.ResetJournalSentDesc": string; - "Ui.Settings.Maintenance.ResetLocalSyncInfo": string; - "Ui.Settings.Maintenance.ResetLocalSyncInfoDesc": string; - "Ui.Settings.Maintenance.ResetReceived": string; - "Ui.Settings.Maintenance.ResetSentHistory": string; - "Ui.Settings.Maintenance.ResetThisDevice": string; - "Ui.Settings.Maintenance.ScheduleAndRestart": string; - "Ui.Settings.Maintenance.Scram": string; - "Ui.Settings.Maintenance.SendChunks": string; - "Ui.Settings.Maintenance.Syncing": string; - "Ui.Settings.Maintenance.WarningLockedReadyAction": string; - "Ui.Settings.Maintenance.WarningLockedReadyText": string; - "Ui.Settings.Maintenance.WarningLockedResolveAction": string; - "Ui.Settings.Maintenance.WarningLockedResolveText": string; - "Ui.Settings.Maintenance.WriteRedFlagAndRestart": string; - "Ui.Settings.Patches.CompatibilityConflict": string; - "Ui.Settings.Patches.CompatibilityDatabase": string; - "Ui.Settings.Patches.CompatibilityInternalApi": string; - "Ui.Settings.Patches.CompatibilityMetadata": string; - "Ui.Settings.Patches.CompatibilityRemote": string; - "Ui.Settings.Patches.CompatibilityTrouble": string; - "Ui.Settings.Patches.CurrentAdapter": string; - "Ui.Settings.Patches.DatabaseAdapter": string; - "Ui.Settings.Patches.DatabaseAdapterDesc": string; - "Ui.Settings.Patches.EdgeCaseBehaviour": string; - "Ui.Settings.Patches.EdgeCaseDatabase": string; - "Ui.Settings.Patches.EdgeCaseProcessing": string; - "Ui.Settings.Patches.IndexedDbWarning": string; - "Ui.Settings.Patches.MigratingToIdb": string; - "Ui.Settings.Patches.MigratingToIndexedDb": string; - "Ui.Settings.Patches.MigrationIdbCompleted": string; - "Ui.Settings.Patches.MigrationIdbCompletedFollowUp": string; - "Ui.Settings.Patches.MigrationIndexedDbCompleted": string; - "Ui.Settings.Patches.MigrationIndexedDbCompletedFollowUp": string; - "Ui.Settings.Patches.MigrationWarning": string; - "Ui.Settings.Patches.OperationToIdb": string; - "Ui.Settings.Patches.OperationToIndexedDb": string; - "Ui.Settings.Patches.Remediation": string; - "Ui.Settings.Patches.RemediationChanged": string; - "Ui.Settings.Patches.RemediationNoLimit": string; - "Ui.Settings.Patches.RemediationRestarting": string; - "Ui.Settings.Patches.RemediationRestartLater": string; - "Ui.Settings.Patches.RemediationRestartMessage": string; - "Ui.Settings.Patches.RemediationRestartNow": string; - "Ui.Settings.Patches.RemediationSuffixChanged": string; - "Ui.Settings.Patches.RemediationWithValue": string; - "Ui.Settings.Patches.RemoteDatabaseSunset": string; - "Ui.Settings.Patches.SwitchToIDB": string; - "Ui.Settings.Patches.SwitchToIndexedDb": string; - "Ui.Settings.PowerUsers.ConfigurationEncryption": string; - "Ui.Settings.PowerUsers.ConnectionTweak": string; - "Ui.Settings.PowerUsers.ConnectionTweakDesc": string; - "Ui.Settings.PowerUsers.Default": string; - "Ui.Settings.PowerUsers.Developer": string; - "Ui.Settings.PowerUsers.EncryptSensitiveConfig": string; - "Ui.Settings.PowerUsers.PromptPassphraseEveryLaunch": string; - "Ui.Settings.PowerUsers.UseCustomPassphrase": string; - "Ui.Settings.Remote.Activate": string; - "Ui.Settings.Remote.ActiveSuffix": string; - "Ui.Settings.Remote.AddConnection": string; - "Ui.Settings.Remote.AddRemoteDefaultName": string; - "Ui.Settings.Remote.ConfigureAndChangeRemote": string; - "Ui.Settings.Remote.ConfigureE2EE": string; - "Ui.Settings.Remote.ConfigureRemote": string; - "Ui.Settings.Remote.DeleteRemoteConfirm": string; - "Ui.Settings.Remote.DeleteRemoteTitle": string; - "Ui.Settings.Remote.DisplayName": string; - "Ui.Settings.Remote.DuplicateRemote": string; - "Ui.Settings.Remote.DuplicateRemoteSuffix": string; - "Ui.Settings.Remote.E2EEConfiguration": string; - "Ui.Settings.Remote.Export": string; - "Ui.Settings.Remote.FetchRemoteSettings": string; - "Ui.Settings.Remote.ImportConnection": string; - "Ui.Settings.Remote.ImportConnectionPrompt": string; - "Ui.Settings.Remote.ImportedCouchDb": string; - "Ui.Settings.Remote.ImportedRemote": string; - "Ui.Settings.Remote.MoreActions": string; - "Ui.Settings.Remote.PeerToPeerPanel": string; - "Ui.Settings.Remote.RemoteConfigurationPrefix": string; - "Ui.Settings.Remote.RemoteDatabases": string; - "Ui.Settings.Remote.RemoteName": string; - "Ui.Settings.Remote.RemoteNameCouchDb": string; - "Ui.Settings.Remote.RemoteNameP2P": string; - "Ui.Settings.Remote.RemoteNameS3": string; - "Ui.Settings.Remote.Rename": string; - "Ui.Settings.Selector.AddDefaultPatterns": string; - "Ui.Settings.Selector.CrossPlatform": string; - "Ui.Settings.Selector.Default": string; - "Ui.Settings.Selector.HiddenFiles": string; - "Ui.Settings.Selector.IgnorePatterns": string; - "Ui.Settings.Selector.NonSynchronisingFiles": string; - "Ui.Settings.Selector.NonSynchronisingFilesDesc": string; - "Ui.Settings.Selector.NormalFiles": string; - "Ui.Settings.Selector.OverwritePatterns": string; - "Ui.Settings.Selector.OverwritePatternsDesc": string; - "Ui.Settings.Selector.SynchronisingFiles": string; - "Ui.Settings.Selector.SynchronisingFilesDesc": string; - "Ui.Settings.Selector.TargetPatterns": string; - "Ui.Settings.Selector.TargetPatternsDesc": string; - "Ui.Settings.Setup.RerunWizardButton": string; - "Ui.Settings.Setup.RerunWizardDesc": string; - "Ui.Settings.Setup.RerunWizardName": string; - "Ui.Settings.SyncSettings.Fetch": string; - "Ui.Settings.SyncSettings.Merge": string; - "Ui.Settings.SyncSettings.Overwrite": string; - "Ui.SetupWizard.Common.Back": string; - "Ui.SetupWizard.Common.Cancel": string; - "Ui.SetupWizard.Common.ProceedSelectOption": string; - "Ui.SetupWizard.Intro.ExistingOption": string; - "Ui.SetupWizard.Intro.ExistingOptionDesc": string; - "Ui.SetupWizard.Intro.Guidance": string; - "Ui.SetupWizard.Intro.NewOption": string; - "Ui.SetupWizard.Intro.NewOptionDesc": string; - "Ui.SetupWizard.Intro.ProceedExisting": string; - "Ui.SetupWizard.Intro.ProceedNew": string; - "Ui.SetupWizard.Intro.Question": string; - "Ui.SetupWizard.Intro.Title": string; - "Ui.SetupWizard.OutroAskUserMode.CompatibleOption": string; - "Ui.SetupWizard.OutroAskUserMode.CompatibleOptionDesc": string; - "Ui.SetupWizard.OutroAskUserMode.ExistingOption": string; - "Ui.SetupWizard.OutroAskUserMode.ExistingOptionDesc": string; - "Ui.SetupWizard.OutroAskUserMode.Guidance": string; - "Ui.SetupWizard.OutroAskUserMode.NewOption": string; - "Ui.SetupWizard.OutroAskUserMode.NewOptionDesc": string; - "Ui.SetupWizard.OutroAskUserMode.ProceedApplySettings": string; - "Ui.SetupWizard.OutroAskUserMode.ProceedNext": string; - "Ui.SetupWizard.OutroAskUserMode.Question": string; - "Ui.SetupWizard.OutroAskUserMode.Title": string; - "Ui.SetupWizard.OutroNewUser.GuidancePrimary": string; - "Ui.SetupWizard.OutroNewUser.GuidanceWarning": string; - "Ui.SetupWizard.OutroNewUser.Important": string; - "Ui.SetupWizard.OutroNewUser.Proceed": string; - "Ui.SetupWizard.OutroNewUser.Question": string; - "Ui.SetupWizard.OutroNewUser.Title": string; - "Ui.SetupWizard.SelectExisting.Guidance": string; - "Ui.SetupWizard.SelectExisting.ManualOption": string; - "Ui.SetupWizard.SelectExisting.ManualOptionDesc": string; - "Ui.SetupWizard.SelectExisting.ProceedManual": string; - "Ui.SetupWizard.SelectExisting.ProceedQr": string; - "Ui.SetupWizard.SelectExisting.ProceedSetupUri": string; - "Ui.SetupWizard.SelectExisting.QrOption": string; - "Ui.SetupWizard.SelectExisting.QrOptionDesc": string; - "Ui.SetupWizard.SelectExisting.Question": string; - "Ui.SetupWizard.SelectExisting.SetupUriOption": string; - "Ui.SetupWizard.SelectExisting.SetupUriOptionDesc": string; - "Ui.SetupWizard.SelectExisting.Title": string; - "Ui.SetupWizard.SelectNew.Guidance": string; - "Ui.SetupWizard.SelectNew.ManualOption": string; - "Ui.SetupWizard.SelectNew.ManualOptionDesc": string; - "Ui.SetupWizard.SelectNew.ProceedManual": string; - "Ui.SetupWizard.SelectNew.ProceedSetupUri": string; - "Ui.SetupWizard.SelectNew.Question": string; - "Ui.SetupWizard.SelectNew.SetupUriOption": string; - "Ui.SetupWizard.SelectNew.SetupUriOptionDesc": string; - "Ui.SetupWizard.SelectNew.Title": string; - "Ui.SetupWizard.SetupRemote.BucketOption": string; - "Ui.SetupWizard.SetupRemote.BucketOptionDesc": string; - "Ui.SetupWizard.SetupRemote.CouchDbOptionDesc": string; - "Ui.SetupWizard.SetupRemote.Guidance": string; - "Ui.SetupWizard.SetupRemote.P2POption": string; - "Ui.SetupWizard.SetupRemote.P2POptionDesc": string; - "Ui.SetupWizard.SetupRemote.ProceedBucket": string; - "Ui.SetupWizard.SetupRemote.ProceedCouchDb": string; - "Ui.SetupWizard.SetupRemote.ProceedP2P": string; - "Ui.SetupWizard.SetupRemote.Title": string; - "Unique name between all synchronized devices. To edit this setting, please disable customization sync once.": string; - "Use a custom passphrase": string; - "Use a Setup URI (Recommended)": string; - "Use Custom HTTP Handler": string; - "Use dynamic iteration count": string; - "Use Segmented-splitter": string; - "Use splitting-limit-capped chunk splitter": string; - "Use the trash bin": string; - "Use timeouts instead of heartbeats": string; - username: string; - Username: string; - "Verbose Log": string; - "Verify all": string; - "Verify and repair all files": string; - "Warning! This will have a serious impact on performance. And the logs will not be synchronised under the default name. Please be careful with logs; they often contain your confidential information.": string; - "We cannot change the device name while this feature is enabled. Please disable this feature to change the device name.": string; - "We will now guide you through a few questions to simplify the synchronisation setup.": string; - "We will now proceed with the server configuration.": string; - "Welcome to Self-hosted LiveSync": string; - "When you save a file in the editor, start a sync automatically": string; - "Write credentials in the file": string; - "Write logs into the file": string; - "xxhash32 (Fast but less collision resistance)": string; - "xxhash64 (Fastest)": string; - "Yes, I want to add this device to my existing synchronisation": string; - "Yes, I want to set up a new synchronisation": string; - "You are adding this device to an existing synchronisation setup.": string; - }; -}; diff --git a/_types/src/lib/src/common/models/auth.type.d.ts b/_types/src/lib/src/common/models/auth.type.d.ts deleted file mode 100644 index 1828bb7a..00000000 --- a/_types/src/lib/src/common/models/auth.type.d.ts +++ /dev/null @@ -1,43 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export type CouchDBCredentials = BasicCredentials | JWTCredentials; -export type JWTAlgorithm = "HS256" | "HS512" | "ES256" | "ES512" | ""; -export type Credential = { - username: string; - password: string; -}; -export type BasicCredentials = { - username: string; - password: string; - type: "basic"; -}; -export type JWTCredentials = { - jwtAlgorithm: JWTAlgorithm; - jwtKey: string; - jwtKid: string; - jwtSub: string; - jwtExpDuration: number; - type: "jwt"; -}; -export interface JWTHeader { - alg: string; - typ: string; - kid?: string; -} -export interface JWTPayload { - sub: string; - exp: number; - iss?: string; - iat: number; - [key: string]: unknown; -} -export interface JWTParams { - header: JWTHeader; - payload: JWTPayload; - credentials: JWTCredentials; -} -export interface PreparedJWT { - header: JWTHeader; - payload: JWTPayload; - token: string; -} diff --git a/_types/src/lib/src/common/models/db.const.d.ts b/_types/src/lib/src/common/models/db.const.d.ts deleted file mode 100644 index fcf58dfc..00000000 --- a/_types/src/lib/src/common/models/db.const.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { DocumentID } from "./db.type"; -export declare const VERSIONING_DOCID: DocumentID; -export declare const MILESTONE_DOCID: DocumentID; -export declare const NODEINFO_DOCID: DocumentID; -export declare const SYNCINFO_ID: DocumentID; -export declare const EntryTypes: { - readonly NOTE_LEGACY: "notes"; - readonly NOTE_BINARY: "newnote"; - readonly NOTE_PLAIN: "plain"; - readonly INTERNAL_FILE: "internalfile"; - readonly CHUNK: "leaf"; - readonly CHUNK_PACK: "chunkpack"; - readonly VERSION_INFO: "versioninfo"; - readonly SYNC_INFO: "syncinfo"; - readonly SYNC_PARAMETERS: "sync-parameters"; - readonly MILESTONE_INFO: "milestoneinfo"; - readonly NODE_INFO: "nodeinfo"; -}; -export declare const NoteTypes: ("notes" | "newnote" | "plain")[]; -export declare const ChunkTypes: ("leaf" | "chunkpack")[]; diff --git a/_types/src/lib/src/common/models/db.definition.d.ts b/_types/src/lib/src/common/models/db.definition.d.ts deleted file mode 100644 index ed47b49c..00000000 --- a/_types/src/lib/src/common/models/db.definition.d.ts +++ /dev/null @@ -1,58 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { MILESTONE_DOCID, NODEINFO_DOCID } from "./db.const"; -import type { AnyEntry, ChunkVersionRange, DatabaseEntry, EntryChunkPack, EntryLeaf, EntryTypes, EntryVersionInfo, InternalFileEntry, LoadedEntry, MetaEntry, NewEntry, NoteEntry, PlainEntry } from "./db.type"; -import type { TweakValues } from "./tweak.definition"; -export type NodeKey = string; -export interface DeviceInfo { - /** - * Name of the device (Initially from deviceAndVaultName setting, configurable). - */ - device_name: string; - /** - * Vault name (From vaultName setting). - */ - vault_name: string; - /** - * Obsidian App version of the device. - */ - app_version: string; - /** - * Plugin version of the device. - */ - plugin_version: string; - progress: string; -} -export interface NodeData extends DeviceInfo { - /** - * Epoch time in milliseconds when the device last connected. - */ - last_connected: number; -} -export interface EntryMilestoneInfo extends DatabaseEntry { - _id: typeof MILESTONE_DOCID; - type: EntryTypes["MILESTONE_INFO"]; - created: number; - accepted_nodes: string[]; - node_info: { - [key: NodeKey]: NodeData; - }; - locked: boolean; - cleaned?: boolean; - node_chunk_info: { - [key: NodeKey]: ChunkVersionRange; - }; - tweak_values: { - [key: NodeKey]: TweakValues; - }; -} -export interface EntryNodeInfo extends DatabaseEntry { - _id: typeof NODEINFO_DOCID; - type: EntryTypes["NODE_INFO"]; - nodeid: string; - v20220607?: boolean; -} -export type EntryBody = NoteEntry | NewEntry | PlainEntry | InternalFileEntry; -export type EntryDoc = EntryBody | LoadedEntry | EntryLeaf | EntryVersionInfo | EntryMilestoneInfo | EntryNodeInfo | EntryChunkPack; -export type EntryDocResponse = EntryDoc & PouchDB.Core.IdMeta & PouchDB.Core.GetMeta; -export declare function isMetaEntry(entry: AnyEntry): entry is MetaEntry; diff --git a/_types/src/lib/src/common/models/db.type.d.ts b/_types/src/lib/src/common/models/db.type.d.ts deleted file mode 100644 index 5d92debb..00000000 --- a/_types/src/lib/src/common/models/db.type.d.ts +++ /dev/null @@ -1,144 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { TaggedType } from "octagonal-wheels/common/types"; -import type { EntryTypes, SYNCINFO_ID } from "./db.const"; -export type FilePath = TaggedType; -export type FilePathWithPrefixLC = TaggedType; -export type FilePathWithPrefix = TaggedType | FilePath | FilePathWithPrefixLC; -export type DocumentID = TaggedType; -export type EntryType = (typeof EntryTypes)[keyof typeof EntryTypes]; -export type EntryTypes = typeof EntryTypes; -export type EntryTypeNotes = EntryTypes["NOTE_BINARY"] | EntryTypes["NOTE_PLAIN"]; -export type EntryTypeNotesWithLegacy = EntryTypeNotes | EntryTypes["NOTE_LEGACY"]; -/** - * Represents an entry in the database. - */ -export interface DatabaseEntry { - /** - * The ID of the document. - */ - _id: DocumentID; - /** - * The revision of the document. - */ - _rev?: string; - /** - * Deleted flag. - */ - _deleted?: boolean; - /** - * Conflicts (if exists). - */ - _conflicts?: string[]; -} -/** - * Represents the base structure for an entry that represents a file. - */ -export type EntryBase = { - /** - * The creation time of the file. - */ - ctime: number; - /** - * The modification time of the file. - */ - mtime: number; - /** - * The size of the file. - */ - size: number; - /** - * Deleted flag. - */ - deleted?: boolean; -}; -export type EdenChunk = { - data: string; - epoch: number; -}; -export type EntryWithEden = { - eden: Record; -}; -export type NoteEntry = DatabaseEntry & EntryBase & EntryWithEden & { - /** - * The path of the file. - */ - path: FilePathWithPrefix; - /** - * Contents of the file. - */ - data: string | string[]; - /** - * The type of the entry. - */ - type: EntryTypes["NOTE_LEGACY"]; -}; -export type NewEntry = DatabaseEntry & EntryBase & EntryWithEden & { - /** - * The path of the file. - */ - path: FilePathWithPrefix; - /** - * Chunk IDs indicating the contents of the file. - */ - children: string[]; - /** - * The type of the entry. - */ - type: EntryTypes["NOTE_BINARY"]; -}; -export type PlainEntry = DatabaseEntry & EntryBase & EntryWithEden & { - /** - * The path of the file. - */ - path: FilePathWithPrefix; - /** - * Chunk IDs indicating the contents of the file. - */ - children: string[]; - /** - * The type of the entry. - */ - type: EntryTypes["NOTE_PLAIN"]; -}; -export type InternalFileEntry = DatabaseEntry & NewEntry & EntryBase & { - deleted?: boolean; -}; -export type AnyEntry = NoteEntry | NewEntry | PlainEntry | InternalFileEntry; -export type LoadedEntry = AnyEntry & { - data: string | string[]; - datatype: EntryTypeNotes; -}; -export type SavingEntry = AnyEntry & { - data: Blob; - datatype: EntryTypeNotes; -}; -export type MetaEntry = AnyEntry & { - children: string[]; -}; -export type EntryLeaf = DatabaseEntry & { - type: EntryTypes["CHUNK"]; - data: string; - isCorrupted?: boolean; -}; -export type EntryChunkPack = DatabaseEntry & { - type: EntryTypes["CHUNK_PACK"]; - data: string; -}; -export interface EntryVersionInfo extends DatabaseEntry { - type: EntryTypes["VERSION_INFO"]; - version: number; -} -export interface EntryHasPath { - path: FilePathWithPrefix | FilePath; -} -export interface ChunkVersionRange { - min: number; - max: number; - current: number; -} -export interface SyncInfo extends DatabaseEntry { - _id: typeof SYNCINFO_ID; - type: EntryTypes["SYNC_INFO"]; - data: string; -} diff --git a/_types/src/lib/src/common/models/diff.definition.d.ts b/_types/src/lib/src/common/models/diff.definition.d.ts deleted file mode 100644 index 1250d927..00000000 --- a/_types/src/lib/src/common/models/diff.definition.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { AUTO_MERGED, CANCELLED, MISSING_OR_ERROR, NOT_CONFLICTED } from "./shared.const.symbols"; -export type diff_result_leaf = { - rev: string; - data: string; - ctime: number; - mtime: number; - deleted?: boolean; -}; -export type dmp_result = Array<[number, string]>; -export type diff_result = { - left: diff_result_leaf; - right: diff_result_leaf; - diff: dmp_result; -}; -export type DIFF_CHECK_RESULT_AUTO = typeof CANCELLED | typeof AUTO_MERGED | typeof NOT_CONFLICTED | typeof MISSING_OR_ERROR; -export type diff_check_result = DIFF_CHECK_RESULT_AUTO | diff_result; diff --git a/_types/src/lib/src/common/models/fileaccess.const.d.ts b/_types/src/lib/src/common/models/fileaccess.const.d.ts deleted file mode 100644 index 2de10a8d..00000000 --- a/_types/src/lib/src/common/models/fileaccess.const.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare const CHeader = "h:"; -export declare const PSCHeader = "ps:"; -export declare const PSCHeaderEnd = "ps;"; -export declare const ICHeader = "i:"; -export declare const ICHeaderEnd = "i;"; -export declare const ICHeaderLength: number; -export declare const ICXHeader = "ix:"; diff --git a/_types/src/lib/src/common/models/fileaccess.type.d.ts b/_types/src/lib/src/common/models/fileaccess.type.d.ts deleted file mode 100644 index 09ad7a8f..00000000 --- a/_types/src/lib/src/common/models/fileaccess.type.d.ts +++ /dev/null @@ -1,74 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { FilePath, FilePathWithPrefix } from "./db.type"; -export type UXStat = { - size: number; - mtime: number; - ctime: number; - type: "file" | "folder"; -}; -export type UXFileInfoStub = { - name: string; - path: FilePath | FilePathWithPrefix; - stat: UXStat; - deleted?: boolean; - isFolder?: false; - isInternal?: boolean; -}; -export type UXFileInfo = UXFileInfoStub & { - body: Blob; -}; -export type UXAbstractInfoStub = UXFileInfoStub | UXFolderInfo; -export type UXInternalFileInfoStub = { - name: string; - path: FilePath | FilePathWithPrefix; - deleted?: boolean; - isFolder?: false; - isInternal: true; - stat: undefined; -}; -export type UXFolderInfo = { - name: string; - path: FilePath | FilePathWithPrefix; - deleted?: boolean; - isFolder: true; - children: UXFileInfoStub[]; - parent: FilePath | FilePathWithPrefix | undefined; -}; -export type UXDataWriteOptions = { - /** - * Time of creation, represented as a unix timestamp, in milliseconds. - * Omit this if you want to keep the default behaviour. - * @public - * */ - ctime?: number; - /** - * Time of last modification, represented as a unix timestamp, in milliseconds. - * Omit this if you want to keep the default behaviour. - * @public - */ - mtime?: number; -}; -export type CacheData = string | ArrayBuffer; -export type FileEventType = "CREATE" | "DELETE" | "CHANGED" | "INTERNAL"; -export type FileEventArgs = { - file: UXFileInfoStub | UXInternalFileInfoStub; - cache?: CacheData; - oldPath?: string; - ctx?: unknown; -}; -export type FileEventItem = { - type: FileEventType; - args: FileEventArgs; - key: string; - skipBatchWait?: boolean; - cancelled?: boolean; - batched?: boolean; -}; -export interface FileWithFileStat extends Omit { - path: FilePath; -} -export interface FileWithStatAsProp { - path: FilePath; - stat: Omit; -} diff --git a/_types/src/lib/src/common/models/redflag.const.d.ts b/_types/src/lib/src/common/models/redflag.const.d.ts deleted file mode 100644 index da032874..00000000 --- a/_types/src/lib/src/common/models/redflag.const.d.ts +++ /dev/null @@ -1,34 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { FilePath } from "./db.type"; -export declare const PREFIXMD_LOGFILE = "livesync_log_"; -export declare const PREFIXMD_LOGFILE_UC = "LIVESYNC_LOG_"; -export declare const FlagFilesOriginal: { - readonly SUSPEND_ALL: FilePath; - readonly REBUILD_ALL: FilePath; - readonly FETCH_ALL: FilePath; -}; -export declare const FlagFilesHumanReadable: { - readonly REBUILD_ALL: FilePath; - readonly FETCH_ALL: FilePath; -}; -/** - * @deprecated Use `FlagFilesOriginal.SUSPEND_ALL` instead. - */ -export declare const FLAGMD_REDFLAG: FilePath; -/** - * @deprecated Use `FlagFilesHumanReadable.REBUILD_ALL` instead. - */ -export declare const FLAGMD_REDFLAG2: FilePath; -/** - * @deprecated Use `FlagFilesHumanReadable.FETCH_ALL` instead. - */ -export declare const FLAGMD_REDFLAG2_HR: FilePath; -/** - * @deprecated Use `FlagFilesOriginal.FETCH_ALL` instead. - */ -export declare const FLAGMD_REDFLAG3: FilePath; -/** - * @deprecated Use `FlagFilesHumanReadable.FETCH_ALL` instead. - */ -export declare const FLAGMD_REDFLAG3_HR: FilePath; diff --git a/_types/src/lib/src/common/models/setting.const.d.ts b/_types/src/lib/src/common/models/setting.const.d.ts deleted file mode 100644 index 18d867ef..00000000 --- a/_types/src/lib/src/common/models/setting.const.d.ts +++ /dev/null @@ -1,51 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare const SETTING_VERSION_INITIAL = 0; -export declare const SETTING_VERSION_SUPPORT_CASE_INSENSITIVE = 10; -export declare const CURRENT_SETTING_VERSION = 10; -export declare const RemoteTypes: { - readonly REMOTE_COUCHDB: ""; - readonly REMOTE_MINIO: "MINIO"; - readonly REMOTE_P2P: "ONLY_P2P"; -}; -export declare const REMOTE_COUCHDB: ""; -export declare const REMOTE_MINIO: "MINIO"; -export declare const REMOTE_P2P: "ONLY_P2P"; -export declare const E2EEAlgorithmNames: { - readonly "": "V1: Legacy"; - readonly v2: "V2: AES-256-GCM With HKDF"; - readonly forceV1: "Force-V1: Force Legacy (Not recommended)"; -}; -export declare const E2EEAlgorithms: { - readonly V1: ""; - readonly V2: "v2"; - readonly ForceV1: "forceV1"; -}; -export declare const HashAlgorithms: { - readonly XXHASH32: "xxhash32"; - readonly XXHASH64: "xxhash64"; - readonly MIXED_PUREJS: "mixed-purejs"; - readonly SHA1: "sha1"; - readonly LEGACY: ""; -}; -export declare const ChunkAlgorithmNames: { - readonly v1: "V1: Legacy"; - readonly v2: "V2: Simple (Default)"; - readonly "v2-segmenter": "V2.5: Lexical chunks"; - readonly "v3-rabin-karp": "V3: Fine deduplication"; -}; -export declare const ChunkAlgorithms: { - readonly V1: "v1"; - readonly V2: "v2"; - readonly V2Segmenter: "v2-segmenter"; - readonly RabinKarp: "v3-rabin-karp"; -}; -export declare const MODE_SELECTIVE = 0; -export declare const MODE_AUTOMATIC = 1; -export declare const MODE_PAUSED = 2; -export declare const MODE_SHINY = 3; -export declare const NetworkWarningStyles: { - readonly BANNER: ""; - readonly ICON: "icon"; - readonly HIDDEN: "hidden"; -}; diff --git a/_types/src/lib/src/common/models/setting.const.defaults.d.ts b/_types/src/lib/src/common/models/setting.const.defaults.d.ts deleted file mode 100644 index 285758d2..00000000 --- a/_types/src/lib/src/common/models/setting.const.defaults.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type ObsidianLiveSyncSettings, type P2PSyncSetting } from "./setting.type"; -export declare const P2P_DEFAULT_SETTINGS: P2PSyncSetting; -export declare const DEFAULT_SETTINGS: ObsidianLiveSyncSettings; diff --git a/_types/src/lib/src/common/models/setting.const.preferred.d.ts b/_types/src/lib/src/common/models/setting.const.preferred.d.ts deleted file mode 100644 index d881aa19..00000000 --- a/_types/src/lib/src/common/models/setting.const.preferred.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ObsidianLiveSyncSettings } from "./setting.type"; -export declare const PREFERRED_BASE: Partial; -export declare const PREFERRED_SETTING_CLOUDANT: Partial; -export declare const PREFERRED_SETTING_SELF_HOSTED: Partial; -export declare const PREFERRED_JOURNAL_SYNC: Partial; diff --git a/_types/src/lib/src/common/models/setting.const.qr.d.ts b/_types/src/lib/src/common/models/setting.const.qr.d.ts deleted file mode 100644 index d5a2a522..00000000 --- a/_types/src/lib/src/common/models/setting.const.qr.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ObsidianLiveSyncSettings } from "./setting.type"; -export declare const KeyIndexOfSettings: Record; diff --git a/_types/src/lib/src/common/models/setting.type.d.ts b/_types/src/lib/src/common/models/setting.type.d.ts deleted file mode 100644 index 82b5465e..00000000 --- a/_types/src/lib/src/common/models/setting.type.d.ts +++ /dev/null @@ -1,902 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ChunkAlgorithms, E2EEAlgorithms, HashAlgorithms, MODE_AUTOMATIC, MODE_PAUSED, MODE_SELECTIVE, MODE_SHINY, RemoteTypes } from "./setting.const"; -import type { I18N_LANGS } from "@lib/common/rosetta"; -import type { CustomRegExpSourceList } from "./shared.type.util"; -import type { JWTAlgorithm } from "./auth.type"; -/** - * Represents the connection details required to connect to a CouchDB instance. - */ -export interface CouchDBConnection { - /** - * The URI of the CouchDB instance. - */ - couchDB_URI: string; - /** - * The username to use when connecting to the CouchDB instance. - */ - couchDB_USER: string; - /** - * The password to use when connecting to the CouchDB instance. - */ - couchDB_PASSWORD: string; - /** - * The name of the database to use. - */ - couchDB_DBNAME: string; - /** - * e.g. `x-some-header: some-value\n x-some-header2: some-value2` - */ - couchDB_CustomHeaders: string; - useJWT: boolean; - jwtAlgorithm: JWTAlgorithm; - jwtKey: string; - jwtKid: string; - jwtSub: string; - jwtExpDuration: number; - /** - * Use Request API to avoid `inevitable` CORS problem. - * Seems stable, so promoted to the normal setting. - */ - useRequestAPI: boolean; -} -/** - * Interface representing the settings for periodic replication. - */ -interface PeriodicReplicationSettings { - /** - * Indicates whether periodic replication is enabled. - */ - periodicReplication: boolean; - /** - * The interval, in milliseconds, at which periodic replication occurs. - */ - periodicReplicationInterval: number; -} -export type ConfigPassphraseStore = "" | "LOCALSTORAGE" | "ASK_AT_LAUNCH"; -/** - * Represents the user settings that are encrypted. - */ -interface EncryptedUserSettings { - /** - * The store for the configuration passphrase. - */ - configPassphraseStore: ConfigPassphraseStore; - /** - * The encrypted passphrase used for E2EE. - */ - encryptedPassphrase: string; - /** - * The encrypted connection details for CouchDB. - */ - encryptedCouchDBConnection: string; -} -/** - * Interface representing the settings for different sync invocation methods. - */ -interface SyncMethodSettings { - /** - * Synchronise in Live. This is an exclusive setting against other sync methods. - */ - liveSync: boolean; - /** - * automatically run sync on save. - * File modification will trigger the sync, even if the file is not changed on the editor. - */ - syncOnSave: boolean; - /** - * automatically run sync on starting the plug-in. - */ - syncOnStart: boolean; - /** - * automatically run sync on opening a file. - */ - syncOnFileOpen: boolean; - /** - * automatically run sync on editor save. - * Different from syncOnSave, this is only reacts to the editor save event. - */ - syncOnEditorSave: boolean; - /** - * Desktop only, opt-in. Keep replication running while the window is hidden or minimised, - * instead of suspending it until the window becomes visible again. The trigger is - * document.hidden, not window focus. Applies to the background-capable sync modes (LiveSync - * and Periodic). Ignored on mobile. Default false. - */ - keepReplicationActiveInBackground: boolean; - /** - * The minimum delay between synchronisation operations (in milliseconds). - * If the operation is triggered before this delay, the operation will be delayed until the delay is over, and executed as a single operation. - */ - syncMinimumInterval: number; -} -/** - * Interface representing the settings for file handling. - */ -interface FileHandlingSettings { - /** - * Use trash instead of actually delete. - */ - trashInsteadDelete: boolean; - /** - * Do not delete the folder even if it has got empty. - */ - doNotDeleteFolder: boolean; - /** - * Thinning out the changes and make a single change for the same file. - */ - batchSave: boolean; - batchSaveMinimumDelay: number; - batchSaveMaximumDelay: number; - /** - * Maximum size of the file to be synchronized (in MB). - */ - syncMaxSizeInMB: number; - /** - * Use ignore files. - */ - useIgnoreFiles: boolean; - /** - * Ignore files pattern, i,e, `.gitignore, .obsidianignore` (This should be separated by comma) - */ - ignoreFiles: string; - /** - * Do not prevent write if the size is mismatched. - */ - processSizeMismatchedFiles: boolean; -} -/** - * Interface representing the settings for Hidden File Sync. - */ -interface InternalFileSettings { - /** - * Synchronise internal files. - */ - syncInternalFiles: boolean; - /** - * Scan internal files before replication. - */ - syncInternalFilesBeforeReplication: boolean; - /** - * Interval for scanning internal files (in seconds). - */ - syncInternalFilesInterval: number; - /** - * Ignore patterns for internal files. - * (Comma separated list of regular expressions) - */ - syncInternalFilesIgnorePatterns: CustomRegExpSourceList<",">; - /** - * Limit patterns for internal files. - */ - syncInternalFilesTargetPatterns: CustomRegExpSourceList<",">; - /** - * Enable watch internal file changes (This option uses the unexposed API) - */ - watchInternalFileChanges: boolean; - /** - * Suppress notification of hidden files change. - */ - suppressNotifyHiddenFilesChange: boolean; - /** - * Overwrite instead of merging patterns for internal files. - */ - syncInternalFileOverwritePatterns: CustomRegExpSourceList<",">; -} -export type SYNC_MODE = typeof MODE_SELECTIVE | typeof MODE_AUTOMATIC | typeof MODE_PAUSED | typeof MODE_SHINY; -export interface PluginSyncSettingEntry { - key: string; - mode: SYNC_MODE; - files: string[]; -} -/** - * Interface representing the settings for plugin synchronisation. - */ -interface PluginSyncSettings { - /** - * Indicates whether plugin synchronisation is enabled. - */ - usePluginSync: boolean; - /** - * Indicates whether plugin settings synchronisation is enabled. - */ - usePluginSettings: boolean; - /** - * Indicates whether to show the device's own plugins. - */ - showOwnPlugins: boolean; - /** - * Indicates whether to automatically scan plugins. - */ - autoSweepPlugins: boolean; - /** - * Indicates whether to periodically scan plugins automatically. - */ - autoSweepPluginsPeriodic: boolean; - /** - * Indicates whether to notify when a plugin or setting is updated. - */ - notifyPluginOrSettingUpdated: boolean; - /** - * The name of the device and vault. - * This is used to identify the device and vault among synchronised devices and vaults. - * Hence, this should be unique among devices and vaults. - */ - deviceAndVaultName: string; - /** - * Indicates whether the v2 of plugin synchronisation is enabled. - */ - usePluginSyncV2: boolean; - /** - * Indicates whether additional plugin synchronisation settings are enabled. - * This setting is hidden from the UI. - */ - usePluginEtc: boolean; - /** - * Extended settings for plugin synchronisation. - */ - pluginSyncExtendedSetting: Record; -} -/** - * Interface representing the user interface settings. - */ -interface UISettings { - /** - * Indicates whether verbose logging has been enabled. - */ - showVerboseLog: boolean; - /** - * Indicates whether less information should be shown in the log. - */ - lessInformationInLog: boolean; - /** - * Indicates whether longer status line should be shown inside the editor. - */ - showLongerLogInsideEditor: boolean; - /** - * Indicates whether the status line should be shown on the editor. - */ - showStatusOnEditor: boolean; - /** - * Indicates whether the status line should be shown on the status bar. - */ - showStatusOnStatusbar: boolean; - /** - * Indicates whether only icons instead of status line should be shown on the editor. - */ - showOnlyIconsOnEditor: boolean; - /** - * Hide File warning notice bar. - */ - hideFileWarningNotice: boolean; - /** - * How to display connection error warnings. - * "banner" shows the full banner, "icon" shows only an icon, "hidden" suppresses entirely. - */ - networkWarningStyle: "" | "icon" | "hidden"; - /** - * The language to be used for display. - */ - displayLanguage: I18N_LANGS; -} -/** - * Interface representing the settings for mode of exposing advanced things. - */ -interface ModeSettings { - /** - * Indicates whether the advanced mode is enabled. - */ - useAdvancedMode: boolean; - /** - * Indicates whether the power user mode is enabled. - */ - usePowerUserMode: boolean; - /** - * Indicates whether the edge case mode is enabled. - */ - useEdgeCaseMode: boolean; -} -/** - * Interface representing the settings for debug mode. - */ -interface DebugModeSettings { - /** - * Indicates whether the debug tools of Self-hosted LiveSync are enabled. - */ - enableDebugTools: boolean; - /** - * Indicates whether to write log to the file. - */ - writeLogToTheFile: boolean; -} -/** - * Interface representing additional tweak settings. - */ -interface ExtraTweakSettings { - /** - * The threshold value for notifying about the size of remote storage. - * When the size of the remote storage exceeds this threshold, a notification will be triggered. - */ - notifyThresholdOfRemoteStorageSize: number; -} -/** - * Interface representing the settings for beta tweaks. - */ -interface BetaTweakSettings { - /** - * Indicates whether to disable the WebWorker for generating chunks. - */ - disableWorkerForGeneratingChunks: boolean; - /** - * Indicates whether to process small files in the UI thread. - */ - processSmallFilesInUIThread: boolean; -} -/** - * Interface representing the settings for synchronising settings via file. - */ -interface SettingSyncSettings { - /** - * The file path where the settings is stored. - */ - settingSyncFile: string; - /** - * Indicates whether to write credentials for settings synchronising. - */ - writeCredentialsForSettingSync: boolean; - /** - * Indicates whether to notify all settings synchronising files events. - */ - notifyAllSettingSyncFile: boolean; -} -/** - * Represents settings that are considered obsolete and are not configurable from the UI. - */ -interface ObsoleteSettings { - /** - * Saving delay (in milliseconds). - */ - savingDelay: number; - /** - * Garbage collection delay (in milliseconds). Now, no longer GC is implemented. - */ - gcDelay: number; - /** - * Skip older files on sync. No effect now. - */ - skipOlderFilesOnSync: boolean; - /** - * Use the IndexedDB adapter. Now always true. Should be. - */ - useIndexedDBAdapter: boolean; -} -/** - * Interface representing some data stored in the settings for the plugin. - */ -interface DataOnSettings { - /** - * VersionUp flash message which is shown when some incompatible changes are made during the update. - */ - versionUpFlash: string; - /** - * Setting file version, to migrate the settings. - */ - settingVersion: number; - /** - * Indicates whether the setting of the plug-in is configured once. - */ - isConfigured?: boolean; - /** - * The user-last-read version number. - */ - lastReadUpdates: number; - /** - * The last checked version by the doctor. - */ - doctorProcessedVersion: string; -} -/** - * Interface representing the settings for a safety valve mechanism. - */ -interface SafetyValveSettings { - /** - * Indicates whether file watching should be suspended. - */ - suspendFileWatching: boolean; - /** - * Indicates whether parsing and reflecting of replication results should be suspended. - */ - suspendParseReplicationResult: boolean; - /** - * Indicates whether suspension should be avoided during fetching operations. - */ - doNotSuspendOnFetching: boolean; - /** - * Maximum file modification time applied to reflected file events - */ - maxMTimeForReflectEvents: number; -} -/** - * Represents the settings required to synchronise with a bucket. - */ -export interface BucketSyncSetting { - /** - * The access key to use when connecting to the bucket. - */ - accessKey: string; - /** - * The secret to use when connecting to the bucket. - */ - secretKey: string; - /** - * The name of bucket to use. - */ - bucket: string; - /** - * The region of the bucket. - */ - region: string; - /** - * The endpoint of the bucket. - */ - endpoint: string; - /** - * Indicates whether to use a custom request handler. - * (This is for CORS issue). - */ - useCustomRequestHandler: boolean; - bucketCustomHeaders: string; - /** - * The prefix to use for the bucket (e.g., "my-bucket/", means mostly like a folder). - */ - bucketPrefix: string; - /** - * Indicates whether to force path style access. - */ - forcePathStyle: boolean; -} -export interface LocalDBSettings { - /** - * Indicates whether to use the IndexedDB adapter for the local database. - * @deprecated - */ - useIndexedDBAdapter: boolean; -} -export type RemoteType = (typeof RemoteTypes)[keyof typeof RemoteTypes]; -export declare enum AutoAccepting { - NONE = 0, - ALL = 1 -} -export interface P2PConnectionInfo { - /** - * Indicates whether P2P connection is enabled. - */ - P2P_Enabled: boolean; - /** - * Nostr relay server URL. (Comma separated list) - * This is only for the channelling server to establish for the P2P connection. - * No data is transferred through this server. - */ - P2P_relays: string; - /** - * The room ID for `your devices`. This should be unique among the users. - * (Or, lines will be got mixed up). - */ - P2P_roomID: string; - /** - * The passphrase for your devices. - * It can be empty, but it will help you if you have a duplicate Room ID. - */ - P2P_passphrase: string; - /** - * The Application ID for the P2P connection. - * This is used to identify the application using the P2P network. - * In Self-hosted LiveSync, fixed to "self-hosted-livesync". - */ - P2P_AppID: string; - /** - * Indicates whether to auto-start the P2P connection on launch. - */ - P2P_AutoStart: boolean; - /** - * Indicates whether to automatically broadcast changes to connected peers. - */ - P2P_AutoBroadcast: boolean; - /** - * The name of the device peer (This only for editing-setting purpose, not saved in the actual setting file, due to avoid setting-sync issues). - */ - P2P_DevicePeerName?: string; - /** - * The TURN server URLs for the P2P connection. (Comma separated list) - */ - P2P_turnServers: string; - /** - * The TURN username for the P2P connection. - */ - P2P_turnUsername: string; - /** - * The TURN credential (password, secret, etc...) for the P2P connection. - */ - P2P_turnCredential: string; - /** - * Use Diagnostic Wrapper for RTCPeerConnection to collect statistics. - */ - P2P_useDiagRTC?: boolean; -} -export interface P2PSyncSetting extends P2PConnectionInfo { - P2P_AutoAccepting: AutoAccepting; - P2P_AutoSyncPeers: string; - P2P_AutoWatchPeers: string; - P2P_SyncOnReplication: string; - P2P_RebuildFrom: string; - P2P_AutoAcceptingPeers: string; - P2P_AutoDenyingPeers: string; - P2P_IsHeadless?: boolean; -} -/** - * Interface representing the settings for a remote type. - */ -export interface RemoteTypeSettings { - /** - * The type of the remote. - */ - remoteType: RemoteType; -} -export type E2EEAlgorithm = (typeof E2EEAlgorithms)[keyof typeof E2EEAlgorithms] | ""; -/** - * Represents the settings used for End-to-End encryption. - */ -export interface EncryptionSettings { - /** - * Indicates whether E2EE is enabled. - */ - encrypt: boolean; - /** - * The passphrase used for E2EE. - */ - passphrase: string; - /** - * Indicates whether path obfuscation is used. - * If not, the path will be stored as it is, as the document ID. - */ - usePathObfuscation: boolean; - /** - * The algorithm used for hashing the passphrase. - * This is used for E2EE. - */ - E2EEAlgorithm: E2EEAlgorithm; -} -export type HashAlgorithm = (typeof HashAlgorithms)[keyof typeof HashAlgorithms]; -export type ChunkSplitterVersion = (typeof ChunkAlgorithms)[keyof typeof ChunkAlgorithms] | ""; -/** - * Interface representing the settings for chunk processing. - */ -interface ChunkSettings { - /** - * The algorithm used for hashing chunks. - */ - hashAlg: HashAlgorithm; - /** - * The minimum size of a chunk in chars. - */ - minimumChunkSize: number; - /** - * The custom size of a chunk. - * Note: This value used as a coefficient for the normal chunk size. - */ - customChunkSize: number; - /** - * The threshold for considering a line as long. - * (Not respected in v0.24.x). - */ - longLineThreshold: number; - /** - * Flag indicating whether to use a segmenter for chunking. - * @deprecated use chunkSplitterVersion instead. - */ - useSegmenter: boolean; - /** - * Flag indicating whether to enable version 2 of the chunk splitter. - * @deprecated use chunkSplitterVersion instead. - */ - enableChunkSplitterV2: boolean; - /** - * Flag indicating whether to avoid using a fixed revision for chunks. - */ - doNotUseFixedRevisionForChunks: boolean; - /** - * The version of the chunk splitter to use. - */ - chunkSplitterVersion: ChunkSplitterVersion; -} -/** - * Settings for on-demand chunk fetching. - */ -interface OnDemandChunkSettings { - /** - * Indicates whether chunks should be fetch online. (means replication transfers only metadata). - */ - readChunksOnline: boolean; - /** - * Indicates whether to use only local chunks without fetching online. - */ - useOnlyLocalChunk: boolean; - /** - * The number of concurrent chunk reads allowed when fetching online. - */ - concurrencyOfReadChunksOnline: number; - /** - * The minimum interval (in milliseconds) between consecutive online chunk fetching. - */ - minimumIntervalOfReadChunksOnline: number; -} -/** - * Configuration settings for Eden. - */ -interface EdenSettings { - /** - * Indicates whether Eden is enabled. - */ - useEden: boolean; - /** - * The maximum number of chunks allowed in Eden. - */ - maxChunksInEden: number; - /** - * The maximum total length allowed in Eden. - */ - maxTotalLengthInEden: number; - /** - * The maximum age allowed in Eden. - */ - maxAgeInEden: number; -} -/** - * Interface representing obsolete settings for an remote database. - */ -interface ObsoleteRemoteDBSettings { - /** - * Indicates whether to check the integrity of the data on save. - */ - checkIntegrityOnSave: boolean; - /** - * Indicates whether to use history tracking. - * (Now always true) - */ - useHistory: boolean; - /** - * Indicates whether to disable using API of Obsidian. - * (Now always true: Note: Obsidian cannot handle multiple requests at the same time). - */ - disableRequestURI: boolean; - /** - * Indicates whether to send data in bulk chunks. - */ - sendChunksBulk: boolean; - /** - * The maximum size of the bulk chunks to be sent. - */ - sendChunksBulkMaxSize: number; - /** - * Indicates whether to use a dynamic iteration count. - */ - useDynamicIterationCount: boolean; - /** - * Indicates weather to pace the replication processing interval. - * Now (v0.24.x) not be respected. - */ - doNotPaceReplication: boolean; -} -/** - * Interface representing the settings for beta tweaks for the remote database. - */ -interface BetaRemoteDBSettings { - /** - * Indicates whether compression is enabled for the remote database. - */ - enableCompression: boolean; -} -/** - * Interface representing the some data stored on the settings. - */ -interface DataOnRemoteDBSettings { - /** - * VersionUp flash message which is shown when some incompatible changes are made during the update. - */ - versionUpFlash: string; - /** - * Unix timestamp (ms) of the latest tweak update. - * Used to determine which side has newer tweak values. - */ - tweakModified: number | undefined; -} -/** - * Interface representing the settings for replication. - */ -interface ReplicationSetting { - /** - * The maximum number of documents to be processed in a batch. - */ - batch_size: number; - /** - * The maximum number of batches to be processed. - */ - batches_limit: number; -} -/** - * Interface representing the settings for targetting files. - */ -interface FileHandlingSettings { - /** - * The regular expression for files to be synchronised. - */ - syncOnlyRegEx: CustomRegExpSourceList<"|[]|">; - /** - * The regular expression for files to be ignored during synchronisation. - */ - syncIgnoreRegEx: CustomRegExpSourceList<"|[]|">; -} -/** - * Interface representing the settings for processing behaviour. - */ -interface ProcessingBehaviourSettings { - /** - * Hash cache maximum count. - */ - hashCacheMaxCount: number; - /** - * Hash cache maximum amount. - */ - hashCacheMaxAmount: number; -} -/** - * Interface representing the settings for remote database tweaks. - */ -interface RemoteDBTweakSettings { - /** - * Indicates whether to ignore the version check. - */ - ignoreVersionCheck: boolean; - /** - * Indicates whether to ignore and continue syncing even if the configuration-mismatch is detected. - * (Note: Mismatched settings can lead to inappropriate de-duplication, leading to storage wastage and increased traffic). - */ - disableCheckingConfigMismatch: boolean; - /** - * Automatically accepts compatible-but-lossy tweak mismatches. - * If undefined, the feature is not configured yet. - */ - autoAcceptCompatibleTweak: boolean | undefined; -} -/** - * Interface representing the settings for optional and not exposed remote database settings. - */ -interface OptionalAndNotExposedRemoteDBSettings { - /** - * Indicates whether to accept empty passphrase. - * This not meant to `Not be encrypted`, but `Be encrypted with empty passphrase`. - */ - permitEmptyPassphrase: boolean; -} -/** - * Interface representing the settings for cross-platform interoperability. - */ -interface CrossPlatformInteroperabilitySettings { - /** - * Indicates whether to handle filename case sensitively. - */ - handleFilenameCaseSensitive: boolean; -} -/** - * Interface representing the settings for conflict handling. - */ -interface ConflictHandlingSettings { - /** - * Indicates whether to check conflicts only on file open. - */ - checkConflictOnlyOnOpen: boolean; - /** - * Indicates whether to show the merge dialog only on active file. - */ - showMergeDialogOnlyOnActive: boolean; -} -/** - * Settings that define the behavior of the merge process. - */ -interface MergeBehaviourSettings { - /** - * Indicates whether to synchronise after merging. - */ - syncAfterMerge: boolean; - /** - * Determines if conflicts should be resolved by choosing the newer file. - */ - resolveConflictsByNewerFile: boolean; - /** - * Specifies whether to write documents even if there are conflicts. - */ - writeDocumentsIfConflicted: boolean; - /** - * Disables automatic merging of markdown files. - */ - disableMarkdownAutoMerge: boolean; -} -/** - * Configuration settings for handling edge cases in the application. - */ -interface EdgeCaseHandlingSettings { - /** - * An optional suffix to append to the database name after the vault name. - */ - additionalSuffixOfDatabaseName: string | undefined; - /** - * Flag to disable the worker thread for generating chunks. - */ - disableWorkerForGeneratingChunks: boolean; - /** - * Flag to process small files in the UI thread instead of a worker thread. - */ - processSmallFilesInUIThread: boolean; - /** - * Indicates whether to use timeout for PouchDB replication. - */ - useTimeouts: boolean; -} -/** - * Configuration settings for handling deleted files. - */ -interface DeletedFileMetadataSettings { - /** - * Indicates whether to delete metadata of deleted files. - */ - deleteMetadataOfDeletedFiles: boolean; - /** - * The number of days to wait before automatically deleting metadata of deleted files. - */ - automaticallyDeleteMetadataOfDeletedFiles: number; -} -/** - * Represents a single remote configuration. - */ -export interface RemoteConfiguration { - /** - * Unique identifier for this configuration. - */ - id: string; - /** - * Display name for the configuration. - */ - name: string; - /** - * The connection string (URI) for the remote. - * This may be an encrypted string if configPassphraseStore is set. - */ - uri: string; - /** - * Indicates whether this configuration is encrypted. - */ - isEncrypted: boolean; -} -export interface RemoteConfigurations { - /** - * The list of remote configurations. - */ - remoteConfigurations: Record; - /** - * The ID of the currently active remote configuration. - */ - activeConfigurationId: string; - /** - * The ID of the active remote configuration dedicated for P2P features. - * If empty, P2P features should request explicit selection from the user. - */ - P2P_ActiveRemoteConfigurationId: string; -} -interface ObsidianLiveSyncSettings_PluginSetting extends SyncMethodSettings, UISettings, FileHandlingSettings, MergeBehaviourSettings, EncryptedUserSettings, PeriodicReplicationSettings, InternalFileSettings, PluginSyncSettings, ModeSettings, ExtraTweakSettings, BetaTweakSettings, ObsoleteSettings, DebugModeSettings, SettingSyncSettings, SafetyValveSettings, DataOnSettings, RemoteConfigurations { // eslint-disable-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-empty-interface -- Empty interface -} -export type RemoteDBSettings = CouchDBConnection & BucketSyncSetting & RemoteTypeSettings & EncryptionSettings & ChunkSettings & EdenSettings & DataOnRemoteDBSettings & ObsoleteRemoteDBSettings & OnDemandChunkSettings & BetaRemoteDBSettings & ReplicationSetting & RemoteDBTweakSettings & FileHandlingSettings & ProcessingBehaviourSettings & OptionalAndNotExposedRemoteDBSettings & CrossPlatformInteroperabilitySettings & ConflictHandlingSettings & EdgeCaseHandlingSettings & DeletedFileMetadataSettings & P2PSyncSetting & RemoteConfigurations; -export type ObsidianLiveSyncSettings = ObsidianLiveSyncSettings_PluginSetting & RemoteDBSettings & LocalDBSettings; -export interface HasSettings> { - settings: T; -} -export {}; diff --git a/_types/src/lib/src/common/models/shared.const.behabiour.d.ts b/_types/src/lib/src/common/models/shared.const.behabiour.d.ts deleted file mode 100644 index 2762fc1c..00000000 --- a/_types/src/lib/src/common/models/shared.const.behabiour.d.ts +++ /dev/null @@ -1,30 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare const MAX_DOC_SIZE = 1000; -export declare const MAX_DOC_SIZE_BIN = 102400; -export declare const VER = 12; -export declare const RECENT_MODIFIED_DOCS_QTY = 30; -export declare const LEAF_WAIT_TIMEOUT = 30000; -export declare const LEAF_WAIT_ONLY_REMOTE = 5000; -export declare const LEAF_WAIT_TIMEOUT_SEQUENTIAL_REPLICATOR = 5000; -export declare const REPLICATION_BUSY_TIMEOUT = 3000000; -export declare const SALT_OF_PASSPHRASE = "rHGMPtr6oWw7VSa3W3wpa8fT8U"; -export declare const SALT_OF_ID = "a83hrf7f\u0003y7sa8g31"; -export declare const SEED_MURMURHASH = 305419896; -export declare const IDPrefixes: { - Obfuscated: string; - Chunk: string; - EncryptedChunk: string; -}; -/** - * @deprecated Use `IDPrefixes.Obfuscated` instead. - */ -export declare const PREFIX_OBFUSCATED = "f:"; -/** - * @deprecated Use `IDPrefixes.Chunk` instead. - */ -export declare const PREFIX_CHUNK = "h:"; -/** - * @deprecated Use `IDPrefixes.EncryptedChunk` instead. - */ -export declare const PREFIX_ENCRYPTED_CHUNK = "h:+"; diff --git a/_types/src/lib/src/common/models/shared.const.d.ts b/_types/src/lib/src/common/models/shared.const.d.ts deleted file mode 100644 index fc299cc6..00000000 --- a/_types/src/lib/src/common/models/shared.const.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare const SETTING_KEY_P2P_DEVICE_NAME = "p2p_device_name"; -export declare const configURIBase = "obsidian://setuplivesync?settings="; -export declare const configURIBaseQR = "obsidian://setuplivesync?settingsQR="; -export declare const SuffixDatabaseName = "-livesync-v2"; -export declare const ExtraSuffixIndexedDB = "-indexeddb"; diff --git a/_types/src/lib/src/common/models/shared.const.symbols.d.ts b/_types/src/lib/src/common/models/shared.const.symbols.d.ts deleted file mode 100644 index 06ef69df..00000000 --- a/_types/src/lib/src/common/models/shared.const.symbols.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare const CANCELLED: unique symbol; -export declare const AUTO_MERGED: unique symbol; -export declare const NOT_CONFLICTED: unique symbol; -export declare const MISSING_OR_ERROR: unique symbol; -export declare const LEAVE_TO_SUBSEQUENT: unique symbol; -export declare const TIME_ARGUMENT_INFINITY: unique symbol; -export declare const BASE_IS_NEW: unique symbol; -export declare const TARGET_IS_NEW: unique symbol; -export declare const EVEN: unique symbol; diff --git a/_types/src/lib/src/common/models/shared.definition.configNames.d.ts b/_types/src/lib/src/common/models/shared.definition.configNames.d.ts deleted file mode 100644 index 2605c71a..00000000 --- a/_types/src/lib/src/common/models/shared.definition.configNames.d.ts +++ /dev/null @@ -1,38 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ObsidianLiveSyncSettings } from "./setting.type"; -export declare const LEVEL_ADVANCED = "ADVANCED"; -export declare const LEVEL_POWER_USER = "POWER_USER"; -export declare const LEVEL_EDGE_CASE = "EDGE_CASE"; -export type ConfigLevel = "" | "ADVANCED" | "POWER_USER" | "EDGE_CASE"; -export type ConfigurationItem = { - name: string; - desc?: string; - placeHolder?: string; - status?: "BETA" | "ALPHA" | "EXPERIMENTAL"; - obsolete?: boolean; - level?: ConfigLevel; - isHidden?: boolean; - isAdvanced?: boolean; -}; -export declare const configurationNames: Partial>; -/** - * Get human readable Configuration stability - * @param status - * @returns - */ -export declare function statusDisplay(status?: string): string; -/** - * Get human readable configuration name. - * @param key configuration key - * @param alt - * @returns - */ -export declare function confName(key: keyof ObsidianLiveSyncSettings, alt?: string): string; -/** - * Get human readable configuration description. - * @param key configuration key - * @param alt - * @returns - */ -export declare function confDesc(key: keyof ObsidianLiveSyncSettings, alt?: string): string | undefined; diff --git a/_types/src/lib/src/common/models/shared.definition.d.ts b/_types/src/lib/src/common/models/shared.definition.d.ts deleted file mode 100644 index 614e378a..00000000 --- a/_types/src/lib/src/common/models/shared.definition.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare const DatabaseConnectingStatuses: { - readonly STARTED: "STARTED"; - readonly NOT_CONNECTED: "NOT_CONNECTED"; - readonly PAUSED: "PAUSED"; - readonly CONNECTED: "CONNECTED"; - readonly COMPLETED: "COMPLETED"; - readonly CLOSED: "CLOSED"; - readonly ERRORED: "ERRORED"; - readonly JOURNAL_SEND: "JOURNAL_SEND"; - readonly JOURNAL_RECEIVE: "JOURNAL_RECEIVE"; -}; -export type DatabaseConnectingStatus = (typeof DatabaseConnectingStatuses)[keyof typeof DatabaseConnectingStatuses]; -export type ReplicationStatics = { - sent: number; - arrived: number; - maxPullSeq: number; - maxPushSeq: number; - lastSyncPullSeq: number; - lastSyncPushSeq: number; - syncStatus: DatabaseConnectingStatus; -}; -export declare const DEFAULT_REPLICATION_STATICS: ReplicationStatics; diff --git a/_types/src/lib/src/common/models/shared.type.util.d.ts b/_types/src/lib/src/common/models/shared.type.util.d.ts deleted file mode 100644 index 57025267..00000000 --- a/_types/src/lib/src/common/models/shared.type.util.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { TaggedType } from "octagonal-wheels/common/types"; -export type { TaggedType }; -export type CustomRegExpSource = TaggedType; -export type CustomRegExpSourceList = TaggedType; -export type ParsedCustomRegExp = [isInverted: boolean, pattern: string]; -export type Prettify = { - [K in keyof T]: T[K]; -} & {}; // eslint-disable-line @typescript-eslint/no-empty-object-type, @typescript-eslint/ban-types -- Empty object type diff --git a/_types/src/lib/src/common/models/sync.definition.d.ts b/_types/src/lib/src/common/models/sync.definition.d.ts deleted file mode 100644 index 2880a49c..00000000 --- a/_types/src/lib/src/common/models/sync.definition.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { EntryTypes } from "./db.const"; -import type { DatabaseEntry, DocumentID } from "./db.type"; -export declare const ProtocolVersions: { - readonly UNSET: undefined; - readonly LEGACY: 1; - readonly ADVANCED_E2EE: 2; -}; -export type ProtocolVersion = (typeof ProtocolVersions)[keyof typeof ProtocolVersions]; -export declare const DOCID_SYNC_PARAMETERS: DocumentID; -export declare const DOCID_JOURNAL_SYNC_PARAMETERS: DocumentID; -export interface SyncParameters extends DatabaseEntry { - _id: typeof DOCID_SYNC_PARAMETERS; - _rev?: string; - type: (typeof EntryTypes)["SYNC_PARAMETERS"]; - protocolVersion: ProtocolVersion; - pbkdf2salt: string; -} -export declare const DEFAULT_SYNC_PARAMETERS: SyncParameters; diff --git a/_types/src/lib/src/common/models/tweak.definition.d.ts b/_types/src/lib/src/common/models/tweak.definition.d.ts deleted file mode 100644 index e5120751..00000000 --- a/_types/src/lib/src/common/models/tweak.definition.d.ts +++ /dev/null @@ -1,193 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ObsidianLiveSyncSettings } from "./setting.type"; -export declare const TweakValuesShouldMatchedTemplate: Partial; -type TweakKeys = keyof TweakValues; -export declare const IncompatibleChanges: TweakKeys[]; -export declare const CompatibleButLossyChanges: TweakKeys[]; -type IncompatibleRecommendationPatterns = { - key: T; - isRecommendation?: boolean; -} & ({ - from: TweakValues[T]; - to: TweakValues[T]; -} | { - from: TweakValues[T]; -} | { - to: TweakValues[T]; -}); -export declare const IncompatibleChangesInSpecificPattern: IncompatibleRecommendationPatterns[]; -export declare const TweakValuesRecommendedTemplate: Partial; -export declare const TweakValuesDefault: Partial; -export declare const TweakValuesTemplate: { - tweakModified: number; - liveSync?: boolean | undefined; - syncOnSave?: boolean | undefined; - syncOnStart?: boolean | undefined; - syncOnFileOpen?: boolean | undefined; - syncOnEditorSave?: boolean | undefined; - keepReplicationActiveInBackground?: boolean | undefined; - syncMinimumInterval?: number | undefined; - showVerboseLog?: boolean | undefined; - lessInformationInLog?: boolean | undefined; - showLongerLogInsideEditor?: boolean | undefined; - showStatusOnEditor?: boolean | undefined; - showStatusOnStatusbar?: boolean | undefined; - showOnlyIconsOnEditor?: boolean | undefined; - hideFileWarningNotice?: boolean | undefined; - networkWarningStyle?: "" | "icon" | "hidden" | undefined; - displayLanguage?: import("../rosetta").I18N_LANGS | undefined; - trashInsteadDelete?: boolean | undefined; - doNotDeleteFolder?: boolean | undefined; - batchSave?: boolean | undefined; - batchSaveMinimumDelay?: number | undefined; - batchSaveMaximumDelay?: number | undefined; - syncMaxSizeInMB?: number | undefined; - useIgnoreFiles?: boolean | undefined; - ignoreFiles?: string | undefined; - processSizeMismatchedFiles?: boolean | undefined; - syncOnlyRegEx?: import("./shared.type.util").CustomRegExpSourceList<"|[]|"> | undefined; - syncIgnoreRegEx?: import("./shared.type.util").CustomRegExpSourceList<"|[]|"> | undefined; - syncAfterMerge?: boolean | undefined; - resolveConflictsByNewerFile?: boolean | undefined; - writeDocumentsIfConflicted?: boolean | undefined; - disableMarkdownAutoMerge?: boolean | undefined; - configPassphraseStore?: import("./setting.type").ConfigPassphraseStore | undefined; - encryptedPassphrase?: string | undefined; - encryptedCouchDBConnection?: string | undefined; - periodicReplication?: boolean | undefined; - periodicReplicationInterval?: number | undefined; - syncInternalFiles?: boolean | undefined; - syncInternalFilesBeforeReplication?: boolean | undefined; - syncInternalFilesInterval?: number | undefined; - syncInternalFilesIgnorePatterns?: import("./shared.type.util").CustomRegExpSourceList<","> | undefined; - syncInternalFilesTargetPatterns?: import("./shared.type.util").CustomRegExpSourceList<","> | undefined; - watchInternalFileChanges?: boolean | undefined; - suppressNotifyHiddenFilesChange?: boolean | undefined; - syncInternalFileOverwritePatterns?: import("./shared.type.util").CustomRegExpSourceList<","> | undefined; - usePluginSync?: boolean | undefined; - usePluginSettings?: boolean | undefined; - showOwnPlugins?: boolean | undefined; - autoSweepPlugins?: boolean | undefined; - autoSweepPluginsPeriodic?: boolean | undefined; - notifyPluginOrSettingUpdated?: boolean | undefined; - deviceAndVaultName?: string | undefined; - usePluginSyncV2?: boolean | undefined; - usePluginEtc?: boolean | undefined; - pluginSyncExtendedSetting?: Record | undefined; - useAdvancedMode?: boolean | undefined; - usePowerUserMode?: boolean | undefined; - useEdgeCaseMode?: boolean | undefined; - notifyThresholdOfRemoteStorageSize?: number | undefined; - disableWorkerForGeneratingChunks?: boolean | undefined; - processSmallFilesInUIThread?: boolean | undefined; - savingDelay?: number | undefined; - gcDelay?: number | undefined; - skipOlderFilesOnSync?: boolean | undefined; - useIndexedDBAdapter?: boolean | undefined; - enableDebugTools?: boolean | undefined; - writeLogToTheFile?: boolean | undefined; - settingSyncFile?: string | undefined; - writeCredentialsForSettingSync?: boolean | undefined; - notifyAllSettingSyncFile?: boolean | undefined; - suspendFileWatching?: boolean | undefined; - suspendParseReplicationResult?: boolean | undefined; - doNotSuspendOnFetching?: boolean | undefined; - maxMTimeForReflectEvents?: number | undefined; - versionUpFlash?: string | undefined; - settingVersion?: number | undefined; - isConfigured?: boolean; - lastReadUpdates?: number | undefined; - doctorProcessedVersion?: string | undefined; - remoteConfigurations?: Record | undefined; - activeConfigurationId?: string | undefined; - P2P_ActiveRemoteConfigurationId?: string | undefined; - couchDB_URI?: string | undefined; - couchDB_USER?: string | undefined; - couchDB_PASSWORD?: string | undefined; - couchDB_DBNAME?: string | undefined; - couchDB_CustomHeaders?: string | undefined; - useJWT?: boolean | undefined; - jwtAlgorithm?: import("./auth.type").JWTAlgorithm | undefined; - jwtKey?: string | undefined; - jwtKid?: string | undefined; - jwtSub?: string | undefined; - jwtExpDuration?: number | undefined; - useRequestAPI?: boolean | undefined; - accessKey?: string | undefined; - secretKey?: string | undefined; - bucket?: string | undefined; - region?: string | undefined; - endpoint?: string | undefined; - useCustomRequestHandler?: boolean | undefined; - bucketCustomHeaders?: string | undefined; - bucketPrefix?: string | undefined; - forcePathStyle?: boolean | undefined; - remoteType?: import("./setting.type").RemoteType | undefined; - encrypt?: boolean | undefined; - passphrase?: string | undefined; - usePathObfuscation?: boolean | undefined; - E2EEAlgorithm?: import("./setting.type").E2EEAlgorithm | undefined; - hashAlg?: import("./setting.type").HashAlgorithm | undefined; - minimumChunkSize?: number | undefined; - customChunkSize?: number | undefined; - longLineThreshold?: number | undefined; - useSegmenter?: boolean | undefined; - enableChunkSplitterV2?: boolean | undefined; - doNotUseFixedRevisionForChunks?: boolean | undefined; - chunkSplitterVersion?: import("./setting.type").ChunkSplitterVersion | undefined; - useEden?: boolean | undefined; - maxChunksInEden?: number | undefined; - maxTotalLengthInEden?: number | undefined; - maxAgeInEden?: number | undefined; - checkIntegrityOnSave?: boolean | undefined; - useHistory?: boolean | undefined; - disableRequestURI?: boolean | undefined; - sendChunksBulk?: boolean | undefined; - sendChunksBulkMaxSize?: number | undefined; - useDynamicIterationCount?: boolean | undefined; - doNotPaceReplication?: boolean | undefined; - readChunksOnline?: boolean | undefined; - useOnlyLocalChunk?: boolean | undefined; - concurrencyOfReadChunksOnline?: number | undefined; - minimumIntervalOfReadChunksOnline?: number | undefined; - enableCompression?: boolean | undefined; - batch_size?: number | undefined; - batches_limit?: number | undefined; - ignoreVersionCheck?: boolean | undefined; - disableCheckingConfigMismatch?: boolean | undefined; - autoAcceptCompatibleTweak?: boolean | undefined; - hashCacheMaxCount?: number | undefined; - hashCacheMaxAmount?: number | undefined; - permitEmptyPassphrase?: boolean | undefined; - handleFilenameCaseSensitive?: boolean | undefined; - checkConflictOnlyOnOpen?: boolean | undefined; - showMergeDialogOnlyOnActive?: boolean | undefined; - additionalSuffixOfDatabaseName?: string | undefined; - useTimeouts?: boolean | undefined; - deleteMetadataOfDeletedFiles?: boolean | undefined; - automaticallyDeleteMetadataOfDeletedFiles?: number | undefined; - P2P_AutoAccepting?: import("./setting.type").AutoAccepting | undefined; - P2P_AutoSyncPeers?: string | undefined; - P2P_AutoWatchPeers?: string | undefined; - P2P_SyncOnReplication?: string | undefined; - P2P_RebuildFrom?: string | undefined; - P2P_AutoAcceptingPeers?: string | undefined; - P2P_AutoDenyingPeers?: string | undefined; - P2P_IsHeadless?: boolean; - P2P_Enabled?: boolean | undefined; - P2P_relays?: string | undefined; - P2P_roomID?: string | undefined; - P2P_passphrase?: string | undefined; - P2P_AppID?: string | undefined; - P2P_AutoStart?: boolean | undefined; - P2P_AutoBroadcast?: boolean | undefined; - P2P_DevicePeerName?: string; - P2P_turnServers?: string | undefined; - P2P_turnUsername?: string | undefined; - P2P_turnCredential?: string | undefined; - P2P_useDiagRTC?: boolean; -}; -export type TweakValues = Partial; -export declare const DEVICE_ID_PREFERRED = "PREFERRED"; -export {}; diff --git a/_types/src/lib/src/common/rosetta.d.ts b/_types/src/lib/src/common/rosetta.d.ts deleted file mode 100644 index 06167315..00000000 --- a/_types/src/lib/src/common/rosetta.d.ts +++ /dev/null @@ -1,44 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -/** -# Rosetta stone -- To localise messages to your language, please write a translation to this file and submit a PR. -- Please order languages in alphabetic order, if you write multiple items. - -## Notice to ensure that your favours are not wasted. - -If you plan to utilise machine translation engines to contribute translated resources, -please ensure the engine's terms of service are compatible with our project's license. -Your diligence in this matter helps maintain compliance and avoid potential licensing issues. -Thank you for your consideration. - -Usually, our projects (Self-hosted LiveSync and its families) are licensed under MIT License. -To see details, please refer to the LICENSES file on each repository. - -## How to internationalise untranslated items? -1. Change the message literal to use `$msg` - "Could not parse YAML" -> $msg('anyKey') -2. Create `ls-debug` folder under the `.obsidian` folder of your vault. -3. Run Self-hosted LiveSync in dev mode (npm run dev). -4. You will get the `missing-translation-YYYY-MM-DD.jsonl` under `ls-debug`. Please copy and paste inside `allMessages` and write the translations. -5. Send me the PR! -*/ -declare const LANG_DE = "de"; -declare const LANG_ES = "es"; -declare const LANG_FR = "fr"; -declare const LANG_HE = "he"; -declare const LANG_JA = "ja"; -declare const LANG_RU = "ru"; -declare const LANG_ZH = "zh"; -declare const LANG_KO = "ko"; -declare const LANG_ZH_TW = "zh-tw"; -declare const LANG_DEF = "def"; -export declare const SUPPORTED_I18N_LANGS: string[]; -export type I18N_LANGS = typeof LANG_DEF | typeof LANG_DE | typeof LANG_ES | typeof LANG_FR | typeof LANG_HE | typeof LANG_JA | typeof LANG_KO | typeof LANG_RU | typeof LANG_ZH | typeof LANG_ZH_TW | ""; -export type MESSAGE = { - [key in I18N_LANGS]?: string; -}; -import { type MessageKeys } from "./messages/combinedMessages.dev"; -export declare function expandKeywords, U extends Record>(message: T, lang: I18N_LANGS, recurseLimit?: number): T; -export type AllMessageKeys = MessageKeys; -export {}; diff --git a/_types/src/lib/src/common/settingConstants.d.ts b/_types/src/lib/src/common/settingConstants.d.ts deleted file mode 100644 index 8f6111d0..00000000 --- a/_types/src/lib/src/common/settingConstants.d.ts +++ /dev/null @@ -1,217 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type ConfigurationItem, type ObsidianLiveSyncSettings } from "./types.ts"; -type ExtractPropertiesByType = { - [K in keyof T as T[K] extends U ? K : never]: T[K] extends U ? K : never; -}; -export type FilterStringKeys = keyof ExtractPropertiesByType; -export type FilterBooleanKeys = keyof ExtractPropertiesByType; -export type FilterNumberKeys = keyof ExtractPropertiesByType; -import type { FilePath, FilePathWithPrefixLC, FilePathWithPrefix, DocumentID } from "./models/db.type.ts"; -export type { FilePath, FilePathWithPrefixLC, FilePathWithPrefix, DocumentID }; -/** - * Self-hosted LiveSync settings pane items. - */ -export type OnDialogSettings = { - configPassphrase: string; - preset: "" | "PERIODIC" | "LIVESYNC" | "DISABLE"; - syncMode: "ONEVENTS" | "PERIODIC" | "LIVESYNC"; - dummy: number; - deviceAndVaultName: string; -}; -/** - * Default settings for the OnDialogSettings. - * Not used for the common library, but used for the Obsidian plugin. - */ -export declare const OnDialogSettingsDefault: OnDialogSettings; -export declare const AllSettingDefault: { - configPassphrase: string; - preset: "" | "PERIODIC" | "LIVESYNC" | "DISABLE"; - syncMode: "ONEVENTS" | "PERIODIC" | "LIVESYNC"; - dummy: number; - deviceAndVaultName: string; - liveSync: boolean; - syncOnSave: boolean; - syncOnStart: boolean; - syncOnFileOpen: boolean; - syncOnEditorSave: boolean; - keepReplicationActiveInBackground: boolean; - syncMinimumInterval: number; - showVerboseLog: boolean; - lessInformationInLog: boolean; - showLongerLogInsideEditor: boolean; - showStatusOnEditor: boolean; - showStatusOnStatusbar: boolean; - showOnlyIconsOnEditor: boolean; - hideFileWarningNotice: boolean; - networkWarningStyle: "" | "icon" | "hidden"; - displayLanguage: import("./rosetta.ts").I18N_LANGS; - trashInsteadDelete: boolean; - doNotDeleteFolder: boolean; - batchSave: boolean; - batchSaveMinimumDelay: number; - batchSaveMaximumDelay: number; - syncMaxSizeInMB: number; - useIgnoreFiles: boolean; - ignoreFiles: string; - processSizeMismatchedFiles: boolean; - syncOnlyRegEx: import("./types.ts").CustomRegExpSourceList<"|[]|">; - syncIgnoreRegEx: import("./types.ts").CustomRegExpSourceList<"|[]|">; - syncAfterMerge: boolean; - resolveConflictsByNewerFile: boolean; - writeDocumentsIfConflicted: boolean; - disableMarkdownAutoMerge: boolean; - configPassphraseStore: import("./types.ts").ConfigPassphraseStore; - encryptedPassphrase: string; - encryptedCouchDBConnection: string; - periodicReplication: boolean; - periodicReplicationInterval: number; - syncInternalFiles: boolean; - syncInternalFilesBeforeReplication: boolean; - syncInternalFilesInterval: number; - syncInternalFilesIgnorePatterns: import("./types.ts").CustomRegExpSourceList<",">; - syncInternalFilesTargetPatterns: import("./types.ts").CustomRegExpSourceList<",">; - watchInternalFileChanges: boolean; - suppressNotifyHiddenFilesChange: boolean; - syncInternalFileOverwritePatterns: import("./types.ts").CustomRegExpSourceList<",">; - usePluginSync: boolean; - usePluginSettings: boolean; - showOwnPlugins: boolean; - autoSweepPlugins: boolean; - autoSweepPluginsPeriodic: boolean; - notifyPluginOrSettingUpdated: boolean; - usePluginSyncV2: boolean; - usePluginEtc: boolean; - pluginSyncExtendedSetting: Record; - useAdvancedMode: boolean; - usePowerUserMode: boolean; - useEdgeCaseMode: boolean; - notifyThresholdOfRemoteStorageSize: number; - disableWorkerForGeneratingChunks: boolean; - processSmallFilesInUIThread: boolean; - savingDelay: number; - gcDelay: number; - skipOlderFilesOnSync: boolean; - useIndexedDBAdapter: boolean; - enableDebugTools: boolean; - writeLogToTheFile: boolean; - settingSyncFile: string; - writeCredentialsForSettingSync: boolean; - notifyAllSettingSyncFile: boolean; - suspendFileWatching: boolean; - suspendParseReplicationResult: boolean; - doNotSuspendOnFetching: boolean; - maxMTimeForReflectEvents: number; - versionUpFlash: string; - settingVersion: number; - isConfigured?: boolean; - lastReadUpdates: number; - doctorProcessedVersion: string; - remoteConfigurations: Record; - activeConfigurationId: string; - P2P_ActiveRemoteConfigurationId: string; - couchDB_URI: string; - couchDB_USER: string; - couchDB_PASSWORD: string; - couchDB_DBNAME: string; - couchDB_CustomHeaders: string; - useJWT: boolean; - jwtAlgorithm: import("./models/auth.type.ts").JWTAlgorithm; - jwtKey: string; - jwtKid: string; - jwtSub: string; - jwtExpDuration: number; - useRequestAPI: boolean; - accessKey: string; - secretKey: string; - bucket: string; - region: string; - endpoint: string; - useCustomRequestHandler: boolean; - bucketCustomHeaders: string; - bucketPrefix: string; - forcePathStyle: boolean; - remoteType: import("./types.ts").RemoteType; - encrypt: boolean; - passphrase: string; - usePathObfuscation: boolean; - E2EEAlgorithm: import("./types.ts").E2EEAlgorithm; - hashAlg: import("./types.ts").HashAlgorithm; - minimumChunkSize: number; - customChunkSize: number; - longLineThreshold: number; - useSegmenter: boolean; - enableChunkSplitterV2: boolean; - doNotUseFixedRevisionForChunks: boolean; - chunkSplitterVersion: import("./types.ts").ChunkSplitterVersion; - useEden: boolean; - maxChunksInEden: number; - maxTotalLengthInEden: number; - maxAgeInEden: number; - tweakModified: number | undefined; - checkIntegrityOnSave: boolean; - useHistory: boolean; - disableRequestURI: boolean; - sendChunksBulk: boolean; - sendChunksBulkMaxSize: number; - useDynamicIterationCount: boolean; - doNotPaceReplication: boolean; - readChunksOnline: boolean; - useOnlyLocalChunk: boolean; - concurrencyOfReadChunksOnline: number; - minimumIntervalOfReadChunksOnline: number; - enableCompression: boolean; - batch_size: number; - batches_limit: number; - ignoreVersionCheck: boolean; - disableCheckingConfigMismatch: boolean; - autoAcceptCompatibleTweak: boolean | undefined; - hashCacheMaxCount: number; - hashCacheMaxAmount: number; - permitEmptyPassphrase: boolean; - handleFilenameCaseSensitive: boolean; - checkConflictOnlyOnOpen: boolean; - showMergeDialogOnlyOnActive: boolean; - additionalSuffixOfDatabaseName: string | undefined; - useTimeouts: boolean; - deleteMetadataOfDeletedFiles: boolean; - automaticallyDeleteMetadataOfDeletedFiles: number; - P2P_AutoAccepting: import("./types.ts").AutoAccepting; - P2P_AutoSyncPeers: string; - P2P_AutoWatchPeers: string; - P2P_SyncOnReplication: string; - P2P_RebuildFrom: string; - P2P_AutoAcceptingPeers: string; - P2P_AutoDenyingPeers: string; - P2P_IsHeadless?: boolean; - P2P_Enabled: boolean; - P2P_relays: string; - P2P_roomID: string; - P2P_passphrase: string; - P2P_AppID: string; - P2P_AutoStart: boolean; - P2P_AutoBroadcast: boolean; - P2P_DevicePeerName?: string; - P2P_turnServers: string; - P2P_turnUsername: string; - P2P_turnCredential: string; - P2P_useDiagRTC?: boolean; -}; -export type AllSettings = ObsidianLiveSyncSettings & OnDialogSettings; -export type AllStringItemKey = FilterStringKeys; -export type AllNumericItemKey = FilterNumberKeys; -export type AllBooleanItemKey = FilterBooleanKeys; -export type AllSettingItemKey = AllStringItemKey | AllNumericItemKey | AllBooleanItemKey; -export type ValueOf = T extends AllStringItemKey ? string : T extends AllNumericItemKey ? number : T extends AllBooleanItemKey ? boolean : AllSettings[T]; -export declare const SettingInformation: Partial>; -export declare function getConfig(key: AllSettingItemKey): false | { - name: string; - desc?: string; - placeHolder?: string; - status?: "BETA" | "ALPHA" | "EXPERIMENTAL"; - obsolete?: boolean; - level?: import("./types.ts").ConfigLevel; - isHidden?: boolean; - isAdvanced?: boolean; -}; -export declare function getConfName(key: AllSettingItemKey): string; diff --git a/_types/src/lib/src/common/typeUtils.d.ts b/_types/src/lib/src/common/typeUtils.d.ts deleted file mode 100644 index f177da96..00000000 --- a/_types/src/lib/src/common/typeUtils.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { DocumentID, FilePath, FilePathWithPrefix } from "./models/db.type"; -import type { UXFileInfoStub } from "./types"; -/** - * returns is internal chunk of file - * @param id ID - * @returns - */ -export declare function isInternalMetadata(id: FilePath | FilePathWithPrefix | DocumentID): boolean; -export declare function isInternalFile(file: UXFileInfoStub | string | FilePathWithPrefix): boolean; -export declare function stripInternalMetadataPrefix(id: T): T; -export declare function id2InternalMetadataId(id: DocumentID): DocumentID; -export declare function isChunk(str: string): boolean; -export declare function isPluginMetadata(str: string): boolean; -export declare function isCustomisationSyncMetadata(str: string): boolean; -export declare function getPathFromUXFileInfo(file: UXFileInfoStub | string | FilePathWithPrefix): FilePathWithPrefix; -export declare function getStoragePathFromUXFileInfo(file: UXFileInfoStub | string | FilePathWithPrefix): FilePath; -export declare function getDatabasePathFromUXFileInfo(file: UXFileInfoStub | string | FilePathWithPrefix): FilePathWithPrefix; diff --git a/_types/src/lib/src/common/types.d.ts b/_types/src/lib/src/common/types.d.ts deleted file mode 100644 index 496952c1..00000000 --- a/_types/src/lib/src/common/types.d.ts +++ /dev/null @@ -1,109 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export type { TaggedType } from "./models/shared.type.util.ts"; -export { LOG_LEVEL_DEBUG, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_URGENT, LOG_LEVEL_VERBOSE, } from "octagonal-wheels/common/logger"; -export type { LOG_LEVEL } from "octagonal-wheels/common/logger"; -import { RESULT_NOT_FOUND, RESULT_TIMED_OUT } from "octagonal-wheels/common/const"; -import type { Credential } from "./models/auth.type.ts"; -import type { AnyEntry, ChunkVersionRange, DatabaseEntry, EdenChunk, EntryBase, EntryChunkPack, EntryHasPath, EntryLeaf, EntryType, EntryTypeNotes, EntryTypeNotesWithLegacy, EntryVersionInfo, EntryWithEden, InternalFileEntry, LoadedEntry, MetaEntry, NewEntry, NoteEntry, PlainEntry, SavingEntry, SyncInfo } from "./models/db.type.ts"; -import { ChunkTypes, EntryTypes, MILESTONE_DOCID, NODEINFO_DOCID, NoteTypes, SYNCINFO_ID, VERSIONING_DOCID } from "./models/db.const.ts"; -import { AUTO_MERGED, CANCELLED, LEAVE_TO_SUBSEQUENT, MISSING_OR_ERROR, NOT_CONFLICTED, TIME_ARGUMENT_INFINITY } from "./models/shared.const.symbols.ts"; -import { IDPrefixes, LEAF_WAIT_ONLY_REMOTE, LEAF_WAIT_TIMEOUT, LEAF_WAIT_TIMEOUT_SEQUENTIAL_REPLICATOR, MAX_DOC_SIZE, MAX_DOC_SIZE_BIN, PREFIX_CHUNK, PREFIX_ENCRYPTED_CHUNK, PREFIX_OBFUSCATED, RECENT_MODIFIED_DOCS_QTY, REPLICATION_BUSY_TIMEOUT, SALT_OF_ID, SALT_OF_PASSPHRASE, SEED_MURMURHASH, VER } from "./models/shared.const.behabiour.ts"; -import { AutoAccepting, type BucketSyncSetting, type ChunkSplitterVersion, type ConfigPassphraseStore, type CouchDBConnection, type E2EEAlgorithm, type EncryptionSettings, type HashAlgorithm, type HasSettings, type LocalDBSettings, type ObsidianLiveSyncSettings, type P2PConnectionInfo, type P2PSyncSetting, type PluginSyncSettingEntry, type RemoteDBSettings, type RemoteType, type RemoteTypeSettings, type SYNC_MODE } from "./models/setting.type.ts"; -import { ChunkAlgorithmNames, ChunkAlgorithms, CURRENT_SETTING_VERSION, E2EEAlgorithmNames, E2EEAlgorithms, HashAlgorithms, REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P, RemoteTypes, SETTING_VERSION_INITIAL, SETTING_VERSION_SUPPORT_CASE_INSENSITIVE, MODE_AUTOMATIC, MODE_PAUSED, MODE_SELECTIVE, MODE_SHINY } from "./models/setting.const.ts"; -import { PREFERRED_BASE, PREFERRED_JOURNAL_SYNC, PREFERRED_SETTING_CLOUDANT, PREFERRED_SETTING_SELF_HOSTED } from "./models/setting.const.preferred.ts"; -import { P2P_DEFAULT_SETTINGS, DEFAULT_SETTINGS } from "./models/setting.const.defaults.ts"; -import { KeyIndexOfSettings } from "./models/setting.const.qr.ts"; -import type { DeviceInfo, EntryBody, EntryDoc, EntryDocResponse, EntryMilestoneInfo, EntryNodeInfo, NodeData, NodeKey } from "./models/db.definition.ts"; -import { isMetaEntry } from "./models/db.definition.ts"; -import type { CouchDBCredentials, BasicCredentials, JWTCredentials, JWTHeader, JWTPayload, JWTParams, PreparedJWT } from "./models/auth.type.ts"; -import type { CacheData, FileEventArgs, FileEventItem, FileEventType, UXAbstractInfoStub, UXDataWriteOptions, UXFileInfo, UXFileInfoStub, UXFolderInfo, UXInternalFileInfoStub, UXStat } from "./models/fileaccess.type.ts"; -import { SETTING_KEY_P2P_DEVICE_NAME, configURIBase, configURIBaseQR, SuffixDatabaseName, ExtraSuffixIndexedDB } from "./models/shared.const.ts"; -import { configurationNames, LEVEL_ADVANCED, LEVEL_POWER_USER, LEVEL_EDGE_CASE, type ConfigLevel, type ConfigurationItem, statusDisplay, confName, confDesc } from "./models/shared.definition.configNames.ts"; -import type { CustomRegExpSource, CustomRegExpSourceList, ParsedCustomRegExp, Prettify } from "./models/shared.type.util.ts"; -import { ProtocolVersions, type ProtocolVersion, DOCID_SYNC_PARAMETERS, DOCID_JOURNAL_SYNC_PARAMETERS, type SyncParameters, DEFAULT_SYNC_PARAMETERS } from "./models/sync.definition.ts"; -import { TweakValuesShouldMatchedTemplate, IncompatibleChanges, CompatibleButLossyChanges, IncompatibleChangesInSpecificPattern, TweakValuesRecommendedTemplate, TweakValuesDefault, TweakValuesTemplate, type TweakValues, DEVICE_ID_PREFERRED } from "./models/tweak.definition.ts"; -import type { diff_result_leaf, dmp_result, diff_result, DIFF_CHECK_RESULT_AUTO, diff_check_result } from "./models/diff.definition.ts"; -import { PREFIXMD_LOGFILE, PREFIXMD_LOGFILE_UC, FlagFilesOriginal, FlagFilesHumanReadable, FLAGMD_REDFLAG, FLAGMD_REDFLAG2, FLAGMD_REDFLAG2_HR, FLAGMD_REDFLAG3, FLAGMD_REDFLAG3_HR } from "./models/redflag.const.ts"; -import { DatabaseConnectingStatuses, type DatabaseConnectingStatus } from "./models/shared.definition.ts"; -export { RESULT_NOT_FOUND, RESULT_TIMED_OUT }; -export type { FilePath, FilePathWithPrefixLC, FilePathWithPrefix, DocumentID } from "./models/db.type.ts"; -export { MAX_DOC_SIZE, MAX_DOC_SIZE_BIN, VER, RECENT_MODIFIED_DOCS_QTY, LEAF_WAIT_TIMEOUT, LEAF_WAIT_ONLY_REMOTE, LEAF_WAIT_TIMEOUT_SEQUENTIAL_REPLICATOR, REPLICATION_BUSY_TIMEOUT, CANCELLED, AUTO_MERGED, NOT_CONFLICTED, MISSING_OR_ERROR, LEAVE_TO_SUBSEQUENT, TIME_ARGUMENT_INFINITY, VERSIONING_DOCID, MILESTONE_DOCID, NODEINFO_DOCID, }; -export { type CouchDBConnection }; -export type { ConfigPassphraseStore }; -export { MODE_SELECTIVE, MODE_AUTOMATIC, MODE_PAUSED, MODE_SHINY, type SYNC_MODE }; -export { type PluginSyncSettingEntry }; -export { SETTING_VERSION_INITIAL, SETTING_VERSION_SUPPORT_CASE_INSENSITIVE, CURRENT_SETTING_VERSION }; -export type { BucketSyncSetting, LocalDBSettings }; -export { RemoteTypes, REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P, type RemoteType, AutoAccepting }; -export type { P2PConnectionInfo, P2PSyncSetting }; -export { P2P_DEFAULT_SETTINGS }; -export type { RemoteTypeSettings }; -export { E2EEAlgorithmNames, E2EEAlgorithms, type E2EEAlgorithm }; -export type { EncryptionSettings }; -export { HashAlgorithms, type HashAlgorithm, ChunkAlgorithmNames, ChunkAlgorithms, type ChunkSplitterVersion }; -export type { RemoteDBSettings }; -export type { ObsidianLiveSyncSettings }; -export { DEFAULT_SETTINGS }; -export { KeyIndexOfSettings }; -export { type HasSettings }; -export { PREFERRED_BASE, PREFERRED_SETTING_CLOUDANT, PREFERRED_SETTING_SELF_HOSTED, PREFERRED_JOURNAL_SYNC }; -export { EntryTypes, NoteTypes, ChunkTypes, type EntryType, type EntryTypeNotes, type EntryTypeNotesWithLegacy, type DatabaseEntry, type EntryBase, type EdenChunk, type EntryWithEden, type NoteEntry, type NewEntry, type PlainEntry, type InternalFileEntry, type AnyEntry, type LoadedEntry, type SavingEntry, type MetaEntry, isMetaEntry, type EntryLeaf, type EntryChunkPack, type EntryVersionInfo, type EntryHasPath, }; -export type { ChunkVersionRange }; -export { TweakValuesShouldMatchedTemplate }; -export { IncompatibleChanges }; -export { CompatibleButLossyChanges }; -export { IncompatibleChangesInSpecificPattern }; -export { TweakValuesRecommendedTemplate }; -export { TweakValuesDefault }; -export { configurationNames }; -export { LEVEL_ADVANCED }; -export { LEVEL_POWER_USER }; -export { LEVEL_EDGE_CASE }; -export type { ConfigLevel }; -export type { ConfigurationItem }; -export { statusDisplay }; -export { confName }; -export { confDesc }; -export { TweakValuesTemplate }; -export type { TweakValues }; -export { DEVICE_ID_PREFERRED }; -export type { NodeKey }; -export type { DeviceInfo }; -export type { NodeData }; -export type { EntryMilestoneInfo }; -export type { EntryNodeInfo }; -export type { EntryBody }; -export type { EntryDoc }; -export type { diff_result_leaf }; -export type { dmp_result }; -export type { diff_result }; -export type { DIFF_CHECK_RESULT_AUTO }; -export type { diff_check_result }; -export type { Credential }; -export type { EntryDocResponse }; -export { DatabaseConnectingStatuses }; -export type { DatabaseConnectingStatus }; -export { PREFIXMD_LOGFILE, PREFIXMD_LOGFILE_UC, FlagFilesOriginal, FlagFilesHumanReadable, FLAGMD_REDFLAG, FLAGMD_REDFLAG2, FLAGMD_REDFLAG2_HR, FLAGMD_REDFLAG3, FLAGMD_REDFLAG3_HR, }; -export { SYNCINFO_ID }; -export type { SyncInfo }; -export { SALT_OF_PASSPHRASE, SALT_OF_ID, SEED_MURMURHASH, IDPrefixes, PREFIX_OBFUSCATED, PREFIX_CHUNK, PREFIX_ENCRYPTED_CHUNK, }; -export type { UXStat, UXFileInfo, UXAbstractInfoStub, UXFileInfoStub, UXInternalFileInfoStub, UXFolderInfo, UXDataWriteOptions, CacheData, FileEventType, FileEventArgs, FileEventItem, }; -export type { Prettify }; -export type { CouchDBCredentials }; -export type { BasicCredentials }; -export type { JWTCredentials }; -export type { JWTHeader }; -export type { JWTPayload }; -export type { JWTParams }; -export type { PreparedJWT }; -export type { CustomRegExpSource }; -export type { CustomRegExpSourceList }; -export type { ParsedCustomRegExp }; -export { ProtocolVersions }; -export type { ProtocolVersion }; -export { DOCID_SYNC_PARAMETERS }; -export { DOCID_JOURNAL_SYNC_PARAMETERS }; -export type { SyncParameters }; -export { DEFAULT_SYNC_PARAMETERS }; -export { SETTING_KEY_P2P_DEVICE_NAME, configURIBase, configURIBaseQR, SuffixDatabaseName, ExtraSuffixIndexedDB }; diff --git a/_types/src/lib/src/common/utils.d.ts b/_types/src/lib/src/common/utils.d.ts deleted file mode 100644 index fc98c4fc..00000000 --- a/_types/src/lib/src/common/utils.d.ts +++ /dev/null @@ -1,131 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type AnyEntry, type DatabaseEntry, type EntryLeaf, type SyncInfo, type LoadedEntry, type SavingEntry, type NewEntry, type PlainEntry, type CustomRegExpSource, type ParsedCustomRegExp, type CustomRegExpSourceList, type ObsidianLiveSyncSettings, type RemoteDBSettings, type P2PConnectionInfo, type BucketSyncSetting, type CouchDBConnection, type EncryptionSettings } from "./types.ts"; -import { replaceAll, replaceAllPairs } from "octagonal-wheels/string"; -export { replaceAll, replaceAllPairs }; -import { concatUInt8Array } from "octagonal-wheels/binary"; -export { concatUInt8Array }; -import { delay, fireAndForget } from "octagonal-wheels/promises"; -export { delay, fireAndForget }; -import { arrayToChunkedArray, unique } from "octagonal-wheels/collection"; -export { arrayToChunkedArray, unique }; -import { extractObject, isObjectDifferent } from "octagonal-wheels/object"; -export { extractObject, isObjectDifferent }; -import { sendValue, sendSignal, waitForSignal, waitForValue } from "octagonal-wheels/messagepassing/signal"; -export { sendValue, sendSignal, waitForSignal, waitForValue }; -import { throttle } from "octagonal-wheels/function"; -export { throttle }; -import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase"; -import { BASE_IS_NEW, EVEN, TARGET_IS_NEW } from "./models/shared.const.symbols.ts"; -export type { SimpleStore }; -export { sizeToHumanReadable } from "octagonal-wheels/number"; -export declare function resolveWithIgnoreKnownError(p: Promise, def: T): Promise; -export declare const Parallels: (ps?: Set>) => { - add: (p: Promise) => Set>; - wait: (limit: number) => false | Promise; - all: () => Promise; -}; -export declare function allSettledWithConcurrencyLimit(processes: Promise[], limit: number): Promise; -export declare function getDocData(doc: string | string[]): string; -export declare function getDocDataAsArray(doc: string | string[]): string[]; -export declare function getDocDataAsArrayBuffer(doc: string | string[] | ArrayBuffer): Uint8Array; -export declare function isTextBlob(blob: Blob): boolean; -export declare function createTextBlob(data: string | string[]): Blob; -export declare function createBinaryBlob(data: Uint8Array | ArrayBuffer): Blob; -export declare function createBlob(data: string | string[] | Uint8Array | ArrayBuffer | Blob): Blob; -export declare function isTextDocument(doc: LoadedEntry): boolean; -export declare function readAsBlob(doc: LoadedEntry): Blob; -export declare function readContent(doc: LoadedEntry): string | ArrayBuffer; -export declare function isDocContentSame(docA: string | string[] | Blob | ArrayBuffer, docB: string | string[] | Blob | ArrayBuffer): Promise; -export declare function isObfuscatedEntry(doc: DatabaseEntry): doc is AnyEntry; -export declare function isEncryptedChunkEntry(doc: DatabaseEntry): doc is EntryLeaf; -export declare function isSyncInfoEntry(doc: DatabaseEntry): doc is SyncInfo; -export declare function memorizeFuncWithLRUCache(func: (key: T) => U): (key: T) => U | undefined; -export declare function memorizeFuncWithLRUCacheMulti(func: (...keys: T) => U): (keys: T) => U | undefined; -/** - * - * @param exclusion return only not exclusion - * @returns - * - * ["something",false,"aaaaa"].filter(onlyNot(false)) => yields ["something","aaaaaa"]. but, as string[]. - */ -export declare function onlyNot(exclusion: B): (item: A | B) => item is Exclude; -/** - * Run task with keeping minimum interval - * @param key waiting key - * @param interval interval (ms) - * @param task task to perform. - * @returns result of task - * @remarks This function is not designed to be concurrent. - */ -export declare function runWithInterval(key: string, interval: number, task: () => Promise): Promise; -/** - * Run task with keeping minimum interval on start - * @param key waiting key - * @param interval interval (ms) - * @param task task to perform. - * @returns result of task - * @remarks This function is not designed to be concurrent. - */ -export declare function runWithStartInterval(key: string, interval: number, task: () => Promise): Promise; -export declare const globalConcurrencyController: import("octagonal-wheels/concurrency/semaphore_v2").SemaphoreObject; -export declare function determineTypeFromBlob(data: Blob): "newnote" | "plain"; -export declare function determineType(path: string, data: string | string[] | Uint8Array | ArrayBuffer | Blob): "newnote" | "plain"; -export declare function isAnyNote(doc: DatabaseEntry): doc is NewEntry | PlainEntry; -export declare function isLoadedEntry(doc: DatabaseEntry): doc is LoadedEntry; -export declare function isDeletedEntry(doc: LoadedEntry): boolean; -export declare function createSavingEntryFromLoadedEntry(doc: LoadedEntry): SavingEntry; -export declare function setAllItems(set: Set, items: T[]): Set; -export declare function escapeNewLineFromString(str: string): string; -export declare function unescapeNewLineFromString(str: string): string; -export declare function escapeMarkdownValue(value: T): T; -export declare function timeDeltaToHumanReadable(delta: number): string; -export declare function wrapException(func: () => Promise>): Promise | Error>; -export declare function toRanges(sorted: number[]): string; -export declare function isDirty(key: string, value: unknown): boolean; -export declare function tryParseJSON(str: string, fallbackValue?: T): T | undefined; -export { mergeObject, applyPatch, generatePatchObj, flattenObject, isObjectMargeApplicable, isSensibleMargeApplicable, } from "./utils.patch.ts"; -export declare function parseHeaderValues(strHeader: string): Record; -/*** - * Parse custom regular expression - * @param regexp - * @returns [negate: boolean, regexp: string] - * @example `!!foo` => [true, "foo"] - * @example `foo` => [false, "foo"] - */ -export declare function parseCustomRegExp(regexp: CustomRegExpSource): ParsedCustomRegExp; -export declare function matchRegExp(regexp: CustomRegExpSource, target: string): boolean; -export declare function isValidRegExp(regexp: CustomRegExpSource): boolean; -export declare function isInvertedRegExp(regexp: CustomRegExpSource): boolean; -export declare function constructCustomRegExpList(items: CustomRegExpSource[], delimiter: D): CustomRegExpSourceList; -export declare function splitCustomRegExpList(list: CustomRegExpSourceList, delimiter: D): CustomRegExpSource[]; -export declare class CustomRegExp { - regexp: RegExp; - negate: boolean; - pattern: string; - constructor(regexp: CustomRegExpSource, flags?: string); - test(str: string): boolean; -} -type RegExpSettingKey = "syncOnlyRegEx" | "syncIgnoreRegEx" | "syncInternalFilesIgnorePatterns" | "syncInternalFilesTargetPatterns" | "syncInternalFileOverwritePatterns"; -export declare function getFileRegExp(settings: ObsidianLiveSyncSettings | RemoteDBSettings, key: RegExpSettingKey): CustomRegExp[]; -/** - * Copies properties from the source object to the target object only if they exist in the target. - * @param source The object to copy properties from. - * @param target The object to copy properties to. - */ -export declare function copyTo(source: U, target: T): void; -export declare function pickBucketSyncSettings(setting: ObsidianLiveSyncSettings): BucketSyncSetting; -export declare function pickCouchDBSyncSettings(setting: ObsidianLiveSyncSettings): CouchDBConnection; -export declare function pickEncryptionSettings(setting: ObsidianLiveSyncSettings | EncryptionSettings): EncryptionSettings; -export declare function pickP2PSyncSettings(setting: Partial & P2PConnectionInfo): P2PConnectionInfo; -export declare function wrapByDefault(func: () => T, onError: (err: Error) => U): T | U; -export declare function compareMTime(baseMTime: number, targetMTime: number): typeof BASE_IS_NEW | typeof TARGET_IS_NEW | typeof EVEN; -export declare function displayRev(rev: string): string; -/** - * Generate a random P2P Room ID in the format `123-456-789-abc`. - */ -export declare function generateP2PRoomId(): string; -/** - * Extract the stable suffix (last segment) from a Room ID. - */ -export declare function extractP2PRoomSuffix(roomId: string): string; diff --git a/_types/src/lib/src/common/utils.doc.d.ts b/_types/src/lib/src/common/utils.doc.d.ts deleted file mode 100644 index 9f9aaea9..00000000 --- a/_types/src/lib/src/common/utils.doc.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare function isErrorOf(ex: unknown, statusCode: number): boolean; -/** - * Checks if the error is effectively a 404 error from CouchDB or PouchDB. - * @param ex some error object, expected to be from CouchDB or PouchDB. - * @returns true if the error is a 404 not found error, false otherwise. - * @throws if the input is not an object or does not have a numeric "status" property. - */ -export declare function isNotFoundError(ex: unknown): boolean; -export declare function isConflictError(ex: unknown): boolean; -export declare function isUnauthorizedError(ex: unknown): boolean; -export declare function tryGetFilePath(entry: unknown): string | undefined; diff --git a/_types/src/lib/src/common/utils.object.d.ts b/_types/src/lib/src/common/utils.object.d.ts deleted file mode 100644 index 8ef1cad7..00000000 --- a/_types/src/lib/src/common/utils.object.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare function asCopy(obj: T): T; -export declare function ensureError(error: unknown): Error; diff --git a/_types/src/lib/src/common/utils.patch.d.ts b/_types/src/lib/src/common/utils.patch.d.ts deleted file mode 100644 index 1d9ef80b..00000000 --- a/_types/src/lib/src/common/utils.patch.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare function generatePatchObj(from: Record, to: Record): Record; -export declare function applyPatch(from: Record, patch: Record): Record; -export declare function mergeObject(objA: Record | [unknown], objB: Record | [unknown]): unknown[] | { - [k: string]: unknown; -}; -export declare function flattenObject(obj: Record, path?: string[]): [string, unknown][]; -export declare function isSensibleMargeApplicable(path: string): boolean; -export declare function isObjectMargeApplicable(path: string): boolean; diff --git a/_types/src/lib/src/common/utils.type.d.ts b/_types/src/lib/src/common/utils.type.d.ts deleted file mode 100644 index 30059819..00000000 --- a/_types/src/lib/src/common/utils.type.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export type Constructor = new (...args: any[]) => T; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration diff --git a/_types/src/lib/src/dataobject/StoredMap.d.ts b/_types/src/lib/src/dataobject/StoredMap.d.ts deleted file mode 100644 index c183ccdd..00000000 --- a/_types/src/lib/src/dataobject/StoredMap.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase"; -export declare class StoredMapLike { - _store: SimpleStore; - _cache: Map; - _prefix: string; - constructor(store: SimpleStore>, prefix?: string); - addPrefix(key: string): string; - get(key: string): Promise; - set(key: string, value: U): Promise; - delete(key: string): Promise; - has(key: string): Promise; -} diff --git a/_types/src/lib/src/dev/checks.d.ts b/_types/src/lib/src/dev/checks.d.ts deleted file mode 100644 index 45d3af5d..00000000 --- a/_types/src/lib/src/dev/checks.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -interface InstanceHaveOnBindFunction { - onBindFunction: (...params: T[]) => void; -} -export declare function __$checkInstanceBinding>(instance: T): void; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration -export {}; diff --git a/_types/src/lib/src/encryption/encryptHKDF.d.ts b/_types/src/lib/src/encryption/encryptHKDF.d.ts deleted file mode 100644 index ce744358..00000000 --- a/_types/src/lib/src/encryption/encryptHKDF.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { encryptHKDFWorker, decryptHKDFWorker } from "@lib/worker/bgWorker.ts"; -export declare const encryptHKDF: typeof encryptHKDFWorker; -export declare const decryptHKDF: typeof decryptHKDFWorker; diff --git a/_types/src/lib/src/encryption/stringEncryption.d.ts b/_types/src/lib/src/encryption/stringEncryption.d.ts deleted file mode 100644 index 1567676a..00000000 --- a/_types/src/lib/src/encryption/stringEncryption.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -/** - * Encrypts a string using a passphrase, unless the string is already encrypted. - * - * If the input string begins with `ENCRYPT_V2_PREFIX` or `HKDF_SALTED_ENCRYPTED_PREFIX`, - * we assume it is already encrypted and return it unchanged. - * Otherwise, we encrypt the string using an ephemeral salt and the provided passphrase. - * - * @param source - The plaintext string to encrypt, or an already encrypted string. - * @param passphrase - The passphrase used for encryption. - * @returns A promise resolving to the encrypted string, or the original string if it is already encrypted. - */ -export declare function encryptString(source: string, passphrase: string): Promise; -/** - * Decrypts an encrypted string using the provided passphrase. - * - * This function determines the encryption format by inspecting the string prefix, then applies - * the appropriate decryption method. It supports several encryption formats, including - * HKDF salted encryption and legacy formats (V1, V2, V3). If the format is not supported, - * an error is thrown. - * - * @param encrypted - The encrypted string to decrypt. - * @param passphrase - The passphrase used for decryption. - * @returns A promise resolving to the decrypted string. - * @throws {Error} If the encryption format is unsupported. - */ -export declare function decryptString(encrypted: string, passphrase: string): Promise; -export declare function tryDecryptString(encrypted: string, passphrase: string | false): Promise; diff --git a/_types/src/lib/src/events/coreEvents.d.ts b/_types/src/lib/src/events/coreEvents.d.ts deleted file mode 100644 index 2c2efb65..00000000 --- a/_types/src/lib/src/events/coreEvents.d.ts +++ /dev/null @@ -1,47 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { FilePathWithPrefix, ObsidianLiveSyncSettings } from "@lib/common/types"; -export declare const EVENT_LAYOUT_READY = "layout-ready"; -export declare const EVENT_PLUGIN_LOADED = "plugin-loaded"; -export declare const EVENT_PLUGIN_UNLOADED = "plugin-unloaded"; -export declare const EVENT_SETTING_SAVED = "setting-saved"; -export declare const EVENT_FILE_RENAMED = "file-renamed"; -export declare const EVENT_FILE_SAVED = "file-saved"; -export declare const EVENT_LEAF_ACTIVE_CHANGED = "leaf-active-changed"; -export declare const EVENT_DATABASE_REBUILT = "database-rebuilt"; -export declare const EVENT_LOG_ADDED = "log-added"; -export declare const EVENT_REQUEST_OPEN_SETUP_URI = "request-open-setup-uri"; -export declare const EVENT_REQUEST_COPY_SETUP_URI = "request-copy-setup-uri"; -export declare const EVENT_REQUEST_SHOW_SETUP_QR = "request-show-setup-qr"; -export declare const EVENT_REQUEST_RELOAD_SETTING_TAB = "reload-setting-tab"; -export declare const EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG = "request-open-plugin-sync-dialog"; -export declare const EVENT_FILE_CHANGED = "event-file-changed"; -export declare const EVENT_REQUEST_OPEN_P2P_SETTINGS = "request-open-p2p-settings"; -export declare const EVENT_REQUEST_OPEN_P2P = "request-open-p2p"; -export declare const EVENT_REQUEST_CLOSE_P2P = "request-close-p2p"; -export declare const EVENT_PLATFORM_UNLOADED = "platform-unloaded"; -export declare const EVENT_ON_UNRESOLVED_ERROR = "on-unresolved-error"; -export declare const EVENT_REQUEST_CHECK_REMOTE_SIZE = "request-check-remote-size"; -declare global { - interface LSEvents { - [EVENT_FILE_SAVED]: undefined; - [EVENT_SETTING_SAVED]: ObsidianLiveSyncSettings; - [EVENT_LAYOUT_READY]: undefined; - [EVENT_FILE_CHANGED]: { - file: FilePathWithPrefix; - automated: boolean; - }; - [EVENT_FILE_RENAMED]: { - newPath: FilePathWithPrefix; - old: FilePathWithPrefix; - }; - [EVENT_DATABASE_REBUILT]: undefined; - [EVENT_REQUEST_OPEN_P2P_SETTINGS]: undefined; - [EVENT_REQUEST_SHOW_SETUP_QR]: undefined; - [EVENT_REQUEST_OPEN_P2P]: undefined; - [EVENT_REQUEST_CLOSE_P2P]: undefined; - [EVENT_PLATFORM_UNLOADED]: undefined; - [EVENT_ON_UNRESOLVED_ERROR]: undefined; - [EVENT_REQUEST_CHECK_REMOTE_SIZE]: undefined; - } -} diff --git a/_types/src/lib/src/hub/hub.d.ts b/_types/src/lib/src/hub/hub.d.ts deleted file mode 100644 index 06529686..00000000 --- a/_types/src/lib/src/hub/hub.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { EventHub } from "octagonal-wheels/events"; -declare global { - interface LSEvents { - hello: string; - world: undefined; - } -} -export declare const eventHub: EventHub; diff --git a/_types/src/lib/src/index.d.ts b/_types/src/lib/src/index.d.ts deleted file mode 100644 index 90e53504..00000000 --- a/_types/src/lib/src/index.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export { DirectFileManipulator, type DirectFileManipulatorOptions } from "./API/DirectFileManipulator.ts"; diff --git a/_types/src/lib/src/interfaces/AsyncActivityRunner.d.ts b/_types/src/lib/src/interfaces/AsyncActivityRunner.d.ts deleted file mode 100644 index 397029f4..00000000 --- a/_types/src/lib/src/interfaces/AsyncActivityRunner.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -/** Options attached to one bounded asynchronous activity. */ -export interface AsyncActivityOptions { - /** An optional diagnostic label supplied to the activity owner. */ - label?: string; -} -/** - * Runs bounded asynchronous work inside a consumer-owned activity scope. - * - * The common library deliberately does not prescribe what the scope does. A - * browser host may keep the screen awake, while a headless host may omit the - * runner and execute the task directly. - */ -export interface AsyncActivityRunner { - /** Runs the task and returns its result without changing its error semantics. */ - run(task: () => T | PromiseLike, options?: AsyncActivityOptions): Promise; -} -/** Runs a task through the injected activity owner, or directly when none is supplied. */ -export declare function runWithOptionalActivity(runner: AsyncActivityRunner | undefined, task: () => T | PromiseLike, options?: AsyncActivityOptions): Promise; diff --git a/_types/src/lib/src/interfaces/Confirm.d.ts b/_types/src/lib/src/interfaces/Confirm.d.ts deleted file mode 100644 index 315cb661..00000000 --- a/_types/src/lib/src/interfaces/Confirm.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export interface Confirm { - askYesNo(message: string): Promise<"yes" | "no">; - askString(title: string, key: string, placeholder: string, isPassword?: boolean): Promise; - askYesNoDialog(message: string, opt: { - title?: string; - defaultOption?: "Yes" | "No"; - timeout?: number; - }): Promise<"yes" | "no">; - askSelectString(message: string, items: string[]): Promise; - askSelectStringDialogue(message: string, buttons: T, opt: { - title?: string; - defaultAction: T[number]; - timeout?: number; - }): Promise; - askInPopup(key: string, dialogText: string, anchorCallback: (anchor: HTMLAnchorElement) => void): void; - confirmWithMessage(title: string, contentMd: string, buttons: string[], defaultAction: (typeof buttons)[number], timeout?: number): Promise<(typeof buttons)[number] | false>; -} diff --git a/_types/src/lib/src/interfaces/DatabaseFileAccess.d.ts b/_types/src/lib/src/interfaces/DatabaseFileAccess.d.ts deleted file mode 100644 index 67bf1070..00000000 --- a/_types/src/lib/src/interfaces/DatabaseFileAccess.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { FilePathWithPrefix, LoadedEntry, MetaEntry, UXFileInfo, UXFileInfoStub } from "@lib/common/types"; -export interface DatabaseFileAccess { - delete: (file: UXFileInfoStub | FilePathWithPrefix, rev?: string) => Promise; - store: (file: UXFileInfo, force?: boolean, skipCheck?: boolean) => Promise; - storeAsConflictedRevision: (file: UXFileInfo, currentRev: string, skipCheck?: boolean) => Promise; - storeContent(path: FilePathWithPrefix, content: string): Promise; - createChunks: (file: UXFileInfo, force?: boolean, skipCheck?: boolean) => Promise; - hasContentInRevisionHistory: (file: UXFileInfoStub | FilePathWithPrefix, content: string | string[] | Blob | ArrayBuffer, currentRev?: string) => Promise; - fetch: (file: UXFileInfoStub | FilePathWithPrefix, rev?: string, waitForReady?: boolean, skipCheck?: boolean) => Promise; - fetchEntryFromMeta: (meta: MetaEntry, waitForReady?: boolean, skipCheck?: boolean) => Promise; - fetchEntryMeta: (file: UXFileInfoStub | FilePathWithPrefix, rev?: string, skipCheck?: boolean) => Promise; - fetchEntry: (file: UXFileInfoStub | FilePathWithPrefix, rev?: string, waitForReady?: boolean, skipCheck?: boolean) => Promise; - getConflictedRevs: (file: UXFileInfoStub | FilePathWithPrefix) => Promise; -} diff --git a/_types/src/lib/src/interfaces/DatabaseRebuilder.d.ts b/_types/src/lib/src/interfaces/DatabaseRebuilder.d.ts deleted file mode 100644 index dfbc2a5d..00000000 --- a/_types/src/lib/src/interfaces/DatabaseRebuilder.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export interface Rebuilder { - $performRebuildDB(method: "localOnly" | "remoteOnly" | "rebuildBothByThisDevice" | "localOnlyWithChunks"): Promise; - $rebuildRemote(): Promise; - $rebuildEverything(): Promise; - $fetchLocal(makeLocalChunkBeforeSync?: boolean, preventMakeLocalFilesBeforeSync?: boolean): Promise; - $fetchLocalDBFast(autoResume: boolean): Promise; - scheduleRebuild(): Promise; - scheduleFetch(): Promise; - /** - * Declares the finish of the rebuild process and unlock remote, resume reflecting the changes. - */ - finishRebuild(): Promise; -} diff --git a/_types/src/lib/src/interfaces/FileHandler.d.ts b/_types/src/lib/src/interfaces/FileHandler.d.ts deleted file mode 100644 index 74c1e516..00000000 --- a/_types/src/lib/src/interfaces/FileHandler.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { FilePath, FilePathWithPrefix, MetaEntry } from "@lib/common/models/db.type"; -import type { UXFileInfo, UXFileInfoStub, UXInternalFileInfoStub } from "@lib/common/models/fileaccess.type"; -export interface IFileHandler { - readFileFromStub(file: UXFileInfoStub | UXFileInfo): Promise; - storeFileToDB(info: UXFileInfoStub | UXFileInfo | UXInternalFileInfoStub | FilePathWithPrefix, force?: boolean, onlyChunks?: boolean): Promise; - deleteFileFromDB(info: UXFileInfoStub | UXInternalFileInfoStub | FilePath): Promise; - deleteRevisionFromDB(info: UXFileInfoStub | FilePath | FilePathWithPrefix, rev: string): Promise; - resolveConflictedByDeletingRevision(info: UXFileInfoStub | FilePath, rev: string): Promise; - dbToStorageWithSpecificRev(info: UXFileInfoStub | UXFileInfo | FilePath | null, rev: string, force?: boolean): Promise; - dbToStorage(entryInfo: MetaEntry | FilePathWithPrefix, info: UXFileInfoStub | UXFileInfo | FilePath | null, force?: boolean): Promise; - createAllChunks(showingNotice?: boolean): Promise; -} diff --git a/_types/src/lib/src/interfaces/KeyValueDatabase.d.ts b/_types/src/lib/src/interfaces/KeyValueDatabase.d.ts deleted file mode 100644 index 287bf3c0..00000000 --- a/_types/src/lib/src/interfaces/KeyValueDatabase.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export interface KeyValueDatabase { - get(key: IDBValidKey): Promise; - set(key: IDBValidKey, value: T): Promise; - del(key: IDBValidKey): Promise; - clear(): Promise; - keys(query?: IDBValidKey | IDBKeyRange, count?: number): Promise; - close(): Promise; - destroy(): Promise; -} diff --git a/_types/src/lib/src/interfaces/ServiceModule.d.ts b/_types/src/lib/src/interfaces/ServiceModule.d.ts deleted file mode 100644 index 7b9f4b9d..00000000 --- a/_types/src/lib/src/interfaces/ServiceModule.d.ts +++ /dev/null @@ -1,61 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { DatabaseFileAccess } from "@lib/interfaces/DatabaseFileAccess"; -import type { Rebuilder } from "@lib/interfaces/DatabaseRebuilder"; -import type { IFileHandler } from "@lib/interfaces/FileHandler"; -import type { StorageAccess } from "@lib/interfaces/StorageAccess"; -import type { LogFunction } from "@lib/services/lib/logUtils"; -import type { ServiceHub } from "@lib/services/ServiceHub"; -import type { IServiceHub } from "@lib/services/base/IService"; -export interface ServiceModules { - storageAccess: StorageAccess; - /** - * Database File Accessor for handling file operations related to the database, such as exporting the database, importing from a file, etc. - */ - databaseFileAccess: DatabaseFileAccess; - /** - * File Handler for handling file operations related to replication, such as resolving conflicts, applying changes from replication, etc. - */ - fileHandler: IFileHandler; - /** - * Rebuilder for handling database rebuilding operations. - */ - rebuilder: Rebuilder; -} -export type RequiredServices = Pick; -export type RequiredServiceModules = Pick; -export type RequiredServicesInterfaces = Pick; -export type RequiredServiceModulesInterfaces = Pick; -export type NecessaryServices = { - services: RequiredServices; - serviceModules: RequiredServiceModules; -}; -export type NecessaryServicesInterfaces = { - services: RequiredServicesInterfaces; - serviceModules: RequiredServiceModulesInterfaces; -}; -export type ServiceFeatureFunction = (host: NecessaryServices) => TR; -type ServiceFeatureContext = T & { - _log: LogFunction; -}; -export type ServiceFeatureFunctionWithContext = (host: NecessaryServices, context: ServiceFeatureContext) => TR; -/** - * Helper function to create a service feature with proper typing. - * @param featureFunction The feature function to be wrapped. - * @returns The same feature function with proper typing. - * @example - * const myFeatureDef = createServiceFeature(({ services: { API }, serviceModules: { storageAccess } }) => { - * // ... - * }); - * const myFeature = myFeatureDef.bind(null, this); // <- `this` may `ObsidianLiveSyncPlugin` or a custom context object - * appLifecycle.onLayoutReady(myFeature); - */ -export declare function createServiceFeature(featureFunction: ServiceFeatureFunction): ServiceFeatureFunction; -type ContextFactory = (host: NecessaryServices) => ServiceFeatureContext; -export declare function serviceFeature(): { - create(featureFunction: ServiceFeatureFunction): ServiceFeatureFunction; - withContext(ContextFactory: ContextFactory): { - create: (featureFunction: ServiceFeatureFunctionWithContext) => (host: NecessaryServices, context: ServiceFeatureContext) => TR; - }; -}; -export {}; diff --git a/_types/src/lib/src/interfaces/StorageAccess.d.ts b/_types/src/lib/src/interfaces/StorageAccess.d.ts deleted file mode 100644 index 2b127310..00000000 --- a/_types/src/lib/src/interfaces/StorageAccess.d.ts +++ /dev/null @@ -1,45 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { FilePath, FilePathWithPrefix, UXDataWriteOptions, UXFileInfo, UXFileInfoStub, UXFolderInfo, UXStat } from "@lib/common/types"; -import type { CustomRegExp } from "@lib/common/utils"; -import type { FileWithFileStat, FileWithStatAsProp } from "@lib/common/models/fileaccess.type"; -export interface IStorageAccessManager { - processWriteFile(file: UXFileInfoStub | FilePathWithPrefix, proc: () => Promise): Promise; - processReadFile(file: UXFileInfoStub | FilePathWithPrefix, proc: () => Promise): Promise; - isFileProcessing(file: UXFileInfoStub | FilePathWithPrefix): boolean; - recentlyTouched(file: FileWithStatAsProp | FileWithFileStat): boolean; - touch(file: FileWithStatAsProp | FileWithFileStat): void; - clearTouched(): void; -} -export interface StorageAccess { - normalisePath(path: string): string; - restoreState(): Promise; - deleteVaultItem(file: FilePathWithPrefix | UXFileInfoStub | UXFolderInfo): Promise; - writeFileAuto(path: string, data: string | ArrayBuffer, opt?: UXDataWriteOptions): Promise; - readFileAuto(path: string): Promise; - readFileText(path: string): Promise; - isExists(path: string): Promise; - writeHiddenFileAuto(path: string, data: string | ArrayBuffer, opt?: UXDataWriteOptions): Promise; - appendHiddenFile(path: string, data: string, opt?: UXDataWriteOptions): Promise; - stat(path: string): Promise; - statHidden(path: string): Promise; - removeHidden(path: string): Promise; - readHiddenFileAuto(path: string): Promise; - readHiddenFileBinary(path: string): Promise; - readHiddenFileText(path: string): Promise; - isExistsIncludeHidden(path: string): Promise; - ensureDir(path: string): Promise; - triggerFileEvent(event: string, path: string): void; - triggerHiddenFile(path: string): Promise; - getFileStub(path: string): Promise; - readStubContent(stub: UXFileInfoStub): Promise; - getStub(path: string): Promise; - getFiles(): Promise; - getFileNames(): Promise; - touched(file: UXFileInfoStub | FilePathWithPrefix): Promise; - recentlyTouched(file: UXFileInfoStub | FilePathWithPrefix): Promise; - clearTouched(): void; - delete(file: FilePathWithPrefix | UXFileInfoStub | string, force: boolean): Promise; - trash(file: FilePathWithPrefix | UXFileInfoStub | string, system: boolean): Promise; - getFilesIncludeHidden(basePath: string, includeFilter?: CustomRegExp[], excludeFilter?: CustomRegExp[], skipFolder?: string[]): Promise; -} diff --git a/_types/src/lib/src/interfaces/StorageEventManager.d.ts b/_types/src/lib/src/interfaces/StorageEventManager.d.ts deleted file mode 100644 index f828dfbf..00000000 --- a/_types/src/lib/src/interfaces/StorageEventManager.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { FileEventType, FilePath, UXFileInfoStub, UXInternalFileInfoStub } from "@lib/common/types"; -export type FileEvent = { - type: FileEventType; - file: UXFileInfoStub | UXInternalFileInfoStub; - oldPath?: string; - cachedData?: string; - skipBatchWait?: boolean; - cancelled?: boolean; -}; -export declare abstract class StorageEventManager { - abstract beginWatch(): Promise; - abstract appendQueue(items: FileEvent[], ctx?: unknown): Promise; - abstract isWaiting(filename: FilePath): boolean; - abstract waitForIdle(): Promise; - abstract restoreState(): Promise; -} diff --git a/_types/src/lib/src/managers/ChangeManager.d.ts b/_types/src/lib/src/managers/ChangeManager.d.ts deleted file mode 100644 index 86a3859b..00000000 --- a/_types/src/lib/src/managers/ChangeManager.d.ts +++ /dev/null @@ -1,63 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { FallbackWeakRef } from "octagonal-wheels/common/polyfill"; -/** - * Options for configuring the ChangeManager. - */ -export interface ChangeManagerOptions { - /** - * The PouchDB database instance to monitor for changes. - */ - database: PouchDB.Database; -} -export type ChangeManagerCallback = (change: PouchDB.Core.ChangesResponseChange) => void | Promise; -/** - * Manages and dispatches changes from a PouchDB database to registered callbacks. - * - * @template T The type of documents stored in the PouchDB database. - */ -export declare class ChangeManager { - /** - * The PouchDB database instance being monitored. - */ - _database: PouchDB.Database; - /** - * Creates a new instance of the ChangeManager. - * - * @param options - Configuration options for the ChangeManager. - */ - constructor(options: ChangeManagerOptions); - /** - * A list of registered callbacks wrapped in WeakRefs to avoid memory leaks. - */ - _callbacks: FallbackWeakRef>[]; - /** - * Registers a new callback to be invoked when a change occurs. - * - * @param callback - The callback function to register. - */ - addCallback(callback: ChangeManagerCallback): () => void; - removeCallback(callback: ChangeManagerCallback): void; - /** - * The PouchDB changes feed instance, if active. - */ - _changes?: PouchDB.Core.Changes; - /** - * Handles a change event from the PouchDB changes feed. - * - * @param changeResponse - The change response object from the PouchDB changes feed. - */ - _onChange(changeResponse: PouchDB.Core.ChangesResponseChange): Promise; - /** - * Sets up the PouchDB changes feed listener to monitor for database changes. - */ - setupListener(): void; - /** - * Tears down the PouchDB changes feed listener and cleans up resources. - */ - teardown(): void; - /** - * Restarts the PouchDB changes feed listener. - */ - restartWatch(): void; -} diff --git a/_types/src/lib/src/managers/ChunkFetcher.d.ts b/_types/src/lib/src/managers/ChunkFetcher.d.ts deleted file mode 100644 index 35113740..00000000 --- a/_types/src/lib/src/managers/ChunkFetcher.d.ts +++ /dev/null @@ -1,37 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type DocumentID } from "@lib/common/types.ts"; -import { type ChunkManager } from "./ChunkManager.ts"; -import type { IReplicatorService, ISettingService } from "@lib/services/base/IService.ts"; -export declare const EVENT_MISSING_CHUNKS = "missingChunks"; -export declare const EVENT_MISSING_CHUNK_REMOTE = "missingChunkRemote"; -export declare const EVENT_CHUNK_FETCHED = "chunkFetched"; -export type ChunkFetcherOptions = { - settingService: ISettingService; - chunkManager: ChunkManager; - replicatorService: IReplicatorService; -}; -export declare class ChunkFetcher { - options: ChunkFetcherOptions; - get chunkManager(): ChunkManager; - queue: DocumentID[]; - get interval(): number; - get concurrency(): number; - abort: AbortController; - constructor(options: ChunkFetcherOptions); - destroy(): void; - onEventHandler: (ids: DocumentID[]) => void; - onEvent(ids: DocumentID[]): void; - /** - * Processing requests - */ - currentProcessing: number; - /** - * Time of the last request to the remote server. - * This is used to manage the interval between requests. - * Even if concurrency allows, every start of a request will ensure that the interval is respected. - */ - previousRequestTime: number; - canRequestMore(): boolean; - requestMissingChunks(): Promise; -} diff --git a/_types/src/lib/src/managers/ChunkManager.d.ts b/_types/src/lib/src/managers/ChunkManager.d.ts deleted file mode 100644 index 12215261..00000000 --- a/_types/src/lib/src/managers/ChunkManager.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { LayeredChunkManager } from "./LayeredChunkManager"; -export { LayeredChunkManager as ChunkManager }; diff --git a/_types/src/lib/src/managers/ConflictManager.d.ts b/_types/src/lib/src/managers/ConflictManager.d.ts deleted file mode 100644 index efe17528..00000000 --- a/_types/src/lib/src/managers/ConflictManager.d.ts +++ /dev/null @@ -1,39 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type Diff } from "diff-match-patch"; -import { type EntryDoc, type FilePathWithPrefix, type diff_result_leaf, type LoadedEntry, type DIFF_CHECK_RESULT_AUTO } from "@lib/common/types.ts"; -import type { EntryManager } from "@lib/managers/EntryManager/EntryManager.ts"; -import type { IPathService } from "@lib/services/base/IService.ts"; -type AutoMergeOutcomeOK = { - ok: DIFF_CHECK_RESULT_AUTO; -}; -type AutoMergeCanBeDoneByDeletingRev = { - result: string; - conflictedRev: string; -}; -type UserActionRequired = { - leftRev: string; - rightRev: string; - leftLeaf: diff_result_leaf | false; - rightLeaf: diff_result_leaf | false; -}; -export type AutoMergeResult = Promise; -export interface ConflictManagerOptions { - entryManager: EntryManager; - pathService: IPathService; - database: PouchDB.Database; -} -export declare class ConflictManager { - options: ConflictManagerOptions; - constructor(options: ConflictManagerOptions); - get database(): PouchDB.Database; - getConflictedDoc(path: FilePathWithPrefix, rev: string): Promise; - mergeSensibly(path: FilePathWithPrefix, baseRev: string, currentRev: string, conflictedRev: string): Promise; - mergeObject(path: FilePathWithPrefix, baseRev: string, currentRev: string, conflictedRev: string): Promise; - tryAutoMergeSensibly(path: FilePathWithPrefix, test: LoadedEntry, conflicts: string[]): Promise; - tryAutoMerge(path: FilePathWithPrefix, enableMarkdownAutoMerge: boolean): AutoMergeResult; -} -export {}; diff --git a/_types/src/lib/src/managers/EntryManager/EntryManager.d.ts b/_types/src/lib/src/managers/EntryManager/EntryManager.d.ts deleted file mode 100644 index 53fa9c9a..00000000 --- a/_types/src/lib/src/managers/EntryManager/EntryManager.d.ts +++ /dev/null @@ -1,39 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type FilePathWithPrefix, type FilePath, type LoadedEntry, type EntryDoc, type SavingEntry, type MetaEntry } from "@lib/common/types"; -import type { ChunkManager } from "@lib/managers/ChunkManager"; -import type { ContentSplitter } from "@lib/ContentSplitter/ContentSplitters"; -import type { HashManager } from "@lib/managers/HashManager/HashManager"; -import type { GeneratedChunk } from "@lib/pouchdb/LiveSyncLocalDB"; -import type { IPathService, ISettingService } from "@lib/services/base/IService"; -export interface EntryManagerOptions { - hashManager: HashManager; - chunkManager: ChunkManager; - splitter: ContentSplitter; - database: PouchDB.Database; - settingService: ISettingService; - pathService: IPathService; -} -export declare class EntryManager { - options: EntryManagerOptions; - constructor(options: EntryManagerOptions); - get localDatabase(): PouchDB.Database; - get hashManager(): HashManager; - get chunkManager(): ChunkManager; - get splitter(): ContentSplitter; - get serviceHost(): { - services: { - setting: ISettingService; - path: IPathService; - }; - serviceModules: {}; // eslint-disable-line @typescript-eslint/no-empty-object-type, @typescript-eslint/ban-types -- Empty object type - }; - get isOnDemandChunkEnabled(): boolean; - isTargetFile(filenameSrc: string): boolean; - prepareChunk(piece: string): Promise; - getDBEntryMeta(path: FilePathWithPrefix | FilePath, opt?: PouchDB.Core.GetOptions, includeDeleted?: boolean): Promise; - getDBEntry(path: FilePathWithPrefix | FilePath, opt?: PouchDB.Core.GetOptions, dump?: boolean, waitForReady?: boolean, includeDeleted?: boolean): Promise; - getDBEntryFromMeta(meta: LoadedEntry | MetaEntry, dump?: boolean, waitForReady?: boolean): Promise; - deleteDBEntry(path: FilePathWithPrefix | FilePath, opt?: PouchDB.Core.GetOptions): Promise; - putDBEntry(note: SavingEntry, onlyChunks?: boolean, conflictBaseRev?: string): Promise; -} diff --git a/_types/src/lib/src/managers/EntryManager/EntryManagerImpls.d.ts b/_types/src/lib/src/managers/EntryManager/EntryManagerImpls.d.ts deleted file mode 100644 index 68819994..00000000 --- a/_types/src/lib/src/managers/EntryManager/EntryManagerImpls.d.ts +++ /dev/null @@ -1,81 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type SavingEntry, type DocumentID, type EntryDoc, type EntryBase, type FilePath, type FilePathWithPrefix, type LoadedEntry, type ObsidianLiveSyncSettings, type MetaEntry } from "@lib/common/types"; -import type { ContentSplitter } from "@lib/ContentSplitter/ContentSplitters"; -import type { HashManager } from "@lib/managers/HashManager/HashManager"; -import type { LayeredChunkManager as ChunkManager } from "@lib/managers/LayeredChunkManager"; -import type { NecessaryServicesInterfaces } from "@lib/interfaces/ServiceModule"; -import type { GeneratedChunk } from "@lib/pouchdb/LiveSyncLocalDB"; -type Managers = { - hashManager: HashManager; - chunkManager: ChunkManager; - splitter: ContentSplitter; - localDatabase: PouchDB.Database; -}; -type NecessaryManagers = Pick; -export declare function createChunks(managers: NecessaryManagers<"chunkManager" | "hashManager" | "splitter">, dispFilename: string, note: SavingEntry): Promise; -export declare function putDBEntry(host: NecessaryServicesInterfaces<"path" | "setting", never>, managers: NecessaryManagers<"localDatabase" | "chunkManager" | "hashManager" | "splitter">, note: SavingEntry, onlyChunks?: boolean, conflictBaseRev?: string): Promise; -export declare function isTargetFile(host: NecessaryServicesInterfaces<"setting", never>, filenameSrc: string): boolean; -export declare function prepareChunk({ chunkManager, hashManager }: NecessaryManagers<"chunkManager" | "hashManager">, piece: string): Promise; -export declare function getDBEntryMetaByPath(host: NecessaryServicesInterfaces<"path" | "setting", never>, { localDatabase }: NecessaryManagers<"localDatabase">, path: FilePathWithPrefix | FilePath, opt?: PouchDB.Core.GetOptions, includeDeleted?: boolean): Promise; -export declare function isLegacyNote(meta: LoadedEntry | MetaEntry): meta is (import("@lib/common/types").DatabaseEntry & EntryBase & import("@lib/common/types").EntryWithEden & { - path: FilePathWithPrefix; - data: string | string[]; - type: import("../../common/models/db.type").EntryTypes["NOTE_LEGACY"]; -} & { - data: string | string[]; - datatype: import("@lib/common/types").EntryTypeNotes; -}) | (import("@lib/common/types").DatabaseEntry & EntryBase & import("@lib/common/types").EntryWithEden & { - path: FilePathWithPrefix; - data: string | string[]; - type: import("../../common/models/db.type").EntryTypes["NOTE_LEGACY"]; -} & { - children: string[]; -}); -export declare function canUseOnDemandChunking(settings: ObsidianLiveSyncSettings): boolean; -export declare function getDBEntryFromMeta(host: NecessaryServicesInterfaces<"path" | "setting", never>, { localDatabase, chunkManager }: NecessaryManagers<"localDatabase" | "chunkManager">, meta: LoadedEntry | MetaEntry, dump?: boolean, waitForReady?: boolean): Promise; -export declare function getDBEntryByPath(host: NecessaryServicesInterfaces<"path" | "setting", never>, managers: NecessaryManagers<"localDatabase" | "chunkManager">, path: FilePathWithPrefix | FilePath, opt?: PouchDB.Core.GetOptions, dump?: boolean, waitForReady?: boolean, includeDeleted?: boolean): Promise; -export declare function deleteDBEntryByPath(host: NecessaryServicesInterfaces<"path" | "setting", never>, { localDatabase }: NecessaryManagers<"localDatabase">, path: FilePathWithPrefix | FilePath, opt?: PouchDB.Core.GetOptions): Promise; -export {}; diff --git a/_types/src/lib/src/managers/HashManager/HashManager.d.ts b/_types/src/lib/src/managers/HashManager/HashManager.d.ts deleted file mode 100644 index b6cabd1c..00000000 --- a/_types/src/lib/src/managers/HashManager/HashManager.d.ts +++ /dev/null @@ -1,62 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { HashAlgorithm } from "@lib/common/models/setting.type.ts"; -import { HashManagerCore, type HashManagerCoreOptions } from "./HashManagerCore.ts"; -/** - * Class for managing hash managers and performing hash calculations. - * Selects an appropriate manager according to the available hash algorithm. - */ -export declare class HashManager extends HashManagerCore { - /** - * Instance of the hash manager currently in use. - */ - manager: HashManagerCore; - /** - * Checks whether the specified hash algorithm is available. - * - * @param hashAlg The hash algorithm to check - * @returns True if available - */ - static isAvailableFor(hashAlg: HashAlgorithm): boolean; - /** - * Selects and initialises an available hash manager. - * - * @returns True if initialisation is successful - * @throws Throws an error if no available manager exists - */ - setManager(): Promise; - /** - * Constructs a new HashManager. - * - * @param options Initialisation options - */ - constructor(options: HashManagerCoreOptions); - /** - * Initialises the hash manager. - * - * @returns True if initialisation is successful - * @throws Throws an error if initialisation fails - */ - processInitialise(): Promise; - /** - * Computes the hash value for the specified string. - * - * @param piece The string to be hashed - * @returns The hash value (returned as a Promise) - */ - computeHash(piece: string): Promise; - /** - * Computes the hash value without encryption. - * - * @param piece The string to be hashed - * @returns The hash value (returned as a Promise) - */ - computeHashWithoutEncryption(piece: string): Promise; - /** - * Computes the hash value with encryption. - * - * @param piece The string to be hashed - * @returns The hash value (returned as a Promise) - */ - computeHashWithEncryption(piece: string): Promise; -} diff --git a/_types/src/lib/src/managers/HashManager/HashManagerCore.d.ts b/_types/src/lib/src/managers/HashManager/HashManagerCore.d.ts deleted file mode 100644 index a303a856..00000000 --- a/_types/src/lib/src/managers/HashManager/HashManagerCore.d.ts +++ /dev/null @@ -1,116 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ISettingService } from "@lib/services/base/IService.ts"; -import type { HashAlgorithm } from "@lib/common/models/setting.type.ts"; -/** - * Prefix for encrypted hashes. - * - * This constant is prepended to hash strings when encryption is enabled. - */ -export declare const HashEncryptedPrefix = "+"; -/** - * Options for initialising {@link HashManagerCore}. - */ -export type HashManagerCoreOptions = { - /** - * Remote database settings used for hash management. - */ - settingService: ISettingService; -}; -/** - * Abstract base class for hash management. - * - * Provides core logic for handling passphrase hashing, encryption toggling, - * and initialisation routines. Subclasses should implement encryption-specific - * hash computation methods. - */ -export declare abstract class HashManagerCore { - protected settingService: ISettingService; - /** - * Indicates whether encryption is enabled for hash computation. - */ - useEncryption: boolean; - /** - * Hashed passphrase as a string, used for hash operations. - */ - hashedPassphrase: string; - /** - * Hashed passphrase as a 32-bit number, used for hash operations. - */ - hashedPassphrase32: number; - /** - * Options used for initialisation and configuration. - */ - options: HashManagerCoreOptions; - /** - * Constructs a new {@link HashManagerCore} instance. - * - * @param options - Configuration options for hash management. - */ - constructor(options: HashManagerCoreOptions); - /** - * Applies the given options to the hash manager. - * - * Updates encryption settings and computes passphrase hashes. - * - * @param options - Optional configuration to apply. - */ - applyOptions(options?: HashManagerCoreOptions): void; - /** - * Performs initialisation logic specific to the hash manager implementation. - * - * Subclasses must implement this method. - * - * @returns Promise resolving to true if initialisation succeeds. - */ - abstract processInitialise(): Promise; - /** - * Task representing the initialisation process. - */ - initialiseTask?: Promise; - /** - * Ensures the hash manager is initialised. - * - * Returns a promise that resolves when initialisation is complete. - * - * @returns Promise resolving to true if initialisation succeeds. - */ - initialise(): Promise; - /** - * Computes a hash for the given string. - * - * If encryption is enabled, the hash is computed with encryption and prefixed. - * Otherwise, a plain hash is computed. - * - * @param piece - The input string to hash. - * @returns Promise resolving to the computed hash string. - */ - computeHash(piece: string): Promise; - /** - * Computes a hash for the given string without encryption. - * - * Subclasses must implement this method. - * - * @param piece - The input string to hash. - * @returns Promise resolving to the computed hash string. - */ - abstract computeHashWithoutEncryption(piece: string): Promise; - /** - * Computes a hash for the given string with encryption. - * - * Subclasses must implement this method. - * - * @param piece - The input string to hash. - * @returns Promise resolving to the computed encrypted hash string. - */ - abstract computeHashWithEncryption(piece: string): Promise; - /** - * Determines whether the hash manager is available for the specified algorithm. - * - * Subclasses should override this method to indicate supported algorithms. - * - * @param hashAlg - The hash algorithm to check. - * @returns True if available, false otherwise. - */ - static isAvailableFor(hashAlg: HashAlgorithm): boolean; -} diff --git a/_types/src/lib/src/managers/HashManager/PureJSHashManager.d.ts b/_types/src/lib/src/managers/HashManager/PureJSHashManager.d.ts deleted file mode 100644 index e67906f0..00000000 --- a/_types/src/lib/src/managers/HashManager/PureJSHashManager.d.ts +++ /dev/null @@ -1,79 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { HashManagerCore } from "./HashManagerCore.ts"; -import type { HashAlgorithm } from "@lib/common/models/setting.type.ts"; -/** - * Provides hash management using the "mixed-purejs" algorithm. - * - * This manager utilises a pure JavaScript implementation for hashing. - * It is available only when the hash algorithm is set to "mixed-purejs". - */ -export declare class PureJSHashManager extends HashManagerCore { - /** - * Determines whether this manager is available for the specified algorithm. - * @param hashAlg The hash algorithm to check. - * @returns True if the algorithm is "mixed-purejs". - */ - static isAvailableFor(hashAlg: HashAlgorithm): boolean; - /** - * Initialises the hash manager. - * @returns Always resolves to true. - */ - processInitialise(): Promise; - /** - * Computes a hash for the given input, including encryption. - * @param input The input string to hash. - * @returns The computed hash as a promise. - */ - computeHashWithEncryption(input: string): Promise; - /** - * Computes a hash for the given input, without encryption. - * @param input The input string to hash. - * @returns The computed hash as a promise. - */ - computeHashWithoutEncryption(input: string): Promise; -} -/** - * Provides hash management using the "sha1" algorithm. - * - * This manager utilises a pure JavaScript SHA-1 implementation. - * It is available only when the hash algorithm is set to "sha1". - */ -export declare class SHA1HashManager extends HashManagerCore { - /** - * Determines whether this manager is available for the specified algorithm. - * @param hashAlg The hash algorithm to check. - * @returns True if the algorithm is "sha1". - */ - static isAvailableFor(hashAlg: HashAlgorithm): boolean; - /** - * Initialises the hash manager. - * @returns Always resolves to true. - */ - processInitialise(): Promise; - /** - * Computes a SHA-1 hash for the given input, including encryption. - * @param input The input string to hash. - * @returns The computed SHA-1 hash as a promise. - */ - computeHashWithEncryption(input: string): Promise; - /** - * Computes a SHA-1 hash for the given input, without encryption. - * @param input The input string to hash. - * @returns The computed SHA-1 hash as a promise. - */ - computeHashWithoutEncryption(input: string): Promise; -} -/** - * Fallback hash manager using the pure JavaScript implementation. - * - * This manager is always available and acts as a fallback when no specific algorithm matches. - */ -export declare class FallbackPureJSHashManager extends PureJSHashManager { - /** - * Always returns true, indicating this manager is available for any algorithm. - * @param _hashAlg The hash algorithm (ignored). - * @returns True. - */ - static isAvailableFor(_hashAlg: HashAlgorithm): boolean; -} diff --git a/_types/src/lib/src/managers/HashManager/XXHashHashManager.d.ts b/_types/src/lib/src/managers/HashManager/XXHashHashManager.d.ts deleted file mode 100644 index 65c093bd..00000000 --- a/_types/src/lib/src/managers/HashManager/XXHashHashManager.d.ts +++ /dev/null @@ -1,98 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { HashManagerCore, type HashManagerCoreOptions } from "./HashManagerCore.ts"; -import type { XXHashAPI } from "xxhash-wasm-102"; -import type { HashAlgorithm } from "@lib/common/models/setting.type.ts"; -/** - * Abstract base class for hash managers using XXHash algorithms. - * Provides initialisation and common properties for XXHash-based managers. - */ -export declare abstract class XXHashHashManager extends HashManagerCore { - /** - * Instance of XXHash API used for hashing operations. - */ - xxhash: XXHashAPI; - /** - * Constructs a new XXHashHashManager. - * @param options - Options for the hash manager core. - */ - constructor(options: HashManagerCoreOptions); - /** - * Initialises the XXHash API instance. - * @returns A promise resolving to true when initialisation is complete. - */ - processInitialise(): Promise; -} -/** - * Hash manager for the legacy hash algorithm (empty string). - * Utilises XXHash32 raw hashing. - */ -export declare class XXHash32RawHashManager extends XXHashHashManager { - /** - * Determines whether this manager is available for the specified algorithm. - * @param hashAlg - The hash algorithm to check. - * @returns True if available, false otherwise. - */ - static isAvailableFor(hashAlg: HashAlgorithm): boolean; - /** - * Computes a hash for the given piece using encryption. - * @param piece - The input string to hash. - * @returns A promise resolving to the hash string. - */ - computeHashWithEncryption(piece: string): Promise; - /** - * Computes a hash for the given piece without encryption. - * @param piece - The input string to hash. - * @returns A promise resolving to the hash string. - */ - computeHashWithoutEncryption(piece: string): Promise; -} -/** - * Hash manager for the XXHash64 algorithm ("xxhash64"). - */ -export declare class XXHash64HashManager extends XXHashHashManager { - /** - * Determines whether this manager is available for the specified algorithm. - * @param hashAlg - The hash algorithm to check. - * @returns True if available, false otherwise. - */ - static isAvailableFor(hashAlg: HashAlgorithm): boolean; - /** - * Computes a hash for the given piece using encryption. - * @param piece - The input string to hash. - * @returns A promise resolving to the hash string. - */ - computeHashWithEncryption(piece: string): Promise; - /** - * Computes a hash for the given piece without encryption. - * @param piece - The input string to hash. - * @returns A promise resolving to the hash string. - */ - computeHashWithoutEncryption(piece: string): Promise; -} -/** - * Fallback hash manager utilising XXHash32. - * Used when no specific algorithm is matched. - * Please be careful with this manager, as it is different from XXHash32RawHashManager. - */ -export declare class FallbackWasmHashManager extends XXHashHashManager { - /** - * Determines whether this manager is available for the specified algorithm. - * Always returns true as a fallback. - * @param hashAlg - The hash algorithm to check. - * @returns True. - */ - static isAvailableFor(hashAlg: HashAlgorithm): boolean; - /** - * Computes a hash for the given piece using encryption. - * @param piece - The input string to hash. - * @returns A promise resolving to the hash string. - */ - computeHashWithEncryption(piece: string): Promise; - /** - * Computes a hash for the given piece without encryption. - * @param piece - The input string to hash. - * @returns A promise resolving to the hash string. - */ - computeHashWithoutEncryption(piece: string): Promise; -} diff --git a/_types/src/lib/src/managers/LayeredChunkManager.d.ts b/_types/src/lib/src/managers/LayeredChunkManager.d.ts deleted file mode 100644 index 090eaf58..00000000 --- a/_types/src/lib/src/managers/LayeredChunkManager.d.ts +++ /dev/null @@ -1,52 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { DocumentID, EntryDoc, EntryLeaf } from "@lib/common/types.ts"; -import type { ChangeManager } from "@lib/managers/ChangeManager.ts"; -import type { ChunkManagerEventMap, ChunkManagerOptions, ChunkReadOptions, ChunkWriteOptions, WriteResult } from "./LayeredChunkManager/types.ts"; -/** - * ChunkManager class that manages chunk operations such as reading, writing, and caching. - * Now uses a middleware layer architecture for read and write operations. - */ -export declare class LayeredChunkManager { - protected options: ChunkManagerOptions; - protected eventTarget: EventTarget; - private cacheLayer; - private readLayers; - private writeLayers; - private arrivalWaitLayer; - get changeManager(): ChangeManager; - get database(): PouchDB.Database; - get cacheStatistics(): { - size: number; - allocCount: number; - derefCount: number; - }; - addListener(type: K, listener: (this: LayeredChunkManager, ev: ChunkManagerEventMap[K]) => void, options?: boolean | AddEventListenerOptions): () => void; - emitEvent(type: K, detail: ChunkManagerEventMap[K]): void; - protected abort: AbortController; - protected offChangeHandler: ReturnType; - protected initialised: Promise; - _initialise(): Promise; - constructor(options: ChunkManagerOptions); - destroy(): void; - getCachedChunk(id: DocumentID): EntryLeaf | false; - getChunkIDFromCache(data: string): DocumentID | false; - cacheChunk(chunk: EntryLeaf): void; - clearCaches(): void; - read(ids: DocumentID[], options: ChunkReadOptions, preloadedChunks?: Record): Promise<(EntryLeaf | false)[]>; - private executeReadPipeline; - write(chunks: EntryLeaf[], options: ChunkWriteOptions, origin: DocumentID): Promise; - private executeWritePipeline; - private isChunkDoc; - private onChunkArrived; - protected onChunkArrivedHandler: (doc: EntryLeaf, deleted?: boolean) => void; - private onChange; - protected onChangeHandler: (change: PouchDB.Core.ChangesResponseChange) => void; - onMissingChunkRemote(id: DocumentID): void; - protected onMissingChunkRemoteHandler: (id: DocumentID) => void; - protected concurrentTransactions: number; - protected stabilised: Promise; - transaction(callback: () => Promise): Promise; - _stabilise(): Promise; - __stabilise(): Promise; -} diff --git a/_types/src/lib/src/managers/LayeredChunkManager/ArrivalWaitLayer.d.ts b/_types/src/lib/src/managers/LayeredChunkManager/ArrivalWaitLayer.d.ts deleted file mode 100644 index 10c47722..00000000 --- a/_types/src/lib/src/managers/LayeredChunkManager/ArrivalWaitLayer.d.ts +++ /dev/null @@ -1,34 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { DocumentID, EntryLeaf } from "@lib/common/types"; -import type { IReadLayer } from "./ChunkLayerInterfaces"; -import type { ChunkReadOptions } from "./types.ts"; -/** - * Arrival wait layer - emits events for fetcher, and waits for chunks to arrive - */ -export declare class ArrivalWaitLayer implements IReadLayer { - private waitingMap; - private readonly DEFAULT_TIMEOUT; - private readonly eventEmitter; - constructor(eventEmitter: (eventName: string, data: DocumentID[]) => void); - private enqueueWaiting; - private withTimeout; - /** - * Handle chunk arrival (called when a chunk document arrives) - */ - onChunkArrived(doc: EntryLeaf, deleted?: boolean): void; - /** - * Handle missing chunk (called when a chunk is confirmed missing) - */ - onMissingChunk(id: DocumentID): void; - read(ids: DocumentID[], options: ChunkReadOptions, next: (remaining: DocumentID[]) => Promise<(EntryLeaf | false)[]>): Promise<(EntryLeaf | false)[]>; - /** - * Clear all waiting requests - */ - clearWaiting(): void; - tearDown(): void; - /** - * Get count of waiting chunks - */ - getWaitingCount(): number; -} diff --git a/_types/src/lib/src/managers/LayeredChunkManager/CacheLayer.d.ts b/_types/src/lib/src/managers/LayeredChunkManager/CacheLayer.d.ts deleted file mode 100644 index b1094e24..00000000 --- a/_types/src/lib/src/managers/LayeredChunkManager/CacheLayer.d.ts +++ /dev/null @@ -1,61 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { DocumentID, EntryLeaf } from "@lib/common/types"; -import type { IReadLayer, IWriteLayer } from "./ChunkLayerInterfaces"; -import type { ChunkReadOptions, ChunkWriteOptions, WriteResult } from "./types.ts"; -/** - * Cache layer - manages in-memory cache of chunks. - * Implements both IReadLayer and IWriteLayer for unified cache management. - * This layer is self-contained and handles cache operations for both read and write operations. - */ -export declare class CacheLayer implements IReadLayer, IWriteLayer { - private caches; - private maxCacheSize; - allocCount: number; - derefCount: number; - constructor(maxCacheSize: number); - /** - * Get a cached chunk - */ - getCachedChunk(id: DocumentID): EntryLeaf | false; - /** - * Find chunk ID by data content - */ - getChunkIDFromCache(data: string): DocumentID | false; - /** - * Cache a chunk - */ - cacheChunk(chunk: EntryLeaf): void; - /** - * Reorder chunk for LRU (move to end) - */ - reorderChunk(id: DocumentID): void; - /** - * Delete a cached chunk - */ - deleteCachedChunk(id: DocumentID): void; - /** - * Clear all caches - */ - clearCaches(): void; - /** - * Tear down the layer (clear caches on shutdown) - */ - tearDown(): void; - /** - * Get current cache statistics - */ - getStatistics(): { - size: number; - allocCount: number; - derefCount: number; - }; - /** - * IReadLayer implementation - read from cache - */ - read(ids: DocumentID[], options: ChunkReadOptions, next: (remaining: DocumentID[]) => Promise<(EntryLeaf | false)[]>): Promise<(EntryLeaf | false)[]>; - /** - * IWriteLayer implementation - cache chunks after database write - */ - write(chunks: EntryLeaf[], options: ChunkWriteOptions, origin: DocumentID, next: (remaining: EntryLeaf[]) => Promise): Promise; -} diff --git a/_types/src/lib/src/managers/LayeredChunkManager/ChunkLayerInterfaces.d.ts b/_types/src/lib/src/managers/LayeredChunkManager/ChunkLayerInterfaces.d.ts deleted file mode 100644 index 2c2a14b3..00000000 --- a/_types/src/lib/src/managers/LayeredChunkManager/ChunkLayerInterfaces.d.ts +++ /dev/null @@ -1,39 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { DocumentID, EntryLeaf } from "@lib/common/types.ts"; -import type { ChunkReadOptions, ChunkWriteOptions, WriteResult } from "./types.ts"; -/** - * Interface for read layers in the chunk reading pipeline. - * Each layer processes a chunk read request and passes it to the next layer. - */ -export interface IReadLayer { - /** - * Process a read request for the given chunk IDs. - * The next layer in the pipeline should be called to continue processing. - * - * @param ids - The chunk IDs to read - * @param options - Read options - * @param next - The next layer to process the remaining IDs - * @returns A promise that resolves to an array of chunks or false values - */ - read(ids: DocumentID[], options: ChunkReadOptions, next: (remaining: DocumentID[]) => Promise<(EntryLeaf | false)[]>): Promise<(EntryLeaf | false)[]>; - tearDown?(): void; -} -/** - * Interface for write layers in the chunk writing pipeline. - * Each layer processes chunk write requests and passes them to the next layer. - */ -export interface IWriteLayer { - /** - * Process a write request for the given chunks. - * The next layer in the pipeline should be called to continue processing. - * - * @param chunks - The chunks to write - * @param options - Write options - * @param origin - The origin of the write request - * @param next - The next layer to process the remaining chunks - * @returns A promise that resolves to the write result - */ - write(chunks: EntryLeaf[], options: ChunkWriteOptions, origin: DocumentID, next: (remaining: EntryLeaf[]) => Promise): Promise; - tearDown?(): void; -} diff --git a/_types/src/lib/src/managers/LayeredChunkManager/DatabaseReadLayer.d.ts b/_types/src/lib/src/managers/LayeredChunkManager/DatabaseReadLayer.d.ts deleted file mode 100644 index 311b46da..00000000 --- a/_types/src/lib/src/managers/LayeredChunkManager/DatabaseReadLayer.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { EntryLeaf, DocumentID, EntryDoc } from "@lib/common/types"; -import type { IReadLayer } from "./ChunkLayerInterfaces"; -import type { ChunkReadOptions } from "./types.ts"; -/** - * Database read layer - reads chunks from the database - */ -export declare class DatabaseReadLayer implements IReadLayer { - private database; - constructor(database: PouchDB.Database); - private isChunkDoc; - private getError; - private isMissingError; - read(ids: DocumentID[], options: ChunkReadOptions, next: (remaining: DocumentID[]) => Promise<(EntryLeaf | false)[]>): Promise<(EntryLeaf | false)[]>; -} diff --git a/_types/src/lib/src/managers/LayeredChunkManager/DatabaseWriteLayer.d.ts b/_types/src/lib/src/managers/LayeredChunkManager/DatabaseWriteLayer.d.ts deleted file mode 100644 index 990f4956..00000000 --- a/_types/src/lib/src/managers/LayeredChunkManager/DatabaseWriteLayer.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { EntryLeaf, DocumentID, EntryDoc } from "@lib/common/types"; -import type { IWriteLayer } from "./ChunkLayerInterfaces"; -import type { ChunkWriteOptions, WriteResult } from "./types.ts"; -/** - * Database write layer - writes chunks to the database - */ -export declare class DatabaseWriteLayer implements IWriteLayer { - private database; - constructor(database: PouchDB.Database); - write(chunks: EntryLeaf[], options: ChunkWriteOptions | undefined, origin: DocumentID, next: (remaining: EntryLeaf[]) => Promise): Promise; -} diff --git a/_types/src/lib/src/managers/LayeredChunkManager/HotPackLayer.d.ts b/_types/src/lib/src/managers/LayeredChunkManager/HotPackLayer.d.ts deleted file mode 100644 index 09b3791b..00000000 --- a/_types/src/lib/src/managers/LayeredChunkManager/HotPackLayer.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { EntryLeaf, DocumentID } from "@lib/common/types"; -import type { IWriteLayer } from "./ChunkLayerInterfaces"; -import type { ChunkWriteOptions, WriteResult } from "./types.ts"; -/** - * Hot pack layer - placeholder for hot pack processing - */ -export declare class HotPackLayer implements IWriteLayer { - write(chunks: EntryLeaf[], options: ChunkWriteOptions, origin: DocumentID, next: (remaining: EntryLeaf[]) => Promise): Promise; -} diff --git a/_types/src/lib/src/managers/LayeredChunkManager/types.d.ts b/_types/src/lib/src/managers/LayeredChunkManager/types.d.ts deleted file mode 100644 index fc2d3901..00000000 --- a/_types/src/lib/src/managers/LayeredChunkManager/types.d.ts +++ /dev/null @@ -1,35 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { EntryDoc } from "@lib/common/models/db.definition"; -import type { DocumentID, EntryLeaf } from "@lib/common/models/db.type"; -import type { ISettingService } from "@lib/services/base/IService"; -import type { ChangeManager } from "@lib/managers/ChangeManager"; -import type { EVENT_CHUNK_FETCHED, EVENT_MISSING_CHUNK_REMOTE, EVENT_MISSING_CHUNKS } from "@lib/managers/ChunkFetcher"; -export type ChunkManagerOptions = { - database: PouchDB.Database; - changeManager: ChangeManager; - settingService: ISettingService; -}; -export type ChunkReadOptions = { - skipCache?: boolean; - timeout?: number; - preventRemoteRequest?: boolean; -}; -export type ChunkWriteOptions = { - skipCache?: boolean; - force?: boolean; -}; -export type WriteResult = { - result: boolean; - processed: { - cached: number; - hotPack: number; - written: number; - duplicated: number; - }; -}; -export type ChunkManagerEventMap = { - [EVENT_MISSING_CHUNK_REMOTE]: DocumentID; - [EVENT_MISSING_CHUNKS]: DocumentID[]; - [EVENT_CHUNK_FETCHED]: EntryLeaf; -}; diff --git a/_types/src/lib/src/managers/LiveSyncManagers.d.ts b/_types/src/lib/src/managers/LiveSyncManagers.d.ts deleted file mode 100644 index d70b99d9..00000000 --- a/_types/src/lib/src/managers/LiveSyncManagers.d.ts +++ /dev/null @@ -1,51 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type EntryDoc } from "@lib/common/types"; -import { ContentSplitter } from "@lib/ContentSplitter/ContentSplitters.ts"; -import { ChangeManager } from "@lib/managers/ChangeManager.ts"; -import { ChunkFetcher } from "@lib/managers/ChunkFetcher.ts"; -import { ChunkManager } from "@lib/managers/ChunkManager.ts"; -import { ConflictManager } from "@lib/managers/ConflictManager.ts"; -import { EntryManager } from "@lib/managers/EntryManager/EntryManager.ts"; -import { HashManager } from "@lib/managers/HashManager/HashManager.ts"; -import type { APIService } from "@lib/services/base/APIService.ts"; -import type { IDatabaseService, IPathService, IReplicatorService, ISettingService } from "@lib/services/base/IService.ts"; -import { type LogFunction } from "@lib/services/lib/logUtils.ts"; -export interface LiveSyncManagersOptions { - database: PouchDB.Database; - databaseService: IDatabaseService; - settingService: TSettingService; - pathService: IPathService; - replicatorService: IReplicatorService; - APIService: APIService; -} -export declare class LiveSyncManagers { - protected _pathService: IPathService; - protected _replicatorService: IReplicatorService; - protected _settingService: ISettingService; - protected _APIService: APIService; - hashManager: HashManager; - chunkFetcher: ChunkFetcher; - changeManager: ChangeManager; - chunkManager: ChunkManager; - splitter: ContentSplitter; - entryManager: EntryManager; - conflictManager: ConflictManager; - protected options: LiveSyncManagersOptions; - protected log: LogFunction; - constructor(options: LiveSyncManagersOptions); - teardownManagers(): Promise; - protected getManagerMembers(): { - changeManager: ChangeManager; - hashManager: HashManager; - splitter: ContentSplitter; - chunkManager: ChunkManager; - chunkFetcher: ChunkFetcher; - entryManager: EntryManager; - conflictManager: ConflictManager; - }; - initialise(): Promise; - reinitialise(): Promise; - clearCaches(): void; - prepareHashFunction(): Promise; -} diff --git a/_types/src/lib/src/managers/StorageEventManager.d.ts b/_types/src/lib/src/managers/StorageEventManager.d.ts deleted file mode 100644 index 1231b574..00000000 --- a/_types/src/lib/src/managers/StorageEventManager.d.ts +++ /dev/null @@ -1,137 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type FileEventType, type FilePath, type UXFileInfoStub, type UXFolderInfo, type UXInternalFileInfoStub } from "@lib/common/types.ts"; -import { type FileEventItem } from "@lib/common/types.ts"; -import type { IStorageAccessManager } from "@lib/interfaces/StorageAccess.ts"; -import { type PromiseWithResolvers } from "octagonal-wheels/promises"; -import { StorageEventManager, type FileEvent } from "@lib/interfaces/StorageEventManager.ts"; -import type { IAPIService, IVaultService } from "@lib/services/base/IService.ts"; -import type { SettingService } from "@lib/services/base/SettingService.ts"; -import type { FileProcessingService } from "@lib/services/base/FileProcessingService.ts"; -import { createInstanceLogFunction } from "@lib/services/lib/logUtils"; -import type { IStorageEventManagerAdapter } from "./adapters"; -import { type CompatTimeoutHandle } from "@lib/common/coreEnvFunctions"; -type WaitInfo = { - since: number; - type: FileEventType; - canProceed: PromiseWithResolvers; - timerHandler: CompatTimeoutHandle; - event: FileEventItem; -}; -declare const TYPE_SENTINEL_FLUSH = "SENTINEL_FLUSH"; -type FileEventItemSentinelFlush = { - type: typeof TYPE_SENTINEL_FLUSH; -}; -export type FileEventItemSentinel = FileEventItemSentinelFlush; -export interface StorageEventManagerBaseDependencies { - setting: SettingService; - vaultService: IVaultService; - fileProcessing: FileProcessingService; - storageAccessManager: IStorageAccessManager; - APIService: IAPIService; -} -/** - * Type helper to extract the file type from a storage event manager adapter - */ -export type ExtractFile = T extends IStorageEventManagerAdapter ? F : never; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration -/** - * Type helper to extract the folder type from a storage event manager adapter - */ -export type ExtractFolder = T extends IStorageEventManagerAdapter ? D : never; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration -/** - * Base class for storage event management - * Uses adapter pattern for platform-specific implementations - * - * @template TAdapter - The storage event manager adapter type - */ -export declare abstract class StorageEventManagerBase> extends StorageEventManager { // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - _log: ReturnType; - protected setting: SettingService; - protected vaultService: IVaultService; - protected fileProcessing: FileProcessingService; - protected storageAccess: IStorageAccessManager; - protected adapter: TAdapter; - protected get shouldBatchSave(): boolean; - protected get batchSaveMinimumDelay(): number; - protected get batchSaveMaximumDelay(): number; - get settings(): import("@lib/common/types.ts").ObsidianLiveSyncSettings; - constructor(adapter: TAdapter, dependencies: StorageEventManagerBaseDependencies); - _saveSnapshot(snapshot: (FileEventItem | FileEventItemSentinel)[]): Promise; - _loadSnapshot(): Promise<(FileEventItem | FileEventItemSentinel)[] | null>; - isFolder(file: UXFileInfoStub | UXInternalFileInfoStub | UXFolderInfo | ExtractFolder | ExtractFile): boolean; - isFile(file: UXFileInfoStub | UXInternalFileInfoStub | UXFolderInfo | ExtractFolder | ExtractFile): boolean; - protected updateStatus(): void; - /** - * Snapshot restoration promise. - * Snapshot will be restored before starting to watch vault changes. - * In designed time, this has been called from Initialisation process, which has been implemented on `ModuleInitializerFile.ts`. - */ - snapShotRestored: Promise | null; - /** - * Restore the previous snapshot if exists. - * @returns - */ - restoreState(): Promise; - appendQueue(params: FileEvent[], ctx?: unknown): Promise; - protected bufferedQueuedItems: (FileEventItem | FileEventItemSentinel)[]; - enqueue(newItem: FileEventItem): void; - /** - * Immediately take snapshot. - */ - private _triggerTakeSnapshot; - /** - * Trigger taking snapshot after throttled period. - */ - triggerTakeSnapshot: import("octagonal-wheels/function").ThrottledFunction<() => void>; - protected concurrentProcessing: import("octagonal-wheels/concurrency/semaphore_v2").SemaphoreObject; - protected _waitingMap: Map; - private _waitForIdle; - /** - * Wait until all queued events are processed. - * Subsequent new events will not be waited, but new events will not be added. - * @returns - */ - waitForIdle(): Promise; - /** - * Proceed waiting for the given key immediately. - */ - private _proceedWaiting; - /** - * Cancel waiting for the given key. - */ - private _cancelWaiting; - /** - * Add waiting for the given key. - * @param key - * @param event - * @param waitedSince Optional waited since timestamp to calculate the remaining delay. - */ - private _addWaiting; - /** - * Process the given file event. - */ - processFileEvent(fei: FileEventItem): Promise; - _takeSnapshot(): Promise; - _restoreFromSnapshot(): Promise; - protected runQueuedEvents(): Promise; - protected processingCount: number; - protected requestProcessQueue(fei: FileEventItem): Promise; - isWaiting(filename: FilePath): boolean; - protected handleFileEvent(queue: FileEventItem): Promise; - protected cancelRelativeEvent(item: FileEventItem): void; - /** - * Begin watching for storage events - */ - beginWatch(): Promise; - /** - * Platform-agnostic event handlers - */ - protected watchEditorChange(editor: TEditor, info: TInfo): void; - protected watchVaultCreate(file: TFile, ctx?: TCtx): void; - protected watchVaultChange(file: TFile, ctx?: TCtx): void; - protected watchVaultDelete(file: TFile, ctx?: TCtx): void; - protected watchVaultRename(file: TFile, oldPath: string, ctx?: TCtx): void; - protected watchVaultRawEvents(path: FilePath): void; - protected _watchVaultRawEvents(path: FilePath): Promise; -} -export {}; diff --git a/_types/src/lib/src/managers/StorageProcessingManager.d.ts b/_types/src/lib/src/managers/StorageProcessingManager.d.ts deleted file mode 100644 index 006ee126..00000000 --- a/_types/src/lib/src/managers/StorageProcessingManager.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { FilePathWithPrefix } from "@lib/common/models/db.type"; -import type { UXFileInfoStub } from "@lib/common/models/fileaccess.type"; -import type { IStorageAccessManager } from "@lib/interfaces/StorageAccess"; -import type { FileWithFileStat, FileWithStatAsProp } from "@lib/common/models/fileaccess.type"; -export declare class StorageAccessManager implements IStorageAccessManager { - processingFiles: Set; - processWriteFile(file: UXFileInfoStub | FilePathWithPrefix, proc: () => Promise): Promise; - processReadFile(file: UXFileInfoStub | FilePathWithPrefix, proc: () => Promise): Promise; - isFileProcessing(file: UXFileInfoStub | FilePathWithPrefix): boolean; - private touchedFiles; - touch(file: FileWithFileStat | FileWithStatAsProp): void; - recentlyTouched(file: FileWithStatAsProp | FileWithFileStat): boolean; - clearTouched(): void; -} diff --git a/_types/src/lib/src/managers/adapters/IStorageEventConverterAdapter.d.ts b/_types/src/lib/src/managers/adapters/IStorageEventConverterAdapter.d.ts deleted file mode 100644 index df5a27b7..00000000 --- a/_types/src/lib/src/managers/adapters/IStorageEventConverterAdapter.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { FilePath, UXFileInfoStub, UXInternalFileInfoStub } from "@lib/common/types"; -/** - * Adapter interface for converting platform-specific file types to UX types - * - * @template TFile - Platform-specific file type - */ -export interface IStorageEventConverterAdapter { - /** - * Convert platform-specific file to UXFileInfoStub - */ - toFileInfo(file: TFile, deleted?: boolean): UXFileInfoStub; - /** - * Convert path to UXInternalFileInfoStub - */ - toInternalFileInfo(path: FilePath): UXInternalFileInfoStub; -} diff --git a/_types/src/lib/src/managers/adapters/IStorageEventManagerAdapter.d.ts b/_types/src/lib/src/managers/adapters/IStorageEventManagerAdapter.d.ts deleted file mode 100644 index 4f369f7c..00000000 --- a/_types/src/lib/src/managers/adapters/IStorageEventManagerAdapter.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { IStorageEventTypeGuardAdapter } from "./IStorageEventTypeGuardAdapter"; -import type { IStorageEventPersistenceAdapter } from "./IStorageEventPersistenceAdapter"; -import type { IStorageEventWatchAdapter } from "./IStorageEventWatchAdapter"; -import type { IStorageEventStatusAdapter } from "./IStorageEventStatusAdapter"; -import type { IStorageEventConverterAdapter } from "./IStorageEventConverterAdapter"; -/** - * Composite adapter interface for StorageEventManager - * - * @template TFile - Platform-specific file type - * @template TFolder - Platform-specific folder type - */ -export interface IStorageEventManagerAdapter { - readonly typeGuard: IStorageEventTypeGuardAdapter; - readonly persistence: IStorageEventPersistenceAdapter; - readonly watch: IStorageEventWatchAdapter; - readonly status: IStorageEventStatusAdapter; - readonly converter: IStorageEventConverterAdapter; -} diff --git a/_types/src/lib/src/managers/adapters/IStorageEventPersistenceAdapter.d.ts b/_types/src/lib/src/managers/adapters/IStorageEventPersistenceAdapter.d.ts deleted file mode 100644 index 235c4208..00000000 --- a/_types/src/lib/src/managers/adapters/IStorageEventPersistenceAdapter.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { FileEventItem } from "@lib/common/types"; -import type { FileEventItemSentinel } from "@lib/managers/StorageEventManager"; -/** - * Adapter interface for snapshot persistence operations - */ -export interface IStorageEventPersistenceAdapter { - /** - * Save the snapshot of pending events - */ - saveSnapshot(snapshot: (FileEventItem | FileEventItemSentinel)[]): Promise; - /** - * Load the snapshot of pending events - */ - loadSnapshot(): Promise<(FileEventItem | FileEventItemSentinel)[] | null>; -} diff --git a/_types/src/lib/src/managers/adapters/IStorageEventStatusAdapter.d.ts b/_types/src/lib/src/managers/adapters/IStorageEventStatusAdapter.d.ts deleted file mode 100644 index 45c97c17..00000000 --- a/_types/src/lib/src/managers/adapters/IStorageEventStatusAdapter.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -/** - * Adapter interface for status update operations - */ -export interface IStorageEventStatusAdapter { - /** - * Update the status display - */ - updateStatus(status: { - batched: number; - processing: number; - totalQueued: number; - }): void; -} diff --git a/_types/src/lib/src/managers/adapters/IStorageEventTypeGuardAdapter.d.ts b/_types/src/lib/src/managers/adapters/IStorageEventTypeGuardAdapter.d.ts deleted file mode 100644 index 3baf9264..00000000 --- a/_types/src/lib/src/managers/adapters/IStorageEventTypeGuardAdapter.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -/** - * Adapter interface for type guard operations in StorageEventManager - * - * @template TFile - Platform-specific file type - * @template TFolder - Platform-specific folder type - */ -export interface IStorageEventTypeGuardAdapter { - /** - * Check if the given item is a file - */ - isFile(file: unknown): file is TFile; - /** - * Check if the given item is a folder - */ - isFolder(item: unknown): item is TFolder; -} diff --git a/_types/src/lib/src/managers/adapters/IStorageEventWatchAdapter.d.ts b/_types/src/lib/src/managers/adapters/IStorageEventWatchAdapter.d.ts deleted file mode 100644 index c0a368d3..00000000 --- a/_types/src/lib/src/managers/adapters/IStorageEventWatchAdapter.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { FilePath } from "@lib/common/types"; -/** - * Event handlers for storage events - */ -export interface IStorageEventWatchHandlers { - onCreate: (file: TFile, ctx?: TCtx) => void; - onChange: (file: TFile, ctx?: TCtx) => void; - onDelete: (file: TFile, ctx?: TCtx) => void; - onRename: (file: TFile, oldPath: string, ctx?: TCtx) => void; - onRaw: (path: FilePath) => void; - onEditorChange?: (editor: TEditor, info: TInfo) => void; -} -/** - * Adapter interface for watching vault/storage events - */ -export interface IStorageEventWatchAdapter { - /** - * Begin watching for storage events - */ - beginWatch(handlers: IStorageEventWatchHandlers): Promise; -} diff --git a/_types/src/lib/src/managers/adapters/index.d.ts b/_types/src/lib/src/managers/adapters/index.d.ts deleted file mode 100644 index a94af70b..00000000 --- a/_types/src/lib/src/managers/adapters/index.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export type { IStorageEventTypeGuardAdapter } from "./IStorageEventTypeGuardAdapter"; -export type { IStorageEventPersistenceAdapter } from "./IStorageEventPersistenceAdapter"; -export type { IStorageEventWatchAdapter, IStorageEventWatchHandlers } from "./IStorageEventWatchAdapter"; -export type { IStorageEventStatusAdapter } from "./IStorageEventStatusAdapter"; -export type { IStorageEventConverterAdapter } from "./IStorageEventConverterAdapter"; -export type { IStorageEventManagerAdapter } from "./IStorageEventManagerAdapter"; diff --git a/_types/src/lib/src/mock_and_interop/stores.d.ts b/_types/src/lib/src/mock_and_interop/stores.d.ts deleted file mode 100644 index 67e87090..00000000 --- a/_types/src/lib/src/mock_and_interop/stores.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { LOG_LEVEL } from "@lib/common/types.ts"; -export type LockStats = { - pending: string[]; - running: string[]; - count: number; -}; -export declare const lockStats: import("octagonal-wheels/dataobject/reactive_v2").ReactiveSource<{ - pending: never[]; - running: never[]; - count: number; -}>; -export declare const collectingChunks: import("octagonal-wheels/dataobject/reactive_v2").ReactiveSource; -export declare const pluginScanningCount: import("octagonal-wheels/dataobject/reactive_v2").ReactiveSource; -export declare const hiddenFilesProcessingCount: import("octagonal-wheels/dataobject/reactive_v2").ReactiveSource; -export declare const hiddenFilesEventCount: import("octagonal-wheels/dataobject/reactive_v2").ReactiveSource; -export type LogEntry = { - message: string | Error; - level?: LOG_LEVEL; - key?: string; -}; -export declare const logMessages: import("octagonal-wheels/dataobject/reactive_v2").ReactiveSource; diff --git a/_types/src/lib/src/mock_and_interop/wrapper.d.ts b/_types/src/lib/src/mock_and_interop/wrapper.d.ts deleted file mode 100644 index b13e9a96..00000000 --- a/_types/src/lib/src/mock_and_interop/wrapper.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare class WrappedNotice { - constructor(message: string | DocumentFragment, timeout?: number); - setMessage(message: string | DocumentFragment): this; - hide(): void; -} -export declare function setNoticeClass(notice: typeof WrappedNotice): void; -export declare function NewNotice(message: string | DocumentFragment, timeout?: number): WrappedNotice; diff --git a/_types/src/lib/src/mods.d.ts b/_types/src/lib/src/mods.d.ts deleted file mode 100644 index 37a47b49..00000000 --- a/_types/src/lib/src/mods.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare function getWebCrypto(): Promise; diff --git a/_types/src/lib/src/pouchdb/LiveSyncDBFunctions.d.ts b/_types/src/lib/src/pouchdb/LiveSyncDBFunctions.d.ts deleted file mode 100644 index 2ad26116..00000000 --- a/_types/src/lib/src/pouchdb/LiveSyncDBFunctions.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type EntryDoc, type EntryMilestoneInfo, type RemoteDBSettings, type ChunkVersionRange, type TweakValues, type DeviceInfo } from "@lib/common/types.ts"; -export type ENSURE_DB_RESULT = "OK" | "INCOMPATIBLE" | "LOCKED" | "NODE_LOCKED" | "NODE_CLEANED" | ["MISMATCHED", TweakValues]; -/** - * Ensures that the remote database is compatible with the current device. - * - * @param infoSrc - The information about the remote database (which retrieved from the remote). - * @param setting - The current settings. - * @param deviceNodeID - The ID of the current device node. - * @param currentVersionRange - The current version range of the database. - * @param updateCallback - The callback function to update the remote milestone. - * @returns A promise that resolves to the result of ensuring compatibility. - */ -export declare function ensureRemoteIsCompatible(infoSrc: EntryMilestoneInfo | false, setting: RemoteDBSettings, deviceNodeID: string, currentVersionRange: ChunkVersionRange, nodeDeviceInfo: DeviceInfo, updateCallback: (info: EntryMilestoneInfo) => Promise): Promise; -export declare function ensureDatabaseIsCompatible(db: PouchDB.Database, setting: RemoteDBSettings, deviceNodeID: string, currentVersionRange: ChunkVersionRange, nodeDeviceInfo: DeviceInfo): Promise; diff --git a/_types/src/lib/src/pouchdb/LiveSyncLocalDB.d.ts b/_types/src/lib/src/pouchdb/LiveSyncLocalDB.d.ts deleted file mode 100644 index 05a20a7c..00000000 --- a/_types/src/lib/src/pouchdb/LiveSyncLocalDB.d.ts +++ /dev/null @@ -1,195 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type EntryDoc, type EntryLeaf, type Credential, type RemoteDBSettings, type DocumentID, type FilePathWithPrefix, type FilePath, type DatabaseEntry, type LoadedEntry, type MetaEntry, type SavingEntry, type diff_result_leaf } from "@lib/common/types.ts"; -import { eventHub } from "@lib/hub/hub.ts"; -import { LiveSyncManagers } from "@lib/managers/LiveSyncManagers.ts"; -import type { AutoMergeResult } from "@lib/managers/ConflictManager.ts"; -import type { IServiceHub } from "@lib/services/base/IService.ts"; -import { type LogFunction } from "@lib/services/lib/logUtils.ts"; -export declare const REMOTE_CHUNK_FETCHED = "remote-chunk-fetched"; -export type REMOTE_CHUNK_FETCHED = typeof REMOTE_CHUNK_FETCHED; -declare global { - interface LSEvents { - [REMOTE_CHUNK_FETCHED]: EntryLeaf; - } -} -export type ChunkRetrievalResultSuccess = { - _id: DocumentID; - data: string; - type: "leaf"; -}; -export type ChunkRetrievalResultError = { - _id: DocumentID; - error: string; -}; -export type ChunkRetrievalResult = ChunkRetrievalResultSuccess | ChunkRetrievalResultError; -export interface LiveSyncLocalDBEnv { - services: Pick; -} -export declare function getNoFromRev(rev: string): number; -export type GeneratedChunk = { - isNew: boolean; - id: DocumentID; - piece: string; -}; -export declare class LiveSyncLocalDB { - auth: Credential; - dbname: string; - settings: RemoteDBSettings; - localDatabase: PouchDB.Database; - _log: LogFunction; - private _managers?; - get managers(): LiveSyncManagers; - isReady: boolean; - needScanning: boolean; - env: LiveSyncLocalDBEnv; - clearCaches(): void; - _prepareHashFunctions(): Promise; - onunload(): void; - refreshSettings(): void; - offRemoteChunkFetchedHandler?: ReturnType; - constructor(dbname: string, env: LiveSyncLocalDBEnv); - close(): Promise; - onNewLeaf(chunk: EntryLeaf): void; - initializeDatabase(): Promise; - /** - * Retrieve all used and existing chunks in the database. - * @param includeDeleted include deleted chunks in the result. - * @returns {used: Set, existing: Map} used: Set of chunk ids that are used in the database. existing: Map of chunk id and EntryLeaf that are existing in the database. - */ - allChunks(includeDeleted?: boolean): Promise<{ - used: Set; - existing: Map; - }>; - resetDatabase(): Promise; - findEntries(startKey: string, endKey: string, opt: PouchDB.Core.AllDocsWithKeyOptions | PouchDB.Core.AllDocsOptions | PouchDB.Core.AllDocsWithKeysOptions | PouchDB.Core.AllDocsWithinRangeOptions): AsyncGenerator<(DatabaseEntry & import("@lib/common/types.ts").EntryBase & import("@lib/common/types.ts").EntryWithEden & { - path: FilePathWithPrefix; - children: string[]; - type: import("../common/models/db.type.ts").EntryTypes["NOTE_BINARY"]; - } & PouchDB.Core.AllDocsMeta & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta) | (DatabaseEntry & import("@lib/common/types.ts").EntryBase & import("@lib/common/types.ts").EntryWithEden & { - path: FilePathWithPrefix; - children: string[]; - type: import("../common/models/db.type.ts").EntryTypes["NOTE_PLAIN"]; - } & PouchDB.Core.AllDocsMeta & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta) | (DatabaseEntry & import("@lib/common/types.ts").EntryBase & import("@lib/common/types.ts").EntryWithEden & { - path: FilePathWithPrefix; - children: string[]; - type: import("../common/models/db.type.ts").EntryTypes["NOTE_BINARY"]; - } & { - deleted?: boolean; - } & PouchDB.Core.AllDocsMeta & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta) | (DatabaseEntry & import("@lib/common/types.ts").EntryBase & import("@lib/common/types.ts").EntryWithEden & { - path: FilePathWithPrefix; - children: string[]; - type: import("../common/models/db.type.ts").EntryTypes["NOTE_BINARY"]; - } & { - data: string | string[]; - datatype: import("@lib/common/types.ts").EntryTypeNotes; - } & PouchDB.Core.AllDocsMeta & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta) | (DatabaseEntry & import("@lib/common/types.ts").EntryBase & import("@lib/common/types.ts").EntryWithEden & { - path: FilePathWithPrefix; - children: string[]; - type: import("../common/models/db.type.ts").EntryTypes["NOTE_PLAIN"]; - } & { - data: string | string[]; - datatype: import("@lib/common/types.ts").EntryTypeNotes; - } & PouchDB.Core.AllDocsMeta & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta) | (DatabaseEntry & import("@lib/common/types.ts").EntryBase & import("@lib/common/types.ts").EntryWithEden & { - path: FilePathWithPrefix; - children: string[]; - type: import("../common/models/db.type.ts").EntryTypes["NOTE_BINARY"]; - } & { - deleted?: boolean; - } & { - data: string | string[]; - datatype: import("@lib/common/types.ts").EntryTypeNotes; - } & PouchDB.Core.AllDocsMeta & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta), void, unknown>; - findAllDocs(opt?: PouchDB.Core.AllDocsWithKeyOptions | PouchDB.Core.AllDocsOptions | PouchDB.Core.AllDocsWithKeysOptions | PouchDB.Core.AllDocsWithinRangeOptions): AsyncGenerator<(DatabaseEntry & import("@lib/common/types.ts").EntryBase & import("@lib/common/types.ts").EntryWithEden & { - path: FilePathWithPrefix; - children: string[]; - type: import("../common/models/db.type.ts").EntryTypes["NOTE_BINARY"]; - } & PouchDB.Core.AllDocsMeta & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta) | (DatabaseEntry & import("@lib/common/types.ts").EntryBase & import("@lib/common/types.ts").EntryWithEden & { - path: FilePathWithPrefix; - children: string[]; - type: import("../common/models/db.type.ts").EntryTypes["NOTE_PLAIN"]; - } & PouchDB.Core.AllDocsMeta & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta) | (DatabaseEntry & import("@lib/common/types.ts").EntryBase & import("@lib/common/types.ts").EntryWithEden & { - path: FilePathWithPrefix; - children: string[]; - type: import("../common/models/db.type.ts").EntryTypes["NOTE_BINARY"]; - } & { - deleted?: boolean; - } & PouchDB.Core.AllDocsMeta & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta) | (DatabaseEntry & import("@lib/common/types.ts").EntryBase & import("@lib/common/types.ts").EntryWithEden & { - path: FilePathWithPrefix; - children: string[]; - type: import("../common/models/db.type.ts").EntryTypes["NOTE_BINARY"]; - } & { - data: string | string[]; - datatype: import("@lib/common/types.ts").EntryTypeNotes; - } & PouchDB.Core.AllDocsMeta & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta) | (DatabaseEntry & import("@lib/common/types.ts").EntryBase & import("@lib/common/types.ts").EntryWithEden & { - path: FilePathWithPrefix; - children: string[]; - type: import("../common/models/db.type.ts").EntryTypes["NOTE_PLAIN"]; - } & { - data: string | string[]; - datatype: import("@lib/common/types.ts").EntryTypeNotes; - } & PouchDB.Core.AllDocsMeta & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta) | (DatabaseEntry & import("@lib/common/types.ts").EntryBase & import("@lib/common/types.ts").EntryWithEden & { - path: FilePathWithPrefix; - children: string[]; - type: import("../common/models/db.type.ts").EntryTypes["NOTE_BINARY"]; - } & { - deleted?: boolean; - } & { - data: string | string[]; - datatype: import("@lib/common/types.ts").EntryTypeNotes; - } & PouchDB.Core.AllDocsMeta & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta), void, unknown>; - findEntryNames(startKey: string, endKey: string, opt: PouchDB.Core.AllDocsWithKeyOptions | PouchDB.Core.AllDocsOptions | PouchDB.Core.AllDocsWithKeysOptions | PouchDB.Core.AllDocsWithinRangeOptions): AsyncGenerator; - findAllDocNames(opt?: PouchDB.Core.AllDocsWithKeyOptions | PouchDB.Core.AllDocsOptions | PouchDB.Core.AllDocsWithKeysOptions | PouchDB.Core.AllDocsWithinRangeOptions): AsyncGenerator; - findAllNormalDocs(opt?: PouchDB.Core.AllDocsWithKeyOptions | PouchDB.Core.AllDocsOptions | PouchDB.Core.AllDocsWithKeysOptions | PouchDB.Core.AllDocsWithinRangeOptions): AsyncGenerator<(DatabaseEntry & import("@lib/common/types.ts").EntryBase & import("@lib/common/types.ts").EntryWithEden & { - path: FilePathWithPrefix; - children: string[]; - type: import("../common/models/db.type.ts").EntryTypes["NOTE_BINARY"]; - } & PouchDB.Core.AllDocsMeta & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta) | (DatabaseEntry & import("@lib/common/types.ts").EntryBase & import("@lib/common/types.ts").EntryWithEden & { - path: FilePathWithPrefix; - children: string[]; - type: import("../common/models/db.type.ts").EntryTypes["NOTE_PLAIN"]; - } & PouchDB.Core.AllDocsMeta & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta) | (DatabaseEntry & import("@lib/common/types.ts").EntryBase & import("@lib/common/types.ts").EntryWithEden & { - path: FilePathWithPrefix; - children: string[]; - type: import("../common/models/db.type.ts").EntryTypes["NOTE_BINARY"]; - } & { - deleted?: boolean; - } & PouchDB.Core.AllDocsMeta & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta) | (DatabaseEntry & import("@lib/common/types.ts").EntryBase & import("@lib/common/types.ts").EntryWithEden & { - path: FilePathWithPrefix; - children: string[]; - type: import("../common/models/db.type.ts").EntryTypes["NOTE_BINARY"]; - } & { - data: string | string[]; - datatype: import("@lib/common/types.ts").EntryTypeNotes; - } & PouchDB.Core.AllDocsMeta & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta) | (DatabaseEntry & import("@lib/common/types.ts").EntryBase & import("@lib/common/types.ts").EntryWithEden & { - path: FilePathWithPrefix; - children: string[]; - type: import("../common/models/db.type.ts").EntryTypes["NOTE_PLAIN"]; - } & { - data: string | string[]; - datatype: import("@lib/common/types.ts").EntryTypeNotes; - } & PouchDB.Core.AllDocsMeta & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta) | (DatabaseEntry & import("@lib/common/types.ts").EntryBase & import("@lib/common/types.ts").EntryWithEden & { - path: FilePathWithPrefix; - children: string[]; - type: import("../common/models/db.type.ts").EntryTypes["NOTE_BINARY"]; - } & { - deleted?: boolean; - } & { - data: string | string[]; - datatype: import("@lib/common/types.ts").EntryTypeNotes; - } & PouchDB.Core.AllDocsMeta & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta), void, unknown>; - removeRevision(docId: DocumentID, revision: string): Promise; - getRaw(docId: DocumentID, options?: PouchDB.Core.GetOptions): Promise; - removeRaw(docId: DocumentID, revision: string, options?: PouchDB.Core.Options): Promise; - putRaw(doc: T, options?: PouchDB.Core.PutOptions): Promise; - allDocsRaw(options?: PouchDB.Core.AllDocsWithKeyOptions | PouchDB.Core.AllDocsWithKeysOptions | PouchDB.Core.AllDocsWithinRangeOptions | PouchDB.Core.AllDocsOptions): Promise>; - bulkDocsRaw(docs: Array>, options?: PouchDB.Core.BulkDocsOptions): Promise>; - isTargetFile(filenameSrc: string): boolean; - getDBEntryMeta(path: FilePathWithPrefix | FilePath, opt?: PouchDB.Core.GetOptions, includeDeleted?: boolean): Promise; - getDBEntry(path: FilePathWithPrefix | FilePath, opt?: PouchDB.Core.GetOptions, dump?: boolean, waitForReady?: boolean, includeDeleted?: boolean): Promise; - getDBEntryFromMeta(meta: LoadedEntry | MetaEntry, dump?: boolean, waitForReady?: boolean): Promise; - deleteDBEntry(path: FilePathWithPrefix | FilePath, opt?: PouchDB.Core.GetOptions): Promise; - putDBEntry(note: SavingEntry, onlyChunks?: boolean, conflictBaseRev?: string): Promise; - getConflictedDoc(path: FilePathWithPrefix, rev: string): Promise; - tryAutoMerge(path: FilePathWithPrefix, enableMarkdownAutoMerge: boolean): AutoMergeResult; -} diff --git a/_types/src/lib/src/pouchdb/ReplicatorShim.d.ts b/_types/src/lib/src/pouchdb/ReplicatorShim.d.ts deleted file mode 100644 index a9348865..00000000 --- a/_types/src/lib/src/pouchdb/ReplicatorShim.d.ts +++ /dev/null @@ -1,53 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export type SomeDocument = PouchDB.Core.ExistingDocument & PouchDB.Core.ChangesMeta; -/** - * Minimal subset of the PouchDB public API required by {@link replicateShim}. - * Both a real `PouchDB.Database` and an {@link RpcPouchDBProxy} satisfy this - * interface, allowing replication across an RPC transport. - */ -export type PouchDBShim = { - info: () => Promise; - changes: (options: PouchDB.Core.ChangesOptions) => PromiseLike>; - revsDiff: (diff: PouchDB.Core.RevisionDiffOptions) => Promise; - bulkDocs: (docs: PouchDB.Core.PutDocument[], options?: PouchDB.Core.BulkDocsOptions) => Promise<(PouchDB.Core.Response | PouchDB.Core.Error)[]>; - bulkGet: (options: PouchDB.Core.BulkGetOptions) => Promise>; - put: (doc: PouchDB.Core.PutDocument, options?: PouchDB.Core.PutOptions) => Promise; - get: (id: string, options?: PouchDB.Core.GetOptions) => Promise; -}; -type CompatibleDatabase = PouchDB.Database> | PouchDBShim>; -/** Upserts a document by `id`, calling `func` to produce the updated version. */ -export declare function upsert = CompatibleDatabase, T extends SomeDocument = SomeDocument>(db: TDB, id: string, func: (doc: T) => T): Promise; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration -export type ShimReplicationOptionBase = { - rewind?: boolean; - batch_size?: number; -}; -export type ShimReplicationOneShot = { - live?: false; - controller?: AbortController; -} & ShimReplicationOptionBase; -export type ShimReplicationOptionContinuous = { - live: true; - controller: AbortController; -} & ShimReplicationOptionBase; -export type ShimReplicationOption = ShimReplicationOneShot | ShimReplicationOptionContinuous; -export type ProgressInfo = { - lastSeq: number; - maxSeqInBatch: number; -}; -export type ShimReplicationProgressReportFunc = (progress: SomeDocument[], progressInfo: ProgressInfo) => Promise; -/** - * Replicate documents from `sourceDB` into `targetDB` using a CouchDB-style - * checkpoint protocol. - * - * Both parameters accept either a real `PouchDB.Database` or any object - * implementing {@link PouchDBShim} — including {@link RpcPouchDBProxy} — so - * replication can span an RPC transport boundary. - * - * @param targetDB Destination database (usually local). - * @param sourceDB Source database (may be remote / RPC-backed). - * @param progress Called after each batch with the written documents. - * @param option Replication options (live mode, batch size, abort signal). - */ -export declare function replicateShim, U extends CompatibleDatabase, V extends object>(targetDB: T, sourceDB: U, progress: ShimReplicationProgressReportFunc, option?: ShimReplicationOption): Promise; -export {}; diff --git a/_types/src/lib/src/pouchdb/StreamingFetch.d.ts b/_types/src/lib/src/pouchdb/StreamingFetch.d.ts deleted file mode 100644 index 858e0655..00000000 --- a/_types/src/lib/src/pouchdb/StreamingFetch.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { EntryDoc } from "@lib/common/models/db.definition"; -import type { AnyEntry, EntryLeaf } from "@lib/common/models/db.type"; -type DBSequence = number | string; -export type FetchChangesForInitialSyncProgress = { - totalFetched: number; - totalValidFetched: number; - targetSeq: number | string; - docsToFetch: number; - totalBytes: number; -}; -/** - * Fetches initial data from CouchDB as a stream and writes it into PouchDB. - * @param downloadToDB PouchDB instance. - * @param remoteDbUrl CouchDB database URL (for example: 'https://xxx.com/mydb'). - * @param decryptFunction Function to decrypt each document. - * @param since Sequence ID to start fetching changes from (default is '0'). - */ -export declare function fetchChangesForInitialSync(downloadToDB: PouchDB.Database, remoteDbUrl: string, authHeader: string, decryptFunction: (doc: EntryDoc) => Promise, since?: number | string, onProgress?: (progress: FetchChangesForInitialSyncProgress) => void, onCheckpoint?: (sequence: DBSequence) => void | Promise): Promise; -export {}; diff --git a/_types/src/lib/src/pouchdb/StreamingFetch.integration.spec.d.ts b/_types/src/lib/src/pouchdb/StreamingFetch.integration.spec.d.ts deleted file mode 100644 index 08dc832e..00000000 --- a/_types/src/lib/src/pouchdb/StreamingFetch.integration.spec.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export {}; diff --git a/_types/src/lib/src/pouchdb/chunks.d.ts b/_types/src/lib/src/pouchdb/chunks.d.ts deleted file mode 100644 index ab4aea7c..00000000 --- a/_types/src/lib/src/pouchdb/chunks.d.ts +++ /dev/null @@ -1,40 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { CouchDBConnection } from "@lib/common/types"; -export declare function purgeUnreferencedChunks(db: PouchDB.Database, dryRun: boolean, connSetting?: CouchDBConnection, performCompact?: boolean): Promise; -export declare function transferChunks(key: string, label: string, dbFrom: PouchDB.Database, dbTo: PouchDB.Database, items: { - id: string; - rev: string; -}[]): Promise; -export declare function balanceChunkPurgedDBs(local: PouchDB.Database, remote: PouchDB.Database): Promise; -export declare function fetchAllUsedChunks(local: PouchDB.Database, remote: PouchDB.Database): Promise; -export declare function purgeChunksLocal(db: PouchDB.Database, docs: { - id: string; - rev: string; -}[]): Promise; -export declare function collectUnbalancedChunkIDs(local: PouchDB.Database, remote: PouchDB.Database): Promise<{ - onlyOnLocal: { - id: string; - rev: string; - }[]; - onlyOnRemote: { - id: string; - rev: string; - }[]; -}>; -export declare function collectChunks(db: PouchDB.Database, type: "INUSE" | "DANGLING" | "ALL"): Promise<{ - id: string; - rev: string; -}[]>; -export declare function collectChunksUsage(db: PouchDB.Database): Promise<{ - value: number; - key: string[]; -}[]>; -export declare function collectUnreferencedChunks(db: PouchDB.Database): Promise<{ - id: string; - rev: string; -}[]>; -export declare function purgeChunksRemote(setting: CouchDBConnection, docs: { - id: string; - rev: string; -}[]): Promise; diff --git a/_types/src/lib/src/pouchdb/compress.d.ts b/_types/src/lib/src/pouchdb/compress.d.ts deleted file mode 100644 index efafb63b..00000000 --- a/_types/src/lib/src/pouchdb/compress.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import * as fflate from "fflate"; -import type { EntryDoc } from "@lib/common/types"; -export declare function _compressText(text: string): Promise; -export declare const wrappedInflate: (data: Uint8Array, opts: fflate.AsyncInflateOptions) => Promise; -export declare const wrappedDeflate: (data: Uint8Array, opts: fflate.AsyncDeflateOptions) => Promise; -export declare function _decompressText(compressed: string, _useUTF16?: boolean): Promise; -export declare function compressDoc(doc: EntryDoc): Promise; -export declare function decompressDoc(doc: EntryDoc): Promise; -export declare function wrapFflateFunc(func: (data: T, opts: U, cb: fflate.FlateCallback) => unknown): (data: T, opts: U) => Promise; -export declare const replicationFilter: (db: PouchDB.Database, compress: boolean) => void; -export declare const MARK_SHIFT_COMPRESSED = "\u000ELZ\u001D"; diff --git a/_types/src/lib/src/pouchdb/encryption.d.ts b/_types/src/lib/src/pouchdb/encryption.d.ts deleted file mode 100644 index 44ae3372..00000000 --- a/_types/src/lib/src/pouchdb/encryption.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type EntryDoc, type AnyEntry, type EntryLeaf, type DocumentID, type E2EEAlgorithm } from "@lib/common/types"; -import { encryptWorker, decryptWorker, encryptHKDFWorker, decryptHKDFWorker } from "@lib/worker/bgWorker.ts"; -export declare const encrypt: typeof encryptWorker; -export declare const decrypt: typeof decryptWorker; -export declare const encryptHKDF: typeof encryptHKDFWorker; -export declare const decryptHKDF: typeof decryptHKDFWorker; -export declare let preprocessOutgoing: (doc: AnyEntry | EntryLeaf) => Promise; -export declare let preprocessIncoming: (doc: EntryDoc) => Promise; -export declare function getConfiguredFunctionsForEncryption(passphrase: string, useDynamicIterationCount: boolean, migrationDecrypt: boolean, getPBKDF2Salt: () => Promise, algorithm: E2EEAlgorithm): { - incoming: (doc: AnyEntry | EntryLeaf) => Promise; - outgoing: (doc: EntryDoc) => Promise; -}; -export declare const enableEncryption: (db: PouchDB.Database, passphrase: string, useDynamicIterationCount: boolean, migrationDecrypt: boolean, getPBKDF2Salt: () => Promise, algorithm: E2EEAlgorithm) => void; -export declare function disableEncryption(): void; -export declare const EDEN_ENCRYPTED_KEY: DocumentID; -export declare const EDEN_ENCRYPTED_KEY_HKDF: DocumentID; -export declare function shouldEncryptEden(doc: AnyEntry | EntryLeaf): doc is AnyEntry; -export declare function shouldEncryptEdenHKDF(doc: AnyEntry | EntryLeaf): doc is AnyEntry; -export declare function shouldDecryptEden(doc: AnyEntry | EntryLeaf): doc is AnyEntry; -export declare function shouldDecryptEdenHKDF(doc: AnyEntry | EntryLeaf): doc is AnyEntry; diff --git a/_types/src/lib/src/pouchdb/negotiation.d.ts b/_types/src/lib/src/pouchdb/negotiation.d.ts deleted file mode 100644 index 9e566047..00000000 --- a/_types/src/lib/src/pouchdb/negotiation.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare const checkRemoteVersion: (db: PouchDB.Database, migrate: (from: number, to: number) => Promise, barrier?: number) => Promise; -export declare const bumpRemoteVersion: (db: PouchDB.Database, barrier?: number) => Promise; -export declare const checkSyncInfo: (db: PouchDB.Database) => Promise; -/** - * Counts the number of remote (potentially) compromised chunks in the database. - * @param db The PouchDB database instance. - * @returns The number of compromised chunks or false if an error occurs. - */ -export declare function countCompromisedChunks(db: PouchDB.Database): Promise; diff --git a/_types/src/lib/src/pouchdb/pouchdb-browser.d.ts b/_types/src/lib/src/pouchdb/pouchdb-browser.d.ts deleted file mode 100644 index b317d182..00000000 --- a/_types/src/lib/src/pouchdb/pouchdb-browser.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import PouchDB from "pouchdb-core"; -export { PouchDB }; diff --git a/_types/src/lib/src/pouchdb/pouchdb-http.d.ts b/_types/src/lib/src/pouchdb/pouchdb-http.d.ts deleted file mode 100644 index b317d182..00000000 --- a/_types/src/lib/src/pouchdb/pouchdb-http.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import PouchDB from "pouchdb-core"; -export { PouchDB }; diff --git a/_types/src/lib/src/pouchdb/pouchdb-test.d.ts b/_types/src/lib/src/pouchdb/pouchdb-test.d.ts deleted file mode 100644 index b317d182..00000000 --- a/_types/src/lib/src/pouchdb/pouchdb-test.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import PouchDB from "pouchdb-core"; -export { PouchDB }; diff --git a/_types/src/lib/src/pouchdb/utils_couchdb.d.ts b/_types/src/lib/src/pouchdb/utils_couchdb.d.ts deleted file mode 100644 index cfe01b68..00000000 --- a/_types/src/lib/src/pouchdb/utils_couchdb.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare const isValidRemoteCouchDBURI: (uri: string) => boolean; -export declare function isCloudantURI(uri: string): boolean; -export declare function isErrorOfMissingDoc(ex: unknown): boolean; -export declare const _requestToCouchDBFetch: (baseUri: string, username: string, password: string, path?: string, body?: unknown, method?: string) => Promise; diff --git a/_types/src/lib/src/replication/LiveSyncAbstractReplicator.d.ts b/_types/src/lib/src/replication/LiveSyncAbstractReplicator.d.ts deleted file mode 100644 index 2a951be2..00000000 --- a/_types/src/lib/src/replication/LiveSyncAbstractReplicator.d.ts +++ /dev/null @@ -1,69 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type EntryDoc, type DatabaseConnectingStatus, type RemoteDBSettings, type EntryLeaf, type TweakValues, type NodeData } from "@lib/common/types.ts"; -import type { RequiredServices } from "@lib/interfaces/ServiceModule"; -export type ReplicationCallback = (e: PouchDB.Core.ExistingDocument[]) => Promise | boolean; -export type ReplicationStat = { - sent: number; - arrived: number; - maxPullSeq: number; - maxPushSeq: number; - lastSyncPullSeq: number; - lastSyncPushSeq: number; - syncStatus: DatabaseConnectingStatus; -}; -export interface LiveSyncReplicatorEnv { - services: RequiredServices<"API" | "appLifecycle" | "setting" | "vault" | "database" | "databaseEvents" | "keyValueDB" | "replication" | "config" | "UI" | "replicator" | "remote">; -} -export type RemoteDBStatus = { - [key: string]: unknown; - estimatedSize?: number; -}; -export declare abstract class LiveSyncAbstractReplicator { - syncStatus: DatabaseConnectingStatus; - docArrived: number; - docSent: number; - lastSyncPullSeq: number; - maxPullSeq: number; - lastSyncPushSeq: number; - maxPushSeq: number; - controller?: AbortController; - originalSetting: RemoteDBSettings; - nodeid: string; - remoteLocked: boolean; - remoteCleaned: boolean; - remoteLockedAndDeviceNotAccepted: boolean; - tweakSettingsMismatched: boolean; - preferredTweakValue?: TweakValues; - abstract get isChunkSendingSupported(): boolean; - get database(): import("../pouchdb/LiveSyncLocalDB").LiveSyncLocalDB; - get rawDatabase(): PouchDB.Database; - get currentSettings(): import("@lib/common/types.ts").ObsidianLiveSyncSettings; - sendChunks(setting: RemoteDBSettings, remoteDB: PouchDB.Database | undefined, showResult: boolean, fromSeq?: number | string): Promise; - abstract getReplicationPBKDF2Salt(setting: RemoteDBSettings, refresh?: boolean): Promise; - ensurePBKDF2Salt(setting: RemoteDBSettings, showMessage?: boolean, useCache?: boolean): Promise; - env: LiveSyncReplicatorEnv; - initializeDatabaseForReplication(): Promise; - constructor(env: LiveSyncReplicatorEnv); - abstract terminateSync(): void; - abstract openReplication(setting: RemoteDBSettings, keepAlive: boolean, showResult: boolean, ignoreCleanLock: boolean): Promise; - updateInfo: () => void; - abstract tryConnectRemote(setting: RemoteDBSettings, showResult?: boolean): Promise; - abstract replicateAllToServer(setting: RemoteDBSettings, showingNotice?: boolean, sendChunksInBulkDisabled?: boolean): Promise; - abstract replicateAllFromServer(setting: RemoteDBSettings, showingNotice?: boolean): Promise; - abstract closeReplication(): void; - abstract tryResetRemoteDatabase(setting: RemoteDBSettings): Promise; - abstract tryCreateRemoteDatabase(setting: RemoteDBSettings): Promise; - abstract markRemoteLocked(setting: RemoteDBSettings, locked: boolean, lockByClean: boolean): Promise; - abstract markRemoteResolved(setting: RemoteDBSettings): Promise; - abstract resetRemoteTweakSettings(setting: RemoteDBSettings): Promise; - abstract setPreferredRemoteTweakSettings(setting: RemoteDBSettings): Promise; - abstract fetchRemoteChunks(missingChunks: string[], showResult: boolean): Promise; - abstract getRemoteStatus(setting: RemoteDBSettings): Promise; - abstract getRemotePreferredTweakValues(setting: RemoteDBSettings): Promise; - abstract countCompromisedChunks(setting?: RemoteDBSettings): Promise; - abstract getConnectedDeviceList(setting?: RemoteDBSettings): Promise; - accepted_nodes: string[]; - }>; -} diff --git a/_types/src/lib/src/replication/SyncParamsHandler.d.ts b/_types/src/lib/src/replication/SyncParamsHandler.d.ts deleted file mode 100644 index 7af086b5..00000000 --- a/_types/src/lib/src/replication/SyncParamsHandler.d.ts +++ /dev/null @@ -1,37 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { SyncParameters } from "@lib/common/types.ts"; -import { LiveSyncError } from "@lib/common/LSError.ts"; -/** - * Creates a SyncParamsHandler for managing synchronisation parameters. - */ -type putFunc = (params: SyncParameters) => Promise; -/** - * Fetches synchronisation parameters from the server. - */ -type getFunc = () => Promise; -/** - * The function to create new synchronisation parameters. - * Note that this function should not return `pbkdf2salt` in the result; it should be generated and stored in the handler. - */ -type createFunc = () => Promise; -type CreateSyncParamsHanderOptions = { - put: putFunc; - get: getFunc; - create: createFunc; -}; -export type SyncParamsHandler = { - fetch: (refresh?: boolean) => Promise; - getPBKDF2Salt: (refresh?: boolean) => Promise; -}; -export declare function createSyncParamsHanderForServer(key: string, options: CreateSyncParamsHanderOptions): SyncParamsHandler; -export declare function clearHandlers(): void; -export declare class SyncParamsHandlerError extends LiveSyncError { -} -export declare class SyncParamsFetchError extends SyncParamsHandlerError { -} -export declare class SyncParamsNotFoundError extends SyncParamsHandlerError { -} -export declare class SyncParamsUpdateError extends SyncParamsHandlerError { -} -export {}; diff --git a/_types/src/lib/src/replication/couchdb/LiveSyncReplicator.d.ts b/_types/src/lib/src/replication/couchdb/LiveSyncReplicator.d.ts deleted file mode 100644 index 55c60e04..00000000 --- a/_types/src/lib/src/replication/couchdb/LiveSyncReplicator.d.ts +++ /dev/null @@ -1,87 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type EntryDoc, type RemoteDBSettings, type EntryLeaf, type TweakValues, type SyncParameters, type DatabaseEntry, type NodeData } from "@lib/common/types.ts"; -import { LiveSyncAbstractReplicator, type LiveSyncReplicatorEnv, type RemoteDBStatus } from "@lib/replication/LiveSyncAbstractReplicator.ts"; -import type { ServiceHub } from "@lib/services/ServiceHub.ts"; -export interface LiveSyncCouchDBReplicatorEnv extends LiveSyncReplicatorEnv { - services: ServiceHub; -} -export declare class LiveSyncCouchDBReplicator extends LiveSyncAbstractReplicator { - get isChunkSendingSupported(): boolean; - isMobile(): boolean; - constructor(env: LiveSyncCouchDBReplicatorEnv); - getInitialSyncParameters(setting: RemoteDBSettings): Promise; - getSyncParameters(setting: RemoteDBSettings): Promise; - putSyncParameters(setting: RemoteDBSettings, params: SyncParameters): Promise; - getReplicationPBKDF2Salt(setting: RemoteDBSettings, refresh?: boolean): Promise; - migrate(from: number, to: number): Promise; - terminateSync(): void; - openReplication(setting: RemoteDBSettings, keepAlive: boolean, showResult: boolean, ignoreCleanLock: boolean): Promise; - replicationActivated(showResult: boolean): void; - replicationChangeDetected(e: PouchDB.Replication.SyncResult, showResult: boolean, docSentOnStart: number, docArrivedOnStart: number): Promise; - replicationCompleted(showResult: boolean): void; - replicationDenied(e: unknown): void; - replicationErrored(e: unknown): void; - replicationPaused(): void; - processSync(syncHandler: PouchDB.Replication.Sync | PouchDB.Replication.Replication, showResult: boolean, docSentOnStart: number, docArrivedOnStart: number, syncMode: "sync" | "pullOnly" | "pushOnly", retrying: boolean, reportCancelledAsDone?: boolean): Promise<"DONE" | "NEED_RETRY" | "NEED_RESURRECT" | "FAILED" | "CANCELLED">; - getEmptyMaxEntry(remoteID: number): { - _id: string; - maxSeq: number | string; - remoteID: number; - seqStatusMap: Record; - _rev: string | undefined; - }; - getLastTransferredSeqOfChunks(localDB: PouchDB.Database, remoteID: number): Promise>; - updateMaxTransferredSeqOnChunks(localDB: PouchDB.Database, remoteID: number, seqStatusMap: Record): Promise>; - sendChunks(setting: RemoteDBSettings, remoteDB: PouchDB.Database | undefined, showResult: boolean, fromSeq?: number | string): Promise; - openOneShotReplication(setting: RemoteDBSettings, showResult: boolean, retrying: boolean, syncMode: "sync" | "pullOnly" | "pushOnly", ignoreCleanLock?: boolean): Promise; - replicateAllToServer(setting: RemoteDBSettings, showingNotice?: boolean): Promise; - replicateAllFromServer(setting: RemoteDBSettings, showingNotice?: boolean): Promise; - checkReplicationConnectivity(setting: RemoteDBSettings, keepAlive: boolean, skipCheck: boolean, showResult: boolean, ignoreCleanLock?: boolean): Promise; - info: PouchDB.Core.DatabaseInfo; - syncOptionBase: PouchDB.Replication.SyncOptions; - syncOption: PouchDB.Replication.SyncOptions; - }>; - openContinuousReplication(setting: RemoteDBSettings, showResult: boolean, retrying: boolean): Promise; - closeReplication(): void; - tryResetRemoteDatabase(setting: RemoteDBSettings): Promise; - tryCreateRemoteDatabase(setting: RemoteDBSettings): Promise; - markRemoteLocked(setting: RemoteDBSettings, locked: boolean, lockByClean: boolean): Promise; - markRemoteResolved(setting: RemoteDBSettings): Promise; - connectRemoteCouchDBWithSetting(settings: RemoteDBSettings, isMobile: boolean, performSetup?: boolean, skipInfo?: boolean): Promise; - info: PouchDB.Core.DatabaseInfo; - }> | "Empty passphrases cannot be used without explicit permission"; - _ensureConnection(settings: RemoteDBSettings, performSetup?: boolean): Promise>; - /** - * Fetch a document from the remote database directly. - * @param settings RemoteDBSettings for the connection. - * @param id Document ID to fetch. - * @param db Optional PouchDB instance to use. If provided, it will use this instance instead of creating a new connection (then settings will be ignored). - * @returns The fetched document or false if the document does not exist. - * @throws {Error} Other errors that may occur during the fetch operation. - */ - fetchRemoteDocument(settings: RemoteDBSettings, id: string, db?: PouchDB.Database): Promise; - /** - * Puts a document to the remote database directly - * @param settings RemoteDBSettings for the connection. - * @param doc Document to put. - * @param db Optional PouchDB instance to use. If provided, it will use this instance instead of creating a new connection (then settings will be ignored). - * @returns Response from the remote database or false if an error occurred. - * @throws {Error} If the document could not be put. - */ - putRemoteDocument(settings: RemoteDBSettings, doc: T, db?: PouchDB.Database): Promise; - fetchRemoteChunks(missingChunks: string[], showResult: boolean): Promise; - tryConnectRemote(setting: RemoteDBSettings, showResult?: boolean): Promise; - resetRemoteTweakSettings(setting: RemoteDBSettings): Promise; - setPreferredRemoteTweakSettings(setting: RemoteDBSettings): Promise; - getRemotePreferredTweakValues(setting: RemoteDBSettings): Promise; - compactRemote(setting: RemoteDBSettings): Promise; - getRemoteStatus(setting: RemoteDBSettings): Promise; - countCompromisedChunks(setting?: RemoteDBSettings): Promise; - getConnectedDeviceList(setting?: RemoteDBSettings): Promise; - accepted_nodes: string[]; - }>; -} diff --git a/_types/src/lib/src/replication/httplib.d.ts b/_types/src/lib/src/replication/httplib.d.ts deleted file mode 100644 index 26673d92..00000000 --- a/_types/src/lib/src/replication/httplib.d.ts +++ /dev/null @@ -1,77 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { CouchDBCredentials, JWTCredentials, JWTHeader, JWTParams, JWTPayload, PreparedJWT, RemoteDBSettings } from "@lib/common/types"; -import { Computed } from "octagonal-wheels/dataobject/Computed"; -/** - * Generates a credential object based on the provided settings. - * @param settings - RemoteDBSettings - * @returns {CouchDBCredentials} credentials object - */ -export declare function generateCredentialObject(settings: RemoteDBSettings): { - jwtAlgorithm: import("../common/models/auth.type").JWTAlgorithm; - jwtKey: string; - jwtKid: string; - jwtSub: string; - jwtExpDuration: number; - type: "jwt"; - username?: undefined; - password?: undefined; -} | { - username: string; - password: string; - type: "basic"; - jwtAlgorithm?: undefined; - jwtKey?: undefined; - jwtKid?: undefined; - jwtSub?: undefined; - jwtExpDuration?: undefined; -}; -/** - * Generates a basic authentication header for CouchDB credentials using the provided username and password. - * And it caches the result for performance if the credentials are not changed. - */ -export declare class BasicHeaderGenerator { - _header: Computed<[source: CouchDBCredentials], string>; - /** - * Generates a basic authentication header for CouchDB credentials using the provided username and password. - * @param auth - CouchDBCredentials - * @returns {Promise} The basic authentication header (without "Basic" prefix). - */ - getBasicHeader(auth: CouchDBCredentials): Promise; -} -/** - * Generates a JWT token based on the provided credentials and parameters. - * And it caches the result for performance if the credentials are not changed. - */ -export declare class JWTTokenGenerator { - _importKey(auth: JWTCredentials): Promise; - _currentCryptoKey: Computed<[auth: JWTCredentials], CryptoKey>; - _jwt: Computed<[params: JWTParams], { - token: string; - header: JWTHeader; - payload: JWTPayload; - credentials: JWTCredentials; - }>; - _jwtParams: Computed<[source: JWTCredentials], { - header: JWTHeader; - payload: { - exp: number; - iat: number; - sub: string; - "_couchdb.roles": string[]; - }; - credentials: JWTCredentials; - }>; - getJWT(auth: JWTCredentials): Promise; - /** - * Generates a JWT token based on the provided credentials and parameters. - * @param auth - JWTCredentials - * @returns {Promise} The JWT token (with "Bearer" prefix). - */ - getBearerToken(auth: JWTCredentials): Promise; -} -export declare class AuthorizationHeaderGenerator { - _basicHeader: BasicHeaderGenerator; - _jwtHeader: JWTTokenGenerator; - getAuthorizationHeader(auth: CouchDBCredentials): Promise; -} diff --git a/_types/src/lib/src/replication/journal/JournalSyncCore.d.ts b/_types/src/lib/src/replication/journal/JournalSyncCore.d.ts deleted file mode 100644 index 8f372b30..00000000 --- a/_types/src/lib/src/replication/journal/JournalSyncCore.d.ts +++ /dev/null @@ -1,72 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type EntryDoc, type SyncParameters, type BucketSyncSetting, type RemoteDBSettings } from "@lib/common/types.ts"; -import type { ReplicationCallback, ReplicationStat } from "@lib/replication/LiveSyncAbstractReplicator.ts"; -import { type SimpleStore } from "@lib/common/utils.ts"; -import { type CheckPointInfo } from "./JournalSyncTypes.ts"; -import type { LiveSyncJournalReplicatorEnv } from "./LiveSyncJournalReplicatorEnv.ts"; -import type { IJournalStorage } from "./objectstore/JournalStorageAdapter.ts"; -type ProcessingEntry = PouchDB.Core.PutDocument & PouchDB.Core.GetMeta; -export declare class JournalSyncCore { - _settings: BucketSyncSetting; - storage: IJournalStorage; - get db(): PouchDB.Database; - get currentSettings(): import("@lib/common/types.ts").ObsidianLiveSyncSettings; - hash: string; - processReplication: ReplicationCallback; - batchSize: number; - env: LiveSyncJournalReplicatorEnv; - store: SimpleStore; - requestedStop: boolean; - getInitialSyncParameters(): Promise; - getSyncParameters(): Promise; - putSyncParameters(params: SyncParameters): Promise; - getHash(settings: BucketSyncSetting): string; - constructor(settings: BucketSyncSetting, store: SimpleStore, env: LiveSyncJournalReplicatorEnv, storage: IJournalStorage); - downloadJson(key: string): Promise; - uploadJson(key: string, body: T): Promise; - applyNewConfig(settings: BucketSyncSetting, store: SimpleStore, env: LiveSyncJournalReplicatorEnv): void; - updateInfo(info: Partial): void; - updateCheckPointInfo(func: (infoFrom: CheckPointInfo) => CheckPointInfo): Promise; - _currentCheckPointInfo: { - lastLocalSeq: number | string; - journalEpoch: string; - knownIDs: Set; - sentIDs: Set; - receivedFiles: Set; - sentFiles: Set; - }; - getCheckpointInfo(): Promise; - resetAllCaches(): void; - resetCheckpointInfo(): Promise; - private getJournalEpochFromSyncParams; - ensureCheckpointCachesAreFresh(): Promise; - isAvailable(): Promise; - resetBucket(): Promise; - getRemoteKey(): string; - getReplicationPBKDF2Salt(refresh?: boolean): Promise; - isEncryptionPrevented(fileName: string): boolean; - private decryptDataV2; - private decryptDataV1; - decryptDownloaded(key: string, encrypted: Uint8Array, set: RemoteDBSettings): Promise; - encryptForUpload(key: string, data: Uint8Array, set: RemoteDBSettings): Promise; - getDocKey(doc: EntryDoc): string; - _createJournalPack(override?: number | string): Promise<{ - changes: EntryDoc[]; - hasNext: boolean; - packLastSeq: string | number; - }>; - private _createSendReadableStream; - private _createSendCompressTransformStream; - private _createSendUploadWritableStream; - sendLocalJournal(showMessage?: boolean): Promise; - _getRemoteJournals(): Promise; - processDocuments(allDocs: ProcessingEntry[]): Promise; - private _createReceiveReadableStream; - private _createReceiveTransformStream; - private _createReceiveWritableStream; - receiveRemoteJournal(showMessage?: boolean): Promise; - sync(showResult?: boolean): Promise; - requestStop(): void; -} -export {}; diff --git a/_types/src/lib/src/replication/journal/JournalSyncTypes.d.ts b/_types/src/lib/src/replication/journal/JournalSyncTypes.d.ts deleted file mode 100644 index 8996d6fe..00000000 --- a/_types/src/lib/src/replication/journal/JournalSyncTypes.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export type CheckPointInfo = { - lastLocalSeq: number | string; - journalEpoch: string; - knownIDs: Set; - sentIDs: Set; - receivedFiles: Set; - sentFiles: Set; -}; -export declare const CheckPointInfoDefault: CheckPointInfo; diff --git a/_types/src/lib/src/replication/journal/LiveSyncJournalReplicator.d.ts b/_types/src/lib/src/replication/journal/LiveSyncJournalReplicator.d.ts deleted file mode 100644 index 955d7a31..00000000 --- a/_types/src/lib/src/replication/journal/LiveSyncJournalReplicator.d.ts +++ /dev/null @@ -1,42 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type RemoteDBSettings, type EntryLeaf, type ChunkVersionRange, type TweakValues, type NodeData } from "@lib/common/types.ts"; -import { JournalSyncCore } from "./JournalSyncCore.ts"; -import { LiveSyncAbstractReplicator, type RemoteDBStatus } from "@lib/replication/LiveSyncAbstractReplicator.ts"; -import { type ENSURE_DB_RESULT } from "@lib/pouchdb/LiveSyncDBFunctions.ts"; -import type { CheckPointInfo } from "./JournalSyncTypes.ts"; -import { type SimpleStore } from "@lib/common/utils.ts"; -import type { LiveSyncJournalReplicatorEnv } from "./LiveSyncJournalReplicatorEnv.ts"; -export declare class LiveSyncJournalReplicator extends LiveSyncAbstractReplicator { - env: LiveSyncJournalReplicatorEnv; - get isChunkSendingSupported(): boolean; - get client(): JournalSyncCore; - get simpleStore(): SimpleStore; - _client: JournalSyncCore; - getReplicationPBKDF2Salt(setting: RemoteDBSettings, refresh?: boolean): Promise; - setupJournalSyncClient(): JournalSyncCore; - ensureBucketIsCompatible(deviceNodeID: string, currentVersionRange: ChunkVersionRange): Promise; - constructor(env: LiveSyncJournalReplicatorEnv); - migrate(from: number, to: number): Promise; - terminateSync(): void; - openReplication(setting: RemoteDBSettings, _: boolean, showResult: boolean, ignoreCleanLock?: boolean): Promise; - replicateAllToServer(setting: RemoteDBSettings, showingNotice?: boolean): Promise; - replicateAllFromServer(setting: RemoteDBSettings, showingNotice?: boolean): Promise; - checkReplicationConnectivity(skipCheck: boolean, ignoreCleanLock?: boolean, showMessage?: boolean): Promise; - fetchRemoteChunks(missingChunks: string[], showResult: boolean): Promise; - closeReplication(): void; - tryResetRemoteDatabase(setting: RemoteDBSettings): Promise; - tryCreateRemoteDatabase(setting: RemoteDBSettings): Promise; - markRemoteLocked(setting: RemoteDBSettings, locked: boolean, lockByClean: boolean): Promise; - markRemoteResolved(setting: RemoteDBSettings): Promise; - tryConnectRemote(setting: RemoteDBSettings, showResult?: boolean): Promise; - resetRemoteTweakSettings(setting: RemoteDBSettings): Promise; - setPreferredRemoteTweakSettings(setting: RemoteDBSettings): Promise; - getRemotePreferredTweakValues(setting: RemoteDBSettings): Promise; - getRemoteStatus(setting: RemoteDBSettings): Promise; - countCompromisedChunks(): Promise; - getConnectedDeviceList(setting?: RemoteDBSettings): Promise; - accepted_nodes: string[]; - }>; -} diff --git a/_types/src/lib/src/replication/journal/LiveSyncJournalReplicatorEnv.d.ts b/_types/src/lib/src/replication/journal/LiveSyncJournalReplicatorEnv.d.ts deleted file mode 100644 index aa4fd5e5..00000000 --- a/_types/src/lib/src/replication/journal/LiveSyncJournalReplicatorEnv.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { LiveSyncReplicatorEnv } from "@lib/replication/LiveSyncAbstractReplicator"; -export interface LiveSyncJournalReplicatorEnv extends LiveSyncReplicatorEnv { // eslint-disable-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-empty-interface -- Empty interface -} diff --git a/_types/src/lib/src/replication/journal/objectstore/JournalStorageAdapter.d.ts b/_types/src/lib/src/replication/journal/objectstore/JournalStorageAdapter.d.ts deleted file mode 100644 index 836afc6d..00000000 --- a/_types/src/lib/src/replication/journal/objectstore/JournalStorageAdapter.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { RemoteDBStatus } from "@lib/replication/LiveSyncAbstractReplicator.ts"; -import type { BucketSyncSetting } from "@lib/common/types.ts"; -export interface IJournalStorage { - upload(key: string, data: Uint8Array, mime: string): Promise; - download(key: string, ignoreCache?: boolean): Promise; - listFiles(from: string, limit?: number): Promise; - deleteFiles(keys: string[]): Promise; - isAvailable(): Promise; - getUsage(): Promise; - applyNewConfig(settings: BucketSyncSetting): void; -} -import type { LiveSyncJournalReplicatorEnv } from "@lib/replication/journal/LiveSyncJournalReplicatorEnv.ts"; -export interface IJournalStorageAdapterClass { - new (settings: BucketSyncSetting, env: LiveSyncJournalReplicatorEnv): IJournalStorage; -} diff --git a/_types/src/lib/src/replication/journal/objectstore/MinioStorageAdapter.d.ts b/_types/src/lib/src/replication/journal/objectstore/MinioStorageAdapter.d.ts deleted file mode 100644 index a227c09c..00000000 --- a/_types/src/lib/src/replication/journal/objectstore/MinioStorageAdapter.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { S3 } from "@aws-sdk/client-s3"; -import { type BucketSyncSetting } from "@lib/common/types.ts"; -import type { RemoteDBStatus } from "@lib/replication/LiveSyncAbstractReplicator.ts"; -import type { IJournalStorage } from "./JournalStorageAdapter.ts"; -import type { LiveSyncJournalReplicatorEnv } from "@lib/replication/journal/LiveSyncJournalReplicatorEnv.ts"; -export declare class MinioStorageAdapter implements IJournalStorage { - _instance?: S3; - _settings: BucketSyncSetting; - _env: LiveSyncJournalReplicatorEnv; - constructor(settings: BucketSyncSetting, env: LiveSyncJournalReplicatorEnv); - applyNewConfig(settings: BucketSyncSetting): void; - get customHeaders(): [string, string][]; - _getClient(): S3; - upload(key: string, data: Uint8Array, mime: string): Promise; - download(key: string, ignoreCache?: boolean): Promise; - listFiles(from: string, limit?: number): Promise; - deleteFiles(keys: string[]): Promise; - isAvailable(): Promise; - getUsage(): Promise; -} diff --git a/_types/src/lib/src/replication/journal/objectstore/MinioStorageAdapter.integration.spec.d.ts b/_types/src/lib/src/replication/journal/objectstore/MinioStorageAdapter.integration.spec.d.ts deleted file mode 100644 index 08dc832e..00000000 --- a/_types/src/lib/src/replication/journal/objectstore/MinioStorageAdapter.integration.spec.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export {}; diff --git a/_types/src/lib/src/replication/trystero/LiveSyncTrysteroReplicator.d.ts b/_types/src/lib/src/replication/trystero/LiveSyncTrysteroReplicator.d.ts deleted file mode 100644 index 2f256055..00000000 --- a/_types/src/lib/src/replication/trystero/LiveSyncTrysteroReplicator.d.ts +++ /dev/null @@ -1,92 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type RemoteDBSettings, type EntryLeaf, type TweakValues, type LOG_LEVEL, type NodeData } from "@lib/common/types"; -import { LiveSyncAbstractReplicator, type LiveSyncReplicatorEnv, type RemoteDBStatus } from "@lib/replication/LiveSyncAbstractReplicator"; -import { TrysteroReplicator } from "./TrysteroReplicator"; -import { P2PHost, type AcceptanceDecision, type RevokeAcceptanceDecision } from "./TrysteroReplicatorP2PServer"; -import type { Advertisement } from "./types"; -export interface LiveSyncTrysteroReplicatorEnv extends LiveSyncReplicatorEnv { - /** - * Injected by the host platform (e.g. Obsidian) to show a UI for peer selection. - * When not set, openReplication falls back to replicateFromCommand (CLI-safe). - */ - openReplicationUI?: (showResult: boolean) => Promise; - /** - * Injected by the host platform to show a UI for selecting a peer to rebuild from. - * When not set, replicateAllFromServer falls back to the headless selectPeer dialog. - */ - openRebuildUI?: (showResult: boolean) => Promise; -} -export declare class LiveSyncTrysteroReplicator extends LiveSyncAbstractReplicator { - private _p2pHost?; - private _replicator?; - get openReplicationUI(): ((showResult: boolean) => Promise) | undefined; - get rawReplicator(): TrysteroReplicator | undefined; - get rawHost(): P2PHost | undefined; - get isChunkSendingSupported(): boolean; - getReplicationPBKDF2Salt(_setting: RemoteDBSettings, _refresh?: boolean): Promise; - terminateSync(): void; - private _buildEnv; - open(): Promise; - close(): Promise; - closeReplication(): void; - get server(): P2PHost | undefined; - get knownAdvertisements(): Advertisement[]; - enableBroadcastChanges(): void; - disableBroadcastChanges(): void; - requestStatus(): void; - onNewPeer(peer: Advertisement): Promise | undefined; - onPeerLeaved(peerId: string): void; - replicateFromCommand(showResult?: boolean): Promise; - replicateFrom(peerId: string, showNotice?: boolean): Promise<{ - error: unknown; - ok?: undefined; - } | { - ok: boolean; - error?: undefined; - }>; - requestSynchroniseToPeer(peerId: string): Promise<{ - error: unknown; - ok?: undefined; - } | { - ok: boolean; - error?: undefined; - }>; - getRemoteConfig(peerId: string): Promise; - watchPeer(peerId: string): void; - unwatchPeer(peerId: string): void; - sync(peerId: string, showNotice?: boolean): Promise<{ - error: unknown; - ok?: undefined; - } | { - ok: boolean; - error?: undefined; - } | undefined>; - setOnSetup(): void; - clearOnSetup(): void; - makeDecision(decision: AcceptanceDecision): Promise; - revokeDecision(decision: RevokeAcceptanceDecision): Promise; - makeSureOpened(): Promise; - openReplication(_setting: RemoteDBSettings, _keepAlive: boolean, showResult: boolean, _ignoreCleanLock: boolean): Promise; - tryConnectRemote(_setting: RemoteDBSettings, _showResult?: boolean): Promise; - replicateAllToServer(_setting: RemoteDBSettings, _showingNotice?: boolean, _sendChunksInBulkDisabled?: boolean): Promise; - selectPeer(settingPeerName: string, r: TrysteroReplicator, logLevel: LOG_LEVEL): Promise; - tryUntilSuccess(func: () => Promise, repeat: number, logLevel: LOG_LEVEL): Promise; - replicateAllFromServer(setting: RemoteDBSettings, showingNotice?: boolean): Promise; - tryResetRemoteDatabase(_setting: RemoteDBSettings): Promise; - tryCreateRemoteDatabase(_setting: RemoteDBSettings): Promise; - markRemoteLocked(_setting: RemoteDBSettings, _locked: boolean, _lockByClean: boolean): Promise; - markRemoteResolved(_setting: RemoteDBSettings): Promise; - resetRemoteTweakSettings(_setting: RemoteDBSettings): Promise; - setPreferredRemoteTweakSettings(_setting: RemoteDBSettings): Promise; - fetchRemoteChunks(_missingChunks: string[], _showResult: boolean): Promise; - getRemoteStatus(_setting: RemoteDBSettings): Promise; - getRemotePreferredTweakValues(_setting: RemoteDBSettings): Promise; - countCompromisedChunks(): Promise; - getConnectedDeviceList(_setting?: RemoteDBSettings): Promise; - accepted_nodes: string[]; - }>; - env: LiveSyncTrysteroReplicatorEnv; - constructor(env: LiveSyncTrysteroReplicatorEnv); -} diff --git a/_types/src/lib/src/replication/trystero/P2PLogCollector.d.ts b/_types/src/lib/src/replication/trystero/P2PLogCollector.d.ts deleted file mode 100644 index cc7ecee1..00000000 --- a/_types/src/lib/src/replication/trystero/P2PLogCollector.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { P2PReplicationProgress } from "./TrysteroReplicator"; -export declare class P2PLogCollector { - constructor(); - p2pReplicationResult: Map; - updateP2PReplicationLine(): void; - p2pReplicationLine: import("octagonal-wheels/dataobject/reactive_v2").ReactiveSource; -} diff --git a/_types/src/lib/src/replication/trystero/P2PReplicatorBase.d.ts b/_types/src/lib/src/replication/trystero/P2PReplicatorBase.d.ts deleted file mode 100644 index 50f64d42..00000000 --- a/_types/src/lib/src/replication/trystero/P2PReplicatorBase.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { LOG_LEVEL } from "octagonal-wheels/common/logger"; -import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase"; -import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive_v2"; -import type { P2PSyncSetting, EntryDoc } from "@lib/common/types"; -import type { Confirm } from "@lib/interfaces/Confirm"; -import type { InjectableServiceHub } from "@lib/services/InjectableServices"; -export interface P2PReplicatorBase { - storeP2PStatusLine: ReactiveSource; - settings: P2PSyncSetting; - _log(msg: unknown, level?: LOG_LEVEL): void; - _notice(msg: unknown, key?: string): void; - getSettings(): P2PSyncSetting; - getDB: () => PouchDB.Database; - confirm: Confirm; - simpleStore(): SimpleStore; - handleReplicatedDocuments(docs: EntryDoc[]): Promise; - init(): Promise; - services: InjectableServiceHub; -} diff --git a/_types/src/lib/src/replication/trystero/P2PReplicatorCore.d.ts b/_types/src/lib/src/replication/trystero/P2PReplicatorCore.d.ts deleted file mode 100644 index 5f059929..00000000 --- a/_types/src/lib/src/replication/trystero/P2PReplicatorCore.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; -import type { P2PPaneParams } from "./UseP2PReplicatorResult"; -export type P2PViewFactory = (leaf: unknown) => unknown; -/** - * ServiceFeature: P2P Replicator lifecycle management. - * Binds a LiveSyncTrysteroReplicator to the host's lifecycle events, - * following the same middleware style as useOfflineScanner. - * - * @param viewTypeAndFactory Optional [viewType, factory] pair for registering the P2P pane view. - * When provided, also registers commands and ribbon icon via services.API. - */ -export declare function useP2PReplicator(host: NecessaryServices<"API" | "appLifecycle" | "setting" | "vault" | "database" | "databaseEvents" | "keyValueDB" | "replication" | "config" | "UI" | "replicator" | "remote", never>, viewTypeAndFactory?: [viewType: string, factory: P2PViewFactory]): P2PPaneParams; diff --git a/_types/src/lib/src/replication/trystero/P2PReplicatorPaneCommon.d.ts b/_types/src/lib/src/replication/trystero/P2PReplicatorPaneCommon.d.ts deleted file mode 100644 index c9fd8fae..00000000 --- a/_types/src/lib/src/replication/trystero/P2PReplicatorPaneCommon.d.ts +++ /dev/null @@ -1,43 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { InjectableServiceHub } from "@lib/services/InjectableServices.ts"; -export declare const EVENT_P2P_PEER_SHOW_EXTRA_MENU = "p2p-peer-show-extra-menu"; -export declare enum AcceptedStatus { - UNKNOWN = "Unknown", - ACCEPTED = "Accepted", - DENIED = "Denied", - ACCEPTED_IN_SESSION = "Accepted in session", - DENIED_IN_SESSION = "Denied in session" -} -export type PeerExtraMenuEvent = { - peer: PeerStatus; - event: MouseEvent; -}; -export declare enum ConnectionStatus { - CONNECTED = "Connected", - CONNECTED_LIVE = "Connected(live)", - DISCONNECTED = "Disconnected" -} -export type PeerStatus = { - name: string; - peerId: string; - syncOnConnect: boolean; - watchOnConnect: boolean; - syncOnReplicationCommand: boolean; - accepted: AcceptedStatus; - status: ConnectionStatus; - isFetching: boolean; - isSending: boolean; - isWatching: boolean; -}; -declare global { - interface LSEvents { - [EVENT_P2P_PEER_SHOW_EXTRA_MENU]: PeerExtraMenuEvent; - } -} -export interface PluginShim { - services: InjectableServiceHub; - core: { - services: InjectableServiceHub; - }; -} diff --git a/_types/src/lib/src/replication/trystero/ProxiedDB.d.ts b/_types/src/lib/src/replication/trystero/ProxiedDB.d.ts deleted file mode 100644 index 887bef25..00000000 --- a/_types/src/lib/src/replication/trystero/ProxiedDB.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type ReplicatorHostEnv } from "./types"; -import type { EntryDoc } from "@lib/common/models/db.definition"; -export declare function createHostingDB(env: ReplicatorHostEnv): { - info: () => Promise; - changes: (options: PouchDB.Core.ChangesOptions) => PouchDB.Core.Changes; - revsDiff: (diff: PouchDB.Core.RevisionDiffOptions) => Promise; - bulkDocs: (docs: PouchDB.Core.PutDocument>[], options?: PouchDB.Core.BulkDocsOptions) => Promise<(PouchDB.Core.Response | PouchDB.Core.Error)[]>; - bulkGet: (options: PouchDB.Core.BulkGetOptions) => Promise>; - put: (doc: PouchDB.Core.PutDocument>, options?: PouchDB.Core.PutOptions) => Promise; - get: (id: string, options?: PouchDB.Core.GetOptions) => Promise; - _stopHosting: () => void; -}; -export type HostingDB = ReturnType; diff --git a/_types/src/lib/src/replication/trystero/TrysteroReplicator.d.ts b/_types/src/lib/src/replication/trystero/TrysteroReplicator.d.ts deleted file mode 100644 index 6ed7a70e..00000000 --- a/_types/src/lib/src/replication/trystero/TrysteroReplicator.d.ts +++ /dev/null @@ -1,153 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type EntryDoc, type ObsidianLiveSyncSettings } from "@lib/common/types"; -import { type ProgressInfo } from "@lib/pouchdb/ReplicatorShim"; -import type { Confirm } from "@lib/interfaces/Confirm"; -import { type Advertisement, type ReplicatorHostEnv } from "./types"; -import { EVENT_P2P_REPLICATOR_PROGRESS, EVENT_P2P_REPLICATOR_STATUS, P2PHost } from "./TrysteroReplicatorP2PServer"; -export type P2PReplicatorStatus = { - isBroadcasting: boolean; - replicatingTo: string[]; - replicatingFrom: string[]; - watchingPeers: string[]; -}; -export type P2PReplicationProgress = { - peerId: string; - peerName: string; - fetching: { - max: number; - current: number; - isActive: boolean; - }; - sending: { - max: number; - current: number; - isActive: boolean; - }; -}; -export type P2PReplicationReport = { - peerId: string; - peerName: string; -} & ({ - fetching: { - max: number; - current: number; - isActive: boolean; - }; -} | { - sending: { - max: number; - current: number; - isActive: boolean; - }; -}); -declare global { - interface LSEvents { - [EVENT_P2P_REPLICATOR_STATUS]: P2PReplicatorStatus; - [EVENT_P2P_REPLICATOR_PROGRESS]: P2PReplicationReport; - } -} -export type AllReplicationClientStatus = { - [peerId: string]: { - isReplicatingTo: boolean; - isReplicatingFrom: boolean; - isWatching: boolean; - stats: P2PReplicationProgress; - }; -}; -export declare class TrysteroReplicator { - _env: ReplicatorHostEnv; - server?: P2PHost; - replicationStatus(): {}; // eslint-disable-line @typescript-eslint/no-empty-object-type, @typescript-eslint/ban-types -- Empty object type - get settings(): import("@lib/common/types").P2PSyncSetting; - get db(): PouchDB.Database; - get deviceName(): string; - get platform(): string; - get confirm(): Confirm; - private runBoundedRemoteActivity; - constructor(env: ReplicatorHostEnv, server?: P2PHost); - close(): Promise; - open(): Promise; - makeSureOpened(): Promise; - get autoSyncPeers(): RegExp[]; - get autoWatchPeers(): RegExp[]; - onNewPeer(peer: Advertisement): Promise; - onPeerLeaved(peerId: string): void; - _onSetup: boolean; - setOnSetup(): void; - clearOnSetup(): void; - getTweakSettings(fromPeerId: string): Promise>; - getCommands(): { - reqSync: (fromPeerId: string) => Promise<{ - error: unknown; - ok?: undefined; - } | { - ok: boolean; - error?: undefined; - }>; - "!reqAuth": (fromPeerId: string) => Promise; - getTweakSettings: (fromPeerId: string) => Promise>; - onProgress: (fromPeerId: string) => Promise<{ - error: Error; - } | undefined>; - getAllConfig: (fromPeerId: string) => Promise; - onProgressAcknowledged: (fromPeerId: string, info: ProgressInfo) => Promise; - getIsBroadcasting: () => Promise; - requestBroadcasting: (peerId: string) => Promise; - }; - requestAuthenticate(peerId: string): Promise; - lastSeq: string | number; - requestSynchroniseToPeer(peerId: string): Promise["reqSync"]>>>; - requestSynchroniseToAllAvailablePeers(): Promise; - dispatchStatus(): void; - requestStatus(): void; - changes?: PouchDB.Core.Changes; - _isBroadcasting: boolean; - disableBroadcastChanges(): void; - enableBroadcastChanges(): void; - get knownAdvertisements(): Advertisement[]; - availableReplicationPairs: Set; - sync(remotePeer: string, showNotice?: boolean): Promise<{ - error: unknown; - ok?: undefined; - } | { - ok: boolean; - error?: undefined; - } | undefined>; - _replicateToPeers: Set; - _replicateFromPeers: Set; - dispatchReplicationProgress(peerId: string, info?: ProgressInfo): void; - onReplicationProgress(peerId: string, info?: ProgressInfo): boolean; - onProgressAcknowledged(peerId: string, info?: ProgressInfo): boolean; - acknowledgeProgress(remotePeerId: string, info?: ProgressInfo): void; - replicateFrom(remotePeer: string, showNotice?: boolean, fromStart?: boolean): Promise<{ - error: unknown; - ok?: undefined; - } | { - ok: boolean; - error?: undefined; - }>; - notifyProgress(excludePeerId?: string): Promise | undefined; - requestBroadcastChanges(peerId: string): Promise; - getRemoteIsBroadcasting(peerId: string): Promise; - _watchingPeers: Set; - watchPeer(peerId: string): void; - unwatchPeer(peerId: string): void; - onUpdateDatabase(fromPeerId: string): Promise; - getRemoteConfig(peerId: string): Promise; - checkTweakValues(peerId: string): Promise; - replicateFromCommand(showResult?: boolean): Promise; - disconnectFromServer(): void; - pauseServe(): Promise; - allowReconnection(): void; -} diff --git a/_types/src/lib/src/replication/trystero/TrysteroReplicatorP2PClient.d.ts b/_types/src/lib/src/replication/trystero/TrysteroReplicatorP2PClient.d.ts deleted file mode 100644 index 1c311822..00000000 --- a/_types/src/lib/src/replication/trystero/TrysteroReplicatorP2PClient.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { PouchDBShim, SomeDocument } from "@lib/pouchdb/ReplicatorShim"; -import type { TrysteroReplicatorP2PServer } from "./TrysteroReplicatorP2PServer"; -import { type BindableObject, type NonPrivateMethodKeys, type Response } from "./types"; -import type { JsonLike } from "@lib/rpc"; -export declare class TrysteroReplicatorP2PClient { - _server: TrysteroReplicatorP2PServer; - _connectedPeerId: string; - _remoteDB: PouchDBShim>; - get remoteDB(): PouchDBShim>; - constructor(server: TrysteroReplicatorP2PServer, connectedPeerId: string); - _bindRemoteDB(): PouchDBShim>; - _sendRPC(type: string, args: JsonLike[], timeout?: number): Promise; - __onResponse(_data: Response): void; - bindRemoteFunction(type: string, timeout?: number): (...args: T) => Promise; - invokeRemoteFunction(type: string, args: T, timeout?: number): Promise; - bindRemoteObjectFunctions, U extends keyof T>(key: U, timeout?: number): (...args: Parameters) => Promise>>; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - invokeRemoteObjectFunction, U extends NonPrivateMethodKeys>(key: U, args: Parameters, timeout?: number): Promise>>; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - close(): void; -} diff --git a/_types/src/lib/src/replication/trystero/TrysteroReplicatorP2PConnection.d.ts b/_types/src/lib/src/replication/trystero/TrysteroReplicatorP2PConnection.d.ts deleted file mode 100644 index 01844319..00000000 --- a/_types/src/lib/src/replication/trystero/TrysteroReplicatorP2PConnection.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { TrysteroReplicatorP2PServer } from "./TrysteroReplicatorP2PServer"; -export { TrysteroReplicatorP2PServer as TrysteroConnection }; diff --git a/_types/src/lib/src/replication/trystero/TrysteroReplicatorP2PServer.d.ts b/_types/src/lib/src/replication/trystero/TrysteroReplicatorP2PServer.d.ts deleted file mode 100644 index 4fdfb5d0..00000000 --- a/_types/src/lib/src/replication/trystero/TrysteroReplicatorP2PServer.d.ts +++ /dev/null @@ -1,119 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type ActionSender, type Room } from "@trystero-p2p/nostr"; -import { type P2PSyncSetting } from "@lib/common/types"; -import { type ReplicatorHostEnv, type FullFilledDeviceInfo, type Request, type Response, type Payload, type Advertisement, type BindableObject, type BindableFunction } from "./types"; -import { StoredMapLike } from "@lib/dataobject/StoredMap"; -import { TrysteroReplicatorP2PClient } from "./TrysteroReplicatorP2PClient"; -import { Computed } from "octagonal-wheels/dataobject/Computed"; -import { RpcRoom, type JsonLike } from "@lib/rpc"; -import { type DiagRTCStats } from "@lib/rpc/transports/DiagRTCPeerConnections.types"; -export type PeerInfo = Advertisement & { - isAccepted: boolean | undefined; - isTemporaryAccepted: boolean | undefined; -}; -export type AcceptanceDecision = { - peerId: string; - name: string; - decision: boolean; - isTemporary: boolean; -}; -export type RevokeAcceptanceDecision = { - peerId: string; - name: string; -}; -export type P2PServerInfo = { - isConnected: boolean; - knownAdvertisements: PeerInfo[]; - serverPeerId: string; - roomId: string; - diag: DiagRTCStats; -}; -export declare const EVENT_SERVER_STATUS = "p2p-server-status"; -export declare const EVENT_MAKE_DECISION = "make-decision-p2p-peer"; -export declare const EVENT_REVOKE_DECISION = "revoke-decision-p2p-peer"; -export declare const EVENT_ADVERTISEMENT_RECEIVED = "p2p-advertisement-received"; -export declare const EVENT_DEVICE_LEAVED = "p2p-device-leaved"; -export declare const EVENT_REQUEST_STATUS = "p2p-request-status"; -export declare const EVENT_P2P_REQUEST_FORCE_OPEN = "p2p-request-force-open"; -export declare const EVENT_P2P_CONNECTED = "p2p-connected"; -export declare const EVENT_P2P_DISCONNECTED = "p2p-disconnected"; -export declare const EVENT_P2P_REPLICATOR_STATUS = "p2p-replicator-status"; -export declare const EVENT_P2P_REPLICATOR_PROGRESS = "p2p-replicator-progress"; -declare global { - interface LSEvents { - [EVENT_SERVER_STATUS]: P2PServerInfo; - [EVENT_MAKE_DECISION]: AcceptanceDecision; - [EVENT_REVOKE_DECISION]: RevokeAcceptanceDecision; - [EVENT_ADVERTISEMENT_RECEIVED]: Advertisement; - [EVENT_DEVICE_LEAVED]: string; - [EVENT_REQUEST_STATUS]: undefined; - [EVENT_P2P_REQUEST_FORCE_OPEN]: undefined; - [EVENT_P2P_CONNECTED]: undefined; - [EVENT_P2P_DISCONNECTED]: undefined; - } -} -export declare class TrysteroReplicatorP2PServer { - _env: ReplicatorHostEnv; - _room?: Room; - _serverPeerId: string; - _activeRoomId: string; - ___send?: ActionSender; - assignedFunctions: Map; - clients: Map; - _bindingObjects: BindableObject[]; - _rpcRoom?: RpcRoom; - protected _peerStatusEventCleanup: (() => void) | undefined; - protected _peerFailureAnalysisCleanup: (() => void) | undefined; - protected _peerConnectionEventCleanup(): void; - _diagStats: DiagRTCStats; - get isDisposed(): boolean; - get isServing(): boolean; - ensureLeaved(): Promise; - setRoom(room: Room): Promise; - shutdown(): Promise; - dispatchConnectionStatus(): Promise; - constructor(env: ReplicatorHostEnv, _serverPeerId?: string); - makeDecision(decision: AcceptanceDecision): Promise; - revokeDecision(decision: RevokeAcceptanceDecision): Promise; - get room(): Room | undefined; - get serverPeerId(): string; - get db(): PouchDB.Database; - get confirm(): import("../../interfaces/Confirm").Confirm; - get settings(): P2PSyncSetting; - get isEnabled(): boolean; - get deviceInfo(): FullFilledDeviceInfo; - _sendAdvertisement?: ActionSender; - sendAdvertisement(peerId?: string): void; - _knownAdvertisements: Map; - get knownAdvertisements(): Advertisement[]; - onAdvertisement(data: Advertisement, peerId: string): void; - acceptedPeers: StoredMapLike; - temporaryAcceptedPeers: Map; - confirmUserToAccept(peerId: string): Promise; - _confirmUserToAccept(peerId: string): Promise; - _acceptablePeers: Computed<[settings: P2PSyncSetting], RegExp[]>; - _shouldDenyPeers: Computed<[settings: P2PSyncSetting], RegExp[]>; - isAcceptablePeer(peerId: string): Promise; - __send(data: Payload, peerId: string): Promise; - processArrivedRPC(data: Payload, peerId: string): Promise; - private _onPeerJoin; - private _onPeerLeave; - activePeer: Map; - onAfterJoinRoom(): void; - startService(bindings?: BindableObject[]): Promise; - start(bindings?: BindableObject[]): Promise; - /** - * @deprecated Use serveFunction or serveObject instead. This is only for backward compatibility and may be removed in the future. - * @param type - * @param func - */ - serveFunction(type: string, func: (peerId: string, ...args: T) => U | Promise): void; - serveObject(obj: BindableObject): void; - __onResponse(data: Response, peerId: string): void; - __onRequest(data: Request, peerId: string): Promise; - close(): Promise; - getConnection(peerId: string): TrysteroReplicatorP2PClient; - get rpcRoom(): RpcRoom | undefined; -} -export { TrysteroReplicatorP2PServer as P2PHost }; diff --git a/_types/src/lib/src/replication/trystero/UseP2PReplicatorResult.d.ts b/_types/src/lib/src/replication/trystero/UseP2PReplicatorResult.d.ts deleted file mode 100644 index e25de199..00000000 --- a/_types/src/lib/src/replication/trystero/UseP2PReplicatorResult.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive_v2"; -import type { LiveSyncTrysteroReplicator } from "./LiveSyncTrysteroReplicator"; -import type { P2PLogCollector } from "./P2PLogCollector"; -export type UseP2PReplicatorResult = { - replicator: LiveSyncTrysteroReplicator; -}; -export type P2PPaneParams = { - replicator: LiveSyncTrysteroReplicator; - p2pLogCollector: P2PLogCollector; - storeP2PStatusLine: ReactiveSource; -}; diff --git a/_types/src/lib/src/replication/trystero/addP2PEventHandlers.d.ts b/_types/src/lib/src/replication/trystero/addP2PEventHandlers.d.ts deleted file mode 100644 index 66813e65..00000000 --- a/_types/src/lib/src/replication/trystero/addP2PEventHandlers.d.ts +++ /dev/null @@ -1,36 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { LiveSyncTrysteroReplicator } from "./LiveSyncTrysteroReplicator"; -import type { Advertisement } from "./types"; -/** - * Minimal interface that a P2P replicator instance should satisfy for addP2PEventHandlers to work. - */ -export interface P2PReplicatorLike { - onNewPeer(peer: Advertisement): Promise | void; - onPeerLeaved(peerId: string): void; - requestStatus(): void; - open(): Promise; - close(): Promise; - /** Indicates whether the room is currently active. */ - readonly isServing?: boolean; - /** Legacy: host object that may carry isServing (LiveSyncTrysteroReplicator). */ - readonly server?: { - isServing?: boolean; - }; -} -/** - * Add event handlers for P2P replication related events. - * @param instance P2PReplicatorLike instance - */ -export declare function addP2PEventHandlers(instance: P2PReplicatorLike): void; -/** - * open P2P replicator if not opened yet. - * @param instance - */ -export declare function openP2PReplicator(instance: P2PReplicatorLike): Promise; -/** - * close P2P replicator - * @param instance - */ -export declare function closeP2PReplicator(instance: P2PReplicatorLike): Promise; -export type { LiveSyncTrysteroReplicator }; diff --git a/_types/src/lib/src/replication/trystero/rpcCompat.d.ts b/_types/src/lib/src/replication/trystero/rpcCompat.d.ts deleted file mode 100644 index fed02e23..00000000 --- a/_types/src/lib/src/replication/trystero/rpcCompat.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare function toRpcMethodName(method: string): string; diff --git a/_types/src/lib/src/replication/trystero/types.d.ts b/_types/src/lib/src/replication/trystero/types.d.ts deleted file mode 100644 index 601c0a78..00000000 --- a/_types/src/lib/src/replication/trystero/types.d.ts +++ /dev/null @@ -1,91 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { JsonLike } from "@lib/rpc"; -import type { P2PSyncSetting, EntryDoc } from "@lib/common/types"; -import type { SimpleStore } from "@lib/common/utils"; -import type { Confirm } from "@lib/interfaces/Confirm"; -import type { AsyncActivityRunner } from "@lib/interfaces/AsyncActivityRunner"; -export declare const DIRECTION_REQUEST = "request"; -export type DIRECTION_REQUEST = typeof DIRECTION_REQUEST; -export declare const DIRECTION_RESPONSE = "response"; -export type DIRECTION_RESPONSE = typeof DIRECTION_RESPONSE; -export declare const DEFAULT_RPC_TIMEOUT = 30000; -export declare const BULK_GET_RPC_TIMEOUT = 40000; -export type BindableFunction = (...args: any[]) => any; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration -export type NonPrivateMethodKeys = { - [K in keyof T]: K extends `_${string}` ? never : K extends `constructor` ? never : T[K] extends BindableFunction ? K : never; -}[keyof T]; -export type BindableObject = { // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - [k in NonPrivateMethodKeys]: T[k] extends BindableFunction ? T[k] : never; -}; -export type ConnectionInfo = { - relayURIs: string[]; - roomId: string; - password: string; - appId: string; -}; -export declare class ResponsePreventedError extends Error { - constructor(message: string); -} -export type Request = { - type: string; - direction: DIRECTION_REQUEST; - seq: number; - args: T; -}; -export type Response = { - type: string; - direction: DIRECTION_RESPONSE; - seq: number; - data?: T; - error?: JsonLike; -}; -export type DeviceInfo = { - currentPeerId: string; - name?: string; - version?: string; - platform?: string; - decision?: DeviceDecisions; -}; -export type DeviceInfoForRequest = { - currentPeerId: string; - name: string; -}; -export type FullFilledDeviceInfo = { - currentPeerId: string; - name: string; - version: string; - platform: string; - decision?: DeviceDecisions; -}; -export declare enum DeviceDecisions { - ACCEPT = "accepted", - REJECT = "rejected", - IGNORE = "ignore" -} -export declare const ID_P2PKnownDevices = "_local/P2PKnownDevices"; -export type KnownDevices = { - _id: typeof ID_P2PKnownDevices; - devices: { - [deviceName: string]: DeviceDecisions; - }; -}; -export type Payload = Request | Response; -export interface ReplicatorHost { - deviceName: string; - platform: string; - confirm: Confirm; -} -export interface ReplicatorHostEnv extends ReplicatorHost { - settings: P2PSyncSetting; - db: PouchDB.Database; - simpleStore: SimpleStore; - runBoundedRemoteActivity?: AsyncActivityRunner["run"]; - processReplicatedDocs(docs: Array>): void | Promise; -} -export type Advertisement = { - peerId: string; - name: string; - platform: string; -}; -export declare const KEY_DEVICE_DECISIONS = "p2p-device-decisions"; diff --git a/_types/src/lib/src/replication/trystero/useP2PReplicatorCommands.d.ts b/_types/src/lib/src/replication/trystero/useP2PReplicatorCommands.d.ts deleted file mode 100644 index 2798cbf5..00000000 --- a/_types/src/lib/src/replication/trystero/useP2PReplicatorCommands.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; -import type { UseP2PReplicatorResult } from "./UseP2PReplicatorResult"; -/** - * ServiceFeature: Registers event handlers for P2P replication and manages the lifecycle of a LiveSyncTrysteroReplicator instance. - * @param host - */ -export declare function useP2PReplicatorCommands(host: NecessaryServices<"API" | "setting", never>, { replicator }: UseP2PReplicatorResult): void; diff --git a/_types/src/lib/src/replication/trystero/useP2PReplicatorFeature.d.ts b/_types/src/lib/src/replication/trystero/useP2PReplicatorFeature.d.ts deleted file mode 100644 index bf086484..00000000 --- a/_types/src/lib/src/replication/trystero/useP2PReplicatorFeature.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; -import { LiveSyncTrysteroReplicator } from "./LiveSyncTrysteroReplicator"; -import { type UseP2PReplicatorResult } from "./UseP2PReplicatorResult"; -/** - * Factory type: given a replicator instance, returns the openReplicationUI callback for that instance. - * Injected by the host platform (e.g. Obsidian). CLI/headless environments omit this. - */ -export type OpenReplicationUIFactory = (replicator: LiveSyncTrysteroReplicator) => (showResult: boolean) => Promise; -/** Same shape as OpenReplicationUIFactory, used for the rebuild/replicateAllFromServer flow. */ -export type OpenRebuildUIFactory = OpenReplicationUIFactory; -/** - * ServiceFeature: P2P Replicator integration and lifecycle management. - * Registers a LiveSyncTrysteroReplicator instance as the active replicator when P2P is enabled in settings, - * and binds it to lifecycle events for proper initialization and cleanup. - * @param host - */ -export declare function useP2PReplicatorFeature(host: NecessaryServices<"API" | "appLifecycle" | "setting" | "vault" | "database" | "databaseEvents" | "keyValueDB" | "replication" | "config" | "UI" | "replicator" | "remote", never>, openReplicationUIFactory?: OpenReplicationUIFactory, openRebuildUIFactory?: OpenRebuildUIFactory): UseP2PReplicatorResult; diff --git a/_types/src/lib/src/rpc/RpcRoom.d.ts b/_types/src/lib/src/rpc/RpcRoom.d.ts deleted file mode 100644 index 3cf7b519..00000000 --- a/_types/src/lib/src/rpc/RpcRoom.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { RpcSession } from "./RpcSession"; -import { type JsonLike, type RpcMethodHandler, type RpcRegisterOptions, type RpcRoomOptions } from "./types"; -export declare class RpcRoom { - private options; - private pending; - private inboundCalls; - private methods; - private sessions; - private outgoingChunkMap; - private incomingChunkMap; - private incomingChunkTimers; - private peerVersion; - private disposer; - constructor(options: RpcRoomOptions); - close(): void; - session(peerId: string): RpcSession; - register(method: string, handler: RpcMethodHandler, options?: RpcRegisterOptions): void; - invoke(peerId: string, method: string, args: JsonLike[], timeoutMs?: number): Promise; - cancel(peerId: string, requestId: string): Promise; - private sendEnvelope; - private scheduleMissingAck; - private onWireMessage; - private onEnvelopePayload; -} diff --git a/_types/src/lib/src/rpc/RpcSession.d.ts b/_types/src/lib/src/rpc/RpcSession.d.ts deleted file mode 100644 index 85849e4e..00000000 --- a/_types/src/lib/src/rpc/RpcSession.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { JsonLike } from "./types"; -import type { RpcRoom } from "./RpcRoom"; -export declare class RpcSession { - readonly peerId: string; - private room; - constructor(room: RpcRoom, peerId: string); - call(method: string, args?: JsonLike[], timeoutMs?: number): Promise; - createProxy(namespace: string): T; -} diff --git a/_types/src/lib/src/rpc/chunking.d.ts b/_types/src/lib/src/rpc/chunking.d.ts deleted file mode 100644 index 70ffd726..00000000 --- a/_types/src/lib/src/rpc/chunking.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare function estimateBytes(text: string): number; -export declare function splitIntoChunks(payload: string, maxBytes: number): string[]; -export declare class IncomingChunkBuffer { - total: number; - parts: Map; - constructor(total: number); - add(index: number, payload: string): void; - missingIndices(): number[]; - isComplete(): boolean; - toPayload(): string; -} diff --git a/_types/src/lib/src/rpc/errors.d.ts b/_types/src/lib/src/rpc/errors.d.ts deleted file mode 100644 index 83972c90..00000000 --- a/_types/src/lib/src/rpc/errors.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { JsonLike, RpcErrorCode, RpcErrorShape } from "./types"; -export declare class RpcError extends Error { - code: RpcErrorCode; - details?: JsonLike; - constructor(code: RpcErrorCode, message: string, details?: JsonLike); - toShape(): RpcErrorShape; -} -export declare function asRpcErrorShape(ex: unknown): RpcErrorShape; diff --git a/_types/src/lib/src/rpc/index.d.ts b/_types/src/lib/src/rpc/index.d.ts deleted file mode 100644 index ad81af57..00000000 --- a/_types/src/lib/src/rpc/index.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export { RpcRoom } from "./RpcRoom"; -export { RpcSession } from "./RpcSession"; -export { RpcError } from "./errors"; -export { exposeDB } from "./pouchdb/RpcPouchDBServer"; -export { RpcPouchDBProxy } from "./pouchdb/RpcPouchDBProxy"; -export type { JsonLike, RpcEnvelope, RpcErrorCode, RpcErrorShape, RpcMethodHandler, RpcRegisterOptions, RpcRoomOptions, RpcWireMessage, TransportAdapter, } from "./types"; diff --git a/_types/src/lib/src/rpc/pouchdb/RpcPouchDBProxy.d.ts b/_types/src/lib/src/rpc/pouchdb/RpcPouchDBProxy.d.ts deleted file mode 100644 index 75146fb8..00000000 --- a/_types/src/lib/src/rpc/pouchdb/RpcPouchDBProxy.d.ts +++ /dev/null @@ -1,86 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { RpcSession } from "@lib/rpc/RpcSession"; -/** - * A PouchDB-compatible proxy that forwards all database operations to a remote - * peer via an {@link RpcSession}. - * - * The proxy exposes the same public interface as a PouchDB instance and can be - * passed directly to `PouchDB.replicate()` or `PouchDB.sync()` as either the - * source or the target database. It can also be used with {@link replicateShim} - * from `ReplicatorShim.ts`. - * - * The remote side must have called {@link exposeDB} to register the matching - * RPC handlers. - * - * ### Changes feed - * `changes()` returns an object that satisfies both the EventEmitter interface - * required by `pouchdb-replication` and the Promise interface required by - * `replicateShim` (i.e. it can be `await`ed directly). - * - * ### Error propagation - * PouchDB-specific error properties (`status`, `name`, `reason`) are preserved - * across the RPC transport and reconstructed on the proxy side so that callers - * such as `pouchdb-checkpointer` can inspect `err.status === 404` correctly. - */ -export declare class RpcPouchDBProxy extends EventEmitter { - /** The logical name of the remote database. */ - readonly name: string; - /** - * Stub ActiveTasks object required by `pouchdb-replication`. All - * operations are no-ops; task state is not tracked across the RPC boundary. - */ - readonly activeTasks: { - add: (_task: object) => unknown; - get: (_id: unknown) => unknown; - update: (_id: unknown, _update: object) => void; - remove: (_id: unknown, _err?: Error) => void; - list: () => unknown[]; - }; - private readonly session; - private readonly ns; - constructor(session: RpcSession, name: string, ns?: string); - /** - * Invoke an RPC method and reconstruct PouchDB error shapes on the response. - * - * When the remote handler wraps a PouchDB error via {@link exposeDB}'s - * `runDB` helper, the `RpcError.details` object carries `status`, `name`, - * and `reason`. This method rebuilds a plain `Error` with those properties - * so that callers (e.g. pouchdb-checkpointer) can use `err.status` / - * `err.name` as expected. - */ - private callDB; - info(): Promise; - id(): Promise; - /** - * Returns a `Changes`-compatible object that is simultaneously: - * - An **EventEmitter** with `change`, `complete`, and `error` events, plus - * a `cancel()` method — satisfying the interface consumed by - * `PouchDB.replicate()` / `PouchDB.sync()`. - * - A **thenable** (`then` / `catch`) — allowing `await db.changes(opts)` - * as used by `replicateShim`. - * - * The remote changes feed is always fetched as a one-shot snapshot - * (`live: false`). - */ - changes(opts: PouchDB.Core.ChangesOptions): PouchDB.Core.Changes; - get(id: string, opts?: PouchDB.Core.GetOptions): Promise; - put(doc: PouchDB.Core.PutDocument, opts?: PouchDB.Core.PutOptions): Promise; - bulkGet(opts: PouchDB.Core.BulkGetOptions): Promise>; - bulkDocs(docs: PouchDB.Core.PostDocument[] | { - docs: PouchDB.Core.PostDocument[]; - new_edits?: boolean; - }, opts?: PouchDB.Core.BulkDocsOptions): Promise<(PouchDB.Core.Response | PouchDB.Core.Error)[]>; - revsDiff(diff: PouchDB.Core.RevisionDiffOptions): Promise; - allDocs(opts?: PouchDB.Core.AllDocsOptions): Promise>; -} - -class EventEmitter { - on(event: string | symbol, listener: (...args: any[]) => void): this; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - once(event: string | symbol, listener: (...args: any[]) => void): this; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - off(event: string | symbol, listener: (...args: any[]) => void): this; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - emit(event: string | symbol, ...args: any[]): boolean; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - addListener(event: string | symbol, listener: (...args: any[]) => void): this; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - removeListener(event: string | symbol, listener: (...args: any[]) => void): this; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - removeAllListeners(event: string | symbol): this; -} diff --git a/_types/src/lib/src/rpc/pouchdb/RpcPouchDBServer.d.ts b/_types/src/lib/src/rpc/pouchdb/RpcPouchDBServer.d.ts deleted file mode 100644 index 232f2c40..00000000 --- a/_types/src/lib/src/rpc/pouchdb/RpcPouchDBServer.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { RpcRoom } from "@lib/rpc/RpcRoom"; -/** - * Exposes a PouchDB database as a set of RPC methods registered on an - * {@link RpcRoom}. The remote peer can access the database via - * {@link RpcPouchDBProxy}. - * - * All methods are registered under the given namespace prefix `ns` (default: - * `'pdb'`). For example, with the default namespace the method names are - * `pdb.info`, `pdb.id`, `pdb.changes`, `pdb.get`, `pdb.put`, `pdb.bulkGet`, - * `pdb.bulkDocs`, `pdb.revsDiff`, and `pdb.allDocs`. - * - * @param room The {@link RpcRoom} on which to register handlers. - * @param db The PouchDB database instance to expose. - * @param ns Method namespace prefix (default: `'pdb'`). - */ -export declare function exposeDB(room: RpcRoom, db: PouchDB.Database, ns?: string): void; diff --git a/_types/src/lib/src/rpc/transports/DiagRTCPeerConnections.d.ts b/_types/src/lib/src/rpc/transports/DiagRTCPeerConnections.d.ts deleted file mode 100644 index 7b00ab20..00000000 --- a/_types/src/lib/src/rpc/transports/DiagRTCPeerConnections.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { DiagRTCStats, DiagRTCFailureDiagnosis } from "./DiagRTCPeerConnections.types"; -/** - * Subscribes to connection status updates. The callback will be called with the latest connection statistics whenever there is a change in the connection status of any RTCPeerConnection instance. - * Returns an unsubscribe function to stop receiving updates. - * - * @param callback - The function to call with the latest connection statistics. - * @returns A function that can be called to unsubscribe from updates. - */ -export declare function subscribeConnectionStatus(callback: (status: DiagRTCStats) => void): () => void; -/** - * Subscribes to failure diagnosis updates. The callback will be called with the diagnosis information whenever a connection failure is detected in any RTCPeerConnection instance. - * Returns an unsubscribe function to stop receiving updates. - * @param callback - The function to call with the diagnosis information. - * @returns A function that can be called to unsubscribe from updates. - */ -export declare function subscribeFailureDiagnosis(callback: (diagnosis: DiagRTCFailureDiagnosis) => void): () => void; -export type DiagRTCPeerConnectionConstructor = typeof RTCPeerConnection; -/** - * A wrapper around RTCPeerConnection to collect statistics for diagnostics. - * It extends the native (or globally-polyfilled) RTCPeerConnection and overrides its constructor to add event listeners for connection state changes, - * ice connection state changes, ice gathering state changes, and signaling state changes. It maintains a history of these states and logs the progress. - * It also tracks the number of new connections, failed connections, successful connections, and closed connections, and dispatches this information to subscribers. - */ -export declare function createDiagRTCPeerConnectionConstructor(): DiagRTCPeerConnectionConstructor; diff --git a/_types/src/lib/src/rpc/transports/DiagRTCPeerConnections.types.d.ts b/_types/src/lib/src/rpc/transports/DiagRTCPeerConnections.types.d.ts deleted file mode 100644 index cfaebd51..00000000 --- a/_types/src/lib/src/rpc/transports/DiagRTCPeerConnections.types.d.ts +++ /dev/null @@ -1,66 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export type DiagRTCConnectionStatus = { - connectionState: RTCPeerConnection["connectionState"]; - iceConnectionState: RTCPeerConnection["iceConnectionState"]; -}; -export type DiagRTCStats = { - totalNewConnections: number; - totalFailedConnections: number; - totalSuccessfulConnections: number; - totalClosedConnections: number; - details: Record; -}; -export type DiagRTCPeerConnectionInternalStateHistory = { - connectionHistory: RTCPeerConnection["connectionState"][]; - iceConnectionHistory: RTCPeerConnection["iceConnectionState"][]; - iceGatheringHistory: RTCPeerConnection["iceGatheringState"][]; - signalingHistory: RTCPeerConnection["signalingState"][]; -}; -export declare const DiagRTCFailureReasonCodes: { - readonly ICE_GATHERING_NOT_COMPLETED: "ICE_GATHERING_NOT_COMPLETED"; - readonly ICE_CONNECTIVITY_FAILED: "ICE_CONNECTIVITY_FAILED"; - readonly STUN_REQUEST_TIMEOUT: "STUN_REQUEST_TIMEOUT"; - readonly SIGNALING_NOT_STABLE: "SIGNALING_NOT_STABLE"; - readonly CONNECTION_DROPPED_AFTER_ESTABLISHED: "CONNECTION_DROPPED_AFTER_ESTABLISHED"; - readonly NETWORK_INTERRUPTED: "NETWORK_INTERRUPTED"; - readonly UNKNOWN: "UNKNOWN"; -}; -export type DiagRTCFailureDiagnosis = { - reasonCode: keyof typeof DiagRTCFailureReasonCodes; - userMessage: string; -}; -export type DiagRTCFailureStats = { - diagnosis: DiagRTCFailureDiagnosis; - stats: { - reportsCount: number; - selectedPair: { - id: string; - state: string; - localCandidateId: string; - remoteCandidateId: string; - currentRoundTripTime: number | "unknown"; - totalRoundTripTime: number | "unknown"; - requestsSent: number | "unknown"; - responsesReceived: number | "unknown"; - packetsDiscardedOnSend: number | "unknown"; - bytesSent: number | "unknown"; - bytesReceived: number | "unknown"; - }; - }; -}; -export type DiagRTCPeerConnectionMetrics = { - selectedPair: Record | undefined; - selectedPairId: string; - state: string; - localCandidateId: string; - remoteCandidateId: string; - currentRoundTripTime: number | "unknown"; - totalRoundTripTime: number | "unknown"; - requestsSent: number | "unknown"; - responsesReceived: number | "unknown"; - packetsDiscardedOnSend: number | "unknown"; - bytesSent: number | "unknown"; - bytesReceived: number | "unknown"; - reports: unknown[]; -}; diff --git a/_types/src/lib/src/rpc/transports/DiagRTCPeerConnections.utils.d.ts b/_types/src/lib/src/rpc/transports/DiagRTCPeerConnections.utils.d.ts deleted file mode 100644 index 9cf4c3af..00000000 --- a/_types/src/lib/src/rpc/transports/DiagRTCPeerConnections.utils.d.ts +++ /dev/null @@ -1,35 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type DiagRTCPeerConnectionInternalStateHistory, type DiagRTCFailureDiagnosis, type DiagRTCPeerConnectionMetrics, type DiagRTCFailureStats } from "./DiagRTCPeerConnections.types"; -/** - * Diagnoses the failure reason of a failed RTCPeerConnection based on its internal state history and selected candidate pair information. - * @param internalStateHistory The internal state history of the RTCPeerConnection. - * @param selectedPair The selected candidate pair information. - * @returns The diagnosis of the RTC failure. - */ -export declare function diagnoseRtcFailure(internalStateHistory: DiagRTCPeerConnectionInternalStateHistory, selectedPair: Record | undefined): DiagRTCFailureDiagnosis; -/** - * Describes the current progress of the RTCPeerConnection based on its internal state history and selected candidate pair information. - * This is useful for providing user-friendly status messages during the connection process. - * @param internalStateHistory The internal state history of the RTCPeerConnection. - * @param selectedPair The selected candidate pair information. - * @returns A user-friendly description of the current connection progress. - */ -export declare function describeRTCProgress(internalStateHistory: DiagRTCPeerConnectionInternalStateHistory, selectedPair: Record | undefined): string; -/** - * Fetches the RTCPeerConnection statistics and returns a structured metrics object. - * This is useful for diagnosing connection issues and understanding the connection performance. - * @param instanceId An identifier for the RTCPeerConnection instance, used for logging. - * @param peer The RTCPeerConnection instance to fetch stats from. - * @returns A structured object containing the connection metrics, or undefined if fetching stats failed. - */ -export declare function getPeerConnectionStats(instanceId: string, peer: RTCPeerConnection): Promise; -/** - * Audits the RTCPeerConnection for failures and returns a structured failure report. - * This is useful for diagnosing connection issues and understanding the reasons for failure. - * @param instanceId An identifier for the RTCPeerConnection instance, used for logging. - * @param internalStateHistory The internal state history of the RTCPeerConnection. - * @param peer The RTCPeerConnection instance to audit. - * @returns A structured object containing the failure diagnosis and metrics, or undefined if auditing failed. - */ -export declare function auditRtcConnectionFailures(instanceId: string, internalStateHistory: DiagRTCPeerConnectionInternalStateHistory, peer: RTCPeerConnection): Promise; diff --git a/_types/src/lib/src/rpc/transports/TrysteroTransport.d.ts b/_types/src/lib/src/rpc/transports/TrysteroTransport.d.ts deleted file mode 100644 index 13026697..00000000 --- a/_types/src/lib/src/rpc/transports/TrysteroTransport.d.ts +++ /dev/null @@ -1,218 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type Room } from "@trystero-p2p/nostr"; -import { RpcRoom } from "@lib/rpc/RpcRoom"; -import { RpcPouchDBProxy } from "@lib/rpc/pouchdb/RpcPouchDBProxy"; -import type { RpcRoomOptions, TransportAdapter } from "@lib/rpc/types"; -import type { P2PConnectionInfo } from "@lib/common/models/setting.type"; -/** - * Lightweight presence record broadcast by each peer when it joins the room. - * The `peerId` field MUST match the Trystero sender ID to prevent spoofing. - */ -export type TrysteroAdvertisement = { - /** The Trystero `selfId` of the sending peer. */ - peerId: string; - /** Human-readable device / vault name. */ - name: string; - /** Optional platform tag (e.g. `"desktop"`, `"mobile"`). */ - platform: string; -}; -/** The handle returned by {@link attachAdvertisement}. */ -export type AdvertisementHandle = { - /** - * All currently known peers, keyed by `peerId`. - * Updated in real-time as advertisements arrive or peers leave. - */ - readonly peers: ReadonlyMap; - /** - * (Re-)broadcast your own advertisement. - * Pass a `toPeerId` to send only to that peer; omit to broadcast to all. - */ - sendAdvertisement(toPeerId?: string): Promise; - /** Stop listening for advertisements and clear the peer map. */ - stop(): void; -}; -/** - * Attach advertisement-based peer discovery to an already-joined Trystero room. - * - * - Sends your advertisement to every peer that joins after this call. - * - Stores incoming advertisements in `handle.peers`. - * - Removes entries when peers leave. - * - * @param room An active Trystero room. - * @param localPeerId Your own Trystero `selfId`. - * @param name Human-readable name broadcast to other peers. - * @param platform Optional platform string (default `"unknown"`). - */ -export declare function attachAdvertisement(room: Room, localPeerId: string, name: string, platform?: string): AdvertisementHandle; -/** - * Options forwarded to the `RpcRoom` constructor when creating a room through - * the high-level helpers (`serveTrysteroDB`, `connectTrysteroDBClient`, or by - * calling `joinTrysteroRoom` and then constructing `RpcRoom` manually). - * - * Trystero internally chunks at ~16 348 bytes (16 KiB minus a 36-byte header). - * Setting `maxWirePayloadBytes` above that threshold causes double-chunking. - * The Trystero-appropriate default is therefore **15 360 bytes** (15 KiB), - * which leaves ~1 KiB of headroom for the JSON wrapper overhead. - * - * Trystero uses WebRTC SCTP data channels which guarantee delivery and order, - * so missing-chunk retransmission virtually never triggers. `chunkMissingRetryMs` - * can safely be lowered from the generic default of 350 ms to **150 ms**. - */ -export type TrysteroRoomOptions = Pick & { - maxWirePayloadBytes?: number; - chunkMissingRetryMs?: number; -}; -/** Trystero-appropriate defaults for `RpcRoom`. */ -export declare const TRYSTERO_RPC_DEFAULTS: { - /** Stay below Trystero's internal ~16 348-byte chunk boundary. */ - readonly maxWirePayloadBytes: number; - /** SCTP guarantees delivery; retransmission is a last-resort safety net. */ - readonly chunkMissingRetryMs: 150; -}; -/** - * Wrap an already-joined Trystero `Room` as a `TransportAdapter` for `RpcRoom`. - * - * A dedicated Trystero action channel named `"rpc2"` is created on the room. - * Note: Trystero does not expose per-handler unsubscription for `onPeerJoin` - * / `onPeerLeave`, so the returned cleanup stubs are no-ops. Close the - * Trystero room via `leave()` when done. - * - * @param room An active Trystero `Room` returned by `joinRoom()`. - * @returns A `TransportAdapter` that can be passed to `new RpcRoom(...)`. - */ -export declare function wrapTrysteroRoom(room: Room): TransportAdapter; -/** The result of joining a Trystero room. */ -export type TrysteroRoomHandle = { - /** `TransportAdapter` ready to pass to `new RpcRoom(...)`. */ - transport: TransportAdapter; - /** This peer's own ID (Trystero `selfId`). */ - peerId: string; - /** Leave the Trystero room and release resources. */ - leave: () => Promise; - /** - * Attach advertisement-based peer discovery to this room. - * Call once after joining; returns a handle to read and re-broadcast. - */ - advertise: (name: string, platform?: string) => AdvertisementHandle; - /** - * Returns the current WebRTC peer connections keyed by peer ID. - * Equivalent to the Trystero `room.getPeers()` call. - */ - getPeers: () => Record; - /** - * The underlying Trystero `Room` instance. - * Use when you need raw access to Trystero APIs such as `makeAction`, - * `onPeerJoin`, or `onPeerLeave` beyond what the helpers expose. - */ - room: Room; -}; -/** - * Join a Trystero room from a {@link P2PConnectionInfo} and return a - * `TransportAdapter` together with this peer's own ID. - * - * The raw passphrase is hashed with `mixedHash` before being passed to - * Trystero, matching the convention used by `TrysteroReplicatorP2PServer`. - */ -export declare function joinTrysteroRoom(settings: P2PConnectionInfo): TrysteroRoomHandle; -/** - * Join a Trystero room from a `sls+p2p://` connection string and return a - * `TransportAdapter` together with this peer's own ID. - * - * The URL must be parseable by {@link ConnectionStringParser.parse} as type - * `"p2p"`, i.e. it must start with `sls+p2p://`. - * - * @example - * ```ts - * const handle = joinTrysteroRoomFromUrl( - * "sls+p2p://my-room?relays=wss://relay.example.com&appId=my-app" - * ); - * const rpcRoom = new RpcRoom({ transport: handle.transport }); - * ``` - */ -export declare function joinTrysteroRoomFromUrl(url: string): TrysteroRoomHandle; -/** The result of starting a DB server over Trystero. */ -export type TrysteroDBServerHandle = { - /** This peer's own ID. Share it with clients so they can call `session()`. */ - peerId: string; - /** The `RpcRoom` that hosts the DB methods. */ - rpcRoom: RpcRoom; - /** Stop serving and leave the room. */ - close: () => Promise; - /** - * Attach advertisement-based peer discovery so that clients can find this - * server without needing the peerId passed out-of-band. - */ - advertise: (name: string, platform?: string) => AdvertisementHandle; -}; -/** - * **Server side.** Join a Trystero room and expose `db` as a set of RPC - * methods. The caller's `peerId` is returned so that it can be shared with - * clients (e.g. via a separate signalling channel or advertisement). - * - * @param settings P2P connection settings (from `P2PConnectionInfo` or parsed - * from a `sls+p2p://` URL via {@link ConnectionStringParser}). - * @param db The PouchDB database to expose. - * @param ns Method namespace (default `"pdb"`). - * - * @example - * ```ts - * const server = serveTrysteroDB(settings, db); - * console.log("server peer ID:", server.peerId); - * // Share server.peerId with the client out-of-band. - * ``` - */ -export declare function serveTrysteroDB(settings: P2PConnectionInfo, db: PouchDB.Database, ns?: string, options?: TrysteroRoomOptions): TrysteroDBServerHandle; -/** The result of connecting to a DB server over Trystero. */ -export type TrysteroDBClientHandle = { - /** Proxy object usable with `PouchDB.replicate()` or `replicateShim()`. */ - proxy: RpcPouchDBProxy; - /** The `RpcRoom` used to communicate with the server. */ - rpcRoom: RpcRoom; - /** Disconnect and leave the room. */ - close: () => Promise; - /** - * Attach advertisement-based peer discovery so this client can locate the - * server peer without needing its peerId passed statically. - */ - advertise: (name: string, platform?: string) => AdvertisementHandle; -}; -/** - * **Client side.** Join a Trystero room and create an {@link RpcPouchDBProxy} - * pointing at `serverPeerId`. The proxy can be passed directly to - * `PouchDB.replicate()` or `replicateShim()`. - * - * @param settings P2P connection settings. - * @param serverPeerId The `peerId` of the server peer (returned by - * {@link serveTrysteroDB}). - * @param dbName Logical name for the remote database. - * @param ns Method namespace, must match the server's `ns` - * (default `"pdb"`). - * - * @example - * ```ts - * const client = connectTrysteroDBClient(settings, serverPeerId, "my-db"); - * await PouchDB.replicate(client.proxy, localDb, { live: false }); - * await client.close(); - * ``` - */ -export declare function connectTrysteroDBClient(settings: P2PConnectionInfo, serverPeerId: string, dbName: string, ns?: string, options?: TrysteroRoomOptions): TrysteroDBClientHandle; -/** - * Join a Trystero room, advertise as `name`, wait `timeoutMs`, collect all - * received peer advertisements, then leave the room and return the results. - * - * Useful for CLI-style peer discovery where you need a one-shot list of - * present peers before deciding how to connect. - * - * @param settings P2P connection settings. - * @param name Your local device / vault name advertised to others. - * @param timeoutMs How long to listen before returning (milliseconds). - * @param platform Optional platform tag (default `"unknown"`). - * - * @example - * ```ts - * const peers = await collectTrysteroAdvertisements(settings, "my-vault", 5000); - * console.log(peers.map(p => `${p.name} (${p.peerId})`)); - * ``` - */ -export declare function collectTrysteroAdvertisements(settings: P2PConnectionInfo, name: string, timeoutMs: number, platform?: string): Promise; diff --git a/_types/src/lib/src/rpc/transports/trysteroUtils.d.ts b/_types/src/lib/src/rpc/transports/trysteroUtils.d.ts deleted file mode 100644 index 3bdd5caa..00000000 --- a/_types/src/lib/src/rpc/transports/trysteroUtils.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { BaseRoomConfig } from "@trystero-p2p/nostr"; -import type { P2PConnectionInfo } from "@lib/common/models/setting.type"; -export declare function generateJoinRoomOptions(settings: P2PConnectionInfo): BaseRoomConfig; diff --git a/_types/src/lib/src/rpc/types.d.ts b/_types/src/lib/src/rpc/types.d.ts deleted file mode 100644 index a518eb00..00000000 --- a/_types/src/lib/src/rpc/types.d.ts +++ /dev/null @@ -1,77 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare const RPC_VERSION_MAJOR = 1; -export declare const RPC_VERSION_MINOR = 0; -export type JsonLike = null | boolean | number | string | JsonLike[] | { - [key: string]: JsonLike; -}; -export type RpcErrorCode = "TIMEOUT" | "NOT_CONNECTED" | "REMOTE_ERROR" | "CANCELLED" | "PROTOCOL_ERROR"; -export type RpcErrorShape = { - code: RpcErrorCode; - message: string; - details?: JsonLike; -}; -export type RpcRequestEnvelope = { - kind: "request"; - requestId: string; - method: string; - args: JsonLike[]; -}; -export type RpcResponseEnvelope = { - kind: "response"; - requestId: string; - ok: true; - data: JsonLike; -}; -export type RpcResponseErrorEnvelope = { - kind: "response"; - requestId: string; - ok: false; - error: RpcErrorShape; -}; -export type RpcCancelEnvelope = { - kind: "cancel"; - requestId: string; -}; -export type RpcHandshakeEnvelope = { - kind: "handshake"; - versionMajor: number; - versionMinor: number; -}; -export type RpcEnvelope = RpcRequestEnvelope | RpcResponseEnvelope | RpcResponseErrorEnvelope | RpcCancelEnvelope | RpcHandshakeEnvelope; -export type RpcWireMessageRaw = { - wire: "raw"; - payload: string; -}; -export type RpcWireMessageChunk = { - wire: "chunk"; - streamId: string; - index: number; - total: number; - payload: string; -}; -export type RpcWireMessageChunkAck = { - wire: "chunk-ack"; - streamId: string; - missing: number[]; -}; -export type RpcWireMessage = RpcWireMessageRaw | RpcWireMessageChunk | RpcWireMessageChunkAck; -export type TransportOnMessage = (message: RpcWireMessage, peerId: string) => void; -export type TransportOnPeer = (peerId: string) => void; -export interface TransportAdapter { - send(message: RpcWireMessage, peerId: string): void | Promise; - onMessage(handler: TransportOnMessage): () => void; - onPeerJoin?(handler: TransportOnPeer): () => void; - onPeerLeave?(handler: TransportOnPeer): () => void; -} -export type RpcRoomOptions = { - transport: TransportAdapter; - maxWirePayloadBytes?: number; - chunkMissingRetryMs?: number; - canAcceptRequest?: (peerId: string, method: string) => boolean | Promise; - onProtocolWarning?: (message: string, peerId?: string) => void; -}; -export type RpcMethodHandler = (peerId: string, ...args: T) => U | Promise; -export type RpcRegisterOptions = { - serial?: boolean; -}; diff --git a/_types/src/lib/src/serviceFeatures/checkRemoteSize.d.ts b/_types/src/lib/src/serviceFeatures/checkRemoteSize.d.ts deleted file mode 100644 index c3690fda..00000000 --- a/_types/src/lib/src/serviceFeatures/checkRemoteSize.d.ts +++ /dev/null @@ -1,30 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { createInstanceLogFunction, type LogFunction } from "@lib/services/lib/logUtils"; -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; -/** - * Notify when checking remote storage size is not configured. - * @returns true if the check is passed or user has configured the notification, false to block subsequent processes. (always true). - */ -export declare function onNotifyRemoteSizeNotConfiguredFactory(host: NecessaryServices<"appLifecycle" | "API" | "setting", never>, log: ReturnType): () => Promise; -/** - * Notify when the remote storage size exceed the threshold. - * @returns true if the check is passed or user has chosen to ignore the warning, false to block subsequent processes. - * @param host - * @param log - * @returns - */ -export declare function onNotifyRemoteSizeExceedFactory(host: NecessaryServices<"API" | "setting" | "replicator", "rebuilder">, log: ReturnType): () => Promise; -/** - * Scan the remote storage size and notify if it is not configured or exceed the threshold. - * @param host The necessary services required for the operation. - * @param log The logging function to use for logging messages. - * @param resetThreshold Whether to reset the notification threshold before scanning. This is useful when you want to force the notification to show up again. - * @returns A promise that resolves to true if all checks pass or user has configured the notification. - */ -export declare function scanAllStat(host: NecessaryServices<"API" | "setting" | "replicator" | "appLifecycle", "rebuilder">, log: LogFunction, resetThreshold?: boolean): Promise; -/** - * Associate the remote storage size check feature with the app lifecycle events. - * @param host - */ -export declare function useCheckRemoteSize(host: NecessaryServices<"API" | "setting" | "replicator" | "appLifecycle", "rebuilder">): void; diff --git a/_types/src/lib/src/serviceFeatures/offlineScanner.d.ts b/_types/src/lib/src/serviceFeatures/offlineScanner.d.ts deleted file mode 100644 index 97f4dd25..00000000 --- a/_types/src/lib/src/serviceFeatures/offlineScanner.d.ts +++ /dev/null @@ -1,130 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type FilePathWithPrefix, type FilePathWithPrefixLC, type MetaEntry, type UXFileInfoStub, type ObsidianLiveSyncSettings, type LOG_LEVEL } from "@lib/common/types"; -import { type LogFunction } from "@lib/services/lib/logUtils"; -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; -import { UnresolvedErrorManager } from "@lib/services/base/UnresolvedErrorManager"; -/** - * Collect deleted files that have expired according to retention policy. - * @param host Services container - * @param log Logging function - * @returns Array of expired deletion history - */ -export declare function collectDeletedFiles(host: NecessaryServices<"setting" | "database", never>, log: LogFunction): Promise; -/** - * Get the file path from a meta entry. - * This is a helper function to extract path from various document types. - * @param doc Meta entry document - * @returns Path string - */ -export declare function getPathFromEntry(host: NecessaryServices<"path", never>, doc: MetaEntry): FilePathWithPrefix; -/** - * Synchronise a single file between database and storage based on freshness comparison. - * @param host Services container - * @param log Logging function - * @param file Storage file information - * @param doc Database entry - */ -export declare function syncFileBetweenDBandStorage(host: NecessaryServices<"setting" | "vault" | "path", "storageAccess" | "fileHandler">, log: LogFunction, file: UXFileInfoStub, doc: MetaEntry): Promise; -export declare function canProceedScan(host: NecessaryServices<"keyValueDB" | "setting", never>, errorManager: UnresolvedErrorManager, log: LogFunction, showingNotice?: boolean, ignoreSuspending?: boolean): boolean; -/** - * Convert file path to lower case if the settings indicate that filename case should be handled insensitively. - * @param settings - * @param path - * @returns - */ -export declare function convertCase(settings: ObsidianLiveSyncSettings, path: T): FilePathWithPrefixLC; -export declare function collectFilesOnStorage(host: NecessaryServices<"vault", "storageAccess">, settings: ObsidianLiveSyncSettings, log: LogFunction): Promise<{ - storageFileNameMap: { - [k: string]: UXFileInfoStub; - }; - storageFileNames: FilePathWithPrefix[]; - storageFileNameCI2CS: Record; -}>; -export declare function collectDatabaseFiles(host: NecessaryServices<"database" | "vault" | "path", never>, settings: ObsidianLiveSyncSettings, log: LogFunction, showingNotice: boolean): Promise<{ - databaseFileNameMap: { - [k: string]: MetaEntry; - }; - databaseFileNames: FilePathWithPrefix[]; - databaseFileNameCI2CS: Record; -}>; -export declare function updateToDatabase(host: NecessaryServices<"vault", "fileHandler">, log: LogFunction, logLevel: LOG_LEVEL, file: UXFileInfoStub): Promise; -export declare function updateToStorage(host: NecessaryServices<"vault" | "path", "fileHandler">, log: LogFunction, logLevel: LOG_LEVEL, w: MetaEntry): Promise; -export declare function syncStorageAndDatabase(host: NecessaryServices<"setting" | "vault" | "path", "storageAccess" | "fileHandler">, log: LogFunction, file: UXFileInfoStub, logLevel: LOG_LEVEL, doc: MetaEntry): Promise; -export declare const FullScanModes: { - readonly DB_APPLY: "db-apply"; - readonly NEWER_WINS: "newer-wins"; -}; -export declare const ExtraOnRemote: { - /** - * Delete database entries if they are missing on storage. - */ - readonly DELETE_LOCAL_MISSING: "delete-local-missing"; -}; -export declare const ExtraOnLocal: { - /** - * Delete local files if they were deleted on database. - */ - readonly DELETE_DB_DELETED: "delete-db-deleted"; - /** - * Delete local files if they are missing on database or were deleted on database. - */ - readonly DELETE_DB_MISSING: "delete-db-missing"; - /** - * Merge local files to database - */ - readonly APPEND_STORAGE_ONLY: "append-storage-only"; -}; -export interface FullScanOptions { - mode: FullScanMode; - extraOnLocal?: (typeof ExtraOnLocal)[keyof typeof ExtraOnLocal]; - extraOnRemote?: (typeof ExtraOnRemote)[keyof typeof ExtraOnRemote]; - omitEvents?: boolean; - showingNotice?: boolean; - ignoreSuspending?: boolean; -} -export type FullScanMode = (typeof FullScanModes)[keyof typeof FullScanModes]; -type FilePair = { - file: UXFileInfoStub; - doc: MetaEntry; -} | { - file: undefined; - doc: MetaEntry; -} | { - file: UXFileInfoStub; - doc: undefined; -}; -type FilePairState = "storage-only" | "db-only" | "db-only-deleted" | "both" | "both-db-deleted"; -type FilePairAction = "update-db" | "update-storage" | "sync-newer" | "delete-local" | "delete-db" | "skip"; -export declare function getFilePairState(pair: FilePair): FilePairState; -/** - * Determine the action to be taken for a file pair based on its state and the selected scan options. - */ -export declare function resolveFilePairAction(state: FilePairState, options: FullScanOptions): FilePairAction; -/** - * Synchronise all files between database and storage based on the selected mode and options. - * @param host Core - * @param log Logging function - * @param errorManager Error manager - * @param options Full scan options - */ -export declare function synchroniseAllFilesBetweenDBandStorage(host: NecessaryServices<"setting" | "vault" | "path" | "fileProcessing" | "database" | "keyValueDB", "storageAccess" | "fileHandler">, log: LogFunction, errorManager: UnresolvedErrorManager, options: FullScanOptions): Promise; -export declare function normaliseFullScanOptions(showingNoticeOrOptions: Partial | boolean | undefined, ignoreSuspending?: boolean): FullScanOptions; -/** - * Perform a full scan and synchronisation between database and storage. - * @param host Services container - * @param log Logging function - * @param errorManager Error manager - * @param showingNotice Whether to show notices during scanning - * @param ignoreSuspending Whether to ignore suspension settings - * @returns True if scan completed successfully - */ -export declare function performFullScan(host: NecessaryServices<"setting" | "vault" | "path" | "fileProcessing" | "database" | "keyValueDB", "storageAccess" | "fileHandler">, log: LogFunction, errorManager: UnresolvedErrorManager, options?: Partial): Promise; -export declare function performFullScan(host: NecessaryServices<"setting" | "vault" | "path" | "fileProcessing" | "database" | "keyValueDB", "storageAccess" | "fileHandler">, log: LogFunction, errorManager: UnresolvedErrorManager, showingNotice?: boolean, ignoreSuspending?: boolean): Promise; -/** - * Associate the initialiser file feature with the app lifecycle events. - * This function binds initialization handlers to the appropriate lifecycle events. - * @param host Services container with required dependencies - */ -export declare function useOfflineScanner(host: NecessaryServices<"API" | "appLifecycle" | "setting" | "vault" | "path" | "database" | "databaseEvents" | "fileProcessing" | "keyValueDB" | "replicator", "storageAccess" | "fileHandler">): void; -export {}; diff --git a/_types/src/lib/src/serviceFeatures/prepareDatabaseForUse.d.ts b/_types/src/lib/src/serviceFeatures/prepareDatabaseForUse.d.ts deleted file mode 100644 index c3c57715..00000000 --- a/_types/src/lib/src/serviceFeatures/prepareDatabaseForUse.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; -import { UnresolvedErrorManager } from "@lib/services/base/UnresolvedErrorManager"; -import { type LogFunction } from "@lib/services/lib/logUtils"; -/** - * Initialise the database and trigger a full vault scan. - * @param host Services container - * @param log Logging function - * @param errorManager Error manager - * @param showingNotice Whether to show notices during initialisation - * @param reopenDatabase Whether to reopen the database connection - * @param ignoreSuspending Whether to ignore suspension settings - * @returns True if initialisation succeeded - */ -export declare function prepareDatabaseForUse(host: NecessaryServices<"appLifecycle" | "setting" | "vault" | "path" | "database" | "databaseEvents" | "fileProcessing" | "replicator", never>, log: LogFunction, errorManager: UnresolvedErrorManager, showingNotice?: boolean, reopenDatabase?: boolean, ignoreSuspending?: boolean): Promise; -/** - * Associate the initialiser file feature with the app lifecycle events. - * This function binds initialization handlers to the appropriate lifecycle events. - * @param host Services container with required dependencies - */ -export declare function usePrepareDatabaseForUse(host: NecessaryServices<"API" | "appLifecycle" | "setting" | "vault" | "path" | "database" | "databaseEvents" | "fileProcessing" | "replicator", never>): void; diff --git a/_types/src/lib/src/serviceFeatures/remoteConfig.d.ts b/_types/src/lib/src/serviceFeatures/remoteConfig.d.ts deleted file mode 100644 index 67fc90c9..00000000 --- a/_types/src/lib/src/serviceFeatures/remoteConfig.d.ts +++ /dev/null @@ -1,48 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type LOG_LEVEL } from "@lib/common/logger"; -import type { ObsidianLiveSyncSettings } from "@lib/common/models/setting.type"; -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; -export type RemoteConfigHost = NecessaryServices<"setting" | "UI" | "replication" | "control" | "appLifecycle" | "API", never>; -export declare function migrateLegacyRemoteConfigurationsInPlace(settings: ObsidianLiveSyncSettings, log?: (message: string, level?: LOG_LEVEL) => void): boolean; -/** - * Generate a unique ID for a new remote configuration. - * @returns A unique string identifier. - */ -export declare function createRemoteConfigurationId(): string; -/** - * Keep compatibility for users who were already using P2P as their main active remote. - */ -export declare function migrateP2PActiveRemoteConfigurationIdInPlace(settings: ObsidianLiveSyncSettings): boolean; -/** - * SF:RemoteConfig - Service Feature for Remote Configuration Management - */ -/** - * Migrates existing flat settings to the new multiple remote configurations list. - */ -export declare function migrateToMultipleRemoteConfigurations(host: RemoteConfigHost): Promise; -/** - * Logic to switch the active configuration. - */ -export declare function activateRemoteConfiguration(settings: ObsidianLiveSyncSettings, id: string): ObsidianLiveSyncSettings | false; -/** - * Apply a dedicated P2P remote configuration onto runtime P2P-related fields, - * while keeping the current `remoteType` unchanged. - */ -export declare function activateP2PRemoteConfiguration(settings: ObsidianLiveSyncSettings, id: string): ObsidianLiveSyncSettings | false; -/** - * Command: Switch Active Remote - */ -export declare function commandSwitchActiveRemote(host: RemoteConfigHost): Promise; -/** - * Command: Replicate with specific remote - */ -export declare function commandReplicateWithSpecificRemote(host: RemoteConfigHost): Promise; -/** - * Migration feature to be used during initialisation. - */ -export declare function useRemoteConfigurationMigration(host: RemoteConfigHost): void; -/** - * Hook to set up remote configuration features (Commands). - */ -export declare function useRemoteConfiguration(host: RemoteConfigHost): boolean; diff --git a/_types/src/lib/src/serviceFeatures/setupObsidian/qrCode.d.ts b/_types/src/lib/src/serviceFeatures/setupObsidian/qrCode.d.ts deleted file mode 100644 index 6bf65c3c..00000000 --- a/_types/src/lib/src/serviceFeatures/setupObsidian/qrCode.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; -import type { SetupFeatureHost } from "./types"; -export declare function encodeSetupSettingsAsQR(host: SetupFeatureHost): Promise; -export declare function useSetupQRCodeFeature(host: NecessaryServices<"API" | "UI" | "setting" | "appLifecycle", never>): void; diff --git a/_types/src/lib/src/serviceFeatures/setupObsidian/setupUri.d.ts b/_types/src/lib/src/serviceFeatures/setupObsidian/setupUri.d.ts deleted file mode 100644 index 53b8892a..00000000 --- a/_types/src/lib/src/serviceFeatures/setupObsidian/setupUri.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { LogFunction } from "@lib/services/lib/logUtils"; -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; -import type { SetupFeatureHost } from "./types"; -export declare function askEncryptingPassphrase(host: SetupFeatureHost): Promise; -export declare function copySetupURI(host: SetupFeatureHost, log: LogFunction, stripExtra?: boolean): Promise; -export declare function copySetupURIFull(host: SetupFeatureHost, log: LogFunction): Promise; -export declare function useSetupURIFeature(host: NecessaryServices<"API" | "UI" | "setting" | "appLifecycle", never>): void; diff --git a/_types/src/lib/src/serviceFeatures/setupObsidian/types.d.ts b/_types/src/lib/src/serviceFeatures/setupObsidian/types.d.ts deleted file mode 100644 index 015f6d33..00000000 --- a/_types/src/lib/src/serviceFeatures/setupObsidian/types.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; -export type SetupFeatureHost = NecessaryServices<"API" | "UI" | "setting", never>; diff --git a/_types/src/lib/src/serviceFeatures/targetFilter.d.ts b/_types/src/lib/src/serviceFeatures/targetFilter.d.ts deleted file mode 100644 index 2b5669c5..00000000 --- a/_types/src/lib/src/serviceFeatures/targetFilter.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type UXFileInfoStub } from "@lib/common/types"; -import { type LogFunction } from "@lib/services/lib/logUtils"; -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; -/** - * This is a simple handler that accepts all files. - */ -export declare function isAcceptedAlwaysFactory(host: NecessaryServices, log: LogFunction): (file: string | UXFileInfoStub) => Promise; -/** - * Check if a file is accepted based on filename duplication in the vault. - */ -export declare function isAcceptedInFilenameDuplicationFactory(host: NecessaryServices<"vault" | "fileProcessing", "storageAccess">, log: LogFunction): (file: string | UXFileInfoStub) => Promise; -/** - * Check if a file is accepted by the local database (e.g., not rejected by the local DB's target file check). - * Local database responsible for non-internal files, syncOnlyRegEx, syncIgnoreRegEx - * This possibly should be separated. - */ -export declare function isAcceptedByLocalDBFactory(host: NecessaryServices<"database" | "databaseEvents", never>, log: LogFunction): (file: string | UXFileInfoStub) => Promise; -/** - * Factory function to create the isAcceptedByIgnoreFiles handler. - * This handler checks if a file is ignored based on the ignore files specified in the settings. - * It also caches the ignore file contents for performance and listens to settings changes to invalidate the cache. - */ -export declare function isAcceptedByIgnoreFilesFactory(host: NecessaryServices<"setting" | "appLifecycle", "storageAccess">, log: LogFunction): (file: string | UXFileInfoStub) => Promise; -export declare function useTargetFilters(host: NecessaryServices<"API" | "vault" | "fileProcessing" | "setting" | "appLifecycle" | "database" | "databaseEvents", "storageAccess">): void; diff --git a/_types/src/lib/src/serviceModules/FileAccessBase.d.ts b/_types/src/lib/src/serviceModules/FileAccessBase.d.ts deleted file mode 100644 index 0893b3ad..00000000 --- a/_types/src/lib/src/serviceModules/FileAccessBase.d.ts +++ /dev/null @@ -1,94 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { FilePath, UXDataWriteOptions, UXFileInfoStub, UXFolderInfo } from "@lib/common/types.ts"; -import type { IStorageAccessManager } from "@lib/interfaces/StorageAccess.ts"; -import type { IAPIService, IPathService, ISettingService, IVaultService } from "@lib/services/base/IService.ts"; -import { createInstanceLogFunction } from "@lib/services/lib/logUtils.ts"; -import type { FileWithFileStat } from "@lib/common/models/fileaccess.type"; -import type { IFileSystemAdapter } from "./adapters"; -export declare function toArrayBuffer(arr: Uint8Array | ArrayBuffer | DataView): ArrayBuffer; -export interface FileAccessBaseDependencies { - vaultService: IVaultService; - storageAccessManager: IStorageAccessManager; - settingService: ISettingService; - pathService: IPathService; - APIService: IAPIService; -} -/** - * Type helper to extract the abstract file type from a file system adapter - */ -export type ExtractAbstractFile = T extends IFileSystemAdapter ? A : never; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration -/** - * Type helper to extract the file type from a file system adapter - */ -export type ExtractFile = T extends IFileSystemAdapter ? F : never; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration -/** - * Type helper to extract the folder type from a file system adapter - */ -export type ExtractFolder = T extends IFileSystemAdapter ? D : never; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration -/** - * Type helper to extract the stat type from a file system adapter - */ -export type ExtractStat = T extends IFileSystemAdapter ? S : never; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration -/** - * Base class for file access operations - * Uses adapter pattern for platform-specific implementations - * - * @template TAdapter - The file system adapter type, which determines all native file types - */ -export declare class FileAccessBase> { // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - protected storageAccessManager: IStorageAccessManager; - protected vaultService: IVaultService; - protected settingService: ISettingService; - protected APIService: IAPIService; - protected path: IPathService; - protected adapter: TAdapter; - _log: ReturnType; - constructor(adapter: TAdapter, dependencies: FileAccessBaseDependencies); - isFile(file: UXFileInfoStub | ExtractAbstractFile | FilePath | ExtractFolder | ExtractFile | null): file is ExtractFile; - isFolder(item: UXFileInfoStub | ExtractAbstractFile | FilePath | ExtractFolder | ExtractFile | null): item is ExtractFolder; - getPath(file: ExtractAbstractFile | string): FilePath; - nativeFileToUXFileInfoStub(file: ExtractFile): UXFileInfoStub; - nativeFolderToUXFolder(file: ExtractFolder): UXFolderInfo; - normalisePath(path: string): string; - protected _writeOp | string, U>(file: T, callback: (path: FilePath, file: T) => Promise): Promise; - protected _readOp | string, U>(file: T, callback: (path: FilePath, file: T) => Promise): Promise; - tryAdapterStat(file: ExtractFile | string): Promise; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - adapterStat(file: ExtractFile | string): Promise | null>; - adapterExists(file: ExtractFile | string): Promise; - adapterRemove(file: ExtractFile | string): Promise; - adapterRead(file: ExtractFile | string): Promise; - adapterReadBinary(file: ExtractFile | string): Promise; - adapterReadAuto(file: ExtractFile | string): Promise; - adapterWrite(file: ExtractFile | string, data: string | ArrayBuffer | Uint8Array, options?: UXDataWriteOptions): Promise; - adapterList(basePath: string): Promise<{ - files: string[]; - folders: string[]; - }>; - vaultCacheRead(file: ExtractFile): Promise; - vaultRead(file: ExtractFile): Promise; - vaultReadBinary(file: ExtractFile): Promise; - vaultReadAuto(file: ExtractFile): Promise; - vaultModify(file: ExtractFile, data: string | ArrayBuffer | Uint8Array, options?: UXDataWriteOptions): Promise; - vaultCreate(path: string, data: string | ArrayBuffer | Uint8Array, options?: UXDataWriteOptions): Promise>; - trigger(name: string, ...data: unknown[]): void; - reconcileInternalFile(path: string): Promise; - /** - * Append data to a file using the adapter's append method. This is useful for large files that cannot be read into memory. - * Please note that this method does not check concurrent modifications. - * @param normalizedPath - * @param data - * @param options - * @returns - */ - adapterAppend(normalizedPath: string, data: string, options?: UXDataWriteOptions): Promise; - delete(file: ExtractAbstractFile | ExtractFolder, force?: boolean): Promise; - trash(file: ExtractAbstractFile | ExtractFolder, force?: boolean): Promise; - protected isStorageInsensitive(): boolean; - getAbstractFileByPath(path: FilePath | string): Promise | null>; - getFiles(): Promise[]>; - ensureDirectory(fullPath: string): Promise; - touch(file: ExtractFile | FilePath): Promise; - recentlyTouched(file: ExtractFile | UXFileInfoStub | FileWithFileStat): boolean; - clearTouched(): void; -} diff --git a/_types/src/lib/src/serviceModules/Rebuilder.d.ts b/_types/src/lib/src/serviceModules/Rebuilder.d.ts deleted file mode 100644 index 1e2b32e6..00000000 --- a/_types/src/lib/src/serviceModules/Rebuilder.d.ts +++ /dev/null @@ -1,91 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { IFileHandler } from "@lib/interfaces/FileHandler"; -import type { APIService } from "@lib/services/base/APIService"; -import type { AppLifecycleService } from "@lib/services/base/AppLifecycleService"; -import type { DatabaseEventService } from "@lib/services/base/DatabaseEventService"; -import type { DatabaseService } from "@lib/services/base/DatabaseService"; -import type { RemoteService } from "@lib/services/base/RemoteService"; -import type { ReplicationService } from "@lib/services/base/ReplicationService"; -import type { ReplicatorService } from "@lib/services/base/ReplicatorService"; -import type { SettingService } from "@lib/services/base/SettingService"; -import type { VaultService } from "@lib/services/base/VaultService"; -import type { UIService } from "@lib/services/implements/base/UIService"; -import type { Rebuilder } from "@lib/interfaces/DatabaseRebuilder"; -import type { StorageAccess } from "@lib/interfaces/StorageAccess"; -import { ServiceModuleBase } from "@lib/serviceModules/ServiceModuleBase"; -import type { ControlService } from "@lib/services/base/ControlService"; -export interface ServiceRebuilderDependencies { - appLifecycle: AppLifecycleService; - API: APIService; - UI: UIService; - setting: SettingService; - remote: RemoteService; - databaseEvents: DatabaseEventService; - storageAccess: StorageAccess; - replicator: ReplicatorService; - vault: VaultService; - replication: ReplicationService; - database: DatabaseService; - fileHandler: IFileHandler; - control: ControlService; -} -export declare class ServiceRebuilder extends ServiceModuleBase implements Rebuilder { - private appLifecycle; - private API; - private UI; - private setting; - private remote; - private databaseEvents; - private storageAccess; - private replicator; - private vault; - private replication; - private database; - private fileHandler; - private control; - constructor(services: ServiceRebuilderDependencies); - $performRebuildDB(method: "localOnly" | "remoteOnly" | "rebuildBothByThisDevice" | "localOnlyWithChunks"): Promise; - informOptionalFeatures(): Promise; - askUsingOptionalFeature(opt: { - enableFetch?: boolean; - enableOverwrite?: boolean; - }): Promise; - rebuildRemote(): Promise; - private performRemoteRebuild; - $rebuildRemote(): Promise; - rebuildEverything(): Promise; - private performRebuildEverything; - $rebuildEverything(): Promise; - $fetchLocal(makeLocalChunkBeforeSync?: boolean, preventMakeLocalFilesBeforeSync?: boolean): Promise; - $fetchLocalDBFast(autoResume: boolean): Promise; - scheduleRebuild(): Promise; - scheduleFetch(): Promise; - private _tryResetRemoteDatabase; - private _onResetLocalDatabase; - suspendAllSync(): Promise; - suspendReflectingDatabase(ignoreMinIO?: boolean): Promise; - resumeReflectingDatabase(ignoreMinIO?: boolean): Promise; - fetchLocal(makeLocalChunkBeforeSync?: boolean, preventMakeLocalFilesBeforeSync?: boolean, autoResume?: boolean): Promise; - private performFetchLocal; - fetchLocalDBFast(autoResume: boolean): Promise; - private performFetchLocalDBFast; - /** - * Finish rebuild process with resuming the reflection. - * - * @param ignoreMinIO Whether to ignore minio for resuming the reflection. - */ - finishRebuild(ignoreMinIO?: boolean): Promise; - /** - * Fetch local database with making all chunks. - * This is a wrapper for {@link fetchLocal} with makeLocalChunkBeforeSync = true. - * - * @returns - */ - fetchLocalWithRebuild(): Promise; - private _allSuspendAllSync; - resetLocalDatabase(): Promise; - private getFastFetchCheckpoint; - private saveFastFetchCheckpoint; - private clearFastFetchCheckpoint; -} diff --git a/_types/src/lib/src/serviceModules/ServiceDatabaseFileAccessBase.d.ts b/_types/src/lib/src/serviceModules/ServiceDatabaseFileAccessBase.d.ts deleted file mode 100644 index 3b0bb557..00000000 --- a/_types/src/lib/src/serviceModules/ServiceDatabaseFileAccessBase.d.ts +++ /dev/null @@ -1,39 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { UXFileInfoStub, FilePathWithPrefix, UXFileInfo, MetaEntry, LoadedEntry, FilePath } from "@lib/common/types"; -import type { DatabaseFileAccess } from "@lib/interfaces/DatabaseFileAccess"; -import type { StorageAccess } from "@lib/interfaces/StorageAccess"; -import type { APIService } from "@lib/services/base/APIService"; -import type { DatabaseService } from "@lib/services/base/DatabaseService"; -import type { PathService } from "@lib/services/base/PathService"; -import type { VaultService } from "@lib/services/base/VaultService"; -import { ServiceModuleBase } from "@lib/serviceModules/ServiceModuleBase"; -export interface ServiceDatabaseFileAccessDependencies { - API: APIService; - vault: VaultService; - storageAccess: StorageAccess; - path: PathService; - database: DatabaseService; -} -export declare class ServiceDatabaseFileAccessBase extends ServiceModuleBase implements DatabaseFileAccess { - private vault; - private storageAccess; - private path; - private database; - constructor(services: ServiceDatabaseFileAccessDependencies); - checkIsTargetFile(file: UXFileInfoStub | FilePathWithPrefix): Promise; - delete(file: UXFileInfoStub | FilePathWithPrefix, rev?: string): Promise; - createChunks(file: UXFileInfo, force?: boolean, skipCheck?: boolean): Promise; - store(file: UXFileInfo, force?: boolean, skipCheck?: boolean): Promise; - storeAsConflictedRevision(file: UXFileInfo, currentRev: string, skipCheck?: boolean): Promise; - storeContent(path: FilePathWithPrefix, content: string): Promise; - private __store; - private getParentRev; - hasContentInRevisionHistory(file: UXFileInfoStub | FilePathWithPrefix, content: string | string[] | Blob | ArrayBuffer, currentRev?: string): Promise; - getConflictedRevs(file: UXFileInfoStub | FilePathWithPrefix): Promise; - fetch(file: UXFileInfoStub | FilePathWithPrefix, rev?: string, waitForReady?: boolean, skipCheck?: boolean): Promise; - fetchEntryMeta(file: UXFileInfoStub | FilePathWithPrefix, rev?: string, skipCheck?: boolean): Promise; - fetchEntryFromMeta(meta: MetaEntry, waitForReady?: boolean, skipCheck?: boolean): Promise; - fetchEntry(file: UXFileInfoStub | FilePathWithPrefix, rev?: string, waitForReady?: boolean, skipCheck?: boolean): Promise; - deleteFromDBbyPath(fullPath: FilePath | FilePathWithPrefix, rev?: string): Promise; -} diff --git a/_types/src/lib/src/serviceModules/ServiceFileAccessBase.d.ts b/_types/src/lib/src/serviceModules/ServiceFileAccessBase.d.ts deleted file mode 100644 index 69a49a07..00000000 --- a/_types/src/lib/src/serviceModules/ServiceFileAccessBase.d.ts +++ /dev/null @@ -1,65 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { FilePath, FilePathWithPrefix, UXDataWriteOptions, UXFileInfo, UXFileInfoStub, UXFolderInfo, UXStat } from "@lib/common/types"; -import { ServiceModuleBase } from "@lib/serviceModules/ServiceModuleBase"; -import type { APIService } from "@lib/services/base/APIService"; -import type { IStorageAccessManager, StorageAccess } from "@lib/interfaces/StorageAccess.ts"; -import type { AppLifecycleService } from "@lib/services/base/AppLifecycleService"; -import type { FileProcessingService } from "@lib/services/base/FileProcessingService"; -import { StorageEventManager } from "@lib/interfaces/StorageEventManager.ts"; -import { type CustomRegExp } from "@lib/common/utils"; -import type { VaultService } from "@lib/services/base/VaultService"; -import type { SettingService } from "@lib/services/base/SettingService"; -import type { FileAccessBase, ExtractFile, ExtractFolder } from "@lib/serviceModules/FileAccessBase"; -import type { IFileSystemAdapter } from "@lib/serviceModules/adapters"; -export interface StorageAccessBaseDependencies> { // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - API: APIService; - appLifecycle: AppLifecycleService; - fileProcessing: FileProcessingService; - vault: VaultService; - setting: SettingService; - storageEventManager: StorageEventManager; - storageAccessManager: IStorageAccessManager; - vaultAccess: FileAccessBase; -} -export declare class ServiceFileAccessBase> extends ServiceModuleBase> implements StorageAccess { // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - private vaultAccess; - private vaultManager; - private vault; - private setting; - constructor(services: StorageAccessBaseDependencies); - restoreState(): Promise; - _everyOnFirstInitialize(): Promise; - _everyCommitPendingFileEvent(): Promise; - normalisePath(path: string): string; - writeFileAuto(path: string, data: string | ArrayBuffer, opt?: UXDataWriteOptions): Promise; - readFileAuto(path: string): Promise; - readFileText(path: string): Promise; - isExists(path: string): Promise; - writeHiddenFileAuto(path: string, data: string | ArrayBuffer, opt?: UXDataWriteOptions): Promise; - appendHiddenFile(path: string, data: string, opt?: UXDataWriteOptions): Promise; - stat(path: string): Promise; - statHidden(path: string): Promise; - removeHidden(path: string): Promise; - readHiddenFileAuto(path: string): Promise; - readHiddenFileText(path: string): Promise; - readHiddenFileBinary(path: string): Promise; - isExistsIncludeHidden(path: string): Promise; - ensureDir(path: string): Promise; - _triggerFileEvent(event: string, path: string): Promise; - triggerFileEvent(event: string, path: string): void; - triggerHiddenFile(path: string): Promise; - getFileStub(path: string): Promise; - readStubContent(stub: UXFileInfoStub): Promise; - getStub(path: string): Promise; - getFiles(): Promise; - getFileNames(): Promise; - getFilesIncludeHidden(basePath: string, includeFilter?: CustomRegExp[], excludeFilter?: CustomRegExp[], skipFolder?: string[]): Promise; - touched(file: UXFileInfoStub | FilePathWithPrefix): Promise; - recentlyTouched(file: UXFileInfoStub | FilePathWithPrefix): Promise; - clearTouched(): void; - delete(file: FilePathWithPrefix | UXFileInfoStub | string, force: boolean): Promise; - trash(file: FilePathWithPrefix | UXFileInfoStub | string, system: boolean): Promise; - __deleteVaultItem(file: ExtractFile | ExtractFolder): Promise; - deleteVaultItem(fileSrc: FilePathWithPrefix | UXFileInfoStub | UXFolderInfo): Promise; -} diff --git a/_types/src/lib/src/serviceModules/ServiceFileHandlerBase.d.ts b/_types/src/lib/src/serviceModules/ServiceFileHandlerBase.d.ts deleted file mode 100644 index 8b315008..00000000 --- a/_types/src/lib/src/serviceModules/ServiceFileHandlerBase.d.ts +++ /dev/null @@ -1,50 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { AnyEntry, FilePath, FilePathWithPrefix, MetaEntry, UXFileInfo, UXFileInfoStub, UXInternalFileInfoStub } from "@lib/common/types"; -import type { IFileHandler } from "@lib/interfaces/FileHandler.ts"; -import { ServiceModuleBase } from "@lib/serviceModules/ServiceModuleBase"; -import type { APIService } from "@lib/services/base/APIService.ts"; -import type { DatabaseFileAccess } from "@lib/interfaces/DatabaseFileAccess.ts"; -import type { StorageAccess } from "@lib/interfaces/StorageAccess.ts"; -import type { FileProcessingService } from "@lib/services/base/FileProcessingService.ts"; -import type { ReplicationService } from "@lib/services/base/ReplicationService.ts"; -import type { ConflictService } from "@lib/services/base/ConflictService.ts"; -import type { PathService } from "@lib/services/base/PathService.ts"; -import type { SettingService } from "@lib/services/base/SettingService.ts"; -import type { VaultService } from "@lib/services/base/VaultService.ts"; -export interface ServiceFileHandlerDependencies { - API: APIService; - databaseFileAccess: DatabaseFileAccess; - storageAccess: StorageAccess; - fileProcessing: FileProcessingService; - replication: ReplicationService; - conflict: ConflictService; - path: PathService; - setting: SettingService; - vault: VaultService; -} -export declare abstract class ServiceFileHandlerBase extends ServiceModuleBase implements IFileHandler { - private databaseFileAccess; - private storageAccess; - private conflict; - private path; - private setting; - private vault; - constructor(services: ServiceFileHandlerDependencies); - get db(): DatabaseFileAccess; - get storage(): StorageAccess; - getPath(entry: AnyEntry): FilePathWithPrefix; - getPathWithoutPrefix(entry: AnyEntry): FilePathWithPrefix; - readFileFromStub(file: UXFileInfoStub | UXFileInfo): Promise; - private infoToStub; - storeFileToDB(info: UXFileInfoStub | UXFileInfo | UXInternalFileInfoStub | FilePathWithPrefix, force?: boolean, onlyChunks?: boolean): Promise; - deleteFileFromDB(info: UXFileInfoStub | UXInternalFileInfoStub | FilePath): Promise; - deleteRevisionFromDB(info: UXFileInfoStub | FilePath | FilePathWithPrefix, rev: string): Promise; - resolveConflictedByDeletingRevision(info: UXFileInfoStub | FilePath, rev: string): Promise; - dbToStorageWithSpecificRev(info: UXFileInfoStub | UXFileInfo | FilePath | null, rev: string, force?: boolean): Promise; - dbToStorage(entryInfo: MetaEntry | FilePathWithPrefix, info: UXFileInfoStub | UXFileInfo | FilePath | null, force?: boolean): Promise; - private preserveUnsyncedStorageAsConflict; - private _anyHandlerProcessesFileEvent; - _anyProcessReplicatedDoc(entry: MetaEntry): Promise; - createAllChunks(showingNotice?: boolean): Promise; -} diff --git a/_types/src/lib/src/serviceModules/ServiceModuleBase.d.ts b/_types/src/lib/src/serviceModules/ServiceModuleBase.d.ts deleted file mode 100644 index 3668b800..00000000 --- a/_types/src/lib/src/serviceModules/ServiceModuleBase.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { APIService } from "@lib/services/base/APIService"; -import { createInstanceLogFunction } from "@lib/services/lib/logUtils"; -export interface ServiceModuleBaseDependencies { - API: APIService; -} -export declare abstract class ServiceModuleBase { - _log: ReturnType; - get name(): string; - constructor(services: T); -} diff --git a/_types/src/lib/src/serviceModules/adapters/IConversionAdapter.d.ts b/_types/src/lib/src/serviceModules/adapters/IConversionAdapter.d.ts deleted file mode 100644 index 6aebb501..00000000 --- a/_types/src/lib/src/serviceModules/adapters/IConversionAdapter.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { UXFileInfoStub, UXFolderInfo } from "@lib/common/types.ts"; -/** - * Conversion adapter interface - * Converts between native file system types and universal types - */ -export interface IConversionAdapter { // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - /** - * Convert a native file object to a universal file info stub - */ - nativeFileToUXFileInfoStub(file: TNativeFile): UXFileInfoStub; - /** - * Convert a native folder object to a universal folder info - */ - nativeFolderToUXFolder(folder: TNativeFolder): UXFolderInfo; -} diff --git a/_types/src/lib/src/serviceModules/adapters/IFileSystemAdapter.d.ts b/_types/src/lib/src/serviceModules/adapters/IFileSystemAdapter.d.ts deleted file mode 100644 index a760eaab..00000000 --- a/_types/src/lib/src/serviceModules/adapters/IFileSystemAdapter.d.ts +++ /dev/null @@ -1,45 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { FilePath, UXStat } from "@lib/common/types.ts"; -import type { IPathAdapter } from "./IPathAdapter.ts"; -import type { ITypeGuardAdapter } from "./ITypeGuardAdapter.ts"; -import type { IConversionAdapter } from "./IConversionAdapter.ts"; -import type { IStorageAdapter } from "./IStorageAdapter.ts"; -import type { IVaultAdapter } from "./IVaultAdapter.ts"; -/** - * Main file system adapter interface - * Composes all other adapters and provides platform-specific operations - */ -export interface IFileSystemAdapter { // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - /** Path operations */ - readonly path: IPathAdapter; - /** Type guard operations */ - readonly typeGuard: ITypeGuardAdapter; - /** Conversion operations */ - readonly conversion: IConversionAdapter; - /** Storage operations */ - readonly storage: IStorageAdapter; - /** Vault operations */ - readonly vault: IVaultAdapter; - /** - * Get a file or folder by path (case-sensitive) - */ - getAbstractFileByPath(path: FilePath | string): Promise; - /** - * Get a file or folder by path (case-insensitive) - */ - getAbstractFileByPathInsensitive(path: FilePath | string): Promise; - /** - * Get all files in the vault - */ - getFiles(): Promise; - /** - * Get file statistics from a native file object - */ - statFromNative(file: TNativeFile): Promise; - /** - * Reconcile internal file state - * Platform-specific operation for syncing internal metadata - */ - reconcileInternalFile(path: string): Promise; -} diff --git a/_types/src/lib/src/serviceModules/adapters/IPathAdapter.d.ts b/_types/src/lib/src/serviceModules/adapters/IPathAdapter.d.ts deleted file mode 100644 index 1d428c92..00000000 --- a/_types/src/lib/src/serviceModules/adapters/IPathAdapter.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { FilePath } from "@lib/common/types.ts"; -/** - * Path operations adapter interface - * Handles path normalization and extraction - */ -export interface IPathAdapter { // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - /** - * Get the path from a file object or return the path string as-is - */ - getPath(file: TNativeAbstractFile | string): FilePath; - /** - * Normalize a path according to the platform's conventions - */ - normalisePath(path: string): string; -} diff --git a/_types/src/lib/src/serviceModules/adapters/IStorageAdapter.d.ts b/_types/src/lib/src/serviceModules/adapters/IStorageAdapter.d.ts deleted file mode 100644 index 12c40758..00000000 --- a/_types/src/lib/src/serviceModules/adapters/IStorageAdapter.d.ts +++ /dev/null @@ -1,58 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -/** - * Focused compatibility views of the existing storage adapter. - * - * These interfaces allow internal consumers to depend on fewer operations while - * retaining the existing `UXStat`, `UXDataWriteOptions`, and method semantics. - * They are not a neutral or extraction-ready storage API. Their names, shared - * types, and behavioural contracts may change when the cross-platform contract - * is designed and stabilised. - * - * @packageDocumentation - */ -import type { UXDataWriteOptions, UXStat } from "@lib/common/types.ts"; -/** File and directory existence and metadata operations. */ -export interface IStorageMetadataAccess { - exists(path: string): Promise; - trystat(path: string): Promise; - stat(path: string): Promise; -} -/** Text-file read operation. */ -export interface IStorageTextReadAccess { - read(path: string): Promise; -} -/** Binary-file read operation. */ -export interface IStorageBinaryReadAccess { - readBinary(path: string): Promise; -} -/** Text-file write operation. */ -export interface IStorageTextWriteAccess { - write(path: string, data: string, options?: UXDataWriteOptions): Promise; -} -/** Binary-file write operation. */ -export interface IStorageBinaryWriteAccess { - writeBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise; -} -/** Text-file append operation. */ -export interface IStorageTextAppendAccess { - append(path: string, data: string, options?: UXDataWriteOptions): Promise; -} -/** Directory creation and direct-child listing operations. */ -export interface IStorageDirectoryAccess { - mkdir(path: string): Promise; - list(basePath: string): Promise<{ - files: string[]; - folders: string[]; - }>; -} -/** File or directory removal operation. */ -export interface IStorageRemoveAccess { - remove(path: string): Promise; -} -/** - * Storage adapter interface - * Backwards-compatible aggregate of the focused storage capability views. - */ -export interface IStorageAdapter extends IStorageMetadataAccess, IStorageTextReadAccess, IStorageBinaryReadAccess, IStorageTextWriteAccess, IStorageBinaryWriteAccess, IStorageTextAppendAccess, IStorageDirectoryAccess, IStorageRemoveAccess { // eslint-disable-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-empty-interface -- Empty interface -} diff --git a/_types/src/lib/src/serviceModules/adapters/ITypeGuardAdapter.d.ts b/_types/src/lib/src/serviceModules/adapters/ITypeGuardAdapter.d.ts deleted file mode 100644 index 7cbd8b21..00000000 --- a/_types/src/lib/src/serviceModules/adapters/ITypeGuardAdapter.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -/** - * Type guard adapter interface - * Provides runtime type checking for native file system objects - */ -export interface ITypeGuardAdapter { - /** - * Check if the given object is a file - */ - isFile(file: unknown): file is TNativeFile; - /** - * Check if the given object is a folder - */ - isFolder(item: unknown): item is TNativeFolder; -} diff --git a/_types/src/lib/src/serviceModules/adapters/IVaultAdapter.d.ts b/_types/src/lib/src/serviceModules/adapters/IVaultAdapter.d.ts deleted file mode 100644 index 7c689a51..00000000 --- a/_types/src/lib/src/serviceModules/adapters/IVaultAdapter.d.ts +++ /dev/null @@ -1,49 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { UXDataWriteOptions } from "@lib/common/types.ts"; -/** - * Vault adapter interface - * High-level file operations that interact with the vault layer - */ -export interface IVaultAdapter { - /** - * Read a file as text - */ - read(file: TNativeFile): Promise; - /** - * Read a file using cached content if available - */ - cachedRead(file: TNativeFile): Promise; - /** - * Read a file as binary - */ - readBinary(file: TNativeFile): Promise; - /** - * Modify an existing file with text content - */ - modify(file: TNativeFile, data: string, options?: UXDataWriteOptions): Promise; - /** - * Modify an existing file with binary content - */ - modifyBinary(file: TNativeFile, data: ArrayBuffer, options?: UXDataWriteOptions): Promise; - /** - * Create a new file with text content - */ - create(path: string, data: string, options?: UXDataWriteOptions): Promise; - /** - * Create a new file with binary content - */ - createBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise; - /** - * Delete a file or folder - */ - delete(file: TNativeFile | TNativeFolder, force?: boolean): Promise; - /** - * Move a file or folder to trash - */ - trash(file: TNativeFile | TNativeFolder, force?: boolean): Promise; - /** - * Trigger an event in the vault - */ - trigger(name: string, ...data: unknown[]): void; -} diff --git a/_types/src/lib/src/serviceModules/adapters/index.d.ts b/_types/src/lib/src/serviceModules/adapters/index.d.ts deleted file mode 100644 index ef5f61f5..00000000 --- a/_types/src/lib/src/serviceModules/adapters/index.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export type { IPathAdapter } from "./IPathAdapter.ts"; -export type { ITypeGuardAdapter } from "./ITypeGuardAdapter.ts"; -export type { IConversionAdapter } from "./IConversionAdapter.ts"; -export type { IStorageAdapter, IStorageBinaryReadAccess, IStorageBinaryWriteAccess, IStorageDirectoryAccess, IStorageMetadataAccess, IStorageRemoveAccess, IStorageTextAppendAccess, IStorageTextReadAccess, IStorageTextWriteAccess, } from "./IStorageAdapter.ts"; -export type { IVaultAdapter } from "./IVaultAdapter.ts"; -export type { IFileSystemAdapter } from "./IFileSystemAdapter.ts"; diff --git a/_types/src/lib/src/services/BrowserServices.d.ts b/_types/src/lib/src/services/BrowserServices.d.ts deleted file mode 100644 index 836ed106..00000000 --- a/_types/src/lib/src/services/BrowserServices.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { InjectableVaultServiceCompat } from "@lib/services/implements/injectable/InjectableVaultService"; -import { ServiceContext } from "@lib/services/base/ServiceBase"; -import { InjectableServiceHub } from "@lib/services/implements/injectable/InjectableServiceHub"; -export declare class BrowserServiceHub extends InjectableServiceHub { - get vault(): InjectableVaultServiceCompat; - constructor(); -} diff --git a/_types/src/lib/src/services/HeadlessServices.d.ts b/_types/src/lib/src/services/HeadlessServices.d.ts deleted file mode 100644 index 0c9c503e..00000000 --- a/_types/src/lib/src/services/HeadlessServices.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { ServiceContext } from "@lib/services/base/ServiceBase"; -import { InjectableServiceHub } from "@lib/services/implements/injectable/InjectableServiceHub"; -import type { DatabaseService } from "@lib/services/base/DatabaseService.ts"; -import type { Constructor } from "@lib/common/utils.type"; -export declare class HeadlessServiceHub extends InjectableServiceHub { - constructor(_context?: T, overrideServiceConstructor?: { - database?: Constructor>; - }); -} diff --git a/_types/src/lib/src/services/InjectableServices.d.ts b/_types/src/lib/src/services/InjectableServices.d.ts deleted file mode 100644 index 4e54e8e6..00000000 --- a/_types/src/lib/src/services/InjectableServices.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export { InjectableServiceHub } from "@lib/services/implements/injectable/InjectableServiceHub.ts"; diff --git a/_types/src/lib/src/services/ServiceHub.d.ts b/_types/src/lib/src/services/ServiceHub.d.ts deleted file mode 100644 index bb35246f..00000000 --- a/_types/src/lib/src/services/ServiceHub.d.ts +++ /dev/null @@ -1,83 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { UIService } from "./implements/base/UIService.ts"; -import type { ConfigService } from "@lib/services/base/ConfigService.ts"; -import type { TestService } from "@lib/services/base/TestService.ts"; -import type { VaultService } from "@lib/services/base/VaultService.ts"; -import type { TweakValueService } from "@lib/services/base/TweakValueService.ts"; -import type { SettingService } from "@lib/services/base/SettingService.ts"; -import type { AppLifecycleService } from "@lib/services/base/AppLifecycleService.ts"; -import type { ConflictService } from "@lib/services/base/ConflictService.ts"; -import type { RemoteService } from "@lib/services/base/RemoteService.ts"; -import type { ReplicationService } from "@lib/services/base/ReplicationService.ts"; -import type { ReplicatorService } from "@lib/services/base/ReplicatorService.ts"; -import type { FileProcessingService } from "@lib/services/base/FileProcessingService.ts"; -import type { DatabaseEventService } from "@lib/services/base/DatabaseEventService.ts"; -import type { DatabaseService } from "@lib/services/base/DatabaseService.ts"; -import type { PathService } from "@lib/services/base/PathService.ts"; -import type { APIService } from "@lib/services/base/APIService.ts"; -import type { ServiceContext } from "./base/ServiceBase.ts"; -import type { IServiceHub } from "./base/IService.ts"; -import type { KeyValueDBService } from "./base/KeyValueDBService.ts"; -import type { ControlService } from "./base/ControlService.ts"; -export type ServiceInstances = { - API?: APIService; - path?: PathService; - database?: DatabaseService; - databaseEvents?: DatabaseEventService; - replicator?: ReplicatorService; - fileProcessing?: FileProcessingService; - replication?: ReplicationService; - remote?: RemoteService; - conflict?: ConflictService; - appLifecycle?: AppLifecycleService; - setting?: SettingService; - tweakValue?: TweakValueService; - vault?: VaultService; - test?: TestService; - ui?: UIService; - config?: ConfigService; - keyValueDB?: KeyValueDBService; - control?: ControlService; -}; -export declare abstract class ServiceHub implements IServiceHub { - protected context: T; - protected abstract _api: APIService; - protected abstract _path: PathService; - protected abstract _database: DatabaseService; - protected abstract _databaseEvents: DatabaseEventService; - protected abstract _replicator: ReplicatorService; - protected abstract _fileProcessing: FileProcessingService; - protected abstract _replication: ReplicationService; - protected abstract _remote: RemoteService; - protected abstract _conflict: ConflictService; - protected abstract _appLifecycle: AppLifecycleService; - protected abstract _setting: SettingService; - protected abstract _tweakValue: TweakValueService; - protected abstract _vault: VaultService; - protected abstract _test: TestService; - protected abstract _ui: UIService; - protected abstract _config: ConfigService; - protected abstract _keyValueDB: KeyValueDBService; - protected abstract _control: ControlService; - protected _injected: ServiceInstances; - constructor(context: T, services?: ServiceInstances); - get API(): APIService; - get path(): PathService; - get database(): DatabaseService; - get databaseEvents(): DatabaseEventService; - get replicator(): ReplicatorService; - get fileProcessing(): FileProcessingService; - get replication(): ReplicationService; - get remote(): RemoteService; - get conflict(): ConflictService; - get appLifecycle(): AppLifecycleService; - get setting(): SettingService; - get tweakValue(): TweakValueService; - get vault(): VaultService; - get test(): TestService; - get UI(): UIService; - get config(): ConfigService; - get keyValueDB(): KeyValueDBService; - get control(): ControlService; -} diff --git a/_types/src/lib/src/services/base/APIService.d.ts b/_types/src/lib/src/services/base/APIService.d.ts deleted file mode 100644 index 2ec51e79..00000000 --- a/_types/src/lib/src/services/base/APIService.d.ts +++ /dev/null @@ -1,95 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { FetchHttpHandler } from "@smithy/fetch-http-handler"; -import type { LOG_LEVEL } from "@lib/common/logger"; -import type { IAPIService, ICommandCompat } from "./IService"; -import { ServiceBase, type ServiceContext } from "./ServiceBase"; -import type { Confirm } from "@lib/interfaces/Confirm"; -/** - * The APIService provides methods for interacting with the plug-in's API, - */ -export declare abstract class APIService extends ServiceBase implements IAPIService { - /** - * Get a custom fetch handler for making HTTP requests (e.g., S3 without CORS issues). - */ - abstract getCustomFetchHandler(): FetchHttpHandler; - /** - * Add a log entry to the log (Now not used). - * @param message The log message. - * @param level The log level. - * @param key The log key. - */ - abstract addLog(message: unknown, level: LOG_LEVEL, key: string): void; - /** - * Check if the app is running on a mobile device. - * @returns true if running on mobile, false otherwise. - */ - abstract isMobile(): boolean; - /** - * Show a window (or in Obsidian, a leaf). - * @param type The type of window to show. - */ - abstract showWindow(type: string): Promise; - /** - * Show a window on the right sidebar when supported. - * Platforms that do not support sidebars can fall back to showWindow. - */ - showWindowOnRight(type: string): Promise; - /** - * returns App ID. In Obsidian, it is vault ID. - */ - abstract getAppID(): string; - /** - * Returns the vaultName which system has identified, without any additional suffix. - */ - abstract getSystemVaultName(): string; - /** - * Check if the last POST request failed due to payload size. - */ - abstract getPlatform(): string; - abstract getAppVersion(): string; - abstract getPluginVersion(): string; - abstract getCrypto(): Crypto; - /** - * Register a command to the runtime. - * @param command - */ - abstract addCommand(command: TCommand): TCommand; - /** - * Register a window (or leaf) type to the runtime. - * @param type - * @param factory - */ - abstract registerWindow(type: string, factory: (leaf: T) => unknown): void; - /** - * Add a ribbon icon to the UI. - * @param icon - * @param title - * @param callback - */ - abstract addRibbonIcon(icon: string, title: string, callback: (evt: MouseEvent) => unknown): HTMLElement; - /** - * Register a protocol handler. - * @param action The action string for the protocol. - * @param handler The handler function for the protocol. - */ - abstract registerProtocolHandler(action: string, handler: (params: Record) => unknown): void; - /** - * Get the basic UI component for showing a confirmation dialog to the user. - */ - abstract get confirm(): Confirm; - requestCount: import("octagonal-wheels/dataobject/reactive_v2").ReactiveSource; - responseCount: import("octagonal-wheels/dataobject/reactive_v2").ReactiveSource; - get isOnline(): boolean; - webCompatFetch(req: string | Request, opts?: RequestInit): Promise; - nativeFetch(req: string | Request, opts?: RequestInit): Promise; - abstract addStatusBarItem(): HTMLElement | undefined; - setInterval(handler: () => void, timeout: number): number; - clearInterval(timerId: number): void; - /** - * Get the system configuration directory. - * This is used for storing configuration files in a consistent location across platforms. - * @returns - */ - getSystemConfigDir(): string; -} diff --git a/_types/src/lib/src/services/base/AppLifecycleService.d.ts b/_types/src/lib/src/services/base/AppLifecycleService.d.ts deleted file mode 100644 index 1dbc9fb8..00000000 --- a/_types/src/lib/src/services/base/AppLifecycleService.d.ts +++ /dev/null @@ -1,134 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { IAppLifecycleService, ISettingService } from "./IService"; -import { ServiceBase, type ServiceContext } from "./ServiceBase"; -export interface AppLifecycleServiceDependencies { - settingService: ISettingService; -} -/** - * The AppLifecycleService provides methods for managing the plug-in's lifecycle events. - */ -export declare abstract class AppLifecycleService extends ServiceBase implements IAppLifecycleService { - protected readonly settingService: ISettingService; - constructor(context: T, dependencies: AppLifecycleServiceDependencies); - /** - * Event triggered when the plug-in's layout is ready. - * In Obsidian, it is after the workspace is ready. - */ - readonly onLayoutReady: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - /** - * Event triggered when the plug-in is being initialized for the first time. - * This is only called once per plug-in lifecycle. - */ - readonly onFirstInitialise: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - /** - * Event triggered when the plug-in is fully ready. - * This is called after all initialisation processes are complete. - */ - readonly onReady: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - /** - * Event triggered to wire up necessary event listeners. - * This is typically called during the initialisation phase. - */ - readonly onWireUpEvents: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - /** - * Event triggered when the plug-in is being initialised. - */ - readonly onInitialise: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - /** - * Event triggered when the plug-in is loading. - * This is typically called during the quite early initialisation phase, before everything. - * In Obsidian, it is in the onload() method of the plugin. - */ - readonly onLoad: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - /** - * Event triggered when the plug-in's settings have been loaded and applied. - */ - readonly onSettingLoaded: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - /** - * Event triggered when the plug-in has fully loaded. - * This is typically called after all initialisation and loading processes are complete. - */ - readonly onLoaded: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - /** - * Scan for any startup issues that may affect the plug-in's operation. - */ - readonly onScanningStartupIssues: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - /** - * Event triggered when the plug-in is unloading (e.g., during app shutdown or plug-in disable). - * This is typically called during the unload() method of the plugin. - * Entry point to unload everything. - */ - readonly onAppUnload: import("@lib/services/lib/HandlerUtils").CollectiveHandlerFunction<() => Promise, unknown>; - /** - * Event triggered before the plug-in is unloaded. - * This is typically used to perform any necessary cleanup or save state before the plug-in is unloaded. - */ - readonly onBeforeUnload: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - /** - * Event triggered when the plug-in is being unloaded. - */ - readonly onUnload: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - /** - * Perform an immediate restart of the application. - * Note that this is not graceful, and not only the plug-in. APPLICATION (means Obsidian) will be restarted. - */ - abstract performRestart(): void; - /** - * Ask the user for a restart. - * @param message Optional message to display to the user when asking for a restart. - */ - abstract askRestart(message?: string): void; - /** - * Schedule a restart of the application. - * After the current operation is done, the application will be restarted. - * Note that this is not graceful, and not only the plug-in. APPLICATION (means Obsidian) will be restarted. - */ - abstract scheduleRestart(): void; - /** - * Event triggered when the application is being suspended (e.g., system sleep). - */ - readonly onSuspending: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - /** - * Event triggered when the application is resuming from a suspended state. - */ - readonly onResuming: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - /** - * Event triggered after the application has resumed from a suspended state. - */ - readonly onResumed: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - private _isSuspended; - /** - * Check if the plug-in is currently suspended. - * Also consider the plug-in as suspended if it is not configured, to prevent any issues before configuration. - */ - isSuspended(): boolean; - /** - * Set the suspension state of the plug-in. - * @param suspend Set to true to suspend the plug-in, false to resume. - */ - setSuspended(suspend: boolean): void; - private _isReady; - /** - * Check if the plug-in is ready. - * A ready plug-in means it has been fully initialised and is operational. - * If not ready, most operations will be blocked. - */ - isReady(): boolean; - /** - * Mark the plug-in as ready. - */ - markIsReady(): void; - /** - * Reset the ready state of the plug-in. - */ - resetIsReady(): void; - /** - * Check if a restart has been scheduled. - */ - abstract isReloadingScheduled(): boolean; - /** - * Get unresolved error messages. - */ - readonly getUnresolvedMessages: import("@lib/services/lib/HandlerUtils").CollectiveHandlerFunction<() => Promise<(string | Error)[][]>, unknown>; -} diff --git a/_types/src/lib/src/services/base/ConfigService.d.ts b/_types/src/lib/src/services/base/ConfigService.d.ts deleted file mode 100644 index 1edce2f2..00000000 --- a/_types/src/lib/src/services/base/ConfigService.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { IConfigService } from "@lib/services/base/IService"; -import { ServiceBase, type ServiceContext } from "./ServiceBase"; -export declare abstract class ConfigService extends ServiceBase implements IConfigService { - abstract getSmallConfig(key: string): string | null; - abstract setSmallConfig(key: string, value: string): void; - abstract deleteSmallConfig(key: string): void; -} diff --git a/_types/src/lib/src/services/base/ConflictService.d.ts b/_types/src/lib/src/services/base/ConflictService.d.ts deleted file mode 100644 index 55de6f15..00000000 --- a/_types/src/lib/src/services/base/ConflictService.d.ts +++ /dev/null @@ -1,56 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { FilePathWithPrefix, MISSING_OR_ERROR, AUTO_MERGED } from "@lib/common/types"; -import type { IConflictService } from "@lib/services/base/IService"; -import { ServiceBase } from "@lib/services/base/ServiceBase"; -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -/** - * The ConflictService provides methods for handling file conflicts. - */ -export declare abstract class ConflictService extends ServiceBase implements IConflictService { - /** - * Get an optional conflict check method for a given file (virtual) path. - */ - readonly getOptionalConflictCheckMethod: import("@lib/services/lib/HandlerUtils").MultipleHandlerFunction<(path: FilePathWithPrefix) => Promise, unknown>; - /** - * Queue a check for conflicts if the file is currently open in the editor. - * @param path The file (virtual) path to check for conflicts. - */ - abstract queueCheckForIfOpen(path: FilePathWithPrefix): Promise; - /** - * Queue a check for conflicts for a given file (virtual) path. - * @param path The file (virtual) path to check for conflicts. - */ - abstract queueCheckFor(path: FilePathWithPrefix): Promise; - /** - * Ensure all queued file conflict checks are processed. - */ - abstract ensureAllProcessed(): Promise; - /** - * Resolve a conflict by user interaction (e.g., showing a modal dialog). - * @param filename The file (virtual) path with conflict. - * @param conflictCheckResult The result of the conflict check. - * @returns A promise that resolves to true if the conflict was resolved, false if not, or undefined if no action was taken. - */ - readonly resolveByUserInteraction: import("@lib/services/lib/HandlerUtils").MultipleHandlerFunction<(filename: FilePathWithPrefix, conflictCheckResult: import("@lib/common/types").diff_result) => Promise, unknown>; - /** - * Resolve a conflict by deleting a specific revision. - * @param path The file (virtual) path with conflict. - * @param deleteRevision The revision to delete. - * @param title The title of the conflict (for user display). - */ - abstract resolveByDeletingRevision(path: FilePathWithPrefix, deleteRevision: string, title: string): Promise; - /** - * Resolve a conflict as several possible strategies. - * It may involve user interaction (means raising resolveByUserInteraction). - * @param filename The file (virtual) path to resolve. - */ - abstract resolve(filename: FilePathWithPrefix): Promise; - /** - * Resolve a conflict by choosing the newest version. - * @param filename The file (virtual) path to resolve. - */ - abstract resolveByNewest(filename: FilePathWithPrefix): Promise; - abstract resolveAllConflictedFilesByNewerOnes(): Promise; - conflictProcessQueueCount: import("octagonal-wheels/dataobject/reactive_v2").ReactiveSource; -} diff --git a/_types/src/lib/src/services/base/ControlService.d.ts b/_types/src/lib/src/services/base/ControlService.d.ts deleted file mode 100644 index 5a8e6a1f..00000000 --- a/_types/src/lib/src/services/base/ControlService.d.ts +++ /dev/null @@ -1,56 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { createInstanceLogFunction } from "@lib/services/lib/logUtils"; -import type { APIService } from "./APIService"; -import type { DatabaseService } from "./DatabaseService"; -import type { IControlService, IFileProcessingService, IReplicatorService, ISettingService } from "./IService"; -import { ServiceBase, type ServiceContext } from "./ServiceBase"; -import { type PromiseWithResolvers } from "octagonal-wheels/promises"; -import type { AppLifecycleService } from "./AppLifecycleService"; -export interface ControlServiceDependencies { - appLifecycleService: AppLifecycleService; - replicatorService: IReplicatorService; - settingService: ISettingService; - databaseService: DatabaseService; - fileProcessingService: IFileProcessingService; - APIService: APIService; -} -/** - * The ControlService provides methods for controlling the overall behaviour of the plugin, such as applying settings or handling lifecycle events. - */ -export declare class ControlService extends ServiceBase implements IControlService { - protected services: ControlServiceDependencies; - protected _log: ReturnType; - protected _unloaded: boolean; - protected _activated: PromiseWithResolvers; - /** - * Check if the plug-in has been unloaded. - */ - hasUnloaded(): boolean; - constructor(context: T, dependencies: ControlServiceDependencies); - get activated(): Promise; - private onActivated; - /** - * Apply current settings to reflect the changes immediately. - * @returns - */ - applySettings(): Promise; - private _onLiveSyncUnload; - /** - * Called when the plugin is loaded. It will trigger the app lifecycle event onLoad. - * Main process should be called in onReady. - * @returns - */ - onLoad(): Promise; - /** - * Main entry point of the plugin. It will trigger the app lifecycle event onReady. - * Usually it should be called on `app.workspace.onLayoutReady` - * @returns - */ - onReady(): Promise; - /** - * On unload event of the plugin. It will trigger the app lifecycle event onUnload. - * @returns - */ - onUnload(): Promise; -} diff --git a/_types/src/lib/src/services/base/DatabaseEventService.d.ts b/_types/src/lib/src/services/base/DatabaseEventService.d.ts deleted file mode 100644 index ae010c97..00000000 --- a/_types/src/lib/src/services/base/DatabaseEventService.d.ts +++ /dev/null @@ -1,40 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { IDatabaseEventService } from "./IService"; -import { ServiceBase, type ServiceContext } from "./ServiceBase"; -/** - * The DatabaseEventService provides methods for handling database lifecycle events. - */ -export declare abstract class DatabaseEventService extends ServiceBase implements IDatabaseEventService { - /** - * Event triggered when the database is about to be unloaded. - */ - readonly onUnloadDatabase: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(db: import("../../pouchdb/LiveSyncLocalDB").LiveSyncLocalDB) => Promise>; - /** - * Event triggered when the database is about to be closed. - */ - readonly onCloseDatabase: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(db: import("../../pouchdb/LiveSyncLocalDB").LiveSyncLocalDB) => Promise>; - /** - * Event triggered when the database is being initialized. - */ - readonly onDatabaseInitialisation: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(db: import("../../pouchdb/LiveSyncLocalDB").LiveSyncLocalDB) => Promise>; - /** - * Event triggered when the database has been initialized. - */ - readonly onDatabaseInitialised: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(showNotice: boolean) => Promise>; - /** - * Event triggered when the database is being reset. - */ - readonly onResetDatabase: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(db: import("../../pouchdb/LiveSyncLocalDB").LiveSyncLocalDB) => Promise>; - /** - * Event triggered when the database is ready for use. - */ - readonly onDatabaseHasReady: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - /** - * Initialize the database. - * @param showingNotice Whether to show a notice to the user. - * @param reopenDatabase Whether to reopen the database if it is already open. - * @param ignoreSuspending Whether to ignore any suspending state. - */ - readonly initialiseDatabase: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(showingNotice?: boolean, reopenDatabase?: boolean, ignoreSuspending?: boolean) => Promise>; -} diff --git a/_types/src/lib/src/services/base/DatabaseService.d.ts b/_types/src/lib/src/services/base/DatabaseService.d.ts deleted file mode 100644 index f919f462..00000000 --- a/_types/src/lib/src/services/base/DatabaseService.d.ts +++ /dev/null @@ -1,39 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { IDatabaseService, IPathService, IVaultService, openDatabaseParameters } from "./IService"; -import { ServiceBase, type ServiceContext } from "./ServiceBase"; -import { LiveSyncLocalDB } from "@lib/pouchdb/LiveSyncLocalDB"; -import type { SettingService } from "./SettingService"; -import type { APIService } from "./APIService"; -import type { ObsidianLiveSyncSettings } from "@lib/common/models/setting.type"; -export type DatabaseServiceDependencies = { - path: IPathService; - vault: IVaultService; - setting: SettingService; - API: APIService; -}; -/** - * The DatabaseService provides methods for managing the local database. - * Please note that each event of database lifecycle is handled in DatabaseEventService. - */ -export declare abstract class DatabaseService extends ServiceBase implements IDatabaseService { - _log: (msg: unknown, level?: import("octagonal-wheels/common/logger").LOG_LEVEL, key?: string) => void; - constructor(context: T, dependencies: DatabaseServiceDependencies); - protected _localDatabase: LiveSyncLocalDB | null; - protected services: DatabaseServiceDependencies; - onOpenDatabase: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(vaultName: string) => Promise>; - /** - * Called after the local database has been reset. - */ - onDatabaseReset: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - get localDatabase(): LiveSyncLocalDB; - get localDatabaseDirect(): LiveSyncLocalDB | null; - protected modifyDatabaseOptions(settings: ObsidianLiveSyncSettings, name: string, options: PouchDB.Configuration.DatabaseConfiguration): { - name: string; - options: PouchDB.Configuration.DatabaseConfiguration; - }; - createPouchDBInstance(name?: string, options?: PouchDB.Configuration.DatabaseConfiguration): PouchDB.Database; - openDatabase(params: openDatabaseParameters): Promise; - isDatabaseReady(): boolean; - resetDatabase(): Promise; -} diff --git a/_types/src/lib/src/services/base/FileProcessingService.d.ts b/_types/src/lib/src/services/base/FileProcessingService.d.ts deleted file mode 100644 index f600c57a..00000000 --- a/_types/src/lib/src/services/base/FileProcessingService.d.ts +++ /dev/null @@ -1,27 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { IFileProcessingService } from "./IService"; -import { ServiceBase, type ServiceContext } from "./ServiceBase"; -/** - * File processing service handles file events and processes them accordingly. - */ -export declare class FileProcessingService extends ServiceBase implements IFileProcessingService { - /** - * Process a file event item by the registered handlers. - */ - readonly processFileEvent: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(item: import("../../common/types").FileEventItem) => Promise>; - /** - * Process a file event item optionally, if any handler is registered. - * i.e., hidden files synchronisation or customisation sync. - */ - readonly processOptionalFileEvent: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(path: import("../../common/types").FilePath) => Promise>; - /** - * Commit any pending file events that have been queued for processing. - */ - readonly commitPendingFileEvents: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - batched: import("octagonal-wheels/dataobject/reactive_v2").ReactiveSource; - totalQueued: import("octagonal-wheels/dataobject/reactive_v2").ReactiveSource; - processing: import("octagonal-wheels/dataobject/reactive_v2").ReactiveSource; - totalStorageFileEventCount: number; - onStorageFileEvent(): void; -} diff --git a/_types/src/lib/src/services/base/IService.d.ts b/_types/src/lib/src/services/base/IService.d.ts deleted file mode 100644 index 7897d119..00000000 --- a/_types/src/lib/src/services/base/IService.d.ts +++ /dev/null @@ -1,282 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { FetchHttpHandler } from "@smithy/fetch-http-handler"; -import { type LOG_LEVEL } from "octagonal-wheels/common/logger"; -import type { AnyEntry, AUTO_MERGED, CouchDBCredentials, diff_result, DocumentID, EntryDoc, EntryHasPath, FileEventItem, FilePath, FilePathWithPrefix, LoadedEntry, MetaEntry, MISSING_OR_ERROR, ObsidianLiveSyncSettings, RemoteDBSettings, TweakValues, UXFileInfo, UXFileInfoStub } from "@lib/common/types"; -import type { LiveSyncLocalDB } from "@lib/pouchdb/LiveSyncLocalDB"; -import type { LiveSyncAbstractReplicator } from "@lib/replication/LiveSyncAbstractReplicator"; -import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase"; -import type { Confirm } from "@lib/interfaces/Confirm"; -import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive"; -import type { ReplicationStatics } from "@lib/common/models/shared.definition"; -import type { ReplicatorService } from "./ReplicatorService"; -import type { DatabaseEventService } from "./DatabaseEventService"; -import type { BASE_IS_NEW, EVEN, TARGET_IS_NEW } from "@lib/common/models/shared.const.symbols"; -import type { AsyncActivityOptions } from "@lib/interfaces/AsyncActivityRunner.ts"; -declare global { - interface OPTIONAL_SYNC_FEATURES { - DISABLE: "DISABLE"; - } -} -export interface ICommandCompat { - id: string; - name: string; - icon?: string; - callback?: () => any; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - checkCallback?: (checking: boolean) => boolean | void; - editorCallback?: (editor: any, ctx: any) => any; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - editorCheckCallback?: (checking: any, editor: any, ctx: any) => boolean | void; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration -} -export interface IAPIService { - getCustomFetchHandler(): FetchHttpHandler; - addStatusBarItem(): HTMLElement | undefined; - addLog(message: unknown, level: LOG_LEVEL, key?: string): void; - isMobile(): boolean; - showWindow(type: string): Promise; - showWindowOnRight?(type: string): Promise; - getAppID(): string; - getSystemVaultName(): string; - getPlatform(): string; - getAppVersion(): string; - getPluginVersion(): string; - addCommand(command: TCommand): TCommand; - registerWindow(type: string, factory: (leaf: T) => unknown): void; - addRibbonIcon(icon: string, title: string, callback: (evt: MouseEvent) => unknown): HTMLElement; - registerProtocolHandler(action: string, handler: (params: Record) => unknown): void; - confirm: Confirm; - responseCount: ReactiveSource; - requestCount: ReactiveSource; - isOnline: boolean; - webCompatFetch(url: string | Request, opts?: RequestInit): Promise; - nativeFetch(url: string | Request, opts?: RequestInit): Promise; - setInterval(handler: () => void, timeout: number): number; - clearInterval(timerId: number): void; - getSystemConfigDir(): string; -} -export interface IPathService { - id2path(id: DocumentID, entry?: EntryHasPath, stripPrefix?: boolean): FilePathWithPrefix; - path2id(filename: FilePathWithPrefix | FilePath, prefix?: string): Promise; - getPath(entry: AnyEntry): FilePathWithPrefix; - markChangesAreSame(old: UXFileInfo | AnyEntry | FilePathWithPrefix, newMtime: number, oldMtime: number): boolean | undefined; - unmarkChanges(file: AnyEntry | FilePathWithPrefix | UXFileInfoStub): void; - compareFileFreshness(baseFile: UXFileInfoStub | AnyEntry | undefined, checkTarget: UXFileInfo | AnyEntry | undefined): typeof BASE_IS_NEW | typeof TARGET_IS_NEW | typeof EVEN; - isMarkedAsSameChanges(file: UXFileInfoStub | AnyEntry | FilePathWithPrefix, mtimes: number[]): undefined | typeof EVEN; -} -export interface openDatabaseParameters { - replicator: ReplicatorService; - databaseEvents: DatabaseEventService; -} -export interface IDatabaseService { - localDatabase: LiveSyncLocalDB; - createPouchDBInstance(name?: string, options?: PouchDB.Configuration.DatabaseConfiguration): PouchDB.Database; - openDatabase(params: openDatabaseParameters): Promise; - resetDatabase(): Promise; - onDatabaseReset: () => Promise; - onOpenDatabase: (vaultName: string) => Promise; - isDatabaseReady(): boolean; -} -export interface IDatabaseEventService { - onUnloadDatabase(db: LiveSyncLocalDB): Promise; - onCloseDatabase(db: LiveSyncLocalDB): Promise; - onDatabaseInitialisation(db: LiveSyncLocalDB): Promise; - onDatabaseInitialised(showNotice: boolean): Promise; - onDatabaseHasReady(): Promise; - onResetDatabase(db: LiveSyncLocalDB): Promise; - initialiseDatabase(showingNotice?: boolean, reopenDatabase?: boolean, ignoreSuspending?: boolean): Promise; -} -export interface IKeyValueDBService { - openSimpleStore(kind: string): SimpleStore; - simpleStore: SimpleStore; -} -export interface IFileProcessingService { - processFileEvent(item: FileEventItem): Promise; - processOptionalFileEvent(path: FilePath): Promise; - commitPendingFileEvents(): Promise; - batched: ReactiveSource; - processing: ReactiveSource; - totalQueued: ReactiveSource; - totalStorageFileEventCount: number; - onStorageFileEvent(): void; -} -export interface IReplicatorService { - onCloseActiveReplication(): Promise; - onReplicatorInitialised(): Promise; - getNewReplicator(settingOverride?: Partial): Promise; - getActiveReplicator(): LiveSyncAbstractReplicator | undefined; - replicationStatics: ReactiveSource; - /** Number of finite remote operations currently in progress. */ - boundedRemoteActivityCount: ReactiveSource; - /** Runs a finite remote operation within the host activity policy. */ - runBoundedRemoteActivity(task: () => T | PromiseLike, options?: AsyncActivityOptions): Promise; -} -export interface IReplicationService { - processSynchroniseResult(doc: MetaEntry): Promise; - processOptionalSynchroniseResult(doc: LoadedEntry): Promise; - processVirtualDocument(docs: PouchDB.Core.ExistingDocument): Promise; - onBeforeReplicate(showMessage: boolean): Promise; - checkConnectionFailure(): Promise; - onCheckReplicationReady(showMessage: boolean): Promise; - isReplicationReady(showMessage: boolean): Promise; - performReplication(showMessage?: boolean): Promise; - replicate(showMessage?: boolean): Promise; - replicateByEvent(showMessage?: boolean): Promise; - onReplicationFailed(showMessage?: boolean): Promise; - parseSynchroniseResult(docs: Array>): Promise; - databaseQueueCount: ReactiveSource; - storageApplyingCount: ReactiveSource; - replicationResultCount: ReactiveSource; - replicateAllToRemote(showingNotice?: boolean, sendChunksInBulkDisabled?: boolean): Promise; - replicateAllFromRemote(showingNotice?: boolean): Promise; - markLocked(lockByClean?: boolean): Promise; - markUnlocked(): Promise; - markResolved(): Promise; -} -export interface IRemoteService { - connect(uri: string, auth: CouchDBCredentials, disableRequestURI: boolean, passphrase: string | false, useDynamicIterationCount: boolean, performSetup: boolean, skipInfo: boolean, compression: boolean, customHeaders: Record, useRequestAPI: boolean, getPBKDF2Salt: () => Promise): Promise; - info: PouchDB.Core.DatabaseInfo; - }>; - /** - * State if the last POST request failed due to payload size. - */ - get hadLastPostFailedBySize(): boolean; -} -export interface IConflictService { - getOptionalConflictCheckMethod(path: FilePathWithPrefix): Promise; - resolveByUserInteraction: (filename: FilePathWithPrefix, conflictCheckResult: diff_result) => Promise; - queueCheckForIfOpen(path: FilePathWithPrefix): Promise; - queueCheckFor(path: FilePathWithPrefix): Promise; - ensureAllProcessed(): Promise; - resolveByDeletingRevision(path: FilePathWithPrefix, deleteRevision: string, title: string): Promise; - resolve(filename: FilePathWithPrefix): Promise; - resolveByNewest(filename: FilePathWithPrefix): Promise; - resolveAllConflictedFilesByNewerOnes(): Promise; - conflictProcessQueueCount: ReactiveSource; -} -export interface IAppLifecycleService { - onLayoutReady(): Promise; - onFirstInitialise(): Promise; - onReady(): Promise; - onWireUpEvents(): Promise; - onInitialise(): Promise; - onLoad(): Promise; - onSettingLoaded(): Promise; - onLoaded(): Promise; - onScanningStartupIssues(): Promise; - onAppUnload(): Promise; - onBeforeUnload(): Promise; - onUnload(): Promise; - onSuspending(): Promise; - onResuming(): Promise; - onResumed(): Promise; - getUnresolvedMessages: () => Promise<(string | Error)[][]>; - performRestart(): void; - askRestart(message?: string): void; - scheduleRestart(): void; - isSuspended(): boolean; - setSuspended(suspend: boolean): void; - isReady(): boolean; - markIsReady(): void; - resetIsReady(): void; - isReloadingScheduled(): boolean; -} -export interface ISettingService { - onBeforeRealiseSetting(): Promise; - onSettingRealised(): Promise; - onRealiseSetting(): Promise; - suspendAllSync(): Promise; - suspendExtraSync(): Promise; - suggestOptionalFeatures(opt: { - enableFetch?: boolean; - enableOverwrite?: boolean; - }): Promise; - enableOptionalFeature(mode: keyof OPTIONAL_SYNC_FEATURES): Promise; - clearUsedPassphrase(): void; - decryptSettings(settings: ObsidianLiveSyncSettings): Promise; - adjustSettings(settings: ObsidianLiveSyncSettings): Promise; - loadSettings(): Promise; - getDeviceAndVaultName(): string; - setDeviceAndVaultName(name: string): void; - saveDeviceAndVaultName(): void; - onBeforeSaveSettingData(nextSettings: ObsidianLiveSyncSettings, previousSettings: ObsidianLiveSyncSettings): Promise<(Partial | void)[]>; - saveSettingData(): Promise; - currentSettings(): ObsidianLiveSyncSettings; - updateSettings(updateFn: (current: ObsidianLiveSyncSettings) => ObsidianLiveSyncSettings, saveImmediately?: boolean): Promise; - applyExternalSettings(partial: Partial, saveImmediately?: boolean): Promise; - applyPartial(partial: Partial, saveImmediately?: boolean): Promise; - onSettingLoaded(settings: ObsidianLiveSyncSettings): Promise; - onSettingChanged(settings: ObsidianLiveSyncSettings): Promise; - onSettingSaved(settings: ObsidianLiveSyncSettings): Promise; - getSmallConfig(key: string): string | null; - setSmallConfig(key: string, value: string): void; - deleteSmallConfig(key: string): void; -} -export interface ITweakValueService { - fetchRemotePreferred(trialSetting: RemoteDBSettings): Promise; - checkAndAskResolvingMismatched(preferred: Partial): Promise<[TweakValues | boolean, boolean]>; - askResolvingMismatched(preferredSource: TweakValues): Promise<"OK" | "CHECKAGAIN" | "IGNORE">; - checkAndAskUseRemoteConfiguration(settings: RemoteDBSettings): Promise<{ - result: false | TweakValues; - requireFetch: boolean; - }>; - askUseRemoteConfiguration(trialSetting: RemoteDBSettings, preferred: TweakValues): Promise<{ - result: false | TweakValues; - requireFetch: boolean; - }>; -} -export interface IVaultService { - vaultName(): string; - getVaultName(): string; - scanVault(showingNotice?: boolean, ignoreSuspending?: boolean): Promise; - isIgnoredByIgnoreFile(file: string | UXFileInfoStub): Promise; - isTargetFile(file: string | UXFileInfoStub): Promise; - isTargetFileInExtra(file: string | UXFileInfoStub): Promise; - isFileSizeTooLarge(size: number): boolean; - getActiveFilePath(): FilePath | undefined; - isStorageInsensitive(): boolean; - shouldCheckCaseInsensitively(): boolean; - isValidPath(path: string): boolean; -} -export interface ITestService { - test(): Promise; - testMultiDevice(): Promise; - addTestResult(name: string, key: string, result: boolean, summary?: string, message?: string): void; -} -export interface IUIService { - promptCopyToClipboard(title: string, value: string): Promise; - showMarkdownDialog(title: string, contentMD: string, buttons: T): Promise<(typeof buttons)[number] | false>; - get confirm(): Confirm; -} -export interface IConfigService { - getSmallConfig(key: string): string | null; - setSmallConfig(key: string, value: string): void; - deleteSmallConfig(key: string): void; -} -export interface IServiceHub { - API: IAPIService; - path: IPathService; - database: IDatabaseService; - databaseEvents: IDatabaseEventService; - replicator: IReplicatorService; - fileProcessing: IFileProcessingService; - replication: IReplicationService; - remote: IRemoteService; - conflict: IConflictService; - appLifecycle: IAppLifecycleService; - setting: ISettingService; - tweakValue: ITweakValueService; - vault: IVaultService; - test: ITestService; - UI: IUIService; - config: IConfigService; - keyValueDB: IKeyValueDBService; - control: IControlService; -} -export interface IControlService { - applySettings(): Promise; - onLoad(): Promise; - onReady(): Promise; - onUnload(): Promise; - hasUnloaded(): boolean; - activated: Promise; -} diff --git a/_types/src/lib/src/services/base/KeyValueDBService.d.ts b/_types/src/lib/src/services/base/KeyValueDBService.d.ts deleted file mode 100644 index d7d0dea7..00000000 --- a/_types/src/lib/src/services/base/KeyValueDBService.d.ts +++ /dev/null @@ -1,42 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { SimpleStore } from "@lib/common/utils"; -import type { IKeyValueDBService, IVaultService } from "./IService"; -import { ServiceBase, type ServiceContext } from "./ServiceBase"; -import type { KeyValueDatabase } from "@lib/interfaces/KeyValueDatabase"; -import type { InjectableDatabaseEventService } from "@lib/services/implements/injectable/InjectableDatabaseEventService"; -import type { AppLifecycleServiceBase } from "@lib/services/implements/injectable/InjectableAppLifecycleService"; -export interface KeyValueDBDependencies { - databaseEvents: InjectableDatabaseEventService; - vault: IVaultService; - appLifecycle: AppLifecycleServiceBase; -} -/** - * The KeyValueDBService provides methods for managing the local key-value database. - * Please note that each event of database lifecycle is handled in DatabaseEventService. - */ -export declare abstract class KeyValueDBService extends ServiceBase implements IKeyValueDBService { - private _kvDB; - private _simpleStore; - get simpleStore(): SimpleStore; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - get kvDB(): KeyValueDatabase; - private databaseEvents; - private vault; - private appLifecycle; - private _log; - private _everyOnResetDatabase; - private tryCloseKvDB; - private openKeyValueDB; - private _onOtherDatabaseUnload; - private _onOtherDatabaseClose; - private _everyOnInitializeDatabase; - private _everyOnloadAfterLoadSettings; - constructor(context: T, dependencies: KeyValueDBDependencies); - openSimpleStore(kind: string): { - get: (key: string) => Promise; - set: (key: string, value: unknown) => Promise; - delete: (key: string) => Promise; - keys: (from: string | undefined, to: string | undefined, count?: number) => Promise; - db: Promise; - }; -} diff --git a/_types/src/lib/src/services/base/PathService.d.ts b/_types/src/lib/src/services/base/PathService.d.ts deleted file mode 100644 index 81582fc8..00000000 --- a/_types/src/lib/src/services/base/PathService.d.ts +++ /dev/null @@ -1,40 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { DocumentID, EntryHasPath, FilePathWithPrefix, FilePath, AnyEntry, UXFileInfo, UXFileInfoStub } from "@lib/common/types"; -import type { IPathService, ISettingService } from "./IService"; -import { ServiceBase, type ServiceContext } from "./ServiceBase"; -import type { BASE_IS_NEW, EVEN, TARGET_IS_NEW } from "@lib/common/models/shared.const.symbols"; -export interface PathServiceDependencies { - settingService: ISettingService; -} -/** - * The PathService provides methods for converting between file paths and document IDs. - * This class would be migrated to the new logic later. - */ -export declare abstract class PathService extends ServiceBase implements IPathService { - protected settingService: ISettingService; - protected abstract normalizePath(path: string): string; - get settings(): import("@lib/common/types").ObsidianLiveSyncSettings; - constructor(context: T, dependencies: PathServiceDependencies); - private _id2path; - private _path2id; - /** - * Convert a document ID or entry to a virtual file path. - * @param id A document ID. Nowadays, it is mostly not the same as the file path. - * If the document has `_` prefixed, saved as `/_`. - * @param entry An entry object. If provided, it can be used to get the path directly. - * @param stripPrefix Whether to strip the prefix from the path. - */ - id2path(id: DocumentID, entry?: EntryHasPath, stripPrefix?: boolean): FilePathWithPrefix; - /** - * Convert a virtual file path to a document ID (with prefix if any). - * @param filename A file path with or without prefix. - * @param prefix The prefix to use for the document ID. - */ - path2id(filename: FilePathWithPrefix | FilePath, prefix?: string): Promise; - getPath(entry: AnyEntry): FilePathWithPrefix; - abstract markChangesAreSame(old: UXFileInfo | AnyEntry | FilePathWithPrefix, newMtime: number, oldMtime: number): boolean | undefined; - abstract unmarkChanges(file: AnyEntry | FilePathWithPrefix | UXFileInfoStub): void; - abstract compareFileFreshness(baseFile: UXFileInfoStub | AnyEntry | undefined, checkTarget: UXFileInfo | AnyEntry | undefined): typeof BASE_IS_NEW | typeof TARGET_IS_NEW | typeof EVEN; - abstract isMarkedAsSameChanges(file: UXFileInfoStub | AnyEntry | FilePathWithPrefix, mtimes: number[]): undefined | typeof EVEN; -} diff --git a/_types/src/lib/src/services/base/RemoteService.d.ts b/_types/src/lib/src/services/base/RemoteService.d.ts deleted file mode 100644 index d173dfaf..00000000 --- a/_types/src/lib/src/services/base/RemoteService.d.ts +++ /dev/null @@ -1,59 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { CouchDBCredentials, EntryDoc } from "@lib/common/types"; -import type { IRemoteService } from "./IService"; -import { ServiceBase, type ServiceContext } from "./ServiceBase"; -import { type LOG_LEVEL } from "@lib/common/types"; -import { AuthorizationHeaderGenerator } from "@lib/replication/httplib"; -import type { APIService } from "@lib/services/base/APIService"; -import type { AppLifecycleService } from "@lib/services/base/AppLifecycleService"; -import type { SettingService } from "@lib/services/base/SettingService"; -import { UnresolvedErrorManager } from "@lib/services/base/UnresolvedErrorManager"; -import { type LogFunction } from "@lib/services/lib/logUtils"; -export interface RemoteServiceDependencies { - APIService: APIService; - appLifecycle: AppLifecycleService; - setting: SettingService; -} -declare const FetchMethod: { - readonly webCompat: 0; - readonly native: 1; -}; -type FetchMethod = (typeof FetchMethod)[keyof typeof FetchMethod]; -/** - * The RemoteService provides methods for interacting with the remote database. - */ -export declare abstract class RemoteService extends ServiceBase implements IRemoteService { - /** - * Connect to the remote database with the provided settings. - * @param uri The URI of the remote database. - * @param auth The authentication credentials for the remote database. - * @param disableRequestURI Whether to disable the request URI. - * @param passphrase The passphrase for the remote database. - * @param useDynamicIterationCount Whether to use dynamic iteration count. - * @param performSetup Whether to perform setup. - * @param skipInfo Whether to skip information retrieval. - * @param compression Whether to enable compression. - * @param customHeaders Custom headers to include in the request. - * @param useRequestAPI Whether to use the request API. - * @param getPBKDF2Salt Function to retrieve the PBKDF2 salt. - * Note that this function is used for CouchDB and compatible only. - */ - protected _log: LogFunction; - protected _authHeader: AuthorizationHeaderGenerator; - protected _APIService: APIService; - protected _appLifecycleService: AppLifecycleService; - protected _settingService: SettingService; - protected _unresolvedErrors: UnresolvedErrorManager; - protected last_successful_post: boolean; - get hadLastPostFailedBySize(): boolean; - constructor(context: T, dependencies: RemoteServiceDependencies); - showError(msg: string, max_log_level?: LOG_LEVEL): void; - clearErrors(): void; - performFetch(req: string | Request, opts?: RequestInit, fetchMethod?: FetchMethod): Promise; - connect(uri: string, auth: CouchDBCredentials, disableRequestURI: boolean, passphrase: string | false, useDynamicIterationCount: boolean, performSetup: boolean, skipInfo: boolean, compression: boolean, customHeaders: Record, useRequestAPI: boolean, getPBKDF2Salt: () => Promise): Promise; - info: PouchDB.Core.DatabaseInfo; - }>; -} -export {}; diff --git a/_types/src/lib/src/services/base/ReplicationService.d.ts b/_types/src/lib/src/services/base/ReplicationService.d.ts deleted file mode 100644 index b0f116e0..00000000 --- a/_types/src/lib/src/services/base/ReplicationService.d.ts +++ /dev/null @@ -1,94 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type LOG_LEVEL } from "@lib/common/types"; -import type { IAPIService, IDatabaseService, IFileProcessingService, IReplicationService, IReplicatorService, ISettingService } from "./IService"; -import { ServiceBase, type ServiceContext } from "./ServiceBase"; -import { type LogFunction } from "@lib/services/lib/logUtils"; -import type { LiveSyncAbstractReplicator } from "@lib/replication/LiveSyncAbstractReplicator"; -import type { AppLifecycleService } from "./AppLifecycleService"; -export interface ReplicationServiceDependencies { - APIService: IAPIService; - settingService: ISettingService; - appLifecycleService: AppLifecycleService; - databaseService: IDatabaseService; - replicatorService: IReplicatorService; - fileProcessingService: IFileProcessingService; -} -/** - * The ReplicationService provides methods for managing replication processes. - */ -export declare abstract class ReplicationService extends ServiceBase implements IReplicationService { - private _unresolvedErrorManager; - showError(msg: string, max_log_level?: LOG_LEVEL): void; - clearErrors(): void; - _log: LogFunction; - settingService: ISettingService; - appLifecycleService: AppLifecycleService; - replicatorService: IReplicatorService; - APIService: IAPIService; - fileProcessing: IFileProcessingService; - databaseService: IDatabaseService; - constructor(context: T, dependencies: ReplicationServiceDependencies); - /** - * Process a synchronisation result document. - */ - readonly processSynchroniseResult: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(doc: import("@lib/common/types").MetaEntry) => Promise>; - /** - * Process a synchronisation result document for optional entries i.e., hidden files. - */ - readonly processOptionalSynchroniseResult: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(doc: import("@lib/common/types").LoadedEntry) => Promise>; - /** - * Process an array of synchronisation result documents. - * @param docs An array of documents to parse and handle. - */ - readonly parseSynchroniseResult: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(docs: Array>) => Promise>; - /** - * Process a virtual document (e.g., for customisation sync). - */ - readonly processVirtualDocument: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(docs: PouchDB.Core.ExistingDocument) => Promise>; - /** - * An event triggered before starting replication. - */ - readonly onBeforeReplicate: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(showMessage: boolean) => Promise>; - /** - * - */ - readonly onCheckReplicationReady: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(showMessage: boolean) => Promise>; - /** - * Check if the replication is ready to start. - * @param showMessage Whether to show messages to the user. - */ - isReplicationReady(showMessage?: boolean): Promise; - onReplicationFailed: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(showMessage?: boolean) => Promise>; - private performReplicationRequest; - /** - * Perform replication and handle a failed result. - * @param showMessage Whether to show replication progress messages. - */ - performReplication(showMessage?: boolean): Promise; - /** - * Start the replication process. - * @param showMessage Whether to show messages to the user. - */ - replicate(showMessage?: boolean): Promise; - previousReplicated: number; - /** - * Start the replication process triggered by an event (e.g., file change). - * @param showMessage Whether to show messages to the user. - */ - replicateByEvent(showMessage?: boolean): Promise; - /** - * Check if there is a connection failure with the remote database. - */ - readonly checkConnectionFailure: import("@lib/services/lib/HandlerUtils").MultipleHandlerFunction<() => Promise, unknown>; - databaseQueueCount: import("octagonal-wheels/dataobject/reactive_v2").ReactiveSource; - storageApplyingCount: import("octagonal-wheels/dataobject/reactive_v2").ReactiveSource; - replicationResultCount: import("octagonal-wheels/dataobject/reactive_v2").ReactiveSource; - getActiveReplicatorFor(usage: string): false | LiveSyncAbstractReplicator; - replicateAllToRemote(showingNotice?: boolean, sendChunksInBulkDisabled?: boolean): Promise; - replicateAllFromRemote(showingNotice?: boolean): Promise; - private _getReplicatorAndPerform; - markLocked(lockByClean?: boolean): Promise; - markUnlocked(): Promise; - markResolved(): Promise; -} diff --git a/_types/src/lib/src/services/base/ReplicatorService.d.ts b/_types/src/lib/src/services/base/ReplicatorService.d.ts deleted file mode 100644 index 41c50da5..00000000 --- a/_types/src/lib/src/services/base/ReplicatorService.d.ts +++ /dev/null @@ -1,64 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { LiveSyncAbstractReplicator } from "@lib/replication/LiveSyncAbstractReplicator"; -import type { IReplicatorService } from "./IService"; -import { ServiceBase, type ServiceContext } from "./ServiceBase"; -import type { SettingService } from "./SettingService"; -import type { AppLifecycleService } from "./AppLifecycleService"; -import { UnresolvedErrorManager } from "./UnresolvedErrorManager"; -import type { DatabaseEventService } from "./DatabaseEventService"; -import { type AsyncActivityOptions, type AsyncActivityRunner } from "@lib/interfaces/AsyncActivityRunner.ts"; -export interface ReplicatorServiceDependencies { - settingService: SettingService; - appLifecycleService: AppLifecycleService; - databaseEventService: DatabaseEventService; - activityRunner?: AsyncActivityRunner; -} -/** - * The ReplicatorService provides methods for managing replication. - */ -export declare abstract class ReplicatorService extends ServiceBase implements IReplicatorService { - protected dependencies: ReplicatorServiceDependencies; - readonly boundedRemoteActivityCount: import("octagonal-wheels/dataobject/reactive_v2").ReactiveSource; - _log: (msg: unknown, level?: import("octagonal-wheels/common/logger").LOG_LEVEL, key?: string) => void; - private settingService; - private databaseEventService; - private _activeReplicator; - private _replicatorType; - private appLifecycleService; - _unresolvedErrorManager: UnresolvedErrorManager; - constructor(context: T, dependencies: ReplicatorServiceDependencies); - /** - * Runs one finite remote operation while exposing its lifetime to host policy and status UI. - * Continuous replication must not use this boundary. - */ - runBoundedRemoteActivity(task: () => TValue | PromiseLike, options?: AsyncActivityOptions): Promise; - private suspendReplication; - private reinitialiseReplicator; - private disposeReplicator; - private _initialiseReplicator; - /** - * Close the active replication if any. - * Not used currently. - */ - readonly onCloseActiveReplication: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - /** - * Get a new replicator instance based on the provided settings. - */ - readonly getNewReplicator: import("@lib/services/lib/HandlerUtils").MultipleHandlerFunction<(settingOverride?: Partial) => Promise, unknown>; - readonly onReplicatorInitialised: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - /** - * Get the currently active replicator instance. - * If no active replicator, return undefined but that is the fatal situation (on Obsidian). - */ - getActiveReplicator(): LiveSyncAbstractReplicator | undefined; - replicationStatics: import("octagonal-wheels/dataobject/reactive_v2").ReactiveSource<{ - sent: number; - arrived: number; - maxPullSeq: number; - maxPushSeq: number; - lastSyncPullSeq: number; - lastSyncPushSeq: number; - syncStatus: import("@lib/common/types").DatabaseConnectingStatus; - }>; -} diff --git a/_types/src/lib/src/services/base/ServiceBase.d.ts b/_types/src/lib/src/services/base/ServiceBase.d.ts deleted file mode 100644 index 6bc014c9..00000000 --- a/_types/src/lib/src/services/base/ServiceBase.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare class ServiceContext { -} -export declare abstract class ServiceBase { - protected context: T; - constructor(context: T); -} diff --git a/_types/src/lib/src/services/base/SettingService.d.ts b/_types/src/lib/src/services/base/SettingService.d.ts deleted file mode 100644 index fe082174..00000000 --- a/_types/src/lib/src/services/base/SettingService.d.ts +++ /dev/null @@ -1,114 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type ObsidianLiveSyncSettings } from "@lib/common/types"; -import type { IAPIService, ISettingService } from "./IService"; -import { ServiceBase, type ServiceContext } from "./ServiceBase"; -import { createInstanceLogFunction } from "@lib/services/lib/logUtils"; -export interface SettingServiceDependencies { - APIService: IAPIService; -} -export declare abstract class SettingService extends ServiceBase implements ISettingService { - deviceAndVaultName: string; - protected APIService: IAPIService; - protected abstract setItem(key: string, value: string): void; - protected abstract getItem(key: string): string; - protected abstract deleteItem(key: string): void; - _settings: ObsidianLiveSyncSettings; - get settings(): ObsidianLiveSyncSettings; - set settings(value: ObsidianLiveSyncSettings); - protected abstract saveData(setting: ObsidianLiveSyncSettings): Promise; - protected abstract loadData(): Promise; - private _lastPersistedSettings?; - _log: ReturnType; - constructor(context: T, dependencies: SettingServiceDependencies); - /** - * Adjust the given settings, e.g., migrate old settings to new format. - * @param settings The settings to adjust. - */ - adjustSettings(settings: ObsidianLiveSyncSettings): Promise; - /** - * Get the unique name for identify the device. - */ - getDeviceAndVaultName(): string; - /** - * Set the unique name for identify the device. - * @param name The unique name to set. - */ - setDeviceAndVaultName(name: string): void; - /** - * Save the current device and vault name to settings, aside from the main settings. - */ - saveDeviceAndVaultName(): void; - private additionalSuffixOfDatabaseName; - private getKey; - setSmallConfig(key: string, value: string): void; - getSmallConfig(key: string): string; - deleteSmallConfig(key: string): void; - /** - * Save the current settings to storage. - */ - saveSettingData(): Promise; - private encryptRemoteConfigurationUris; - private decryptRemoteConfigurationUris; - /** - * Event triggered before realising the settings. - * Handlers can return false to abort the realisation process. - */ - readonly onBeforeRealiseSetting: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - /** - * Event triggered after the settings have been realised. - */ - readonly onSettingRealised: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - /** - * Event triggered to realise the settings. - */ - readonly onRealiseSetting: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - /** - * Suspend all synchronisation activities and save to the settings. - */ - readonly suspendAllSync: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - /** - * Suspend extra synchronisation activities, e.g., hidden files sync. - */ - readonly suspendExtraSync: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - /** - * Suggest enabling optional features to the user. - */ - readonly suggestOptionalFeatures: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(opt: { - enableFetch?: boolean; - enableOverwrite?: boolean; - }) => Promise>; - /** - * Enable an optional feature and save to the settings. - * It may also raised from `handleSuggestOptionalFeatures` if the user agrees. - * @param mode The optional feature to enable. - */ - readonly enableOptionalFeature: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(mode: keyof OPTIONAL_SYNC_FEATURES) => Promise>; - readonly onSettingLoaded: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(settings: ObsidianLiveSyncSettings) => Promise>; - readonly onSettingChanged: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(settings: ObsidianLiveSyncSettings) => Promise>; - readonly onSettingSaved: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(settings: ObsidianLiveSyncSettings) => Promise>; - readonly onBeforeSaveSettingData: import("@lib/services/lib/HandlerUtils").CollectiveHandlerFunction<(nextSettings: ObsidianLiveSyncSettings, previousSettings: ObsidianLiveSyncSettings) => Promise<(Partial | void)[]>, unknown>; - /** - * Get the current settings. - */ - currentSettings(): ObsidianLiveSyncSettings; - updateSettings(updateFn: (settings: ObsidianLiveSyncSettings) => ObsidianLiveSyncSettings, saveImmediately?: boolean): Promise; - applyExternalSettings(partial: Partial, saveImmediately?: boolean): Promise; - applyPartial(partial: Partial, saveImmediately?: boolean): Promise; - getPassphrase(settings: ObsidianLiveSyncSettings): Promise; - private usedPassphrase; - /** - * Clear any used passphrase from memory. - */ - clearUsedPassphrase(): void; - decryptConfigurationItem(encrypted: string, passphrase: string): Promise; - encryptConfigurationItem(src: string, settings: ObsidianLiveSyncSettings): Promise; - /** - * Decrypt the given settings. - * @param settings The settings to decrypt. - */ - decryptSettings(settings: ObsidianLiveSyncSettings): Promise; - loadSettings(): Promise; - private tryDecodeJson; - private cloneSettings; -} diff --git a/_types/src/lib/src/services/base/TestService.d.ts b/_types/src/lib/src/services/base/TestService.d.ts deleted file mode 100644 index 439d3afd..00000000 --- a/_types/src/lib/src/services/base/TestService.d.ts +++ /dev/null @@ -1,30 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ITestService } from "./IService"; -import { ServiceBase, type ServiceContext } from "./ServiceBase"; -/** - * The TestService provides methods for adding and handling test results. - */ -export declare abstract class TestService extends ServiceBase implements ITestService { - /** - * Run the test suite to verify the plug-in's functionality. - * This is typically used for development and debugging purposes. - * It may involve user interaction (means raising resolveByUserInteraction). - */ - readonly test: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - /** - * Run the multi-device test suite to verify the plug-in's functionality across multiple devices. - * This is typically used for development and debugging purposes. - * It may involve user interaction (means raising resolveByUserInteraction). - */ - readonly testMultiDevice: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<() => Promise>; - /** - * Add a test result to the test suite. - * @param name The name of the test case. - * @param key The key of the test result. - * @param result The result of the test (true for success, false for failure). - * @param summary A brief summary of the test result. - * @param message A detailed message about the test result. - */ - abstract addTestResult(name: string, key: string, result: boolean, summary?: string, message?: string): void; -} diff --git a/_types/src/lib/src/services/base/TweakValueService.d.ts b/_types/src/lib/src/services/base/TweakValueService.d.ts deleted file mode 100644 index 91970238..00000000 --- a/_types/src/lib/src/services/base/TweakValueService.d.ts +++ /dev/null @@ -1,42 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { RemoteDBSettings, TweakValues } from "@lib/common/types"; -import type { ITweakValueService } from "./IService"; -import { ServiceBase, type ServiceContext } from "./ServiceBase"; -/** - * The TweakValueService provides methods for managing tweak values and resolving mismatches. - */ -export declare abstract class TweakValueService extends ServiceBase implements ITweakValueService { - /** - * Fetch and trial the remote database settings to determine if they are preferred. - * @param trialSetting The remote database settings to connect. - */ - abstract fetchRemotePreferred(trialSetting: RemoteDBSettings): Promise; - /** - * Check and ask the user to resolve any mismatched tweak values. - * @param preferred The preferred tweak values to check against. - */ - abstract checkAndAskResolvingMismatched(preferred: Partial): Promise<[TweakValues | boolean, boolean]>; - /** - * Ask the user to resolve any mismatched tweak values. - * @param preferredSource The preferred tweak values to resolve against. - */ - abstract askResolvingMismatched(preferredSource: TweakValues): Promise<"OK" | "CHECKAGAIN" | "IGNORE">; - /** - * Check and ask the user to use the remote configuration. - * @param settings The remote database settings to connect. - */ - abstract checkAndAskUseRemoteConfiguration(settings: RemoteDBSettings): Promise<{ - result: false | TweakValues; - requireFetch: boolean; - }>; - /** - * Ask the user to use the remote configuration. - * @param trialSetting The remote database settings to connect. - * @param preferred The preferred tweak values to use. - */ - abstract askUseRemoteConfiguration(trialSetting: RemoteDBSettings, preferred: TweakValues): Promise<{ - result: false | TweakValues; - requireFetch: boolean; - }>; -} diff --git a/_types/src/lib/src/services/base/UnresolvedErrorManager.d.ts b/_types/src/lib/src/services/base/UnresolvedErrorManager.d.ts deleted file mode 100644 index 3cbe2bdb..00000000 --- a/_types/src/lib/src/services/base/UnresolvedErrorManager.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type LOG_LEVEL } from "octagonal-wheels/common/logger"; -import type { AppLifecycleService } from "./AppLifecycleService"; -export declare class UnresolvedErrorManager { - private _log; - private appLifecycleService; - private _occurredErrors; - showError(msg: string, max_log_level?: LOG_LEVEL): void; - clearError(msg: string): void; - clearErrors(): void; - countErrors(needle: string): number; - private _reportUnresolvedMessages; - constructor(appLifecycleService: AppLifecycleService); -} diff --git a/_types/src/lib/src/services/base/VaultService.d.ts b/_types/src/lib/src/services/base/VaultService.d.ts deleted file mode 100644 index fc25dbce..00000000 --- a/_types/src/lib/src/services/base/VaultService.d.ts +++ /dev/null @@ -1,71 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { FilePath } from "@lib/common/types"; -import type { IAPIService, ISettingService, IVaultService } from "./IService"; -import { ServiceBase, type ServiceContext } from "./ServiceBase"; -export interface VaultServiceDependencies { - settingService: ISettingService; - APIService: IAPIService; -} -/** - * The VaultService provides methods for interacting with the vault (local file system). - */ -export declare abstract class VaultService extends ServiceBase implements IVaultService { - protected settingService: ISettingService; - protected APIService: IAPIService; - get settings(): import("@lib/common/types").ObsidianLiveSyncSettings; - constructor(context: T, dependencies: VaultServiceDependencies); - /** - * Get the vault name only. - */ - vaultName(): string; - /** - * Get the vault name with additional suffixes. - */ - getVaultName(): string; - /** - * Scan the vault for changes (especially for changes during the plug-in were not running). - * @param showingNotice Whether to show a notice to the user. - * @param ignoreSuspending Whether to ignore any suspending state. - */ - readonly scanVault: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(showingNotice?: boolean, ignoreSuspending?: boolean) => Promise>; - /** - * Check if a file is ignored by the ignore file (e.g., .gitignore, .obsidianignore). - * @param file The file path or file info stub to check. - */ - readonly isIgnoredByIgnoreFile: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(file: string | import("@lib/common/types").UXFileInfoStub) => Promise>; - /** - * Check if a file is a target file for synchronisation. - * @param file The file path or file info stub to check. - * @param keepFileCheckList Whether to keep the file in the check list. - */ - readonly isTargetFile: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(file: string | import("@lib/common/types").UXFileInfoStub) => Promise>; - /** - * Check if a file is a target file for some extra feature - */ - readonly isTargetFileInExtra: import("@lib/services/lib/HandlerUtils").BooleanMultipleHandlerFunction<(file: string | import("@lib/common/types").UXFileInfoStub) => Promise>; - /** - * Check if a filesize is too large against the current settings. - * @param size The file size to check. - */ - isFileSizeTooLarge(size: number): boolean; - /** - * Get the currently active file path in the editor, if any. - */ - abstract getActiveFilePath(): FilePath | undefined; - /** - * Check if the vault is on a case-insensitive file system. - * This is important for certain operating systems like Windows and macOS. - */ - abstract isStorageInsensitive(): boolean; - /** - * Check if the file system should be treated case-insensitively. - * This is important for certain operating systems like Windows and macOS. - */ - shouldCheckCaseInsensitively(): boolean; - /** - * Check if a given path is valid in the vault. - * @param path The file path to check. - */ - abstract isValidPath(path: string): boolean; -} diff --git a/_types/src/lib/src/services/implements/base/UIService.d.ts b/_types/src/lib/src/services/implements/base/UIService.d.ts deleted file mode 100644 index 1d7e1ab0..00000000 --- a/_types/src/lib/src/services/implements/base/UIService.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { Confirm } from "@lib/interfaces/Confirm"; -import type { ComponentHasResult, SvelteDialogManagerBase } from "@lib/UI/svelteDialog"; -import type { IAPIService, IUIService } from "@lib/services/base/IService"; -import { type AppLifecycleService } from "@lib/services/base/AppLifecycleService.ts"; -import { ServiceBase, type ServiceContext } from "@lib/services/base/ServiceBase"; -export type UIServiceDependencies = { - appLifecycle: AppLifecycleService; - dialogManager: SvelteDialogManagerBase; - APIService: IAPIService; -}; -type DialogResult = "ok" | "cancel"; -type DialogParams = { - title: string; - dataToCopy: string; -}; -export declare abstract class UIService extends ServiceBase implements IUIService { - private _dialogManager; - protected _APIService: IAPIService; - abstract get dialogToCopy(): ComponentHasResult; - constructor(context: T, dependents: UIServiceDependencies); - get dialogManager(): SvelteDialogManagerBase; - promptCopyToClipboard(title: string, value: string): Promise; - showMarkdownDialog(title: string, contentMD: string, buttons: T, defaultAction?: (typeof buttons)[number]): Promise<(typeof buttons)[number] | false>; - get confirm(): Confirm; -} -export {}; diff --git a/_types/src/lib/src/services/implements/browser/BrowserAPIService.d.ts b/_types/src/lib/src/services/implements/browser/BrowserAPIService.d.ts deleted file mode 100644 index 0749f5a0..00000000 --- a/_types/src/lib/src/services/implements/browser/BrowserAPIService.d.ts +++ /dev/null @@ -1,53 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -import { InjectableAPIService } from "@lib/services/implements/injectable/InjectableAPIService"; -import type { FetchHttpHandler } from "@smithy/fetch-http-handler"; -import type { ICommandCompat } from "@lib/services/base/IService"; -import type { Confirm } from "@lib/interfaces/Confirm"; -export declare const PACKAGE_VERSION: string; -export declare const MANIFEST_VERSION: string; -export declare class BrowserAPIService extends InjectableAPIService { - _confirmInstance: Confirm; - private commandBar; - private commandButtons; - private logPanel; - private logViewport; - private readonly maxLogLines; - private windowFactories; - private windowInstances; - private windowRoot; - private windowTabs; - private windowBody; - private windowPanels; - private activeWindowType; - constructor(context: T); - get confirm(): Confirm; - showWindow(type: string): Promise; - getCustomFetchHandler(): FetchHttpHandler; - isMobile(): boolean; - getAppID(): string; - getSystemVaultName: import("@lib/services/lib/HandlerUtils").HandlerFunction<() => string, unknown>; - getAppVersion(): string; - getPluginVersion(): string; - getPlatform(): string; - getCrypto(): Crypto; - nativeFetch(req: string | Request, opts?: RequestInit): Promise; - private ensureLogPanel; - private formatLogLine; - private appendLog; - private ensureCommandBar; - private ensureWindowHost; - private ensureWindowTab; - private ensureWindowPanel; - private activateWindow; - private createLeafShim; - private evaluateEnabled; - private executeCommand; - private refreshCommandStates; - addCommand(command: TCommand): TCommand; - addRibbonIcon(icon: string, title: string, callback: (evt: MouseEvent) => any): HTMLElement; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - registerWindow(type: string, factory: (leaf: any) => any): void; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - registerProtocolHandler(action: string, handler: (params: Record) => any): void; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - addStatusBarItem(): HTMLElement | undefined; -} diff --git a/_types/src/lib/src/services/implements/browser/BrowserConfirm.d.ts b/_types/src/lib/src/services/implements/browser/BrowserConfirm.d.ts deleted file mode 100644 index eb0e390c..00000000 --- a/_types/src/lib/src/services/implements/browser/BrowserConfirm.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { Confirm } from "@lib/interfaces/Confirm"; -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -export declare class BrowserConfirm implements Confirm { - _context: T; - constructor(context: T); - askYesNo(message: string): Promise<"yes" | "no">; - askString(title: string, key: string, placeholder: string, isPassword?: boolean): Promise; - askYesNoDialog(message: string, opt: { - title?: string; - defaultOption?: "Yes" | "No"; - timeout?: number; - }): Promise<"yes" | "no">; - askSelectString(message: string, items: string[]): Promise; - askSelectStringDialogue(message: string, buttons: T, opt: { - title?: string; - defaultAction: T[number]; - timeout?: number; - }): Promise; - askInPopup(key: string, dialogText: string, anchorCallback: (anchor: HTMLAnchorElement) => void): void; - confirmWithMessage(title: string, contentMd: string, buttons: string[], defaultAction: (typeof buttons)[number], timeout?: number): Promise<(typeof buttons)[number] | false>; -} diff --git a/_types/src/lib/src/services/implements/browser/BrowserDatabaseService.d.ts b/_types/src/lib/src/services/implements/browser/BrowserDatabaseService.d.ts deleted file mode 100644 index 522abd72..00000000 --- a/_types/src/lib/src/services/implements/browser/BrowserDatabaseService.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -import { KeyValueDBService } from "@lib/services/base/KeyValueDBService"; -import { DatabaseService } from "@lib/services/base/DatabaseService.ts"; -export declare class BrowserDatabaseService extends DatabaseService { -} -export declare class BrowserKeyValueDBService extends KeyValueDBService { -} diff --git a/_types/src/lib/src/services/implements/browser/BrowserUIService.d.ts b/_types/src/lib/src/services/implements/browser/BrowserUIService.d.ts deleted file mode 100644 index b2881d28..00000000 --- a/_types/src/lib/src/services/implements/browser/BrowserUIService.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { UIService } from "@lib/services/implements/base/UIService"; -import type { ConfigService } from "@lib/services/base/ConfigService"; -import type { AppLifecycleService } from "@lib/services/base/AppLifecycleService"; -import type { ReplicatorService } from "@lib/services/base/ReplicatorService"; -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -import type { IAPIService, IControlService } from "@lib/services/base/IService"; -export type BrowserUIServiceDependencies = { - appLifecycle: AppLifecycleService; - config: ConfigService; - replicator: ReplicatorService; - APIService: IAPIService; - control: IControlService; -}; -export declare class BrowserUIService extends UIService { - get dialogToCopy(): import("svelte/legacy").LegacyComponentType; - constructor(context: T, dependents: BrowserUIServiceDependencies); -} diff --git a/_types/src/lib/src/services/implements/browser/ConfigServiceBrowserCompat.d.ts b/_types/src/lib/src/services/implements/browser/ConfigServiceBrowserCompat.d.ts deleted file mode 100644 index fa78c8c8..00000000 --- a/_types/src/lib/src/services/implements/browser/ConfigServiceBrowserCompat.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { ConfigService } from "@lib/services/base/ConfigService"; -import type { IAPIService, ISettingService } from "@lib/services/base/IService"; -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -import { createInstanceLogFunction } from "@lib/services/lib/logUtils"; -export interface ConfigServiceBrowserCompatDependencies { - settingService: ISettingService; - APIService: IAPIService; -} -export declare class ConfigServiceBrowserCompat extends ConfigService { - private _settingService; - _log: ReturnType; - constructor(context: T, dependencies: ConfigServiceBrowserCompatDependencies); - getSmallConfig(key: string): string | null; - setSmallConfig(key: string, value: string): void; - deleteSmallConfig(key: string): void; -} diff --git a/_types/src/lib/src/services/implements/browser/Menu.d.ts b/_types/src/lib/src/services/implements/browser/Menu.d.ts deleted file mode 100644 index ed9827dc..00000000 --- a/_types/src/lib/src/services/implements/browser/Menu.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type PromiseWithResolvers } from "octagonal-wheels/promises"; -export declare class MenuItem { - type: string; - title: string; - handler?: () => void | Promise; - icon: string; - setTitle(title: string): this; - onClick(callback: () => void | Promise): this; - setIcon(icon: string | null): this; -} -export declare class MenuSeparator { - type: string; -} -export declare class Menu { - type: string; - items: (MenuItem | MenuSeparator)[]; - constructor(); - addItem(callback: (item: MenuItem) => void): this; - addSeparator(): this; - waitingForClose?: PromiseWithResolvers; - showAtPosition(pos: { - x: number; - y: number; - }): Promise; - hide(): void; -} diff --git a/_types/src/lib/src/services/implements/browser/ui/renderMessageMarkdown.d.ts b/_types/src/lib/src/services/implements/browser/ui/renderMessageMarkdown.d.ts deleted file mode 100644 index a101da97..00000000 --- a/_types/src/lib/src/services/implements/browser/ui/renderMessageMarkdown.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare function renderMessageMarkdown(message: string): string; diff --git a/_types/src/lib/src/services/implements/headless/HeadlessAPIService.d.ts b/_types/src/lib/src/services/implements/headless/HeadlessAPIService.d.ts deleted file mode 100644 index 56ec91b3..00000000 --- a/_types/src/lib/src/services/implements/headless/HeadlessAPIService.d.ts +++ /dev/null @@ -1,54 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -import { InjectableAPIService } from "@lib/services/implements/injectable/InjectableAPIService"; -import type { FetchHttpHandler } from "@smithy/fetch-http-handler"; -import type { ICommandCompat } from "@lib/services/base/IService"; -import type { Confirm } from "@lib/interfaces/Confirm"; -/** - * Headless implementation of Confirm that returns sensible defaults instead - * of throwing. Dialogs are logged to stderr so the prompts are visible in - * service logs, and the default/conservative action is taken automatically. - */ -export declare class HeadlessConfirm implements Confirm { - askYesNo(message: string): Promise<"yes" | "no">; - askString(title: string, key: string, placeholder: string, isPassword?: boolean): Promise; - askYesNoDialog(message: string, opt: { - title?: string; - defaultOption?: "Yes" | "No"; - timeout?: number; - }): Promise<"yes" | "no">; - askSelectString(message: string, items: string[]): Promise; - askSelectStringDialogue(message: string, buttons: T, opt: { - title?: string; - defaultAction: T[number]; - timeout?: number; - }): Promise; - askInPopup(key: string, dialogText: string, anchorCallback: (anchor: HTMLAnchorElement) => void): void; - confirmWithMessage(title: string, contentMd: string, buttons: string[], defaultAction: (typeof buttons)[number], timeout?: number): Promise<(typeof buttons)[number] | false>; -} -export declare class HeadlessAPIService extends InjectableAPIService { - private _confirmInstance; - private _systemVaultName; - constructor(context: T); - get confirm(): Confirm; - showWindow(type: string): Promise; - getCustomFetchHandler(): FetchHttpHandler; - isMobile(): boolean; - getAppID(): string; - getAppVersion(): string; - getPluginVersion(): string; - getPlatform(): string; - getCrypto(): Crypto; - addCommand(command: TCommand): TCommand; - addRibbonIcon(icon: string, title: string, callback: (evt: MouseEvent) => any): HTMLElement; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - registerWindow(type: string, factory: (leaf: any) => any): void; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - registerProtocolHandler(action: string, handler: (params: Record) => any): void; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - addStatusBarItem(): HTMLElement | undefined; - private toSafeKeyPart; - private hash32; - private deriveSystemVaultName; - getSystemVaultName(): string; - get isOnline(): boolean; - nativeFetch(req: string | Request, opts?: RequestInit): Promise; -} diff --git a/_types/src/lib/src/services/implements/headless/HeadlessDatabaseService.d.ts b/_types/src/lib/src/services/implements/headless/HeadlessDatabaseService.d.ts deleted file mode 100644 index 01b73501..00000000 --- a/_types/src/lib/src/services/implements/headless/HeadlessDatabaseService.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { KeyValueDBService } from "@lib/services/base/KeyValueDBService"; -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -import { DatabaseService } from "@lib/services/base/DatabaseService.ts"; -export declare class HeadlessDatabaseService extends DatabaseService { -} -export declare class HeadlessKeyValueDBService extends KeyValueDBService { -} diff --git a/_types/src/lib/src/services/implements/injectable/InjectableAPIService.d.ts b/_types/src/lib/src/services/implements/injectable/InjectableAPIService.d.ts deleted file mode 100644 index 3d64ea1f..00000000 --- a/_types/src/lib/src/services/implements/injectable/InjectableAPIService.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { APIService } from "@lib/services/base/APIService"; -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -export declare abstract class InjectableAPIService extends APIService { - addLog: import("@lib/services/lib/HandlerUtils").HandlerFunction<(message: unknown, level: import("octagonal-wheels/common/logger").LOG_LEVEL, key?: string) => void, unknown>; - getPlatform(): string; - getCrypto(): Crypto; -} diff --git a/_types/src/lib/src/services/implements/injectable/InjectableAppLifecycleService.d.ts b/_types/src/lib/src/services/implements/injectable/InjectableAppLifecycleService.d.ts deleted file mode 100644 index 065b4095..00000000 --- a/_types/src/lib/src/services/implements/injectable/InjectableAppLifecycleService.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { AppLifecycleService } from "@lib/services/base/AppLifecycleService"; -import type { IAppLifecycleService } from "@lib/services/base/IService"; -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -export declare abstract class AppLifecycleServiceBase extends AppLifecycleService { - askRestart: import("@lib/services/lib/HandlerUtils").HandlerFunction<(message?: string) => void, unknown>; - scheduleRestart: import("@lib/services/lib/HandlerUtils").HandlerFunction<() => void, unknown>; - isReloadingScheduled: import("@lib/services/lib/HandlerUtils").HandlerFunction<() => boolean, unknown>; -} -export declare abstract class InjectableAppLifecycleService extends AppLifecycleServiceBase implements IAppLifecycleService { - performRestart: import("@lib/services/lib/HandlerUtils").HandlerFunction<() => void, unknown>; -} diff --git a/_types/src/lib/src/services/implements/injectable/InjectableConflictService.d.ts b/_types/src/lib/src/services/implements/injectable/InjectableConflictService.d.ts deleted file mode 100644 index 24f987eb..00000000 --- a/_types/src/lib/src/services/implements/injectable/InjectableConflictService.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { ConflictService } from "@lib/services/base/ConflictService"; -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -export declare class InjectableConflictService extends ConflictService { - queueCheckForIfOpen: import("@lib/services/lib/HandlerUtils").HandlerFunction<(path: import("../../../common/types").FilePathWithPrefix) => Promise, unknown>; - queueCheckFor: import("@lib/services/lib/HandlerUtils").HandlerFunction<(path: import("../../../common/types").FilePathWithPrefix) => Promise, unknown>; - ensureAllProcessed: import("@lib/services/lib/HandlerUtils").HandlerFunction<() => Promise, unknown>; - resolveByDeletingRevision: import("@lib/services/lib/HandlerUtils").HandlerFunction<(path: import("../../../common/types").FilePathWithPrefix, deleteRevision: string, title: string) => Promise, unknown>; - resolve: import("@lib/services/lib/HandlerUtils").HandlerFunction<(filename: import("../../../common/types").FilePathWithPrefix) => Promise, unknown>; - resolveByNewest: import("@lib/services/lib/HandlerUtils").HandlerFunction<(filename: import("../../../common/types").FilePathWithPrefix) => Promise, unknown>; - resolveAllConflictedFilesByNewerOnes: import("@lib/services/lib/HandlerUtils").HandlerFunction<() => Promise, unknown>; -} diff --git a/_types/src/lib/src/services/implements/injectable/InjectableDatabaseEventService.d.ts b/_types/src/lib/src/services/implements/injectable/InjectableDatabaseEventService.d.ts deleted file mode 100644 index d82f9c8f..00000000 --- a/_types/src/lib/src/services/implements/injectable/InjectableDatabaseEventService.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { DatabaseEventService } from "@lib/services/base/DatabaseEventService"; -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -export declare class InjectableDatabaseEventService extends DatabaseEventService { -} diff --git a/_types/src/lib/src/services/implements/injectable/InjectableFileProcessingService.d.ts b/_types/src/lib/src/services/implements/injectable/InjectableFileProcessingService.d.ts deleted file mode 100644 index 23042bce..00000000 --- a/_types/src/lib/src/services/implements/injectable/InjectableFileProcessingService.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { FileProcessingService } from "@lib/services/base/FileProcessingService"; -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -export declare class InjectableFileProcessingService extends FileProcessingService { -} diff --git a/_types/src/lib/src/services/implements/injectable/InjectablePathService.d.ts b/_types/src/lib/src/services/implements/injectable/InjectablePathService.d.ts deleted file mode 100644 index aabc3d73..00000000 --- a/_types/src/lib/src/services/implements/injectable/InjectablePathService.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { UXFileInfo, AnyEntry, UXFileInfoStub, FilePathWithPrefix } from "@lib/common/types"; -import { PathService } from "@lib/services/base/PathService"; -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -import { BASE_IS_NEW, EVEN, TARGET_IS_NEW } from "@lib/common/models/shared.const.symbols"; -export declare function compareFileFreshnessGeneric(baseFile: UXFileInfoStub | AnyEntry | undefined, checkTarget: UXFileInfo | AnyEntry | undefined): typeof BASE_IS_NEW | typeof TARGET_IS_NEW | typeof EVEN; -export declare class PathServiceCompat extends PathService { - markChangesAreSame(old: UXFileInfo | AnyEntry | FilePathWithPrefix, newMtime: number, oldMtime: number): boolean | undefined; - unmarkChanges(file: AnyEntry | FilePathWithPrefix | UXFileInfoStub): void; - compareFileFreshness(baseFile: UXFileInfoStub | AnyEntry | undefined, checkTarget: UXFileInfo | AnyEntry | undefined): typeof BASE_IS_NEW | typeof TARGET_IS_NEW | typeof EVEN; - isMarkedAsSameChanges(file: UXFileInfoStub | AnyEntry | FilePathWithPrefix, mtimes: number[]): undefined | typeof EVEN; - normalizePath(path: string): string; -} diff --git a/_types/src/lib/src/services/implements/injectable/InjectableRemoteService.d.ts b/_types/src/lib/src/services/implements/injectable/InjectableRemoteService.d.ts deleted file mode 100644 index bd8de8c0..00000000 --- a/_types/src/lib/src/services/implements/injectable/InjectableRemoteService.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { RemoteService } from "@lib/services/base/RemoteService"; -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -export declare class InjectableRemoteService extends RemoteService { -} diff --git a/_types/src/lib/src/services/implements/injectable/InjectableReplicationService.d.ts b/_types/src/lib/src/services/implements/injectable/InjectableReplicationService.d.ts deleted file mode 100644 index 6f85ac54..00000000 --- a/_types/src/lib/src/services/implements/injectable/InjectableReplicationService.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { ReplicationService } from "@lib/services/base/ReplicationService"; -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -export declare class InjectableReplicationService extends ReplicationService { -} diff --git a/_types/src/lib/src/services/implements/injectable/InjectableReplicatorService.d.ts b/_types/src/lib/src/services/implements/injectable/InjectableReplicatorService.d.ts deleted file mode 100644 index e339eeb0..00000000 --- a/_types/src/lib/src/services/implements/injectable/InjectableReplicatorService.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { ReplicatorService } from "@lib/services/base/ReplicatorService"; -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -export declare class InjectableReplicatorService extends ReplicatorService { -} diff --git a/_types/src/lib/src/services/implements/injectable/InjectableServiceHub.d.ts b/_types/src/lib/src/services/implements/injectable/InjectableServiceHub.d.ts deleted file mode 100644 index 3d224e2b..00000000 --- a/_types/src/lib/src/services/implements/injectable/InjectableServiceHub.d.ts +++ /dev/null @@ -1,73 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ConfigService } from "@lib/services/base/ConfigService"; -import { ControlService } from "@lib/services/base/ControlService"; -import type { KeyValueDBService } from "@lib/services/base/KeyValueDBService"; -import { PathService } from "@lib/services/base/PathService"; -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -import type { SettingService } from "@lib/services/base/SettingService"; -import { ServiceHub } from "@lib/services/ServiceHub"; -import type { UIService } from "@lib/services/implements/base/UIService"; -import { InjectableAPIService } from "./InjectableAPIService"; -import { type AppLifecycleServiceBase } from "./InjectableAppLifecycleService"; -import { InjectableConflictService } from "./InjectableConflictService"; -import { InjectableDatabaseEventService } from "./InjectableDatabaseEventService"; -import { InjectableFileProcessingService } from "./InjectableFileProcessingService"; -import { InjectableRemoteService } from "./InjectableRemoteService"; -import { InjectableReplicationService } from "./InjectableReplicationService"; -import { InjectableReplicatorService } from "./InjectableReplicatorService"; -import type { InjectableServiceInstances } from "./InjectableServices"; -import { InjectableTestService } from "./InjectableTestService"; -import { InjectableTweakValueService } from "./InjectableTweakValueService"; -import { InjectableVaultService } from "./InjectableVaultService"; -import type { DatabaseService } from "@lib/services/base/DatabaseService.ts"; -export declare class InjectableServiceHub extends ServiceHub { - protected readonly _api: InjectableAPIService; - protected readonly _path: PathService; - protected readonly _database: DatabaseService; - protected readonly _databaseEvents: InjectableDatabaseEventService; - protected readonly _replicator: InjectableReplicatorService; - protected readonly _fileProcessing: InjectableFileProcessingService; - protected readonly _replication: InjectableReplicationService; - protected readonly _remote: InjectableRemoteService; - protected readonly _conflict: InjectableConflictService; - protected readonly _appLifecycle: AppLifecycleServiceBase; - protected readonly _setting: SettingService; - protected readonly _tweakValue: InjectableTweakValueService; - protected readonly _vault: InjectableVaultService; - protected readonly _test: InjectableTestService; - protected readonly _ui: UIService; - protected readonly _config: ConfigService; - protected readonly _keyValueDB: KeyValueDBService; - protected readonly _control: ControlService; - get API(): InjectableAPIService; - get path(): PathService; - get database(): DatabaseService; - get databaseEvents(): InjectableDatabaseEventService; - get replicator(): InjectableReplicatorService; - get fileProcessing(): InjectableFileProcessingService; - get replication(): InjectableReplicationService; - get remote(): InjectableRemoteService; - get conflict(): InjectableConflictService; - get appLifecycle(): AppLifecycleServiceBase; - get setting(): SettingService; - get tweakValue(): InjectableTweakValueService; - get vault(): InjectableVaultService; - get test(): InjectableTestService; - get control(): ControlService; - get keyValueDB(): KeyValueDBService; - get UI(): UIService; - get config(): ConfigService; - constructor(context: T, services: InjectableServiceInstances & { - setting: SettingService; - appLifecycle: AppLifecycleServiceBase; - path: PathService; - API: InjectableAPIService; - ui: UIService; - config: ConfigService; - database: DatabaseService; - vault: InjectableVaultService; - keyValueDB: KeyValueDBService; - replicator: InjectableReplicatorService; - }); -} diff --git a/_types/src/lib/src/services/implements/injectable/InjectableServices.d.ts b/_types/src/lib/src/services/implements/injectable/InjectableServices.d.ts deleted file mode 100644 index 39eb0050..00000000 --- a/_types/src/lib/src/services/implements/injectable/InjectableServices.d.ts +++ /dev/null @@ -1,38 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type ServiceInstances } from "@lib/services/ServiceHub.ts"; -import type { UIService } from "@lib/services/implements/base/UIService.ts"; -import type { ConfigService } from "@lib/services/base/ConfigService.ts"; -import type { ServiceContext } from "@lib/services/base/ServiceBase.ts"; -import type { InjectableAPIService } from "./InjectableAPIService"; -import type { InjectableDatabaseEventService } from "./InjectableDatabaseEventService"; -import type { InjectableReplicatorService } from "./InjectableReplicatorService"; -import type { InjectableFileProcessingService } from "./InjectableFileProcessingService"; -import type { InjectableReplicationService } from "./InjectableReplicationService"; -import type { InjectableRemoteService } from "./InjectableRemoteService"; -import type { InjectableConflictService } from "./InjectableConflictService"; -import type { AppLifecycleServiceBase } from "./InjectableAppLifecycleService"; -import type { InjectableTweakValueService } from "./InjectableTweakValueService"; -import type { InjectableVaultService } from "./InjectableVaultService"; -import type { InjectableTestService } from "./InjectableTestService"; -import type { PathService } from "@lib/services/base/PathService"; -import type { DatabaseService } from "@lib/services/base/DatabaseService.ts"; -import type { SettingService } from "@lib/services/base/SettingService"; -export type InjectableServiceInstances = ServiceInstances & { - API?: InjectableAPIService; - path?: PathService; - database?: DatabaseService; - databaseEvents?: InjectableDatabaseEventService; - replicator?: InjectableReplicatorService; - fileProcessing?: InjectableFileProcessingService; - replication?: InjectableReplicationService; - remote?: InjectableRemoteService; - conflict?: InjectableConflictService; - appLifecycle?: AppLifecycleServiceBase; - setting?: SettingService; - tweakValue?: InjectableTweakValueService; - vault?: InjectableVaultService; - test?: InjectableTestService; - ui?: UIService; - config?: ConfigService; -}; diff --git a/_types/src/lib/src/services/implements/injectable/InjectableSettingService.d.ts b/_types/src/lib/src/services/implements/injectable/InjectableSettingService.d.ts deleted file mode 100644 index d5ef854e..00000000 --- a/_types/src/lib/src/services/implements/injectable/InjectableSettingService.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -import { SettingService, type SettingServiceDependencies } from "@lib/services/base/SettingService"; -import type { ObsidianLiveSyncSettings } from "@lib/common/types"; -export declare class InjectableSettingService extends SettingService { - constructor(context: T, dependencies: SettingServiceDependencies); - protected setItem(key: string, value: string): void; - protected getItem(key: string): string; - protected deleteItem(key: string): void; - saveData: import("@lib/services/lib/HandlerUtils").HandlerFunction<(data: ObsidianLiveSyncSettings) => Promise, unknown>; - loadData: import("@lib/services/lib/HandlerUtils").HandlerFunction<() => Promise, unknown>; -} diff --git a/_types/src/lib/src/services/implements/injectable/InjectableTestService.d.ts b/_types/src/lib/src/services/implements/injectable/InjectableTestService.d.ts deleted file mode 100644 index 059bac13..00000000 --- a/_types/src/lib/src/services/implements/injectable/InjectableTestService.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -import { TestService } from "@lib/services/base/TestService"; -export declare class InjectableTestService extends TestService { - addTestResult: import("@lib/services/lib/HandlerUtils").HandlerFunction<(name: string, key: string, result: boolean, summary?: string, message?: string) => void, unknown>; -} diff --git a/_types/src/lib/src/services/implements/injectable/InjectableTweakValueService.d.ts b/_types/src/lib/src/services/implements/injectable/InjectableTweakValueService.d.ts deleted file mode 100644 index d91cb238..00000000 --- a/_types/src/lib/src/services/implements/injectable/InjectableTweakValueService.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -import { TweakValueService } from "@lib/services/base/TweakValueService"; -export declare class InjectableTweakValueService extends TweakValueService { - fetchRemotePreferred: import("@lib/services/lib/HandlerUtils").HandlerFunction<(trialSetting: import("../../../common/types").RemoteDBSettings) => Promise, unknown>; - checkAndAskResolvingMismatched: import("@lib/services/lib/HandlerUtils").HandlerFunction<(preferred: Partial) => Promise<[import("../../../common/types").TweakValues | boolean, boolean]>, unknown>; - askResolvingMismatched: import("@lib/services/lib/HandlerUtils").HandlerFunction<(preferredSource: import("../../../common/types").TweakValues) => Promise<"OK" | "CHECKAGAIN" | "IGNORE">, unknown>; - checkAndAskUseRemoteConfiguration: import("@lib/services/lib/HandlerUtils").HandlerFunction<(settings: import("../../../common/types").RemoteDBSettings) => Promise<{ - result: false | import("../../../common/types").TweakValues; - requireFetch: boolean; - }>, unknown>; - askUseRemoteConfiguration: import("@lib/services/lib/HandlerUtils").HandlerFunction<(trialSetting: import("../../../common/types").RemoteDBSettings, preferred: import("../../../common/types").TweakValues) => Promise<{ - result: false | import("../../../common/types").TweakValues; - requireFetch: boolean; - }>, unknown>; -} diff --git a/_types/src/lib/src/services/implements/injectable/InjectableVaultService.d.ts b/_types/src/lib/src/services/implements/injectable/InjectableVaultService.d.ts deleted file mode 100644 index 36019b6b..00000000 --- a/_types/src/lib/src/services/implements/injectable/InjectableVaultService.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -import { VaultService } from "@lib/services/base/VaultService"; -export declare abstract class InjectableVaultService extends VaultService { -} -export declare class InjectableVaultServiceCompat extends InjectableVaultService { - isStorageInsensitive: import("@lib/services/lib/HandlerUtils").HandlerFunction<() => boolean, unknown>; - getActiveFilePath: import("@lib/services/lib/HandlerUtils").HandlerFunction<() => import("../../../common/types").FilePath | undefined, unknown>; - isValidPath(path: string): boolean; -} diff --git a/_types/src/lib/src/services/implements/obsidian/ObsidianServiceContext.d.ts b/_types/src/lib/src/services/implements/obsidian/ObsidianServiceContext.d.ts deleted file mode 100644 index 85d999ae..00000000 --- a/_types/src/lib/src/services/implements/obsidian/ObsidianServiceContext.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { ServiceContext } from "@lib/services/base/ServiceBase"; -import type ObsidianLiveSyncPlugin from "@/main"; -import type { App, Plugin } from "@/deps"; -export declare class ObsidianServiceContext extends ServiceContext { - app: App; - plugin: Plugin; - liveSyncPlugin: ObsidianLiveSyncPlugin; - constructor(app: App, plugin: Plugin, liveSyncPlugin: ObsidianLiveSyncPlugin); -} diff --git a/_types/src/lib/src/services/lib/HandlerUtils.d.ts b/_types/src/lib/src/services/lib/HandlerUtils.d.ts deleted file mode 100644 index c257de16..00000000 --- a/_types/src/lib/src/services/lib/HandlerUtils.d.ts +++ /dev/null @@ -1,376 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -/** - * A function type that can be used as a handler. - */ -type HandlerFunc = (...args: TArg) => TResult | Promise; -/** - * A function type that returns a boolean or a Promise of boolean. - */ -type BooleanHandlerFunc = (...args: TArg) => U | Promise; -/** - * An interface for invokable handlers that can add and remove handler functions. - */ -export interface InvokableHandler { - /** - * Invokes the handler with the provided arguments. - * @param args The arguments to pass to the handler. - * @returns A Promise that resolves to the result of the handler. - */ - invoke(...args: T): Promise; -} -/** - * An interface for invokable boolean handlers that can add and remove handler functions. - */ -type InvokableBooleanHandler = InvokableHandler; -/** - * A function type that can be used to unregister a handler. - */ -export type UnregisterFunction = () => void; -/** - * An interface for binder handlers that can assign a single handler function. - */ -export interface BinderHandler { - assign(callback: HandlerFunc, override?: boolean): UnregisterFunction; -} -/** - * An interface for multi-binder handlers that can add and remove handler functions. - */ -export interface MultiRegisterHandler { - /** - * Adds a handler function. - * Note: The same function only added once. - * If you want to prevent duplication, please remove the existing handler before adding it again. - * @param callback The handler function to add. - * @returns A function to remove the added handler. - */ - addHandler(callback: BooleanHandlerFunc): UnregisterFunction; - /** - * Removes a handler function. - * @param callback The handler function to remove. - */ - removeHandler(callback: BooleanHandlerFunc): void; - use(callback: BooleanHandlerFunc): UnregisterFunction; -} -/** - * An interface for dispatch handlers that can dispatch events to multiple handlers. - */ -export interface DispatcherHandler { - dispatch(...args: T): Promise<(Awaited | Error)[]>; -} -/** - * An interface for dispatch handlers that can add and remove handler functions. - */ -export interface DispatchHandler extends DispatcherHandler, MultiRegisterHandler { // eslint-disable-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-empty-interface -- Empty interface -} -/** - * A binder that allows assigning and invoking a single handler function. - */ -export declare class Binder any> implements BinderHandler, ReturnType>, InvokableHandler, ReturnType> { // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - private _name; - /** - * Creates a new Binder instance. - * @param name The name of the handler. - * @param initialCallback An optional initial callback function to assign. - */ - constructor(name: string, initialCallback?: T); - private _callback; - /** - * Assigns a new handler function. - * @param callback The new handler function to assign. - */ - assign(callback: T, override?: boolean): () => void; - /** - * Invokes the assigned handler function with the provided arguments. - * @param args The arguments to pass to the handler function. - * @returns The result of the handler function. - */ - invoke(...args: Parameters): ReturnType; -} -/** - * A binder that allows assigning and invoking a single handler function asynchronously. - * The invocation will wait until a handler is assigned. - */ -export declare class LazyBinder any> implements BinderHandler, ReturnType>, InvokableHandler, Promise>>> { // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - private _name; - private _callbackPromise; - private _callback; - /** - * Creates a new LazyBinder instance. - * @param name The name of the handler. - * @param initialCallback An optional initial callback function to assign. - */ - constructor(name: string, initialCallback?: T); - assign(callback: T, override?: boolean): () => void; - /** - * Invokes the assigned handler function with the provided arguments. - * @param args The arguments to pass to the handler function. - * @returns The result of the handler function. - */ - invoke(...args: Parameters): Promise>>; -} -/** - * A multi-binder that allows adding and removing multiple handler functions. - */ -export declare class MultiBinder any> implements MultiRegisterHandler, ReturnType> { // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - protected _name: string; - /** - * Creates a new MultiBinder instance. - * @param name The name of the handler. - */ - constructor(name: string); - protected _callbackMap: Map; - protected _isCallbackDirty: boolean; - protected _maxUsedPriority: number; - /** - * Adds a handler function. - * Note: The same function is only added once. - * @param callback The handler function to add. - * @param priority The priority of the handler, Do not use floating numbers to prevent confusion. - * @returns A function to unregister the added handler. - * - */ - addHandler(callback: T, priority?: number, allowSwap?: boolean): UnregisterFunction; - /** - * Removes a handler function. - * @param callback The handler function to remove. - */ - removeHandler(callback: T): void; - /** - * Adds a handler function (alias of addHandler, but more semantic). - * @param callback - * @returns - */ - use(callback: T, priority?: number): UnregisterFunction; - _sortedCallbacks: T[]; - protected get _callbacks(): T[]; -} -/** - * A dispatcher that invokes all added handler functions sequentially and collects their results. - * */ -export declare class Dispatch extends MultiBinder> implements DispatcherHandler { - /** - * Dispatches the event to all registered handlers sequentially. - * @param args The arguments to pass to the handlers. - * @returns An array of results or errors from each handler. - */ - dispatch(...args: T): Promise<(Awaited | Error)[]>; -} -/** - * A dispatcher that invokes all added handler functions in parallel and collects their results. - */ -export declare class DispatchParallel extends MultiBinder> implements DispatcherHandler { - /** - * Dispatches the event to all registered handlers in parallel. - * @param args The arguments to pass to the handlers. - * @returns An array of results or errors from each handler. - */ - dispatch(...args: T): Promise<(Awaited | Error)[]>; -} -/** - * A base class for boolean handlers that can add and remove handler functions. - */ -export declare abstract class BooleanHandlerBase extends MultiBinder> implements InvokableBooleanHandler { - abstract invoke(...args: T): Promise; -} -/** - * A handler that invokes all added handler functions sequentially until one returns false. - */ -export declare class AllHandler extends BooleanHandlerBase { - /** - * Invoke all handlers sequentially until one returns false. - * @param args The arguments to pass to the handlers. - * @returns A Promise that resolves to true if all handlers return true, otherwise false. - */ - invoke(...args: T): Promise; -} -/** - * A handler that invokes all added handler functions in parallel and returns true only if all return true. - */ -export declare class ParallelAllHandler extends BooleanHandlerBase { - /** - * Invoke all handlers in parallel - * @param args The arguments to pass to the handlers. - * @returns True if all handlers return true, otherwise false. - */ - invoke(...args: T): Promise; -} -/** - * A handler that invokes all added handler functions sequentially until one returns true. - */ -export declare class AnySuccessHandler extends BooleanHandlerBase { - /** - * Invokes handlers sequentially until one returns true. - * @param args The arguments to pass to the handlers. - * @returns True if any handler returns true, otherwise false. - */ - invoke(...args: T): Promise; -} -/** - * A handler that invokes all added handler functions sequentially until one returns a non-falsy value. - */ -export declare class FirstResultHandler extends MultiBinder> { - /** - * Invokes handlers sequentially until one returns a non-falsy value. - * @param args The arguments to pass to the handlers. - * @returns The first non-falsy result from the handlers, or false if none found. - */ - invoke(...args: T): Promise; -} -/** - * A function type that can be used as a handler with assignable functionality. - */ -export interface HandlerFunction U | Promise, U = unknown> { // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - /** - * Invokes the handler function with the provided arguments. - */ - (...args: Parameters): ReturnType; - /** - * Assigns a new handler function. - * @param callback The new handler function to assign. - * @param override Whether to override the existing handler if one is already assigned. - * @returns A function to unregister the assigned handler. - */ - setHandler: (callback: TFunc, override?: boolean) => void; -} -/** - * A function type that can be used as a handler with assignable functionality. - */ -export interface LazyHandlerFunction U | Promise, U = unknown> { // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - /** - * Invokes the handler function with the provided arguments. - */ - (...args: Parameters): Promise>>; - /** - * Assigns a new handler function. - * @param callback The new handler function to assign. - * @param override Whether to override the existing handler if one is already assigned. - * @returns A function to unregister the assigned handler. - */ - setHandler: (callback: TFunc, override?: boolean) => void; -} -/** - * A function type that can be used as a multiple handler with add/remove functionality. - */ -export interface MultipleHandlerFunction U | Promise, U = unknown> { // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - /** - * Invokes the handler function with the provided arguments. - */ - (...args: Parameters): ReturnType; - /** - * Adds a handler function. - * @param callback The handler function to add. - * @returns A function to remove the added handler. - */ - addHandler: (callback: TFunc) => () => void; - /** - * Removes a handler function. - * @param callback The handler function to remove. - * @returns - */ - removeHandler: (callback: TFunc) => void; -} -/** - * A function type that can be used as a value-collecting handler with add/remove functionality. - */ -export type CollectorFunction U | Promise, U = unknown> = (...args: Parameters) => Promise>; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration -/** - * A Handler function type that can have multiple handlers added or removed, and collects their results into an array. - */ -export interface CollectiveHandlerFunction U[] | Promise, U = unknown> { // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - /** - * Invokes the handler function with the provided arguments. - */ - (...args: Parameters): ReturnType; - /** - * Adds a handler function. - * @param callback The handler function to add. - * @returns A function to remove the added handler. - */ - addHandler: (callback: CollectorFunction) => () => void; - /** - * Removes a handler function. - * @param callback The handler function to remove. - * @returns - */ - removeHandler: (callback: CollectorFunction) => void; -} -export interface BooleanMultipleHandlerFunction boolean | Promise> { // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - /** - * Invokes the handler function with the provided arguments. - */ - (...args: Parameters): ReturnType; - /** - * Adds a handler function. - * @param callback The handler function to add. - * @returns A function to remove the added handler. - */ - addHandler: (callback: TFunc, priority?: number) => () => void; - /** - * Removes a handler function. - * @param callback The handler function to remove. - * @returns - */ - removeHandler: (callback: TFunc) => void; -} -export interface MultiBinderInstance extends InvokableHandler, MultiRegisterHandler { // eslint-disable-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-empty-interface -- Empty interface -} -export interface BooleanMultiBinderInstance extends InvokableBooleanHandler, MultiRegisterHandler { // eslint-disable-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-empty-interface -- Empty interface -} -export declare function allFunction Promise>(name?: string): BooleanMultipleHandlerFunction; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration -export declare function bailFirstFailureFunction Promise>(name?: string): BooleanMultipleHandlerFunction; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration -export declare function allParallelFunction Promise>(name?: string): BooleanMultipleHandlerFunction; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration -export declare function anySuccessFunction Promise>(name?: string): BooleanMultipleHandlerFunction; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration -export declare function firstResultFunction Promise>(name?: string): MultipleHandlerFunction; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration -export declare function dispatchParallelFunction Promise>(name?: string): CollectiveHandlerFunction; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration -export declare function bindableFunction any>(name?: string): HandlerFunction; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration -export declare function lazyBindableFunction any>(name?: string): LazyHandlerFunction; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration -type FunctionKeys = Extract<{ - [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration -}[keyof T], string>; -export declare function handlers(): { - /** - * Create a handler that invokes all added handler functions sequentially until one returns false. - * @param name - * @returns - */ - all>(name: K): BooleanMultipleHandlerFunction Promise>>; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - /** - * Create a handler that invokes all added handler functions in parallel and returns true only if all return true. - * @param name - * @returns - */ - allParallel>(name: K): BooleanMultipleHandlerFunction Promise>>; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - /** - * Create a handler that invokes all added handler functions sequentially until one returns false. - * @param name - * @returns - */ - bailFirstFailure>(name: K): BooleanMultipleHandlerFunction Promise>>; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - /** - * Create a handler that invokes all added handler functions sequentially until one returns true. - * @param name - * @returns - */ - anySuccess>(name: K): BooleanMultipleHandlerFunction Promise>>; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - /** - * Create a handler that invokes all added handler functions sequentially until one returns a non-falsy value. - * @param name - * @returns - */ - firstResult>(name: K): MultipleHandlerFunction Promise>>; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - /** - * Create a handler that invokes all added handler functions in parallel. - * @param name - * @returns - */ - dispatchParallel>(name: K): CollectiveHandlerFunction Promise>>; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - /** - * Create a binder handler that can assign a single handler function. - * @param name - * @returns - */ - binder>(name: K): HandlerFunction any>>; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - lazyBinder>(name: K): LazyHandlerFunction any>>; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration -}; -export {}; diff --git a/_types/src/lib/src/services/lib/logUtils.d.ts b/_types/src/lib/src/services/lib/logUtils.d.ts deleted file mode 100644 index f3d450a1..00000000 --- a/_types/src/lib/src/services/lib/logUtils.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type LOG_LEVEL } from "octagonal-wheels/common/logger"; -import type { IAPIService } from "@lib/services/base/IService"; -export declare const MARK_LOG_SEPARATOR = "\u200A"; -export declare const MARK_LOG_NETWORK_ERROR = "\u200B"; -/** - * Creates a log function that prefixes messages with the service name and uses the provided APIService's addLog method if available. - * If APIService is not provided, it falls back to using the global Logger function. - * @param serviceName The name of the service to prefix log messages with. - * @param APIService An optional APIService instance to use for logging. - * @returns A log function that can be used to log messages with the specified service name and APIService. - */ -export declare function createInstanceLogFunction(serviceName: string, APIService?: IAPIService): (msg: unknown, level?: LOG_LEVEL, key?: string) => void; -export type LogFunction = ReturnType; diff --git a/_types/src/lib/src/string_and_binary/chunks.d.ts b/_types/src/lib/src/string_and_binary/chunks.d.ts deleted file mode 100644 index 489ef127..00000000 --- a/_types/src/lib/src/string_and_binary/chunks.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare function splitPiecesTextV2(dataSrc: string | string[], pieceSize: number, minimumChunkSize: number): () => Generator; -export declare function binaryTextSplit(data: string, pieceSize: number, minimumChunkSize: number): () => Generator; -export declare function splitPiecesText(dataSrc: string | string[], pieceSize: number, plainSplit: boolean, minimumChunkSize: number, useSegmenter: boolean): () => Generator; -export declare function splitPiecesTextV1(dataSrc: string | string[], pieceSize: number, plainSplit: boolean, minimumChunkSize: number): () => Generator; -export declare function collectGenAll(strGen: AsyncGenerator | Generator): Promise; -export declare function concatGeneratedAll(strGen: AsyncGenerator | Generator): Promise; -export declare function splitPieces2V2(dataSrc: Blob, pieceSize: number, plainSplit: boolean, minimumChunkSize: number, filename?: string, useSegmenter?: boolean): Promise<(() => Generator) | (() => AsyncGenerator)>; -export declare function splitPieces2(dataSrc: Blob, pieceSize: number, plainSplit: boolean, minimumChunkSize: number, filename?: string, useSegmenter?: boolean): Promise<(() => Generator) | (() => AsyncGenerator)>; -export declare function splitPiecesRabinKarp(dataSrc: Blob, absoluteMaxPieceSize: number, doPlainSplit: boolean, minimumChunkSize: number, _filename?: string, _useSegmenter?: boolean): Promise<() => AsyncGenerator>; -export declare function splitPiecesRabinKarpOld(dataSrc: Blob, absoluteMaxPieceSize: number, doPlainSplit: boolean, minimumChunkSize: number, _filename?: string, _useSegmenter?: boolean): Promise<() => AsyncGenerator>; diff --git a/_types/src/lib/src/string_and_binary/convert.d.ts b/_types/src/lib/src/string_and_binary/convert.d.ts deleted file mode 100644 index a6cfc665..00000000 --- a/_types/src/lib/src/string_and_binary/convert.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { arrayBufferToBase64, base64ToArrayBuffer, base64ToArrayBufferInternalBrowser, readString, writeString, tryConvertBase64ToArrayBuffer } from "octagonal-wheels/binary"; -export { arrayBufferToBase64, base64ToArrayBuffer, base64ToArrayBufferInternalBrowser, readString, writeString, tryConvertBase64ToArrayBuffer, }; -export declare function arrayBufferToBase64Single(buffer: Uint8Array | ArrayBuffer): Promise; -export { uint8ArrayToHexString, hexStringToUint8Array } from "octagonal-wheels/binary/hex"; -export { encodeBinaryEach, decodeToArrayBuffer } from "octagonal-wheels/binary/encodedUTF16"; -export { decodeBinary, encodeBinary } from "octagonal-wheels/binary"; -export { escapeStringToHTML } from "octagonal-wheels/string"; -export declare function versionNumberString2Number(version: string): number; diff --git a/_types/src/lib/src/string_and_binary/hash.d.ts b/_types/src/lib/src/string_and_binary/hash.d.ts deleted file mode 100644 index dbdc7fec..00000000 --- a/_types/src/lib/src/string_and_binary/hash.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export * from "octagonal-wheels/hash/xxhash.js"; -export type * from "octagonal-wheels/hash/xxhash.js"; diff --git a/_types/src/lib/src/string_and_binary/path.d.ts b/_types/src/lib/src/string_and_binary/path.d.ts deleted file mode 100644 index d50ab23f..00000000 --- a/_types/src/lib/src/string_and_binary/path.d.ts +++ /dev/null @@ -1,37 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type AnyEntry, type DocumentID, type EntryHasPath, type FilePath, type FilePathWithPrefix } from "@lib/common/types.ts"; -export declare function isValidFilenameInWidows(filename: string): boolean; -export declare function isValidFilenameInDarwin(filename: string): boolean; -export declare function isValidFilenameInLinux(filename: string): boolean; -export declare function isValidFilenameInAndroid(filename: string): boolean; -export declare function isFilePath(path: FilePath | FilePathWithPrefix): path is FilePath; -export declare function stripAllPrefixes(prefixedPath: FilePathWithPrefix): FilePath; -export declare function addPrefix(path: FilePath | FilePathWithPrefix, prefix: string): FilePathWithPrefix; -export declare function expandFilePathPrefix(path: FilePathWithPrefix | FilePath): [string, FilePathWithPrefix]; -export declare function expandDocumentIDPrefix(id: DocumentID): [string, FilePathWithPrefix]; -export declare function path2id_base(filenameSrc: FilePathWithPrefix | FilePath, obfuscatePassphrase: string | false, caseInsensitive: boolean): Promise; -export declare function id2path_base(id: DocumentID, entry?: EntryHasPath): FilePathWithPrefix; -export declare function getPath(entry: AnyEntry): FilePathWithPrefix; -export declare function getPathWithoutPrefix(entry: AnyEntry): FilePath; -export declare function stripPrefix(prefixedPath: FilePathWithPrefix): FilePath; -export declare function shouldBeIgnored(filename: string): boolean; -export declare function isPlainText(filename: string): boolean; -export declare function shouldSplitAsPlainText(filename: string): boolean; -/** - * returns whether the given path is accepted (not ignored) by the `.gitignore`. - * @param path path of the file which is relative from `.gitignore` file - * @param ignore lines of `.gitignore` - * @returns true when accepted. - * false when not accepted. - * undefined when the path is not mentioned in the `.gitignore` file. - */ -export declare function isAccepted(path: string, ignore: string[]): boolean | undefined; -/** - * Checks whether the path is accepted by all ignored files. - * @param path path of target file - * @param ignoreFiles list of ignore files. i.e. [".gitignore", ".dockerignore"] - * @param getList function to retrieve the file. - * @returns true when accepted. false when should be ignored. - */ -export declare function isAcceptedAll(path: string, ignoreFiles: string[], getList: (path: string) => Promise): Promise; diff --git a/_types/src/lib/src/worker/bg.common.d.ts b/_types/src/lib/src/worker/bg.common.d.ts deleted file mode 100644 index 3ddf011b..00000000 --- a/_types/src/lib/src/worker/bg.common.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { END_OF_DATA } from "./universalTypes.ts"; -export declare function postBack(key: number, seq: number, data: string | END_OF_DATA): void; diff --git a/_types/src/lib/src/worker/bg.worker.d.ts b/_types/src/lib/src/worker/bg.worker.d.ts deleted file mode 100644 index 08dc832e..00000000 --- a/_types/src/lib/src/worker/bg.worker.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export {}; diff --git a/_types/src/lib/src/worker/bg.worker.encryption.d.ts b/_types/src/lib/src/worker/bg.worker.encryption.d.ts deleted file mode 100644 index e6fcc8df..00000000 --- a/_types/src/lib/src/worker/bg.worker.encryption.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { EncryptHKDFArguments } from "./universalTypes.ts"; -import type { EncryptArguments } from "./universalTypes.ts"; -/** - * Processes the encryption of data. - * @param data The data to be encrypted or decrypted. - */ -export declare function processEncryption(data: EncryptArguments | EncryptHKDFArguments): Promise; diff --git a/_types/src/lib/src/worker/bg.worker.splitting.d.ts b/_types/src/lib/src/worker/bg.worker.splitting.d.ts deleted file mode 100644 index 22a3fe01..00000000 --- a/_types/src/lib/src/worker/bg.worker.splitting.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { SplitArguments } from "./universalTypes.ts"; -/** - * Processes the splitting of data into chunks. - * @param data The data to be split. - */ -export declare function processSplit(data: SplitArguments): Promise; diff --git a/_types/src/lib/src/worker/bgWorker.d.ts b/_types/src/lib/src/worker/bgWorker.d.ts deleted file mode 100644 index a3610e42..00000000 --- a/_types/src/lib/src/worker/bgWorker.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { EncryptArguments, EncryptHKDFArguments, EncryptHKDFProcessItem, EncryptProcessItem, ProcessItem, SplitArguments, SplitProcessItem } from "./universalTypes.ts"; -export type WorkerInstance = { - worker: Worker; - processing: number; - /** Keys of tasks currently dispatched to this worker instance. */ - taskKeys: Set; -}; -export declare function splitPieces2Worker(dataSrc: Blob, pieceSize: number, plainSplit: boolean, minimumChunkSize: number, filename?: string, useSegmenter?: boolean): () => AsyncGenerator; -export declare function splitPieces2WorkerV2(dataSrc: Blob, pieceSize: number, plainSplit: boolean, minimumChunkSize: number, filename?: string, useSegmenter?: boolean): () => AsyncGenerator; -export declare function splitPieces2WorkerRabinKarp(dataSrc: Blob, pieceSize: number, plainSplit: boolean, minimumChunkSize: number, filename?: string, useSegmenter?: boolean): () => AsyncGenerator; -export declare function encryptWorker(input: string, passphrase: string, autoCalculateIterations: boolean): Promise; -export declare function decryptWorker(input: string, passphrase: string, autoCalculateIterations: boolean): Promise; -export declare function encryptHKDFWorker(input: string, passphrase: string, pbkdf2Salt: Uint8Array): Promise; -export declare function decryptHKDFWorker(input: string, passphrase: string, pbkdf2Salt: Uint8Array): Promise; -export declare const tasks: Map; -/** - * Remove a completed (or aborted) task from both the tasks map and its worker's taskKeys set. - */ -export declare function removeTask(key: number): void; -export declare function initialiseWorkerModule(): void; -export declare function startWorker(data: Omit): EncryptHKDFProcessItem; -export declare function startWorker(data: Omit): EncryptProcessItem; -export declare function startWorker(data: Omit): SplitProcessItem; -export declare function terminateWorker(): void; diff --git a/_types/src/lib/src/worker/bgWorker.encryption.d.ts b/_types/src/lib/src/worker/bgWorker.encryption.d.ts deleted file mode 100644 index b7444ca5..00000000 --- a/_types/src/lib/src/worker/bgWorker.encryption.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type EncryptHKDFProcessItem, type ResultPayload } from "./universalTypes.ts"; -import { type EncryptProcessItem } from "./universalTypes.ts"; -import { type EncryptHKDFArguments } from "./universalTypes.ts"; -import { type EncryptArguments } from "./universalTypes.ts"; -/** - * Offloads encryption to a web worker. - * @param data The data to be encrypted. - * @returns A promise that resolves with the encryption result. - */ -export declare function encryptionOnWorker(data: Omit): Promise; -/** - * Offloads HKDF encryption to a web worker. - * @param data The data to be encrypted. - * @returns A promise that resolves with the encryption result. - */ -export declare function encryptionHKDFOnWorker(data: Omit): Promise; -/** - * Handles the encryption callbacks - * @param process The process item associated with the task. - * @param data The data to be processed. - */ -export declare function handleTaskEncrypt(process: EncryptProcessItem | EncryptHKDFProcessItem, data: ResultPayload): void; diff --git a/_types/src/lib/src/worker/bgWorker.mock.d.ts b/_types/src/lib/src/worker/bgWorker.mock.d.ts deleted file mode 100644 index 5b953a9c..00000000 --- a/_types/src/lib/src/worker/bgWorker.mock.d.ts +++ /dev/null @@ -1,41 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { EncryptHKDFProcessItem, EncryptProcessItem, SplitProcessItem, ProcessItem } from "./universalTypes.ts"; -export type SplitArguments = { - key: number; - type: "split"; - dataSrc: Blob; - pieceSize: number; - plainSplit: boolean; - minimumChunkSize: number; - filename?: string; - useV2: boolean; - useSegmenter: boolean; -}; -export type EncryptArguments = { - key: number; - type: "encrypt" | "decrypt"; - input: string; - passphrase: string; - autoCalculateIterations: boolean; -}; -export type EncryptHKDFArguments = { - key: number; - type: "encryptHKDF" | "decryptHKDF"; - input: string; - passphrase: string; - pbkdf2Salt: Uint8Array; -}; -export declare function terminateWorker(): void; -export declare function splitPieces2Worker(dataSrc: Blob, pieceSize: number, plainSplit: boolean, minimumChunkSize: number, filename?: string, useSegmenter?: boolean): Promise<(() => Generator) | (() => AsyncGenerator)>; -export declare function splitPieces2WorkerV2(dataSrc: Blob, pieceSize: number, plainSplit: boolean, minimumChunkSize: number, filename?: string, useSegmenter?: boolean): Promise<(() => Generator) | (() => AsyncGenerator)>; -export declare function splitPieces2WorkerRabinKarp(dataSrc: Blob, pieceSize: number, plainSplit: boolean, minimumChunkSize: number, filename?: string, useSegmenter?: boolean): () => AsyncGenerator; -export declare function encryptWorker(input: string, passphrase: string, autoCalculateIterations: boolean): Promise; -export declare function decryptWorker(input: string, passphrase: string, autoCalculateIterations: boolean): Promise; -export declare function encryptHKDFWorker(input: string, passphrase: string, pbkdf2Salt: Uint8Array): Promise; -export declare function decryptHKDFWorker(input: string, passphrase: string, pbkdf2Salt: Uint8Array): Promise; -export declare function startWorker(data: Omit): EncryptHKDFProcessItem; -export declare function startWorker(data: Omit): EncryptProcessItem; -export declare function startWorker(data: Omit): SplitProcessItem; -export declare const tasks: Map; -export declare function initialiseWorkerModule(): void; diff --git a/_types/src/lib/src/worker/bgWorker.splitting.d.ts b/_types/src/lib/src/worker/bgWorker.splitting.d.ts deleted file mode 100644 index 7205f91f..00000000 --- a/_types/src/lib/src/worker/bgWorker.splitting.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type ResultPayloadWithSeq, type SplitProcessItem } from "./universalTypes"; -/** - * Splits data into pieces using a worker. - * @param dataSrc The source data to be split. - * @param pieceSize The size of each piece. - * @param plainSplit Whether to use plain splitting. - * @param minimumChunkSize The minimum size of each chunk. - * @param filename The name of the file being processed. - * @param splitVersion The version of the splitting algorithm to use. - * @param useSegmenter Whether to use a segmenter (only works on splitVersion:2) - * @returns A generator that yields the split pieces. - */ -export declare function _splitPieces2Worker(dataSrc: Blob, pieceSize: number, plainSplit: boolean, minimumChunkSize: number, filename: string | undefined, splitVersion: 1 | 2 | 3, useSegmenter: boolean): () => AsyncGenerator; -/** - * Aborts all in-flight split tasks identified by the given keys. - * Called when the background worker that owned these tasks has crashed, so the streams - * will never receive any more data and must be torn down to unblock callers. - * @param keys The task keys to abort. - * @param error The error to report to each stream. - */ -export declare function abortSplitTasks(keys: number[], error: Error): void; -/** - * Handles the splitting callback from the worker. - * @param process the splitting process item - * @param data the data received from the worker - */ -export declare function handleTaskSplit(process: SplitProcessItem, data: ResultPayloadWithSeq): void; diff --git a/_types/src/lib/src/worker/universalTypes.d.ts b/_types/src/lib/src/worker/universalTypes.d.ts deleted file mode 100644 index 46994282..00000000 --- a/_types/src/lib/src/worker/universalTypes.d.ts +++ /dev/null @@ -1,65 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { PromiseWithResolvers } from "octagonal-wheels/promises"; -export type EncryptArguments = { - key: number; - type: "encrypt" | "decrypt"; - input: string; - passphrase: string; - autoCalculateIterations: boolean; -}; -export type EncryptHKDFArguments = { - key: number; - type: "encryptHKDF" | "decryptHKDF"; - input: string; - passphrase: string; - pbkdf2Salt: Uint8Array; -}; -export type SplitArguments = { - key: number; - type: "split"; - dataSrc: Blob; - pieceSize: number; - plainSplit: boolean; - minimumChunkSize: number; - filename?: string; - useSegmenter: boolean; - splitVersion: 1 | 2 | 3; -}; -export type SplitProcessItem = { - key: number; - type: SplitArguments["type"]; - finalize: () => void; -}; -export type EncryptProcessItem = { - key: number; - task: PromiseWithResolvers; - type: EncryptArguments["type"]; - finalize: () => void; -}; -export type EncryptHKDFProcessItem = { - key: number; - task: PromiseWithResolvers; - type: EncryptHKDFArguments["type"]; - finalize: () => void; -}; -export type ProcessItem = SplitProcessItem | EncryptProcessItem | EncryptHKDFProcessItem; -export declare const END_OF_DATA: null; -export type END_OF_DATA = typeof END_OF_DATA; -export type ResultPayloadBase = { - key: number; -}; -export type ResultPayloadWithResult = ResultPayloadBase & { - result: string; -}; -export type ResultPayloadWithError = ResultPayloadBase & { - error: unknown; -}; -export type ResultPayload = ResultPayloadWithResult | ResultPayloadWithError; -export type ResultPayloadWithSeqBase = ResultPayload & { - seq: number; -}; -export type ResultPayloadPiece = ResultPayloadWithSeqBase & { - result: string | END_OF_DATA; -}; -export type ResultPayloadWithSeq = ResultPayloadPiece | ResultPayloadWithError; diff --git a/_types/src/main.d.ts b/_types/src/main.d.ts deleted file mode 100644 index 86b1e2ea..00000000 --- a/_types/src/main.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { Plugin, type App, type PluginManifest } from "./deps"; -import { LiveSyncCommands } from "./features/LiveSyncCommands.ts"; -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext.ts"; -import { LiveSyncBaseCore } from "./LiveSyncBaseCore.ts"; -export type LiveSyncCore = LiveSyncBaseCore; -export default class ObsidianLiveSyncPlugin extends Plugin { - core: LiveSyncCore; - /** - * Initialise service modules. - */ - private initialiseServiceModules; - /** - * @obsolete Use services.setting.saveSettingData instead. Save the settings to the disk. This is usually called after changing the settings in the code, to persist the changes. - */ - saveSettings(): Promise; - constructor(app: App, manifest: PluginManifest); - private _startUp; - onload(): void; - onunload(): undefined; -} diff --git a/_types/src/managers/ObsidianStorageEventManagerAdapter.d.ts b/_types/src/managers/ObsidianStorageEventManagerAdapter.d.ts deleted file mode 100644 index 946faf94..00000000 --- a/_types/src/managers/ObsidianStorageEventManagerAdapter.d.ts +++ /dev/null @@ -1,66 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { TFile, TFolder } from "@/deps"; -import type { FilePath, UXFileInfoStub, UXInternalFileInfoStub } from "@lib/common/types"; -import type { FileEventItem } from "@lib/common/types"; -import type { IStorageEventManagerAdapter } from "@lib/managers/adapters"; -import type { IStorageEventTypeGuardAdapter, IStorageEventPersistenceAdapter, IStorageEventWatchAdapter, IStorageEventStatusAdapter, IStorageEventConverterAdapter, IStorageEventWatchHandlers } from "@lib/managers/adapters"; -import type { FileEventItemSentinel } from "@lib/managers/StorageEventManager"; -import type ObsidianLiveSyncPlugin from "@/main"; -import type { LiveSyncCore } from "@/main"; -import type { FileProcessingService } from "@lib/services/base/FileProcessingService"; -/** - * Obsidian-specific type guard adapter - */ -declare class ObsidianTypeGuardAdapter implements IStorageEventTypeGuardAdapter { - isFile(file: unknown): file is TFile; - isFolder(item: unknown): item is TFolder; -} -/** - * Obsidian-specific persistence adapter - */ -declare class ObsidianPersistenceAdapter implements IStorageEventPersistenceAdapter { - private core; - constructor(core: LiveSyncCore); - saveSnapshot(snapshot: (FileEventItem | FileEventItemSentinel)[]): Promise; - loadSnapshot(): Promise<(FileEventItem | FileEventItemSentinel)[] | null>; -} -/** - * Obsidian-specific status adapter - */ -declare class ObsidianStatusAdapter implements IStorageEventStatusAdapter { - private fileProcessing; - constructor(fileProcessing: FileProcessingService); - updateStatus(status: { - batched: number; - processing: number; - totalQueued: number; - }): void; -} -/** - * Obsidian-specific converter adapter - */ -declare class ObsidianConverterAdapter implements IStorageEventConverterAdapter { - toFileInfo(file: TFile, deleted?: boolean): UXFileInfoStub; - toInternalFileInfo(path: FilePath): UXInternalFileInfoStub; -} -/** - * Obsidian-specific watch adapter - */ -declare class ObsidianWatchAdapter implements IStorageEventWatchAdapter { - private plugin; - constructor(plugin: ObsidianLiveSyncPlugin); - beginWatch(handlers: IStorageEventWatchHandlers): Promise; -} -/** - * Composite adapter for Obsidian StorageEventManager - */ -export declare class ObsidianStorageEventManagerAdapter implements IStorageEventManagerAdapter { - readonly typeGuard: ObsidianTypeGuardAdapter; - readonly persistence: ObsidianPersistenceAdapter; - readonly watch: ObsidianWatchAdapter; - readonly status: ObsidianStatusAdapter; - readonly converter: ObsidianConverterAdapter; - constructor(plugin: ObsidianLiveSyncPlugin, core: LiveSyncCore, fileProcessing: FileProcessingService); -} -export {}; diff --git a/_types/src/managers/StorageEventManagerObsidian.d.ts b/_types/src/managers/StorageEventManagerObsidian.d.ts deleted file mode 100644 index b066cfa9..00000000 --- a/_types/src/managers/StorageEventManagerObsidian.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { FilePath } from "@lib/common/types"; -import type ObsidianLiveSyncPlugin from "@/main"; -import type { LiveSyncCore } from "@/main"; -import { StorageEventManagerBase, type StorageEventManagerBaseDependencies } from "@lib/managers/StorageEventManager"; -import { ObsidianStorageEventManagerAdapter } from "./ObsidianStorageEventManagerAdapter"; -export declare class StorageEventManagerObsidian extends StorageEventManagerBase { - core: LiveSyncCore; - constructor(plugin: ObsidianLiveSyncPlugin, core: LiveSyncCore, dependencies: StorageEventManagerBaseDependencies); - /** - * Override _watchVaultRawEvents to add Obsidian-specific logic - */ - protected _watchVaultRawEvents(path: FilePath): Promise; -} diff --git a/_types/src/modules/AbstractModule.d.ts b/_types/src/modules/AbstractModule.d.ts deleted file mode 100644 index 812660fc..00000000 --- a/_types/src/modules/AbstractModule.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { AnyEntry, FilePathWithPrefix } from "@lib/common/types"; -import type { IMinimumLiveSyncCommands, LiveSyncBaseCore } from "@/LiveSyncBaseCore"; -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -export declare abstract class AbstractModule = LiveSyncBaseCore> { - core: T; - _log: (msg: unknown, level?: import("octagonal-wheels/common/logger").LOG_LEVEL, key?: string) => void; - get services(): import("../lib/src/services/InjectableServices").InjectableServiceHub; - addCommand: (command: TCommand) => TCommand; - registerView: (type: string, factory: (leaf: T_1) => unknown) => void; - addRibbonIcon: (icon: string, title: string, callback: (evt: MouseEvent) => unknown) => HTMLElement; - registerObsidianProtocolHandler: (action: string, handler: (params: Record) => unknown) => void; - get localDatabase(): import("../lib/src/pouchdb/LiveSyncLocalDB").LiveSyncLocalDB; - get settings(): import("@lib/common/types").ObsidianLiveSyncSettings; - set settings(value: import("@lib/common/types").ObsidianLiveSyncSettings); - getPath(entry: AnyEntry): FilePathWithPrefix; - getPathWithoutPrefix(entry: AnyEntry): FilePathWithPrefix; - onBindFunction(core: T, services: typeof core.services): void; - constructor(core: T); - saveSettings: () => Promise; - addTestResult(key: string, value: boolean, summary?: string, message?: string): void; - testDone(result?: boolean): Promise; - testFail(message: string): Promise; - isMainReady(): boolean; - isMainSuspended(): boolean; - isDatabaseReady(): boolean; -} diff --git a/_types/src/modules/AbstractObsidianModule.d.ts b/_types/src/modules/AbstractObsidianModule.d.ts deleted file mode 100644 index 5c655a15..00000000 --- a/_types/src/modules/AbstractObsidianModule.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { LiveSyncCore } from "@/main"; -import type ObsidianLiveSyncPlugin from "@/main"; -import { AbstractModule } from "./AbstractModule.ts"; -export declare abstract class AbstractObsidianModule extends AbstractModule { - plugin: ObsidianLiveSyncPlugin; - get app(): import("obsidian").App; - constructor(plugin: ObsidianLiveSyncPlugin, core: LiveSyncCore); - isThisModuleEnabled(): boolean; -} diff --git a/_types/src/modules/core/ModulePeriodicProcess.d.ts b/_types/src/modules/core/ModulePeriodicProcess.d.ts deleted file mode 100644 index 09f6392c..00000000 --- a/_types/src/modules/core/ModulePeriodicProcess.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { PeriodicProcessor } from "@/common/PeriodicProcessor"; -import type { LiveSyncCore } from "@/main"; -import { AbstractModule } from "@/modules/AbstractModule"; -export declare class ModulePeriodicProcess extends AbstractModule { - periodicSyncProcessor: PeriodicProcessor; - disablePeriodic(): Promise; - resumePeriodic(): Promise; - private _allOnUnload; - private _everyBeforeRealizeSetting; - private _everyBeforeSuspendProcess; - private _everyAfterResumeProcess; - private _everyAfterRealizeSetting; - onBindFunction(core: LiveSyncCore, services: typeof core.services): void; -} diff --git a/_types/src/modules/core/ModuleReplicator.d.ts b/_types/src/modules/core/ModuleReplicator.d.ts deleted file mode 100644 index f8d160fa..00000000 --- a/_types/src/modules/core/ModuleReplicator.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { AbstractModule } from "@/modules/AbstractModule"; -import { type EntryDoc, type RemoteType } from "@lib/common/types"; -import type { LiveSyncCore } from "@/main"; -import { ReplicateResultProcessor } from "./ReplicateResultProcessor"; -export declare class ModuleReplicator extends AbstractModule { - _replicatorType?: RemoteType; - processor: ReplicateResultProcessor; - private _unresolvedErrorManager; - clearErrors(): void; - private _everyOnloadAfterLoadSettings; - _onReplicatorInitialised(): Promise; - _everyOnDatabaseInitialized(showNotice: boolean): Promise; - _everyBeforeReplicate(showMessage: boolean): Promise; - /** - * obsolete method. No longer maintained and will be removed in the future. - * @deprecated v0.24.17 - * @param showMessage If true, show message to the user. - */ - cleaned(showMessage: boolean): Promise; - private onReplicationFailed; - _parseReplicationResult(docs: Array>): Promise; - onBindFunction(core: LiveSyncCore, services: typeof core.services): void; -} diff --git a/_types/src/modules/core/ModuleReplicatorCouchDB.d.ts b/_types/src/modules/core/ModuleReplicatorCouchDB.d.ts deleted file mode 100644 index 70e535db..00000000 --- a/_types/src/modules/core/ModuleReplicatorCouchDB.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type RemoteDBSettings } from "@lib/common/types"; -import type { LiveSyncAbstractReplicator } from "@lib/replication/LiveSyncAbstractReplicator"; -import { AbstractModule } from "@/modules/AbstractModule"; -import type { LiveSyncCore } from "@/main"; -export declare class ModuleReplicatorCouchDB extends AbstractModule { - _anyNewReplicator(settingOverride?: Partial): Promise; - _everyAfterResumeProcess(): Promise; - onBindFunction(core: LiveSyncCore, services: typeof core.services): void; -} diff --git a/_types/src/modules/core/ModuleReplicatorMinIO.d.ts b/_types/src/modules/core/ModuleReplicatorMinIO.d.ts deleted file mode 100644 index 094472ca..00000000 --- a/_types/src/modules/core/ModuleReplicatorMinIO.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type RemoteDBSettings } from "@lib/common/types"; -import type { LiveSyncAbstractReplicator } from "@lib/replication/LiveSyncAbstractReplicator"; -import type { LiveSyncCore } from "@/main"; -import { AbstractModule } from "@/modules/AbstractModule"; -export declare class ModuleReplicatorMinIO extends AbstractModule { - _anyNewReplicator(settingOverride?: Partial): Promise; - onBindFunction(core: LiveSyncCore, services: typeof core.services): void; -} diff --git a/_types/src/modules/core/ReplicateResultProcessor.d.ts b/_types/src/modules/core/ReplicateResultProcessor.d.ts deleted file mode 100644 index 96b38854..00000000 --- a/_types/src/modules/core/ReplicateResultProcessor.d.ts +++ /dev/null @@ -1,116 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type AnyEntry, type EntryDoc, type LoadedEntry, type MetaEntry } from "@lib/common/types"; -import type { ModuleReplicator } from "./ModuleReplicator"; -import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive_v2"; -import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore"; -export declare class ReplicateResultProcessor { - private log; - private logError; - private replicator; - constructor(replicator: ModuleReplicator); - get localDatabase(): import("../../lib/src/pouchdb/LiveSyncLocalDB").LiveSyncLocalDB; - get services(): import("../../lib/src/services/InjectableServices").InjectableServiceHub; - get core(): LiveSyncBaseCore; - getPath(entry: AnyEntry): string; - suspend(): void; - resume(): void; - private _suspended; - get isSuspended(): boolean; - /** - * Take a snapshot of the current processing state. - * This snapshot is stored in the KV database for recovery on restart. - */ - protected _takeSnapshot(): Promise; - /** - * Trigger taking a snapshot. - */ - protected _triggerTakeSnapshot(): void; - /** - * Throttled version of triggerTakeSnapshot. - */ - protected triggerTakeSnapshot: import("octagonal-wheels/function").ThrottledFunction<() => void>; - /** - * Restore from snapshot. - */ - restoreFromSnapshot(): Promise; - private _restoreFromSnapshot; - /** - * Restore from snapshot only once. - * @returns Promise that resolves when restoration is complete. - */ - restoreFromSnapshotOnce(): Promise; - /** - * Perform the given procedure while counting the concurrency. - * @param proc async procedure to perform - * @param countValue reactive source to count concurrency - * @returns result of the procedure - */ - withCounting(proc: () => Promise, countValue: ReactiveSource): Promise; - /** - * Report the current status. - */ - protected reportStatus(): void; - /** - * Enqueue all the given changes for processing. - * @param changes Changes to enqueue - */ - enqueueAll(changes: PouchDB.Core.ExistingDocument[]): void; - /** - * Process the change if it is not a document change. - * @param change Change to process - * @returns True if the change was processed; false otherwise - */ - protected processIfNonDocumentChange(change: PouchDB.Core.ExistingDocument): boolean; - /** - * Queue of changes to be processed. - */ - private _queuedChanges; - /** - * List of changes being processed. - */ - private _processingChanges; - /** - * Enqueue the given document change for processing. - * @param doc Document change to enqueue - * @returns - */ - protected enqueueChange(doc: PouchDB.Core.ExistingDocument): void; - /** - * Trigger processing of the queued changes. - */ - protected triggerProcessQueue(): void; - /** - * Semaphore to limit concurrent processing. - * This is the per-id semaphore + concurrency-control (max 10 concurrent = 10 documents being processed at the same time). - */ - private _semaphore; - /** - * Flag indicating whether the process queue is currently running. - */ - private _isRunningProcessQueue; - /** - * Process the queued changes. - */ - private runProcessQueue; - /** - * Parse the given document change. - * @param change - * @returns - */ - parseDocumentChange(change: PouchDB.Core.ExistingDocument): Promise; - protected applyToDatabase(doc: PouchDB.Core.ExistingDocument): Promise; - private _applyToDatabase; - /** - * Phase 3: Apply the given entry to storage. - * @param entry - * @returns - */ - protected applyToStorage(entry: MetaEntry): Promise; - /** - * Check whether processing is required for the given document. - * @param dbDoc Document to check - * @returns True if processing is required; false otherwise - */ - protected checkIsChangeRequiredForDatabaseProcessing(dbDoc: LoadedEntry): Promise; -} diff --git a/_types/src/modules/coreFeatures/ModuleConflictChecker.d.ts b/_types/src/modules/coreFeatures/ModuleConflictChecker.d.ts deleted file mode 100644 index 3a331eaa..00000000 --- a/_types/src/modules/coreFeatures/ModuleConflictChecker.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { AbstractModule } from "@/modules/AbstractModule.ts"; -import { type FilePathWithPrefix } from "@lib/common/types"; -import { QueueProcessor } from "octagonal-wheels/concurrency/processor"; -import type { InjectableServiceHub } from "@lib/services/InjectableServices.ts"; -import type { LiveSyncCore } from "@/main.ts"; -export declare class ModuleConflictChecker extends AbstractModule { - _queueConflictCheckIfOpen(file: FilePathWithPrefix): Promise; - _queueConflictCheck(file: FilePathWithPrefix): Promise; - _waitForAllConflictProcessed(): Promise; - conflictResolveQueue: QueueProcessor; - conflictCheckQueue: QueueProcessor; - onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void; -} diff --git a/_types/src/modules/coreFeatures/ModuleConflictResolver.d.ts b/_types/src/modules/coreFeatures/ModuleConflictResolver.d.ts deleted file mode 100644 index b45e731e..00000000 --- a/_types/src/modules/coreFeatures/ModuleConflictResolver.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { AbstractModule } from "@/modules/AbstractModule.ts"; -import { type diff_check_result, type FilePathWithPrefix } from "@lib/common/types"; -import type { InjectableServiceHub } from "@lib/services/InjectableServices.ts"; -import type { LiveSyncCore } from "@/main.ts"; -declare global { - interface LSEvents { - "conflict-cancelled": FilePathWithPrefix; - } -} -export declare class ModuleConflictResolver extends AbstractModule { - private _resolveConflictByDeletingRev; - checkConflictAndPerformAutoMerge(path: FilePathWithPrefix): Promise; - private _resolveConflict; - private _anyResolveConflictByNewest; - private _resolveAllConflictedFilesByNewerOnes; - onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void; -} diff --git a/_types/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.d.ts b/_types/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.d.ts deleted file mode 100644 index 25a846dd..00000000 --- a/_types/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.d.ts +++ /dev/null @@ -1,36 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type TweakValues, type ObsidianLiveSyncSettings, type RemoteDBSettings } from "@lib/common/types.ts"; -import { AbstractModule } from "@/modules/AbstractModule.ts"; -import type { InjectableServiceHub } from "@lib/services/InjectableServices.ts"; -import type { LiveSyncCore } from "@/main.ts"; -export declare class ModuleResolvingMismatchedTweaks extends AbstractModule { - private _hasNotifiedAutoAcceptCompatibleUndefined; - private _collectMismatchedTweakKeys; - private _selectNewerTweakSide; - private _shouldAutoAcceptCompatibleLossy; - /** - * Hook before saving settings, to check if there are changes in tweak values, and if so, - * update the tweakModified timestamp to current time. - * This allows other devices to know that the tweak values have been changed and decide whether to accept the new values based on the modification time. - * @param next - * @param previous - * @returns - */ - _onBeforeSaveSettingData(next: ObsidianLiveSyncSettings, previous: ObsidianLiveSyncSettings): Promise<{ - tweakModified: number; - } | undefined>; - _anyAfterConnectCheckFailed(): Promise; - _checkAndAskResolvingMismatchedTweaks(preferred: TweakValues): Promise<[TweakValues | boolean, boolean]>; - _askResolvingMismatchedTweaks(): Promise<"OK" | "CHECKAGAIN" | "IGNORE">; - _fetchRemotePreferredTweakValues(trialSetting: RemoteDBSettings): Promise; - _checkAndAskUseRemoteConfiguration(trialSetting: RemoteDBSettings): Promise<{ - result: false | TweakValues; - requireFetch: boolean; - }>; - _askUseRemoteConfiguration(trialSetting: RemoteDBSettings, preferred: TweakValues): Promise<{ - result: false | TweakValues; - requireFetch: boolean; - }>; - onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void; -} diff --git a/_types/src/modules/coreObsidian/UILib/dialogs.d.ts b/_types/src/modules/coreObsidian/UILib/dialogs.d.ts deleted file mode 100644 index 1c45b785..00000000 --- a/_types/src/modules/coreObsidian/UILib/dialogs.d.ts +++ /dev/null @@ -1,56 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { ButtonComponent } from "@/deps.ts"; -import { App, FuzzySuggestModal, Modal, Plugin, Component } from "@/deps.ts"; -import { type CompatIntervalHandle } from "@lib/common/coreEnvFunctions.ts"; -declare class AutoClosableModal extends Modal { - _closeByUnload(): void; - constructor(app: App); - onClose(): void; -} -export declare class InputStringDialog extends AutoClosableModal { - result: string | false; - onSubmit: (result: string | false) => void; - title: string; - key: string; - placeholder: string; - isManuallyClosed: boolean; - isPassword: boolean; - constructor(app: App, title: string, key: string, placeholder: string, isPassword: boolean, onSubmit: (result: string | false) => void); - onOpen(): void; - onClose(): void; -} -export declare class PopoverSelectString extends FuzzySuggestModal { - _app: App; - callback: ((e: string) => void) | undefined; - getItemsFun: () => string[]; - constructor(app: App, note: string, placeholder: string | undefined, getItemsFun: (() => string[]) | undefined, callback: (e: string) => void); - getItems(): string[]; - getItemText(item: string): string; - onChooseItem(item: string, evt: MouseEvent | KeyboardEvent): void; - onClose(): void; -} -export declare class MessageBox extends AutoClosableModal { - plugin: Plugin; - title: string; - contentMd: string; - buttons: T; - result: string | false; - isManuallyClosed: boolean; - defaultAction: string | undefined; - timeout: number | undefined; - timer: CompatIntervalHandle | undefined; - defaultButtonComponent: ButtonComponent | undefined; - wideButton: boolean; - onSubmit: (result: string | false) => void; - component: Component; - constructor(plugin: Plugin, title: string, contentMd: string, buttons: T, defaultAction: T[number], timeout: number | undefined, wideButton: boolean, onSubmit: (result: T[number] | false) => void); - onOpen(): void; - onClose(): void; -} -export declare function confirmWithMessage(plugin: Plugin, title: string, contentMd: string, buttons: T, defaultAction: T[number], timeout?: number): Promise; -export declare function confirmWithMessageWithWideButton(plugin: Plugin, title: string, contentMd: string, buttons: T, defaultAction: T[number], timeout?: number): Promise; -export declare const askYesNo: (app: App, message: string) => Promise<"yes" | "no">; -export declare const askSelectString: (app: App, message: string, items: string[]) => Promise; -export declare const askString: (app: App, title: string, key: string, placeholder: string, isPassword?: boolean) => Promise; -export {}; diff --git a/_types/src/modules/coreObsidian/storageLib/utilObsidian.d.ts b/_types/src/modules/coreObsidian/storageLib/utilObsidian.d.ts deleted file mode 100644 index 6c4a7d8c..00000000 --- a/_types/src/modules/coreObsidian/storageLib/utilObsidian.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { TFile, type TAbstractFile, type TFolder } from "@/deps.ts"; -import type { FilePathWithPrefix, UXFileInfo, UXFileInfoStub, UXFolderInfo, UXInternalFileInfoStub } from "@lib/common/types.ts"; -import type { LiveSyncCore } from "@/main.ts"; -import type { FileAccessObsidian } from "@/serviceModules/FileAccessObsidian.ts"; -export declare function TFileToUXFileInfo(core: LiveSyncCore, file: TFile, prefix?: string, deleted?: boolean): Promise; -export declare function InternalFileToUXFileInfo(fullPath: string, vaultAccess: FileAccessObsidian, prefix?: string): Promise; -export declare function TFileToUXFileInfoStub(file: TFile | TAbstractFile, deleted?: boolean): UXFileInfoStub; -export declare function InternalFileToUXFileInfoStub(filename: FilePathWithPrefix, deleted?: boolean): UXInternalFileInfoStub; -export declare function TFolderToUXFileInfoStub(file: TFolder): UXFolderInfo; diff --git a/_types/src/modules/essential/ModuleBasicMenu.d.ts b/_types/src/modules/essential/ModuleBasicMenu.d.ts deleted file mode 100644 index 4caa8d61..00000000 --- a/_types/src/modules/essential/ModuleBasicMenu.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { LiveSyncCore } from "@/main"; -import { AbstractModule } from "@/modules/AbstractModule"; -export declare class ModuleBasicMenu extends AbstractModule { - _everyOnloadStart(): Promise; - onBindFunction(core: LiveSyncCore, services: typeof core.services): void; -} diff --git a/_types/src/modules/essential/ModuleMigration.d.ts b/_types/src/modules/essential/ModuleMigration.d.ts deleted file mode 100644 index 45ec4c02..00000000 --- a/_types/src/modules/essential/ModuleMigration.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { AbstractModule } from "@/modules/AbstractModule.ts"; -import type { LiveSyncCore } from "@/main.ts"; -export declare class ModuleMigration extends AbstractModule { - migrateUsingDoctor(skipRebuild?: boolean, activateReason?: string, forceRescan?: boolean): Promise; - migrateDisableBulkSend(): Promise; - initialMessage(): Promise; - askAgainForSetupURI(): Promise; - hasIncompleteDocs(force?: boolean): Promise; - hasCompromisedChunks(): Promise; - _everyOnFirstInitialize(): Promise; - _everyOnLayoutReady(): Promise; - onBindFunction(core: LiveSyncCore, services: typeof core.services): void; -} diff --git a/_types/src/modules/essentialObsidian/APILib/ObsHttpHandler.d.ts b/_types/src/modules/essentialObsidian/APILib/ObsHttpHandler.d.ts deleted file mode 100644 index db60b074..00000000 --- a/_types/src/modules/essentialObsidian/APILib/ObsHttpHandler.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { FetchHttpHandler, type FetchHttpHandlerOptions } from "@smithy/fetch-http-handler"; -import { HttpRequest, HttpResponse, type HttpHandlerOptions } from "@smithy/protocol-http"; -/** - * This is close to origin implementation of FetchHttpHandler - * https://github.com/aws/aws-sdk-js-v3/blob/main/packages/fetch-http-handler/src/fetch-http-handler.ts - * that is released under Apache 2 License. - * But this uses Obsidian requestUrl instead. - */ -export declare class ObsHttpHandler extends FetchHttpHandler { - requestTimeoutInMs: number | undefined; - reverseProxyNoSignUrl: string | undefined; - constructor(options?: FetchHttpHandlerOptions, reverseProxyNoSignUrl?: string); - handle(request: HttpRequest, { abortSignal }?: HttpHandlerOptions): Promise<{ - response: HttpResponse; - }>; -} diff --git a/_types/src/modules/essentialObsidian/ModuleObsidianEvents.d.ts b/_types/src/modules/essentialObsidian/ModuleObsidianEvents.d.ts deleted file mode 100644 index eb2bfb43..00000000 --- a/_types/src/modules/essentialObsidian/ModuleObsidianEvents.d.ts +++ /dev/null @@ -1,33 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { AbstractObsidianModule } from "@/modules/AbstractObsidianModule.ts"; -import type { TFile } from "@/deps.ts"; -import { type ReactiveSource } from "octagonal-wheels/dataobject/reactive"; -import type { LiveSyncCore } from "@/main.ts"; -export declare class ModuleObsidianEvents extends AbstractObsidianModule { - _everyOnloadStart(): Promise; - __performAppReload(): void; - initialCallback: (() => void) | undefined; - swapSaveCommand(): void; - registerWatchEvents(): void; - hasFocus: boolean; - isLastHidden: boolean; - private boundedRemoteActivityEndHandler?; - private deferredBoundedLifecycle?; - private keepReplicationActiveInBackground; - private applyDeferredBoundedActivityLifecycle; - private deferLifecycleUntilBoundedRemoteActivityEnds; - setHasFocus(hasFocus: boolean): void; - watchWindowVisibility(): void; - watchOnline(): void; - watchOnlineAsync(): Promise; - watchWindowVisibilityAsync(): Promise; - watchWorkspaceOpen(file: TFile | null): void; - watchWorkspaceOpenAsync(file: TFile): Promise; - _everyOnLayoutReady(): Promise; - private _askReload; - _totalProcessingCount?: ReactiveSource; - private _scheduleAppReload; - _isReloadingScheduled(): boolean; - onBindFunction(core: LiveSyncCore, services: typeof core.services): void; -} diff --git a/_types/src/modules/essentialObsidian/ModuleObsidianMenu.d.ts b/_types/src/modules/essentialObsidian/ModuleObsidianMenu.d.ts deleted file mode 100644 index c4d24e56..00000000 --- a/_types/src/modules/essentialObsidian/ModuleObsidianMenu.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { LiveSyncCore } from "@/main.ts"; -import { AbstractModule } from "@/modules/AbstractModule.ts"; -export declare class ModuleObsidianMenu extends AbstractModule { - _everyOnloadStart(): Promise; - onBindFunction(core: LiveSyncCore, services: typeof core.services): void; -} diff --git a/_types/src/modules/features/DocumentHistory/DocumentHistoryModal.d.ts b/_types/src/modules/features/DocumentHistory/DocumentHistoryModal.d.ts deleted file mode 100644 index 22f58151..00000000 --- a/_types/src/modules/features/DocumentHistory/DocumentHistoryModal.d.ts +++ /dev/null @@ -1,71 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { TFile, Modal, App } from "@/deps.ts"; -import ObsidianLiveSyncPlugin from "@/main.ts"; -import { type DocumentID, type FilePathWithPrefix, type LoadedEntry } from "@lib/common/types.ts"; -import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts"; -export declare class DocumentHistoryModal extends Modal { - plugin: ObsidianLiveSyncPlugin; - core: LiveSyncBaseCore; - get services(): import("../../../lib/src/services/InjectableServices").InjectableServiceHub; - range: HTMLInputElement; - contentView: HTMLDivElement; - info: HTMLDivElement; - fileInfo: HTMLDivElement; - showDiff: boolean; - diffOnly: boolean; - id?: DocumentID; - file: FilePathWithPrefix; - revs_info: PouchDB.Core.RevisionInfo[]; - currentDoc?: LoadedEntry; - currentText: string; - currentDeleted: boolean; - initialRev?: string; - currentDiffIndex: number; - diffNavContainer: HTMLDivElement; - diffNavIndicator: HTMLSpanElement; - diffOnlyLabel: HTMLLabelElement; - searchKeyword: string; - searchResults: { - rev: string; - index: number; - matchType: "Content" | "Diff"; - }[]; - currentSearchIndex: number; - searchResultIndicator: HTMLSpanElement; - searchProgressIndicator: HTMLSpanElement; - searchTimeout: number | null; - constructor(app: App, core: LiveSyncBaseCore, plugin: ObsidianLiveSyncPlugin, file: TFile | FilePathWithPrefix, id?: DocumentID, revision?: string); - loadFile(initialRev?: string): Promise; - loadRevs(initialRev?: string): Promise; - BlobURLs: Map; - revokeURL(key: string): void; - generateBlobURL(key: string, data: Uint8Array): string; - prepareContentView(usePreformatted?: boolean): void; - appendTextDiff(diff: [number, string][]): void; - appendSearchHighlightedText(container: HTMLElement, text: string): void; - appendImageDiff(baseSrc: string, overlaySrc?: string): void; - appendDeletedNotice(usePreformatted?: boolean): void; - showExactRev(rev: string): Promise; - /** - * Navigate to the previous or next diff block in the content view. - * Only effective when diff highlighting is enabled. - */ - navigateDiff(direction: "prev" | "next"): void; - /** - * Reset the diff navigation index and update the indicator. - */ - resetDiffNavigation(): void; - /** - * Show or hide the diff navigation buttons based on the showDiff state. - */ - updateDiffNavVisibility(): void; - /** - * Search through the last 100 revisions for the given keyword. - */ - performSearch(keyword: string): Promise; - updateSearchUI(): void; - navigateSearch(direction: "prev" | "next"): void; - onOpen(): void; - onClose(): void; -} diff --git a/_types/src/modules/features/GlobalHistory/GlobalHistoryView.d.ts b/_types/src/modules/features/GlobalHistory/GlobalHistoryView.d.ts deleted file mode 100644 index a41394c3..00000000 --- a/_types/src/modules/features/GlobalHistory/GlobalHistoryView.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { WorkspaceLeaf } from "@/deps.ts"; -import type ObsidianLiveSyncPlugin from "@/main.ts"; -import { SvelteItemView } from "@/common/SvelteItemView.ts"; -export declare const VIEW_TYPE_GLOBAL_HISTORY = "global-history"; -export declare class GlobalHistoryView extends SvelteItemView { - instantiateComponent(target: HTMLElement): { - $on?(type: string, callback: (e: any) => void): () => void; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - $set?(props: Partial>): void; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - } & Record; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - plugin: ObsidianLiveSyncPlugin; - icon: string; - title: string; - navigation: boolean; - getIcon(): string; - constructor(leaf: WorkspaceLeaf, plugin: ObsidianLiveSyncPlugin); - getViewType(): string; - getDisplayText(): string; -} diff --git a/_types/src/modules/features/InteractiveConflictResolving/ConflictResolveModal.d.ts b/_types/src/modules/features/InteractiveConflictResolving/ConflictResolveModal.d.ts deleted file mode 100644 index ffafc8e8..00000000 --- a/_types/src/modules/features/InteractiveConflictResolving/ConflictResolveModal.d.ts +++ /dev/null @@ -1,35 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { App, Modal } from "@/deps.ts"; -import { CANCELLED, LEAVE_TO_SUBSEQUENT, type diff_result } from "@lib/common/types.ts"; -import { eventHub } from "@/common/events.ts"; -export type MergeDialogResult = typeof CANCELLED | typeof LEAVE_TO_SUBSEQUENT | string; -declare global { - interface Slips extends LSSlips { - "conflict-resolved": typeof CANCELLED | MergeDialogResult; - } -} -export declare class ConflictResolveModal extends Modal { - result: diff_result; - filename: string; - response: MergeDialogResult; - isClosed: boolean; - consumed: boolean; - title: string; - pluginPickMode: boolean; - localName: string; - remoteName: string; - offEvent?: ReturnType; - currentDiffIndex: number; - diffView: HTMLDivElement; - diffNavIndicator: HTMLSpanElement; - constructor(app: App, filename: string, diff: diff_result, pluginPickMode?: boolean, remoteName?: string); - appendDiffFragment(container: HTMLDivElement, text: string, cls: string): void; - appendVersionInfo(container: HTMLDivElement, cls: string, name: string, date: string): void; - navigateDiff(direction: "prev" | "next"): void; - resetDiffNavigation(): void; - onOpen(): void; - sendResponse(result: MergeDialogResult): void; - onClose(): void; - waitForResult(): Promise; -} diff --git a/_types/src/modules/features/Log/LogPaneView.d.ts b/_types/src/modules/features/Log/LogPaneView.d.ts deleted file mode 100644 index 13e65f51..00000000 --- a/_types/src/modules/features/Log/LogPaneView.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { WorkspaceLeaf } from "@/deps.ts"; -import type ObsidianLiveSyncPlugin from "@/main.ts"; -import { SvelteItemView } from "@/common/SvelteItemView.ts"; -export declare const VIEW_TYPE_LOG = "log-log"; -export declare class LogPaneView extends SvelteItemView { - instantiateComponent(target: HTMLElement): { - $on?(type: string, callback: (e: any) => void): () => void; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - $set?(props: Partial>): void; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - } & Record; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration - plugin: ObsidianLiveSyncPlugin; - icon: string; - title: string; - navigation: boolean; - getIcon(): string; - constructor(leaf: WorkspaceLeaf, plugin: ObsidianLiveSyncPlugin); - getViewType(): string; - getDisplayText(): import("octagonal-wheels/common/types").TaggedType; -} diff --git a/_types/src/modules/features/ModuleGlobalHistory.d.ts b/_types/src/modules/features/ModuleGlobalHistory.d.ts deleted file mode 100644 index f178d464..00000000 --- a/_types/src/modules/features/ModuleGlobalHistory.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { AbstractObsidianModule } from "@/modules/AbstractObsidianModule.ts"; -export declare class ModuleObsidianGlobalHistory extends AbstractObsidianModule { - _everyOnloadStart(): Promise; - showGlobalHistory(): void; - onBindFunction(core: typeof this.core, services: typeof core.services): void; -} diff --git a/_types/src/modules/features/ModuleInteractiveConflictResolver.d.ts b/_types/src/modules/features/ModuleInteractiveConflictResolver.d.ts deleted file mode 100644 index c7df10b7..00000000 --- a/_types/src/modules/features/ModuleInteractiveConflictResolver.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type FilePathWithPrefix, type diff_result } from "@lib/common/types.ts"; -import { AbstractObsidianModule } from "@/modules/AbstractObsidianModule.ts"; -import type { LiveSyncCore } from "@/main.ts"; -export declare class ModuleInteractiveConflictResolver extends AbstractObsidianModule { - _everyOnloadStart(): Promise; - _anyResolveConflictByUI(filename: FilePathWithPrefix, conflictCheckResult: diff_result): Promise; - allConflictCheck(): Promise; - pickFileForResolve(): Promise; - _allScanStat(): Promise; - onBindFunction(core: LiveSyncCore, services: typeof core.services): void; -} diff --git a/_types/src/modules/features/ModuleLog.d.ts b/_types/src/modules/features/ModuleLog.d.ts deleted file mode 100644 index a99f8c48..00000000 --- a/_types/src/modules/features/ModuleLog.d.ts +++ /dev/null @@ -1,50 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type ReactiveValue } from "octagonal-wheels/dataobject/reactive"; -import { type LOG_LEVEL } from "@lib/common/types.ts"; -import { AbstractObsidianModule } from "@/modules/AbstractObsidianModule.ts"; -import { Notice } from "@/deps.ts"; -import { P2PLogCollector } from "@lib/replication/trystero/P2PLogCollector.ts"; -import type { LiveSyncCore } from "@/main.ts"; -import { compatGlobal } from "@lib/common/coreEnvFunctions.ts"; -export declare const MARK_DONE = "\u2009\u2009"; -export declare class ModuleLog extends AbstractObsidianModule { - statusBar?: HTMLElement; - statusDiv?: HTMLElement; - statusLine?: HTMLDivElement; - logMessage?: HTMLDivElement; - logHistory?: HTMLDivElement; - messageArea?: HTMLDivElement; - statusBarLabels: ReactiveValue<{ - message: string; - status: string; - }>; - statusLog: import("octagonal-wheels/dataobject/reactive_v2").ReactiveSource; - activeFileStatus: import("octagonal-wheels/dataobject/reactive_v2").ReactiveSource; - notifies: { - [key: string]: { - notice: Notice; - count: number; - }; - }; - p2pLogCollector: P2PLogCollector; - observeForLogs(): void; - private _everyOnload; - adjustStatusDivPosition(): void; - getActiveFileStatus(): Promise; - setFileStatus(): Promise; - updateMessageArea(): Promise; - onActiveLeafChange(): void; - nextFrameQueue: ReturnType | undefined; - logLines: { - ttl: number; - message: string; - }[]; - applyStatusBarText(): void; - private _allStartOnUnload; - _everyOnloadStart(): Promise; - private _everyOnloadAfterLoadSettings; - writeLogToTheFile(now: Date, vaultName: string, newMessage: string): void; - __addLog(message: unknown, level?: LOG_LEVEL, key?: string): void; - onBindFunction(core: LiveSyncCore, services: typeof core.services): void; -} diff --git a/_types/src/modules/features/ModuleObsidianDocumentHistory.d.ts b/_types/src/modules/features/ModuleObsidianDocumentHistory.d.ts deleted file mode 100644 index b95bb288..00000000 --- a/_types/src/modules/features/ModuleObsidianDocumentHistory.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type TFile } from "@/deps.ts"; -import type { FilePathWithPrefix, DocumentID } from "@lib/common/types.ts"; -import { AbstractObsidianModule } from "@/modules/AbstractObsidianModule.ts"; -export declare class ModuleObsidianDocumentHistory extends AbstractObsidianModule { - _everyOnloadStart(): Promise; - showHistory(file: TFile | FilePathWithPrefix, id?: DocumentID): void; - fileHistory(): Promise; - onBindFunction(core: typeof this.core, services: typeof core.services): void; -} diff --git a/_types/src/modules/features/ModuleObsidianSettingAsMarkdown.d.ts b/_types/src/modules/features/ModuleObsidianSettingAsMarkdown.d.ts deleted file mode 100644 index 9805763f..00000000 --- a/_types/src/modules/features/ModuleObsidianSettingAsMarkdown.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type ObsidianLiveSyncSettings } from "@lib/common/types"; -import { AbstractModule } from "@/modules/AbstractModule.ts"; -import type { ServiceContext } from "@lib/services/base/ServiceBase.ts"; -import type { InjectableServiceHub } from "@lib/services/InjectableServices.ts"; -import type { LiveSyncCore } from "@/main.ts"; -export declare class ModuleObsidianSettingsAsMarkdown extends AbstractModule { - _everyOnloadStart(): Promise; - extractSettingFromWholeText(data: string): { - preamble: string; - body: string; - postscript: string; - }; - parseSettingFromMarkdown(filename: string, data?: string): Promise<{ - preamble: string; - body: string; - postscript: string; - }>; - checkAndApplySettingFromMarkdown(filename: string, automated?: boolean): Promise; - generateSettingForMarkdown(settings?: ObsidianLiveSyncSettings, keepCredential?: boolean): Partial; - saveSettingToMarkdown(filename: string): Promise; - onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void; -} diff --git a/_types/src/modules/features/ModuleObsidianSettingTab.d.ts b/_types/src/modules/features/ModuleObsidianSettingTab.d.ts deleted file mode 100644 index 75fe43d9..00000000 --- a/_types/src/modules/features/ModuleObsidianSettingTab.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { ObsidianLiveSyncSettingTab } from "./SettingDialogue/ObsidianLiveSyncSettingTab.ts"; -import { AbstractObsidianModule } from "@/modules/AbstractObsidianModule.ts"; -import type { LiveSyncCore } from "@/main.ts"; -export declare class ModuleObsidianSettingDialogue extends AbstractObsidianModule { - settingTab: ObsidianLiveSyncSettingTab; - _everyOnloadStart(): Promise; - openSetting(): void; - get appId(): string; - onBindFunction(core: LiveSyncCore, services: typeof core.services): void; -} diff --git a/_types/src/modules/features/RemoteActivityStatus.d.ts b/_types/src/modules/features/RemoteActivityStatus.d.ts deleted file mode 100644 index 4e85a9e8..00000000 --- a/_types/src/modules/features/RemoteActivityStatus.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -/** Returns whether the status UI should report HTTP traffic or a finite remote operation in progress. */ -export declare function hasRemoteActivity(requestCount: number, responseCount: number, boundedRemoteActivityCount: number): boolean; diff --git a/_types/src/modules/features/SettingDialogue/LiveSyncSetting.d.ts b/_types/src/modules/features/SettingDialogue/LiveSyncSetting.d.ts deleted file mode 100644 index c04a0a9c..00000000 --- a/_types/src/modules/features/SettingDialogue/LiveSyncSetting.d.ts +++ /dev/null @@ -1,55 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { Setting, TextComponent, type ToggleComponent, type DropdownComponent, ButtonComponent, type TextAreaComponent, type ValueComponent } from "@/deps.ts"; -import { type ConfigurationItem } from "@lib/common/types.ts"; -import { type ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; -import { type AllSettingItemKey, type AllSettings, type AllStringItemKey, type AllNumericItemKey, type AllBooleanItemKey } from "./settingConstants.ts"; -import { type AutoWireOption, type OnUpdateResult } from "./SettingPane.ts"; -export declare class LiveSyncSetting extends Setting { - autoWiredComponent?: TextComponent | ToggleComponent | DropdownComponent | ButtonComponent | TextAreaComponent; - applyButtonComponent?: ButtonComponent; - selfKey?: AllSettingItemKey; - watchDirtyKeys: AllSettingItemKey[]; - holdValue: boolean; - static env: ObsidianLiveSyncSettingTab; - descBuf: string | DocumentFragment; - nameBuf: string | DocumentFragment; - placeHolderBuf: string; - hasPassword: boolean; - invalidateValue?: () => void; - setValue?: (value: unknown) => void; - constructor(containerEl: HTMLElement); - setDesc(desc: string | DocumentFragment): this; - setName(name: string | DocumentFragment): this; - setAuto(key: AllSettingItemKey, opt?: AutoWireOption): this; - autoWireSetting(key: AllSettingItemKey, opt?: AutoWireOption): { - name: string; - desc?: string; - placeHolder?: string; - status?: "BETA" | "ALPHA" | "EXPERIMENTAL"; - obsolete?: boolean; - level?: import("@lib/common/types.ts").ConfigLevel; - isHidden?: boolean; - isAdvanced?: boolean; - } | undefined; - autoWireComponent(component: ValueComponent, conf?: ConfigurationItem, opt?: AutoWireOption): void; - commitValue(value: AllSettings[T]): Promise; - autoWireText(key: AllStringItemKey, opt?: AutoWireOption): this; - autoWireTextArea(key: AllStringItemKey, opt?: AutoWireOption): this; - autoWireNumeric(key: AllNumericItemKey, opt: AutoWireOption & { - clampMin?: number; - clampMax?: number; - acceptZero?: boolean; - }): this; - autoWireToggle(key: AllBooleanItemKey, opt?: AutoWireOption): this; - autoWireDropDown(key: AllStringItemKey, opt: AutoWireOption & { - options: Record; - }): this; - addApplyButton(keys: AllSettingItemKey[], text?: string): this; - addOnUpdate(func: () => OnUpdateResult): this; - updateHandlers: Set<() => OnUpdateResult>; - prevStatus: OnUpdateResult; - _getComputedStatus(): OnUpdateResult; - _applyOnUpdateHandlers(): void; - _onUpdate(): void; -} diff --git a/_types/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.d.ts b/_types/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.d.ts deleted file mode 100644 index 76bc3bcf..00000000 --- a/_types/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.d.ts +++ /dev/null @@ -1,104 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { App, Component, PluginSettingTab } from "@/deps.ts"; -import { type ObsidianLiveSyncSettings } from "@lib/common/types.ts"; -import ObsidianLiveSyncPlugin from "@/main.ts"; -import { type AllSettingItemKey, type AllStringItemKey, type AllNumericItemKey, type AllBooleanItemKey, type AllSettings, OnDialogSettingsDefault, type OnDialogSettings } from "./settingConstants.ts"; -import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts"; -import { type OnSavedHandler, type OnSavedHandlerFunc, type OnUpdateFunc, type OnUpdateResult, type UpdateFunction } from "./SettingPane.ts"; -import { JournalSyncCore } from "@lib/replication/journal/JournalSyncCore.js"; -export declare class ObsidianLiveSyncSettingTab extends PluginSettingTab { - plugin: ObsidianLiveSyncPlugin; - private _lifetimeComponent; - get lifetimeComponent(): Component; - get core(): import("@/main.ts").LiveSyncCore; - get services(): import("../../../lib/src/services/InjectableServices.ts").InjectableServiceHub; - selectedScreen: string; - _editingSettings?: AllSettings; - get editingSettings(): AllSettings; - set editingSettings(v: AllSettings); - initialSettings?: typeof this.editingSettings; - /** - * Apply editing setting to the plug-in. - * @param keys setting keys for applying - */ - applySetting(keys: AllSettingItemKey[]): void; - applyAllSettings(): void; - saveLocalSetting(key: keyof typeof OnDialogSettingsDefault): Promise; - /** - * Apply and save setting to the plug-in. - * @param keys setting keys for applying - */ - saveSettings(keys: AllSettingItemKey[]): Promise; - /** - * Apply all editing setting to the plug-in. - * @param keys setting keys for applying - */ - saveAllDirtySettings(): Promise; - /** - * Invalidate buffered value and fetch the latest. - */ - requestUpdate(): void; - reloadAllLocalSettings(): { - configPassphrase: string; - preset: "" | "PERIODIC" | "LIVESYNC" | "DISABLE"; - syncMode: "ONEVENTS" | "PERIODIC" | "LIVESYNC"; - dummy: number; - deviceAndVaultName: string; - }; - computeAllLocalSettings(): Partial; - /** - * Reread all settings and request invalidate - */ - reloadAllSettings(skipUpdate?: boolean): void; - /** - * Reread each setting and request invalidate - */ - refreshSetting(key: AllSettingItemKey): void; - isDirty(key: AllSettingItemKey): boolean; - isSomeDirty(keys: AllSettingItemKey[]): boolean; - isConfiguredAs(key: AllStringItemKey, value: string): boolean; - isConfiguredAs(key: AllNumericItemKey, value: number): boolean; - isConfiguredAs(key: AllBooleanItemKey, value: boolean): boolean; - settingComponents: Setting[]; - controlledElementFunc: UpdateFunction[]; - onSavedHandlers: OnSavedHandler[]; - inWizard: boolean; - constructor(app: App, plugin: ObsidianLiveSyncPlugin); - testConnection(settingOverride?: Partial): Promise; - closeSetting(): void; - handleElement(element: HTMLElement, func: OnUpdateFunc): void; - createEl(el: HTMLElement, tag: T, o?: string | DomElementInfo, callback?: (el: HTMLElementTagNameMap[T]) => void, func?: OnUpdateFunc): HTMLElementTagNameMap[T]; - addEl(el: HTMLElement, tag: T, o?: string | DomElementInfo, callback?: (el: HTMLElementTagNameMap[T]) => void, func?: OnUpdateFunc): Promise>; - addOnSaved(key: T, func: OnSavedHandlerFunc): void; - resetEditingSettings(): void; - hide(): void; - isShown: boolean; - requestReload(): void; - manifestVersion: string; - lastVersion: number; - screenElements: { - [key: string]: HTMLElement[]; - }; - changeDisplay(screen: string): void; - enableMinimalSetup(): Promise; - menuEl?: HTMLElement; - addScreenElement(key: string, element: HTMLElement): void; - selectPane(event: Event): void; - isNeedRebuildLocal(): boolean; - isNeedRebuildRemote(): boolean; - isAnySyncEnabled(): boolean; - enableOnlySyncDisabled: OnUpdateFunc; - onlyOnP2POrCouchDB: () => OnUpdateResult; - onlyOnCouchDB: () => OnUpdateResult; - onlyOnMinIO: () => OnUpdateResult; - onlyOnOnlyP2P: () => OnUpdateResult; - onlyOnCouchDBOrMinIO: () => OnUpdateResult; - checkWorkingPassphrase: () => Promise; - isPassphraseValid: () => Promise; - rebuildDB: (method: "localOnly" | "remoteOnly" | "rebuildBothByThisDevice" | "localOnlyWithChunks") => Promise; - confirmRebuild(): Promise; - display(): void; - getMinioJournalSyncClient(): JournalSyncCore; - resetRemoteBucket(): Promise; -} diff --git a/_types/src/modules/features/SettingDialogue/PaneAdvanced.d.ts b/_types/src/modules/features/SettingDialogue/PaneAdvanced.d.ts deleted file mode 100644 index 3f6ee68b..00000000 --- a/_types/src/modules/features/SettingDialogue/PaneAdvanced.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; -import type { PageFunctions } from "./SettingPane.ts"; -export declare function paneAdvanced(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void; diff --git a/_types/src/modules/features/SettingDialogue/PaneChangeLog.d.ts b/_types/src/modules/features/SettingDialogue/PaneChangeLog.d.ts deleted file mode 100644 index f44bfaad..00000000 --- a/_types/src/modules/features/SettingDialogue/PaneChangeLog.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; -export declare function paneChangeLog(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement): void; diff --git a/_types/src/modules/features/SettingDialogue/PaneCustomisationSync.d.ts b/_types/src/modules/features/SettingDialogue/PaneCustomisationSync.d.ts deleted file mode 100644 index 0bd9dde3..00000000 --- a/_types/src/modules/features/SettingDialogue/PaneCustomisationSync.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; -import type { PageFunctions } from "./SettingPane.ts"; -export declare function paneCustomisationSync(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void; diff --git a/_types/src/modules/features/SettingDialogue/PaneGeneral.d.ts b/_types/src/modules/features/SettingDialogue/PaneGeneral.d.ts deleted file mode 100644 index 6c5d0614..00000000 --- a/_types/src/modules/features/SettingDialogue/PaneGeneral.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; -import type { PageFunctions } from "./SettingPane.ts"; -export declare function paneGeneral(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel, addPane }: PageFunctions): void; diff --git a/_types/src/modules/features/SettingDialogue/PaneHatch.d.ts b/_types/src/modules/features/SettingDialogue/PaneHatch.d.ts deleted file mode 100644 index 38d12b2d..00000000 --- a/_types/src/modules/features/SettingDialogue/PaneHatch.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; -import type { PageFunctions } from "./SettingPane.ts"; -export declare function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void; diff --git a/_types/src/modules/features/SettingDialogue/PaneMaintenance.d.ts b/_types/src/modules/features/SettingDialogue/PaneMaintenance.d.ts deleted file mode 100644 index 2e83024c..00000000 --- a/_types/src/modules/features/SettingDialogue/PaneMaintenance.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab"; -import { type PageFunctions } from "./SettingPane"; -export declare function paneMaintenance(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void; diff --git a/_types/src/modules/features/SettingDialogue/PanePatches.d.ts b/_types/src/modules/features/SettingDialogue/PanePatches.d.ts deleted file mode 100644 index 0a28bb0a..00000000 --- a/_types/src/modules/features/SettingDialogue/PanePatches.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; -import type { PageFunctions } from "./SettingPane.ts"; -export declare function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void; diff --git a/_types/src/modules/features/SettingDialogue/PanePowerUsers.d.ts b/_types/src/modules/features/SettingDialogue/PanePowerUsers.d.ts deleted file mode 100644 index 88d52aea..00000000 --- a/_types/src/modules/features/SettingDialogue/PanePowerUsers.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; -import type { PageFunctions } from "./SettingPane.ts"; -export declare function panePowerUsers(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void; diff --git a/_types/src/modules/features/SettingDialogue/PaneRemoteConfig.d.ts b/_types/src/modules/features/SettingDialogue/PaneRemoteConfig.d.ts deleted file mode 100644 index a8549c63..00000000 --- a/_types/src/modules/features/SettingDialogue/PaneRemoteConfig.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; -import type { PageFunctions } from "./SettingPane.ts"; -export declare function paneRemoteConfig(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel, addPane }: PageFunctions): void; diff --git a/_types/src/modules/features/SettingDialogue/PaneSelector.d.ts b/_types/src/modules/features/SettingDialogue/PaneSelector.d.ts deleted file mode 100644 index 2ac430a3..00000000 --- a/_types/src/modules/features/SettingDialogue/PaneSelector.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; -import type { PageFunctions } from "./SettingPane.ts"; -export declare function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void; diff --git a/_types/src/modules/features/SettingDialogue/PaneSetup.d.ts b/_types/src/modules/features/SettingDialogue/PaneSetup.d.ts deleted file mode 100644 index fe9ed905..00000000 --- a/_types/src/modules/features/SettingDialogue/PaneSetup.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; -import type { PageFunctions } from "./SettingPane.ts"; -export declare function paneSetup(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel, addPane }: PageFunctions): void; diff --git a/_types/src/modules/features/SettingDialogue/PaneSyncSettings.d.ts b/_types/src/modules/features/SettingDialogue/PaneSyncSettings.d.ts deleted file mode 100644 index 568a8b6e..00000000 --- a/_types/src/modules/features/SettingDialogue/PaneSyncSettings.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; -import type { PageFunctions } from "./SettingPane.ts"; -export declare function paneSyncSettings(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel, addPane }: PageFunctions): void; diff --git a/_types/src/modules/features/SettingDialogue/SettingPane.d.ts b/_types/src/modules/features/SettingDialogue/SettingPane.d.ts deleted file mode 100644 index 534084f8..00000000 --- a/_types/src/modules/features/SettingDialogue/SettingPane.d.ts +++ /dev/null @@ -1,38 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type ConfigLevel } from "@lib/common/types"; -import type { AllSettingItemKey, AllSettings } from "./settingConstants"; -export declare const combineOnUpdate: (func1: OnUpdateFunc, func2: OnUpdateFunc) => OnUpdateFunc; -export declare const setLevelClass: (el: HTMLElement, level?: ConfigLevel) => void; -export declare function setStyle(el: HTMLElement, styleHead: string, condition: () => boolean): void; -export declare function visibleOnly(cond: () => boolean): OnUpdateFunc; -export declare function enableOnly(cond: () => boolean): OnUpdateFunc; -export type OnUpdateResult = { - visibility?: boolean; - disabled?: boolean; - classes?: string[]; - isCta?: boolean; - isWarning?: boolean; -}; -export type OnUpdateFunc = () => OnUpdateResult; -export type UpdateFunction = () => void; -export type OnSavedHandlerFunc = (value: AllSettings[T]) => Promise | void; -export type OnSavedHandler = { - key: T; - handler: OnSavedHandlerFunc; -}; -export declare function getLevelStr(level: ConfigLevel): "" | import("octagonal-wheels/common/types").TaggedType | import("octagonal-wheels/common/types").TaggedType | import("octagonal-wheels/common/types").TaggedType; -export type AutoWireOption = { - placeHolder?: string; - holdValue?: boolean; - isPassword?: boolean; - invert?: boolean; - onUpdate?: OnUpdateFunc; - obsolete?: boolean; -}; -export declare function findAttrFromParent(el: HTMLElement, attr: string): string; -export declare function wrapMemo(func: (arg: T) => void): (arg: T) => void; -export type PageFunctions = { - addPane: (parentEl: HTMLElement, title: string, icon: string, order: number, wizardHidden: boolean, level?: ConfigLevel) => Promise; - addPanel: (parentEl: HTMLElement, title: string, callback?: (el: HTMLDivElement) => void, func?: OnUpdateFunc, level?: ConfigLevel) => Promise; -}; diff --git a/_types/src/modules/features/SettingDialogue/SveltePanel.d.ts b/_types/src/modules/features/SettingDialogue/SveltePanel.d.ts deleted file mode 100644 index 5b15cc80..00000000 --- a/_types/src/modules/features/SettingDialogue/SveltePanel.d.ts +++ /dev/null @@ -1,36 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type Component } from "svelte"; -import { type Writable } from "svelte/store"; -/** - * Props passed to Svelte panels, containing a writable port - * to communicate with the panel - */ -export type SveltePanelProps = { - port: Writable; -}; -/** - * A class to manage a Svelte panel within Obsidian - * Especially useful for settings panels - */ -export declare class SveltePanel { - private _mountedComponent; - private _componentValue; - /** - * Creates a Svelte panel instance - * @param component Component to mount - * @param mountTo HTMLElement to mount the component to - * @param valueStore Optional writable store to bind to the component's port, if not provided a new one will be created - * @returns The SveltePanel instance - */ - constructor(component: Component>, mountTo: HTMLElement, valueStore?: Writable); - /** - * Destroys the Svelte panel instance by unmounting the component - */ - destroy(): void; - /** - * Gets or sets the current value of the component's port - */ - get componentValue(): T | undefined; - set componentValue(value: T | undefined); -} diff --git a/_types/src/modules/features/SettingDialogue/remoteConfigBuffer.d.ts b/_types/src/modules/features/SettingDialogue/remoteConfigBuffer.d.ts deleted file mode 100644 index 7bd60d83..00000000 --- a/_types/src/modules/features/SettingDialogue/remoteConfigBuffer.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ObsidianLiveSyncSettings } from "@lib/common/types.ts"; -export declare function syncActivatedRemoteSettings(target: Partial, source: ObsidianLiveSyncSettings): void; diff --git a/_types/src/modules/features/SettingDialogue/settingConstants.d.ts b/_types/src/modules/features/SettingDialogue/settingConstants.d.ts deleted file mode 100644 index b29fd844..00000000 --- a/_types/src/modules/features/SettingDialogue/settingConstants.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export * from "@lib/common/settingConstants.ts"; diff --git a/_types/src/modules/features/SettingDialogue/settingUtils.d.ts b/_types/src/modules/features/SettingDialogue/settingUtils.d.ts deleted file mode 100644 index 8c2d4932..00000000 --- a/_types/src/modules/features/SettingDialogue/settingUtils.d.ts +++ /dev/null @@ -1,59 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type ObsidianLiveSyncSettings } from "@lib/common/types"; -/** - * Generates a summary of P2P configuration settings - * @param setting Settings object - * @param additional Additional summary information to include - * @param showAdvanced Whether to include advanced settings - * @returns Summary object - */ -export declare function getP2PConfigSummary(setting: ObsidianLiveSyncSettings, additional?: Record, showAdvanced?: boolean): { - [x: string]: string; -}; -/** - * Generates a summary of Object Storage configuration settings - * @param setting Settings object - * @param showAdvanced Whether to include advanced settings - * @returns Summary object - */ -export declare function getBucketConfigSummary(setting: ObsidianLiveSyncSettings, showAdvanced?: boolean): Record; -/** - * Generates a summary of CouchDB configuration settings - * @param setting Settings object - * @param showAdvanced Whether to include advanced settings - * @returns Summary object - */ -export declare function getCouchDBConfigSummary(setting: ObsidianLiveSyncSettings, showAdvanced?: boolean): Record; -/** - * Generates a summary of E2EE configuration settings - * @param setting Settings object - * @param showAdvanced Whether to include advanced settings - * @returns Summary object - */ -export declare function getE2EEConfigSummary(setting: ObsidianLiveSyncSettings, showAdvanced?: boolean): Record; -/** - * Converts partial settings into a summary object - * @param setting Partial settings object - * @param showAdvanced Whether to include advanced settings - * @returns Summary object - */ -export declare function getSummaryFromPartialSettings(setting: Partial, showAdvanced?: boolean): Record; -/** - * Copy document from one database to another for migration purposes - * @param docName document ID - * @param dbFrom source database - * @param dbTo destination database - * @returns - */ -export declare function copyMigrationDocs(docName: string, dbFrom: PouchDB.Database, dbTo: PouchDB.Database): Promise; -type PouchDBOpenFunction = () => Promise | PouchDB.Database; -/** - * Migrate databases from one to another - * @param operationName Name of the migration operation - * @param from source database - * @param openTo function to open destination database - * @returns True if migration succeeded - */ -export declare function migrateDatabases(operationName: string, from: PouchDB.Database, openTo: PouchDBOpenFunction): Promise; -export {}; diff --git a/_types/src/modules/features/SetupManager.d.ts b/_types/src/modules/features/SetupManager.d.ts deleted file mode 100644 index 8307d098..00000000 --- a/_types/src/modules/features/SetupManager.d.ts +++ /dev/null @@ -1,121 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type ObsidianLiveSyncSettings } from "@lib/common/types.ts"; -import { AbstractModule } from "@/modules/AbstractModule.ts"; -/** - * User modes for onboarding and setup - */ -export declare const enum UserMode { - /** - * New User Mode - for users who are new to the plugin - */ - NewUser = "new-user", - /** - * Existing User Mode - for users who have used the plugin before, or just configuring again - */ - ExistingUser = "existing-user", - /** - * Unknown User Mode - for cases where the user mode is not determined - */ - Unknown = "unknown", - /** - * Update User Mode - for users who are updating configuration. May be `existing-user` as well, but possibly they want to treat it differently. - */ - Update = "unknown" // eslint-disable-line @typescript-eslint/no-duplicate-enum-values -- Duplicate enum value -} -/** - * Setup Manager to handle onboarding and configuration setup - */ -export declare class SetupManager extends AbstractModule { - get dialogManager(): import("../../lib/src/UI/svelteDialog.ts").SvelteDialogManagerBase; - /** - * Starts the onboarding process - * @returns Promise that resolves to true if onboarding completed successfully, false otherwise - */ - startOnBoarding(): Promise; - /** - * Handles the onboarding process based on user mode - * @param userMode - * @returns Promise that resolves to true if onboarding completed successfully, false otherwise - */ - onOnboard(userMode: UserMode): Promise; - /** - * Handles setup using a setup URI - * @param userMode - * @param setupURI - * @returns Promise that resolves to true if onboarding completed successfully, false otherwise - */ - onUseSetupURI(userMode: UserMode, setupURI?: string): Promise; - /** - * Handles manual setup for CouchDB - * @param userMode - * @param currentSetting - * @param activate Whether to activate the CouchDB as remote type - * @returns Promise that resolves to true if setup completed successfully, false otherwise - */ - onCouchDBManualSetup(userMode: UserMode, currentSetting: ObsidianLiveSyncSettings, activate?: boolean): Promise; - /** - * Handles manual setup for S3-compatible bucket - * @param userMode - * @param currentSetting - * @param activate Whether to activate the Bucket as remote type - * @returns Promise that resolves to true if setup completed successfully, false otherwise - */ - onBucketManualSetup(userMode: UserMode, currentSetting: ObsidianLiveSyncSettings, activate?: boolean): Promise; - /** - * Handles manual setup for P2P - * @param userMode - * @param currentSetting - * @param activate Whether to activate the P2P as remote type (as P2P Only setup) - * @returns Promise that resolves to true if setup completed successfully, false otherwise - */ - onP2PManualSetup(userMode: UserMode, currentSetting: ObsidianLiveSyncSettings, activate?: boolean): Promise; - /** - * Handles only E2EE configuration - * @param userMode - * @param currentSetting - * @returns - */ - onlyE2EEConfiguration(userMode: UserMode, currentSetting: ObsidianLiveSyncSettings): Promise; - /** - * Handles manual configuration flow (E2EE + select server) - * @param originalSetting - * @param userMode - * @returns - */ - onConfigureManually(originalSetting: ObsidianLiveSyncSettings, userMode: UserMode): Promise; - /** - * Handles server selection during manual configuration - * @param currentSetting - * @param userMode - * @returns - */ - onSelectServer(currentSetting: ObsidianLiveSyncSettings, userMode: UserMode): Promise; - /** - * Confirms and applies settings obtained from the wizard - * @param newConf - * @param _userMode - * @param activate Whether to activate the remote type in the new settings - * @param extra Extra function to run before applying settings - * @returns Promise that resolves to true if settings applied successfully, false otherwise - */ - onConfirmApplySettingsFromWizard(newConf: ObsidianLiveSyncSettings, _userMode: UserMode, activate?: boolean, extra?: () => void): Promise; - /** - * Prompts the user with QR code scanning instructions - * @returns Promise that resolves to false as QR code instruction dialog does not yield settings directly - */ - onPromptQRCodeInstruction(): Promise; - /** - * Decodes settings from a QR code string and applies them - * @param qr QR code string containing encoded settings - * @returns Promise that resolves to true if settings applied successfully, false otherwise - */ - decodeQR(qr: string): Promise; - /** - * Applies the new settings to the core settings and saves them - * @param newConf - * @param userMode - * @returns Promise that resolves to true if settings applied successfully, false otherwise - */ - applySetting(newConf: ObsidianLiveSyncSettings, userMode: UserMode): Promise; -} diff --git a/_types/src/modules/features/SetupWizard/dialogs/setupDialogTypes.d.ts b/_types/src/modules/features/SetupWizard/dialogs/setupDialogTypes.d.ts deleted file mode 100644 index 9fa6d6bc..00000000 --- a/_types/src/modules/features/SetupWizard/dialogs/setupDialogTypes.d.ts +++ /dev/null @@ -1,51 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { BucketSyncSetting, CouchDBConnection, EncryptionSettings, ObsidianLiveSyncSettings, P2PConnectionInfo } from "@lib/common/models/setting.type"; -export declare const TYPE_IDENTICAL = "identical"; -export declare const TYPE_INDEPENDENT = "independent"; -export declare const TYPE_UNBALANCED = "unbalanced"; -export declare const TYPE_CANCEL = "cancelled"; -export declare const TYPE_BACKUP_DONE = "backup_done"; -export declare const TYPE_BACKUP_SKIPPED = "backup_skipped"; -export declare const TYPE_UNABLE_TO_BACKUP = "unable_to_backup"; -export declare const TYPE_NEW_USER = "new-user"; -export declare const TYPE_EXISTING_USER = "existing-user"; -export declare const TYPE_CANCELLED = "cancelled"; -export declare const TYPE_EXISTING = "existing-user"; -export declare const TYPE_NEW = "new-user"; -export declare const TYPE_COMPATIBLE_EXISTING = "compatible-existing-user"; -export declare const TYPE_APPLY = "apply"; -export declare const TYPE_USE_SETUP_URI = "use-setup-uri"; -export declare const TYPE_SCAN_QR_CODE = "scan-qr-code"; -export declare const TYPE_CONFIGURE_MANUALLY = "configure-manually"; -export declare const TYPE_CLOSE = "close"; -export declare const TYPE_COUCHDB = "couchdb"; -export declare const TYPE_BUCKET = "bucket"; -export declare 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; diff --git a/_types/src/modules/main/ModuleLiveSyncMain.d.ts b/_types/src/modules/main/ModuleLiveSyncMain.d.ts deleted file mode 100644 index ec303e8e..00000000 --- a/_types/src/modules/main/ModuleLiveSyncMain.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { AbstractModule } from "@/modules/AbstractModule.ts"; -import type { InjectableServiceHub } from "@lib/services/implements/injectable/InjectableServiceHub.ts"; -import type { LiveSyncCore } from "@/main.ts"; -export declare class ModuleLiveSyncMain extends AbstractModule { - _onLiveSyncReady(): Promise; - _wireUpEvents(): Promise; - _onLiveSyncLoad(): Promise; - onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void; -} diff --git a/_types/src/modules/services/ObsidianAPIService.d.ts b/_types/src/modules/services/ObsidianAPIService.d.ts deleted file mode 100644 index bb4ccccb..00000000 --- a/_types/src/modules/services/ObsidianAPIService.d.ts +++ /dev/null @@ -1,41 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { InjectableAPIService } from "@lib/services/implements/injectable/InjectableAPIService"; -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; -import { type Command } from "@/deps.ts"; -import { ObsHttpHandler } from "@/modules/essentialObsidian/APILib/ObsHttpHandler"; -import type { Confirm } from "@lib/interfaces/Confirm"; -declare module "obsidian" { - interface App { - appId?: string; - isMobile?: boolean; - } -} -export declare class ObsidianAPIService extends InjectableAPIService { - _customHandler: ObsHttpHandler | undefined; - _confirmInstance: Confirm; - constructor(context: ObsidianServiceContext); - getCustomFetchHandler(): ObsHttpHandler; - showWindow(viewType: string): Promise; - showWindowOnRight(viewType: string): Promise; - private get app(); - getPlatform(): string; - isMobile(): boolean; - getAppID(): string; - getSystemVaultName(): string; - getAppVersion(): string; - getPluginVersion(): string; - get confirm(): Confirm; - addCommand(command: TCommand): TCommand; - registerWindow(type: string, factory: (leaf: T) => unknown): void; - addRibbonIcon(icon: string, title: string, callback: (evt: MouseEvent) => unknown): HTMLElement; - registerProtocolHandler(action: string, handler: (params: Record) => unknown): void; - /** - * In Obsidian, we will use the native `requestUrl` function as the default fetch handler, - * to address unavoidable CORS issues. - */ - nativeFetch(req: string | Request, opts?: RequestInit): Promise; - addStatusBarItem(): HTMLElement | undefined; - setInterval(handler: () => void, timeout: number): number; - getSystemConfigDir(): string; -} diff --git a/_types/src/modules/services/ObsidianAppLifecycleService.d.ts b/_types/src/modules/services/ObsidianAppLifecycleService.d.ts deleted file mode 100644 index fbf2ade2..00000000 --- a/_types/src/modules/services/ObsidianAppLifecycleService.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { AppLifecycleServiceBase } from "@lib/services/implements/injectable/InjectableAppLifecycleService"; -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; -declare module "obsidian" { - interface App { - commands: { - executeCommandById: (id: string) => Promise; - }; - } -} -export declare class ObsidianAppLifecycleService extends AppLifecycleServiceBase { - performRestart(): void; -} diff --git a/_types/src/modules/services/ObsidianConfirm.d.ts b/_types/src/modules/services/ObsidianConfirm.d.ts deleted file mode 100644 index cce07512..00000000 --- a/_types/src/modules/services/ObsidianConfirm.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type App, type Plugin } from "@/deps"; -import type { Confirm } from "@lib/interfaces/Confirm"; -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; -export declare class ObsidianConfirm implements Confirm { - private _context; - get _app(): App; - get _plugin(): Plugin; - constructor(context: T); - askYesNo(message: string): Promise<"yes" | "no">; - askString(title: string, key: string, placeholder: string, isPassword?: boolean): Promise; - askYesNoDialog(message: string, opt?: { - title?: string; - defaultOption?: "Yes" | "No"; - timeout?: number; - }): Promise<"yes" | "no">; - askSelectString(message: string, items: string[]): Promise; - askSelectStringDialogue(message: string, buttons: T, opt: { - title?: string; - defaultAction: T[number]; - timeout?: number; - }): Promise; - askInPopup(key: string, dialogText: string, anchorCallback: (anchor: HTMLAnchorElement) => void): void; - confirmWithMessage(title: string, contentMd: string, buttons: string[], defaultAction: (typeof buttons)[number], timeout?: number): Promise<(typeof buttons)[number] | false>; -} diff --git a/_types/src/modules/services/ObsidianDatabaseService.d.ts b/_types/src/modules/services/ObsidianDatabaseService.d.ts deleted file mode 100644 index 8c21fd72..00000000 --- a/_types/src/modules/services/ObsidianDatabaseService.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; -import { DatabaseService, type DatabaseServiceDependencies } from "@lib/services/base/DatabaseService.ts"; -export declare class ObsidianDatabaseService extends DatabaseService { - private __onOpenDatabase; - constructor(context: T, dependencies: DatabaseServiceDependencies); -} diff --git a/_types/src/modules/services/ObsidianPathService.d.ts b/_types/src/modules/services/ObsidianPathService.d.ts deleted file mode 100644 index f66e10ae..00000000 --- a/_types/src/modules/services/ObsidianPathService.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; -import { PathService } from "@lib/services/base/PathService"; -import { type BASE_IS_NEW, type TARGET_IS_NEW, type EVEN } from "@/common/utils"; -import type { UXFileInfo, AnyEntry, UXFileInfoStub, FilePathWithPrefix } from "@lib/common/types"; -export declare class ObsidianPathService extends PathService { - markChangesAreSame(old: UXFileInfo | AnyEntry | FilePathWithPrefix, newMtime: number, oldMtime: number): boolean | undefined; - unmarkChanges(file: AnyEntry | FilePathWithPrefix | UXFileInfoStub): void; - compareFileFreshness(baseFile: UXFileInfoStub | AnyEntry | undefined, checkTarget: UXFileInfo | AnyEntry | undefined): typeof BASE_IS_NEW | typeof TARGET_IS_NEW | typeof EVEN; - isMarkedAsSameChanges(file: UXFileInfoStub | AnyEntry | FilePathWithPrefix, mtimes: number[]): undefined | typeof EVEN; - protected normalizePath(path: string): string; -} diff --git a/_types/src/modules/services/ObsidianServiceHub.d.ts b/_types/src/modules/services/ObsidianServiceHub.d.ts deleted file mode 100644 index daca8c2e..00000000 --- a/_types/src/modules/services/ObsidianServiceHub.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { InjectableServiceHub } from "@lib/services/implements/injectable/InjectableServiceHub"; -import { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; -import type ObsidianLiveSyncPlugin from "@/main"; -export declare class ObsidianServiceHub extends InjectableServiceHub { - constructor(plugin: ObsidianLiveSyncPlugin); -} diff --git a/_types/src/modules/services/ObsidianServices.d.ts b/_types/src/modules/services/ObsidianServices.d.ts deleted file mode 100644 index 8444b6ac..00000000 --- a/_types/src/modules/services/ObsidianServices.d.ts +++ /dev/null @@ -1,36 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { InjectableConflictService } from "@lib/services/implements/injectable/InjectableConflictService"; -import { InjectableDatabaseEventService } from "@lib/services/implements/injectable/InjectableDatabaseEventService"; -import { InjectableFileProcessingService } from "@lib/services/implements/injectable/InjectableFileProcessingService"; -import { InjectableRemoteService } from "@lib/services/implements/injectable/InjectableRemoteService"; -import { InjectableReplicationService } from "@lib/services/implements/injectable/InjectableReplicationService"; -import { InjectableReplicatorService } from "@lib/services/implements/injectable/InjectableReplicatorService"; -import { InjectableTestService } from "@lib/services/implements/injectable/InjectableTestService"; -import { InjectableTweakValueService } from "@lib/services/implements/injectable/InjectableTweakValueService"; -import { ConfigServiceBrowserCompat } from "@lib/services/implements/browser/ConfigServiceBrowserCompat"; -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext.ts"; -import { KeyValueDBService } from "@lib/services/base/KeyValueDBService"; -import { ControlService } from "@lib/services/base/ControlService"; -export declare class ObsidianDatabaseEventService extends InjectableDatabaseEventService { -} -export declare class ObsidianReplicatorService extends InjectableReplicatorService { -} -export declare class ObsidianFileProcessingService extends InjectableFileProcessingService { -} -export declare class ObsidianReplicationService extends InjectableReplicationService { -} -export declare class ObsidianRemoteService extends InjectableRemoteService { -} -export declare class ObsidianConflictService extends InjectableConflictService { -} -export declare class ObsidianTweakValueService extends InjectableTweakValueService { -} -export declare class ObsidianTestService extends InjectableTestService { -} -export declare class ObsidianConfigService extends ConfigServiceBrowserCompat { -} -export declare class ObsidianKeyValueDBService extends KeyValueDBService { -} -export declare class ObsidianControlService extends ControlService { -} diff --git a/_types/src/modules/services/ObsidianSettingService.d.ts b/_types/src/modules/services/ObsidianSettingService.d.ts deleted file mode 100644 index e4954e60..00000000 --- a/_types/src/modules/services/ObsidianSettingService.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type ObsidianLiveSyncSettings } from "@lib/common/types"; -import { SettingService, type SettingServiceDependencies } from "@lib/services/base/SettingService"; -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; -export declare class ObsidianSettingService extends SettingService { - constructor(context: T, dependencies: SettingServiceDependencies); - protected setItem(key: string, value: string): void; - protected getItem(key: string): string; - protected deleteItem(key: string): void; - protected saveData(data: ObsidianLiveSyncSettings): Promise; - protected loadData(): Promise; -} diff --git a/_types/src/modules/services/ObsidianUIService.d.ts b/_types/src/modules/services/ObsidianUIService.d.ts deleted file mode 100644 index 98e3e7fc..00000000 --- a/_types/src/modules/services/ObsidianUIService.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ConfigService } from "@lib/services/base/ConfigService"; -import type { AppLifecycleService } from "@lib/services/base/AppLifecycleService"; -import type { ReplicatorService } from "@lib/services/base/ReplicatorService"; -import { UIService } from "@lib/services/implements/base/UIService"; -import { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; -import type { IAPIService, IControlService } from "@lib/services/base/IService"; -export type ObsidianUIServiceDependencies = { - appLifecycle: AppLifecycleService; - config: ConfigService; - replicator: ReplicatorService; - APIService: IAPIService; - control: IControlService; -}; -export declare class ObsidianUIService extends UIService { - get dialogToCopy(): import("svelte/legacy").LegacyComponentType; - constructor(context: ObsidianServiceContext, dependents: ObsidianUIServiceDependencies); -} diff --git a/_types/src/modules/services/ObsidianVaultService.d.ts b/_types/src/modules/services/ObsidianVaultService.d.ts deleted file mode 100644 index 96e76669..00000000 --- a/_types/src/modules/services/ObsidianVaultService.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { InjectableVaultService } from "@lib/services/implements/injectable/InjectableVaultService"; -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; -import type { FilePath } from "@lib/common/types"; -declare module "obsidian" { - interface DataAdapter { - insensitive?: boolean; - } -} -export declare class ObsidianVaultService extends InjectableVaultService { - vaultName(): string; - getActiveFilePath(): FilePath | undefined; - isStorageInsensitive(): boolean; - shouldCheckCaseInsensitively(): boolean; - isValidPath(path: string): boolean; -} diff --git a/_types/src/serviceFeatures/onLayoutReady/enablei18n.d.ts b/_types/src/serviceFeatures/onLayoutReady/enablei18n.d.ts deleted file mode 100644 index b6e48b58..00000000 --- a/_types/src/serviceFeatures/onLayoutReady/enablei18n.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -export declare const enableI18nFeature: import("@lib/interfaces/ServiceModule").ServiceFeatureFunction, keyof import("@lib/interfaces/ServiceModule").ServiceModules, Promise>; diff --git a/_types/src/serviceFeatures/redFlag.d.ts b/_types/src/serviceFeatures/redFlag.d.ts deleted file mode 100644 index 553afb0c..00000000 --- a/_types/src/serviceFeatures/redFlag.d.ts +++ /dev/null @@ -1,59 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; -import { type LogFunction } from "@lib/services/lib/logUtils"; -import type { ObsidianLiveSyncSettings } from "@lib/common/models/setting.type"; -/** - * Flag file handler interface, similar to target filter pattern. - */ -interface FlagFileHandler { - priority: number; - check: () => Promise; - handle: () => Promise; -} -export declare function isFlagFileExist(host: NecessaryServices, path: string): Promise; -export declare function deleteFlagFile(host: NecessaryServices, log: LogFunction, path: string): Promise; -/** - * Factory function to create a fetch all flag handler. - * All logic related to fetch all flag is encapsulated here. - */ -export declare function createFetchAllFlagHandler(host: NecessaryServices<"vault" | "fileProcessing" | "tweakValue" | "UI" | "setting" | "appLifecycle" | "path" | "keyValueDB" | "database", "storageAccess" | "rebuilder" | "fileHandler">, log: LogFunction): FlagFileHandler; -/** - * Adjust setting to remote configuration. - * @param config current configuration to retrieve remote preferred config - * @returns updated configuration if applied, otherwise null. - */ -export declare function adjustSettingToRemote(host: NecessaryServices<"tweakValue" | "UI" | "setting", never>, log: LogFunction, config: ObsidianLiveSyncSettings): Promise; -/** - * Adjust setting to remote if needed. - * @param extra result of dialogues that may contain preventFetchingConfig flag (e.g, from FetchEverything or RebuildEverything) - * @param config current configuration to retrieve remote preferred config - */ -export declare function adjustSettingToRemoteIfNeeded(host: NecessaryServices<"tweakValue" | "UI" | "setting", never>, log: LogFunction, extra: { - preventFetchingConfig: boolean; -}, config: ObsidianLiveSyncSettings): Promise; -/** - * Process vault initialisation with suspending file watching and sync. - * @param proc process to be executed during initialisation, should return true if can be continued, false if app is unable to continue the process. - * @param keepSuspending whether to keep suspending file watching after the process. - * @returns result of the process, or false if error occurs. - */ -export declare function processVaultInitialisation(host: NecessaryServices<"setting", never>, log: LogFunction, proc: () => Promise, keepSuspending?: boolean): Promise; -export declare function verifyAndUnlockSuspension(host: NecessaryServices<"setting" | "appLifecycle" | "UI", never>, log: LogFunction): Promise; -/** - * Factory function to create a rebuild flag handler. - * All logic related to rebuild flag is encapsulated here. - */ -export declare function createRebuildFlagHandler(host: NecessaryServices<"setting" | "appLifecycle" | "UI" | "tweakValue", "storageAccess" | "rebuilder">, log: LogFunction): { - priority: number; - check: () => Promise; - handle: () => Promise; -}; -/** - * Factory function to create a suspend all flag handler. - * All logic related to suspend flag is encapsulated here. - */ -export declare function createSuspendFlagHandler(host: NecessaryServices<"setting", "storageAccess">, log: LogFunction): FlagFileHandler; -export declare function flagHandlerToEventHandler(flagHandler: FlagFileHandler): () => Promise; -export declare function useRedFlagFeatures(host: NecessaryServices<"API" | "appLifecycle" | "UI" | "setting" | "tweakValue" | "fileProcessing" | "vault" | "path" | "keyValueDB" | "database", "storageAccess" | "rebuilder" | "fileHandler">): void; -export {}; diff --git a/_types/src/serviceFeatures/redFlag.simpleFetch.d.ts b/_types/src/serviceFeatures/redFlag.simpleFetch.d.ts deleted file mode 100644 index 8f43a4a2..00000000 --- a/_types/src/serviceFeatures/redFlag.simpleFetch.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; -import { type LogFunction } from "@lib/services/lib/logUtils"; -import { type FullScanOptions } from "@lib/serviceFeatures/offlineScanner"; -export declare const SIMPLE_FETCH_STAGE1_REMOTE_WINS = "Overwrite all with remote files"; -export declare const SIMPLE_FETCH_STAGE1_NEWER_WINS = "Compare time and take newer"; -export declare const SIMPLE_FETCH_STAGE1_LEGACY = "Use the detailed flow"; -export declare const SIMPLE_FETCH_STAGE1_CANCEL = "Cancel"; -export declare const SIMPLE_FETCH_STAGE2_REMOTE_DELETE_NONE = "Keep local files even if not on remote"; -export declare const SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL = "Delete local files if not on remote"; -export declare const SIMPLE_FETCH_STAGE2_NEWER_CLEANUP = "Delete local files if deleted on remote"; -export declare const SIMPLE_FETCH_STAGE2_NEWER_SYNC_ALL = "Keep local files even if deleted on remote"; -export declare const STAGE2_ABORT = "Cancel all and reboot"; -export declare function askSimpleFetchMode(host: NecessaryServices<"UI" | "setting", never>): Promise<{ - mode: string; - options: Partial; -} | "cancelled" | "aborted">; -export declare function askAndPerformFastSetupOnScheduledFetchAll(host: NecessaryServices<"vault" | "fileProcessing" | "tweakValue" | "UI" | "setting" | "appLifecycle" | "path" | "keyValueDB" | "database", "storageAccess" | "rebuilder" | "fileHandler">, log: LogFunction, cleanupFlag: () => Promise): Promise; diff --git a/_types/src/serviceFeatures/setupObsidian/setupManagerHandlers.d.ts b/_types/src/serviceFeatures/setupObsidian/setupManagerHandlers.d.ts deleted file mode 100644 index aa9cc4dc..00000000 --- a/_types/src/serviceFeatures/setupObsidian/setupManagerHandlers.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type SetupManager } from "@/modules/features/SetupManager"; -import type { SetupFeatureHost } from "@lib/serviceFeatures/setupObsidian/types"; -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; -export declare function openSetupURI(setupManager: SetupManager): Promise; -export declare function openP2PSettings(host: SetupFeatureHost, setupManager: SetupManager): Promise; -export declare function useSetupManagerHandlersFeature(host: NecessaryServices<"API" | "UI" | "setting" | "appLifecycle", never>, setupManager: SetupManager): void; diff --git a/_types/src/serviceFeatures/setupObsidian/setupProtocol.d.ts b/_types/src/serviceFeatures/setupObsidian/setupProtocol.d.ts deleted file mode 100644 index 840744eb..00000000 --- a/_types/src/serviceFeatures/setupObsidian/setupProtocol.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { LogFunction } from "@lib/services/lib/logUtils"; -import type { SetupFeatureHost } from "@lib/serviceFeatures/setupObsidian/types"; -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; -import { type SetupManager } from "@/modules/features/SetupManager"; -export declare function registerSetupProtocolHandler(host: SetupFeatureHost, log: LogFunction, setupManager: SetupManager): void; -export declare function useSetupProtocolFeature(host: NecessaryServices<"API" | "UI" | "setting" | "appLifecycle", never>, setupManager: SetupManager): void; diff --git a/_types/src/serviceFeatures/useP2PReplicatorUI.d.ts b/_types/src/serviceFeatures/useP2PReplicatorUI.d.ts deleted file mode 100644 index 3aceacf1..00000000 --- a/_types/src/serviceFeatures/useP2PReplicatorUI.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; -import { type UseP2PReplicatorResult } from "@lib/replication/trystero/UseP2PReplicatorResult"; -import { P2PLogCollector } from "@lib/replication/trystero/P2PLogCollector"; -import type { LiveSyncCore } from "@/main"; -/** - * ServiceFeature: P2P Replicator lifecycle management. - * Binds a LiveSyncTrysteroReplicator to the host's lifecycle events, - * following the same middleware style as useOfflineScanner. - * - * @param viewTypeAndFactory Optional [viewType, factory] pair for registering the P2P pane view. - * When provided, also registers commands and ribbon icon via services.API. - */ -export declare function useP2PReplicatorUI(host: NecessaryServices<"API" | "appLifecycle" | "setting" | "vault" | "database" | "databaseEvents" | "keyValueDB" | "replication" | "config" | "UI" | "replicator", never>, core: LiveSyncCore, replicator: UseP2PReplicatorResult): { - replicator: import("../lib/src/replication/trystero/LiveSyncTrysteroReplicator").LiveSyncTrysteroReplicator; - p2pLogCollector: P2PLogCollector; - storeP2PStatusLine: import("octagonal-wheels/dataobject/reactive_v2").ReactiveSource; -}; diff --git a/_types/src/serviceModules/DatabaseFileAccess.d.ts b/_types/src/serviceModules/DatabaseFileAccess.d.ts deleted file mode 100644 index da5a3c0f..00000000 --- a/_types/src/serviceModules/DatabaseFileAccess.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { DatabaseFileAccess } from "@lib/interfaces/DatabaseFileAccess.ts"; -import { ServiceDatabaseFileAccessBase } from "@lib/serviceModules/ServiceDatabaseFileAccessBase"; -export declare class ServiceDatabaseFileAccess extends ServiceDatabaseFileAccessBase implements DatabaseFileAccess { -} diff --git a/_types/src/serviceModules/FileAccessObsidian.d.ts b/_types/src/serviceModules/FileAccessObsidian.d.ts deleted file mode 100644 index 07f2eb05..00000000 --- a/_types/src/serviceModules/FileAccessObsidian.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type App } from "@/deps"; -import { FileAccessBase, type FileAccessBaseDependencies } from "@lib/serviceModules/FileAccessBase.ts"; -import { ObsidianFileSystemAdapter } from "./FileSystemAdapters/ObsidianFileSystemAdapter"; -/** - * Obsidian-specific implementation of FileAccessBase - * Uses ObsidianFileSystemAdapter for platform-specific operations - */ -export declare class FileAccessObsidian extends FileAccessBase { - constructor(app: App, dependencies: FileAccessBaseDependencies); -} diff --git a/_types/src/serviceModules/FileHandler.d.ts b/_types/src/serviceModules/FileHandler.d.ts deleted file mode 100644 index 77f11153..00000000 --- a/_types/src/serviceModules/FileHandler.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { ServiceFileHandlerBase } from "@lib/serviceModules/ServiceFileHandlerBase"; -export declare class ServiceFileHandler extends ServiceFileHandlerBase { -} diff --git a/_types/src/serviceModules/FileSystemAdapters/ObsidianConversionAdapter.d.ts b/_types/src/serviceModules/FileSystemAdapters/ObsidianConversionAdapter.d.ts deleted file mode 100644 index 5f564a36..00000000 --- a/_types/src/serviceModules/FileSystemAdapters/ObsidianConversionAdapter.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { UXFileInfoStub, UXFolderInfo } from "@lib/common/types"; -import type { IConversionAdapter } from "@lib/serviceModules/adapters"; -import type { TFile, TFolder } from "obsidian"; -/** - * Conversion adapter implementation for Obsidian - */ -export declare class ObsidianConversionAdapter implements IConversionAdapter { - nativeFileToUXFileInfoStub(file: TFile): UXFileInfoStub; - nativeFolderToUXFolder(folder: TFolder): UXFolderInfo; -} diff --git a/_types/src/serviceModules/FileSystemAdapters/ObsidianFileSystemAdapter.d.ts b/_types/src/serviceModules/FileSystemAdapters/ObsidianFileSystemAdapter.d.ts deleted file mode 100644 index e43e10fa..00000000 --- a/_types/src/serviceModules/FileSystemAdapters/ObsidianFileSystemAdapter.d.ts +++ /dev/null @@ -1,30 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { FilePath, UXStat } from "@lib/common/types"; -import type { IFileSystemAdapter, IPathAdapter, ITypeGuardAdapter, IConversionAdapter, IStorageAdapter, IVaultAdapter } from "@lib/serviceModules/adapters"; -import type { TAbstractFile, TFile, TFolder, Stat, App } from "obsidian"; -declare module "obsidian" { - interface Vault { - getAbstractFileByPathInsensitive(path: string): TAbstractFile | null; - } - interface DataAdapter { - reconcileInternalFile?(path: string): Promise; - } -} -/** - * Complete file system adapter implementation for Obsidian - */ -export declare class ObsidianFileSystemAdapter implements IFileSystemAdapter { - private app; - readonly path: IPathAdapter; - readonly typeGuard: ITypeGuardAdapter; - readonly conversion: IConversionAdapter; - readonly storage: IStorageAdapter; - readonly vault: IVaultAdapter; - constructor(app: App); - getAbstractFileByPath(path: FilePath | string): Promise; - getAbstractFileByPathInsensitive(path: FilePath | string): Promise; - getFiles(): Promise; - statFromNative(file: TFile): Promise; - reconcileInternalFile(path: string): Promise; -} diff --git a/_types/src/serviceModules/FileSystemAdapters/ObsidianPathAdapter.d.ts b/_types/src/serviceModules/FileSystemAdapters/ObsidianPathAdapter.d.ts deleted file mode 100644 index 0f9a7830..00000000 --- a/_types/src/serviceModules/FileSystemAdapters/ObsidianPathAdapter.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { type TAbstractFile } from "@/deps"; -import type { FilePath } from "@lib/common/types"; -import type { IPathAdapter } from "@lib/serviceModules/adapters"; -/** - * Path adapter implementation for Obsidian - */ -export declare class ObsidianPathAdapter implements IPathAdapter { - getPath(file: string | TAbstractFile): FilePath; - normalisePath(path: string): string; -} diff --git a/_types/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.d.ts b/_types/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.d.ts deleted file mode 100644 index 3463e334..00000000 --- a/_types/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { UXDataWriteOptions } from "@lib/common/types"; -import type { IStorageAdapter } from "@lib/serviceModules/adapters"; -import type { Stat, App } from "obsidian"; -/** - * Storage adapter implementation for Obsidian - */ -export declare class ObsidianStorageAdapter implements IStorageAdapter { - private app; - constructor(app: App); - exists(path: string): Promise; - trystat(path: string): Promise; - stat(path: string): Promise; - mkdir(path: string): Promise; - remove(path: string): Promise; - read(path: string): Promise; - readBinary(path: string): Promise; - write(path: string, data: string, options?: UXDataWriteOptions): Promise; - writeBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise; - append(path: string, data: string, options?: UXDataWriteOptions): Promise; - list(basePath: string): Promise<{ - files: string[]; - folders: string[]; - }>; -} diff --git a/_types/src/serviceModules/FileSystemAdapters/ObsidianTypeGuardAdapter.d.ts b/_types/src/serviceModules/FileSystemAdapters/ObsidianTypeGuardAdapter.d.ts deleted file mode 100644 index 9c78b2f1..00000000 --- a/_types/src/serviceModules/FileSystemAdapters/ObsidianTypeGuardAdapter.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { ITypeGuardAdapter } from "@lib/serviceModules/adapters"; -import { TFile, TFolder } from "obsidian"; -/** - * Type guard adapter implementation for Obsidian - */ -export declare class ObsidianTypeGuardAdapter implements ITypeGuardAdapter { - isFile(file: unknown): file is TFile; - isFolder(item: unknown): item is TFolder; -} diff --git a/_types/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.d.ts b/_types/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.d.ts deleted file mode 100644 index 90acfc81..00000000 --- a/_types/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { UXDataWriteOptions } from "@lib/common/types"; -import type { IVaultAdapter } from "@lib/serviceModules/adapters"; -import type { TFile, App, TFolder } from "obsidian"; -/** - * Vault adapter implementation for Obsidian - */ -export declare class ObsidianVaultAdapter implements IVaultAdapter { - private app; - constructor(app: App); - read(file: TFile): Promise; - cachedRead(file: TFile): Promise; - readBinary(file: TFile): Promise; - modify(file: TFile, data: string, options?: UXDataWriteOptions): Promise; - modifyBinary(file: TFile, data: ArrayBuffer, options?: UXDataWriteOptions): Promise; - create(path: string, data: string, options?: UXDataWriteOptions): Promise; - createBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise; - delete(file: TFile | TFolder, force?: boolean): Promise; - trash(file: TFile | TFolder, force?: boolean): Promise; - trigger(name: string, ...data: unknown[]): void; -} diff --git a/_types/src/serviceModules/ServiceFileAccessImpl.d.ts b/_types/src/serviceModules/ServiceFileAccessImpl.d.ts deleted file mode 100644 index 648f1c73..00000000 --- a/_types/src/serviceModules/ServiceFileAccessImpl.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import { ServiceFileAccessBase } from "@lib/serviceModules/ServiceFileAccessBase"; -import type { ObsidianFileSystemAdapter } from "./FileSystemAdapters/ObsidianFileSystemAdapter"; -export declare class ServiceFileAccessObsidian extends ServiceFileAccessBase { -} diff --git a/_types/src/types.d.ts b/_types/src/types.d.ts deleted file mode 100644 index 2f2e01de..00000000 --- a/_types/src/types.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: a58965f -import type { DatabaseFileAccess } from "@lib/interfaces/DatabaseFileAccess"; -import type { Rebuilder } from "@lib/interfaces/DatabaseRebuilder"; -import type { IFileHandler } from "@lib/interfaces/FileHandler"; -import type { StorageAccess } from "@lib/interfaces/StorageAccess"; -import type { IServiceHub } from "@lib/services/base/IService"; -export interface ServiceModules { - storageAccess: StorageAccess; - /** - * Database File Accessor for handling file operations related to the database, such as exporting the database, importing from a file, etc. - */ - databaseFileAccess: DatabaseFileAccess; - /** - * File Handler for handling file operations related to replication, such as resolving conflicts, applying changes from replication, etc. - */ - fileHandler: IFileHandler; - /** - * Rebuilder for handling database rebuilding operations. - */ - rebuilder: Rebuilder; -} -export interface LiveSyncHost { - services: IServiceHub; - serviceModules: ServiceModules; -} diff --git a/aggregator.html b/aggregator.html index c3c422bf..20125b19 100644 --- a/aggregator.html +++ b/aggregator.html @@ -33,7 +33,8 @@ const id = params.get('id'); const total = parseInt(params.get('n') || '0'); const index = parseInt(params.get('i') || '-1'); - const data = params.get('d'); + // Keep the chunk percent-encoded so URI delimiters remain part of the settings payload. + const data = hash.match(/(?:^|&)d=([^&]*)/)?.[1]; const app = document.getElementById('app'); diff --git a/devs.md b/devs.md index d066bc60..b6cb2eb0 100644 --- a/devs.md +++ b/devs.md @@ -10,22 +10,19 @@ Self-hosted LiveSync is an Obsidian plugin for synchronising vaults across devic #### First-time Setup -This repository uses submodules by convention. Therefore, you must use the `--recursive` flag when cloning it. - ```bash -git clone --recursive https://github.com/vrtmrz/obsidian-livesync +git clone https://github.com/vrtmrz/obsidian-livesync +cd obsidian-livesync npm ci npm run build ``` -Note: if you already cloned without submodules, run: `git submodule update --init --recursive` - #### Branch switching -When switching branches, please make sure to update submodules as well, since they may be updated in the new branch. +When switching branches, reinstall dependencies when the lockfile changes. ```bash -git checkout --recurse-submodules 0.25.70-patch1 # tag or branch name +git checkout 0.25.70-patch1 # tag or branch name npm ci npm run build ``` @@ -38,14 +35,17 @@ npm run check # TypeScript and svelte type checking npm run dev # Development build with auto-rebuild (uses .env for test vault paths) npm run build # Production build npm run buildDev # Development build (one-time) -npm run bakei18n # Pre-build step: compile i18n resources (YAML → JSON → TS) -npm run test:unit # Run unit tests only (no Docker services required) -npm test # Run Harness based vitest tests (requires Docker services), not recommended, unstable. +npm run test:integration # Run CouchDB-backed integration tests +npm run test:setup-tools # Check provisioning and Setup URI package contracts +npm run test:e2e:cli:p2p # Run canonical P2P validation in Compose +npm run test:browser-apps # Build and run the WebApp and WebPeer app-owned Chromium tests +npm run test:e2e:browser-apps:interop # Run WebApp → WebPeer → CLI in Compose +npm run test:e2e:obsidian:local-suite # Run the real Obsidian local suite ``` ### Tips -Use CLI E2E tests or real Obsidian E2E scripts instead of `npm test` when the behaviour can be verified outside the browser harness. +Select the narrowest unit, integration, CLI E2E, or real Obsidian E2E command that owns the behaviour being changed. The obsolete mocked browser Harness has been retired. ### Unreleased change notes @@ -62,22 +62,23 @@ To facilitate development and testing, the build process can automatically copy ### Testing Infrastructure -- ~~**Deno Tests**: Unit tests for platform-independent code (e.g., `HashManager.test.ts`)~~ - - This is now obsolete, migrated to vitest. - **Vitest**: - **Unit Tests** (`vitest.config.unit.ts`): Unit tests run in Node.js (excluding harnesses and integration tests). Unit tests should be `*.unit.spec.ts` and placed alongside the implementation file (e.g., `ChunkFetcher.unit.spec.ts`). Executed via `npm run test:unit`. - **Integration Tests** (`vitest.config.integration.ts`): Tests run in Node.js against a real CouchDB instance. Integration tests should be `*.integration.spec.ts` or `*.integration.test.ts` and placed alongside the implementation file (e.g., `StreamingFetch.integration.spec.ts`). Executed via `npm run test:integration`. - - If you add a feature that interacts with the remote database (e.g., replication changes, custom changes feed parameters, or custom HTTP queries), you strongly expected to write an integration test to verify the behaviour against a real CouchDB server. - - **Browser Harness Tests** (`vitest.config.ts`): Transitional browser-based harness tests using Playwright/Chromium. Executed via `npm run test`. This layer is no longer the preferred destination for new broad E2E coverage because `test/harness` can drift from real Obsidian behaviour. - - **P2P Tests** (`vitest.config.p2p.ts`): Browser-based Peer-to-Peer replication tests. Executed via `npm run test:p2p`. - - **RPC Unit Tests** (`vitest.config.rpc-unit.ts`): RPC-specific unit tests with coverage thresholds. + - If you add a feature that interacts with the remote database (e.g., replication changes, custom changes feed parameters, or custom HTTP queries), you are strongly expected to write an integration test to verify the behaviour against a real CouchDB server. + - **Commonlib Tests**: Commonlib owns unit and package tests for shared RPC, storage, replication, and platform contracts. LiveSync CI verifies the exact packed dependency as a downstream consumer. + +Regression tests remain in the suite owned by the implementation under test. Plug-in tests may be co-located with their source, while independent application tests remain under `test/apps/` or `test/browser-apps/` so that they stay outside the Community Review source boundary. Prefix a case or group with `compatibility:` when it protects a persisted input or state which current releases still accept, and with `retirement guard:` when it prevents a removed setting, control, or notification from returning. Remove or replace a compatibility case only when the corresponding input is no longer accepted or an equivalent maintained case preserves the contract. Remove a retirement guard only when another current contract makes the old behaviour unreachable. Do not preserve a disconnected historical test as an executable specification when no maintained runner invokes it; Git history is the reference for retired test infrastructure. + +- **CLI E2E** (`src/apps/cli/testdeno/`): Host-independent consumer workflows. The canonical Compose P2P suite covers ordinary two-peer synchronisation, replacement of the current replicator followed by transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. Its lifecycle entry point is included only in the Docker test build and does not add a public CLI command. Run `npm run test:e2e:cli` for the ordinary suite or `npm run test:e2e:cli:p2p` for P2P validation. +- **Self-hosted setup tools** (`utils/couchdb/`, `utils/setup/`, and `utils/flyio/`): Deno contract tests consume the exact locked Commonlib registry package, verify current CouchDB, Object Storage, and random-room P2P Setup URI defaults and remote profiles, and keep CouchDB administration separate from package-owned LiveSync database-version negotiation. `unit-ci` also provisions a real temporary CouchDB database and verifies its version document against the installed Commonlib package. Run `npm run test:setup-tools` for the local contract gate. - **Real Obsidian E2E** (`test/e2e-obsidian/`): Local-first scripts that launch real Obsidian with temporary vaults and the built Self-hosted LiveSync plug-in. Use these for boot-up sequence, vault reflection, RedFlag flows, Fast Setup (Simple Fetch), settings dialogues, restart-sensitive workflows, Object Storage regressions, and other behaviour that depends on Obsidian itself. Run focused scripts such as `npm run test:e2e:obsidian:two-vault-sync`, or use `npm run test:e2e:obsidian:local-suite:services` to run the broader local suite with CouchDB and MinIO fixtures managed by the wrapper. -- **Docker Services**: Tests require CouchDB, MinIO (S3), and P2P services: +- **Docker Services**: Service-backed tests use CouchDB and MinIO (S3). Canonical P2P validation owns its relay through the CLI Compose runner: ```bash npm run test:docker-all:start # Start all test services - npm run test:full # Run tests with coverage + npm run test:integration # Run the relevant service-backed suite npm run test:docker-all:stop # Stop services ``` @@ -86,35 +87,30 @@ To facilitate development and testing, the build process can automatically copy - **Test Structure**: - `test/e2e-obsidian/` - Real Obsidian E2E scripts for local verification - - `test/suite/` - Transitional browser harness tests for sync operations - - `test/unit/` - Unit tests (via vitest, as harness is browser-based) - - `test/harness/` - Transitional mock implementations (e.g., `obsidian-mock.ts`). Avoid adding broad new E2E coverage here unless it is explicitly a compatibility bridge. + - `test/apps/webapp/` and `test/apps/webpeer/` - app-owned unit tests outside the Community Review source boundary + - `test/browser-apps/webapp/` and `test/browser-apps/webpeer/` - app-owned Deno and Chromium tests outside the Community Review source boundary + - `test/browser-apps/` - Compose-owned WebApp → WebPeer → CLI P2P interoperability and shared browser-test support + - co-located `*.unit.spec.ts` files - Node-based unit tests + - co-located `*.integration.spec.ts` files - service-backed integration tests ### Import Path Normalisation -The codebase uses `@/` and `@lib/` path aliases to keep import structures clean. To normalise imports and exports across files, use the following utility script: +The codebase uses the `@/` alias for source owned by this repository. Commonlib imports use explicit `@vrtmrz/livesync-commonlib` package subpaths. To normalise LiveSync-owned imports and exports, use the following utility script: ```bash npm run pretty:importpath ``` -Under the hood, this runs Deno with the script [utilsdeno/normalise-imports.ts](file:///p:/plant25/obsidian/projects/obsidian-livesync/utilsdeno/normalise-imports.ts). You can pass additional flags to this script if required (by running it via Deno directly from the `utilsdeno` directory): +Under the hood, this runs Deno with the script [utilsdeno/normalise-imports.ts](utilsdeno/normalise-imports.ts). You can pass additional flags to this script if required (by running it via Deno directly from the `utilsdeno` directory): - `--run`: Applies the changes (the script runs in dry-run mode by default). - `--all-alias`: Normalises sibling/child relative imports starting with `./` to use aliases. -### Type Generation +### Commonlib dependency -To generate fallback type definitions for the shared library and add appropriate Deno ignore comments (which suppresses Deno compilation warnings and linting warnings inside the `_types` directory), run: +Shared synchronisation code is compiled and typed by the `@vrtmrz/livesync-commonlib` package. `npm ci` installs the exact artefact recorded by the lockfile; this repository does not compile Commonlib source or commit fallback declarations. -```bash -npm run build:lib:types -``` - -This script executes: - -1. TypeScript compilation (`tsconfig.types.json`) to output definitions to the `_types` directory. -2. The Deno script [utilsdeno/types-add-ignore.ts](file:///p:/plant25/obsidian/projects/obsidian-livesync/utilsdeno/types-add-ignore.ts) to prepend Deno ignore comments to the generated type files. +Changes spanning both repositories must first produce a packed Commonlib artefact which passes its standalone package checks. Install that exact artefact in LiveSync, then run the LiveSync type checks, unit tests, application builds, CLI E2E, and any focused real-Obsidian E2E required by the changed boundary. Replace the temporary artefact reference with the reviewed immutable package version before release. ## Architecture @@ -149,10 +145,12 @@ Hence, the new feature should be implemented as follows: ### Key Architectural Components -- **LiveSyncLocalDB** (`src/lib/src/pouchdb/`): Local PouchDB database wrapper -- **Replicators** (`src/lib/src/replication/`): CouchDB, Journal, and MinIO sync engines +- **LiveSyncLocalDB** (`@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB`): Local PouchDB database wrapper +- **Replicators** (`@vrtmrz/livesync-commonlib/compat/replication/*`): CouchDB, Journal, and P2P synchronisation engines - **Service Hub** (`src/modules/services/`): Central service registry using dependency injection -- **Common Library** (`src/lib/`): Platform-independent sync logic, shared with other tools +- **Common Library** (`@vrtmrz/livesync-commonlib`): Platform-independent synchronisation logic, shared with the CLI, WebApp, WebPeer, and external tools + +Commonlib owns the P2P replicator and Trystero transport lifecycle. Host commands, event handlers, and views must retain the Commonlib service-feature result and resolve its current `replicator` at the point of use. They must not snapshot an instance which can be replaced when settings or the local database change, close Trystero-owned raw peers, or install another Trystero transport generation at the application root. ### Conflict Merge Policy @@ -160,6 +158,10 @@ Markdown conflict auto-merge should behave like a conservative three-way merge. When in doubt, prefer the safer outcome: preserve data, keep the conflict visible, and ask the user rather than silently discarding content or choosing one side. +The detailed contract is documented in [Conflict resolution and revision provenance](docs/specs_conflict_resolution.md). Determine the merge base by intersecting the exact `available` revision IDs from both leaf histories and selecting the nearest shared revision. Do not infer ancestry from revision generation numbers. When a remote resolution reaches a Vault which still contains the exact content of a deleted losing branch, treat that content as known synchronised history so the resolution can be reflected without recreating the conflict. + +File operations made while a conflict is active must use the device-local file-reflection provenance injected into `ServiceFileHandlerBase`. Treat its exact revision as authoritative; use byte equality only to reconstruct a missing record when exactly one available revision matches. If branch identity remains unknown, preserve data and leave the conflict visible. Do not hide key-value database readiness behind an implicit wait: maintained hosts open it through the sequential settings lifecycle before file events or replication begin. + - If one side deletes a line and the other side leaves that same line unchanged, treat it as a safe deletion. The deleted line must not be reintroduced into the merged result. - If one side inserts new content in a different region while the other side deletes an unchanged old region, preserve the insertion and the deletion. - If one side deletes a line and the other side modifies that same line, keep the conflict for user resolution. @@ -172,15 +174,15 @@ This policy is intentionally aligned with the conflict checkboxes and compatibil - **Platform-specific code**: Use `.platform.ts` suffix (replaced with `.obsidian.ts` in production builds via esbuild) - **Development code**: Use `.dev.ts` suffix (replaced with `.prod.ts` in production) -- **Path aliases**: `@/*` maps to `src/*`, `@lib/*` maps to `src/lib/src/*` +- **Path aliases**: `@/*` maps to `src/*`; Commonlib uses package exports rather than a source alias ## Code Conventions ### Internationalisation (i18n) - **Translation Workflow**: - 1. Edit YAML files in `src/lib/src/common/messagesYAML/` (human-editable) - 2. Run `npm run bakei18n` to compile: YAML → JSON → TypeScript constants + 1. Edit the human-readable YAML files in this repository under `src/common/messagesYAML/` + 2. Run `npm run i18n:bake` to compile YAML → JSON → TypeScript constants 3. Use `$t()`, `$msg()` functions for translations You can also use `$f` for formatted messages with Tagged Template Literals. - **Usage**: @@ -189,13 +191,15 @@ This policy is intentionally aligned with the conflict checkboxes and compatibil $t("Some message"); // Direct translation $f`Hello, ${userName}`; // Formatted message ``` -- **Supported languages**: `def` (English), `de`, `es`, `ja`, `ko`, `ru`, `zh`, `zh-tw` +- **Supported languages**: `def` (English), `de`, `es`, `fr`, `he`, `ja`, `ko`, `ru`, `zh`, `zh-tw` + +Commonlib owns the typed English fallback for messages requested by its services. LiveSync owns the multilingual application catalogue and injects its translator into the Obsidian, CLI, and browser service compositions. Adding a Commonlib message therefore requires its canonical English definition in Commonlib; LiveSync may provide translations here, while an untranslated key falls back to Commonlib English. Importing a Commonlib language catalogue is not part of the boundary. ### File Path Handling - Use tagged types from `types.ts`: `FilePath`, `FilePathWithPrefix`, `DocumentID` - Prefix constants: `CHeader` (chunks), `ICHeader`/`ICHeaderEnd` (internal data) -- Path utilities in `src/lib/src/string_and_binary/path.ts`: `addPrefix()`, `stripAllPrefixes()`, `shouldBeIgnored()` +- Path utilities are supplied by the focused Commonlib compatibility path `@vrtmrz/livesync-commonlib/compat/string_and_binary/path` ### Logging & Debugging @@ -224,8 +228,8 @@ export class ModuleExample extends AbstractObsidianModule { ### Settings Management -- Settings defined in `src/lib/src/common/types.ts` (`ObsidianLiveSyncSettings`) -- Configuration metadata in `src/lib/src/common/settingConstants.ts` +- Settings are defined by Commonlib (`ObsidianLiveSyncSettings`) +- Configuration metadata is supplied by the Commonlib settings exports - Use `this.services.setting.saveSettingData()` instead of using plugin methods directly ### Database Operations @@ -239,24 +243,15 @@ export class ModuleExample extends AbstractObsidianModule { - [esbuild.config.mjs](esbuild.config.mjs) - Build configuration with platform/dev file replacement - [package.json](package.json) - Scripts reference and dependencies -## Beta Policy +## Pre-release Policy -- Beta versions are denoted by appending `-patchedN` to the base version number. - - `The base version` mostly corresponds to the stable release version. - - e.g., v0.25.41-patched1 is equivalent to v0.25.42-beta1. - - This notation is due to SemVer incompatibility of Obsidian's plugin system. - - Hence, this release is `0.25.41-patched1`. -- Each beta version may include larger changes, but bug fixes will often not be included. - - I think that in most cases, bug fixes will cause the stable releases. - - They will not be released per branch or backported; they will simply be released. - - Bug fixes for previous versions will be applied to the latest beta version. - This means, if xx.yy.02-patched1 exists and there is a defect in xx.yy.01, a fix is applied to xx.yy.02-patched1 and yields xx.yy.02-patched2. - If the fix is required immediately, it is released as xx.yy.02 (with xx.yy.01-patched1). - - This procedure remains unchanged from the current one. -- At the very least, I am using the latest beta. - - However, I will not be using a beta continuously for a week after it has been released. It is probably closer to an RC in nature. - -In short, the situation remains unchanged for me, but it means you all become a little safer. Thank you for your understanding! +- Use SemVer beta identifiers such as `1.0.0-beta.0` for immutable integration previews. Increment the beta number when a published preview needs a correction. Reserve `1.0.0-rc.0` for the first feature- and contract-frozen release candidate. Historical `-patchedN` releases remain unchanged in the release history. +- Publish a pre-release from an immutable reviewed tag, mark its GitHub Release as a pre-release, and do not replace the latest stable release. +- A plug-in review release may omit the CLI image when the CLI artefact is not part of the required validation. When a pre-release CLI image is published, it receives immutable version and SHA-qualified tags only; it must not advance `latest` or a stable major-minor tag. +- Keep a hyphenated pre-release's release pull request in draft and unmerged after BRAT validation. Reconcile the published version's metadata into its base branch through a reviewed metadata-only commit, then close the release pull request only through a separate maintainer action. +- Stage a stable version for BRAT by publishing its exact `x.y.z` tag initially as a GitHub pre-release with `prerelease=true` and `publish_cli=false`. The stable manifest version would otherwise make the CLI workflow advance `latest` and the major-minor image tag before validation. +- After a staged stable version passes BRAT validation, remove the GitHub pre-release designation and make the exact release the latest stable release first. Confirm that promotion, then merge its exact release commit into the reviewed base branch and integrate it through the reviewed branch chain into the repository's default branch. This order prevents Community Directory review from inspecting a stable manifest version while the matching GitHub Release remains a pre-release. Publish stable CLI tags through a separate maintainer gate. +- If validation fails, leave every published tag unchanged and prepare the next pre-release or patch version. ## Release Notes @@ -272,42 +267,48 @@ In short, the situation remains unchanged for me, but it means you all become a This workflow is for maintainers. Contributors should update `## Unreleased` for user-facing feature or fix PRs, but do not need to run the release workflows. The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `release` GitHub Environment. Configure Environment protection in the repository settings so tag creation and release publication require maintainer approval. -- Run the `Prepare Release PR` workflow with the target version. It creates the release branch, updates versions, regenerates the `_types` fallback definitions used by the community plug-in scan, moves the `## Unreleased` notes to the target version, commits the release preparation, pushes the branch, and opens a draft release PR. +- Run the `Prepare Release PR` workflow with the target version and selected base branch. It creates the release branch, updates versions, confirms that Commonlib is locked to an immutable package version, moves the `## Unreleased` notes to the target version, commits the release preparation, pushes the branch, and opens a draft release PR. The base branch may already select the target development version; the workflow still runs the version lifecycle so that release-only metadata such as `versions.json` is recorded in the release commit. - Do not tag the release branch when the PR is first created. Polish the release PR first, especially `updates.md`. -- Once the release PR head is fixed, run the `Finalise Release Tags` workflow with its full head commit SHA. It validates the release branch, pushes both the plug-in tag (for example, `0.25.81`) and the CLI tag (for example, `0.25.81-cli`) to that commit, and explicitly dispatches the plug-in and CLI publishing workflows. An explicit dispatch is required because tags pushed with `GITHUB_TOKEN` do not trigger tag-push workflows. -- Approve the `Release Obsidian Plugin` workflow for the `release` environment, then inspect the generated draft GitHub Release. Confirm that the CLI workflow has published the fixed version tag, the major-minor moving tag (for example, `0.25-cli`), `latest`, and the SHA-qualified tag. -- Publish the draft GitHub Release as the latest stable release while keeping the release PR in draft and leaving `main` unchanged. Record the state in the PR with: 'Release `` has been published as the latest stable release. This pull request intentionally remains in draft, and `main` has not yet been updated. Merge is on hold until BRAT validation is complete.' +- Once the release PR head is fixed, run the `Finalise Release Tags` workflow with its full head commit SHA. It validates the release branch, ensures that the plug-in tag points to that commit, optionally creates the corresponding CLI tag, and explicitly dispatches the selected plug-in and CLI release workflows. The finalisation workflow can be retried when existing tags already point to the reviewed commit, but stops if a selected tag points elsewhere. +- The plug-in publishing workflow is intentionally dispatch-only. Pushing a plug-in tag directly does not publish a GitHub Release; use `Finalise Release Tags`, or dispatch `Release Obsidian Plugin` explicitly for recovery or a pre-release. When CLI publication is selected, finalisation dispatches the CLI Docker workflow against the reviewed CLI tag instead of relying on a tag created by `GITHUB_TOKEN` to start another workflow. +- For a hyphenated pre-release, run finalisation with `prerelease=true`; CLI publication remains optional. For a stable version awaiting BRAT validation, use `prerelease=true` and `publish_cli=false`. +- Approve the `Release Obsidian Plugin` workflow for the `release` environment, then inspect the generated draft GitHub Release. When a hyphenated pre-release includes the CLI, confirm that it received only its immutable version and SHA-qualified image tags. +- Publish the draft as a GitHub pre-release without replacing the latest stable release. Keep its release pull request in draft and leave its base branch unchanged throughout BRAT validation. Record that state in the pull request. - Validate the published release through BRAT. Confirm start-up, ordinary bidirectional synchronisation, and any regression scenario relevant to the release. -- After BRAT validation succeeds, mark the release PR ready and merge it with a merge commit. This keeps the tagged release commit in the `main` history. -- If BRAT validation fails, keep the release PR in draft. Do not move published tags; prepare and publish a new patch release instead. -- If a pre-release is needed, run the `Release Obsidian Plugin` workflow manually with the target tag, `draft=false`, and `prerelease=true`. +- After a hyphenated pre-release passes, keep its release pull request unmerged. Add a reviewed metadata-only commit to the selected base branch which records the published version in `versions.json` and moves its exact tagged release notes out of `## Unreleased`, then close the release pull request only through a separate maintainer action. +- After a stable version passes, remove the GitHub pre-release designation and make that exact release the latest stable release. Confirm that the release is no longer a pre-release, then mark its release pull request ready, merge the exact release commit into the selected base branch with a merge commit, and integrate that exact commit through the reviewed branch chain into the repository's default branch. Confirm that the default branch contains the matching stable metadata. Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate. +- If BRAT validation fails, keep the release PR in draft and do not move published tags. Before preparing the next version, add a reviewed metadata-only commit to the selected base branch which records the published version in `versions.json` and moves its exact tagged release notes out of `## Unreleased`. Keep only changes made after that tag under `## Unreleased`. Compare the historical section with `git show :updates.md`; do not merge the failed release PR or describe it as validated. The next release PR can then rotate only the correction notes while preserving the immutable release history. +- Prepare and publish the next patch or pre-release version from that reconciled base. Leave the failed release PR draft until it is deliberately closed as superseded under a separate maintainer action. +- A hyphenated version is rejected unless `prerelease=true`. A stable version staged with `prerelease=true` is rejected unless `publish_cli=false`. ### Release Cheat Sheet 1. Before starting, add user-facing notes under `## Unreleased` in `updates.md`. 2. Run `Prepare Release PR` from GitHub Actions. - `version`: the target version, for example `0.25.81`. - - `base_branch`: normally `main`. + - `base_branch`: normally `main`, or the reviewed integration branch for an integration preview. - `release_branch`: leave blank to use the default branch name, for example `0_25_81`. - `release_date`: use an ordinal date such as `14th July, 2026`, or leave blank to use the current UTC date. - `allow_empty_updates`: leave disabled unless the release intentionally has no user-facing notes. 3. Review the generated draft PR. - Polish `updates.md`. - - Confirm `package.json`, `manifest.json`, `versions.json`, workspace package versions, and the generated `_types` definitions. + - Confirm `package.json`, `manifest.json`, `versions.json`, workspace package versions, and the locked Commonlib package version. - Confirm that `manifest.json` has the intended `minAppVersion`. - Wait for the necessary CI checks. 4. When the PR head is fixed, run `Finalise Release Tags`. - `version`: the same target version. - `release_branch`: leave blank unless the release branch used a custom name. - `expected_head_sha`: the full head commit SHA reviewed in the release PR. + - `prerelease`: enable for a version such as `1.0.0-rc.0`, and also when staging a stable version for BRAT. + - `publish_cli`: optional for a hyphenated pre-release, but disable it when staging a stable version. 5. Approve the `Release Obsidian Plugin` workflow for the `release` environment, then check the generated draft GitHub Release. -6. Confirm that the explicitly dispatched CLI Docker workflow has published the expected image tags. -7. Publish the draft GitHub Release as the latest stable release, but keep the release PR in draft and leave `main` unchanged. -8. Update the PR state message to say that the release is now the latest stable release and that merging is intentionally on hold until BRAT validation is complete. +6. If a hyphenated pre-release includes the CLI, confirm that the explicitly dispatched CLI workflow published only immutable version and SHA-qualified image tags. +7. Publish the draft as a GitHub pre-release without replacing the latest stable release, but keep the release PR in draft and leave its base branch unchanged. +8. Update the PR state message to describe the published pre-release and state that merging remains on hold. 9. Validate the published release through BRAT, including start-up, ordinary bidirectional synchronisation, and any release-specific regression scenario. -10. After BRAT validation succeeds, mark the release PR ready and merge it into `main` with a merge commit. -11. If validation fails, leave the PR in draft and prepare a new patch release without moving the published tags. -12. If the release should be a pre-release instead of a draft release, run `Release Obsidian Plugin` manually with the target `tag`, `draft=false`, and `prerelease=true`. +10. After a hyphenated pre-release passes, keep its release PR unmerged, reconcile its `versions.json` entry and exact release-note section into the selected base branch as metadata only, then close the PR through a separate maintainer action. +11. After a stable version passes, remove the pre-release designation and make the exact release the latest stable release. Confirm that promotion, then mark the release PR ready, merge the exact release commit into the selected base branch, and integrate it through the reviewed branch chain into the repository's default branch. Confirm that the default branch contains the matching stable metadata, then publish stable CLI tags through a separate maintainer gate if selected. +12. If validation fails, leave the PR in draft and do not move the published tags. Reconcile the published version's `updates.md` section and `versions.json` entry into the base branch as metadata only, then prepare the next patch or pre-release version from the remaining `## Unreleased` entries. ## Contribution Guidelines diff --git a/docs/adding_translations.md b/docs/adding_translations.md index 4aa595e2..78732358 100644 --- a/docs/adding_translations.md +++ b/docs/adding_translations.md @@ -1,34 +1,49 @@ # How to add translations +Self-hosted LiveSync owns its multilingual catalogue. Commonlib supplies only the typed English messages which its services request; the plug-in combines those keys with its application messages and injects the selected translator at each service composition root. + ## Getting ready -1. Clone this repository recursively. -```sh -git clone --recursive https://github.com/vrtmrz/obsidian-livesync -``` -2. Make `ls-debug` folder under your vault's `.obsidian` folder (as like `.../dev/.obsidian/ls-debug`). +1. Clone this repository. -## Add translations for already defined terms + ```sh + git clone https://github.com/vrtmrz/obsidian-livesync + cd obsidian-livesync + npm ci + ``` -1. Install dependencies, and build the plug-in as dev. build. -```sh -cd obsidian-livesync -npm i -D -npm run buildDev -``` +2. Create an `ls-debug` directory below the test Vault's `.obsidian` directory, for example `.obsidian/ls-debug`. -2. Copy the `main.js` to `.obsidian/plugins/obsidian-livesync` folder of your vault, and run Obsidian-Self-hosted LiveSync. -3. You will get the `missing-translation-yyyy-mm-dd.jsonl`, please fill in new translations. -4. Build the plug-in again, and confirm that displayed things were expected. -5. Merge them into `rosetta.ts`, and make the PR to `https://github.com/vrtmrz/livesync-commonlib`. +## Add translations for existing messages -## Make messages to be translated +1. Edit the human-readable YAML files under `src/common/messagesYAML/`. +2. Regenerate the JSON and TypeScript resources. -1. Find the message that you want to be translated. -2. Change the literal to use `$tf`, like below. -```diff -- Logger("Could not determine passphrase to save data.json! You probably make the configuration sure again!", LOG_LEVEL_URGENT); -+ Logger($tf('someKeyForPassphraseError'), LOG_LEVEL_URGENT); -``` -3. Make the PR to `https://github.com/vrtmrz/obsidian-livesync`. -4. Follow the steps of "Add translations for already defined terms" to add the translations. \ No newline at end of file + ```sh + npm run i18n:bake + ``` + +3. Build the plug-in in development mode, install it in the test Vault, and run Self-hosted LiveSync. +4. Review any `missing-translation-yyyy-mm-dd.jsonl` file written below `.obsidian/ls-debug`, and add the required translations to the YAML catalogue. +5. Bake and build again, then confirm the displayed text and placeholder substitution in the relevant workflow. + +Commit the edited YAML and all regenerated JSON and TypeScript resources together. + +## Make a message translatable + +LiveSync-owned messages may first be added to +`src/common/messages/LiveSyncProvisionalMessages.ts` while their wording is being +exercised. This keeps an application-only message out of Commonlib and provides a +typed English fallback without requiring contributors to update every language. + +When the wording is ready for translation: + +1. Move its canonical English entry from + `src/common/messages/LiveSyncProvisionalMessages.ts` to + `src/common/messagesYAML/en.yaml`. Remove the provisional entry in the same + change. Translations in the other LiveSync YAML files may follow as contributor + updates. +2. Replace the source literal with `$msg()` or another existing translation helper, using the English catalogue key as the typed contract. +3. Run `npm run i18n:bake`, build the plug-in, and verify the affected workflow. + +When a new message belongs to Commonlib rather than the application, add its canonical English definition and key type in Commonlib first. Add any available translations to LiveSync when consuming that package; untranslated languages use Commonlib's canonical English fallback. diff --git a/docs/adr/2026_06_real_obsidian_e2e.md b/docs/adr/2026_06_real_obsidian_e2e.md index 863b7568..cade7701 100644 --- a/docs/adr/2026_06_real_obsidian_e2e.md +++ b/docs/adr/2026_06_real_obsidian_e2e.md @@ -2,15 +2,15 @@ ## Status -Proposed / Spike Implemented +Accepted / Implemented ## Release -Not yet. Planned after the serviceFeature refactoring branch is reviewed. +Accepted — implemented as the maintained local E2E layer for the 1.0 release preparation. ## Context -The current end-to-end tests run through Vitest browser mode and a mocked Obsidian environment in `test/harness`. This has been useful for exercising synchronisation flows without launching Obsidian, but it is no longer a reliable final signal for plug-in behaviour. +When this decision was proposed, the end-to-end tests ran through Vitest browser mode and a mocked Obsidian environment in `test/harness`. This was useful for exercising synchronisation flows without launching Obsidian, but it was no longer a reliable final signal for plug-in behaviour. The main issues are: @@ -31,7 +31,7 @@ The long-term test pyramid should be: 2. Integration tests for CouchDB, Object Storage, P2P services, database operations, and replication protocols. 3. Real Obsidian E2E tests for boot-up sequence, vault reflection, command registration, settings dialogues, restart scheduling, and user-visible workflows. -The existing `test/harness` should be demoted to a transitional compatibility layer. It may remain temporarily while the real Obsidian runner reaches parity for critical flows, but new high-level E2E coverage should target the real runner. +Retain the existing browser Harness only as a transitional compatibility layer while the real Obsidian runner reaches parity for critical flows, then remove it. New high-level E2E coverage targets the real runner. ## Non-Goals @@ -143,19 +143,19 @@ Current verification: - `npm run test:e2e:obsidian:couchdb-upload` configures a unique CouchDB database, creates a note through Obsidian, commits it into the local database, runs one-shot synchronisation, and verifies that CouchDB contains the metadata document and all referenced chunk documents. - `npm run test:e2e:obsidian:minio-upload` configures a unique Object Storage bucket prefix, creates a note through Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written to the S3-compatible bucket. - `npm run test:e2e:obsidian:startup-scan` verifies that a file written while Obsidian is stopped is picked up during the next real Obsidian boot and uploaded to CouchDB after one-shot synchronisation. -- `npm run test:e2e:obsidian:two-vault-sync` verifies two-vault note synchronisation: creation, update, rename, deletion, per-device target-filter differences, and a separate encrypted round-trip with Path Obfuscation enabled. The experimental Markdown conflict automatic merge check is available with `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true` but is not part of the default local suite. +- `npm run test:e2e:obsidian:two-vault-sync` verifies two-vault note synchronisation: creation, update, rename, deletion, per-device target-filter differences, and a separate encrypted round-trip with Path Obfuscation enabled. The optional Markdown conflict check creates divergent branches in two real Vaults, conservatively merges them, edits the merged result again, and verifies that the Vault holding the deleted losing revision accepts the propagated result without recreating a conflict. Enable it with `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true`. A separate `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` scope keeps four conflicts active while a Vault edits, deletes, case-renames, and cross-path-renames files, then verifies exact parent revisions and replicated trees. Neither scope is part of the default local suite. - `npm run test:e2e:obsidian:hidden-file-snippet-sync` verifies hidden file synchronisation as a two-vault round-trip: creation, deletion, automatic JSON conflict merging with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target-pattern differences. - `npm run test:e2e:obsidian:customisation-sync` verifies a two-vault Customisation Sync workflow: scan a real snippet CSS file, config JSON file, and sample plug-in fixture into per-file Customisation Sync data, synchronise them through CouchDB, apply them on the second vault, assert the resulting `.obsidian` files, propagate a snippet update, and verify deletion of the source-vault snippet sync data without confusing it with the target vault's own applied copy. - `npm run test:e2e:obsidian:setting-markdown-export` verifies that setting Markdown export creates a vault file and omits credentials when credential export is disabled. - `npm run test:e2e:obsidian:install-appimage` reuses the existing AppImage and extracted binary when they are already present. -- `npm run test:e2e:obsidian:local-suite` runs the local verification sequence for the real Obsidian runner after CouchDB and MinIO have been started. -- `npm run test:e2e:obsidian:local-suite:services` stops leftover CouchDB and MinIO fixtures, starts fresh fixtures, runs the local suite, and stops the fixtures again. -- `npm run test:e2e:obsidian:local-suite:services` has been verified locally with real Obsidian, CouchDB, and MinIO. The run completed discovery, smoke, vault reflection, CouchDB upload, Object Storage upload, startup scan, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export. The build step still emits existing Svelte warnings. +- `npm run test:e2e:obsidian:local-suite` runs the local verification sequence for the real Obsidian runner after CouchDB, MinIO, and the P2P relay have been started. +- `npm run test:e2e:obsidian:local-suite:services` stops leftover CouchDB, MinIO, and P2P relay fixtures, starts fresh fixtures, runs the local suite, and stops the fixtures again. +- `npm run test:e2e:obsidian:local-suite:services` has been verified locally with real Obsidian and all three fixtures. In addition to the core launch and synchronisation scenarios, the maintained suite covers CouchDB, Object Storage, and P2P Setup URI workflows in which the working first device generates the URI imported by the second device. Known limits: - The smoke runner currently proves only one-vault launch and plug-in load readiness. Broader workflows are covered by separate real Obsidian scripts, including CouchDB upload, startup scan, two-vault note synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export. -- Cross-platform support is still discovery-level. The working path has been validated on Linux ARM64. macOS and Windows should be validated in their own environments as follow-up work. +- The working path has been validated on Linux ARM64 and on macOS with Obsidian 1.12.7. Windows remains unverified. - CI wiring is intentionally not implemented. The runner depends on a licensed desktop application and is treated as a local verification tool. ### Phase 2: First Real Workflow @@ -186,7 +186,7 @@ Current implementation status: Current implementation status: -- `test:e2e:obsidian:two-vault-sync` covers creation, update, rename, deletion, and per-device target-filter behaviour for a non-encrypted CouchDB configuration. Markdown conflict automatic merging remains an optional check because it needs a dedicated, less timing-sensitive fixture. +- `test:e2e:obsidian:two-vault-sync` covers creation, update, rename, deletion, and per-device target-filter behaviour for a non-encrypted CouchDB configuration. Its optional conflict fixtures cover real two-Vault divergence, conservative merge, post-resolution editing, propagation to a Vault whose file still contains the deleted losing revision, and edit, logical deletion, case-only rename, and cross-path rename while conflicts remain active. - The same script creates a separate temporary CouchDB database and temporary vault pair for an encrypted two-vault round-trip with Path Obfuscation enabled. ### Phase 4: Harness Retirement @@ -199,17 +199,18 @@ Current implementation status: Current implementation status: -- `test/harness` is now documented as a transitional compatibility layer. -- New broad E2E work should target `test/e2e-obsidian/` when real Obsidian behaviour is the risk being tested. -- The next high-value scenarios are RedFlag variants and Fast Setup (Simple Fetch) variants, not a line-by-line migration of `test/suite`. +- The mocked Vitest browser suites, their P2P runner, their root-level relay helpers, and the manual `harness-ci` workflow have been removed after maintained suites covered the critical flows. +- Headless CouchDB and Object Storage combinations, with and without encryption, remain owned by the CLI two-Vault matrix. P2P transport replacement and relay lifecycle remain owned by the CLI Compose E2E suite. Real Obsidian owns the visible CouchDB, Object Storage, and P2P Setup URI workflows, including URI generation on the first device, import on the second device, and two-way Vault synchronisation. +- The Obsidian compatibility implementation still needed by the Webapp has moved to `src/apps/webapp/obsidianMock.ts`; it is not a retained browser E2E Harness. +- Remaining high-value scenarios, including RedFlag and Fast Setup (Simple Fetch) variants, should be added according to their owning integration boundary rather than copied line by line from the retired suite. ## Local Verification Strategy Real Obsidian E2E is a local verification layer. It should not be wired into the default CI gate. - Keep the scripts individually runnable for focused local debugging. -- Provide `test:e2e:obsidian:local-suite` for a broader local pass after the CouchDB and MinIO fixtures have been started. -- Provide `test:e2e:obsidian:local-suite:services` for a broader local pass that manages the CouchDB and MinIO fixtures itself. +- Provide `test:e2e:obsidian:local-suite` for a broader local pass after the CouchDB, MinIO, and P2P relay fixtures have been started. +- Provide `test:e2e:obsidian:local-suite:services` for a broader local pass that manages all three fixtures itself. - Use `OBSIDIAN_BINARY` when testing against an installed desktop application. - Use `test:e2e:obsidian:install-appimage` on Linux when a local AppImage copy is needed, and reuse the extracted `_testdata/obsidian/squashfs-root` directory between local runs. - Capture Obsidian logs, plug-in logs, vault snapshots, and service logs manually when investigating failures. @@ -240,12 +241,12 @@ Real Obsidian E2E is a local verification layer. It should not be wired into the 4. Add `test:e2e:obsidian:smoke` for one-vault plug-in load. 5. Document required local environment variables, especially `OBSIDIAN_BINARY`. 6. Port one CouchDB-backed workflow after the smoke test is stable. -7. Mark `test/harness` as transitional and block new broad E2E work from targeting it. +7. Retire the browser Harness after critical workflows have replacement coverage. 8. Add the local suite script for broader local verification. ## Consequences - Real Obsidian E2E becomes the source of truth for plug-in lifecycle and vault integration. - Unit and integration tests remain the primary fast feedback loops. -- The old browser harness can be deleted once the new runner covers the critical workflows. +- The old browser Harness has been deleted now that the replacement coverage owns its critical workflows. - The project will gain slower but higher-confidence tests for the behaviours most likely to differ between mocks and Obsidian itself. diff --git a/docs/adr/2026_07_bounded_remote_activity.md b/docs/adr/2026_07_bounded_remote_activity.md index 386e1393..07fcf60e 100644 --- a/docs/adr/2026_07_bounded_remote_activity.md +++ b/docs/adr/2026_07_bounded_remote_activity.md @@ -31,13 +31,19 @@ The boundary has the following contract: - allow overlapping tasks, so transitions may be `0 → 1 → 2 → 1 → 0`; and - count logical operations, not physical connections, sockets, HTTP requests, queued work, or retry delays outside the bounded task. +`ReplicatorService` also owns a narrower `finiteReplicationActivityCount`. Callers enter it through the typed `runFiniteReplicationActivity` method; diagnostic labels do not determine behaviour. The narrower count describes operations which may still place replicated documents in the local database; it excludes rebuilds, chunk-fetch claims, and other bounded work which cannot satisfy an arbitrary missing-chunk read. It is a delivery-lifecycle capability, not a second Wake Lock policy or a connection counter. + The Obsidian host injects the screen wake-lock manager from the `octagonal-wheels` package in the Fancy Kit monorepo as the activity runner on both mobile and desktop. Unsupported or rejected wake-lock requests remain best effort and do not prevent the remote task from running. The manager is disposed when the plug-in unloads. -Finite replication enters the boundary only after readiness checks have succeeded and leaves it after `openReplication(..., continuous: false, ...)` settles. Failure handling runs afterwards so a mismatch or recovery dialogue does not retain the activity. This includes the direct start-up synchronisation path as well as manual, event-driven, and periodic calls through `ReplicationService`. Continuous replication does not enter the boundary. +Finite replication enters both counts only after readiness checks have succeeded and leaves them after `openReplication(..., continuous: false, ...)` settles. A successful completion has reached the latest sequence in that operation's scope and is therefore an authoritative quiescence boundary for chunk retrieval. A failed operation does not prove latest state, but can no longer deliver documents from that attempt. Failure handling runs afterwards so a mismatch or recovery dialogue does not retain the activity. This includes the direct start-up synchronisation path as well as manual, event-driven, and periodic calls through `ReplicationService`. The unbounded continuous channel does not enter either boundary, but its finite initial pull-only catch-up does; the one-shot parameter fallback chain remains inside that boundary. -Manual P2P commands which bypass `ReplicationService` enter the same boundary. Direct P2P pull and push entry points also create finite transfer activities, covering the Obsidian panes, CLI, and Webapp. Automatic synchronisation on peer discovery, a pull requested by a remote peer, and a watched pull following a peer progress notification enter the same boundary. A normal P2P peer-selection dialogue represents one finite session: it remains inside the boundary while waiting for a peer and while the person may perform repeated synchronisations, then settles when the dialogue closes and any in-flight synchronisation has finished. Closing without synchronising returns a failed result and releases the boundary. The 'Start Sync & Close' action completes its synchronisation before closing. This deliberately protects peer discovery and selection, because display sleep can interrupt discovery or connection establishment and require the person to start detection again. It may therefore retain a Wake Lock longer than the network transfer alone. A transfer performed inside that session temporarily adds a nested activity; the count remains a logical-operation count rather than a connection total. +Delivery into the local database and application to the Obsidian Vault are deliberately separate lifetimes. Finite remote activity ends when the transfer operation settles, even when the `đŸ“Ĩ` queue still contains documents awaiting Vault application. The Obsidian host tracks that queue through a separate `boundedLocalApplicationActivityCount`, so local Vault writes do not increment either remote-activity count or keep the `📲` indicator visible. -`ChunkFetcher` enters the same boundary only around the actual `fetchRemoteChunks` request. Queue waiting, interval throttling, and local chunk validation or persistence remain outside the remote activity scope. +Applying downloaded document changes enters one local-application boundary from the first queued document until the queue and its in-progress set are both empty. The final empty recovery snapshot is attempted before the boundary settles; snapshot failure is logged and still releases the boundary. Additional batches share the existing boundary. Suspending replication-result processing releases it, and resuming reacquires it while work remains. The same host activity runner supplies best-effort Wake Lock protection, while the visibility lifecycle waits for both remote and local bounded activity to finish. + +Manual P2P commands which bypass `ReplicationService` enter the broad boundary. Direct P2P pull and push entry points are therefore both protected as finite remote work, covering the Obsidian panes, CLI, and Webapp. A pull or bidirectional synchronisation also enters the narrower finite-replication boundary because it can place documents in the local database. A push-only request remains broad-only: it cannot satisfy a local missing-chunk read and must not present itself as a delivery source. Automatic synchronisation on peer discovery, a pull requested by a remote peer, and a watched pull following a peer progress notification enter both boundaries because each can deliver local documents. A normal P2P peer-selection dialogue represents one broad finite session: it remains inside the boundary while waiting for a peer and while the person may perform repeated synchronisations, then settles when the dialogue closes and any in-flight synchronisation has finished. Closing without synchronising returns a failed result and releases the boundary. The 'Start Sync & Close' action completes its synchronisation before closing. This deliberately protects peer discovery and selection, because display sleep can interrupt discovery or connection establishment and require the person to start detection again. It may therefore retain a Wake Lock longer than the network transfer alone. A transfer performed inside that session temporarily adds a nested activity; the count remains a logical-operation count rather than a connection total. + +`ChunkFetcher` enters the broad boundary synchronously when it accepts newly missing chunk identifiers, but it does not increment the finite-replication count. A typed per-identifier claim keeps the broad boundary active through queue waiting, interval throttling, `fetchRemoteChunks`, validation, local persistence, and terminal event delivery. Duplicate requests share the existing claim. Explicit absence, failure, cancellation, or a conservative five-minute period without fetcher-observable progress settles the affected claim. The five-minute value is only a last-resort leak fuse: it prevents a never-settling integration from retaining the per-identifier claim and waiter indefinitely and, once the activity runner has entered the claim task, lets the associated Wake Lock, lifecycle deferral, and indicator finish. It is not an arrival estimate, proof of remote absence, or a transport deadline. This scope is defined in detail by the chunk-arrival-quiescence ADR. Rebuild operations use the same boundary at their destructive or remote phase: @@ -56,40 +62,54 @@ Fetch rebuilds temporarily suspend file watching. Their visibility event still r If the desktop background setting applies, its existing continuous or periodic policy remains authoritative. When a Desktop LiveSync window becomes visible during bounded activity, the normal continuous-channel teardown and resume sequence is also deferred until that activity ends, so recovery does not abort the finite operation. -The status-bar remote-work indicator is shown when either the existing HTTP request balance is non-zero or the bounded remote activity count is non-zero. This preserves coverage from the existing counters while adding finite replication, peer waiting, and remote chunk fetching. The combined icon reports broader remote work, not an exact physical connection state; connection-level telemetry remains a separate concern. +The status bar separates the two meanings. `📲` is shown while the bounded remote activity count is non-zero. It therefore reports a finite logical operation, including periods such as P2P peer selection or chunk-fetch queueing when no request is currently crossing the network. An adjacent `🌐N` reports the approximate number of tracked physical-request units currently in progress. The icon values and the physical indicator's 150 ms minimum display time are named constants so their presentation can be revised without changing the activity contract. + +Each physical HTTP attempt owns one balanced counter pair. The request counter is incremented immediately before invoking the selected fetch implementation, and the response counter is incremented in `finally`, whether the attempt returns or rejects. A web-fetch failure followed by the native fallback is two physical attempts and therefore contributes two balanced pairs. Callers must not add another pair around `performFetch`, because duplicated or missing increments leave the status indicator permanently active. + +Object Storage contributes one approximate unit for each AWS SDK command issued by its adapter, including upload, download, listing, deletion, availability, and usage requests. A download remains active until its response body has been consumed. This boundary is above the request-handler choice, so it covers both the standard SDK handler and Obsidian's internal request API without double counting. SDK-internal retries remain within one reported command. The displayed value is therefore intentionally approximate and is not an exact count of sockets, HTTP exchanges, or bytes transferred. + +P2P does not yet contribute to the physical-request count because it does not have a request unit comparable with CouchDB HTTP attempts or Object Storage SDK commands. Its finite operations remain visible through `📲`. A future P2P transfer metric should be added only when it has a stable meaning, rather than being inferred from the broad logical-operation count. ## Ownership -`ReplicatorService` is the shared ownership point because both `ReplicationService` and `ChunkFetcher` already depend on it. Placing the activity state in `ReplicationService` would make chunk fetching depend in the opposite direction and risk a service dependency cycle. Adding another Service Hub service would introduce a wider capability surface without a distinct lifecycle owner. +`ReplicatorService` is the shared ownership point because both `ReplicationService` and `ChunkFetcher` already depend on it. It owns the broad activity count and classifies the semantic subset which represents finite replication. Placing either activity state in `ReplicationService` would make chunk fetching depend in the opposite direction and risk a service dependency cycle. Adding another Service Hub service would introduce a wider capability surface without a distinct lifecycle owner. The platform activity runner remains injected. Common library and headless consumers can omit it while retaining the same bounded activity count and operation semantics. +The Obsidian-specific `ObsidianReplicatorService` owns `boundedLocalApplicationActivityCount` and reuses the injected activity runner. It is deliberately absent from the common service contract: CLI and Webapp processing continue directly, while the Obsidian host uses the count for Wake Lock and visibility-lifecycle policy without changing remote-operation reporting. + ## Non-Goals - Do not count continuous replication as a bounded activity. - Do not reinterpret the count as an exact number of network connections or HTTP requests. -- Do not use this logical-operation count as a replacement for future connection-level telemetry. +- Do not use either diagnostic count for replication completion, throttling, protocol correctness, or power-policy decisions. - Do not claim or implement privileged mobile background execution. - Do not guarantee protection against operating-system suspension, closing a laptop lid, forced termination, network loss, or a user-initiated sleep action. - Do not add a lifecycle timeout which would abort an unusually slow rebuild. A genuinely stalled operation may postpone LiveSync's visibility suspension until it settles, but the platform may still suspend or terminate background work. - Do not broaden `keepReplicationActiveInBackground`; it remains an opt-in desktop policy for continuous and periodic operation after finite work has ended. -- Do not include local storage reflection in this boundary. It is re-entrant and offline-capable, and needs a separate decision if activity reporting or power policy is added later. +- Do not include offline scans, unrelated local storage reflection, or the durable replication-result queue in either remote-activity count. The queue uses its separate local-application boundary. ## Verification +Before changing the transfer/application separation, keep deterministic coverage which leaves downloaded documents queued after finite transfer settles, verifies that remote activity has ended, and retains the separate local-application boundary until Vault application and the final recovery snapshot settle. + Unit tests cover: - overlapping bounded activities and their reactive count transitions; - count cleanup after rejection; - entry into the boundary only after replication readiness succeeds; - replication failure handling occurring after the finite activity ends; -- start-up one-shot replication entering the boundary while continuous start-up does not; +- start-up one-shot replication and continuous start-up's finite pull-only catch-up entering the boundary, while the unbounded live channel does not; - start-up readiness failure avoiding the boundary; - direct P2P commands entering the boundary; -- direct P2P pull and push entry points entering the boundary; +- direct P2P pull and push entry points entering the broad boundary, while only pull and bidirectional operations enter the finite-delivery boundary; - automatic synchronisation on peer discovery, remote pull requests, and watched peer progress entering the boundary; - P2P peer-selection sessions settling on close, including cancellation, repeated synchronisation, and a close during in-flight work; -- remote chunk fetching through the shared boundary; +- remote chunk fetching remaining inside the shared boundary from synchronous queue acceptance through local persistence and terminal notification; +- missing-chunk waiters rechecking local storage when observed per-identifier claims and finite replication have settled; +- the finite-replication count excluding other bounded work; +- replicated document application sharing one local boundary, settling after the final recovery snapshot, and releasing around processing suspension; +- local application activity leaving both remote-activity counts unchanged; - standard, fast, remote, and combined rebuild activity boundaries; - Rebuilder-owned confirmation and completion dialogues remaining outside rebuild activity; - fallback from fast fetch avoiding a nested activity boundary; @@ -100,6 +120,8 @@ Unit tests cover: - hidden-to-visible transitions before the final activity avoiding an unmatched resume; and - continuous-channel recovery being deferred when a desktop window becomes visible during bounded activity. +Additional tests cover balanced physical-request counters after success and rejection, each Object Storage command boundary, response-body consumption for downloads, the split `📲` and `🌐N` status labels, and a deterministic real-Obsidian CouchDB request held while the physical indicator is observed. The Object Storage integration and real-Obsidian MinIO workflows verify that actual AWS SDK operations advance and rebalance the shared counters. + The exact Fancy Kit screen wake-lock behaviour is covered by its package and Harness tests. A real Obsidian smoke test remains appropriate when changing the platform adapter or lifecycle integration, but is not required for changes confined to the already-tested injected activity-runner contract. ## Consequences @@ -107,4 +129,5 @@ The exact Fancy Kit screen wake-lock behaviour is covered by its package and Har - One finite activity definition drives Wake Lock, lifecycle protection, and status UI without coupling common library code to Obsidian or browser globals. - Callers can observe accurate logical activity even in CLI and Webapp hosts which do not inject a Wake Lock implementation. - Rebuild operations now retain Wake Lock and lifecycle protection across their longest interruption-sensitive phases without retaining them for Rebuilder-owned pre-operation or completion dialogues. Post-reset P2P discovery and selection remain protected as an intentional part of completing the rebuild. -- Future remote-work indicators should distinguish logical activity from connection-level telemetry when that distinction matters to users. +- Downloaded documents may remain in the durable Vault-application queue after remote activity has ended, while a separate local-application boundary retains best-effort Wake Lock and lifecycle protection without presenting local writes as communication. +- Users can now distinguish the lifetime of a finite remote operation from approximate request activity within it. diff --git a/docs/adr/2026_07_chunk_arrival_quiescence.md b/docs/adr/2026_07_chunk_arrival_quiescence.md new file mode 100644 index 00000000..bbb77426 --- /dev/null +++ b/docs/adr/2026_07_chunk_arrival_quiescence.md @@ -0,0 +1,144 @@ +# Architectural Decision Record: Chunk Arrival Quiescence + +## Status + +Accepted + +## Context + +A file metadata document refers to one or more content-addressed chunk documents. LiveSync normally writes the chunks before writing the metadata, but those writes are separate database operations and are not replicated as one atomic unit. A local reader can consequently observe metadata before every referenced chunk is locally readable. This is expected when CouchDB on-demand chunk fetching is enabled, and can also occur around conflict resolution, replication event processing, and historical data. + +Before this decision, a missing-chunk waiter used a fixed 5-second or 30-second timeout. Its budget included queueing, throttling, network transfer, validation, local persistence, and event delivery. A healthy operation could therefore time out immediately before it delivered the requested chunk. Conversely, when no operation was capable of delivering the chunk, the timer merely delayed the same unavailable result. + +Finite replication provides a stronger boundary than elapsed wall-clock time. In the absence of an error, a finite replication does not complete until it has reached the latest sequence in its scope. Once it completes, that replication cannot deliver another chunk. An on-demand fetch is a separate finite delivery path and needs its own per-identifier boundary. + +## Decision + +Wait only for a delivery lifecycle which is observable when the local miss is handled. Do not guess how long an unobserved producer might take. + +Two lifecycle signals are relevant: + +- `finiteReplicationActivityCount` records finite replication operations which can still place database documents in the local database; and +- a `ChunkDeliveryCoordinator` claim records the complete lifetime of an accepted on-demand request for each missing chunk identifier. + +The finite replication count is distinct from the broader `boundedRemoteActivityCount`. Rebuilds and other bounded remote work may need Wake Lock and lifecycle protection without being able to satisfy an arbitrary missing-chunk read. A P2P push-only operation is broad for the same reason: it sends documents away but cannot place one in the local database. P2P pull and bidirectional operations are finite-delivery sources. `ReplicatorService` updates the narrower count only through the typed `runFiniteReplicationActivity` boundary; the diagnostic activity label is not used as a behavioural discriminator. + +The waiting layer follows these rules: + +1. Register the waiter before requesting on-demand delivery. +2. Dispatch `missingChunks` synchronously when direct fetch is permitted. `ChunkFetcher` must claim accepted identifiers before dispatch returns, closing the scheduling gap without a timer. +3. If a matching claim or finite replication is active, wait for that observable producer. +4. A valid chunk arrival resolves the waiter immediately. +5. An explicit remote-missing result resolves it as missing immediately. +6. Once every observed producer completes, read the requested identifier from the local database once more, bypassing the cache. +7. Return the rechecked chunk, or return unavailable. Do not add a fixed grace period after the producer has stopped. +8. If no producer is observable after synchronous dispatch, return unavailable immediately. There is no operation for a duration to represent. + +If new relevant activity starts while the final database recheck is pending, that result is stale. The waiter remains active until the newer producer completes and a current recheck finishes. + +A successful finite replication completion is the authoritative ‘latest’ boundary. A failed operation does not prove that the remote lacks the chunk, but it is still terminal for that attempt: it can no longer deliver data. The same local recheck preserves any documents received before the failure, after which normal replication error and retry handling remains responsible for recovery. Missing-chunk code must not misreport that case as an explicit remote absence. + +### On-demand fetch boundary + +`ChunkFetcher` claims newly requested identifiers synchronously while handling the `missingChunks` event. The claim remains active through: + +- queueing and concurrency scheduling; +- configured interval throttling; +- entry into the injected bounded-activity runner; +- `fetchRemoteChunks`; +- response validation; +- local database persistence; and +- fetched or missing event delivery. + +The claim settles on every terminal path, including explicit absence, no active replicator, rejection, invalid results, destruction, and cancellation. Its completion Promise is the task passed to the bounded `chunk-fetch` activity, keeping Wake Lock, application lifecycle deferral, the remote-work indicator, and missing-chunk delivery aligned. + +### Five-minute leak fuse + +An accepted on-demand claim has a separate five-minute inactivity fuse. This is a last-resort leak safety valve, not a chunk-arrival budget or a remote-request timeout. + +Its purpose is to prevent a faulty integration, a never-settling Promise, or a stalled transport from retaining logical ownership indefinitely. When it fires, the coordinator releases the per-identifier claim and its waiter. If the bounded activity callback has been entered, resolving the claim also allows the associated Wake Lock, application-lifecycle deferral, and remote-work indicator to be released. `ChunkFetcher` refreshes the fuse only at observable progress points, such as entering the activity boundary, beginning and completing throttling or transfer, and completing persistence. + +Five minutes is deliberately a conservative operational limit, not a value derived from a network protocol, a benchmark, or evidence that a missing chunk will arrive within that period. Firing the fuse neither proves remote absence nor makes the underlying request safe to abort. The current `fetchRemoteChunks` contract has no `AbortSignal`, so a physical request may still complete after its logical claim has been released. A future cancellable transport contract should add transport-specific deadlines and explicit cancellation without changing the lifecycle-based wait rule. + +### Continuous replication + +The unbounded live channel is not a quiescence gate because it has no natural end. Its initial pull-only catch-up is finite, however, and must enter `runFiniteReplicationActivity`. This includes the one-shot parameter fallback chain: every retry remains within the catch-up boundary until it succeeds or stops. If continuous replication later restarts with adjusted parameters, the new initial catch-up enters a new finite boundary. + +Once the live channel has begun, a chunk delivered through it still resolves an existing waiter immediately, but the channel itself does not keep a new waiter open. CouchDB on-demand fetching supplies its own per-identifier claim. If a future defect demonstrates a delivery race inside a live batch, that batch lifecycle should be exposed explicitly rather than approximated with another elapsed delay. + +## Ownership + +`ReplicatorService` owns both the broad bounded-operation count and the narrower finite-replication count. It is the common lifecycle owner for CouchDB, sequential, and P2P replicators. + +`LayeredChunkManager` owns one `ChunkDeliveryCoordinator` and supplies it to its arrival layer and `ChunkFetcher`. `ArrivalWaitLayer` owns waiter resolution and the final local database recheck. It depends on the narrow coordinator capability rather than on `ReplicatorService` itself. + +`ChunkFetcher` owns per-identifier claims because it knows when each identifier enters its queue and reaches a terminal result. + +## Compatibility + +- Preserve immediate reads when `waitForDelivery` is false or the deprecated call-site `timeout` is zero or negative. +- Treat a positive deprecated `timeout` only as source-compatible opt-in to lifecycle waiting. Its numeric value no longer represents an arrival duration. +- Preserve `preventRemoteRequest`: no on-demand request is dispatched, although an already-active finite replication may satisfy the waiter. +- Preserve Promise sharing for concurrent reads of the same chunk identifier. +- Preserve immediate explicit remote-missing results. +- Do not change which remote types support direct on-demand fetching. + +## Historical Evidence and Scope + +This decision addresses the lifecycle-race class rather than treating every ‘Load failed’ report as a timeout: + +- [Issue #166](https://github.com/vrtmrz/obsidian-livesync/issues/166) contained logs where chunk collection failed shortly before related chunk writes appeared. It is evidence for the timing class, although that issue's hidden-file start-up path was repaired separately and is not claimed as a direct regression test here. +- The 2021 timing fixes in [commit `39e2eab0`](https://github.com/vrtmrz/obsidian-livesync/commit/39e2eab0238d9c37e3653cdec884cbeed543fc23) and the extended leaf timeout in [commit `9facb577`](https://github.com/vrtmrz/obsidian-livesync/commit/9facb577601d8aceff7df547cd2a6f9357fdaa29) show that elapsed timeout values have historically been used to absorb the same ordering uncertainty. They do not provide a protocol basis for retaining 5-second or 30-second delays. +- Replication pacing introduced by [commit `8d66c372`](https://github.com/vrtmrz/obsidian-livesync/commit/8d66c372e15c43a2de84a223c6385077b7724eec) and commonlib [commit `051b50c`](https://github.com/vrtmrz/livesync-commonlib/commit/051b50ca38ec4c05a11e8216ac259b4488b825f0) is a direct precedent for preventing replication progress from outrunning chunk collection. The present design expresses that dependency as an explicit lifecycle and completion recheck. +- [Issue #505](https://github.com/vrtmrz/obsidian-livesync/issues/505) was traced to chunks which were genuinely absent after the former bulk-send option broke the chunks-before-metadata guarantee. Waiting cannot recreate missing data, so this decision does not claim to fix it. +- [Issue #771](https://github.com/vrtmrz/obsidian-livesync/issues/771) and [Issue #986](https://github.com/vrtmrz/obsidian-livesync/issues/986) contain ambiguous or version-dependent `Load failed` reports. They remain unclaimed until the original writer and database state can be reproduced. + +The detailed setting and replicator matrix is recorded in [Chunk Retrieval and Waiting](../design_docs/chunk_retrieval_and_waiting.md). + +## Alternatives Rejected + +### Increase the fixed timeout constants + +Any fixed total duration can still expire immediately before a queued or progressing operation reports its result. Larger values also make genuine failures slower without defining what the system is waiting for. + +### Start another grace period after finite completion + +A successful finite replication has already reached its latest sequence, and the completion recheck observes documents persisted without a waiter event. Waiting an additional 5 or 30 seconds has no identified producer to wait for and merely retains the historical approximation. + +### Observe all bounded remote activity + +The broad count includes operations which cannot provide the requested chunk. Using it as the delivery gate lets unrelated work delay a read and makes its completion semantically meaningless. A separate finite-replication count avoids that leak. + +### Keep a fallback timer for unobserved delivery + +An unobserved producer has no defined start, progress, or completion semantics. A timer would therefore be a guess rather than a safety property. Relevant delivery paths must claim their work synchronously or expose a finite replication boundary; otherwise the read returns unavailable. + +### Remove every timer + +The arrival wait has no elapsed timer, but an implementation fault can leave a delivery claim unresolved forever. The five-minute inactivity fuse bounds that leaked logical state without being used as a successful delivery condition. + +## Verification + +Unit tests use deterministic clocks and deferred Promises to cover: + +- a finite replication which lasts well beyond the former arrival values; +- successful finite completion causing a cache-bypassing local database recheck; +- immediate unavailability when no producer is observable; +- per-identifier claims covering queueing, throttling, remote fetch, validation, persistence, and event delivery; +- explicit missing, no-replicator, rejection, invalid response, cancellation, runner rejection, and teardown paths; +- overlapping claims and finite replications; +- a runner which never enters the task and a request which never settles; +- the five-minute fuse being refreshed by observable progress; +- continuous replication's finite initial catch-up, including its parameter fallback path; and +- the setting and replicator decision matrix. + +Integration-style unit tests exercise `LayeredChunkManager`, `ChunkFetcher`, a memory-backed PouchDB database, and a deferred fake replicator together. A real Obsidian test is not required because the change remains behind the existing database, service, and event boundaries and does not alter platform UI or an adapter contract. + +## Consequences + +- A healthy finite replication or on-demand request no longer loses a race against an unrelated wall-clock estimate. +- Successful finite replication completion provides a precise latest boundary for missing-chunk reads. +- The local recheck closes event-delivery and cache timing gaps without extending the wait after completion. +- Reads no longer pause for 5 or 30 seconds when no observable operation can deliver the chunk. +- Relevant producers must expose a lifecycle and must continue to prove cleanup on every exceptional path. +- The five-minute fuse bounds leaked logical activity, but it neither establishes remote absence nor cancels a physical request. diff --git a/docs/adr/2026_07_common_library_package_boundary.md b/docs/adr/2026_07_common_library_package_boundary.md new file mode 100644 index 00000000..43be51da --- /dev/null +++ b/docs/adr/2026_07_common_library_package_boundary.md @@ -0,0 +1,347 @@ +# Architectural Decision Record: Package the Common Library Behind Explicit Host Boundaries + +## Status + +Accepted — the package boundary is implemented, published pre-release artefacts have been verified as exact downstream dependencies, and the Community directory scanner preview confirms the intended source boundary. Each later release candidate still requires immutable artefact and downstream validation. + +## Context + +Self-hosted LiveSync currently consumes `livesync-commonlib` as the `src/lib` Git submodule. TypeScript, Vite, Vitest, the CLI, Webapp, and WebPeer resolve `@lib/*` directly to the submodule's TypeScript source. The common-library repository has no package manifest, standalone build, export map, or self-contained test command. Its tests and dependency versions are consequently supplied by the Self-hosted LiveSync repository. + +Source archives do not populate a Git submodule. Self-hosted LiveSync therefore also commits generated declarations under `_types` and resolves `@lib/*` to those declarations as a fallback. This makes one logical dependency appear in the plug-in repository twice: once as a submodule checkout and once as generated declarations. Release preparation must regenerate and commit the fallback, and repository scanners can report generated lint directives as if they were maintained plug-in source. + +The Obsidian community scanner currently inspects repository source beyond the plug-in entry point. Reports include `src/apps/cli`, `src/apps/webapp`, `src/apps/webpeer`, and generated `_types`. Moving the common library from `src/lib` to another source directory or a workspace package inside the Self-hosted LiveSync repository would therefore preserve the scan surface. It could remove the submodule fallback, but it would not create the same dependency boundary as consuming a published npm package. + +The common library is already a substantial shared domain layer. At the time of this decision it contains 270 non-test TypeScript or Svelte source files. At Self-hosted LiveSync revision `e114f66fb2b7c6f3fec2d53701f6638d2557e606`, source outside `src/lib` uses 180 distinct raw `@lib/*` specifiers across plug-in, application, and test source. Normalising optional TypeScript source suffixes leaves 140 migration paths. The plug-in contributes most of those imports, but the CLI, Webapp, and WebPeer also depend on the library. Treating every existing deep path as a permanent public API would make future refactoring impractical. + +The intended external use is not new: + +- [commonlib issue #1](https://github.com/vrtmrz/livesync-commonlib/issues/1) has requested an npm package and an established API since 2022; +- [Self-hosted LiveSync issue #87](https://github.com/vrtmrz/obsidian-livesync/issues/87) asks for a client with list, get, submit, and delete operations; +- [commonlib issue #10](https://github.com/vrtmrz/livesync-commonlib/issues/10) asks for an API which lets another application add Markdown files to the CouchDB data model; and +- [commonlib issue #13](https://github.com/vrtmrz/livesync-commonlib/issues/13) records the integration cost of consuming a Git submodule and compiling its source. + +Existing external consumers use custom TypeScript aliases, loaders, and host stubs to reach `DirectFileManipulator` and other deep modules. The present source shape therefore imposes real integration work without supplying a stable contract. + +The library is close to being self-contained, but it crosses its boundary in a small number of important places: + +- four files depend on Self-hosted LiveSync's event hub or event identifiers; +- `KeyValueDBService` imports the host's concrete database-opening function; +- `ObsidianServiceContext` imports the plug-in class and Obsidian types from the parent repository; and +- `coreEnvFunctions` imports an Obsidian function type even though the module is intended to be host-neutral. + +The library also mixes domain logic with host presentation: + +- Svelte dialogue mounting depends on Svelte, LiveSync service context, application-lifecycle events, translation, and cancellation policy in one implementation; +- Obsidian context and setup helpers remain below the common-library directory; +- browser dialogue shims and LiveSync Svelte components are shipped alongside headless replication code; and +- the translation implementation statically imports the complete generated catalogue. The generated message modules and JSON catalogues account for approximately 1.5 MB of source and generated data, even when a headless consumer does not require them. + +The root `src/index.ts` currently exports only `DirectFileManipulator` and its options. `DirectFileManipulator` is useful evidence for a future SDK, but it is not yet a sufficient stable façade: initialisation starts from its constructor, enumeration remains unfinished, watch ownership and failure semantics are not documented, and conflict and concurrency semantics are not defined as a public guarantee. + +The present process model also assumes one main LiveSync instance. Event dispatch, translation state, environment configuration, offline-scan state, worker pools, synchronisation-parameter handlers, diagnostic counters, and some compatibility caches are module-scoped. Some of these are safe process-wide facilities, while others can allow two client instances to influence each other. Publishing a package makes concurrent clients in one process a supported possibility, so this state must be classified rather than carried across accidentally. + +## Decision + +Maintain `livesync-commonlib` as the authoritative independent repository and publish its compiled output as the scoped, pre-1.0 npm package `@vrtmrz/livesync-commonlib`. + +Self-hosted LiveSync, its CLI, Webapp, WebPeer, and external tools will consume the compiled package and declarations through package export maps. They must not compile the common library's source through a path alias. The package lock records the exact resolved artefact used to build a plug-in release. + +The common-library repository may become a small workspace if independently useful artefacts emerge, but it does not move into the Fancy Kit repository or the Self-hosted LiveSync plug-in repository. This preserves its domain ownership, existing history, issues, and independent release cadence while making the plug-in repository a package consumer. + +The first package release is an infrastructure and compatibility release, not a declaration that every internal service is stable. It exposes: + +- a small documented root API; +- named, task-oriented public subpaths; +- explicitly marked compatibility subpaths required to migrate current Self-hosted LiveSync imports; and +- no unrestricted source-directory wildcard as a permanent contract. + +Compatibility subpaths may be exported during the migration, but they remain pre-1.0 and are documented as internal. New external integrations should use the high-level client façade rather than reproduce Self-hosted LiveSync's internal Service Hub composition. + +### Domain ownership + +The common library continues to own behaviour which defines the Self-hosted LiveSync data and synchronisation model: + +- document, metadata, chunk, setting, and protocol types; +- path and identifier encoding; +- chunk splitting, hashing, compression, encryption, and content reconstruction; +- PouchDB-facing data access and replication primitives; +- CouchDB, Object Storage, and P2P replication domain logic; +- conflict, chunk-delivery, storage-event, and replication managers; +- platform-neutral storage and service contracts; +- headless composition; and +- the future high-level client façade. + +The Self-hosted LiveSync repository owns plug-in and product integration: + +- `ObsidianServiceContext` and every reference to the plug-in class, `App`, `Plugin`, or Obsidian lifecycle; +- Setup Wizard components and LiveSync-specific presentation policy; +- Obsidian menus, notices, settings panes, and dialogue composition; +- plug-in event wiring which is not part of the replication protocol; and +- the concrete initialisation of injected environment, translation, storage, key-value database, and UI capabilities. + +Browser-only implementations may remain in the common-library repository only when they implement a documented host-neutral contract used by more than one browser consumer. LiveSync-specific Webapp and WebPeer composition remains application code even if it later moves out of the plug-in repository. + +Neutral utilities which do not express LiveSync data, storage, replication, or UI policy may move to `octagonal-wheels` after more than one consumer proves the abstraction. Reusable Obsidian adapters may move to `@vrtmrz/obsidian-plugin-kit`. Neither package becomes an owner of LiveSync domain behaviour. + +### Dependency inversion + +The package must contain no `@/` import and no direct Obsidian import. The current reverse dependencies are removed as follows: + +- common protocol and service events live with their common-library contracts; +- host-only event reactions are registered by Self-hosted LiveSync at its composition root; +- `KeyValueDBService` receives an `openKeyValueDatabase` factory through constructor or service dependencies; +- `ObsidianServiceContext` moves to Self-hosted LiveSync; +- the language getter uses a local function type rather than importing the Obsidian declaration; and +- browser globals, fetch, crypto, timers, storage, and document access are obtained through explicit host capabilities where behaviour varies by runtime. + +Temporary package-level configuration is acceptable where converting every call site in one change would be unsafe, but configuration must be instance-scoped wherever multiple clients can coexist. Importing the package must not patch `HTMLElement`, `SVGElement`, or another global prototype. Webapp compatibility patches belong in Webapp bootstrap code. + +Every mutable module-level value is classified as one of: + +- immutable or safely shared process infrastructure; +- an explicitly keyed cache with bounded ownership and disposal; or +- client state which moves behind an instance-owned service or dependency. + +In particular, event subscriptions, translation selection, offline-scan maps and timers, synchronisation-parameter handlers, and database transformation policy must not leak between independent client instances. A high-level client owns an explicit asynchronous disposal path which removes subscriptions, cancels work it owns, and releases its instance state. + +### Translation resources + +Translation lookup is separated from the application catalogue. Commonlib owns a typed canonical English definition for the messages requested by its services and uses it when a host omits the optional translator capability. This ensures that another consumer receives meaningful English rather than symbolic keys. + +Self-hosted LiveSync owns the complete multilingual catalogue, generation tools, selected-language state, and translator implementation. It injects that translator into the Obsidian, CLI, and browser composition roots. Commonlib does not publish `compat/common/i18n`, `compat/common/rosetta`, generated language modules, or JSON catalogues. + +The canonical Commonlib key type and English fallback change with Commonlib. LiveSync may add translations for those keys without duplicating every English definition; its translator delegates absent keys to Commonlib's canonical English fallback. A separate language package remains possible only if independent consumers and release cadence later justify it; core must never depend on an application catalogue. + +LiveSync-owned wording may remain in a typed, application-local provisional English map while it is being exercised. The LiveSync translator composes those keys with the generated application catalogue and the Commonlib key type. Moving a stable message into LiveSync's YAML catalogue makes it available for translation without changing Commonlib. + +### Svelte dialogue hosting + +The present Svelte dialogue implementation is split into three responsibilities: + +1. an Obsidian `Modal` host which owns content, title, close, one-shot settlement, and disposal; +2. a Svelte adapter which mounts and unmounts a component in that host; and +3. Self-hosted LiveSync policy which supplies service context, reacts to plug-in unload, requires explicit cancellation in selected workflows, translates messages, and styles Setup Wizard content. + +Fancy Kit is an appropriate owner for the first responsibility when the contract is useful beyond this migration. The Kit API should be framework-neutral: it accepts a mount callback, exposes typed `resolve`, `cancel`, `close`, and `setTitle` controls, and accepts a disposer returned by the callback. It returns a `Promise` which settles once, and it owns safe-area, viewport, focus, and Obsidian Modal lifecycle guarantees. + +Fancy Kit does not add Svelte to its core dependencies for this migration. Self-hosted LiveSync initially owns the small Svelte adapter which calls Svelte's `mount` and `unmount` through the framework-neutral host. The browser dialogue host also remains outside the Obsidian plug-in kit. If a second Obsidian plug-in needs the same Svelte adapter, it can be promoted to an optional Kit subpath with an optional Svelte peer dependency, or to a separate `@vrtmrz/obsidian-svelte-kit` package. That promotion must preserve the existing `sideEffects: false` and focused-import guarantees for consumers which do not use Svelte. + +Self-hosted LiveSync's `openWithExplicitCancel` retry policy, application service context, and Setup Wizard components do not move to Fancy Kit. + +Arbitrary component mounting is not added to the neutral `UiInteractions` contract. A component instance is a framework and host integration detail rather than a portable interaction request which a generic driver can serialise or answer. A LiveSync workflow which needs a hosted component defines and receives its own narrow, typed dialogue capability; Self-hosted LiveSync composes that capability from the Kit Modal host and its local Svelte adapter. + +### Package artefacts + +The common-library package publishes compiled ESM and generated declaration files. Consumers do not receive raw TypeScript as the runtime entry point. The package must provide: + +- an explicit `exports` map; +- declaration maps where they remain useful for debugging; +- browser and Node entry points where implementations differ; +- accurate `sideEffects` metadata; +- explicit runtime, peer, and optional dependencies; +- no unresolved `@lib/*`, `@/*`, Vite query, or source `.ts` import in published JavaScript or declarations; +- an `npm pack` contents check; and +- clean-install consumer fixtures for Node, a browser bundle, and Self-hosted LiveSync. + +Svelte source, worker query imports, AWS SDK adapters, PouchDB adapters, and Node-only crypto must not leak through the root entry point. Optional feature subpaths may carry their own heavier dependency surface. + +### Platform host entries + +Platform-specific host access is explicit rather than inferred. `@vrtmrz/livesync-commonlib/node` supplies Node-only capabilities and `createNodeStorage({ rootPath })`. `@vrtmrz/livesync-commonlib/browser` supplies `createFileSystemAccessStorage({ rootHandle })` for the browser File System Access API. Browser bundles cannot resolve the Node implementation through the browser entry. + +Both storage factories receive an existing root from their host. Commonlib does not choose a process directory, present a browser directory picker, request browser permission, or persist a `FileSystemDirectoryHandle`. The CLI owns path selection and configuration. The Webapp owns user activation, permission, handle persistence, and re-authorisation. The adapters own only path containment and storage operations below the injected root. + +Here, the browser capability means the browser File System Access API, not Node's `fs` API. The package proof deliberately moves the rooted `IStorageAdapter` implementation first. The Webapp retains its LiveSync-specific `IFileSystemAdapter` composition, file-object cache, Vault semantics, picker flow, and storage-event policy. Those responsibilities can move later only with their own documented contracts; they are not implied by the low-level browser entry point. + +A later composition may place the constructed storage contract on a host-specific `ServiceContext` subtype, so services depend only on the injected capability while each platform owns context initialisation. This proof does not add storage or raw platform objects to the base `ServiceContext`: doing so would make an optional application capability mandatory for Obsidian, CLI, Webapp, WebPeer, and test contexts at the same time. A future change should inject the narrow `IStorageAdapter` contract rather than expose `FileSystemDirectoryHandle`, Node `fs`, or environment detection through the shared context. + +The paired adapters run against the same contract suite for metadata, text and binary access, append, listing, removal, missing paths, parent creation, empty-root handling, and traversal rejection. Timestamp fidelity remains platform-specific because the File System Access API does not provide the same creation-time and timestamp-setting facilities as Node. + +The Node adapter rejects symbolic-link components which it observes in adapter paths before an operation and opens file entries without following the final link where the platform supports that flag. The CLI delegates its Vault reads, writes, discovery, deletion, and atomic rename to this rooted adapter rather than maintaining a second direct `fs` path. This rejects traversal paths and symbolic links which already exist when a remote-derived operation begins, while retaining case-only and cross-directory rename behaviour. + +This adapter is not an operating-system filesystem sandbox. The CLI assumes that the host-selected Vault root and its local filesystem are trusted and are not concurrently rewritten by an untrusted local process. A privileged CLI must therefore not use a Vault root which is writable by a less-trusted local actor. + +The Node entry also centralises direct Node built-in access needed by trusted headless application code. This is a package and scanner boundary, not an assertion that Node and browser APIs are interchangeable. Cross-platform behaviour belongs in a shared contract with separate implementations, as demonstrated by rooted storage. + +### Standard input and output + +Commonlib's `context` entry exposes a narrow `StandardIo` contract for command-line composition. It reads UTF-8 standard input, asks one line-oriented question, and writes text or binary chunks to standard output and standard error without adding delimiters. Commonlib's `node` entry supplies `createNodeStandardIo()`, which binds that contract to host-selected Node streams and defaults to the current process streams. + +Self-hosted LiveSync constructs the Node implementation at the CLI composition root and places the exact instance on `NodeServiceContext`. `NodeServiceHub` must receive that Context rather than silently constructing one. Command handlers reach input, prompts, and protocol output through the Context, so unit tests can inject memory I/O without replacing process globals. Obsidian and browser Contexts do not acquire a fictitious terminal capability, and the base `ServiceContextContract` remains limited to capabilities required by every composition. + +Standard I/O does not own command-line arguments, exit codes, signals, stream lifecycle, log levels, or log persistence. Diagnostic logging remains the responsibility of the existing API and Logger composition. CLI adapters receive a narrow diagnostic callback wired to the service logging API instead of calling `console` directly. A CLI host may render selected logs on standard error, but Logger is not added to `StandardIo` or made mandatory on the base Context. + +Commonlib verifies memory injection, split UTF-8 decoding, object-mode rejection, text and binary output, and prompting against injected Node streams. The downstream CLI verifies Context identity and exact injected I/O in unit tests, then runs the built Node artefact through its Deno command E2E. + +### Context and result compatibility + +`ServiceContextContract` is the minimum host-neutral composition contract. It supplies an event channel and message translator selected by the host. Obsidian and CLI contexts extend the default implementation with their own capabilities; the Webapp currently uses the default implementation. Every Service Hub and every service in one composition must retain the exact Context object supplied by that host. + +Compatibility is established through observable results, not only structural TypeScript compatibility. A shared probe verifies event delivery, translation results, and Context identity across the public Service Hub surface. Commonlib separately verifies that default contexts isolate their event channels and translators. Real Obsidian runs the same invariants against the loaded plug-in through `obsidian-cli eval`; the CLI runs its composition contract in unit tests and then exercises the built Node artefact through Deno E2E; and the Webapp composition runs the shared contract directly without depending on its currently stale Playwright workflow. + +The same rule applies as more APIs move: define the shared result set first, run the same cases against each implementation, and document platform-specific behaviour outside that result set. Matching method names or return types alone does not establish behavioural compatibility. The contract runner remains test support during this proof rather than becoming a new public package API. + +### Barrels and export surfaces + +The migration does not prohibit every barrel. A small root entry point or task-oriented subpath entry point is an intentional package contract when it has one documented responsibility, uses explicit named exports, and does not load unrelated implementations or optional dependencies. The root client entry point, a focused RPC entry point, and type-only storage-adapter entry points are examples which may remain after review. + +An existing barrel or forwarding façade is removed when the migration provides a clearer import and the barrel: + +- aggregates unrelated domains or exposes implementation layout as API; +- hides a platform, UI framework, worker, database adapter, or another optional dependency; +- makes side effects or tree-shaking behaviour difficult to determine; +- merely re-exports another package without adding a LiveSync-owned contract; or +- exists only to preserve the present `@lib/*` source alias or Service Hub composition. + +In particular, the broad `common/types` barrel is retained only as a temporary compatibility path while focused settings, model, protocol, and path contracts are extracted. The `InjectableServices` forwarding barrel is removed in favour of explicit compatibility imports. Forwarding exports for `octagonal-wheels` facilities are removed where consumers can import the owning package directly; mixed modules such as conversion and utility modules are split when this prevents neutral dependencies from being presented as LiveSync-owned API. + +Every retained barrel must correspond to an explicit `exports` entry, list named exports rather than use an unrestricted wildcard, and have a packed-consumer or bundle test proving that unrelated optional code is not loaded. Removing a barrel is not sufficient reason to expose every underlying file as a public subpath. + +## Migration Plan + +### Phase 0: Record and enforce the boundary + +- Add a package-boundary check which rejects `@/` and direct Obsidian imports in common-library production source. +- Record the current Self-hosted LiveSync `@lib/*` import inventory and classify each path as public, compatibility-only, host-owned, or obsolete. +- Inventory mutable module-level state and add a two-client isolation test before promising a public client API. +- Add standalone test and type-check configuration to the common-library repository while retaining downstream Self-hosted LiveSync CI. +- Add a packed-consumer fixture before changing Self-hosted LiveSync resolution. + +### Phase 1: Remove host leaks + +- Move `ObsidianServiceContext` and `setupObsidian` presentation code to Self-hosted LiveSync. +- Inject the key-value database factory and host event reactions. +- Replace the Obsidian language type import with a local contract. +- Move global DOM compatibility mutation to the browser application bootstrap. +- Introduce translator injection and detach core imports from the full catalogue. +- Split the generic dialogue host, Svelte adapter, and LiveSync policy without changing visible behaviour. + +### Phase 2: Build the package + +- Add the package manifest, compiled ESM build, declarations, export map, and package documentation. +- Keep the root export deliberately small. +- Remove accidental and forwarding barrels where focused imports are clearer; retain only reviewed package entry points with explicit named exports. +- Add temporary compatibility subpaths required by the classified 140-path migration inventory. +- Test Node, browser, worker, PouchDB, Object Storage, P2P, and headless entry points independently. +- Verify that installing a core entry does not pull Svelte UI or language catalogue code into a representative bundle. + +### Phase 3: Publish and validate a pre-release + +- Publish an immutable pre-1.0 version to a pre-release npm dist-tag. +- Verify its package name, provenance, checksum, export map, and packed files. +- Run common-library tests against the packed artefact rather than the source checkout. +- Run downstream Self-hosted LiveSync type checks, unit tests, integration tests, CLI tests, Webapp tests, WebPeer tests, production builds, and focused real-Obsidian E2E against the exact package version. +- Validate at least one external consumer which currently uses a submodule or custom loader. + +### Phase 4: Convert Self-hosted LiveSync + +- Replace `@lib/*` source aliases with package imports. +- Remove the `src/lib` submodule, `_types`, `tsconfig.types.json`, `generate-types.mjs`, and release-workflow steps which regenerate fallback declarations. +- Move the multilingual catalogue and its generation tooling into Self-hosted LiveSync, while retaining only Commonlib's typed English fallback in the package. +- Keep application-owned source under `src/apps` until a separate decision moves an application. +- Run the community scanner's branch or commit preview before releasing the converted plug-in. + +### Phase 5: Stabilise the SDK + +- Design a high-level asynchronous client around explicit `create`, `list`, `get`, `put`, `delete`, `watch`, and `close` lifecycles. +- Specify path normalisation, encryption negotiation, conflict handling, conditional writes, deletion, history, and resource disposal before declaring the façade stable. +- Adapt `DirectFileManipulator` behind that façade or deprecate it; do not treat its current constructor and deep dependencies as the final API. +- Narrow or remove compatibility-only export paths as Self-hosted LiveSync imports migrate to documented package modules. + +## Implementation Proof + +The package proof builds Commonlib as one compiled ESM package with a small root, `context`, `settings`, `remote-configurations`, `browser`, `node`, and `rpc` entries, plus only the explicit compatibility exports required by the reviewed downstream inventory. It publishes neither raw TypeScript nor Svelte source, uses no unrestricted export wildcard, and can be installed into a clean consumer, imported in Node, type-checked from declarations, and bundled independently for browser context, browser storage, browser services, and workers. Release validation records and compares the immutable registry version and checksum. + +The compatibility inventory is regenerated from the maintained Self-hosted LiveSync consumer. Focused entries replace compatibility imports when their contracts are proven; obsolete paths are removed from the next candidate rather than retained merely because an earlier immutable release contained them. The multilingual `common/i18n` and `common/rosetta` paths have been removed in this way after ownership moved to the host. + +The proof found and fixed three boundary defects which source-alias consumption had hidden: compiled JSON imports required explicit output extensions, precompiled Svelte output could not safely be treated as source by the downstream Svelte pipeline, and Vite's default client conditions selected Commonlib's browser worker while building the Node CLI. Packed-consumer regressions cover the first two. The CLI now uses Vite's server conditions and treats every Node built-in reported by Commonlib's Node entry as external; the built CLI is exercised through Deno E2E. Importing root or context also no longer patches DOM prototypes, translator injection prevents the context entry from loading the complete language catalogue, and standard input and protocol output are supplied by the package-owned host contract rather than direct stream access in command handlers. + +Self-hosted LiveSync, its CLI, Webapp, WebPeer, plug-in source, and tests compile against that exact registry artefact without `@lib/*`. Focused downstream storage tests pass against the package-owned Node and File System Access API implementations. Commonlib also owns the Trystero implementation and version; the host retains no direct Trystero dependency, preventing two transport generations from entering one application graph. The old `src/lib` Git submodule, generated `_types` fallback, type-generation scripts, and source aliases are removed by the proof branch. + +Commonlib's contract and complete suites cover Context results, both platform storage implementations, standard I/O, replication, settings, and package consumption. Self-hosted LiveSync runs the same host-composition result contract for Obsidian, CLI, and browser compositions against the exact packed artefact, followed by its unit, integration, application-build, CLI E2E, and focused real-Obsidian gates. + +The package-owned Trystero transport also completes the canonical Compose P2P synchronisation workflow with a local relay and two isolated CLI peers. In real Obsidian, the plug-in starts with one consistent `ObsidianServiceContext`, the representative server-selection and Setup URI Svelte dialogues mount and close through their normal controls, their mobile variants satisfy viewport, safe-area, and touch-target assertions, and the settings pane exposes only the effective deletion controls. These runtime checks complement the package tests without making Webapp maintenance the primary release gate. + +The Community directory scanner preview completes with no source-code errors. The former findings attributed to generated `_types`, raw `src/lib` source, Node built-ins, forbidden rule suppressions, unsupported Obsidian APIs, and undescribed directive comments are absent. This confirms that the registry dependency is recognised as a package boundary. The preview identified behaviour-neutral redundant CLI candidate types and Webapp File System Access API assertions; these are corrected in the host source rather than carried as known warnings. + +The remaining source warnings belong to application code. Browser dialogue visibility now uses DOM state instead of inline static styling, so the earlier styling warning is absent. Direct diagnostic output was resolved at its existing ownership boundaries: Webapp components use an injected log function backed by `BrowserAPIService`, WebPeer retains output in its Svelte log store, Obsidian modules use the established Logger path, and duplicate console emission was removed from `ModuleLog`. The later Webapp and WebPeer recomposition around maintained Context and serviceFeature APIs should preserve these explicit output paths. + +The final Community lint inventory for this boundary has no errors and 126 warnings: 67 sentence-case findings, 58 deprecated-API findings, and one declarative setting-definition suggestion. The sentence-case strings and deprecated interfaces are retained deliberately to avoid an unrelated localisation and host-lifecycle change. Declarative definitions would migrate the complete Obsidian setting tab into the 1.13 settings-search model; that is a separate visible UI project after LiveSync 1.0, not a hidden package-boundary release gate. Revisit each category through focused UI and compatibility work rather than suppressing the rules or treating the warning count as zero. + +WebPeer's production build still reports that Vite externalises the guarded Node `crypto` fallback reached through a compatibility path. Browser execution selects `globalThis.crypto`, and the focused root, `context`, and `browser` bundle checks do not include the Node fallback, so this is not a leak in the reviewed public browser entries. Removing the compatibility-build warning requires a focused crypto-capability contract or a platform-specific implementation split and remains part of compatibility-surface narrowing. + +The dependency preview also reports `uuid`, but the installed and locked graph resolves PouchDB's UUID dependency to the patched `uuid@11.1.1` through the repository override, and `npm audit` does not report the UUID advisory. The scanner appears to infer the older declared PouchDB range rather than the resolved override, so this warning is treated as a scanner false positive unless a packed-artefact inspection shows otherwise. + +The 1.0 dependency review found newly disclosed parser and denial-of-service advisories with compatible fixes. All locked `brace-expansion` generations now use their patched releases, including the production generation reached by Commonlib path matching and the CLI's user-configured ignore patterns. The development-only ESLint and Istanbul `js-yaml` generations likewise use patched releases. The production Markdown parser uses the patched `linkify-it` release to avoid quadratic processing of maliciously structured `mailto:` links, while the development-only JSON Schema toolchain uses the patched `fast-uri` release for unambiguous hostname parsing. A clean install and both complete and production-only `npm audit` checks no longer report these packages. + +The remaining audit report is the existing `werift` and `werift-ice` dependency on `ip`, for which npm offers no patched version. The advisory concerns `ip.isPublic()` misclassifying unusual loopback representations. The locked werift implementation uses `ip` for address encoding, decoding, format detection, and loopback filtering, but does not call `isPublic()` or `isPrivate()`. LiveSync reaches werift only through the Node CLI's injected `RTCPeerConnection`; the Obsidian plug-in and browser applications use their platform WebRTC implementation, and the plug-in artefact does not contain werift. The package-level finding is therefore accepted for the 1.0 integration preview as a non-reachable advisory in the reviewed call path, not as a general waiver. Revisit it when werift or `ip` publishes a replacement, or before any change which delegates address trust, routing, or URL access decisions to that dependency. + +The local real-Obsidian suite verifies the actual loaded `ObsidianServiceContext`, all 18 services, Vault reflection, CouchDB and Object Storage transfer, remote-activity accounting, CLI-to-Obsidian encrypted synchronisation, startup scanning, two-Vault create, update, delete, ordinary rename, case-only rename, target mismatch, Hidden File Sync, Customisation Sync, setting Markdown export, and two-device CouchDB, Object Storage, and P2P Setup URI workflows. These checks establish observable results and host composition rather than relying on declaration compatibility alone. + +The obsolete mocked browser Harness and its `harness-ci` workflow have been removed. Webapp production builds and the direct browser composition contract remain part of the package-boundary proof; future Webapp browser automation is a Webapp-owned workflow rather than a substitute for real Obsidian E2E. + +## Scanner and Repository Consequences + +Consuming the npm package removes the common-library source and generated `_types` from the plug-in repository's Community directory scan, because package dependencies are treated as dependencies rather than maintained plug-in source. The branch preview confirms this expected boundary. + +The change does not hide or resolve warnings in Self-hosted LiveSync-owned source. The scanner will continue to inspect the plug-in, CLI, Webapp, and WebPeer while those applications remain in the repository. Moving those applications to a LiveSync-family application repository may be considered after the package boundary is stable, but it is not part of this decision because the CLI and Webapp still import shared plug-in composition code. + +Package extraction also does not resolve release-asset attestation verification, unsupported release assets, declarative settings migration, or other scanner findings which are independent of source ownership. + +## Alternatives Rejected + +### Move the common library into the Self-hosted LiveSync monorepo + +This would permit atomic source changes and remove the Git submodule, but the community scanner already examines non-plug-in application source. A source workspace would remain in scope, and every library directive and platform dependency would be attributed to the plug-in repository. It would also make independent consumers depend on the plug-in repository's release cadence. + +### Move the common library into Fancy Kit + +Fancy Kit owns reusable framework-neutral interactions, Obsidian adapters, test infrastructure, and neutral utilities. Replication protocols, chunk and metadata formats, PouchDB composition, conflict rules, and LiveSync storage policy are a different domain. Moving them would obscure ownership and make general plug-in tooling carry Self-hosted LiveSync release concerns. + +### Publish the current source tree without changing boundaries + +This would expose accidental deep imports, global mutations, Svelte and browser code, Node-only modules, and parent-repository imports. Consumers would still need source aliases and bundler-specific behaviour, while maintainers would be unable to distinguish public API from implementation. + +### Split every concern into a separate npm package immediately + +Core, language, UI, browser, Node, P2P, Object Storage, and SDK packages could make dependency graphs precise, but the present source has not yet proven those release boundaries. Starting with one compiled package and explicit optional subpaths allows measurement without creating a coordinated release matrix. Additional packages remain possible after their contracts and consumers are demonstrated. + +## Verification + +The package-boundary conversion is ready for acceptance only when: + +- common-library production source has no parent-repository or Obsidian dependency; +- the package builds and tests from a standalone clean checkout; +- `npm pack` contains only intended compiled artefacts and documentation; +- packed Node and browser consumers resolve only exported paths; +- a headless client does not bundle Svelte or the full language catalogue; +- retained entry-point barrels do not load unrelated optional implementations, and no removed barrel is replaced by unrestricted deep exports; +- Self-hosted LiveSync verifies its local Svelte adapter and workflow policy through injected tests and a focused composition smoke test; +- Self-hosted LiveSync no longer has `src/lib`, `_types`, or `@lib/*` source aliases; +- the Commonlib owner and packed-consumer suites, Self-hosted LiveSync unit and integration tests, Deno CLI E2E, plug-in and application production builds, and focused real-Obsidian E2E pass against the reviewed package artefact; +- common-library downstream CI records and tests the exact Self-hosted LiveSync ref; +- a Community directory scanner preview confirms the expected change in source findings; and +- compatibility exports are explicitly enumerated from a reviewed downstream inventory rather than exposed through a wildcard. + +The following are later SDK-stabilisation gates, not blockers for accepting the package boundary: + +- two clients with different database, encryption, language, and lifecycle settings can coexist without sharing mutable client state; +- an external consumer can replace its submodule or custom loader with the documented high-level package API; and +- if the framework-neutral Modal host is promoted to Fancy Kit, Kit-owned lifecycle, viewport, safe-area, and touch-target guarantees replace duplicated device tests in LiveSync. + +## Consequences + +- The common library gains an independently consumable and testable artefact while retaining its domain ownership and history. +- Self-hosted LiveSync release archives no longer need generated fallback declarations for an absent submodule. +- Community directory scanning distinguishes plug-in source from the reviewed dependency. +- Changes which span the package and plug-in require coordinated package and downstream validation rather than one atomic source commit. +- Temporary compatibility exports increase the first package surface, but their pre-1.0 status and explicit classification prevent them from becoming silent permanent contracts. +- Application translation, Svelte, and platform dependencies become optional composition concerns rather than root-package side effects; Commonlib's small English fallback remains safe by default. +- Fancy Kit may gain a generally useful typed Obsidian Modal host without acquiring LiveSync policy or a mandatory Svelte dependency. + +## Later Open Questions + +- Define the minimum conflict and conditional-write guarantees required by the first public high-level client. +- Continue narrowing the broad model, service-composition, and replication compatibility paths as focused result contracts become available. +- Confirm whether another Fancy Kit consumer needs the framework-neutral Modal host before it is added to the Kit, or whether LiveSync should pilot the contract first. diff --git a/docs/adr/2026_07_feature_maturity_for_1_0.md b/docs/adr/2026_07_feature_maturity_for_1_0.md new file mode 100644 index 00000000..c1668947 --- /dev/null +++ b/docs/adr/2026_07_feature_maturity_for_1_0.md @@ -0,0 +1,49 @@ +# Feature maturity for 1.0 + +## Status + +Proposed for the 1.0 integration branch. + +## Context + +Self-hosted LiveSync accumulated several labels such as 'experimental', 'Beta', 'obsolete', and 'sunset' over the 0.x line. Those labels did not consistently describe the current implementation. Some formerly experimental features now have maintained unit, Compose, and real-Obsidian coverage, while some old database-format options remain executable only because existing data must still be opened safely. + +Version 1.0 needs to distinguish supported opt-in features from previews and from compatibility paths. Removing a label does not make a network environment reliable, and retaining a setting key does not make it a recommendation. + +## Decision + +### Supported opt-in features + +- Peer-to-Peer Synchronisation is supported. Commonlib owns the transport lifecycle, the Compose suite covers transfer, replacement, disconnect, and reconnect behaviour, and the Obsidian host verifies its current pane boundary. Documentation must describe environmental WebRTC limitations without calling the feature experimental. +- Hidden File Sync is supported as an advanced, separately initialised feature. It remains disabled during ordinary Setup URI initialisation and has its own two-Vault, conflict, filtering, and notification acceptance workflow. +- Customisation Sync is supported as an advanced opt-in feature. Its maintained two-Vault real-Obsidian workflow covers snippets, configuration files, and plug-in files, including updates and deletion of source synchronisation data. It remains separate from Hidden File Sync and must not manage the same files concurrently. +- Data Compression is maintained as an advanced opt-in storage and bandwidth trade-off. It remains disabled by default. The three-repeat CLI and CouchDB benchmark reduced stored chunk data and upload bodies by about 9% for the mixed fixture, but processing and worker-memory costs remained substantial. The [Data Compression specification](../specs_data_compression.md) records the contract, measurements, compatibility behaviour, execution model, and reproduction path for future default-setting decisions. +- The real-Obsidian E2E runner is maintained release infrastructure rather than an experimental Harness. + +### Retained previews + +- JWT authentication remains experimental because it depends on specialised CouchDB server configuration and does not yet have a maintained server-backed authentication matrix. The current implementation, Setup URI transport, focused unit coverage, and reported ES512 use justify retaining it. +- Ignore files remain Beta. They have focused target-filter tests, but nested rules, hidden-file expectations, and open user reports still require review. +- Automatic newer-file conflict resolution remains Beta and disabled by default because it can deliberately overwrite one side of a conflict. +- Garbage Collection V3 remains Beta and explicitly initiated. Its algorithm and safety protocol are outside this classification change and require a separate decision. + +### Compatibility-only and sunset paths + +- E2EE V1 and its dynamic iteration-count setting remain for existing encrypted databases. E2EE V2 is the new-Vault contract. +- The old IndexedDB adapter remains only with its migration path back to IDB. +- `xxhash64` is the current hash contract. Other hash algorithms remain available for existing databases and edge-case recovery, not as experimental alternatives for new Vaults. +- Eden chunks remain accepted at runtime and in transported settings, but are not offered in the settings interface. +- `doNotUseFixedRevisionForChunks` remains an inert compatibility input. Chunk revisions are always content-derived. +- The deprecated cleaned-database reconciliation callback remains internal while an old IndexedDB client may still encounter that remote state. It is not a user-selectable maintenance action and is omitted from the settings reference. + +### Already removed + +The obsolete mocked browser Harness, automatic bulk chunk pre-send, legacy trash toggle, and fixed-revision control have no supported 1.0 UI path. Their compatibility data, where required, remains accepted independently of their removed controls. + +## Consequences + +- Supported opt-in features retain focused release gates and user documentation. +- Preview features remain off by default and keep explicit maturity labels. +- Compatibility-only settings must not silently change existing data formats. New configuration should not expose retired formats merely because their decoders remain available. +- Commonlib setting types and Setup URI decoding remain broad enough to read existing configurations. Removing those package contracts requires a separately versioned compatibility decision. +- Deprecated host accessors and Community directory API warnings are a separate refactoring boundary. This decision does not authorise removing broadly used internal access paths. diff --git a/docs/adr/2026_07_multiple_remote_onboarding.md b/docs/adr/2026_07_multiple_remote_onboarding.md new file mode 100644 index 00000000..ff07cdec --- /dev/null +++ b/docs/adr/2026_07_multiple_remote_onboarding.md @@ -0,0 +1,88 @@ +# Architectural Decision Record: Make Onboarding Profile-Aware + +## Status + +Accepted — implemented and verified against the locked Commonlib package. + +## Context + +Self-hosted LiveSync stores multiple remote connections in `remoteConfigurations` and selects the ordinary replication target with `activeConfigurationId`. P2P features have a separate `P2P_ActiveRemoteConfigurationId`. Existing replication implementations still consume the older flat CouchDB, Object Storage, and P2P fields, so selecting a profile projects its connection settings onto those compatibility fields. + +The settings pane already creates and edits profiles directly. The Setup Wizard was inconsistent: + +- modern Setup URI and QR payloads retained a supplied profile map; +- legacy imports relied on `SettingService` to migrate flat connection fields into `legacy-*` profiles; +- manual CouchDB and Object Storage setup wrote only the flat fields and relied on that same migration; and +- manual P2P setup partly updated the profile map itself. + +Legacy migration deliberately runs only when the profile map is empty. If a Vault already had one or more profiles, manually configuring another CouchDB or Object Storage connection changed the flat fields but did not add a profile. The selected stored profile could subsequently project its older values back onto those fields. Fresh P2P onboarding could also finish without a profile when no P2P profile ID existed beforehand. + +## Decision + +The profile map is authoritative persisted state for every newly configured remote. The compatibility fields remain the runtime projection of a selected profile and the input accepted from an older settings payload. + +### Manual onboarding + +A successful manual CouchDB, Object Storage, or P2P setup creates or deliberately updates a profile through Commonlib's focused `@vrtmrz/livesync-commonlib/remote-configurations` entry. + +- Existing profiles are preserved. +- A newly created profile receives an opaque generated ID. +- CouchDB and Object Storage setup select the new profile as the main remote. +- P2P setup selects the profile through `P2P_ActiveRemoteConfigurationId`. +- P2P setup also selects it as the main remote when P2P is being configured as the main remote. +- Configuring P2P alongside another main remote changes only the P2P selection. +- A known profile ID is supplied only when an existing profile is deliberately being updated; omitting the ID creates another profile. + +The onboarding dialogue does not add a naming step. Commonlib proposes a concise type-specific display name and adds a numeric suffix when necessary. The settings pane can rename it later. + +### Identity and naming + +Profile names are presentation only. They are neither unique identity nor a marker for the selected profile. No entry receives a special `default` ID or name. Opaque IDs establish identity, `activeConfigurationId` establishes the main selection, and `P2P_ActiveRemoteConfigurationId` establishes the P2P selection. + +Generated names describe the connection without exposing credentials, for example `CouchDB couch.example`, `S3 notes`, or `P2P team-room`. + +### Setup URI and QR import + +A modern payload preserves its profile IDs, display names, profile URIs, main selection, and P2P selection. Setup does not rename or recreate those profiles. + +A legacy payload containing only flat connection fields remains supported. `SettingService` migrates it into clearly labelled `legacy-couchdb`, `legacy-s3`, or `legacy-p2p` profiles only when no modern profile map exists. This is the only onboarding path which intentionally relies on the compatibility migration. + +### Persistence and restart ordering + +Profile construction happens before the settings are submitted to the onboarding completion boundary. Existing-device and new-device setup reserve Fetch or Rebuild respectively before enabling and saving the settings. Profile awareness does not change that initialisation ownership or restart ordering. + +Commonlib produces an in-memory plaintext profile URI. The standard setting service applies its configured at-rest encryption during persistence. + +## Alternatives rejected + +### Keep relying on legacy migration + +This works only while the profile map is empty. It silently fails to register a newly configured connection once multiple-remote settings are already in use and leaves P2P with a separate implementation. + +### Create a special `default` profile + +The special meaning would duplicate `activeConfigurationId`, make a user-visible name carry identity, and become ambiguous as soon as the user selects another profile. Selection IDs already express the required state. + +### Add profile naming and full list editing to onboarding + +That would make the first-run path longer and duplicate the established Saved connections interface. Automatic descriptive names and later renaming keep this change limited to data integrity and consistent selection. + +### Replace the compatibility fields immediately + +Replication, diagnostics, and older settings paths still consume the projected fields. Removing them belongs to a broader runtime migration and is not required to make onboarding correctly profile-aware. + +## Verification + +Commonlib unit tests cover preserving existing profiles, opaque-ID insertion, generated display names, duplicate-name suffixes, main activation, independent P2P selection, and URI serialisation. Its packed-consumer test imports the focused entry point from the generated package. + +Self-hosted LiveSync unit tests cover preserving modern Setup URI profiles and their active selection, retaining legacy Setup URI and QR migration, adding CouchDB and Object Storage profiles beside an existing profile, independent P2P selection, fresh P2P selection as both main and P2P remote, and cancellation without mutation. + +The real-Obsidian onboarding E2E owns the invitation, dialogue presentation, safe-area and touch-target checks, cancellation, and reopening from the Setup pane. It does not contact a remote or submit credentials. Remote connection correctness remains owned by the CouchDB, Object Storage, P2P, and two-Vault suites. The end-to-end Setup URI and provisioning acceptance workflow remains a separate release gate. + +## Consequences + +- Manual onboarding and the Saved connections list share one Commonlib profile contract. +- Existing profiles survive reconfiguration, and a newly configured connection becomes explicitly selectable. +- Modern imports retain user-assigned profile identity and names. +- Legacy Setup URIs continue to work through an isolated compatibility boundary. +- Runtime consumers may keep using projected flat fields while the persisted model and new APIs use the 1.0 multiple-remote contract. diff --git a/docs/adr/2026_07_p2p_connection_check.md b/docs/adr/2026_07_p2p_connection_check.md new file mode 100644 index 00000000..10105c41 --- /dev/null +++ b/docs/adr/2026_07_p2p_connection_check.md @@ -0,0 +1,103 @@ +# Architectural Decision Record: Browser-assisted P2P Connection Check + +## Status + +Accepted — WebPeer provides a disposable, browser-observed connection check with a clean one-device path and an optional same-room second-device attempt. + +## Context + +P2P availability depends on the browser, device, signalling relay, NAT, firewall, and current network. A Setup URI can make a trial easier, but a connection observed between a browser and one device does not prove that two other devices can connect to each other, and a WebRTC connection does not prove that LiveSync documents can be synchronised correctly. + +Showing this experiment inside the ordinary LiveSync interface would mix a temporary connectivity check with the state of a real Vault. It would also make a browser-owned diagnostic result appear to be a plug-in-owned synchronisation result. + +The Commonlib diagnostic counters are cumulative within one JavaScript realm. A negotiation may create more than one `RTCPeerConnection`, a failed attempt may later be followed by a successful retry, and closing a successful connection is not a failure. The counters therefore cannot be interpreted as peer counts or as mutually exclusive outcomes. + +## Decision + +WebPeer provides a separate page labelled 'P2P connection check'. It acts as a temporary reference peer and creates a fresh, random P2P configuration locally in the browser. The page displays an encrypted Setup URI, a QR code containing that URI, and the separate Setup URI passphrase. + +The initial implementation is part of the WebPeer production build and is served over HTTPS or from `localhost`. A downloaded copy of `check.html` alone is not a supported standalone application because the build uses separate module assets and origin-scoped browser storage. + +Each check has these boundaries: + +| Participant | Responsibility | +| -------------------------- | --------------------------------------------------------------------------------------------------- | +| Browser page | Joins the generated room as the diagnostic reference peer and displays its own diagnostic counters. | +| Desktop or mobile LiveSync | Opens the generated Setup URI in a dedicated empty Vault and attempts the ordinary P2P connection. | +| User | Uses an empty Vault for every device and chooses either a fresh room or the explicitly less isolated same-room second-device attempt. | + +The generated device configuration: + +- uses a dedicated P2P application identifier so that test rooms do not overlap ordinary LiveSync rooms; +- uses random room, P2P, Vault-encryption, and Setup URI secrets; +- enables P2P auto-start so that a dedicated empty test Vault attempts the connection after setup; +- does not enable automatic broadcasting, peer acceptance, watching, or replication; +- omits device-local names from the Setup URI; and +- enables diagnostic WebRTC wrapping only on the browser reference peer. + +The browser joins the room only after the user explicitly starts monitoring. Preparing the Setup URI and QR code does not contact the signalling relay. The page must remain open while monitoring. + +The primary path checks one target device. The user selects either desktop or mobile before generating the configuration. Starting a fresh check reloads the page, creates a new room, and resets the page-scoped diagnostic baseline; this remains the clearest way to compare devices. + +After the first connection succeeds, the page can scroll back to the existing QR code without generating new credentials. It also offers an optional same-room second-device attempt. This keeps the browser and first device connected, records the current diagnostic totals and active `RTCPeerConnection` identifiers as a local baseline, and reuses the exact Setup URI for another empty Vault. It reports an additional connection only when both the successful-connection total and the number of simultaneous active connections increase, with an active connection identifier absent from the baseline. A reconnect which replaces the first connection can therefore increase negotiation counters without satisfying the additional-connection result. + +The same-room result remains a convenience rather than an isolated comparison. Its totals are still cumulative, a connection is not a device identity, it depends on the first device remaining connected, and unusual reconnect timing can be harder to interpret than a fresh room. The interface keeps the fresh-check action available and explains this limitation beside the additional result. + +The page displays the following Commonlib diagnostic totals: + +- new connection states; +- successful connection states; +- failed connection states; and +- closed connection states. + +A successful total greater than zero proves that this browser and the target device established at least one WebRTC connection in the generated room. That successful result remains valid if a later closed or failed total increases. A failed total without a successful total describes a failed attempt, not a final verdict, because the transport may retry. If no success has been observed after the documented observation period, the result is inconclusive for P2P on the current network. The page recommends CouchDB for predictable synchronisation when repeated checks on the intended networks do not connect. + +The page does not claim to verify document transfer, conflict handling, sustained connectivity, background execution, or a direct desktop-to-mobile path. An optional final test can use two disposable empty Vaults and a note round trip directly between the user's devices. That direct test is more representative, but its result cannot be observed reliably by the browser page without a separate reporting protocol. + +## Security and privacy + +All credentials are generated with Web Crypto in the browser. The Setup URI is encrypted, its passphrase is shown separately, and neither value is uploaded merely by preparing the check. Their read-only fields disable browser autocomplete and spell-check; copying either value to the system clipboard remains an explicit user action. Starting monitoring uses the configured signalling relay, which is the existing public relay by default, and exposes the usual P2P signalling metadata to that service. WebRTC and optional relay or TURN behaviour retain their existing Commonlib security and privacy properties. + +The production page does not accept a relay override from a deployed origin. A `relay` query parameter is honoured only when the page itself is served from a loopback host, and only for `ws:` or `wss:` URLs. This narrow boundary allows the real-Obsidian E2E scenario to use the local Compose relay without making an arbitrary relay query part of the hosted user workflow. + +The generated configuration is disposable. It must be used only in a dedicated empty Vault, must not replace a production Vault's settings, and must not be retained as the credentials for a later production setup. The browser page stores no ordinary Vault files for this workflow, although browser-local database infrastructure is opened to satisfy the existing WebPeer runtime contract. + +## Alternatives rejected + +### Display the result in the LiveSync plug-in + +The observation belongs to the browser reference peer and does not prove LiveSync document synchronisation. Keeping the result beside the QR code makes the evidence and its owner explicit. + +### Use browser, desktop, and mobile simultaneously as the primary comparison + +The diagnostic totals are cumulative and do not attribute an `RTCPeerConnection` to a named target device. Separate random rooms provide a clearer result. Same-room reuse is retained only as an explicit convenience, with a counter baseline and another simultaneous active connection required for its result. + +### Treat any failure increment as a final failure + +Negotiation can fail and then retry successfully. A later successful increment takes precedence over earlier failure increments. + +### Treat browser success as proof of device-to-device synchronisation + +Browser-to-device success is a useful preflight check, but it does not exercise the exact desktop-to-mobile network path or a LiveSync note round trip. + +### Put the Setup URI passphrase in the QR code + +Keeping the encrypted URI and passphrase separate preserves the existing Setup URI boundary and avoids turning one captured QR code into the complete configuration secret. + +### Distribute one standalone downloaded HTML file + +WebPeer already depends on a compiled module graph, origin-scoped storage, and secure browser capabilities. A hosted production build reuses those boundaries and can be updated consistently; a single self-contained file would require a separate bundling and trust model. + +## Verification + +Unit tests decode each generated Setup URI and verify its isolation identifier, random credentials, remote-profile selection, automatic-start policy, absence of a device-local name, and browser-only diagnostic setting. Pure outcome tests cover waiting, failed attempts, later success, and closure after success. + +The WebPeer browser smoke test verifies that the production page can prepare a desktop or mobile check, render the QR code, display a separately formatted passphrase, expose zeroed counters without contacting the signalling relay, and return to the unchanged QR code. Pure tests verify that a reconnect which replaces the first connection does not satisfy the same-room additional-connection rule. The focused real-Obsidian E2E test serves the production page from loopback, starts the local Compose Nostr relay, applies the browser-generated Setup URI through visible onboarding in one empty Vault, records the same-room baseline, reuses the unchanged URI in a second empty Vault, and requires the browser to observe another simultaneous active connection before retaining a result screenshot. Existing Commonlib tests remain authoritative for diagnostic WebRTC wrapping, Trystero negotiation, and P2P lifecycle behaviour. + +## Consequences + +- Users can assess whether P2P is plausible on each intended device and network before relying on it. +- Results remain visibly scoped to connectivity rather than synchronisation correctness. +- A fresh test requires a fresh page session and disposable empty Vault; same-room reuse requires another empty Vault and retains cumulative counters. +- CouchDB remains the recommended predictable option when representative P2P checks repeatedly fail. +- Direct device-to-device note transfer remains a separate, optional verification step. diff --git a/docs/adr/2026_07_p2p_transport_lifecycle.md b/docs/adr/2026_07_p2p_transport_lifecycle.md new file mode 100644 index 00000000..e7f4b671 --- /dev/null +++ b/docs/adr/2026_07_p2p_transport_lifecycle.md @@ -0,0 +1,91 @@ +# Architectural Decision Record: P2P Room and Transport Lifecycle + +## Status + +Accepted — implemented and verified through Commonlib owner tests, the Compose transport suite, and the real-Obsidian setup workflow. + +## Context + +Self-hosted LiveSync uses Trystero's Nostr strategy for P2P discovery, signalling, and WebRTC transport. Three related resources have different owners and lifetimes: + +- a LiveSync P2P service instance owns its commands, RPC sessions, advertisements, and room membership; +- Trystero owns the underlying WebRTC peers and may share one physical peer across more than one room; and +- Trystero's Nostr relay manager owns WebSocket clients shared by relay URL. + +Closing every `RTCPeerConnection` returned by `room.getPeers()` bypasses Trystero's shared-peer manager. The manager may then retain a stale shared peer and prevent a replacement LiveSync replicator from discovering the same remote peer again. + +Room departure and physical transport destruction are not equivalent. `room.leave()` sends the room-leave action, removes that room's actions and callbacks, and detaches its shared-peer binding. Trystero may retain a healthy physical WebRTC peer for later reuse after the last room binding has gone. The retained peer cannot carry actions for the room which has been left. + +Relay WebSockets have a separate lifecycle. LiveSync's explicit disconnect operation must close them and prevent automatic reconnection. A later explicit connect must allow reconnection before joining the room again. + +## Decision + +Normal P2P shutdown delegates physical peer ownership to Trystero: + +1. Stop LiveSync broadcast, replication, watch, client, and RPC state. +2. Leave the active Trystero room through `room.leave()`. +3. Remove LiveSync's room, advertisement, diagnostic-listener, and active-instance references. +4. Pause Trystero relay reconnection and close the current relay WebSockets. + +LiveSync does not call `close()` on the `RTCPeerConnection` values returned by `room.getPeers()` during normal shutdown or from a peer-leave callback. A peer-leave callback removes LiveSync-owned advertisement and client state only. Trystero remains responsible for deciding whether an underlying shared peer is reusable, stale, or ready for idle destruction. + +The explicit disconnect operation therefore has the following contract: + +| Resource | State after the operation | +| --------------------------------- | ------------------------------------------------------------------------------------------------- | +| LiveSync P2P service and RPC room | Closed immediately. | +| Trystero room membership | Left; room actions and advertisements are no longer available. | +| Nostr relay WebSockets | Closed, with automatic reconnection paused. | +| Underlying WebRTC peer | May remain idle under Trystero ownership for reuse, but cannot carry the departed room's traffic. | + +This operation is a logical LiveSync disconnection and a physical signalling-server disconnection. It does not promise that every browser-owned WebRTC object has been destroyed synchronously. + +An explicit connect resumes relay reconnection before opening a new room. Settings application and database lifecycle replacement close the current LiveSync replicator, discard it, construct a new instance from the current settings, and open that current instance when the configured policy requires it. Commands, event handlers, and panes resolve the current service-feature result at the point of use rather than retaining an obsolete replicator. + +Lifecycle operations on one `LiveSyncTrysteroReplicator` are serialised. A close requested while an open is in progress must leave no orphan room serving, and repeated opens must not create parallel rooms. No fixed delay is inserted between close and open: readiness is determined by the actual lifecycle operation and peer discovery. + +Relay sockets retain their Trystero-provided close handlers. LiveSync pauses relay reconnection, closes the sockets, and later resumes reconnection through Trystero's public functions. It does not replace `socket.onclose`, because Trystero uses that handler to retire and recreate shared relay clients correctly. + +P2P setup follows the transport's actual ownership model. Initialising the first device resets and scans the local database, but does not attempt to lock, reset, or upload to a non-existent central remote database. Its confirmation dialogues therefore describe preparing this device and do not present warnings about overwriting a central server or an option to fetch its configuration. An additional device selects a peer once, performs Fetch once, then resumes database and Vault reflection. The generic second convergence pass remains reserved for central remote types because repeating it for P2P would ask the user to select the same peer twice. + +## Ownership + +Commonlib owns the LiveSync-specific P2P service, RPC, command, and lifecycle composition. Trystero owns WebRTC peer creation, sharing, reuse, stale detection, and destruction, as well as relay-client reconstruction. The Self-hosted LiveSync host owns the current Commonlib service-feature result and supplies the platform services used by its current replicator. + +Self-hosted LiveSync does not add a separate root Trystero dependency. Tests which must observe relay sockets resolve the exact Trystero generation owned by the locked Commonlib package, avoiding two independent transport singletons in one process. + +## Alternatives rejected + +### Close every value returned by `room.getPeers()` + +This bypasses Trystero's shared-peer manager and can prevent a replacement replicator from rediscovering the same peer. + +### Add a fixed close-to-open delay + +A timing guess does not repair stale ownership and would make ordinary settings application slower. + +### Keep raw close behaviour behind a force command + +Changing the command name does not make the lifecycle safe. A force command which cannot reconnect predictably has no reliable operational value. + +### Override relay `onclose` to suppress reconnection + +This interferes with Trystero's shared relay clients. The public pause and resume functions provide the intended control boundary. + +## Verification + +Commonlib unit tests prove that normal P2P host closure calls `room.leave()` without directly closing Trystero-owned peer connections. Additional package tests cover the action API, replaceable peer-event subscriptions, multiple RPC transport disposers, serialised open and close operations, initialisation of the first device without a central remote, and Fetch running once for an additional device. + +Self-hosted LiveSync unit tests prove that settings and database replacement leave panes on the current replicator, and that an explicit P2P rebuild bypasses the policy intended for ordinary replication. + +The canonical Compose P2P suite uses a real local Nostr relay and WebRTC implementation. It covers ordinary two-peer synchronisation, replacement of the active LiveSync replicator followed by discovery and transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. The lifecycle scenario is exposed only through a Docker test build and an injected CLI command runner; it is not part of the public CLI command surface. + +The real-Obsidian P2P Setup URI workflow creates the first device, generates the second-device URI from it, accepts each peer visibly, and verifies a two-way note round-trip through a local relay. A separate focused pane test covers the principal connection control and teardown without requiring a remote peer. Transport replacement and relay-socket lifecycle remain owned by the package and Compose tests rather than being duplicated in Obsidian. + +## Consequences + +- Replacing a P2P replicator no longer leaves host views or commands bound to an obsolete instance. +- Explicit signalling-server disconnection has a testable socket-level meaning without claiming immediate destruction of idle WebRTC objects. +- Settings which change the relay, room, passphrase, or TURN configuration can replace the whole LiveSync room safely. +- Trystero may reuse healthy peers across room lifecycles, reducing unnecessary renegotiation. +- Strict physical WebRTC teardown remains unavailable until Trystero exposes a safe ownership-aware operation. diff --git a/docs/adr/2026_07_release_notes_and_database_compatibility.md b/docs/adr/2026_07_release_notes_and_database_compatibility.md new file mode 100644 index 00000000..a444e40d --- /dev/null +++ b/docs/adr/2026_07_release_notes_and_database_compatibility.md @@ -0,0 +1,103 @@ +# Release notes and database compatibility gates + +## Status + +Accepted for the 1.0 release line. + +## Context + +Self-hosted LiveSync historically used several unrelated kinds of version and settings state during start-up. + +The plug-in SemVer was converted into a numeric major/minor value and stored in `lastReadUpdates`. The settings dialogue used that value to open the change log automatically, and offered a button which marked the release line as read. Patch versions were intentionally ignored. Pre-release identifiers containing an additional dot did not fit this numeric representation and could be interpreted as a much larger release line. + +Separately, the internal database compatibility constant `VER` is recorded in device-local storage under a Vault-scoped key. Crossing this internal version used to set `versionUpFlash` and permanently change several automatic synchronisation settings to `false`. Replication services already reject work while `versionUpFlash` is non-empty, so changing the user's saved choices duplicated the runtime safety gate and required manual reconstruction after acknowledgement. + +The remote `obsydian_livesync_version` document also carries the internal database version. It is a protocol and data-compatibility mechanism, not a copy of the plug-in SemVer. + +Commonlib's `settingVersion` describes the stored settings shape, while `DEFAULT_SETTINGS` was historically used both to complete missing values in an existing document and to initialise a new Vault. Those operations require different defaults: an existing Vault needs conservative completion, while a genuinely new Vault can use current recommendations without changing an established configuration. + +## Decision + +### Release notes + +- Keep the change-log pane and render the current release history whenever it is opened. +- Remove automatic unread-version tracking, the acknowledgement button for ordinary release notes, and automatic navigation to the change-log pane. +- Do not derive data-compatibility behaviour from the plug-in's major, minor, patch, or pre-release identifiers. +- Retain the saved `lastReadUpdates` field in the settings schema for backwards compatibility, but do not use it in the plug-in. It can be removed through a separately reviewed settings-schema migration if retaining it later becomes burdensome. + +### Settings schema and initial settings + +- Keep the Commonlib settings schema version independent of the plug-in SemVer and the internal database version `VER`. Increment it only for an ordered change to the stored settings shape. +- Use Commonlib's conservative schema defaults to complete an existing settings document. Explicit stored values take precedence, and an ordinary migration does not disable or replace the person's synchronisation choices. +- Use `createNewVaultSettings()` only for a store which has never held Self-hosted LiveSync settings, explicit new-user onboarding, a factory reset, or CLI settings creation. Setup URI, QR, Markdown, and other existing-setting imports retain conservative completion semantics. +- Apply remote-specific preferred values only when that remote is explicitly selected during setup. Do not infer and merge new recommendations into an existing configuration. +- Keep settings saved by a future Commonlib schema fail-closed and do not persist an apparent downgrade migration. + +The current new-Vault base selects a 50 MB maximum synchronised file size, Rabin–Karp chunk splitting, Plug-in Sync V2, case-insensitive file-name handling, and E2EE V2. It does not enable synchronisation, encryption, or a remote connection without user action. Chunk revisions are always content-derived; `doNotUseFixedRevisionForChunks` remains only as deprecated compatibility input and is not a recommendation or review setting. + +Relative to the conservative existing-setting fallbacks, only Plug-in Sync V2 and the explicit case-insensitive value differ for a new Vault. The 50 MB limit, Rabin–Karp splitter, and E2EE V2 already match the legacy fallback values. Data Compression, Eden, V1 dynamic iteration, the legacy IndexedDB adapter, Hidden File Sync, and automatic synchronisation remain disabled when absent from an existing settings document. + +An existing settings document without an explicit `handleFilenameCaseSensitive` choice is normalised to `false` and saved. This preserves the effective case-insensitive branch used by earlier releases when the value was absent, and does not require a review or rebuild. An explicit `true` or `false` choice remains unchanged. + +Keep configured-state inference separate from new-Vault initialisation. If an existing legacy document has no `isConfigured` value, repeat the pre-1.0 comparison with the conservative defaults: a default-equivalent document remains unconfigured, while a non-default stored value is evidence that it was configured. Persist that inferred boolean so a migration cannot turn an unconfigured Vault into an irreversible configured state merely because its settings document was non-empty. + +### Database compatibility + +- Continue to use the internal database version `VER` for changes which require explicit compatibility review. Changing the plug-in SemVer alone does not increment `VER`. +- Store the last acknowledged internal database version through Commonlib's device-local small-configuration contract under `database-compatibility-version`. Copy the legacy raw local-storage value into that contract once, then remove the legacy key after the copy has completed. +- Initialise the marker to the current `VER` only when Commonlib identifies a genuinely new Vault with no pending review. A configured existing Vault with a missing or invalid marker requires review instead of being silently accepted. +- Defer database compatibility evaluation for an existing unconfigured Vault. It cannot replicate, so do not persist a misleading pause or acknowledge its missing marker while onboarding is still pending. Keep the marker absent so a later configured start evaluates the same state before ordinary synchronisation. +- Treat a missing marker on a configured Vault as an ambiguous device transition. Copying or restoring a Vault, or opening it with a new Obsidian profile, can preserve settings and database files without preserving device-local storage. Do not infer acknowledgement from an empty local database: a recovery operation, partial copy, or remote-first setup can also produce that state. Explain these cases and require an explicit decision in the compatibility dialogue. +- Derive one structured pause from the acknowledged database version, Commonlib's settings-migration state, and any persisted legacy review message. Persist the generic `versionUpFlash` message without changing any automatic synchronisation setting, because Commonlib already treats that field as a replication gate. +- Treat non-empty `versionUpFlash` as a runtime replication gate. Standard and one-shot replication must stop before remote work begins. +- Apply the same ordinary replication policy to P2P pull, push, and peer-requested synchronisation. An explicitly confirmed Fetch or Rebuild may bypass the ordinary policy because it is the operation selected to construct or recover the local state. +- Present the reason in a dedicated dialogue after the Obsidian layout is ready. The details view is explanatory only and returns to the summary before any decision can be made. The safe default and closing either dialogue keep synchronisation paused. A persistent Notice and a command allow the dialogue to be reopened without using the settings pane. +- Let the person read focused compatibility details without presenting the whole release history as a safety instruction. The Change Log remains a manually opened release-history pane and contains no compatibility acknowledgement control. +- Offer an explicit resume action only when every reason is recoverable in the running implementation. An upgrade, a missing or invalid marker on an existing Vault, and a reviewed migration from an older settings schema are resumable after all devices have been updated. A downgrade from a newer acknowledged `VER`, or settings saved by a future schema, cannot be acknowledged by the older installation. +- On resume, clear `versionUpFlash` and persist that fail-closed change before recording the current `VER` as acknowledged. If saving fails, restore the gate. Reapply settings only after the marker has advanced so that the previously configured synchronisation behaviour can resume without reconstruction. +- Preserve the original legacy review message as a structured reason when no more specific database or settings-schema reason is available. Escape it before including it in Markdown UI. +- Continue to reject a remote version document which is newer than the running implementation. That receiver-side check is independent of the local upgrade review. + +### Onboarding activation and initialisation + +- Keep an unconfigured Vault outside database initialisation, offline scanning, and configured-only checks. Offer setup through the long-lived onboarding Notice, and allow the wizard to be reopened from the Setup pane instead of opening a competing dialogue automatically. +- For new-device onboarding, reserve Rebuild before enabling and saving the accepted settings. +- For an unconfigured existing device, reserve Fetch before enabling and saving imported or manually confirmed settings. +- Suspend the current runtime after the flag has been written, apply the accepted settings through the scheduler's preparation callback, and request restart only after that callback succeeds. +- If the flag cannot be written, do not enable the settings. If preparation fails, remove the flag, resume the current runtime, and leave the transition incomplete. +- Applying compatible settings to an already configured device remains an ordinary edit and does not schedule Fetch automatically. + +`isConfigured`, the Fetch and Rebuild flags, and in-memory suspension therefore retain separate meanings. `isConfigured` controls participation in ordinary processing, a flag selects a one-shot operation for the next start, and suspension prevents the old process from observing newly enabled settings before that selected restart. + +### Flag-file recovery order + +- For a configured Vault, evaluate and persist the compatibility gate after settings load, before Obsidian layout-ready recovery begins. This blocks ordinary and one-shot replication even while the review dialogue has not yet opened. An existing unconfigured Vault follows the deferred rule above instead. +- Preserve the existing ordered flag-file recovery handlers: SCRAM at priority 5, fetch-all at priority 10, and rebuild-all at priority 20. These files express an explicit recovery instruction and may invoke their focused storage or rebuild service while ordinary replication remains gated. +- Present the compatibility review at priority 30, after any selected recovery operation. A recovery handler which cancels start-up, keeps SCRAM active, or schedules a restart returns `false`, so the current process does not open a competing compatibility dialogue. If recovery completes and start-up continues, the dialogue opens before normal synchronisation is allowed to resume. +- Keep database preparation independent of an unanswered compatibility dialogue, because the compatibility gate already blocks replication. Before Config Doctor begins its interactive checks, await the active initial review so that the two update dialogues cannot overlap. +- Never mark compatibility as acknowledged merely because fetch, rebuild, or local database reset completed. The person must still use the explicit resume action. This keeps destructive recovery intent separate from protocol and settings compatibility acknowledgement. + +## Consequences + +- Ordinary releases no longer force the settings dialogue to show release notes. Important operational instructions must be clear in the published release notes and any explicit migration notice. +- SemVer pre-releases such as `1.0.0-rc.0` no longer require a special numeric encoding inside plug-in settings. +- An internal compatibility change remains fail-closed for replication, but it no longer destroys the person's synchronisation preferences. +- A new installation has no previous internal-version marker and therefore does not show an upgrade review. Its initial settings and onboarding remain responsible for keeping replication disabled until configuration is complete. +- An existing unconfigured installation also remains on onboarding without a compatibility warning. Unlike a genuinely new Vault, it does not receive an acknowledgement marker; activation leaves the compatibility decision for its next configured start. +- A copied or restored configured Vault can show a one-time compatibility review on its new device or profile. This is intentional even when its local database appears empty, because emptiness does not prove how the Vault was produced. +- A genuinely new Vault receives current recommendations without applying them as fallbacks to an existing configuration. It remains inert until onboarding is accepted. +- Accepted new-device and existing-device setup cannot enable ordinary processing before the selected Rebuild or Fetch has been reserved. +- An older installation cannot dismiss evidence that a newer implementation or settings schema has already been used on the device. +- The Obsidian-specific dialogue depends only on a host-neutral compatibility result and the injected confirmation capability. Commonlib remains responsible for settings migration, device-local storage, and the replication gate. +- A future incompatible database change must increment `VER`, provide an actionable review message, verify the remote version negotiation, and test both the pending and acknowledged states. A major SemVer increase without those changes has no database-compatibility effect. + +## Verification + +- Unit tests verify new-Vault initialisation, upgrades, missing and invalid markers, downgrades, future settings schemas, legacy marker migration, acknowledgement ordering, and save-failure recovery while retaining automatic synchronisation choices. +- Commonlib package tests verify conservative stored-setting completion, independently mutable new-Vault settings, legacy file-name case normalisation, future-schema protection, and the focused settings entry from a clean consumer. +- Host unit tests verify new-Vault factory use, conservative import paths, the unconfigured start-up gate, deferred compatibility evaluation and later re-evaluation, flag-before-settings ordering, rollback when the flag cannot be reserved, ordinary configured edits, and compatibility acknowledgement persistence. +- Unit tests verify that a pending review is honoured by the packaged Commonlib replication service before remote activity begins. +- Unit and Compose tests verify that ordinary P2P replication observes the policy, explicit P2P rebuild uses the setup bypass, and replacement leaves host actions on the current replicator. +- A real-Obsidian settings test verifies the dedicated summary and details dialogues, captures representative screenshots, confirms that the acknowledged internal version advances only after explicit resume, and confirms that the Change Log contains no acknowledgement control. +- The real-Obsidian CouchDB workflow starts from configured plug-in data without a device-local marker, verifies the copied-or-restored Vault explanation, resumes through the actual dialogue, and then completes remote metadata, chunk, and activity checks. The two-Vault workflow performs the same review once per isolated Vault before reusing the acknowledged device state for later process launches. +- Unit tests fix the layout-ready priority after the three flag-file recovery priorities, so a recovery which stops start-up cannot race the compatibility dialogue. diff --git a/docs/adr/2026_08_fast_fetch_persistence_and_completion.md b/docs/adr/2026_08_fast_fetch_persistence_and_completion.md new file mode 100644 index 00000000..8a492737 --- /dev/null +++ b/docs/adr/2026_08_fast_fetch_persistence_and_completion.md @@ -0,0 +1,351 @@ +# Architectural Decision Record: Fast Fetch Persistence and Completion Semantics + +## Status + +Accepted + +## Context + +Fast Fetch accelerates Fast Setup (Simple Fetch) by reading CouchDB's continuous +changes feed directly, decrypting each document, and writing batches to the local +database. It then allows LiveSync to reflect the completed database into the +Vault. + +This path deliberately bypasses PouchDB's ordinary replication machinery. It +must therefore reproduce the correctness guarantees on which the rest of the +initialisation workflow relies: + +- a remote document is decrypted and validated before it is written locally; +- every result from a batch write is checked; +- a checkpoint represents the last contiguous remote sequence which is durable + in the local database; and +- successful completion requires CouchDB to terminate every finite changes page, + every returned row to be durable, and a subsequent normal probe to report no + available rows. + +The existing implementation combines line parsing, decryption, persistence, and +completion checks within one broad error handler. A decryption or persistence +failure can consequently be reported as a malformed JSON line and skipped. A +batch write can also resolve while containing individual failed results. In both +cases the stream may continue, advance its checkpoint incorrectly, or wait +indefinitely for a completion condition which the failed row would have +satisfied. + +Fast Fetch also reads the normal changes feed before opening each continuous +page. A normal response's `pending` value counts items which remain after the +response's `results`, so `pending` alone is not the available workload. With a +one-row probe, the page can contain `results.length + pending` rows. + +The documented `limit=0` behaviour cannot be used as a portable zero-payload +probe. CouchDB's API documentation says that `limit=0` has the same effect as +`limit=1`, while CouchDB 3.5.0 with a two-shard database was observed to return +no result rows and leave the complete count in `pending`. Fast Fetch therefore +uses an explicit one-row normal probe and includes no document bodies. + +Finite continuous-feed completion differs across supported CouchDB releases. +CouchDB 3.5.0 was observed to close a heartbeat-enabled feed with a +`{ "last_seq": ... }` line when its finite `limit` is met. CouchDB 3.2 instead +continues to wait for database updates after the limit has been consumed. With +a heartbeat configured, each wait emits another heartbeat and the page can +remain open indefinitely, even after every requested row has arrived. + +On CouchDB 3.2, an explicit `timeout` without a heartbeat has different +semantics from a total request deadline. Shard-result waits may emit blank +keep-alive lines and continue processing. Once the currently available changes +have been exhausted and the feed is waiting for another database update, the +timeout stops that wait and returns the feed-level `last_seq`. The timeout can +therefore terminate a finite page without limiting the duration of an active +page transfer. + +The CouchDB sequence token is opaque and must be handled using CouchDB's +sequence semantics, without parsing, ordering, or comparison. On clustered +CouchDB, a changes row and the feed-level `last_seq` may encode related +positions with different opaque tokens. Separate requests are also not one +locked snapshot: their rows may be partially ordered, and replica failover may +repeat changes. Fast Fetch must therefore be idempotent and must use each +terminal `last_seq` only by returning it to CouchDB as the next `since` value. + +## Decision + +### Remote page sizing and completion + +Fast Fetch obtains an approximate progress target from a normal changes-feed +request with `since=now`, `limit=1`, and `include_docs=false`. This token is for +progress reporting only. It is not compared with any other token and is not +used as a completion checkpoint. + +Before every bounded page, Fast Fetch requests a normal changes feed from the +current durable cursor with `limit=1` and `include_docs=false`. The probe and +the following continuous page use the same `since`, style, and filter +selection. Reading the probe does not consume rows from CouchDB; the continuous +request starts again from that same cursor. + +The number currently available is `results.length + pending`. If it is zero, +Fast Fetch is caught up and completes without opening another stream. Otherwise, +the next continuous request uses the smaller of that count and 10,000 as its +finite `limit`. + +Each finite page omits `heartbeat` and sets `timeout=1000`. This lets CouchDB +3.2 return the page's terminator one second after it exhausts the currently +available changes, rather than keeping the request open for future writes. The +client immediately reconnects from that terminator while another normal probe +reports available work. This bounded cycle also preserves the intent of the +earlier iOS and iPadOS heartbeat workaround: Fast Fetch no longer depends on a +silent continuous request eventually closing at CouchDB's default 60-second +timeout. + +The probe and page are separate HTTP requests, not a transactional snapshot. +New writes, replica selection, or administrative changes may alter the rows +between them. A page which returns at least one row and a valid terminator may +therefore be shorter than the probe's estimate. Fast Fetch persists that page +and probes again. A page which terminates without making progress after a +positive probe is a retryable transport failure, avoiding an unbounded busy +loop. + +Each continuous request ends with its own `{ "last_seq": ... }` line. Fast +Fetch treats that line separately from a changes row, flushes and validates all +preceding local writes, and only then persists the opaque `last_seq`. The exact +value is replayed as the next request's `since`; it is never parsed, ordered, or +compared with a row's `seq`, another request's `last_seq`, or the database's +`update_seq`. + +The limit counts outer changes-result rows. A tombstone is one row and consumes +one page slot even when no document body is present. With `style=all_docs`, +multiple leaf revisions inside one row's `changes` array do not consume +additional slots. Changing `include_docs` between the lightweight probe and the +document-bearing continuous page changes the payload, not the row selection. + +### Processing and persistence + +Each non-blank line from the changes feed is processed through these ordered +stages: + +1. parse and validate the changes-feed row; +2. decrypt and validate its document, when a document is present; +3. add the document to the pending local batch; +4. persist the batch; +5. inspect every result returned by the batch write; and +6. after the finite page ends, persist its `last_seq` terminator. + +With `new_edits: false`, PouchDB follows CouchDB behaviour and may omit successful +results. Fast Fetch therefore inspects every returned result and treats any +error result as a failed batch. An empty result is valid when PouchDB accepted +the complete batch. + +The checkpoint may advance only to the last contiguous sequence for which all +preceding documents are durable. A row which legitimately requires no local +write may advance the checkpoint only after any preceding buffered documents +have been flushed successfully. A page's `last_seq` is committed under the same +rule before that page reports success. + +If a batch is partly written, its checkpoint is not advanced. Retrying the batch +with `new_edits: false` is expected to be idempotent, including for documents +which were accepted during the first attempt. + +Blank heartbeat lines are ignored. Malformed rows are failures; they are not +silently skipped. Logs may describe the stage and sequence involved, but must +not include the raw changes-feed line because it may be large or sensitive. + +Each finite continuous changes request and its decoded reader must be terminated +on every exit. Releasing a reader lock alone does not cancel the underlying +request. Failure and completion paths therefore abort the request and attempt +to cancel the reader before the bounded remote-activity scope ends. + +### Failure classification and retry + +The streaming boundary returns a small structured failure with a stage and an +explicit retryability decision. The initial stages are: + +```typescript +type StreamingFetchFailureStage = "transport" | "authentication" | "protocol" | "decryption" | "storage"; +``` + +This type is an internal behavioural contract, not a user-interface status +enumeration. It may carry safe diagnostic context, such as an HTTP status or a +sequence token, without carrying document content. + +Only explicitly recognised transient transport failures are retried +automatically. Examples include an interrupted connection, HTTP 408, HTTP 429, +and selected HTTP 5xx responses. Authentication, protocol, decryption, and local +storage failures are terminal by default. A future implementation may recognise +a narrower retryable case, but it must do so explicitly rather than retry every +exception. + +Each retry resumes from the last durable contiguous checkpoint. Retry exhaustion +returns an actionable classified failure to the caller. + +### Initialisation lifecycle + +Fast Fetch success is the only path which may continue with the offline scan, +finish the rebuild, resume Vault reflection, clear the Fast Fetch checkpoint, +remove the flag file, or forget the remembered initialisation choice. + +The LiveSync initialisation boundary uses an explicit suspension policy. +Ordinary Fetch and Rebuild resume Vault reflection when they finish, SCRAM keeps +file watching suspended, and Fast Fetch keeps both file watching and replication +result parsing suspended only when initialisation fails. Fast Fetch asserts both +suspensions before it begins and owns their final state: success clears both, +whereas a false result or exception sets both. This final assignment also covers +a late failure after rebuild finalisation and the legacy +`doNotSuspendOnFetching` path. + +On failure: + +- the local checkpoint and any durably fetched documents are retained for a + later retry; +- the local database is not marked as resolved; +- Vault reflection remains suspended; +- the offline scan and rebuild finalisation are not run; and +- the flag file and remembered initialisation choice remain available so that + restart recovery can offer the same operation again. + +Any bounded remote-activity scope must still be released in a `finally` path, as +defined by [Bounded Remote Activity](2026_07_bounded_remote_activity.md). Keeping +Vault reflection suspended does not permit a wake lock or similar resource to +leak. + +## Ownership + +The responsibilities are divided at three injectable boundaries. + +### Streaming Fetch + +The Commonlib streaming implementation owns HTTP response validation, NDJSON +parsing, invocation of the decryption delegate, batch-write result validation, +contiguous checkpoint advancement, finite-page completion, and classified +failures. It does not know about the Vault, setup dialogues, flag files, or +LiveSync settings. + +### Rebuilder + +The Commonlib rebuilder owns the local database lifecycle, checkpoint storage, +retry policy, marking a completed database as resolved, optional resumption of +reflection, and final checkpoint removal. It does not parse changes-feed rows or +interpret user-interface choices. + +### LiveSync Fast Setup + +LiveSync owns the setup choices, suspension of initial Vault reflection, +invocation of Fast Fetch, the success-only offline scan and rebuild finalisation, +and cleanup of flag files and remembered choices. It does not reinterpret +document, encryption, or storage failures as successful setup. + +## Non-Goals + +This decision does not: + +- change the Metadata and Chunks formats, encryption scheme, security seed, or + path obfuscation; +- change ordinary PouchDB replication, Standard Fetch, or offline-scan + semantics; +- provide a transaction spanning CouchDB and the local database; +- skip corrupt or unreadable documents and continue with a partial database; +- add an automatic fallback from Fast Fetch to Standard Fetch; +- define the detailed failure dialogue or other setup user-interface changes; + or +- provide a transaction or locked snapshot across the normal probe and the + following continuous page. + +An explicit Standard Fetch choice remains available when a user needs the +ordinary replication path. Any automatic fallback or richer recovery dialogue +requires a separate decision because it changes user-visible setup behaviour. + +## Verification + +The implementation is verified primarily with London School interaction tests, +using mocks at each owned boundary to prove collaboration and call order. + +### Streaming Fetch unit tests + +Inject the HTTP stream, decryption delegate, local batch writer, and checkpoint +writer. Verify that: + +- the order is decrypt, persist, inspect results, then checkpoint; +- parsing, decryption, and batch-result failures prevent checkpoint advancement + and completion; +- a partly failed batch leaves the checkpoint unchanged and reports a storage + failure; +- rows without a local write flush earlier buffered documents before advancing; +- a returned probe row is counted in addition to `pending`, including when + `pending` is zero; +- every probe uses `limit=1`, excludes document bodies, and is repeated from the + previous page's opaque terminator; +- each bounded page omits `heartbeat`, uses `timeout=1000`, and can complete + under CouchDB 3.2 after its current rows have been delivered; +- deletion and document-less rows consume a page slot; +- a row count cannot complete a page without its `last_seq` terminator; +- a page terminator cannot advance the checkpoint before its batch is durable; +- a final row and `last_seq` with different opaque representations complete + normally without a token comparison; +- a shorter valid page is persisted and followed by another probe, while a + zero-row page after a positive probe fails without looping; +- workloads over 10,000 rows resume from each durable finite-page checkpoint; +- authentication and malformed-protocol responses are terminal; +- recognised transient transport failures are classified as retryable; and +- diagnostics do not log the raw changes-feed line. + +### Rebuilder unit tests + +Inject the streaming operation and lifecycle collaborators. Verify that: + +- transient failures retry from the latest durable checkpoint; +- terminal failures are attempted once; +- success marks the database as resolved, resumes reflection when requested, and + clears the checkpoint in that order; and +- failure does not mark the database as resolved, resume reflection, or clear + the checkpoint. + +### LiveSync orchestration unit tests + +Inject Fast Fetch, the offline scanner, rebuild finalisation, and cleanup +collaborators. Verify that failure performs none of the success-only actions and +leaves initial Vault reflection suspended. Verify that success retains the +existing setup sequence and cleanup. + +### Integration and E2E tests + +Commonlib's CouchDB integration test remains responsible for the real HTTP +changes feed, opaque sequence tokens, deletion rows, and local batch +persistence. It should use the maintained CI CouchDB release, a two-shard +database, and a data set large enough to cross a local batch boundary, and +confirm that the final checkpoint can be passed back to CouchDB as `since` with +no result rows or pending changes. The test must not compare that token's +representation with a separately requested target or changes-row token. The +focused regression test covers CouchDB 3.2's page-tail behaviour; compatibility +with a real CouchDB 3.2 server can be confirmed manually without expanding the +permanent CI matrix. + +LiveSync's real Obsidian Setup URI workflow remains responsible for the actual +Fast Fetch selection, E2EE passphrase, Vault reflection, ordinary file round +trip, and hidden-file synchronisation. Injected parsing, decryption, and +persistence failures remain unit-test responsibilities; repeating them through +the real Obsidian E2E does not add coverage for an unchanged framework boundary. +This follows [Real Obsidian E2E](2026_06_real_obsidian_e2e.md). + +## Consequences + +- A deterministic document failure which previously appeared to be skipped now + fails Fast Fetch. This is an intentional safety improvement because the local + database is known to be incomplete. +- Partial durable work and its contiguous checkpoint can be reused by a later + attempt without exposing the partial database to the Vault. +- Retry delays are no longer spent on authentication, corrupt content, protocol, + or local persistence failures which cannot repair themselves. +- Progress totals remain approximate and may grow when a later probe observes + new work, without affecting correctness. +- A completed page can spend up to one second waiting for its terminator before + Fast Fetch probes and reconnects. Active page transfer is not constrained to + one second. +- The implementation requires coordinated changes in Commonlib and LiveSync. + Commonlib remains the authoritative package for streaming and rebuilder + behaviour; LiveSync consumes an immutable Commonlib release and owns its setup + orchestration. +- Ordinary replication remains unchanged and continues to provide the reference + correctness contract for decrypting, persisting, and checkpointing replicated + documents. + +## References + +- [Apache CouchDB changes-feed API](https://docs.couchdb.org/en/stable/api/database/changes.html) +- [Apache CouchDB 2.0 upgrade notes for opaque update sequences](https://docs.couchdb.org/en/stable/whatsnew/2.0.html#upgrade-notes) +- [Apache CouchDB replication protocol](https://docs.couchdb.org/en/stable/replication/protocol.html) diff --git a/docs/datastructure.md b/docs/datastructure.md index c6de91ae..e923ab5e 100644 --- a/docs/datastructure.md +++ b/docs/datastructure.md @@ -1,4 +1,5 @@ # Data Structures of Self-Hosted LiveSync + ## Overview Self-hosted LiveSync uses the following types of documents: @@ -30,7 +31,7 @@ export interface DatabaseEntry { This document stores version information for Self-hosted LiveSync. The ID is fixed as `obsydian_livesync_version` [VERSIONING_DOCID]. Yes, the typo has become a curse. When Self-hosted LiveSync detects changes to this document via Replication, it reads the version information and checks compatibility. -In that case, if there are major changes, synchronisation may be stopped. +This internal database version is independent of the plug-in's SemVer version. The last version explicitly acknowledged on a device is stored through Commonlib's device-local configuration contract. When that version differs, or when a settings migration requires review, Self-hosted LiveSync presents a dedicated compatibility dialogue and blocks replication without changing the user's automatic synchronisation choices. A supported upgrade can resume only after explicit review. A downgrade from a newer acknowledged database version, or settings written by a future schema, remains blocked until a compatible plug-in is installed. Please refer to negotiation.ts. ### Synchronise Information Document diff --git a/docs/design_docs/chunk_retrieval_and_waiting.md b/docs/design_docs/chunk_retrieval_and_waiting.md new file mode 100644 index 00000000..81038d92 --- /dev/null +++ b/docs/design_docs/chunk_retrieval_and_waiting.md @@ -0,0 +1,118 @@ +# Chunk Retrieval and Waiting + +## Purpose + +This document records how LiveSync retrieves chunks after file metadata has been found, which operation provides each terminal condition, and what the remaining time value means. It is an implementation specification for developers; it is not a user configuration guide. + +The architectural decision and historical rationale are in [Chunk Arrival Quiescence](../adr/2026_07_chunk_arrival_quiescence.md). + +## Invariants and Sources of Apparent Reordering + +A normal local save creates and persists the chunks before it writes the metadata document which refers to them. LiveSync must preserve this invariant: publishing metadata first can expose a reference which no client can satisfy. + +This ordering is not an atomic transaction across documents. A reader may still see metadata before a referenced chunk for these reasons: + +- CouchDB replication transfers individual documents and does not expose the chunk and metadata writes as one atomic unit. +- With `readChunksOnline` enabled, CouchDB pull replication deliberately excludes chunk documents. Seeing metadata first is then the intended design, and the chunk is fetched by identifier. +- A winning metadata conflict revision may refer to chunks created by another revision or client which have not yet reached the local database. +- Replication persists documents before every downstream change callback and file-reflection task has necessarily observed them. +- Historical versions and removed transfer modes may have produced data which did not preserve the normal ordering invariant. + +Waiting may resolve temporary visibility and processing gaps only when a known operation can still deliver the chunk. It cannot repair a chunk which is absent from every available source. + +## Retrieval Capabilities + +Direct on-demand fetch is currently available only for CouchDB when `useOnlyLocalChunk` is false. This capability deliberately does not depend on `readChunksOnline`: + +- when `readChunksOnline` is true, direct fetch is the normal way to obtain a chunk omitted from pull replication; and +- when `readChunksOnline` is false, direct fetch remains a recovery path if a normally replicated chunk is locally absent. + +MinIO's sequential replicator and P2P do not implement direct `fetchRemoteChunks` delivery through this path. Their chunks must arrive through a finite replication operation. A P2P pull or bidirectional synchronisation is such a producer. A push-only P2P request remains broad remote activity for Wake Lock and lifecycle reporting, but it is deliberately excluded from `finiteReplicationActivityCount` because it cannot deliver a local document. + +`waitForReady` is a call-site policy, not a persisted user setting. `true` permits waiting for an already-observable producer. `false` normally requests an immediate local result, except that CouchDB on-demand delivery still waits for the claim which synchronous dispatch creates. + +## Policy Matrix + +The matrix selects whether lifecycle waiting is permitted and whether the waiter may dispatch a direct request. It does not assign elapsed arrival budgets. + +| Remote | `waitForReady` | `useOnlyLocalChunk` | Direct fetch | Wait for observed producer | Intended behaviour | +| ------- | -------------: | ------------------: | -----------: | -------------------------: | --------------------------------------------------------------------------------------- | +| CouchDB | `false` | `false` | Yes | Yes | Dispatch on-demand fetch and finish at its per-identifier claim boundary. | +| CouchDB | `true` | `false` | Yes | Yes | Accept an active finite replication or dispatch direct fetch. | +| CouchDB | `false` | `true` | No | No | Return immediately after the local miss. | +| CouchDB | `true` | `true` | No | Yes | Wait for an already-active finite replication; otherwise return unavailable. | +| MinIO | `false` | Either | No | No | Return immediately after the local miss. | +| MinIO | `true` | Either | No | Yes | Wait for an already-active finite sequential replication; otherwise return unavailable. | +| P2P | `false` | Either | No | No | Return immediately after the local miss. | +| P2P | `true` | Either | No | Yes | Wait for an already-active finite P2P replication; otherwise return unavailable. | + +For CouchDB, `readChunksOnline` changes what normal replication includes, not the direct-fetch capability or this matrix: + +| `readChunksOnline` | CouchDB pull contains chunks | Role of direct fetch | +| -----------------: | ---------------------------: | ------------------------------------------------------------------------ | +| `true` | No | Primary chunk delivery after metadata arrives. | +| `false` | Yes | Recovery fallback for a chunk which is unexpectedly unavailable locally. | + +`concurrencyOfReadChunksOnline` and `minimumIntervalOfReadChunksOnline` affect only the scheduling of CouchDB on-demand requests. They do not change whether a request may be dispatched or which lifecycle a reader observes. Accepted identifiers remain claimed while they wait for a concurrency slot and while the configured interval is applied. A minimum interval of five minutes or more is an exceptional value: the inactivity fuse may release the logical claim before that deliberate pause completes. This safety precedence does not abort the delayed physical request. + +## Wait State Machine + +1. Read the cache and local database. +2. If every requested chunk is present, return it without entering a wait. +3. Register one shared waiter per missing identifier. +4. If policy permits direct fetch, emit `missingChunks`. `ChunkFetcher` synchronously creates the per-identifier claim before the event dispatch returns. +5. Observe both the matching claim and `finiteReplicationActivityCount`. +6. Resolve immediately if a valid chunk or explicit remote-missing event arrives. +7. If an observed producer remains active, do not charge elapsed time against an arrival budget. +8. When all observed producers end, bypass the cache and read the identifiers from the local database once. +9. Return the rechecked chunk, or return unavailable. Do not add another fixed grace after the authoritative boundary. +10. If no producer is observable after synchronous dispatch, return unavailable immediately. + +If new relevant activity starts while the final database recheck is pending, that result becomes stale. The waiter remains active until the newer producer completes and a current recheck finishes. + +## Meaning of Finite Replication Completion + +Finite replication enters the typed `runFiniteReplicationActivity` boundary and is represented by the narrower `finiteReplicationActivityCount`. The optional `replication` label remains diagnostic and does not control this behaviour. + +For a successful finite operation, completion means that its replicator has reached the latest sequence in the operation's scope and processed its replication change callbacks. No more database documents can arrive from that operation. This is the primary semantic cutoff. + +If the operation fails, it has not proved remote absence or latest state. It has nevertheless stopped being a producer. The waiting layer rechecks documents which may have arrived before the failure and then returns unavailable; the replication error and retry workflow owns further recovery. + +Overlapping finite replications keep the count above zero until the final operation settles. The local recheck therefore occurs only after every observed finite producer is quiescent. + +The continuous live channel is intentionally excluded because it has no completion boundary and would otherwise make a chunk read unbounded. The pull-only catch-up run before opening that channel is finite and enters the same typed boundary. Its one-shot batch-size fallback remains inside that boundary. A live-channel fallback starts another continuous attempt and therefore another bounded initial catch-up. + +## Meaning of an On-demand Claim + +An accepted identifier remains claimed from synchronous queue acceptance through throttling, physical fetch, validation, local persistence, and terminal event delivery. The claim is identifier-scoped because a global remote-work count cannot say whether unrelated work can provide this chunk. + +The claim finishes when the fetcher has recorded an outcome for the identifier. A transport error, missing active replicator, or invalid result releases the claim without emitting an explicit remote-missing result unless the remote actually supplied that information. + +## Meaning of the Five-minute Value + +The five-minute value is an inactivity leak fuse for an accepted on-demand claim. It is the only elapsed duration in this state machine, and it is not a normal terminal condition. + +The fuse bounds retention if a faulty activity runner never enters its task, a Promise never settles, or a transport stops making observable progress. It prevents the per-identifier claim and waiter from remaining live forever. Once the bounded activity callback has entered, releasing the claim also allows Wake Lock, application-lifecycle deferral, and the remote-work indicator associated with that callback to finish. Observable progress rearms the fuse. + +Five minutes is a conservative operational ceiling rather than a measured chunk-arrival expectation. It must not be used to infer that the remote lacks a chunk, and it does not abort the physical request. `fetchRemoteChunks` does not yet accept an `AbortSignal`, so the request may complete after the logical state has been released. Transport cancellation and transport-specific deadlines are separate future work. + +The old 5-second and 30-second constants remain exported for source compatibility only. A positive deprecated `ChunkReadOptions.timeout` opts into lifecycle waiting, but its numeric value is ignored. Zero or a negative value still requests an immediate result. New code uses `waitForDelivery` explicitly. + +## Test Obligations + +Changes to this behaviour must keep automated coverage for: + +- chunks-before-metadata save ordering; +- every row in the retrieval policy matrix; +- a finite replication which remains active well beyond the former 5-second and 30-second values; +- successful completion with the chunk already persisted but no arrival event delivered; +- immediate unavailability when no producer is observable; +- overlapping finite operations and overlapping per-identifier claims; +- activity restarting while a local recheck is pending; +- direct fetch queueing, throttling, persistence, and terminal notification; +- explicit remote absence versus transport or replicator failure; +- runner rejection, cancellation, teardown, and an operation which never enters its task; +- leak-fuse refresh at observable progress points; and +- continuous replication's finite initial catch-up and parameter fallback. + +The service, database, and event boundaries are testable with memory-backed PouchDB and injected activity sources. A real Obsidian test is required only when a change crosses into the platform adapter, application lifecycle, or visible UI rather than for this retrieval state machine alone. diff --git a/docs/p2p.md b/docs/p2p.md new file mode 100644 index 00000000..ff47468a --- /dev/null +++ b/docs/p2p.md @@ -0,0 +1,98 @@ +# How peer-to-peer synchronisation works + +Peer-to-peer (P2P) synchronisation transfers Vault data between LiveSync devices through WebRTC. It does not require a central database containing a copy of the Vault. It does require a signalling relay so that devices can discover one another and establish a connection. + +For the procedure for the first and additional devices, see [Set up peer-to-peer synchronisation](setup_p2p.md). For connection problems, see [Peer-to-Peer Synchronisation Tips](tips/p2p-sync-tips.md). + +## Connection model + +```mermaid +flowchart LR + A["Device A"] <-->|"Discovery and connection signalling"| S["Signalling relay"] + S <-->|"Discovery and connection signalling"| B["Device B"] + A <-->|"Encrypted Vault synchronisation"| B + A -.->|"Fallback encrypted WebRTC traffic"| T["TURN server"] + T -.-> B +``` + +The signalling relay and TURN server have different roles: + +- The **signalling relay** is required for peer discovery and connection negotiation. LiveSync uses Nostr-compatible WebSocket relays for this role. The relay does not store or transfer Vault contents. +- A **TURN server** is an optional fallback. WebRTC uses it to relay the encrypted peer connection only when the devices cannot establish a direct path through their networks. + +## The project's public signalling relay + +The project author operates a public signalling relay as a best-effort convenience. Selecting **Use the project's public signalling relay** means that no signalling server needs to be provisioned for an ordinary setup. + +The public relay: + +- is not a Vault storage service; +- may observe signalling metadata, such as connection timing and network addresses; +- has no availability or log-retention guarantee; and +- can be replaced with another compatible relay at any time by updating every device in the P2P group. + +Use a signalling relay which is acceptable for your privacy and availability requirements. A controlled deployment may use its own Nostr-compatible relay. + +## Signalling relay and TURN server + +Both settings contain server addresses, but they are not interchangeable. + +| Setting | Required | Carries Vault contents | Purpose | +| --- | --- | --- | --- | +| **Signalling relay URLs** | Yes | No | Finds peers and exchanges the information needed to establish WebRTC connections. | +| **TURN server URLs** | Only when direct WebRTC connectivity fails | Encrypted WebRTC traffic | Relays traffic between peers when NAT or firewall rules prevent a direct path. | + +A TURN provider cannot read LiveSync's encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust. The project does not operate an official TURN service. + +## P2P Status + +The **P2P Status** pane is the current Obsidian interface for P2P connections. + +- After a P2P configuration exists, the command **Self-hosted LiveSync: P2P Sync : Open P2P Status** is available from the command palette. +- The P2P ribbon icon appears only after a P2P configuration exists. +- LiveSync does not open the pane merely because Obsidian has started. If the pane was already part of the saved Obsidian workspace, Obsidian may restore it. +- Workspaces containing the retired P2P pane are migrated to the current status pane. The retired command is no longer exposed. + +The active P2P remote is selected independently from the main CouchDB or Object Storage remote. Devices can therefore use P2P alongside their main remote without replacing it. + +![P2P Status on desktop](../images/p2p-setup/p2p-status-pane.png) + +![P2P Status in a mobile layout](../images/p2p-setup/p2p-status-pane-mobile.png) + +**Open connection** joins the signalling room and makes the device available for discovery. **Disconnect** leaves the LiveSync room, stops its P2P replication service, and closes the signalling connections. It does not delete the saved P2P profile. + +Every participating device must use the same signalling relay set, Group ID, and P2P passphrase. Each device should have a distinct device name. A peer which joins after another device is already connected is advertised to that device; use **Refresh**, or reconnect the device which should be discovered, if a peer is not yet listed. + +## Manual and automatic data movement + +**Replicate now** performs an explicit bidirectional synchronisation with the selected peer. This is the clearest option when proving a new configuration. + +**Announce changes** and **Follow changes** provide a more continuous experience: + +- The source device must enable **Announce changes** before it dispatches change notifications. +- A receiving device must enable **Follow changes** for that peer before it fetches in response to those notifications. +- A notification contains no Vault data. It only asks the following peer to fetch through the encrypted P2P connection. +- Missing a notification does not make an explicit later synchronisation unsafe; **Replicate now** still compares the available data. + +The peer's **More actions** menu can save these choices for that device: + +- **Synchronise when this device connects** runs one synchronisation when that named peer is discovered. +- **Follow whenever this device connects** restores following for that named peer. +- **Include in the P2P synchronisation command** includes that peer when the command for registered targets is run. + +![Persistent actions for a detected peer](../images/p2p-setup/guide-p2p-setup-peer-actions-menu.png) + +Configure these only after a manual round trip has succeeded. Device names used by persistent rules should remain unique and stable. + +## Approval and privacy + +A device must approve a peer before serving its data. Permanent approval is stored; session approval lasts only for the current Obsidian session. Check the displayed device name before approving a request. + +The encrypted Setup URI contains the shared P2P configuration but deliberately omits the device-specific name. Store the Setup URI and its passphrase separately, and generate a Setup URI for another device from a first device which has completed setup. + +## Operational limits + +- At least one device which already has the required data must be online while another device fetches it. +- P2P does not provide the continuously available central copy offered by CouchDB or Object Storage. Keep independent backups. +- Mobile operating systems may pause Obsidian in the background. Keep Obsidian visible and the device awake during initial transfer, rebuild, or a large synchronisation. +- Changing from CouchDB to P2P is not a repair operation for a stopped CouchDB setup. Diagnose the existing transport first. diff --git a/docs/p2p_sync_updates_2026.md b/docs/p2p_sync_updates_2026.md index 563a6d2d..529d816f 100644 --- a/docs/p2p_sync_updates_2026.md +++ b/docs/p2p_sync_updates_2026.md @@ -1,62 +1,7 @@ -# User Guide: Peer-to-Peer Synchronisation (2026 Edition) +# Peer-to-peer synchronisation -Peer-to-Peer (P2P) synchronisation has evolved significantly. This guide covers the essential setup and the new features introduced in the 2026 updates. +This address is retained for links to an earlier P2P guide. The time-specific interface description has been replaced by stable documentation: -## 1. Core Concept: Server-less Freedom -P2P synchronisation allows your devices to talk directly to each other using WebRTC. A central server is not required for data storage, ensuring maximum privacy and "freedom." - -## 2. Setting Up via P2P Status Pane -You no longer need to navigate through complex menus. Simply open the **P2P Status** (via the ribbon icon or command palette) and click the **⚙ (Cog)** icon. - -This opens the **P2P Setup** dialogue where you can configure the essentials: -- **Room ID:** A unique identifier for your synchronisation group. -- **Passphrase:** Your encryption key. Ensure all your devices use the exact same passphrase. -- **Device Name:** A recognisable name for the current device (e.g., `iphone-16`). - -Once you have saved the settings, return to the **P2P Status Pane** and click the **Connect** button to join the network. - -*Tip: You can also toggle **Auto Connect** in the setup dialogue to automatically join the network whenever Obsidian starts.* - -## 3. Real-time Control -The status pane in the right sidebar provides granular control over your synchronisation: - -- **Active P2P Remote (new):** P2P now has its own active remote selection, separate from the normal active remote for database replication. Use the combo box next to the cog icon to choose which P2P remote configuration is active for P2P features. -- **Create P2P Remote (new):** Use the **+** button to open the P2P setup dialogue and create a dedicated P2P remote configuration. This is recommended when no P2P active remote has been selected yet. -- **Selection required (new):** If no P2P active remote is selected, the pane asks for selection before P2P target-related changes are saved. - -- **Signalling Status:** Shows if you are connected to the relay (đŸŸĸ Online). -- **Live-push (Broadcast):** Toggle "Broadcast changes" to notify other peers whenever you make an edit. -- **Replicate now (🔄):** Start immediate bidirectional replication with a visible peer (Pull, then Push). -- **Watch (🔔/🔕):** Enable "Watch" on specific peers to automatically pull changes when they broadcast. This creates a "LiveSync-like" experience. -- **Sync target (🔗/â›“ī¸â€đŸ’Ĩ):** Mark specific peers as **sync targets**. Peers marked here will be included when you run the **"P2P: Sync with targets"** command (see section 5). Click the button next to a peer to toggle it on (🔗, highlighted) or off (â›“ī¸â€đŸ’Ĩ). This setting is persisted in your configuration. - -## 4. Replication Dialogue -If you want to synchronise with a specific peer manually, use the **Replication** command or button. This opens the **Replication Dialogue** listing available devices. - -Inside the dialogue, the **Server Status** card at the top confirms you are still connected while performing the sync. -The status card now shows a stable **Room ID suffix** above **Peer ID**. The Room ID suffix is better for identifying your P2P group, while Peer ID may change between connections. - -Two actions are available per peer: - -- **Sync** — Starts a bidirectional synchronisation (Pull then Push) and keeps the dialogue open so you can monitor progress or sync with additional peers. -- **Start Sync & Close** — Runs the same bidirectional synchronisation, waits for it to settle, then closes the dialogue. After a successful synchronisation, it also closes the signalling connection. - -On supported mobile and desktop devices, LiveSync keeps the screen awake while this peer-selection dialogue is open and while its synchronisations finish. This is intentional: display sleep can interrupt peer discovery or connection establishment and require detection to start again. Wake Lock support remains best effort, does not keep a hidden application running in the background, and does not override operating-system sleep or suspension. - -During a P2P rebuild, peer discovery and selection remain protected after the rebuild has started. Keep Obsidian visible until a peer is selected and the transfer finishes, because mobile platform restrictions can still pause or terminate a hidden application. - -## 5. Syncing with Registered Targets via Command Palette - -You can now trigger a synchronisation with all your pre-registered target peers in one step, without opening any UI. - -1. Open the **Command Palette** (`Ctrl/Cmd + P`). -2. Run **"P2P: Sync with targets"**. - -This command synchronises with every peer whose **SYNC** toggle is enabled in the **Detected Peers** list. If no targets are registered, or if the P2P server is not running, the command will notify you accordingly. - -*Tip: Pair this command with a hotkey for a quick, keyboard-driven sync workflow.* - -## 6. Technical Improvements in 2026 -- **Decoupled Architecture:** The UI is now strictly separated from the core logic, making the plug-in more stable across different platforms (Mobile, Desktop, and Web). -- **Svelte 5 UI:** The interface has been rebuilt for better responsiveness and clearer status indicators. -- **Security:** All data remains end-to-end encrypted. Even the signalling relay never sees your actual notes. +- [Set up peer-to-peer synchronisation](setup_p2p.md) for configuring the first device, generating a Setup URI for another device, approving the connection, and verifying synchronisation in both directions. +- [How peer-to-peer synchronisation works](p2p.md) for signalling, TURN, privacy, the P2P Status pane, and automatic behaviour. +- [Peer-to-Peer Synchronisation Tips](tips/p2p-sync-tips.md) for connection troubleshooting. diff --git a/docs/quick_setup.md b/docs/quick_setup.md index f56f575a..afac78e8 100644 --- a/docs/quick_setup.md +++ b/docs/quick_setup.md @@ -2,132 +2,139 @@ [Japanese docs](./quick_setup_ja.md) - [Chinese docs](./quick_setup_cn.md). -The plug-in has so many configuration options to deal with different circumstances. However, only a few settings are required in the normal cases. Therefore, `The Setup wizard` has been implemented to simplify the setup. +This guide establishes ordinary note synchronisation on the first device and then adds another device. Optional features are configured only after this basic path works. -![](../images/quick_setup_1.png) +Before starting: -There are three methods to set up Self-hosted LiveSync. +- back up every Vault involved; +- disable Obsidian Sync, iCloud synchronisation, and any other service which writes to the same Vault; +- prepare the remote service and a Setup URI; and +- keep the Setup URI and its passphrase separate from each other. -1. [Using setup URIs](#1-using-setup-uris) *(Recommended)* -2. [Minimal setup](#2-minimal-setup) -3. [Fully manual setup and enabling on this dialogue](#3-manually-setup) +This walkthrough covers the recommended provisioned CouchDB path. Follow [Set up a CouchDB server](./setup_own_server.md) to prepare the server and Setup URI. -## At the first device +## What a Setup URI contains -### 1. Using setup URIs +A Setup URI starts with `obsidian://setuplivesync?settings=`. It contains encrypted connection settings, including credentials, and must be protected even though it is encrypted. -> [!TIP] -> What is the setup URI? Why is it required? -> The setup URI is the encrypted representation of Self-hosted LiveSync configuration as a URI. This starts `obsidian://setuplivesync?settings=`. This is encrypted with a passphrase, so that it can be shared relatively securely between devices. It is a bit long, but it is one line. This allows a series of settings to be set at once without any inconsistencies. -> -> If you have configured the remote database by [Automated setup on Fly.io](./setup_flyio.md#a-very-automated-setup) or [set up your server with the tool](./setup_own_server.md#1-generate-the-setup-uri-on-a-desktop-device-or-server), **you should have one of them** +The Setup URI passphrase decrypts the URI. It is different from the Vault encryption passphrase which protects synchronised data. Store both securely, and do not send the Setup URI and its passphrase through the same channel. -In this procedure, [this video](https://youtu.be/7sa_I1832Xc?t=146) may help us. +## Set up the first device -1. Click the `Use` button (or launch the `Use the copied setup URI (Formerly Open setup URI)` command from the command palette). -2. Paste the Setup URI into the dialogue -3. Type the passphrase of the Setup URI -4. Answer `yes` for `Importing LiveSync's conf, OK?`. -5. Answer `Set it up as secondary or subsequent device` for `How would you like to set it up?`. -6. Initialisation will begin, please hold a while. -7. You will asked about the hidden file synchronisation, answer as you like. - 1. If you are new to Self-hosted LiveSync, we can configure it later so leave it once. -8. Synchronisation has been started! `Reload app without saving` is recommended after the indicators of Self-hosted LiveSync disappear. +Use this path only when the remote database is new, or when this device is intentionally the source of truth for a full server rebuild. -OK, we can proceed the [next step](#). +1. Install and enable Self-hosted LiveSync in the intended Vault. +2. Select the `Welcome to Self-hosted LiveSync` Notice to open onboarding. +3. Select `I am setting this up for the first time`, then confirm that you want to set up a new synchronisation. +4. On `Connection Method`, select `Use a Setup URI (Recommended)`. +5. Paste the Setup URI, enter its Setup URI passphrase, and select `Test Settings and Continue`. -### 2. Minimal setup + ![Encrypted Setup URI and masked passphrase](../images/quick-setup/guide-quick-setup-first-setup-uri.png) -If you do not have any setup URI, Press the `start` button. The setting dialogue turns into the wizard mode and will display only minimal items. +6. Review `Setup Complete: Preparing to Initialise Server`, then select `Restart and Initialise Server`. ->[!TIP] -> We can generate the setup URI with the tool in any time. Please use [this tool](./setup_own_server.md#1-generate-the-setup-uri-on-a-desktop-device-or-server). + ![First-device server initialisation confirmation](../images/quick-setup/guide-quick-setup-first-initialise.png) -![](../images/quick_setup_2.png) +7. Read the final overwrite warning carefully. Select `I Understand, Overwrite Server` only after checking that backups exist and that replacing the remote data is intended. + ![Final server overwrite warning](../images/quick-setup/guide-quick-setup-first-rebuild-confirmation.png) -#### Select the remote type +8. A newly provisioned database may show `No Synchronisation Settings Found`. Select `Use this device's settings` for the intended new database. If you expected existing settings, cancel and check the selected remote. Cancellation restarts with Vault and database reflection paused; keep LiveSync disabled until the remote has been checked. If `Could Not Read Synchronisation Settings` appears, retry, or use this device's settings to continue the overwrite. The overwrite still requires a working connection. -1. Select the Remote Type from dropdown list. -We now have a choice between CouchDB (and its compatibles) and object storage (MinIO, S3, R2). CouchDB is the first choice and is also recommended. And supporting Object Storage is an experimental feature. + ![Expected missing remote configuration choice for a new database](../images/quick-setup/guide-quick-setup-missing-remote-configuration.png) -#### Remote configuration +9. Acknowledge `All optional features are disabled`. Optional features remain off until the ordinary synchronisation path has been verified. +10. Allow initialisation and any requested restart to finish. Keep Obsidian open until the LiveSync progress indicators have cleared. -##### CouchDB +Create an ordinary test note and allow it to upload before adding another device. -Enter the information for the database we have set up. +## Create a Setup URI for another device -![](../images/quick_setup_3.png) +Generate a Setup URI for another device from the working first device. This captures the settings which that device is actually using, rather than asking another device to reuse the Setup URI produced during server provisioning. -##### Object Storage +1. Open the Obsidian command palette on the first device. +2. Run `Self-hosted LiveSync: Copy settings as a new Setup URI`. +3. Enter a new passphrase which will protect this Setup URI, then select `OK`. -1. Enter the information for the S3 API and bucket. + ![Masked passphrase for a new Setup URI for another device](../images/quick-setup/guide-quick-setup-copy-setup-uri-passphrase.png) -![](../images/quick_setup_3b.png) +4. Copy the resulting Setup URI, then select `OK`. -Note 1: if you use S3, you can leave the Endpoint URL empty. -Note 2: if your Object Storage cannot configure the CORS setting fully, you may able to connect to the server by enabling the `Use Custom HTTP Handler` toggle. + ![Setup URI generated by the working first device](../images/quick-setup/guide-quick-setup-copy-setup-uri-result.png) -2. Press `Test` of `Test Connection` once and ensure you can connect to the Object Storage. +Store the new Setup URI and its passphrase separately. The URI is encrypted, but it contains credentials and Vault settings, so continue to protect it. -#### Only CouchDB: Test database connection and Check database configuration +## Add another device -We can check the connectivity to the database, and the database settings. +Start with a new or separately backed-up Vault. Do not use a production Vault containing unsynchronised notes unless you have reviewed the [Fast Setup choices](./tips/fast-setup.md). -![](../images/quick_setup_5.png) +1. Install and enable Self-hosted LiveSync. +2. Open onboarding from the `Welcome to Self-hosted LiveSync` Notice. +3. Select `I am adding a device to an existing synchronisation setup`, then confirm that you want to add the device. +4. On `Device Setup Method`, select `Use a Setup URI (Recommended)`. +5. Paste the new Setup URI generated by the first device, enter its Setup URI passphrase, and select `Test Settings and Continue`. +6. Review `Setup Complete: Preparing to Fetch Synchronisation Data`, then select `Restart and Fetch Data`. -#### Only CouchDB: Check and Fix database configuration + ![Fetch confirmation on the additional device](../images/quick-setup/guide-quick-setup-second-fetch.png) -Check the database settings and fix any problems on the spot. +7. For a new or empty Vault, select `Overwrite all with remote files`. For a Vault with local work, stop and choose the appropriate strategy from the [Fast Setup guide](./tips/fast-setup.md). -![](../images/quick_setup_6.png) + ![Fast Setup data retrieval choices](../images/quick-setup/guide-quick-setup-retrieval-method.png) -This item may vary depending on the connection. In the above case, press all three Fix buttons. -If the Fix buttons disappear and all become check marks, we are done. +8. When asked how to handle extra local files, the conservative choice is `Keep local files even if not on remote`. Select the delete option only when the local Vault is disposable and an exact remote copy is intended. -#### Confidentiality configuration (Optional but very preferred) + ![Local file policy on the additional device](../images/quick-setup/guide-quick-setup-local-file-policy.png) -![](../images/quick_setup_4.png) +9. Allow retrieval, file reflection, and any requested restart to finish. Keep Obsidian open until the LiveSync progress indicators have cleared. -Enable End-to-end encryption and the contents of your notes will be encrypted at the moment it leaves the device. We strongly recommend enabling it. And `Path Obfuscation` also obfuscates filenames. Now stable and recommended. +Confirm that the ordinary test note from the first device appears unchanged. Then edit or create a second ordinary note on the new device, and confirm that it reaches the first device. -These setting can be disabled if you are inside a closed network and it is clear that you will not be accessed by third parties. +![Ordinary note received through the provisioned Setup URI](../images/quick-setup/guide-quick-setup-synchronised-note.png) -> [!TIP] -> Encryption is based on 256-bit AES-GCM. +## After ordinary synchronisation works -We should proceed to the Next step. +Add optional features separately so that their ownership and initialisation direction are explicit: -#### Sync Settings -Finally, finish the wizard by selecting a preset for synchronisation. +- [Hidden File Sync](./tips/hidden-file-sync.md) for selected hidden files and folders; or +- [Customisation Sync](./settings.md#6-customisation-sync-advanced) for managed Obsidian customisations. -Note: If you are going to use Object Storage, you cannot select `LiveSync`. +Do not enable both features for the same files. -![](../images/quick_setup_9_1.png) +## Configure CouchDB manually on the first device -Select any synchronisation methods we want to use and `Apply`. If database initialisation is required, it will be performed at this time. When `All done!` is displayed, we are ready to synchronise. +Use this path when CouchDB is ready but a Setup URI is unavailable. It configures one first device through the visible onboarding dialogue; it does not provision or repair the CouchDB server. Add later devices with a Setup URI generated by this working first device instead of entering the credentials again. -The dialogue of `Copy current settings as a new setup URI` will open automatically. Please input a passphrase to encrypt the new `Setup URI`. (This passphrase is to encrypt the setup URI, not the vault). +1. Install and enable Self-hosted LiveSync in the intended Vault. +2. Select the `Welcome to Self-hosted LiveSync` Notice, choose `I am setting this up for the first time`, then confirm that you want to set up a new synchronisation. +3. On `Connection Method`, select `Configure a remote manually`, then select `Proceed with manual configuration`. -![](../images/quick_setup_10.png) + ![Manual remote configuration option during onboarding](../images/couchdb-manual/guide-couchdb-manual-connection-method.png) -The Setup URI will be copied to the clipboard, please make a note(Not in Obsidian) of this. +4. On `End-to-End Encryption`, decide how the synchronised data will be protected. + - For an ordinary new Vault, enable `End-to-End Encryption` and enter a strong Vault encryption passphrase. + - Enable `Obfuscate Properties` if remote document properties should also be concealed. + - Store the Vault encryption passphrase securely. It is separate from the passphrase used to protect a Setup URI. ->[!TIP] -We can copy this at any time by running the "Copy settings as a new setup URI" command from the command palette (or clicking the "Copy the current settings to a Setup URI" button in the settings UI). + ![CouchDB Vault encryption settings with the passphrase masked](../images/couchdb-manual/guide-couchdb-manual-encryption.png) -### 3. Manually setup +5. On `Choose a synchronisation remote`, select `CouchDB`, then select `Continue to CouchDB setup`. -It is strongly recommended to perform a "minimal set-up" first and set up the other contents after making sure has been synchronised. + ![CouchDB option in the list of synchronisation remotes](../images/couchdb-manual/guide-couchdb-manual-remote-selection.png) -However, if you have some specific reasons to configure it manually, please click the `Enable` button of `Enable LiveSync on this device as the set-up was completed manually`. -And, please copy the setup URI by running the "Copy settings as a new setup URI" command (or using the "Copy the current settings to a Setup URI" button) and make a note(Not in Obsidian) of this. +6. Enter the complete CouchDB URL, username, password, and database name. + - Obsidian Mobile requires HTTPS. Plain HTTP is suitable only for a trusted local connection from a desktop device. + - Use credentials which are allowed to connect to the selected database and, when configuring the first device, create it if it does not exist. -## At the subsequent device -After installing Self-hosted LiveSync on the first device, we should have a setup URI. **The first choice is to use it**. Please share it with the device you want to setup. + ![Manual CouchDB connection fields with the password masked](../images/couchdb-manual/guide-couchdb-manual-connection-details.png) -It is completely same as [Using setup URIs on the first device](#1-using-setup-uris). Please refer it. +7. `Check server requirements` is optional. It sends the displayed credentials to the configured server through Obsidian's internal request API, and some checks require CouchDB administrator access. The initial check is read-only. If it offers a server change, review and confirm that individual change separately. -> [!TIP] -> **Fast Setup (Simple Fetch)** -> In recent versions, when you import a Setup URI or trigger a Fetch All, the plug-in boots in scheduled fetch mode and runs a simplified **Fast Setup** process. This allows you to choose your sync strategy with a single dialogue and performs initial synchronisation in one step. Refer to the [Fast Setup Guide](./tips/fast-setup.md) for more details. + ![Successful optional CouchDB server requirements check](../images/couchdb-manual/guide-couchdb-manual-server-requirements.png) + +8. Select `Create or connect to database and continue`. Onboarding requires this connection test to succeed. +9. Review `Setup Complete: Preparing to Initialise Server`, then select `Restart and Initialise Server`. +10. Read the final overwrite warning. Select `I Understand, Overwrite Server` only when this device is intentionally the source of truth and a current backup exists. +11. A newly created database can show `No Synchronisation Settings Found`. Select `Use this device's settings` for this known new database. If you expected existing settings, cancel and check the selected remote. Cancellation restarts with Vault and database reflection paused; keep LiveSync disabled until the remote has been checked. If `Could Not Read Synchronisation Settings` appears, retry, or use this device's settings to continue the overwrite. The overwrite still requires a working connection. +12. Acknowledge `All optional features are disabled`, then keep Obsidian open until the initialisation progress has cleared. + +Create and synchronise an ordinary test note. Once it has reached CouchDB, follow [Create a Setup URI for another device](#create-a-setup-uri-for-another-device), then [Add another device](#add-another-device). This keeps the second device aligned with the remote profile and encryption settings which the first device actually applied. diff --git a/docs/recovery.md b/docs/recovery.md new file mode 100644 index 00000000..05c3b03d --- /dev/null +++ b/docs/recovery.md @@ -0,0 +1,122 @@ +# Recovery and flag files + +This guide covers emergency suspension, local database recovery, and deliberate remote reconstruction. These operations are not ordinary synchronisation. + +> [!IMPORTANT] +> Back up every available Vault before recovery. If a central remote is involved, back up that database or bucket as well. Stop or suspend other LiveSync devices until you have chosen the authoritative copy. + +If Obsidian will not start normally, do not give up. Flag files can be created or removed with the operating system's file manager while Obsidian is closed. They are the only supported way to intervene before the ordinary LiveSync boot-up sequence reaches its database and synchronisation work. + +## First choose the authoritative copy + +Use the least destructive operation which matches the evidence: + +- If the correct data is uncertain, suspend all work with `redflag.md`, preserve every copy, and inspect them before proceeding. +- If the central remote is healthy and should win, use **Reset Synchronisation on This Device** or `flag_fetch.md`. +- If this device's Vault is healthy and should replace a damaged or unwanted central remote, use **Overwrite Server Data with This Device's Files** or `flag_rebuild.md`. +- If both the Vault and local database are healthy and the only concern is unused storage, Garbage Collection may be appropriate. It does not repair a damaged database. + +Do not switch transport, enable P2P, or run Garbage Collection as a substitute for diagnosing a stopped CouchDB or Object Storage setup. + +## Suspend before diagnosis + +Close Obsidian completely, then create an empty file or directory named `redflag.md` at the root of the Vault. On the next start, LiveSync enters its emergency suspension state before ordinary database, file-watching, and synchronisation work continues. + +While suspended: + +1. Back up the Vault and any available remote data. +2. Check which device or remote contains the intended files. +3. Correct only the identified configuration or storage problem. +4. Remove `redflag.md`. +5. Start Obsidian and review the remaining suspension controls under `Hatch` -> `Scram Switches`. + +The flag deliberately enables file logging, which may affect performance. Remove it after the emergency has been understood. + +## Recover a conflicted or mismatched file + +Use this workflow when one file, or a small number of known files, has conflicts, missing chunks, or a difference between the current Vault file and the local LiveSync database. The inspection is device-local: it does not query a remote database or prove that another device has the same chunks. + +The `Hatch` recovery controls are ordered by escalation. Running **Recreate chunks for current Vault files** again with unchanged chunk settings and file contents produces the same chunks, and does not alter the revision tree. **Inspect conflicts and file/database differences** then provides actions for exact revisions. **Resolve All conflicted files by the newer one** is last because it applies a modification-time policy in bulk and logically deletes every other live version. + +1. Stop editing the affected file, pause replication on the participating devices, and keep a separate copy of every readable version. +2. If another device or backup has the intended content, preserve that copy before changing any revision. +3. If the current Vault file is readable, select **Recreate current chunks**. This can restore only chunks derived from the current Vault contents; it cannot reconstruct unique bytes from an unavailable historical or conflict revision. +4. Select **Inspect conflicts and file/database differences** → **Begin inspection**. +5. Review the database winner, every conflict revision, and any unavailable shared ancestor separately. Revision identifiers, `Δsize`, `Δtime`, and chunk availability are diagnostic evidence; they do not decide which content is correct. +6. Use the wrench menu on the exact revision: + - **Compare with Vault** opens a read-only comparison for readable text. + - **Apply this revision to Vault** replaces the Vault file with that readable database revision. + - **Mark this revision as the Vault version** records an exact byte-for-byte match without creating a child revision. + - **Store Vault file as a child of this revision** preserves the current Vault bytes on that selected branch. + - **Retry reading revision** retries configured chunk retrieval without changing the revision tree. + - **Apply logical deletion to Vault**, **Discard this branch**, and **Discard unreadable revision** are destructive decisions. Use them only after preserving every version which may still be needed. +7. Synchronise the healthy source if chunks were restored, scan again, and confirm that the expected conflict or difference has disappeared before resuming ordinary editing. + +An absent Vault file and a logical-deletion winner already agree and do not require a repair card unless another live branch remains. If the scan reports many unrelated files, or the local database itself is incomplete or corrupt, stop the per-file workflow and use [Reset synchronisation on this device](#reset-synchronisation-on-this-device) from a trusted remote. If the central remote must instead be reconstructed from an authoritative Vault, use [Overwrite server data with this device's files](#overwrite-server-data-with-this-devices-files). + +## Reset synchronisation on this device + +Use this when the remote copy is trusted but this device's local LiveSync database is incomplete, corrupt, or no longer aligned with it. + +The readable flag is `flag_fetch.md`; the legacy name `redflag3.md` remains accepted. + +On the next start, LiveSync: + +1. pauses ordinary start-up work; +2. asks which remote to use when more than one remote profile exists; +3. asks how to treat existing Vault files; +4. discards and reconstructs the local LiveSync database from the selected remote; and +5. resumes only after the scheduled operation has completed or been cancelled safely. + +Fast Setup retains its fetch flag, last successfully stored remote position, and selected data-processing method when an error while decrypting data, reading the remote response, or writing to the local database stops the reconstruction. File watching and database reflection remain suspended. Review the first specific error in **Show log**, correct its cause, then restart Obsidian to retry from the retained state. Do not remove the fetch flag or resume the Scram switches while you intend to continue the operation. If the same error remains, leave LiveSync suspended and [collect a report](troubleshooting.md#collect-a-report). + +For P2P, a source peer must be online, discovered, and selected in `P2P Rebuild`. Merely opening an empty signalling room does not complete Fetch. Closing the rebuild dialogue without selecting a peer reports failure and does not treat the local database as restored. + +Review the [Fast Setup guide](tips/fast-setup.md) before using this operation on a Vault which contains unsynchronised local work. + +## Overwrite server data with this device's files + +Use this only when this device's Vault is the authoritative copy and the central remote should be reconstructed from it. + +The readable flag is `flag_rebuild.md`; the legacy name `redflag2.md` remains accepted. + +For CouchDB and Object Storage, this is destructive to the selected remote state. Other devices may still contain revisions or files which are not present in the authoritative Vault, so keep them stopped until the new remote has been verified and then reset them from that remote. + +For a P2P-only setup, there is no central remote database to overwrite. Preparing the first device instead rebuilds its local LiveSync database from its Vault. + +## Garbage Collection is not Rebuild + +Garbage Collection removes unreferenced chunks while preserving the current database and its revision model. Use it only when: + +- the Vault is healthy; +- the local LiveSync database is healthy; +- all relevant devices have synchronised; and +- the remaining historical and deletion state is understood. + +Deleted documents, tombstones, live conflicts, and retained metadata are not free. Live conflict branches keep the chunks needed for review, while an ordinary superseded linear revision does not protect its former chunks. Garbage Collection can therefore make old content unreadable and cannot promise the smallest possible remote. Review the [Garbage Collection V3 specification](specs_garbage_collection.md) before using it. + +Rebuild is a different operation. It reconstructs the database from a chosen authoritative state and is the more certain way to remove unwanted history or repair a damaged remote, but it is also more disruptive and can discard changes which exist only elsewhere. + +## Flag-file reference + +Create only the flag required for the chosen operation. + +| File at the Vault root | Effect | +| --- | --- | +| `redflag.md` | Suspend ordinary LiveSync work for diagnosis. It remains until removed manually. | +| `flag_fetch.md` or `redflag3.md` | Schedule **Reset Synchronisation on This Device** from the selected remote. | +| `flag_rebuild.md` or `redflag2.md` | Schedule **Overwrite Server Data with This Device's Files**, or local P2P preparation when no central remote exists. | + +Flag files themselves are excluded from synchronisation. Fetch and rebuild flags are removed by the scheduled workflow after completion or safe cancellation. A failed Fast Setup retains its fetch flag so that a later start can retry it; `redflag.md` is a manual emergency stop. + +## When the warning continues + +If LiveSync still reports emergency suspension after a recovery dialogue has closed: + +1. close Obsidian completely; +2. inspect the Vault root for every name in the table above; +3. remove only flags whose intended operation has finished or been abandoned; +4. restart Obsidian; and +5. check `Hatch` -> `Scram Switches` for remaining suspended file watching or database reflection. + +If the intended authoritative copy is still uncertain, leave synchronisation suspended and collect a [full report](troubleshooting.md#collect-a-report) before changing the databases again. diff --git a/docs/releases/0.25.md b/docs/releases/0.25.md new file mode 100644 index 00000000..4b01dff0 --- /dev/null +++ b/docs/releases/0.25.md @@ -0,0 +1,1826 @@ +# 0.25 release history + +This is the 0.25 release line of the [Self-hosted LiveSync release history](../../updates.md). Earlier releases continue in the [legacy history](legacy.md). + +## 0.25.83 + +16th July, 2026 + +Our plug-in continues to improve every day thanks to all of your contributions. We have finally resolved an issue first reported in 2023. Thank you for everything you contribute. + +### Fixed + +- Fixed the 📲 remote-activity indicator remaining visible after CouchDB requests had completed. +- Fixed missing chunks being reported unavailable while an in-progress on-demand fetch or finite replication could still deliver them. Reads now follow the actual delivery lifecycle and recheck the local database when it finishes. +- Fixed an issue where changing only the letter case of a file name within the same directory could delete it on other devices when 'Handle files as Case-Sensitive' was disabled. Directory case changes remain unsupported (#198, PR #1014; [commonlib PR #68](https://github.com/vrtmrz/livesync-commonlib/pull/68)). Thank you to @metrovoc for the fix! +- Fixed **Resolve All conflicted files by the newer one** displaying a separate success notice for every resolved file and updating its progress notice for nine out of every ten checked files. Successful per-file results are now logged, progress updates every ten files, and errors and non-bulk success notices remain visible (#1016, PR #1017). Thank you to @apple-ouyang for the fix! + +### Improved + +- Split the status-bar remote activity display into `📲` for a finite remote operation and `🌐N` for the approximate number of tracked CouchDB or Object Storage requests currently in progress. + +## 0.25.82 + +15th July, 2026 + +Recently, I created a repository called Fancy Kit and have been trying to build some proper infrastructure around it. Does any of this look like cumbersome bureaucracy? No need to worry: you can carry on as usual. Codex is simply tidying up my usual rambling prose, terrible pull requests, and disjointed remarks. + +### Fixed + +- Refreshed the remote Security Seed before each replication, preventing a client that remained open during a remote database rebuild from uploading data encrypted with the previous seed (#1018, PR #1019). Thank you to @apple-ouyang for the fix! +- The P2P **Start Sync & Close** action now waits for synchronisation to settle before closing the dialogue, avoiding premature release of screen-awake protection while work remains in flight. + +### Improved + +- On supported mobile and desktop devices, one-shot replication, P2P peer discovery and selection, database rebuild and fetch operations, and remote chunk fetching now keep the screen awake for the duration of these finite remote operations. LiveSync also postpones its normal visibility suspension until the operation finishes, although platform restrictions may still pause or terminate background work. +- The 📲 status-bar indicator now covers finite remote work as well as HTTP requests. It reports broad remote activity, such as P2P peer selection and remote chunk fetching, rather than an exact physical connection count. + +### Improved (CLI) + +- CLI container images are now published for both AMD64 and ARM64 platforms. + +### Testing + +- Added a local real-Obsidian compatibility test that writes an encrypted, path-obfuscated note through the LiveSync CLI and verifies that the plug-in materialises the same content. + +## 0.25.81 + +14th July, 2026 + +I am releasing this update to make the chunk-boundary fix available before the next broader release. Thank you to everyone who helped reproduce, trace, and verify the issue. + +### Fixed + +- Fixed an issue where a U+FEFF character at a Rabin–Karp chunk boundary could be lost, changing the reconstructed file and causing repeated size-mismatch conflicts (#1000). + +### Improved + +- Improved vault scanning and CLI file filtering by reusing compiled ignore patterns, reducing processing overhead for vaults with many files or ignore rules (#1006, #1007, and #1008). + +### Improved (CLI and Webapp) + +- Rooted storage adapters now reject absolute, drive-qualified, backslash-separated, and traversal paths. They also prevent file writes, appends, and removal from targeting the configured root itself. +- File System Access API storage can now create files below previously missing parent directories, matching the existing Node behaviour. + +### Testing + +- Added shared Node and File System Access API storage contract coverage for metadata, text and binary operations, append, listing, removal, path containment, and empty-root handling. +- Added a Compose-based P2P end-to-end smoke test and repeatable network benchmark cases for local performance investigations. + +### Miscellaneous + +- Split the internal storage adapter contract into focused capability views without changing existing runtime behaviour. + +## 0.25.80 + +7th July, 2026 + +### Fixed + +- Improved Markdown conflict auto-merge so that non-overlapping edits are merged while overlapping delete-and-edit cases remain visible for manual resolution (#993). + - Behaviour change: + - When one side deletes an unchanged line and the other side edits a different region, the deleted line is no longer reintroduced into the merged result. + - When one side deletes a line and the other side modifies that same line, the conflict is preserved instead of silently choosing one side. +- Fixed an issue where applying a newer database entry to storage could incorrectly preserve an older local file as a conflict (#994). + - Behaviour change: + - Local storage is preserved as a conflict when it may contain unsynchronised changes that are not represented in the revision history. A newer incoming text entry is applied without creating a conflict only when it clearly extends the existing local text. +- Fixed an issue where choosing Disable and then Overwrite in Hidden File Sync could silently skip hidden files, because the overwrite setup ran while hidden file synchronisation was still disabled (#989, PR #992). + - Hidden File Sync is now re-enabled before the Fetch, Overwrite, or Merge initialisation runs, instead of after it completes. If that initialisation fails, the setting may remain enabled. + +## 0.25.79 + +29th June, 2026 + +### Fixed + +- Fast Fetch now retries transient stream interruptions and resumes from the latest persisted checkpoint, instead of starting over after ordinary network or platform interruptions (#977, PR #978; commonlib PR #59). Thank you so much for @apple-ouyang for the fix! +- Simple Fetch now remembers the selected setup choices while an interrupted Fetch All operation is still pending, so users are not asked the same questions again on retry (#977, PR #978). Thank you so much for @apple-ouyang for the fix! +- No longer hidden storage events, such as `.git` paths, reach the normal target-file filter when internal file synchronisation is disabled. This avoids noisy non-target logs before those files are skipped (commonlib PR #60). Thank you so much for @apple-ouyang for the fix! +- Fixed an issue where a file deleted from storage could be resurrected by the offline scanner because the database tombstone was not written when the storage file was already gone (commonlib PR #56). Thank you so much for @cosmic-fire-eng for the fix! + +### Improved + +- Local database maintenance commands now ask before applying the required chunk settings, and can apply those prerequisites before continuing (#980, PR #981). Thank you so much for @apple-ouyang for the improvement! +- Improved CouchDB replication event handling by using the new `StreamInbox` helper from `octagonal-wheels` (commonlib PR #62). + +### Documentation + +- Added `nginx` to the setup documentation table of contents (PR #976). Thank you so much for @kiraventom for the improvement! + +### Miscellaneous + +- Updated `octagonal-wheels` to `0.1.47` across the plug-in and workspace packages to use the newly published helper modules. + +## 0.25.78 + +23rd June, 2026 + +### Fixed +- No longer fast synchronisation (a.k.a. Fast Fetch) causes a rewind and re-fetch of the entire database when some errors occur during the process (#972, PR #973). Thank you so much for @apple-ouyang for the fix! + +### Improved + +- Overhauled the Object Storage (e.g., MinIO and S3) replication engine ('Journal Replicator 2nd Edition'). + - It now leverages the standard Web Streams API for a resilient, backpressure-aware architecture, reducing memory footprints/temporary storage usage on large vaults. + - Decoupled the physical storage logic to make it easier to add new storage backends in the future. + - Stricter compliance with CouchDB's replication protocol (proper `_revisions` transfers with `new_edits: false`) when using Object Storage. + +### Testing + - Added comprehensive unit tests for the new `JournalSyncCore` engine, covering streams, backpressure, and `new_edits: false` validation. + - Improved integration test workflows in the CI pipeline to run MinIO tests automatically using standard environment variables. + +## 0.25.77 + +19th June, 2026 + +This update is mostly meaningless for users. But for maintainers, not, I hope. I wonder if I were done well in the start, there would be no hassles. It really was a great opportunity. + +Also, this update is a very large one, even if we had a lot of time, and we had CI tests, and mostly only fixing the types. Please let me know if you find any issues! + +### Improved + +- File deletion now respects the user's deletion preferences (by utilising the `FileManager.trashFile` API) on Obsidian v1.7.2 or newer, regardless of the plug-in's internal trashbin setting. + +### Miscellaneous +- Typings of the library are now included +- Many typing errors have been improved. +- Import paths have been normalised to be relative to the root and to the `lib/src` directory, to avoid breaking the boundary between the library and the plug-in. +- Subprojects, such as the CLI and the webapp, are now in the workspace. + +## 0.25.76 + +15th June, 2026 + +### Fixed + +- Now the S3 connection with custom headers works properly (#875). + - Previously, custom headers injected for proxy authentication were incorrectly included in the AWS Signature v4 calculation. This led to a '400 Bad Request' error (such as 'signed header is not present') on strict S3 backends (for example, Garage), or when reverse proxies modified, renamed, or stripped these headers before they reached the storage service. +- No longer connection information of the P2P synchronisation is broken on the specific platform (#956). + +## 0.25.75 + +13th June, 2026 + +### Fixed + +- Fixed an issue where using fast synchronisation caused a TypeError in some environments (#953). + +### New features +- Now we can configure to keep replication active in the background on desktop platforms (#939, PR #949). Thank you so much for @migsferro! + +### Fixed (CLI, automated) + +- Fixed an issue where the mirror command could fail to apply updates when conflict preservation checks prevented overwriting unsynchronised local changes, even when the `force` parameter or `writeDocumentsIfConflicted` setting was enabled. + +### Improved + +- (CLI) Ported the remaining bash regression tests (`test-daemon-linux.sh`, `test-decoupled-vault-linux.sh`, and `test-remote-commands-linux.sh`) to Deno for cross-platform validation. + +### Miscellaneous +- Some dependencies have been updated. +- Now we check the compatibility with iOS 15 in the CI tests to ensure the plugin continues to work on older iOS versions even after we upgrade some dependencies. + +## 0.25.74 + +8th June, 2026 + +### Fixed + +- Fixed an issue where disabling hidden file synchronisation did not take effect, allowing non-target hidden files to continue to be processed and synchronised by replication or boot-sequence scan (#941). +- Prevented the automatic merging of conflicted revisions when one of the revisions has been deleted, which was causing deleted files to reappear (#911). +- The startup sequence now saves the state more effectively (Thank you so much for @bmcyver)! + +## Only CLI + +8th June, 2026 + +I should also consider the version numbering for the CLI... + +### Improved + +- Added new remote database management commands: `remote-status`, `unlock-remote`, `lock-remote`, and `mark-resolved`. +- --vault option is now available for daemon and mirror commands! (Thank you so much for @starskyzheng)! +- Decoupled the database directory path from the actual vault directory path using the `--vault` (or `-V`) option. + +### Fixed (preventive) + +- Validated that the specified vault path exists and is indeed a directory before starting the CLI. +- Integrated path resolution and validations for one-off commands (such as `'push'`, `'pull'`, `'cat'`, `'rm'`, `'info'`, and `'resolve'`) against the decoupled vault path instead of the database path. + +## 0.25.73 + +4th June, 2026 + +### Fixed + +- Adjust CouchDB's database name checking to its specification (#926). +- `Reset Syncronisation on This Device` for minio and P2P is now working properly. + +## ~~0.25.71~~ 0.25.72 + +0.25.71 was cancelled due to the fixes needed (Object Storage related) + +3rd June, 2026 + +### Improved + +- Database fetching (a.k.a. Reset Synchronisation on This Device) on the initialisation now supports streaming and is faster (CouchDB only) +- The database fetching process has been streamlined, and database operations are now suspended until it has been completed +- The initial synchronisation process has been simplified, making it easier to synchronise files with the remote server +- We can select the remote database to fetch from during the initialisation, when there are multiple remote databases configured (e.g. multiple CouchDBs or S3 remotes) +- Hebrew (he) Translation has been added (Thank you so much, @MusiCode1)! +- Translation loading time has been reduced (Thank you so much, @bmcyver)! + +### Fixed + +- No longer does the status element break other plugins' interaction (#930). +- No longer does file events occured during initial database fetching using Object Storage. + +### Refactored + +To support the new Community automated tests, we fixed numerous lint warnings. This may have also resolved potential issues. + +## 0.25.70 + +25th May, 2026 + +### New features +- Diff dialogue now has great tools to navigate and understand the differences, including: + - A checkbox to toggle the visibility of collapsed identical sections, making it easier to focus on the actual differences (PR #889). + - A search feature to find specific text in past revisions, and navigate revisions with search results highlighted in the dialogue (PR #890). + +- Conflict resolution dialogue now has a navigation feature to jump between conflicts (PR #891). + +Thank you so much to @SeleiXi for implementing these features! + +### Improved + +- More diagnostic information for P2P connections is now shown, including why a connection failure occurred and the current connection status. + +## 0.25.69 + +22nd May, 2026 + +### Fixed +- No longer does the P2P passphrase mismatch cause a server shutdown. +- Settings related to P2P synchronisation are now correctly applied on start-up and no longer reverted. + +### New features +- Diagnostic P2P connection stats are now available. + - These stats indicate the number of connection trials, successes, and failures. + +## 0.25.68 + +22nd May, 2026 + +### Improved + +- P2P connections have improved slightly + - Upgrade to `trystero` v0.24.0, and fixes event handler assignment. This should fix some edge cases where P2P connections fail to establish or messages are not properly handled. + - Weaken terser options to avoid potential issues with minification that could cause runtime errors in some environments. + +## ~~0.25.66~~ 0.25.67 + +20th May, 2026 + +0.25.66 had a bug that the auto-accept logic for compatible but lossy mismatches was not working as intended. + +### New features +- Implement an auto-accept compatible tweak setting and enhance the mismatch resolution logic. + +### Improved +- Many messages related to tweak mismatch resolution have been updated for clarity. + +## 0.25.65 + +19th May, 2026 + +### Fixed +- Fix an issue about resuming from background on iOS (#888). +- Now Chunk Splitter: `V3: Fine Deduplication` is working fine again (#866). + - It has some drawbacks, such as fewer chunks are generated. However, it makes less transfer and storage when the files are modified but not completely changed. +- Unsynchronised local changes (which means changes that have not been sent) are now correctly preserved as a conflict (Thank you so much for @SeleiXi!). +- Avoid creating a new revision when the current and conflicted revisions have identical content (Thank you so much for @daichi-629). + +### Improved +- Improved the error verbosity on concurrent processing during the start-up process. +- Now the `report` includes recent logs (of verbosity `verbose` even settings is not set to `verbose`). +- Updating logs is now debounced to avoid excessive updates during rapid log generation. +- Added a `Generate full report for opening the issue with debug info` command to the command palette, which generates a report without opening the settings dialogue. + +## 0.25.64 + +17th May, 2026 + +### P2P Status Pane + +- Added active P2P remote selector (combo box) and `+` action to create/select a P2P remote from the P2P setup dialogue. +- Added per-peer immediate replication action on accepted peers. +- Updated status control icons for clarity: + - Replicate now: `🔄` (`âŗ` while running) + - Watch: `🔔` / `🔕` + - Sync target: `🔗` / `â›“ī¸â€đŸ’Ĩ` +- Added warning state when no active P2P remote is selected. + +### P2P Status Card + +- Added stable Room ID suffix display and placed it above Peer ID for better identification. + +### Non behavioural internal changes + +#### P2P + +- Added `P2P_ActiveRemoteConfigurationId` as a dedicated active remote selection for P2P features, separate from the normal active remote. +- Added activation logic for P2P dedicated remote configuration that reflects P2P settings while keeping `remoteType` unchanged. +- Added migration support to carry over P2P active remote selection when appropriate. +- Added shared Room ID utility functions and applied them across P2P setup and P2P panes. + +#### Tests + +- Added/updated unit test coverage around settings load behaviour for P2P active remote application. + +## 0.25.63 + +17th May, 2026 + +### Fixed +- The issue which cannot synchronise in Only-P2P mode has been fixed. +- Fixed an issue where "Failed to connect to the remote server" was shown during the redFlag rebuild flow when P2P was the primary remote type. Remote configuration fetch is now skipped for P2P. + +### P2P Replication UI Improvements +- Brand-new P2P Server Status pane has been added to provide real-time visibility into your connection status and peer network. + - For detailed instructions on using the new P2P features, please refer to the updated [User Guide: Peer-to-Peer Synchronisation (2026 Edition)](./docs/p2p_sync_updates_2026.md). +- Now `Replicate` button or ribbon icon opens a redesigned interactive replication dialogue that performs smart bidirectional sync with a single click. +- The vault rebuild flow (`replicateAllFromServer`) now opens the redesigned P2P Replication modal instead of a plain text selection dialogue, providing a consistent UI experience. + +## 0.25.62 + +14th May, 2026 + +### Fixed + +- Fixed an issue where a connection could not be established when attempting to connect to a brand-new remote database without going through the set-up wizard or configuration checking (#660). + +## 0.25.61 + +13th May, 2026 + +Reviews have started on the Obsidian Community, haven't they? It was quite a struggle, what with having to fix the outdated ESLint. +I am a bit nervous, but it is far better than just plodding along aimlessly, so let us get on with it. If you spot any issues, please let me know straight away. + +From now on, I am avoiding committing directly to the main branch. This is because you lots have all been sending so much PRs. I wanted to keep things harmonious. +That said, I am still not used to rebasing, so there are some parts where the commit history is a right mess. I will work on improving that. + +### Improved + +- P2P synchronisation has been made more robust + Now the foundation for P2P synchronisation has been rewritten, and the unit tests have been added. The foundation has been separated into the transport layer, signalling-and-connection layer, and, an RPC layers. And each layer has been unit-tested. As the result, the P2P synchronisation now uses the robust shim that uses RPC-ed PouchDB synchronisation in contrast to previous implementation. +This P2P synchronisation is not compatible with previous versions in terms of connectivity. All devices must be updated. + +### Fixed + +- No longer baffling errors occur when setting-update is triggered during the early stage of initialisation. +- Network error notice pop-ups are now suppressed when 'NetworkWarningStyle' is set to 'Hidden'. (Thank you so much @SeleiXi!) + +### New features + +- Diff navigation buttons have been added to the diff view, making it easier to move between differences. (Thank you so much @SeleiXi! #871) + +### Translations + +- Chinese (Simplified) translations for settings and the Setup Wizard have been added. (Thank you so much @zombiek731!) +- Common UI controls and signal words are now localised into Chinese (Simplified). (Thank you so much @zombiek731!) +- i18n runtime behaviour and locale coverage have been improved. (Thank you so much @52sanmao!) + +### CLI + +#### New features + +- Daemon synchronisation is now supported. (Thank you so much @andrewleech! #843) +- `HeadlessConfirm` has been implemented with sensible defaults, enabling unattended operation in headless environments. (Thank you so much @andrewleech!) +- The CLI onboarding experience has been improved. (Thank you so much @OriBoharon! #872) + +#### Fixed + +- Sub-millisecond CLI mtimes are now truncated to prevent mobile crash. (Thank you so much @brian-spackman! #893) + +## 0.25.60 + +29th April, 2026 + +### Fixed + +- Now larger settings can be exported and imported via QR code without issues. (#595) + - When the settings data exceeds the QR code capacity, it is now split into multiple QR codes. + - These QR codes are reassembled by the aggregator page, which collects the split data and reconstructs the original settings. + - Aggregator page is available at `https://vrtmrz.github.io/obsidian-livesync/aggregator.html`, and this file is also included in the repository. + - We will not send the settings data to any server. The QR code data is generated and processed entirely on the client side, ensuring that your settings remain private and secure. HOWEVER, please be careful your network environment. +- Fixed some errors during serialisation and deserialisation of the settings, which caused issues in some cases when importing/exporting settings via QR code. + +### Fixed (CLI) + +- `ls` and `mirror` commands now provide informative feedback when no documents are found or filters skip all files, resolving the issue where they would exit silently (#860). + - Improved the clarity of CLI command logs by including the total count of processed items. +- The command-line argument `vault` has been renamed to a more appropriate name, `databaseDir`. +- The `mirror` command now accepts a `vault` directory, which specifies the location where the actual files are stored. For compatibility reasons, the previous behaviour is still supported. + +## 0.25.59 + +### Fixed + +- No longer Setup-wizard drops username and password silently. (#865) + - Thank you so much for @koteitan ! +- Setup URI is now correctly imported (#859). + - Also thank you so much for @koteitan ! + +### Improved + +- now French translation is added by @foXaCe ! Thank you so much! + +## 0.25.58 + +### Fixed + +- No longer credentials are broken during object storage configuration (related: #852). +- Fixed a worker-side recursion issue that could raise `Maximum call stack size exceeded` during chunk splitting (related: #855). +- Improved background worker crash cleanup so pending split/encryption tasks are released cleanly instead of being left in a waiting state (related: #855). +- On start-up, the selected remote configuration is now applied to runtime connection fields as well, reducing intermittent authentication failures caused by stale runtime settings (related: #855). +- Issue report generation now redacts `remoteConfigurations` connection strings and keeps only the scheme (e.g. `sls+https://`), so credentials are not exposed in reports. +- Hidden file JSON conflicts no longer keep re-opening and dismissing the merge dialogue before we can act, which fixes persistent unresolvable `data.json` conflicts in plug-in settings sync (related: #850). + +## 0.25.57 + +9th April, 2026 + +- Packing a batch during the journal sync now continues even if the batch contains no items to upload. +- No unexpected error (about a replicator) during the early stage of initialisation. +- Now error messages are kept hidden if the show status inside the editor is disabled (related: #829). +- Fixed an issue where devices could no longer upload after another device performed 'Fresh Start Wipe' and 'Overwrite remote' in Object Storage mode (#848). + - Each device's local deduplication caches (`knownIDs`, `sentIDs`, `receivedFiles`, `sentFiles`) now track the remote journal epoch (derived from the encryption parameters stored on the remote). + - When the epoch changes, the plugin verifies whether the device's last uploaded file still exists on the remote. If the file is gone, it confirms a remote wipe and automatically clears the stale caches. If the file is still present (e.g. a protocol upgrade without a wipe), the caches are preserved, and only the epoch is updated. This means normal upgrades never cause unnecessary re-processing. + +### Translations + +- Russian translation has been added! Thank you so much for the contribution, @vipka1n! (#845) + +### New features + +- Now we can configure multiple Remote Databases of the same type, e.g, multiple CouchDBs or S3 remotes. + - A user interface for managing multiple remote databases has been added to the settings dialogue. I think no explanation is needed for the UI, but please let me know if you have any questions. +- We can switch between multiple Remote Databases in the settings dialogue. + +### CLI + +#### Fixed + +- Replication progress is now correctly saved and restored in the CLI (related: #846). + +## ~~0.25.55~~ 0.25.56 + +30th March, 2026 + +### Fixed + +- No longer `Peer-to-Peer Sync is not enabled. We cannot open a new connection.` error occurs when we have not enabled P2P sync and are not expected to use it (#830). + +### CLI + +- Fixed incomplete localStorage support in the CLI (#831). Thank you so much @rewse ! +- Fixed the issue where the CLI could not be connected to the remote which had been locked once (#833), also thanks to @rewse ! + +## 0.25.54 + +18th March, 2026 + +### Fixed + +- Remote storage size check now works correctly again (#818). +- Some buttons on the settings dialogue now respond correctly again (#827). + +### Refactored + +- P2P replicator has been refactored to be a little more robust and easier to understand. +- Delete items which are no longer used that might cause potential problems + +### CLI + +- Fixed the corrupted display of the help message. +- Remove some unnecessary code. + +### WebApp + +- Fixed the issue where the detail level was not being applied in the log pane. +- Pop-ups are now shown. +- Add coverage for the test. +- Pop-ups are now shown in the web app as well. + +## 0.25.53 + +17th March, 2026 + +I did wonder whether I should have released a minor version update, but when I actually tested it, compatibility seemed to be intact, so I didn’t. Hmm. + +### Fixed + +#### P2P Synchronisation + +- Fixed flaky timing issues in P2P synchronisation. +- No longer unexpected `Unhandled Rejections` during P2P operations (waiting for acceptance). + +#### Journal Sync + +- Fixed an issue where some conflicts cannot be resolved in Journal Sync. +- Many minor fixes have been made for better stability and reliability. + +### Tests + +- Rewrite P2P end-to-end tests to use the CLI as a host. + +### CLI + +We have previously developed FileSystem LiveSync and various other components in a separate repository, but updates have been significantly delayed, and we have been plagued by compatibility issues. Now, a CLI tool using the same core logic is emerging. This does not directly manipulate the file system, but it offers a more convenient way of working and can also communicate with Object Storage. We can also resolve conflicts. Please refer to the code in `src/apps/cli` for the [self-hosted-livesync-cli](./src/apps/cli/README.md) for more details. +- Add `self-hosted-livesync-cli` to `src/apps/cli` as a headless and dedicated version. +- P2P sync and Object Storage are also supported in the CLI. + - Yes, we have finally managed to 'get one file'. + - Also, no more need for a [LiveSync PeerServer](https://github.com/vrtmrz/livesync-serverpeer) for virtual environments! The CLI can do it. + +- Now binary files are also supported in the CLI. + +### Refactored or internal changes + +- ServiceFileAccessBase now correctly handles the reading of binary files. +- HeadlessAPIService now correctly provides the online status (always online) to the plug-in. +- Non-worker version of bgWorker now correctly handles some functions. +- Separated `ObsidianLiveSyncPlugin` into `ObsidianLiveSyncPlugin` and `LiveSyncBaseCore`. +- Now `LiveSyncCore` indicates the type specified version of `LiveSyncBaseCore`. +- Referencing `plugin.xxx` has been rewritten to referencing the corresponding service or `core.xxx`. +- Offline change scanner and the local database preparation have been separated. +- Set default priority for processFileEvent and processSynchroniseResult for the place to add hooks. +- ControlService now provides the readiness for processing operations. +- DatabaseService is now able to modify database opening options on derived classes. +- Now `useOfflineScanner`, `useCheckRemoteSize`, and `useRedFlagFeatures` are set from `main.ts`, instead of `LiveSyncBaseCore`. +- Storage Access APIs are now yielding Promises. This is to allow more limited storage platforms to be supported. +- Journal Replicator now yields true after the replication is done. + +### R&D + +- Browser-version of Self-hosted LiveSync is now in development. This is not intended for public use now, but I will eventually make it available for testing. +- We can see the code in `src/apps/webapp` for the browser version. + + +## 0.25.52-patched-3 + +16th March, 2026 + +### Fixed + +- Fixed flaky timing issues in P2P synchronisation. +- Fixed more binary file handling issues in CLI. + +### Tests + +- Rewrite P2P end-to-end tests to use the CLI as host. + + +## 0.25.52-patched-2 + +14th March, 2026 + +### Fixed + +- No longer unexpected `Unhandled Rejections` during P2P operations (waiting acceptance). +- Fixed an issue where conflicts cannot be resolved in Journal Sync + +### CLI new features + +- `mirror` command has been added to the CLI. This command is intended to mirror the storage to the local database. +- `p2p-sync`, `p2p-peers`, and `p2p-host` commands have been added to the CLI. These commands are intended for P2P synchronisation. + - Yes, no more need for a [LiveSync PeerServer](https://github.com/vrtmrz/livesync-serverpeer) for virtual environments! The CLI can handle it by itself. + +## 0.25.52-patched-1 + +12th March, 2026 + +### Fixed + +- Fixed Journal Sync had not been working on some timing, due to a compatibility issue (for a long time). +- ServiceFileAccessBase now correctly handles the reading of binary files. +- HeadlessAPIService now correctly provides the online status (always online) to the plug-in. +- Non-worker version of bgWorker now correctly handles some functions. + +### Refactored + +- Separated `ObsidianLiveSyncPlugin` into `ObsidianLiveSyncPlugin` and `LiveSyncBaseCore`. +- Now `LiveSyncCore` indicates the type specified version of `LiveSyncBaseCore`. +- Referencing `plugin.xxx` has been rewritten to referencing the corresponding service or `core.xxx`. +- Offline change scanner and the local database preparation have been separated. +- Set default priority for processFileEvent and processSynchroniseResult for the place to add hooks. +- ControlService now provides the readiness for processing operations. +- DatabaseService is now able to modify database opening options on derived classes. +- Now `useOfflineScanner`, `useCheckRemoteSize`, and `useRedFlagFeatures` are set from `main.ts`, instead of `LiveSyncBaseCore`. + +### Internal API changes + +- Storage Access APIs are now yielding Promises. This is to allow more limited storage platforms to be supported. +- Journal Replicator now yields true after the replication is done. + +### CLI + +We have previously developed FileSystem LiveSync and various other components in a separate repository, but updates have been significantly delayed, and we have been plagued by compatibility issues. Now, a CLI tool using the same core logic is emerging. This does not directly manipulate the file system, but it offers a more convenient way of working and can also communicate with Object Storage. We can also resolve conflicts. Please refer to the code in `src/apps/cli` for the [self-hosted-livesync-cli](./src/apps/cli/README.md) for more details. + +- Add `self-hosted-livesync-cli` to `src/apps/cli` as a headless and dedicated version. +- Add more tests. +- Object Storage support has also been confirmed (and fixed) in CLI. + - Yes, we have finally managed to 'get one file'. +- Now binary files are also supported in the CLI. + +### R&D + +- Browser-version of Self-hosted LiveSync is now in development. This is not intended for public use now, but I will eventually make it available for testing. +- We can see the code in `src/apps/webapp` for the browser version. + + +## 0.25.52 + +9th March, 2026 + +Excuses: Too much `I`. +Whilst I had a fever, I could not figure it out at all, but once I felt better, I spotted the problem in about thirty seconds. I apologise for causing you concern. I am grateful for your patience. +I would like to devise a mechanism for running simple test scenarios. Now that we have got the Obsidian CLI up and running, it seems the perfect opportunity. + +To improve the bus factor, we really need to organise the source code more thoroughly. Your cooperation and contributions would be greatly appreciated. + +### Fixed + +- No longer unexpected deletion-propagation occurs when the parent directory is not empty (#813). + +### Revert reversions + +- Reverted the reversion of ModuleCheckRemoteSize. Now it is back to the service feature. + +## 0.25.51 + +7th March, 2026 + +### Reverted + +- Reverted to ModuleRedFlag and ModuleInitializerFile to the previous version because of some unexpected issues. (#813) + - I will re-implement them in the future with better design and tests. + +## 0.25.50 + +3rd March, 2026 + +Note: 0.25.49 has been skipped because of too verbose logging (credentials are logged in verbose level, but I realised that could lead to unexpected exposure on issue reporting). Please bump to 0.25.50 to get the fix if you are on 0.25.49. (No expected behaviour changes except the logging). + +### Fixed + +- No longer deleted files are not clickable in the Global History pane. +- Diff view now uses more specific classes (#803). +- A message of configuration mismatching slightly added for better understanding. + - Now it says `When replication is initiated manually via the command palette or ribbon, a dialogue box will open to address this.` to make it clear that the user can fix the issue by themselves. + +### Refactored + +- `ModuleRedFlag` has been refactored to `serviceFeatures/redFlag` and also tested. +- `ModuleInitializerFile` has been refactored to `lib/serviceFeatures/offlineScanner` and also tested. + +## 0.25.48 + +2nd March, 2026 + +No behavioural changes except unidentified faults. Please report if you find any unexpected behaviour after this update. + +### Refactored + +- Many storage-related functions have been refactored for better maintainability and testability. + - Now all platform-specific logics are supplied as adapters, and the core logic has become platform-agnostic. + - Quite a number of tests have been added for the core logic, and the platform-specific logics are also tested with mocked adapters. + +## 0.25.47 + +27th February, 2026 + +Phew, the financial year is still not over yet, but I have got some time to work on the plug-in again! + +### Fixed and refactored + +- Fixed the inexplicable behaviour when retrieving chunks from the network. + - The chunk manager has been layered to be responsible for its own areas and duties. e.g., `DatabaseWriteLayer`, `DatabaseReadLayer`, `NetworkLayer`, `CacheLayer`, and `ArrivalWaitLayer`. + - All layers have been tested now! + - `LayeredChunkManager` has been implemented to manage these layers. Also tested. + - `EntryManager` has been mostly rewritten and also tested. + +- Now we can configure `Never warn` for remote storage size notification again. + +### Tests + +- The following test has been added: + - `ConflictManager`. + +## 0.25.46 + +26th February, 2026 + +### Fixed + +- Unexpected errors no longer occurred when the plug-in was unloaded. +- Hidden File Sync now respects selectors. +- Registering protocol-handlers now works safely without causing unexpected errors. + +### Refactored + +- `ModuleCheckRemoteSize` has been ported to a serviceFeature, and tests have also been added. +- Some unnecessary things have been removed. +- LiveSyncManagers has now explicit dependencies. +- LiveSyncLocalDB is now responsible for LiveSyncManagers, not accepting the managers as dependencies. + - This is to avoid circular dependencies and clarify the ownership of the managers. +- ChangeManager has been refactored. This had a potential issue, so something had been fixed, possibly. +- Some tests have been ported from Deno's test runner to Vitest to accumulate coverage. + +## 0.25.45 + +25th February, 2026 + +As a result of recent refactoring, we are able to write tests more easily now! + +### Refactored + +- `ModuleTargetFilter`, which was responsible for checking if a file is a target file, has been ported to a serviceFeature. + - And also tests have been added. The middleware-style-power. +- `ModuleObsidianAPI` has been removed and implemented in `APIService` and `RemoteService`. +- Now `APIService` is responsible for the network-online-status, not `databaseService.managers.networkManager`. + +## 0.25.44 + +24th February, 2026 + +This release represents a significant architectural overhaul of the plug-in, focusing on modularity, testability, and stability. While many changes are internal, they pave the way for more robust features and easier maintenance. +However, as this update is very substantial, please do feel free to let me know if you encounter any issues. + +### Fixed + +- Ignore files (e.g., `.ignore`) are now handled efficiently. +- Replication & Database: + - Replication statistics are now correctly reset after switching replicators. +- Fixed `File already exists` for .md files has been merged (PR #802) So thanks @waspeer for the contribution! + +### Improved + +- Now we can configure network-error banners as icons, or hide them completely with the new `Network Warning Style` setting in the `General` pane of the settings dialogue. (#770, PR #804) + - Thanks so much to @A-wry! + +### Refactored + +#### Architectural Overhaul: + +- A major transition from Class-based Modules to a Service/Middleware architecture has begun. + - Many modules (for example, `ModulePouchDB`, `ModuleLocalDatabaseObsidian`, `ModuleKeyValueDB`) have been removed or integrated into specific Services (`database`, `keyValueDB`, etc.). + - Reduced reliance on dynamic binding and inverted dependencies; dependencies are now explicit. + - `ObsidianLiveSyncPlugin` properties (`replicator`, `localDatabase`, `storageAccess`, etc.) have been moved to their respective services for better separation of concerns. + - In this refactoring, the Service will henceforth, as a rule, cease to use setHandler, that is to say, simple lazy binding. + - They will be implemented directly in the service. + - However, not everything will be middlewarised. Modules that maintain state or make decisions based on the results of multiple handlers are permitted. +- Lifecycle: + - Application LifeCycle now starts in `Main` rather than `ServiceHub` or `ObsidianMenuModule`, ensuring smoother startup coordination. + +#### New Services & Utilities: + +- Added a `control` service to orchestrate other services (for example, handling stop/start logic during settings realisation). +- Added `UnresolvedErrorManager` to handle and display unresolved errors in a unified way. +- Added `logUtils` to unify logging injection and formatting. +- `VaultService.isTargetFile` now uses multiple, distinct checkers for better extensibility. + +#### Code Separation: + +- Separated Obsidian-specific logic from base logic for `StorageEventManager` and `FileAccess` modules. +- Moved reactive state values and statistics from the main plug-in instance to the services responsible for them. + +#### Internal Cleanups: + +- Many functions have been renamed for clarity (for example, `_isTargetFileByLocalDB` is now `_isTargetAcceptedByLocalDB`). +- Added `override` keywords to overridden items and removed dynamic binding for clearer code inheritance. +- Moved common functions to the common library. + +#### Dependencies: + +- Bumped dependencies simply to a point where they can be considered problem-free (by human-powered-artefacts-diff). + - Svelte, terser, and more something will be bumped later. They have a significant impact on the diff and paint it totally. + - You may be surprised, but when I bump the library, I am actually checking for any unintended code. + +## 0.25.43-patched-9 a.k.a. 0.25.44-rc1 + +We are finally ready for release. I think I will go ahead and release it after using it for a few days. + +### Fixed + +- Hidden file synchronisation now works! +- Now Hidden file synchronisation respects `.ignore` files. +- Replicator initialisation during rebuilding now works correctly. + +### Refactored + +- Some methods naming have been changed for better clarity, i.e., `_isTargetFileByLocalDB` is now `_isTargetAcceptedByLocalDB`. + +### Follow-up tasks memo (After 0.25.44) + +Going forward, functionality that does not span multiple events is expected to be implemented as middleware-style functions rather than modules based on classes. + +Consequently, the existing modules will likely be gradually dismantled. +For reference, `ModuleReplicator.ts` has extracted several functionalities as functions. + +However, this does not negate object-oriented design. Where lifecycles and state are present, and the Liskov Substitution Principle can be upheld, we design using classes. After all, a visible state is preferable to a hidden state. In other words, the handler still accepts both functions and member methods, so formally there is no change. + +As undertaking this for everything would be a bit longer task, I intend to release it at this stage. + +Note: I left using `setHandler`s that as a mark of `need to be refactored`. Basically, they should be implemented in the service itself. That is because it is just a mis-designed, separated implementation. + +## 0.25.43-patched-8 + +I really must thank you all. You know that it seems we have just a little more to do. +Note: This version is not fully tested yet. Be careful to use this. Very dogfood-y one. + +### Fixed + +- Now the device name is saved correctly. + +### Refactored + +- Add `override` keyword to all overridden items. +- More dynamic binding has been removed. +- The number of inverted dependencies has decreased much more. +- Some check-logic; i.e., like pre-replication check is now separated into check functions and added to the service as handlers, layered. + - This may help with better testing and better maintainability. + + +## 0.25.43-patched-7 + +19th February, 2026 + +Right then, let us make a decision already. + +Last time, since I found a bug, I ended up doing a few other things as well, but next time I intend to release it with just the bug fix. It is quite substantial, after all. + +Customisation Sync has mostly been verified. Hidden file synchronisation has not been done yet. + +Vite's build system is not in the production. However, I possibly migrate to it in the future. + +And, the `daily-progress` will be tidied on releasing 0.25.44. Do not worry! + +### Fixed + +- Fixed an issue where the StorageEventManager was not correctly loading the settings. +- Replication statistics are now correctly reset after switching replicators. + +### Refactored + +- Now, many reactive values which keep the state or statistics of the plugin are moved to the services which have the responsibility for these states. +- `serviceFeatures` are now able to be added to the services; this is not a class module, but a function which accepts dependencies and returns an addHandler-able function. This is for better separation of concerns, better maintainability, and testability. +- `control` service; is a meta-service which is responsible for orchestrating services has been added. + - Don't you think stopping replication or something occurs during `settingService.realiseSetting` is quite weird? It may be done by the control service, which can orchestrate the setting service and the replicator service. + - +- Some functions on services have been moved. e.g., `getSystemVaultName` is now on the API service. +- Setting Service is now responsible for the setting, no longer using dynamic binding for the modules. + +## 0.25.43-patched-6 + +18th February, 2026 + +Let me confess that I have lied about `now all ambiguous properties`... I have found some more implicit calling. + +Note: I have not checked hidden file sync and customisation sync yet. Please report if you find any unexpected behaviour in these features. + +### Fixed + +- Now ReplicatorService responds to database reset and database initialisation events to dispose of the active replicator. + - Fixes some unlocking issues during rebuilding. + +### Refactored + +- Now `StorageEventManagerBase` is separated from `StorageEventManagerObsidian` following their concerns. + - No longer using `ObsidianFileAccess` indirectly during checking duplicated-file events. + - Last event memorisation is now moved into the StorageAccessManager, just like the file processing interlocking. + - These methods, i.e., `ObsidianFileAccess.touch`. `StorageEventManager.recentlyTouched`, and `StorageEventManager.touch` are still available, but simply call the StorageAccessManager's methods. +- Now `FileAccessBase` is separated from `FileAccessObsidian` following their concerns. + +## 0.25.43-patched-5 + +17th February, 2026 + +Yes, we mostly have got refactored! + +### Refactored + +- Following properties of `ObsidianLiveSyncPlugin` are now initialised more explicitly: + + - property : what is responsible + - `storageAccess` : `ServiceFileAccessObsidian` + - `databaseFileAccess` : `ServiceDatabaseFileAccess` + - `fileHandler` : `ServiceFileHandler` + - `rebuilder` : `ServiceRebuilder` + - Not so long from now, ServiceFileAccessObsidian might be abstracted to a more general FileAccessService, and make more testable and maintainable. + - These properties are initialised in `initialiseServiceModules` on `ObsidianLiveSyncPlugin`. + - They are `ServiceModule`s. + - Which means they do not use dynamic binding themselves, but they use bound services. + - ServiceModules are in src/lib/src/serviceModules for common implementations, and src/serviceModules for Obsidian-specific implementations. + - Hence, now all ambiguous properties of `ObsidianLiveSyncPlugin` are initialised explicitly. We can proceed to testing. + - Well, I will release v0.25.44 after testing this. + +- Conflict service is now responsible for `resolveAllConflictedFilesByNewerOnes` function, which has been in the rebuilder. +- New functions `updateSettings`, and `applyPartial` have been added to the setting service. We should use these functions instead of directly writing the settings on `ObsidianLiveSyncPlugin.setting`. +- Some interfaces for services have been moved to src/lib/src/interfaces. +- `RemoteService.tryResetDatabase` and `tryCreateDatabase` are now moved to the replicator service. + - You know that these functions are surely performed by the replicator. + - Probably, most of the functions in `RemoteService` should be moved to the replicator service, but for now, these two functions are moved as they are the most related ones, to rewrite the rebuilder service. +- Common functions are gradually moved to the common library. +- Now, binding functions on modules have been delayed until the services and service modules are initialised, to avoid fragile behaviour. + +## 0.25.43-patched-4 + +16th February, 2026 + +I have been working on it little by little in my spare time. Sorry for the delayed response for issues! ! However, thanks for your patience, we seems the `revert to 0.25.43` is not necessary, and I will keep going with this version. + +### Refactored + +- No longer `DatabaseService` is an injectable service. It is now actually a service which has its own handlers. No dynamic binding for necessary functions. +- Now the following properties of `ObsidianLiveSyncPlugin` belong to each service: + - `replicator` : `services.replicator` (still we can access `ObsidianLiveSyncPlugin.replicator` for the active replicator) +- A Handy class `UnresolvedErrorManager` has been added, which is responsible for managing unresolved errors and their handlers (we will see `unresolved errors` on a red-background-banner in the editor when they occur). + - This manager can be used to handle unresolved errors in a unified way, and it can also be used to display notifications or something when unresolved errors occur. + +## 0.25.43-patched-3 + +16th February, 2026 + +### Refactored + +- Now following properties of `ObsidianLiveSyncPlugin` belong to each service: + - property : service (still we can access these properties from `ObsidianLiveSyncPlugin` for better usability, but probably we should access these from services to clarify the dependencies) + - `localDatabase` : `services.database` + - `managers` : `services.database` + - `simpleStore` : `services.keyValueDB` + - `kvDB`: `services.keyValueDB` +- Initialising modules, addOns, and services are now explicitly separated in the `_startUp` function of the main plug-in class. +- LiveSyncLocalDB now depends more explicitly on specified services, not the whole `ServiceHub`. +- New service `keyValueDB` has been added. This had been separated from the `database` service. +- Non-trivial modules, such as `ModuleExtraSyncObsidian` (which only holds deviceAndVaultName), are simply implemented in the service. +- Add `logUtils` for unifying logging method injection and formatting. This utility is able to accept the API service for log writing. +- `ModuleKeyValueDB` has been removed, and its functionality is now implemented in the `keyValueDB` service. +- `ModulePouchDB` and `ModuleLocalDatabaseObsidian` have been removed, and their functionality is now implemented in the `database` service. + - Please be aware that you have overridden createPouchDBInstance or something by dynamic binding; you should now override the createPouchDBInstance in the database service instead of using the module. + - You can refer to the `DirectFileManipulatorV2` for an example of how to override the createPouchDBInstance function in the database service. + +## 0.25.43-patched-2 + +14th February, 2026 + +### Fixed + +- Application LifeCycle has now started in Main, not ServiceHub. + - Indeed, ServiceHub cannot be known other things in main have got ready, so it is quite natural to start the lifecycle in main. + +## 0.25.43-patched-1 + +13th February, 2026 + +**NOTE: Hidden File Sync and Customisation Sync may not work in this version.** + +Just a heads-up: this is a patch version, which is essentially a beta release. Do not worry about the following memos, as they are indeed freaking us out. I trust that you have thought this was too large; you're right. + +If this cannot be stable, I will revert to 0.24.43 and try again. + +### Refactored + +- Now resolving unexpected and inexplicable dependency order issues... +- The function which is able to implement to the service is now moved to each service. + - AppLifecycleService.performRestart +- VaultService.isTargetFile is now using multiple checkers instead of a single function. + - This change allows better separation of concerns and easier extension in the future. +- Application LifeCycle has now started in ServiceHub, not ObsidianMenuModule. + + - It was in a QUITE unexpected place..., isn't it? + - Instead of, we should call `await this.services.appLifecycle.onReady()` in other platforms. + - As in the browser platform, it will be called at `DOMContentLoaded` event. + +- ModuleTargetFilter, which is responsible for parsing ignore files, has been refined. + - This should be separated to a TargetFilter and an IgnoreFileFilter for better maintainability. +- Using `API.addCommand` or some Obsidian API and shimmer APIs, Many modules have been refactored to be derived to AbstractModule from AbstractObsidianModule, to clarify the dependencies. (we should make `app` usage clearer...) +- Fixed initialising `storageAccess` too late in `FileAccessObsidian` module (I am still wondering why it worked before...). +- Remove some redundant overrides in modules. + +### Planned + +- Some services have an ambiguous name, such as `Injectable`. These will be renamed in the future for better clarity. +- Following properties of `ObsidianLiveSyncPlugin` should be initialised more explicitly: + - property : where it is initialised currently + - `localDatabase` : `ModuleLocalDatabaseObsidian` + - `managers` : `ModuleLocalDatabaseObsidian` + - `replicator` : `ModuleReplicator` + - `simpleStore` : `ModuleKeyValueDB` + - `storageAccess` : `ModuleFileAccessObsidian` + - `databaseFileAccess` : `ModuleDatabaseFileAccess` + - `fileHandler` : `ModuleFileHandler` + - `rebuilder` : `ModuleRebuilder` + - `kvDB`: `ModuleKeyValueDB` + - And I think that having a feature in modules directly is not good for maintainability, these should be separated to some module (loader) and implementation (not only service, but also independent something). +- Plug-in statuses such as requestCount, responseCount... should be moved to a status service or somewhere for better separation of concerns. + +## 0.25.43 + +5th, February, 2026 + +### Fixed + +- Encryption/decryption issues when using Object Storage as remote have been fixed. + - Now the plug-in falls back to V1 encryption/decryption when V2 fails (if not configured as ForceV1). + - This may fix the issue reported in #772. + +### Notice + +Quite a few packages have been updated in this release. Please report if you find any unexpected behaviour after this update. + +## 0.25.42 + +2nd, February, 2026 + +This release is identical to 0.25.41-patched-3, except for the version number. + +### Refactored + +- Now the service context is `protected` instead of `private` in `ServiceBase`. + - This change allows derived classes to access the context directly. +- Some dynamically bound services have been moved to services for better dependency management. +- `WebPeer` has been moved to the main repository from the sub repository `livesync-commonlib` for correct dependency management. +- Migrated from the outdated, unstable platform abstraction layer to services. + - A bit more services will be added in the future for better maintainability. + +## 0.25.41 + +24th January, 2026 + +### Fixed + +- No longer `No available splitter for settings!!` errors occur after fetching old remote settings while rebuilding local database. (#748) + +### Improved + +- Boot sequence warning is now kept in the in-editor notification area. + +### New feature + +- We can now set the maximum modified time for reflect events in the settings. (for #754) + - This setting can be configured from `Patches` -> `Remediation` in the settings dialogue. + - Enabling this setting will restrict the propagation from the database to storage to only those changes made before the specified date and time. + - This feature is primarily intended for recovery purposes. After placing `redflag.md` in an empty vault and importing the Self-hosted LiveSync configuration, please perform this configuration, and then fetch the local database from the remote. + - This feature is useful when we want to prevent recent unwanted changes from being reflected in the local storage. + +### Refactored + +- Module to service refactoring has been started for better maintainability: + - UI module has been moved to UI service. + +### Behaviour change + +- Default chunk splitter version has been changed to `Rabin-Karp` for new installations. + +## 0.25.40 + +23rd January, 2026 + +### Fixed + +- Fixed an issue where some events were not triggered correctly after the refactoring in 0.25.39. + +## 0.25.39 + +23rd January, 2026 + +Also no behaviour changes or fixes in this release. Just refactoring for better maintainability. Thank you for your patience! I will address some of the reported issues soon. +However, this is not a minor refactoring, so please be careful. Let me know if you find any unexpected behaviour after this update. + +### Refactored + +- Rewrite the service's binding/handler assignment systems +- Removed loopholes that allowed traversal between services to clarify dependencies. +- Consolidated the hidden state-related state, the handler, and the addition of bindings to the handler into a single object. + - Currently, functions that can have handlers added implement either addHandler or setHandler directly on the function itself. + I understand there are differing opinions on this, but for now, this is how it stands. +- Services now possess a Context. Please ensure each platform has a class that inherits from ServiceContext. +- To permit services to be dynamically bound, the services themselves are now defined by interfaces. + +## 0.25.38 + +17th January, 2026 + +### Fixed + +- Fixed an issue where indexedDB would not close correctly on some environments, causing unexpected errors during database operations. + +## 0.25.37 + +15th January, 2026 + +Thank you for your patience until my return! + +This release contains minor changes discovered and fixed during test implementation. +There are no changes affecting usage. + +### Refactored + +- Logging system has been slightly refactored to improve maintainability. +- Some import statements have been unified. + +## 0.25.36 + +25th December, 2025 + +### Improved + +- Now the garbage collector (V3) has been implemented. (Beta) + - This garbage collector ensures that all devices are synchronised to the latest progress to prevent inconsistencies. + - In other words, it makes sure that no new conflicts would have arisen. + - This feature requires additional information (via node information), but it should be more reliable. + - This feature requires all devices have v0.25.36 or later. + - After the garbage collector runs, the database size may be reduced (Compaction will be run automatically after GC). + - We should have an administrative privilege on the remote database to run this garbage collector. +- Now the plug-in and device information is stored in the remote database. + - This information is used for the garbage collector (V3). + - Some additional features may be added in the future using this information. + +## 0.25.35 + +24th December, 2025 + +Sorry for a small release! I would like to keep things moving along like this if possible. After all, the holidays seem to be starting soon. I will be doubled by my business until the 27th though, indeed. + +### Fixed + +- Now the conflict resolution dialogue shows correctly which device only has older APIs (#764). + +## 0.25.34 + +10th December, 2025 + +### Behaviour change + +- The plug-in automatically fetches the missing chunks even if `Fetch chunks on demand` is disabled. + - This change is to avoid loss of data when receiving a bulk of revisions. + - This can be prevented by enabling `Use Only Local Chunks` in the settings. +- Storage application now saved during each event and restored on startup. +- Synchronisation result application is also now saved during each event and restored on startup. + - These may avoid some unexpected loss of data when the editor crashes. + +### Fixed + +- Now the plug-in waits for the application of pended batch changes before the synchronisation starts. + - This may avoid some unexpected loss or unexpected conflicts. + Plug-in sends custom headers correctly when RequestAPI is used. +- No longer causing unexpected chunk creation during `Reset synchronisation on This Device` with bucket sync. + +### Refactored + +- Synchronisation result application process has been refactored. +- Storage application process has been refactored. + - Please report if you find any unexpected behaviour after this update. A bit of large refactoring. + +## 0.25.33 + +05th December, 2025 + +### New feature + +- We can analyse the local database with the `Analyse database usage` command. + - This command makes a TSV-style report of the database usage, which can be pasted into spreadsheet applications. + - The report contains the number of unique chunks and shared chunks for each document revision. + - Unique chunks indicate the actual consumption. + - Shared chunks indicate the reference counts from other chunks with no consumption. + - We can find which notes or files are using large amounts of storage in the database. Or which notes cannot share chunks effectively. + - This command is useful when optimising the database size or investigating an unexpectedly large database size. +- We can reset the notification threshold and check the remote usage at once with the `Reset notification threshold and check the remote database usage` command. +- Commands are available from the Command Palette, or `Hatch` pane in the settings dialogue. + +### Fixed + +- Now the plug-in resets the remote size notification threshold after rebuild. + +## 0.25.32 + +02nd December, 2025 + +Now I am back from a short (?) break! Thank you all for your patience. (It is nothing major, but the first half of the year has finally come to an end). +Anyway, I will release the things a bit by bit. I think that we need a rehabilitation or getting gears in again. + +### Improved + +- Now the plugin warns when we are in several file-related situations that may cause unexpected behaviour (#300). + - These errors are displayed alongside issues such as file size exceeding limits. + - Such situations include: + - When the document has a name which is not supported by some file systems. + - When the vault has the same file names with different letter cases. + +## 0.25.31 + +18th November, 2025 + +### Fixed + +- Now fetching configuration from the server can handle the empty remote correctly (reported on #756). +- No longer asking to switch adapters during rebuilding. + +## 0.25.0 through 0.25.30 + +(0.25.0 through 0.25.30) + +Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025) + +After reading Issue #668, I conducted another self-review of the E2EE-related code. In retrospect, it was clearly written by someone inexperienced, which is understandable, but it is still rather embarrassing. Three years is certainly enough time for growth. + +I have now rewritten the E2EE code to be more robust and easier to understand. It is significantly more readable and should be easier to maintain in the future. The performance issue, previously considered a concern, has been addressed by introducing a master key and deriving keys using HKDF. This approach is both fast and robust, and it provides protection against rainbow table attacks. (In addition, this implementation has been [a dedicated package on the npm registry](https://github.com/vrtmrz/octagonal-wheels), and tested in 100% branch-coverage). + +As a result, this is the first time in a while that forward compatibility has been broken. We have also taken the opportunity to change all metadata to use encryption rather than obfuscation. Furthermore, the `Dynamic Iteration Count` setting is now redundant and has been moved to the `Patches` pane in the settings. Thanks to Rabin-Karp, the eden setting is also no longer necessary and has been relocated accordingly. Therefore, v0.25.0 represents a legitimate and correct evolution. + +--- + +## 0.25.30 + +17th November, 2025 + +So sorry for the quick follow-up release, due to a humble mistake in a quick causing a matter. + +### Fixed + +- Now we can save settings correctly again (#756). + +## ~~0.25.28~~ 0.25.29 + +(0.25.28 was skipped due to a packaging issue.) + +17th November, 2025 + +### New feature + +- We can now configure hidden file synchronisation to always overwrite with the latest version (#579). + +### Fixed + +- Timing dependency issues during initialisation have been mitigated (#714) + +### Improved + +- Error logs now contain stack-traces for better inspection. + +## 0.25.27 + +12th November, 2025 + +### Improved + +- Now we can switch the database adapter between IndexedDB and IDB without rebuilding (#747). + - Just a local migration will be required, but faster than a full rebuild. +- No longer checking for the adapter by `Doctor`. + +### Changes + +- The default adapter is reverted to IDB to avoid memory leaks (#747). + +### Fixed (?) + +- Reverted QR code library to v1.4.4 (To make sure #752). + +## 0.25.26 + +07th November, 2025 + +### Improved + +- Some JWT notes have been added to the setting dialogue (#742). + +### Fixed + +- No longer wrong values encoded into the QR code. +- We can acknowledge why the QR codes have not been generated. + - Probably too large a dataset to encode. When this happens, please consider using Setup-URI via text instead of QR code, or reduce the settings temporarily. + +### Refactored + +- Some dependencies have been updated. +- Internal functions have been modularised into `octagonal-wheels` packages and are well tested. + - `dataobject/Computed` for caching computed values. + - `encodeAnyArray/decodeAnyArray` for encoding and decoding any array-like data into compact strings (#729). +- Fixed importing from the parent project in library codes. (#729). + +## 0.25.25 + +06th November, 2025 + +### Fixed + +#### JWT Authentication + +- Now we can use JWT Authentication ES512 correctly (#742). +- Several misdirections in the Setting dialogues have been fixed (i.e., seconds and minutes confusion...). +- The key area in the Setting dialogue has been enlarged and accepts newlines correctly. +- Caching of JWT tokens now works correctly + - Tokens are now cached and reused until they expire. + - They will be kept until 10% of the expiration duration is remaining or 10 seconds, whichever is longer (but at a maximum of 1 minute). +- JWT settings are now correctly displayed on the Setting dialogue. + +And, tips about JWT Authentication on CouchDB have been added to the documentation (docs/tips/jwt-on-couchdb.md). + +#### Other fixes + +- Receiving non-latest revisions no longer causes unexpected overwrites. + - On receiving revisions that made conflicting changes, we are still able to handle them. + +### Improved + +- No longer duplicated message notifications are shown when a connection to the remote server fails. + - Instead, a single notification is shown, and it will be kept on the notification area inside the editor until the situation is resolved. +- The notification area is no longer imposing, distracting, and overwhelming. + - With a pale background, but bordered and with icons. + +## 0.25.24 + +04th November, 2025 + +(Beta release notes have been consolidated to this note). + +### Guidance and UI improvements! + +Since several issues were pointed out, our setup procedure had been quite `system-oriented`. This is not good for users. Therefore, I have changed the procedure to be more `goal-oriented`. I have made extensive use of Svelte, resulting in a very straightforward setup. +While I would like to accelerate documentation and i18n adoption, I do not want to confuse everyone who's already working on it. Therefore, I have decided to release a Beta version at this stage. Significant changes are not expected from this point onward, so I will proceed to stabilise the codebase. (However, this is significant). + +### TURN server support and important notice + +TURN server settings are only necessary if you are behind a strict NAT or firewall that prevents direct P2P +connections. In most cases, you do not need to set up a TURN server. + +Using public TURN servers may have privacy implications, as your data will be relayed through third-party +servers. Even if your data are encrypted, your existence may be known to them. Please ensure you trust the TURN +server provider before using their services. Also your `network administrator` too. You should consider setting +up your own TURN server for your FQDN, if possible. + +### New features + +- We can use the TURN server for P2P connections now. + +### Fixed + +- P2P Replication got more robust and stable. + - Update [Trystero](https://github.com/dmotz/trystero) to the official v0.22.0! + - Fixed a bug that caused P2P connections to drop or (unwanted reconnection to the relay server) unexpectedly in some environments. + - Now, the connection status is more accurately reported. + - While in the background, the connection to the signalling server is now disconnected to save resources. + - When returning to the foreground, it will not reconnect automatically for safety. Please reconnect manually. +- All connection configurations should be edited in each dedicated dialogue now. +- No longer will larger files create chunks during preparing `Reset Synchronisation on This Device`. +- Now hidden file synchronisation respects the filters correctly (#631, #735) + - And `ignore-files` settings are also respected and surely read during the start-up. + +### Behaviour changes + +- The setup wizard is now more `goal-oriented`. Brand-new screens are introduced. +- `Fetch everything` and `Rebuild everything` are now `Reset Synchronisation on This Device` and `Overwrite Server Data with This Device's Files`. +- Remote configuration and E2EE settings are now separated into each modal dialogue. + - Remote configuration is now more straightforward. And if we need the rebuild (No... `Overwrite Server Data with This Device's Files`), it is now clearly indicated. +- Peer-to-Peer settings are also separated into their own modal dialogue (still in progress, and we need to open a P2P pane, still). +- Setup-URI, and Report for the Issue are now not copied to the clipboard automatically. Instead, there are copy-dialogue and buttons to copy them explicitly. + - This is to avoid confusion for users who do not want to use these features. +- No longer optional features are introduced during the setup, or `Reset Synchronisation on This Device`, `Overwrite Server Data with This Device's Files`. + - This is to avoid confusion for users who do not want to use these features. Instead, we will be informed that optional features are available after the setup is completed. +- We cannot perform `Fetch everything` and `Rebuild everything` (Removed, so the old name) without restarting Obsidian now. + +### Miscellaneous + +- Setup QR Code generation is separated into a src/lib/src/API/processSetting.ts file. Please use it as a subrepository if you want to generate QR codes in your own application. +- Setup-URI is also separated into a src/lib/src/API/processSetting.ts +- Some direct access to web APIs is now wrapped into the services layer. + +### Dependency updates + +- Many dependencies are updated. Please see `package.json`. + - This is the hardest part of this update. I read most of the changes in the dependencies. If you find any extra information, please let me know. +- As upgrading TypeScript, Fixed many UInt8Array and Uint8Array type mismatches. +- + +### Breaking changes + +- Sending configuration via Peer-to-Peer connection is not compatible with older versions. + - Please upgrade all devices to v0.25.24.beta1 or later to use this feature again. + - This is due to security improvements in the encryption scheme. + +## 0.25.23 + +26th October, 2025 + +The next version we are preparing (you know that as 0.25.23.beta1) is now still on beta, resulting in this rather unfortunate versioning situation. Apologies for the confusion. The next v0.25.23.beta2 will be v0.25.24.beta1. In other words, this is a v0.25.22.patch-1 actually, but possibly not allowed by Obsidian's rule. +(Perhaps we ought to declare 1.0.0 with a little more confidence. The current minor part has been effectively a major one for a long time. If it were 1.22.1 and 1.23.0.beta1, no confusion ). + +### Fixed + +- We are now able to enable optional features correctly again (#732). +- No longer oversized files have been processed, furthermore. + + - Before creating a chunk, the file is verified as the target. + - The behaviour upon receiving replication has been changed as follows: + - If the remote file is oversized, it is ignored. + - If not, but while the local file is oversized, it is also ignored. + +- We are now able to enable optional features correctly again (#732). +- No longer oversized files have been processed, furthermore. + - Before creating a chunk, the file is verified as the target. + - The behaviour upon receiving replication has been changed as follows: + - If the remote file is oversized, it is ignored. + - If not, but while the local file is oversized, it is also ignored. + +## 0.25.22 + +15th October, 2025 + +### Fixed + +- Fixed a bug that caused wrong event bindings and flag inversion (#727) + - This caused following issues: + - In some cases, settings changes were not applied or saved correctly. + - Automatic synchronisation did not begin correctly. + +### Improved + +- Too large diffs are not shown in the file comparison view, due to performance reasons. + +### Notes + +- The checking algorithm implemented in 0.25.20 is also raised as PR (#237). And completely I merged it manually. + - Sorry for lacking merging this PR, and let me say thanks to the great contribution, @bioluks ! +- Known issues: + - Sync on Editor save seems not to work correctly in some cases. + - I am investigating this issue. If you have any information, please let me know. + +## 0.25.21 + +13th October, 2025 + +This release including 0.25.21.beta1 and 0.25.21.beta2. + +Apologies for taking a little time. I was seriously tackling this. +(Of course, being caught up in an unfamiliar structure due to personnel changes on my workplace played a part, but fortunately I have returned to a place where I can do research and development rather than production. Completely beside the point, though). +Now then, this time, moving away from 'convention over configuration', I have changed to a mechanism for manually binding events. This makes it much easier to leverage IDE assistance. +And, also, we are ready to separate `Features` and `APIs` from `Module`. Features are still in the module, but APIs will be moved to a Service layer. This will make it easier to maintain and extend the codebase in the future. + +If you have found any issues, please let me know. I am now on the following: + +- GitHub [Issues](https://github.com/vrtmrz/obsidian-livesync/issues) Excellent! May the other contributors will help you too. +- Twitter [@vorotamoroz](https://twitter.com/vorotamoroz) Quickest! +- Matrix [@vrtmrz:matrix.org](https://matrix.to/#/@vrtmrz:matrix.org) Also quick, and if you need to keep it private! + I am creating rooms too, but I'm struggling to figure out how to use them effectively because I cannot tell the difference of use-case between them and discussions. However, if you want to use Discord, this is a answer; We should on E2E encrypted platform. + +## 0.25.21.beta2 + +8th October, 2025 + +### Fixed + +- Fixed wrong event type bindings (which caused some events not to be handled correctly). +- Fixed detected a timing issue in StorageEventManager + - When multiple events for the same file are fired in quick succession, metadata has been kept older information. This induces unexpected wrong notifications and write prevention. + +## 0.25.21.beta1 + +6th October, 2025 + +### Refactored + +- Event handling now does not rely on 'convention over configuration'. + - Services.ts now have a proper event handler registration system. + +## 0.25.20 + +26th September, 2025 + +### Fixed + +- Chunk fetching no longer reports errors when the fetched chunk could not be saved (#710). + - Just using the fetched chunk temporarily. +- Chunk fetching reports errors when the fetched chunk is surely corrupted (#710, #712). +- It no longer detects files that the plug-in has modified. + - It may reduce unnecessary file comparisons and unexpected file states. + +### Improved + +- Now checking the remote database configuration respecting the CouchDB version (#714). + +## 0.25.19 + +18th September, 2025 + +### Improved + +- Now encoding/decoding for chunk data and encryption/decryption are performed in native functions (if they were available). + - This uses Uint8Array.fromBase64 and Uint8Array.toBase64, which are natively available in iOS 18.2+ and Android with Chrome 140+. + - In Android, WebView is by default updated with Chrome, so it should be available in most cases. + - Note that this is not available in Desktop yet (due to being based on Electron). We are staying tuned for future updates. + - This realised by an external(?) package [octagonal-wheels](https://github.com/vrtmrz/octagonal-wheels). Therefore, this update only updates the dependency. + +## 0.25.18 + +17th September, 2025 + +### Fixed + +- Property encryption detection now works correctly (On Self-hosted LiveSync, it was not broken, but as a library, it was not working correctly). +- Initialising the chunk splitter is now surely performed. +- DirectFileManipulator now works fine (as a library) + - Old `DirectFileManipulatorV1` is now removed. + +### Refactored + +- Removed some unnecessary intermediate files. + +## 0.25.17 + +16th September, 2025 + +### Fixed + +- No longer information-level logs have produced during toggling `Show only notifications` in the settings (#708). +- Ignoring filters for Hidden file sync now works correctly (#709). + +### Refactored + +- Removed some unnecessary intermediate files. + +## 0.25.16 + +4th September, 2025 + +### Improved + +- Improved connectivity for P2P connections +- The connection to the signalling server can now be disconnected while in the background or when explicitly disconnected. + - These features use a patch that has not been incorporated upstream. + - This patch is available at [vrtmrz/trystero](https://github.com/vrtmrz/trystero). + +## 0.25.15 + +3rd September, 2025 + +### Improved + +- Now we can configure `forcePathStyle` for bucket synchronisation (#707). + +## 0.25.14 + +2nd September, 2025 + +### Fixed + +- Opening IndexedDB handling has been ensured. +- Migration check of corrupted files detection has been fixed. + - Now informs us about conflicted files as non-recoverable, but noted so. + - No longer errors on not-found files. + +## 0.25.13 + +1st September, 2025 + +### Fixed + +- Conflict resolving dialogue now properly displays the changeset name instead of A or B (#691). + +## 0.25.12 + +29th August, 2025 + +### Fixed + +- Fixed an issue with automatic synchronisation starting (#702). + +## 0.25.11 + +28th August, 2025 + +### Fixed + +- Automatic translation detection on the first launch now works correctly (#630). +- No errors are shown during synchronisations in offline (if not explicitly requested) (#699). +- Missing some checking during automatic-synchronisation now works correctly. + +## 0.25.10 + +26th August, 2025 + +### New experimental feature + +- We can perform Garbage Collection (Beta2) without rebuilding the entire database, and also fetch the database. + - Note that this feature is very experimental and should be used with caution. + - This feature requires disabling `Fetch chunks on demand`. + +### Fixed + +- Resetting the bucket now properly clears all uploaded files. + +### Refactored + +- Some files have been moved to better reflect their purpose and improve maintainability. +- The extensive LiveSyncLocalDB has been split into separate files for each role. + +### Fixed + +- Unexpected `Failed to obtain PBKDF2 salt` or similar errors during bucket-synchronisation no longer occur. +- Unexpected long delays for chunk-missing documents when using bucket-synchronisation have been resolved. +- Fetched remote chunks are now properly stored in the local database if `Fetch chunks on demand` is enabled. +- The 'fetch' dialogue's message has been refined. +- No longer overwriting any corrupted documents to the storage on boot-sequence. + +### Refactored + +- Type errors have been corrected. + +## 0.25.9 + +20th August, 2025 + +### Fixed + +- CORS Checking messages now use replacements. +- Configuring CORS setting via the UI now respects the existing rules. +- Now startup-checking works correctly again, performs migration check serially and then it will also fix starting LiveSync or start-up sync. (#696) +- Statusline in editor now supported 'Bases'. + +## 0.25.8 + +18th August, 2025 + +### New feature + +- Insecure chunk detection has been implemented. + - A notification dialogue will be shown if any insecure chunks are detected; these may have been created by v0.25.6 due to its issue. If this dialogue appears, please ensure you rebuild the database after backing it up. + +## 0.25.7 + +15th August, 2025 + +**Since the release of 0.25.6, there are two large problem. Please update immediately.** + +- We may have corrupted some documents during the migration process. **Please check your documents on the wizard.** +- Due to a chunk ID assignment issue, some data has not been encrypted. **Please rebuild the database using Rebuild Everything** if you have enabled E2EE. + +**_So, If you have enabled E2EE, please perform `Rebuild everything`. If not, please check your documents on the wizard._** + +In next version, insecure chunk detection will be implemented. + +### Fixed + +- Off-loaded chunking have been fixed to ensure proper functionality (#693). +- Chunk document ID assignment has been fixed. +- Replication prevention message during version up detection has been improved (#686). +- `Keep A` and `Keep B` on Conflict resolving dialogue has been renamed to `Use Base` and `Use Conflicted` (#691). + +### Improved + +- Metadata and content-size unmatched documents are now detected and reported, prevented to be applied to the storage. + - This behaviour can be configured in `Patch` -> `Edge case addressing (Behaviour)` -> `Process files even if seems to be corrupted` + - Note: this toggle is for the direct-database-manipulation users. + +### New Features + +- `Scan for Broken files` has been implemented on `Hatch` -> `TroubleShooting`. + +### Refactored + +- Off-loaded processes have been refactored for the better maintainability. + - Files prefixed `bg.worker` are now work on the worker threads. + - Files prefixed `bgWorker.` are now also controls these worker threads. (I know what you want to say... I will rename them). +- Removed unused code. + +## ~~0.25.5~~ 0.25.6 + +(0.25.5 has been withdrawn due to a bug in the `Fetch chunks on demand` feature). + +9th August, 2025 + +### Fixed + +- Storage scanning no longer occurs when `Suspend file watching` is enabled (including boot-sequence). + - This change improves safety when troubleshooting or fetching the remote database. +- `Fetch chunks on demand` is now working again (if you installed 0.25.5, other versions are not affected). + +### Improved + +- Saving notes and files now consumes less memory. + - Data is no longer fully buffered in memory and written at once; instead, it is now written in each over-2MB increments. +- Chunk caching is now more efficient. + - Chunks are now managed solely by their count (still maintained as LRU). If memory usage becomes excessive, they will be automatically released by the system-runtime. + - Reverse-indexing is also no longer used. It is performed as scanning caches and act also as a WeakRef thinning. +- Both of them (may) are effective for #692, #680, and some more. + +### Changed + +- `Incubate Chunks in Document` (also known as `Eden`) is now fully sunset. + - Existing chunks can still be read, but new ones will no longer be created. +- The `Compute revisions for chunks` setting has also been removed. + - This feature is now always enabled and is no longer configurable (restoring the original behaviour). +- As mentioned, `Memory cache size (by total characters)` has been removed. + - The `Memory cache size (by total items)` setting is now the only option available (but it has 10x ratio compared to the previous version). + +### Refactored + +- A significant refactoring of the core codebase is underway. + - This is part of our ongoing efforts to improve code maintainability, readability, and to unify interfaces. + - Previously, complex files posed a risk due to a low bus factor. Fortunately, as our devices have become faster and more capable, we can now write code that is clearer and more maintainable (And not so much costs on performance). + - Hashing functions have been refactored into the `HashManager` class and its derived classes. + - Chunk splitting functions have been refactored into the `ContentSplitterCore` class and its derived classes. + - Change tracking functions have been refactored into the `ChangeManager` class. + - Chunk read/write functions have been refactored into the `ChunkManager` class. + - Fetching chunks on demand is now handled separately from the `ChunkManager` and chunk reading functions. Chunks are queued by the `ChunkManager` and then processed by the `ChunkFetcher`, simplifying the process and reducing unnecessary complexity. + - Then, local database access via `LiveSyncLocalDB` has been refactored to use the new classes. +- References to external sources from `commonlib` have been corrected. +- Type definitions in `types.ts` have been refined. +- Unit tests are being added incrementally. + - I am using `Deno` for testing, to simplify testing and coverage reporting. + - While this is not identical to the Obsidian environment, `jest` may also have limitations. It is certainly better than having no tests. + - In other words, recent manual scenario testing has highlighted some shortcomings. + - `pouchdb-test`, used for testing PouchDB with Deno, has been added, utilising the `memory` adapter. + +Side note: Although class-oriented programming is sometimes considered an outdated style, However, I have come to re-evaluate it as valuable from the perspectives of maintainability and readability. + +## 0.25.4 + +29th July, 2025 + +### Fixed + +- The PBKDF2Salt is no longer corrupted when attempting replication while the device is offline. (#686) + - If this issue has already occurred, please use `Maintenance` -> `Rebuilding Operations (Remote Only)` -> `Overwrite Remote` and `Send` to resolve it. + - Please perform this operation on the device that is most reliable. + - I am so sorry for the inconvenience; there are no patching workarounds. The rebuilding operation is the only solution. + - This issue only affects the encryption of the remote database and does not impact the local databases on any devices. + - (Preventing synchronisation is by design and expected behaviour, even if it is sometimes inconvenient. This is also why we should avoid using workarounds; it is, admittedly, an excuse). + - In any case, we can unlock the remote from the warning dialogue on receiving devices. We are performing replication, instead of simple synchronisation at the expense of a little complexity (I would love to express thank you again for your every effort to manage and maintain the settings! Your all understanding saves our notes). + - This process may require considerable time and bandwidth (as usual), so please wait patiently and ensure a stable network connection. + +### Side note + +The PBKDF2Salt will be referred to as the `Security Seed`, and it is used to derive the encryption key for replication. Therefore, it should be stored on the server prior to synchronisation. We apologise for the lack of explanation in previous updates! + +## 0.25.3 + +22nd July, 2025 + +### Fixed + +- Now the `Doctor` at migration will save the configuration. + +## 0.25.2 ~~0.25.1~~ + +(0.25.1 was missed due to a mistake in the versioning process). +19th July, 2025 + +### Refined and New Features + +- Fetching the remote database on `RedFlag` now also retrieves remote configurations optionally. + - This is beneficial if we have already set up another device and wish to use the same configuration. We will see a much less frequent `Unmatched` dialogue. +- The setup wizard using Set-up URI and QR code has been improved. + - The message is now more user-friendly. + - The obsolete method (manual setting application) has been removed. + - The `Cancel` button has been added to the setup wizard. + - We can now fetch the remote configuration from the server if it exists, which is useful for adding new devices. + - Mostly same as a `RedFlag` fetching remote configuration. + - We can also use the `Doctor` to check and fix the imported (and fetched) configuration before applying it. + +### Changes + +- The Set-up URI is now encrypted with a new encryption algorithm (mostly the same as `V2`). + - The new Set-up URI is not compatible with version 0.24.x or earlier. + +## 0.25.0 + +### Fixed + +- The encryption algorithm now uses HKDF with a master key. + - This is more robust and faster than the previous implementation. + - It is now more secure against rainbow table attacks. + - The previous implementation can still be used via `Patches` -> `End-to-end encryption algorithm` -> `Force V1`. + - Note that `V1: Legacy` can decrypt V2, but produces V1 output. +- `Fetch everything from the remote` now works correctly. + - It no longer creates local database entries before synchronisation. +- Extra log messages during QR code decoding have been removed. + +### Changed + +- The following settings have been moved to the `Patches` pane: + - `Remote Database Tweak` + - `Incubate Chunks in Document` + - `Data Compression` + +### Behavioural and API Changes + +- `DirectFileManipulatorV2` now requires new settings (as you may already know, E2EEAlgorithm). +- The database version has been increased to `12` from `10`. + - If an older version is detected, we will be notified and synchronisation will be paused until the update is acknowledged. (It has been a long time since this behaviour was last encountered; we always err on the side of caution, even if it is less convenient.) + +### Refactored + +- `couchdb_utils.ts` has been separated into several explicitly named files. +- Some missing functions in `bgWorker.mock.ts` have been added. diff --git a/docs/releases/1.0-previews.md b/docs/releases/1.0-previews.md new file mode 100644 index 00000000..d4940dc2 --- /dev/null +++ b/docs/releases/1.0-previews.md @@ -0,0 +1,161 @@ +# 1.0 preview release history + +This document records the opt-in beta and release-candidate builds published before 1.0.0. Most users upgrading from 0.25.83 only need the consolidated [1.0.0 release notes](../../updates.md). + +The prepared `1.0.0-rc.0` tag was not published as a plug-in release and is therefore omitted. + +## 1.0.0-rc.1 + +27th July, 2026 + +The work towards 1.0 has become so substantial that I have written [an article about it](https://fancy-syncing.vrtmrz.net/blog/0036-livesync-1_0_0-en.html) (linked again here). + +### Important + +- This candidate retains the plug-in behaviour prepared for rc.0. The version was advanced because release tags are immutable; rc.0 was stopped during CLI validation before a plug-in release was published. +- This remains an opt-in pre-release for BRAT validation and does not replace the latest stable release. The exact rc.1 plug-in and CLI artefacts will be validated separately after publication. + +### CLI and release validation + +- CLI release validation now generates Setup URIs through the supported ESM package interface, allowing the Docker test to reach the CLI container instead of stopping during test preparation. +- The CLI Docker image now assigns its entrypoint permissions explicitly, so non-root execution does not depend on permissions inherited from the source checkout. +- Release finalisation now explicitly dispatches the CLI container workflow when CLI publication is selected, rather than relying on a workflow-created tag to start another workflow. +- Focused regression tests guard the ESM execution mode and deterministic container entrypoint permissions, while the existing release-workflow tests now require explicit CLI dispatch with non-dry-run, immutable-tag inputs. + +### Testing + +- The native CLI setup, put, cat, list, information, deletion, conflict-resolution, and revision-retrieval scenario completed with the packaged Commonlib dependency. +- The same scenario completed through the rebuilt non-root Docker image. +- The focused CLI and release-workflow unit tests passed after first demonstrating all three regressions against the unmodified implementation. + +## 1.0.0-beta.5 + +26th July, 2026 + +### Improved + +- **Inspect conflicts and file/database differences** now compares the current Vault file with the database winner and every live conflict revision. Compact, mobile-friendly indicators show missing chunks, `Δsize`, `Δtime`, whether the Vault matches the winner, and whether conflict branches remain. +- Each reported file and live revision now has a compact wrench menu. Its available actions can compare readable text, apply the selected revision to the Vault, record an exact byte match, store the Vault content as a child of the selected branch, retry retrieving missing chunks without changing the revision tree, or discard only the selected live branch after confirmation. + +### Fixed + +- A winning logical deletion is no longer reported as a missing Vault file when the file is already absent. + +### Testing + +- Strengthened Real Obsidian coverage for start-up with an existing configuration, Security Seed readiness, failure diagnostics, and P2P status pane placement in separate desktop and mobile sessions. + +## 1.0.0-beta.4 + +25th July, 2026 + +### Improved + +- **Verify and repair all files** now reports the database winner, every conflict revision, missing chunks, and unavailable shared ancestors separately. It can retry an exact revision without changing the tree, while discarding an unreadable live revision requires explicit confirmation. +- Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands keep their identifiers, so hotkeys already assigned to them continue to work. The onboarding wizard can be reopened from **Self-hosted LiveSync settings** → **Setup**. +- Text in setup and review dialogues can now be selected for copying or translation. +- When LiveSync adopts an available interface translation on first start-up, it now continues initialisation and leaves a persistent Notice from which the translation details can be opened, instead of waiting for an unsolicited dialogue. + +### Fixed + +- An unreadable conflict revision is no longer deleted automatically merely because its chunks are unavailable on the current device. +- Garbage Collection V3 now protects chunks required by every live conflict branch and the available revision ancestry needed to review and merge conflicts, instead of considering only the database winner. The action is offered only for CouchDB because P2P has no central database to compact and does not provide the device inventory required by the workflow. Collection now stops when device progress cannot be verified, and a compaction timeout is no longer followed by a contradictory success message. + +### Testing + +- Added regressions for revision repair, command availability, selectable dialogues, conflict-aware chunk reachability, device-progress safeguards, and compaction timeouts. +- Added a real CouchDB integration test for logical chunk deletion, shared and conflict chunk retention, compaction completion, downstream replication, and content-addressed chunk recreation. +- Added a real Obsidian encrypted reconnect scenario which replaces the remote Security Seed while one client retains the previous value, verifies that synchronisation refreshes it without restoring the old value, and proves a bidirectional encrypted round-trip. + +## 1.0.0-beta.3 + +24th July, 2026 + +### Improved + +- Enabling Hidden File Sync now opens one progress Notice before its setting is saved and reuses that Notice throughout the initial file scan, instead of stacking separate phase and restart Notices. +- P2P is now presented only after it has been configured: its status pane no longer opens at start-up, its ribbon icon remains hidden for CouchDB-only Vaults, and the retired P2P pane command has been removed. The current pane distinguishes announcing changes, following a peer, and persistent per-device actions. Setup and guidance now distinguish the required signalling relay from optional TURN, and describe the public signalling relay's privacy and availability limits. +- First-device P2P setup now accepts a successfully opened signalling room without requiring another peer to be online. Additional-device Fetch still requires selecting a source peer and completing `P2P Rebuild`. +- Manual CouchDB setup now distinguishes creating a first database from connecting an additional device to an existing one. Settings mode can save an unverified profile explicitly, while onboarding requires a successful connection, and each proposed server-configuration fix requires separate confirmation. +- Differences limited to the chunk hash algorithm, chunk size, or splitter version are now aligned automatically by default. Existing content remains readable, while an explicit opt-out and any difference which also involves an incompatible setting retain manual review. + +### Fixed + +- Choosing **Apply settings to this device, and fetch again** for a compatible configuration mismatch now applies the remote settings before Fetch, instead of updating the remote database with this device's settings. +- Accepted settings which control how new chunks are created now take effect before synchronisation is retried, rather than leaving the previous hash or splitter active until restart. + +### Testing + +- Added regressions for P2P configuration, the distinction between setting up the first device and using Fetch on an additional device, the P2P status pane, CouchDB setup policy, and mobile dialogues. + +## 1.0.0-beta.2 + +23rd July, 2026 + +### Improved + +- Choosing **Not now** on a merge conflict now postpones repeated dialogues for that conflict while the active file retains an unresolved-conflict warning. Three or more live versions show their current count and are reviewed one deterministic pair at a time; completed pairs remain resolved across restart. The existing conflict commands can reopen a postponed conflict explicitly, and a later conflict prompts again after the current one has been resolved. + +### Fixed + +- Answering or externally closing a merge dialogue immediately no longer leaves conflict processing waiting for a response which has already occurred. + +### Testing + +- Added revision-tree regressions and focused real-Obsidian scenarios for multiple-version review and restart between resolution stages. + +## 1.0.0-beta.1 + +22nd July, 2026 + +### Important + +- This corrected opt-in integration preview follows `1.0.0-beta.0` and does not replace the latest stable release. Update every participating device before resuming synchronisation, and continue to use a current backup while testing with an existing Vault. + +### Fixed + +- Conflict resolutions made on another device no longer recreate the same conflict when the receiving Vault still contains the exact content of the deleted losing revision. Automatic text and structured-data merge now uses the nearest revision actually shared by both branches instead of inferring ancestry from revision generation numbers. +- Edits, deletions, and renames made while a file is conflicted now extend the exact revision displayed on that device. If LiveSync cannot prove the displayed branch, it preserves the affected branches for review instead of silently applying the operation to the database winner. + +### Testing + +- Added revision-tree regressions and focused real-Obsidian scenarios for propagated resolutions and file operations performed while a conflict remains active. + +## 1.0.0-beta.0 + +22nd July, 2026 + +### Important + +- This is an opt-in 1.0 integration preview for BRAT and testing with existing Vaults. It does not replace the latest stable release. Use it with a current backup, and update every participating device before resuming synchronisation. +- An upgraded, copied, or restored Vault may pause replication for an explicit compatibility review. The review preserves the existing automatic synchronisation choices and resumes them only after the decision has been saved successfully. + +### Improved + +- An unconfigured installation now waits for you to start setup. A long-lived Notice offers the setup action, and **Open onboarding wizard** remains available from the command palette instead of the dialogue opening automatically. +- The setup wizard now creates named remote profiles for CouchDB, Object Storage, and P2P. Current Setup URIs preserve their profile names and selections, and the wizard reserves Rebuild or Fetch before the ordinary start-up scan begins. +- Peer-to-Peer Synchronisation (P2P) and Hidden File Sync are supported opt-in features. JWT authentication, ignore files, automatic newer-file conflict resolution, and Garbage Collection V3 remain previews. Customisation Sync remains a supported advanced workflow. +- Data Compression remains available after measurement showed a modest, workload-dependent reduction in stored and transferred chunk data. Its benefits, costs, and reason for remaining disabled by default in 1.0 are described in the Data Compression specification. +- Compatibility review now runs before Config Doctor without overlapping it. Existing Vaults retain their automatic synchronisation choices and explicit file-name case setting. For installations created by earlier releases, LiveSync preserves whether setup had been completed and saves a missing legacy case setting as case-insensitive. +- P2P connections now restart reliably after settings are reapplied or the local database is reset. Setup on an additional device asks you to select the source device once. Disconnecting leaves the LiveSync room and closes its signalling relay connections so that reconnecting can establish a new room. +- Action buttons are stacked vertically, long setup dialogues keep their controls reachable on mobile screens, and persistent Notices no longer cover close controls. Hidden File Sync reload and restart requests are grouped into one message, including the case reported in issue #555. +- Warnings about estimated remote storage size now appear as long-lived clickable Notices instead of timed dialogues. Initial uploads and Rebuild operations no longer prompt to send every chunk in advance; ordinary replication completes the transfer. +- Removed the obsolete **Use the trash bin** control and the setting for fixed chunk revisions. Remote deletion still follows Obsidian's preference, and chunk revisions remain content-derived. The Change Log remains available but no longer opens automatically or tracks unread versions. + +### Fixed + +- The optional Custom HTTP Handler used by Object Storage now sends the correct byte range from binary request bodies and reports unsupported body types instead of silently sending an empty request. +- When selectors, ignore files, size limits, modification-time limits, or file-name case settings are broadened, LiveSync now rechecks previously received files without requiring another remote update. +- P2P setup on the first device no longer displays reset or upload steps for a central database, and Config Doctor now offers its chunk size recommendation for CouchDB only when a CouchDB remote profile is selected. + +### Security + +- Fly.io setup now generates CouchDB and Vault encryption secrets with cryptographically secure randomness. Dependency updates prevent excessive CPU use from specially crafted path patterns and `mailto:` links. The CLI rejects path traversal and symbolic-link components detected before Vault operations. + +### Miscellaneous + +- Self-hosted LiveSync now owns its translation catalogue. Commonlib provides English messages to other applications, and translation contributions can be made directly to the Self-hosted LiveSync repository. + +### Testing + +- Expanded automated testing in Obsidian for upgrades, synchronisation between two devices, CouchDB, Object Storage, P2P, Hidden File Sync, mobile dialogues, and clean-up after failures. diff --git a/docs/releases/legacy.md b/docs/releases/legacy.md new file mode 100644 index 00000000..e238bff8 --- /dev/null +++ b/docs/releases/legacy.md @@ -0,0 +1,1794 @@ +# Legacy release history + +This history covers releases before 0.25. Later releases are recorded in the [0.25 history](0.25.md) and the [current release history](../../updates.md). + +## 0.24 + +I know that we have been waiting for a long time. It is finally released! + +Over the past three years since the inception of the plugin, various features have been implemented to address diverse user needs. This is truly honourable, and I am grateful for your years of support. However, this process has led to an increasingly disorganised codebase, with features becoming entangled. Consequently, this has led to a situation where bugs can go unnoticed and resolving one issue may inadvertently introduce another. + +In 0.24.0, I reorganised the previously jumbled main codebase into clearly defined modules. Although I had assumed that the total size of the code would not increase, I discovered that it has in fact increased. While the complexity is still considerable, the refactoring has improved the clarity of the code's structure. Additionally, while testing the release candidates, we still found many bugs to fix, which helped to make this plug-in robust and stable. Therefore, we are now ready to use the updated plug-in, and in addition to that, proceed to the next step. + +This is also the first step towards a fully-fledged-fancy LiveSync, not just a plug-in from Obsidian. Of course, it will still be a plug-in primarily and foremost, but this development marks a significant step towards the self-hosting concept. + +Finally, I would like to once again express my respect and gratitude to all of you. My gratitude extends to all of the dev testers! Your contributions have certainly made the plug-in robust and stable! + +Thank you, and I hope your troubles will be resolved! + +--- + +## 0.24.31 + +10th July, 2025 + +### Fixed + +- The description of `Enable Developers' Debug Tools.` has been refined. + - Now performance impact is more clearly stated. +- Automatic conflict checking and resolution has been improved. + - It now works parallelly for each other file, instead of sequentially. It makes significantly faster on first synchronisation when with local files information. +- Resolving conflicts dialogue will not be shown for the multiple files at once. + - It will be shown for each file, one by one. + +## 0.24.30 + +9th July, 2025 + +### New Feature + +- New chunking algorithm `V3: Fine deduplication` has been added, and will be recommended after updates. + - The Rabin-Karp algorithm is used for efficient chunking. + - This will be the default in the new installations. + - It is more robust and faster than the previous one. + - We can change it in the `Advanced` pane of the settings. +- New language `ko` (Korean) has been added. + - Thank you for your contribution, [@ellixspace](https://x.com/ellixspace)! + - Any contributions are welcome, from any route. Please let me know if I seem to be unaware of this. It is often the case that I am not really aware of it. +- Chinese (Simplified) translation has been updated. + - Thank you for your contribution, [@52sanmao](https://github.com/52sanmao)! + +### Fixed + +- Numeric settings are now never lost the focus during value changing. +- Doctor now redacts more sensitive information on error reports. + +### Improved + +- All translations have been rewritten into YAML format, to easier to manage and contribute. + - We can write them with comments, newlines, and other YAML features. +- Doctor recommendations are now shown in a user-friendly notation. + - We can now see the recommended as `V3: Fine deduplication` instead of `v3-rabin-karp`. + +### Refactored + +- Never-ending `ObsidianLiveSyncSettingTab.ts` has finally been separated into each pane's file. +- Some commented-out code has been removed. + +### Acknowledgement + +- Jun Murakami, Shun Ishiguro, and Yoshihiro Oyama. 2012. Implementation and Evaluation of a Cache Deduplication Mechanism with Content-Defined Chunking. In _IPSJ SIG Technical Report_, Vol.2012-ARC-202, No.4. Information Processing Society of Japan, 1-7. + +## 0.24.29 + +20th June, 2025 + +### Fixed + +- Synchronisation with buckets now works correctly, regardless of whether a prefix is set or the bucket has been (re-) initialised (#664). +- An information message is now displayed again, during any automatic synchronisation is enabled (#662). + +### Tidied up + +- Importing paths have been tidied up. + +## 0.24.28 + +15th June, 2025 + +### Fixed + +- Batch Update is no longer available in LiveSync mode to avoid unexpected behaviour. (#653) +- Now compatible with Cloudflare R2 again for bucket synchronisation. + - @edo-bari-ikutsu, thank you for [your contribution](https://github.com/vrtmrz/livesync-commonlib/pull/12)! +- Prevention of broken behaviour due to database connection failures added (#649). + +## 0.24.27 + +10th June, 2025 + +### Improved + +- We can use prefix for path for the Bucket synchronisation. + - For example, if you set the `vaultName/` as a prefix for the bucket in the root directory, all data will be transferred to the bucket under the `vaultName/` directory. +- The "Use Request API to avoid `inevitable` CORS problem" option is now promoted to the normal setting, not a niche patch. + +### Fixed + +- Now switching replicators applied immediately, without the need to restart Obsidian. + +### Tidied up + +- Some dependencies have been updated to the latest version. + +## 0.24.26 + +14th May, 2025 + +This update introduces an option to circumvent Cross-Origin Resource Sharing +(CORS) constraints for CouchDB requests, by leveraging Obsidian's native request +API. The implementation of such a feature had previously been deferred due to +significant security considerations. + +CORS is a vital security mechanism, enabling servers like CouchDB -- which +functions as a sophisticated REST API -- to control access from different +origins, thereby ensuring secure communication across trust boundaries. I had +long hesitated to offer a CORS circumvention method, as it deviates from +security best practices; My preference was for users to configure CORS correctly +on the server-side. + +However, this policy has shifted due to specific reports of intractable +CORS-related configuration issues, particularly within enterprise proxy +environments where proxy servers can unpredictably alter or block +communications. Given that a primary objective of the "Self-hosted LiveSync" +plugin is to facilitate secure Obsidian usage within stringent corporate +settings, addressing these 'unavoidable' user-reported problems became +essential. Mostly raison d'ÃĒtre of this plugin. + +Consequently, the option "Use Request API to avoid `inevitable` CORS problem" +has been implemented. Users are strongly advised to enable this _only_ when +operating within a trusted environment. We can enable this option in the `Patch` pane. + +However, just to whisper, this is tremendously fast. + +### New Features + +- Automatic display-language changing according to the Obsidian language + setting. + - We will be asked on the migration or first startup. + - **Note: Please revert to the default language if you report any issues.** + - Not all messages are translated yet. We welcome your contribution! +- Now we can limit files to be synchronised even in the hidden files. +- "Use Request API to avoid `inevitable` CORS problem" has been implemented. + - Less secure, please use it only if you are sure that you are in the trusted + environment and be able to ignore the CORS. No `Web viewer` or similar tools + are recommended. (To avoid the origin forged attack). If you are able to + configure the server setting, always that is recommended. +- `Show status icon instead of file warnings banner` has been implemented. + - If enabled, the ⛔ icon will be shown inside the status instead of the file + warnings banner. No details will be shown. + +### Improved + +- All regular expressions can be inverted by prefixing `!!` now. + +### Fixed + +- No longer unexpected files will be gathered during hidden file sync. +- No longer broken `\n` and new-line characters during the bucket + synchronisation. +- We can purge the remote bucket again if we using MinIO instead of AWS S3 or + Cloudflare R2. +- Purging the remote bucket is now more reliable. + - 100 files are purged at a time. +- Some wrong messages have been fixed. + +### Behaviour changed + +- Entering into the deeper directories to gather the hidden files is now limited + by `/` or `\/` prefixed ignore filters. (It means that directories are scanned + deeper than before). + - However, inside the these directories, the files are still limited by the + ignore filters. + +### Etcetera + +- Some code has been tidied up. +- Trying less warning-suppressing and be more safer-coding. +- Dependent libraries have been updated to the latest version. +- Some build processes have been separated to `pre` and `post` processes. + +## 0.24.25 + +22nd April, 2025 + +### Improved + +- Peer-to-peer synchronisation has been got more robust. + +### Fixed + +- No longer broken falsy values in settings during set-up by the QR code + generation. + +### Refactored + +- Some `window` references now have pointed to `globalThis`. +- Some sloppy-import has been fixed. +- A server side implementation `Synchromesh` has been suffixed with `deno` + instead of `server` now. + +## 0.24.24 + +15th April, 2025 + +### Fixed + +- No longer broken JSON files including `\n`, during the bucket synchronisation. + (#623) +- Custom headers and JWT tokens are now correctly sent to the server during + configuration checking. (#624) + +### Improved + +- Bucket synchronisation has been enhanced for better performance and + reliability. + - Now less duplicated chunks are sent to the server. Note: If you have + encountered about too less chunks, please let me know. However, you can send + it to the server by `Overwrite remote`. + - Fetching conflicted files from the server is now more reliable. + - Dependent libraries have been updated to the latest version. + - Also, let me know if you have encountered any issues with this update. + Especially you are using a device that has been in use for a little + longer. + +## 0.24.23 + +10th April, 2025 + +### New Feature + +- Now, we can send custom headers to the server. + - They can be sent to either CouchDB or Object Storage. +- Authentication with JWT in CouchDB is now supported. + - I will describe steps later, but please refer to the + [CouchDB document](https://docs.couchdb.org/en/stable/config/auth.html#authentication-configuration). + - A JWT keypair for testing can be generated in the setting dialogue. + +### Improved + +- The QR Code for set-up can be shown also from the setting dialogue now. +- Conflict checking for preventing unexpected overwriting on the boot-up process + has been quite faster. + +### Fixed + +- Some bugs on Dev and Testing modules have been fixed. + +## 0.24.22 ~~0.24.21~~ + +1st April, 2025 + +(Really sorry for the confusion. I have got a miss at releasing...). + +### Fixed + +- No longer conflicted files are handled in the boot-up process. No more + unexpected overwriting. + - It ignores `Always overwrite with a newer file`, and always be prevented for + the safety. Please pick it manually or open the file. +- Some log messages on conflict resolution has been corrected. +- Automatic merge notifications, displayed on the grounds of `same`, have been + degraded to logs. + +### Improved + +- Now we can fetch the remote database with keeping local files completely + intact. + - In new option, all files are stored into the local database before the + fetching, and will be merged automatically or detected as conflicts. +- The dialogue presenting options when performing `Fetch` are now more + informative. + +### Refactored + +- Some class methods have been fixed its arguments to be more consistent. +- Types have been defined for some conditional results. + +## 0.24.20 + +24th March, 2025 + +### Improved + +- Now we can see the detail of `TypeError` using Obsidian API during remote + database access. + +### Behaviour and default changed + +- **NOW INDEED AND ACTUALLY** `Compute revisions for chunks` are backed into + enabled again. it is necessary for garbage collection of chunks. + - As far as existing users are concerned, this will not automatically change, + but the Doctor will inform us. + +## 0.24.19 + +5th March, 2025 + +### New Feature + +- Now we can generate a QR Code for transferring the configuration to another device. + - This QR Code can be scanned by the camera app or something QR Code Reader of another device, and via Obsidian URL, the configuration will be transferred. + - Note: This QR Code is not encrypted. So, please be careful when transferring the configuration. + +## 0.24.18 + +28th February, 2025 + +### Fixed + +- Now no chunk creation errors will be raised after switching `Compute revisions for chunks`. +- Some invisible file can be handled correctly (e.g., `writing-goals-history.csv`). +- Fetching configuration from the server is now saves the configuration immediately (if we are not in the wizard). + +### Improved + +- Mismatched configuration dialogue is now more informative, and rewritten to more user-friendly. +- Applying configuration mismatch is now without rebuilding (at our own risks). +- Now, rebuilding is decided more fine grained. + +### Improved internally + +- Translations can be nested. i.e., task:`Some procedure`, check: `%{task} checking`, checkfailed: `%{check} failed` produces `Some procedure checking failed`. + - Max to 10 levels of nesting + +## 0.24.17 + +27th February, 2025 + +Confession. I got the default values wrong. So scary and sorry. + +## 0.24.16 + +### Improved + +#### Peer-to-Peer + +- Now peer-to-peer synchronisation checks the settings are compatible with each other. + - No longer unexpected database broken, phew. +- Peer-to-peer synchronisation now handles the platform and detects pseudo-clients. + - Pseudo clients will not decrypt/encrypt anything, just relay the data. Hence, always settings are not compatible. Therefore, we have to accept the incompatibility for pseudo clients. + +#### General + +- New migration method has been implemented, that called `Doctor`. + + - `Doctor` checks the difference between the ideal and actual values and encourages corrective action. To facilitate our decision, the reasons for this and the recommendations are also presented. + - This can be used not only during migration. We can invoke the doctor from the settings for trouble-shooting. + +- The minimum interval for replication to be caused when an event occurs can now be configurable. +- Some detail note has been added and change nuance about the `Report` in the setting dialogue, which had less informative. + +### Behaviour and default changed + +- `Compute revisions for chunks` are backed into enabled again. it is necessary for garbage collection of chunks. + - As far as existing users are concerned, this will not automatically change, but the Doctor will inform us. + +### Refactored + +- Platform specific codes are more separated. No longer `node` modules were used in the browser and Obsidian. + +## 0.24.15 + +### Fixed + +- Now, even without WeakRef, Polyfill is used and the whole thing works without error. However, if you can switch WebView Engine, it is recommended to switch to a WebView Engine that supports WeakRef. + +## 0.24.14 + +### Fixed + +- Resolving conflicts of JSON files (and sensibly merging them) is now working fine, again! + - And, failure logs are more informative. +- More robust to release the event listeners on unwatching the local database. + +### Refactored + +- JSON file conflict resolution dialogue has been rewritten into svelte v5. +- Upgrade eslint. +- Remove unnecessary pragma comments for eslint. + +## 0.24.13 + +Sorry for the lack of replies. The ones that were not good are popping up, so I am just going to go ahead and get this one... However, they realised that refactoring and restructuring is about clarifying the problem. Your patience and understanding is much appreciated. + +### Fixed + +#### General Replication + +- No longer unexpected errors occur when the replication is stopped during for some reason (e.g., network disconnection). + +#### Peer-to-Peer Synchronisation + +- Set-up process will not receive data from unexpected sources. +- No longer resource leaks while enabling the `broadcasting changes` +- Logs are less verbose. +- Received data is now correctly dispatched to other devices. +- `Timeout` error now more informative. +- No longer timeout error occurs for reporting the progress to other devices. +- Decision dialogues for the same thing are not shown multiply at the same time anymore. +- Disconnection of the peer-to-peer synchronisation is now more robust and less error-prone. + +#### Webpeer + +- Now we can toggle Peers' configuration. + +### Refactored + +- Cross-platform compatibility layer has been improved. +- Common events are moved to the common library. +- Displaying replication status of the peer-to-peer synchronisation is separated from the main-log-logic. +- Some file names have been changed to be more consistent. + +## 0.24.12 + +I created a SPA called [webpeer](https://github.com/vrtmrz/livesync-commonlib/tree/main/apps/webpeer) (well, right... I will think of a name again), which replaces the server when using Peer-to-Peer synchronisation. This is a pseudo-client that appears to other devices as if it were one of the clients. . As with the client, it receives and sends data without storing it as a file. +And, this is just a single web page, without any server-side code. It is a static web page that can be hosted on any static web server, such as GitHub Pages, Netlify, or Vercel. All you have to do is to open the page and enter several items, and leave it open. + +### Fixed + +- No longer unnecessary acknowledgements are sent when starting peer-to-peer synchronisation. + +### Refactored + +- Platform impedance-matching-layer has been improved. + - And you can see the actual usage of this on [webpeer](https://github.com/vrtmrz/livesync-commonlib/tree/main/apps/webpeer) that a pseudo client for peer-to-peer synchronisation. +- Some UIs have been got isomorphic among Obsidian and web applications (for `webpeer`). + +## 0.24.11 + +Peer-to-peer synchronisation has been implemented! + +Until now, I have not provided a synchronisation server. More people may not +even know that I have shut down the test server. I confess that this is a bit +repetitive, but I confess it is a cautionary tale. This is out of a sense of +self-discipline that someone has occurred who could see your data. Even if the +'someone' is me. I should not be unaware of its superiority, even though +well-meaning and am a servant of all. (Half joking, but also serious). However, +now I can provide you with a signalling server. Because, to the best of my +knowledge, it is only the network that is connected to your device. Also, this +signalling server is just a Nostr relay, not my implementation. You can run your +implementation, which you consider trustworthy, on a trustworthy server. You do +not even have to trust me. Mate, it is great, isn't it? For your information, +strfry is running on my signalling server. + +Nevertheless, that being said, to be more honest, I still have not decided what +to do with this signalling server if too much traffic comes in. + +Note: Already you have noticed this, but let me mention it again, this is a +significantly large update. If you have noticed anything, please let me know. I +will try to fix it as soon as possible (Some address is on my +[profile](https://github.com/vrtmrz)). + +### Improved + +- New Translation: `es` (Spanish) by @zeedif (Thank you so much)! +- Now all of messages can be selectable and copyable, also on the iPhone, iPad, and Android devices. Now we can copy or share the messages easily. + +### New Feature + +- Peer-to-Peer Synchronisation has been implemented! + - This feature is still in early beta, and it is recommended to use it with caution. + - However, it is a significant step towards the self-hosting concept. It is now possible to synchronise your data without using any remote database or storage. It is a direct connection between your devices. + - Note: We should keep the device online to synchronise the data. It is not a background synchronisation. Also it needs a signalling server to establish the connection. But, the signalling server is used only for establishing the connection, and it does not store any data. + +### Fixed + +- No longer memory or resource leaks when the plug-in is disabled. +- Now deleted chunks are correctly detected on conflict resolution, and we are guided to resurrect them. +- Hanging issue during the initial synchronisation has been fixed. +- Some unnecessary logs have been removed. +- Now all modal dialogues are correctly closed when the plug-in is disabled. + +### Refactor + +- Several interfaces have been moved to the separated library. +- Translations have been moved to each language file, and during the build, they are merged into one file. +- Non-mobile friendly code has been removed and replaced with the safer code. + - (Now a days, mostly server-side engine can use webcrypto, so it will be rewritten in the future more). +- Started writing Platform impedance-matching-layer. +- Svelte has been updated to v5. +- Some function have got more robust type definitions. +- Terser optimisation has slightly improved. +- During the build, analysis meta-file of the bundled codes will be generated. + +## 0.24.10 + +### Fixed + +- Fixed the issue which the filename is shown as `undefined`. +- Fixed the issue where files transferred at short intervals were not reflected. + +### Improved + +- Add more translations: `ja-JP` (Japanese) by @kohki-shikata (Thank you so much)! + +### Internal + +- Some files have been prettified. + +## 0.24.9 + +Skipped. + +## 0.24.8 + +### Fixed + +- Some parallel-processing tasks are now performed more safely. +- Some error messages has been fixed. + +### Improved + +- Synchronisation is now more efficient and faster. +- Saving chunks is a bit more robust. + +### New Feature + +- We can remove orphaned chunks again, now! + - Without rebuilding the database! + - Note: Please synchronise devices completely before removing orphaned chunks. + - Note2: Deleted files are using chunks, if you want to remove them, please commit the deletion first. (`Commit File Deletion`) + - Note3: If you lost some chunks, do not worry. They will be resurrected if not so much time has passed. Try `Resurrect deleted chunks`. + - Note4: This feature is still beta. Please report any issues you encounter. + - Note5: Please disable `On demand chunk fetching`, and enable `Compute revisions for each chunk` before using this feature. + - These settings is going to be default in the future. + +## 0.24.7 + +### Fixed (Security) + +- Assigning IDs to chunks has been corrected for more safety. + - Before version 0.24.6, there were possibilities in End-to-End encryption where a brute-force attack could be carried out against an E2EE passphrase via a chunk ID if a zero-byte file was present. Now the chunk ID should be assigned more safely, and not all of passphrases are used for generating the chunk ID. + - This is a security fix, and it is recommended to update and rebuild database to this version as soon as possible. + - Note: It keeps the compatibility with the previous versions, but the chunk ID will be changed for the new files and modified files. Hence, deduplication will not work for the files which are modified after the update. It is recommended to rebuild the database to avoid the potential issues, and reduce the database size. + - Note2: This fix is only for with E2EE. Plain synchronisation is not affected by this issue. + +### Fixed + +- Now the conflict resolving dialogue is automatically closed after the conflict has been resolved (and transferred from other devices; or written by some other resolution). +- Resolving conflicts by timestamp is now working correctly. + - It also fixes customisation sync. + +### Improved + +- Notifications can be suppressed for the hidden files update now. +- No longer uses the old-xxhash and sha1 for generating the chunk ID. Chunk ID is now generated with the new algorithm (Pure JavaScript hash implementation; which is using Murmur3Hash and FNV-1a now used). + +## 0.24.6 + +### Fixed (Quick Fix) + +- Fixed the issue of log is not displayed on the log pane if the pane has not been shown on startup. + - This release is only for it. However, fixing this had been necessary to report any other issues. + +## 0.24.5 + +### Fixed + +- Fixed incorrect behaviour when comparing objects with undefined as a property value. + +### Improved + +- The status line and the log summary are now displayed more smoothly and efficiently. + - This improvement has also been applied to the logs displayed in the log pane. + +## 0.24.4 + +### Fixed + +- Fixed so many inefficient and buggy modules inherited from the past. + +### Improved + +- Tasks are now executed in an efficient asynchronous library. +- On-demand chunk fetching is now more efficient and keeps the interval between requests. + - This will reduce the load on the server and the network. + - And, safe for the Cloudant. + +## 0.24.3 + +### Improved + +- Many messages have been improved for better understanding as thanks to the fine works of @Volkor3-16! Thank you so much! +- Documentations also have been updated to reflect the changes in the messages. +- Now the style of In-Editor Status has been solid for some Android devices. + +## 0.24.2 + +### Rewritten + +- Hidden File Sync is now respects the file changes on the storage. Not simply comparing modified times. + - This makes hidden file sync more robust and reliable. + +### Fixed + +- `Scan hidden files before replication` is now configurable again. +- Some unexpected errors are now handled more gracefully. +- Meaningless event passing during boot sequence is now prevented. +- Error handling for non-existing files has been fixed. +- Hidden files will not be batched to avoid the potential error. + - This behaviour had been causing the error in the previous versions in specific situations. +- The log which checking automatic conflict resolution is now in verbose level. +- Replication log (skipping non-targetting files) shows the correct information. +- The dialogue that asking enabling optional feature during `Rebuild Everything` now prevents to show the `overwrite` option. + - The rebuilding device is the first, meaningless. +- Files with different modified time but identical content are no longer processed repeatedly. +- Some unexpected errors which caused after terminating plug-in are now avoided. +- + +### Improved + +- JSON files are now more transferred efficiently. + - Now the JSON files are transferred in more fine chunks, which makes the transfer more efficient. + +## 0.24.1 + +### Fixed + +- Vault History can show the correct information of match-or-not for each file and database even if it is a binary file. +- `Sync settings via markdown` is now hidden during the setup wizard. +- Verify and Fix will ignore the hidden files if the hidden file sync is disabled. + +#### New feature + +- Now we can fetch the tweaks from the remote database while the setting dialogue and wizard are processing. + +### Improved + +- More things are moved to the modules. + - Includes the Main codebase. Now `main.ts` is almost stub. +- EventHub is now more robust and typesafe. + +## 0.24.0 + +### Improved + +- The welcome message is now more simple to encourage the use of the Setup-URI. + - The secondary message is also simpler to guide users to Minimal Setup. + - But Setup-URI will be recommended again, due to its importance. + - These dialogues contain a link to the documentation which can be clicked. +- The minimal setup is more minimal now. And, the setup is more user-friendly. + - Now the Configuration of the remote database is checked more robustly, but we can ignore the warning and proceed with the setup. +- Before we are asked about each feature, we are asked if we want to use optional features in the first place. + - This is to prevent the user from being overwhelmed by the features. + - And made it clear that it is not recommended for new users. +- Many messages have been improved for better understanding. + - Ridiculous messages have been (carefully) refined. + - Dialogues are more informative and friendly. + - A lot of messages have been mostly rewritten, leveraging Markdown. + - Especially auto-closing dialogues are now explicitly labelled: `To stop the countdown, tap anywhere on the dialogue`. +- Now if the is plugin configured to ignore some events, we will get a chance to fix it, in addition to the warning. + - And why that has happened is also explained in the dialogue. +- A note relating to device names has been added to Customisation Sync on the setting dialogue. +- We can verify and resolve also the hidden files now. + +### Fixed + +- We can resolve the conflict of the JSON file correctly now. +- Verifying files between the local database and storage is now working correctly. +- While restarting the plug-in, the shown dialogues will be automatically closed to avoid unexpected behaviour. +- Replicated documents that the local device has configured to ignore are now correctly ignored. +- The chunks of the document on the local device during the first transfer will be created correctly. + - And why we should create them is now explained in the dialogue. +- If optional features have been enabled in the wizard, `Enable advanced features` will be toggled correctly. + The hidden file sync is now working correctly. - Now the deletion of hidden files is correctly synchronised. +- Customisation Sync is now working correctly together with hidden file sync. +- No longer database suffix is stored in the setting sharing markdown. +- A fair number of bugs have been fixed. + +### Changed + +- Some default settings have been changed for an easier new user experience. + - Preventing the meaningless migration of the settings. + +### Tiding + +- The codebase has been reorganised into clearly defined modules. +- Commented-out codes have been gradually removed. + +### 0.23.0 + +Incredibly new features! + +Now, we can use object storage (MinIO, S3, R2 or anything you like) for synchronising! Moreover, despite that, we can use all the features as if we were using CouchDB. +Note: As this is a pretty experimental feature, hence we have some limitations. + +- This is built on the append-only architecture. It will not shrink used storage if we do not perform a rebuild. +- A bit fragile. However, our version x.yy.0 is always so. +- When the first synchronisation, the entire history to date is transferred. For this reason, it is preferable to do this under the WiFi network. +- Do not worry, from the second synchronisation, we always transfer only differences. + +I hope this feature empowers users to maintain independence and self-host their data, offering an alternative for those who prefer to manage their own storage solutions and avoid being stuck on the right side of a sudden change in business model. + +Of course, I use Self-hosted MinIO for testing and recommend this. It is for the same reason as using CouchDB. -- open, controllable, auditable and indeed already audited by numerous eyes. + +Let me write one more acknowledgement. + +I have a lot of respect for that plugin, even though it is sometimes treated as if it is a competitor, remotely-save. I think it is a great architecture that embodies a different approach to my approach of recreating history. This time, with all due respect, I have used some of its code as a reference. +Hooray for open source, and generous licences, and the sharing of knowledge by experts. + +#### Version history + +- 0.23.23: + - Refined: + - Setting dialogue very slightly refined. + - The hodgepodge inside the `Hatch` pane has been sorted into more explicit categorised panes. + - Now we have new panes for: + - `Selector` + - `Advanced` + - `Power users` + - `Patches (Edge case)` + - Applying the settings will now be more informative. + - The header bar will be shown for applying the settings which needs a database rebuild. + - Applying methods are now more clearly navigated. + - Definitely, drastic change. I hope this will be more user-friendly. However, if you notice any issues, please let me know. I hope that nothing missed. + - New features: + - Word-segmented chunk building on users language + - Chunks can now be built with word-segmented data, enhancing efficiency for markdown files which contains the multiple sentences in a single line. + - This feature is enabled by default through `Use Segmented-splitter`. + - (Default: Disabled, Please be relived, I have learnt). + - Fixed: + - Sending chunks on `Send chunk in bulk` are now buffered to avoid the out-of-memory error. + - `Send chunk in bulk` is back to default disabled. (Sorry, not applied to the migrated users; I did not think we should deepen the wound any further "automatically"). + - Merging conflicts of JSON files are now works fine even if it contains `null`. + - Development: + - Implemented the logic for automatically generating the stub of document for the setting dialogue. +- 0.23.22: + - Fixed: + - Case-insensitive file handling + - Full-lower-case files are no longer created during database checking. + - Bulk chunk transfer + - The default value will automatically adjust to an acceptable size when using IBM Cloudant. +- 0.23.21: + - New Features: + - Case-insensitive file handling + - Files can now be handled case-insensitively. + - This behaviour can be modified in the settings under `Handle files as Case-Sensitive` (Default: Prompt, Enabled for previous behaviour). + - Improved chunk revision fixing + - Revisions for chunks can now be fixed for faster chunk creation. + - This can be adjusted in the settings under `Compute revisions for chunks` (Default: Prompt, Enabled for previous behaviour). + - Bulk chunk transfer + - Chunks can now be transferred in bulk during uploads. + - This feature is enabled by default through `Send chunks in bulk`. + - Creation of missing chunks without + - Missing chunks can be created without storing notes, enhancing efficiency for first synchronisation or after prolonged periods without synchronisation. + - Improvements: + - File status scanning on the startup + - Quite significant performance improvements. + - No more missing scans of some files. + - Status in editor enhancements + - Significant performance improvements in the status display within the editor. + - Notifications for files that will not be synchronised will now be properly communicated. + - Encryption and Decryption + - These processes are now performed in background threads to ensure fast and stable transfers. + - Verify and repair all files + - Got faster through parallel checking. + - Migration on update + - Migration messages and wizards have become more helpful. + - Behavioural changes: + - Chunk size adjustments + - Large chunks will no longer be created for older, stable files, addressing storage consumption issues. + - Flag file automation + - Confirmation will be shown and we can cancel it. + - Fixed: + - Database File Scanning + - All files in the database will now be enumerated correctly. + - Miscellaneous + - Dependency updated. + - Now, tree shaking is left to terser, from esbuild. +- 0.23.20: + - Fixed: + - Customisation Sync now checks the difference while storing or applying the configuration. + - No longer storing the same configuration multiple times. + - Time difference in the dialogue has been fixed. + - Remote Storage Limit Notification dialogue has been fixed, now the chosen value is saved. + - Improved: + - The Enlarging button on the enlarging threshold dialogue now displays the new value. +- 0.23.19: + - Not released. +- 0.23.18: + - New feature: + - Per-file-saved customization sync has been shipped. + - We can synchronise plug-igs etc., more smoothly. + - Default: disabled. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost compatibility with old versions. + - Customisation sync has got beta3. + - We can set `Flag` to each item to select the newest, automatically. + - This configuration is per device. + - Improved: + - Start-up speed has been improved. + - Fixed: + - On the customisation sync dialogue, buttons are kept within the screen. + - No more unnecessary entries on `data.json` for customisation sync. + - Selections are no longer lost while updating customisation items. + - Tidied on source codes: + - Many typos have been fixed. + - Some unnecessary type casting removed. +- 0.23.17: + - Improved: + - Overall performance has been improved by using PouchDB 9.0.0. + - Configuration mismatch detection is refined. We can resolve mismatches more smoothly and naturally. + More detail is on `troubleshooting.md` on the repository. + - Fixed: + - Customisation Sync will be disabled when a corrupted configuration is detected. + Therefore, the Device Name can be changed even in the event of a configuration mismatch. + - New feature: + - We can get a notification about the storage usage of the remote database. + - Default: We will be asked. + - If the remote storage usage approaches the configured value, we will be asked whether we want to Rebuild or increase the limit. +- 0.23.16: + - Maintenance Update: + - Library refining (Phase 1 - step 2). There are no significant changes on the user side. + - Including the following fixes of potentially problems: + - the problem which the path had been obfuscating twice has been resolved. + - Note: Potential problems of the library; which has not happened in Self-hosted LiveSync for some reasons. +- 0.23.15: + - Maintenance Update: + - Library refining (Phase 1). There are no significant changes on the user side. +- 0.23.14: + - Fixed: + - No longer batch-saving ignores editor inputs. + - The file-watching and serialisation processes have been changed to the one which is similar to previous implementations. + - We can configure the settings (Especially about text-boxes) even if we have configured the device name. + - Improved: + - We can configure the delay of batch-saving. + - Default: 5 seconds, the same as the previous hard-coded value. (Note: also, the previous behaviour was not correct). + - Also, we can configure the limit of delaying batch-saving. + - The performance of showing status indicators has been improved. +- 0.23.13: + - Fixed: + - No longer files have been trimmed even delimiters have been continuous. + - Fixed the toggle title to `Do not split chunks in the background` from `Do not split chunks in the foreground`. + - Non-configured item mismatches are no longer detected. +- 0.23.12: + - Improved: + - Now notes will be split into chunks in the background thread to improve smoothness. + - Default enabled, to disable, toggle `Do not split chunks in the foreground` on `Hatch` -> `Compatibility`. + - If you want to process very small notes in the foreground, please enable `Process small files in the foreground` on `Hatch` -> `Compatibility`. + - We can use a `splitting-limit-capped chunk splitter`; which performs more simple and make less amount of chunks. + - Default disabled, to enable, toggle `Use splitting-limit-capped chunk splitter` on `Sync settings` -> `Performance tweaks` + - Tidied + - Some files have been separated into multiple files to make them more explicit in what they are responsible for. +- 0.23.11: + - Fixed: + - Now we _surely_ can set the device name and enable customised synchronisation. + - Unnecessary dialogue update processes have been eliminated. + - Customisation sync no longer stores half-collected files. + - No longer hangs up when removing or renaming files with the `Sync on Save` toggle enabled. + - Improved: + - Customisation sync now performs data deserialization more smoothly. + - New translations have been merged. +- 0.23.10 + - Fixed: + - No longer configurations have been locked in the minimal setup. +- 0.23.9 + - Fixed: + - No longer unexpected parallel replication is performed. + - Now we can set the device name and enable customised synchronisation again. +- 0.23.8 + - New feature: + - Now we are ready for i18n. + - Patch or PR of `rosetta.ts` are welcome! + - The setting dialogue has been refined. Very controllable, clearly displayed disabled items, and ready to i18n. + - Fixed: + - Many memory leaks have been rescued. + - Chunk caches now work well. + - Many trivial but potential bugs are fixed. + - No longer error messages will be shown on retrieving checkpoint or server information. + - Now we can check and correct tweak mismatch during the setup + - Improved: + - Customisation synchronisation has got more smoother. + - Tidied + - Practically unused functions have been removed or are being prepared for removal. + - Many of the type-errors and lint errors have been corrected. + - Unused files have been removed. + - Note: + - From this version, some test files have been included. However, they are not enabled and released in the release build. + - To try them, please run Self-hosted LiveSync in the dev build. +- 0.23.7 + - Fixed: + - No longer missing tasks which have queued as the same key (e.g., for the same operation to the same file). + - This occurs, for example, with hidden files that have been changed multiple times in a very short period of time, such as `appearance.json`. Thanks for the report! + - Some trivial issues have been fixed. + - New feature: + - Reloading Obsidian can be scheduled until that file and database operations are stable. +- 0.23.6: + - Fixed: + - Now the remote chunks could be decrypted even if we are using `Incubate chunks in Document`. (The note of 0.23.6 has been fixed). + - Chunk retrieving with `Incubate chunks in document` got more efficiently. + - No longer task processor misses the completed tasks. + - Replication is no longer started automatically during changes in window visibility (e.g., task switching on the desktop) when off-focused. +- 0.23.5: + - New feature: + - Now we can check configuration mismatching between clients before synchronisation. + - Default: enabled / Preferred: enabled / We can disable this by the `Do not check configuration mismatch before replication` toggle in the `Hatch` pane. + - It detects configuration mismatches and prevents synchronisation failures and wasted storage. + - Now we can perform remote database compaction from the `Maintenance` pane. + - Fixed: + - We can detect the bucket could not be reachable. + - Note: + - Known inexplicable behaviour: Recently, (Maybe while enabling `Incubate chunks in Document` and `Fetch chunks on demand` or some more toggles), our customisation sync data is sometimes corrupted. It will be addressed by the next release. +- 0.23.4 + - Fixed: + - No longer experimental configuration is shown on the Minimal Setup. + - New feature: + - We can now use `Incubate Chunks in Document` to reduce non-well-formed chunks. + - Default: disabled / Preferred: enabled in all devices. + - When we enabled this toggle, newly created chunks are temporarily kept within the document, and graduated to become independent chunks once stabilised. + - The [design document](https://github.com/vrtmrz/obsidian-livesync/blob/3925052f9290b3579e45a4b716b3679c833d8ca0/docs/design_docs_of_keep_newborn_chunks.md) has been also available.. +- 0.23.3 + - Fixed: No longer unwanted `\f` in journal sync. +- 0.23.2 + - Sorry for all the fixes to experimental features. (These things were also critical for dogfooding). The next release would be the main fixes! Thank you for your patience and understanding! + - Fixed: + - Journal Sync will not hang up during big replication, especially the initial one. + - All changes which have been replicated while rebuilding will not be postponed (Previous behaviour). + - Improved: + - Now Journal Sync works efficiently in download and parse, or pack and upload. + - Less server storage and faster packing/unpacking usage by the new chunk format. +- 0.23.1 + + - Fixed: + - Now journal synchronisation considers untransferred each from sent and received. + - Journal sync now handles retrying. + - Journal synchronisation no longer considers the synchronisation of chunks as revision updates (Simply ignored). + - Journal sync now splits the journal pack to prevent mobile device rebooting. + - Maintenance menus which had been on the command palette are now back in the maintain pane on the setting dialogue. + - Improved: + - Now all changes which have been replicated while rebuilding will be postponed. + +- 0.23.0 + - New feature: + - Now we can use Object Storage. + +### 0.22.0 + +A few years passed since Self-hosted LiveSync was born, and our codebase had been very complicated. This could be patient now, but it should be a tremendous hurt. +Therefore at v0.22.0, for future maintainability, I refined task scheduling logic totally. + +Of course, I think this would be our suffering in some cases. However, I would love to ask you for your cooperation and contribution. + +Sorry for being absent so much long. And thank you for your patience! + +Note: we got a very performance improvement. +Note at 0.22.2: **Now, to rescue mobile devices, Maximum file size is set to 50 by default**. Please configure the limit as you need. If you do not want to limit the sizes, set zero manually, please. + +#### Version history + +- 0.22.19 + - Fixed: + - No longer data corrupting due to false BASE64 detections. + - Improved: + - A bit more efficient in Automatic data compression. +- 0.22.18 + - New feature (Very Experimental): + - Now we can use `Automatic data compression` to reduce amount of traffic and the usage of remote database. + - Please make sure all devices are updated to v0.22.18 before trying this feature. + - If you are using some other utilities which connected to your vault, please make sure that they have compatibilities. + - Note: Setting `File Compression` on the remote database works for shrink the size of remote database. Please refer the [Doc](https://docs.couchdb.org/en/stable/config/couchdb.html#couchdb/file_compression). +- 0.22.17: + - Fixed: + - Error handling on booting now works fine. + - Replication is now started automatically in LiveSync mode. + - Batch database update is now disabled in LiveSync mode. + - No longer automatically reconnection while off-focused. + - Status saves are thinned out. + - Now Self-hosted LiveSync waits for all files between the local database and storage to be surely checked. + - Improved: + - The job scheduler is now more robust and stable. + - The status indicator no longer flickers and keeps zero for a while. + - No longer meaningless frequent updates of status indicators. + - Now we can configure regular expression filters in handy UI. Thank you so much, @eth-p! + - `Fetch` or `Rebuild everything` is now more safely performed. + - Minor things + - Some utility function has been added. + - Customisation sync now less wrong messages. + - Digging the weeds for eradication of type errors. +- 0.22.16: + - Fixed: + - Fixed the issue that binary files were sometimes corrupted. + - Fixed customisation sync data could be corrupted. + - Improved: + - Now the remote database costs lower memory. + - This release requires a brief wait on the first synchronisation, to track the latest changeset again. + - Description added for the `Device name`. + - Refactored: + - Many type-errors have been resolved. + - Obsolete file has been deleted. +- 0.22.15: + - Improved: - Faster start-up by removing too many logs which indicates normality - By streamlined scanning of customised synchronisation extra phases have been deleted. +- 0.22.14: + - New feature: + - We can disable the status bar in the setting dialogue. + - Improved: + - Now some files are handled as correct data type. + - Customisation sync now uses the digest of each file for better performance. + - The status in the Editor now works performant. + - Refactored: + - Common functions have been ready and the codebase has been organised. + - Stricter type checking following TypeScript updates. + - Remove old iOS workaround for simplicity and performance. +- 0.22.13: + - Improved: + - Now using HTTP for the remote database URI warns of an error (on mobile) or notice (on desktop). + - Refactored: + - Dependencies have been polished. +- 0.22.12: + - Changed: + - The default settings has been changed. + - Improved: + - Default and preferred settings are applied on completion of the wizard. + - Fixed: + - Now Initialisation `Fetch` will be performed smoothly and there will be fewer conflicts. + - No longer stuck while Handling transferred or initialised documents. +- 0.22.11: + - Fixed: + - `Verify and repair all files` is no longer broken. + - New feature: + - Now `Verify and repair all files` is able to... + - Restore if the file only in the local database. + - Show the history. + - Improved: + - Performance improved. +- 0.22.10 + - Fixed: + - No longer unchanged hidden files and customisations are saved and transferred now. + - File integrity of vault history indicates the integrity correctly. + - Improved: + - In the report, the schema of the remote database URI is now printed. +- 0.22.9 + - Fixed: + - Fixed a bug on `fetch chunks on demand` that could not fetch the chunks on demand. + - Improved: + - `fetch chunks on demand` works more smoothly. + - Initialisation `Fetch` is now more efficient. + - Tidied: + - Removed some meaningless codes. +- 0.22.8 + - Fixed: + - Now fetch and unlock the locked remote database works well again. + - No longer crash on symbolic links inside hidden folders. + - Improved: + - Chunks are now created more efficiently. + - Splitting old notes into a larger chunk. + - Better performance in saving notes. + - Network activities are indicated as an icon. + - Less memory used for binary processing. + - Tidied: + - Cleaned unused functions up. + - Sorting out the codes that have become nonsense. + - Changed: + - Now no longer `fetch chunks on demand` needs `Pacing replication` + - The setting `Do not pace synchronization` has been deleted. +- 0.22.7 + - Fixed: + - No longer deleted hidden files were ignored. + - The document history dialogue is now able to process the deleted revisions. + - Deletion of a hidden file is now surely performed even if the file is already conflicted. +- 0.22.6 + - Fixed: + - Fixed a problem with synchronisation taking a long time to start in some cases. + - The first synchronisation after update might take a bit longer. + - Now we can disable E2EE encryption. + - Improved: + - `Setup Wizard` is now more clear. + - `Minimal Setup` is now more simple. + - Self-hosted LiveSync now be able to use even if there are vaults with the same name. + - Database suffix will automatically added. + - Now Self-hosted LiveSync waits until set-up is complete. + - Show reload prompts when possibly recommended while settings. + - New feature: + - A guidance dialogue prompting for settings will be shown after the installation. + - Changed + - `Open setup URI` is now `Use the copied setup URI` + - `Copy setup URI` is now `Copy current settings as a new setup URI` + - `Setup Wizard` is now `Minimal Setup` + - `Check database configuration` is now `Check and Fix database configuration` +- 0.22.5 + - Fixed: + - Some description of settings have been refined + - New feature: + - TroubleShooting is now shown in the setting dialogue. +- 0.22.4 + - Fixed: + - Now the result of conflict resolution could be surely written into the storage. + - Deleted files can be handled correctly again in the history dialogue and conflict dialogue. + - Some wrong log messages were fixed. + - Change handling now has become more stable. + - Some event handling became to be safer. + - Improved: + - Dumping document information shows conflicts and revisions. + - The timestamp-only differences can be surely cached. + - Timestamp difference detection can be rounded by two seconds. + - Refactored: + - A bit of organisation to write the test. +- 0.22.3 + - Fixed: + - No longer detects storage changes which have been caused by Self-hosted LiveSync itself. + - Setting sync file will be detected only if it has been configured now. + - And its log will be shown only while the verbose log is enabled. + - Customisation file enumeration has got less blingy. + - Deletion of files is now reliably synchronised. + - Fixed and improved: + - In-editor-status is now shown in the following areas: + - Note editing pane (Source mode and live-preview mode). + - New tab pane. + - Canvas pane. +- 0.22.2 + - Fixed: + - Now the results of resolving conflicts are surely synchronised. + - Modified: + - Some setting items got new clear names. (`Sync Settings` -> `Targets`). + - New feature: + - We can limit the synchronising files by their size. (`Sync Settings` -> `Targets` -> `Maximum file size`). + - It depends on the size of the newer one. + - At Obsidian 1.5.3 on mobile, we should set this to around 50MB to avoid restarting Obsidian. + - Now the settings could be stored in a specific markdown file to synchronise or switch it (`General Setting` -> `Share settings via markdown`). + - [Screwdriver](https://github.com/vrtmrz/obsidian-screwdriver) is quite good, but mostly we only need this. + - Customisation of the obsoleted device is now able to be deleted at once. + - We have to put the maintenance mode in at the Customisation sync dialogue. +- 0.22.1 + - New feature: + - We can perform automatic conflict resolution for inactive files, and postpone only manual ones by `Postpone manual resolution of inactive files`. + - Now we can see the image in the document history dialogue. + - We can see the difference of the image, in the document history dialogue. + - And also we can highlight differences. + - Improved: + - Hidden file sync has been stabilised. + - Now automatically reloads the conflict-resolution dialogue when new conflicted revisions have arrived. + - Fixed: + - No longer periodic process runs after unloading the plug-in. + - Now the modification of binary files is surely stored in the storage. +- 0.22.0 + - Refined: + - Task scheduling logics has been rewritten. + - Screen updates are also now efficient. + - Possibly many bugs and fragile behaviour has been fixed. + - Status updates and logging have been thinned out to display. + - Fixed: + - Remote-chunk-fetching now works with keeping request intervals + - New feature: + - We can show only the icons in the editor. + - Progress indicators have been more meaningful: + - đŸ“Ĩ Unprocessed transferred items + - 📄 Working database operation + - 💾 Working write storage processes + - âŗ Working read storage processes + - đŸ›Ģ Pending read storage processes + - âš™ī¸ Working or pending storage processes of hidden files + - 🧩 Waiting chunks + - 🔌 Working Customisation items (Configuration, snippets and plug-ins) + +### 0.21.0 + +The E2EE encryption V2 format has been reverted. That was probably the cause of the glitch. +Instead, to maintain efficiency, files are treated with Blob until just before saving. Along with this, the old-fashioned encryption format has also been discontinued. +There are both forward and backwards compatibilities, with recent versions. However, unfortunately, we lost compatibility with filesystem-livesync or some. +It will be addressed soon. Please be patient if you are using filesystem-livesync with E2EE. + +- 0.21.5 + - Improved: + - Now all revisions will be shown only its first a few letters. + - Now ID of the documents is shown in the log with the first 8 letters. + - Fixed: + - Check before modifying files has been implemented. + - Content change detection has been improved. +- 0.21.4 + - This release had been skipped. +- 0.21.3 + - Implemented: + - Now we can use SHA1 for hash function as fallback. +- 0.21.2 + - IMPORTANT NOTICE: **0.21.1 CONTAINS A BUG WHILE REBUILDING THE DATABASE. IF YOU HAVE BEEN REBUILT, PLEASE MAKE SURE THAT ALL FILES ARE SANE.** + - This has been fixed in this version. + - Fixed: + - No longer files are broken while rebuilding. + - Now, Large binary files can be written correctly on a mobile platform. + - Any decoding errors now make zero-byte files. + - Modified: + - All files are processed sequentially for each. +- 0.21.1 + - Fixed: + - No more infinity loops on larger files. + - Show message on decode error. + - Refactored: + - Fixed to avoid obsolete global variables. +- 0.21.0 + - Changes and performance improvements: + - Now the saving files are processed by Blob. + - The V2-Format has been reverted. + - New encoding format has been enabled in default. + - WARNING: Since this version, the compatibilities with older Filesystem LiveSync have been lost. + +## 0.20.0 + +At 0.20.0, Self-hosted LiveSync has changed the binary file format and encrypting format, for efficient synchronisation. +The dialogue will be shown and asks us to decide whether to keep v1 or use v2. Once we have enabled v2, all subsequent edits will be saved in v2. Therefore, devices running 0.19 or below cannot understand this and they might say that decryption error. Please update all devices. +Then we will have an impressive performance. + +Of course, these are very impactful changes. If you have any questions or troubled things, please feel free to open an issue and mention me. + +Note: if you want to roll it back to v1, please enable `Use binary and encryption version 1` on the `Hatch` pane and perform the `rebuild everything` once. + +Extra but notable information: + +This format change gives us the ability to detect some `marks` in the binary files as same as text files. Therefore, we can split binary files and some specific sort of them (i.e., PDF files) at the specific character. It means that editing the middle of files could be detected with marks. + +Now only a few chunks are transferred, even if we add a comment to the PDF or put new files into the ZIP archives. + +- 0.20.7 + - Fixed + - To better replication, path obfuscation is now deterministic even if with E2EE. + Note: Compatible with previous database without any conversion. Only new files will be obfuscated in deterministic. +- 0.20.6 + - Fixed + - Now empty file could be decoded. + - Local files are no longer pre-saved before fetching from a remote database. + - No longer deadlock while applying customisation sync. + - Configuration with multiple files is now able to be applied correctly. + - Deleting folder propagation now works without enabling the use of a trash bin. +- 0.20.5 + - Fixed + - Now the files which having digit or character prefixes in the path will not be ignored. +- 0.20.4 + - Fixed + - The text-input-dialogue is no longer broken. + - Finally, we can use the Setup URI again on mobile. +- 0.20.3 + - New feature: + - We can launch Customization sync from the Ribbon if we enabled it. + - Fixed: + - Setup URI is now back to the previous spec; be encrypted by V1. + - It may avoid the trouble with iOS 17. + - The Settings dialogue is now registered at the beginning of the start-up process. + - We can change the configuration even though LiveSync could not be launched in normal. + - Improved: + - Enumerating documents has been faster. +- 0.20.2 + - New feature: + - We can delete all data of customization sync from the `Delete all customization sync data` on the `Hatch` pane. + - Fixed: + - Prevent keep restarting on iOS by yielding microtasks. +- 0.20.1 + - Fixed: + - No more UI freezing and keep restarting on iOS. + - Diff of Non-markdown documents are now shown correctly. + - Improved: + - Performance has been a bit improved. + - Customization sync has gotten faster. + - However, We lost forward compatibility again (only for this feature). Please update all devices. + - Misc + - Terser configuration has been more aggressive. +- 0.20.0 + - Improved: + - A New binary file handling implemented + - A new encrypted format has been implemented + - Now the chunk sizes will be adjusted for efficient sync + - Fixed: + - levels of exception in some logs have been fixed + - Tidied: + - Some Lint warnings have been suppressed. + +### 0.19.0 + +#### Customization sync + +Since `Plugin and their settings` have been broken, so I tried to fix it, not just fix it, but fix it the way it should be. + +Now, we have `Customization sync`. + +It is a real shame that the compatibility between these features has been broken. However, this new feature is surely useful and I believe that worth getting over the pain. +We can use the new feature with the same configuration. Only the menu on the command palette has been changed. The dialog can be opened by `Show customization sync dialog`. + +I hope you will give it a try. + +#### Minors + +- 0.19.1 + - Fixed: Fixed hidden file handling on Linux + - Improved: Now customization sync works more smoothly. +- 0.19.2 + - Fixed: + - Fixed garbage collection error while unreferenced chunks exist many. + - Fixed filename validation on Linux. + - Improved: + - Showing status is now thinned for performance. + - Enhance caching while collecting chunks. +- 0.19.3 + - Improved: + - Now replication will be paced by collecting chunks. If synchronisation has been deadlocked, please enable `Do not pace synchronization` once. +- 0.19.4 + - Improved: + - Reduced remote database checking to improve speed and reduce bandwidth. + - Fixed: + - Chunks which previously misinterpreted are now interpreted correctly. + - No more missing chunks which not be found forever, except if it has been actually missing. + - Deleted file detection on hidden file synchronising now works fine. + - Now the Customisation sync is surely quiet while it has been disabled. +- 0.19.5 + - Fixed: + - Now hidden file synchronisation would not be hanged, even if so many files exist. + - Improved: + - Customisation sync works more smoothly. + - Note: Concurrent processing has been rollbacked into the original implementation. As a result, the total number of processes is no longer shown next to the hourglass icon. However, only the processes that are running concurrently are shown. +- 0.19.6 + - Fixed: + - Logging has been tweaked. + - No more too many planes and rockets. + - The batch database update now surely only works in non-live mode. + - Internal things: + - Some frameworks has been upgraded. + - Import declaration has been fixed. + - Improved: + - The plug-in now asks to enable a new adaptor, when rebuilding, if it is not enabled yet. + - The setting dialogue refined. + - Configurations for compatibilities have been moved under the hatch. + - Made it clear that disabled is the default. + - Ambiguous names configuration have been renamed. + - Items that have no meaning in the settings are no longer displayed. + - Some items have been reordered for clarity. + - Each configuration has been grouped. +- 0.19.7 + - Fixed: + - The initial pane of Setting dialogue is now changed to General Settings. + - The Setup Wizard is now able to flush existing settings and get into the mode again. +- 0.19.8 + - New feature: + - Vault history: A tab has been implemented to give a birds-eye view of the changes that have occurred in the vault. + - Improved: + - Now the passphrases on the dialogue masked out. Thank you @antoKeinanen! + - Log dialogue is now shown as one of tabs. + - Fixed: + - Some minor issues has been fixed. +- 0.19.9 + - New feature (For fixing a problem): + - We can fix the database obfuscated and plain paths that have been mixed up. + - Improvements + - Customisation Sync performance has been improved. +- 0.19.10 + - Fixed + - Fixed the issue about fixing the database. +- 0.19.11 + - Improvements: + - Hashing ChunkID has been improved. + - Logging keeps 400 lines now. + - Refactored: + - Import statement has been fixed about types. +- 0.19.12 + - Improved: + - Boot-up performance has been improved. + - Customisation sync performance has been improved. + - Synchronising performance has been improved. +- 0.19.13 + - Implemented: + - Database clean-up is now in beta 2! + We can shrink the remote database by deleting unused chunks, with keeping history. + Note: Local database is not cleaned up totally. We have to `Fetch` again to let it done. + **Note2**: Still in beta. Please back your vault up anything before. + - Fixed: + - The log updates are not thinned out now. +- 0.19.14 + - Fixed: + - Internal documents are now ignored. + - Merge dialogue now respond immediately to button pressing. + - Periodic processing now works fine. + - The checking interval of detecting conflicted has got shorter. + - Replication is now cancelled while cleaning up. + - The database locking by the cleaning up is now carefully unlocked. + - Missing chunks message is correctly reported. + - New feature: + - Suspend database reflecting has been implemented. + - This can be disabled by `Fetch database with previous behaviour`. + - Now fetch suspends the reflecting database and storage changes temporarily to improve the performance. + - We can choose the action when the remote database has been cleaned + - Merge dialogue now show `↲` before the new line. + - Improved: + - Now progress is reported while the cleaning up and fetch process. + - Cancelled replication is now detected. +- 0.19.15 + - Fixed: + - Now storing files after cleaning up is correct works. + - Improved: + - Cleaning the local database up got incredibly fastened. + Now we can clean instead of fetching again when synchronising with the remote which has been cleaned up. +- 0.19.16 + - Many upgrades on this release. I have tried not to let that happen, if something got corrupted, please feel free to notify me. + - New feature: + - (Beta) ignore files handling + We can use `.gitignore`, `.dockerignore`, and anything you like to filter the synchronising files. + - Fixed: + - Buttons on lock-detected-dialogue now can be shown in narrow-width devices. + - Improved: + - Some constant has been flattened to be evaluated. + - The usage of the deprecated API of obsidian has been reduced. + - Now the indexedDB adapter will be enabled while the importing configuration. + - Misc: + - Compiler, framework, and dependencies have been upgraded. + - Due to standing for these impacts (especially in esbuild and svelte,) terser has been introduced. + Feel free to notify your opinion to me! I do not like to obfuscate the code too. +- 0.19.17 + - Fixed: + - Now nested ignore files could be parsed correctly. + - The unexpected deletion of hidden files in some cases has been corrected. + - Hidden file change is no longer reflected on the device which has made the change itself. + - Behaviour changed: + - From this version, the file which has `:` in its name should be ignored even if on Linux devices. +- 0.19.18 + - Fixed: + - Now the empty (or deleted) file could be conflict-resolved. +- 0.19.19 + - Fixed: + - Resolving conflicted revision has become more robust. + - LiveSync now try to keep local changes when fetching from the rebuilt remote database. + Local changes now have been kept as a revision and fetched things will be new revisions. + - Now, all files will be restored after performing `fetch` immediately. +- 0.19.20 + - New feature: + - `Sync on Editor save` has been implemented + - We can start synchronisation when we save from the Obsidian explicitly. + - Now we can use the `Hidden file sync` and the `Customization sync` cooperatively. + - We can exclude files from `Hidden file sync` which is already handled in Customization sync. + - We can ignore specific plugins in Customization sync. + - Now the message of leftover conflicted files accepts our click. + - We can open `Resolve all conflicted files` in an instant. + - Refactored: + - Parallelism functions made more explicit. + - Type errors have been reduced. + - Fixed: + - Now documents would not be overwritten if they are conflicted. + It will be saved as a new conflicted revision. + - Some error messages have been fixed. + - Missing dialogue titles have been shown now. + - We can click close buttons on mobile now. + - Conflicted Customisation sync files will be resolved automatically by their modified time. +- 0.19.21 + - Fixed: + - Hidden files are no longer handled in the initial replication. + - Report from `Making report` fixed + - No longer contains customisation sync information. + - Version of LiveSync has been added. +- 0.19.22 + - Fixed: + - Now the synchronisation will begin without our interaction. + - No longer puts the configuration of the remote database into the log while checking configuration. + - Some outdated description notes have been removed. + - Options that are meaningless depending on other settings configured are now hidden. + - Scan for hidden files before replication + - Scan customization periodically +- 0.19.23 + -Improved: + - We can open the log pane also from the command palette now. + - Now, the hidden file scanning interval could be configured to 0. + - `Check database configuration` now points out that we do not have administrator permission. + +### 0.18.0 + +#### Now, paths of files in the database can now be obfuscated. (Experimental Feature) + +At before v0.18.0, Self-hosted LiveSync used the path of files, to detect and resolve conflicts. In naive. The ID of the document stored in the CouchDB was naturally the filename. +However, it means a sort of lacking confidentiality. If the credentials of the database have been leaked, the attacker (or an innocent bystander) can read the path of files. So we could not use confidential things in the filename in some environments. +Since v0.18.0, they can be obfuscated. so it is no longer possible to decipher the path from the ID. Instead of that, it costs a bit CPU load than before, and the data structure has been changed a bit. + +We can configure the `Path Obfuscation` in the `Remote database configuration` pane. +Note: **When changing this configuration, we need to rebuild both of the local and the remote databases**. + +#### Minors + +- 0.18.1 + - Fixed: + - Some messages are fixed (Typo) + - File type detection now works fine! +- 0.18.2 + - Improved: + - The setting pane has been refined. + - We can enable `hidden files sync` with several initial behaviours; `Merge`, `Fetch` remote, and `Overwrite` remote. + - No longer `Touch hidden files`. +- 0.18.3 + - Fixed Pop-up is now correctly shown after hidden file synchronisation. +- 0.18.4 + - Fixed: + - `Fetch` and `Rebuild database` will work more safely. + - Case-sensitive renaming now works fine. + Revoked the logic which was made at #130, however, looks fine now. +- 0.18.5 + + - Improved: + - Actions for maintaining databases moved to the `đŸŽ›ī¸Maintain databases`. + - Clean-up of unreferenced chunks has been implemented on an **experimental**. + - This feature requires enabling `Use new adapter`. + - Be sure to fully all devices synchronised before perform it. + - After cleaning up the remote, all devices will be locked out. If we are sure had it be synchronised, we can perform only cleaning-up locally. If not, we have to perform `Fetch`. + +- 0.18.6 + - New features: + - Now remote database cleaning-up will be detected automatically. + - A solution selection dialogue will be shown if synchronisation is rejected after cleaning or rebuilding the remote database. + - During fetching or rebuilding, we can configure `Hidden file synchronisation` on the spot. + - It let us free from conflict resolution on initial synchronising. + +### 0.17.0 + +- 0.17.0 has no surfaced changes but the design of saving chunks has been changed. They have compatibility but changing files after upgrading makes different chunks than before 0.16.x. + Please rebuild databases once if you have been worried about storage usage. + + - Improved: + + - Splitting markdown + - Saving chunks + + - Changed: + - Chunk ID numbering rules + +#### Minors + +- 0.17.1 + + - Fixed: Now we can verify and repair the database. + - Refactored inside. + +- 0.17.2 + + - New feature + - We can merge conflicted documents automatically if sensible. + - Fixed + - Writing to the storage will be pended while they have conflicts after replication. + +- 0.17.3 + + - Now we supported canvas! And conflicted JSON files are also synchronised with merging its content if they are obvious. + +- 0.17.4 + + - Canvases are now treated as a sort of plain text file. now we transfer only the metadata and chunks that have differences. + +- 0.17.5 Now `read chunks online` had been fixed, and a new feature: `Use dynamic iteration count` to reduce the load on encryption/decryption. + Note: `Use dynamic iteration count` is not compatible with earlier versions. +- 0.17.6 Now our renamed/deleted files have been surely deleted again. +- 0.17.7 + - Fixed: + - Fixed merging issues. + - Fixed button styling. + - Changed: + - Conflict checking on synchronising has been enabled for every note in default. +- 0.17.8 + - Improved: Performance improved. Prebuilt PouchDB is no longer used. + - Fixed: Merging hidden files is also fixed. + - New Feature: Now we can synchronise automatically after merging conflicts. +- 0.17.9 + - Fixed: Conflict merge of internal files is no longer broken. + - Improved: Smoother status display inside the editor. +- 0.17.10 + - Fixed: Large file synchronising has been now addressed! + Note: When synchronising large files, we have to set `Chunk size` to lower than 50, disable `Read chunks online`, `Batch size` should be set 50-100, and `Batch limit` could be around 20. +- 0.17.11 + - Fixed: + - Performance improvement + - Now `Chunk size` can be set to under one hundred. + - New feature: + - The number of transfers required before replication stabilises is now displayed. +- 0.17.12: Skipped. +- 0.17.13 + - Fixed: Document history is now displayed again. + - Reorganised: Many files have been refactored. +- 0.17.14: Skipped. +- 0.17.15 + - Improved: + - Confidential information has no longer stored in data.json as is. + - Synchronising progress has been shown in the notification. + - We can commit passphrases with a keyboard. + - Configuration which had not been saved yet is marked now. + - Now the filename is shown on the Conflict resolving dialog + - Fixed: + - Hidden files have been synchronised again. + - Rename of files has been fixed again. + And, minor changes have been included. +- 0.17.16: + - Improved: + - Plugins and their settings no longer need scanning if changes are monitored. + - Now synchronising plugins and their settings are performed parallelly and faster. + - We can place `redflag2.md` to rebuild the database automatically while the boot sequence. + - Experimental: + - We can use a new adapter on PouchDB. This will make us smoother. + - Note: Not compatible with the older version. + - Fixed: + - The default batch size is smaller again. + - Plugins and their setting can be synchronised again. + - Hidden files and plugins are correctly scanned while rebuilding. + - Files with the name started `_` are also being performed conflict-checking. +- 0.17.17 + - Fixed: Now we can merge JSON files even if we failed to compare items like null. +- 0.17.18 + - Fixed: Fixed lack of error handling. +- 0.17.19 + - Fixed: Error reporting has been ensured. +- 0.17.20 + - Improved: Changes of hidden files will be notified to Obsidian. +- 0.17.21 + - Fixed: Skip patterns now handle capital letters. + - Improved + - New configuration to avoid exceeding throttle capacity. + - We have been grateful to @karasevm! + - The conflicted `data.json` is no longer merged automatically. + - This behaviour is not configurable, unlike the `Use newer file if conflicted` of normal files. +- 0.17.22 + - Fixed: + - Now hidden files will not be synchronised while we are not configured. + - Some processes could start without waiting for synchronisation to complete, but now they will wait for. + - Improved + - Now, by placing `redflag3.md`, we can discard the local database and fetch again. + - The document has been updated! Thanks to @hilsonp! +- 0.17.23 + - Improved: + - Now we can preserve the logs into the file. + - Note: This option will be enabled automatically also when we flagging a red flag. + - File names can now be made platform-appropriate. + - Refactored: + - Some redundant implementations have been sorted out. +- 0.17.24 + - New feature: + - If any conflicted files have been left, they will be reported. + - Fixed: + - Now the name of the conflicting file is shown on the conflict-resolving dialogue. + - Hidden files are now able to be merged again. + - No longer error caused at plug-in being loaded. + - Improved: + - Caching chunks are now limited in total size of cached chunks. +- 0.17.25 + - Fixed: + - Now reading error will be reported. +- 0.17.26 + - Fixed(Urgent): + - The modified document will be reflected in the storage now. +- 0.17.27 + - Improved: + - Now, the filename of the conflicted settings will be shown on the merging dialogue + - The plugin data can be resolved when conflicted. + - The semaphore status display has been changed to count only. + - Applying to the storage will be concurrent with a few files. +- 0.17.28 + -Fixed: + - Some messages have been refined. + - Boot sequence has been speeded up. + - Opening the local database multiple times in a short duration has been suppressed. + - Older migration logic. + - Note: If you have used 0.10.0 or lower and have not upgraded, you will need to run 0.17.27 or earlier once or reinstall Obsidian. +- 0.17.29 + - Fixed: + - Requests of reading chunks online are now split into a reasonable(and configurable) size. + - No longer error message will be shown on Linux devices with hidden file synchronisation. + - Improved: + - The interval of reading chunks online is now configurable. + - Boot sequence has been speeded up, more. + - Misc: + - Messages on the boot sequence will now be more detailed. If you want to see them, please enable the verbose log. + - Logs became be kept for 1000 lines while the verbose log is enabled. +- 0.17.30 + - Implemented: + - `Resolve all conflicted files` has been implemented. + - Fixed: + - Fixed a problem about reading chunks online when a file has more chunks than the concurrency limit. + - Rollbacked: + - Logs are kept only for 100 lines, again. +- 0.17.31 + - Fixed: + - Now `redflag3` can be run surely. + - Synchronisation can now be aborted. + - Note: The synchronisation flow has been rewritten drastically. Please do not haste to inform me if you have noticed anything. +- 0.17.32 + - Fixed: + - Now periodic internal file scanning works well. + - The handler of Window-visibility-changed has been fixed. + - And minor fixes possibly included. + - Refactored: + - Unused logic has been removed. + - Some utility functions have been moved into suitable files. + - Function names have been renamed. +- 0.17.33 + - Maintenance update: Refactored; the responsibilities that `LocalDatabase` had were shared. (Hoping) No changes in behaviour. +- 0.17.34 + - Fixed: The `Fetch` that was broken at 0.17.33 has been fixed. + - Refactored again: Internal file sync, plug-in sync and Set up URI have been moved into each file. + +### 0.16.0 + +- Now hidden files need not be scanned. Changes will be detected automatically. + - If you want it to back to its previous behaviour, please disable `Monitor changes to internal files`. + - Due to using an internal API, this feature may become unusable with a major update. If this happens, please disable this once. + +#### Minors + +- 0.16.1 Added missing log updates. +- 0.16.2 Fixed many problems caused by combinations of `Sync On Save` and the tracking logic that changed at 0.15.6. +- 0.16.3 + - Fixed detection of IBM Cloudant (And if there are some issues, be fixed automatically). + - A configuration information reporting tool has been implemented. +- 0.16.4 Fixed detection failure. Please set the `Chunk size` again when using a self-hosted database. +- 0.16.5 + - Fixed + - Conflict detection and merging now be able to treat deleted files. + - Logs while the boot-up sequence has been tidied up. + - Fixed incorrect log entries. + - New Feature + - The feature of automatically deleting old expired metadata has been implemented. + We can configure it in `Delete old metadata of deleted files on start-up` in the `General Settings` pane. +- 0.16.6 + - Fixed + - Automatic (temporary) batch size adjustment has been restored to work correctly. + - Chunk splitting has been backed to the previous behaviour for saving them correctly. + - Improved + - Corrupted chunks will be detected automatically. + - Now on the case-insensitive system, `aaa.md` and `AAA.md` will be treated as the same file or path at applying changesets. +- 0.16.7 Nothing has been changed except toolsets, framework library, and as like them. Please inform me if something had been getting strange! +- 0.16.8 Now we can synchronise without `bad_request:invalid UTF-8 JSON` even while end-to-end encryption has been disabled. + +Note: +Before 0.16.5, LiveSync had some issues making chunks. In this case, synchronisation had became been always failing after a corrupted one should be made. After 0.16.6, the corrupted chunk is automatically detected. Sorry for troubling you but please do `rebuild everything` when this plug-in notified so. + +### 0.15.0 + +- Outdated configuration items have been removed. +- Setup wizard has been implemented! + +I appreciate for reviewing and giving me advice @Pouhon158! + +#### Minors + +- 0.15.1 Missed the stylesheet. +- 0.15.2 The wizard has been improved and documented! +- 0.15.3 Fixed the issue about locking/unlocking remote database while rebuilding in the wizard. +- 0.15.4 Fixed issues about asynchronous processing (e.g., Conflict check or hidden file detection) +- 0.15.5 Add new features for setting Self-hosted LiveSync up more easier. +- 0.15.6 File tracking logic has been refined. +- 0.15.7 Fixed bug about renaming file. +- 0.15.8 Fixed bug about deleting empty directory, weird behaviour on boot-sequence on mobile devices. +- 0.15.9 Improved chunk retrieving, now chunks are retrieved in batch on continuous requests. +- 0.15.10 Fixed: + - The boot sequence has been corrected and now boots smoothly. + - Auto applying of batch save will be processed earlier than before. + +### 0.14.1 + +- The target selecting filter was implemented. + Now we can set what files are synchronised by regular expression. +- We can configure the size of chunks. + We can use larger chunks to improve performance. + (This feature can not be used with IBM Cloudant) +- Read chunks online. + Now we can synchronise only metadata and retrieve chunks on demand. It reduces local database size and time for replication. +- Added this note. +- Use local chunks in preference to remote them if present, + +#### Recommended configuration for Self-hosted CouchDB + +- Set chunk size to around 100 to 250 (10MB - 25MB per chunk) +- _Set batch size to 100 and batch limit to 20 (0.14.2)_ +- Be sure to `Read chunks online` checked. + +#### Minors + +- 0.14.2 Fixed issue about retrieving files if synchronisation has been interrupted or failed +- 0.14.3 New test items have been added to `Check database configuration`. +- 0.14.4 Fixed issue of importing configurations. +- 0.14.5 Auto chunk size adjusting implemented. +- 0.14.6 Change Target to ES2018 +- 0.14.7 Refactor and fix typos. +- 0.14.8 Refactored again. There should be no change in behaviour, but please let me know if there is any. + +### 0.13.0 + +- The metadata of the deleted files will be kept on the database by default. If you want to delete this as the previous version, please turn on `Delete metadata of deleted files.`. And, if you have upgraded from the older version, please ensure every device has been upgraded. +- Please turn on `Delete metadata of deleted files.` if you are using livesync-classroom or filesystem-livesync. +- We can see the history of deleted files. +- `Pick file to show` was renamed to `Pick a file to show. +- Files in the `Pick a file to show` are now ordered by their modified date descent. +- Update information became to be shown on the major upgrade. + +#### Minors + +- 0.13.1 Fixed on conflict resolution. +- 0.13.2 Fixed file deletion failures. +- 0.13.4 + - Now, we can synchronise hidden files that conflicted on each devices. + - We can search for conflicting docs. + - Pending processes can now be run at any time. + - Performance improved on synchronising large numbers of files at once. diff --git a/docs/review_harness.md b/docs/review_harness.md new file mode 100644 index 00000000..f94c670f --- /dev/null +++ b/docs/review_harness.md @@ -0,0 +1,67 @@ +# Review Harness + +The Review Harness is an opt-in, real-device review tool for Self-hosted LiveSync maintainers. It replaces the disabled legacy Test Pane with fixed, auditable scenarios, a device-local one-shot continuation, and a Markdown report which can be pasted into a pull request. + +It supplies supporting evidence rather than a release gate by itself. Unit, integration, Compose, CLI E2E, and real-Obsidian E2E remain authoritative for the boundaries they own. + +## Enabling the Harness + +1. Use a dedicated test Vault. +2. Enable **Power users → Enable Developers' Debug Tools**. +3. Restart Obsidian. +4. Run **Self-hosted LiveSync: Open review harness** from the command palette. + +The command and view are registered only when developer tools are enabled. Disabling the setting and restarting removes the entry point from an ordinary user session. + +## Scenarios and access + +| Scenario | Mode | Access | Purpose | +| --- | --- | --- | --- | +| Settings lifecycle | Automatic | Read-only | Exposes the seven synchronisation choices as typed observations and, for a genuinely new Vault, compares the selected recommendations with Commonlib's new-Vault contract. Existing-Vault setting preservation remains an automated migration and `settings-ui` E2E responsibility. | +| Compatibility review boundary | Guided | Device-local state | Observes the current dedicated compatibility controller and opens its actual review action. The Harness does not restore Change Log acknowledgement, `lastReadUpdates`, or a separate manual Pass or Fail result. | +| P2P composition | Automatic | Read-only | Confirms that the live P2P result resolves one replicator bound to the active Obsidian services. Peer discovery, replacement, reconnection, and transfer remain unit, CLI, and Compose E2E responsibilities. | +| Vault fixture round trip | Automatic, explicit | Dedicated Vault fixtures | After explicit confirmation, creates, reads, modifies, renames, and removes one owned fixture tree. General Vault reflection remains covered by its separate real-Obsidian E2E workflow. | + +**Automatic** runs only the two read-only local observations. **Full review** also starts the guided compatibility observation and asks before the Vault fixture scenario writes anything. A scenario can also be run individually. + +These observations deliberately do not repeat stronger automated workflows. They exist to record which contracts the current real-device composition exposes while a maintainer reviews an immutable artefact. + +### Vault fixture boundary + +The Vault scenario owns only `__self-hosted-livesync-review-harness__`. It refuses to run if that path already exists, so it cannot assume ownership of a user's existing file or folder. Once it creates the root, it removes the complete owned tree from a `finally` block whether the round trip passes or fails. + +The Harness accepts no path, command, code, remote configuration, or credential through plug-in data or its continuation state. New write scenarios must use a distinct fixed fixture root, describe their side effects in the interface, require confirmation, and clean up in `finally`. + +## Restart continuation + +The restart action writes a small device-local record under `review-harness-v1`, then asks Obsidian to reload. The record permits only: + +- the fixed `compatibility-review` scenario; +- the fixed `awaiting-restart` stage; +- a canonical ISO request time; and +- a request identifier derived exactly as `compatibility-review-`. + +On the next settings load, the Harness deletes the record before parsing and acting on it. A valid record reopens the Harness after layout is ready and leaves the compatibility observation waiting for the reviewer. Invalid state is removed and reported as a failed continuation. The record is not stored in `data.json`, copied through a Setup URI, or synchronised to another device. + +The compatibility controller does not require an Obsidian restart to acknowledge a pause. This continuation belongs to the review tool and proves its one-shot reload boundary; it is not a second compatibility lifecycle. + +## Reports and privacy + +**Copy Markdown report** includes: + +- the plug-in and Obsidian versions; +- the platform, user agent, and viewport; +- each scenario's status and bounded summary; and +- a bounded event transcript. + +The formatter has no inputs for Vault identifiers, paths, file names, file contents, remote configuration, or secrets. The report is copied locally and is never transmitted by the plug-in. Review the environment information before posting it because a user agent and viewport may identify a device or operating system. + +Unexpected runtime errors are written to the local LiveSync log, while copied reports retain only a generic failure summary. This prevents an adapter error from copying a local path or file name into a pull request by accident. + +## Automated real-Obsidian coverage + +External automation drives the stable `data-testid` attributes beginning with `review-harness-`. The dedicated workflow checks only Harness-owned behaviour: debug-only registration, consume-before-use continuation handling, fixed Vault fixture clean-up, report privacy, and mobile layout. + +Compatibility dialogue behaviour and persistence belong to `test:e2e:obsidian:settings-ui`. Real P2P transport belongs to the Compose P2P suite. General Vault reflection belongs to `test:e2e:obsidian:vault-reflection`. Keeping those responsibilities separate prevents the review tool from becoming a second, weaker copy of the acceptance suite. + +The mobile checks use `app.emulateMobile(true)`, a representative viewport, and safe-area and touch-target assertions. They do not claim to reproduce native operating-system overlays. diff --git a/docs/settings.md b/docs/settings.md index 754ba7e6..ed8b6e39 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -4,6 +4,17 @@ NOTE: This document not completed. I'll improve this doc in a while. but your co There are many settings in Self-hosted LiveSync. This document describes each setting in detail (not how-to). Configuration and settings are divided into several categories and indicated by icons. The icon is as follows: +## Feature maturity for 1.0 + +The following status applies to optional and compatibility features in the 1.0 line: + +| Status | Features | 1.0 policy | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Supported, opt-in | Peer-to-Peer Synchronisation, Hidden File Sync, and Customisation Sync | Maintained and covered by focused real-runtime tests. Enable them only where their separate setup and operational constraints are acceptable. | +| Maintained, advanced | Data Compression | Available as an explicit storage and bandwidth trade-off. It remains disabled by default because the measured processing and memory costs outweigh the mixed-dataset saving. | +| Beta or experimental | JWT authentication, ignore files, automatic newer-file conflict resolution, and Garbage Collection V3 for CouchDB | Retained for explicit testing and specialised use. They remain disabled by default and are not part of the minimum supported setup. | +| Compatibility only | V1 dynamic iteration counts, the old IndexedDB adapter, non-current hash algorithms, Eden chunks, and the stored `doNotUseFixedRevisionForChunks` key | Existing settings and data remain readable. New Vaults use the current defaults, and compatibility controls are shown only where a migration or recovery path still needs them. | + | Icon | Description | | :--: | ------------------------------------------------------------------ | | đŸ’Ŧ | [0. Change Log](#0-change-log) | @@ -21,12 +32,18 @@ There are many settings in Self-hosted LiveSync. This document describes each se ## 0. Change Log -This pane shows version up information. You can check what has been changed in recent versions. +This pane always shows the current release history. It does not track whether a particular plug-in version has been read and does not open automatically after an ordinary update. + +Internal database or settings compatibility reviews use a separate safety dialogue, not this pane. The dialogue explains why remote synchronisation has been paused and preserves the automatic synchronisation choices which were configured before the update. A configured Vault which was copied, restored, or opened in a new Obsidian profile can require this review because its device-local acknowledgement is not part of the Vault data. An empty local database is not accepted as evidence that it is safe to continue. An existing unconfigured Vault remains in onboarding without this synchronisation warning; its missing acknowledgement is not filled in automatically, so it is evaluated if the Vault is configured later. Closing the dialogue keeps synchronisation paused. When the detected state can be handled by the running version, the explicit resume action records the current internal database version and restores the configured behaviour. A persistent Notice and the `Review why synchronisation is paused` command reopen the review. An older installation cannot dismiss a pause caused by a newer database or settings version. ## 1. Setup This pane is used for setting up Self-hosted LiveSync. There are several options to set up Self-hosted LiveSync. +An unconfigured installation does not open the onboarding dialogue automatically or scan the Vault into the local database. A long-lived Notice offers the onboarding action. If the Notice is dismissed, open **Self-hosted LiveSync settings** → **Setup** → **Rerun Onboarding Wizard**. + +Choose the new-device path when this device owns the files which should initialise synchronisation. Choose the existing-device path when it should receive an established remote state. The wizard reserves Rebuild or Fetch respectively before enabling the settings and requesting a restart, so the selected initialisation runs before the ordinary start-up scan. + ### 1. Quick Setup Most preferred method to setup Self-hosted LiveSync. You can setup Self-hosted LiveSync with a few clicks. @@ -35,10 +52,14 @@ Most preferred method to setup Self-hosted LiveSync. You can setup Self-hosted L Setup the Self-hosted LiveSync with the `setup URI` which is [copied from another device](#copy-current-settings-as-a-new-setup-uri) or the setup script. +A current Setup URI retains its remote profiles, display names, and separate main and P2P selections. Older Setup URIs containing only flat connection settings remain supported and are migrated to a remote profile when applied. + #### Manual setup Step-by-step setup for Self-hosted LiveSync. You can setup Self-hosted LiveSync manually with Minimal setting items. +Completing manual CouchDB, Object Storage, or P2P setup creates the corresponding remote profile without replacing profiles which are already saved. CouchDB and Object Storage setup select the new profile as the main remote. P2P setup selects it for P2P use and, when the wizard is enabling LiveSync, also selects it as the main remote. A descriptive display name is generated and can be changed later. + #### Enable LiveSync This button only appears when the setup was not completed. If you have completed the setup manually, you can enable LiveSync on this device by this button. @@ -148,9 +169,11 @@ Show verbose log. Please enable when you report the logs ## 3. Remote Configuration -### 1. Remote Server +### 1. Connection settings -Self-hosted LiveSync supports multiple remote connection profiles under **Remote Server** -> **Remote Databases**. This allows you to save and switch between multiple databases or bucket configurations in a single vault. +Self-hosted LiveSync stores multiple remote connection profiles under **Connection settings** → **Saved connections**. Each profile represents a CouchDB database, an Object Storage connection, or a P2P configuration, and several profiles can be kept in one Vault. + +Each profile has an opaque identifier and a presentation name. The name does not need to be unique and is not used to select the profile. The main remote and the P2P remote are selected independently, so code and settings imports must preserve both selections rather than relying on a special identifier such as `default`. - **➕ Add new connection**: Create a new connection profile by launching the setup dialogue. - **đŸ“Ĩ Import connection**: Paste a connection string (e.g., `sls+https://...`, `sls+s3://...`, `sls+p2p://...`) to import a remote configuration profile. @@ -162,7 +185,7 @@ Self-hosted LiveSync supports multiple remote connection profiles under **Remote Setting key: remoteType -The active remote server type. This is automatically projected to the legacy configuration when you activate a connection profile. +The active connection type. This is automatically projected to the legacy configuration when you activate a connection profile. ### 2. Notification @@ -170,7 +193,7 @@ The active remote server type. This is automatically projected to the legacy con Setting key: notifyThresholdOfRemoteStorageSize -MB (0 to disable). We can get a notification when the estimated remote storage size exceeds this value. +MB (0 to disable). At startup, Self-hosted LiveSync shows a long-lived, clickable notice when this value has not been configured or the estimated remote storage size exceeds it. Select **Review options** to open the detailed, untimed dialogue. Running the remote-size check explicitly opens that dialogue directly. ### 3. Privacy & Encryption @@ -197,14 +220,15 @@ In default, the path of the file is not obfuscated to improve the performance. I Setting key: E2EEAlgorithm The encryption algorithm version used for end-to-end encryption. + - `v2` (V2: AES-256-GCM With HKDF): Recommended and default version. - `forceV1` or `""` (V1: Legacy): Older legacy encryption. Only use this if you have an existing vault encrypted in the legacy format. -#### Use dynamic iteration count (Experimental) +#### Use dynamic iteration count (legacy V1 compatibility) Setting key: useDynamicIterationCount -This is an experimental feature and not recommended. If you enable this, the iteration count of the encryption will be dynamically determined. This is useful when you want to improve the performance. +This setting applies only to legacy V1 encryption data. Keep the saved value when opening an existing V1 database. New Vaults use E2EE V2 and do not use this setting. --- @@ -290,7 +314,7 @@ These settings are configured within the CouchDB Setup dialogue when adding (` Setting key: couchDB_URI The URI of the CouchDB server. -Note: Only Secure (HTTPS) connections can be used on Obsidian Mobile. The URI must not end with a trailing slash. +Only secure HTTPS connections can be used on Obsidian Mobile. The setup dialogue accepts a complete HTTP or HTTPS URL and normalises it when the settings are applied. #### Username @@ -308,14 +332,13 @@ The password used to authenticate with CouchDB. Setting key: couchDB_DBNAME -The name of the database. -Note: The database name cannot contain capital letters, spaces, or special characters other than `_$()+/-`, and cannot start with an underscore (`_`). +The name of the database. It must not be empty. CouchDB validates the name when the connection is attempted; the setup dialogue does not apply a narrower client-side naming rule. #### Use Request API to avoid inevitable CORS problem Setting key: useRequestAPI -This option is labeled **Use Internal API** in the setup dialogue. If enabled, Obsidian's internal request API will be used to bypass CORS restrictions. This is a workaround that may not be compliant with web standards and is less secure. Note that this might break in future Obsidian versions. +This option is labelled **Use Internal API** in the setup dialogue. If enabled, Obsidian's internal request API is used to bypass CORS restrictions. It sends the configured credentials to the CouchDB server through an Obsidian-owned API, so use it only with a server you trust. Configure CouchDB CORS correctly where possible; this compatibility workaround may change in future Obsidian versions. #### Custom Headers @@ -359,13 +382,20 @@ Setting key: jwtSub The subject (`sub`) claim of the JWT, which should match your CouchDB username. -#### Test Database Connection +#### Connection and save actions -Open database connection. If the remote database is not found and you have permission to create a database, the database will be created. +The action depends on why the dialogue was opened: -#### Validate Database Configuration +- Onboarding for the first device uses **Create or connect to database and continue**. It may create the database when it does not exist and the supplied account has permission. +- Onboarding for an additional device uses **Connect to existing database and continue**. It does not create a missing database. +- Adding or editing a saved remote profile uses **Test connection and save**. It does not create a missing database. +- Settings mode also offers **Save without connecting**. The existing profile is updated, but automatic synchronisation may fail until the connection is corrected. -Checks and fixes any potential issues with the database config. +Onboarding requires a successful connection. It does not expose an unverified continuation action. + +#### Check server requirements + +This optional check reads the CouchDB server configuration through Obsidian's internal request API and sends the configured credentials to that server. Administrator access may be required. The initial check is read-only. Each offered fix names the exact CouchDB setting and proposed value, and requires separate confirmation before making that change. #### Apply Settings @@ -377,11 +407,11 @@ Setting key: P2P_Enabled Enable direct peer-to-peer synchronisation via WebRTC. -#### Relay URL +#### Signalling relay URLs Setting key: P2P_relays -The WebSocket relay server URL(s) used for coordinating P2P connections via WebRTC. Multiple URLs can be separated by commas. +The Nostr-compatible WebSocket relay URL or URLs used for peer discovery and WebRTC connection negotiation. Multiple URLs can be separated by commas. A signalling relay does not store or transfer Vault contents. See [How peer-to-peer synchronisation works](p2p.md). #### Group ID @@ -407,17 +437,21 @@ Setting key: P2P_AutoStart This option is labeled **Auto Start P2P Connection** in the setup dialogue. If enabled, the P2P connection will start automatically when the plug-in launches. -#### Automatically broadcast changes to connected peers +#### Connect and disconnect + +Closing a P2P connection leaves the LiveSync P2P room, stops its replication service, closes the signalling relay sockets, and pauses their automatic reconnection. An idle WebRTC connection may remain temporarily under the transport's ownership so that it can be reused, but it cannot carry traffic for the room which has been left. Connecting again resumes relay reconnection and joins a new LiveSync room. + +#### Announce changes automatically after connecting Setting key: P2P_AutoBroadcast -This option is labeled **Auto Broadcast Changes** in the setup dialogue. If enabled, changes will be automatically broadcasted to connected peers, requesting them to fetch the changes. +When enabled, this device notifies connected peers after a local change. The notification contains no Vault data. A receiving peer fetches the change only when it follows this device. #### TURN Server URLs (comma-separated) Setting key: P2P_turnServers -A comma-separated list of TURN/STUN server URLs. Used to relay P2P connections when direct WebRTC connection fails due to strict NAT or firewalls. In most cases, these can be left blank. +A comma-separated list of TURN server URLs. TURN is an optional fallback which relays encrypted WebRTC traffic when strict NAT or firewall rules prevent a direct peer connection. It is distinct from the required signalling relay. In most environments, this field can remain blank. #### TURN Username @@ -447,6 +481,7 @@ Apply preset configuration Setting key: syncMode The trigger mechanism for synchronisation. + - **LiveSync** (`LIVESYNC`): Real-time, continuous, bidirectional synchronisation. Note: This requires a CouchDB or WebRTC P2P remote server. It is not supported for S3-compatible Object Storage. - **Periodic Sync** (`PERIODIC`): Synchronisation is performed at regular intervals specified by the **Periodic Sync interval** setting. @@ -493,7 +528,19 @@ Sync automatically after merging files Setting key: keepReplicationActiveInBackground Desktop only; uses more battery and network. This setting applies to continuous and periodic replication. -Finite remote operations, including one-shot replication, P2P peer discovery and selection, rebuilds, fetches, and remote chunk fetching, request best-effort screen-awake protection automatically and do not require this setting. That protection does not guarantee execution while Obsidian is hidden or while the operating system suspends the device. +#### Allow sleep during synchronisation + +Setting key: allowSleepDuringSynchronisation + +Allow the operating system to sleep while finite synchronisation operations are in progress. This option applies on every platform and is disabled by default. When it is disabled, finite operations request best-effort screen-awake protection. + +#### Allow sleep during synchronisation on the desktop + +Setting key: allowSleepDuringSynchronisationOnDesktop + +Desktop only. Allow the operating system to sleep during finite synchronisation operations even when the general option is disabled. This option is enabled by default, so periodic or event-driven synchronisation does not repeatedly prevent automatic desktop sleep. Disable it to retain best-effort screen-awake protection on desktop. + +Setup URIs preserve both sleep preferences. Older Setup URIs which do not contain them use the defaults described above. The preferences cover finite remote operations, including one-shot replication, P2P peer discovery and selection, rebuilds, fetches, remote chunk fetching, and applying downloaded documents to the Vault. They do not control whether continuous replication remains active while Obsidian is hidden, and allowing sleep does not force the operating system to suspend the device. ### 3. Update thinning @@ -514,10 +561,10 @@ Saving will be performed forcefully after this number of seconds. ### 4. Deletion Propagation (Advanced) -#### Use the trash bin +#### Legacy trash setting Setting key: trashInsteadDelete -Move remotely deleted files to the trash, instead of deleting. On Obsidian v1.7.2 or newer, file deletion respects the user's deletion preferences (by utilising the `FileManager.trashFile` API), regardless of this setting. +This key remains accepted for settings imports, Setup URIs, and compatibility with earlier versions, but it is no longer shown in the settings interface. Remote file deletion follows the user's Obsidian deletion preferences through the `FileManager.trashFile` API, regardless of this legacy value. #### Keep empty folder @@ -526,10 +573,12 @@ Should we keep folders that do not have any files inside? ### 5. Conflict resolution (Advanced) +Conflict resolution preserves unknown local content and automatically merges only when the available revision history supplies a safe shared base. See [Conflict resolution and revision provenance](specs_conflict_resolution.md) for the revision-tree rules, stale and concurrent resolutions, binary-file limitation, and the device-local provenance used for operations while a conflict is live. + #### (BETA) Always overwrite with a newer file Setting key: resolveConflictsByNewerFile -Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned. +Testing only. Resolve file conflicts by selecting the copy with the newer modification time. This can overwrite modified files and cannot establish which revision reflects the user's intent. #### Delay conflict resolution of inactive files @@ -559,6 +608,8 @@ Setting key: notifyAllSettingSyncFile ### 7. Hidden Files (Advanced) +See the [Hidden File Sync guide](./tips/hidden-file-sync.md) before enabling this feature. Rebuild and Fetch setup operations deliberately leave optional features disabled; establish ordinary note synchronisation first, then initialise Hidden File Sync independently on each device. + #### Enable Hidden files sync Setting key: syncInternalFiles @@ -606,6 +657,8 @@ If this is set, changes to local files which are matched by the ignore files wil Setting key: ignoreFiles Comma separated `.gitignore, .dockerignore` +When a saved setting changes whether a normal file can be reflected, LiveSync re-evaluates the normal-file metadata already held in the local database. This covers selector expressions, ignore-file settings, maximum-size and modification-time limits, and file-name case handling. A remote revision which was received and checkpointed while excluded can therefore be reflected after the criteria are broadened, without rewinding the remote checkpoint. Narrowing the criteria does not delete files which have already been reflected. + ### 2. Hidden Files (Advanced) #### Ignore patterns @@ -614,6 +667,8 @@ Comma separated `.gitignore, .dockerignore` ## 6. Customisation sync (Advanced) +Customisation Sync is a supported, advanced opt-in feature. Its current per-file implementation is covered by a two-Vault real-Obsidian workflow for snippets, configuration files, and plug-in files. Hidden File Sync is a separate feature with different setup, selection, and conflict behaviour; do not use both features to manage the same files. + ### 1. Customisation Sync #### Device name @@ -655,6 +710,12 @@ Open the dialogue #### Make report to inform the issue +#### Copy database information for a file + +Select a file to copy its local database information. The command **Copy database information for the active file** performs the same inspection for the file open in the editor. + +The report includes the Vault-relative path, document and chunk identifiers, local database revisions, conflicts, and local chunk availability. It does not query the remote or include file contents. Paths and identifiers can still be private metadata, so review the report before sharing it. + #### Write logs into the file Setting key: writeLogToTheFile @@ -678,17 +739,19 @@ Stop reflecting database changes to storage files. ### 3. Recovery and Repair -#### Recreate missing chunks for all files +#### Recreate chunks for current Vault files -This will recreate chunks for all files. If there were missing chunks, this may fix the errors. +Recreate chunks from files currently present in the Vault. This can repair missing chunks for those exact current contents after they have been confirmed as authoritative. It cannot reconstruct unavailable historical or conflict content. + +#### Inspect conflicts and file/database differences + +Compare each Vault file with every current live revision in the local database. Each winner and conflict revision is shown separately with its exact revision identifier, local chunk availability, and relationship to the current Vault file. Unavailable shared ancestors are reported separately because they prevent conservative three-way merging but are not live revisions which can be discarded. + +Select **Begin inspection** to run the inspection. Each reported file and live revision has a wrench menu for read-only comparison, applying an exact database revision to the Vault, recording an exact byte match, preserving the Vault file as a child of a selected branch, retrying chunk retrieval, or explicitly discarding a branch. Destructive actions require confirmation. Follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file) before changing revision history. #### Resolve All conflicted files by the newer one -Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one. - -#### Verify and repair all files - -Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep. +After confirmation, resolve every conflict by modification time. This logically deletes every version except the newest one. It is a destructive policy choice and cannot recover content which is already unavailable. #### Check and convert non-path-obfuscated files @@ -748,20 +811,20 @@ Setting key: concurrencyOfReadChunksOnline Setting key: minimumIntervalOfReadChunksOnline -#### Maximum size of chunks to send in one request +#### Maximum request size for manually resending chunks Setting key: sendChunksBulkMaxSize -Limit the maximum size of chunks to send in a single bulk request (MB). +Limit the maximum size of chunks sent in one request by the explicit **Resend all chunks** maintenance operation (MB). Ordinary and initial replication do not use this setting. ## 9. Power users (Power User) ### 1. Remote Database Tweak -#### Incubate Chunks in Document (Beta) +#### Incubate Chunks in Document (sunset compatibility) Setting key: useEden -If enabled, newly created chunks are temporarily kept within the document, and graduated to become independent chunks once stabilised. +This setting is no longer offered for new configuration. Existing saved values remain accepted so that established databases can be opened and migrated without silently changing their structure. #### Maximum Incubating Chunks @@ -778,10 +841,14 @@ The maximum total size of chunks that can be incubated within the document. Chun Setting key: maxAgeInEden The maximum duration for which chunks can be incubated within the document. Chunks exceeding this period will graduate to independent chunks. -#### Data Compression (Experimental) +#### Data Compression (advanced opt-in) Setting key: enableCompression +Data Compression applies fflate level 8 to each chunk before E2EE. A chunk is left uncompressed when compression would not make it smaller, and readers continue to accept both representations. Changing the setting does not require a rebuild for compatibility; existing and new representations can coexist. + +The setting remains disabled by default because the measured storage and transfer benefit comes with workload-dependent processing and memory costs. See the [Data Compression specification](specs_data_compression.md) for the contract, evidence, execution model, and reproduction command. + ### 2. CouchDB Connection Tweak #### Batch size @@ -849,16 +916,20 @@ Enable this option to automatically apply the most recent change to documents ev Setting key: useIndexedDBAdapter Before v0.17.16, we used an old adapter for the local database. Now the new adapter is preferred. However, it needs local database rebuilding. Please disable this toggle when you have enough time. If leave it enabled, also while fetching from the remote database, you will be asked to disable this. -#### Compute revisions for chunks (Previous behaviour) +#### Content-derived chunk revisions (obsolete setting) Setting key: doNotUseFixedRevisionForChunks -If this enabled, all chunks will be stored with the revision made from its content. (Previous behaviour) +Chunk revisions are always derived from their content. This key remains accepted in stored settings and Setup URIs for compatibility, but its value no longer changes behaviour and it is not a maintenance prerequisite. #### Handle files as Case-Sensitive Setting key: handleFilenameCaseSensitive If this enabled, All files are handled as case-Sensitive (Previous behaviour). +When this setting is disabled, changing only the letter case of a file name within the same directory is synchronised as a rename. Changing the letter case of a directory name is not supported by this handling. + +New Vaults use case-insensitive handling for cross-platform compatibility. Existing settings with an explicit value preserve that choice. Earlier releases also followed the case-insensitive branch when this value was absent, so 1.0 saves a missing legacy value as `false` without requiring a compatibility review or database rebuild. + ### 4. Compatibility (Internal API Usage) #### Scan changes on customisation sync @@ -873,10 +944,12 @@ Do not use internal API Setting key: additionalSuffixOfDatabaseName LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured. -#### The Hash algorithm for chunk IDs (Experimental) +#### The Hash algorithm for chunk IDs (compatibility) Setting key: hashAlg +`xxhash64` is the supported current value. Older algorithms remain selectable only as an edge-case compatibility path for existing databases. Changing the algorithm can reduce chunk reuse between devices and requires the normal tweak review. + ### 6. Edge case addressing (Behaviour) #### Fetch database with previous behaviour @@ -906,6 +979,12 @@ If disabled(toggled), chunks will be split on the UI thread (Previous behaviour) Setting key: processSmallFilesInUIThread If enabled, the file under 1kb will be processed in the UI thread. +#### Automatically align compatible chunk settings + +Setting key: autoAcceptCompatibleTweak + +Current releases enable this by default when the differences are limited to compatible chunk settings. The side with the newer recorded modification time is used for the chunk hash algorithm, chunk size, or splitter version; the remote value is used when neither side has a recorded time or the times are equal. No dialogue or database reconstruction is required. Existing content remains readable, but changing these values can reduce chunk reuse. Turn this off to review compatible differences manually. Any difference which also involves an incompatible setting always requires an explicit decision. + ### 8. Compatibility (Trouble addressed) #### Do not check configuration mismatch before replication @@ -936,7 +1015,7 @@ Disables all synchronisation and restart. #### Resend -Resend all chunks to the remote. +Explicitly resend all locally available chunks to the remote. This is a recovery and maintenance operation; ordinary replication does not pre-send every chunk. #### Reset journal received history @@ -980,11 +1059,11 @@ Purge all download/upload cache. Delete all data on the remote server. -### 6. Deprecated +### 6. Garbage Collection V3 (CouchDB only) -#### Run database cleanup +Garbage Collection V3 identifies chunk documents which are not reachable from any current file or live conflict branch, creates logical deletions for those chunks locally, propagates the deletions to CouchDB, and requests remote compaction. -Attempt to shrink the database by deleting unused chunks. This may not work consistently. Use the 'Overwrite Server Data with This Device's Files' under Reset Synchronisation information. +Use it only when the Vault, local database, and remote are healthy, and every relevant device has synchronised. It can make an ordinary superseded file revision unreadable when no live state still needs its chunks. It does not repair corruption or replace a deliberate rebuild. See the [Garbage Collection V3 specification](specs_garbage_collection.md). ### 7. Reset diff --git a/docs/setup_object_storage.md b/docs/setup_object_storage.md new file mode 100644 index 00000000..0244a766 --- /dev/null +++ b/docs/setup_object_storage.md @@ -0,0 +1,108 @@ +# Set up Object Storage + +This guide establishes Object Storage synchronisation on a first device, generates a Setup URI for another device from that working device, and verifies synchronisation in both directions. + +Object Storage uses the S3-compatible API. Prepare the following before starting: + +- an HTTPS endpoint reachable by every device; +- an access key and secret key with access to the selected bucket; +- a bucket name and region; +- a unique bucket prefix when the bucket is shared; and +- separate passphrases for Vault encryption and Setup URI encryption. + +Back up every Vault involved, and do not use Obsidian Sync, iCloud synchronisation, or another synchronisation service on the same Vault. + +## Generate the initial Setup URI + +The public generator applies the Object Storage preset and records the connection as the selected remote profile. Run it from a trusted terminal: + +```sh +export remote_type=s3 +export endpoint=https://objects.example.com +export access_key= +export secret_key= +export bucket=vault-data +export region=auto +export bucket_prefix=my-vault +export passphrase= +export uri_passphrase= +deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/setup/generate_setup_uri.ts +``` + +For providers which require them, set `force_path_style`, `use_custom_request_handler`, or `bucket_custom_headers` as described in the [setup utility reference](../utils/readme.md#object-storage). + +Store the generated Setup URI and Setup URI passphrase separately. The URI is encrypted, but it contains the Object Storage credentials. + +## Set up the first device + +Use a new bucket prefix, or a prefix whose contents you deliberately intend to replace. + +1. Install and enable Self-hosted LiveSync in the intended Vault. +2. Open onboarding from the `Welcome to Self-hosted LiveSync` Notice. +3. Select `I am setting this up for the first time`, then choose the recommended Setup URI method. +4. Paste the initial Setup URI, enter its passphrase, and select `Test Settings and Continue`. + + ![Object Storage Setup URI on the first device](../images/object-storage-setup/guide-object-storage-setup-first-setup-uri.png) + +5. Select `Restart and Initialise Server`, then read and accept the final overwrite confirmation only when this Vault is the intended source of truth. + + ![Object Storage initialisation on the first device](../images/object-storage-setup/guide-object-storage-setup-first-initialise.png) + + ![Final Object Storage overwrite confirmation](../images/object-storage-setup/guide-object-storage-setup-first-rebuild-confirmation.png) + +6. A new prefix may show `No Synchronisation Settings Found`. Select `Use this device's settings` for a genuinely new prefix. If you expected existing settings, cancel and check the endpoint, credentials, bucket, and prefix. Cancellation restarts with Vault and database reflection paused; keep LiveSync disabled until the remote has been checked. If `Could Not Read Synchronisation Settings` appears, retry, or use this device's settings to continue the overwrite. The overwrite still requires a working connection. + + ![Expected missing remote configuration for a new Object Storage prefix](../images/object-storage-setup/guide-object-storage-setup-missing-remote-configuration.png) + +7. Keep optional features disabled until ordinary note synchronisation works. +8. Create an ordinary test note, and keep Obsidian open until the LiveSync progress indicators have cleared. + +## Generate the second-device Setup URI + +Generate a fresh Setup URI from the working first device: + +1. Run `Self-hosted LiveSync: Copy settings as a new Setup URI` from the command palette. +2. Enter a new Setup URI passphrase. + + ![Masked passphrase for the Object Storage Setup URI for another device](../images/object-storage-setup/guide-object-storage-setup-copy-setup-uri-passphrase.png) + +3. Copy the resulting URI. + + ![Object Storage Setup URI generated by the working first device](../images/object-storage-setup/guide-object-storage-setup-copy-setup-uri-result.png) + +Store this URI and its passphrase separately. + +## Add another device + +Start with a new or separately backed-up Vault. + +1. Install and enable Self-hosted LiveSync. +2. Open onboarding, select `I am adding a device to an existing synchronisation setup`, and choose the recommended Setup URI method. +3. Enter the URI generated by the first device and its passphrase. + + ![Object Storage Setup URI from the first device entered on the second device](../images/object-storage-setup/guide-object-storage-setup-second-setup-uri.png) + +4. Select `Restart and Fetch Data`. + + ![Object Storage Fetch confirmation on the second device](../images/object-storage-setup/guide-object-storage-setup-second-fetch.png) + +5. For a new or empty Vault, choose `Overwrite all with remote files`, then `Keep local files even if not on remote`. Review the [Fast Setup guide](./tips/fast-setup.md) before choosing a different policy for a Vault containing local work. + + ![Object Storage retrieval method](../images/object-storage-setup/guide-object-storage-setup-retrieval-method.png) + + ![Object Storage local-file policy](../images/object-storage-setup/guide-object-storage-setup-local-file-policy.png) + +6. Keep Obsidian open until retrieval and file reflection finish. + +Confirm that the first device's test note appears unchanged. Create a second ordinary note on the new device, wait for its journal synchronisation to finish, and confirm that it reaches the first device. Configure optional features only after this two-way check passes. + +![Note from the first device received by the second Object Storage device](../images/object-storage-setup/guide-object-storage-setup-first-to-second.png) + +![Second-device note received by the first Object Storage device](../images/object-storage-setup/guide-object-storage-setup-second-to-first.png) + +## Safety notes + +- Treat the endpoint, bucket, prefix, access key, secret key, Vault passphrase, Setup URI, and Setup URI passphrase as sensitive. +- Use a distinct prefix per synchronisation set unless shared data is explicitly intended. +- Do not initialise the first device against an existing prefix unless replacing its contents is deliberate. +- Object Storage is not a Vault backup. Keep independent backups and test restoration separately. diff --git a/docs/setup_own_server.md b/docs/setup_own_server.md index 9db4a594..5ac2b753 100644 --- a/docs/setup_own_server.md +++ b/docs/setup_own_server.md @@ -33,7 +33,7 @@ ```bash # Adding environment variables. -export hostname=localhost:5984 +export hostname=http://localhost:5984 export username=goojdasjdas #Please change as you like. export password=kpkdasdosakpdsa #Please change as you like @@ -115,31 +115,27 @@ Congrats, move on to [step 2](#2-run-couchdb-initsh-for-initialise) Please refer to the [official document](https://docs.couchdb.org/en/stable/install/index.html). However, we do not have to configure it fully. Just the administrator needs to be configured. ## 2. Run couchdb-init.sh for initialise + +Deno 2 is required. Export the CouchDB connection and database details, then run the provisioning wrapper: + ``` +export hostname=http://localhost:5984 +export username= +export password= +export database=obsidiannotes curl -s https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/couchdb-init.sh | bash ``` If it results like the following: ``` --- Configuring CouchDB by REST APIs... --> -{"ok":true} -"" -"" -"" -"" -"" -"" -"" -"" -"" -<-- Configuring CouchDB by REST APIs Done! +CouchDB provisioning completed. ``` -Your CouchDB has been initialised successfully. If you want this manually, please read the script. +The wrapper runs the exact registry-pinned Commonlib consumer. When `database` is supplied, it creates the database and initialises its LiveSync database-version document through Commonlib. Without `database`, it configures only the CouchDB server. If you are using Docker Compose and the above command does not work or displays `ERROR: Hostname missing`, you can try running the following command, replacing the placeholders with your own values: ``` -curl -s https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/couchdb-init.sh | hostname=http://:5984 username= password= bash +curl -s https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/couchdb-init.sh | hostname=http://:5984 username= password= database=obsidiannotes bash ``` ## 3. Expose CouchDB to the Internet @@ -171,43 +167,42 @@ Now `https://tiles-photograph-routine-groundwater.trycloudflare.com` is our serv ## 4. Client Setup > [!TIP] -> Now manual configuration is not recommended for some reasons. However, if you want to do so, please use `Setup wizard`. The recommended extra configurations will be also set. +> A generated Setup URI is the recommended path because it carries the current defaults for a new Vault and the selected remote profile. If a Setup URI cannot be generated, follow [Configure CouchDB manually on the first device](./quick_setup.md#configure-couchdb-manually-on-the-first-device), then generate a new Setup URI from that working device for every additional device. ### 1. Generate the setup URI on a desktop device or server ```bash -export hostname=https://tiles-photograph-routine-groundwater.trycloudflare.com #Point to your vault -export database=obsidiannotes #Please change as you like -export passphrase=dfsapkdjaskdjasdas #Please change as you like +export hostname=https://tiles-photograph-routine-groundwater.trycloudflare.com +export database=obsidiannotes export username=johndoe -export password=abc123 -deno run -A https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/flyio/generate_setupuri.ts +export password= +export passphrase= +export uri_passphrase= # Optional +deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/setup/generate_setup_uri.ts ``` > [!TIP] -> What is the `passphrase`? Is it different from `uri_passphrase`? -> Yes, the `passphrase` we have exported now is for an End-to-End Encryption passphrase. -> And, `uri_passphrase` that used in the `generate_setupuri.ts` is a different one; for decrypting Set-up URI at using that. -> Why: I (vorotamoroz) think that the passphrase of the Setup-URI should be different from the E2EE passphrase to prevent exposure caused by operational errors or the possibility of evil in our environment. On top of that, I believe that it is desirable for the Setup-URI to be random. Setup-URI is inevitably long, so it goes through the clipboard. I think that its passphrase should not go through the same path, so it should essentially be typed manually. -> Hence, if we keep empty for uri_passphrase, generate_setupuri.ts generates an adjective-noun-randomnumber passphrase so that we can remember it without going through the clipboard. +> `passphrase` protects the synchronised Vault data with end-to-end encryption. `uri_passphrase` protects only the Setup URI. Use different values, store both securely, and do not send the Setup URI and its passphrase through the same channel. +> +> If `uri_passphrase` is omitted, the generator creates a cryptographically random value and prints it once. + +The generator consumes the exact registry-pinned Commonlib release used by the provisioning utility. It creates a configured CouchDB remote profile, applies the current defaults for a new Vault, and encodes them with Commonlib's Setup URI contract. You will then get the following output: ```bash +Generated couchdb Setup URI. +Your passphrase for the Setup URI is: H7vX...a-random-32-character-value +This passphrase is never shown again, so store it safely. obsidian://setuplivesync?settings=%5B%22tm2DpsOE74nJAryprZO2M93wF%2Fvg.......4b26ed33230729%22%5D - -Your passphrase of Setup-URI is: patient-haze -This passphrase is never shown again, so please note it in a safe place. ``` -Please keep your passphrase of Setup-URI. +Store the Setup URI and its passphrase separately. ### 2. Setup Self-hosted LiveSync to Obsidian -[This video](https://youtu.be/7sa_I1832Xc?t=146) may help us. -1. Install Self-hosted LiveSync -2. Choose `Use the copied setup URI` from the command palette and paste the setup URI. (obsidian://setuplivesync?settings=.....). -3. Type the previously displayed passphrase (`patient-haze`) for setup-uri passphrase. -4. Answer `yes` and `Set it up...`, and finish the first dialogue with `Keep them disabled`. -5. `Reload app without save` once. + +Follow [Quick setup](./quick_setup.md#set-up-the-first-device) for the first device. It covers the current onboarding Notice, Setup URI import, server initialisation, and the safety prompts shown for a newly provisioned database. + +After ordinary note synchronisation works, [generate a new Setup URI on that first device](./quick_setup.md#create-a-setup-uri-for-another-device), then follow [Add another device](./quick_setup.md#add-another-device). Do not make the second device depend on retaining the initial Setup URI produced during provisioning. Configure optional features only after the normal path is verified; [Hidden File Sync has its own guide](./tips/hidden-file-sync.md). --- diff --git a/docs/setup_own_server_cn.md b/docs/setup_own_server_cn.md index 1c88ed61..809c02e3 100644 --- a/docs/setup_own_server_cn.md +++ b/docs/setup_own_server_cn.md @@ -1,152 +1,152 @@ -# 在äŊ č‡Ēåˇąįš„æœåŠĄå™¨ä¸ŠčŽžįŊŽ CouchDB - -## į›ŽåŊ• -- [配įŊŽ CouchDB](#配įŊŽ-CouchDB) -- [čŋčĄŒ CouchDB](#čŋčĄŒ-CouchDB) - - [Docker CLI](#docker-cli) - - [Docker Compose](#docker-compose) -- [创åģēæ•°æŽåē“](#创åģēæ•°æŽåē“) -- [äģŽį§ģåŠ¨čŽžå¤‡čŽŋ问](#äģŽį§ģåŠ¨čŽžå¤‡čŽŋ问) - - [į§ģåŠ¨čŽžå¤‡æĩ‹č¯•](#į§ģåŠ¨čŽžå¤‡æĩ‹č¯•) - - [莞įŊŽäŊ įš„域名](#莞įŊŽäŊ įš„域名) ---- - -> æŗ¨īŧšæäž›äē† [docker-compose.yml 和 ini 文äģļ](https://github.com/vrtmrz/self-hosted-livesync-server) 可äģĨ同æ—ļ启动 Caddy 和 CouchDBã€‚æŽ¨čį›´æŽĨäŊŋᔍč¯Ĩ docker-compose 配įŊŽčŋ›čĄŒæ­åģē。īŧˆč‹ĨäŊŋᔍīŧŒč¯ˇæŸĨ阅链æŽĨä¸­įš„æ–‡æĄŖīŧŒč€Œä¸æ˜¯čŋ™ä¸Ēæ–‡æĄŖīŧ‰ - -## 配įŊŽ CouchDB - -莞įŊŽ CouchDB įš„æœ€įŽ€å•æ–šæŗ•æ˜¯äŊŋᔍ [CouchDB docker image]((https://hub.docker.com/_/couchdb)). - -需čρäŋŽæ”šä¸€äē› `local.ini` ä¸­įš„é…įŊŽīŧŒäģĨčŽŠåŽƒå¯äģĨᔍäēŽ Self-hosted LiveSyncīŧŒåς䏋īŧš - -``` -[couchdb] -single_node=true -max_document_size = 50000000 - -[chttpd] -require_valid_user = true -max_http_request_size = 4294967296 - -[chttpd_auth] -require_valid_user = true -authentication_redirect = /_utils/session.html - -[httpd] -WWW-Authenticate = Basic realm="couchdb" -enable_cors = true - -[cors] -origins = app://obsidian.md,capacitor://localhost,http://localhost -credentials = true -headers = accept, authorization, content-type, origin, referer -methods = GET, PUT, POST, HEAD, DELETE -max_age = 3600 -``` - -## čŋčĄŒ CouchDB - -### Docker CLI - -äŊ å¯äģĨ通čŋ‡æŒ‡åޚ `local.ini` 配įŊŽčŋčĄŒ CouchDB: - -``` -$ docker run --rm -it -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v /path/to/local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb -``` -*čŽ°åž—å°†ä¸Ščŋ°å‘Ŋäģ¤ä¸­įš„ local.ini 挂čŊŊčˇ¯åž„æ›ŋæĸæˆåŽžé™…įš„å­˜æ”žčˇ¯åž„* - -后台čŋčĄŒ: -``` -$ docker run -d --restart always -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v /path/to/local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb -``` -*čŽ°åž—å°†ä¸Ščŋ°å‘Ŋäģ¤ä¸­įš„ local.ini 挂čŊŊčˇ¯åž„æ›ŋæĸæˆåŽžé™…įš„å­˜æ”žčˇ¯åž„* - -### Docker Compose -创åģē一ä¸Ē文äģļ多, 将äŊ įš„ `local.ini` 攞在文äģļ多内, į„ļ后在文äģļ多内创åģē `docker-compose.yml`. č¯ˇįĄŽäŋå¯š `local.ini` 有č¯ģ写权限åšļä¸”įĄŽäŋåœ¨åŽšå™¨čŋčĄŒåŽčƒŊ创åģē `data` 文äģļ多. 文äģļ多į쓿ž„大æĻ‚åς䏋: -``` -obsidian-livesync -├── docker-compose.yml -└── local.ini -``` - -可äģĨå‚į…§äģĨ下内厚įŧ–čž‘ `docker-compose.yml`: -```yaml -services: - couchdb: - image: couchdb - container_name: obsidian-livesync - user: 1000:1000 - environment: - - COUCHDB_USER=admin - - COUCHDB_PASSWORD=password - volumes: - - ./data:/opt/couchdb/data - - ./local.ini:/opt/couchdb/etc/local.ini - ports: - - 5984:5984 - restart: unless-stopped -``` - -最后, 创åģēåšļ启动厚器: -``` -# -d will launch detached so the container runs in background -docker-compose up -d -``` - -## 创åģēæ•°æŽåē“ - -CouchDB 部įŊ˛æˆåŠŸåŽ, 需čĻæ‰‹åŠ¨åˆ›åģē一ä¸Ē数捎åē“, æ–šäžŋ插äģļčŋžæŽĨåšļ同æ­Ĩ. - -1. čŽŋ问 `http://localhost:5984/_utils`, 输å…Ĩå¸åˇå¯†į åŽčŋ›å…ĨįŽĄį†éĄĩéĸ -2. į‚šå‡ģ Create Database, į„ļ后栚捎ä¸Ēäēē喜åĨŊ创åģēæ•°æŽåē“ - -## äģŽį§ģåŠ¨čŽžå¤‡čŽŋ问 -åĻ‚æžœäŊ æƒŗčρäģŽį§ģåŠ¨čŽžå¤‡čŽŋ问 Self-hosted LiveSyncīŧŒäŊ éœ€čρ䏀ä¸Ēåˆæŗ•įš„ SSL 蝁äšĻ。 - -### į§ģåŠ¨čŽžå¤‡æĩ‹č¯• -æĩ‹č¯•æ—ļīŧŒ[localhost.run](http://localhost.run/) čŋ™ä¸€įąģįš„åå‘éš§é“æœåŠĄåžˆåŽžį”¨ã€‚īŧˆéžåŋ…éĄģīŧŒåĒæ˜¯į”¨äēŽįģˆįĢ¯čŽžå¤‡ä¸æ–šäžŋ ssh įš„æ—ļå€™įš„å¤‡é€‰æ–šæĄˆīŧ‰ - -``` -$ ssh -R 80:localhost:5984 nokey@localhost.run -Warning: Permanently added the RSA host key for IP address '35.171.254.69' to the list of known hosts. - -=============================================================================== -Welcome to localhost.run! - -Follow your favourite reverse tunnel at [https://twitter.com/localhost_run]. - -**You need a SSH key to access this service.** -If you get a permission denied follow Gitlab's most excellent howto: -https://docs.gitlab.com/ee/ssh/ -*Only rsa and ed25519 keys are supported* - -To set up and manage custom domains go to https://admin.localhost.run/ - -More details on custom domains (and how to enable subdomains of your custom -domain) at https://localhost.run/docs/custom-domains - -To explore using localhost.run visit the documentation site: -https://localhost.run/docs/ - -=============================================================================== - - -** your connection id is xxxxxxxxxxxxxxxxxxxxxxxxxxxx, please mention it if you send me a message about an issue. ** - -xxxxxxxx.localhost.run tunneled with tls termination, https://xxxxxxxx.localhost.run -Connection to localhost.run closed by remote host. -Connection to localhost.run closed. -``` - -https://xxxxxxxx.localhost.run åŗä¸ē临æ—ļæœåŠĄå™¨åœ°å€ã€‚ - -### 莞įŊŽäŊ įš„域名 - -莞įŊŽä¸€ä¸Ē指向äŊ æœåŠĄå™¨įš„ A 莰åŊ•īŧŒåšļ栚捎需čĻčŽžįŊŽåå‘äģŖį†ã€‚ - -Note: ä¸æŽ¨čå°† CouchDB 挂čŊŊåˆ°æ šį›ŽåŊ• -可äģĨäŊŋᔍ Caddy 垈斚äžŋįš„į왿œåŠĄå™¨åŠ ä¸Š SSL 功čƒŊ - -提䞛äē† [docker-compose.yml 和 ini 文äģļ](https://github.com/vrtmrz/self-hosted-livesync-server) 可äģĨ同æ—ļ启动 Caddy 和 CouchDB。 - -æŗ¨æ„æŖ€æŸĨæœåŠĄå™¨æ—Ĩåŋ—īŧŒåŊ“åŋƒæļ意čŽŋ闎。 +# 在äŊ č‡Ēåˇąįš„æœåŠĄå™¨ä¸ŠčŽžįŊŽ CouchDB + +## į›ŽåŊ• +- [配įŊŽ CouchDB](#配įŊŽ-CouchDB) +- [čŋčĄŒ CouchDB](#čŋčĄŒ-CouchDB) + - [Docker CLI](#docker-cli) + - [Docker Compose](#docker-compose) +- [创åģēæ•°æŽåē“](#创åģēæ•°æŽåē“) +- [äģŽį§ģåŠ¨čŽžå¤‡čŽŋ问](#äģŽį§ģåŠ¨čŽžå¤‡čŽŋ问) + - [į§ģåŠ¨čŽžå¤‡æĩ‹č¯•](#į§ģåŠ¨čŽžå¤‡æĩ‹č¯•) + - [莞įŊŽäŊ įš„域名](#莞įŊŽäŊ įš„域名) +--- + +> æŗ¨īŧšæäž›äē† [docker-compose.yml 和 ini 文äģļ](https://github.com/vrtmrz/self-hosted-livesync-server) 可äģĨ同æ—ļ启动 Caddy 和 CouchDBã€‚æŽ¨čį›´æŽĨäŊŋᔍč¯Ĩ docker-compose 配įŊŽčŋ›čĄŒæ­åģē。īŧˆč‹ĨäŊŋᔍīŧŒč¯ˇæŸĨ阅链æŽĨä¸­įš„æ–‡æĄŖīŧŒč€Œä¸æ˜¯čŋ™ä¸Ēæ–‡æĄŖīŧ‰ + +## 配įŊŽ CouchDB + +莞įŊŽ CouchDB įš„æœ€įŽ€å•æ–šæŗ•æ˜¯äŊŋᔍ [CouchDB docker image]((https://hub.docker.com/_/couchdb)). + +需čρäŋŽæ”šä¸€äē› `local.ini` ä¸­įš„é…įŊŽīŧŒäģĨčŽŠåŽƒå¯äģĨᔍäēŽ Self-hosted LiveSyncīŧŒåς䏋īŧš + +``` +[couchdb] +single_node=true +max_document_size = 50000000 + +[chttpd] +require_valid_user = true +max_http_request_size = 4294967296 + +[chttpd_auth] +require_valid_user = true +authentication_redirect = /_utils/session.html + +[httpd] +WWW-Authenticate = Basic realm="couchdb" +enable_cors = true + +[cors] +origins = app://obsidian.md,capacitor://localhost,http://localhost +credentials = true +headers = accept, authorization, content-type, origin, referer +methods = GET, PUT, POST, HEAD, DELETE +max_age = 3600 +``` + +## čŋčĄŒ CouchDB + +### Docker CLI + +äŊ å¯äģĨ通čŋ‡æŒ‡åޚ `local.ini` 配įŊŽčŋčĄŒ CouchDB: + +``` +$ docker run --rm -it -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v /path/to/local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb +``` +*čŽ°åž—å°†ä¸Ščŋ°å‘Ŋäģ¤ä¸­įš„ local.ini 挂čŊŊčˇ¯åž„æ›ŋæĸæˆåŽžé™…įš„å­˜æ”žčˇ¯åž„* + +后台čŋčĄŒ: +``` +$ docker run -d --restart always -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v /path/to/local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb +``` +*čŽ°åž—å°†ä¸Ščŋ°å‘Ŋäģ¤ä¸­įš„ local.ini 挂čŊŊčˇ¯åž„æ›ŋæĸæˆåŽžé™…įš„å­˜æ”žčˇ¯åž„* + +### Docker Compose +创åģē一ä¸Ē文äģļ多, 将äŊ įš„ `local.ini` 攞在文äģļ多内, į„ļ后在文äģļ多内创åģē `docker-compose.yml`. č¯ˇįĄŽäŋå¯š `local.ini` 有č¯ģ写权限åšļä¸”įĄŽäŋåœ¨åŽšå™¨čŋčĄŒåŽčƒŊ创åģē `data` 文äģļ多. 文äģļ多į쓿ž„大æĻ‚åς䏋: +``` +obsidian-livesync +├── docker-compose.yml +└── local.ini +``` + +可äģĨå‚į…§äģĨ下内厚įŧ–čž‘ `docker-compose.yml`: +```yaml +services: + couchdb: + image: couchdb + container_name: obsidian-livesync + user: 1000:1000 + environment: + - COUCHDB_USER=admin + - COUCHDB_PASSWORD=password + volumes: + - ./data:/opt/couchdb/data + - ./local.ini:/opt/couchdb/etc/local.ini + ports: + - 5984:5984 + restart: unless-stopped +``` + +最后, 创åģēåšļ启动厚器: +``` +# -d will launch detached so the container runs in background +docker-compose up -d +``` + +## 创åģēæ•°æŽåē“ + +CouchDB 部įŊ˛æˆåŠŸåŽ, 需čĻæ‰‹åŠ¨åˆ›åģē一ä¸Ē数捎åē“, æ–šäžŋ插äģļčŋžæŽĨåšļ同æ­Ĩ. + +1. čŽŋ问 `http://localhost:5984/_utils`, 输å…Ĩå¸åˇå¯†į åŽčŋ›å…ĨįŽĄį†éĄĩéĸ +2. į‚šå‡ģ Create Database, į„ļ后栚捎ä¸Ēäēē喜åĨŊ创åģēæ•°æŽåē“ + +## äģŽį§ģåŠ¨čŽžå¤‡čŽŋ问 +åĻ‚æžœäŊ æƒŗčρäģŽį§ģåŠ¨čŽžå¤‡čŽŋ问 Self-hosted LiveSyncīŧŒäŊ éœ€čρ䏀ä¸Ēåˆæŗ•įš„ SSL 蝁äšĻ。 + +### į§ģåŠ¨čŽžå¤‡æĩ‹č¯• +æĩ‹č¯•æ—ļīŧŒ[localhost.run](http://localhost.run/) čŋ™ä¸€įąģįš„åå‘éš§é“æœåŠĄåžˆåŽžį”¨ã€‚īŧˆéžåŋ…éĄģīŧŒåĒæ˜¯į”¨äēŽįģˆįĢ¯čŽžå¤‡ä¸æ–šäžŋ ssh įš„æ—ļå€™įš„å¤‡é€‰æ–šæĄˆīŧ‰ + +``` +$ ssh -R 80:localhost:5984 nokey@localhost.run +Warning: Permanently added the RSA host key for IP address '35.171.254.69' to the list of known hosts. + +=============================================================================== +Welcome to localhost.run! + +Follow your favourite reverse tunnel at [https://twitter.com/localhost_run]. + +**You need a SSH key to access this service.** +If you get a permission denied follow Gitlab's most excellent howto: +https://docs.gitlab.com/ee/ssh/ +*Only rsa and ed25519 keys are supported* + +To set up and manage custom domains go to https://admin.localhost.run/ + +More details on custom domains (and how to enable subdomains of your custom +domain) at https://localhost.run/docs/custom-domains + +To explore using localhost.run visit the documentation site: +https://localhost.run/docs/ + +=============================================================================== + + +** your connection id is xxxxxxxxxxxxxxxxxxxxxxxxxxxx, please mention it if you send me a message about an issue. ** + +xxxxxxxx.localhost.run tunneled with tls termination, https://xxxxxxxx.localhost.run +Connection to localhost.run closed by remote host. +Connection to localhost.run closed. +``` + +https://xxxxxxxx.localhost.run åŗä¸ē临æ—ļæœåŠĄå™¨åœ°å€ã€‚ + +### 莞įŊŽäŊ įš„域名 + +莞įŊŽä¸€ä¸Ē指向äŊ æœåŠĄå™¨įš„ A 莰åŊ•īŧŒåšļ栚捎需čĻčŽžįŊŽåå‘äģŖį†ã€‚ + +Note: ä¸æŽ¨čå°† CouchDB 挂čŊŊåˆ°æ šį›ŽåŊ• +可äģĨäŊŋᔍ Caddy 垈斚äžŋįš„į왿œåŠĄå™¨åŠ ä¸Š SSL 功čƒŊ + +提䞛äē† [docker-compose.yml 和 ini 文äģļ](https://github.com/vrtmrz/self-hosted-livesync-server) 可äģĨ同æ—ļ启动 Caddy 和 CouchDB。 + +æŗ¨æ„æŖ€æŸĨæœåŠĄå™¨æ—Ĩåŋ—īŧŒåŊ“åŋƒæļ意čŽŋ闎。 diff --git a/docs/setup_p2p.md b/docs/setup_p2p.md new file mode 100644 index 00000000..fb13a9c1 --- /dev/null +++ b/docs/setup_p2p.md @@ -0,0 +1,138 @@ +# Set up peer-to-peer synchronisation + +This guide configures a working first device through the ordinary user interface, generates a Setup URI for an additional device, and verifies synchronisation in both directions with explicit peer approval. + +Peer-to-peer synchronisation has no central data-storage server containing a copy of the Vault. A signalling relay is still required for peer discovery. The project's public signalling relay avoids the need to provision one for an ordinary setup; a controlled setup can use another Nostr-compatible relay. Vault data travels through the encrypted peer connection, not through the signalling relay. + +See [How peer-to-peer synchronisation works](p2p.md) for the communication model, the public relay policy, and the distinction between signalling and TURN. + +Before starting: + +- back up both Vaults; +- decide whether to use the project's public signalling relay or another relay reachable by both devices; +- ensure the networks permit a WebRTC connection, or review the [P2P troubleshooting guidance](./tips/p2p-sync-tips.md); +- disable every other synchronisation service for these Vaults; and +- keep both devices awake and Obsidian open during the initial transfer. + +## Set up the first device + +1. Install and enable Self-hosted LiveSync in the intended Vault. +2. Open onboarding from the `Welcome to Self-hosted LiveSync` Notice. +3. Select `I am setting this up for the first time`, choose manual configuration, then select `Peer-to-Peer only`. +4. In `P2P Configuration`: + - enable P2P; + - select `Use the project's public signalling relay`, or enter your own signalling relay URLs; + - generate or enter a private Group ID; + - enter a strong P2P passphrase; + - enter a unique name for this device; and + - leave automatic start and automatic announcements disabled until the manual round trip succeeds. +5. Select `Test Settings and Continue`. The test joins the signalling relay; it does not require another peer to be online. +6. Complete the initialisation and final confirmation on the first device. This initialises the local LiveSync database; P2P has no central remote database to erase. + + ![P2P local initialisation on the first device](../images/p2p-setup/guide-p2p-setup-first-initialise.png) + + ![P2P local database confirmation on the first device](../images/p2p-setup/guide-p2p-setup-first-rebuild-confirmation.png) + +7. Keep optional features disabled until ordinary note synchronisation works. +8. After saving the P2P profile, open `Self-hosted LiveSync: P2P Sync : Open P2P Status` from the command palette. The P2P ribbon icon provides the same destination. Select `Open connection` if signalling is disconnected. + + ![First P2P device connected to the signalling relay](../images/p2p-setup/guide-p2p-setup-first-device-connected.png) + +9. Create an ordinary test note and wait for the local LiveSync progress indicators to clear. + +## Generate the second-device Setup URI + +On the working first device: + +1. Run `Self-hosted LiveSync: Copy settings as a new Setup URI` from the command palette. +2. Enter a new Setup URI passphrase. + + ![Masked passphrase for the P2P Setup URI for another device](../images/p2p-setup/guide-p2p-setup-copy-setup-uri-passphrase.png) + +3. Copy the resulting URI. + + ![P2P Setup URI generated by the working first device](../images/p2p-setup/guide-p2p-setup-copy-setup-uri-result.png) + +Keep the first device online. Store the new URI and its passphrase separately. + +## Add the second device + +1. Install and enable Self-hosted LiveSync in a new or separately backed-up Vault. +2. Open onboarding, select `I am adding a device to an existing synchronisation setup`, and choose the recommended Setup URI method. +3. Enter the Setup URI generated by the first device and its passphrase. + + ![P2P Setup URI from the first device entered on the second device](../images/p2p-setup/guide-p2p-setup-second-setup-uri.png) + +4. Select `Restart and Fetch Data`. + + ![P2P Fetch confirmation on the second device](../images/p2p-setup/guide-p2p-setup-second-fetch.png) + +5. For a new or empty Vault, choose `Overwrite all with remote files`, then `Keep local files even if not on remote`. Review the [Fast Setup guide](./tips/fast-setup.md) before using a Vault which contains local work. + + ![P2P retrieval method](../images/p2p-setup/guide-p2p-setup-retrieval-method.png) + + ![P2P local-file policy](../images/p2p-setup/guide-p2p-setup-local-file-policy.png) + +6. In `P2P Rebuild`, confirm that the expected name of the first device is shown, then select `Sync`. + + ![Selecting the first device for P2P Rebuild](../images/p2p-setup/guide-p2p-setup-select-first-device.png) + +7. On the first device, verify the requesting device name and select `Accept`. Use `Accept Temporarily` instead when approval should last only for this Obsidian session. + + ![Explicit P2P connection request on the first device](../images/p2p-setup/guide-p2p-setup-connection-request-1.png) + +8. Keep both devices open until the test note appears on the second device. + + ![Note from the first device received by the second P2P device](../images/p2p-setup/guide-p2p-setup-first-to-second.png) + +## Verify the return journey + +Create a second ordinary note on the second device. Keep automatic announcements disabled, then run and verify the next synchronisation explicitly: + +1. Open `P2P Status` on both devices. +2. If a peer no longer appears, select `Disconnect` and then `Open connection` on the first device, followed by the second device. The device which joins last is advertised to devices which are already in the room. +3. On the first device, select `Refresh`, verify the second-device name, then select `Replicate now`. +4. On the second device, verify the name of the requesting first device and select `Accept` or `Accept Temporarily`. + + ![Explicit return-journey connection request on the second device](../images/p2p-setup/guide-p2p-setup-connection-request-2.png) + +5. Confirm that the second-device note appears unchanged on the first device. + + ![Second-device note received by the first P2P device](../images/p2p-setup/guide-p2p-setup-second-to-first.png) + +The two devices are now proven to share the same room, encryption settings, and data format in both directions. + +After this manual path works, configure automatic behaviour deliberately: + +- `Announce changes` on a source device dispatches change notifications while it is connected. +- `Follow changes` on the receiving device fetches after notifications from that peer. +- The peer's `More actions` menu can synchronise or follow whenever that named device connects, or include it in the P2P synchronisation command. + +An announcement contains no Vault data and does not transfer a change by itself. The source must announce, the receiver must follow, and both devices must be connected. + +## If a peer does not appear + +- Confirm that both panes show `Connected` and the same Room ID suffix. +- Select `Refresh` after the other device joins. +- Reconnect the device which should be discovered last. +- Check that the Setup URI came from the working first device and that neither device copied a peer name manually. +- Check signalling relay reachability separately from WebRTC connectivity. +- Review VPN and TURN options in [Peer-to-Peer Synchronisation Tips](./tips/p2p-sync-tips.md). + +## Controlled or self-hosted setup + +The ordinary route above starts in the plug-in UI and can use the project's public signalling relay. For a controlled deployment, prepare your own Nostr-compatible relay and enter it in `Signalling relay URLs` on every device. + +The public Setup URI generator is also available when configuration must be created outside Obsidian. Run it from a trusted terminal: + +```sh +export remote_type=p2p +export p2p_relays=wss://relay.example.com +export p2p_room_id= # Optional; generated when omitted +export p2p_passphrase= # Optional; generated when omitted +export passphrase= +export uri_passphrase= +deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/setup/generate_setup_uri.ts +``` + +The generated Setup URI contains the encrypted room, relay, and Vault settings. It deliberately omits the device-specific name. Store the URI and its passphrase separately. After importing it on the first device, continue from the initialisation step above, then generate a fresh Setup URI for an additional device from that working device. diff --git a/docs/specs_conflict_resolution.md b/docs/specs_conflict_resolution.md new file mode 100644 index 00000000..e476f75c --- /dev/null +++ b/docs/specs_conflict_resolution.md @@ -0,0 +1,258 @@ +# Conflict resolution and revision provenance + +This document describes the conflict-resolution and file-reflection guarantees used by Self-hosted LiveSync 1.0, together with the cases which still require user judgement. The underlying revision-tree operations and injectable provenance contract are owned by `@vrtmrz/livesync-commonlib`; LiveSync owns persistent device-local composition, Vault reflection, settings, and dialogue policy. + +## Revision-tree model + +PouchDB stores a document as a revision tree. It selects one live leaf as the deterministic winner and reports the other live leaves as conflicts. That winner is not proof that its content is newer, safer, or the version currently shown in the Vault. + +For example: + +```text +A1 +├── B1 ── C1 ── D1 +└── B2 ── C2 +``` + +The two live leaves are `D1` and `C2`. Their nearest shared ancestor is `A1`; neither `B1` nor `B2` is shared. A conservative three-way merge therefore compares the changes from `A1` to each leaf. Matching generation numbers, or selecting the first older revision from one branch, does not prove shared ancestry. + +Resolving a conflict writes the selected or merged result on one observed branch and deletes the other observed live leaf. A stale device may still have the deleted leaf's content in its Vault when it receives the resolution. + +## Implemented 1.0 guarantees + +- Automatic text and structured-data merge uses the nearest `available` revision ID which is present in both leaf histories. +- Missing or compacted history stops conservative automatic merge instead of guessing a base. +- A receiving Vault file which exactly matches any available revision in the document tree is treated as previously synchronised content. This includes an ancestor below a deleted losing leaf. +- A receiving Vault file whose bytes do not match any available revision is preserved as an unsynchronised local change. +- File bytes, rather than path, size, modification time, or revision generation, determine whether content is known. +- Three or more live versions are reviewed one pair at a time in a deterministic order, with each completed pair committed before the next live pair is read. +- Each device records the exact revision most recently reflected in each Vault file. An edit, deletion, or case-only rename made while a conflict is active extends that displayed branch rather than the deterministic database winner. +- A cross-path rename stores the target before logically deleting only the displayed source branch. + +The all-branch history check prevents a resolved conflict from being recreated merely because the receiving Vault still contains the known losing version. If the user has edited that version again, its bytes differ and the overwrite guard preserves it. + +## Resolution patterns + +| State | Safe action | +| -------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| Both leaves contain identical bytes | Collapse the duplicate leaf. | +| Text or structured data has an available shared base and non-overlapping changes | Perform a conservative three-way merge. | +| One side deletes content which the other leaves unchanged | Preserve the deletion. | +| One side deletes content which the other modifies | Ask the user. | +| A receiving file matches a revision available anywhere in the tree | Apply the propagated database result. | +| A receiving file matches no available revision | Preserve it and ask the user. | +| A required body or shared ancestor is missing or compacted | Ask the user. | +| Binary contents differ | Prefer an explicit user selection; semantic merge is unavailable. | + +The compatibility implementation currently selects the newer modification time for differing binary conflicts even when the general **Always overwrite with a newer file** option is disabled. This is existing behaviour, not a new 1.0 guarantee. Changing it to explicit selection only is a separate compatibility decision. + +## Unreadable revisions and repair + +A document revision can remain in the PouchDB tree while one or more chunks needed to reconstruct its content are unavailable. Missing content is not evidence that the revision is obsolete. LiveSync therefore leaves an unreadable winner or conflict revision in the tree instead of deleting it during automatic conflict processing. + +**Hatch** → **Inspect conflicts and file/database differences** inspects the current winner, every current conflict revision, and the nearest shared ancestor for each conflict. A logical-deletion winner and an absent Vault file already agree, so that state is not reported unless another live branch still requires attention. When the Vault already matches the winner but conflict branches remain, the card shows the compact status `✅ Vault matches winner ¡ âš ī¸ Conflicts: N`; matching the winner does not mean that the conflict has been resolved. + +Each reported live revision has a compact wrench menu. The available actions depend on the exact revision and current Vault state: + +- **Compare with Vault** opens the existing difference dialogue in read-only mode for differing text files. +- **Apply this revision to Vault** writes the selected readable revision, even when it is not the database winner. Replacing an existing file requires confirmation. +- **Mark this revision as the Vault version** is offered when the bytes already match. It records exact device-local provenance without creating a child revision, and refuses the operation if the file changed after inspection. +- **Store Vault file as a child of this revision** preserves the current Vault bytes on the explicitly selected live branch. +- **Apply logical deletion to Vault** removes an existing Vault file after confirmation. An absent file needs no retained deletion provenance. +- **Retry reading revision** attempts the configured chunk-retrieval path again. It does not change the revision tree. +- **Discard this branch** is available for each exact live revision while at least one other live branch remains. It requires confirmation and creates a logical deletion on only the selected branch without changing the current Vault file. +- **Discard unreadable revision** remains available as a recovery action when an unreadable revision is the only live leaf. It requires confirmation because no other database branch remains. + +Every mutating action rechecks that the selected revision is still a current live leaf. If another operation resolved or replaced it, the action fails and the card is refreshed instead of extending an obsolete branch. + +The card uses compact, mobile-friendly diagnostic rows with an emoji and a text label. `🧩 Missing chunks: N` identifies an unreadable revision. In the database row, `Δsize` is decoded size minus recorded size; in the Vault row, `Δsize vs DB` is Vault size minus decoded database size. `Δtime` is Vault modification time minus database modification time. The ordinary two-second comparison window still labels which side is newer. These values help diagnose a mismatch; path, size, and modification time do not prove revision identity or decide which content should win. + +A shared ancestor is informational. An ancestor which is no longer a live revision cannot be discarded independently through this workflow. If its body is unavailable, conservative three-way merge remains disabled, although readable live revisions can still be selected manually. + +Logical deletion does not recreate missing bytes, purge the document history, or prove that the deleted version was unimportant. Another replica or backup may still contain the missing chunks. Recover from that source before discarding a revision whenever possible. + +**Recreate chunks for current Vault files** can recreate chunks only from files which are readable in the current Vault. It cannot reconstruct unique bytes from an unavailable historical or conflict revision. + +Garbage Collection V3 treats every live conflict revision and its nearest available shared ancestor as reachable. Their locally available chunks are retained until the conflict is resolved. After resolution, chunks used only by the discarded branch or no-longer-needed merge ancestry can become eligible for collection. See the [Garbage Collection V3 specification](specs_garbage_collection.md). + +A generation-one revision has no parent. When its body is unavailable, LiveSync cannot preserve a changed Vault file as a sibling branch without inventing ancestry. It leaves the operation unresolved. Recover the missing chunks from another replica or backup, or explicitly discard that live revision. If the current Vault file is the intended replacement, it can be stored after the unreadable revision has been logically deleted. + +### Two devices independently create the same path + +If two devices create the same full synchronised path before either device has +received the other creation, the two generation-one leaves have no shared +revision. Files with the same name in different directories remain separate +paths and do not form this conflict. + +When the independently created files contain identical bytes, LiveSync deletes +one duplicate leaf without synthesising merged content. A device which still +records the deleted duplicate as its displayed revision already has the same +bytes as the surviving revision, so it does not recreate the conflict. It +rebinds its device-local provenance to the surviving revision. + +When the independently created files contain different bytes, conservative +three-way merge has no valid base. LiveSync therefore leaves the two versions +for manual selection; it does not guess an empty base or concatenate unrelated +files. If both versions instead descend from a revision which the devices had +previously synchronised, they are ordinary divergent branches: LiveSync may +merge non-overlapping text or structured-data changes from that shared base, +and otherwise asks the user. + +## Stale and concurrent resolutions + +A device can resolve only the leaves which it has observed. If another device has already extended a branch, later replication can reveal another live leaf and require another resolution. Two devices can also produce different resolutions concurrently, leaving multiple live leaves after their trees meet. + +A higher revision generation or modification time does not make either result authoritative. The resolver must examine every current live leaf again until one result remains or user action is required. This is continued conflict processing, not a reset of the synchronisation checkpoint. + +## More than two live versions + +When three or more versions remain, LiveSync compares the current PouchDB winner with one conflict leaf at a time. Commonlib orders the remaining candidates by revision generation ascending, original leaf modification time ascending, then the complete revision ID in code-unit lexical order. A missing or non-finite modification time is ordered before a finite value. Modification time makes pair selection reproducible here; it does not decide which content wins. + +For each pair, LiveSync first collapses identical content, then attempts a conservative sensible merge, and finally asks the user when neither automatic action is safe. A completed action is written to the ordinary revision tree and its losing observed leaf is deleted before LiveSync reads the remaining live leaves again. There is no separate persistent merge accumulator. + +**Concat both** writes the concatenated result as a new child of the displayed PouchDB winner, then deletes only the other leaf shown in that dialogue. With two live versions, that action resolves the conflict. With three or more, the new child remains live against every untouched leaf and becomes part of the next pairwise review; it does not create an unrelated root or consume an unseen branch. + +Consequently, choosing **Not now** or closing Obsidian cannot undo a completed pair. After restart, LiveSync reconstructs the next pair from the live tree. If replication changes either revision while a dialogue is open, LiveSync discards the stale selection, refreshes the live count, and rechecks the path rather than deleting a revision which was not the one shown. + +## Device-local file provenance + +LiveSync composes Commonlib's injected `FileReflectionProvenance` with its local key-value database. Each device stores: + +```text +path -> { revision, observedStorageMtime? } +``` + +`revision` identifies the exact database revision which most recently produced the displayed Vault file. `observedStorageMtime` is the raw local modification time observed after reflection. It is not rounded, combined with another device's value, or used as proof of branch identity. No content hash is persisted. + +The record changes only after a successful database-to-Vault reflection or Vault-to-database write. Reading a file does not change it. The recorded revision remains authoritative even if the user edits the file to bytes which equal another branch; otherwise content equality could silently move the edit to a branch which was not displayed. + +LiveSync creates the namespaced store handle during service composition, before the key-value database is open. The sequential `onSettingLoaded` lifecycle opens that database before Vault scanning, watching, or replication starts. Store operations do not wait for implicit readiness: a lifecycle violation fails promptly, avoiding an indefinite or self-referential initialisation wait. Local database reset is a transient unavailable boundary, after which scanning reconstructs derived state. + +When no record exists, LiveSync may reconstruct the displayed revision only if the current Vault bytes match exactly one available revision body. No match, or identical content in multiple revisions, cannot prove branch identity. + +## Operations while a conflict exists + +- Editing a file writes a child of its recorded or uniquely reconstructed displayed revision. +- Deleting a file writes a logical-deletion child of that revision. It uses LiveSync's `deleted` marker, rather than a PouchDB `_deleted` tombstone, so the deletion remains a live branch which can replicate and be resolved against the other branch. +- A case-only rename writes the new path as a child in the same document tree. +- A cross-path rename stores the target document first, then writes a logical-deletion child on the displayed source branch. + +If an edit's base cannot be proved, LiveSync keeps the bytes as another manual-resolution branch instead of attaching them silently to the database winner. If a deletion's displayed branch cannot be proved after the file body has gone, LiveSync preserves every branch and requests conflict review. For an unproven cross-path rename, the new target remains stored and every source branch is preserved for review. These fallbacks can leave a temporary duplicate or unresolved source, but they do not discard an unproven branch. + +## Interactive dialogue policy + +Choosing **Not now** postpones repeated merge dialogues for the same +uninterrupted conflict episode in the current plug-in session. Ordinary file +checks and replication do not reopen the dialogue while at least one conflict +leaf remains. If the in-editor status display is enabled, the active file shows +**This file has 3 unresolved versions. They will be reviewed one pair at a +time.** for three or more live versions, using the current count, and **This +file has unresolved conflicts.** for two. Postponement therefore does not make +the conflict invisible. + +The command **Resolve if conflicted.**, and selecting a file through **Pick a +file to resolve conflict**, explicitly clear the postponement and request the +dialogue again. Cancellation caused by another conflict dialogue does not count +as **Not now**. Once the document has no remaining conflicts, the episode ends; +a later conflict at the same path prompts normally. The postponement is not +persisted across a plug-in reload. Completed pairwise resolutions are persisted +in the ordinary revision tree, so a reload forgets only the postponement and +does not repeat an already committed stage. + +When synchronisation supplies a resolved document, the existing incoming-file +processing event closes an open conflict dialogue for that path. The same event +rechecks the local revision tree: if no conflict leaf remains, it ends any +postponed episode and removes the active-file warning. If conflict leaves still +exist, the stale dialogue closes and the warning changes to the current live +version count. A postponed episode stays postponed; otherwise, subsequent +conflict processing may open a fresh dialogue for the current revision tree. +Each dialogue owns its completion result, so a prompt which is answered or +closed immediately still completes the waiting conflict operation; the result +does not depend on a later global listener being ready. +The end of an automatic repeat is silent. An explicit **Pick a file to resolve +conflict** request which starts with no conflicts may show one confirmation +Notice. + +## Example device scenarios + +### A user edits the branch shown on one device + +Mac and Android have produced two branches of `shared.md`. Mac's local database selects revision `C1` as its deterministic winner, but the file currently shown in the Android Vault came from revision `C2`: + +```text +A1 +├── B1 ── C1 database winner +└── B2 ── C2 displayed on Android +``` + +Android recorded `C2` when it wrote that revision into the Vault. If the user edits the file on Android, the new revision extends `C2`: + +```text +A1 +├── B1 ── C1 +└── B2 ── C2 ── D2 Android edit +``` + +After synchronisation, both devices receive `C1` and `D2` as the live branches. The edit is not moved silently onto `C1`, and ordinary conflict resolution can compare the real descendants. + +### A user deletes the branch shown on one device + +If Android deletes the file while it still displays `C2`, LiveSync writes a logical-deletion revision below `C2`: + +```text +A1 +├── B1 ── C1 +└── B2 ── C2 ── D2 (deleted: true) +``` + +The deletion remains one side of the live conflict. The user can still choose between the content at `C1` and deleting the file. LiveSync does not delete `C1` merely because PouchDB selected it as the winner. + +### A user renames a conflicted file + +If the user changes only the spelling case, such as `Note.md` to `note.md`, LiveSync keeps the rename in the same revision tree and extends the revision displayed on that device. + +If the user renames `draft.md` to `published.md`, LiveSync stores `published.md` before it marks the displayed `draft.md` branch as logically deleted. If an interruption occurs between those operations, the recoverable result is a duplicate which can be reviewed, rather than loss of the only copy. Any other live branch of `draft.md` remains available for conflict resolution. + +### A remote resolution reaches a device which still shows the losing content + +Android may resolve a conflict and continue editing while Mac still shows the losing revision. When Mac receives the resolved tree, LiveSync searches every available branch and recognises Mac's unchanged bytes as content which was already synchronised below the deleted losing leaf. It can apply Android's resolution without asking Mac to resolve the same unchanged conflict again. + +If the user edited the file on Mac before the resolution arrived, the bytes no longer match that historical revision. LiveSync preserves the Mac edit as an unsynchronised conflict instead of overwriting it. + +### A three-version review is interrupted + +Mac receives three live versions of `shared.md`. The active-file status reports three unresolved versions, and the first dialogue compares the deterministic winner with the first ordered conflict leaf. The user completes that pair, leaving two live versions, then chooses **Not now** on the next dialogue and closes Obsidian. + +The first decision has already changed the ordinary revision tree. On restart, LiveSync reads the two surviving versions and presents only that remaining pair; it does not reconstruct the original three-version state. If another device resolves the remaining pair before or while the dialogue is open, the warning disappears and the stale dialogue closes. + +### The device-local record is missing + +A local-database reset removes revision provenance. On the next scan, if the Vault file matches exactly one available revision, LiveSync can reconstruct which branch was displayed and continue from it. If the bytes match multiple revisions, or no available revision, the branch remains unproved. + +In that unproved state, an edit is retained as another manual-resolution branch. A deletion leaves all existing branches intact. A cross-path rename stores the target but leaves every source branch for review. The result can require an extra decision, but it does not discard data by guessing the winner. + +### Start-up or reset overlaps a provenance operation + +LiveSync creates the provenance handle during composition, then opens its backing store during the sequential settings lifecycle before starting scans, watchers, or replication. If the store cannot open, start-up stops rather than leaving file processing waiting indefinitely. + +During reset, the store can be temporarily unavailable. A racing provenance lookup fails promptly and follows the same conservative missing-record behaviour. After reopen, scanning can reconstruct a record when one exact revision body matches the Vault file. + +## Unsafe shortcuts + +Do not: + +- infer a common ancestor from generation numbers alone; +- assume that the PouchDB winner is the version currently displayed in the Vault; +- replace recorded displayed provenance merely because current bytes match another branch; +- discard local content when revision-history lookup fails; +- infer revision identity from path, size, modification time, or content hash without a revision ID; +- select the newest modification time unless the user has explicitly chosen that destructive policy; or +- merge overlapping text edits or unrelated binary contents automatically. + +## Verification + +Commonlib's real-PouchDB and injected-boundary unit tests cover unequal branch lengths, exact shared ancestry, deterministic ordering of multiple live leaves, a sensible stage followed by reconstruction of a manual pair, content below a deleted losing leaf, recorded and reconstructed branch identity, ambiguous matches, conflict-time editing, missing-body preservation when parent metadata is available, refusal to invent a parent for a generation-one revision, logical deletion, case-only rename, cross-path rename, and safe unproven fallbacks. + +LiveSync's optional real-Obsidian two-Vault checks have two scopes. `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true` resolves and edits a Markdown conflict, propagates it to a Vault which still displays the deleted losing content, and requires one live result to remain. `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` edits, deletes, case-renames, and cross-path-renames files while conflicts remain active; it verifies the parent revision of each resulting branch, replicates those exact trees, and confirms that the other live branches remain intact. + +The focused `test:e2e:obsidian:conflict-dialog-policy` scenario creates three live versions in one real Obsidian Vault. It verifies the count warning, commits a concatenated child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the isolated Obsidian profile, and confirms that only the live pair is reconstructed. It also verifies that an incoming resolution closes a stale dialogue, completes the waiting conflict operation, and clears the warning. The repair scenario removes a referenced local chunk, confirms that the exact unreadable live revision remains in the tree, and exercises explicit retry and discard controls without deleting another live revision. diff --git a/docs/specs_data_compression.md b/docs/specs_data_compression.md new file mode 100644 index 00000000..ac6e2845 --- /dev/null +++ b/docs/specs_data_compression.md @@ -0,0 +1,116 @@ +# Data Compression specification + +## Status and decision for 1.0 + +Data Compression is a maintained, advanced opt-in feature for CouchDB-compatible remote databases. It remains disabled by default in the 1.0 line. + +The feature provides a modest, measurable reduction in mixed-workload storage and transfer volume. Its value depends heavily on the chunk contents, while the current implementation adds substantial processing and worker-memory costs. Users with slow, metered, or storage-constrained connections may still find that trade-off worthwhile. + +## Stored-data behaviour + +The `enableCompression` setting affects chunk documents written through the CouchDB remote connection. It does not compress the local database, and it is separate from the journal format used by Object Storage synchronisation. + +For each document containing string `data`, the writer: + +1. detects canonical Base64 and decodes it to bytes, or encodes text as UTF-8; +2. applies raw DEFLATE through fflate at level 8; +3. encodes the result as Base64 and adds the LiveSync compressed-data marker; and +4. stores the compressed representation only when its string representation is shorter than the original. + +Already compressed data and data which does not become smaller are therefore retained unchanged. Readers always recognise and expand the compressed-data marker, including when their own `enableCompression` setting is off. Compressed and uncompressed chunks can coexist in the same remote database. + +When E2EE is enabled, compression is applied before E2EE V2 encryption. The encrypted remote representation does not expose the compression marker. + +Changing the setting does not require a rebuild for compatibility: new writes adopt the selected policy, while existing chunks remain readable. All devices are still asked to agree on the remote tweak value so that future writes use one consistent policy. A deliberate remote rebuild can normalise existing storage, but it is optional rather than a prerequisite for synchronisation. + +## Execution model + +The compression and decompression algorithms are not executed synchronously on Obsidian's UI thread. Browser builds call fflate's asynchronous `deflate` and `inflate` APIs, which create a Web Worker for an operation and terminate it after the callback. The CLI uses the corresponding Node worker-thread path. + +The PouchDB transform hook, Base64 detection and conversion, UTF-8 conversion, worker creation, result conversion, and document mutation still run in the calling JavaScript context. The current implementation also creates a separate fflate worker for each attempted chunk rather than reusing LiveSync's persistent splitting and encryption worker pool. `transform-pouch` applies the incoming transform to a bulk batch with `Promise.all`, so a batch can start many of these workers concurrently. It can therefore consume significant total CPU and memory, and worker churn may still affect responsiveness or mobile process limits even though the DEFLATE calculation itself is off the UI thread. + +The current benchmark measures the Node CLI process. It does not establish Obsidian UI event-loop latency, Electron renderer responsiveness, mobile WebView memory behaviour, battery use, thermal throttling, or platform watchdog thresholds. Those remain real-runtime validation gaps. + +## Reproducible benchmark + +The benchmark is implemented under `src/apps/cli/testdeno` and packaged by `test/bench-network`. It uses the real CLI mirror and synchronisation path, Commonlib 0.1.0-rc.4, CouchDB 3.5.0, the V3 Rabin–Karp splitter, optional Data Compression, and E2EE V2. It runs each of these conditions three times in rotating order: + +- E2EE off, compression off; +- E2EE off, compression on; +- E2EE on, compression off; and +- E2EE on, compression on. + +The 623,553-byte deterministic fixture contains three Markdown files, two generated JPEG files, two repository PNG files, two JSON files, two TypeScript files, one gzip file, and one high-entropy binary file. Every run materialises and byte-compares all 13 files after synchronisation. + +Run it from the repository root: + +```bash +BENCH_COMMAND=compression \ +BENCH_COMPRESSION_REPEAT_COUNT=3 \ +BENCH_COUCHDB_RTT_MS=1 \ +docker compose -f test/bench-network/compose.yml run --build --rm bench-runner +``` + +The latest local three-repeat result was generated on 21st July, 2026. The percentages below compare medians with compression off and on. + +| Measurement | E2EE off | E2EE on | +| ------------------------------------------------ | -------: | ------: | +| Stored chunk-data reduction | 9.12% | 9.01% | +| CouchDB external-size reduction | 9.03% | 8.92% | +| CouchDB file-size reduction | 2.57% | 7.62% | +| Upload request-body reduction | 8.58% | 8.61% | +| Complete materialisation response-body reduction | 6.56% | 4.84% | +| Upload wall-time increase | 197.10% | 199.29% | +| Upload CPU-time increase | 650.43% | 581.54% | +| Complete materialisation wall-time increase | 19.58% | 19.75% | +| Complete materialisation CPU-time increase | 45.11% | 43.98% | + +With E2EE on, median upload wall time rose from 1.49 seconds to 4.45 seconds, CPU time rose from 1.30 seconds to 8.86 seconds, and upload maximum resident memory rose from about 160 MiB to 403 MiB. The full materialisation workflow starts a CLI process for each file and produced a much higher compressed-path peak of about 983 MiB; treat that figure as evidence about the current CLI workflow rather than a browser decompression lower bound. + +The E2EE upload saved 99,034 decoded HTTP body bytes while adding about 2.96 seconds of local processing wall time. A simple serial-transfer estimate puts the wall-time break-even near 0.27 Mbit/s. This estimate excludes headers, contention, request overlap, radio energy, data charges, and server behaviour. The benchmark does not emulate a bandwidth-limited mobile link, so transfer-volume reduction may still be valuable where elapsed time is not the only cost. + +## Results by file kind + +The E2EE stored chunk-data reductions were: + +| Fixture kind | Reduction | +| ------------------- | --------: | +| Markdown | 16.30% | +| JPEG | 4.72% | +| PNG | 6.16% | +| JSON | 72.80% | +| TypeScript | 74.11% | +| gzip | 0% | +| high-entropy binary | 0% | + +These results describe payload and chunk characteristics, not a reliable file-extension policy. The JSON and TypeScript fixtures were repetitive and mapped to relatively large chunks. The Markdown files were split into 151 referenced chunks, so small-chunk overhead and limited repetition windows reduced their benefit. JPEG, PNG, and gzip inputs had already undergone format-level compression, while the deterministic binary input was intentionally difficult to compress. + +The remote transform sees a content-addressed chunk, not a trustworthy original file type. A chunk can also be deduplicated across files with different extensions. Enabling compression only for selected extensions would therefore either require carrying new provenance into the chunk format or make the representation depend on whichever file first produced a shared chunk. Neither is suitable for the 1.0 format. + +## Follow-up optimisation candidates + +The first optimisation should remove unbounded per-chunk worker creation: + +1. add compression and decompression tasks to a reusable worker pool; +2. run synchronous fflate inside those workers so that it does not create a nested worker for each task; +3. put a bounded scheduler or semaphore before dispatch, rather than submitting the complete `Promise.all` batch at once; +4. transfer derived input and output `ArrayBuffer` objects explicitly in each `postMessage` transfer list; and +5. decide that a result is not smaller inside the worker, so an unhelpful compressed buffer does not need to be returned. + +LiveSync's current `bgWorker` is a useful starting point because it creates a fixed pool of approximately half the reported hardware concurrency and selects workers in round-robin order. It is not sufficient unchanged: its `processing` count does not control selection, and it does not limit the number of tasks posted to each worker. Its foreground modules also form a circular dependency: `bgWorker.ts` imports the splitting and encryption adapters, while those adapters import task dispatch and removal from `bgWorker.ts`. Compression must not add another branch to that cycle. + +Before adding compression, the Worker code should be separated into a dependency-bottom pool, task registry, scheduler, and transfer transport, with splitting, encryption, and compression implemented as task adapters above it. The shared scheduler should own concurrency limits, cancellation, crash propagation, fairness, and task clean-up. Compression then needs either a separate bounded lane or scheduling which prevents a long level-8 task from starving splitting and encryption work. + +The `TransformStream` currently used by `bgWorker.splitting` is local to the calling context; it is not transferred to the Worker. Moving a stream endpoint across contexts could provide real end-to-end back-pressure, but it is not the first memory optimisation for Data Compression. The [Streams Standard transfer algorithm](https://streams.spec.whatwg.org/#transferable-streams) transfers the stream endpoint and posts each chunk through an internal `MessagePort` with an empty transfer list. Binary chunks can therefore still be cloned. Explicitly transferring each `ArrayBuffer` is the clearer zero-copy boundary for independent chunk tasks. + +Cross-context transferable streams also cannot yet be required by the supported mobile baseline. WebKit lists `ReadableStream`, `WritableStream`, and `TransformStream` transfer via `postMessage()` as a [Safari 27 beta addition](https://webkit.org/blog/17967/news-from-wwdc26-webkit-in-safari-27-beta/#readablestream-improvements). A future general `bgWorker` redesign may use transferable streams behind capability detection, particularly where back-pressure matters more than copying, but it needs a message-based fallback. + +After bounding worker use, adaptive per-chunk compression can reduce unnecessary work without depending on the source filename: + +1. skip very small chunks where the marker and worker start-up dominate; +2. sample decoded bytes and estimate entropy or repetition before starting level-8 DEFLATE; +3. require both a minimum byte saving and a minimum percentage saving; +4. compare lower DEFLATE levels against level 8; and +5. replace one-worker-per-chunk operation with a bounded, persistent compression worker pool and back-pressure. + +Any optimisation must preserve the current wire contract: content may remain uncompressed, the compressed marker must stay readable, compression must precede E2EE, and mixed representations must interoperate. It should be evaluated with the same four-condition benchmark, a bandwidth-shaped case, UI event-loop latency, and at least one real mobile Obsidian run before reconsidering the default. diff --git a/docs/specs_garbage_collection.md b/docs/specs_garbage_collection.md new file mode 100644 index 00000000..67ca4a30 --- /dev/null +++ b/docs/specs_garbage_collection.md @@ -0,0 +1,65 @@ +# Garbage Collection V3 + +Garbage Collection V3 is a beta maintenance operation for CouchDB remotes. It removes chunk documents which are no longer required by the current local revision tree, propagates those logical deletions to CouchDB, and then requests remote compaction. + +It is not a repair operation. Use it only when the Vault, the local LiveSync database, and the CouchDB remote are healthy, every relevant device has synchronised, and recoverable backups exist. + +## Supported scope + +Garbage Collection V3 is available in Edge Case mode for CouchDB. It is not offered for Object Storage or P2P: + +- Object Storage has a different journal and object lifecycle. +- P2P has no central database to compact and cannot provide the accepted-device progress information required by this workflow. + +The operation requires **Fetch chunks on demand** to be off so that its local reachability result is not confused with chunks which exist only on the remote. + +## Workflow + +After the user starts Garbage Collection V3, LiveSync: + +1. completes a one-shot bidirectional CouchDB synchronisation; +2. reads the accepted-device list and current progress recorded on the remote; +3. requires parseable progress information, warns when an accepted device has no current information or device progress differs, then requires explicit confirmation; +4. computes the chunks reachable from the local PouchDB revision tree; +5. creates a logical deletion for each locally present chunk which is not reachable; +6. completes a push-only replication so that those deletions reach CouchDB; +7. requests CouchDB compaction and waits for it for up to two minutes; and +8. clears the local chunk caches. + +If the initial synchronisation, device inspection, or confirmation fails, the workflow stops before collection. Missing or invalid device progress is treated as a failed inspection rather than offered as a confirmation override. If push-only replication fails, the local logical deletions have already been created, but remote compaction is not started; synchronise again before retrying or using another device. A compaction failure or completion timeout is reported separately and is not also reported as a successful completion. + +## Reachability rules + +A chunk remains reachable when it is referenced by any of the following: + +- the current database winner for a file; +- any other live conflict revision for that file; +- an available revision on either side of a live conflict which is required to describe the divergence; or +- the nearest available revision shared by both live conflict branches. + +Chunk identifiers are content-derived and shared between files. Reachability is therefore collected into one set across the database. A chunk used by two or more current files remains protected even when one file is updated or deleted. + +An ordinary superseded linear revision does not protect its former chunks. Once no current file or live conflict branch references a chunk, it can be collected. After a conflict is resolved, chunks unique to the discarded branch and to no-longer-needed merge ancestry can also become eligible. + +## Consequences + +Garbage Collection deliberately trades historical recoverability for storage. A metadata revision may remain in the revision tree after a chunk which only that superseded revision used has been collected, so that historical body can become unreadable. Remote compaction can then discard old CouchDB revision bodies. Tombstones and retained metadata also consume storage, so the operation does not promise the smallest possible database. + +Writing the same bytes again produces the same content-derived chunk identifier. If that chunk was collected previously, the normal chunk-writing path creates a new live revision for it, and ordinary replication can transfer it again. This does not recover an older file revision automatically; it only makes the newly written content available. + +Garbage Collection does not reconstruct a chunk which is already missing, determine whether an unreadable revision is important, or repair a damaged local database. Use **Inspect conflicts and file/database differences**, another healthy replica, or a backup for those cases. Use **Overwrite Server Data with This Device's Files** only when a chosen Vault is authoritative and a deliberate remote rebuild is required. + +## Verification + +Commonlib tests use real in-memory PouchDB revision trees to verify: + +- collection eligibility after a normal file update; +- protection of chunks shared by multiple current files; +- protection of all live conflict branches and their nearest available shared ancestor; +- eligibility of losing-branch and ancestor-only chunks after conflict resolution; +- propagation of chunk deletion to another PouchDB database; and +- recreation and propagation when the same content is written again. + +Self-hosted LiveSync unit tests verify that Garbage Collection V3 uses Commonlib's revision-aware result, deletes only unreachable chunks, performs the initial bidirectional and final push-only replications in order, and requests remote compaction. + +A disposable real-CouchDB integration test verifies logical deletion on the server, retention of shared and conflict chunks, compaction completion, replication after collection, and recreation of a content-addressed chunk. CouchDB's choice and timing of physical byte reclamation remain an external database boundary, so the test does not assert a particular reduction in database size. diff --git a/docs/terms.md b/docs/terms.md index c8809c85..98cdfcbe 100644 --- a/docs/terms.md +++ b/docs/terms.md @@ -27,7 +27,7 @@ All guidelines and conventions listed below are disclosed and maintained solely - Boot-up sequence (boot-sequence) - The initialisation process of the plug-in when Obsidian starts. It starts with the loading of the plug-in, setting up core services, loading saved settings, and opening the local database. Once the layout is ready, the plug-in checks for the presence of flag files, runs configuration diagnostics, connects to the remote database, and begins file watching. The sequence finishes once the plug-in is fully ready and operational. - Broken files (Size mismatch) - - A state where a file's metadata and the actual content stored in its chunks do not match, causing file retrieval or synchronisation failures. These mismatches can be detected and resolved by running validation tools such as `Verify and repair all files` on the Hatch pane. + - A state where a file's metadata and the actual content stored in its chunks do not match, causing file retrieval or synchronisation failures. These mismatches can be inspected with `Inspect conflicts and file/database differences` on the Hatch pane, then handled one exact revision at a time. - Chunk / Chunks - Divided units of data stored in the database or object storage to facilitate efficient synchronisation. - Compaction @@ -74,8 +74,8 @@ All guidelines and conventions listed below are disclosed and maintained solely - A privacy option that encrypts file paths and folder names on the remote server. - plug-in - We use the hyphenated form `plug-in` in user-facing messages and general documentation, while `plugin` may appear in codebase files, configuration settings, or technical contexts. -- Relay Server (P2P relays) - - A WebSocket-based coordination server used to establish direct WebRTC peer-to-peer connections. The default relay is provided by the plug-in author. +- Signalling relay (P2P) + - A Nostr-compatible WebSocket relay used for peer discovery and WebRTC connection negotiation. It does not store or transfer Vault contents. The project author operates a public relay as a best-effort convenience, and users can provide another compatible relay. - Remediation (maxMTimeForReflectEvents) - A recovery setting that restricts the propagation of changes from the database to local storage, ignoring any file events (such as accidental mass deletions) that occurred after a specified date and time. - Reset Synchronisation on This Device @@ -95,7 +95,7 @@ All guidelines and conventions listed below are disclosed and maintained solely - Sync Mode - The replication trigger mechanism. Users can select from `On Events` (synchronising on local file changes), `Periodic and Events` (synchronising at fixed intervals as well as on events), or `LiveSync` (continuous, real-time synchronisation). - TURN Server (WebRTC P2P) - - A server type (Traversal Using Relays around NAT) used as a fallback to relay traffic when direct WebRTC peer-to-peer connection is blocked by strict NAT or firewalls. + - A Traversal Using Relays around NAT server used as an optional fallback to relay encrypted WebRTC traffic when strict NAT or firewall rules block a direct peer connection. It is distinct from the signalling relay. - Update Thinning (Batch database update) - An optimisation that groups multiple local file edits together over a short delay before committing them to the local database, reducing the number of database write operations. - WebRTC P2P (Peer-to-Peer) diff --git a/docs/tips/fast-setup.md b/docs/tips/fast-setup.md index 3e33e354..d67c743d 100644 --- a/docs/tips/fast-setup.md +++ b/docs/tips/fast-setup.md @@ -63,3 +63,11 @@ Once you confirm your choices: 1. The plug-in performs a fast download of the remote database (`fetchLocalDBFast`). 2. It automatically runs a full scan (`synchroniseAllFilesBetweenDBandStorage`) in the foreground to reflect database changes in your local vault files immediately. 3. The plug-in finalises the process and resumes normal operational status. + +### If Fast Setup Stops + +Fast Setup records the last successfully stored remote position as it saves documents. A transient connection interruption is retried automatically from that position. The operation reports completion only after the captured remote state has been stored successfully. + +If an error while decrypting data, reading the remote response, or writing to the local database stops the operation, LiveSync does not run the Vault scan or finalise the reconstructed local database. It retains the saved position, the selected data-processing method, and the fetch flag. File watching and database reflection also remain suspended so that a partly reconstructed database cannot be applied to the Vault or combined with new local changes. + +Review the first specific error in **Show log**, correct its cause, then restart Obsidian to retry from the retained state. While you intend to continue the operation, do not remove the fetch flag or manually resume the Scram switches. If the same error remains after a restart, leave LiveSync suspended, preserve the available data, and [collect a report](../troubleshooting.md#collect-a-report). diff --git a/docs/tips/hidden-file-sync.md b/docs/tips/hidden-file-sync.md new file mode 100644 index 00000000..4fd89a09 --- /dev/null +++ b/docs/tips/hidden-file-sync.md @@ -0,0 +1,73 @@ +# Hidden File Sync + +Hidden File Sync is an advanced, optional feature for synchronising hidden files and folders, including selected content below `.obsidian`. It is separate from ordinary note synchronisation and is deliberately left disabled during Rebuild and Fetch setup operations. + +Enable it only after ordinary notes synchronise correctly in both directions on every device. + +> [!WARNING] +> Hidden files can control active Obsidian settings, themes, snippets, and plug-ins. Back up every Vault before enabling this feature. Do not use Hidden File Sync and Customisation Sync to manage the same files. + +## Choose the initial source + +Enabling Hidden File Sync requires an initialisation direction: + +- `Merge` compares the local and remote hidden-file state without declaring either side authoritative. Use this when both sides contain changes which must be retained, and review any conflicts. +- `Fetch` treats the remote hidden-file state as authoritative and applies it locally. Use this on an additional device after the desired files have been uploaded. +- `Overwrite` treats this device's hidden files as authoritative and writes them to the remote database. Use this first on the device whose hidden files you intend to distribute. + +`Fetch` and `Overwrite` can replace files on one side. Check the direction and the backup before continuing. + +## Review the file selection + +1. Open Self-hosted LiveSync settings. +2. Open `Setup`, find `Enable extra and advanced features`, and enable `Advanced features`. + + ![Advanced features enabled](../../images/hidden-file-sync/guide-hidden-file-advanced-features.png) + +3. Open `Selector`, then review the `Hidden Files` section. +4. Use `Target patterns` to limit the feature to the hidden files you intend to synchronise. An empty target list includes every otherwise eligible hidden file. +5. Review `Ignore patterns`. The default excludes `node_modules`, `.git`, and Self-hosted LiveSync's own plug-in data. `Add default patterns` offers a `Cross-platform` set which also excludes Obsidian workspace files. + + ![Hidden File target and ignore selectors](../../images/hidden-file-sync/guide-hidden-file-selector.png) + +Prefer a narrow target list. Device-specific workspace state and another plug-in's credentials are usually poor candidates for cross-device synchronisation. + +Target patterns also control directory traversal. A pattern must therefore match each parent directory as well as the intended files. For example, the following pattern admits the `.obsidian` parent and only its `snippets` subtree: + +```text +^\.obsidian(?:$|/snippets(?:/|$)) +``` + +A pattern containing only `snippets` does not admit the `.obsidian` parent, so the scan cannot reach that directory. + +## Enable the first device + +1. Open `Sync Settings`, then find the advanced `Hidden Files` section. + + ![Hidden File Sync initialisation choices](../../images/hidden-file-sync/guide-hidden-file-enable.png) + +2. Under `Enable Hidden File Sync`, select the initialisation direction chosen above. +3. Keep Obsidian open while the initial scan and synchronisation finish. A progress Notice appears when preparation begins and remains visible until the initial scan has finished. + + ![Hidden File Sync initial scan progress Notice](../../images/hidden-file-sync/guide-hidden-file-initial-scan-progress.png) + +4. Restart Obsidian when the completion Notice recommends it. +5. Confirm that the expected hidden files, and only those files, are present in the remote synchronisation state. + +For the common source-of-truth rollout, select `Overwrite` on the authoritative device first. + +## Enable each additional device + +Hidden File Sync must be enabled independently after ordinary setup on each device. + +1. Back up the additional device's Vault. +2. Apply the same target and ignore patterns. +3. Select `Fetch` if the remote hidden files are authoritative. Select `Merge` only when local hidden-file changes must also be retained. +4. Wait for the initial scan and file application to finish, then restart Obsidian if requested. +5. Verify representative files before enabling the feature on the next device. + +If you later run Rebuild or Fetch as a recovery operation, expect optional features to be disabled again. Complete ordinary recovery first, then repeat this guide deliberately. + +## Related settings + +The [settings reference](../settings.md#7-hidden-files-advanced) describes periodic scanning, scanning before replication, notification suppression, selectors, and overwrite patterns. Change those controls only after the basic Hidden File Sync path is working. diff --git a/docs/tips/p2p-sync-tips.md b/docs/tips/p2p-sync-tips.md index 4337bc43..533935ed 100644 --- a/docs/tips/p2p-sync-tips.md +++ b/docs/tips/p2p-sync-tips.md @@ -10,20 +10,55 @@ authors: # Peer-to-Peer Synchronisation Tips +For the first device, Setup URI, additional device, and two-way verification procedure, see [Set up peer-to-peer synchronisation](../setup_p2p.md). For the communication and privacy model, see [How peer-to-peer synchronisation works](../p2p.md). + > [!IMPORTANT] -> Peer-to-peer synchronisation is still an experimental feature. Although we have made every effort to ensure its reliability, it may not function correctly in all environments. +> P2P is a supported opt-in feature, but WebRTC connectivity still depends on the networks available to every device. A direct connection cannot be guaranteed in every environment. -## Difficulties with Peer-to-Peer Synchronisation +## A peer does not appear -It is often the case that peer-to-peer connections do not function correctly, for instance, when using mobile data services. -In such circumstances, we recommend connecting all devices to a single Virtual Private Network (VPN). It is advisable to select a service, such as Tailscale, which facilitates direct communication between peers wherever possible. -Should one be in an environment where even Tailscale is unable to connect, or where it cannot be lawfully installed, please continue reading. +Check discovery before changing any Vault settings: -## A More Detailed Explanation +1. Confirm that both devices use the same **Signalling relay URLs**, Group ID, and P2P passphrase. +2. Confirm that each device has a distinct device name. +3. Open `P2P Status` on both devices and confirm that each shows `Connected`. +4. Select `Refresh` after the other device joins. +5. If the peer remains absent, select `Disconnect`, then `Open connection` on the device which should be advertised again. -The failure of a Peer-to-Peer connection via WebRTC can be attributed to several factors. These may include an unsuccessful UDP hole-punching attempt, or an intermediary gateway intentionally terminating the connection. Troubleshooting this matter is not a simple undertaking. Furthermore, and rather unfortunately, gateway administrators are typically aware of this type of network behaviour. Whilst a legitimate purpose for such traffic can be cited, such as for web conferencing, this is often insufficient to prevent it from being blocked. +The signalling relay discovers peers; it does not prove that the networks can carry a WebRTC data connection. -This situation, however, is the primary reason that our project does not provide a TURN server. Although it is said that a TURN server within WebRTC does not decrypt communications, the project holds the view that the risk of a malicious party impersonating a TURN server must be avoided. Consequently, configuring a TURN server for relay communication is not currently possible through the user interface. Furthermore, there is no official project TURN server, which is to say, one that could be monitored by a third party. +## A peer appears but synchronisation cannot connect -We request that you provide your own server, using your own Fully Qualified Domain Name (FQDN), and subsequently enter its details into the advanced settings. -For testing purposes, Cloudflare's Real-Time TURN Service is exceedingly convenient and offers a generous amount of free data. However, it must be noted that because it is a well-known destination, such traffic is highly conspicuous. There is also a significant possibility that it may be blocked by default. We advise proceeding with caution. +WebRTC may fail when UDP hole punching is blocked by carrier-grade NAT, a firewall, a VPN policy, or an intermediary gateway. + +Try these in order: + +1. Put both devices on the same ordinary network and retry. +2. Remove a VPN temporarily if it blocks peer traffic, or use a trusted VPN such as Tailscale when it provides a reachable path between the devices. +3. In `P2P Configuration` -> `Advanced Settings`, configure a trusted TURN service. + +TURN is a fallback for encrypted WebRTC traffic. It is different from the required signalling relay. The project does not operate an official TURN service. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. + +## A connected peer does not receive later edits + +An open signalling connection does not automatically move every change. + +- Use `Replicate now` to prove an explicit bidirectional round trip. +- Enable `Announce changes` on the source device before it dispatches notifications. +- Enable `Follow changes` for that source on the receiving device before it fetches in response. +- Use the peer's `More actions` menu only after the manual round trip works. + +If the device was asleep, Obsidian was in the background, or the peer disconnected, run an explicit synchronisation after both devices are visible and connected. + +## Mobile limitations + +Keep Obsidian visible and the device awake during initial transfer, rebuild, or a large synchronisation. Wake Lock support is best effort and cannot prevent the operating system from suspending or terminating a background application. + +## Collect evidence + +If the same room works on one network but not another, include both network types in the report. Run `Generate full report for opening the issue with debug info`, remove credentials and private relay details, and state whether: + +- both devices reached `Connected`; +- each device appeared in `Detected Peers`; +- a connection request appeared; and +- a TURN server or VPN was in use. diff --git a/docs/to_issue_reporting.md b/docs/to_issue_reporting.md index 615171b0..7512f8a9 100644 --- a/docs/to_issue_reporting.md +++ b/docs/to_issue_reporting.md @@ -1,145 +1,65 @@ # How to report an issue -Thank you for helping improve Self-hosted LiveSync! +Thank you for helping improve Self-hosted LiveSync. A concise report with the right evidence is more useful than trying several recovery operations before reporting the original symptom. -This document explains how to collect the information needed for an issue report. Issues with sufficient information will be prioritised. +Use the [issue report template](https://github.com/vrtmrz/obsidian-livesync/issues/new?template=issue-report.md) for the report itself. Use [Troubleshooting](troubleshooting.md) to diagnose a symptom or choose a recovery action. ---- +## Preserve the original symptom -## Filled example +Do not reset a database, rebuild a remote, change transport, or enable P2P merely to see whether the problem disappears. These actions can change the evidence and may make the original cause harder to identify. -Here is an example of a well-filled report for reference. +If the problem may involve data loss, corruption, or unexpected deletion, preserve a copy of every readable affected file and stop editing it on other devices before changing settings. -### Abstract +Include when the problem began, whether it followed an update or restart, how often it occurs, and which device and remote type were involved. -The synchronisation hung up immediately after connecting. +## Required information -### Expected behaviour +### Describe the behaviour -- Synchronisation ends with the message `Replication completed` -- Everything synchronised +Complete the issue template with: -### Actually happened +- a one- or two-sentence summary; +- the expected and actual behaviour; +- repeatable steps, or the frequency and timing when reliable reproduction is not available; and +- the role of each relevant device, such as the device where the change originated and the device where the failure appeared. -- Synchronisation was cancelled with the message `TypeError: Failed to fetch` (visible in the plug-in log around lines 10–12) -- No files synchronised +### Obsidian debug information -### Reproducing procedure +Open the command palette with `Ctrl`+`P` or `Command`+`P`, run `Show debug info`, and include its output for each relevant device. The device where the problem appeared is required. Information from the other participating devices is particularly useful for synchronisation problems. -1. Configure LiveSync with the settings shown in the attached report. -2. Click the sync button on the ribbon. -3. Synchronisation begins. -4. About two or three seconds later, the error `TypeError: Failed to fetch` appears. -5. Replication stops. No files synchronised. +### Full LiveSync report -### Obsidian debug info (Device 1 — Windows desktop) +Run `Generate full report for opening the issue with debug info` on the device where the problem appeared. For a synchronisation problem, also collect a report from another participating device when its settings or logs are relevant. The command copies the current LiveSync settings summary and up to 1,000 recent log lines. It collects verbose log lines even when `Verbose Log` is disabled, so you do not need to enable that setting before reproducing the problem. -``` -SYSTEM INFO: - Obsidian version: v1.2.8 - Installer version: v1.1.15 - Operating system: Windows 10 Pro 10.0.19044 - Login status: logged in - Catalyst license: supporter - Insider build toggle: off - Community theme: Minimal v6.1.11 - Snippets enabled: 3 - Restricted mode: off - Plugins installed: 35 - Plugins enabled: 11 - 1: Self-hosted LiveSync v0.19.4 - ... -``` +The command automatically redacts known credential fields in the settings summary. It cannot guarantee that private text in log messages or unrecognised configuration fields is removed. Review the complete output before sharing it. Remove or replace: -### Report from LiveSync +- usernames, passwords, passphrases, tokens, keys, and custom headers; +- private server URLs, network addresses, database names, bucket names, room identifiers, and relay details; +- Vault names, device names, and file paths; and +- file contents or other private text which appears in a log message. -``` -----remote config---- -cors: - credentials: "true" - ... ----- Plug-in config --- -couchDB_URI: self-hosted -couchDB_USER: 𝑅𝐸𝐷𝐴đļ𝑇𝐸𝐷 -... -``` +Document and chunk identifiers can also be private metadata, but they may be necessary for diagnosing file reconstruction and chunk availability. Decide deliberately whether to share them. If you remove them, state that the report was redacted and that this may limit the diagnosis. -### Plug-in log +For a large report, you may share a GitHub Gist after reviewing and redacting it. Deleting a Gist later cannot undo information which has already been disclosed. -``` -2023/5/24 10:50:33->HTTP:GET to:/ -> failed -2023/5/24 10:50:33->TypeError:Failed to fetch -2023/5/24 10:50:33->could not connect to https://example.com/ : your vault -(TypeError:Failed to fetch) -``` +## Additional evidence when relevant ---- +### A problem involving one file -## How to collect each piece of information +Run `Copy database information for the active file`, or use **Hatch** → **Copy database information for a file** to select another file. -### Obsidian debug info +This report describes only the local database on that device. It includes the Vault-relative path, document and chunk identifiers, local revisions, conflicts, and local chunk availability. It does not query the remote or include file contents. Review paths and identifiers as private metadata before sharing them. -Open the command palette (`Ctrl/Cmd + P`) and run **"Show debug info"**. Copy the output and paste it into the issue. +### A problem which crosses a restart -If multiple devices are involved in the problem (e.g., sync between a phone and a desktop), please provide the debug info for each device. The device where the issue occurred is required; information from other devices is strongly recommended. +Use `Write logs into the file` under **Hatch** only when the in-memory report cannot cover the restart. Persistent logging affects performance and can record private information. Disable it after reproducing the problem, review the log before sharing it, and remove the log file when it is no longer needed. -### Report from LiveSync (hatch report) +### A connection, authentication, or CORS problem -1. Open LiveSync settings. -2. Go to the **Hatch** pane. -3. Press the **Make report** button. +Include network evidence only when the ordinary LiveSync log cannot show the rejected response. Follow [Inspect a network failure](troubleshooting.md#inspect-a-network-failure), and remove request paths, remote addresses, authority and authorisation values, cookies, credentials, payload identifiers, and response secrets before sharing screenshots or copied data. -The report will be copied to your clipboard. It contains your LiveSync configuration and the remote server configuration, with credentials automatically redacted. +## Sharing the report -**Tip:** For large reports, consider uploading to [GitHub Gist](https://gist.github.com/) and sharing the link instead of pasting directly into the issue. This makes it easier to manage, and if you accidentally leave sensitive data in, a Gist can be deleted. +Paste reports into the matching collapsible sections in the issue template, or provide a link to an already-redacted Gist. A separate plug-in log is normally unnecessary because the full LiveSync report already contains the recent verbose log history. -If you paste directly, wrap it in a `
` tag to keep the issue readable: - -``` -
-Report from hatch - -``` -----remote config---- - : -``` -
-``` - -### Plug-in log - -The plug-in log is volatile by default (not saved to disk) and shown only in the log dialogue, which can be opened by tapping the **document box icon** in the ribbon. - -#### Enable verbose log - -Before reproducing the issue, enable **Verbose Log** in LiveSync's **General Settings** pane. Without this, many diagnostic messages will be suppressed. - -#### Persist the log to a file (optional) - -If you need to capture a log across a restart, enable **"Write logs into the file"** in General Settings. Note that log files may contain sensitive information — use this option only for troubleshooting, and disable it afterwards. - -As with the hatch report, consider uploading large logs to [GitHub Gist](https://gist.github.com/). - -### Network log (for connection-related issues only) - -If the issue is related to network connectivity (e.g., cannot connect to the server, authentication errors), a network log captured from browser DevTools can be very helpful. You do not need to include this for non-connection issues. - -#### Opening DevTools - -| Platform | Shortcut | -|----------|----------| -| Windows / Linux | `Ctrl + Shift + I` | -| macOS | `Cmd + Shift + I` | -| Android | Use [Chrome remote debugging](https://developer.chrome.com/docs/devtools/remote-debugging/) | -| iOS | Use [Safari Web Inspector](https://developer.apple.com/documentation/safari-developer-tools/inspecting-ios) on a Mac | - -#### What to capture - -1. Open the **Network** pane in DevTools. -2. Reproduce the issue. -3. Look for requests marked in red. -4. Capture screenshots of the **Headers**, **Payload**, and **Response** tabs for those requests. - -**Important — redact before sharing:** -- Headers: conceal the request URL path, Remote Address, `authority`, and `authorisation` values. -- Payload / Response: the `_id` field contains your file paths — redact if needed. +If a maintainer asks for a more specialised diagnostic, collect only that additional evidence and review it again before publishing it. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 36d11688..7db86119 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,452 +1,174 @@ -# Tips and Troubleshooting -- [Tips and Troubleshooting](#tips-and-troubleshooting) - - [Tips](#tips) - - [CORS avoidance](#cors-avoidance) - - [CORS configuration with reverse proxy](#cors-configuration-with-reverse-proxy) - - [Nginx](#nginx) - - [Nginx and subdirectory](#nginx-and-subdirectory) - - [Caddy](#caddy) - - [Caddy and subdirectory](#caddy-and-subdirectory) - - [Apache](#apache) - - [Show all setting panes](#show-all-setting-panes) - - [How to resolve `Tweaks Mismatched of Changed`](#how-to-resolve-tweaks-mismatched-of-changed) - - [Notable bugs and fixes](#notable-bugs-and-fixes) - - [Binary files get bigger on iOS](#binary-files-get-bigger-on-ios) - - [Some setting name has been changed](#some-setting-name-has-been-changed) - - [Questions and Answers](#questions-and-answers) - - [How should I share the settings between multiple devices?](#how-should-i-share-the-settings-between-multiple-devices) - - [What should I enter for the passphrase of Setup-URI?](#what-should-i-enter-for-the-passphrase-of-setup-uri) - - [Why the settings of Self-hosted LiveSync itself is disabled in default?](#why-the-settings-of-self-hosted-livesync-itself-is-disabled-in-default) - - [The plug-in says `something went wrong`.](#the-plug-in-says-something-went-wrong) - - [A large number of files were deleted, and were synchronised!](#a-large-number-of-files-were-deleted-and-were-synchronised) - - [Why `Use an old adapter for compatibility` is somehow enabled in my vault?](#why-use-an-old-adapter-for-compatibility-is-somehow-enabled-in-my-vault) - - [ZIP (or any extensions) files were not synchronised. Why?](#zip-or-any-extensions-files-were-not-synchronised-why) - - [I hope to report the issue, but you said you needs `Report`. How to make it?](#i-hope-to-report-the-issue-but-you-said-you-needs-report-how-to-make-it) - - [Where can I check the log?](#where-can-i-check-the-log) - - [Why are the logs volatile and ephemeral?](#why-are-the-logs-volatile-and-ephemeral) - - [Some network logs are not written into the file.](#some-network-logs-are-not-written-into-the-file) - - [If a file were deleted or trimmed, the capacity of the database should be reduced, right?](#if-a-file-were-deleted-or-trimmed-the-capacity-of-the-database-should-be-reduced-right) - - [How to launch the DevTools](#how-to-launch-the-devtools) - - [On Desktop Devices](#on-desktop-devices) - - [On Android](#on-android) - - [On iOS, iPadOS devices](#on-ios-ipados-devices) - - [How can I use the DevTools?](#how-can-i-use-the-devtools) - - [Checking the network log](#checking-the-network-log) - - [Troubleshooting](#troubleshooting) - - [While using Cloudflare Tunnels, often Obsidian API fallback and `524` error occurs.](#while-using-cloudflare-tunnels-often-obsidian-api-fallback-and-524-error-occurs) - - [On the mobile device, cannot synchronise on the local network!](#on-the-mobile-device-cannot-synchronise-on-the-local-network) - - [I think that something bad happening on the vault...](#i-think-that-something-bad-happening-on-the-vault) - - [Flag Files](#flag-files) - - [Old tips](#old-tips) +# Troubleshooting - - -## Tips - -### CORS avoidance - -If we are unable to configure CORS properly for any reason (for example, if we cannot configure non-administered network devices), we may choose to ignore CORS. -To use the Obsidian API (also known as the Non-Native API) to bypass CORS, we can enable the toggle ``Use Request API to avoid `inevitable` CORS problem``. - - - -### CORS configuration with reverse proxy - -- IMPORTANT: CouchDB handles CORS by itself. Do not process CORS on the reverse - proxy. - - Do not process `Option` requests on the reverse proxy! - - Make sure `host` and `X-Forwarded-For` headers are forwarded to the CouchDB. - - If you are using a subdirectory, make sure to handle it properly. More - detailed information is in the - [CouchDB documentation](https://docs.couchdb.org/en/stable/best-practices/reverse-proxies.html). - -Minimal configurations are as follows: - -#### Nginx - -```nginx -location / { - proxy_pass http://localhost:5984; - proxy_redirect off; - proxy_buffering off; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -} -``` - -#### Nginx and subdirectory - -```nginx -location /couchdb { - rewrite ^ $request_uri; - rewrite ^/couchdb/(.*) /$1 break; - proxy_pass http://localhost:5984$uri; - proxy_redirect off; - proxy_buffering off; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -} - -location /_session { - proxy_pass http://localhost:5984/_session; - proxy_redirect off; - proxy_buffering off; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -} -``` - -#### Caddy - -```caddyfile -domain.com { - reverse_proxy localhost:5984 -} -``` - -#### Caddy and subdirectory - -```caddyfile -domain.com { - reverse_proxy /couchdb/* localhost:5984 - reverse_proxy /_session/* localhost:5984/_session -} -``` - -#### Apache - -Sorry, Apache is not recommended for CouchDB. Omit the configuration from here. -Please refer to the -[Official documentation](https://docs.couchdb.org/en/stable/best-practices/reverse-proxies.html#reverse-proxying-with-apache-http-server). - -### Show all setting panes - -Full pane is not shown by default. To show all panes, please toggle all in -`đŸ§™â€â™‚ī¸ Wizard` -> `Enable extra and advanced features`. - -For your information, the all panes are as follows: -![All Panes](all_toggles.png) - -### How to resolve `Tweaks Mismatched of Changed` - -(Since v0.23.17) - -If you have changed some configurations or tweaks which should be unified -between the devices, you will be asked how to reflect (or not) other devices at -the next synchronisation. It also occurs on the device itself, where changes are -made, to prevent unexpected configuration changes from unwanted propagation.\ -(We may thank this behaviour if we have synchronised or backed up and restored -Self-hosted LiveSync. At least, for me so). - -Following dialogue will be shown: ![Dialogue](tweak_mismatch_dialogue.png) - -- If we want to propagate the setting of the device, we should choose - `Update with mine`. -- On other devices, we should choose `Use configured` to accept and use the - configured configuration. -- `Dismiss` can postpone a decision. However, we cannot synchronise until we - have decided. - -Rest assured that in most cases we can choose `Use configured`. (Unless you are -certain that you have not changed the configuration). - -If we see it for the first time, it reflects the settings of the device that has -been synchronised with the remote for the first time since the upgrade. -Probably, we can accept that. - - - -## Notable bugs and fixes - -### Binary files get bigger on iOS - -- Reported at: v0.20.x -- Fixed at: v0.21.2 (Fixed but not reviewed) -- Required action: larger files will not be fixed automatically, please perform - `Verify and repair all files`. If our local database and storage are not - matched, we will be asked to apply which one. - -### Some setting name has been changed - -- Fixed at: v0.22.6 - -| Previous name | New name | -| ---------------------------- | ---------------------------------------- | -| Open setup URI | Use the copied setup URI | -| Copy setup URI | Copy current settings as a new setup URI | -| Setup Wizard | Minimal Setup | -| Check database configuration | Check and Fix database configuration | - -## Questions and Answers - -### How should I share the settings between multiple devices? - -- Device setup: - - Using `Setup URI` is the most straightforward way. -- Setting changes during use: - - Use `Sync settings via Markdown files` on the `đŸ”„ī¸ Sync settings` pane. - -### What should I enter for the passphrase of Setup-URI? - -- Anything you like is OK. However, the recommendation is as follows: - - Include the vault (group) information. - - Include the date of operation. - - Anything random for your security. - - For example, `MyVault-20240901-r4nd0mStr1ng`. -- Why? - - The Setup-URI is encoded; that means it cannot indicate the actual settings. Hence, if you use the same passphrase for multiple vaults, you may accidentally mix up vaults. - -### Why the settings of Self-hosted LiveSync itself is disabled in default? - -Basically, if we configure all `additionalSuffixOfDatabaseName` the same, we can synchronise this file between multiple devices. -(`additionalSuffixOfDatabaseName` should be unique in each device, not in the synchronised vaults). -However, if we synchronise the settings of Self-hosted LiveSync itself, we may encounter some unexpected behaviours. -For example, if a setting that 'let Self-hosted LiveSync setting be excluded' is synced, it is very unlikely that things will recover automatically after this, and there is little chance we will even notice this. Even if we change our minds and change the settings back on other devices. It could get even worse if incompatible changes are automatically reflected; everything will break. - -### The plug-in says `something went wrong`. - -There are many cases where this is really unclear. One possibility is that the chunk fetch did not go well. - -1. Restarting Obsidian sometimes helps (fetch-order problem). -2. If actually there are no chunks, please perform `Recreate missing chunks for all files` on the `🧰 Hatch` pane at the other devices. And synchronise again. (also restart Obsidian may effect). -3. If the problem persists, please perform `Verify and repair all files` on the `🧰 Hatch` pane. If our local database and storage are not matched, we will be asked to apply which one. - -### A large number of files were deleted, and were synchronised! - -1. Backup everything important. - - Your local vault. - - Your CouchDB database (this can be done by replicating to another database). -2. Prepare the empty vault -3. Place `redflag.md` at the top of the vault. -4. Apply the settings **BUT DO NOT PROCEED TO RESTORE YET**. - - You can use `Setup URI`, QR Code, or manually apply the settings. -5. Set `Maximum file modification time for reflected file events` in `Remediation` on the `🩹 Patches` pane. - - If you know when the files were deleted, set the time a bit before that. - - If not, bisecting may help us. -6. Delete `redflag.md`. -7. Perform `Reset Synchronisation on This Device` on the `đŸŽ›ī¸ Maintenance` pane. - -This mode is very fragile. Please be careful. - -### Why `Use an old adapter for compatibility` is somehow enabled in my vault? - -Because you are a compassionate and experienced user. Before v0.17.16, we used -an old adapter for the local database. At that time, current default adapter has -not been stable. The new adapter has better performance and has a new feature -like purging. Therefore, we should use new adapters and current default is so. - -However, when switching from an old adapter to a new adapter, some converting or -local database rebuilding is required, and it takes some time. It was a long -time ago now, but we once inconvenienced everyone in a hurry when we changed the -format of our database. For these reasons, this toggle is automatically on if we -have upgraded from vault which using an old adapter. - -When you overwrite server data with this device's files or reset synchronisation on this device again, you will be asked to -switch this. - -Therefore, experienced users (especially those stable enough not to have to -overwrite server data) may have this toggle enabled in their Vault. Please -disable it when you have enough time. - -### ZIP (or any extensions) files were not synchronised. Why? - -It depends on Obsidian detects. May toggling `Detect all extensions` of -`File and links` (setting of Obsidian) will help us. - -### I hope to report the issue, but you said you needs `Report`. How to make it? - -We can copy the report to the clipboard, by performing -`Generate full report for opening the issue with debug info` command! - -### Where can I check the log? - -We can launch the log pane by `Show log` on the command palette. And if you have -troubled something, please enable the `Verbose Log` on the `General Setting` -pane. -`Generate full report for opening the issue with debug info` command also contains -the recent 1000 log lines, which is very helpful for debugging. Full-report is -already set to the verbose level, so it contains all the logs without enabling the -`Verbose Log` toggle. - -Let me note that please be sure to remove any sensitive information before sharing the report. - -However, the logs would not be kept so long and cleared when restarted. If you -want to check the logs, please enable `Write logs into the file` temporarily. - -![ScreenShot](../images/write_logs_into_the_file.png) +Start with the symptom which is visible now. Do not reset a database, change transport, or enable P2P merely to see whether the problem disappears. > [!IMPORTANT] -> -> - Writing logs into the file will impact the performance. -> - Please make sure that you have erased all your confidential information -> before reporting issue. +> If Obsidian will not start, do not give up. Close it, create `redflag.md` at the Vault root with the operating system's file manager, then follow [Recovery and flag files](recovery.md). This is the supported route for intervening before ordinary LiveSync start-up work. -### Why are the logs volatile and ephemeral? +Before changing settings: -To avoid unexpected exposure to our confidential things. +1. Back up the affected Vaults and, where possible, the remote database or bucket. +2. Stop editing on other devices. +3. Confirm that every participating device uses the intended plug-in version. +4. Identify whether the active main remote is CouchDB, Object Storage, or P2P. +5. Open `Show log` and note the first error, rather than only the final summary. -### Some network logs are not written into the file. +For a report, run `Generate full report for opening the issue with debug info`, remove credentials and private server details, and include the steps which caused the symptom. -Especially the CORS error will be reported as a general error to the plug-in for -security reasons. So we cannot detect and log it. We are only able to -investigate them by [Checking the network log](#checking-the-network-log). +## CouchDB does not connect -### If a file were deleted or trimmed, the capacity of the database should be reduced, right? +Check the connection in this order: -No, even though if files were deleted, chunks were not deleted. Self-hosted -LiveSync splits the files into multiple chunks and transfers only newly created. -This behaviour enables us to less traffic. And, the chunks will be shared -between the files to reduce the total usage of the database. +1. Confirm that the URL is complete and points to the intended server. +2. On mobile, use HTTPS with a certificate trusted by the operating system. Plain HTTP and self-signed certificates are not supported. +3. Confirm the username, password, database name, and any custom headers. +4. Confirm that the server responds outside the plug-in and that the database exists on additional devices. +5. Use the setup dialogue's connection test. +6. If basic access works, run **Check server requirements**. Its initial check is read-only. Each offered server change requires separate confirmation. -And one more thing, we can handle the conflicts on any device even though it has -happened on other devices. This means that conflicts will happen in the past, -after the time we have synchronised. Hence we cannot collect and delete the -unused chunks even though if we are not currently referenced. +Configure CouchDB CORS first. Reverse-proxy examples belong in [Set up your own CouchDB server](setup_own_server.md), alongside the rest of the server configuration. -To shrink the database size, `Overwrite Server Data with This Device's Files` is the only reliable and effective way. -But do not worry, if we have synchronised well. We have the actual and real -files. Only it takes a bit of time and traffic. +`Use Internal API` is a compatibility workaround for a trusted server. It sends the configured credentials through Obsidian's internal request API. Enable it only after checking the destination, and do not treat a fallback through that API as proof that the server or proxy is correctly configured. -### How to launch the DevTools +A Cloudflare `524` response means that Cloudflare timed out while waiting for the origin. The response may also lack the CORS headers which would have been present on an ordinary CouchDB response. Correct the long-running server or proxy request first. The advanced CouchDB option `Use timeouts instead of heartbeats` may help only when the underlying operation is otherwise healthy. -#### On Desktop Devices +For JWT-specific setup and key-format errors, see [JWT Authentication on CouchDB](tips/jwt-on-couchdb.md). -We can launch the DevTools by pressing `ctrl`+`shift`+`i` (`Command`+`shift`+`i` on Mac). +## CouchDB was working but synchronisation stopped -#### On Android +Do not switch to P2P or reset the database as the first response. Check: -Please refer to [Remote debug Android devices](https://developer.chrome.com/docs/devtools/remote-debugging/). -Once the DevTools have been launched, everything operates the same as on a PC. +1. the active remote profile and connection state; +2. the plug-in version on every device; +3. the CouchDB response and server logs; +4. pending LiveSync progress indicators; +5. `Check server requirements`; and +6. the LiveSync log and full report. -#### On iOS, iPadOS devices +If the remote is healthy but one device's local database is not, use [Reset Synchronisation on This Device](recovery.md#reset-synchronisation-on-this-device) only after backing up unsynchronised local files. -If we have a Mac, we can inspect from Safari on the Mac. Please refer to [Inspecting iOS and iPadOS](https://developer.apple.com/documentation/safari-developer-tools/inspecting-ios). +## Files are missing or excluded -### How can I use the DevTools? +Check Obsidian's `Detect all file extensions`, LiveSync selectors, ignore files, file-size limits, modification-time limits, and Hidden File Sync rules. A filtered file is different from a file which reached the database but could not be reconstructed from its chunks. -#### Checking the network log +If the log reports missing chunks or a size mismatch: + +1. stop editing the affected file and keep a separate copy of any readable content; +2. restart Obsidian once to rule out an interrupted fetch; +3. synchronise a device or restore a backup which still has the correct content; +4. on that healthy device, run `Recreate chunks for current Vault files`, then synchronise; +5. follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file); run `Inspect conflicts and file/database differences` from `Hatch`, then use each revision's wrench menu to review and act on that exact branch; and +6. use `Discard this branch` only after confirming that the exact live branch is no longer wanted. Use the separate `Discard unreadable revision` recovery action only when an unreadable revision is the sole live leaf. + +The repair card uses compact diagnostic rows which remain readable in a narrow mobile settings pane. `🧩 Missing chunks: N` marks an unreadable revision. In the database row, `Δsize` means decoded size minus recorded size; `Δsize vs DB` means Vault size minus decoded database size; and `Δtime` means Vault modification time minus database modification time. These are diagnostic values, not a rule for deciding which revision is correct. `✅ Vault matches winner ¡ âš ī¸ Conflicts: N` means that the current Vault bytes agree with the database winner while other live branches still need a decision. Every mutating action rechecks that its selected revision is still live. Applying a logical deletion to an existing Vault file requires confirmation; a logical-deletion winner with no Vault file already agrees and is omitted. + +`Retry reading revision` does not change the revision tree. `Discard this branch` creates a logical deletion on one exact live revision while another live branch remains and leaves the current Vault file unchanged. If the discarded revision was recorded as the Vault's exact source, that stale device-local provenance is removed. `Discard unreadable revision` provides the corresponding explicit escape hatch for a sole unreadable live leaf. Neither action purges history or reconstructs missing content. An unavailable non-live ancestor cannot be deleted through this workflow; it disables conservative three-way merge but does not prevent explicit selection between readable live revisions. + +`Recreate chunks for current Vault files` uses current Vault content. It cannot recreate unique bytes which exist only in an unreadable historical or conflict revision. + +## A configuration mismatch dialogue blocks synchronisation + +Some settings must match across devices. LiveSync pauses synchronisation when the local and remote values differ rather than propagating an unexpected change silently. + +Current releases automatically align compatible settings which control how new chunks are created, by default and where possible. This applies to the chunk hash algorithm, chunk size, and splitter version. Existing content remains readable across these choices, although using different choices can reduce chunk reuse and increase storage or transfer work. An explicit opt-out retains the manual review. A mismatch involving encryption, path obfuscation, file-name case handling, or any combination which includes one of those settings always remains a manual decision. + +The available actions depend on when the mismatch is found: + +- While checking a remote profile, `Use configured settings` accepts the shared values already stored in that remote. `Dismiss` leaves this device's settings unchanged. +- For a mismatch found before synchronisation, `Apply settings to this device` accepts the remote values. Choose `Update remote database settings` only when this device's values are intended to become the shared values. +- When the change requires local or remote reconstruction, the action itself states that Fetch or Rebuild will follow. Make sure that the intended authoritative copy is available before choosing it. +- `Dismiss` postpones a mismatch found before synchronisation. Synchronisation remains paused until the mismatch is resolved. + +![Configuration mismatch dialogue](tweak_mismatch_dialogue.png) + +Historic defect notices and renamed controls are retained in the [0.25 release history](releases/0.25.md) and [legacy release history](releases/legacy.md), rather than in the current troubleshooting path. + +## Setup and settings questions + +### Share a configuration with another device + +Generate an encrypted Setup URI from a working device. This preserves the intended remote profiles and selections while allowing the additional device to keep its own device-specific name. Store the URI and its passphrase separately. + +For deliberate setting changes during normal use, use `Sync Settings via Markdown` under `Sync settings`. + +### Choose a Setup URI passphrase + +Use a strong passphrase which is distinct from the Vault encryption passphrase. Record enough context outside the encrypted URI to identify the intended Vault and date, but do not rely on a reused human-readable pattern alone. + +### Why synchronising LiveSync's own settings is disabled by default + +An automatically propagated transport, database, or exclusion setting can disable the mechanism needed to reverse it. LiveSync therefore keeps its own settings out of Customisation Sync by default. Enable that advanced behaviour only with an independent recovery path and device-specific database suffixes. + +### The plug-in reports that something went wrong + +Use the first specific error in `Show log` to choose the relevant section. When it names chunks or a size mismatch, follow [Files are missing or excluded](#files-are-missing-or-excluded). Do not rebuild solely from the generic final message. + +### A large deletion propagated + +Stop every device, preserve the available copies, and follow [Recovery and flag files](recovery.md). If the deletion time is known, `Maximum file modification time for reflected file events` under `Remediation` can limit which remote events are applied while recovering into a separate, backed-up Vault. Treat that as a forensic recovery constraint, not as an ordinary synchronisation setting. + +### An old database adapter is still selected + +Very old Vaults may retain the compatibility adapter until a deliberate local database migration or reset. Do not toggle it merely to troubleshoot an unrelated current failure. The history and migration notes are in the [legacy release history](releases/legacy.md). + +### ZIP or another extension is not synchronised + +Enable Obsidian's `Detect all file extensions`, then check LiveSync selectors, ignore rules, and size limits as described in [Files are missing or excluded](#files-are-missing-or-excluded). + +## Collect a report + +Follow [How to report an issue](to_issue_reporting.md) for the complete reporting checklist, including Obsidian debug information and the privacy review required before sharing evidence. + +Run `Generate full report for opening the issue with debug info` to copy the current settings summary and recent verbose log lines. Remove credentials, remote URLs, Vault names, file contents, and other private information before sharing it. + +When a problem concerns one file, run **Copy database information for the active file**, or use **Hatch** → **Copy database information for a file** to select another file. The report describes this device's local database view, including the Vault-relative path, document and chunk identifiers, local database revisions, conflicts, and local chunk availability. It does not query the remote server or include file contents. Treat paths and identifiers as private metadata before sharing. + +Use `Show log` for live inspection. Logs are intentionally kept in memory for a limited time to reduce accidental disclosure. Enable `Write logs into the file` only while reproducing a problem, then disable it and remove the file after review because persistent logging affects performance and may contain private data. + +![Write logs into the file](../images/write_logs_into_the_file.png) + +Browser security errors, particularly CORS failures, may reach the plug-in only as a general network error. Use the network inspector when the ordinary log cannot show the rejected response. + +## The database remains large after files are deleted + +LiveSync stores file metadata, chunks, revision history, conflicts, deletions, and tombstones. Deleting or shortening a file therefore does not immediately remove every object which once represented it. + +Garbage Collection V3 can remove unreferenced chunks from a healthy CouchDB setup, but it is appropriate only when the Vault and local database are healthy and all relevant devices have synchronised. Current files and live conflict branches protect their required chunks; an ordinary superseded revision does not. Tombstones and retained metadata are not free, so Garbage Collection does not guarantee a minimal database. Review the [Garbage Collection V3 specification](specs_garbage_collection.md) before using it. + +`Overwrite Server Data with This Device's Files` is a separate rebuild operation and is the more certain way to reconstruct a central remote from a chosen authoritative Vault. It is also destructive and may discard changes which exist only on another device. Review [Recovery and flag files](recovery.md#garbage-collection-is-not-rebuild) before choosing between them. + +## Inspect a network failure + +### Desktop + +Open Developer Tools with `Ctrl`+`Shift`+`I`, or `Command`+`Option`+`I` on macOS. + +### Android + +Follow Chrome's [Remote debug Android devices](https://developer.chrome.com/docs/devtools/remote-debugging/) guide. + +### iOS and iPadOS + +Use Safari on a Mac and follow Apple's [Inspecting iOS and iPadOS](https://developer.apple.com/documentation/safari-developer-tools/inspecting-ios) guide. + +### Network evidence 1. Open the network pane. -2. Find the requests marked in red.\ +2. Reproduce the failure and select the request marked in red. ![Errored](../images/devtools1.png) -3. Capture the `Headers`, `Payload`, and, `Response`. **Please be sure to keep - important information confidential**. If the `Response` contains secrets, you - can omitted that. Note: Headers contains a some credentials. **The path of - the request URL, Remote Address, authority, and authorization must be - concealed.**\ +3. Record the status, timing, and a sanitised version of the headers, payload, and response. +4. Remove the request path, remote address, authority, authorisation, cookies, credentials, and response secrets before sharing. + ![Concealed sample](../images/devtools2.png) -## Troubleshooting +## P2P does not connect or transfer changes - +Use [Peer-to-Peer Synchronisation Tips](tips/p2p-sync-tips.md). Check signalling discovery separately from the WebRTC data path, and confirm which devices announce and follow changes. P2P is not a repair step for another transport. -### While using Cloudflare Tunnels, often Obsidian API fallback and `524` error occurs. +## Obsidian or LiveSync remains suspended -A `524` error occurs when the request to the server is not completed within a -`specified time`. This is a timeout error from Cloudflare. From the reported -issue, it seems to be 100 seconds. (#627). +Follow [Recovery and flag files](recovery.md). A `redflag.md` emergency stop remains active until it is removed outside Obsidian. Fetch and rebuild flags have different, potentially destructive meanings; do not create them merely to clear a warning. -Therefore, this error returns from Cloudflare, not from the server. Hence, the -result contains no CORS field. It means that this response makes the Obsidian -API fallback. +## Further technical context -However, even if the Obsidian API fallback occurs, the request is still not -completed within the `specified time`, 100 seconds. - -To solve this issue, we need to configure the timeout settings. - -Please enable the toggle in `đŸ’Ē Power users` -> `CouchDB Connection Tweak` -> -`Use timeouts instead of heartbeats`. - -### On the mobile device, cannot synchronise on the local network! - -Obsidian mobile is not able to connect to the non-secure end-point, such as -starting with `http://`. Make sure your URI of CouchDB. Also not able to use a -self-signed certificate. - -### I think that something bad happening on the vault... - -Place the [flag file](#flag-files) on top of the vault, and restart Obsidian. The most simple -way is to create a new note and rename it to `redflag`. Of course, we can put it -without Obsidian. - -For example, if there is `redflag.md`, Self-hosted LiveSync suspends all database and storage -processes. - -### Scram State and Flag Files (SCRAM Warning Loop) - -The plug-in uses a **Scram state** (emergency suspension of all synchronisation processes) to prevent database corruption when severe errors or conflicts are detected. This state is often triggered or persisted by **flag files** placed at the root of the vault. - -If you encounter a warning saying **"Scram detected, all sync operations are suspended per SCRAM"** or get caught in an infinite loop where the warning persists even after clicking "Resume", it is likely due to a flag file in your vault. - -#### Flag Files - -A flag file is a simple Markdown file located at the root of your vault. Its very existence is significant; it may be left blank or contain any text. These files are used so they can be easily placed or deleted from outside Obsidian (e.g., when Obsidian fails to launch). - -| Filename | Human-Friendly Name | Description | -| ------------- | ------------------- | --------------------------------------------------------------------------------------- | -| `redflag.md` | - | Suspends all processes (activates Scram). | -| `redflag2.md` | `flag_rebuild.md` | Suspends all processes, and overwrites server data with this device's files. | -| `redflag3.md` | `flag_fetch.md` | Suspends all processes, discards the local database, and resets synchronisation on this device. | - -When resetting synchronisation on this device or overwriting server data, restarting Obsidian is performed once for safety reasons. At that time, Self-hosted LiveSync uses these files to determine whether the process should be carried out. (This mechanism is especially useful on mobile devices to force cancellation if the database rebuilding fails). These files are not subject to synchronisation. - -#### How to Resolve the Scram Loop - -If you cannot disable Scram, please follow these steps: -1. Close Obsidian completely. -2. Open your system's file manager and check the root directory of your vault. -3. Locate and delete any flag files (such as `redflag.md`, `redflag2.md`, or `redflag3.md`). -4. Launch Obsidian. -5. Go to the settings dialogue -> **Hatch** -> **Scram Switches**, and manually toggle **Suspend file watching** and **Suspend database reflecting** to `false` (disabled) if they have not been reset automatically. - -> [!TIP] -> This is the reason why flag files are standard `.md` files: it allows you to manage them externally. On mobile devices, you can use system file manager applications (such as the native **Files** app on iOS/iPadOS or **Files by Google** on Android) to find and delete these files to resolve a lock, or conversely, create/place a new `redflag.md` file (or directory) at the root of your vault to force-suspend synchronisation and stop Obsidian's boot-up sequence if you need to fix a database issue. - -### JWT Authentication Errors - -#### DataError when configuring JWT authentication - -If you encounter a `DataError:` with no additional information in the logs when configuring JWT authentication, this usually indicates a private key formatting issue. - -Self-hosted LiveSync requires the private key (for ES256/ES512 algorithms) to be in the **PKCS#8 PEM** format. Standard SEC1 EC private keys (which begin with `-----BEGIN EC PRIVATE KEY-----`) will trigger this error. - -To resolve this, convert your private key to PKCS#8 format using the following `openssl` command: -```bash -openssl pkcs8 -topk8 -inform PEM -nocrypt -in private.key -out pkcs8.key -``` -Then paste the contents of `pkcs8.key` (which begins with `-----BEGIN PRIVATE KEY-----`) into the JWT Key field. - -### Old tips - -- Rarely, a file in the database could be corrupted. The plug-in will not write - to local storage when a file looks corrupted. If a local version of the file - is on your device, the corruption could be fixed by editing the local file and - synchronising it. But if the file does not exist on any of your devices, then - it can not be rescued. In this case, you can delete these items from the - settings dialogue. -- To stop the boot-up sequence (eg. for fixing problems on databases), you can - put a `redflag.md` file (or directory) at the root of your vault. Tip for iOS: - a redflag directory can be created at the root of the vault using the File - application. -- Also, with `redflag2.md` placed, we can automatically overwrite server data with this device's files during the boot-up sequence. With `redflag3.md`, we - can discard only the local database and reset synchronisation on this device. -- Q: The database is growing, how can I shrink it down? A: each of the docs is - saved with their past 100 revisions for detecting and resolving conflicts. - Picturing that one device has been offline for a while, and comes online - again. The device has to compare its notes with the remotely saved ones. If - there exists a historic revision in which the note used to be identical, it - could be updated safely (like git fast-forward). Even if that is not in - revision histories, we only have to check the differences after the revision - that both devices commonly have. This is like git's conflict-resolving method. - So, We have to make the database again like an enlarged git repo if you want - to solve the root of the problem. -- And more technical Information is in the [Technical Information](tech_info.md) -- If you want to synchronise files without obsidian, you can use - [filesystem-livesync](https://github.com/vrtmrz/filesystem-livesync). -- WebClipper is also available on Chrome Web - Store:[obsidian-livesync-webclip](https://chrome.google.com/webstore/detail/obsidian-livesync-webclip/jfpaflmpckblieefkegjncjoceapakdf) - -Repo is here: -[obsidian-livesync-webclip](https://github.com/vrtmrz/obsidian-livesync-webclip). -(Docs are a work in progress.) +See [Technical Information](tech_info.md) for database and synchronisation internals. Current behaviour belongs in this guide; instructions for older defects remain in the release histories. diff --git a/docs/tweak_mismatch_dialogue.png b/docs/tweak_mismatch_dialogue.png index 073a999b..d25566ca 100644 Binary files a/docs/tweak_mismatch_dialogue.png and b/docs/tweak_mismatch_dialogue.png differ diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 23b8fe9b..e2e16e8e 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -73,8 +73,12 @@ const moduleAliasPlugin = { const removePragmaCommentsPlugin = { name: "remove-pragma-comments", setup(build) { + const sourceRoot = `${path.resolve("src")}${path.sep}`; // Filter target extensions (e.g., JavaScript and TypeScript) build.onLoad({ filter: /\.[jt]s?$/ }, async (args) => { + // Dependencies are already compiled and may contain comment-like text inside strings. + // Only maintained plug-in source needs its local suppression directives removed. + if (!args.path.startsWith(sourceRoot)) return; const source = await fs.promises.readFile(args.path, "utf8"); // Regex targeting both single-line and multi-line comments diff --git a/eslint.community.config.mjs b/eslint.community.config.mjs new file mode 100644 index 00000000..4944a5f8 --- /dev/null +++ b/eslint.community.config.mjs @@ -0,0 +1,79 @@ +import obsidianmd from "eslint-plugin-obsidianmd"; +import globals from "globals"; +import { defineConfig, globalIgnores } from "eslint/config"; + +export default defineConfig( + globalIgnores([ + "node_modules", + "**/dist", + "build", + "coverage", + "main.js", + "main_org.js", + "pouchdb-browser.js", + "package.json", + "package-lock.json", + "versions.json", + // Svelte is covered by the project lint and svelte-check; the directory report currently analyses TypeScript. + "**/*.svelte", + "**/svelte.config.js", + "**/*.unit.spec.ts", + "**/test/**", + "src/apps/_test/**", + "src/apps/cli/testdeno/**", + ]), + { + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + MANIFEST_VERSION: "readonly", + PACKAGE_VERSION: "readonly", + UPDATE_INFO: "readonly", + hostPlatform: "readonly", + }, + parserOptions: { + project: [ + "./tsconfig.json", + "./src/apps/browser/tsconfig.json", + "./src/apps/cli/tsconfig.json", + "./src/apps/webapp/tsconfig.json", + "./src/apps/webpeer/tsconfig.app.json", + "./src/apps/webpeer/tsconfig.node.json", + ], + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + ...obsidianmd.configs.recommended, + { + rules: { + // The directory review reports console usage as guidance rather than a release blocker. + "obsidianmd/rule-custom-message": "off", + "no-console": "warn", + "obsidianmd/no-unsupported-api": "error", + // Keep legacy type-safety debt visible while reserving errors for directory-review blockers. + "@typescript-eslint/no-unsafe-argument": "warn", + "@typescript-eslint/no-unsafe-assignment": "warn", + "@typescript-eslint/no-unsafe-call": "warn", + "@typescript-eslint/no-unsafe-member-access": "warn", + "@typescript-eslint/no-unsafe-return": "warn", + "@typescript-eslint/no-base-to-string": "warn", + "@typescript-eslint/no-redundant-type-constituents": "warn", + "@typescript-eslint/no-unnecessary-type-assertion": "warn", + }, + }, + { + files: ["src/apps/**/*.{ts,js,mjs}"], + rules: { + // These applications are inspected by the directory review but accept external command and file data. + "@typescript-eslint/restrict-template-expressions": "warn", + }, + }, + { + files: ["src/apps/browser/**/*.ts", "src/apps/webapp/**/*.ts"], + rules: { + "obsidianmd/prefer-active-doc": "off", + }, + } +); diff --git a/eslint.config.common.mjs b/eslint.config.common.mjs index e9e36021..bdecfb05 100644 --- a/eslint.config.common.mjs +++ b/eslint.config.common.mjs @@ -91,8 +91,8 @@ export const baseRules = { */ export const obsidianRules = { // -- 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, + // Runtime fallbacks must use requireApiVersion guards recognised by the official rule. + "obsidianmd/no-unsupported-api": "error", // -- Plugin specific overrides "obsidianmd/rule-custom-message": "off", @@ -112,7 +112,6 @@ export const ImportAliasRules = (base) => ({ aliasForSubpaths: true, alias: { "@": `${base}/src`, - "@lib": `${base}/src/lib/src`, }, }, ], diff --git a/eslint.config.mjs b/eslint.config.mjs index 69384d2e..b436c9e3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,10 +7,19 @@ import svelteParser from "svelte-eslint-parser"; import importAlias from "@dword-design/eslint-plugin-import-alias"; import { baseRules, ImportAliasRules, obsidianRules } from "./eslint.config.common.mjs"; const warnWhileDev = "off"; // Change to "warn" to enable warnings for rules that are currently disabled. +const lintProjects = [ + "./tsconfig.json", + "./src/apps/browser/tsconfig.json", + "./src/apps/cli/tsconfig.json", + "./src/apps/webapp/tsconfig.json", + "./src/apps/webpeer/tsconfig.app.json", + "./src/apps/webpeer/tsconfig.node.json", +]; export default defineConfig([ globalIgnores([ // Build outputs and legacy files "**/build", + "**/dist/**", "coverage", "**/main.js", "main_org.js", @@ -22,21 +31,9 @@ export default defineConfig([ // 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", + // Sub-project tooling with its own environment "utils", - // Specific exclusions from common library (src/lib) - "src/lib/coverage", - "src/lib/browsertest", - "src/lib/test", - "src/lib/_tools", - "src/lib/src/patches/pouchdb-utils", - "src/lib/src/cli", - "src/lib/src/services/implements/browser/**", - "src/lib/src/services/implements/headless/**", - "src/lib/src/API", - // Config files and build scripts "**/jest.config.js", "**/rollup.config.js", @@ -49,6 +46,9 @@ export default defineConfig([ "vitest.*", // Testing files (Simplified patterns) "test/**", + "**/test/**", + "src/apps/_test/**", + "src/apps/cli/testdeno/**", "**/*.test.ts", "**/*.unit.spec.ts", "**/test.ts", @@ -59,13 +59,12 @@ export default defineConfig([ importAlias.configs.recommended, { files: ["**/*.ts"], - // ignores:["src/lib/**/*.ts"], // Exclude library files from root linting (they have different environments and rules). languageOptions: { globals: { ...globals.browser, PouchDB: "readonly" }, parser: tsParser, parserOptions: { - project: "./tsconfig.json", - rootDir: "./", + project: lintProjects, + tsconfigRootDir: import.meta.dirname, }, }, linterOptions: { @@ -85,6 +84,8 @@ export default defineConfig([ parser: svelteParser, parserOptions: { parser: tsParser, + project: lintProjects, + tsconfigRootDir: import.meta.dirname, extraFileExtensions: [".svelte"], }, }, @@ -98,4 +99,23 @@ export default defineConfig([ ...ImportAliasRules("."), }, }, + { + files: ["src/apps/**/*.ts"], + rules: { + // Platform adapters implement asynchronous contracts even when a local operation is synchronous. + "@typescript-eslint/require-await": "off", + // Keep existing application code visible without making gradual type tightening a release blocker. + "@typescript-eslint/no-base-to-string": "warn", + "@typescript-eslint/no-unnecessary-type-assertion": "warn", + "@typescript-eslint/restrict-template-expressions": "warn", + }, + }, + { + files: ["src/apps/browser/**/*.{ts,svelte}", "src/apps/webapp/**/*.ts"], + rules: { + // Browser applications use the DOM rather than Obsidian's DOM extensions. + "obsidianmd/prefer-create-el": "off", + "obsidianmd/prefer-active-doc": "off", + }, + }, ]); diff --git a/generate-types.mjs b/generate-types.mjs deleted file mode 100644 index 868e6fae..00000000 --- a/generate-types.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import { execSync } from "node:child_process"; -import fs from "node:fs"; -try { - fs.rmSync("./_types", { recursive: true, force: true }); - fs.mkdirSync("./_types"); - console.log("[Postbuild] Generating type definitions for fallback..."); - execSync("npx tsc -p tsconfig.types.json", { stdio: "inherit" }); - - console.log("[Postbuild] Type definitions generated successfully."); -} catch (error) { - // Ignore compiler errors from tsc so that pre-existing type errors in the submodule - // do not block the build from succeeding. - console.warn("[Postbuild] Type definitions generated with some compilation warnings."); - // process.exit(-1); -} - -try { - console.log("[Postbuild] Type definitions generated successfully. Adding ignore comments..."); - execSync("deno run -A ./utilsdeno/types-add-ignore.ts", { stdio: "inherit" }); - console.log("[Postbuild] Ignore comments added successfully."); -} catch (error) { - console.warn("[Postbuild] Failed to add ignore comments to type definitions."); - process.exit(-1); -} diff --git a/images/couchdb-manual/guide-couchdb-manual-connection-details.png b/images/couchdb-manual/guide-couchdb-manual-connection-details.png new file mode 100644 index 00000000..27a3e54e Binary files /dev/null and b/images/couchdb-manual/guide-couchdb-manual-connection-details.png differ diff --git a/images/couchdb-manual/guide-couchdb-manual-connection-method.png b/images/couchdb-manual/guide-couchdb-manual-connection-method.png new file mode 100644 index 00000000..259ca162 Binary files /dev/null and b/images/couchdb-manual/guide-couchdb-manual-connection-method.png differ diff --git a/images/couchdb-manual/guide-couchdb-manual-encryption.png b/images/couchdb-manual/guide-couchdb-manual-encryption.png new file mode 100644 index 00000000..fa0aa27d Binary files /dev/null and b/images/couchdb-manual/guide-couchdb-manual-encryption.png differ diff --git a/images/couchdb-manual/guide-couchdb-manual-remote-selection.png b/images/couchdb-manual/guide-couchdb-manual-remote-selection.png new file mode 100644 index 00000000..21c02829 Binary files /dev/null and b/images/couchdb-manual/guide-couchdb-manual-remote-selection.png differ diff --git a/images/couchdb-manual/guide-couchdb-manual-server-requirements.png b/images/couchdb-manual/guide-couchdb-manual-server-requirements.png new file mode 100644 index 00000000..a08541aa Binary files /dev/null and b/images/couchdb-manual/guide-couchdb-manual-server-requirements.png differ diff --git a/images/hidden-file-sync/guide-hidden-file-advanced-features.png b/images/hidden-file-sync/guide-hidden-file-advanced-features.png new file mode 100644 index 00000000..f62fb555 Binary files /dev/null and b/images/hidden-file-sync/guide-hidden-file-advanced-features.png differ diff --git a/images/hidden-file-sync/guide-hidden-file-enable.png b/images/hidden-file-sync/guide-hidden-file-enable.png new file mode 100644 index 00000000..ab54fc48 Binary files /dev/null and b/images/hidden-file-sync/guide-hidden-file-enable.png differ diff --git a/images/hidden-file-sync/guide-hidden-file-initial-scan-progress.png b/images/hidden-file-sync/guide-hidden-file-initial-scan-progress.png new file mode 100644 index 00000000..b9ddff7d Binary files /dev/null and b/images/hidden-file-sync/guide-hidden-file-initial-scan-progress.png differ diff --git a/images/hidden-file-sync/guide-hidden-file-selector.png b/images/hidden-file-sync/guide-hidden-file-selector.png new file mode 100644 index 00000000..42c22eed Binary files /dev/null and b/images/hidden-file-sync/guide-hidden-file-selector.png differ diff --git a/images/object-storage-setup/guide-object-storage-setup-copy-setup-uri-passphrase.png b/images/object-storage-setup/guide-object-storage-setup-copy-setup-uri-passphrase.png new file mode 100644 index 00000000..e8745a39 Binary files /dev/null and b/images/object-storage-setup/guide-object-storage-setup-copy-setup-uri-passphrase.png differ diff --git a/images/object-storage-setup/guide-object-storage-setup-copy-setup-uri-result.png b/images/object-storage-setup/guide-object-storage-setup-copy-setup-uri-result.png new file mode 100644 index 00000000..998dcb95 Binary files /dev/null and b/images/object-storage-setup/guide-object-storage-setup-copy-setup-uri-result.png differ diff --git a/images/object-storage-setup/guide-object-storage-setup-first-initialise.png b/images/object-storage-setup/guide-object-storage-setup-first-initialise.png new file mode 100644 index 00000000..597d52e1 Binary files /dev/null and b/images/object-storage-setup/guide-object-storage-setup-first-initialise.png differ diff --git a/images/object-storage-setup/guide-object-storage-setup-first-rebuild-confirmation.png b/images/object-storage-setup/guide-object-storage-setup-first-rebuild-confirmation.png new file mode 100644 index 00000000..152b034d Binary files /dev/null and b/images/object-storage-setup/guide-object-storage-setup-first-rebuild-confirmation.png differ diff --git a/images/object-storage-setup/guide-object-storage-setup-first-setup-uri.png b/images/object-storage-setup/guide-object-storage-setup-first-setup-uri.png new file mode 100644 index 00000000..52242817 Binary files /dev/null and b/images/object-storage-setup/guide-object-storage-setup-first-setup-uri.png differ diff --git a/images/object-storage-setup/guide-object-storage-setup-first-to-second.png b/images/object-storage-setup/guide-object-storage-setup-first-to-second.png new file mode 100644 index 00000000..a2af3b5b Binary files /dev/null and b/images/object-storage-setup/guide-object-storage-setup-first-to-second.png differ diff --git a/images/object-storage-setup/guide-object-storage-setup-local-file-policy.png b/images/object-storage-setup/guide-object-storage-setup-local-file-policy.png new file mode 100644 index 00000000..423a92fb Binary files /dev/null and b/images/object-storage-setup/guide-object-storage-setup-local-file-policy.png differ diff --git a/images/object-storage-setup/guide-object-storage-setup-missing-remote-configuration.png b/images/object-storage-setup/guide-object-storage-setup-missing-remote-configuration.png new file mode 100644 index 00000000..6d816e23 Binary files /dev/null and b/images/object-storage-setup/guide-object-storage-setup-missing-remote-configuration.png differ diff --git a/images/object-storage-setup/guide-object-storage-setup-retrieval-method.png b/images/object-storage-setup/guide-object-storage-setup-retrieval-method.png new file mode 100644 index 00000000..7a48bf73 Binary files /dev/null and b/images/object-storage-setup/guide-object-storage-setup-retrieval-method.png differ diff --git a/images/object-storage-setup/guide-object-storage-setup-second-fetch.png b/images/object-storage-setup/guide-object-storage-setup-second-fetch.png new file mode 100644 index 00000000..84c7d411 Binary files /dev/null and b/images/object-storage-setup/guide-object-storage-setup-second-fetch.png differ diff --git a/images/object-storage-setup/guide-object-storage-setup-second-setup-uri.png b/images/object-storage-setup/guide-object-storage-setup-second-setup-uri.png new file mode 100644 index 00000000..7b5b0e37 Binary files /dev/null and b/images/object-storage-setup/guide-object-storage-setup-second-setup-uri.png differ diff --git a/images/object-storage-setup/guide-object-storage-setup-second-to-first.png b/images/object-storage-setup/guide-object-storage-setup-second-to-first.png new file mode 100644 index 00000000..412cd359 Binary files /dev/null and b/images/object-storage-setup/guide-object-storage-setup-second-to-first.png differ diff --git a/images/p2p-setup/guide-p2p-setup-connection-request-1.png b/images/p2p-setup/guide-p2p-setup-connection-request-1.png new file mode 100644 index 00000000..26c638f7 Binary files /dev/null and b/images/p2p-setup/guide-p2p-setup-connection-request-1.png differ diff --git a/images/p2p-setup/guide-p2p-setup-connection-request-2.png b/images/p2p-setup/guide-p2p-setup-connection-request-2.png new file mode 100644 index 00000000..a5da0e9f Binary files /dev/null and b/images/p2p-setup/guide-p2p-setup-connection-request-2.png differ diff --git a/images/p2p-setup/guide-p2p-setup-copy-setup-uri-passphrase.png b/images/p2p-setup/guide-p2p-setup-copy-setup-uri-passphrase.png new file mode 100644 index 00000000..815068d4 Binary files /dev/null and b/images/p2p-setup/guide-p2p-setup-copy-setup-uri-passphrase.png differ diff --git a/images/p2p-setup/guide-p2p-setup-copy-setup-uri-result.png b/images/p2p-setup/guide-p2p-setup-copy-setup-uri-result.png new file mode 100644 index 00000000..57acd4e3 Binary files /dev/null and b/images/p2p-setup/guide-p2p-setup-copy-setup-uri-result.png differ diff --git a/images/p2p-setup/guide-p2p-setup-first-device-connected.png b/images/p2p-setup/guide-p2p-setup-first-device-connected.png new file mode 100644 index 00000000..72dea764 Binary files /dev/null and b/images/p2p-setup/guide-p2p-setup-first-device-connected.png differ diff --git a/images/p2p-setup/guide-p2p-setup-first-initialise.png b/images/p2p-setup/guide-p2p-setup-first-initialise.png new file mode 100644 index 00000000..9cf9a206 Binary files /dev/null and b/images/p2p-setup/guide-p2p-setup-first-initialise.png differ diff --git a/images/p2p-setup/guide-p2p-setup-first-rebuild-confirmation.png b/images/p2p-setup/guide-p2p-setup-first-rebuild-confirmation.png new file mode 100644 index 00000000..9940c589 Binary files /dev/null and b/images/p2p-setup/guide-p2p-setup-first-rebuild-confirmation.png differ diff --git a/images/p2p-setup/guide-p2p-setup-first-setup-uri.png b/images/p2p-setup/guide-p2p-setup-first-setup-uri.png new file mode 100644 index 00000000..c57cddf9 Binary files /dev/null and b/images/p2p-setup/guide-p2p-setup-first-setup-uri.png differ diff --git a/images/p2p-setup/guide-p2p-setup-first-to-second.png b/images/p2p-setup/guide-p2p-setup-first-to-second.png new file mode 100644 index 00000000..ba2909c1 Binary files /dev/null and b/images/p2p-setup/guide-p2p-setup-first-to-second.png differ diff --git a/images/p2p-setup/guide-p2p-setup-local-file-policy.png b/images/p2p-setup/guide-p2p-setup-local-file-policy.png new file mode 100644 index 00000000..423a92fb Binary files /dev/null and b/images/p2p-setup/guide-p2p-setup-local-file-policy.png differ diff --git a/images/p2p-setup/guide-p2p-setup-peer-actions-menu.png b/images/p2p-setup/guide-p2p-setup-peer-actions-menu.png new file mode 100644 index 00000000..ffc3392f Binary files /dev/null and b/images/p2p-setup/guide-p2p-setup-peer-actions-menu.png differ diff --git a/images/p2p-setup/guide-p2p-setup-retrieval-method.png b/images/p2p-setup/guide-p2p-setup-retrieval-method.png new file mode 100644 index 00000000..b3b8f520 Binary files /dev/null and b/images/p2p-setup/guide-p2p-setup-retrieval-method.png differ diff --git a/images/p2p-setup/guide-p2p-setup-second-fetch.png b/images/p2p-setup/guide-p2p-setup-second-fetch.png new file mode 100644 index 00000000..2c65ca95 Binary files /dev/null and b/images/p2p-setup/guide-p2p-setup-second-fetch.png differ diff --git a/images/p2p-setup/guide-p2p-setup-second-setup-uri.png b/images/p2p-setup/guide-p2p-setup-second-setup-uri.png new file mode 100644 index 00000000..c4c37951 Binary files /dev/null and b/images/p2p-setup/guide-p2p-setup-second-setup-uri.png differ diff --git a/images/p2p-setup/guide-p2p-setup-second-to-first.png b/images/p2p-setup/guide-p2p-setup-second-to-first.png new file mode 100644 index 00000000..3baab2b7 Binary files /dev/null and b/images/p2p-setup/guide-p2p-setup-second-to-first.png differ diff --git a/images/p2p-setup/guide-p2p-setup-select-first-device.png b/images/p2p-setup/guide-p2p-setup-select-first-device.png new file mode 100644 index 00000000..c7f39d42 Binary files /dev/null and b/images/p2p-setup/guide-p2p-setup-select-first-device.png differ diff --git a/images/p2p-setup/p2p-status-pane-mobile.png b/images/p2p-setup/p2p-status-pane-mobile.png new file mode 100644 index 00000000..961b8485 Binary files /dev/null and b/images/p2p-setup/p2p-status-pane-mobile.png differ diff --git a/images/p2p-setup/p2p-status-pane.png b/images/p2p-setup/p2p-status-pane.png new file mode 100644 index 00000000..b0cfac88 Binary files /dev/null and b/images/p2p-setup/p2p-status-pane.png differ diff --git a/images/quick-setup/guide-quick-setup-copy-setup-uri-passphrase.png b/images/quick-setup/guide-quick-setup-copy-setup-uri-passphrase.png new file mode 100644 index 00000000..815068d4 Binary files /dev/null and b/images/quick-setup/guide-quick-setup-copy-setup-uri-passphrase.png differ diff --git a/images/quick-setup/guide-quick-setup-copy-setup-uri-result.png b/images/quick-setup/guide-quick-setup-copy-setup-uri-result.png new file mode 100644 index 00000000..01773d1e Binary files /dev/null and b/images/quick-setup/guide-quick-setup-copy-setup-uri-result.png differ diff --git a/images/quick-setup/guide-quick-setup-first-initialise.png b/images/quick-setup/guide-quick-setup-first-initialise.png new file mode 100644 index 00000000..597d52e1 Binary files /dev/null and b/images/quick-setup/guide-quick-setup-first-initialise.png differ diff --git a/images/quick-setup/guide-quick-setup-first-rebuild-confirmation.png b/images/quick-setup/guide-quick-setup-first-rebuild-confirmation.png new file mode 100644 index 00000000..152b034d Binary files /dev/null and b/images/quick-setup/guide-quick-setup-first-rebuild-confirmation.png differ diff --git a/images/quick-setup/guide-quick-setup-first-setup-uri.png b/images/quick-setup/guide-quick-setup-first-setup-uri.png new file mode 100644 index 00000000..2942512a Binary files /dev/null and b/images/quick-setup/guide-quick-setup-first-setup-uri.png differ diff --git a/images/quick-setup/guide-quick-setup-local-file-policy.png b/images/quick-setup/guide-quick-setup-local-file-policy.png new file mode 100644 index 00000000..423a92fb Binary files /dev/null and b/images/quick-setup/guide-quick-setup-local-file-policy.png differ diff --git a/images/quick-setup/guide-quick-setup-missing-remote-configuration.png b/images/quick-setup/guide-quick-setup-missing-remote-configuration.png new file mode 100644 index 00000000..6d816e23 Binary files /dev/null and b/images/quick-setup/guide-quick-setup-missing-remote-configuration.png differ diff --git a/images/quick-setup/guide-quick-setup-retrieval-method.png b/images/quick-setup/guide-quick-setup-retrieval-method.png new file mode 100644 index 00000000..7a48bf73 Binary files /dev/null and b/images/quick-setup/guide-quick-setup-retrieval-method.png differ diff --git a/images/quick-setup/guide-quick-setup-second-fetch.png b/images/quick-setup/guide-quick-setup-second-fetch.png new file mode 100644 index 00000000..84c7d411 Binary files /dev/null and b/images/quick-setup/guide-quick-setup-second-fetch.png differ diff --git a/images/quick-setup/guide-quick-setup-synchronised-note.png b/images/quick-setup/guide-quick-setup-synchronised-note.png new file mode 100644 index 00000000..8e6a3433 Binary files /dev/null and b/images/quick-setup/guide-quick-setup-synchronised-note.png differ diff --git a/manifest.json b/manifest.json index ac5881a8..a5e5ab4b 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "obsidian-livesync", "name": "Self-hosted LiveSync", - "version": "0.25.82", + "version": "1.0.10", "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", diff --git a/package-lock.json b/package-lock.json index 890b6c01..6a6b97c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-livesync", - "version": "0.25.82", + "version": "1.0.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-livesync", - "version": "0.25.82", + "version": "1.0.10", "license": "MIT", "workspaces": [ "src/apps/cli", @@ -22,14 +22,17 @@ "@smithy/querystring-builder": "^4.2.9", "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", - "@trystero-p2p/nostr": "^0.24.0", + "@vrtmrz/browser-ui-kit": "0.1.0", + "@vrtmrz/livesync-commonlib": "0.1.9", + "@vrtmrz/obsidian-plugin-kit": "0.1.3", + "@vrtmrz/ui-interactions": "0.1.2", "diff-match-patch": "^1.0.5", "fflate": "^0.8.2", "idb": "^8.0.3", "markdown-it": "^14.2.0", "minimatch": "^10.2.5", "obsidian": "^1.13.1", - "octagonal-wheels": "^0.1.51", + "octagonal-wheels": "^0.1.52", "qrcode-generator": "^1.4.4", "xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2" }, @@ -38,7 +41,6 @@ "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@eslint/js": "^9.39.3", - "@playwright/test": "^1.58.2", "@sveltejs/vite-plugin-svelte": "^7.1.2", "@tsconfig/svelte": "^5.0.8", "@types/deno": "^2.5.0", @@ -55,16 +57,14 @@ "@types/pouchdb-replication": "^6.4.7", "@types/transform-pouch": "^1.0.6", "@typescript-eslint/parser": "8.56.1", - "@vitest/browser": "^4.1.8", - "@vitest/browser-playwright": "^4.1.8", "@vitest/coverage-v8": "^4.1.8", - "@vrtmrz/obsidian-test-session": "0.1.0", + "@vrtmrz/obsidian-test-session": "0.2.6", "dotenv-cli": "^11.0.0", "esbuild": "0.28.1", "esbuild-plugin-inline-worker": "^0.1.1", "esbuild-svelte": "^0.9.4", "eslint": "^9.39.3", - "eslint-plugin-obsidianmd": "^0.3.0", + "eslint-plugin-obsidianmd": "^0.4.1", "eslint-plugin-svelte": "^3.19.0", "events": "^3.3.0", "globals": "^14.0.0", @@ -1260,13 +1260,6 @@ "node": ">=18" } }, - "node_modules/@blazediff/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", - "integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==", - "dev": true, - "license": "MIT" - }, "node_modules/@codemirror/state": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", @@ -1812,6 +1805,36 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-plugin-eslint-comments": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-4.7.2.tgz", + "integrity": "sha512-LF03qURSwEWm2dz5wtdDCzNk+7Opl0X7q6I3undsaIuNsEiNvRV3BCtqu14Q/6Pzg1tBj44LcxpW2EpSLZStZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "ignore": "^7.0.5" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/@eslint-community/eslint-plugin-eslint-comments/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1864,9 +1887,9 @@ "license": "MIT" }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -1945,9 +1968,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -2179,128 +2202,11 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -2311,7 +2217,7 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -2322,7 +2228,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -2343,14 +2249,14 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2710,29 +2616,6 @@ "url": "https://opencollective.com/unts" } }, - "node_modules/@playwright/test": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", - "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.61.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, - "license": "MIT" - }, "node_modules/@promptbook/utils": { "version": "0.69.5", "resolved": "https://registry.npmjs.org/@promptbook/utils/-/utils-0.69.5.tgz", @@ -3877,7 +3760,7 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -3920,22 +3803,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@trystero-p2p/core": { - "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.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.1.0", - "@trystero-p2p/core": "0.24.0" - } - }, "node_modules/@tsconfig/svelte": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", @@ -4413,7 +4280,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/which": { @@ -4753,54 +4620,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@vitest/browser": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.8.tgz", - "integrity": "sha512-u21VzX07HzlJYpFgkxmjEXar/tG2UqWGgyGG/46SrrPc7rSdCTPw5vuowopO9CIqF8UCUQzDFdbVnNpw6N0BfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@blazediff/core": "1.9.1", - "@vitest/mocker": "4.1.8", - "@vitest/utils": "4.1.8", - "magic-string": "^0.30.21", - "pngjs": "^7.0.0", - "sirv": "^3.0.2", - "tinyrainbow": "^3.1.0", - "ws": "^8.19.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "vitest": "4.1.8" - } - }, - "node_modules/@vitest/browser-playwright": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.8.tgz", - "integrity": "sha512-SR7FqgegaexEg73xvf3ArtygXegagMdXnL0EZMpxrWvvhQxvicD/E8p0ib0J91riPRtQUViyh67Xjw3NqvyhVg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@vitest/browser": "4.1.8", - "@vitest/mocker": "4.1.8", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "playwright": "*", - "vitest": "4.1.8" - }, - "peerDependenciesMeta": { - "playwright": { - "optional": false - } - } - }, "node_modules/@vitest/coverage-v8": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", @@ -4946,10 +4765,94 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vrtmrz/obsidian-test-session": { + "node_modules/@vrtmrz/browser-ui-kit": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.1.0.tgz", - "integrity": "sha512-asBOIRTc3xK5GF5ds5mkxN6vsO4RE8o7puvVjoJiGYSlxoFa9jzr8FwAO13CyoOriHF05pOZfTB+eQmL1aNb/A==", + "resolved": "https://registry.npmjs.org/@vrtmrz/browser-ui-kit/-/browser-ui-kit-0.1.0.tgz", + "integrity": "sha512-UlMPRZS3lLFcuEKBaAPcSkIY0we3+kj32GHLcOAfxmooy8XaJWh9/VP5MCC+lo06GrZ6graE3vwmAiFsAHwgng==", + "license": "MIT", + "dependencies": { + "@vrtmrz/ui-interactions": "0.1.2" + } + }, + "node_modules/@vrtmrz/livesync-commonlib": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.9.tgz", + "integrity": "sha512-qacNUbRAqt32j756XTTdI5K+/co8oc2BKjzsUAhEsDHC8tc18q4a3cQrUHNJuxYO+nYnx0FKhf9lHMZcCX+Scg==", + "license": "MIT", + "dependencies": { + "@aws-sdk/client-s3": "^3.808.0", + "@smithy/fetch-http-handler": "^5.3.10", + "@smithy/md5-js": "^4.2.9", + "@smithy/middleware-apply-body-checksum": "^4.3.9", + "@smithy/types": "^4.14.3", + "@smithy/util-retry": "^4.4.5", + "@trystero-p2p/nostr": "0.25.3", + "diff-match-patch": "^1.0.5", + "events": "^3.3.0", + "fflate": "^0.8.2", + "idb": "^8.0.3", + "markdown-it": "^14.2.0", + "minimatch": "^10.2.5", + "octagonal-wheels": "^0.1.51", + "pouchdb-adapter-http": "^9.0.0", + "pouchdb-adapter-idb": "^9.0.0", + "pouchdb-adapter-indexeddb": "^9.0.0", + "pouchdb-adapter-memory": "^9.0.0", + "pouchdb-core": "^9.0.0", + "pouchdb-errors": "^9.0.0", + "pouchdb-find": "^9.0.0", + "pouchdb-mapreduce": "^9.0.0", + "pouchdb-merge": "^9.0.0", + "pouchdb-replication": "^9.0.0", + "pouchdb-utils": "^9.0.0", + "qrcode-generator": "^1.4.4", + "transform-pouch": "^2.0.0", + "xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "svelte": "5.56.3" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/@vrtmrz/livesync-commonlib/node_modules/@trystero-p2p/core": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@trystero-p2p/core/-/core-0.25.3.tgz", + "integrity": "sha512-lQKNq/ha+vF6kQZrpaXJGzlzxLF/Fhizoy5dwJUYQlkqRrbPup/Jov2EpPX/CPr2X+f79HxVzonkeni3MxmCuQ==", + "license": "MIT" + }, + "node_modules/@vrtmrz/livesync-commonlib/node_modules/@trystero-p2p/nostr": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@trystero-p2p/nostr/-/nostr-0.25.3.tgz", + "integrity": "sha512-nZV9Fl/GXuhIkJSQ+wIkdMoRLO1oSTUL/+vZorukaojeAdOpT/61e8b9Vzbt8L1ktMTaGPEJHw2BG/B5BCwBuw==", + "license": "MIT", + "dependencies": { + "@noble/secp256k1": "^3.1.0", + "@trystero-p2p/core": "0.25.3" + } + }, + "node_modules/@vrtmrz/obsidian-plugin-kit": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-plugin-kit/-/obsidian-plugin-kit-0.1.3.tgz", + "integrity": "sha512-6fsKdhFZtBv6FXlZHtSmpqwROohFzDmres6q08nr2xYGVeh2ooBGU3zJS94WN/tOjDT+wa/Vr3yE42wmI0pIZA==", + "license": "MIT", + "dependencies": { + "@vrtmrz/ui-interactions": "0.1.2" + }, + "peerDependencies": { + "obsidian": ">=1.8.7" + } + }, + "node_modules/@vrtmrz/obsidian-test-session": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.2.6.tgz", + "integrity": "sha512-7xDTmTW3igBxwQGgbbpoRZU0JvOBpGgdRT4qF2WVrKz03OtKMJdkfJILY04/HZ+n4tvn9Ze8phJBr0dx3wewcQ==", "dev": true, "license": "MIT", "engines": { @@ -4960,6 +4863,12 @@ "playwright": ">=1.50.0" } }, + "node_modules/@vrtmrz/ui-interactions": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@vrtmrz/ui-interactions/-/ui-interactions-0.1.2.tgz", + "integrity": "sha512-njT2BSFh57imwGDxWXJOcGbZOYFVNGmeXMrS7/RUdBoAeOjWzJB9TxI7WcR1sgQIXKh8TE9hqALU8lue5b8dRw==", + "license": "MIT" + }, "node_modules/@wdio/config": { "version": "9.27.0", "resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.27.0.tgz", @@ -4987,9 +4896,9 @@ "license": "MIT" }, "node_modules/@wdio/config/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -5227,7 +5136,7 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "bin": { @@ -5355,9 +5264,9 @@ "license": "MIT" }, "node_modules/archiver-utils/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -5520,7 +5429,7 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -5782,7 +5691,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -5825,9 +5734,9 @@ "license": "MIT" }, "node_modules/babel-plugin-module-resolver/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -6077,15 +5986,15 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -6236,16 +6145,6 @@ "node": ">=6" } }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001799", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", @@ -6404,7 +6303,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -6892,7 +6791,7 @@ "version": "5.8.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/diff-match-patch": { @@ -7774,9 +7673,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-import/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -7871,9 +7770,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-json-schema-validator/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -7937,9 +7836,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-n/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -7986,12 +7885,13 @@ } }, "node_modules/eslint-plugin-obsidianmd": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-obsidianmd/-/eslint-plugin-obsidianmd-0.3.0.tgz", - "integrity": "sha512-QvGDI6B2nxJBrsZKGTg31da2A/fEJNlnwN+fRZkaoPIu1QL3fYXUdpP7ThyMdr/0iTYQxifb9lt2X9cpydQx1w==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-obsidianmd/-/eslint-plugin-obsidianmd-0.4.1.tgz", + "integrity": "sha512-Nv3593kVsFOS8kir/HWaJ5CR3xcyiPWEPyXst5Pib8mxRYYuLYPpJS2ALMoZXHulHyiGwoYW8AaxvLRc4rhrRg==", "dev": true, "license": "MIT", "dependencies": { + "@eslint-community/eslint-plugin-eslint-comments": "^4.7.2", "@eslint/config-helpers": "^0.4.2", "@eslint/js": "^9.30.1", "@eslint/json": "0.14.0", @@ -8000,7 +7900,7 @@ "@types/node": "20.12.12", "@typescript-eslint/types": "^8.33.1", "@typescript-eslint/utils": "^8.33.1", - "eslint": ">=9.0.0", + "eslint": ">=9.19.0", "eslint-plugin-depend": "1.3.1", "eslint-plugin-import": "^2.31.0", "eslint-plugin-json-schema-validator": "5.1.0", @@ -8021,7 +7921,7 @@ "peerDependencies": { "@eslint/js": "^9.30.1", "@eslint/json": "0.14.0", - "eslint": ">=9.0.0", + "eslint": ">=9.19.0", "obsidian": "1.8.7", "typescript-eslint": "^8.35.1" } @@ -8113,9 +8013,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -8315,9 +8215,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -8355,7 +8255,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/espree": { @@ -8420,7 +8320,7 @@ "version": "2.2.11", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.11.tgz", "integrity": "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -8491,7 +8391,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -8596,9 +8495,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ { @@ -8895,7 +8794,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", - "dev": true, "license": "MIT" }, "node_modules/functions-have-names": { @@ -8994,16 +8892,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/get-port": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", @@ -9093,24 +8981,6 @@ "node": ">= 14" } }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -9182,9 +9052,9 @@ "license": "MIT" }, "node_modules/globby/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -9884,7 +9754,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" @@ -10081,23 +9951,6 @@ "node": ">=8" } }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/istanbul-lib-report": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", @@ -10196,9 +10049,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -11003,9 +10856,9 @@ } }, "node_modules/linkify-it": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", - "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", "funding": [ { "type": "github", @@ -11051,7 +10904,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/locate-path": { @@ -11145,16 +10998,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/ltgt": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/ltgt/-/ltgt-2.2.1.tgz", @@ -11165,7 +11008,7 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -11257,7 +11100,6 @@ "resolved": "https://registry.npmjs.org/memdown/-/memdown-1.4.1.tgz", "integrity": "sha512-iVrGHZB8i4OQfM155xx8akvG9FIj+ht14DX5CQkCTG4EHzZ3d3sgckIf/Lm9ivZalEsFuEVnWv2B2WZvbrro2w==", "deprecated": "Superseded by memory-level (https://github.com/Level/community#faq)", - "dev": true, "license": "MIT", "dependencies": { "abstract-leveldown": "~2.7.1", @@ -11273,7 +11115,6 @@ "resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-2.7.2.tgz", "integrity": "sha512-+OVvxH2rHVEhWLdbudP6p0+dNMXu8JA1CbhP19T8paTYAcX7oJ4OVjT+ZUVpv7mITxXHqDMej+GdqXBmXkw09w==", "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", - "dev": true, "license": "MIT", "dependencies": { "xtend": "~4.0.0" @@ -11387,16 +11228,6 @@ "node": ">=4" } }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -11679,6 +11510,7 @@ "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.13.1.tgz", "integrity": "sha512-qtTEA2pmhJzhuhJqzbBFRYhpIOqvW+krDYjtFynv66KbxBbumHBlsJfWw3I4jtnK/6fZwbQhCrmmDdRwXmX56w==", "license": "MIT", + "peer": true, "dependencies": { "@types/codemirror": "5.60.8", "moment": "2.29.4" @@ -11700,9 +11532,9 @@ "license": "MIT" }, "node_modules/octagonal-wheels": { - "version": "0.1.51", - "resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.51.tgz", - "integrity": "sha512-KTlfqKPjobHJg/t3A539srnFf+VHr1aXkHSmsNDDpiI5UFC7FamZ95dWpJfGE2EI/HULR5hveQDgkazmz8SAcg==", + "version": "0.1.52", + "resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.52.tgz", + "integrity": "sha512-9WJN2UveNh90Op1S07cIso1WyNrQbO/unibDLfUGnpomIcU4g6F+p8reZHW0Ed8sGzKF5qGnQp991Y9MB1TwNg==", "license": "MIT", "dependencies": { "idb": "^8.0.3" @@ -11971,23 +11803,6 @@ "dev": true, "license": "MIT" }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -12213,16 +12028,6 @@ "node": ">=18" } }, - "node_modules/pngjs": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", - "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.19.0" - } - }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -12362,7 +12167,6 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/pouchdb-adapter-idb/-/pouchdb-adapter-idb-9.0.0.tgz", "integrity": "sha512-2oLlgwMyOQwdKuzrEmOv8T7jFVgX7JgT4Cr81zX3eiiRClp7xXGgjv41ZRdVCAbM530sIN8BudafaQRVFKRVmA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "pouchdb-adapter-utils": "9.0.0", @@ -12377,7 +12181,6 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/pouchdb-adapter-indexeddb/-/pouchdb-adapter-indexeddb-9.0.0.tgz", "integrity": "sha512-/mcCbnVR0VKwtVZWKf8lVSdADLD0yApjFudu4d+0jeLWAeBSGZBRKYlogz2PGs4uTA7GVc2TXjVCNGUdkCM9ZQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "pouchdb-adapter-utils": "9.0.0", @@ -12426,7 +12229,6 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/pouchdb-adapter-memory/-/pouchdb-adapter-memory-9.0.0.tgz", "integrity": "sha512-XbCwJ5f5U9dGdkiDikzYjTebdPHuA6Ghylx1Pq0lDe4y6l8R9xhjDSUy56pJ8G2F4Z+8QdB5FBY9EQoFlFSXWQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "memdown": "1.4.1", @@ -12866,9 +12668,9 @@ "license": "MIT" }, "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -13208,7 +13010,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, "license": "MIT" }, "node_modules/safe-push-apply": { @@ -13513,21 +13314,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/sirv": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", - "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -13643,13 +13429,6 @@ "node": ">= 10.x" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -14040,7 +13819,7 @@ "version": "5.56.3", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.3.tgz", "integrity": "sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -14295,21 +14074,6 @@ "node": ">=10" } }, - "node_modules/test-exclude": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", - "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^13.0.6", - "minimatch": "^10.2.2" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/text-decoder": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", @@ -14441,16 +14205,6 @@ "url": "https://github.com/sponsors/ota-meshi" } }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/tough-cookie": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", @@ -15083,12 +14837,16 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/vite": { @@ -15170,67 +14928,6 @@ } } }, - "node_modules/vite-plugin-istanbul": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/vite-plugin-istanbul/-/vite-plugin-istanbul-9.0.1.tgz", - "integrity": "sha512-zgcdcqa4r3urX+xqhMQG2uLR2s6IdklQW+acBEtLw6fvUWhuvXswYqxjHAZZ5HCfHEgRs7RH7qIZCklTLRsKLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/generator": "^7.29.7", - "@istanbuljs/load-nyc-config": "^1.1.0", - "@types/babel__generator": "7.27.0", - "espree": "^11.2.0", - "istanbul-lib-instrument": "^6.0.3", - "picocolors": "^1.1.1", - "source-map": "^0.7.6", - "test-exclude": "^8.0.0" - }, - "peerDependencies": { - "vite": ">=7" - } - }, - "node_modules/vite-plugin-istanbul/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/vite-plugin-istanbul/node_modules/espree": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/vite-plugin-istanbul/node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 12" - } - }, "node_modules/vite/node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -15285,6 +14982,7 @@ "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.8", "@vitest/mocker": "4.1.8", @@ -16164,7 +15862,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/zip-stream": { @@ -16226,11 +15924,11 @@ }, "src/apps/cli": { "name": "self-hosted-livesync-cli", - "version": "0.25.82-cli", + "version": "1.0.10-cli", "dependencies": { "chokidar": "^4.0.0", "minimatch": "^10.2.5", - "octagonal-wheels": "^0.1.51", + "octagonal-wheels": "^0.1.52", "pouchdb-adapter-http": "^9.0.0", "pouchdb-adapter-leveldb": "^9.0.0", "pouchdb-core": "^9.0.0", @@ -16244,7 +15942,6 @@ "werift": "^0.23.0" }, "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^7.1.2", "typescript": "5.9.3", "vite": "^8.0.16", "vitest": "^4.1.8" @@ -16252,24 +15949,21 @@ }, "src/apps/webapp": { "name": "livesync-webapp", - "version": "0.25.82-webapp", + "version": "1.0.10-webapp", "dependencies": { - "octagonal-wheels": "^0.1.51" + "octagonal-wheels": "^0.1.52" }, "devDependencies": { - "@playwright/test": "^1.58.2", "@sveltejs/vite-plugin-svelte": "^7.1.2", - "playwright": "^1.58.2", "svelte": "5.56.3", "typescript": "5.9.3", - "vite": "^8.0.16", - "vite-plugin-istanbul": "^9.0.1" + "vite": "^8.0.16" } }, "src/apps/webpeer": { - "version": "0.25.82-webpeer", + "version": "1.0.10-webpeer", "dependencies": { - "octagonal-wheels": "^0.1.51" + "octagonal-wheels": "^0.1.52" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.1.2", diff --git a/package.json b/package.json index e4a854a5..3aa3eb0f 100644 --- a/package.json +++ b/package.json @@ -1,60 +1,89 @@ { "name": "obsidian-livesync", - "version": "0.25.82", + "version": "1.0.10", "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", "scripts": { - "bakei18n": "npm run i18n:yaml2json && npm run i18n:bakejson", - "i18n:bakejson": "npx tsx ./src/lib/_tools/bakei18n.ts", - "i18n:yaml2json": "npx tsx ./src/lib/_tools/yaml2json.ts", - "i18n:json2yaml": "npx tsx ./src/lib/_tools/json2yaml.ts", - "prettyjson": "prettier --config ./.prettierrc.mjs ./src/lib/src/common/messagesJson/*.json --write --log-level error", - "postbakei18n": "prettier --config ./.prettierrc.mjs ./src/lib/src/common/messages/*.ts --write --log-level error", - "posti18n:yaml2json": "npm run prettyjson", - "predev": "npm run bakei18n", "dev": "node --env-file=.env esbuild.config.mjs", - "prebuild": "npm run bakei18n", "build": "node esbuild.config.mjs production", - "build:lib:types": "node generate-types.mjs", "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 --cache --concurrency auto src", - "svelte-check": "svelte-check --tsconfig ./tsconfig.json", + "lint": "eslint --cache --cache-strategy content --concurrency off src", + "lint:community": "eslint --config eslint.community.config.mjs --concurrency off src", + "lint:community:tools": "eslint --config eslint.community.config.mjs --concurrency off --max-warnings 0 _tools", + "svelte-check": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings", "tsc-check": "tsc --noEmit", + "tsc-check:apps": "tsc --noEmit -p src/apps/browser/tsconfig.json && tsc --noEmit -p src/apps/cli/tsconfig.json && tsc --noEmit -p src/apps/webapp/tsconfig.json && tsc --noEmit -p src/apps/webpeer/tsconfig.app.json && tsc --noEmit -p src/apps/webpeer/tsconfig.node.json", "pretty:importpath": "cd utilsdeno && deno run -A ./normalise-imports.ts", "pretty:json": "prettier --config ./.prettierrc.mjs \"**/*.json\" --write --log-level error", "pretty": "npm run prettyNoWrite -- --write --log-level error", "prettyCheck": "npm run prettyNoWrite -- --check", "prettyNoWrite": "prettier --config ./.prettierrc.mjs \"**/*.js\" \"**/*.ts\" \"**/*.json\" ", + "precheck:compatibility": "npm run build", "check:compatibility": "node utils/check-compatibility.js --file main.js --ios 15", - "precheck": "npm run build:lib:types", - "check": "npm run tsc-check && npm run lint && npm run svelte-check && npm run check:compatibility", - "unittest": "deno test -A --no-check --coverage=cov_profile --v8-flags=--expose-gc --trace-leaks ./src/", - "test": "vitest run", + "check": "npm run tsc-check && npm run tsc-check:apps && npm run lint && npm run lint:community -- --quiet && npm run lint:community:tools && npm run svelte-check && npm run check:compatibility", + "i18n:bake": "npm run i18n:yaml2json && npm run i18n:bakejson && npm run i18n:format", + "i18n:bakejson": "tsx _tools/bakei18n.ts", + "i18n:format": "prettier --config .prettierrc.mjs --write --log-level error 'src/common/messagesJson/*.json' 'src/common/messages/*.ts'", + "i18n:json2yaml": "tsx _tools/json2yaml.ts", + "i18n:yaml2json": "tsx _tools/yaml2json.ts", "test:unit": "vitest run --config vitest.config.unit.ts", + "build:browser-apps": "npm run build --workspace livesync-webapp --workspace webpeer", + "test:browser-apps": "npm run test:browser --workspace livesync-webapp && npm run test:browser --workspace webpeer", + "test:browser-apps:pages": "deno test -A --no-check --frozen --config test/browser-apps/deno.json --lock test/browser-apps/deno.lock test/browser-apps/pages/browser-smoke.test.ts", + "test:e2e:browser-apps": "npm run test:browser-apps", + "pretest:e2e:browser-apps:interop": "npm run build:browser-apps && npm run build --workspace self-hosted-livesync-cli", + "test:e2e:browser-apps:interop": "deno run -A --no-check --frozen --config test/browser-apps/deno.json --lock test/browser-apps/deno.lock test/browser-apps/run-compose-interop.ts", + "inspect:troubleshooting": "tsx _tools/inspect-troubleshooting-docs.ts", + "test:setup-tools": "bash utils/flyio/setenv.test.sh && deno test -A --config=utils/flyio/deno.jsonc --frozen --lock=utils/flyio/deno.lock utils/livesync-commonlib-version.test.ts utils/couchdb/provision.test.ts utils/setup/generate_setup_uri.test.ts utils/flyio/generate_setupuri.test.ts", + "test:contract:contexts": "vitest run --config vitest.config.unit.ts src/modules/services/ObsidianServiceContext.unit.spec.ts src/apps/cli/services/NodeServiceContext.unit.spec.ts src/apps/browser/createLiveSyncBrowserServiceHub.unit.spec.ts", + "test:contract:context:webapp": "vitest run --config vitest.config.unit.ts src/apps/browser/createLiveSyncBrowserServiceHub.unit.spec.ts", + "test:contract:context:cli": "npm run build --workspace self-hosted-livesync-cli && deno task --cwd src/apps/cli/testdeno test:setup-put-cat", + "test:contract:context:obsidian": "npm run build && npm run test:e2e:obsidian:smoke", + "test:e2e:cli": "npm run test:e2e:ci --workspace self-hosted-livesync-cli", + "test:e2e:cli:p2p": "npm run test:e2e:p2p --workspace self-hosted-livesync-cli", + "test:e2e:cli:all": "npm run test:e2e:all --workspace self-hosted-livesync-cli", "test:integration": "npx dotenv-cli -e .env -e .test.env -- vitest run --config vitest.config.integration.ts", "test:unit:coverage": "vitest run --config vitest.config.unit.ts --coverage", - "test:install-playwright": "npx playwright install chromium", - "test:install-dependencies": "npm run test:install-playwright", "test:e2e:obsidian:install-appimage": "tsx test/e2e-obsidian/scripts/install-appimage.ts", + "test:e2e:obsidian:runner": "vitest run --config vitest.config.e2e-runner.ts", "test:e2e:obsidian:discover": "tsx test/e2e-obsidian/scripts/discover.ts", "test:e2e:obsidian:cli-help": "tsx test/e2e-obsidian/scripts/cli-help.ts", "test:e2e:obsidian:debug-ui": "tsx test/e2e-obsidian/scripts/debug-ui.ts", + "test:e2e:obsidian:focused": "tsx test/e2e-obsidian/scripts/run-focused.ts", "test:e2e:obsidian:smoke": "tsx test/e2e-obsidian/scripts/smoke.ts", + "test:e2e:obsidian:onboarding-invitation": "tsx test/e2e-obsidian/scripts/onboarding-invitation.ts", + "test:e2e:obsidian:dialog-mounts": "tsx test/e2e-obsidian/scripts/dialog-mounts.ts", + "test:e2e:obsidian:conflict-dialog-policy": "tsx test/e2e-obsidian/scripts/conflict-dialog-policy.ts", + "test:e2e:obsidian:revision-repair": "tsx test/e2e-obsidian/scripts/revision-repair.ts", + "test:e2e:obsidian:document-history-nav": "tsx test/e2e-obsidian/scripts/document-history-nav.ts", + "test:e2e:obsidian:settings-ui": "tsx test/e2e-obsidian/scripts/settings-ui.ts", + "test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts", + "test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts", "test:e2e:obsidian:vault-reflection": "tsx test/e2e-obsidian/scripts/vault-reflection.ts", "test:e2e:obsidian:couchdb-upload": "tsx test/e2e-obsidian/scripts/couchdb-upload.ts", + "test:e2e:obsidian:couchdb-manual-setup-workflow": "tsx test/e2e-obsidian/scripts/couchdb-manual-setup-workflow.ts", "test:e2e:obsidian:cli-to-obsidian-sync": "tsx test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts", "test:e2e:obsidian:minio-upload": "tsx test/e2e-obsidian/scripts/minio-upload.ts", + "test:e2e:obsidian:object-storage-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts", + "test:e2e:obsidian:p2p-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/p2p-setup-uri-workflow.ts", + "pretest:e2e:obsidian:p2p-connection-check": "npm run build && npm run build --workspace webpeer", + "test:e2e:obsidian:p2p-connection-check": "tsx test/e2e-obsidian/scripts/p2p-connection-check.ts", + "test:e2e:obsidian:p2p-connection-check:services": "npm run test:e2e:obsidian:p2p-connection-check -- --manage-p2p", "test:e2e:obsidian:startup-scan": "tsx test/e2e-obsidian/scripts/startup-scan.ts", + "test:e2e:obsidian:setup-uri-workflow": "tsx test/e2e-obsidian/scripts/setup-uri-workflow.ts", "test:e2e:obsidian:two-vault-sync": "tsx test/e2e-obsidian/scripts/two-vault-sync.ts", + "test:e2e:obsidian:security-seed-reconnect": "tsx test/e2e-obsidian/scripts/security-seed-reconnect.ts", "test:e2e:obsidian:hidden-file-snippet-sync": "tsx test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts", "test:e2e:obsidian:customisation-sync": "tsx test/e2e-obsidian/scripts/customisation-sync.ts", "test:e2e:obsidian:setting-markdown-export": "tsx test/e2e-obsidian/scripts/setting-markdown-export.ts", + "test:e2e:obsidian:upgrade-from-stable": "tsx test/e2e-obsidian/scripts/upgrade-from-stable.ts", "test:e2e:obsidian:local-suite": "tsx test/e2e-obsidian/scripts/local-suite.ts", "test:e2e:obsidian:local-suite:services": "tsx test/e2e-obsidian/scripts/local-suite.ts --manage-services", - "test:coverage": "vitest run --coverage", + "test:docker-p2p:start": "docker compose -f test/fixtures/p2p-relay/compose.yml up -d", + "test:docker-p2p:stop": "docker compose -f test/fixtures/p2p-relay/compose.yml down --volumes", "test:docker-couchdb:up": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/couchdb-start.sh", "test:docker-couchdb:init": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/couchdb-init.sh", "test:docker-couchdb:start": "npm run test:docker-couchdb:up && sleep 5 && npm run test:docker-couchdb:init", @@ -65,18 +94,11 @@ "test:docker-s3:start": "npm run test:docker-s3:up && sleep 3 && npm run test:docker-s3:init", "test:docker-s3:down": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/minio-stop.sh", "test:docker-s3:stop": "npm run test:docker-s3:down", - "test:docker-p2p:up": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/p2p-start.sh", - "test:docker-p2p:init": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/p2p-init.sh", - "test:docker-p2p:start": "npm run test:docker-p2p:up && sleep 3 && npm run test:docker-p2p:init", - "test:docker-p2p:down": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/p2p-stop.sh", - "test:docker-p2p:stop": "npm run test:docker-p2p:down", - "test:docker-all:up": "npm run test:docker-couchdb:up ; npm run test:docker-s3:up ; npm run test:docker-p2p:up", - "test:docker-all:init": "npm run test:docker-couchdb:init ; npm run test:docker-s3:init ; npm run test:docker-p2p:init", - "test:docker-all:down": "npm run test:docker-couchdb:down ; npm run test:docker-s3:down ; npm run test:docker-p2p:down", + "test:docker-all:up": "npm run test:docker-couchdb:up ; npm run test:docker-s3:up", + "test:docker-all:init": "npm run test:docker-couchdb:init ; npm run test:docker-s3:init", + "test:docker-all:down": "npm run test:docker-couchdb:down ; npm run test:docker-s3:down", "test:docker-all:start": "npm run test:docker-all:up && sleep 5 && npm run test:docker-all:init", "test:docker-all:stop": "npm run test:docker-all:down", - "test:full": "npm run test:docker-all:start && vitest run --coverage && npm run test:docker-all:stop", - "test:p2p": "bash test/suitep2p/run-p2p-tests.sh", "update-workspaces": "node update-workspaces.mjs", "version": "node version-bump.mjs && node update-workspaces.mjs && npm run pretty:json && git add manifest.json versions.json src/apps/cli/package.json src/apps/webpeer/package.json src/apps/webapp/package.json" }, @@ -85,8 +107,9 @@ "license": "MIT", "devDependencies": { "@dword-design/eslint-plugin-import-alias": "^8.1.8", + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", "@eslint/js": "^9.39.3", - "@playwright/test": "^1.58.2", "@sveltejs/vite-plugin-svelte": "^7.1.2", "@tsconfig/svelte": "^5.0.8", "@types/deno": "^2.5.0", @@ -103,16 +126,14 @@ "@types/pouchdb-replication": "^6.4.7", "@types/transform-pouch": "^1.0.6", "@typescript-eslint/parser": "8.56.1", - "@vitest/browser": "^4.1.8", - "@vitest/browser-playwright": "^4.1.8", "@vitest/coverage-v8": "^4.1.8", - "@vrtmrz/obsidian-test-session": "0.1.0", + "@vrtmrz/obsidian-test-session": "0.2.6", "dotenv-cli": "^11.0.0", "esbuild": "0.28.1", "esbuild-plugin-inline-worker": "^0.1.1", "esbuild-svelte": "^0.9.4", "eslint": "^9.39.3", - "eslint-plugin-obsidianmd": "^0.3.0", + "eslint-plugin-obsidianmd": "^0.4.1", "eslint-plugin-svelte": "^3.19.0", "events": "^3.3.0", "globals": "^14.0.0", @@ -144,9 +165,7 @@ "vite": "^8.0.16", "vitest": "^4.1.8", "webdriverio": "^9.27.0", - "yaml": "^2.8.2", - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1" + "yaml": "^2.8.2" }, "dependencies": { "@aws-sdk/client-s3": "^3.808.0", @@ -157,17 +176,28 @@ "@smithy/querystring-builder": "^4.2.9", "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", - "@trystero-p2p/nostr": "^0.24.0", + "@vrtmrz/browser-ui-kit": "0.1.0", + "@vrtmrz/livesync-commonlib": "0.1.9", + "@vrtmrz/obsidian-plugin-kit": "0.1.3", + "@vrtmrz/ui-interactions": "0.1.2", "diff-match-patch": "^1.0.5", "fflate": "^0.8.2", "idb": "^8.0.3", "markdown-it": "^14.2.0", "minimatch": "^10.2.5", "obsidian": "^1.13.1", - "octagonal-wheels": "^0.1.51", + "octagonal-wheels": "^0.1.52", "qrcode-generator": "^1.4.4", "xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2" }, + "overrides": { + "pouchdb-core": { + "uuid": "11.1.1" + }, + "pouchdb-utils": { + "uuid": "11.1.1" + } + }, "workspaces": [ "src/apps/cli", "src/apps/webpeer", diff --git a/src/LiveSyncBaseCore.ts b/src/LiveSyncBaseCore.ts index ca4f6a2a..116d7b06 100644 --- a/src/LiveSyncBaseCore.ts +++ b/src/LiveSyncBaseCore.ts @@ -1,22 +1,22 @@ import { LOG_LEVEL_INFO } from "octagonal-wheels/common/logger"; import type PouchDB from "pouchdb-core"; import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase"; -import type { HasSettings, ObsidianLiveSyncSettings, EntryDoc } from "@lib/common/types"; -import { __$checkInstanceBinding } from "@lib/dev/checks"; -import type { Confirm } from "@lib/interfaces/Confirm"; -import type { DatabaseFileAccess } from "@lib/interfaces/DatabaseFileAccess"; -import type { Rebuilder } from "@lib/interfaces/DatabaseRebuilder"; -import type { IFileHandler } from "@lib/interfaces/FileHandler"; -import type { StorageAccess } from "@lib/interfaces/StorageAccess"; -import type { LiveSyncLocalDBEnv } from "@lib/pouchdb/LiveSyncLocalDB"; -import type { LiveSyncCouchDBReplicatorEnv } from "@lib/replication/couchdb/LiveSyncReplicator"; -import type { CheckPointInfo } from "@lib/replication/journal/JournalSyncTypes"; -import type { LiveSyncJournalReplicatorEnv } from "@lib/replication/journal/LiveSyncJournalReplicatorEnv"; -import type { LiveSyncReplicatorEnv } from "@lib/replication/LiveSyncAbstractReplicator"; -import { useTargetFilters } from "@lib/serviceFeatures/targetFilter"; -import { useRemoteConfigurationMigration } from "@lib/serviceFeatures/remoteConfig"; -import type { ServiceContext } from "@lib/services/base/ServiceBase"; -import type { InjectableServiceHub } from "@lib/services/InjectableServices"; +import type { HasSettings, ObsidianLiveSyncSettings, EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { __$checkInstanceBinding } from "@vrtmrz/livesync-commonlib/compat/dev/checks"; +import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm"; +import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess"; +import type { Rebuilder } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseRebuilder"; +import type { IFileHandler } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileHandler"; +import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess"; +import type { LiveSyncLocalDBEnv } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB"; +import type { LiveSyncCouchDBReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator"; +import type { CheckPointInfo } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncTypes"; +import type { LiveSyncJournalReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicatorEnv"; +import type { LiveSyncReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator"; +import { useTargetFilters } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/targetFilter"; +import { useRemoteConfigurationMigration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig"; +import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; +import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub"; import { AbstractModule } from "./modules/AbstractModule"; import { ModulePeriodicProcess } from "./modules/core/ModulePeriodicProcess"; import { ModuleReplicator } from "./modules/core/ModuleReplicator"; @@ -26,10 +26,10 @@ import { ModuleConflictChecker } from "./modules/coreFeatures/ModuleConflictChec import { ModuleConflictResolver } from "./modules/coreFeatures/ModuleConflictResolver"; import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks"; import { ModuleLiveSyncMain } from "./modules/main/ModuleLiveSyncMain"; -import type { ServiceModules } from "@lib/interfaces/ServiceModule"; +import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; import { ModuleBasicMenu } from "./modules/essential/ModuleBasicMenu"; -import { usePrepareDatabaseForUse } from "@lib/serviceFeatures/prepareDatabaseForUse"; -import type { Constructor } from "@lib/common/utils.type"; +import { usePrepareDatabaseForUse } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/prepareDatabaseForUse"; +import type { Constructor } from "@vrtmrz/livesync-commonlib/compat/common/utils.type"; export class LiveSyncBaseCore< T extends ServiceContext = ServiceContext, diff --git a/src/apps/_test/storageAdapterContract.ts b/src/apps/_test/storageAdapterContract.ts index 5693e198..0d325c7e 100644 --- a/src/apps/_test/storageAdapterContract.ts +++ b/src/apps/_test/storageAdapterContract.ts @@ -1,4 +1,4 @@ -import type { IStorageAdapter } from "@lib/serviceModules/adapters"; +import type { IStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters"; /** One platform-neutral storage adapter contract case. */ export interface StorageAdapterContractCase { diff --git a/src/apps/browser/BrowserConfirm.ts b/src/apps/browser/BrowserConfirm.ts new file mode 100644 index 00000000..a7cbc5a7 --- /dev/null +++ b/src/apps/browser/BrowserConfirm.ts @@ -0,0 +1,39 @@ +import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; +import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; +import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; +import { BrowserUiNotifications, createBrowserUi } from "@vrtmrz/browser-ui-kit"; + +import { createNativeElement } from "@/apps/browserDom"; +import { renderMessageMarkdownInto } from "./ui/renderMessageMarkdown"; +import { UiInteractionsConfirm } from "./UiInteractionsConfirm"; + +/** + * Compatibility facade consumed by Commonlib while browser presentation is + * implemented through Fancy Kit's neutral `UiInteractions` contract. + */ +export class BrowserConfirm extends UiInteractionsConfirm { + readonly context: T; + + constructor(context: T) { + const dialogueController = new AbortController(); + const notifications = new BrowserUiNotifications({ + document: _activeDocument, + }); + super({ + ui: createBrowserUi({ + document: _activeDocument, + signal: dialogueController.signal, + renderMarkdown: ({ container, markdown }) => { + renderMessageMarkdownInto(container, markdown); + }, + }), + notifications, + createActionAnchor: () => createNativeElement(_activeDocument, "a"), + }); + this.context = context; + context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => { + dialogueController.abort(); + notifications.dispose(); + }); + } +} diff --git a/src/apps/browser/BrowserKeyValueDatabase.ts b/src/apps/browser/BrowserKeyValueDatabase.ts new file mode 100644 index 00000000..f1f17a1f --- /dev/null +++ b/src/apps/browser/BrowserKeyValueDatabase.ts @@ -0,0 +1,122 @@ +import type { + KeyValueDatabase, + KeyValueDatabaseFactory, +} from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase"; +import { deleteDB, openDB, type IDBPDatabase } from "idb"; +import { serialized } from "octagonal-wheels/concurrency/lock"; + +/** Creates an application-owned IndexedDB key-value factory for browser runtimes. */ +export function createBrowserKeyValueDatabaseFactory(): KeyValueDatabaseFactory { + const cache = new Map(); + + return async (databaseKey) => + await serialized(`OpenBrowserKeyValueDatabase-${databaseKey}`, async () => { + const cached = cache.get(databaseKey); + if (cached && !cached.isDestroyed) { + return cached; + } + if (cached) { + await cached.ensuredDestroyed; + cache.delete(databaseKey); + } + + const database = new BrowserKeyValueDatabase(databaseKey); + await database.getIsReady(); + cache.set(databaseKey, database); + return database; + }); +} + +class BrowserKeyValueDatabase implements KeyValueDatabase { + private databasePromise?: Promise>; + private destroyed = false; + private destroyedPromise?: Promise; + + constructor(private readonly databaseKey: string) {} + + get isDestroyed(): boolean { + return this.destroyed; + } + + get ensuredDestroyed(): Promise { + return this.destroyedPromise ?? Promise.resolve(); + } + + async getIsReady(): Promise { + await this.ensureDatabase(); + return !this.destroyed; + } + + private ensureDatabase(): Promise> { + if (this.destroyed) { + throw new Error("Database is destroyed"); + } + this.databasePromise ??= openDB(this.databaseKey, undefined, { + upgrade: (database) => { + if (!database.objectStoreNames.contains(this.databaseKey)) { + database.createObjectStore(this.databaseKey); + } + }, + blocking: () => { + void this.closeDatabase(); + }, + terminated: () => { + this.databasePromise = undefined; + }, + }).catch((error: unknown) => { + this.databasePromise = undefined; + throw error; + }); + return this.databasePromise; + } + + private get database(): Promise> { + return this.ensureDatabase(); + } + + private async closeDatabase(): Promise { + const databasePromise = this.databasePromise; + this.databasePromise = undefined; + if (databasePromise) { + (await databasePromise).close(); + } + } + + async get(key: IDBValidKey): Promise { + return (await (await this.database).get(this.databaseKey, key)) as T; + } + + async set(key: IDBValidKey, value: T): Promise { + await (await this.database).put(this.databaseKey, value, key); + return key; + } + + async del(key: IDBValidKey): Promise { + await (await this.database).delete(this.databaseKey, key); + } + + async clear(): Promise { + await (await this.database).clear(this.databaseKey); + } + + async keys(query?: IDBValidKey | IDBKeyRange, count?: number): Promise { + return await (await this.database).getAllKeys(this.databaseKey, query, count); + } + + async close(): Promise { + await this.closeDatabase(); + } + + async destroy(): Promise { + if (this.destroyedPromise) { + await this.destroyedPromise; + return; + } + this.destroyed = true; + this.destroyedPromise = (async () => { + await this.closeDatabase(); + await deleteDB(this.databaseKey); + })(); + await this.destroyedPromise; + } +} diff --git a/src/apps/browser/BrowserMenu.ts b/src/apps/browser/BrowserMenu.ts new file mode 100644 index 00000000..55af8921 --- /dev/null +++ b/src/apps/browser/BrowserMenu.ts @@ -0,0 +1,72 @@ +import { promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises"; +import { createNativeElement } from "@/apps/browserDom"; +import { mount } from "svelte"; +import MenuView from "./ui/MenuView.svelte"; +import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; + +export class MenuItem { + type = "item"; + title = ""; + handler?: () => void | Promise; + icon: string = ""; + setTitle(title: string) { + this.title = title; + return this; + } + onClick(callback: () => void | Promise) { + this.handler = callback; + return this; + } + setIcon(icon: string | null) { + this.icon = icon || ""; + return this; + } +} +export class MenuSeparator { + type = "separator"; +} +export class Menu { + type = "menu"; + items: (MenuItem | MenuSeparator)[] = []; + + constructor() {} + addItem(callback: (item: MenuItem) => void) { + const item = new MenuItem(); + callback(item); + this.items.push(item); + return this; + } + addSeparator() { + this.items.push(new MenuSeparator()); + return this; + } + waitingForClose?: PromiseWithResolvers; + showAtPosition(pos: { x: number; y: number }) { + const el = createNativeElement(_activeDocument, "div"); + if (this.waitingForClose) { + this.waitingForClose.resolve(); + } + this.waitingForClose = promiseWithResolvers(); + mount(MenuView, { + target: el, + props: { + items: this.items, + closeMenu: () => { + this.waitingForClose?.resolve(); + this.waitingForClose = undefined; + }, + x: pos.x, + y: pos.y, + }, + }); + _activeDocument.body.appendChild(el); + void this.waitingForClose.promise.finally(() => { + el.remove(); + }); + return this.waitingForClose.promise; + } + hide() { + this.waitingForClose?.resolve(); + this.waitingForClose = undefined; + } +} diff --git a/src/apps/browser/BrowserP2PTransportSettings.svelte b/src/apps/browser/BrowserP2PTransportSettings.svelte new file mode 100644 index 00000000..eed3bc52 --- /dev/null +++ b/src/apps/browser/BrowserP2PTransportSettings.svelte @@ -0,0 +1,133 @@ + + +
+
+ Optional TURN server settings +

+ Configure TURN only when a direct peer-to-peer connection cannot be established. +

+ + + +
+ + +
+
+
+ + diff --git a/src/apps/browser/BrowserSvelteDialogManager.ts b/src/apps/browser/BrowserSvelteDialogManager.ts new file mode 100644 index 00000000..b0e89a0d --- /dev/null +++ b/src/apps/browser/BrowserSvelteDialogManager.ts @@ -0,0 +1,94 @@ +import { + type ComponentHasResult, + SvelteDialogManagerBase, +} from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog"; +import { createNativeElement } from "@/apps/browserDom"; +import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; +import type { SvelteDialogManagerDependencies } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog"; +import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; +import DialogHost from "@/modules/services/LiveSyncUI/DialogHost.svelte"; +import { SvelteDialogSession } from "@/modules/services/SvelteDialogSession"; + +export class BrowserModal { + contentEl: HTMLElement; + titleEl: HTMLElement; + modalEl: HTMLElement; + isOpen: boolean = false; + baseEl: HTMLElement; + constructor() { + const baseEl = createNativeElement(_activeDocument, "popup"); + this.baseEl = baseEl; + this.contentEl = createNativeElement(_activeDocument, "div"); + this.contentEl.className = "modal-content"; + this.titleEl = createNativeElement(_activeDocument, "div"); + this.titleEl.className = "modal-title"; + this.modalEl = createNativeElement(_activeDocument, "div"); + this.modalEl.className = "modal"; + this.modalEl.hidden = true; + this.modalEl.appendChild(this.titleEl); + this.modalEl.appendChild(this.contentEl); + this.baseEl.appendChild(this.modalEl); + } + open() { + this.isOpen = true; + this.modalEl.hidden = false; + if (!this.baseEl.parentElement) { + _activeDocument.body.appendChild(this.baseEl); + } + this.onOpen(); + } + close() { + this.isOpen = false; + this.modalEl.hidden = true; + this.baseEl.remove(); + this.onClose(); + } + onOpen() {} + onClose() {} + setTitle(t: string) { + this.titleEl.textContent = t; + } +} + +export class LiveSyncBrowserDialog extends BrowserModal { + private readonly session: SvelteDialogSession; + + constructor( + context: C, + dependents: SvelteDialogManagerDependencies, + component: ComponentHasResult, + initialData?: U + ) { + super(); + this.session = new SvelteDialogSession({ + surface: this, + context, + dependencies: dependents, + dialogHost: DialogHost, + component, + initialData, + }); + } + + override onOpen(): void { + this.session.onOpen(); + } + + override onClose(): void { + this.session.onClose(); + } + + waitForClose(): Promise { + return this.session.waitForClose(); + } +} +export class BrowserSvelteDialogManager extends SvelteDialogManagerBase { + override async openSvelteDialog( + component: ComponentHasResult, + initialData?: TU + ): Promise { + const dialog = new LiveSyncBrowserDialog(this.context, this.dependents, component, initialData); + dialog.open(); + return await dialog.waitForClose(); + } +} diff --git a/src/apps/browser/LiveSyncBrowserAPIService.ts b/src/apps/browser/LiveSyncBrowserAPIService.ts new file mode 100644 index 00000000..4218772f --- /dev/null +++ b/src/apps/browser/LiveSyncBrowserAPIService.ts @@ -0,0 +1,110 @@ +import type { LOG_LEVEL } from "@vrtmrz/livesync-commonlib/compat/common/logger"; +import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm"; +import type { ICommandCompat } from "@vrtmrz/livesync-commonlib/compat/services/base/IService"; +import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; +import { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAPIService"; +import { FetchHttpHandler } from "@smithy/fetch-http-handler"; +import { _fetch } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; + +declare const MANIFEST_VERSION: string | undefined; +declare const PACKAGE_VERSION: string | undefined; + +export interface LiveSyncBrowserAPIServiceOptions { + confirm: Confirm; + getSystemVaultName(): string; + appId?: string; + isMobile?: () => boolean; + fetch?: typeof _fetch; + addLog?: (message: unknown, level: LOG_LEVEL, key?: string) => void; + addCommand?: (command: TCommand) => TCommand; + showWindow?: (type: string) => Promise; + registerWindow?: (type: string, factory: (leaf: T) => unknown) => void; + addRibbonIcon?: (icon: string, title: string, callback: (event: MouseEvent) => unknown) => HTMLElement; + registerProtocolHandler?: (action: string, handler: (params: Record) => unknown) => void; + addStatusBarItem?: () => HTMLElement | undefined; +} + +/** Browser application implementation of Commonlib's injected host API contract. */ +export class LiveSyncBrowserAPIService extends InjectableAPIService { + private readonly options: LiveSyncBrowserAPIServiceOptions; + + constructor(context: T, options: LiveSyncBrowserAPIServiceOptions) { + super(context); + this.options = options; + this.addLog.setHandler((message, level, key) => { + options.addLog?.(message, level, key); + }); + } + + get confirm(): Confirm { + return this.options.confirm; + } + + getCustomFetchHandler(): FetchHttpHandler { + return new FetchHttpHandler(); + } + + isMobile(): boolean { + return this.options.isMobile?.() ?? false; + } + + showWindow(type: string): Promise { + return this.options.showWindow?.(type) ?? Promise.resolve(); + } + + getAppID(): string { + return this.options.appId ?? this.options.getSystemVaultName(); + } + + getSystemVaultName(): string { + return this.options.getSystemVaultName(); + } + + override getPlatform(): string { + return "browser"; + } + + getAppVersion(): string { + return MANIFEST_VERSION ?? "0.0.0"; + } + + getPluginVersion(): string { + return PACKAGE_VERSION ?? "0.0.0"; + } + + addCommand(command: TCommand): TCommand { + return this.options.addCommand?.(command) ?? command; + } + + registerWindow(type: string, factory: (leaf: T) => unknown): void { + this.options.registerWindow?.(type, factory); + } + + addRibbonIcon( + icon: string, + title: string, + callback: (event: MouseEvent) => unknown + ): HTMLElement { + const element = this.options.addRibbonIcon?.(icon, title, callback); + if (!element) { + throw new Error("Ribbon icons are not supported by this browser application"); + } + return element; + } + + registerProtocolHandler( + action: string, + handler: (params: Record) => unknown + ): void { + this.options.registerProtocolHandler?.(action, handler); + } + + override nativeFetch(request: string | Request, options?: RequestInit): Promise { + const fetchImplementation = this.options.fetch ?? _fetch; + return fetchImplementation(request, options); + } + + addStatusBarItem(): HTMLElement | undefined { + return this.options.addStatusBarItem?.(); + } +} diff --git a/src/apps/browser/LiveSyncBrowserUIService.ts b/src/apps/browser/LiveSyncBrowserUIService.ts new file mode 100644 index 00000000..ea23cf5a --- /dev/null +++ b/src/apps/browser/LiveSyncBrowserUIService.ts @@ -0,0 +1,37 @@ +import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; +import type { AppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService"; +import type { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService"; +import type { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService"; +import type { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService"; +import type { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAPIService"; +import { UIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/UIService"; +import DialogToCopy from "@/modules/services/LiveSyncUI/dialogues/DialogueToCopy.svelte"; +import { BrowserSvelteDialogManager } from "./BrowserSvelteDialogManager"; + +export interface LiveSyncBrowserUIServiceDependencies { + API: InjectableAPIService; + appLifecycle: AppLifecycleService; + config: ConfigService; + control: ControlService; + replicator: ReplicatorService; +} + +export class LiveSyncBrowserUIService extends UIService { + override get dialogToCopy() { + return DialogToCopy; + } + constructor(context: T, dependents: LiveSyncBrowserUIServiceDependencies) { + const browserConfirm = dependents.API.confirm; + const obsidianSvelteDialogManager = new BrowserSvelteDialogManager(context, { + appLifecycle: dependents.appLifecycle, + config: dependents.config, + replicator: dependents.replicator, + confirm: browserConfirm, + control: dependents.control, + }); + super(context, { + dialogManager: obsidianSvelteDialogManager, + APIService: dependents.API, + }); + } +} diff --git a/src/apps/browser/UiInteractionsConfirm.ts b/src/apps/browser/UiInteractionsConfirm.ts new file mode 100644 index 00000000..f9043f79 --- /dev/null +++ b/src/apps/browser/UiInteractionsConfirm.ts @@ -0,0 +1,158 @@ +import type { + Confirm, + ConfirmActionLayout, +} from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm"; +import type { UiInteractions, UiNotifications } from "@vrtmrz/ui-interactions"; + +const DEFAULT_LABELS = { + confirmationTitle: "Confirmation", + selectionTitle: "Select", + yes: "Yes", + no: "No", +} as const; + +export interface UiInteractionsConfirmOptions { + ui: UiInteractions; + notifications: UiNotifications; + createActionAnchor: () => HTMLAnchorElement; +} + +function timeoutOption(timeoutSeconds?: number): { timeoutMs?: number } { + return timeoutSeconds !== undefined && timeoutSeconds > 0 + ? { timeoutMs: timeoutSeconds * 1_000 } + : {}; +} + +/** Adapts Commonlib's legacy confirmation contract to Fancy Kit capabilities. */ +export class UiInteractionsConfirm implements Confirm { + readonly notifications: UiNotifications; + + constructor(private readonly options: UiInteractionsConfirmOptions) { + this.notifications = options.notifications; + } + + async askYesNo(message: string): Promise<"yes" | "no"> { + const result = await this.options.ui.confirmAction( + { + title: DEFAULT_LABELS.confirmationTitle, + message, + actions: ["yes", "no"], + labels: { yes: DEFAULT_LABELS.yes, no: DEFAULT_LABELS.no }, + defaultAction: "no", + actionLayout: "vertical", + }, + "legacy-confirm.ask-yes-no" + ); + return result === "yes" ? "yes" : "no"; + } + + async askString( + title: string, + key: string, + placeholder: string, + isPassword = false + ): Promise { + const prompt = isPassword + ? this.options.ui.promptPassword.bind(this.options.ui) + : this.options.ui.promptText.bind(this.options.ui); + const result = await prompt( + { + title, + label: key, + placeholder, + }, + "legacy-confirm.ask-string" + ); + return result ?? false; + } + + async askYesNoDialog( + message: string, + opt: { title?: string; defaultOption?: "Yes" | "No"; timeout?: number } = {} + ): Promise<"yes" | "no"> { + const result = await this.options.ui.confirmAction( + { + title: opt.title ?? DEFAULT_LABELS.confirmationTitle, + message, + actions: ["Yes", "No"], + labels: { Yes: DEFAULT_LABELS.yes, No: DEFAULT_LABELS.no }, + defaultAction: opt.defaultOption ?? "No", + actionLayout: "vertical", + ...timeoutOption(opt.timeout), + }, + "legacy-confirm.ask-yes-no-dialog" + ); + return result === "Yes" ? "yes" : "no"; + } + + async askSelectString(message: string, items: string[]): Promise { + const result = await this.options.ui.pickOne( + { + items, + getText: (item) => item, + placeholder: message, + }, + "legacy-confirm.ask-select-string" + ); + return result ?? ""; + } + + async askSelectStringDialogue( + message: string, + buttons: T, + opt: { title?: string; defaultAction: T[number]; timeout?: number } + ): Promise { + const result = await this.options.ui.confirmAction( + { + title: opt.title ?? DEFAULT_LABELS.selectionTitle, + message, + actions: buttons, + defaultAction: opt.defaultAction, + actionLayout: "vertical", + ...timeoutOption(opt.timeout), + }, + "legacy-confirm.ask-select-string-dialogue" + ); + return result ?? false; + } + + askInPopup( + key: string, + dialogText: string, + anchorCallback: (anchor: HTMLAnchorElement) => void, + durationMs?: number + ): void { + const anchor = this.options.createActionAnchor(); + anchorCallback(anchor); + this.options.notifications.show(key, { + message: dialogText.replace("{HERE}", "").trim(), + action: { + label: anchor.textContent?.trim() || "Open", + onSelect: () => anchor.click(), + }, + durationMs, + }); + } + + async confirmWithMessage( + title: string, + contentMd: string, + buttons: string[], + defaultAction: (typeof buttons)[number], + timeout?: number, + actionLayout?: ConfirmActionLayout + ): Promise<(typeof buttons)[number] | false> { + const result = await this.options.ui.confirmAction( + { + title, + message: contentMd, + actions: buttons, + defaultAction, + actionLayout: actionLayout ?? "vertical", + ...timeoutOption(timeout), + }, + "legacy-confirm.confirm-with-message" + ); + return result ?? false; + } +} diff --git a/src/apps/browser/createLiveSyncBrowserServiceHub.ts b/src/apps/browser/createLiveSyncBrowserServiceHub.ts new file mode 100644 index 00000000..76c671c0 --- /dev/null +++ b/src/apps/browser/createLiveSyncBrowserServiceHub.ts @@ -0,0 +1,215 @@ +import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { KeyValueDatabaseFactory } from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase"; +import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser"; +import { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService"; +import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService"; +import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService"; +import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService"; +import type { ISettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService"; +import { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; +import { InjectableAppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAppLifecycleService"; +import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService"; +import { InjectableDatabaseEventService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableDatabaseEventService"; +import { InjectableFileProcessingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableFileProcessingService"; +import { PathServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectablePathService"; +import { InjectableRemoteService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableRemoteService"; +import { InjectableReplicationService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicationService"; +import { InjectableReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicatorService"; +import { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub"; +import { InjectableSettingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableSettingService"; +import { InjectableTestService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTestService"; +import { InjectableTweakValueService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTweakValueService"; +import { InjectableVaultServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableVaultService"; + +import { setLang, translateLiveSyncMessage } from "@/common/translation"; +import { BrowserConfirm } from "./BrowserConfirm"; +import { createBrowserKeyValueDatabaseFactory } from "./BrowserKeyValueDatabase"; +import { + LiveSyncBrowserAPIService, + type LiveSyncBrowserAPIServiceOptions, +} from "./LiveSyncBrowserAPIService"; +import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService"; + +export interface LiveSyncBrowserSettingsPersistence { + load(): Promise; + save(settings: ObsidianLiveSyncSettings): Promise; +} + +export interface LiveSyncBrowserRestartPolicy { + schedule(): void; + perform?: () => void; + ask?: (message?: string) => void; + isScheduled?: () => boolean; +} + +export interface LiveSyncBrowserServiceHubOptions { + context?: T; + getSystemVaultName?: () => string; + settings?: LiveSyncBrowserSettingsPersistence; + restart?: LiveSyncBrowserRestartPolicy; + openKeyValueDatabase?: KeyValueDatabaseFactory; + API?: Omit; +} + +class LiveSyncBrowserAppLifecycleService< + T extends ServiceContext, +> extends InjectableAppLifecycleService {} + +class LiveSyncBrowserDatabaseService extends DatabaseService {} + +class LiveSyncBrowserKeyValueDBService extends KeyValueDBService {} + +class LiveSyncBrowserConfigService extends ConfigService { + constructor( + context: T, + private readonly setting: ISettingService + ) { + super(context); + } + + getSmallConfig(key: string): string | null { + return this.setting.getSmallConfig(key); + } + + setSmallConfig(key: string, value: string): void { + this.setting.setSmallConfig(key, value); + } + + deleteSmallConfig(key: string): void { + this.setting.deleteSmallConfig(key); + } +} + +/** LiveSync-owned service composition shared by WebApp and WebPeer. */ +export class LiveSyncBrowserServiceHub extends InjectableServiceHub { + constructor(options: LiveSyncBrowserServiceHubOptions) { + const context = + options.context ?? + (new ServiceContext({ + translate: translateLiveSyncMessage, + }) as T); + const API = new LiveSyncBrowserAPIService(context, { + ...options.API, + confirm: new BrowserConfirm(context), + getSystemVaultName: options.getSystemVaultName ?? (() => "livesync-browser"), + }); + const conflict = new InjectableConflictService(context); + const fileProcessing = new InjectableFileProcessingService(context); + const setting = new InjectableSettingService(context, { + APIService: API, + onDisplayLanguageChanged: setLang, + }); + const settingsPersistence = options.settings; + setting.loadData.setHandler( + settingsPersistence + ? () => settingsPersistence.load() + : () => Promise.resolve(undefined) + ); + setting.saveData.setHandler( + settingsPersistence + ? (settings) => settingsPersistence.save(settings) + : () => Promise.resolve() + ); + + const appLifecycle = new LiveSyncBrowserAppLifecycleService(context, { + settingService: setting, + }); + const restartPolicy = options.restart; + const scheduleRestart = restartPolicy ? () => restartPolicy.schedule() : () => {}; + appLifecycle.scheduleRestart.setHandler(scheduleRestart); + appLifecycle.performRestart.setHandler( + restartPolicy?.perform ? () => restartPolicy.perform?.() : scheduleRestart + ); + appLifecycle.askRestart.setHandler( + restartPolicy?.ask ? (message) => restartPolicy.ask?.(message) : scheduleRestart + ); + appLifecycle.isReloadingScheduled.setHandler( + restartPolicy?.isScheduled ? () => restartPolicy.isScheduled?.() ?? false : () => false + ); + + const databaseEvents = new InjectableDatabaseEventService(context); + const path = new PathServiceCompat(context, { + settingService: setting, + }); + const vault = new InjectableVaultServiceCompat(context, { + settingService: setting, + APIService: API, + }); + const database = new LiveSyncBrowserDatabaseService(context, { + pouchDB: PouchDB, + path, + vault, + setting, + API, + }); + const config = new LiveSyncBrowserConfigService(context, setting); + const replicator = new InjectableReplicatorService(context, { + settingService: setting, + appLifecycleService: appLifecycle, + databaseEventService: databaseEvents, + }); + const remote = new InjectableRemoteService(context, { + pouchDB: PouchDB, + APIService: API, + appLifecycle, + setting, + }); + const replication = new InjectableReplicationService(context, { + APIService: API, + appLifecycleService: appLifecycle, + replicatorService: replicator, + settingService: setting, + fileProcessingService: fileProcessing, + databaseService: database, + }); + const keyValueDB = new LiveSyncBrowserKeyValueDBService(context, { + openKeyValueDatabase: + options.openKeyValueDatabase ?? createBrowserKeyValueDatabaseFactory(), + appLifecycle, + databaseEvents, + vault, + }); + const control = new ControlService(context, { + appLifecycleService: appLifecycle, + databaseService: database, + fileProcessingService: fileProcessing, + settingService: setting, + APIService: API, + replicatorService: replicator, + }); + const ui = new LiveSyncBrowserUIService(context, { + API, + appLifecycle, + config, + control, + replicator, + }); + + super(context, { + API, + appLifecycle, + conflict, + config, + control, + database, + databaseEvents, + fileProcessing, + keyValueDB, + path, + remote, + replication, + replicator, + setting, + test: new InjectableTestService(context), + tweakValue: new InjectableTweakValueService(context), + ui, + vault, + }); + } +} + +export function createLiveSyncBrowserServiceHub( + options: LiveSyncBrowserServiceHubOptions = {} +): LiveSyncBrowserServiceHub { + return new LiveSyncBrowserServiceHub(options); +} diff --git a/src/apps/browser/createLiveSyncBrowserServiceHub.unit.spec.ts b/src/apps/browser/createLiveSyncBrowserServiceHub.unit.spec.ts new file mode 100644 index 00000000..06f90938 --- /dev/null +++ b/src/apps/browser/createLiveSyncBrowserServiceHub.unit.spec.ts @@ -0,0 +1,89 @@ +import { createServiceContext } from "@vrtmrz/livesync-commonlib/context"; +import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + observeServiceComposition, + observeServiceContext, + SERVICE_CONTEXT_MEMBERS, +} from "../../../test/contracts/serviceContext"; +import { + createLiveSyncBrowserServiceHub, + type LiveSyncBrowserServiceHubOptions, +} from "./createLiveSyncBrowserServiceHub"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("LiveSync browser service context contract", () => { + it("preserves one injected context and its API results throughout the Webapp composition", () => { + const context = createServiceContext({ + translate: (key) => `webapp:${key}`, + }); + const hub = createLiveSyncBrowserServiceHub({ context }); + + expect(observeServiceContext(context, "moduleLocalDatabase.logWaitingForReady")).toEqual({ + translation: "webapp:moduleLocalDatabase.logWaitingForReady", + receivedEvents: ["context-contract-event"], + }); + const composition = observeServiceComposition(hub, context); + expect(composition.hubUsesExpectedContext).toBe(true); + expect(SERVICE_CONTEXT_MEMBERS.filter((member) => !composition.servicesUsingExpectedContext[member])).toEqual( + [] + ); + }); + + it("binds browser identity, persistence, restart policy, and native Fetch while composing the hub", async () => { + const deviceLocalSettings = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => deviceLocalSettings.get(key) ?? null, + setItem: (key: string, value: string) => deviceLocalSettings.set(key, value), + removeItem: (key: string) => deviceLocalSettings.delete(key), + clear: () => deviceLocalSettings.clear(), + }); + const savedSettings: (typeof DEFAULT_SETTINGS)[] = []; + const restartCalls: string[] = []; + const nativeFetch = vi.fn(async () => new Response("ok")); + const options: LiveSyncBrowserServiceHubOptions> = { + getSystemVaultName: () => "browser-vault", + settings: { + load: async () => ({ + ...DEFAULT_SETTINGS, + couchDB_DBNAME: "browser-database", + }), + save: async (settings) => { + savedSettings.push(settings); + }, + }, + restart: { + schedule: () => restartCalls.push("schedule"), + perform: () => restartCalls.push("perform"), + ask: (message) => restartCalls.push(`ask:${message ?? ""}`), + isScheduled: () => true, + }, + API: { + fetch: nativeFetch as typeof fetch, + }, + }; + + const hub = createLiveSyncBrowserServiceHub(options); + + expect(hub.API.getSystemVaultName()).toBe("browser-vault"); + expect(hub.API.getPlatform()).toBe("browser"); + const response = await hub.API.nativeFetch("https://example.invalid/"); + expect(await response.text()).toBe("ok"); + expect(nativeFetch).toHaveBeenCalledWith("https://example.invalid/", undefined); + await hub.setting.loadSettings(); + expect(hub.setting.currentSettings().couchDB_DBNAME).toBe("browser-database"); + savedSettings.length = 0; + await hub.setting.saveSettingData(); + expect(savedSettings).toHaveLength(1); + + hub.appLifecycle.scheduleRestart(); + hub.appLifecycle.performRestart(); + hub.appLifecycle.askRestart("restart now"); + expect(hub.appLifecycle.isReloadingScheduled()).toBe(true); + expect(restartCalls).toEqual(["schedule", "perform", "ask:restart now"]); + }); +}); diff --git a/src/apps/browser/tsconfig.json b/src/apps/browser/tsconfig.json new file mode 100644 index 00000000..896bf6eb --- /dev/null +++ b/src/apps/browser/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "noEmit": true, + "paths": { + "@/*": ["../../*"] + } + }, + "include": ["**/*.ts", "**/*.svelte"], + "exclude": ["**/*.unit.spec.ts"] +} diff --git a/src/apps/browser/ui/MenuItemView.svelte b/src/apps/browser/ui/MenuItemView.svelte new file mode 100644 index 00000000..1f7be09f --- /dev/null +++ b/src/apps/browser/ui/MenuItemView.svelte @@ -0,0 +1,52 @@ + + + + + diff --git a/src/apps/browser/ui/MenuSeparatorView.svelte b/src/apps/browser/ui/MenuSeparatorView.svelte new file mode 100644 index 00000000..a254a8d9 --- /dev/null +++ b/src/apps/browser/ui/MenuSeparatorView.svelte @@ -0,0 +1,10 @@ + + +
diff --git a/src/apps/browser/ui/MenuView.svelte b/src/apps/browser/ui/MenuView.svelte new file mode 100644 index 00000000..d2dc352f --- /dev/null +++ b/src/apps/browser/ui/MenuView.svelte @@ -0,0 +1,89 @@ + + + + + +
closeMenu()} onkeydown={handleKey} role="none">
+ + diff --git a/src/apps/browser/ui/renderMessageMarkdown.ts b/src/apps/browser/ui/renderMessageMarkdown.ts new file mode 100644 index 00000000..256aff7b --- /dev/null +++ b/src/apps/browser/ui/renderMessageMarkdown.ts @@ -0,0 +1,31 @@ +import MarkdownIt from "markdown-it"; + +const markdownRenderer = new MarkdownIt({ + html: false, + breaks: true, + linkify: true, +}); + +const defaultLinkOpenRenderer = + markdownRenderer.renderer.rules.link_open ?? + ((tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options)); + +markdownRenderer.renderer.rules.link_open = (tokens, idx, options, env, self) => { + tokens[idx].attrSet("target", "_blank"); + tokens[idx].attrSet("rel", "noopener noreferrer"); + return defaultLinkOpenRenderer(tokens, idx, options, env, self); +}; + +export function renderMessageMarkdown(message: string): string { + return markdownRenderer.render(message); +} + +export function renderMessageMarkdownInto(container: HTMLElement, message: string): void { + const DOMParserConstructor = container.ownerDocument.defaultView?.DOMParser; + if (!DOMParserConstructor) { + container.textContent = message; + return; + } + const parsed = new DOMParserConstructor().parseFromString(renderMessageMarkdown(message), "text/html"); + container.replaceChildren(...Array.from(parsed.body.childNodes)); +} diff --git a/src/apps/browser/ui/renderMessageMarkdown.unit.spec.ts b/src/apps/browser/ui/renderMessageMarkdown.unit.spec.ts new file mode 100644 index 00000000..cb5a53a6 --- /dev/null +++ b/src/apps/browser/ui/renderMessageMarkdown.unit.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { renderMessageMarkdown } from "./renderMessageMarkdown"; + +describe("renderMessageMarkdown", () => { + it("renders basic markdown features used by browser dialogues", () => { + const html = renderMessageMarkdown("# Title\n\n| left | right |\n| --- | --- |\n| a | b |\n"); + + expect(html).toContain("

Title

"); + expect(html).toContain(""); + expect(html).toContain(""); + }); + + it("escapes inline HTML instead of rendering it", () => { + const html = renderMessageMarkdown("BeforeAfter"); + + expect(html).not.toContain(" + +
+ + {scanStatus} +
+ + + + + diff --git a/src/apps/webapp/WebAppRuntime.ts b/src/apps/webapp/WebAppRuntime.ts new file mode 100644 index 00000000..a7a17168 --- /dev/null +++ b/src/apps/webapp/WebAppRuntime.ts @@ -0,0 +1,329 @@ +/** Browser runtime for Self-hosted LiveSync over the File System Access API. */ + +import { LiveSyncBaseCore } from "@/LiveSyncBaseCore"; +import { ServiceContext, type LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context"; +import { initialiseServiceModulesFSAPI, type FSAPIServiceModules } from "./serviceModules/FSAPIServiceModules"; +import { + LOG_LEVEL_INFO, + LOG_LEVEL_NOTICE, + LOG_LEVEL_VERBOSE, + type LOG_LEVEL, + type ObsidianLiveSyncSettings, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { + collectFilesOnStorage, + updateToDatabase, + useOfflineScanner, +} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner"; +import { useRedFlagFeatures } from "@/serviceFeatures/redFlag"; +import { useCheckRemoteSize } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/checkRemoteSize"; +import { useRemoteConfiguration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig"; +import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature"; +import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult"; +import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; +import { + createLiveSyncBrowserServiceHub, + type LiveSyncBrowserServiceHub, + type LiveSyncBrowserServiceHubOptions, +} from "@/apps/browser/createLiveSyncBrowserServiceHub"; +import type { P2PReplicatorPaneHost } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost"; + +const SETTINGS_DIR = ".livesync"; +const SETTINGS_FILE = "settings.json"; + +const DEFAULT_SETTINGS: Partial = { + liveSync: false, + syncOnSave: true, + syncOnStart: false, + savingDelay: 200, + lessInformationInLog: false, + gcDelay: 0, + periodicReplication: false, + periodicReplicationInterval: 60, + isConfigured: false, + // CouchDB settings - user needs to configure these + couchDB_URI: "", + couchDB_USER: "", + couchDB_PASSWORD: "", + couchDB_DBNAME: "", + // Disable features which are not available in the WebApp. + usePluginSync: false, + autoSweepPlugins: false, + autoSweepPluginsPeriodic: false, +}; + +export type WebAppRuntimeStatusKind = "info" | "warning" | "error" | "success"; + +export interface WebAppRuntimeOptions { + reportStatus?: (kind: WebAppRuntimeStatusKind, message: string) => void; + scheduleReload?: (delayMilliseconds: number) => void; +} + +export class WebAppRuntime { + private readonly rootHandle: FileSystemDirectoryHandle; + private readonly reportStatus: NonNullable; + private readonly scheduleReload: NonNullable; + private core: LiveSyncBaseCore | null = null; + private serviceHub: LiveSyncBrowserServiceHub | null = null; + private platformServiceModules: FSAPIServiceModules | null = null; + private p2p: UseP2PReplicatorResult | null = null; + private paneHost: P2PReplicatorPaneHost | null = null; + private restartScheduled = false; + + constructor(rootHandle: FileSystemDirectoryHandle, options: WebAppRuntimeOptions = {}) { + this.rootHandle = rootHandle; + this.reportStatus = options.reportStatus ?? (() => {}); + this.scheduleReload = + options.scheduleReload ?? + ((delayMilliseconds) => { + compatGlobal.setTimeout(() => { + compatGlobal.location.reload(); + }, delayMilliseconds); + }); + } + + private addLog(message: unknown, level: LOG_LEVEL = LOG_LEVEL_INFO, key?: string): void { + this.serviceHub?.API.addLog(message, level, key); + } + + private createServiceOptions(): LiveSyncBrowserServiceHubOptions { + return { + getSystemVaultName: () => this.rootHandle.name || "livesync-webapp", + settings: { + save: async (data) => { + try { + await this.saveSettingsToFile(data); + this.addLog("Saved to .livesync/settings.json", LOG_LEVEL_VERBOSE, "settings"); + } catch (error) { + this.addLog(`Failed to save settings: ${String(error)}`, LOG_LEVEL_NOTICE, "settings"); + } + }, + load: async () => { + try { + const data = await this.loadSettingsFromFile(); + if (data) { + this.addLog("Loaded from .livesync/settings.json", LOG_LEVEL_VERBOSE, "settings"); + return { ...DEFAULT_SETTINGS, ...data } as ObsidianLiveSyncSettings; + } + } catch { + this.addLog("Failed to load settings; using defaults", LOG_LEVEL_NOTICE, "settings"); + } + return DEFAULT_SETTINGS as ObsidianLiveSyncSettings; + }, + }, + restart: { + schedule: () => this.scheduleRestart(), + perform: () => this.scheduleRestart(), + ask: () => this.scheduleRestart(), + isScheduled: () => this.restartScheduled, + }, + }; + } + + private scheduleRestart(): void { + if (this.restartScheduled) { + return; + } + this.restartScheduled = true; + void (async () => { + this.addLog("Restart requested", LOG_LEVEL_INFO, "app-lifecycle"); + await this.shutdown(); + this.scheduleReload(1000); + })(); + } + + get events(): LiveSyncEventHub { + if (!this.serviceHub) { + throw new Error("The WebApp service hub is not initialised"); + } + return this.serviceHub.context.events; + } + + get p2pPaneHost(): P2PReplicatorPaneHost { + if (!this.paneHost) { + throw new Error("The WebApp P2P pane host is not initialised"); + } + return this.paneHost; + } + + async scanLocalFiles(): Promise { + const core = this.core; + const fileAccess = this.platformServiceModules?.vaultAccess; + if (!core || !fileAccess) { + throw new Error("The WebApp core is not initialised"); + } + + fileAccess.fsapiAdapter.clearCache(); + await fileAccess.fsapiAdapter.scanDirectory(); + + const log = (message: unknown, level: LOG_LEVEL = LOG_LEVEL_INFO, key?: string): void => { + this.addLog(message, level, key); + }; + const settings = core.services.setting.currentSettings(); + const { storageFileNameMap, storageFileNames } = await collectFilesOnStorage(core, settings, log); + + let succeeded = true; + for (const path of storageFileNames) { + try { + await updateToDatabase(core, log, LOG_LEVEL_INFO, storageFileNameMap[path]); + } catch (error) { + succeeded = false; + this.addLog(`Failed to import ${path}: ${String(error)}`, LOG_LEVEL_NOTICE, "scan"); + } + } + return succeeded; + } + + async start(): Promise { + if (this.core) { + throw new Error("The WebApp runtime has already been started"); + } + + // Create service context and hub + this.serviceHub = createLiveSyncBrowserServiceHub(this.createServiceOptions()); + this.addLog("Self-hosted LiveSync WebApp", LOG_LEVEL_INFO, "initialise"); + this.addLog("Initialising...", LOG_LEVEL_VERBOSE, "initialise"); + this.addLog(`Vault directory: ${this.rootHandle.name}`, LOG_LEVEL_VERBOSE, "initialise"); + + // Create LiveSync core + this.core = new LiveSyncBaseCore( + this.serviceHub, + (core, serviceHub) => { + const serviceModules = initialiseServiceModulesFSAPI(this.rootHandle, core, serviceHub); + this.platformServiceModules = serviceModules; + return serviceModules; + }, + () => [], + () => [] as never[], // No add-ons + (core) => { + useOfflineScanner(core); + useRedFlagFeatures(core); + useCheckRemoteSize(core); + useRemoteConfiguration(core); + this.p2p = useP2PReplicatorFeature(core); + this.paneHost = { + services: core.services, + p2p: this.p2p, + }; + } + ); + + try { + await this.startCore(); + } catch (error) { + try { + await this.shutdown(); + } catch (shutdownError) { + this.addLog(`Failed to clean up after start failure: ${String(shutdownError)}`, LOG_LEVEL_NOTICE); + } + throw error; + } + } + + private async saveSettingsToFile(data: ObsidianLiveSyncSettings): Promise { + // Create .livesync directory if it does not exist + const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR, { create: true }); + + // Create/overwrite settings.json + const fileHandle = await livesyncDir.getFileHandle(SETTINGS_FILE, { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(JSON.stringify(data, null, 2)); + await writable.close(); + } + + private async loadSettingsFromFile(): Promise | null> { + try { + const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR); + const fileHandle = await livesyncDir.getFileHandle(SETTINGS_FILE); + const file = await fileHandle.getFile(); + const text = await file.text(); + const parsed: unknown = JSON.parse(text); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("The WebApp settings file does not contain an object"); + } + return parsed; + } catch { + // The file does not exist yet. + return null; + } + } + + private async startCore(): Promise { + if (!this.core) { + throw new Error("Core not initialised"); + } + + try { + this.addLog("Initialising LiveSync...", LOG_LEVEL_INFO, "start"); + + const loadResult = await this.core.services.control.onLoad(); + if (!loadResult) { + this.addLog("Failed to initialise LiveSync", LOG_LEVEL_NOTICE, "start"); + throw new Error("Failed to initialise LiveSync"); + } + + await this.core.services.control.onReady(); + + this.addLog("LiveSync is running", LOG_LEVEL_INFO, "ready"); + + // Check if configured + const settings = this.core.services.setting.currentSettings(); + if (!settings.isConfigured) { + this.addLog("LiveSync is not configured yet", LOG_LEVEL_NOTICE, "configuration"); + this.showWarning("Please configure CouchDB connection in settings"); + } else { + this.addLog("LiveSync is configured and ready", LOG_LEVEL_INFO, "configuration"); + this.addLog(`Database: ${settings.couchDB_DBNAME}`, LOG_LEVEL_VERBOSE, "configuration"); + this.showSuccess("LiveSync is ready!"); + } + + // Scan the directory to populate file cache + const fileAccess = this.platformServiceModules?.vaultAccess; + if (fileAccess) { + this.addLog("Scanning vault directory...", LOG_LEVEL_VERBOSE, "scan"); + await fileAccess.fsapiAdapter.scanDirectory(); + const files = await fileAccess.fsapiAdapter.getFiles(); + this.addLog(`Found ${files.length} files`, LOG_LEVEL_VERBOSE, "scan"); + } + } catch (error) { + this.addLog(`Failed to start: ${String(error)}`, LOG_LEVEL_NOTICE, "start"); + this.showError(`Failed to start: ${String(error)}`); + throw error; + } + } + + async shutdown(): Promise { + const core = this.core; + if (!core) { + return; + } + this.core = null; + this.paneHost = null; + this.addLog("Shutting down...", LOG_LEVEL_INFO, "shutdown"); + + const storageEventManager = this.platformServiceModules?.storageEventManager; + this.platformServiceModules = null; + try { + if (storageEventManager) { + await storageEventManager.cleanup(); + } + } finally { + await core.services.control.onUnload(); + this.p2p = null; + this.addLog("Shutdown complete", LOG_LEVEL_INFO, "shutdown"); + this.serviceHub = null; + } + } + + private showError(message: string): void { + this.reportStatus("error", `Error: ${message}`); + } + + private showWarning(message: string): void { + this.reportStatus("warning", `Warning: ${message}`); + } + + private showSuccess(message: string): void { + this.reportStatus("success", message); + } +} diff --git a/src/apps/webapp/adapters/FSAPIConversionAdapter.ts b/src/apps/webapp/adapters/FSAPIConversionAdapter.ts index ee40dca6..e6d5ae96 100644 --- a/src/apps/webapp/adapters/FSAPIConversionAdapter.ts +++ b/src/apps/webapp/adapters/FSAPIConversionAdapter.ts @@ -1,5 +1,5 @@ -import type { FilePath, UXFileInfoStub, UXFolderInfo } from "@lib/common/types"; -import type { IConversionAdapter } from "@lib/serviceModules/adapters"; +import type { FilePath, UXFileInfoStub, UXFolderInfo } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { IConversionAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters"; import type { FSAPIFile, FSAPIFolder } from "./FSAPITypes"; /** diff --git a/src/apps/webapp/adapters/FSAPIFileSystemAdapter.ts b/src/apps/webapp/adapters/FSAPIFileSystemAdapter.ts index 2c982451..432ca0f4 100644 --- a/src/apps/webapp/adapters/FSAPIFileSystemAdapter.ts +++ b/src/apps/webapp/adapters/FSAPIFileSystemAdapter.ts @@ -1,12 +1,13 @@ -import type { FilePath, UXStat } from "@lib/common/types"; -import type { IFileSystemAdapter } from "@lib/serviceModules/adapters"; +import { LOG_LEVEL_NOTICE, type FilePath, type UXStat } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { IFileSystemAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters"; import { FSAPIPathAdapter } from "./FSAPIPathAdapter"; import { FSAPITypeGuardAdapter } from "./FSAPITypeGuardAdapter"; import { FSAPIConversionAdapter } from "./FSAPIConversionAdapter"; -import { FSAPIStorageAdapter } from "./FSAPIStorageAdapter"; +import { FileSystemAccessStorageAdapter } from "@vrtmrz/livesync-commonlib/browser"; import { FSAPIVaultAdapter } from "./FSAPIVaultAdapter"; import type { FSAPIFile, FSAPIFolder, FSAPIStat } from "./FSAPITypes"; import { shareRunningResult } from "octagonal-wheels/concurrency/lock_v2"; +import type { WebAppLog } from "@/apps/webapp/WebAppLog"; /** * Complete file system adapter implementation for FileSystem API @@ -15,29 +16,32 @@ export class FSAPIFileSystemAdapter implements IFileSystemAdapter(); private handleCache = new Map(); - constructor(private rootHandle: FileSystemDirectoryHandle) { + constructor( + private rootHandle: FileSystemDirectoryHandle, + private readonly addLog: WebAppLog + ) { this.path = new FSAPIPathAdapter(); this.typeGuard = new FSAPITypeGuardAdapter(); this.conversion = new FSAPIConversionAdapter(); - this.storage = new FSAPIStorageAdapter(rootHandle); + this.storage = new FileSystemAccessStorageAdapter(rootHandle); this.vault = new FSAPIVaultAdapter(rootHandle); } private normalisePath(path: FilePath | string): string { - return this.path.normalisePath(path as string); + return this.path.normalisePath(path); } /** * Get file handle for a given path */ private async getFileHandleByPath(p: FilePath | string): Promise { - const pathStr = p as string; + const pathStr = p; // Check cache first const cached = this.handleCache.get(pathStr); @@ -53,9 +57,11 @@ export class FSAPIFileSystemAdapter implements IFileSystemAdapter { + await this.vault.rename(file, newPath); + this.clearCache(); + const renamedFile = await this.refreshFile(newPath); + if (!renamedFile) throw new Error(`Could not find renamed file: ${newPath}`); + return renamedFile; + } + async statFromNative(file: FSAPIFile): Promise { // Refresh stat from the file handle try { @@ -203,7 +217,11 @@ export class FSAPIFileSystemAdapter implements IFileSystemAdapter { - constructor(private readonly rootHandle: FileSystemDirectoryHandle) {} - - /** - * Resolve a path to directory and file handles - */ - private async resolvePath(p: string): Promise<{ - dirHandle: FileSystemDirectoryHandle; - fileName: string; - } | null> { - validateStoragePath(p, false); - try { - const parts = p.split("/").filter((part) => part !== ""); - - let currentHandle = this.rootHandle; - const fileName = parts[parts.length - 1]; - - // Navigate to the parent directory - for (let i = 0; i < parts.length - 1; i++) { - currentHandle = await currentHandle.getDirectoryHandle(parts[i]); - } - - return { dirHandle: currentHandle, fileName }; - } catch { - return null; - } - } - - /** - * Get file handle for a given path - */ - private async getFileHandle(p: string): Promise { - validateStoragePath(p); - if (p === "") return null; - const resolved = await this.resolvePath(p); - if (!resolved) return null; - - try { - return await resolved.dirHandle.getFileHandle(resolved.fileName); - } catch { - return null; - } - } - - /** - * Get directory handle for a given path - */ - private async getDirectoryHandle(p: string): Promise { - validateStoragePath(p); - try { - const parts = p.split("/").filter((part) => part !== ""); - if (parts.length === 0) { - return this.rootHandle; - } - - let currentHandle = this.rootHandle; - for (const part of parts) { - currentHandle = await currentHandle.getDirectoryHandle(part); - } - - return currentHandle; - } catch { - return null; - } - } - - /** Resolve a writable file path after creating its parent directories. */ - private async resolveWritablePath(p: string): Promise<{ - dirHandle: FileSystemDirectoryHandle; - fileName: string; - } | null> { - validateStoragePath(p, false); - const parts = p.split("/").filter((part) => part !== ""); - const fileName = parts.pop()!; - const parentPath = parts.join("/"); - await this.mkdir(parentPath); - const dirHandle = await this.getDirectoryHandle(parentPath); - return dirHandle ? { dirHandle, fileName } : null; - } - - async exists(p: string): Promise { - const fileHandle = await this.getFileHandle(p); - if (fileHandle) return true; - - const dirHandle = await this.getDirectoryHandle(p); - return dirHandle !== null; - } - - async trystat(p: string): Promise { - // Try as file first - const fileHandle = await this.getFileHandle(p); - if (fileHandle) { - const file = await fileHandle.getFile(); - return { - size: file.size, - mtime: file.lastModified, - ctime: file.lastModified, - type: "file", - }; - } - - // Try as directory - const dirHandle = await this.getDirectoryHandle(p); - if (dirHandle) { - return { - size: 0, - mtime: Date.now(), - ctime: Date.now(), - type: "folder", - }; - } - - return null; - } - - async stat(p: string): Promise { - return await this.trystat(p); - } - - async mkdir(p: string): Promise { - validateStoragePath(p); - const parts = p.split("/").filter((part) => part !== ""); - let currentHandle = this.rootHandle; - - for (const part of parts) { - currentHandle = await currentHandle.getDirectoryHandle(part, { create: true }); - } - } - - async remove(p: string): Promise { - const resolved = await this.resolvePath(p); - if (!resolved) return; - - await resolved.dirHandle.removeEntry(resolved.fileName, { recursive: true }); - } - - async read(p: string): Promise { - const fileHandle = await this.getFileHandle(p); - if (!fileHandle) { - throw new Error(`File not found: ${p}`); - } - - const file = await fileHandle.getFile(); - return await file.text(); - } - - async readBinary(p: string): Promise { - const fileHandle = await this.getFileHandle(p); - if (!fileHandle) { - throw new Error(`File not found: ${p}`); - } - - const file = await fileHandle.getFile(); - return await file.arrayBuffer(); - } - - async write(p: string, data: string, options?: UXDataWriteOptions): Promise { - const resolved = await this.resolveWritablePath(p); - if (!resolved) { - throw new Error(`Invalid path: ${p}`); - } - - const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true }); - const writable = await fileHandle.createWritable(); - await writable.write(data); - await writable.close(); - } - - async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise { - const resolved = await this.resolveWritablePath(p); - if (!resolved) { - throw new Error(`Invalid path: ${p}`); - } - - const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true }); - const writable = await fileHandle.createWritable(); - await writable.write(data); - await writable.close(); - } - - async append(p: string, data: string, options?: UXDataWriteOptions): Promise { - const existing = await this.exists(p); - if (existing) { - const currentContent = await this.read(p); - await this.write(p, currentContent + data, options); - } else { - await this.write(p, data, options); - } - } - - async list(basePath: string): Promise<{ files: string[]; folders: string[] }> { - const dirHandle = await this.getDirectoryHandle(basePath); - if (!dirHandle) { - return { files: [], folders: [] }; - } - - const files: string[] = []; - const folders: string[] = []; - - // Use AsyncIterator instead of .values() for better compatibility - for await (const [name, entry] of ( - dirHandle as unknown as { - entries(): AsyncIterable<[string, FileSystemHandle]>; - } - ).entries()) { - const entryPath = basePath ? `${basePath}/${name}` : name; - - if (entry.kind === "directory") { - folders.push(entryPath); - } else if (entry.kind === "file") { - files.push(entryPath); - } - } - - return { files, folders }; - } -} diff --git a/src/apps/webapp/adapters/FSAPIStorageAdapter.unit.spec.ts b/src/apps/webapp/adapters/FSAPIStorageAdapter.unit.spec.ts index dde5f912..2859023f 100644 --- a/src/apps/webapp/adapters/FSAPIStorageAdapter.unit.spec.ts +++ b/src/apps/webapp/adapters/FSAPIStorageAdapter.unit.spec.ts @@ -1,12 +1,18 @@ -import { describe, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { storageAdapterContractCases } from "@/apps/_test/storageAdapterContract"; -import { FSAPIStorageAdapter } from "./FSAPIStorageAdapter"; +import { FSAPIFileSystemAdapter } from "./FSAPIFileSystemAdapter"; +import { FileSystemAccessStorageAdapter } from "@vrtmrz/livesync-commonlib/browser"; +import { FSAPIVaultAdapter } from "./FSAPIVaultAdapter"; +import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/types"; class MemoryFileHandle { readonly kind = "file"; private data = new Uint8Array(); - constructor(readonly name: string) {} + constructor( + readonly name: string, + private readonly shouldFailWrite: () => boolean = () => false + ) {} async getFile(): Promise { return new File([this.data], this.name, { lastModified: 1 }); @@ -16,6 +22,7 @@ class MemoryFileHandle { const handle = this; return { async write(data: FileSystemWriteChunkType) { + if (handle.shouldFailWrite()) throw new Error(`write failed: ${handle.name}`); if (typeof data === "string") { handle.data = new TextEncoder().encode(data); } else if (data instanceof ArrayBuffer) { @@ -34,34 +41,49 @@ class MemoryFileHandle { class MemoryDirectoryHandle { readonly kind = "directory"; private readonly children = new Map(); + private readonly failedWriteNames = new Set(); - constructor(readonly name: string) {} + constructor( + readonly name: string, + private readonly caseInsensitive = false + ) {} - async getDirectoryHandle(name: string, options?: FileSystemGetDirectoryOptions): Promise { - const existing = this.children.get(name); + private resolveName(name: string): string { + if (!this.caseInsensitive || this.children.has(name)) return name; + return [...this.children.keys()].find((childName) => childName.toLowerCase() === name.toLowerCase()) ?? name; + } + + async getDirectoryHandle( + name: string, + options?: FileSystemGetDirectoryOptions + ): Promise { + const resolvedName = this.resolveName(name); + const existing = this.children.get(resolvedName); if (existing instanceof MemoryDirectoryHandle) return existing as unknown as FileSystemDirectoryHandle; if (existing !== undefined || !options?.create) throw new DOMException("Directory not found", "NotFoundError"); - const directory = new MemoryDirectoryHandle(name); + const directory = new MemoryDirectoryHandle(name, this.caseInsensitive); this.children.set(name, directory); return directory as unknown as FileSystemDirectoryHandle; } async getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise { - const existing = this.children.get(name); + const resolvedName = this.resolveName(name); + const existing = this.children.get(resolvedName); if (existing instanceof MemoryFileHandle) return existing as unknown as FileSystemFileHandle; if (existing !== undefined || !options?.create) throw new DOMException("File not found", "NotFoundError"); - const file = new MemoryFileHandle(name); + const file = new MemoryFileHandle(name, () => this.failedWriteNames.has(name)); this.children.set(name, file); return file as unknown as FileSystemFileHandle; } async removeEntry(name: string, options?: FileSystemRemoveOptions): Promise { - const existing = this.children.get(name); + const resolvedName = this.resolveName(name); + const existing = this.children.get(resolvedName); if (existing === undefined) throw new DOMException("Entry not found", "NotFoundError"); if (existing instanceof MemoryDirectoryHandle && !options?.recursive && existing.children.size > 0) { throw new DOMException("Directory is not empty", "InvalidModificationError"); } - this.children.delete(name); + this.children.delete(resolvedName); } async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> { @@ -69,13 +91,86 @@ class MemoryDirectoryHandle { yield [name, entry as unknown as FileSystemHandle]; } } + + names(): string[] { + return [...this.children.keys()]; + } + + failWritesTo(name: string): void { + this.failedWriteNames.add(name); + } } -describe("FSAPIStorageAdapter", () => { +describe("FileSystemAccessStorageAdapter", () => { for (const contractCase of storageAdapterContractCases) { it(contractCase.name, async () => { const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle; - await contractCase.run(new FSAPIStorageAdapter(root)); + await contractCase.run(new FileSystemAccessStorageAdapter(root)); }); } }); + +describe("FSAPIVaultAdapter.rename", () => { + it("moves the file through a temporary copy while preserving content", async () => { + const memoryRoot = new MemoryDirectoryHandle("root"); + const root = memoryRoot as unknown as FileSystemDirectoryHandle; + const adapter = new FSAPIVaultAdapter(root); + const file = await adapter.create("Calculus.md", "content"); + + await adapter.rename(file, "calculus.md"); + + expect(memoryRoot.names()).toEqual(["calculus.md"]); + const renamedHandle = await root.getFileHandle("calculus.md"); + expect(await (await renamedHandle.getFile()).text()).toBe("content"); + expect(file.path).toBe("calculus.md"); + }); + + it("removes a partially created target before restoring the source", async () => { + const memoryRoot = new MemoryDirectoryHandle("root"); + const root = memoryRoot as unknown as FileSystemDirectoryHandle; + const adapter = new FSAPIVaultAdapter(root); + const file = await adapter.create("Calculus.md", "content"); + memoryRoot.failWritesTo("calculus.md"); + + await expect(adapter.rename(file, "calculus.md")).rejects.toThrow("write failed"); + + expect(memoryRoot.names()).toEqual(["Calculus.md"]); + const restoredHandle = await root.getFileHandle("Calculus.md"); + expect(await (await restoredHandle.getFile()).text()).toBe("content"); + expect(file.path).toBe("Calculus.md"); + }); +}); + +describe("FSAPIFileSystemAdapter path case", () => { + it("returns the stored path case from a case-insensitive file system", async () => { + const memoryRoot = new MemoryDirectoryHandle("root", true); + const root = memoryRoot as unknown as FileSystemDirectoryHandle; + const vault = new FSAPIVaultAdapter(root); + await vault.create("Calculus.md", "content"); + const adapter = new FSAPIFileSystemAdapter(root, vi.fn()); + + await expect(adapter.getAbstractFileByPath("calculus.md")).resolves.toBeNull(); + await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toEqual( + expect.objectContaining({ path: "Calculus.md" }) + ); + }); + + it("reports scan failures through the injected Webapp log", async () => { + const root = { + name: "root", + async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> { + throw new Error("scan failed"); + }, + } as unknown as FileSystemDirectoryHandle; + const addLog = vi.fn(); + const adapter = new FSAPIFileSystemAdapter(root, addLog); + + await adapter.scanDirectory(); + + expect(addLog).toHaveBeenCalledWith( + "Error scanning directory '.': Error: scan failed", + LOG_LEVEL_NOTICE, + "fsapi-scan" + ); + }); +}); diff --git a/src/apps/webapp/adapters/FSAPITypeGuardAdapter.ts b/src/apps/webapp/adapters/FSAPITypeGuardAdapter.ts index a8d1b8d8..d3563c5f 100644 --- a/src/apps/webapp/adapters/FSAPITypeGuardAdapter.ts +++ b/src/apps/webapp/adapters/FSAPITypeGuardAdapter.ts @@ -1,4 +1,4 @@ -import type { ITypeGuardAdapter } from "@lib/serviceModules/adapters"; +import type { ITypeGuardAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters"; import type { FSAPIFile, FSAPIFolder } from "./FSAPITypes"; /** diff --git a/src/apps/webapp/adapters/FSAPITypes.ts b/src/apps/webapp/adapters/FSAPITypes.ts index 91b34071..9fca871e 100644 --- a/src/apps/webapp/adapters/FSAPITypes.ts +++ b/src/apps/webapp/adapters/FSAPITypes.ts @@ -1,4 +1,4 @@ -import type { FilePath, UXStat } from "@lib/common/types"; +import type { FilePath, UXStat } from "@vrtmrz/livesync-commonlib/compat/common/types"; /** * FileSystem API file representation diff --git a/src/apps/webapp/adapters/FSAPIVaultAdapter.ts b/src/apps/webapp/adapters/FSAPIVaultAdapter.ts index 4f548ca2..9e2d8e27 100644 --- a/src/apps/webapp/adapters/FSAPIVaultAdapter.ts +++ b/src/apps/webapp/adapters/FSAPIVaultAdapter.ts @@ -1,5 +1,5 @@ -import type { FilePath, UXDataWriteOptions } from "@lib/common/types"; -import type { IVaultAdapter } from "@lib/serviceModules/adapters"; +import type { FilePath, UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { IVaultAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters"; import type { FSAPIFile, FSAPIFolder } from "./FSAPITypes"; /** @@ -97,6 +97,70 @@ export class FSAPIVaultAdapter implements IVaultAdapter { }; } + private async deletePathIfExists(path: string): Promise { + const parts = path.split("/").filter((part) => part !== ""); + const name = parts.pop(); + if (!name) return; + + try { + let currentHandle = this.rootHandle; + for (const part of parts) { + currentHandle = await currentHandle.getDirectoryHandle(part); + } + await currentHandle.removeEntry(name); + } catch (error) { + if ((error as { name?: unknown })?.name === "NotFoundError") return; + throw error; + } + } + + async rename(file: FSAPIFile, newPath: string): Promise { + const source = await file.handle.getFile(); + const data = await source.arrayBuffer(); + const oldPath = file.path; + const oldPathParts = oldPath.split("/"); + const oldName = oldPathParts.pop() ?? "file"; + const temporaryName = `.${oldName}.livesync-rename-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; + const temporaryPath = [...oldPathParts, temporaryName].join("/"); + const temporaryFile = await this.createBinary(temporaryPath, data); + + try { + await this.delete(file); + } catch (error) { + await this.delete(temporaryFile, true); + throw error; + } + + try { + const renamedFile = await this.createBinary(newPath, data); + file.path = renamedFile.path; + file.stat = renamedFile.stat; + file.handle = renamedFile.handle; + } catch (error) { + try { + await this.deletePathIfExists(newPath); + } catch (cleanupError) { + throw new Error( + `Could not rename ${oldPath} to ${newPath}, or remove the incomplete target. A temporary copy remains at ${temporaryPath}. Rename error: ${String(error)}. Cleanup error: ${String(cleanupError)}` + ); + } + try { + const restoredFile = await this.createBinary(oldPath, data); + file.path = restoredFile.path; + file.stat = restoredFile.stat; + file.handle = restoredFile.handle; + await this.delete(temporaryFile, true); + } catch (restoreError) { + throw new Error( + `Could not rename ${oldPath} to ${newPath}, or restore it. A temporary copy remains at ${temporaryPath}. Rename error: ${String(error)}. Restore error: ${String(restoreError)}` + ); + } + throw error; + } + + await this.delete(temporaryFile, true); + } + async delete(file: FSAPIFile | FSAPIFolder, force = false): Promise { const parts = file.path.split("/").filter((part) => part !== ""); const name = parts[parts.length - 1]; diff --git a/src/apps/webapp/bootstrap.ts b/src/apps/webapp/bootstrap.ts deleted file mode 100644 index 41d871cf..00000000 --- a/src/apps/webapp/bootstrap.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { LiveSyncWebApp } from "./main"; -import { VaultHistoryStore, type VaultHistoryItem } from "./vaultSelector"; -import { compatGlobal, _activeDocument } from "@lib/common/coreEnvFunctions.ts"; - -const historyStore = new VaultHistoryStore(); -let app: LiveSyncWebApp | null = null; - -function getRequiredElement(id: string): T { - const element = _activeDocument.getElementById(id); - if (!element) { - throw new Error(`Missing element: #${id}`); - } - return element as T; -} - -function setStatus(kind: "info" | "warning" | "error" | "success", message: string): void { - const statusEl = getRequiredElement("status"); - statusEl.className = kind; - statusEl.textContent = message; -} - -function setBusyState(isBusy: boolean): void { - const pickNewBtn = getRequiredElement("pick-new-vault"); - pickNewBtn.disabled = isBusy; - - const historyButtons = _activeDocument.querySelectorAll(".vault-item button"); - historyButtons.forEach((button) => { - button.disabled = isBusy; - }); -} - -function formatLastUsed(unixMillis: number): string { - if (!unixMillis) { - return "unknown"; - } - return new Date(unixMillis).toLocaleString(); -} - -async function renderHistoryList(): Promise { - const listEl = getRequiredElement("vault-history-list"); - const emptyEl = getRequiredElement("vault-history-empty"); - - const [items, lastUsedId] = await Promise.all([historyStore.getVaultHistory(), historyStore.getLastUsedVaultId()]); - - listEl.replaceChildren(); - emptyEl.classList.toggle("is-hidden", items.length > 0); - - for (const item of items) { - const row = _activeDocument.createElement("div"); - row.className = "vault-item"; - - const info = _activeDocument.createElement("div"); - info.className = "vault-item-info"; - - const name = _activeDocument.createElement("div"); - name.className = "vault-item-name"; - name.textContent = item.name; - - const meta = _activeDocument.createElement("div"); - meta.className = "vault-item-meta"; - const label = item.id === lastUsedId ? "Last used" : "Used"; - meta.textContent = `${label}: ${formatLastUsed(item.lastUsedAt)}`; - - info.append(name, meta); - - const useButton = _activeDocument.createElement("button"); - useButton.type = "button"; - useButton.textContent = "Use this vault"; - useButton.addEventListener("click", () => { - void startWithHistory(item); - }); - - row.append(info, useButton); - listEl.appendChild(row); - } - - return items; -} - -async function startWithHandle(handle: FileSystemDirectoryHandle): Promise { - setStatus("info", `Starting LiveSync with vault: ${handle.name}`); - app = new LiveSyncWebApp(handle); - await app.initialize(); - - const selectorEl = getRequiredElement("vault-selector"); - selectorEl.classList.add("is-hidden"); -} - -async function startWithHistory(item: VaultHistoryItem): Promise { - setBusyState(true); - try { - const handle = await historyStore.activateHistoryItem(item); - await startWithHandle(handle); - } catch (error) { - console.error("[Directory] Failed to open history vault:", error); - setStatus("error", `Failed to open saved vault: ${String(error)}`); - setBusyState(false); - } -} - -async function startWithNewPicker(): Promise { - setBusyState(true); - try { - const handle = await historyStore.pickNewVault(); - await startWithHandle(handle); - } catch (error) { - console.error("[Directory] Failed to pick vault:", error); - setStatus("warning", `Vault selection was cancelled or failed: ${String(error)}`); - setBusyState(false); - } -} - -async function initializeVaultSelector(): Promise { - setStatus("info", "Select a vault folder to start LiveSync."); - - const pickNewBtn = getRequiredElement("pick-new-vault"); - pickNewBtn.addEventListener("click", () => { - void startWithNewPicker(); - }); - - await renderHistoryList(); -} - -compatGlobal.addEventListener("load", () => { - initializeVaultSelector().catch((error) => { - console.error("Failed to initialize vault selector:", error); - setStatus("error", `Initialization failed: ${String(error)}`); - }); -}); - -compatGlobal.addEventListener("beforeunload", () => { - void app?.shutdown(); -}); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- patching -(compatGlobal as any).livesyncApp = { - getApp: () => app, - historyStore, -}; diff --git a/src/apps/webapp/main.ts b/src/apps/webapp/main.ts index 46589eef..4c8c014e 100644 --- a/src/apps/webapp/main.ts +++ b/src/apps/webapp/main.ts @@ -1,265 +1,172 @@ -/** - * Self-hosted LiveSync WebApp - * Browser-based version of Self-hosted LiveSync plugin using FileSystem API - */ +import { createNativeElement } from "@/apps/browserDom"; +import { compatGlobal, _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; +import { mount } from "svelte"; -import { BrowserServiceHub } from "@lib/services/BrowserServices"; -import { LiveSyncBaseCore } from "@/LiveSyncBaseCore"; -import { ServiceContext } from "@lib/services/base/ServiceBase"; -import { initialiseServiceModulesFSAPI } from "./serviceModules/FSAPIServiceModules"; -import type { ObsidianLiveSyncSettings } from "@lib/common/types"; -import type { BrowserAPIService } from "@lib/services/implements/browser/BrowserAPIService"; -import type { InjectableSettingService } from "@lib/services/implements/injectable/InjectableSettingService"; -import { useOfflineScanner } from "@lib/serviceFeatures/offlineScanner"; -import { useRedFlagFeatures } from "@/serviceFeatures/redFlag"; -import { useCheckRemoteSize } from "@lib/serviceFeatures/checkRemoteSize"; -import { useSetupURIFeature } from "@lib/serviceFeatures/setupObsidian/setupUri"; -import { useRemoteConfiguration } from "@lib/serviceFeatures/remoteConfig"; -import { SetupManager } from "@/modules/features/SetupManager"; -import { useSetupManagerHandlersFeature } from "@/serviceFeatures/setupObsidian/setupManagerHandlers"; -import { useP2PReplicatorCommands } from "@lib/replication/trystero/useP2PReplicatorCommands"; -import { useP2PReplicatorFeature } from "@lib/replication/trystero/useP2PReplicatorFeature"; -import { compatGlobal, _activeDocument } from "@lib/common/coreEnvFunctions.ts"; +import { WebAppRuntime, type WebAppRuntimeStatusKind } from "./WebAppRuntime"; +import WebAppP2P from "./WebAppP2P.svelte"; +import { VaultHistoryStore, type VaultHistoryItem } from "./vaultSelector"; -const SETTINGS_DIR = ".livesync"; -const SETTINGS_FILE = "settings.json"; +const historyStore = new VaultHistoryStore(); +let runtime: WebAppRuntime | null = null; -/** - * Default settings for the webapp - */ -const DEFAULT_SETTINGS: Partial = { - liveSync: false, - syncOnSave: true, - syncOnStart: false, - savingDelay: 200, - lessInformationInLog: false, - gcDelay: 0, - periodicReplication: false, - periodicReplicationInterval: 60, - isConfigured: false, - // CouchDB settings - user needs to configure these - couchDB_URI: "", - couchDB_USER: "", - couchDB_PASSWORD: "", - couchDB_DBNAME: "", - // Disable features not needed in webapp - usePluginSync: false, - autoSweepPlugins: false, - autoSweepPluginsPeriodic: false, +type LiveSyncWebAppDebugApi = { + getRuntime: () => WebAppRuntime | null; + historyStore: VaultHistoryStore; }; -class LiveSyncWebApp { - private rootHandle: FileSystemDirectoryHandle; - private core: LiveSyncBaseCore | null = null; - private serviceHub: BrowserServiceHub | null = null; +type LiveSyncWebAppGlobal = typeof compatGlobal & { + livesyncApp?: LiveSyncWebAppDebugApi; +}; - constructor(rootHandle: FileSystemDirectoryHandle) { - this.rootHandle = rootHandle; +function getRequiredElement(id: string): T { + const element = _activeDocument.getElementById(id); + if (!element) { + throw new Error(`Missing element: #${id}`); } + return element as T; +} - async initialize() { - console.log("Self-hosted LiveSync WebApp"); - console.log("Initializing..."); +function setStatus(kind: WebAppRuntimeStatusKind, message: string): void { + const statusEl = getRequiredElement("status"); + statusEl.className = kind; + statusEl.textContent = message; +} - console.log(`Vault directory: ${this.rootHandle.name}`); +function setBusyState(isBusy: boolean): void { + const pickNewBtn = getRequiredElement("pick-new-vault"); + pickNewBtn.disabled = isBusy; - // Create service context and hub - this.serviceHub = new BrowserServiceHub(); + const historyButtons = _activeDocument.querySelectorAll(".vault-item button"); + historyButtons.forEach((button) => { + button.disabled = isBusy; + }); +} - // Setup API service - (this.serviceHub.API as BrowserAPIService).getSystemVaultName.setHandler( - () => this.rootHandle?.name || "livesync-webapp" - ); +function formatLastUsed(unixMillis: number): string { + if (!unixMillis) { + return "unknown"; + } + return new Date(unixMillis).toLocaleString(); +} - // Setup settings handlers - save to .livesync folder - const settingService = this.serviceHub.setting as InjectableSettingService; +async function renderHistoryList(): Promise { + const listEl = getRequiredElement("vault-history-list"); + const emptyEl = getRequiredElement("vault-history-empty"); - settingService.saveData.setHandler(async (data: ObsidianLiveSyncSettings) => { - try { - await this.saveSettingsToFile(data); - console.log("[Settings] Saved to .livesync/settings.json"); - } catch (error) { - console.error("[Settings] Failed to save:", error); - } + const [items, lastUsedId] = await Promise.all([historyStore.getVaultHistory(), historyStore.getLastUsedVaultId()]); + + listEl.replaceChildren(); + emptyEl.classList.toggle("is-hidden", items.length > 0); + + for (const item of items) { + const row = createNativeElement(_activeDocument, "div"); + row.className = "vault-item"; + + const info = createNativeElement(_activeDocument, "div"); + info.className = "vault-item-info"; + + const name = createNativeElement(_activeDocument, "div"); + name.className = "vault-item-name"; + name.textContent = item.name; + + const meta = createNativeElement(_activeDocument, "div"); + meta.className = "vault-item-meta"; + const label = item.id === lastUsedId ? "Last used" : "Used"; + meta.textContent = `${label}: ${formatLastUsed(item.lastUsedAt)}`; + + info.append(name, meta); + + const useButton = createNativeElement(_activeDocument, "button"); + useButton.type = "button"; + useButton.textContent = "Use this vault"; + useButton.addEventListener("click", () => { + void startWithHistory(item); }); - settingService.loadData.setHandler(async (): Promise => { - try { - const data = await this.loadSettingsFromFile(); - if (data) { - console.log("[Settings] Loaded from .livesync/settings.json"); - return { ...DEFAULT_SETTINGS, ...data } as ObsidianLiveSyncSettings; - } - } catch { - console.log("[Settings] Failed to load, using defaults"); - } - return DEFAULT_SETTINGS as ObsidianLiveSyncSettings; - }); - - // App lifecycle handlers - this.serviceHub.appLifecycle.scheduleRestart.setHandler(() => { - void (async () => { - console.log("[AppLifecycle] Restart requested"); - await this.shutdown(); - await this.initialize(); - compatGlobal.setTimeout(() => { - compatGlobal.location.reload(); - }, 1000); - })(); - }); - - // Create LiveSync core - this.core = new LiveSyncBaseCore( - this.serviceHub, - (core, serviceHub) => { - return initialiseServiceModulesFSAPI(this.rootHandle, core, serviceHub); - }, - (core) => [ - // new ModuleObsidianEvents(this, core), - // new ModuleObsidianSettingDialogue(this, core), - // new ModuleObsidianMenu(core), - // new ModuleObsidianSettingsAsMarkdown(core), - // new ModuleLog(this, core), - // new ModuleObsidianDocumentHistory(this, core), - // new ModuleInteractiveConflictResolver(this, core), - // new ModuleObsidianGlobalHistory(this, core), - // new ModuleDev(this, core), - // new ModuleReplicateTest(this, core), - // new ModuleIntegratedTest(this, core), - // new ModuleReplicatorP2P(core), // Register P2P replicator for CLI (useP2PReplicator is not used here) - new SetupManager(core), - ], - () => [] as never[], // No add-ons - (core) => { - useOfflineScanner(core); - useRedFlagFeatures(core); - useCheckRemoteSize(core); - useRemoteConfiguration(core); - const replicator = useP2PReplicatorFeature(core); - useP2PReplicatorCommands(core, replicator); - const setupManager = core.getModule(SetupManager); - useSetupManagerHandlersFeature(core, setupManager); - useSetupURIFeature(core); - } - ); - - // Start the core - await this.start(); + row.append(info, useButton); + listEl.appendChild(row); } - private async saveSettingsToFile(data: ObsidianLiveSyncSettings): Promise { - try { - // Create .livesync directory if it doesn't exist - const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR, { create: true }); + return items; +} - // Create/overwrite settings.json - const fileHandle = await livesyncDir.getFileHandle(SETTINGS_FILE, { create: true }); - const writable = await fileHandle.createWritable(); - await writable.write(JSON.stringify(data, null, 2)); - await writable.close(); - } catch (error) { - console.error("[Settings] Error saving to file:", error); - throw error; - } +async function startWithHandle(handle: FileSystemDirectoryHandle): Promise { + setStatus("info", `Starting LiveSync with vault: ${handle.name}`); + const nextRuntime = new WebAppRuntime(handle, { reportStatus: setStatus }); + await nextRuntime.start(); + runtime = nextRuntime; + + const selectorEl = getRequiredElement("vault-selector"); + selectorEl.classList.add("is-hidden"); + + const p2pControl = getRequiredElement("p2p-control"); + mount(WebAppP2P, { + target: p2pControl, + props: { + runtime: nextRuntime, + }, + }); + p2pControl.classList.remove("is-hidden"); +} + +async function startWithHistory(item: VaultHistoryItem): Promise { + setBusyState(true); + let handle: FileSystemDirectoryHandle; + try { + handle = await historyStore.activateHistoryItem(item); + } catch (error) { + setStatus("error", `Failed to open saved vault: ${String(error)}`); + setBusyState(false); + return; } - - private async loadSettingsFromFile(): Promise | null> { - try { - const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR); - const fileHandle = await livesyncDir.getFileHandle(SETTINGS_FILE); - const file = await fileHandle.getFile(); - const text = await file.text(); - return JSON.parse(text); - } catch { - // File doesn't exist yet - return null; - } - } - - private async start() { - if (!this.core) { - throw new Error("Core not initialized"); - } - - try { - console.log("[Starting] Initializing LiveSync..."); - - const loadResult = await this.core.services.control.onLoad(); - if (!loadResult) { - console.error("[Error] Failed to initialize LiveSync"); - this.showError("Failed to initialize LiveSync"); - return; - } - - await this.core.services.control.onReady(); - - console.log("[Ready] LiveSync is running"); - - // Check if configured - const settings = this.core.services.setting.currentSettings(); - if (!settings.isConfigured) { - console.warn("[Warning] LiveSync is not configured yet"); - this.showWarning("Please configure CouchDB connection in settings"); - } else { - console.log("[Info] LiveSync is configured and ready"); - console.log(`[Info] Database: ${settings.couchDB_URI}/${settings.couchDB_DBNAME}`); - this.showSuccess("LiveSync is ready!"); - } - - // Scan the directory to populate file cache - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Accessing private service modules - const fileAccess = (this.core as any)._serviceModules?.storageAccess?.vaultAccess; - if (fileAccess?.fsapiAdapter) { - console.log("[Scanning] Scanning vault directory..."); - await fileAccess.fsapiAdapter.scanDirectory(); - const files = await fileAccess.fsapiAdapter.getFiles(); - console.log(`[Scanning] Found ${files.length} files`); - } - } catch (error) { - console.error("[Error] Failed to start:", error); - this.showError(`Failed to start: ${error}`); - } - } - - async shutdown() { - if (this.core) { - console.log("[Shutdown] Shutting down..."); - - // Stop file watching - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Accessing private service modules - const storageEventManager = (this.core as any)._serviceModules?.storageAccess?.storageEventManager; - if (storageEventManager?.cleanup) { - await storageEventManager.cleanup(); - } - - await this.core.services.control.onUnload(); - console.log("[Shutdown] Complete"); - } - } - - private showError(message: string) { - const statusEl = _activeDocument.getElementById("status"); - if (statusEl) { - statusEl.className = "error"; - statusEl.textContent = `Error: ${message}`; - } - } - - private showWarning(message: string) { - const statusEl = _activeDocument.getElementById("status"); - if (statusEl) { - statusEl.className = "warning"; - statusEl.textContent = `Warning: ${message}`; - } - } - - private showSuccess(message: string) { - const statusEl = _activeDocument.getElementById("status"); - if (statusEl) { - statusEl.className = "success"; - statusEl.textContent = message; - } + try { + await startWithHandle(handle); + } catch (error) { + setStatus("error", `Failed to start LiveSync: ${String(error)}`); + setBusyState(false); } } -export { LiveSyncWebApp }; +async function startWithNewPicker(): Promise { + setBusyState(true); + let handle: FileSystemDirectoryHandle; + try { + handle = await historyStore.pickNewVault(); + } catch (error) { + setStatus("warning", `Vault selection was cancelled or failed: ${String(error)}`); + setBusyState(false); + return; + } + try { + await startWithHandle(handle); + } catch (error) { + setStatus("error", `Failed to start LiveSync: ${String(error)}`); + setBusyState(false); + } +} + +async function initialiseVaultSelector(): Promise { + setStatus("info", "Select a vault folder to start LiveSync."); + + const pickNewBtn = getRequiredElement("pick-new-vault"); + pickNewBtn.addEventListener("click", () => { + void startWithNewPicker(); + }); + + await renderHistoryList(); +} + +compatGlobal.addEventListener("load", () => { + initialiseVaultSelector().catch((error) => { + setStatus("error", `Initialisation failed: ${String(error)}`); + }); +}); + +compatGlobal.addEventListener("beforeunload", () => { + void runtime?.shutdown(); +}); + +(compatGlobal as LiveSyncWebAppGlobal).livesyncApp = { + getRuntime: () => runtime, + historyStore, +}; diff --git a/src/apps/webapp/managers/FSAPIStorageEventManagerAdapter.ts b/src/apps/webapp/managers/FSAPIStorageEventManagerAdapter.ts index 9a5e99c0..6594b61d 100644 --- a/src/apps/webapp/managers/FSAPIStorageEventManagerAdapter.ts +++ b/src/apps/webapp/managers/FSAPIStorageEventManagerAdapter.ts @@ -1,6 +1,6 @@ -import type { FilePath, UXFileInfoStub, UXInternalFileInfoStub } from "@lib/common/types"; -import type { FileEventItem } from "@lib/common/types"; -import type { IStorageEventManagerAdapter } from "@lib/managers/adapters"; +import type { FilePath, UXFileInfoStub, UXInternalFileInfoStub } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { FileEventItem } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { IStorageEventManagerAdapter } from "@vrtmrz/livesync-commonlib/compat/managers/adapters"; import type { IStorageEventTypeGuardAdapter, IStorageEventPersistenceAdapter, @@ -8,10 +8,31 @@ import type { IStorageEventStatusAdapter, IStorageEventConverterAdapter, IStorageEventWatchHandlers, -} from "@lib/managers/adapters"; -import type { FileEventItemSentinel } from "@lib/managers/StorageEventManager"; +} from "@vrtmrz/livesync-commonlib/compat/managers/adapters"; +import type { FileEventItemSentinel } from "@vrtmrz/livesync-commonlib/compat/managers/StorageEventManager"; import type { FSAPIFile, FSAPIFolder } from "@/apps/webapp/adapters/FSAPITypes"; -import { compatGlobal } from "@lib/common/coreEnvFunctions.ts"; +import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; +import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { WebAppLog } from "@/apps/webapp/WebAppLog"; + +type FileSystemObserverRecord = { + changedHandle?: FileSystemFileHandle | FileSystemDirectoryHandle; + relativePathComponents?: readonly string[]; + type: "appeared" | "disappeared" | "modified" | "moved" | "unknown" | "errored"; +}; + +type FileSystemObserverInstance = { + observe(handle: FileSystemDirectoryHandle, options: { recursive: boolean }): Promise; + disconnect(): void; +}; + +type FileSystemObserverConstructor = new ( + callback: (records: readonly FileSystemObserverRecord[]) => void | Promise +) => FileSystemObserverInstance; + +type GlobalWithFileSystemObserver = typeof compatGlobal & { + FileSystemObserver?: FileSystemObserverConstructor; +}; /** * FileSystem API-specific type guard adapter @@ -47,11 +68,14 @@ class FSAPIPersistenceAdapter implements IStorageEventPersistenceAdapter { private storeName = "snapshots"; private snapshotKey = "file-events"; + constructor(private readonly addLog: WebAppLog) {} + private async openDB(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(this.dbName, 1); - request.onerror = () => reject(request.error); + request.onerror = () => + reject(request.error ?? new Error("Failed to open the WebApp snapshot database")); request.onsuccess = () => resolve(request.result); request.onupgradeneeded = (event) => { @@ -72,12 +96,13 @@ class FSAPIPersistenceAdapter implements IStorageEventPersistenceAdapter { await new Promise((resolve, reject) => { const request = store.put(snapshot, this.snapshotKey); request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); + request.onerror = () => + reject(request.error ?? new Error("Failed to save the WebApp storage-event snapshot")); }); db.close(); } catch (error) { - console.error("Failed to save snapshot:", error); + this.addLog(`Failed to save snapshot: ${String(error)}`, LOG_LEVEL_NOTICE, "fsapi-snapshot"); } } @@ -89,8 +114,10 @@ class FSAPIPersistenceAdapter implements IStorageEventPersistenceAdapter { const result = await new Promise<(FileEventItem | FileEventItemSentinel)[] | null>((resolve, reject) => { const request = store.get(this.snapshotKey); - request.onsuccess = () => resolve(request.result || null); - request.onerror = () => reject(request.error); + request.onsuccess = () => + resolve((request.result as (FileEventItem | FileEventItemSentinel)[] | undefined) ?? null); + request.onerror = () => + reject(request.error ?? new Error("Failed to load the WebApp storage-event snapshot")); }); db.close(); @@ -108,12 +135,16 @@ class FSAPIStatusAdapter implements IStorageEventStatusAdapter { private lastUpdate = 0; private updateInterval = 5000; // Update every 5 seconds + constructor(private readonly addLog: WebAppLog) {} + updateStatus(status: { batched: number; processing: number; totalQueued: number }): void { const now = Date.now(); if (now - this.lastUpdate > this.updateInterval) { if (status.totalQueued > 0 || status.processing > 0) { - console.log( - `[StorageEventManager] Batched: ${status.batched}, Processing: ${status.processing}, Total Queued: ${status.totalQueued}` + this.addLog( + `Batched: ${status.batched}, Processing: ${status.processing}, Total queued: ${status.totalQueued}`, + LOG_LEVEL_VERBOSE, + "storage-events" ); } this.lastUpdate = now; @@ -155,26 +186,28 @@ class FSAPIConverterAdapter implements IStorageEventConverterAdapter * FileSystem API-specific watch adapter using FileSystemObserver (Chrome only) */ class FSAPIWatchAdapter implements IStorageEventWatchAdapter { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Accessing private service modules - private observer: any = null; // FileSystemObserver type + private observer: FileSystemObserverInstance | null = null; - constructor(private rootHandle: FileSystemDirectoryHandle) {} + constructor( + private rootHandle: FileSystemDirectoryHandle, + private readonly addLog: WebAppLog + ) {} async beginWatch(handlers: IStorageEventWatchHandlers): Promise { - // Use FileSystemObserver if available (Chrome 124+) - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Accessing global FileSystemObserver - if (typeof (compatGlobal as any).FileSystemObserver === "undefined") { - console.log("[FSAPIWatchAdapter] FileSystemObserver not available, file watching disabled"); - console.log("[FSAPIWatchAdapter] Consider using Chrome 124+ for real-time file watching"); + // Use FileSystemObserver when the browser provides it. + const FileSystemObserver = (compatGlobal as GlobalWithFileSystemObserver).FileSystemObserver; + if (!FileSystemObserver) { + this.addLog( + "FileSystemObserver is not available; file watching is disabled", + LOG_LEVEL_INFO, + "fsapi-watch" + ); + this.addLog("Use 'Scan local files' after external changes", LOG_LEVEL_INFO, "fsapi-watch"); return Promise.resolve(); } try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Accessing private service modules - const FileSystemObserver = (compatGlobal as any).FileSystemObserver; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Accessing private service modules - this.observer = new FileSystemObserver(async (records: any[]) => { + this.observer = new FileSystemObserver(async (records) => { for (const record of records) { const changedHandle = record.changedHandle; const relativePathComponents = record.relativePathComponents; @@ -188,7 +221,7 @@ class FSAPIWatchAdapter implements IStorageEventWatchAdapter { continue; } - console.log(`[FileSystemObserver] ${type}: ${relativePath}`); + this.addLog(`${type}: ${relativePath}`, LOG_LEVEL_VERBOSE, "filesystem-observer"); // Convert to our event handlers try { @@ -244,9 +277,10 @@ class FSAPIWatchAdapter implements IStorageEventWatchAdapter { } } } catch (error) { - console.error( - `[FileSystemObserver] Error processing ${type} event for ${relativePath}:`, - error + this.addLog( + `Error processing ${type} event for ${relativePath}: ${String(error)}`, + LOG_LEVEL_NOTICE, + "filesystem-observer" ); } } @@ -254,10 +288,10 @@ class FSAPIWatchAdapter implements IStorageEventWatchAdapter { // Start observing await this.observer.observe(this.rootHandle, { recursive: true }); - console.log("[FSAPIWatchAdapter] FileSystemObserver started successfully"); + this.addLog("FileSystemObserver started successfully", LOG_LEVEL_INFO, "fsapi-watch"); } catch (error) { - console.error("[FSAPIWatchAdapter] Failed to start FileSystemObserver:", error); - console.log("[FSAPIWatchAdapter] Falling back to manual sync mode"); + this.addLog(`Failed to start FileSystemObserver: ${String(error)}`, LOG_LEVEL_NOTICE, "fsapi-watch"); + this.addLog("Falling back to manual sync mode", LOG_LEVEL_INFO, "fsapi-watch"); } return Promise.resolve(); @@ -268,9 +302,9 @@ class FSAPIWatchAdapter implements IStorageEventWatchAdapter { try { this.observer.disconnect(); this.observer = null; - console.log("[FSAPIWatchAdapter] FileSystemObserver stopped"); + this.addLog("FileSystemObserver stopped", LOG_LEVEL_INFO, "fsapi-watch"); } catch (error) { - console.error("[FSAPIWatchAdapter] Error stopping observer:", error); + this.addLog(`Error stopping observer: ${String(error)}`, LOG_LEVEL_NOTICE, "fsapi-watch"); } } } @@ -286,11 +320,11 @@ export class FSAPIStorageEventManagerAdapter implements IStorageEventManagerAdap readonly status: FSAPIStatusAdapter; readonly converter: FSAPIConverterAdapter; - constructor(rootHandle: FileSystemDirectoryHandle) { + constructor(rootHandle: FileSystemDirectoryHandle, addLog: WebAppLog) { this.typeGuard = new FSAPITypeGuardAdapter(); - this.persistence = new FSAPIPersistenceAdapter(); - this.watch = new FSAPIWatchAdapter(rootHandle); - this.status = new FSAPIStatusAdapter(); + this.persistence = new FSAPIPersistenceAdapter(addLog); + this.watch = new FSAPIWatchAdapter(rootHandle, addLog); + this.status = new FSAPIStatusAdapter(addLog); this.converter = new FSAPIConverterAdapter(); } } diff --git a/src/apps/webapp/managers/StorageEventManagerFSAPI.ts b/src/apps/webapp/managers/StorageEventManagerFSAPI.ts index cda07490..4152ad30 100644 --- a/src/apps/webapp/managers/StorageEventManagerFSAPI.ts +++ b/src/apps/webapp/managers/StorageEventManagerFSAPI.ts @@ -1,7 +1,7 @@ -import { StorageEventManagerBase, type StorageEventManagerBaseDependencies } from "@lib/managers/StorageEventManager"; +import { StorageEventManagerBase, type StorageEventManagerBaseDependencies } from "@vrtmrz/livesync-commonlib/compat/managers/StorageEventManager"; import { FSAPIStorageEventManagerAdapter } from "./FSAPIStorageEventManagerAdapter"; import type { IMinimumLiveSyncCommands, LiveSyncBaseCore } from "@/LiveSyncBaseCore"; -import type { ServiceContext } from "@lib/services/base/ServiceBase"; +import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; export class StorageEventManagerFSAPI extends StorageEventManagerBase { core: LiveSyncBaseCore; @@ -12,7 +12,9 @@ export class StorageEventManagerFSAPI extends StorageEventManagerBase, dependencies: StorageEventManagerBaseDependencies ) { - const adapter = new FSAPIStorageEventManagerAdapter(rootHandle); + const adapter = new FSAPIStorageEventManagerAdapter(rootHandle, (message, level, key) => + dependencies.APIService.addLog(message, level, key) + ); super(adapter, dependencies); this.fsapiAdapter = adapter; this.core = core; diff --git a/src/apps/webapp/package.json b/src/apps/webapp/package.json index 785d808c..2d588931 100644 --- a/src/apps/webapp/package.json +++ b/src/apps/webapp/package.json @@ -1,7 +1,7 @@ { "name": "livesync-webapp", "private": true, - "version": "0.25.82-webapp", + "version": "1.0.10-webapp", "type": "module", "description": "Browser-based Self-hosted LiveSync using FileSystem API", "scripts": { @@ -9,18 +9,18 @@ "build": "vite build", "build:docker": "docker build -f Dockerfile -t livesync-webapp ../../..", "run:docker": "docker run -p 8002:80 livesync-webapp", - "preview": "vite preview" + "preview": "vite preview", + "test:unit": "npm --prefix ../../.. run test:unit -- test/apps/webapp", + "pretest:browser": "npm run build", + "test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webapp/browser-smoke.test.ts" }, "dependencies": { - "octagonal-wheels": "^0.1.51" + "octagonal-wheels": "^0.1.52" }, "devDependencies": { - "@playwright/test": "^1.58.2", "@sveltejs/vite-plugin-svelte": "^7.1.2", - "playwright": "^1.58.2", "svelte": "5.56.3", "typescript": "5.9.3", - "vite": "^8.0.16", - "vite-plugin-istanbul": "^9.0.1" + "vite": "^8.0.16" } } diff --git a/src/apps/webapp/playwright.config.ts b/src/apps/webapp/playwright.config.ts deleted file mode 100644 index 357d7394..00000000 --- a/src/apps/webapp/playwright.config.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { defineConfig, devices } from "@playwright/test"; -import * as path from "path"; -import * as fs from "fs"; -import { fileURLToPath } from "url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// --------------------------------------------------------------------------- -// Load environment variables from .test.env (root) so that CouchDB -// connection details are visible to the test process. -// --------------------------------------------------------------------------- -function loadEnvFile(envPath: string): Record { - const result: Record = {}; - if (!fs.existsSync(envPath)) return result; - const lines = fs.readFileSync(envPath, "utf-8").split("\n"); - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eq = trimmed.indexOf("="); - if (eq < 0) continue; - const key = trimmed.slice(0, eq).trim(); - const val = trimmed.slice(eq + 1).trim(); - result[key] = val; - } - return result; -} - -// __dirname is src/apps/webapp — root is three levels up -const ROOT = path.resolve(__dirname, "../../.."); -const envVars = { - ...loadEnvFile(path.join(ROOT, ".env")), - ...loadEnvFile(path.join(ROOT, ".test.env")), -}; - -// Make the loaded variables available to all test files via process.env. -for (const [k, v] of Object.entries(envVars)) { - if (!(k in process.env)) { - process.env[k] = v; - } -} - -export default defineConfig({ - testDir: "./test", - // Give each test plenty of time for replication round-trips. - timeout: 120_000, - expect: { timeout: 30_000 }, - // Run test files sequentially; the tests themselves manage two contexts. - fullyParallel: false, - workers: 1, - reporter: "list", - - use: { - baseURL: "http://localhost:3000", - // Use Chromium for OPFS and FileSystem API support. - ...devices["Desktop Chrome"], - headless: true, - // Launch args to match the main vitest browser config. - launchOptions: { - args: ["--js-flags=--expose-gc"], - }, - }, - - projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - }, - ], - - // Start the vite dev server before running the tests. - webServer: { - command: "npx vite --port 3000", - url: "http://localhost:3000", - // Re-use a running dev server when developing locally. - reuseExistingServer: !process.env.CI, - timeout: 30_000, - // Run from the webapp directory so vite finds its config. - cwd: __dirname, - }, -}); diff --git a/src/apps/webapp/serviceModules/DatabaseFileAccess.ts b/src/apps/webapp/serviceModules/DatabaseFileAccess.ts index 346a9b67..4314b966 100644 --- a/src/apps/webapp/serviceModules/DatabaseFileAccess.ts +++ b/src/apps/webapp/serviceModules/DatabaseFileAccess.ts @@ -1,8 +1,8 @@ import { ServiceDatabaseFileAccessBase, type ServiceDatabaseFileAccessDependencies, -} from "@lib/serviceModules/ServiceDatabaseFileAccessBase"; -import type { DatabaseFileAccess } from "@lib/interfaces/DatabaseFileAccess"; +} from "@vrtmrz/livesync-commonlib/compat/serviceModules/ServiceDatabaseFileAccessBase"; +import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess"; /** * FileSystem API-specific implementation of ServiceDatabaseFileAccess diff --git a/src/apps/webapp/serviceModules/FSAPIServiceModules.ts b/src/apps/webapp/serviceModules/FSAPIServiceModules.ts index 5857a175..cc20885c 100644 --- a/src/apps/webapp/serviceModules/FSAPIServiceModules.ts +++ b/src/apps/webapp/serviceModules/FSAPIServiceModules.ts @@ -1,15 +1,21 @@ -import type { InjectableServiceHub } from "@lib/services/implements/injectable/InjectableServiceHub"; -import { ServiceRebuilder } from "@lib/serviceModules/Rebuilder"; +import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub"; +import { ServiceRebuilder } from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder"; -import { StorageAccessManager } from "@lib/managers/StorageProcessingManager"; +import { StorageAccessManager } from "@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager"; import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore"; -import type { ServiceContext } from "@lib/services/base/ServiceBase"; +import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; import { FileAccessFSAPI } from "./FileAccessFSAPI"; import { ServiceFileAccessFSAPI } from "./ServiceFileAccessImpl"; import { ServiceDatabaseFileAccessFSAPI } from "./DatabaseFileAccess"; import { StorageEventManagerFSAPI } from "@/apps/webapp/managers/StorageEventManagerFSAPI"; -import type { ServiceModules } from "@lib/interfaces/ServiceModule"; +import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; import { ServiceFileHandler } from "@/serviceModules/FileHandler"; +import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance"; + +export interface FSAPIServiceModules extends ServiceModules { + vaultAccess: FileAccessFSAPI; + storageEventManager: StorageEventManagerFSAPI; +} /** * Initialize service modules for FileSystem API webapp version @@ -24,7 +30,7 @@ export function initialiseServiceModulesFSAPI( rootHandle: FileSystemDirectoryHandle, core: LiveSyncBaseCore, services: InjectableServiceHub -): ServiceModules { +): FSAPIServiceModules { const storageAccessManager = new StorageAccessManager(); // FileSystem API-specific file access @@ -59,6 +65,7 @@ export function initialiseServiceModulesFSAPI( // Database file access (platform-independent) const databaseFileAccess = new ServiceDatabaseFileAccessFSAPI({ + events: services.context.events, API: services.API, database: services.database, path: services.path, @@ -68,6 +75,7 @@ export function initialiseServiceModulesFSAPI( // File handler (platform-independent) const fileHandler = new ServiceFileHandler({ + events: services.context.events, API: services.API, databaseFileAccess: databaseFileAccess, conflict: services.conflict, @@ -77,10 +85,12 @@ export function initialiseServiceModulesFSAPI( path: services.path, replication: services.replication, storageAccess: storageAccess, + fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB), }); // Rebuilder (platform-independent) const rebuilder = new ServiceRebuilder({ + events: services.context.events, API: services.API, database: services.database, appLifecycle: services.appLifecycle, @@ -101,5 +111,7 @@ export function initialiseServiceModulesFSAPI( fileHandler, databaseFileAccess, storageAccess, + vaultAccess, + storageEventManager, }; } diff --git a/src/apps/webapp/serviceModules/FileAccessFSAPI.ts b/src/apps/webapp/serviceModules/FileAccessFSAPI.ts index 0ae67a88..e3ca51a7 100644 --- a/src/apps/webapp/serviceModules/FileAccessFSAPI.ts +++ b/src/apps/webapp/serviceModules/FileAccessFSAPI.ts @@ -1,4 +1,4 @@ -import { FileAccessBase, type FileAccessBaseDependencies } from "@lib/serviceModules/FileAccessBase"; +import { FileAccessBase, type FileAccessBaseDependencies } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase"; import { FSAPIFileSystemAdapter } from "@/apps/webapp/adapters/FSAPIFileSystemAdapter"; /** @@ -7,7 +7,9 @@ import { FSAPIFileSystemAdapter } from "@/apps/webapp/adapters/FSAPIFileSystemAd */ export class FileAccessFSAPI extends FileAccessBase { constructor(rootHandle: FileSystemDirectoryHandle, dependencies: FileAccessBaseDependencies) { - const adapter = new FSAPIFileSystemAdapter(rootHandle); + const adapter = new FSAPIFileSystemAdapter(rootHandle, (message, level, key) => + dependencies.APIService.addLog(message, level, key) + ); super(adapter, dependencies); } diff --git a/src/apps/webapp/serviceModules/ServiceFileAccessImpl.ts b/src/apps/webapp/serviceModules/ServiceFileAccessImpl.ts index 62fea06a..8f0ebe2c 100644 --- a/src/apps/webapp/serviceModules/ServiceFileAccessImpl.ts +++ b/src/apps/webapp/serviceModules/ServiceFileAccessImpl.ts @@ -1,4 +1,4 @@ -import { ServiceFileAccessBase, type StorageAccessBaseDependencies } from "@lib/serviceModules/ServiceFileAccessBase"; +import { ServiceFileAccessBase, type StorageAccessBaseDependencies } from "@vrtmrz/livesync-commonlib/compat/serviceModules/ServiceFileAccessBase"; import { FSAPIFileSystemAdapter } from "@/apps/webapp/adapters/FSAPIFileSystemAdapter"; /** diff --git a/src/apps/webapp/test-entry.ts b/src/apps/webapp/test-entry.ts deleted file mode 100644 index d14889ac..00000000 --- a/src/apps/webapp/test-entry.ts +++ /dev/null @@ -1,231 +0,0 @@ -/** - * LiveSync WebApp E2E test entry point. - * - * When served by vite dev server (at /test.html), this module wires up - * `window.livesyncTest`, a plain JS API that Playwright tests can call via - * `page.evaluate()`. All methods are async and serialisation-safe. - * - * Vault storage is backed by OPFS so no `showDirectoryPicker()` interaction - * is required, making it fully headless-compatible. - */ - -import { LiveSyncWebApp } from "./main"; -import type { ObsidianLiveSyncSettings } from "@lib/common/types"; -import type { FilePathWithPrefix } from "@lib/common/types"; -import { compatGlobal } from "@lib/common/coreEnvFunctions.ts"; - -// -------------------------------------------------------------------------- -// Internal state – one app instance per page / browser context -// -------------------------------------------------------------------------- -let app: LiveSyncWebApp | null = null; - -// -------------------------------------------------------------------------- -// Helpers -// -------------------------------------------------------------------------- - -/** Strip the "plain:" / "enc:" / â€Ļ prefix used internally in PouchDB paths. */ -function stripPrefix(raw: string): string { - return raw.replace(/^[^:]+:/, ""); -} - -interface TestCore { - services?: { - replication?: { - databaseQueueCount?: { value: number }; - storageApplyingCount?: { value: number }; - }; - fileProcessing?: { - totalQueued?: { value: number }; - batched?: { value: number }; - processing?: { value: number }; - }; - database?: { - localDatabase: { - findAllNormalDocs(options?: { conflicts?: boolean }): AsyncIterable<{ - _deleted?: boolean; - deleted?: boolean; - path?: string; - _rev?: string; - _conflicts?: string[]; - size?: number; - mtime?: number; - }>; - }; - }; - }; -} - -/** - * Poll every 300 ms until all known processing queues are drained, or until - * the timeout elapses. Mirrors `waitForIdle` in the existing vitest harness. - */ -async function waitForIdle(core: TestCore, timeoutMs = 60_000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const q = - (core.services?.replication?.databaseQueueCount?.value ?? 0) + - (core.services?.fileProcessing?.totalQueued?.value ?? 0) + - (core.services?.fileProcessing?.batched?.value ?? 0) + - (core.services?.fileProcessing?.processing?.value ?? 0) + - (core.services?.replication?.storageApplyingCount?.value ?? 0); - if (q === 0) return; - await new Promise((r) => compatGlobal.setTimeout(r, 300)); - } - throw new Error(`waitForIdle timed out after ${timeoutMs} ms`); -} - -function getCore(): TestCore { - const core = (app as unknown as { core: TestCore | null })?.core; - if (!core) throw new Error("Vault not initialised – call livesyncTest.init() first"); - return core; -} - -// -------------------------------------------------------------------------- -// Public test API -// -------------------------------------------------------------------------- - -export interface LiveSyncTestAPI { - /** - * Initialise a vault in OPFS under the given name and apply `settings`. - * Any previous contents of the OPFS directory are wiped first so each - * test run starts clean. - */ - init(vaultName: string, settings: Partial): Promise; - - /** - * Write `content` to the local PouchDB under `vaultPath` (equivalent to - * the CLI `put` command). Waiting for the DB write to finish is - * included; you still need to call `replicate()` to push to remote. - */ - putFile(vaultPath: string, content: string): Promise; - - /** - * Mark `vaultPath` as deleted in the local PouchDB (equivalent to CLI - * `rm`). Call `replicate()` afterwards to propagate to remote. - */ - deleteFile(vaultPath: string): Promise; - - /** - * Run one full replication cycle (push + pull) against the remote CouchDB, - * then wait for the local storage-application queue to drain. - */ - replicate(): Promise; - - /** - * Wait until all processing queues are idle. Usually not needed after - * `putFile` / `deleteFile` since those already await, but useful when - * testing results after `replicate()`. - */ - waitForIdle(timeoutMs?: number): Promise; - - /** - * Return metadata for `vaultPath` from the local database, or `null` if - * not found / deleted. - */ - getInfo(vaultPath: string): Promise<{ - path: string; - revision: string; - conflicts: string[]; - size: number; - mtime: number; - } | null>; - - /** Convenience wrapper: returns true when the doc has â‰Ĩ1 conflict revision. */ - hasConflict(vaultPath: string): Promise; - - /** Tear down the current app instance. */ - shutdown(): Promise; -} - -// -------------------------------------------------------------------------- -// Implementation -// -------------------------------------------------------------------------- - -const livesyncTest: LiveSyncTestAPI = { - async init(vaultName: string, settings: Partial): Promise { - // Clean up any stale OPFS data from previous runs. - const opfsRoot = await compatGlobal.navigator.storage.getDirectory(); - try { - await opfsRoot.removeEntry(vaultName, { recursive: true }); - } catch { - // directory did not exist – that's fine - } - const vaultDir = await opfsRoot.getDirectoryHandle(vaultName, { create: true }); - - // Pre-write settings so they are loaded during initialise(). - const livesyncDir = await vaultDir.getDirectoryHandle(".livesync", { create: true }); - const settingsFile = await livesyncDir.getFileHandle("settings.json", { create: true }); - const writable = await settingsFile.createWritable(); - await writable.write(JSON.stringify(settings)); - await writable.close(); - - app = new LiveSyncWebApp(vaultDir); - await app.initialize(); - - // Give background startup tasks a moment to settle. - await waitForIdle(getCore(), 30_000); - }, - - async putFile(vaultPath: string, content: string): Promise { - const core = getCore(); - const result = await core.serviceModules.databaseFileAccess.storeContent( - vaultPath as FilePathWithPrefix, - content - ); - await waitForIdle(core); - return result !== false; - }, - - async deleteFile(vaultPath: string): Promise { - const core = getCore(); - const result = await core.serviceModules.databaseFileAccess.delete(vaultPath as FilePathWithPrefix); - await waitForIdle(core); - return result !== false; - }, - - async replicate(): Promise { - const core = getCore(); - const result = await core.services.replication.replicate(true); - // After replicate() resolves, remote docs may still be queued for - // local storage application – wait until all queues are drained. - await waitForIdle(core); - return result !== false; - }, - - async waitForIdle(timeoutMs?: number): Promise { - await waitForIdle(getCore(), timeoutMs ?? 60_000); - }, - - async getInfo(vaultPath: string) { - const core = getCore(); - const db = core.services?.database; - for await (const doc of db.localDatabase.findAllNormalDocs({ conflicts: true })) { - if (doc._deleted || doc.deleted) continue; - const docPath = stripPrefix(doc.path ?? ""); - if (docPath !== vaultPath) continue; - return { - path: docPath, - revision: doc._rev ?? "", - conflicts: doc._conflicts ?? [], - size: doc.size ?? 0, - mtime: doc.mtime ?? 0, - }; - } - return null; - }, - - async hasConflict(vaultPath: string): Promise { - const info = await livesyncTest.getInfo(vaultPath); - return (info?.conflicts?.length ?? 0) > 0; - }, - - async shutdown(): Promise { - if (app) { - await app.shutdown(); - app = null; - } - }, -}; - -// Expose on window for Playwright page.evaluate() calls. -(compatGlobal as unknown as Record).livesyncTest = livesyncTest; diff --git a/src/apps/webapp/test.html b/src/apps/webapp/test.html deleted file mode 100644 index 8a5efcf5..00000000 --- a/src/apps/webapp/test.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - LiveSync WebApp – E2E Test Page - - - -

LiveSync WebApp E2E

-

This page is used by Playwright tests only. window.livesyncTest is exposed by the script below.

- -
Loadingâ€Ļ
- - - diff --git a/src/apps/webapp/test/e2e.spec.ts b/src/apps/webapp/test/e2e.spec.ts deleted file mode 100644 index 72c8343d..00000000 --- a/src/apps/webapp/test/e2e.spec.ts +++ /dev/null @@ -1,294 +0,0 @@ -/** - * WebApp E2E tests – two-vault scenarios. - * - * Each vault (A and B) runs in its own browser context so that JavaScript - * global state (including Trystero's global signalling tables) is fully - * isolated. The two vaults communicate only through the shared remote - * CouchDB database. - * - * Vault storage is OPFS-backed – no file-picker interaction needed. - * - * Prerequisites: - * - A reachable CouchDB instance whose connection details are in .test.env - * (read automatically by playwright.config.ts). - * - * How to run: - * cd src/apps/webapp && npm run test:e2e - */ - -import { test, expect, type BrowserContext, type Page, type TestInfo } from "@playwright/test"; -import type { LiveSyncTestAPI } from "@/apps/webapp/test-entry"; -import { mkdirSync, writeFileSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// --------------------------------------------------------------------------- -// Settings helpers -// --------------------------------------------------------------------------- - -function requireEnv(name: string): string { - const v = process.env[name]; - if (!v) throw new Error(`Missing required env variable: ${name}`); - return v; -} - -async function ensureCouchDbDatabase(uri: string, user: string, pass: string, dbName: string): Promise { - const base = uri.replace(/\/+$/, ""); - const dbUrl = `${base}/${encodeURIComponent(dbName)}`; - const auth = Buffer.from(`${user}:${pass}`, "utf-8").toString("base64"); - const response = await fetch(dbUrl, { - method: "PUT", - headers: { - Authorization: `Basic ${auth}`, - }, - }); - - // 201: created, 202: accepted, 412: already exists - if (response.status === 201 || response.status === 202 || response.status === 412) { - return; - } - - const body = await response.text().catch(() => ""); - throw new Error(`Failed to ensure CouchDB database (${response.status}): ${body}`); -} - -function buildSettings(dbName: string): Record { - return { - // Remote database (shared between A and B – this is the replication target) - couchDB_URI: requireEnv("hostname").replace(/\/+$/, ""), - couchDB_USER: process.env["username"] ?? "", - couchDB_PASSWORD: process.env["password"] ?? "", - couchDB_DBNAME: dbName, - - // Core behaviour - isConfigured: true, - liveSync: false, - syncOnSave: false, - syncOnStart: false, - periodicReplication: false, - gcDelay: 0, - savingDelay: 0, - notifyThresholdOfRemoteStorageSize: 0, - - // Encryption off for test simplicity - encrypt: false, - - // Disable plugin/hidden-file sync (not needed in webapp) - usePluginSync: false, - autoSweepPlugins: false, - autoSweepPluginsPeriodic: false, - - //Auto accept perr - P2P_AutoAcceptingPeers: "~.*", - }; -} - -// --------------------------------------------------------------------------- -// Test-page helpers -// --------------------------------------------------------------------------- - -/** Navigate to the test entry page and wait for `window.livesyncTest`. */ -async function openTestPage(ctx: BrowserContext): Promise { - const page = await ctx.newPage(); - await page.goto("/test.html"); - await page.waitForFunction(() => !!(window as any).livesyncTest, { timeout: 20_000 }); - return page; -} - -/** Type-safe wrapper – calls `window.livesyncTest.(...args)` in the page. */ -async function call( - page: Page, - method: M, - ...args: Parameters -): Promise>> { - const invoke = () => - page.evaluate(([m, a]) => (window as any).livesyncTest[m](...a), [method, args] as [ - string, - unknown[], - ]) as Promise>>; - - try { - return await invoke(); - } catch (ex: any) { - const message = String(ex?.message ?? ex); - // Some startup flows may trigger one page reload; recover once. - if ( - message.includes("Execution context was destroyed") || - message.includes("Most likely the page has been closed") - ) { - await page.waitForFunction(() => !!(window as any).livesyncTest, { timeout: 20_000 }); - return await invoke(); - } - throw ex; - } -} - -async function dumpCoverage(page: Page | undefined, label: string, testInfo: TestInfo): Promise { - if (!process.env.PW_COVERAGE || !page || page.isClosed()) { - return; - } - const cov = await page - .evaluate(() => { - const data = (window as any).__coverage__; - if (!data) return null; - // Reset between tests to avoid runaway accumulation. - (window as any).__coverage__ = {}; - return data; - }) - .catch(() => null!); - if (!cov) return; - if (typeof cov === "object" && Object.keys(cov as Record).length === 0) { - return; - } - - const outDir = path.resolve(__dirname, "../.nyc_output"); - mkdirSync(outDir, { recursive: true }); - const name = `${testInfo.testId.replace(/[^a-zA-Z0-9_-]/g, "_")}-${label}.json`; - writeFileSync(path.join(outDir, name), JSON.stringify(cov), "utf-8"); -} - -// --------------------------------------------------------------------------- -// Two-vault E2E suite -// --------------------------------------------------------------------------- - -test.describe("WebApp two-vault E2E", () => { - let ctxA: BrowserContext; - let ctxB: BrowserContext; - let pageA: Page; - let pageB: Page; - - const DB_SUFFIX = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const dbName = `${requireEnv("dbname")}-${DB_SUFFIX}`; - const settings = buildSettings(dbName); - - test.beforeAll(async ({ browser }) => { - await ensureCouchDbDatabase( - String(settings.couchDB_URI ?? ""), - String(settings.couchDB_USER ?? ""), - String(settings.couchDB_PASSWORD ?? ""), - dbName - ); - - // Open Vault A and Vault B in completely separate browser contexts. - // Each context has its own JS runtime, IndexedDB and OPFS root, so - // Trystero global state and PouchDB instance names cannot collide. - ctxA = await browser.newContext(); - ctxB = await browser.newContext(); - - pageA = await openTestPage(ctxA); - pageB = await openTestPage(ctxB); - - await call(pageA, "init", "testvault_a", settings as any); - await call(pageB, "init", "testvault_b", settings as any); - }); - - test.afterAll(async () => { - await call(pageA, "shutdown").catch(() => {}); - await call(pageB, "shutdown").catch(() => {}); - await ctxA.close(); - await ctxB.close(); - }); - - test.afterEach(async ({}, testInfo) => { - await dumpCoverage(pageA, "vaultA", testInfo); - await dumpCoverage(pageB, "vaultB", testInfo); - }); - - // ----------------------------------------------------------------------- - // Case 1: Vault A writes a file and can read its metadata back from the - // local database (no replication yet). - // ----------------------------------------------------------------------- - test("Case 1: A writes a file and can get its info", async () => { - const FILE = "e2e/case1-a-only.md"; - const CONTENT = "hello from vault A"; - - const ok = await call(pageA, "putFile", FILE, CONTENT); - expect(ok).toBe(true); - - const info = await call(pageA, "getInfo", FILE); - expect(info).not.toBeNull(); - expect(info!.path).toBe(FILE); - expect(info!.revision).toBeTruthy(); - expect(info!.conflicts).toHaveLength(0); - }); - - // ----------------------------------------------------------------------- - // Case 2: Vault A writes a file, both vaults replicate, and Vault B ends - // up with the file in its local database. - // ----------------------------------------------------------------------- - test("Case 2: A writes a file, both replicate, B receives the file", async () => { - const FILE = "e2e/case2-sync.md"; - const CONTENT = "content from A – should appear in B"; - - await call(pageA, "putFile", FILE, CONTENT); - - // A pushes to remote, B pulls from remote. - await call(pageA, "replicate"); - await call(pageB, "replicate"); - - const infoB = await call(pageB, "getInfo", FILE); - expect(infoB).not.toBeNull(); - expect(infoB!.path).toBe(FILE); - }); - - // ----------------------------------------------------------------------- - // Case 3: Vault A deletes the file it synced in case 2. After both - // vaults replicate, Vault B no longer sees the file. - // ----------------------------------------------------------------------- - test("Case 3: A deletes the file, both replicate, B no longer sees it", async () => { - // This test depends on Case 2 having put e2e/case2-sync.md into both vaults. - const FILE = "e2e/case2-sync.md"; - - await call(pageA, "deleteFile", FILE); - - await call(pageA, "replicate"); - await call(pageB, "replicate"); - - const infoB = await call(pageB, "getInfo", FILE); - // The file should be gone (null means not found or deleted). - expect(infoB).toBeNull(); - }); - - // ----------------------------------------------------------------------- - // Case 4: A and B each independently edit the same file that was already - // synced. After both vaults replicate the editing cycle, both - // vaults report a conflict on that file. - // ----------------------------------------------------------------------- - test("Case 4: concurrent edits from A and B produce a conflict on both sides", async () => { - const FILE = "e2e/case4-conflict.md"; - - // 1) Write a baseline and synchronise so both vaults start from the - // same revision. - await call(pageA, "putFile", FILE, "base content"); - await call(pageA, "replicate"); - await call(pageB, "replicate"); - - // Confirm B has the base file with no conflicts yet. - const baseInfoB = await call(pageB, "getInfo", FILE); - expect(baseInfoB).not.toBeNull(); - expect(baseInfoB!.conflicts).toHaveLength(0); - - // 2) Both vaults write diverging content without syncing in between – - // this creates two competing revisions. - await call(pageA, "putFile", FILE, "content from A (conflict side)"); - await call(pageB, "putFile", FILE, "content from B (conflict side)"); - - // 3) Run replication on both sides. The order mirrors the pattern - // from the CLI two-vault tests (A → remote → B → remote → A). - await call(pageA, "replicate"); - await call(pageB, "replicate"); - await call(pageA, "replicate"); // re-check from A to pick up B's revision - - // 4) At least one side must report a conflict. - const hasConflictA = await call(pageA, "hasConflict", FILE); - const hasConflictB = await call(pageB, "hasConflict", FILE); - - expect( - hasConflictA || hasConflictB, - "Expected a conflict to appear on vault A or vault B after diverging edits" - ).toBe(true); - }); -}); diff --git a/src/apps/webapp/tsconfig.json b/src/apps/webapp/tsconfig.json index e2198719..ab83008a 100644 --- a/src/apps/webapp/tsconfig.json +++ b/src/apps/webapp/tsconfig.json @@ -23,8 +23,7 @@ /* Path mapping */ // "baseUrl": ".", "paths": { - "@/*": ["../../*"], - "@lib/*": ["../../lib/src/*", "../../../_types/src/lib/src/*"] + "@/*": ["../../*"] } }, "include": ["*.ts", "**/*.ts", "**/*.tsx", "**/*.svelte"], diff --git a/src/apps/webapp/vaultSelector.ts b/src/apps/webapp/vaultSelector.ts index 35fc4f4f..87cc1816 100644 --- a/src/apps/webapp/vaultSelector.ts +++ b/src/apps/webapp/vaultSelector.ts @@ -1,4 +1,4 @@ -import { compatGlobal } from "@lib/common/coreEnvFunctions.ts"; +import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; const HANDLE_DB_NAME = "livesync-webapp-handles"; const HANDLE_STORE_NAME = "handles"; @@ -15,6 +15,18 @@ export type VaultHistoryItem = { type VaultHistoryValue = VaultHistoryItem; +function isVaultHistoryValue(value: unknown): value is VaultHistoryValue { + if (typeof value !== "object" || value === null) return false; + const candidate = value as Partial; + return ( + typeof candidate.id === "string" && + typeof candidate.name === "string" && + typeof candidate.handle === "object" && + candidate.handle !== null && + typeof candidate.lastUsedAt === "number" + ); +} + function makeVaultKey(id: string): string { return `${VAULT_KEY_PREFIX}${id}`; } @@ -56,7 +68,8 @@ export class VaultHistoryStore { private async openHandleDB(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(HANDLE_DB_NAME, 1); - request.onerror = () => reject(request.error); + request.onerror = () => + reject(request.error ?? new Error("Could not open the WebApp Vault history database")); request.onsuccess = () => resolve(request.result); request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; @@ -81,13 +94,13 @@ export class VaultHistoryStore { private async requestAsPromise(request: IDBRequest): Promise { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); + request.onerror = () => reject(request.error ?? new Error("The WebApp Vault history request failed")); }); } async getLastUsedVaultId(): Promise { return this.withStore("readonly", async (store) => { - const value = await this.requestAsPromise(store.get(LAST_USED_KEY)); + const value: unknown = await this.requestAsPromise(store.get(LAST_USED_KEY)); return typeof value === "string" ? value : null; }); } @@ -95,20 +108,21 @@ export class VaultHistoryStore { async getVaultHistory(): Promise { return this.withStore("readonly", async (store) => { const keys = await this.requestAsPromise(store.getAllKeys()); - const values = (await this.requestAsPromise(store.getAll())) as unknown[]; + const values = await this.requestAsPromise(store.getAll() as IDBRequest); const items: VaultHistoryItem[] = []; for (let i = 0; i < keys.length; i++) { - const key = String(keys[i]); + const key = keys[i]; + if (typeof key !== "string") continue; const id = parseVaultId(key); - const value = values[i] as Partial | undefined; - if (!id || !value || !value.handle || !value.name) { + const value = values[i]; + if (!id || !isVaultHistoryValue(value)) { continue; } items.push({ id, - name: String(value.name), + name: value.name, handle: value.handle, - lastUsedAt: Number(value.lastUsedAt || 0), + lastUsedAt: value.lastUsedAt, }); } items.sort((a, b) => b.lastUsedAt - a.lastUsedAt); diff --git a/src/apps/webapp/vite.config.ts b/src/apps/webapp/vite.config.ts index a82a1484..e48b74d1 100644 --- a/src/apps/webapp/vite.config.ts +++ b/src/apps/webapp/vite.config.ts @@ -1,47 +1,25 @@ import { defineConfig } from "vite"; import { svelte } from "@sveltejs/vite-plugin-svelte"; -import istanbul from "vite-plugin-istanbul"; -import path from "node:path"; -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, fs, path } from "@vrtmrz/livesync-commonlib/node"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, "../../.."); -const packageJson = JSON.parse(readFileSync(path.resolve(repoRoot, "package.json"), "utf-8")); -const manifestJson = JSON.parse(readFileSync(path.resolve(repoRoot, "manifest.json"), "utf-8")); -const enableCoverage = process.env.PW_COVERAGE === "1"; + +function readVersion(filePath: string): string | undefined { + const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf-8")); + if (typeof parsed !== "object" || parsed === null || !("version" in parsed)) { + return undefined; + } + return typeof parsed.version === "string" ? parsed.version : undefined; +} + +const packageVersion = readVersion(path.resolve(repoRoot, "package.json")); +const manifestVersion = readVersion(path.resolve(repoRoot, "manifest.json")); // https://vite.dev/config/ export default defineConfig({ - plugins: [ - svelte(), - ...(enableCoverage - ? [ - istanbul({ - cwd: repoRoot, - include: ["src/**/*.ts", "src/**/*.svelte"], - exclude: [ - "node_modules", - "dist", - "test", - "coverage", - "src/apps/webapp/test/**", - "playwright.config.ts", - "vite.config.ts", - "**/*.spec.ts", - "**/*.test.ts", - ], - extension: [".js", ".ts", ".svelte"], - requireEnv: false, - cypress: false, - checkProd: false, - }), - ] - : []), - ], + plugins: [svelte()], resolve: { alias: { "@": path.resolve(__dirname, "../../"), - "@lib": path.resolve(__dirname, "../../lib/src"), - obsidian: path.resolve(__dirname, "../../../test/harness/obsidian-mock.ts"), }, }, base: "./", @@ -49,21 +27,17 @@ export default defineConfig({ outDir: "dist", emptyOutDir: true, rollupOptions: { - // test.html is used by the Playwright dev-server; include it here - // so the production build doesn't emit warnings about unused inputs. input: { index: path.resolve(__dirname, "index.html"), webapp: path.resolve(__dirname, "webapp.html"), - test: path.resolve(__dirname, "test.html"), }, external: ["crypto"], }, }, define: { - MANIFEST_VERSION: JSON.stringify(process.env.MANIFEST_VERSION || manifestJson.version || "0.0.0"), - PACKAGE_VERSION: JSON.stringify(process.env.PACKAGE_VERSION || packageJson.version || "0.0.0"), + MANIFEST_VERSION: JSON.stringify(process.env.MANIFEST_VERSION || manifestVersion || "0.0.0"), + PACKAGE_VERSION: JSON.stringify(process.env.PACKAGE_VERSION || packageVersion || "0.0.0"), global: "globalThis", - hostPlatform: JSON.stringify(process.platform || "linux"), }, server: { port: 3000, diff --git a/src/apps/webapp/webapp.css b/src/apps/webapp/webapp.css index 0e3999b6..4e4c68c6 100644 --- a/src/apps/webapp/webapp.css +++ b/src/apps/webapp/webapp.css @@ -31,7 +31,7 @@ body { border-radius: 12px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); padding: 40px; - max-width: 700px; + max-width: 1100px; width: 100%; } @@ -161,10 +161,19 @@ button:disabled { } .empty-note.is-hidden, -.vault-selector.is-hidden { +.vault-selector.is-hidden, +.p2p-control.is-hidden { display: none; } +.p2p-control { + margin-bottom: 22px; + padding: 16px; + border: 1px solid #e6e9f2; + border-radius: 8px; + background: #fbfcff; +} + .info-section { margin-top: 20px; padding: 20px; @@ -397,4 +406,4 @@ popup { align-items: center; justify-content: center; backdrop-filter: blur(15px); -} \ No newline at end of file +} diff --git a/src/apps/webapp/webapp.html b/src/apps/webapp/webapp.html index 72ba1585..711b31a9 100644 --- a/src/apps/webapp/webapp.html +++ b/src/apps/webapp/webapp.html @@ -1,45 +1,47 @@ - + - - - - Self-hosted LiveSync WebApp - - - -
-

Self-hosted LiveSync on Web

-

Browser-based Self-hosted LiveSync using FileSystem API

+ + + + Self-hosted LiveSync WebApp + + + +
+

Self-hosted LiveSync on Web

+

Experimental browser proof of concept using the File System Access API

-
Initialising...
+
Initialising...
-
-

Select Vault Folder

-

Open a vault you already used, or pick a new folder.

+
+

Select Vault Folder

+

Open a vault you already used, or pick a new folder.

-
-

No saved vaults yet.

- +
+

No saved vaults yet.

+ +
+ + + +
+

How to Use

+
    +
  • Select the local Vault root and grant access.
  • +
  • Create or edit .livesync/settings.json in that Vault.
  • +
  • Reload this page to apply the settings.
  • +
  • Use the P2P controls only as an optional secondary connection.
  • +
+
+ +
-
-

How to Use

-
    -
  • Select a vault folder and grant permission
  • -
  • Create .livesync/settings.json in your vault folder
  • -
  • Or use Setup-URI to apply settings
  • -
  • Your files will be synced after "replicate now"
  • -
-
- - -
- - - + + diff --git a/src/apps/webpeer/README.md b/src/apps/webpeer/README.md index 5494bd2c..8441896a 100644 --- a/src/apps/webpeer/README.md +++ b/src/apps/webpeer/README.md @@ -1,37 +1,122 @@ -# A pseudo client for Self-hosted LiveSync Peer-to-Peer Sync mode +# Self-hosted LiveSync WebPeer -## What is it for? +WebPeer is an experimental, browser-hosted, P2P-only Self-hosted LiveSync peer. It can receive database changes from one peer and provide them to another without materialising ordinary Vault files. -This is a pseudo client for the Self-hosted LiveSync Peer-to-Peer Sync mode. It is a simple pure-client-side web-application that can be connected to the Self-hosted LiveSync in peer-to-peer. +It stores LiveSync metadata and chunks in an origin-scoped local database, but it does not present them as a Vault. This makes it useful as a temporary browser peer or transfer bridge. It is not a durable replacement for CouchDB, a backup, or an always-on server. -As long as you have a browser, it starts up, so if you leave it opened some device, it can replace your existing remote servers such as CouchDB. +## Requirements -> [!IMPORTANT] -> Of course, it has not been fully tested. Rather, it was created to be tested. +WebPeer requires: -This pseudo client actually receives the data from other devices, and sends if some device requests it. However, it does not store **files** in the local storage. If you want to purge the data, please purge the browser's cache and indexedDB, local storage, etc. +- a secure context, such as HTTPS or `localhost`; +- IndexedDB, WebRTC, WebSocket, and Web Crypto support; +- access to the configured signalling relay; and +- the same Group ID, passphrase, and compatible P2P settings as the other peers. -## How to use it? +TURN is optional and is needed only when a direct WebRTC connection cannot be established. The signalling relay is required for peer discovery, but it does not store or transfer Vault contents. -We can build the application from the repository root by running the following command: +## Use + +Serve `src/apps/webpeer/dist/` over HTTPS, or from `localhost`, then open `index.html`. + +1. Enable the P2P replicator. +2. Enter the signalling relay, Group ID, passphrase, and a unique device name. +3. Select **Save and Apply**. +4. Select **Connect** and wait for the intended peers to appear. +5. Use the peer actions to fetch, send, watch, or configure automatic synchronisation as required. + +**Start change-broadcasting on Connect** allows watching peers to notice changes and fetch them. It does not send Vault data through the signalling relay. Optional TURN settings are available separately. + +Keep the page open while WebPeer is expected to announce or transfer changes. + +## P2P connection check + +`check.html` provides a disposable preflight check for P2P connectivity. It creates a random encrypted Setup URI and QR code locally, joins the generated room as a browser reference peer only after explicit confirmation, and displays the browser's WebRTC diagnostic totals beside the QR code. + +Use a dedicated empty Vault for every device: + +1. Select desktop or mobile, then prepare the check. +2. Start the browser connection monitor. +3. Open or scan the Setup URI in an empty Vault with Self-hosted LiveSync installed and enabled. +4. Enter the separately displayed Setup URI passphrase. +5. Keep both peers open for the observation period and watch the successful-connection total. +6. Use **Show the Setup QR again** to return to the existing configuration without regenerating it. +7. For the clearest device comparison, start a fresh check before testing the other device. + +A successful total greater than zero means that the browser and target established at least one WebRTC connection. It does not verify note synchronisation, sustained connectivity, or a direct desktop-to-mobile path. If representative checks repeatedly do not connect, CouchDB is the more predictable synchronisation option. An optional final check can use two disposable empty Vaults to verify a note round trip directly between the user's devices. + +After the first device connects, **Try another device without resetting** keeps the browser, room, credentials, first device, and cumulative counters in place. The page records a local baseline, shows the same QR code for another empty Vault, and reports an additional connection only after both a new successful connection state and another simultaneous active connection appear. Keep the first device connected. Connections are not device identities, so this same-room route is convenient but a fresh check remains easier to interpret because reconnect activity is isolated. + +Serve the production build over HTTPS or from `localhost`. Downloading `check.html` alone is not supported because the page uses built module assets and origin-scoped browser storage. The generated credentials are temporary and must not replace the settings of a production Vault. + +The detailed scope and result semantics are recorded in the [browser-assisted P2P connection-check ADR](../../../docs/adr/2026_07_p2p_connection_check.md). + +## Storage and lifecycle + +WebPeer stores its settings, metadata, and chunks in storage belonging to the page origin. Consequently: + +- changing the scheme, host, or port creates a different WebPeer state; +- clearing site data removes the local database and settings; +- browser storage eviction can remove the state; and +- page suspension, tab closure, device sleep, and network policy can interrupt P2P activity. + +Use a separately deployed origin when isolation from the public Pages deployment is required. Do not treat WebPeer browser storage as the only copy of any data. + +## Troubleshooting + +### No peers appear + +- Confirm that every peer uses the same signalling relay, Group ID, and passphrase. +- Confirm that each peer has a distinct device name. +- Confirm that P2P is enabled and that the signalling connection reports **Connected**. +- Check whether the browser or network blocks the relay WebSocket. + +### Peers connect but changes do not transfer + +- Confirm which peer should fetch and which should send. +- Start broadcasting on the source when another peer is watching it. +- Use the explicit fetch or send action to test one direction. +- Confirm that the peers use compatible Self-hosted LiveSync versions and P2P settings. + +### Saved settings or data appear to be missing + +- Confirm that the page is using the same origin as before. +- Check whether site data was cleared or evicted. +- Check the status line and WebPeer log for the first reported error. + +For more detail about relay, TURN, announcing, and following behaviour, see the [P2P guide](../../../docs/p2p.md). + +## Development + +Build it from the repository root: ```bash -npm run build -w webpeer +npm run build --workspace webpeer ``` -Or from the package directory: +The app-owned unit and Chromium tests can be run with: ```bash -cd src/apps/webpeer -npm run build +npm run test:unit --workspace webpeer +npm run test:browser --workspace webpeer ``` -Then, open `dist/index.html` in the browser. It can be configured in the same way as Self-hosted LiveSync (the same components are used[^1]). +The focused browser-to-Obsidian E2E test builds both production artefacts, manages the local Compose P2P relay, applies the browser-generated Setup URI to two isolated empty Vaults without resetting the browser room, and retains the successful additional-device result under the Obsidian diagnostics directory: -## Some notes +```bash +npm run test:e2e:obsidian:p2p-connection-check:services +``` -I will launch this application in the github pages later, so will be able to use it without building it. However, that shares the origin. Hence, the application that your have built and deployed would be more secure. +Configure `OBSIDIAN_BINARY` and `OBSIDIAN_CLI` when the E2E runner cannot discover them automatically. +The unit tests are stored in `test/apps/webpeer/`, outside the Community Review source boundary. -[^1]: Congrats! I made it modular. Finally... +## Composition +The WebPeer production bundle has two HTML entry points. `index.html` loads `src/main.ts` and the ordinary browser peer, while `check.html` loads `src/check.ts` and the isolated P2P connection check. The ordinary page links to the check, so it is a WebPeer feature without sharing its saved settings or runtime session. + +`WebPeerRuntime.ts` owns the browser service composition, local database lifecycle, P2P replicator, and peer actions. `WebPeerPersistence.ts` owns origin-scoped settings persistence, while the shared P2P pane supplies the connection and peer controls. The connection-check modules create an isolated runtime with in-memory test settings and derive user-visible results from Commonlib's browser-side diagnostic events. Both entry points use the same WebPeer visual foundation. + +## Licence + +The same licence as the main Self-hosted LiveSync project applies. diff --git a/src/apps/webpeer/check.html b/src/apps/webpeer/check.html new file mode 100644 index 00000000..785d748c --- /dev/null +++ b/src/apps/webpeer/check.html @@ -0,0 +1,18 @@ + + + + + + + + P2P connection check ¡ Self-hosted LiveSync + + + +
+ + + diff --git a/src/apps/webpeer/index.html b/src/apps/webpeer/index.html index 17531554..f2643259 100644 --- a/src/apps/webpeer/index.html +++ b/src/apps/webpeer/index.html @@ -6,7 +6,11 @@ - Peer-to-Peer Daemon on Browser + + WebPeer ¡ Self-hosted LiveSync @@ -14,4 +18,4 @@ - \ No newline at end of file + diff --git a/src/apps/webpeer/package.json b/src/apps/webpeer/package.json index ecf7a2b3..de2c09b5 100644 --- a/src/apps/webpeer/package.json +++ b/src/apps/webpeer/package.json @@ -1,7 +1,7 @@ { "name": "webpeer", "private": true, - "version": "0.25.82-webpeer", + "version": "1.0.10-webpeer", "type": "module", "scripts": { "dev": "vite", @@ -9,10 +9,13 @@ "build:docker": "docker build -f Dockerfile -t livesync-webpeer ../../..", "run:docker": "docker run -p 8001:80 livesync-webpeer", "preview": "vite preview", - "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json" + "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json", + "test:unit": "npm --prefix ../../.. run test:unit -- test/apps/webpeer", + "pretest:browser": "npm run build", + "test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webpeer/browser-smoke.test.ts" }, "dependencies": { - "octagonal-wheels": "^0.1.51" + "octagonal-wheels": "^0.1.52" }, "devDependencies": { "eslint-plugin-svelte": "^3.19.0", @@ -22,9 +25,5 @@ "svelte-check": "^4.6.0", "typescript": "5.9.3", "vite": "^8.0.16" - }, - "imports": { - "../../src/worker/bgWorker.ts": "../../src/worker/bgWorker.mock.ts", - "@lib/worker/bgWorker.ts": "@lib/worker/bgWorker.mock.ts" } } diff --git a/src/apps/webpeer/src/CommandsShim.ts b/src/apps/webpeer/src/CommandsShim.ts deleted file mode 100644 index 0f53cd1c..00000000 --- a/src/apps/webpeer/src/CommandsShim.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { LOG_LEVEL_VERBOSE } from "@lib/common/types"; - -import { defaultLoggerEnv, setGlobalLogFunction } from "@lib/common/logger"; -import { writable } from "svelte/store"; - -export const logs = writable([] as string[]); - -let _logs = [] as string[]; - -const maxLines = 10000; -setGlobalLogFunction((msg, level) => { - console.log(msg); - const msgstr = typeof msg === "string" ? msg : JSON.stringify(msg); - const strLog = `${new Date().toISOString()}\u2001${msgstr}`; - _logs.push(strLog); - if (_logs.length > maxLines) { - _logs = _logs.slice(_logs.length - maxLines); - } - logs.set(_logs); -}); -defaultLoggerEnv.minLogLevel = LOG_LEVEL_VERBOSE; - -export const storeP2PStatusLine = writable(""); diff --git a/src/apps/webpeer/src/P2PCheck.svelte b/src/apps/webpeer/src/P2PCheck.svelte new file mode 100644 index 00000000..670b306f --- /dev/null +++ b/src/apps/webpeer/src/P2PCheck.svelte @@ -0,0 +1,601 @@ + + + + + + +
+
+ Self-hosted LiveSync ¡ WebPeer +

P2P connection check

+

+ See whether one LiveSync device can establish a WebRTC connection to this browser on + the current network. +

+
+ + Empty test Vaults only. + This is a disposable connectivity check, not a synchronisation or backup test. +
+
+ +
+ +
+

Choose one target

+

Which device will connect to this browser?

+

+ Check desktop and mobile separately. A fresh test gives each device its own random + room and clear diagnostic counters. +

+ +
+ Target device + + +
+ + +

+ Preparing generates and encrypts everything in this browser. It does not contact + the signalling relay. +

+ {#if preparationError} + + {/if} +
+
+ + {#if setup} +
+ +
+

Set up the empty Vault

+

+ {additionalDeviceAttempt + ? "Use this same one-off configuration on another device" + : `Open this one-off configuration on ${targetLabel}`} +

+ {#if additionalDeviceAttempt} +

+ Keep the browser and first device open. In another new empty Vault, scan + this same QR code or open the same Setup URI, then enter the same separate + passphrase. +

+

+ The room, credentials, connection, and existing counters have not been + reset. The additional-device result uses the values recorded when you + selected the button. +

+ {:else} +

+ In a new empty Vault with Self-hosted LiveSync installed and enabled, scan + the QR code or open the Setup URI. Enter the separate passphrase when + prompted. +

+ {/if} + +
+
+ {additionalDeviceAttempt +

+ {additionalDeviceAttempt + ? "This is the original encrypted Setup URI; it was not regenerated." + : "QR contains the encrypted Setup URI only."} +

+
+ +
+ +
+ + +
+

Type this when LiveSync asks to decrypt the Setup URI.

+ + + +
+ + Open in Obsidian +
+ {#if copyError} + + {/if} +
+
+ +
+
+
Target
+
{targetLabel}
+
+
+
Test Group ID
+
{setup.groupId}
+
+
+
Signalling relay
+
{setup.relay}
+
+
+
+
+ +
+ +
+

Watch the browser diagnostics

+

Start monitoring, then complete setup on {targetLabel}

+

+ Starting monitoring joins the configured signalling relay. Keep this page and + the target Vault open for at least {observationSeconds} seconds. +

+ + + +
+ +
+

{outcomeCopy.title}

+

{outcomeCopy.body}

+ {#if monitorActive} + {elapsedSeconds}s observed ¡ {activeConnections} currently connected + {/if} + {#if monitorError} + {monitorError} + {/if} +
+
+ +
+
+ New + {diagnostics.totalNewConnections} + connection states +
+
+ Successful + {diagnostics.totalSuccessfulConnections} + connection states +
+
+ Failed + {diagnostics.totalFailedConnections} + connection states +
+
+ Closed + {diagnostics.totalClosedConnections} + connection states +
+
+ +

+ These are negotiation events, not device counts. Successful > 0 + is the connection signal; later failures or closures do not erase it. +

+ + {#if additionalProgress && additionalOutcomeCopy} +
+ +
+

Another device in this room

+

{additionalOutcomeCopy.title}

+

{additionalOutcomeCopy.body}

+ + {additionalElapsedSeconds}s observed ¡ + + +{additionalProgress.successfulConnections} successful + + ¡ + + +{additionalProgress.activeConnections} active + + +
+
+

+ Same-room counters remain cumulative and connections are not device counts. + The first device must stay connected; a fresh room remains the clearest + per-device comparison. +

+ {/if} + +
+ + {#if outcome === "connected" && !additionalDeviceAttempt} + + {/if} + +
+
+
+ + + {/if} + +
+

+ The generated credentials are disposable. Delete the empty test Vault afterwards and + generate new credentials for any real setup. +

+
+
diff --git a/src/apps/webpeer/src/P2PCheckSession.ts b/src/apps/webpeer/src/P2PCheckSession.ts new file mode 100644 index 00000000..2c49569b --- /dev/null +++ b/src/apps/webpeer/src/P2PCheckSession.ts @@ -0,0 +1,70 @@ +import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { + EVENT_SERVER_STATUS, + type P2PServerInfo, +} from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer"; +import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase"; + +import { WEBPEER_SETTINGS_KEY } from "./WebPeerPersistence"; +import { WebPeerRuntime } from "./WebPeerRuntime"; + +export const P2P_CHECK_SYSTEM_VAULT_NAME = "p2p-livesync-connection-check"; + +function createMemoryStore(settings: ObsidianLiveSyncSettings): SimpleStore { + const values = new Map([[WEBPEER_SETTINGS_KEY, settings]]); + return { + db: Promise.resolve(undefined), + get: async (key) => values.get(key), + set: async (key, value) => { + values.set(key, value); + }, + delete: async (key) => { + values.delete(key); + }, + keys: async (from, to, count) => { + const selected = [...values.keys()] + .sort() + .filter((key) => (from === undefined || key >= from) && (to === undefined || key <= to)); + return count === undefined ? selected : selected.slice(0, count); + }, + }; +} + +export class P2PCheckSession { + private runtime?: WebPeerRuntime; + private removeStatusListener?: () => void; + + async start( + settings: ObsidianLiveSyncSettings, + browserDeviceName: string, + onStatus: (status: P2PServerInfo) => void + ): Promise { + if (this.runtime) { + throw new Error("This P2P connection-check session has already started"); + } + + const runtime = new WebPeerRuntime({ + store: createMemoryStore(settings), + deviceName: browserDeviceName, + systemVaultName: P2P_CHECK_SYSTEM_VAULT_NAME, + }); + this.runtime = runtime; + this.removeStatusListener = runtime.events.onEvent(EVENT_SERVER_STATUS, onStatus); + + try { + await runtime.start(); + await runtime.currentReplicator.makeSureOpened(); + } catch (error) { + await this.stop(); + throw error; + } + } + + async stop(): Promise { + this.removeStatusListener?.(); + this.removeStatusListener = undefined; + const runtime = this.runtime; + this.runtime = undefined; + await runtime?.shutdown(); + } +} diff --git a/src/apps/webpeer/src/P2PCheckSetup.ts b/src/apps/webpeer/src/P2PCheckSetup.ts new file mode 100644 index 00000000..9a5c8b69 --- /dev/null +++ b/src/apps/webpeer/src/P2PCheckSetup.ts @@ -0,0 +1,166 @@ +import { encodeSettingsToSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting"; +import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; +import { + P2P_DEFAULT_SETTINGS, + PREFERRED_BASE, + createNewVaultSettings, + type ObsidianLiveSyncSettings, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { generateP2PRoomId } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations"; + +export const P2P_CHECK_APP_ID = "self-hosted-livesync-p2p-check-v1"; +export const P2P_CHECK_REMOTE_NAME = "P2P connection check"; + +export type P2PCheckTarget = "desktop" | "mobile"; + +export interface GeneratedP2PCheckSetup { + readonly target: P2PCheckTarget; + readonly setupURI: string; + readonly setupPassphrase: string; + readonly groupId: string; + readonly relay: string; + readonly browserDeviceName: string; + readonly browserSettings: ObsidianLiveSyncSettings; +} + +export interface P2PCheckSetupOptions { + readonly relay?: string; +} + +export interface P2PCheckPageLocation { + readonly hostname: string; + readonly search: string; +} + +interface SharedP2PCheckCredentials { + readonly groupId: string; + readonly p2pPassphrase: string; + readonly vaultPassphrase: string; +} + +const READABLE_SECRET_ALPHABET = "23456789abcdefghjkmnpqrstuvwxyz"; +const RANDOM_BYTE_ACCEPTANCE_LIMIT = + Math.floor(256 / READABLE_SECRET_ALPHABET.length) * READABLE_SECRET_ALPHABET.length; + +function generateReadableSecret(length: number): string { + const crypto = compatGlobal.crypto; + if (!crypto) { + throw new Error("Web Crypto is required to prepare a P2P connection check"); + } + + let result = ""; + const bytes = new Uint8Array(Math.max(16, length)); + while (result.length < length) { + crypto.getRandomValues(bytes); + for (const byte of bytes) { + if (byte >= RANDOM_BYTE_ACCEPTANCE_LIMIT) { + continue; + } + result += READABLE_SECRET_ALPHABET[byte % READABLE_SECRET_ALPHABET.length]; + if (result.length === length) { + break; + } + } + } + return result; +} + +function generateSetupPassphrase(): string { + return generateReadableSecret(16).match(/.{4}/g)!.join("-"); +} + +function createSettings( + credentials: SharedP2PCheckCredentials, + options: { + readonly autoStart: boolean; + readonly relay: string; + readonly useDiagnostics: boolean; + } +): ObsidianLiveSyncSettings { + const settings = createNewVaultSettings(); + Object.assign(settings, PREFERRED_BASE, P2P_DEFAULT_SETTINGS, { + isConfigured: true, + encrypt: true, + passphrase: credentials.vaultPassphrase, + usePathObfuscation: true, + P2P_Enabled: true, + P2P_AppID: P2P_CHECK_APP_ID, + P2P_roomID: credentials.groupId, + P2P_passphrase: credentials.p2pPassphrase, + P2P_relays: options.relay, + P2P_AutoStart: options.autoStart, + P2P_AutoBroadcast: false, + P2P_DevicePeerName: "", + P2P_useDiagRTC: options.useDiagnostics, + }); + upsertRemoteConfigurationInPlace(settings, "p2p", { + name: P2P_CHECK_REMOTE_NAME, + activate: true, + activateForP2P: true, + }); + return settings; +} + +export function resolveLocalP2PCheckRelayOverride(location: P2PCheckPageLocation): string | undefined { + const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); + if (!loopbackHosts.has(location.hostname.toLowerCase())) { + return undefined; + } + + const requestedRelay = new URLSearchParams(location.search).get("relay")?.trim(); + if (!requestedRelay) { + return undefined; + } + + try { + const relay = new URL(requestedRelay); + if (relay.protocol !== "ws:" && relay.protocol !== "wss:") { + return undefined; + } + return relay.href; + } catch { + return undefined; + } +} + +export async function generateP2PCheckSetup( + target: P2PCheckTarget, + options: P2PCheckSetupOptions = {} +): Promise { + const relay = options.relay?.trim() || P2P_DEFAULT_SETTINGS.P2P_relays; + const credentials: SharedP2PCheckCredentials = { + groupId: generateP2PRoomId(), + p2pPassphrase: generateReadableSecret(32), + vaultPassphrase: generateReadableSecret(32), + }; + const deviceSettings = createSettings(credentials, { + autoStart: true, + relay, + useDiagnostics: false, + }); + const browserSettings = createSettings(credentials, { + autoStart: false, + relay, + useDiagnostics: true, + }); + browserSettings.suspendParseReplicationResult = true; + + const setupPassphrase = generateSetupPassphrase(); + const setupURI = await encodeSettingsToSetupURI( + deviceSettings, + setupPassphrase, + ["pluginSyncExtendedSetting", "doNotUseFixedRevisionForChunks", "P2P_DevicePeerName", "deviceAndVaultName"], + true + ); + + return { + target, + setupURI: setupURI.trim(), + setupPassphrase, + groupId: credentials.groupId, + relay: deviceSettings.P2P_relays, + browserDeviceName: `p2p-check-browser-${target}-${credentials.groupId.slice(-3)}`, + browserSettings, + }; +} diff --git a/src/apps/webpeer/src/P2PCheckState.ts b/src/apps/webpeer/src/P2PCheckState.ts new file mode 100644 index 00000000..18d055da --- /dev/null +++ b/src/apps/webpeer/src/P2PCheckState.ts @@ -0,0 +1,129 @@ +import type { P2PServerInfo } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer"; + +export const P2P_CHECK_OBSERVATION_MILLISECONDS = 60_000; + +export type P2PCheckDiagnostics = P2PServerInfo["diag"]; +export type P2PCheckOutcome = "idle" | "waiting" | "connecting" | "retrying" | "connected" | "inconclusive" | "error"; +export type P2PAdditionalCheckOutcome = "waiting" | "negotiating" | "connected" | "inconclusive"; + +export interface P2PAdditionalCheckBaseline { + readonly activeConnectionIds: readonly string[]; + readonly totalClosedConnections: number; + readonly totalFailedConnections: number; + readonly totalNewConnections: number; + readonly totalSuccessfulConnections: number; +} + +export interface P2PAdditionalCheckProgress { + readonly activeConnections: number; + readonly closedConnections: number; + readonly failedConnections: number; + readonly newConnections: number; + readonly newActiveConnectionIds: readonly string[]; + readonly outcome: P2PAdditionalCheckOutcome; + readonly successfulConnections: number; +} + +export const EMPTY_P2P_CHECK_DIAGNOSTICS: P2PCheckDiagnostics = { + totalNewConnections: 0, + totalFailedConnections: 0, + totalSuccessfulConnections: 0, + totalClosedConnections: 0, + details: {}, +}; + +export function deriveP2PCheckOutcome( + diagnostics: P2PCheckDiagnostics, + monitorActive: boolean, + elapsedMilliseconds: number, + monitorError = false +): P2PCheckOutcome { + if (monitorError) { + return "error"; + } + if (diagnostics.totalSuccessfulConnections > 0) { + return "connected"; + } + if (!monitorActive) { + return "idle"; + } + if (elapsedMilliseconds >= P2P_CHECK_OBSERVATION_MILLISECONDS) { + return "inconclusive"; + } + if (diagnostics.totalFailedConnections > 0) { + return "retrying"; + } + if (diagnostics.totalNewConnections > 0 || Object.keys(diagnostics.details).length > 0) { + return "connecting"; + } + return "waiting"; +} + +export function countActiveP2PConnections(diagnostics: P2PCheckDiagnostics): number { + return Object.values(diagnostics.details).filter(({ connectionState }) => connectionState === "connected").length; +} + +function activeConnectionIds(diagnostics: P2PCheckDiagnostics): string[] { + return Object.entries(diagnostics.details) + .filter(([, { connectionState }]) => connectionState === "connected") + .map(([connectionId]) => connectionId); +} + +export function captureP2PAdditionalCheckBaseline(diagnostics: P2PCheckDiagnostics): P2PAdditionalCheckBaseline { + return { + activeConnectionIds: activeConnectionIds(diagnostics), + totalClosedConnections: diagnostics.totalClosedConnections, + totalFailedConnections: diagnostics.totalFailedConnections, + totalNewConnections: diagnostics.totalNewConnections, + totalSuccessfulConnections: diagnostics.totalSuccessfulConnections, + }; +} + +function counterIncrease(current: number, baseline: number): number { + return Math.max(0, current - baseline); +} + +export function deriveP2PAdditionalCheckProgress( + diagnostics: P2PCheckDiagnostics, + baseline: P2PAdditionalCheckBaseline, + elapsedMilliseconds: number +): P2PAdditionalCheckProgress { + const baselineConnectionIds = new Set(baseline.activeConnectionIds); + const currentActiveConnectionIds = activeConnectionIds(diagnostics); + const newActiveConnectionIds = currentActiveConnectionIds.filter( + (connectionId) => !baselineConnectionIds.has(connectionId) + ); + const activeConnections = counterIncrease(currentActiveConnectionIds.length, baseline.activeConnectionIds.length); + const newConnections = counterIncrease(diagnostics.totalNewConnections, baseline.totalNewConnections); + const successfulConnections = counterIncrease( + diagnostics.totalSuccessfulConnections, + baseline.totalSuccessfulConnections + ); + const failedConnections = counterIncrease(diagnostics.totalFailedConnections, baseline.totalFailedConnections); + const closedConnections = counterIncrease(diagnostics.totalClosedConnections, baseline.totalClosedConnections); + + let outcome: P2PAdditionalCheckOutcome = "waiting"; + if (successfulConnections > 0 && activeConnections > 0 && newActiveConnectionIds.length > 0) { + outcome = "connected"; + } else if (elapsedMilliseconds >= P2P_CHECK_OBSERVATION_MILLISECONDS) { + outcome = "inconclusive"; + } else if ( + newConnections > 0 || + successfulConnections > 0 || + failedConnections > 0 || + closedConnections > 0 || + newActiveConnectionIds.length > 0 + ) { + outcome = "negotiating"; + } + + return { + activeConnections, + closedConnections, + failedConnections, + newConnections, + newActiveConnectionIds, + outcome, + successfulConnections, + }; +} diff --git a/src/apps/webpeer/src/P2PReplicatorShim.ts b/src/apps/webpeer/src/P2PReplicatorShim.ts deleted file mode 100644 index a4d8d625..00000000 --- a/src/apps/webpeer/src/P2PReplicatorShim.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { PouchDB } from "@lib/pouchdb/pouchdb-browser"; -import { - type EntryDoc, - type ObsidianLiveSyncSettings, - type P2PSyncSetting, - LOG_LEVEL_VERBOSE, - P2P_DEFAULT_SETTINGS, - REMOTE_P2P, -} from "@lib/common/types"; -import { eventHub } from "@lib/hub/hub"; - -import type { Confirm } from "@lib/interfaces/Confirm"; -import { LOG_LEVEL_NOTICE, Logger, type LOG_LEVEL } from "@lib/common/logger"; -import { - EVENT_P2P_PEER_SHOW_EXTRA_MENU, - type PeerStatus, - type PluginShim, -} from "@lib/replication/trystero/P2PReplicatorPaneCommon"; -import { useP2PReplicator } from "@lib/replication/trystero/P2PReplicatorCore"; -import { P2PLogCollector } from "@lib/replication/trystero/P2PLogCollector"; -import type { P2PReplicatorBase } from "@lib/replication/trystero/P2PReplicatorBase.ts"; -import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase"; -import { reactiveSource } from "octagonal-wheels/dataobject/reactive_v2"; -import { EVENT_SETTING_SAVED } from "@lib/events/coreEvents"; -import { unique } from "octagonal-wheels/collection"; -import { BrowserServiceHub } from "@lib/services/BrowserServices"; -import { SETTING_KEY_P2P_DEVICE_NAME } from "@lib/common/types"; -import { ServiceContext } from "@lib/services/base/ServiceBase"; -import type { InjectableServiceHub } from "@lib/services/InjectableServices"; -import { Menu } from "@lib/services/implements/browser/Menu"; -import { SimpleStoreIDBv2 } from "octagonal-wheels/databases/SimpleStoreIDBv2"; -import type { BrowserAPIService } from "@lib/services/implements/browser/BrowserAPIService"; -import type { InjectableSettingService } from "@lib/services/implements/injectable/InjectableSettingService"; -import { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator"; -import { compatGlobal } from "@lib/common/coreEnvFunctions.ts"; - -function addToList(item: string, list: string) { - return unique( - list - .split(",") - .map((e) => e.trim()) - .concat(item) - .filter((p) => p) - ).join(","); -} -function removeFromList(item: string, list: string) { - return list - .split(",") - .map((e) => e.trim()) - .filter((p) => p !== item) - .filter((p) => p) - .join(","); -} - -export class P2PReplicatorShim implements P2PReplicatorBase { - storeP2PStatusLine = reactiveSource(""); - plugin!: PluginShim; - confirm!: Confirm; - db?: PouchDB.Database; - services: InjectableServiceHub; - - getDB() { - if (!this.db) { - throw new Error("DB not initialized"); - } - return this.db; - } - _simpleStore!: SimpleStore; - - async closeDB() { - if (this.db) { - await this.db.close(); - this.db = undefined; - } - } - - private _liveSyncReplicator?: LiveSyncTrysteroReplicator; - p2pLogCollector!: P2PLogCollector; - - private _initP2PReplicator() { - const { - replicator, - p2pLogCollector, - storeP2PStatusLine: p2pStatusLine, - } = useP2PReplicator({ services: this.services } as unknown as Parameters[0]); - this._liveSyncReplicator = replicator; - this.p2pLogCollector = p2pLogCollector; - p2pLogCollector.p2pReplicationLine.onChanged((line) => { - p2pStatusLine.value = line.value; - }); - } - - constructor() { - const browserServiceHub = new BrowserServiceHub(); - this.services = browserServiceHub; - - (this.services.API as BrowserAPIService).getSystemVaultName.setHandler( - () => "p2p-livesync-web-peer" - ); - const repStore = SimpleStoreIDBv2.open("p2p-livesync-web-peer"); - this._simpleStore = repStore; - let _settings = { ...P2P_DEFAULT_SETTINGS, additionalSuffixOfDatabaseName: "" } as ObsidianLiveSyncSettings; - this.services.setting.settings = _settings; - (this.services.setting as InjectableSettingService).saveData.setHandler(async (data) => { - await repStore.set("settings", data); - eventHub.emitEvent(EVENT_SETTING_SAVED, data); - }); - (this.services.setting as InjectableSettingService).loadData.setHandler(async () => { - const settings = { ..._settings, ...((await repStore.get("settings")) as ObsidianLiveSyncSettings) }; - return settings; - }); - } - - get settings() { - return this.services.setting.currentSettings() as P2PSyncSetting; - } - - async init() { - this.confirm = this.services.UI.confirm; - - if (this.db) { - try { - await this.closeDB(); - } catch (ex) { - Logger("Error closing db", LOG_LEVEL_VERBOSE); - Logger(ex, LOG_LEVEL_VERBOSE); - } - } - - await this.services.setting.loadSettings(); - this.plugin = { - services: this.services, - core: { - services: this.services, - }, - }; - const database_name = this.settings.P2P_AppID + "-" + this.settings.P2P_roomID + "p2p-livesync-web-peer"; - this.db = new PouchDB(database_name); - - this._initP2PReplicator(); - - compatGlobal.setTimeout(() => { - if (this.settings.P2P_AutoStart && this.settings.P2P_Enabled) { - void this.open(); - } - }, 1000); - return this; - } - - _log(msg: unknown, level?: LOG_LEVEL): void { - Logger(msg, level); - } - _notice(msg: string, key?: string): void { - Logger(msg, LOG_LEVEL_NOTICE, key); - } - getSettings(): P2PSyncSetting { - return this.settings; - } - simpleStore(): SimpleStore { - return this._simpleStore; - } - handleReplicatedDocuments(_docs: EntryDoc[]): Promise { - return Promise.resolve(true); - } - - getConfig(key: string) { - const vaultName = this.services.vault.getVaultName(); - const dbKey = `${vaultName}-${key}`; - return compatGlobal.localStorage.getItem(dbKey); - } - setConfig(key: string, value: string) { - const vaultName = this.services.vault.getVaultName(); - const dbKey = `${vaultName}-${key}`; - compatGlobal.localStorage.setItem(dbKey, value); - } - - getDeviceName(): string { - return this.getConfig(SETTING_KEY_P2P_DEVICE_NAME) ?? this.plugin.services.vault.getVaultName(); - } - - m?: Menu; - afterConstructor(): void { - eventHub.onEvent(EVENT_P2P_PEER_SHOW_EXTRA_MENU, ({ peer, event }) => { - if (this.m) { - this.m.hide(); - } - this.m = new Menu() - .addItem((item) => item.setTitle("đŸ“Ĩ Only Fetch").onClick(() => this.replicateFrom(peer))) - .addItem((item) => item.setTitle("📤 Only Send").onClick(() => this.replicateTo(peer))) - .addSeparator() - .addItem((item) => { - const mark = peer.syncOnConnect ? "checkmark" : null; - item.setTitle("Toggle Sync on connect") - .onClick(async () => { - await this.toggleProp(peer, "syncOnConnect"); - }) - .setIcon(mark); - }) - .addItem((item) => { - const mark = peer.watchOnConnect ? "checkmark" : null; - item.setTitle("Toggle Watch on connect") - .onClick(async () => { - await this.toggleProp(peer, "watchOnConnect"); - }) - .setIcon(mark); - }) - .addItem((item) => { - const mark = peer.syncOnReplicationCommand ? "checkmark" : null; - item.setTitle("Toggle Sync on `Replicate now` command") - .onClick(async () => { - await this.toggleProp(peer, "syncOnReplicationCommand"); - }) - .setIcon(mark); - }); - void this.m.showAtPosition({ x: event.x, y: event.y }); - }); - } - - async open() { - await this._liveSyncReplicator?.open(); - } - - async close() { - await this._liveSyncReplicator?.close(); - } - - enableBroadcastCastings() { - return this._liveSyncReplicator?.enableBroadcastChanges(); - } - disableBroadcastCastings() { - return this._liveSyncReplicator?.disableBroadcastChanges(); - } - - get replicator() { - return this._liveSyncReplicator; - } - - async replicateFrom(peer: PeerStatus) { - const r = this._liveSyncReplicator; - if (!r) return; - await r.replicateFrom(peer.peerId); - } - - async replicateTo(peer: PeerStatus) { - await this._liveSyncReplicator?.requestSynchroniseToPeer(peer.peerId); - } - - async getRemoteConfig(peer: PeerStatus) { - Logger( - `Requesting remote config for ${peer.name}. Please input the passphrase on the remote device`, - LOG_LEVEL_NOTICE - ); - const remoteConfig = await this._liveSyncReplicator?.getRemoteConfig(peer.peerId); - if (remoteConfig) { - Logger(`Remote config for ${peer.name} is retrieved successfully`); - const DROP = "Yes, and drop local database"; - const KEEP = "Yes, but keep local database"; - const CANCEL = "No, cancel"; - const yn = await this.confirm.askSelectStringDialogue( - `Do you really want to apply the remote config? This will overwrite your current config immediately and restart. - And you can also drop the local database to rebuild from the remote device.`, - [DROP, KEEP, CANCEL] as const, - { - defaultAction: CANCEL, - title: "Apply Remote Config ", - } - ); - if (yn === DROP || yn === KEEP) { - if (yn === DROP) { - if (remoteConfig.remoteType !== REMOTE_P2P) { - const yn2 = await this.confirm.askYesNoDialog( - `Do you want to set the remote type to "P2P Sync" to rebuild by "P2P replication"?`, - { title: "Rebuild from remote device" } - ); - if (yn2 === "yes") { - remoteConfig.remoteType = REMOTE_P2P; - remoteConfig.P2P_RebuildFrom = peer.name; - } - } - } - await this.services.setting.applyExternalSettings(remoteConfig, true); - if (yn !== DROP) { - this.plugin.core.services.appLifecycle.scheduleRestart(); - } - } else { - Logger(`Cancelled\nRemote config for ${peer.name} is not applied`, LOG_LEVEL_NOTICE); - } - } else { - Logger(`Cannot retrieve remote config for ${peer.peerId}`); - } - } - - async toggleProp(peer: PeerStatus, prop: "syncOnConnect" | "watchOnConnect" | "syncOnReplicationCommand") { - const settingMap = { - syncOnConnect: "P2P_AutoSyncPeers", - watchOnConnect: "P2P_AutoWatchPeers", - syncOnReplicationCommand: "P2P_SyncOnReplication", - } as const; - - const targetSetting = settingMap[prop]; - const currentSettingAll = this.plugin.core.services.setting.currentSettings(); - const currentSetting = { - [targetSetting]: currentSettingAll ? currentSettingAll[targetSetting] : "", - }; - if (peer[prop]) { - currentSetting[targetSetting] = removeFromList(peer.name, currentSetting[targetSetting]); - } else { - currentSetting[targetSetting] = addToList(peer.name, currentSetting[targetSetting]); - } - await this.plugin.core.services.setting.applyPartial(currentSetting, true); - } -} - -export const cmdSyncShim = new P2PReplicatorShim(); diff --git a/src/apps/webpeer/src/SyncMain.svelte b/src/apps/webpeer/src/SyncMain.svelte index 538fff34..2e667bc9 100644 --- a/src/apps/webpeer/src/SyncMain.svelte +++ b/src/apps/webpeer/src/SyncMain.svelte @@ -1,112 +1,109 @@ -
-
- {#await synchronised then cmdSync} - - {:catch error} -

{error.message}

- {/await} -
-
-
- {statusLine} -
-
- {#each $logs as log} -

{log}

- {/each} -
-
-
+ + + - +
+
+
+ + + Self-hosted LiveSync + WebPeer + A browser-hosted peer for temporary P2P transfers. + +
+ + + + + Disposable connectivity check + Try the P2P connection check + + + +
+ +
+
+
+

Browser peer controls

+ Stored in this browser +
+ + {#await synchronised then activeRuntime} + + + {:catch error} + + {/await} +
+ + +
+ +
+ WebPeer is experimental browser software. Keep this page open while it is connected or + transferring changes. +
+
diff --git a/src/apps/webpeer/src/UITest.svelte b/src/apps/webpeer/src/UITest.svelte index 0e6af79b..6e586a07 100644 --- a/src/apps/webpeer/src/UITest.svelte +++ b/src/apps/webpeer/src/UITest.svelte @@ -1,6 +1,6 @@
- +
-

Available Peers

+

{translateMessage("Available Peers")}

{#if serverInfo && serverInfo.knownAdvertisements.length > 0}
{#each serverInfo.knownAdvertisements as peer (peer.peerId)} @@ -125,14 +127,18 @@ disabled={syncingPeerId !== null} onclick={() => handleSync(peer.peerId)} > - {syncingPeerId === peer.peerId ? "Syncing..." : "Sync"} + {syncingPeerId === peer.peerId + ? translateMessage("Syncing...") + : translateMessage("Sync")} {:else} {/if}
@@ -148,16 +156,22 @@ {/each}
{:else if serverInfo} -

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

+

+ {translateMessage("No devices available. Waiting for other devices to connect...")} +

{/if}
diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts b/src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts index 9ac21507..39cf4a62 100644 --- a/src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts +++ b/src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts @@ -1,7 +1,7 @@ import type { App } from "@/deps.ts"; -import { Logger } from "@lib/common/logger"; -import { LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "@lib/common/types"; -import type { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator"; +import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger"; +import { LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator"; import { P2POpenReplicationModal } from "./P2POpenReplicationModal"; /** @@ -111,7 +111,7 @@ export function createOpenRebuildUI( try { replicator.setOnSetup(); Logger(`Rebuilding from peer ${peerId}`, logLevel); - const result = await replicator.replicateFrom(peerId, showResult); + const result = await replicator.replicateFrom(peerId, showResult, true); sessionResult = result?.ok ?? false; } catch (e) { Logger( diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicationUI.unit.spec.ts b/src/features/P2PSync/P2PReplicator/P2PReplicationUI.unit.spec.ts index e94d9ab3..86ad37e0 100644 --- a/src/features/P2PSync/P2PReplicator/P2PReplicationUI.unit.spec.ts +++ b/src/features/P2PSync/P2PReplicator/P2PReplicationUI.unit.spec.ts @@ -160,6 +160,19 @@ describe("createOpenRebuildUI", () => { await rebuild; await expect(session).resolves.toBe(true); expect(replicator.setOnSetup).toHaveBeenCalledOnce(); + expect(replicator.replicateFrom).toHaveBeenCalledWith("peer-a", true, true); expect(replicator.clearOnSetup).toHaveBeenCalledOnce(); }); + + it("does not complete Fetch when the rebuild dialogue closes without selecting a peer", async () => { + const replicator = createReplicator(); + const session = createOpenRebuildUI({} as any)(replicator)(true); + const modal = modalState.instances[0]; + + modal.onClosed?.(); + + await expect(session).resolves.toBe(false); + expect(replicator.replicateFrom).not.toHaveBeenCalled(); + expect(replicator.setOnSetup).not.toHaveBeenCalled(); + }); }); diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte b/src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte index 5987746d..a059c2b0 100644 --- a/src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte +++ b/src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte @@ -1,36 +1,35 @@
-

Peer to Peer Replicator

+

{_msg("Peer to Peer Replicator")}

{_msg("P2P.Note.Summary")}

{_msg("P2P.Note.important_note")}

@@ -274,23 +274,23 @@

{paragraph}

{/each}
-

Connection Settings

+

{_msg("Connection Settings")}

{#if isObsidian} - You can configure in the Obsidian Plugin Settings. + {_msg("You can configure in the Obsidian Plugin Settings.")} {:else}
{eRelay}
a
- + - + - + - + - + - + - + -->
Enable P2P Replicator {_msg("Enable P2P Replicator")}
Relay settings {_msg("Relay settings")}
Room ID {_msg("Room ID")} - - This can isolate your connections between devices. Use the same Room ID for the same - devices.{_msg( + "This can isolate your connections between devices. Use the same Room ID for the same devices." + )}
Password {_msg("Password")} - This password is used to encrypt the connection. Use something long enough. + {_msg("This password is used to encrypt the connection. Use something long enough.")}
This device name {_msg("This device name")}
Auto Connect {_msg("Auto Connect")}
Start change-broadcasting on Connect {_msg("Start change-broadcasting on Connect")}
- - + +
  • + {renderIcon(item)} + {item.title} +
  • {/if}
    -

    Signaling Server Connection

    +

    {_msg("Signaling Server Connection")}

    {#if !isConnected} -

    No Connection

    +

    {_msg("No Connection")}

    {:else} -

    Connected to Signaling Server (as Peer ID: {serverPeerId})

    +

    {_msg("Connected to Signaling Server (as Peer ID: ${peerId})", { peerId: serverPeerId })}

    {/if}
    {#if !isConnected} - + {:else} - + {#if replicatorInfo?.isBroadcasting !== undefined} {#if replicatorInfo?.isBroadcasting} - + {:else} - + {/if} {/if}
    - Broadcasting? + {_msg("Broadcasting?")}

    - If you want to use `LiveSync`, you should broadcast changes. All `watching` peers which - detects this will start the replication for fetching.
    - However, This should not be enabled if you want to increase your secrecy more. + {_msg( + "If you want to use `LiveSync`, you should broadcast changes. All `watching` peers which detects this will start the replication for fetching." + )} +
    + {_msg("However, This should not be enabled if you want to increase your secrecy more.")}

    @@ -424,18 +430,18 @@
    -

    Peers

    +

    {_msg("Peers")}

    - - - + + + {#each peers as peer} - + {/each}
    NameActionCommand{_msg("Name")}{_msg("Action")}{_msg("Command")}
    diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost.ts b/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost.ts new file mode 100644 index 00000000..0118aa4b --- /dev/null +++ b/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost.ts @@ -0,0 +1,12 @@ +import type { RequiredServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; +import type { PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon"; +import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult"; + +export type P2PReplicatorHandle = Pick; + +/** Host capabilities consumed by the shared P2P pane. */ +export interface P2PReplicatorPaneHost { + readonly services: RequiredServices<"API" | "config" | "setting" | "vault">; + readonly p2p: P2PReplicatorHandle; + readonly showPeerMenu?: (peer: PeerStatus, event: MouseEvent) => void; +} diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.ts b/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.ts index 93a58374..99671f81 100644 --- a/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.ts +++ b/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.ts @@ -2,14 +2,13 @@ import { Menu, WorkspaceLeaf } from "@/deps.ts"; import ReplicatorPaneComponent from "./P2PReplicatorPane.svelte"; import { mount } from "svelte"; import { SvelteItemView } from "@/common/SvelteItemView.ts"; -import { eventHub } from "@/common/events.ts"; import { unique } from "octagonal-wheels/collection"; -import { LOG_LEVEL_NOTICE, REMOTE_P2P } from "@lib/common/types.ts"; -import { Logger } from "@lib/common/logger.ts"; -import { EVENT_P2P_PEER_SHOW_EXTRA_MENU, type PeerStatus } from "@lib/replication/trystero/P2PReplicatorPaneCommon.ts"; +import { LOG_LEVEL_NOTICE, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger"; +import type { PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon"; import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts"; -import type { P2PPaneParams } from "@lib/replication/trystero/UseP2PReplicatorResult"; +import type { P2PPaneParams } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult"; export const VIEW_TYPE_P2P = "p2p-replicator"; function addToList(item: string, list: string) { @@ -127,46 +126,45 @@ And you can also drop the local database to rebuild from the remote device.`, super(leaf); this.core = core; this._p2pResult = p2pResult; - eventHub.onEvent(EVENT_P2P_PEER_SHOW_EXTRA_MENU, ({ peer, event }) => { - if (this.m) { - this.m.hide(); - } - this.m = new Menu() - .addItem((item) => item.setTitle("đŸ“Ĩ Only Fetch").onClick(() => this.replicateFrom(peer))) - .addItem((item) => item.setTitle("📤 Only Send").onClick(() => this.replicateTo(peer))) - .addSeparator() - .addItem((item) => { - item.setTitle("🔧 Get Configuration").onClick(async () => { - await this.getRemoteConfig(peer); - }); - }) - .addSeparator() - .addItem((item) => { - const mark = peer.syncOnConnect ? "checkmark" : null; - item.setTitle("Toggle Sync on connect") - .onClick(async () => { - await this.toggleProp(peer, "syncOnConnect"); - }) - .setIcon(mark); - }) - .addItem((item) => { - const mark = peer.watchOnConnect ? "checkmark" : null; - item.setTitle("Toggle Watch on connect") - .onClick(async () => { - await this.toggleProp(peer, "watchOnConnect"); - }) - .setIcon(mark); - }) - .addItem((item) => { - const mark = peer.syncOnReplicationCommand ? "checkmark" : null; - item.setTitle("Toggle Sync on `Replicate now` command") - .onClick(async () => { - await this.toggleProp(peer, "syncOnReplicationCommand"); - }) - .setIcon(mark); + } + + private showPeerMenu(peer: PeerStatus, event: MouseEvent): void { + this.m?.hide(); + this.m = new Menu() + .addItem((item) => item.setTitle("đŸ“Ĩ Only Fetch").onClick(() => this.replicateFrom(peer))) + .addItem((item) => item.setTitle("📤 Only Send").onClick(() => this.replicateTo(peer))) + .addSeparator() + .addItem((item) => { + item.setTitle("🔧 Get Configuration").onClick(async () => { + await this.getRemoteConfig(peer); }); - this.m.showAtPosition({ x: event.x, y: event.y }); - }); + }) + .addSeparator() + .addItem((item) => { + const mark = peer.syncOnConnect ? "checkmark" : null; + item.setTitle("Toggle Sync on connect") + .onClick(async () => { + await this.toggleProp(peer, "syncOnConnect"); + }) + .setIcon(mark); + }) + .addItem((item) => { + const mark = peer.watchOnConnect ? "checkmark" : null; + item.setTitle("Toggle Watch on connect") + .onClick(async () => { + await this.toggleProp(peer, "watchOnConnect"); + }) + .setIcon(mark); + }) + .addItem((item) => { + const mark = peer.syncOnReplicationCommand ? "checkmark" : null; + item.setTitle("Toggle Sync on `Replicate now` command") + .onClick(async () => { + await this.toggleProp(peer, "syncOnReplicationCommand"); + }) + .setIcon(mark); + }); + void this.m.showAtPosition({ x: event.x, y: event.y }); } getViewType() { @@ -187,8 +185,11 @@ And you can also drop the local database to rebuild from the remote device.`, return mount(ReplicatorPaneComponent, { target: target, props: { - cmdSync: this._p2pResult.replicator, - core: this.core, + host: { + services: this.core.services, + p2p: this._p2pResult, + showPeerMenu: (peer: PeerStatus, event: MouseEvent) => this.showPeerMenu(peer, event), + }, }, }); } diff --git a/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte b/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte index 2dc4592c..caa5cd42 100644 --- a/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte +++ b/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte @@ -2,50 +2,54 @@ import { onMount } from "svelte"; import { eventHub } from "@/common/events"; import { delay, fireAndForget } from "octagonal-wheels/promises"; - import type { P2PServerInfo } from "@lib/replication/trystero/TrysteroReplicatorP2PServer"; + import type { P2PServerInfo } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer"; import { EVENT_SERVER_STATUS, EVENT_REQUEST_STATUS, EVENT_P2P_REPLICATOR_STATUS, - } from "@lib/replication/trystero/TrysteroReplicatorP2PServer"; - import { EVENT_SETTING_SAVED } from "@lib/events/coreEvents"; - import type { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator"; - import type { P2PReplicatorStatus } from "@lib/replication/trystero/TrysteroReplicator"; - import { extractP2PRoomSuffix } from "@lib/common/utils"; + } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer"; + import { EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; + import type { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator"; + import type { P2PReplicatorStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicator"; + import { extractP2PRoomSuffix } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore"; + import { $msg as translateMessage } from "@/common/translation"; interface Props { - liveSyncReplicator: LiveSyncTrysteroReplicator; + getLiveSyncReplicator: () => LiveSyncTrysteroReplicator; showBroadcastToggle?: boolean; core?: LiveSyncBaseCore; } - let { liveSyncReplicator, showBroadcastToggle = true, core }: Props = $props(); + let { getLiveSyncReplicator, showBroadcastToggle = true, core }: Props = $props(); let serverInfo = $state(undefined); let replicatorStatus = $state(undefined); - let roomSuffix = $state(extractP2PRoomSuffix(core?.services.setting.currentSettings()?.P2P_roomID ?? "")); - let useDiagRTC = $state(core?.services.setting.currentSettings()?.P2P_useDiagRTC ?? false); + // Later setting changes arrive through EVENT_SETTING_SAVED; these values only seed local state at mount time. + const readCurrentSettings = () => core?.services.setting.currentSettings(); + const initialSettings = readCurrentSettings(); + let roomSuffix = $state(extractP2PRoomSuffix(initialSettings?.P2P_roomID ?? "")); + let useDiagRTC = $state(initialSettings?.P2P_useDiagRTC ?? false); async function requestServerStatus() { - await Promise.resolve(liveSyncReplicator.requestStatus()); + await Promise.resolve(getLiveSyncReplicator().requestStatus()); eventHub.emitEvent(EVENT_REQUEST_STATUS); } async function onOpenConnection() { - await liveSyncReplicator.makeSureOpened(); + await getLiveSyncReplicator().makeSureOpened(); await requestServerStatus(); } async function onDisconnect() { - await liveSyncReplicator.close(); + await getLiveSyncReplicator().close(); await requestServerStatus(); } function toggleBroadcast() { if (replicatorStatus?.isBroadcasting) { - liveSyncReplicator.disableBroadcastChanges(); + getLiveSyncReplicator().disableBroadcastChanges(); } else { - liveSyncReplicator.enableBroadcastChanges(); + getLiveSyncReplicator().enableBroadcastChanges(); } } @@ -91,57 +95,58 @@
    -

    Signalling Status

    +

    {translateMessage("Signalling Status")}

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

    Stats

    +

    {translateMessage("Stats")}

    - Incoming: + {translateMessage("Incoming:")} {serverInfo.diag.totalNewConnections}
    - Connected: + {translateMessage("Connected:")} {serverInfo.diag.totalSuccessfulConnections}
    - Failed: + {translateMessage("Failed:")} {serverInfo.diag.totalFailedConnections}
    - Closed: + {translateMessage("Closed:")} {serverInfo.diag.totalClosedConnections}
    @@ -307,4 +312,4 @@ font-size: 0.85rem; gap: 0.5rem; } - \ No newline at end of file + diff --git a/src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte b/src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte index 2ed575c8..3171cf72 100644 --- a/src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte +++ b/src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte @@ -7,59 +7,56 @@ EVENT_P2P_REPLICATOR_STATUS, EVENT_P2P_REPLICATOR_PROGRESS, type P2PServerInfo, - } from "@lib/replication/trystero/TrysteroReplicatorP2PServer"; - import type { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator"; - import type { P2PReplicatorStatus, P2PReplicationReport } from "@lib/replication/trystero/TrysteroReplicator"; + } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer"; + import type { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator"; + import type { P2PReplicatorStatus, P2PReplicationReport } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicator"; import { delay, fireAndForget } from "octagonal-wheels/promises"; import P2PServerStatusCard from "./P2PServerStatusCard.svelte"; - import { EVENT_SETTING_SAVED } from "@lib/events/coreEvents"; + import { EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore"; - import { ConnectionStringParser } from "@lib/common/ConnectionString"; - import type { P2PSyncSetting, RemoteConfiguration } from "@lib/common/models/setting.type"; - import { activateP2PRemoteConfiguration, createRemoteConfigurationId } from "@lib/serviceFeatures/remoteConfig"; - import { extractP2PRoomSuffix } from "@lib/common/utils"; + import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString"; + import type { P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type"; + import { + activateP2PRemoteConfiguration, + createRemoteConfigurationId, + type RemoteConfiguration, + } from "@vrtmrz/livesync-commonlib/remote-configurations"; + import { extractP2PRoomSuffix } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { SetupManager } from "@/modules/features/SetupManager"; import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte"; + import { Menu } from "@/deps"; + import { $msg as translateMessage } from "@/common/translation"; + import { + hasExactP2PPeer, + togglePersistedP2PPeer, + type PersistedP2PPeerSetting, + } from "./p2pPeerSettings"; interface Props { - liveSyncReplicator: LiveSyncTrysteroReplicator; + getLiveSyncReplicator: () => LiveSyncTrysteroReplicator; core: LiveSyncBaseCore; } - let { liveSyncReplicator, core }: Props = $props(); + let { getLiveSyncReplicator, core }: Props = $props(); let serverInfo = $state(undefined); let replicatorInfo = $state(undefined); let decidingPeerId = $state(null); let replicatingPeerId = $state(null); let communicatingUntil = $state>({}); const COMMUNICATION_HOLD_MS = 2500; - let syncOnReplicationSetting = $state(core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? ""); + // Later setting changes arrive through EVENT_SETTING_SAVED; these values only seed local state at mount time. + const readCurrentSettings = () => core.services.setting.currentSettings(); + const initialSettings = readCurrentSettings(); type P2PRemoteOption = { id: string; name: string; roomSuffix: string; }; let p2pRemoteOptions = $state([]); - let selectedP2PRemoteConfigurationId = $state( - core.services.setting.currentSettings()?.P2P_ActiveRemoteConfigurationId ?? "" - ); + let selectedP2PRemoteConfigurationId = $state(initialSettings?.P2P_ActiveRemoteConfigurationId ?? ""); let selectingP2PRemote = $state(false); - function addToList(item: string, list: string): string { - const items = list - .split(",") - .map((e) => e.trim()) - .filter((e) => e); - if (!items.includes(item)) items.push(item); - return items.join(","); - } - function removeFromList(item: string, list: string): string { - return list - .split(",") - .map((e) => e.trim()) - .filter((e) => e && e !== item) - .join(","); - } + let peerMenu: Menu | undefined; function markCommunicating(peerId: string) { const expiry = Date.now() + COMMUNICATION_HOLD_MS; @@ -124,7 +121,7 @@ } async function requestServerStatus() { - await liveSyncReplicator.requestStatus(); + await getLiveSyncReplicator().requestStatus(); eventHub.emitEvent(EVENT_REQUEST_STATUS); } @@ -149,7 +146,6 @@ }); const unsubscribeSettings = eventHub.onEvent(EVENT_SETTING_SAVED, (settings) => { - syncOnReplicationSetting = settings?.P2P_SyncOnReplication ?? ""; refreshP2PRemoteOptions(); }); const unsubscribeLayoutReady = eventHub.onEvent(EVENT_LAYOUT_READY, () => { @@ -169,15 +165,16 @@ unsubscribeReplicatorProgress(); unsubscribeSettings(); unsubscribeLayoutReady(); + peerMenu?.hide(); }; }); function getAcceptanceStatus(peer: P2PServerInfo["knownAdvertisements"][number]) { - if (peer.isTemporaryAccepted === true) return "ACCEPTED (in session)"; - if (peer.isAccepted === true) return "ACCEPTED"; - if (peer.isTemporaryAccepted === false) return "DENIED (in session)"; - if (peer.isAccepted === false) return "DENIED"; - return "NEW"; + if (peer.isTemporaryAccepted === true) return translateMessage("ACCEPTED (in session)"); + if (peer.isAccepted === true) return translateMessage("ACCEPTED"); + if (peer.isTemporaryAccepted === false) return translateMessage("DENIED (in session)"); + if (peer.isAccepted === false) return translateMessage("DENIED"); + return translateMessage("NEW"); } function getAcceptanceStatusClass(peer: P2PServerInfo["knownAdvertisements"][number]) { @@ -201,8 +198,6 @@ const activated = activateP2PRemoteConfiguration(settings, id); return activated || settings; }, true); - const latest = core.services.setting.currentSettings(); - syncOnReplicationSetting = latest.P2P_SyncOnReplication ?? ""; refreshP2PRemoteOptions(); } finally { selectingP2PRemote = false; @@ -247,8 +242,6 @@ const activated = activateP2PRemoteConfiguration(settings, id); return activated || settings; }, true); - const latest = core.services.setting.currentSettings(); - syncOnReplicationSetting = latest.P2P_SyncOnReplication ?? ""; refreshP2PRemoteOptions(); } @@ -294,7 +287,6 @@ const activated = activateP2PRemoteConfiguration(settings, selectedId); return activated || settings; }, true); - syncOnReplicationSetting = core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? ""; } async function makeDecision( @@ -304,7 +296,7 @@ ) { decidingPeerId = peer.peerId; try { - await liveSyncReplicator.makeDecision({ + await getLiveSyncReplicator().makeDecision({ peerId: peer.peerId, name: peer.name, decision, @@ -319,7 +311,7 @@ async function revokeDecision(peer: P2PServerInfo["knownAdvertisements"][number]) { decidingPeerId = peer.peerId; try { - await liveSyncReplicator.revokeDecision({ + await getLiveSyncReplicator().revokeDecision({ peerId: peer.peerId, name: peer.name, }); @@ -332,9 +324,9 @@ async function startReplication(peer: P2PServerInfo["knownAdvertisements"][number]) { replicatingPeerId = peer.peerId; try { - const pullResult = await liveSyncReplicator.replicateFrom(peer.peerId, true); + const pullResult = await getLiveSyncReplicator().replicateFrom(peer.peerId, true); if (pullResult?.ok) { - await liveSyncReplicator.requestSynchroniseToPeer(peer.peerId); + await getLiveSyncReplicator().requestSynchroniseToPeer(peer.peerId); } await requestServerStatus(); } finally { @@ -355,9 +347,9 @@ return; } if (isWatching(peerId)) { - liveSyncReplicator.unwatchPeer(peerId); + getLiveSyncReplicator().unwatchPeer(peerId); } else { - liveSyncReplicator.watchPeer(peerId); + getLiveSyncReplicator().watchPeer(peerId); } } @@ -369,29 +361,55 @@ return isLiveCommunicating || isHeldCommunicating; } - function isSyncTarget(peerName: string) { - return syncOnReplicationSetting - .split(",") - .map((e) => e.trim()) - .filter((e) => e) - .includes(peerName); + function isPersistedPeerSettingEnabled(setting: PersistedP2PPeerSetting, peerName: string) { + const settings = core.services.setting.currentSettings(); + return hasExactP2PPeer(settings[setting] ?? "", peerName); } - async function toggleSyncTarget(peer: P2PServerInfo["knownAdvertisements"][number]) { + async function togglePersistedPeerSetting( + peer: P2PServerInfo["knownAdvertisements"][number], + setting: PersistedP2PPeerSetting + ) { if (!canEditP2PSettings()) { return; } - const currentValue = core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? ""; - const newValue = isSyncTarget(peer.name) - ? removeFromList(peer.name, currentValue) - : addToList(peer.name, currentValue); - await updateSelectedP2PRemote({ P2P_SyncOnReplication: newValue }); + const currentSettings = core.services.setting.currentSettings(); + await updateSelectedP2PRemote(togglePersistedP2PPeer(currentSettings, setting, peer.name)); + } + + function openPeerMenu(event: MouseEvent, peer: P2PServerInfo["knownAdvertisements"][number]) { + peerMenu?.hide(); + peerMenu = new Menu() + .addItem((item) => { + item.setTitle(translateMessage("Synchronise when this device connects")) + .setChecked(isPersistedPeerSettingEnabled("P2P_AutoSyncPeers", peer.name)) + .onClick(() => { + void togglePersistedPeerSetting(peer, "P2P_AutoSyncPeers"); + }); + }) + .addItem((item) => { + item.setTitle(translateMessage("Follow whenever this device connects")) + .setChecked(isPersistedPeerSettingEnabled("P2P_AutoWatchPeers", peer.name)) + .onClick(() => { + void togglePersistedPeerSetting(peer, "P2P_AutoWatchPeers"); + }); + }) + .addItem((item) => { + item.setTitle(translateMessage("Include in the P2P synchronisation command")) + .setChecked(isPersistedPeerSettingEnabled("P2P_SyncOnReplication", peer.name)) + .onClick(() => { + void togglePersistedPeerSetting(peer, "P2P_SyncOnReplication"); + }); + }); + const target = event.currentTarget as HTMLElement; + const rect = target.getBoundingClientRect(); + peerMenu.showAtPosition({ x: rect.left, y: rect.bottom }); }
    -

    P2P Status

    +

    {translateMessage("P2P Status")}

    -We can use only Secure (HTTPS) connections on Obsidian Mobile. +{translateMessage("We can use only Secure (HTTPS) connections on Obsidian Mobile.")} - + - + - + - + - + - + - If you want to store the data in a specific folder within the bucket, you can specify a folder prefix here. - Otherwise, leave it blank to store data at the root of the bucket. + {translateMessage( + "If you want to store the data in a specific folder within the bucket, you can specify a folder prefix here. Otherwise, leave it blank to store data at the root of the bucket." + )} - + - If you cannot avoid CORS issues, you might want to try this option. It uses Obsidian's internal API to communicate - with the S3 server. Not compliant with web standards, but works. Note that this might break in future Obsidian - versions. + {translateMessage( + "If you cannot avoid CORS issues, you might want to try this option. It uses Obsidian's internal API to communicate with the S3 server. Not compliant with web standards, but works. Note that this might break in future Obsidian versions." + )} - - + + - - + + - + - + - + - For HS256/HS512 algorithms, provide the shared secret key. For ES256/ES512 algorithms, provide the pkcs8 - PEM-formatted private key. + {translateMessage( + "For HS256/HS512 algorithms, provide the shared secret key. For ES256/ES512 algorithms, provide the pkcs8 PEM-formatted private key." + )} - + - + - JWT (JSON Web Token) authentication allows you to securely authenticate with the CouchDB server using tokens. - Ensure that your CouchDB server is configured to accept JWTs and that the provided key and settings match the - server's configuration. Incidentally, I have not verified it very thoroughly. + {translateMessage( + "JWT (JSON Web Token) authentication allows you to securely authenticate with the CouchDB server using tokens. Ensure that your CouchDB server is configured to accept JWTs and that the provided key and settings match the server's configuration. Incidentally, I have not verified it very thoroughly." + )} + + {translateMessage( + "This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required." + )} +
    @@ -278,11 +312,22 @@ {#if processing} - Checking connection... Please wait. + {translateMessage("Checking connection... Please wait.")} {:else} - checkAndCommit()} /> - commit()} /> - cancel()} /> + checkAndCommit()} /> + {#if setupMode === "settings"} + + {translateMessage( + "Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected." + )} + + commit()} + /> + {/if} + cancel()} /> {/if} diff --git a/src/modules/features/SetupWizard/dialogs/SetupRemoteE2EE.svelte b/src/modules/features/SetupWizard/dialogs/SetupRemoteE2EE.svelte index 09a61766..efce2429 100644 --- a/src/modules/features/SetupWizard/dialogs/SetupRemoteE2EE.svelte +++ b/src/modules/features/SetupWizard/dialogs/SetupRemoteE2EE.svelte @@ -1,22 +1,23 @@ - -Please configure your end-to-end encryption settings. - + +{translateMessage("Please configure your end-to-end encryption settings.")} + - - Enabling end-to-end encryption ensures that your data is encrypted on your device before being sent to the remote - server. This means that even if someone gains access to the server, they won't be able to read your data without the - passphrase. Make sure to remember your passphrase, as it will be required to decrypt your data on other devices. + + {translateMessage( + "Enabling end-to-end encryption ensures that your data is encrypted on your device before being sent to the remote server. This means that even if someone gains access to the server, they won't be able to read your data without the passphrase. Make sure to remember your passphrase, as it will be required to decrypt your data on other devices." + )}
    - Also, please note that if you are using Peer-to-Peer synchronization, this configuration will be used when you switch - to other methods and connect to a remote server in the future. + {translateMessage( + "Also, please note that if you are using Peer-to-Peer synchronization, this configuration will be used when you switch to other methods and connect to a remote server in the future." + )}
    - This setting must be the same even when connecting to multiple synchronisation destinations. + {translateMessage("This setting must be the same even when connecting to multiple synchronisation destinations.")} - + - Obfuscating properties (e.g., path of file, size, creation and modification dates) adds an additional layer of - security by making it harder to identify the structure and names of your files and folders on the remote server. - This helps protect your privacy and makes it more difficult for unauthorized users to infer information about your - data. + {translateMessage( + "Obfuscating properties (e.g., path of file, size, creation and modification dates) adds an additional layer of security by making it harder to identify the structure and names of your files and folders on the remote server. This helps protect your privacy and makes it more difficult for unauthorized users to infer information about your data." + )} - - + + - In most cases, you should stick with the default algorithm ({E2EEAlgorithmNames[ - DEFAULT_SETTINGS.E2EEAlgorithm - ]}), This setting is only required if you have an existing Vault encrypted in a different format. + {translateMessage( + "In most cases, you should stick with the default algorithm (${algorithm}), This setting is only required if you have an existing Vault encrypted in a different format.", + { algorithm: E2EEAlgorithmNames[DEFAULT_SETTINGS.E2EEAlgorithm] } + )} - Changing the encryption algorithm will prevent access to any data previously encrypted with a different - algorithm. Ensure that all your devices are configured to use the same algorithm to maintain access to your - data. + {translateMessage( + "Changing the encryption algorithm will prevent access to any data previously encrypted with a different algorithm. Ensure that all your devices are configured to use the same algorithm to maintain access to your data." + )}

    - Please be aware that the End-to-End Encryption passphrase is not validated until the synchronisation process - actually commences. This is a security measure designed to protect your data. + {translateMessage( + "Please be aware that the End-to-End Encryption passphrase is not validated until the synchronisation process actually commences. This is a security measure designed to protect your data." + )}

    - Therefore, we ask that you exercise extreme caution when configuring server information manually. If an - incorrect passphrase is entered, the data on the server will become corrupted.

    - Please understand that this is intended behaviour. + {translateMessage( + "Therefore, we ask that you exercise extreme caution when configuring server information manually. If an incorrect passphrase is entered, the data on the server will become corrupted." + )}

    + {translateMessage("Please understand that this is intended behaviour.")}

    - commit()} /> - setResult(TYPE_CANCELLED)} /> + commit()} /> + setResult(TYPE_CANCELLED)} /> diff --git a/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte b/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte index 4e1067a4..89a16162 100644 --- a/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte +++ b/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte @@ -1,13 +1,13 @@ - -Please enter the Peer-to-Peer Synchronisation information below. - + +{translateMessage("Please enter the Peer-to-Peer Synchronisation information below.")} + - + - + - + + {translateMessage("Peer discovery uses Nostr-compatible signalling relays.")} + {translateMessage( + "The project's public signalling relay is a best-effort convenience operated by the project author. It does not store Vault contents, but signalling metadata may be visible to the relay. Availability and log retention are not guaranteed. You can replace it with your own Nostr-compatible relay." + )} + {translateMessage("Learn more about P2P connections")}. + + - + - - + + - The Group ID and passphrase are used to identify your group of devices. Make sure to use the same Group ID and - passphrase on all devices you want to synchronise.
    - Note that the Group ID is not limited to the generated format; you can use any string as the Group ID. + {translateMessage( + "The Group ID and passphrase are used to identify your group of devices. Make sure to use the same Group ID and passphrase on all devices you want to synchronise." + )}
    + {translateMessage( + "Note that the Group ID is not limited to the generated format; you can use any string as the Group ID." + )}
    - + - + - If "Auto Start P2P Connection" is enabled, the P2P connection will be started automatically when the plug-in - launches. + {translateMessage( + 'If "Auto Start P2P Connection" is enabled, the P2P connection will be started automatically when the plug-in launches.' + )} - + - If "Auto Broadcast Changes" is enabled, changes will be automatically broadcasted to connected peers without - requiring manual intervention. This requests peers to fetch this device's changes. + {translateMessage( + "When enabled, this device notifies connected peers after a local change. The notification contains no Vault data; a peer which follows this device then fetches the change through the encrypted P2P connection." + )} - + - TURN server settings are only necessary if you are behind a strict NAT or firewall that prevents direct P2P - connections. In most cases, you can leave these fields blank. + {translateMessage( + "TURN server settings are only necessary if you are behind a strict NAT or firewall that prevents direct P2P connections. In most cases, you can leave these fields blank." + )} - Using public TURN servers may have privacy implications, as your data will be relayed through third-party - servers. Even if your data are encrypted, your existence may be known to them. Please ensure you trust the TURN - server provider before using their services. Also your `network administrator` too. You should consider setting - up your own TURN server for your FQDN, if possible. + {translateMessage( + "TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust." + )} + {translateMessage("Learn more about signalling and TURN")}. - + - + - + @@ -298,11 +313,16 @@ {error}
    {#if processing} - Checking connection... Please wait. + {translateMessage("Checking connection... Please wait.")} {:else} - checkAndCommit()} /> - commit()} /> - cancel()} /> + checkAndCommit()} + /> + commit()} /> + cancel()} /> {/if} diff --git a/src/modules/features/SetupWizard/dialogs/UseSetupURI.svelte b/src/modules/features/SetupWizard/dialogs/UseSetupURI.svelte index 840f553f..5300558b 100644 --- a/src/modules/features/SetupWizard/dialogs/UseSetupURI.svelte +++ b/src/modules/features/SetupWizard/dialogs/UseSetupURI.svelte @@ -1,18 +1,19 @@ - + Please enter the Setup URI that was generated during server installation or on another device, along with the vault - passphrase.
    - Note that you can generate a new Setup URI by running the "Copy settings as a new Setup URI" command in the command palette.
    {translateMessage( + "Please enter the Setup URI that was generated during server installation or on another device, along with the vault passphrase." + )}
    + {translateMessage( + 'Note that you can generate a new Setup URI by running the "Copy settings as a new Setup URI" command in the command palette.' + )} - + -The Setup-URI is valid and ready to use. +{translateMessage("The Setup-URI is valid and ready to use.")} - The Setup-URI does not appear to be valid. Please check that you have copied it correctly. + {translateMessage("The Setup-URI does not appear to be valid. Please check that you have copied it correctly.")} - - + + {error} @@ -87,10 +91,10 @@ processSetupURI()} /> - setResult(TYPE_CANCELLED)} /> + setResult(TYPE_CANCELLED)} /> diff --git a/src/modules/features/SetupWizard/dialogs/couchDBConnectionProbe.ts b/src/modules/features/SetupWizard/dialogs/couchDBConnectionProbe.ts new file mode 100644 index 00000000..50b0dd64 --- /dev/null +++ b/src/modules/features/SetupWizard/dialogs/couchDBConnectionProbe.ts @@ -0,0 +1,63 @@ +import type { + ObsidianLiveSyncSettings, + RemoteDBSettings, +} from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type"; + +export type CouchDBConnectionProbeResult = { ok: true } | { ok: false; reason: string }; + +type CouchDBConnectionResult = + | string + | { + db: unknown; + info: unknown; + }; + +export interface CouchDBConnectionProbe { + isMobile(): boolean; + connectRemoteCouchDBWithSetting( + settings: RemoteDBSettings, + isMobile: boolean, + performSetup: boolean, + skipInfo: boolean + ): CouchDBConnectionResult | Promise; +} + +export function isCouchDBConnectionProbe(value: unknown): value is CouchDBConnectionProbe { + return ( + typeof value === "object" && + value !== null && + "isMobile" in value && + typeof value.isMobile === "function" && + "connectRemoteCouchDBWithSetting" in value && + typeof value.connectRemoteCouchDBWithSetting === "function" + ); +} + +export async function probeCouchDBConnection( + replicator: unknown, + settings: ObsidianLiveSyncSettings, + createIfMissing: boolean +): Promise { + if (!isCouchDBConnectionProbe(replicator)) { + return { ok: false, reason: "The CouchDB connection probe is unavailable." }; + } + const result = await replicator.connectRemoteCouchDBWithSetting( + settings, + replicator.isMobile(), + createIfMissing, + false + ); + if (typeof result === "string") { + return { ok: false, reason: result }; + } + return { ok: true }; +} + +export function isValidCouchDBServerURL(value: string): boolean { + try { + const url = new URL(value); + return (url.protocol === "http:" || url.protocol === "https:") && url.hostname !== ""; + } catch { + return false; + } +} diff --git a/src/modules/features/SetupWizard/dialogs/couchDBConnectionProbe.unit.spec.ts b/src/modules/features/SetupWizard/dialogs/couchDBConnectionProbe.unit.spec.ts new file mode 100644 index 00000000..e54659e8 --- /dev/null +++ b/src/modules/features/SetupWizard/dialogs/couchDBConnectionProbe.unit.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type"; +import { isValidCouchDBServerURL, probeCouchDBConnection } from "./couchDBConnectionProbe"; + +const settings = { + couchDB_URI: "https://couch.example", + couchDB_DBNAME: "notes", +} as ObsidianLiveSyncSettings; + +describe("CouchDB setup connection policy", () => { + it.each([ + [false, "connect to an existing database"], + [true, "create or connect to a database"], + ] as const)( + "%s can %s without changing the Commonlib connection contract", + async (createIfMissing, _description) => { + const connectRemoteCouchDBWithSetting = vi.fn(async () => ({ + db: {}, + info: { db_name: "notes" }, + })); + const replicator = { + isMobile: vi.fn(() => false), + connectRemoteCouchDBWithSetting, + tryConnectRemote: vi.fn(), + }; + + await expect(probeCouchDBConnection(replicator, settings, createIfMissing)).resolves.toEqual({ ok: true }); + expect(connectRemoteCouchDBWithSetting).toHaveBeenCalledWith(settings, false, createIfMissing, false); + expect(replicator.tryConnectRemote).not.toHaveBeenCalled(); + } + ); + + it("returns the connection error without saving or creating through another path", async () => { + const replicator = { + isMobile: vi.fn(() => true), + connectRemoteCouchDBWithSetting: vi.fn(() => "database does not exist"), + }; + + await expect(probeCouchDBConnection(replicator, settings, false)).resolves.toEqual({ + ok: false, + reason: "database does not exist", + }); + }); + + it.each([ + ["https://couch.example", true], + ["http://127.0.0.1:5984", true], + ["ftp://couch.example", false], + ["couch.example", false], + ["https://", false], + ])("validates the saved server URL %s", (value, expected) => { + expect(isValidCouchDBServerURL(value)).toBe(expected); + }); +}); diff --git a/src/modules/features/SetupWizard/dialogs/couchDBServerFixConfirmation.ts b/src/modules/features/SetupWizard/dialogs/couchDBServerFixConfirmation.ts new file mode 100644 index 00000000..433594dd --- /dev/null +++ b/src/modules/features/SetupWizard/dialogs/couchDBServerFixConfirmation.ts @@ -0,0 +1,11 @@ +import { $msg } from "@/common/translation"; + +export function getCouchDBServerFixConfirmation(settingKey: string, expectedValue: string) { + return { + title: $msg("Change CouchDB server setting"), + message: $msg("Change CouchDB server setting '${SETTING}' to '${VALUE}'?", { + SETTING: settingKey, + VALUE: expectedValue, + }), + }; +} diff --git a/src/modules/features/SetupWizard/dialogs/couchDBServerFixConfirmation.unit.spec.ts b/src/modules/features/SetupWizard/dialogs/couchDBServerFixConfirmation.unit.spec.ts new file mode 100644 index 00000000..3b789a2c --- /dev/null +++ b/src/modules/features/SetupWizard/dialogs/couchDBServerFixConfirmation.unit.spec.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { getCouchDBServerFixConfirmation } from "./couchDBServerFixConfirmation"; + +describe("CouchDB server requirement fixes", () => { + it("identifies the exact server setting and value before a fix is applied", () => { + expect(getCouchDBServerFixConfirmation("chttpd/require_valid_user", "true")).toEqual({ + title: "Change CouchDB server setting", + message: "Change CouchDB server setting 'chttpd/require_valid_user' to 'true'?", + }); + }); +}); diff --git a/src/modules/features/SetupWizard/dialogs/p2pSetupConnectionProbe.ts b/src/modules/features/SetupWizard/dialogs/p2pSetupConnectionProbe.ts new file mode 100644 index 00000000..44fd8b00 --- /dev/null +++ b/src/modules/features/SetupWizard/dialogs/p2pSetupConnectionProbe.ts @@ -0,0 +1,23 @@ +export type P2PSetupConnectionProbeResult = { ok: true } | { ok: false; reason: string }; + +export interface P2PSetupConnectionProbe { + setOnSetup(): void | Promise; + allowReconnection(): void | Promise; + open(): Promise; +} + +export async function probeP2PSetupConnection( + replicator: P2PSetupConnectionProbe +): Promise { + try { + await replicator.setOnSetup(); + await replicator.allowReconnection(); + await replicator.open(); + return { ok: true }; + } catch (error) { + return { + ok: false, + reason: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/src/modules/features/SetupWizard/dialogs/p2pSetupConnectionProbe.unit.spec.ts b/src/modules/features/SetupWizard/dialogs/p2pSetupConnectionProbe.unit.spec.ts new file mode 100644 index 00000000..28eed360 --- /dev/null +++ b/src/modules/features/SetupWizard/dialogs/p2pSetupConnectionProbe.unit.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; +import { probeP2PSetupConnection } from "./p2pSetupConnectionProbe"; + +describe("P2P setup connection probe", () => { + it("accepts an empty room after the signalling connection opens", async () => { + const replicator = { + knownAdvertisements: [], + setOnSetup: vi.fn(), + allowReconnection: vi.fn(), + open: vi.fn(async () => undefined), + }; + + await expect(probeP2PSetupConnection(replicator)).resolves.toEqual({ ok: true }); + expect(replicator.setOnSetup).toHaveBeenCalledOnce(); + expect(replicator.allowReconnection).toHaveBeenCalledOnce(); + expect(replicator.open).toHaveBeenCalledOnce(); + }); + + it("reports a signalling connection failure", async () => { + const replicator = { + knownAdvertisements: [], + setOnSetup: vi.fn(), + allowReconnection: vi.fn(), + open: vi.fn(async () => { + throw new Error("relay unavailable"); + }), + }; + + await expect(probeP2PSetupConnection(replicator)).resolves.toEqual({ + ok: false, + reason: "relay unavailable", + }); + }); +}); diff --git a/src/modules/features/SetupWizard/dialogs/setupDialogTypes.ts b/src/modules/features/SetupWizard/dialogs/setupDialogTypes.ts index 67ca7b0b..8ad4a97b 100644 --- a/src/modules/features/SetupWizard/dialogs/setupDialogTypes.ts +++ b/src/modules/features/SetupWizard/dialogs/setupDialogTypes.ts @@ -4,7 +4,7 @@ import type { EncryptionSettings, ObsidianLiveSyncSettings, P2PConnectionInfo, -} from "@lib/common/models/setting.type"; +} from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type"; export const TYPE_IDENTICAL = "identical"; export const TYPE_INDEPENDENT = "independent"; @@ -102,6 +102,11 @@ export type SetupRemoteE2EEResultType = typeof TYPE_CANCELLED | EncryptionSettin export type SetupRemoteBucketResultType = typeof TYPE_CANCELLED | BucketSyncSetting; export type SetupRemoteCouchDBResultType = typeof TYPE_CANCELLED | CouchDBConnection; +export type CouchDBSetupMode = "create-or-connect" | "connect-existing" | "settings"; +export type SetupRemoteCouchDBInitialData = { + settings: CouchDBConnection; + mode: CouchDBSetupMode; +}; export type SetupRemoteP2PResultType = typeof TYPE_CANCELLED | P2PConnectionInfo; diff --git a/src/modules/features/SetupWizard/dialogs/utilCheckCouchDB.ts b/src/modules/features/SetupWizard/dialogs/utilCheckCouchDB.ts index 590a4a5b..ed34d166 100644 --- a/src/modules/features/SetupWizard/dialogs/utilCheckCouchDB.ts +++ b/src/modules/features/SetupWizard/dialogs/utilCheckCouchDB.ts @@ -1,17 +1,26 @@ import { requestToCouchDBWithCredentials } from "@/common/utils"; -import { $msg } from "@lib/common/i18n"; -import { Logger } from "@lib/common/logger"; -import type { ObsidianLiveSyncSettings } from "@lib/common/types"; -import { parseHeaderValues } from "@lib/common/utils"; -import { isCloudantURI } from "@lib/pouchdb/utils_couchdb"; -import { generateCredentialObject } from "@lib/replication/httplib"; -import { compatGlobal } from "@lib/common/coreEnvFunctions.ts"; -import { isUnauthorizedError } from "@lib/common/utils.doc"; +import { $msg } from "@/common/translation"; +import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger"; +import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { parseHeaderValues } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import { isCloudantURI } from "@vrtmrz/livesync-commonlib/compat/pouchdb/utils_couchdb"; +import { generateCredentialObject } from "@vrtmrz/livesync-commonlib/compat/replication/httplib"; +import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; +import { isUnauthorizedError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc"; +import { normaliseCouchDBConfiguration } from "@/common/couchdbConfiguration"; export type ResultMessage = { message: string; classes: string[] }; export type ResultErrorMessage = { message: string; result: "error"; classes: string[] }; export type ResultOk = { message: string; result: "ok"; value?: T }; -export type ResultError = { message: string; result: "error"; value: T; fixMessage: string; fix(): Promise }; +export type ResultError = { + message: string; + result: "error"; + value: T; + fixMessage: string; + settingKey: string; + expectedValue: string; + fix(): Promise; +}; export type ConfigCheckResult = | ResultOk | ResultError @@ -78,8 +87,15 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) => const addSuccess = (msg: string, value?: T) => { result.push({ message: msg, result: "ok", value }); }; - const _addError = (message: string, fixMessage: string, fix: () => Promise, value?: T) => { - result.push({ message, result: "error", fixMessage, fix, value }); + const _addError = ( + message: string, + fixMessage: string, + settingKey: string, + expectedValue: string, + fix: () => Promise, + value?: T + ) => { + result.push({ message, result: "error", fixMessage, settingKey, expectedValue, fix, value }); }; const addErrorMessage = (msg: string, classes: string[] = []) => { result.push({ message: msg, result: "error", classes }); @@ -89,6 +105,8 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) => _addError( message, fixMessage, + key, + expected, async () => { await updateRemoteSetting(editingSettings, key, expected); }, @@ -115,7 +133,7 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) => undefined, customHeaders ); - const responseConfig = r.json; + const responseConfig = normaliseCouchDBConfiguration(r.json as unknown); addMessage($msg("obsidianLiveSyncSettingTab.msgNotice"), ["ob-btn-config-head"]); addMessage($msg("obsidianLiveSyncSettingTab.msgIfConfigNotPersistent"), ["ob-btn-config-info"]); addMessage($msg("obsidianLiveSyncSettingTab.msgConfigCheck"), ["ob-btn-config-head"]); diff --git a/src/modules/features/StatusBarDisplay.ts b/src/modules/features/StatusBarDisplay.ts new file mode 100644 index 00000000..e007e5d4 --- /dev/null +++ b/src/modules/features/StatusBarDisplay.ts @@ -0,0 +1,133 @@ +import { reactiveSource, type ReactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive"; + +import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; + +const STATUS_COUNTER_PADDING = "\u2007".repeat(10); + +export const STATUS_COUNTER_INACTIVE_LINGER_MS = 3_000; + +export type DisposableReactiveValue = ReactiveValue & { + dispose(): void; +}; + +function asDisposableReactiveValue(value: ReactiveSource, dispose: () => void): DisposableReactiveValue { + return { + get value() { + return value.value; + }, + onChanged(handler) { + value.onChanged(handler); + }, + offChanged(handler) { + value.offChanged(handler); + }, + dispose, + }; +} + +/** + * Mirrors an activity count while keeping each visible period on screen for a + * minimum total lifetime. The delay applies only when the source becomes zero. + */ +export function createMinimumVisibleActivityCount( + source: ReactiveValue, + minimumVisibleMs: number +): DisposableReactiveValue { + const minimumLifetime = Math.max(0, minimumVisibleMs); + const displayed = reactiveSource(Math.max(0, source.value)); + let visibleSince = displayed.value > 0 ? Date.now() : undefined; + let hideTimer: number | undefined; + let disposed = false; + + const cancelHide = () => { + if (hideTimer === undefined) return; + compatGlobal.clearTimeout(hideTimer); + hideTimer = undefined; + }; + const hideIfIdle = () => { + hideTimer = undefined; + if (disposed || Math.max(0, source.value) > 0) return; + displayed.value = 0; + visibleSince = undefined; + }; + const update = () => { + if (disposed) return; + const nextCount = Math.max(0, source.value); + cancelHide(); + if (nextCount > 0) { + if (displayed.value === 0) { + visibleSince = Date.now(); + } + displayed.value = nextCount; + return; + } + if (displayed.value === 0) { + visibleSince = undefined; + return; + } + + const elapsed = Date.now() - (visibleSince ?? Date.now()); + const remaining = Math.max(0, minimumLifetime - elapsed); + if (remaining === 0) { + hideIfIdle(); + } else { + hideTimer = compatGlobal.setTimeout(hideIfIdle, remaining); + } + }; + + source.onChanged(update); + return asDisposableReactiveValue(displayed, () => { + if (disposed) return; + disposed = true; + cancelHide(); + source.offChanged(update); + }); +} + +/** + * Formats a counter with a stable width and briefly retains its zero value so + * that the completion of queued work remains visible. + */ +export function createPaddedCounterLabel( + source: ReactiveValue, + mark: string, + inactiveLingerMs = STATUS_COUNTER_INACTIVE_LINGER_MS +): DisposableReactiveValue { + const linger = Math.max(0, inactiveLingerMs); + const formatted = reactiveSource(""); + let maximumLength = 1; + let clearTimer: number | undefined; + let disposed = false; + + const cancelClear = () => { + if (clearTimer === undefined) return; + compatGlobal.clearTimeout(clearTimer); + clearTimer = undefined; + }; + const format = (count: number) => { + const requiredLength = `${Math.abs(count)}`.length + 1; + maximumLength = Math.max(maximumLength, requiredLength); + return ` ${mark}${`${STATUS_COUNTER_PADDING}${count}`.slice(-maximumLength)}`; + }; + const update = () => { + if (disposed) return; + cancelClear(); + const count = source.value; + formatted.value = format(count); + if (count !== 0) return; + clearTimer = compatGlobal.setTimeout(() => { + clearTimer = undefined; + if (disposed) return; + formatted.value = ""; + maximumLength = 1; + }, linger); + }; + + source.onChanged(update); + return asDisposableReactiveValue(formatted, () => { + if (disposed) return; + disposed = true; + cancelClear(); + source.offChanged(update); + }); +} diff --git a/src/modules/features/StatusBarDisplay.unit.spec.ts b/src/modules/features/StatusBarDisplay.unit.spec.ts new file mode 100644 index 00000000..f52daa0c --- /dev/null +++ b/src/modules/features/StatusBarDisplay.unit.spec.ts @@ -0,0 +1,139 @@ +import { reactive, reactiveSource } from "octagonal-wheels/dataobject/reactive"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + STATUS_COUNTER_INACTIVE_LINGER_MS, + createMinimumVisibleActivityCount, + createPaddedCounterLabel, +} from "./StatusBarDisplay.ts"; + +describe("createMinimumVisibleActivityCount", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-16T00:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("keeps a short activity visible for the configured minimum lifetime", () => { + const source = reactiveSource(0); + const display = createMinimumVisibleActivityCount(source, 150); + const rendered = reactive(() => `active:${display.value}`); + + expect(rendered.value).toBe("active:0"); + source.value = 1; + expect(rendered.value).toBe("active:1"); + vi.advanceTimersByTime(50); + source.value = 0; + + expect(display.value).toBe(1); + vi.advanceTimersByTime(99); + expect(display.value).toBe(1); + vi.advanceTimersByTime(1); + expect(display.value).toBe(0); + expect(rendered.value).toBe("active:0"); + + display.dispose(); + }); + + it("updates overlapping activity and starts a new minimum lifetime after becoming idle", () => { + const source = reactiveSource(0); + const display = createMinimumVisibleActivityCount(source, 150); + + source.value = 1; + vi.advanceTimersByTime(25); + source.value = 2; + expect(display.value).toBe(2); + source.value = 0; + + vi.advanceTimersByTime(50); + source.value = 1; + expect(display.value).toBe(1); + + vi.advanceTimersByTime(75); + source.value = 0; + expect(display.value).toBe(0); + + source.value = 3; + source.value = 0; + expect(display.value).toBe(3); + vi.advanceTimersByTime(150); + expect(display.value).toBe(0); + + display.dispose(); + }); + + it("cancels pending work and stops observing its source when disposed", () => { + const source = reactiveSource(0); + const display = createMinimumVisibleActivityCount(source, 150); + + source.value = 1; + source.value = 0; + display.dispose(); + vi.advanceTimersByTime(150); + source.value = 2; + + expect(display.value).toBe(1); + }); +}); + +describe("createPaddedCounterLabel", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("keeps the widest counter label until its inactive linger period ends", () => { + const source = reactiveSource(0); + const display = createPaddedCounterLabel(source, "đŸ“Ĩ"); + + expect(display.value).toBe(""); + source.value = 9; + expect(display.value).toBe(" đŸ“Ĩ\u20079"); + source.value = 123; + expect(display.value).toBe(" đŸ“Ĩ\u2007123"); + source.value = 0; + expect(display.value).toBe(" đŸ“Ĩ\u2007\u2007\u20070"); + + vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS - 1); + expect(display.value).toBe(" đŸ“Ĩ\u2007\u2007\u20070"); + vi.advanceTimersByTime(1); + expect(display.value).toBe(""); + + source.value = 7; + expect(display.value).toBe(" đŸ“Ĩ\u20077"); + display.dispose(); + }); + + it("cancels the pending clear when counter activity resumes", () => { + const source = reactiveSource(0); + const display = createPaddedCounterLabel(source, "📄"); + + source.value = 1; + source.value = 0; + vi.advanceTimersByTime(1_000); + source.value = 2; + vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS); + + expect(display.value).toBe(" 📄\u20072"); + display.dispose(); + }); + + it("cancels its inactive timer and source subscription when disposed", () => { + const source = reactiveSource(0); + const display = createPaddedCounterLabel(source, "📄"); + + source.value = 4; + source.value = 0; + display.dispose(); + vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS); + source.value = 5; + + expect(display.value).toBe(" 📄\u20070"); + }); +}); diff --git a/src/modules/main/ModuleLiveSyncMain.ts b/src/modules/main/ModuleLiveSyncMain.ts index 73f45c10..2dc32c2f 100644 --- a/src/modules/main/ModuleLiveSyncMain.ts +++ b/src/modules/main/ModuleLiveSyncMain.ts @@ -1,5 +1,9 @@ import { fireAndForget } from "octagonal-wheels/promises"; -import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, VER, type ObsidianLiveSyncSettings } from "@lib/common/types.ts"; +import { + LOG_LEVEL_NOTICE, + LOG_LEVEL_VERBOSE, + type ObsidianLiveSyncSettings, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; import { EVENT_LAYOUT_READY, EVENT_PLUGIN_LOADED, @@ -7,14 +11,12 @@ import { EVENT_SETTING_SAVED, eventHub, } from "@/common/events.ts"; -import { $msg, setLang } from "@lib/common/i18n.ts"; -import { versionNumberString2Number } from "@lib/string_and_binary/convert.ts"; +import { $msg, setLang } from "@/common/translation"; import { AbstractModule } from "@/modules/AbstractModule.ts"; -import type { InjectableServiceHub } from "@lib/services/implements/injectable/InjectableServiceHub.ts"; +import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub"; 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"; +import { initialiseWorkerModule } from "@vrtmrz/livesync-commonlib/compat/worker/bgWorker"; +import { manifestVersion, packageVersion } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvVars"; export class ModuleLiveSyncMain extends AbstractModule { async _onLiveSyncReady() { @@ -81,7 +83,7 @@ export class ModuleLiveSyncMain extends AbstractModule { } async _onLiveSyncLoad(): Promise { - initialiseWorkerModule(); + initialiseWorkerModule(this.services.context.events); await this.services.appLifecycle.onWireUpEvents(); // debugger; eventHub.emitEvent(EVENT_PLUGIN_LOADED); @@ -97,30 +99,6 @@ export class ModuleLiveSyncMain extends AbstractModule { this._log($msg("moduleLiveSyncMain.logPluginInitCancelled"), LOG_LEVEL_NOTICE); return false; } - const lsKey = "obsidian-live-sync-ver" + this.services.vault.getVaultName(); - const last_version = compatGlobal.localStorage.getItem(lsKey); - - const lastVersion = ~~(versionNumberString2Number(manifestVersion) / 1000); - if (lastVersion > this.settings.lastReadUpdates && this.settings.isConfigured) { - this._log($msg("moduleLiveSyncMain.logReadChangelog"), LOG_LEVEL_NOTICE); - } - - // //@ts-ignore - // if (this.isMobile) { - // this.settings.disableRequestURI = true; - // } - if (last_version && Number(last_version) < VER) { - this.settings.liveSync = false; - this.settings.syncOnSave = false; - this.settings.syncOnEditorSave = false; - this.settings.syncOnStart = false; - this.settings.syncOnFileOpen = false; - this.settings.syncAfterMerge = false; - this.settings.periodicReplication = false; - this.settings.versionUpFlash = $msg("moduleLiveSyncMain.logVersionUpdate"); - await this.saveSettings(); - } - compatGlobal.localStorage.setItem(lsKey, `${VER}`); await this.services.database.openDatabase({ databaseEvents: this.services.databaseEvents, replicator: this.services.replicator, diff --git a/src/modules/services/LiveSyncUI/DialogHost.svelte b/src/modules/services/LiveSyncUI/DialogHost.svelte new file mode 100644 index 00000000..6265c7bf --- /dev/null +++ b/src/modules/services/LiveSyncUI/DialogHost.svelte @@ -0,0 +1,184 @@ + + +
    + +
    + + diff --git a/src/modules/services/LiveSyncUI/components/Check.svelte b/src/modules/services/LiveSyncUI/components/Check.svelte new file mode 100644 index 00000000..5b33a9f1 --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/Check.svelte @@ -0,0 +1,53 @@ + + + +
    + + {#if value && noteOnSelected} + {@render noteOnSelected()} + {:else if !value && noteOnUnselected} + {@render noteOnUnselected()} + {/if} + {@render children?.()} +
    + + diff --git a/src/modules/services/LiveSyncUI/components/Decision.svelte b/src/modules/services/LiveSyncUI/components/Decision.svelte new file mode 100644 index 00000000..3a90725c --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/Decision.svelte @@ -0,0 +1,24 @@ + + + diff --git a/src/modules/services/LiveSyncUI/components/DialogHeader.svelte b/src/modules/services/LiveSyncUI/components/DialogHeader.svelte new file mode 100644 index 00000000..5fc71d93 --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/DialogHeader.svelte @@ -0,0 +1,41 @@ + + +
    +

    {translatedTitle}

    + {#if translatedSubtitle} +

    {translatedSubtitle}

    + {/if} +
    + + diff --git a/src/modules/services/LiveSyncUI/components/ExtraItems.svelte b/src/modules/services/LiveSyncUI/components/ExtraItems.svelte new file mode 100644 index 00000000..71227fea --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/ExtraItems.svelte @@ -0,0 +1,17 @@ + + +
    + {translatedTitle} +
    + {@render children?.()} +
    +
    diff --git a/src/modules/services/LiveSyncUI/components/Guidance.svelte b/src/modules/services/LiveSyncUI/components/Guidance.svelte new file mode 100644 index 00000000..cb6af576 --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/Guidance.svelte @@ -0,0 +1,21 @@ + + +
    + {#if translatedTitle} +

    {translatedTitle}

    + {/if} + {@render children?.()} +
    diff --git a/src/modules/services/LiveSyncUI/components/InfoNote.svelte b/src/modules/services/LiveSyncUI/components/InfoNote.svelte new file mode 100644 index 00000000..117e1760 --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/InfoNote.svelte @@ -0,0 +1,87 @@ + + +{#if visible === undefined || visible === true} +
    + {#if signalWordText} +
    {signalWordText}
    + {/if} + {#if translatedTitle}

    {translatedTitle}

    {/if} + {#if translatedMessage}

    {translatedMessage}

    {/if} + {@render children?.()} +
    +{/if} diff --git a/src/modules/services/LiveSyncUI/components/InfoTable.svelte b/src/modules/services/LiveSyncUI/components/InfoTable.svelte new file mode 100644 index 00000000..e779e624 --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/InfoTable.svelte @@ -0,0 +1,74 @@ + + +
    +
    + {#each infoEntries as [key, value]} +
    +
    {key}
    +
    +
    +
    {value}
    +
    + {/each} +
    +
    + + diff --git a/src/modules/services/LiveSyncUI/components/InputRow.svelte b/src/modules/services/LiveSyncUI/components/InputRow.svelte new file mode 100644 index 00000000..75eed247 --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/InputRow.svelte @@ -0,0 +1,15 @@ + + + diff --git a/src/modules/services/LiveSyncUI/components/Instruction.svelte b/src/modules/services/LiveSyncUI/components/Instruction.svelte new file mode 100644 index 00000000..fde612f5 --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/Instruction.svelte @@ -0,0 +1,10 @@ + + +
    + {@render children?.()} +
    diff --git a/src/modules/services/LiveSyncUI/components/Option.svelte b/src/modules/services/LiveSyncUI/components/Option.svelte new file mode 100644 index 00000000..50248699 --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/Option.svelte @@ -0,0 +1,81 @@ + + +
    + +
    + + diff --git a/src/modules/services/LiveSyncUI/components/Options.svelte b/src/modules/services/LiveSyncUI/components/Options.svelte new file mode 100644 index 00000000..678a37fd --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/Options.svelte @@ -0,0 +1,14 @@ + + +
    + {@render children?.()} +
    diff --git a/src/modules/services/LiveSyncUI/components/Password.svelte b/src/modules/services/LiveSyncUI/components/Password.svelte new file mode 100644 index 00000000..5ebf3884 --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/Password.svelte @@ -0,0 +1,34 @@ + + + + diff --git a/src/modules/services/LiveSyncUI/components/Question.svelte b/src/modules/services/LiveSyncUI/components/Question.svelte new file mode 100644 index 00000000..3956bf11 --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/Question.svelte @@ -0,0 +1,26 @@ + + +
    + {#if question}

    {@render question?.()}

    {/if} +
    + {@render children?.()} +
    +
    + + diff --git a/src/modules/services/LiveSyncUI/components/UserDecisions.svelte b/src/modules/services/LiveSyncUI/components/UserDecisions.svelte new file mode 100644 index 00000000..7aadf81c --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/UserDecisions.svelte @@ -0,0 +1,12 @@ + + +
    + {#if children} + {@render children()} + {/if} +
    diff --git a/src/modules/services/LiveSyncUI/dialogues/DialogueToCopy.svelte b/src/modules/services/LiveSyncUI/dialogues/DialogueToCopy.svelte new file mode 100644 index 00000000..9df487da --- /dev/null +++ b/src/modules/services/LiveSyncUI/dialogues/DialogueToCopy.svelte @@ -0,0 +1,60 @@ + + + + + + + + + + + Your {title || "data"} has been copied to the clipboard. + + + + + + diff --git a/src/modules/services/LiveSyncUI/svelteDialog.ts b/src/modules/services/LiveSyncUI/svelteDialog.ts new file mode 100644 index 00000000..648e4057 --- /dev/null +++ b/src/modules/services/LiveSyncUI/svelteDialog.ts @@ -0,0 +1,14 @@ +export type { + HasSetResult, + HasGetInitialData, + ComponentHasResult, + GuestDialogProps, + DialogSvelteComponentBaseProps, + DialogControlBase, +} from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog"; +export { + CONTEXT_DIALOG_CONTROLS, + setupDialogContext, + getDialogContext, + SvelteDialogManagerBase, +} from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog"; diff --git a/src/modules/services/ObsidianAPIService.ts b/src/modules/services/ObsidianAPIService.ts index daa0a895..c455d172 100644 --- a/src/modules/services/ObsidianAPIService.ts +++ b/src/modules/services/ObsidianAPIService.ts @@ -1,11 +1,11 @@ -import { InjectableAPIService } from "@lib/services/implements/injectable/InjectableAPIService"; -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; +import { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAPIService"; +import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; import { Platform, type Command, type ViewCreator } from "@/deps.ts"; import { ObsHttpHandler } from "@/modules/essentialObsidian/APILib/ObsHttpHandler"; import { ObsidianConfirm } from "./ObsidianConfirm"; -import type { Confirm } from "@lib/interfaces/Confirm"; +import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm"; import { requestUrl, type RequestUrlParam } from "@/deps"; -import { compatGlobal } from "@lib/common/coreEnvFunctions"; +import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; // All Services will be migrated to be based on Plain Services, not Injectable Services. // This is a migration step. diff --git a/src/modules/services/ObsidianAPIService.unit.spec.ts b/src/modules/services/ObsidianAPIService.unit.spec.ts new file mode 100644 index 00000000..6523bfaf --- /dev/null +++ b/src/modules/services/ObsidianAPIService.unit.spec.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + platform: { + isMobile: false, + }, +})); + +vi.mock("@/deps.ts", () => ({ + Platform: mocks.platform, + requestUrl: vi.fn(), +})); + +vi.mock("@/deps", () => ({ + Platform: mocks.platform, + requestUrl: vi.fn(), +})); + +vi.mock("@/modules/essentialObsidian/APILib/ObsHttpHandler", () => ({ + ObsHttpHandler: class {}, +})); + +vi.mock("./ObsidianConfirm", () => ({ + ObsidianConfirm: class {}, +})); + +import { ObsidianAPIService } from "./ObsidianAPIService"; +import type { ObsidianServiceContext } from "./ObsidianServiceContext"; + +function createService(workspace: Record, isMobile = false): ObsidianAPIService { + return new ObsidianAPIService({ + app: { workspace, isMobile }, + } as unknown as ObsidianServiceContext); +} + +beforeEach(() => { + mocks.platform.isMobile = false; + vi.clearAllMocks(); +}); + +describe("ObsidianAPIService.showWindowOnRight", () => { + it("keeps the status view in the right leaf on mobile", async () => { + mocks.platform.isMobile = true; + const rightLeaf = { + setViewState: vi.fn().mockResolvedValue(undefined), + }; + const workspace = { + getLeavesOfType: vi.fn(() => []), + getLeaf: vi.fn(), + getRightLeaf: vi.fn(() => rightLeaf), + revealLeaf: vi.fn().mockResolvedValue(undefined), + }; + const service = createService(workspace, true); + + expect(service.isMobile()).toBe(true); + await service.showWindowOnRight("p2p-status"); + + expect(workspace.getLeavesOfType).toHaveBeenCalledWith("p2p-status"); + expect(workspace.getRightLeaf).toHaveBeenCalledWith(false); + expect(workspace.getLeaf).not.toHaveBeenCalled(); + expect(rightLeaf.setViewState).toHaveBeenCalledWith({ + type: "p2p-status", + active: false, + }); + expect(workspace.revealLeaf).toHaveBeenCalledWith(rightLeaf); + }); +}); diff --git a/src/modules/services/ObsidianAppLifecycleService.ts b/src/modules/services/ObsidianAppLifecycleService.ts index 6c730e62..e2888a7a 100644 --- a/src/modules/services/ObsidianAppLifecycleService.ts +++ b/src/modules/services/ObsidianAppLifecycleService.ts @@ -1,5 +1,5 @@ -import { AppLifecycleServiceBase } from "@lib/services/implements/injectable/InjectableAppLifecycleService"; -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; +import { AppLifecycleServiceBase } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAppLifecycleService"; +import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; declare module "obsidian" { interface App { commands: { diff --git a/src/modules/services/ObsidianConfirm.ts b/src/modules/services/ObsidianConfirm.ts index ca4f9c1a..f9197e02 100644 --- a/src/modules/services/ObsidianConfirm.ts +++ b/src/modules/services/ObsidianConfirm.ts @@ -1,18 +1,16 @@ import { type App, type Plugin, Notice } from "@/deps"; import { scheduleTask, memoIfNotExist, memoObject, retrieveMemoObject, disposeMemoObject } from "@/common/utils"; -import { $msg } from "@lib/common/i18n"; -import type { Confirm } from "@lib/interfaces/Confirm"; -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; -import { - askYesNo, - askString, - confirmWithMessageWithWideButton, - askSelectString, - confirmWithMessage, -} from "@/modules/coreObsidian/UILib/dialogs"; +import { EVENT_PLUGIN_UNLOADED } from "@/common/events"; +import { $msg } from "@/common/translation"; +import type { Confirm, ConfirmActionLayout } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm"; +import { confirmAction, pickOne, promptPassword, promptText } from "@vrtmrz/obsidian-plugin-kit"; +import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; +import { confirmWithMessageWithWideButton } from "@/modules/coreObsidian/UILib/dialogs"; export class ObsidianConfirm implements Confirm { private _context: T; + private readonly dialogueController = new AbortController(); + private readonly popupKeys = new Set(); get _app(): App { return this._context.app; } @@ -21,12 +19,59 @@ export class ObsidianConfirm { + this.dialogueController.abort(); + for (const popupKey of this.popupKeys) { + this.closePopup(popupKey); + } + }); } - askYesNo(message: string): Promise<"yes" | "no"> { - return askYesNo(this._app, message); + + private get dialogueLifecycle() { + return { signal: this.dialogueController.signal }; } - askString(title: string, key: string, placeholder: string, isPassword: boolean = false): Promise { - return askString(this._app, title, key, placeholder, isPassword); + + private hasCountdown(timeout: number | undefined): timeout is number { + return timeout !== undefined && timeout > 0; + } + + async askYesNo(message: string): Promise<"yes" | "no"> { + const result = await confirmAction( + this._app, + { + title: $msg("moduleInputUIObsidian.defaultTitleConfirmation"), + message, + actions: ["yes", "no"] as const, + labels: { + yes: $msg("moduleInputUIObsidian.optionYes"), + no: $msg("moduleInputUIObsidian.optionNo"), + }, + actionLayout: "vertical", + defaultAction: "no", + sourcePath: "/", + }, + this.dialogueLifecycle + ); + return result === "yes" ? "yes" : "no"; + } + + async askString( + title: string, + key: string, + placeholder: string, + isPassword: boolean = false + ): Promise { + const prompt = isPassword ? promptPassword : promptText; + const result = await prompt( + this._app, + { + title, + label: key, + placeholder, + }, + this.dialogueLifecycle + ); + return result ?? false; } async askYesNoDialog( @@ -37,6 +82,22 @@ export class ObsidianConfirm { - return askSelectString(this._app, message, items); + async askSelectString(message: string, items: string[]): Promise { + const result = await pickOne( + this._app, + { + items, + getText: (item) => item, + placeholder: message, + }, + this.dialogueLifecycle + ); + return result ?? ""; } - askSelectStringDialogue( + async askSelectStringDialogue( message: string, buttons: T, opt: { title?: string; defaultAction: T[number]; timeout?: number } ): Promise { const defaultTitle = $msg("moduleInputUIObsidian.defaultTitleSelect"); + // Commonlib owns the transport decision, while LiveSync owns the + // concrete Obsidian view which lets users revise that decision. + const presentedMessage = + opt.title === "P2P Connection Request" + ? message.replace("Peer-to-Peer Replicator Pane", $msg("P2P Status pane")) + : message; + if (!this.hasCountdown(opt.timeout)) { + const result = await confirmAction( + this._app, + { + title: opt.title || defaultTitle, + message: presentedMessage, + actions: buttons, + actionLayout: "vertical", + defaultAction: opt.defaultAction, + sourcePath: "/", + }, + this.dialogueLifecycle + ); + return result ?? false; + } return confirmWithMessageWithWideButton( this._plugin, opt.title || defaultTitle, - message, + presentedMessage, buttons, opt.defaultAction, opt.timeout ); } - askInPopup(key: string, dialogText: string, anchorCallback: (anchor: HTMLAnchorElement) => void) { + askInPopup( + key: string, + dialogText: string, + anchorCallback: (anchor: HTMLAnchorElement) => void, + durationMs: number = 20000 + ) { + const popupKey = "popup-" + key; + this.popupKeys.add(popupKey); const fragment = createFragment((doc) => { const [beforeText, afterText] = dialogText.split("{HERE}", 2); - doc.createEl("span", undefined, (a) => { + doc.createSpan(undefined, (a) => { a.appendText(beforeText); a.appendChild( a.createEl("a", undefined, (anchor) => { anchorCallback(anchor); + anchor.addEventListener("click", () => this.closePopup(popupKey)); }) ); a.appendText(afterText); }); }); - const popupKey = "popup-" + key; scheduleTask(popupKey, 1000, async () => { + if (this.dialogueController.signal.aborted) { + this.popupKeys.delete(popupKey); + return; + } const popup = await memoIfNotExist(popupKey, () => new Notice(fragment, 0)); const isShown = popup?.noticeEl?.isShown(); if (!isShown) { memoObject(popupKey, new Notice(fragment, 0)); } - scheduleTask(popupKey + "-close", 20000, () => { - const popup = retrieveMemoObject(popupKey); - if (!popup) return; - if (popup?.noticeEl?.isShown()) { - popup.hide(); - } - disposeMemoObject(popupKey); - }); + scheduleTask(popupKey + "-close", durationMs, () => this.closePopup(popupKey)); }); } - confirmWithMessage( + private closePopup(popupKey: string) { + const popup = retrieveMemoObject(popupKey); + if (!popup) { + this.popupKeys.delete(popupKey); + return; + } + if (popup.noticeEl?.isShown()) { + popup.hide(); + } + disposeMemoObject(popupKey); + this.popupKeys.delete(popupKey); + } + + async confirmWithMessage( title: string, contentMd: string, buttons: string[], defaultAction: (typeof buttons)[number], - timeout?: number + timeout?: number, + actionLayout?: ConfirmActionLayout ): Promise<(typeof buttons)[number] | false> { - return confirmWithMessage(this._plugin, title, contentMd, buttons, defaultAction, timeout); + if (this.hasCountdown(timeout)) { + return confirmWithMessageWithWideButton(this._plugin, title, contentMd, buttons, defaultAction, timeout); + } + const result = await confirmAction( + this._app, + { + title, + message: contentMd, + actions: buttons, + defaultAction, + sourcePath: "/", + actionLayout: actionLayout ?? "vertical", + }, + this.dialogueLifecycle + ); + return result ?? false; } } diff --git a/src/modules/services/ObsidianConfirm.unit.spec.ts b/src/modules/services/ObsidianConfirm.unit.spec.ts new file mode 100644 index 00000000..afb7e3a0 --- /dev/null +++ b/src/modules/services/ObsidianConfirm.unit.spec.ts @@ -0,0 +1,277 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + confirmAction: vi.fn(), + pickOne: vi.fn(), + promptPassword: vi.fn(), + promptText: vi.fn(), + legacyAskSelectString: vi.fn(), + legacyAskString: vi.fn(), + legacyAskYesNo: vi.fn(), + legacyConfirm: vi.fn(), + legacyWideConfirm: vi.fn(), +})); + +vi.mock("@vrtmrz/obsidian-plugin-kit", () => ({ + confirmAction: mocks.confirmAction, + pickOne: mocks.pickOne, + promptPassword: mocks.promptPassword, + promptText: mocks.promptText, +})); + +vi.mock("@/modules/coreObsidian/UILib/dialogs", () => ({ + askSelectString: mocks.legacyAskSelectString, + askString: mocks.legacyAskString, + askYesNo: mocks.legacyAskYesNo, + confirmWithMessage: mocks.legacyConfirm, + confirmWithMessageWithWideButton: mocks.legacyWideConfirm, +})); + +vi.mock("@/deps", () => ({ + Notice: class {}, +})); + +import { EVENT_PLUGIN_UNLOADED } from "@/common/events"; +import { memoObject, retrieveMemoObject } from "@/common/utils"; +import { createLiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context"; +import { ObsidianConfirm } from "./ObsidianConfirm"; +import type { ObsidianServiceContext } from "./ObsidianServiceContext"; + +function createConfirm() { + const app = { id: "app" }; + const plugin = { app }; + const events = createLiveSyncEventHub(); + const context = { app, plugin, events } as unknown as ObsidianServiceContext; + return { confirm: new ObsidianConfirm(context), events, app, plugin }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("ObsidianConfirm Fancy Kit adapter", () => { + it("uses owner-bound Kit prompts and preserves cancellation and empty input", async () => { + const { confirm, app } = createConfirm(); + mocks.promptText.mockResolvedValueOnce(null); + mocks.promptPassword.mockResolvedValueOnce(""); + + await expect(confirm.askString("Name", "Device name", "New Remote")).resolves.toBe(false); + await expect(confirm.askString("Secret", "Passphrase", "Enter it", true)).resolves.toBe(""); + + expect(mocks.promptText).toHaveBeenCalledWith( + app, + { + title: "Name", + label: "Device name", + placeholder: "New Remote", + }, + { signal: expect.any(AbortSignal) } + ); + expect(mocks.promptPassword).toHaveBeenCalledWith( + app, + { + title: "Secret", + label: "Passphrase", + placeholder: "Enter it", + }, + { signal: expect.any(AbortSignal) } + ); + expect(mocks.legacyAskString).not.toHaveBeenCalled(); + }); + + it("uses Kit for untimed yes/no and typed selection while preserving dismissed results", async () => { + const { confirm, app } = createConfirm(); + mocks.confirmAction.mockResolvedValueOnce("yes"); + mocks.pickOne.mockResolvedValueOnce("Beta").mockResolvedValueOnce(null); + + await expect(confirm.askYesNo("Continue?")).resolves.toBe("yes"); + await expect(confirm.askSelectString("Target", ["Alpha", "Beta"])).resolves.toBe("Beta"); + await expect(confirm.askSelectString("Target", ["Alpha"])).resolves.toBe(""); + + expect(mocks.confirmAction).toHaveBeenCalledWith( + app, + expect.objectContaining({ + message: "Continue?", + actions: ["yes", "no"], + actionLayout: "vertical", + defaultAction: "no", + }), + { signal: expect.any(AbortSignal) } + ); + expect(mocks.pickOne).toHaveBeenCalledWith( + app, + expect.objectContaining({ + items: ["Alpha", "Beta"], + getText: expect.any(Function), + }), + { signal: expect.any(AbortSignal) } + ); + expect(mocks.legacyAskYesNo).not.toHaveBeenCalled(); + expect(mocks.legacyAskSelectString).not.toHaveBeenCalled(); + }); + + it("keeps untimed and countdown action dialogues vertically stacked", async () => { + const { confirm, app, plugin } = createConfirm(); + mocks.confirmAction.mockResolvedValueOnce("Apply").mockResolvedValueOnce("Yes"); + mocks.legacyWideConfirm.mockResolvedValueOnce("Cancel").mockResolvedValueOnce("No"); + + await expect(confirm.confirmWithMessage("Review", "**Apply?**", ["Apply", "Cancel"], "Cancel")).resolves.toBe( + "Apply" + ); + await expect(confirm.askYesNoDialog("Continue?", { title: "Question", defaultOption: "Yes" })).resolves.toBe( + "yes" + ); + await expect(confirm.confirmWithMessage("Timed", "Wait", ["Apply", "Cancel"], "Cancel", 30)).resolves.toBe( + "Cancel" + ); + await expect(confirm.askYesNoDialog("Timed?", { defaultOption: "No", timeout: 10 })).resolves.toBe("no"); + + expect(mocks.confirmAction).toHaveBeenNthCalledWith( + 1, + app, + { + title: "Review", + message: "**Apply?**", + actions: ["Apply", "Cancel"], + actionLayout: "vertical", + defaultAction: "Cancel", + sourcePath: "/", + }, + { signal: expect.any(AbortSignal) } + ); + expect(mocks.confirmAction).toHaveBeenNthCalledWith( + 2, + app, + expect.objectContaining({ + title: "Question", + message: "Continue?", + actions: ["Yes", "No"], + actionLayout: "vertical", + defaultAction: "Yes", + }), + { signal: expect.any(AbortSignal) } + ); + expect(mocks.legacyWideConfirm).toHaveBeenNthCalledWith( + 1, + plugin, + "Timed", + "Wait", + ["Apply", "Cancel"], + "Cancel", + 30 + ); + expect(mocks.legacyWideConfirm).toHaveBeenNthCalledWith( + 2, + plugin, + expect.any(String), + "Timed?", + expect.any(Array), + expect.any(String), + 10 + ); + expect(mocks.legacyConfirm).not.toHaveBeenCalled(); + }); + + it("uses Kit for untimed multi-action selection and keeps timed wide actions on the countdown dialogue", async () => { + const { confirm, app, plugin } = createConfirm(); + const actions = ["Apply now", "Review later"] as const; + mocks.confirmAction.mockResolvedValueOnce("Review later"); + mocks.legacyWideConfirm.mockResolvedValueOnce("Apply now"); + + await expect( + confirm.askSelectStringDialogue("Choose the next step", actions, { + title: "Next step", + defaultAction: "Review later", + }) + ).resolves.toBe("Review later"); + await expect( + confirm.askSelectStringDialogue("Choose before the timer expires", actions, { + title: "Timed step", + defaultAction: "Apply now", + timeout: 15, + }) + ).resolves.toBe("Apply now"); + + expect(mocks.confirmAction).toHaveBeenCalledWith( + app, + { + title: "Next step", + message: "Choose the next step", + actions, + actionLayout: "vertical", + defaultAction: "Review later", + sourcePath: "/", + }, + { signal: expect.any(AbortSignal) } + ); + expect(mocks.legacyWideConfirm).toHaveBeenCalledWith( + plugin, + "Timed step", + "Choose before the timer expires", + actions, + "Apply now", + 15 + ); + }); + + it("refers P2P connection approvals to the current P2P Status pane", async () => { + const { confirm, plugin } = createConfirm(); + const actions = ["Accept", "Ignore"] as const; + mocks.legacyWideConfirm.mockResolvedValueOnce("Ignore"); + + await confirm.askSelectStringDialogue( + "You can revoke your decision from the Peer-to-Peer Replicator Pane.", + actions, + { + title: "P2P Connection Request", + defaultAction: "Ignore", + timeout: 30, + } + ); + + expect(mocks.legacyWideConfirm).toHaveBeenCalledWith( + plugin, + "P2P Connection Request", + "You can revoke your decision from the P2P Status pane.", + actions, + "Ignore", + 30 + ); + }); + + it("dismisses an open Kit dialogue when the plug-in unload event is emitted", async () => { + const { confirm, events } = createConfirm(); + let observedSignal: AbortSignal | undefined; + mocks.confirmAction.mockImplementation( + (_app, _options, lifecycle: { signal: AbortSignal }) => + new Promise((resolve) => { + observedSignal = lifecycle.signal; + lifecycle.signal.addEventListener("abort", () => resolve(null), { once: true }); + }) + ); + + const result = confirm.confirmWithMessage("Review", "Message", ["OK"], "OK"); + expect(observedSignal?.aborted).toBe(false); + + events.emitEvent(EVENT_PLUGIN_UNLOADED); + + await expect(result).resolves.toBe(false); + expect(observedSignal?.aborted).toBe(true); + }); + + it("closes an active Notice when the plug-in unload event is emitted", () => { + const { confirm, events } = createConfirm(); + const popupKey = "popup-remote-size-exceeded"; + const popup = { + hide: vi.fn(), + noticeEl: { isShown: vi.fn(() => true) }, + }; + memoObject(popupKey, popup); + (confirm as unknown as { popupKeys: Set }).popupKeys.add(popupKey); + + events.emitEvent(EVENT_PLUGIN_UNLOADED); + + expect(popup.hide).toHaveBeenCalledOnce(); + expect(retrieveMemoObject(popupKey)).toBe(false); + }); +}); diff --git a/src/modules/services/ObsidianDatabaseService.ts b/src/modules/services/ObsidianDatabaseService.ts index 39759f9d..2768a601 100644 --- a/src/modules/services/ObsidianDatabaseService.ts +++ b/src/modules/services/ObsidianDatabaseService.ts @@ -1,8 +1,8 @@ import { initializeStores } from "@/common/stores"; // import { InjectableDatabaseService } from "@/lib/src/services/implements/injectable/InjectableDatabaseService"; -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; -import { DatabaseService, type DatabaseServiceDependencies } from "@lib/services/base/DatabaseService.ts"; +import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; +import { DatabaseService, type DatabaseServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService"; export class ObsidianDatabaseService extends DatabaseService { private __onOpenDatabase(vaultName: string) { diff --git a/src/modules/services/ObsidianNoticeGroups.ts b/src/modules/services/ObsidianNoticeGroups.ts new file mode 100644 index 00000000..a59105ec --- /dev/null +++ b/src/modules/services/ObsidianNoticeGroups.ts @@ -0,0 +1,51 @@ +import { KeyedNoticeGroupManager } from "@vrtmrz/obsidian-plugin-kit/notice"; + +export interface ObsidianNoticeGroupItem { + message: string; + action?: { + label: string; + onSelect: () => void; + }; +} + +interface KeyedNoticeGroupDriver { + setItem(groupKey: string, itemKey: string, item: ObsidianNoticeGroupItem): unknown; + finish(groupKey: string, options?: { durationMs?: number | false }): boolean; + removeItem(groupKey: string, itemKey: string): boolean; + hide(groupKey: string): boolean; + dispose(): void; +} + +/** Obsidian-owned interactive Notice capability exposed through the application Context. */ +export interface ObsidianNoticeGroups { + setItem(groupKey: string, itemKey: string, item: ObsidianNoticeGroupItem): void; + finish(groupKey: string, options?: { durationMs?: number | false }): boolean; + removeItem(groupKey: string, itemKey: string): boolean; + hide(groupKey: string): boolean; + dispose(): void; +} + +/** Adapts Fancy Kit grouped Notices without exposing Obsidian Notice instances to features. */ +export class ObsidianNoticeGroupManager implements ObsidianNoticeGroups { + constructor(private readonly manager: KeyedNoticeGroupDriver = new KeyedNoticeGroupManager()) {} + + setItem(groupKey: string, itemKey: string, item: ObsidianNoticeGroupItem): void { + this.manager.setItem(groupKey, itemKey, item); + } + + finish(groupKey: string, options?: { durationMs?: number | false }): boolean { + return this.manager.finish(groupKey, options); + } + + removeItem(groupKey: string, itemKey: string): boolean { + return this.manager.removeItem(groupKey, itemKey); + } + + hide(groupKey: string): boolean { + return this.manager.hide(groupKey); + } + + dispose(): void { + this.manager.dispose(); + } +} diff --git a/src/modules/services/ObsidianNoticeGroups.unit.spec.ts b/src/modules/services/ObsidianNoticeGroups.unit.spec.ts new file mode 100644 index 00000000..3af2893d --- /dev/null +++ b/src/modules/services/ObsidianNoticeGroups.unit.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@vrtmrz/obsidian-plugin-kit/notice", () => ({ + KeyedNoticeGroupManager: class KeyedNoticeGroupManager {}, +})); + +import { ObsidianNoticeGroupManager } from "./ObsidianNoticeGroups"; + +describe("ObsidianNoticeGroupManager", () => { + it("keeps Fancy Kit rendering behind the Context-owned capability", () => { + const driver = { + setItem: vi.fn(), + finish: vi.fn(() => true), + removeItem: vi.fn(() => true), + hide: vi.fn(() => true), + dispose: vi.fn(), + }; + const groups = new ObsidianNoticeGroupManager(driver); + const item = { + message: "Complete", + action: { label: "Review", onSelect: vi.fn() }, + }; + + groups.setItem("integrity", "result", item); + expect(driver.setItem).toHaveBeenCalledWith("integrity", "result", item); + expect(groups.finish("integrity", { durationMs: 1_000 })).toBe(true); + expect(groups.removeItem("integrity", "result")).toBe(true); + expect(groups.hide("integrity")).toBe(true); + groups.dispose(); + expect(driver.dispose).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/modules/services/ObsidianPathService.ts b/src/modules/services/ObsidianPathService.ts index ed479e03..12dee2b0 100644 --- a/src/modules/services/ObsidianPathService.ts +++ b/src/modules/services/ObsidianPathService.ts @@ -1,6 +1,6 @@ -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; +import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; import { normalizePath } from "@/deps"; -import { PathService } from "@lib/services/base/PathService"; +import { PathService } from "@vrtmrz/livesync-commonlib/compat/services/base/PathService"; import { type BASE_IS_NEW, @@ -11,7 +11,7 @@ import { compareFileFreshness, isMarkedAsSameChanges, } from "@/common/utils"; -import type { UXFileInfo, AnyEntry, UXFileInfoStub, FilePathWithPrefix } from "@lib/common/types"; +import type { UXFileInfo, AnyEntry, UXFileInfoStub, FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types"; export class ObsidianPathService extends PathService { override markChangesAreSame( old: UXFileInfo | AnyEntry | FilePathWithPrefix, diff --git a/src/modules/services/ObsidianServiceContext.ts b/src/modules/services/ObsidianServiceContext.ts new file mode 100644 index 00000000..694b336e --- /dev/null +++ b/src/modules/services/ObsidianServiceContext.ts @@ -0,0 +1,22 @@ +import type ObsidianLiveSyncPlugin from "@/main"; +import type { App, Plugin } from "@/deps"; +import { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; +import { eventHub } from "@/common/events"; +import { translateLiveSyncMessage } from "@/common/translation"; +import type { ObsidianNoticeGroups } from "./ObsidianNoticeGroups"; + +/** Host capabilities owned by one Self-hosted LiveSync plug-in instance. */ +export class ObsidianServiceContext extends ServiceContext { + app: App; + plugin: Plugin; + liveSyncPlugin: ObsidianLiveSyncPlugin; + readonly noticeGroups: ObsidianNoticeGroups; + + constructor(app: App, plugin: Plugin, liveSyncPlugin: ObsidianLiveSyncPlugin, noticeGroups: ObsidianNoticeGroups) { + super({ events: eventHub, translate: translateLiveSyncMessage }); + this.app = app; + this.plugin = plugin; + this.liveSyncPlugin = liveSyncPlugin; + this.noticeGroups = noticeGroups; + } +} diff --git a/src/modules/services/ObsidianServiceContext.unit.spec.ts b/src/modules/services/ObsidianServiceContext.unit.spec.ts new file mode 100644 index 00000000..d95f9306 --- /dev/null +++ b/src/modules/services/ObsidianServiceContext.unit.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { eventHub } from "@/common/events"; +import { translateLiveSyncMessage } from "@/common/translation"; +import { observeServiceContext } from "../../../test/contracts/serviceContext"; +import { ObsidianServiceContext } from "./ObsidianServiceContext"; + +const TRANSLATION_KEY = "Replicator.Message.InitialiseFatalError"; + +describe("ObsidianServiceContext contract", () => { + it("preserves the plug-in capabilities and host-neutral API results", () => { + type Parameters = ConstructorParameters; + const app = {} as Parameters[0]; + const plugin = {} as Parameters[1]; + const liveSyncPlugin = {} as Parameters[2]; + const noticeGroups = {} as Parameters[3]; + const context = new ObsidianServiceContext(app, plugin, liveSyncPlugin, noticeGroups); + + expect(observeServiceContext(context, TRANSLATION_KEY)).toEqual({ + translation: translateLiveSyncMessage(TRANSLATION_KEY), + receivedEvents: ["context-contract-event"], + }); + expect(context.events).toBe(eventHub); + expect(context.app).toBe(app); + expect(context.plugin).toBe(plugin); + expect(context.liveSyncPlugin).toBe(liveSyncPlugin); + expect(context.noticeGroups).toBe(noticeGroups); + }); +}); diff --git a/src/modules/services/ObsidianServiceHub.ts b/src/modules/services/ObsidianServiceHub.ts index 917637a2..a5d25801 100644 --- a/src/modules/services/ObsidianServiceHub.ts +++ b/src/modules/services/ObsidianServiceHub.ts @@ -1,6 +1,6 @@ -import { InjectableServiceHub } from "@lib/services/implements/injectable/InjectableServiceHub"; -import { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; -import type { ServiceInstances } from "@lib/services/ServiceHub"; +import { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub"; +import { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; +import type { ServiceInstances } from "@vrtmrz/livesync-commonlib/compat/services/ServiceHub"; import type ObsidianLiveSyncPlugin from "@/main"; import { ObsidianConflictService, @@ -23,12 +23,17 @@ import { ObsidianPathService } from "./ObsidianPathService"; import { ObsidianVaultService } from "./ObsidianVaultService"; import { ObsidianUIService } from "./ObsidianUIService"; import { createScreenWakeLockManager } from "octagonal-wheels/browser/wakeLock"; +import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser"; +import { OpenKeyValueDatabase } from "@/common/KeyValueDB"; +import { ObsidianNoticeGroupManager } from "./ObsidianNoticeGroups"; +import { setLang } from "@/common/translation"; // InjectableServiceHub export class ObsidianServiceHub extends InjectableServiceHub { constructor(plugin: ObsidianLiveSyncPlugin) { - const context = new ObsidianServiceContext(plugin.app, plugin, plugin); + const noticeGroups = new ObsidianNoticeGroupManager(); + const context = new ObsidianServiceContext(plugin.app, plugin, plugin, noticeGroups); const API = new ObsidianAPIService(context); const conflict = new ObsidianConflictService(context); @@ -38,11 +43,13 @@ export class ObsidianServiceHub extends InjectableServiceHub { await screenWakeLock.dispose(); + noticeGroups.dispose(); return true; }); const database = new ObsidianDatabaseService(context, { + pouchDB: PouchDB, path: path, vault: vault, setting: setting, API: API, }); const keyValueDB = new ObsidianKeyValueDBService(context, { + openKeyValueDatabase: OpenKeyValueDatabase, appLifecycle: appLifecycle, databaseEvents: databaseEvents, vault: vault, @@ -81,6 +91,7 @@ export class ObsidianServiceHub extends InjectableServiceHub API.isMobile(), }); const replication = new ObsidianReplicationService(context, { APIService: API, diff --git a/src/modules/services/ObsidianServices.ts b/src/modules/services/ObsidianServices.ts index c6812b9e..daf43e48 100644 --- a/src/modules/services/ObsidianServices.ts +++ b/src/modules/services/ObsidianServices.ts @@ -1,20 +1,77 @@ -import { InjectableConflictService } from "@lib/services/implements/injectable/InjectableConflictService"; -import { InjectableDatabaseEventService } from "@lib/services/implements/injectable/InjectableDatabaseEventService"; -import { InjectableFileProcessingService } from "@lib/services/implements/injectable/InjectableFileProcessingService"; -import { InjectableRemoteService } from "@lib/services/implements/injectable/InjectableRemoteService"; -import { InjectableReplicationService } from "@lib/services/implements/injectable/InjectableReplicationService"; -import { InjectableReplicatorService } from "@lib/services/implements/injectable/InjectableReplicatorService"; -import { InjectableTestService } from "@lib/services/implements/injectable/InjectableTestService"; -import { InjectableTweakValueService } from "@lib/services/implements/injectable/InjectableTweakValueService"; -import { ConfigServiceBrowserCompat } from "@lib/services/implements/browser/ConfigServiceBrowserCompat"; -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext.ts"; -import { KeyValueDBService } from "@lib/services/base/KeyValueDBService"; -import { ControlService } from "@lib/services/base/ControlService"; +import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService"; +import { InjectableDatabaseEventService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableDatabaseEventService"; +import { InjectableFileProcessingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableFileProcessingService"; +import { InjectableRemoteService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableRemoteService"; +import { InjectableReplicationService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicationService"; +import { InjectableReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicatorService"; +import { InjectableTestService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTestService"; +import { InjectableTweakValueService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTweakValueService"; +import { ConfigServiceBrowserCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/ConfigServiceBrowserCompat"; +import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; +import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService"; +import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService"; +import { reactiveSource } from "octagonal-wheels/dataobject/reactive"; +import type { ReplicatorServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService"; +import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; + +type ActivityOptions = { + label?: string; +}; + +type ObsidianReplicatorServiceDependencies = ReplicatorServiceDependencies & { + isMobile: () => boolean; +}; + +type SleepPreferenceSettings = Pick< + ObsidianLiveSyncSettings, + "allowSleepDuringSynchronisation" | "allowSleepDuringSynchronisationOnDesktop" +>; + +export function shouldAllowSleepDuringSynchronisation(settings: SleepPreferenceSettings, isMobile: boolean): boolean { + return settings.allowSleepDuringSynchronisation || (!isMobile && settings.allowSleepDuringSynchronisationOnDesktop); +} + +function withSleepPreference(dependencies: ObsidianReplicatorServiceDependencies): ReplicatorServiceDependencies { + const activityRunner = dependencies.activityRunner; + if (!activityRunner) return dependencies; + return { + ...dependencies, + activityRunner: { + async run(task: () => T | PromiseLike, options?: ActivityOptions): Promise { + const allowSleep = shouldAllowSleepDuringSynchronisation( + dependencies.settingService.currentSettings(), + dependencies.isMobile() + ); + return allowSleep ? await task() : await activityRunner.run(task, options); + }, + }, + }; +} export class ObsidianDatabaseEventService extends InjectableDatabaseEventService {} // InjectableReplicatorService -export class ObsidianReplicatorService extends InjectableReplicatorService {} +export class ObsidianReplicatorService extends InjectableReplicatorService { + readonly boundedLocalApplicationActivityCount = reactiveSource(0); + + constructor(context: ObsidianServiceContext, dependencies: ObsidianReplicatorServiceDependencies) { + super(context, withSleepPreference(dependencies)); + } + + async runBoundedLocalApplicationActivity( + task: () => T | PromiseLike, + options?: ActivityOptions + ): Promise { + this.boundedLocalApplicationActivityCount.value++; + try { + return this.dependencies.activityRunner + ? await this.dependencies.activityRunner.run(task, options) + : await task(); + } finally { + this.boundedLocalApplicationActivityCount.value--; + } + } +} // InjectableFileProcessingService export class ObsidianFileProcessingService extends InjectableFileProcessingService {} // InjectableReplicationService diff --git a/src/modules/services/ObsidianServices.unit.spec.ts b/src/modules/services/ObsidianServices.unit.spec.ts new file mode 100644 index 00000000..a46c05c3 --- /dev/null +++ b/src/modules/services/ObsidianServices.unit.spec.ts @@ -0,0 +1,93 @@ +import { promiseWithResolvers } from "octagonal-wheels/promises"; +import { describe, expect, it, vi } from "vitest"; +import { ObsidianReplicatorService, shouldAllowSleepDuringSynchronisation } from "./ObsidianServices"; + +function handler() { + return { addHandler: vi.fn() }; +} + +describe("ObsidianReplicatorService", () => { + it.each([ + { general: false, desktop: false, mobile: false, expected: false }, + { general: false, desktop: true, mobile: false, expected: true }, + { general: false, desktop: true, mobile: true, expected: false }, + { general: true, desktop: false, mobile: false, expected: true }, + { general: true, desktop: false, mobile: true, expected: true }, + ])("applies the sleep preference policy: $general/$desktop/$mobile", ({ general, desktop, mobile, expected }) => { + expect( + shouldAllowSleepDuringSynchronisation( + { + allowSleepDuringSynchronisation: general, + allowSleepDuringSynchronisationOnDesktop: desktop, + }, + mobile + ) + ).toBe(expected); + }); + + it("tracks local application activity without extending remote activity", async () => { + const activity = promiseWithResolvers(); + const service = new ObsidianReplicatorService( + { events: {}, translate: String } as never, + { + settingService: { + onRealiseSetting: handler(), + currentSettings: () => ({ + allowSleepDuringSynchronisation: false, + allowSleepDuringSynchronisationOnDesktop: false, + }), + }, + appLifecycleService: { onSuspending: handler(), getUnresolvedMessages: handler() }, + databaseEventService: { + onResetDatabase: handler(), + onDatabaseInitialisation: handler(), + onDatabaseInitialised: handler(), + onDatabaseHasReady: handler(), + }, + activityRunner: { run: vi.fn(async (task: () => Promise) => await task()) }, + isMobile: () => false, + } as never + ); + + const running = service.runBoundedLocalApplicationActivity(() => activity.promise); + + expect(service.boundedLocalApplicationActivityCount.value).toBe(1); + expect(service.boundedRemoteActivityCount.value).toBe(0); + + activity.resolve(); + await running; + + expect(service.boundedLocalApplicationActivityCount.value).toBe(0); + expect(service.boundedRemoteActivityCount.value).toBe(0); + }); + + it("allows desktop sleep throughout bounded synchronisation activity when configured", async () => { + const runWithWakeLock = vi.fn(async (task: () => Promise) => await task()); + const service = new ObsidianReplicatorService( + { events: {}, translate: String } as never, + { + settingService: { + onRealiseSetting: handler(), + currentSettings: () => ({ + allowSleepDuringSynchronisation: false, + allowSleepDuringSynchronisationOnDesktop: true, + }), + }, + appLifecycleService: { onSuspending: handler(), getUnresolvedMessages: handler() }, + databaseEventService: { + onResetDatabase: handler(), + onDatabaseInitialisation: handler(), + onDatabaseInitialised: handler(), + onDatabaseHasReady: handler(), + }, + activityRunner: { run: runWithWakeLock }, + isMobile: () => false, + } as never + ); + + await service.runBoundedRemoteActivity(async () => undefined); + await service.runBoundedLocalApplicationActivity(async () => undefined); + + expect(runWithWakeLock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/services/ObsidianSettingService.ts b/src/modules/services/ObsidianSettingService.ts index f3a32eef..a934ebd1 100644 --- a/src/modules/services/ObsidianSettingService.ts +++ b/src/modules/services/ObsidianSettingService.ts @@ -1,19 +1,29 @@ -import { compatGlobal } from "@lib/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"; -import { SettingService, type SettingServiceDependencies } from "@lib/services/base/SettingService"; -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; +import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; +import { type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { + EVENT_REQUEST_RELOAD_SETTING_TAB, + EVENT_SETTING_SAVED, +} from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; +import { + SettingService, + type SettingServiceDependencies, +} from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService"; +import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; + +export function normaliseObsidianSettingsData(data: unknown): ObsidianLiveSyncSettings | undefined { + if (typeof data !== "object" || data === null || Array.isArray(data)) return undefined; + return data as ObsidianLiveSyncSettings; +} export class ObsidianSettingService extends SettingService { constructor(context: T, dependencies: SettingServiceDependencies) { super(context, dependencies); this.onSettingSaved.addHandler((settings) => { - eventHub.emitEvent(EVENT_SETTING_SAVED, settings); + this.context.events.emitEvent(EVENT_SETTING_SAVED, settings); return Promise.resolve(true); }); this.onSettingLoaded.addHandler((settings) => { - eventHub.emitEvent(EVENT_REQUEST_RELOAD_SETTING_TAB); + this.context.events.emitEvent(EVENT_REQUEST_RELOAD_SETTING_TAB); return Promise.resolve(true); }); } @@ -34,6 +44,6 @@ export class ObsidianSettingService extends Se return await this.context.liveSyncPlugin.saveData(data); } protected override async loadData(): Promise { - return await this.context.liveSyncPlugin.loadData(); + return normaliseObsidianSettingsData(await this.context.liveSyncPlugin.loadData()); } } diff --git a/src/modules/services/ObsidianSettingService.unit.spec.ts b/src/modules/services/ObsidianSettingService.unit.spec.ts new file mode 100644 index 00000000..9175c931 --- /dev/null +++ b/src/modules/services/ObsidianSettingService.unit.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { normaliseObsidianSettingsData } from "./ObsidianSettingService.ts"; + +describe("normaliseObsidianSettingsData", () => { + it("maps Obsidian's missing data value to Commonlib's new-Vault input", () => { + expect(normaliseObsidianSettingsData(null)).toBeUndefined(); + }); + + it("preserves stored settings", () => { + const settings = { isConfigured: false } as ObsidianLiveSyncSettings; + + expect(normaliseObsidianSettingsData(settings)).toBe(settings); + }); +}); diff --git a/src/modules/services/ObsidianUIService.ts b/src/modules/services/ObsidianUIService.ts index 45975af2..c5abf23f 100644 --- a/src/modules/services/ObsidianUIService.ts +++ b/src/modules/services/ObsidianUIService.ts @@ -1,11 +1,11 @@ -import type { ConfigService } from "@lib/services/base/ConfigService"; -import type { AppLifecycleService } from "@lib/services/base/AppLifecycleService"; -import type { ReplicatorService } from "@lib/services/base/ReplicatorService"; -import { UIService } from "@lib/services/implements/base/UIService"; -import { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; +import type { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService"; +import type { AppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService"; +import type { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService"; +import { UIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/UIService"; +import { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; import { ObsidianSvelteDialogManager } from "./SvelteDialogObsidian"; -import DialogToCopy from "@lib/UI/dialogues/DialogueToCopy.svelte"; -import type { IAPIService, IControlService } from "@lib/services/base/IService"; +import DialogToCopy from "@/modules/services/LiveSyncUI/dialogues/DialogueToCopy.svelte"; +import type { IAPIService, IControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService"; export type ObsidianUIServiceDependencies = { appLifecycle: AppLifecycleService; config: ConfigService; @@ -28,7 +28,6 @@ export class ObsidianUIService extends UIService { control: dependents.control, }); super(context, { - appLifecycle: dependents.appLifecycle, dialogManager: obsidianSvelteDialogManager, APIService: dependents.APIService, }); diff --git a/src/modules/services/ObsidianVaultService.ts b/src/modules/services/ObsidianVaultService.ts index d86c7b19..e3bd3a91 100644 --- a/src/modules/services/ObsidianVaultService.ts +++ b/src/modules/services/ObsidianVaultService.ts @@ -1,7 +1,7 @@ import { getPathFromTFile, isValidPath } from "@/common/utils"; -import { InjectableVaultService } from "@lib/services/implements/injectable/InjectableVaultService"; -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; -import type { FilePath } from "@lib/common/types"; +import { InjectableVaultService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableVaultService"; +import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; +import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types"; declare module "obsidian" { interface DataAdapter { diff --git a/src/modules/services/SvelteDialogObsidian.ts b/src/modules/services/SvelteDialogObsidian.ts index 95c9ba23..5d3ebfba 100644 --- a/src/modules/services/SvelteDialogObsidian.ts +++ b/src/modules/services/SvelteDialogObsidian.ts @@ -2,18 +2,16 @@ import { Modal } from "@/deps"; import { SvelteDialogManagerBase, - SvelteDialogMixIn, type ComponentHasResult, type SvelteDialogManagerDependencies, -} from "@lib/services/implements/base/SvelteDialog"; -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; -import DialogHost from "@lib/UI/DialogHost.svelte"; -export const SvelteDialogBase = SvelteDialogMixIn(Modal, DialogHost); -export class SvelteDialogObsidian< - T, - U, - C extends ObsidianServiceContext = ObsidianServiceContext, -> extends SvelteDialogBase { +} from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog"; +import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; +import DialogHost from "@/modules/services/LiveSyncUI/DialogHost.svelte"; +import { SvelteDialogSession } from "@/modules/services/SvelteDialogSession"; + +export class SvelteDialogObsidian extends Modal { + private readonly session: SvelteDialogSession; + constructor( context: C, dependents: SvelteDialogManagerDependencies, @@ -21,7 +19,27 @@ export class SvelteDialogObsidian< initialData?: U ) { super(context.app); - this.initDialog(context, dependents, component, initialData); + this.session = new SvelteDialogSession({ + surface: this, + context, + dependencies: dependents, + dialogHost: DialogHost, + component, + initialData, + }); + } + + override onOpen(): void { + this.session.onOpen(); + this.contentEl.closest(".modal-container")?.classList.add("livesync-svelte-dialog-container"); + } + + override onClose(): void { + this.session.onClose(); + } + + waitForClose(): Promise { + return this.session.waitForClose(); } } diff --git a/src/modules/services/SvelteDialogSession.ts b/src/modules/services/SvelteDialogSession.ts new file mode 100644 index 00000000..3643aae7 --- /dev/null +++ b/src/modules/services/SvelteDialogSession.ts @@ -0,0 +1,138 @@ +import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; +import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; +import { + setupDialogContext, + type ComponentHasResult, + type DialogContext, + type DialogHostProps, + type DialogSvelteComponentBaseProps, + type SvelteDialogManagerDependencies, +} from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog"; +import { fireAndForget, promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises"; +import { mount, unmount, type Component } from "svelte"; + +/** Host dialogue surface controlled by an explicit session. */ +export interface SvelteDialogSurface { + readonly contentEl: HTMLElement; + setTitle(title: string): unknown; + close(): void; +} + +type MountedDialog = ReturnType; + +/** Injectable renderer used to keep the dialogue lifecycle independently testable. */ +export interface SvelteDialogRenderer { + mount(dialogHost: Component, target: HTMLElement, props: DialogHostProps): MountedDialog; + unmount(component: MountedDialog): Promise; +} + +export interface SvelteDialogSessionOptions { + surface: SvelteDialogSurface; + context: TContext; + dependencies: SvelteDialogManagerDependencies; + dialogHost: Component; + component: ComponentHasResult; + initialData?: TInitial; + renderer?: SvelteDialogRenderer; +} + +const svelteDialogRenderer: SvelteDialogRenderer = { + mount(dialogHost, target, props) { + return mount(dialogHost, { target, props }); + }, + async unmount(component) { + await unmount(component); + }, +}; + +/** Owns one Svelte dialogue result and mount lifecycle for a concrete host surface. */ +export class SvelteDialogSession< + TResult, + TInitial, + TContext extends ServiceContext = ServiceContext, +> { + private readonly surface: SvelteDialogSurface; + private readonly context: TContext; + private readonly dependencies: SvelteDialogManagerDependencies; + private readonly dialogHost: Component; + private readonly component: ComponentHasResult; + private readonly initialData?: TInitial; + private readonly renderer: SvelteDialogRenderer; + + private result?: TResult; + private pendingResult?: PromiseWithResolvers; + private mountedDialog?: MountedDialog; + private unregisterUnload?: () => void; + + constructor(options: SvelteDialogSessionOptions) { + this.surface = options.surface; + this.context = options.context; + this.dependencies = options.dependencies; + this.dialogHost = options.dialogHost; + this.component = options.component; + this.initialData = options.initialData; + this.renderer = options.renderer ?? svelteDialogRenderer; + } + + onOpen(): void { + this.surface.contentEl.replaceChildren(); + this.unregisterUnload?.(); + this.unregisterUnload = undefined; + if (this.pendingResult) { + this.pendingResult.reject(new Error("Dialogue opened again")); + } + + this.result = undefined; + const pendingResult = promiseWithResolvers(); + this.pendingResult = pendingResult; + this.unregisterUnload = this.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => { + if (this.pendingResult !== pendingResult) { + return; + } + this.pendingResult = undefined; + pendingResult.reject(new Error("Plug-in unloaded")); + this.surface.close(); + }); + + this.mountedDialog = this.renderer.mount(this.dialogHost, this.surface.contentEl, { + onSetupContext: (props: DialogSvelteComponentBaseProps) => { + setupDialogContext({ + ...props, + context: this.context, + services: this.dependencies, + } satisfies DialogContext); + }, + setTitle: (title: string) => { + this.surface.setTitle(title); + }, + closeDialog: () => { + this.surface.close(); + }, + setResult: (result: TResult) => { + this.result = result; + }, + getInitialData: () => this.initialData, + mountComponent: this.component, + }); + } + + waitForClose(): Promise { + if (!this.pendingResult) { + throw new Error("Dialogue has not been opened"); + } + return this.pendingResult.promise; + } + + onClose(): void { + this.unregisterUnload?.(); + this.unregisterUnload = undefined; + this.pendingResult?.resolve(this.result); + this.pendingResult = undefined; + + const mountedDialog = this.mountedDialog; + this.mountedDialog = undefined; + if (mountedDialog) { + fireAndForget(() => this.renderer.unmount(mountedDialog)); + } + } +} diff --git a/src/rabinKarpBom.unit.spec.ts b/src/rabinKarpBom.unit.spec.ts index 56596aa1..67e4781f 100644 --- a/src/rabinKarpBom.unit.spec.ts +++ b/src/rabinKarpBom.unit.spec.ts @@ -1,4 +1,4 @@ -import { splitPiecesRabinKarp } from "@lib/string_and_binary/chunks.ts"; +import { splitPiecesRabinKarp } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/chunks"; import { describe, expect, it } from "vitest"; describe("Rabin-Karp text splitting", () => { diff --git a/src/serviceFeatures/compatibilityReview.ts b/src/serviceFeatures/compatibilityReview.ts new file mode 100644 index 00000000..8fc85f3e --- /dev/null +++ b/src/serviceFeatures/compatibilityReview.ts @@ -0,0 +1,186 @@ +import { fireAndForget } from "octagonal-wheels/promises"; +import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { LiveSyncCore } from "@/main.ts"; +import { + COMPATIBILITY_PAUSE_SETTING_MESSAGE, + DATABASE_COMPATIBILITY_VERSION_KEY, + evaluateCompatibilityPause, + legacyDatabaseCompatibilityVersionKey, + type CompatibilityPause, +} from "@/common/databaseCompatibility.ts"; + +export type CompatibilityReviewSummaryAction = "details" | "resume" | "keep-paused" | false; +export type CompatibilityReviewDetailsAction = "back" | false; + +// Explicit flag-file recovery runs at priorities 5, 10, and 20. Present the +// compatibility review only after those operations have completed; a recovery +// handler which stops start-up also prevents this dialogue from competing with it. +export const COMPATIBILITY_REVIEW_LAYOUT_PRIORITY = 30; + +export interface CompatibilityReviewUi { + showSummary(pause: CompatibilityPause): Promise; + showDetails(pause: CompatibilityPause): Promise; + showReminder(openReview: () => void): void; + clearReminder(): void; +} + +export class CompatibilityReviewController { + private pause: CompatibilityPause | undefined; + private activeReview: Promise | undefined; + private _initialised = false; + private disposed = false; + + constructor( + private readonly core: LiveSyncCore, + private readonly ui: CompatibilityReviewUi, + private readonly currentVersion: number = VER + ) {} + + get pendingPause(): CompatibilityPause | undefined { + return this.pause; + } + + get initialised(): boolean { + return this._initialised; + } + + private readAcknowledgedVersion(): string | null { + const setting = this.core.services.setting; + const currentMarker = setting.getSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY); + if (currentMarker) return currentMarker; + + const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName()); + const legacyMarker = setting.getDeviceLocalConfig(legacyKey); + if (!legacyMarker) return null; + + setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, legacyMarker); + setting.deleteDeviceLocalConfig(legacyKey); + return legacyMarker; + } + + async initialise(): Promise { + if (this.disposed) return true; + const setting = this.core.services.setting; + const settings = setting.currentSettings(); + const migrationState = setting.getSettingsMigrationState(); + + // An existing unconfigured Vault cannot replicate, so a database + // compatibility pause would only compete with onboarding and persist + // a misleading sync warning. Do not acknowledge the missing marker: + // activation on a later start must evaluate the same state again. + // Genuinely new Vaults still initialise their marker below. + if (settings.isConfigured !== true && migrationState?.isNewVault !== true) { + this.pause = undefined; + this.ui.clearReminder(); + this._initialised = true; + return true; + } + + const acknowledgedVersion = this.readAcknowledgedVersion(); + const evaluation = evaluateCompatibilityPause({ + acknowledgedVersion, + currentVersion: this.currentVersion, + migrationState, + legacyReviewMessage: settings.versionUpFlash, + }); + + this.pause = evaluation.pause; + if (evaluation.initialiseAcknowledgedVersion) { + setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`); + this._initialised = true; + return true; + } + if (!this.pause) { + this._initialised = true; + return true; + } + + if (settings.versionUpFlash === "") { + settings.versionUpFlash = COMPATIBILITY_PAUSE_SETTING_MESSAGE; + await setting.saveSettingData(); + } + this._initialised = true; + return true; + } + + private async acknowledge(): Promise { + if (!this.pause?.resumable) return; + const setting = this.core.services.setting; + const settings = setting.currentSettings(); + const previousMessage = settings.versionUpFlash; + settings.versionUpFlash = ""; + try { + await setting.saveSettingData(); + } catch (error) { + settings.versionUpFlash = previousMessage || COMPATIBILITY_PAUSE_SETTING_MESSAGE; + throw error; + } + + setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`); + const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName()); + setting.deleteDeviceLocalConfig(legacyKey); + this.pause = undefined; + this.ui.clearReminder(); + await this.core.services.control.applySettings(); + } + + private async runReview(): Promise { + while (this.pause) { + const action = await this.ui.showSummary(this.pause); + if (action === "details") { + const detailsAction = await this.ui.showDetails(this.pause); + if (detailsAction === "back") continue; + break; + } + if (action === "resume" && this.pause.resumable) { + await this.acknowledge(); + return; + } + break; + } + if (this.pause && !this.disposed) { + this.ui.showReminder(() => { + fireAndForget(() => this.openReview()); + }); + } + } + + openReview(): Promise { + if (this.disposed || !this.pause) return Promise.resolve(); + if (this.activeReview) return this.activeReview; + this.ui.clearReminder(); + this.activeReview = this.runReview().finally(() => { + this.activeReview = undefined; + }); + return this.activeReview; + } + + dispose(): void { + this.disposed = true; + this.pause = undefined; + this.ui.clearReminder(); + } +} + +export function useCompatibilityReview(core: LiveSyncCore, ui: CompatibilityReviewUi): CompatibilityReviewController { + const controller = new CompatibilityReviewController(core, ui); + core.services.appLifecycle.onSettingLoaded.addHandler(() => controller.initialise()); + core.services.appLifecycle.onLayoutReady.addHandler(() => { + fireAndForget(() => controller.openReview()); + return Promise.resolve(true); + }, COMPATIBILITY_REVIEW_LAYOUT_PRIORITY); + core.services.appLifecycle.onUnload.addHandler(() => { + controller.dispose(); + return Promise.resolve(true); + }); + core.services.API.addCommand({ + id: "livesync-review-compatibility-pause", + name: "Review why synchronisation is paused", + checkCallback: (checking) => { + if (!controller.pendingPause) return false; + if (!checking) fireAndForget(() => controller.openReview()); + return true; + }, + }); + return controller; +} diff --git a/src/serviceFeatures/compatibilityReview.unit.spec.ts b/src/serviceFeatures/compatibilityReview.unit.spec.ts new file mode 100644 index 00000000..3155477f --- /dev/null +++ b/src/serviceFeatures/compatibilityReview.unit.spec.ts @@ -0,0 +1,221 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + COMPATIBILITY_PAUSE_SETTING_MESSAGE, + DATABASE_COMPATIBILITY_VERSION_KEY, + legacyDatabaseCompatibilityVersionKey, +} from "@/common/databaseCompatibility.ts"; +import { + CompatibilityReviewController, + type CompatibilityReviewUi, + useCompatibilityReview, +} from "./compatibilityReview.ts"; + +function migrationState(overrides: Record = {}) { + return { + sourceVersion: 2, + targetVersion: 2, + isNewVault: false, + isFromFutureSchema: false, + changed: false, + requiresSyncReview: false, + reviewReasons: [], + ...overrides, + }; +} + +function createFixture( + options: { + marker?: string | null; + legacyMarker?: string | null; + versionUpFlash?: string; + isConfigured?: boolean; + migration?: Record; + } = {} +) { + const local = new Map(); + if (options.marker !== undefined && options.marker !== null) { + local.set(DATABASE_COMPATIBILITY_VERSION_KEY, options.marker); + } + const legacyKey = legacyDatabaseCompatibilityVersionKey("Test Vault"); + if (options.legacyMarker !== undefined && options.legacyMarker !== null) { + local.set(legacyKey, options.legacyMarker); + } + const settings = { + versionUpFlash: options.versionUpFlash ?? "", + isConfigured: options.isConfigured ?? true, + }; + const saveSettingData = vi.fn().mockResolvedValue(undefined); + const applySettings = vi.fn().mockResolvedValue(true); + const setting = { + currentSettings: () => settings, + getSettingsMigrationState: () => migrationState(options.migration), + getSmallConfig: (key: string) => local.get(key) ?? "", + setSmallConfig: (key: string, value: string) => local.set(key, value), + getDeviceLocalConfig: (key: string) => local.get(key) ?? null, + deleteDeviceLocalConfig: (key: string) => local.delete(key), + saveSettingData, + }; + const core = { + services: { + setting, + vault: { getVaultName: () => "Test Vault" }, + control: { applySettings }, + }, + } as never; + const ui: CompatibilityReviewUi = { + showSummary: vi.fn().mockResolvedValue("keep-paused"), + showDetails: vi.fn().mockResolvedValue(false), + showReminder: vi.fn(), + clearReminder: vi.fn(), + }; + const controller = new CompatibilityReviewController(core, ui, 12); + return { controller, ui, local, legacyKey, settings, saveSettingData, applySettings }; +} + +describe("compatibility review controller", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("initialises the acknowledged version for a new Vault without showing a pause", async () => { + const fixture = createFixture({ marker: null, isConfigured: false, migration: { isNewVault: true } }); + + expect(fixture.controller.initialised).toBe(false); + + await expect(fixture.controller.initialise()).resolves.toBe(true); + + expect(fixture.controller.initialised).toBe(true); + expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12"); + expect(fixture.controller.pendingPause).toBeUndefined(); + expect(fixture.saveSettingData).not.toHaveBeenCalled(); + }); + + it("defers a missing database marker while the Vault remains unconfigured", async () => { + const fixture = createFixture({ marker: null, isConfigured: false }); + + await expect(fixture.controller.initialise()).resolves.toBe(true); + + expect(fixture.controller.pendingPause).toBeUndefined(); + expect(fixture.settings.versionUpFlash).toBe(""); + expect(fixture.local.has(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe(false); + expect(fixture.saveSettingData).not.toHaveBeenCalled(); + + fixture.settings.isConfigured = true; + await expect(fixture.controller.initialise()).resolves.toBe(true); + + expect(fixture.controller.pendingPause?.reasons).toContainEqual({ + source: "database-version", + state: "missing", + currentVersion: 12, + resumable: true, + }); + expect(fixture.settings.versionUpFlash).toBe(COMPATIBILITY_PAUSE_SETTING_MESSAGE); + expect(fixture.local.has(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe(false); + expect(fixture.saveSettingData).toHaveBeenCalledOnce(); + }); + + it("preserves preferences and advances the marker only after an upgrade review is resumed", async () => { + const fixture = createFixture({ marker: "11" }); + vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume"); + + await fixture.controller.initialise(); + + expect(fixture.settings.versionUpFlash).toBe(COMPATIBILITY_PAUSE_SETTING_MESSAGE); + expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11"); + expect(fixture.saveSettingData).toHaveBeenCalledTimes(1); + + await fixture.controller.openReview(); + + expect(fixture.settings.versionUpFlash).toBe(""); + expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12"); + expect(fixture.saveSettingData).toHaveBeenCalledTimes(2); + expect(fixture.applySettings).toHaveBeenCalledOnce(); + expect(fixture.controller.pendingPause).toBeUndefined(); + expect(fixture.ui.clearReminder).toHaveBeenCalled(); + }); + + it("does not allow a downgrade pause to be resumed", async () => { + const fixture = createFixture({ marker: "13" }); + vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume"); + + await fixture.controller.initialise(); + await fixture.controller.openReview(); + + expect(fixture.controller.pendingPause?.resumable).toBe(false); + expect(fixture.settings.versionUpFlash).toBe(COMPATIBILITY_PAUSE_SETTING_MESSAGE); + expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("13"); + expect(fixture.applySettings).not.toHaveBeenCalled(); + expect(fixture.ui.showReminder).toHaveBeenCalledOnce(); + }); + + it("returns from details to the reason dialogue and leaves a persistent reminder", async () => { + const fixture = createFixture({ marker: "11" }); + vi.mocked(fixture.ui.showSummary).mockResolvedValueOnce("details").mockResolvedValueOnce("keep-paused"); + vi.mocked(fixture.ui.showDetails).mockResolvedValue("back"); + + await fixture.controller.initialise(); + await fixture.controller.openReview(); + + expect(fixture.ui.showSummary).toHaveBeenCalledTimes(2); + expect(fixture.ui.showDetails).toHaveBeenCalledOnce(); + expect(fixture.ui.showReminder).toHaveBeenCalledOnce(); + expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11"); + }); + + it("migrates the old Vault-scoped marker to Commonlib device-local storage", async () => { + const fixture = createFixture({ legacyMarker: "11" }); + + await fixture.controller.initialise(); + + expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11"); + expect(fixture.local.has(fixture.legacyKey)).toBe(false); + expect(fixture.controller.pendingPause).toBeDefined(); + }); + + it("restores the runtime gate if persisting an acknowledgement fails", async () => { + const fixture = createFixture({ marker: "11" }); + vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume"); + await fixture.controller.initialise(); + fixture.saveSettingData.mockRejectedValueOnce(new Error("save failed")); + + await expect(fixture.controller.openReview()).rejects.toThrow("save failed"); + + expect(fixture.settings.versionUpFlash).toBe(COMPATIBILITY_PAUSE_SETTING_MESSAGE); + expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11"); + expect(fixture.applySettings).not.toHaveBeenCalled(); + }); + + it("does not open a delayed review after the controller has been disposed", async () => { + const fixture = createFixture({ marker: "11" }); + await fixture.controller.initialise(); + + fixture.controller.dispose(); + await fixture.controller.openReview(); + + expect(fixture.controller.pendingPause).toBeUndefined(); + expect(fixture.ui.showSummary).not.toHaveBeenCalled(); + expect(fixture.ui.clearReminder).toHaveBeenCalledOnce(); + }); + + it("runs the review after the ordered red flag recovery handlers", () => { + const onSettingLoaded = { addHandler: vi.fn() }; + const onLayoutReady = { addHandler: vi.fn() }; + const onUnload = { addHandler: vi.fn() }; + const core = { + services: { + appLifecycle: { onSettingLoaded, onLayoutReady, onUnload }, + API: { addCommand: vi.fn() }, + }, + } as never; + const ui: CompatibilityReviewUi = { + showSummary: vi.fn(), + showDetails: vi.fn(), + showReminder: vi.fn(), + clearReminder: vi.fn(), + }; + + useCompatibilityReview(core, ui); + + expect(onLayoutReady.addHandler).toHaveBeenCalledWith(expect.any(Function), 30); + }); +}); diff --git a/src/serviceFeatures/compatibilityReviewMarkdown.ts b/src/serviceFeatures/compatibilityReviewMarkdown.ts new file mode 100644 index 00000000..c4730b3b --- /dev/null +++ b/src/serviceFeatures/compatibilityReviewMarkdown.ts @@ -0,0 +1,54 @@ +import type { CompatibilityPause, CompatibilityPauseReason } from "@/common/databaseCompatibility.ts"; + +export function compatibilityReviewSummaryMarkdown(pause: CompatibilityPause): string { + const action = !pause.resumable + ? "This installation cannot safely acknowledge the detected state. Update Self-hosted LiveSync before attempting to synchronise again." + : "Before resuming, review the compatibility details and update Self-hosted LiveSync on every device which uses this remote database."; + return `Remote synchronisation is paused on this device because its compatibility state requires attention. + +${action} + +Your automatic synchronisation preferences have not been changed. Closing this dialogue keeps synchronisation paused.`; +} + +function reasonMarkdown(reason: CompatibilityPauseReason): string { + if (reason.source === "database-version") { + if (reason.state === "upgrade") { + return `- The last acknowledged internal database version was **${reason.acknowledgedVersion}** and this installation uses **${reason.currentVersion}**.`; + } + if (reason.state === "downgrade") { + return `- This installation uses internal database version **${reason.currentVersion}**, but this device previously acknowledged newer version **${reason.acknowledgedVersion}**. An older installation must not resume synchronisation.`; + } + if (reason.state === "missing") { + return `- No previously acknowledged internal database version was found for this existing Vault. This can happen when a Vault is copied or restored, or when it is opened with a new Obsidian profile. This installation uses version **${reason.currentVersion}**. An empty local database does not mean that it is safe to resume automatically.`; + } + return `- The saved internal database version marker is invalid. This installation uses version **${reason.currentVersion}**.`; + } + if (reason.source === "settings-schema") { + if (reason.isFromFutureSchema) { + return `- The saved settings use schema **${reason.sourceVersion}**, which is newer than schema **${reason.currentVersion}** supported by this installation.`; + } + return `- The settings were migrated from schema **${reason.sourceVersion}** to **${reason.currentVersion}** and require review before synchronisation resumes.`; + } + const escapedMessage = reason.message.replace(/[\\`*_{}[\]()<>#+.!|-]/gu, "\\$&"); + return `- An earlier compatibility review remains pending: ${escapedMessage}`; +} + +export function compatibilityReviewDetailsMarkdown(pause: CompatibilityPause): string { + const resolution = !pause.resumable + ? "Install a compatible current version of Self-hosted LiveSync. This pause cannot be dismissed by the current installation." + : "After all devices have been updated, return to the compatibility review summary and explicitly resume synchronisation. The current internal version will only then be recorded as acknowledged."; + return `## Why synchronisation is paused + +${pause.reasons.map(reasonMarkdown).join("\n")} + +## What the pause changes + +- Remote replication is blocked before work begins. +- Your saved automatic synchronisation preferences remain unchanged. +- Closing either dialogue leaves the safety gate active. + +## What to do next + +${resolution}`; +} diff --git a/src/serviceFeatures/compatibilityReviewObsidian.ts b/src/serviceFeatures/compatibilityReviewObsidian.ts new file mode 100644 index 00000000..623b111d --- /dev/null +++ b/src/serviceFeatures/compatibilityReviewObsidian.ts @@ -0,0 +1,82 @@ +import { Notice } from "@/deps.ts"; +import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm"; +import type { CompatibilityPause } from "@/common/databaseCompatibility.ts"; +import type { + CompatibilityReviewDetailsAction, + CompatibilityReviewSummaryAction, + CompatibilityReviewUi, +} from "./compatibilityReview.ts"; +import { + compatibilityReviewDetailsMarkdown, + compatibilityReviewSummaryMarkdown, +} from "./compatibilityReviewMarkdown.ts"; + +const REVIEW_DETAILS = "Review compatibility details"; +const KEEP_PAUSED = "Keep synchronisation paused"; +const RESUME = "Resume synchronisation"; +const BACK = "Back to compatibility review"; + +export class ObsidianCompatibilityReviewUi implements CompatibilityReviewUi { + private reminder: Notice | undefined; + + constructor(private readonly confirm: Confirm) {} + + async showSummary(pause: CompatibilityPause): Promise { + const buttons = !pause.resumable + ? ([REVIEW_DETAILS, KEEP_PAUSED] as const) + : ([REVIEW_DETAILS, RESUME, KEEP_PAUSED] as const); + const result = await this.confirm.confirmWithMessage( + "Synchronisation paused for compatibility review", + compatibilityReviewSummaryMarkdown(pause), + [...buttons], + KEEP_PAUSED, + undefined, + "vertical" + ); + if (result === REVIEW_DETAILS) return "details"; + if (result === RESUME) return "resume"; + if (result === KEEP_PAUSED) return "keep-paused"; + return false; + } + + async showDetails(pause: CompatibilityPause): Promise { + const result = await this.confirm.confirmWithMessage( + "Compatibility review details", + compatibilityReviewDetailsMarkdown(pause), + [BACK], + BACK, + undefined, + "vertical" + ); + if (result === BACK) return "back"; + return false; + } + + showReminder(openReview: () => void): void { + this.clearReminder(); + let reminderAnchor: HTMLAnchorElement | undefined; + const fragment = createFragment((documentFragment) => { + documentFragment.createSpan({ + text: "Self-hosted LiveSync has paused remote synchronisation for compatibility review. ", + }); + documentFragment.createEl("a", { text: "Review why" }, (anchor) => { + reminderAnchor = anchor; + anchor.addEventListener("click", (event) => { + event.preventDefault(); + openReview(); + }); + }); + }); + this.reminder = new Notice(fragment, 0); + reminderAnchor?.closest(".notice")?.classList.add("livesync-compatibility-review-notice"); + } + + clearReminder(): void { + this.reminder?.hide(); + this.reminder = undefined; + } +} + +export function createObsidianCompatibilityReviewUi(confirm: Confirm): CompatibilityReviewUi { + return new ObsidianCompatibilityReviewUi(confirm); +} diff --git a/src/serviceFeatures/compatibilityReviewObsidian.unit.spec.ts b/src/serviceFeatures/compatibilityReviewObsidian.unit.spec.ts new file mode 100644 index 00000000..17a961a0 --- /dev/null +++ b/src/serviceFeatures/compatibilityReviewObsidian.unit.spec.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from "vitest"; +import type { CompatibilityPause } from "@/common/databaseCompatibility.ts"; +import { compatibilityReviewDetailsMarkdown } from "./compatibilityReviewMarkdown.ts"; +import { ObsidianCompatibilityReviewUi } from "./compatibilityReviewObsidian.ts"; + +vi.mock("@/deps.ts", () => ({ + Notice: class { + hide() {} + }, +})); + +const resumablePause: CompatibilityPause = { + resumable: true, + reasons: [ + { + source: "database-version", + state: "upgrade", + acknowledgedVersion: 11, + currentVersion: 12, + resumable: true, + }, + ], +}; + +describe("Obsidian compatibility review", () => { + it("explains why a configured Vault can be missing its device-local acknowledgement", async () => { + const pause: CompatibilityPause = { + resumable: true, + reasons: [ + { + source: "database-version", + state: "missing", + currentVersion: 12, + resumable: true, + }, + ], + }; + + const details = compatibilityReviewDetailsMarkdown(pause); + expect(details).toContain("copied or restored"); + expect(details).toContain("new Obsidian profile"); + expect(details).toContain("does not mean that it is safe to resume automatically"); + }); + + it("offers the generic resume action in a vertical action dialogue", async () => { + const confirmWithMessage = vi.fn().mockResolvedValue("Resume synchronisation"); + const ui = new ObsidianCompatibilityReviewUi({ confirmWithMessage } as never); + + await expect(ui.showSummary(resumablePause)).resolves.toBe("resume"); + expect(confirmWithMessage).toHaveBeenCalledWith( + "Synchronisation paused for compatibility review", + expect.any(String), + ["Review compatibility details", "Resume synchronisation", "Keep synchronisation paused"], + "Keep synchronisation paused", + undefined, + "vertical" + ); + }); +}); diff --git a/src/serviceFeatures/configuredStartupLifecycle.ts b/src/serviceFeatures/configuredStartupLifecycle.ts new file mode 100644 index 00000000..fa179e69 --- /dev/null +++ b/src/serviceFeatures/configuredStartupLifecycle.ts @@ -0,0 +1,41 @@ +export interface ConfiguredStartupLifecycleRuntime { + databaseReady: boolean; + reportDatabaseNotReady(): void; + hasCompromisedChunks(): Promise; + hasIncompleteDocuments(): Promise; + waitForCompatibilityReview(): Promise; + runDoctor(): Promise; + migrateBulkSend(): Promise; +} + +export interface StartupEntryLifecycleRuntime { + configured: boolean; + inviteToOnboarding(): void; +} + +/** + * Keeps an unconfigured Vault outside database initialisation and all + * configured-only start-up work while offering an explicit setup action. + */ +export function runStartupEntryLifecycle(runtime: StartupEntryLifecycleRuntime): boolean { + if (runtime.configured) return true; + runtime.inviteToOnboarding(); + return false; +} + +/** + * Separates the inert, unconfigured startup path from checks which must run + * before an already configured device is allowed to synchronise. + */ +export async function runConfiguredStartupLifecycle(runtime: ConfiguredStartupLifecycleRuntime): Promise { + if (!runtime.databaseReady) { + runtime.reportDatabaseNotReady(); + return false; + } + if (!(await runtime.hasCompromisedChunks())) return false; + if (!(await runtime.hasIncompleteDocuments())) return false; + await runtime.waitForCompatibilityReview(); + if (!(await runtime.runDoctor())) return false; + await runtime.migrateBulkSend(); + return true; +} diff --git a/src/serviceFeatures/configuredStartupLifecycle.unit.spec.ts b/src/serviceFeatures/configuredStartupLifecycle.unit.spec.ts new file mode 100644 index 00000000..9bfaff6e --- /dev/null +++ b/src/serviceFeatures/configuredStartupLifecycle.unit.spec.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from "vitest"; +import { + runConfiguredStartupLifecycle, + runStartupEntryLifecycle, + type ConfiguredStartupLifecycleRuntime, +} from "./configuredStartupLifecycle"; + +function createRuntime(): ConfiguredStartupLifecycleRuntime & { events: string[] } { + const events: string[] = []; + return { + events, + databaseReady: true, + reportDatabaseNotReady: vi.fn(() => events.push("database-not-ready")), + hasCompromisedChunks: vi.fn(async () => { + events.push("compromised-chunks"); + return true; + }), + hasIncompleteDocuments: vi.fn(async () => { + events.push("incomplete-documents"); + return true; + }), + waitForCompatibilityReview: vi.fn(async () => {}), + runDoctor: vi.fn(async () => { + events.push("doctor"); + return true; + }), + migrateBulkSend: vi.fn(async () => { + events.push("bulk-send"); + }), + }; +} + +describe("runConfiguredStartupLifecycle", () => { + it("runs configured checks in order before allowing initialisation", async () => { + const runtime = createRuntime(); + + await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(true); + + expect(runtime.events).toEqual(["compromised-chunks", "incomplete-documents", "doctor", "bulk-send"]); + }); + + it("keeps Config Doctor behind the initial compatibility review", async () => { + const runtime = createRuntime(); + Object.assign(runtime, { + waitForCompatibilityReview: vi.fn(async () => { + runtime.events.push("compatibility-review"); + }), + }); + + await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(true); + + expect(runtime.events).toEqual([ + "compromised-chunks", + "incomplete-documents", + "compatibility-review", + "doctor", + "bulk-send", + ]); + }); + + it("stops before onboarding or checks when the database is unavailable", async () => { + const runtime = createRuntime(); + runtime.databaseReady = false; + + await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false); + + expect(runtime.events).toEqual(["database-not-ready"]); + }); + + it("stops the configured sequence at the first failed check", async () => { + const runtime = createRuntime(); + vi.mocked(runtime.hasIncompleteDocuments).mockImplementation(async () => { + runtime.events.push("incomplete-documents"); + return false; + }); + + await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false); + + expect(runtime.events).toEqual(["compromised-chunks", "incomplete-documents"]); + }); +}); + +describe("runStartupEntryLifecycle", () => { + it("offers onboarding and stops before database initialisation on an unconfigured Vault", () => { + const inviteToOnboarding = vi.fn(); + + expect( + runStartupEntryLifecycle({ + configured: false, + inviteToOnboarding, + }) + ).toBe(false); + + expect(inviteToOnboarding).toHaveBeenCalledOnce(); + }); + + it("allows a configured Vault to continue to database initialisation", () => { + const inviteToOnboarding = vi.fn(); + + expect( + runStartupEntryLifecycle({ + configured: true, + inviteToOnboarding, + }) + ).toBe(true); + + expect(inviteToOnboarding).not.toHaveBeenCalled(); + }); +}); diff --git a/src/serviceFeatures/fileDatabaseInfo.ts b/src/serviceFeatures/fileDatabaseInfo.ts new file mode 100644 index 00000000..40fc01e3 --- /dev/null +++ b/src/serviceFeatures/fileDatabaseInfo.ts @@ -0,0 +1,454 @@ +import { $msg } from "@/common/translation"; +import type { + FilePath, + FilePathWithPrefix, + LoadedEntry, + ObsidianLiveSyncSettings, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { getFileRegExp } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc"; +import { ICHeader, ICXHeader, PSCHeader } from "@vrtmrz/livesync-commonlib/compat/common/models/fileaccess.const"; +import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess"; +import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB"; +import type { IPathService, IUIService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService"; +import { addPrefix, stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; + +type DatabaseMeta = LoadedEntry & { + _rawStorageType: string | null; + _legacyBodyPresent: boolean; + _revs_info?: Array<{ + rev: string; + status: string; + }>; +}; + +export type FileDatabaseInfoCore = { + localDatabase: Pick< + LiveSyncLocalDB, + "allDocsRaw" | "findAllDocs" | "getDBEntryFromMeta" | "getDBEntry" | "localDatabase" + >; + services: { + path: Pick; + UI: IUIService; + }; + settings: ObsidianLiveSyncSettings; + storageAccess: Pick< + StorageAccess, + "getFileNames" | "getFilesIncludeHidden" | "isExistsIncludeHidden" | "statHidden" + >; +}; + +export type RevisionDatabaseInfo = { + documentId: string; + revision: string | null; + current: boolean; + deleted: boolean; + storageType: string; + storageLayout: "chunked" | "legacy-inline"; + ctime: number; + mtime: number; + recordedSize: number; + revisionHistory: Array<{ + revision: string; + status: string; + }>; + chunkReferences: number; + uniqueChunkReferences: number; + embeddedChunkReferences: number; + locallyStoredChunkReferences: number; + contentAvailableLocally: boolean; + chunks: Array<{ + id: string; + referenceCount: number; + embedded: boolean; + storedInLocalDatabase: boolean; + localDatabaseState: "available" | "deleted" | "missing"; + localDatabaseRevision: string | null; + }>; +}; + +export type FileDatabaseMergeBaseInfo = { + winnerRevision: string; + conflictRevision: string; + revision: string | null; + metadataAvailableLocally: boolean; + contentAvailableLocally: boolean; + missingChunkIds: string[]; + unavailableSharedRevisions: string[]; +}; + +export type FileDatabaseInfo = { + path: string; + databasePath: FilePathWithPrefix | FilePath; + storage: { + exists: boolean; + ctime?: number; + mtime?: number; + size?: number; + }; + database: { + source: "local database on this device"; + remoteQueried: false; + exists: boolean; + currentRevision: string | null; + conflictCount: number; + conflictRevisions: string[]; + unavailableConflictRevisions: string[]; + revisions: RevisionDatabaseInfo[]; + mergeBases: FileDatabaseMergeBaseInfo[]; + }; +}; + +const REPORT_WARNING = + "All revisions and chunk availability below are a snapshot of this device's local database; the remote is not queried. Review the Vault-relative path, document identifier, content-derived chunk identifiers, and metadata before sharing this report. File contents are omitted."; + +function toDatabasePath(path: string): FilePathWithPrefix | FilePath { + if (path.startsWith(".")) { + return addPrefix(path as FilePath, ICHeader); + } + return path as FilePath; +} + +type RawDatabaseDocument = { + _id: string; + _rev?: string; + _conflicts?: string[]; + _deleted?: boolean; + _revs_info?: Array<{ + rev: string; + status: string; + }>; + children?: string[]; + ctime?: number; + deleted?: boolean; + data?: string | string[]; + eden?: Record; + mtime?: number; + size?: number; + type?: string; +}; + +async function getLocalDatabaseMeta( + core: FileDatabaseInfoCore, + path: FilePathWithPrefix | FilePath, + options: PouchDB.Core.GetOptions +): Promise { + const documentId = await core.services.path.path2id(path); + let raw: RawDatabaseDocument; + try { + raw = await core.localDatabase.localDatabase.get(documentId, options); + } catch (error) { + if (isNotFoundError(error)) { + return false; + } + throw error; + } + + if (raw.type === "leaf") { + return false; + } + if (raw.type && raw.type !== "notes" && raw.type !== "newnote" && raw.type !== "plain") { + return false; + } + + const rawStorageType = raw.type ?? null; + const legacy = rawStorageType === null || rawStorageType === "notes"; + const type = legacy ? "notes" : rawStorageType; + const legacyBodyPresent = + legacy && (typeof raw.data === "string" || (Array.isArray(raw.data) && raw.data.every((item) => typeof item === "string"))); + return { + _id: raw._id, + _rev: raw._rev, + _conflicts: raw._conflicts, + _revs_info: raw._revs_info, + path, + data: legacyBodyPresent ? raw.data : "", + ctime: raw.ctime ?? 0, + mtime: raw.mtime ?? 0, + size: raw.size ?? 0, + children: type === "newnote" || type === "plain" ? (raw.children ?? []) : [], + datatype: type === "newnote" ? "newnote" : "plain", + deleted: raw.deleted ?? raw._deleted, + type, + eden: raw.eden ?? {}, + _rawStorageType: rawStorageType, + _legacyBodyPresent: legacyBodyPresent, + } as DatabaseMeta; +} + +async function collectRevisionDatabaseInfo( + core: FileDatabaseInfoCore, + meta: DatabaseMeta, + current: boolean +): Promise { + const legacy = meta._rawStorageType === null || meta._rawStorageType === "notes"; + const children = legacy ? [] : "children" in meta ? meta.children : []; + const uniqueChildren = [...new Set(children)]; + const referenceCounts = new Map(); + for (const child of children) { + referenceCounts.set(child, (referenceCounts.get(child) ?? 0) + 1); + } + const embeddedChildren = new Set( + Object.keys("eden" in meta && meta.eden ? meta.eden : {}).filter((id) => uniqueChildren.includes(id)) + ); + const localRows = + uniqueChildren.length === 0 + ? [] + : ( + await core.localDatabase.allDocsRaw({ + keys: uniqueChildren, + include_docs: false, + }) + ).rows; + const localChunkStates = new Map( + localRows + .filter((row) => "value" in row) + .map( + (row) => + [ + row.key, + { + state: row.value.deleted ? ("deleted" as const) : ("available" as const), + revision: row.value.rev, + }, + ] as const + ) + ); + + return { + documentId: meta._id, + revision: meta._rev ?? null, + current, + deleted: Boolean(meta.deleted ?? meta._deleted), + storageType: meta._rawStorageType ?? "absent", + storageLayout: legacy ? "legacy-inline" : "chunked", + ctime: meta.ctime, + mtime: meta.mtime, + recordedSize: meta.size, + revisionHistory: (meta._revs_info ?? []).map(({ rev, status }) => ({ + revision: rev, + status, + })), + chunkReferences: children.length, + uniqueChunkReferences: uniqueChildren.length, + embeddedChunkReferences: children.filter((id) => embeddedChildren.has(id)).length, + locallyStoredChunkReferences: children.filter((id) => localChunkStates.get(id)?.state === "available").length, + contentAvailableLocally: legacy + ? meta._legacyBodyPresent + : uniqueChildren.every( + (id) => embeddedChildren.has(id) || localChunkStates.get(id)?.state === "available" + ), + chunks: uniqueChildren.map((id) => { + const localState = localChunkStates.get(id); + return { + id, + referenceCount: referenceCounts.get(id) ?? 0, + embedded: embeddedChildren.has(id), + storedInLocalDatabase: localState?.state === "available", + localDatabaseState: localState?.state ?? "missing", + localDatabaseRevision: localState?.revision ?? null, + }; + }), + }; +} + +function revisionHistory(meta: DatabaseMeta): Array<{ revision: string; status: string }> { + const history = (meta._revs_info ?? []).map(({ rev, status }) => ({ + revision: rev, + status, + })); + if (meta._rev && !history.some(({ revision }) => revision === meta._rev)) { + history.unshift({ + revision: meta._rev, + status: "available", + }); + } + return history; +} + +function missingChunkIds(info: RevisionDatabaseInfo): string[] { + return info.chunks + .filter(({ embedded, localDatabaseState }) => !embedded && localDatabaseState !== "available") + .map(({ id }) => id); +} + +export async function inspectFileDatabaseInfo(core: FileDatabaseInfoCore, path: string): Promise { + const storageExists = await core.storageAccess.isExistsIncludeHidden(path); + const storageStat = storageExists ? await core.storageAccess.statHidden(path) : null; + const databasePath = toDatabasePath(path); + const currentMeta = await getLocalDatabaseMeta(core, databasePath, { + conflicts: true, + revs: true, + revs_info: true, + }); + + const revisions: RevisionDatabaseInfo[] = []; + const conflictRevisions = currentMeta === false ? [] : (currentMeta._conflicts ?? []); + const unavailableConflictRevisions: string[] = []; + const mergeBases: FileDatabaseMergeBaseInfo[] = []; + const metadataByRevision = new Map(); + if (currentMeta !== false && currentMeta._rev) { + metadataByRevision.set(currentMeta._rev, currentMeta); + } + const getRevisionMeta = async (revision: string): Promise => { + const cached = metadataByRevision.get(revision); + if (cached !== undefined) { + return cached; + } + const meta = await getLocalDatabaseMeta(core, databasePath, { + rev: revision, + revs: true, + revs_info: true, + }); + metadataByRevision.set(revision, meta); + return meta; + }; + + if (currentMeta) { + revisions.push(await collectRevisionDatabaseInfo(core, currentMeta, true)); + for (const revision of conflictRevisions) { + const conflictMeta = await getRevisionMeta(revision); + if (conflictMeta) { + revisions.push(await collectRevisionDatabaseInfo(core, conflictMeta, false)); + const winnerHistory = revisionHistory(currentMeta); + const conflictHistory = revisionHistory(conflictMeta); + const conflictHistoryByRevision = new Map( + conflictHistory.map(({ revision: historyRevision, status }) => [historyRevision, status]) + ); + const sharedHistory = winnerHistory.filter(({ revision: historyRevision }) => + conflictHistoryByRevision.has(historyRevision) + ); + const sharedRevision = sharedHistory[0]?.revision ?? null; + const unavailableSharedRevisions = sharedHistory + .filter( + ({ revision: historyRevision, status }) => + status !== "available" || + conflictHistoryByRevision.get(historyRevision) !== "available" + ) + .map(({ revision: historyRevision }) => historyRevision); + const sharedMeta = sharedRevision ? await getRevisionMeta(sharedRevision) : false; + const sharedInfo = sharedMeta + ? await collectRevisionDatabaseInfo(core, sharedMeta, false) + : undefined; + mergeBases.push({ + winnerRevision: currentMeta._rev ?? "", + conflictRevision: revision, + revision: sharedRevision, + metadataAvailableLocally: Boolean(sharedMeta), + contentAvailableLocally: sharedInfo?.contentAvailableLocally ?? false, + missingChunkIds: sharedInfo ? missingChunkIds(sharedInfo) : [], + unavailableSharedRevisions, + }); + } else { + unavailableConflictRevisions.push(revision); + } + } + } + + const report: FileDatabaseInfo = { + path, + databasePath, + storage: storageStat + ? { + exists: true, + ctime: storageStat.ctime, + mtime: storageStat.mtime, + size: storageStat.size, + } + : { + exists: false, + }, + database: { + source: "local database on this device", + remoteQueried: false, + exists: currentMeta !== false, + currentRevision: currentMeta ? (currentMeta._rev ?? null) : null, + conflictCount: conflictRevisions.length, + conflictRevisions, + unavailableConflictRevisions, + revisions, + mergeBases, + }, + }; + + return report; +} + +export async function readFileDatabaseRevisionLocally( + core: FileDatabaseInfoCore, + path: string, + revision: string +): Promise { + const databasePath = toDatabasePath(path); + const meta = await getLocalDatabaseMeta(core, databasePath, { + rev: revision, + revs: true, + revs_info: true, + }); + if (!meta) { + return false; + } + const info = await collectRevisionDatabaseInfo(core, meta, false); + if (info.deleted || !info.contentAvailableLocally) { + return false; + } + return await core.localDatabase.getDBEntryFromMeta(meta, false, false); +} + +export async function retryReadFileDatabaseRevision( + core: FileDatabaseInfoCore, + path: string, + revision: string +): Promise { + return await core.localDatabase.getDBEntry(toDatabasePath(path), { rev: revision }, false, true, true); +} + +export async function buildFileDatabaseInfoReport(core: FileDatabaseInfoCore, path: string): Promise { + const report = await inspectFileDatabaseInfo(core, path); + return `${$msg(REPORT_WARNING)} + +\`\`\`json +${JSON.stringify(report, null, 2)} +\`\`\``; +} + +export async function copyFileDatabaseInfo(core: FileDatabaseInfoCore, path: string): Promise { + const report = await buildFileDatabaseInfoReport(core, path); + return await core.services.UI.promptCopyToClipboard( + $msg("Database information for ${FILE}", { FILE: path }), + report + ); +} + +export async function collectFileDatabaseInfoPaths(core: FileDatabaseInfoCore): Promise { + const ignorePatterns = getFileRegExp(core.settings, "syncInternalFilesIgnorePatterns"); + const targetPatterns = getFileRegExp(core.settings, "syncInternalFilesTargetPatterns"); + const storagePaths = core.settings.syncInternalFiles + ? await core.storageAccess.getFilesIncludeHidden("/", targetPatterns, ignorePatterns) + : await core.storageAccess.getFileNames(); + const databasePaths: string[] = []; + + for await (const entry of core.localDatabase.findAllDocs()) { + const prefixedPath = entry.path; + if (prefixedPath.startsWith(ICXHeader) || prefixedPath.startsWith(PSCHeader)) { + continue; + } + if (!core.settings.syncInternalFiles && prefixedPath.startsWith(ICHeader)) { + continue; + } + databasePaths.push(stripAllPrefixes(prefixedPath)); + } + + return [...new Set([...storagePaths, ...databasePaths])].sort((left, right) => + left < right ? -1 : left > right ? 1 : 0 + ); +} + +export async function chooseAndCopyFileDatabaseInfo(core: FileDatabaseInfoCore): Promise { + const paths = await collectFileDatabaseInfoPaths(core); + const selected = await core.services.UI.confirm.askSelectString($msg("Choose a file to inspect"), paths); + if (!selected) { + return false; + } + return await copyFileDatabaseInfo(core, selected); +} diff --git a/src/serviceFeatures/fileDatabaseInfo.unit.spec.ts b/src/serviceFeatures/fileDatabaseInfo.unit.spec.ts new file mode 100644 index 00000000..1d1b6207 --- /dev/null +++ b/src/serviceFeatures/fileDatabaseInfo.unit.spec.ts @@ -0,0 +1,413 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildFileDatabaseInfoReport, + chooseAndCopyFileDatabaseInfo, + collectFileDatabaseInfoPaths, + inspectFileDatabaseInfo, + readFileDatabaseRevisionLocally, + retryReadFileDatabaseRevision, +} from "./fileDatabaseInfo"; + +async function* documents(paths: string[]) { + for (const path of paths) { + yield { + _id: `f:${path}`, + path, + }; + } +} + +function createCore() { + const current = { + _id: "f:note", + _rev: "3-current", + _conflicts: ["2-conflict"], + _revs_info: [ + { rev: "3-current", status: "available" }, + { rev: "2-parent", status: "missing" }, + ], + path: "note.md", + ctime: 100, + mtime: 300, + size: 42, + type: "plain", + datatype: "plain", + data: "secret current body", + children: ["h:private-current", "h:private-current", "h:private-embedded", "h:private-deleted"], + eden: { + "h:private-embedded": { + data: "secret embedded body", + epoch: 1, + }, + }, + }; + const conflict = { + ...current, + _rev: "2-conflict", + _conflicts: undefined, + _revs_info: [{ rev: "2-conflict", status: "available" }], + mtime: 200, + data: "secret conflict body", + children: ["h:private-missing"], + eden: {}, + }; + const promptCopyToClipboard = vi.fn(async (_title: string, _value: string) => true); + const askSelectString = vi.fn(async () => "db-only.md"); + const core = { + settings: { + syncInternalFiles: false, + syncInternalFilesIgnorePatterns: "", + syncInternalFilesTargetPatterns: "", + }, + storageAccess: { + isExistsIncludeHidden: vi.fn(async () => true), + statHidden: vi.fn(async () => ({ + ctime: 90, + mtime: 310, + size: 45, + type: "file", + })), + getFileNames: vi.fn(async () => ["z.md", "a.md"]), + getFilesIncludeHidden: vi.fn(async () => [".obsidian/app.json", "a.md"]), + }, + localDatabase: { + getDBEntryFromMeta: vi.fn(async (meta: typeof current) => ({ + ...meta, + data: ["loaded body"], + })), + getDBEntry: vi.fn(async () => current), + localDatabase: { + get: vi.fn(async (_id: string, options?: { rev?: string }) => + options?.rev === "2-conflict" ? conflict : current + ), + }, + allDocsRaw: vi.fn(async ({ keys }: { keys: string[] }) => ({ + rows: [ + ...(keys.includes("h:private-current") + ? [ + { + id: "h:private-current", + key: "h:private-current", + value: { rev: "1-chunk" }, + }, + ] + : []), + ...(keys.includes("h:private-deleted") + ? [ + { + id: "h:private-deleted", + key: "h:private-deleted", + value: { rev: "4-deleted-chunk", deleted: true }, + }, + ] + : []), + ], + })), + findAllDocs: vi.fn(() => documents(["db-only.md", "i:.obsidian/app.json", "ix:ignore", "ps:setting"])), + }, + services: { + path: { + path2id: vi.fn(async () => "f:note"), + }, + UI: { + promptCopyToClipboard, + confirm: { + askSelectString, + }, + }, + }, + }; + return { + askSelectString, + conflict, + core, + current, + promptCopyToClipboard, + }; +} + +describe("file database information", () => { + it("reports document and chunk revisions without exposing file contents", async () => { + const { core } = createCore(); + + const report = await buildFileDatabaseInfoReport(core as never, "note.md"); + + expect(report).toContain('"path": "note.md"'); + expect(report).toContain('"documentId": "f:note"'); + expect(report).toContain('"revision": "3-current"'); + expect(report).toContain('"revision": "2-conflict"'); + expect(report).toContain('"storageType": "plain"'); + expect(report).toContain('"storageLayout": "chunked"'); + expect(report).toContain('"contentAvailableLocally": false'); + expect(report).toContain('"id": "h:private-current"'); + expect(report).toContain('"localDatabaseRevision": "1-chunk"'); + expect(report).toContain('"referenceCount": 2'); + expect(report).toContain('"id": "h:private-embedded"'); + expect(report).toContain('"embedded": true'); + expect(report).toContain('"id": "h:private-deleted"'); + expect(report).toContain('"localDatabaseState": "deleted"'); + expect(report).toContain('"localDatabaseRevision": "4-deleted-chunk"'); + expect(report).toContain('"id": "h:private-missing"'); + expect(report).toContain('"localDatabaseState": "missing"'); + expect(report).toContain('"localDatabaseRevision": null'); + expect(report).not.toContain("secret current body"); + expect(report).not.toContain("secret conflict body"); + expect(report).not.toContain("secret embedded body"); + }); + + it.each([ + { + name: "notes", + document: { + type: "notes", + data: "secret legacy body", + }, + storageType: "notes", + }, + { + name: "an absent type", + document: { + type: undefined, + data: ["secret", " legacy body"], + }, + storageType: "absent", + }, + ])("reports $name as legacy inline storage without exposing its body", async ({ document, storageType }) => { + const { core, current } = createCore(); + core.localDatabase.localDatabase.get.mockResolvedValue({ + ...current, + ...document, + _conflicts: [], + children: ["h:must-not-be-treated-as-a-chunk"], + } as never); + + const info = await inspectFileDatabaseInfo(core as never, "note.md"); + const report = await buildFileDatabaseInfoReport(core as never, "note.md"); + + expect(info.database.revisions).toEqual([ + expect.objectContaining({ + storageType, + storageLayout: "legacy-inline", + chunkReferences: 0, + contentAvailableLocally: true, + }), + ]); + expect(report).not.toContain("secret legacy body"); + expect(report).not.toContain("h:must-not-be-treated-as-a-chunk"); + }); + + it("reports the exact shared ancestor and its missing chunks for each conflict", async () => { + const { conflict, core, current } = createCore(); + const parent = { + ...current, + _rev: "2-parent", + _conflicts: undefined, + _revs_info: [ + { rev: "2-parent", status: "available" }, + { rev: "1-root", status: "missing" }, + ], + children: ["h:missing-parent"], + eden: {}, + }; + core.localDatabase.localDatabase.get.mockImplementation(async (_id: string, options?: { rev?: string }) => { + if (options?.rev === "2-conflict") { + return { + ...conflict, + _revs_info: [ + { rev: "2-conflict", status: "available" }, + { rev: "2-parent", status: "available" }, + { rev: "1-root", status: "missing" }, + ], + }; + } + if (options?.rev === "2-parent") { + return parent; + } + return { + ...current, + _revs_info: [ + { rev: "3-current", status: "available" }, + { rev: "2-parent", status: "available" }, + { rev: "1-root", status: "missing" }, + ], + }; + }); + + const info = await inspectFileDatabaseInfo(core as never, "note.md"); + + expect(info.database.mergeBases).toEqual([ + { + winnerRevision: "3-current", + conflictRevision: "2-conflict", + revision: "2-parent", + metadataAvailableLocally: true, + contentAvailableLocally: false, + missingChunkIds: ["h:missing-parent"], + unavailableSharedRevisions: ["1-root"], + }, + ]); + }); + + it("does not decode a revision whose chunks are not all available locally", async () => { + const { core } = createCore(); + + await expect(readFileDatabaseRevisionLocally(core as never, "note.md", "3-current")).resolves.toBe(false); + + expect(core.localDatabase.getDBEntryFromMeta).not.toHaveBeenCalled(); + }); + + it("decodes an exact revision after confirming that every chunk is available locally", async () => { + const { core, current } = createCore(); + core.localDatabase.localDatabase.get.mockResolvedValue({ + ...current, + children: ["h:available"], + eden: {}, + } as never); + core.localDatabase.allDocsRaw.mockResolvedValue({ + rows: [ + { + id: "h:available", + key: "h:available", + value: { rev: "1-available" }, + }, + ], + }); + + await expect(readFileDatabaseRevisionLocally(core as never, "note.md", "3-current")).resolves.toEqual( + expect.objectContaining({ + data: ["loaded body"], + }) + ); + + expect(core.localDatabase.getDBEntryFromMeta).toHaveBeenCalledWith( + expect.objectContaining({ + _rev: "3-current", + }), + false, + false + ); + }); + + it("retries an exact revision through the configured chunk retrieval path", async () => { + const { core } = createCore(); + + await retryReadFileDatabaseRevision(core as never, "note.md", "2-conflict"); + + expect(core.localDatabase.getDBEntry).toHaveBeenCalledWith( + "note.md", + { rev: "2-conflict" }, + false, + true, + true + ); + }); + + it("reports the exact revision as locally available after retry recovers its missing chunk", async () => { + const { conflict, core } = createCore(); + let recovered = false; + core.localDatabase.getDBEntry.mockImplementation(async () => { + recovered = true; + return conflict as never; + }); + core.localDatabase.allDocsRaw.mockImplementation(async ({ keys }: { keys: string[] }) => ({ + rows: + recovered && keys.includes("h:private-missing") + ? [ + { + id: "h:private-missing", + key: "h:private-missing", + value: { rev: "1-recovered" }, + }, + ] + : [], + })); + + await expect( + retryReadFileDatabaseRevision(core as never, "note.md", "2-conflict") + ).resolves.not.toBe(false); + const information = await inspectFileDatabaseInfo(core as never, "note.md"); + + expect( + information.database.revisions.find(({ revision }) => revision === "2-conflict") + ).toEqual( + expect.objectContaining({ + contentAvailableLocally: true, + chunks: [ + expect.objectContaining({ + id: "h:private-missing", + localDatabaseState: "available", + localDatabaseRevision: "1-recovered", + }), + ], + }) + ); + }); + + it("keeps the exact revision identifiers when conflict metadata is unavailable", async () => { + const { conflict, core, current } = createCore(); + core.localDatabase.localDatabase.get.mockImplementation(async (_id: string, options?: { rev?: string }) => { + if (options?.rev === "2-unavailable") { + throw Object.assign(new Error("missing"), { status: 404 }); + } + if (options?.rev === "2-conflict") { + return conflict; + } + return { ...current, _conflicts: ["2-conflict", "2-unavailable"] }; + }); + + const report = await buildFileDatabaseInfoReport(core as never, "note.md"); + + expect(report).toContain('"conflictRevisions"'); + expect(report).toContain('"2-conflict"'); + expect(report).toContain('"2-unavailable"'); + expect(report).toContain('"unavailableConflictRevisions"'); + }); + + it("reads an existing local document even when current synchronisation filters exclude its path", async () => { + const { core, current } = createCore(); + core.services.path.path2id.mockResolvedValue("f:ignored"); + core.localDatabase.localDatabase.get.mockResolvedValue({ + ...current, + _id: "f:ignored", + _rev: "5-ignored", + _conflicts: [], + _revs_info: [], + path: "ignored.md", + ctime: 10, + mtime: 20, + size: 30, + children: [], + }); + + const report = await buildFileDatabaseInfoReport(core as never, "ignored.md"); + + expect(report).toContain('"exists": true'); + expect(report).toContain('"documentId": "f:ignored"'); + expect(report).toContain('"revision": "5-ignored"'); + }); + + it("offers the union of storage and database paths and excludes inactive internal namespaces", async () => { + const { core } = createCore(); + + await expect(collectFileDatabaseInfoPaths(core as never)).resolves.toEqual(["a.md", "db-only.md", "z.md"]); + + core.settings.syncInternalFiles = true; + await expect(collectFileDatabaseInfoPaths(core as never)).resolves.toEqual([ + ".obsidian/app.json", + "a.md", + "db-only.md", + ]); + }); + + it("copies the selected file report through the existing copy dialogue", async () => { + const { askSelectString, core, promptCopyToClipboard } = createCore(); + + await expect(chooseAndCopyFileDatabaseInfo(core as never)).resolves.toBe(true); + + expect(askSelectString).toHaveBeenCalledWith("Choose a file to inspect", ["a.md", "db-only.md", "z.md"]); + expect(promptCopyToClipboard).toHaveBeenCalledWith( + "Database information for db-only.md", + expect.stringContaining('"path": "db-only.md"') + ); + }); +}); diff --git a/src/serviceFeatures/fileRepair.ts b/src/serviceFeatures/fileRepair.ts new file mode 100644 index 00000000..64172843 --- /dev/null +++ b/src/serviceFeatures/fileRepair.ts @@ -0,0 +1,132 @@ +import type { LoadedEntry } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { createBlob, isDocContentSame, readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess"; +import type { IFileHandler } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileHandler"; +import { + inspectFileDatabaseInfo, + readFileDatabaseRevisionLocally, + type FileDatabaseInfo, + type FileDatabaseInfoCore, + type RevisionDatabaseInfo, +} from "./fileDatabaseInfo"; + +export type FileRepairCore = FileDatabaseInfoCore & { + fileHandler: Pick; + storageAccess: FileDatabaseInfoCore["storageAccess"] & Pick; +}; + +export type FileRepairRevision = { + role: "winner" | "conflict"; + metadata: RevisionDatabaseInfo; + contentReadable: boolean; + contentMatchesStorage: boolean | null; + loadedEntry: LoadedEntry | false; +}; + +export type FileRepairInspection = { + information: FileDatabaseInfo; + revisions: FileRepairRevision[]; + requiresAttention: boolean; +}; + +export type DiscardUnreadableRevisionResult = + | "discarded" + | "failed" + | "no-longer-live" + | "revision-is-readable"; + +export type DiscardLiveBranchResult = "discarded" | "failed" | "no-longer-live" | "only-live-revision"; + +export async function inspectFileRepair(core: FileRepairCore, path: string): Promise { + const information = await inspectFileDatabaseInfo(core, path); + const storageContent = information.storage.exists + ? createBlob(await core.storageAccess.readHiddenFileBinary(path)) + : undefined; + const revisions: FileRepairRevision[] = []; + + for (const metadata of information.database.revisions) { + const loadedEntry = + metadata.deleted || !metadata.contentAvailableLocally + ? false + : await readFileDatabaseRevisionLocally(core, path, metadata.revision ?? ""); + const contentReadable = metadata.deleted || loadedEntry !== false; + const contentMatchesStorage = + storageContent && loadedEntry !== false + ? await isDocContentSame(storageContent, readAsBlob(loadedEntry)) + : null; + revisions.push({ + role: metadata.current ? "winner" : "conflict", + metadata, + contentReadable, + contentMatchesStorage, + loadedEntry, + }); + } + + const winner = revisions.find(({ role }) => role === "winner"); + const winnerRepresentsStoredFile = winner !== undefined && !winner.metadata.deleted; + const databaseAndStorageDiffer = + information.storage.exists !== winnerRepresentsStoredFile || + (information.storage.exists && + winnerRepresentsStoredFile && + winner.contentMatchesStorage === false); + const unreadableLiveRevision = + information.database.unavailableConflictRevisions.length > 0 || + revisions.some(({ contentReadable }) => !contentReadable); + const requiresAttention = + databaseAndStorageDiffer || + information.database.conflictCount > 0 || + unreadableLiveRevision || + (information.database.exists && winner === undefined); + + return { + information, + revisions, + requiresAttention, + }; +} + +export async function discardUnreadableLiveRevision( + core: FileRepairCore, + path: string, + revision: string +): Promise { + const latest = await inspectFileDatabaseInfo(core, path); + const liveRevisions = [ + latest.database.currentRevision, + ...latest.database.conflictRevisions, + ].filter((candidate): candidate is string => candidate !== null); + if (!liveRevisions.includes(revision)) { + return "no-longer-live"; + } + + const metadata = latest.database.revisions.find((candidate) => candidate.revision === revision); + const metadataUnavailable = latest.database.unavailableConflictRevisions.includes(revision); + if (!metadataUnavailable && (metadata?.deleted || metadata?.contentAvailableLocally)) { + return "revision-is-readable"; + } + + const deleted = await core.fileHandler.deleteRevisionFromDB(latest.databasePath, revision); + return deleted ? "discarded" : "failed"; +} + +export async function discardLiveBranch( + core: FileRepairCore, + path: string, + revision: string +): Promise { + const latest = await inspectFileDatabaseInfo(core, path); + const liveRevisions = [ + latest.database.currentRevision, + ...latest.database.conflictRevisions, + ].filter((candidate): candidate is string => candidate !== null); + if (!liveRevisions.includes(revision)) { + return "no-longer-live"; + } + if (liveRevisions.length < 2) { + return "only-live-revision"; + } + + const deleted = await core.fileHandler.deleteRevisionFromDB(latest.databasePath, revision); + return deleted ? "discarded" : "failed"; +} diff --git a/src/serviceFeatures/fileRepair.unit.spec.ts b/src/serviceFeatures/fileRepair.unit.spec.ts new file mode 100644 index 00000000..af12a34b --- /dev/null +++ b/src/serviceFeatures/fileRepair.unit.spec.ts @@ -0,0 +1,228 @@ +import { describe, expect, it, vi } from "vitest"; +import { + discardLiveBranch, + discardUnreadableLiveRevision, + inspectFileRepair, +} from "./fileRepair"; + +function createCore() { + const current = { + _id: "f:note", + _rev: "3-current", + _conflicts: ["2-conflict"], + _revs_info: [{ rev: "3-current", status: "available" }], + path: "note.md", + ctime: 1, + mtime: 3, + size: 7, + type: "plain", + children: ["h:current"], + deleted: false, + eden: {}, + }; + const conflict = { + ...current, + _rev: "2-conflict", + _conflicts: undefined, + _revs_info: [{ rev: "2-conflict", status: "available" }], + mtime: 2, + children: ["h:missing-conflict"], + }; + const deleteRevisionFromDB = vi.fn(async () => true); + const core = { + settings: { + syncInternalFiles: false, + syncInternalFilesIgnorePatterns: "", + syncInternalFilesTargetPatterns: "", + }, + storageAccess: { + isExistsIncludeHidden: vi.fn(async () => true), + statHidden: vi.fn(async () => ({ + ctime: 1, + mtime: 3, + size: 7, + type: "file", + })), + readHiddenFileBinary: vi.fn(async () => new TextEncoder().encode("current").buffer), + getFileNames: vi.fn(async () => ["note.md"]), + getFilesIncludeHidden: vi.fn(async () => ["note.md"]), + }, + localDatabase: { + localDatabase: { + get: vi.fn(async (_id: string, options?: { rev?: string }) => + options?.rev === "2-conflict" ? conflict : current + ), + }, + allDocsRaw: vi.fn(async ({ keys }: { keys: string[] }) => ({ + rows: keys.includes("h:current") + ? [ + { + id: "h:current", + key: "h:current", + value: { rev: "1-current" }, + }, + ] + : [], + })), + getDBEntryFromMeta: vi.fn(async (meta: typeof current) => ({ + ...meta, + data: [meta._rev === "3-current" ? "current" : "conflict"], + })), + getDBEntry: vi.fn(async () => false), + findAllDocs: vi.fn(async function* () { + yield current; + }), + }, + fileHandler: { + deleteRevisionFromDB, + }, + services: { + path: { + path2id: vi.fn(async () => "f:note"), + }, + UI: { + confirm: {}, + }, + }, + }; + return { + conflict, + core, + current, + deleteRevisionFromDB, + }; +} + +describe("file repair inspection", () => { + it("shows the winner and every conflict revision independently", async () => { + const { core } = createCore(); + + const inspection = await inspectFileRepair(core as never, "note.md"); + + expect(inspection.revisions).toEqual([ + expect.objectContaining({ + role: "winner", + contentReadable: true, + contentMatchesStorage: true, + metadata: expect.objectContaining({ + revision: "3-current", + }), + }), + expect.objectContaining({ + role: "conflict", + contentReadable: false, + contentMatchesStorage: null, + metadata: expect.objectContaining({ + revision: "2-conflict", + }), + }), + ]); + expect(inspection.requiresAttention).toBe(true); + }); + + it("omits a logical deletion which already matches an absent Vault file", async () => { + const { core, current } = createCore(); + current.deleted = true; + current._conflicts = []; + current.children = []; + core.storageAccess.isExistsIncludeHidden.mockResolvedValue(false); + core.storageAccess.statHidden.mockResolvedValue(null as never); + + const inspection = await inspectFileRepair(core as never, "note.md"); + + expect(inspection.revisions).toEqual([ + expect.objectContaining({ + role: "winner", + contentReadable: true, + metadata: expect.objectContaining({ + deleted: true, + revision: "3-current", + }), + }), + ]); + expect(inspection.requiresAttention).toBe(false); + }); + + it("rechecks liveness and readability before discarding an exact revision", async () => { + const { core, deleteRevisionFromDB } = createCore(); + + await expect( + discardUnreadableLiveRevision(core as never, "note.md", "2-conflict") + ).resolves.toBe("discarded"); + await expect( + discardUnreadableLiveRevision(core as never, "note.md", "3-current") + ).resolves.toBe("revision-is-readable"); + + expect(deleteRevisionFromDB).toHaveBeenCalledOnce(); + expect(deleteRevisionFromDB).toHaveBeenCalledWith("note.md", "2-conflict"); + }); + + it("allows an exact unreadable generation-one winner to be discarded explicitly", async () => { + const { core, current, deleteRevisionFromDB } = createCore(); + current._rev = "1-root"; + current._conflicts = []; + current.children = ["h:missing-root"]; + core.localDatabase.allDocsRaw.mockResolvedValue({ rows: [] }); + + const inspection = await inspectFileRepair(core as never, "note.md"); + + expect(inspection.revisions).toEqual([ + expect.objectContaining({ + role: "winner", + contentReadable: false, + metadata: expect.objectContaining({ + revision: "1-root", + }), + }), + ]); + await expect( + discardUnreadableLiveRevision(core as never, "note.md", "1-root") + ).resolves.toBe("discarded"); + expect(deleteRevisionFromDB).toHaveBeenCalledWith("note.md", "1-root"); + }); + + it("refuses to discard a revision which stopped being a live leaf", async () => { + const { core, current, deleteRevisionFromDB } = createCore(); + core.localDatabase.localDatabase.get.mockResolvedValue({ + ...current, + _conflicts: [], + }); + + await expect( + discardUnreadableLiveRevision(core as never, "note.md", "2-conflict") + ).resolves.toBe("no-longer-live"); + + expect(deleteRevisionFromDB).not.toHaveBeenCalled(); + }); + + it("discards an exact readable winner while another live branch remains", async () => { + const { core, deleteRevisionFromDB } = createCore(); + + await expect( + discardLiveBranch(core as never, "note.md", "3-current") + ).resolves.toBe("discarded"); + + expect(deleteRevisionFromDB).toHaveBeenCalledWith("note.md", "3-current"); + }); + + it("refuses to discard the only live branch", async () => { + const { core, current, deleteRevisionFromDB } = createCore(); + current._conflicts = []; + + await expect( + discardLiveBranch(core as never, "note.md", "3-current") + ).resolves.toBe("only-live-revision"); + + expect(deleteRevisionFromDB).not.toHaveBeenCalled(); + }); + + it("refuses to discard a branch which is no longer live", async () => { + const { core, deleteRevisionFromDB } = createCore(); + + await expect( + discardLiveBranch(core as never, "note.md", "1-stale") + ).resolves.toBe("no-longer-live"); + + expect(deleteRevisionFromDB).not.toHaveBeenCalled(); + }); +}); diff --git a/src/serviceFeatures/fileRepairPresentation.ts b/src/serviceFeatures/fileRepairPresentation.ts new file mode 100644 index 00000000..4906f4cb --- /dev/null +++ b/src/serviceFeatures/fileRepairPresentation.ts @@ -0,0 +1,144 @@ +import { + BASE_IS_NEW, + EVEN, + TARGET_IS_NEW, +} from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const.symbols"; +import { + compareMTime, + readAsBlob, +} from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import { isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; +import type { + FileRepairInspection, + FileRepairRevision, +} from "./fileRepair"; + +export type FileRepairRevisionActions = { + compareWithVault: boolean; + applyRevisionToVault: boolean; + markAsVaultRevision: boolean; + storeVaultOnBranch: boolean; + applyLogicalDeletionToVault: boolean; + retryRevision: boolean; + discardBranch: boolean; + discardRevision: boolean; +}; + +export type FileRepairTimestampRelation = + | "vault-newer" + | "database-newer" + | "same-window" + | "unavailable"; + +export type FileRepairRevisionComparison = { + recordedSize: number; + decodedSize: number | null; + recordedToDecodedSizeDifference: number | null; + vaultSize: number | null; + databaseToVaultSizeDifference: number | null; + databaseMtime: number; + vaultMtime: number | null; + timestampDifferenceMs: number | null; + timestampRelation: FileRepairTimestampRelation; +}; + +export function getFileRepairRevisionActions( + inspection: FileRepairInspection, + revision: FileRepairRevision +): FileRepairRevisionActions { + const storageExists = inspection.information.storage.exists; + const hasRevision = revision.metadata.revision !== null; + const readableFileRevision = + !revision.metadata.deleted && + revision.contentReadable && + revision.loadedEntry !== false; + const matchesVault = storageExists && revision.contentMatchesStorage === true; + const hasConflictBranches = inspection.information.database.conflictCount > 0; + + return { + compareWithVault: + readableFileRevision && + storageExists && + revision.contentMatchesStorage === false && + isPlainText(inspection.information.path), + applyRevisionToVault: + hasRevision && + readableFileRevision && + (!storageExists || revision.contentMatchesStorage !== true), + markAsVaultRevision: + hasRevision && + readableFileRevision && + matchesVault, + storeVaultOnBranch: + hasRevision && + storageExists && + revision.contentMatchesStorage !== true, + applyLogicalDeletionToVault: + hasRevision && + revision.metadata.deleted && + storageExists, + retryRevision: + hasRevision && + !revision.metadata.deleted && + !revision.contentReadable, + discardBranch: hasRevision && hasConflictBranches, + discardRevision: + hasRevision && + !hasConflictBranches && + !revision.metadata.deleted && + !revision.contentReadable, + }; +} + +export function getFileRepairRevisionComparison( + inspection: FileRepairInspection, + revision: FileRepairRevision +): FileRepairRevisionComparison { + const decodedSize = + revision.loadedEntry === false + ? null + : readAsBlob(revision.loadedEntry).size; + const vaultSize = + inspection.information.storage.exists + ? (inspection.information.storage.size ?? null) + : null; + const databaseMtime = revision.metadata.mtime; + const vaultMtime = + inspection.information.storage.exists + ? (inspection.information.storage.mtime ?? null) + : null; + const timestampDifferenceMs = + databaseMtime > 0 && vaultMtime !== null && vaultMtime > 0 + ? vaultMtime - databaseMtime + : null; + let timestampRelation: FileRepairTimestampRelation = "unavailable"; + if (timestampDifferenceMs !== null) { + const comparison = compareMTime(vaultMtime!, databaseMtime); + timestampRelation = + comparison === EVEN + ? "same-window" + : comparison === BASE_IS_NEW + ? "vault-newer" + : comparison === TARGET_IS_NEW + ? "database-newer" + : "unavailable"; + } + + return { + recordedSize: revision.metadata.recordedSize, + decodedSize, + recordedToDecodedSizeDifference: + decodedSize === null + ? null + : decodedSize - revision.metadata.recordedSize, + vaultSize, + databaseToVaultSizeDifference: + decodedSize === null || vaultSize === null + ? null + : vaultSize - decodedSize, + databaseMtime, + vaultMtime, + timestampDifferenceMs, + timestampRelation, + }; +} diff --git a/src/serviceFeatures/fileRepairPresentation.unit.spec.ts b/src/serviceFeatures/fileRepairPresentation.unit.spec.ts new file mode 100644 index 00000000..1f5f968c --- /dev/null +++ b/src/serviceFeatures/fileRepairPresentation.unit.spec.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from "vitest"; +import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { FileRepairInspection, FileRepairRevision } from "./fileRepair"; +import { + getFileRepairRevisionActions, + getFileRepairRevisionComparison, +} from "./fileRepairPresentation"; + +function createInspection( + revision: Partial = {}, + storage: { exists: boolean; size?: number; mtime?: number } = { + exists: true, + size: 12, + mtime: 5_500, + } +): { inspection: FileRepairInspection; revision: FileRepairRevision } { + const completeRevision = { + role: "conflict", + metadata: { + documentId: "f:note", + revision: "2-conflict", + current: false, + deleted: false, + storageType: "plain", + storageLayout: "chunked", + ctime: 1, + mtime: 2_000, + recordedSize: 9, + revisionHistory: [], + chunkReferences: 0, + uniqueChunkReferences: 0, + embeddedChunkReferences: 0, + locallyStoredChunkReferences: 0, + contentAvailableLocally: true, + chunks: [], + }, + contentReadable: true, + contentMatchesStorage: false, + loadedEntry: { + _id: "f:note", + _rev: "2-conflict", + path: "note.md", + ctime: 1, + mtime: 2_000, + size: 9, + type: "plain", + datatype: "plain", + children: [], + eden: {}, + data: "content", + }, + ...revision, + } as FileRepairRevision; + const inspection = { + information: { + path: "note.md", + databasePath: "note.md" as FilePathWithPrefix, + storage, + database: { + source: "local database on this device", + remoteQueried: false, + exists: true, + currentRevision: "3-winner", + conflictCount: 1, + conflictRevisions: ["2-conflict"], + unavailableConflictRevisions: [], + revisions: [], + mergeBases: [], + }, + }, + revisions: [completeRevision], + requiresAttention: true, + } satisfies FileRepairInspection; + return { inspection, revision: completeRevision }; +} + +describe("file repair presentation", () => { + it("offers both reconciliation directions for a readable differing revision", () => { + const { inspection, revision } = createInspection(); + + expect(getFileRepairRevisionActions(inspection, revision)).toEqual({ + compareWithVault: true, + applyRevisionToVault: true, + markAsVaultRevision: false, + storeVaultOnBranch: true, + applyLogicalDeletionToVault: false, + retryRevision: false, + discardRevision: false, + discardBranch: true, + }); + }); + + it("marks an exact matching revision without creating another child", () => { + const { inspection, revision } = createInspection({ + contentMatchesStorage: true, + }); + + expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({ + compareWithVault: false, + applyRevisionToVault: false, + markAsVaultRevision: true, + storeVaultOnBranch: false, + discardBranch: true, + }); + }); + + it("does not offer a text comparison for a binary file", () => { + const { inspection, revision } = createInspection(); + inspection.information.path = "image.png"; + + expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({ + compareWithVault: false, + applyRevisionToVault: true, + storeVaultOnBranch: true, + }); + }); + + it("offers explicit deletion or branch extension for a logical deletion", () => { + const { inspection, revision } = createInspection({ + metadata: { + ...createInspection().revision.metadata, + deleted: true, + }, + contentReadable: true, + contentMatchesStorage: null, + loadedEntry: false, + }); + + expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({ + applyRevisionToVault: false, + storeVaultOnBranch: true, + applyLogicalDeletionToVault: true, + retryRevision: false, + discardRevision: false, + discardBranch: true, + }); + }); + + it("offers retry, discard, and branch extension for an unreadable live revision", () => { + const { inspection, revision } = createInspection({ + contentReadable: false, + contentMatchesStorage: null, + loadedEntry: false, + }); + + expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({ + compareWithVault: false, + applyRevisionToVault: false, + markAsVaultRevision: false, + storeVaultOnBranch: true, + retryRevision: true, + discardRevision: false, + discardBranch: true, + }); + }); + + it("keeps the existing unreadable-leaf escape hatch when there is no conflict branch", () => { + const { inspection, revision } = createInspection({ + role: "winner", + contentReadable: false, + contentMatchesStorage: null, + loadedEntry: false, + }); + inspection.information.database.conflictCount = 0; + inspection.information.database.conflictRevisions = []; + inspection.information.database.currentRevision = revision.metadata.revision; + + expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({ + discardRevision: true, + discardBranch: false, + }); + }); + + it("does not offer a storage action for a matching absent logical deletion", () => { + const { inspection, revision } = createInspection( + { + metadata: { + ...createInspection().revision.metadata, + deleted: true, + }, + contentReadable: true, + contentMatchesStorage: null, + loadedEntry: false, + }, + { exists: false } + ); + + expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({ + applyLogicalDeletionToVault: false, + storeVaultOnBranch: false, + }); + }); + + it("reports recorded, decoded, Vault-size, and timestamp differences", () => { + const { inspection, revision } = createInspection(); + + expect(getFileRepairRevisionComparison(inspection, revision)).toEqual({ + recordedSize: 9, + decodedSize: 7, + recordedToDecodedSizeDifference: -2, + vaultSize: 12, + databaseToVaultSizeDifference: 5, + databaseMtime: 2_000, + vaultMtime: 5_500, + timestampDifferenceMs: 3_500, + timestampRelation: "vault-newer", + }); + }); + + it("uses the same two-second timestamp comparison window as synchronisation", () => { + const { inspection, revision } = createInspection( + { + metadata: { + ...createInspection().revision.metadata, + mtime: 3_001, + }, + }, + { + exists: true, + size: 12, + mtime: 3_999, + } + ); + + expect(getFileRepairRevisionComparison(inspection, revision)).toMatchObject({ + timestampDifferenceMs: 998, + timestampRelation: "same-window", + }); + }); +}); diff --git a/src/serviceFeatures/onLayoutReady/enablei18n.ts b/src/serviceFeatures/onLayoutReady/enablei18n.ts index fe8e98a8..5a83eafb 100644 --- a/src/serviceFeatures/onLayoutReady/enablei18n.ts +++ b/src/serviceFeatures/onLayoutReady/enablei18n.ts @@ -1,26 +1,66 @@ -import { getLanguage } from "@/deps"; -import { createServiceFeature } from "@lib/interfaces/ServiceModule"; -import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "@lib/common/rosetta"; -import { $msg, __onMissingTranslation, setLang } from "@lib/common/i18n"; +import { getLanguage, Notice, requireApiVersion } from "@/deps"; +import { createServiceFeature } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; +import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "@/common/rosetta"; +import { $msg, __onMissingTranslation, setLang } from "@/common/translation"; +import { LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger"; -function tryGetLanguage() { - try { - // Note: 1.8.7+ is required. but it is 18, Feb., 2025. we want to fallback on earlier versions, so we catch the error here. - // eslint-disable-next-line obsidianmd/no-unsupported-api - return getLanguage(); - } catch (e) { - console.error("Failed to get Obsidian language, defaulting to 'def'", e); - return "en"; +function tryGetLanguage(onError: (error: unknown) => void) { + if (requireApiVersion("1.8.7")) { + try { + return getLanguage(); + } catch (e) { + onError(e); + } + } + return "en"; +} + +class ObsidianLanguageAppliedNotice { + private reminder: Notice | undefined; + + show(openDetails: () => void): void { + this.clear(); + let reminderAnchor: HTMLAnchorElement | undefined; + const appliedMessage = + $msg("dialog.yourLanguageAvailable") + .split(/\r?\n\s*\r?\n/u, 1)[0] + ?.trim() ?? $msg("Display Language"); + const fragment = createFragment((documentFragment) => { + documentFragment.createSpan({ + text: `${appliedMessage} `, + }); + documentFragment.createEl("a", { text: $msg("Open the dialog") }, (anchor) => { + reminderAnchor = anchor; + anchor.addEventListener("click", (event) => { + event.preventDefault(); + this.clear(); + openDetails(); + }); + }); + }); + this.reminder = new Notice(fragment, 0); + reminderAnchor?.closest(".notice")?.classList.add("livesync-language-applied-notice"); + } + + clear(): void { + this.reminder?.hide(); + this.reminder = undefined; } } -export const enableI18nFeature = createServiceFeature(async ({ services: { setting, API } }) => { +export const enableI18nFeature = createServiceFeature(async ({ services: { setting, API, appLifecycle } }) => { // Clear missing translation handler to avoid unnecessary warnings. __onMissingTranslation(() => {}); let isChanged = false; const settings = setting.currentSettings(); if (settings.displayLanguage == "") { - const obsidianLanguage = tryGetLanguage(); + const obsidianLanguage = tryGetLanguage((error) => { + API.addLog( + `Failed to get Obsidian language; defaulting to 'en': ${String(error)}`, + LOG_LEVEL_VERBOSE, + "i18n-language" + ); + }); if ( SUPPORTED_I18N_LANGS.indexOf(obsidianLanguage) !== -1 && // Check if the language is supported obsidianLanguage != settings.displayLanguage // Check if the language is different from the current setting @@ -29,26 +69,48 @@ export const enableI18nFeature = createServiceFeature(async ({ services: { setti // settings.displayLanguage = obsidianLanguage as I18N_LANGS; await setting.applyPartial({ displayLanguage: obsidianLanguage as I18N_LANGS }); isChanged = true; - setLang(settings.displayLanguage); + setLang(obsidianLanguage as I18N_LANGS); } else if (settings.displayLanguage == "") { // settings.displayLanguage = "def"; await setting.applyPartial({ displayLanguage: "def" }); - setLang(settings.displayLanguage); + setLang("def"); await setting.saveSettingData(); } } if (isChanged) { - const revert = $msg("dialog.yourLanguageAvailable.btnRevertToDefault"); - if ( - (await API.confirm.askSelectStringDialogue($msg(`dialog.yourLanguageAvailable`), ["OK", revert], { - defaultAction: "OK", - title: $msg(`dialog.yourLanguageAvailable.Title`), - })) == revert - ) { - await setting.applyPartial({ displayLanguage: "def" }); - setLang(settings.displayLanguage); - } await setting.saveSettingData(); + const reminder = new ObsidianLanguageAppliedNotice(); + appLifecycle.onUnload.addHandler(() => { + reminder.clear(); + return Promise.resolve(true); + }); + reminder.show(() => { + void (async () => { + try { + const revert = $msg("dialog.yourLanguageAvailable.btnRevertToDefault"); + if ( + (await API.confirm.askSelectStringDialogue( + $msg(`dialog.yourLanguageAvailable`), + ["OK", revert], + { + defaultAction: "OK", + title: $msg("Display Language"), + } + )) == revert + ) { + await setting.applyPartial({ displayLanguage: "def" }); + setLang("def"); + await setting.saveSettingData(); + } + } catch (error) { + API.addLog( + `Failed to open translation details: ${String(error)}`, + LOG_LEVEL_VERBOSE, + "i18n-language" + ); + } + })(); + }); } return true; }); diff --git a/src/serviceFeatures/onLayoutReady/enablei18n.unit.spec.ts b/src/serviceFeatures/onLayoutReady/enablei18n.unit.spec.ts new file mode 100644 index 00000000..8f88eb62 --- /dev/null +++ b/src/serviceFeatures/onLayoutReady/enablei18n.unit.spec.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const noticeState = vi.hoisted(() => ({ + instances: [] as Array<{ hide: ReturnType; duration: number }>, + spanTexts: [] as string[], +})); + +vi.mock("@/deps", () => ({ + getLanguage: () => "ja", + requireApiVersion: () => true, + Notice: class { + hide = vi.fn(); + + constructor(_fragment: unknown, duration: number) { + noticeState.instances.push({ hide: this.hide, duration }); + } + }, +})); + +vi.mock("@/common/translation", () => ({ + $msg: (key: string) => + ({ + "dialog.yourLanguageAvailable": "Translation has been applied.\n\nMore details.", + "dialog.yourLanguageAvailable.btnRevertToDefault": "Keep Default", + "dialog.yourLanguageAvailable.Title": "Translation is available!", + "Display Language": "Display language", + "Open the dialog": "Open the dialogue", + })[key] ?? key, + __onMissingTranslation: vi.fn(), + setLang: vi.fn(), +})); + +import { enableI18nFeature } from "./enablei18n.ts"; + +describe("automatic display language", () => { + let clickDetails: ((event: { preventDefault(): void }) => void) | undefined; + + beforeEach(() => { + noticeState.instances.length = 0; + noticeState.spanTexts.length = 0; + clickDetails = undefined; + vi.stubGlobal("createFragment", (build: (fragment: unknown) => void) => { + const anchor = { + addEventListener: (_event: string, listener: (event: { preventDefault(): void }) => void) => { + clickDetails = listener; + }, + closest: () => ({ classList: { add: vi.fn() } }), + }; + const fragment = { + createSpan: ({ text }: { text: string }) => noticeState.spanTexts.push(text), + createEl: (_tag: string, _options: unknown, configure: (element: typeof anchor) => void) => { + configure(anchor); + return anchor; + }, + }; + build(fragment); + return fragment; + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("lets start-up continue and opens translation details only from a persistent Notice", async () => { + const settings = { displayLanguage: "" }; + const applyPartial = vi.fn(async (partial: Partial) => Object.assign(settings, partial)); + const saveSettingData = vi.fn().mockResolvedValue(undefined); + const askSelectStringDialogue = vi.fn().mockResolvedValue("Keep Default"); + const unloadHandlers: Array<() => Promise> = []; + const host = { + services: { + setting: { + currentSettings: () => settings, + applyPartial, + saveSettingData, + }, + API: { + addLog: vi.fn(), + confirm: { askSelectStringDialogue }, + }, + appLifecycle: { + onUnload: { + addHandler: (handler: () => Promise) => unloadHandlers.push(handler), + }, + }, + }, + }; + + await expect(enableI18nFeature(host as never)).resolves.toBe(true); + + expect(settings.displayLanguage).toBe("ja"); + expect(saveSettingData).toHaveBeenCalledOnce(); + expect(askSelectStringDialogue).not.toHaveBeenCalled(); + expect(noticeState.instances).toHaveLength(1); + expect(noticeState.instances[0]?.duration).toBe(0); + expect(noticeState.spanTexts).toEqual(["Translation has been applied. "]); + expect(clickDetails).toBeTypeOf("function"); + + clickDetails?.({ preventDefault: vi.fn() }); + await vi.waitFor(() => expect(askSelectStringDialogue).toHaveBeenCalledOnce()); + expect(askSelectStringDialogue.mock.calls[0]?.[2]).toMatchObject({ title: "Display language" }); + await vi.waitFor(() => expect(settings.displayLanguage).toBe("def")); + expect(saveSettingData).toHaveBeenCalledTimes(2); + + await expect(unloadHandlers[0]?.()).resolves.toBe(true); + expect(noticeState.instances[0]?.hide).toHaveBeenCalled(); + }); +}); diff --git a/src/serviceFeatures/redFlag.simpleFetch.ts b/src/serviceFeatures/redFlag.simpleFetch.ts index 981556d6..1578ab1c 100644 --- a/src/serviceFeatures/redFlag.simpleFetch.ts +++ b/src/serviceFeatures/redFlag.simpleFetch.ts @@ -1,7 +1,7 @@ import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger"; -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; -import { type LogFunction } from "@lib/services/lib/logUtils"; -import { UnresolvedErrorManager } from "@lib/services/base/UnresolvedErrorManager"; +import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; +import { type LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils"; +import { UnresolvedErrorManager } from "@vrtmrz/livesync-commonlib/compat/services/base/UnresolvedErrorManager"; import { ExtraOnLocal, ExtraOnRemote, @@ -9,12 +9,12 @@ import { normaliseFullScanOptions, synchroniseAllFilesBetweenDBandStorage, type FullScanOptions, -} from "@lib/serviceFeatures/offlineScanner"; -import { adjustSettingToRemoteIfNeeded, processVaultInitialisation } from "./redFlag"; +} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner"; +import { adjustSettingToRemoteIfNeeded, cancelScheduledInitialisation, processVaultInitialisation } from "./redFlag"; export const SIMPLE_FETCH_STAGE1_REMOTE_WINS = "Overwrite all with remote files"; export const SIMPLE_FETCH_STAGE1_NEWER_WINS = "Compare time and take newer"; -export const SIMPLE_FETCH_STAGE1_LEGACY = "Use the detailed flow"; +export const SIMPLE_FETCH_STAGE1_DETAILED = "Use the detailed flow"; export const SIMPLE_FETCH_STAGE1_CANCEL = "Cancel"; export const SIMPLE_FETCH_STAGE2_REMOTE_DELETE_NONE = "Keep local files even if not on remote"; @@ -27,8 +27,8 @@ export const STAGE2_ABORT = "Cancel all and reboot"; const SIMPLE_FETCH_MODE_KEY = "simple-fetch-mode"; function buildSimpleFetchResult(stage1: string, stage2?: string) { - if (stage1 === SIMPLE_FETCH_STAGE1_LEGACY) { - return { mode: "legacy", options: {} }; + if (stage1 === SIMPLE_FETCH_STAGE1_DETAILED) { + return { mode: "detailed", options: {} }; } if (stage1 === SIMPLE_FETCH_STAGE1_REMOTE_WINS && stage2) { if (![SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL, SIMPLE_FETCH_STAGE2_REMOTE_DELETE_NONE].includes(stage2)) { @@ -93,14 +93,14 @@ export async function askSimpleFetchMode( const msg = `We are about to retrieve the remote data. -Firstly, how shall we handle the data retrieved from this remote server? +Firstly, how shall we handle the data retrieved from this remote source? - **${SIMPLE_FETCH_STAGE1_NEWER_WINS}**: Compares the modified time of files and takes the newer one. If you have been using Self-hosted LiveSync and have made changes on multiple devices, this option may be suitable for you as it tries to merge changes based on modified time. - **${SIMPLE_FETCH_STAGE1_REMOTE_WINS}**: Remote data is the source of truth. If you are new to using Self-hosted LiveSync. This option may be easiest to understand and get started with. It will overwrite all your local files with the remote data, so please make sure you have a backup if there is any important data in your vault. -- **${SIMPLE_FETCH_STAGE1_LEGACY}**: Opens the detailed setup wizard. +- **${SIMPLE_FETCH_STAGE1_DETAILED}**: Opens the detailed setup wizard. If you want to have more control over the synchronisation process, or want to review the changes before applying, you can choose this option to use the detailed flow. `; const stage1 = await host.services.UI.confirm.confirmWithMessage( @@ -109,7 +109,7 @@ Firstly, how shall we handle the data retrieved from this remote server? [ SIMPLE_FETCH_STAGE1_NEWER_WINS, SIMPLE_FETCH_STAGE1_REMOTE_WINS, - SIMPLE_FETCH_STAGE1_LEGACY, + SIMPLE_FETCH_STAGE1_DETAILED, SIMPLE_FETCH_STAGE1_CANCEL, ], SIMPLE_FETCH_STAGE1_NEWER_WINS, @@ -118,7 +118,7 @@ Firstly, how shall we handle the data retrieved from this remote server? if (!stage1 || stage1 === SIMPLE_FETCH_STAGE1_CANCEL) return "cancelled"; - if (stage1 === SIMPLE_FETCH_STAGE1_LEGACY) { + if (stage1 === SIMPLE_FETCH_STAGE1_DETAILED) { return buildSimpleFetchResult(stage1)!; } @@ -194,9 +194,7 @@ export async function askAndPerformFastSetupOnScheduledFetchAll( if (result === "cancelled") { log("Fetch cancelled by user.", LOG_LEVEL_NOTICE); clearRememberedSimpleFetchMode(host); - await cleanupFlag(); - host.services.appLifecycle.performRestart(); - return false; + return await cancelScheduledInitialisation(host, cleanupFlag); } if (result === "aborted") { log("Fetch exited by user.", LOG_LEVEL_NOTICE); @@ -204,18 +202,23 @@ export async function askAndPerformFastSetupOnScheduledFetchAll( host.services.appLifecycle.performRestart(); return false; } - if (result.mode === "legacy") { - return undefined; // Let the legacy flow handle it. + if (result.mode === "detailed") { + return undefined; // Let the detailed setup flow handle it. } - return await processVaultInitialisation(host, log, async () => { - const settings = host.services.setting.currentSettings(); - await adjustSettingToRemoteIfNeeded(host, log, { preventFetchingConfig: false }, settings); + const settings = host.services.setting.currentSettings(); + if (!(await adjustSettingToRemoteIfNeeded(host, log, { preventFetchingConfig: false }, settings))) { + log("Fetch initialisation cancelled by user.", LOG_LEVEL_NOTICE); + clearRememberedSimpleFetchMode(host); + return await cancelScheduledInitialisation(host, cleanupFlag); + } + + const performFastSetup = async () => { // 1. Perform fast DB fetch (download remote DB content to local DB) await host.serviceModules.rebuilder.$fetchLocalDBFast(false); // 2. Call the extended synchroniseAllFilesBetweenDBandStorage to reflect changes in storage - const errorManager = new UnresolvedErrorManager(host.services.appLifecycle); + const errorManager = new UnresolvedErrorManager(host.services.appLifecycle, host.services.context.events); const syncResult = await synchroniseAllFilesBetweenDBandStorage( host, log, @@ -246,5 +249,6 @@ export async function askAndPerformFastSetupOnScheduledFetchAll( clearRememberedSimpleFetchMode(host); log("Simple fetch and scan operation completed.", LOG_LEVEL_NOTICE); return true; - }); + }; + return await processVaultInitialisation(host, log, performFastSetup, "keep-on-failure"); } diff --git a/src/serviceFeatures/redFlag.ts b/src/serviceFeatures/redFlag.ts index 4fe9d8d8..e651465c 100644 --- a/src/serviceFeatures/redFlag.ts +++ b/src/serviceFeatures/redFlag.ts @@ -1,20 +1,28 @@ import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger"; -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; -import { createInstanceLogFunction, type LogFunction } from "@lib/services/lib/logUtils"; -import { FlagFilesHumanReadable, FlagFilesOriginal } from "@lib/common/models/redflag.const"; +import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; +import { createInstanceLogFunction, type LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils"; +import { + FlagFilesHumanReadable, + FlagFilesOriginal, +} from "@vrtmrz/livesync-commonlib/compat/common/models/redflag.const"; import FetchEverything from "@/modules/features/SetupWizard/dialogs/FetchEverything.svelte"; import RebuildEverything from "@/modules/features/SetupWizard/dialogs/RebuildEverything.svelte"; import { extractObject } from "octagonal-wheels/object"; -import { REMOTE_MINIO, 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 { REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const"; +import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/settings"; +import { + RemotePreferredTweakStatuses, + TweakValuesShouldMatchedTemplate, +} from "@vrtmrz/livesync-commonlib/compat/common/models/tweak.definition"; import type { FetchEverythingResult, RebuildEverythingResult, } from "@/modules/features/SetupWizard/dialogs/setupDialogTypes"; import { askAndPerformFastSetupOnScheduledFetchAll } from "./redFlag.simpleFetch"; -import { ConnectionStringParser } from "@lib/common/ConnectionString"; -import { activateRemoteConfiguration } from "@lib/serviceFeatures/remoteConfig"; +import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString"; +import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations"; +import { isP2PMainRemote } from "@/common/remoteConfiguration"; +import { $msg } from "@/common/translation"; /** * Flag file handler interface, similar to target filter pattern. @@ -25,6 +33,8 @@ interface FlagFileHandler { handle: () => Promise; } +type InitialisationOperation = "fetch" | "rebuild"; + export async function isFlagFileExist(host: NecessaryServices, path: string) { const redFlagExist = await host.serviceModules.storageAccess.isExists( host.serviceModules.storageAccess.normalisePath(path) @@ -140,7 +150,7 @@ export function createFetchAllFlagHandler( // Select the remote database if there are multiple remotes configured. const isRemoteActivated = await askAndActivateRemoteDatabase(host, log); if (!isRemoteActivated) { - return false; + return await cancelScheduledInitialisation(host, cleanupFlag); } // Ask user for use Fast Setup @@ -154,9 +164,7 @@ export function createFetchAllFlagHandler( await host.services.UI.dialogManager.openWithExplicitCancel(FetchEverything); if (method === "cancelled") { log("Fetch everything cancelled by user.", LOG_LEVEL_NOTICE); - await cleanupFlag(); - host.services.appLifecycle.performRestart(); - return false; + return await cancelScheduledInitialisation(host, cleanupFlag); } const { vault, extra } = method; const settings = await Promise.resolve(host.services.setting.currentSettings()); @@ -181,9 +189,11 @@ export function createFetchAllFlagHandler( }, } as const; + if (!(await adjustSettingToRemoteIfNeeded(host, log, extra, settings))) { + log("Fetch initialisation cancelled by user.", LOG_LEVEL_NOTICE); + return await cancelScheduledInitialisation(host, cleanupFlag); + } return await processVaultInitialisation(host, log, async () => { - const settings = host.services.setting.currentSettings(); - await adjustSettingToRemoteIfNeeded(host, log, extra, settings); const vaultStateToAction = mapVaultStateToAction[vault]; const { makeLocalChunkBeforeSync, makeLocalFilesBeforeSync } = vaultStateToAction; log( @@ -213,115 +223,184 @@ export function createFetchAllFlagHandler( /** * Adjust setting to remote configuration. * @param config current configuration to retrieve remote preferred config - * @returns updated configuration if applied, otherwise null. + * @param operation operation which will consume the selected configuration + * @returns whether initialisation may continue. */ export async function adjustSettingToRemote( host: NecessaryServices<"tweakValue" | "UI" | "setting", never>, log: LogFunction, - config: ObsidianLiveSyncSettings -) { - // Fetch remote configuration unless prevented. - const SKIP_FETCH = "Skip and proceed"; - const RETRY_FETCH = "Retry (recommended)"; - let canProceed = false; - do { - const remoteTweaks = await host.services.tweakValue.fetchRemotePreferred(config); - if (!remoteTweaks) { + config: ObsidianLiveSyncSettings, + operation: InitialisationOperation = "fetch" +): Promise { + while (true) { + const remoteResult = await host.services.tweakValue.fetchRemotePreferred(config); + if (remoteResult.status === RemotePreferredTweakStatuses.NOT_CONFIGURED) { + const useDeviceSettings = $msg("Use this device's settings"); + const cancelInitialisation = $msg("Cancel"); + log(`Remote synchronisation settings are not configured (${remoteResult.reason}).`, LOG_LEVEL_INFO); const choice = await host.services.UI.confirm.askSelectStringDialogue( - "Could not fetch configuration from remote. If you are new to the Self-hosted LiveSync, this might be expected. If not, you should check your network or server settings.", - [SKIP_FETCH, RETRY_FETCH] as const, + $msg( + "The selected remote has no saved synchronisation settings. This is normal for a new remote. Use this device's settings, or cancel if you expected existing settings." + ), + [useDeviceSettings, cancelInitialisation] as const, { - defaultAction: RETRY_FETCH, + defaultAction: useDeviceSettings, timeout: 0, - title: "Fetch Remote Configuration Failed", + title: $msg("No Synchronisation Settings Found"), } ); - if (choice === SKIP_FETCH) { - canProceed = true; - } - } else { - const necessary = extractObject(TweakValuesShouldMatchedTemplate, remoteTweaks); - // Check if any necessary tweak value is different from current config. - const differentItems = Object.entries(necessary).filter(([key, value]) => { - return config[key as keyof ObsidianLiveSyncSettings] !== value; - }); - if (differentItems.length === 0) { - log("Remote configuration matches local configuration. No changes applied.", LOG_LEVEL_NOTICE); - } else { - await host.services.UI.confirm.askSelectStringDialogue( - "Your settings differed slightly from the server's. The plug-in has supplemented the incompatible parts with the server settings!", - ["OK"] as const, + return choice === useDeviceSettings; + } + if (remoteResult.status === RemotePreferredTweakStatuses.UNAVAILABLE) { + const retryRemoteSettings = $msg("Retry"); + const useDeviceSettings = $msg("Use this device's settings"); + const cancelInitialisation = $msg("Cancel"); + log("Could not read synchronisation settings from the remote.", LOG_LEVEL_NOTICE); + log(remoteResult.error, LOG_LEVEL_VERBOSE); + if (operation === "rebuild") { + const choice = await host.services.UI.confirm.askSelectStringDialogue( + $msg( + "Could not read the remote's synchronisation settings. Retry, or continue the overwrite with this device's settings. A working connection is still required." + ), + [retryRemoteSettings, useDeviceSettings, cancelInitialisation] as const, { - defaultAction: "OK", + defaultAction: retryRemoteSettings, timeout: 0, + title: $msg("Could Not Read Synchronisation Settings"), } ); + if (choice === retryRemoteSettings) continue; + return choice === useDeviceSettings; } - - config = { - ...config, - ...(Object.fromEntries(differentItems) as Partial), - } satisfies ObsidianLiveSyncSettings; - await host.services.setting.applyExternalSettings(config, true); - log("Remote configuration applied.", LOG_LEVEL_NOTICE); - canProceed = true; - const updatedConfig = host.services.setting.currentSettings(); - return updatedConfig; + const choice = await host.services.UI.confirm.askSelectStringDialogue( + $msg( + "Could not read the remote's synchronisation settings. Check the connection and credentials, then retry." + ), + [retryRemoteSettings, cancelInitialisation] as const, + { + defaultAction: retryRemoteSettings, + timeout: 0, + title: $msg("Could Not Read Synchronisation Settings"), + } + ); + if (choice === retryRemoteSettings) continue; + return false; } - } while (!canProceed); + if (remoteResult.status === RemotePreferredTweakStatuses.UNSUPPORTED) { + log("Remote synchronisation settings are not supported by this remote type.", LOG_LEVEL_INFO); + return true; + } + + const remoteTweaks = remoteResult.values; + const necessary = extractObject(TweakValuesShouldMatchedTemplate, remoteTweaks); + // Check if any necessary tweak value is different from current config. + const differentItems = Object.entries(necessary).filter(([key, value]) => { + return config[key as keyof ObsidianLiveSyncSettings] !== value; + }); + if (differentItems.length === 0) { + log("Remote configuration matches local configuration. No changes applied.", LOG_LEVEL_NOTICE); + } else { + await host.services.UI.confirm.askSelectStringDialogue( + "Your settings differed slightly from the server's. The plug-in has supplemented the incompatible parts with the server settings!", + ["OK"] as const, + { + defaultAction: "OK", + timeout: 0, + } + ); + } + + config = { + ...config, + ...(Object.fromEntries(differentItems) as Partial), + } satisfies ObsidianLiveSyncSettings; + await host.services.setting.applyExternalSettings(config, true); + log("Remote configuration applied.", LOG_LEVEL_NOTICE); + return true; + } } /** * Adjust setting to remote if needed. * @param extra result of dialogues that may contain preventFetchingConfig flag (e.g, from FetchEverything or RebuildEverything) * @param config current configuration to retrieve remote preferred config + * @param operation operation which will consume the selected configuration */ export async function adjustSettingToRemoteIfNeeded( host: NecessaryServices<"tweakValue" | "UI" | "setting", never>, log: LogFunction, - extra: { preventFetchingConfig: boolean }, - config: ObsidianLiveSyncSettings -) { - if (extra && extra.preventFetchingConfig) { - return; + extra: { preventFetchingConfig: boolean } | null, + config: ObsidianLiveSyncSettings, + operation: InitialisationOperation = "fetch" +): Promise { + if (extra?.preventFetchingConfig) { + return true; } // P2P has no centralised remote configuration; skip to avoid a spurious // "Failed to connect to the remote server" error dialog. if (config.remoteType === REMOTE_P2P) { log("Remote configuration fetch skipped (P2P mode).", LOG_LEVEL_INFO); - return; + return true; } - // Remote configuration fetched and applied. - if (await adjustSettingToRemote(host, log, config)) { - config = host.services.setting.currentSettings(); - } else { + const canProceed = await adjustSettingToRemote(host, log, config, operation); + if (!canProceed) { log("Remote configuration not applied.", LOG_LEVEL_NOTICE); } - // log(JSON.stringify(config), LOG_LEVEL_VERBOSE); + return canProceed; } /** - * Process vault initialisation with suspending file watching and sync. - * @param proc process to be executed during initialisation, should return true if can be continued, false if app is unable to continue the process. - * @param keepSuspending whether to keep suspending file watching after the process. - * @returns result of the process, or false if error occurs. + * Cancel a scheduled Fetch or Rebuild without changing the selected automatic + * synchronisation mode. The persisted Scram switches keep both reflection + * directions paused until the existing start-up dialogue resumes them. + */ +export async function cancelScheduledInitialisation( + host: NecessaryServices<"setting" | "appLifecycle", never>, + cleanupFlag: () => Promise +): Promise { + await host.services.setting.applyPartial( + { + suspendFileWatching: true, + suspendParseReplicationResult: true, + }, + true + ); + await cleanupFlag(); + host.services.appLifecycle.performRestart(); + return false; +} + +type InitialisationSuspensionPolicy = "resume" | "keep" | "keep-on-failure"; + +/** + * Process Vault initialisation with file watching and synchronisation suspended. + * @param proc Process to execute during initialisation. It returns true only when normal operation may resume. + * @param suspensionPolicy Final file-reflection state. `keep-on-failure` controls both reflection directions so a partly completed Fast Setup remains isolated. + * @returns The result of the process, or false if an error occurs. */ export async function processVaultInitialisation( host: NecessaryServices<"setting", never>, log: LogFunction, proc: () => Promise, - keepSuspending = false + suspensionPolicy: InitialisationSuspensionPolicy = "resume" ) { + let completed = false; try { // Disable batch saving and file watching during initialisation. await host.services.setting.applyPartial({ batchSave: false }, false); await host.services.setting.suspendAllSync(); await host.services.setting.suspendExtraSync(); - await host.services.setting.applyPartial({ suspendFileWatching: true }, true); + await host.services.setting.applyPartial( + suspensionPolicy === "keep-on-failure" + ? { suspendFileWatching: true, suspendParseReplicationResult: true } + : { suspendFileWatching: true }, + true + ); try { const result = await proc(); + completed = result; return result; } catch (ex) { log("Error during vault initialisation process.", LOG_LEVEL_NOTICE); @@ -333,9 +412,21 @@ export async function processVaultInitialisation( log(ex, LOG_LEVEL_VERBOSE); return false; } finally { - if (!keepSuspending) { - // Re-enable file watching after initialisation. + if (suspensionPolicy === "resume") { await host.services.setting.applyPartial({ suspendFileWatching: false }, true); + } else if (suspensionPolicy === "keep") { + await host.services.setting.applyPartial({ suspendFileWatching: true }, true); + } else { + // Fast Setup owns both directions at this boundary. Reasserting the + // outcome also covers a late failure after finishRebuild started to + // resume reflection, and the legacy doNotSuspendOnFetching path. + await host.services.setting.applyPartial( + { + suspendFileWatching: !completed, + suspendParseReplicationResult: !completed, + }, + true + ); } } } @@ -382,17 +473,20 @@ export function createRebuildFlagHandler( // Handle the rebuild everything scheduled operation const onScheduled = async () => { - const method = - await host.services.UI.dialogManager.openWithExplicitCancel(RebuildEverything); + const settings = host.services.setting.currentSettings(); + const method = await host.services.UI.dialogManager.openWithExplicitCancel< + RebuildEverythingResult, + { isP2P: boolean } + >(RebuildEverything, { isP2P: isP2PMainRemote(settings) }); if (method === "cancelled") { log("Rebuild everything cancelled by user.", LOG_LEVEL_NOTICE); - await cleanupFlag(); - host.services.appLifecycle.performRestart(); - return false; + return await cancelScheduledInitialisation(host, cleanupFlag); } const { extra } = method; - const settings = host.services.setting.currentSettings(); - await adjustSettingToRemoteIfNeeded(host, log, extra, settings); + if (!(await adjustSettingToRemoteIfNeeded(host, log, extra, settings, "rebuild"))) { + log("Rebuild initialisation cancelled by user.", LOG_LEVEL_NOTICE); + return await cancelScheduledInitialisation(host, cleanupFlag); + } return await processVaultInitialisation(host, log, async () => { await host.serviceModules.rebuilder.$rebuildEverything(); await cleanupFlag(); @@ -439,7 +533,7 @@ export function createSuspendFlagHandler( await host.services.setting.applyPartial({ writeLogToTheFile: true }, true); return Promise.resolve(false); }, - true + "keep" ); }; diff --git a/src/serviceFeatures/redFlag.unit.spec.ts b/src/serviceFeatures/redFlag.unit.spec.ts index f94103e0..5a05a451 100644 --- a/src/serviceFeatures/redFlag.unit.spec.ts +++ b/src/serviceFeatures/redFlag.unit.spec.ts @@ -1,7 +1,11 @@ import { describe, it, expect, vi } from "vitest"; -import type { LogFunction } from "@lib/services/lib/logUtils"; -import { FlagFilesHumanReadable, FlagFilesOriginal } from "@lib/common/models/redflag.const"; -import { REMOTE_MINIO } from "@lib/common/models/setting.const"; +import { createServiceContext } from "@vrtmrz/livesync-commonlib/context"; +import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils"; +import { + FlagFilesHumanReadable, + FlagFilesOriginal, +} from "@vrtmrz/livesync-commonlib/compat/common/models/redflag.const"; +import { REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const"; import { createFetchAllFlagHandler, createRebuildFlagHandler, @@ -18,14 +22,14 @@ import { TweakValuesRecommendedTemplate, TweakValuesShouldMatchedTemplate, TweakValuesTemplate, -} from "@lib/common/types"; +} from "@vrtmrz/livesync-commonlib/compat/common/types"; import { ExtraOnLocal, FullScanModes, synchroniseAllFilesBetweenDBandStorage, -} from "@lib/serviceFeatures/offlineScanner"; +} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner"; import { - SIMPLE_FETCH_STAGE1_LEGACY, + SIMPLE_FETCH_STAGE1_DETAILED, SIMPLE_FETCH_STAGE1_NEWER_WINS, SIMPLE_FETCH_STAGE1_REMOTE_WINS, SIMPLE_FETCH_STAGE2_NEWER_CLEANUP, @@ -36,9 +40,9 @@ import { askAndPerformFastSetupOnScheduledFetchAll, askSimpleFetchMode, } from "./redFlag.simpleFetch"; -import { activateRemoteConfiguration } from "@lib/serviceFeatures/remoteConfig"; +import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations"; //Mock synchroniseAllFilesBetweenDBandStorage -vi.mock("@/lib/src/serviceFeatures/offlineScanner", async (importOriginal) => { +vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner", async (importOriginal) => { const originalModule = (await importOriginal()) as any; return { ...originalModule, @@ -46,7 +50,7 @@ vi.mock("@/lib/src/serviceFeatures/offlineScanner", async (importOriginal) => { }; }); -vi.mock("@lib/serviceFeatures/remoteConfig", () => { +vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig", () => { return { activateRemoteConfiguration: vi.fn((settings: any, configurationId: string) => { if (!settings?.remoteConfigurations?.[configurationId]) return false; @@ -64,6 +68,8 @@ const createLoggerMock = (): LogFunction => { return vi.fn(); }; +const availableRemoteTweaks = (values: Record) => ({ status: "available", values }) as const; + const createStorageAccessMock = () => { const files: Set = new Set(); return { @@ -145,7 +151,9 @@ const createRebuilderMock = () => { const createTweakValueMock = () => { return { - fetchRemotePreferred: vi.fn(() => Promise.resolve(null)), + fetchRemotePreferred: vi.fn(() => + Promise.resolve({ status: "unavailable", error: new Error("Remote unavailable") }) + ), }; }; @@ -159,6 +167,7 @@ const createHostMock = () => { return { services: { + context: createServiceContext(), setting: settingMock, appLifecycle: appLifecycleMock, UI: uiMock, @@ -316,13 +325,13 @@ describe("Red Flag Feature", () => { () => { return Promise.resolve(true); }, - false + "resume" ); expect(host.mocks.setting.currentSettings().suspendFileWatching).toBe(false); }); - it("should keep suspending when keepSuspending is true", async () => { + it("should keep suspending when the policy is keep", async () => { const host = createHostMock(); const log = createLoggerMock(); @@ -332,7 +341,7 @@ describe("Red Flag Feature", () => { () => { return Promise.resolve(true); }, - true + "keep" ); expect(host.mocks.setting.currentSettings().suspendFileWatching).toBe(true); @@ -348,7 +357,7 @@ describe("Red Flag Feature", () => { () => { throw new Error("Process failed"); }, - false + "resume" ); expect(result).toBe(false); @@ -454,9 +463,9 @@ describe("Red Flag Feature", () => { .mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS) .mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL); - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ - batchSave: false, - } as any); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce( + availableRemoteTweaks({ batchSave: false }) + ); const handler = createFetchAllFlagHandler(host as any, log); const result = await handler.handle(); @@ -464,24 +473,27 @@ describe("Red Flag Feature", () => { expect(result).toBe(true); expect(host.mocks.rebuilder.$fetchLocalDBFast).toHaveBeenCalled(); expect(synchroniseAllFilesBetweenDBandStorage).toHaveBeenCalled(); + const firstPrompt = host.mocks.ui.confirm.confirmWithMessage.mock.calls[0]?.[1]; + expect(firstPrompt).toContain("data retrieved from this remote source"); + expect(firstPrompt).not.toContain("remote server"); // We can't easily check performFullScan call here because it's imported, // but we can verify rebuilder was called. }); - it("should restore legacy fetch flow when requested", async () => { + it("opens the detailed Fetch flow when requested", async () => { const host = createHostMock(); const log = createLoggerMock(); host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL); - host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_LEGACY); + host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED); host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({ vault: "identical", backup: "backup_skipped", extra: { preventFetchingConfig: false }, }); - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ - batchSave: false, - } as any); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce( + availableRemoteTweaks({ batchSave: false }) + ); const handler = createFetchAllFlagHandler(host as any, log); const result = await handler.handle(); @@ -498,9 +510,9 @@ describe("Red Flag Feature", () => { host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(false); const handler = createFetchAllFlagHandler(host as any, log); - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ - batchSave: false, - } as any); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce( + availableRemoteTweaks({ batchSave: false }) + ); const result = await handler.handle(); expect(result).toBe(false); @@ -517,9 +529,9 @@ describe("Red Flag Feature", () => { .mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS) .mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL); - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ - batchSave: false, - } as any); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce( + availableRemoteTweaks({ batchSave: false }) + ); const handler = createFetchAllFlagHandler(host as any, log); const result = await handler.handle(); @@ -569,6 +581,16 @@ describe("Red Flag Feature", () => { expect(result).toBe(false); expect(host.mocks.ui.confirm.confirmWithMessage).not.toHaveBeenCalled(); + expect(host.mocks.storageAccess.files.has(FlagFilesOriginal.FETCH_ALL)).toBe(false); + await expect(handler.check()).resolves.toBe(false); + expect(host.mocks.setting.applyPartial).toHaveBeenCalledWith( + { + suspendFileWatching: true, + suspendParseReplicationResult: true, + }, + true + ); + expect(host.mocks.appLifecycle.performRestart).toHaveBeenCalledOnce(); }); it("should activate selected remote configuration", async () => { @@ -657,11 +679,11 @@ describe("Red Flag Feature", () => { await expect(askSimpleFetchMode(host as any)).resolves.toBe("cancelled"); }); - it("should return legacy mode when selected", async () => { + it("selects the detailed Fetch flow", async () => { const host = createHostMock(); - host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_LEGACY); + host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED); - await expect(askSimpleFetchMode(host as any)).resolves.toEqual({ mode: "legacy", options: {} }); + await expect(askSimpleFetchMode(host as any)).resolves.toEqual({ mode: "detailed", options: {} }); }); it("should return remote-only with keep-local option", async () => { @@ -747,6 +769,79 @@ describe("Red Flag Feature", () => { }); describe("askAndPerformFastSetupOnScheduledFetchAll", () => { + it("releases both reflection suspensions after Fast Setup succeeds", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + const cleanupFlag = vi.fn().mockResolvedValue(undefined); + + Object.assign(host.mocks.setting.settings, { + doNotSuspendOnFetching: true, + suspendParseReplicationResult: true, + }); + host.mocks.ui.confirm.confirmWithMessage + .mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS) + .mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_CLEANUP); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({ batchSave: false })); + + await expect(askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag)).resolves.toBe(true); + + expect(host.mocks.setting.currentSettings()).toMatchObject({ + suspendFileWatching: false, + suspendParseReplicationResult: false, + }); + }); + + it("keeps Vault reflection suspended and preserves recovery state when Fast Fetch fails", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + const cleanupFlag = vi.fn().mockResolvedValue(undefined); + + host.mocks.ui.confirm.confirmWithMessage + .mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS) + .mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_CLEANUP); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({ batchSave: false })); + host.mocks.rebuilder.$fetchLocalDBFast.mockRejectedValueOnce(new Error("cannot decrypt remote document")); + + await expect(askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag)).resolves.toBe(false); + + expect(host.mocks.setting.currentSettings()).toMatchObject({ + suspendFileWatching: true, + suspendParseReplicationResult: true, + }); + expect(host.mocks.rebuilder.finishRebuild).not.toHaveBeenCalled(); + expect(cleanupFlag).not.toHaveBeenCalled(); + expect(host.mocks.setting.deleteSmallConfig).not.toHaveBeenCalledWith("simple-fetch-mode"); + }); + + it("re-suspends both reflection directions when finalisation fails after releasing them", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + const cleanupFlag = vi.fn().mockResolvedValue(undefined); + + host.mocks.ui.confirm.confirmWithMessage + .mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS) + .mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_CLEANUP); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({ batchSave: false })); + host.mocks.rebuilder.finishRebuild.mockImplementationOnce(async () => { + await host.mocks.setting.applyPartial( + { + suspendFileWatching: false, + suspendParseReplicationResult: false, + }, + true + ); + throw new Error("Vault scan failed after reflection resumed"); + }); + + await expect(askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag)).resolves.toBe(false); + + expect(host.mocks.setting.currentSettings()).toMatchObject({ + suspendFileWatching: true, + suspendParseReplicationResult: true, + }); + expect(cleanupFlag).not.toHaveBeenCalled(); + }); + it("should remember quick flow choices while the scheduled fetch is pending", async () => { const host = createHostMock(); const log = createLoggerMock(); @@ -755,7 +850,7 @@ describe("Red Flag Feature", () => { host.mocks.ui.confirm.confirmWithMessage .mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS) .mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_CLEANUP); - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue({ batchSave: false } as any); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({ batchSave: false })); host.mocks.rebuilder.$fetchLocalDBFast.mockRejectedValueOnce(new Error("offline")); await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag); @@ -773,7 +868,7 @@ describe("Red Flag Feature", () => { host.mocks.ui.confirm.confirmWithMessage .mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS) .mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL); - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue({ batchSave: false } as any); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({ batchSave: false })); await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag); @@ -810,12 +905,12 @@ describe("Red Flag Feature", () => { expect(host.mocks.appLifecycle.performRestart).toHaveBeenCalled(); }); - it("should return undefined when legacy mode is selected", async () => { + it("leaves the detailed Fetch flow to its existing handler", async () => { const host = createHostMock(); const log = createLoggerMock(); const cleanupFlag = vi.fn().mockResolvedValue(undefined); - host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_LEGACY); + host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED); const result = await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag); @@ -823,6 +918,65 @@ describe("Red Flag Feature", () => { expect(host.mocks.rebuilder.$fetchLocalDBFast).not.toHaveBeenCalled(); }); + it("should preserve automatic synchronisation choices and enter Scram when quick Fetch is cancelled", async () => { + const host = createHostMock(); + const cleanupFlag = vi.fn().mockResolvedValue(undefined); + Object.assign(host.mocks.setting.settings, { + liveSync: true, + periodicReplication: true, + syncOnSave: true, + syncOnEditorSave: true, + syncOnStart: true, + syncOnFileOpen: true, + syncAfterMerge: true, + suspendParseReplicationResult: false, + }); + host.mocks.ui.confirm.confirmWithMessage + .mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS) + .mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ + status: "not-configured", + reason: "milestone-missing", + }); + host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Cancel"); + + const result = await askAndPerformFastSetupOnScheduledFetchAll( + host as any, + createLoggerMock(), + cleanupFlag + ); + + expect(result).toBe(false); + expect(host.mocks.rebuilder.$fetchLocalDBFast).not.toHaveBeenCalled(); + expect(host.mocks.setting.suspendAllSync).not.toHaveBeenCalled(); + expect(host.mocks.setting.applyPartial).toHaveBeenCalledWith( + { + suspendFileWatching: true, + suspendParseReplicationResult: true, + }, + true + ); + expect(host.mocks.setting.currentSettings()).toMatchObject({ + liveSync: true, + periodicReplication: true, + syncOnSave: true, + syncOnEditorSave: true, + syncOnStart: true, + syncOnFileOpen: true, + syncAfterMerge: true, + suspendFileWatching: true, + suspendParseReplicationResult: true, + }); + expect(cleanupFlag).toHaveBeenCalledOnce(); + expect(host.mocks.appLifecycle.performRestart).toHaveBeenCalledOnce(); + expect(host.mocks.setting.applyPartial.mock.invocationCallOrder[0]).toBeLessThan( + cleanupFlag.mock.invocationCallOrder[0] + ); + expect(cleanupFlag.mock.invocationCallOrder[0]).toBeLessThan( + host.mocks.appLifecycle.performRestart.mock.invocationCallOrder[0] + ); + }); + it("should reboot and return false when sync has failures and user chooses rerun", async () => { const host = createHostMock(); const log = createLoggerMock(); @@ -831,7 +985,9 @@ describe("Red Flag Feature", () => { host.mocks.ui.confirm.confirmWithMessage .mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS) .mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL); - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ batchSave: false } as any); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce( + availableRemoteTweaks({ batchSave: false }) + ); (synchroniseAllFilesBetweenDBandStorage as any).mockResolvedValueOnce(false); host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Reboot to re-run the process"); @@ -851,7 +1007,9 @@ describe("Red Flag Feature", () => { host.mocks.ui.confirm.confirmWithMessage .mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS) .mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL); - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ batchSave: false } as any); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce( + availableRemoteTweaks({ batchSave: false }) + ); (synchroniseAllFilesBetweenDBandStorage as any).mockResolvedValueOnce(false); host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce( "Finalise the process and resume normal operation" @@ -866,6 +1024,20 @@ describe("Red Flag Feature", () => { }); describe("Rebuild All Flag Handler", () => { + it("identifies P2P when opening the scheduled rebuild confirmation", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + host.mocks.setting.settings.remoteType = REMOTE_P2P; + host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL); + host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled"); + + await createRebuildFlagHandler(host as any, log).handle(); + + expect(host.mocks.ui.dialogManager.openWithExplicitCancel).toHaveBeenCalledWith(expect.anything(), { + isP2P: true, + }); + }); + it("should detect rebuild all flag using original filename", async () => { const host = createHostMock(); const log = createLoggerMock(); @@ -995,9 +1167,9 @@ describe("Red Flag Feature", () => { const host = createHostMock(); const config = { batchSave: true } as any; - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ - batchSave: false, - } as any); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce( + availableRemoteTweaks({ batchSave: false }) + ); await adjustSettingToRemoteIfNeeded( host as any, @@ -1025,7 +1197,9 @@ describe("Red Flag Feature", () => { const differentConfig = { [key]: differentValue, }; - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(differentConfig as any); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce( + availableRemoteTweaks(differentConfig) + ); host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("OK"); await adjustSettingToRemote(host as any, createLoggerMock(), config); @@ -1052,7 +1226,9 @@ describe("Red Flag Feature", () => { const differentConfig = { [key]: differentValue, }; - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(differentConfig as any); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce( + availableRemoteTweaks(differentConfig) + ); host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("OK"); await adjustSettingToRemote(host as any, createLoggerMock(), config); @@ -1062,32 +1238,71 @@ describe("Red Flag Feature", () => { } ); - it("should show dialog when remote fetch fails", async () => { + it("should explain that missing remote settings are normal for a new database without offering retry", async () => { const host = createHostMock(); const log = createLoggerMock(); const config = { batchSave: true } as any; - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(null); - host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Skip and proceed"); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ + status: "not-configured", + reason: "milestone-missing", + }); + host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Use this device's settings"); - await adjustSettingToRemote(host as any, log, config); + await expect(adjustSettingToRemote(host as any, log, config)).resolves.toBe(true); - expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalled(); + expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalledWith( + "The selected remote has no saved synchronisation settings. This is normal for a new remote. Use this device's settings, or cancel if you expected existing settings.", + ["Use this device's settings", "Cancel"], + { + defaultAction: "Use this device's settings", + timeout: 0, + title: "No Synchronisation Settings Found", + } + ); + expect(host.mocks.tweakValue.fetchRemotePreferred).toHaveBeenCalledOnce(); + expect(host.mocks.setting.applyExternalSettings).not.toHaveBeenCalled(); }); - it("should retry when user selects retry option", async () => { + it("should retry only when remote settings are unavailable", async () => { const host = createHostMock(); const log = createLoggerMock(); const config = { batchSave: true } as any; + const failure = new Error("network failed"); host.mocks.tweakValue.fetchRemotePreferred - .mockResolvedValueOnce(null) - .mockResolvedValueOnce({ batchSave: false } as any); - host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Retry (recommended)"); + .mockResolvedValueOnce({ status: "unavailable", error: failure }) + .mockResolvedValueOnce({ status: "available", values: { batchSave: false } } as any); + host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Retry"); - await adjustSettingToRemote(host as any, log, config); + await expect(adjustSettingToRemote(host as any, log, config)).resolves.toBe(true); expect(host.mocks.tweakValue.fetchRemotePreferred).toHaveBeenCalledTimes(2); + expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalledWith( + "Could not read the remote's synchronisation settings. Check the connection and credentials, then retry.", + ["Retry", "Cancel"], + { + defaultAction: "Retry", + timeout: 0, + title: "Could Not Read Synchronisation Settings", + } + ); + }); + + it("should cancel initialisation instead of proceeding after an unavailable remote", async () => { + const host = createHostMock(); + const config = { batchSave: true } as any; + + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ + status: "unavailable", + error: new Error("network failed"), + }); + host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Cancel"); + + await expect(adjustSettingToRemote(host as any, createLoggerMock(), config)).resolves.toBe(false); + + expect(host.mocks.tweakValue.fetchRemotePreferred).toHaveBeenCalledOnce(); + expect(host.mocks.setting.applyExternalSettings).not.toHaveBeenCalled(); }); it("should log when no changes needed", async () => { @@ -1095,9 +1310,9 @@ describe("Red Flag Feature", () => { const log = createLoggerMock(); const config = { batchSave: false } as any; - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ - batchSave: false, - } as any); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce( + availableRemoteTweaks({ batchSave: false }) + ); await adjustSettingToRemote(host as any, log, config); @@ -1109,8 +1324,11 @@ describe("Red Flag Feature", () => { const log = createLoggerMock(); const config = { batchSave: true } as any; - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(null); - host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Skip and proceed"); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ + status: "not-configured", + reason: "preferred-values-missing", + }); + host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Use this device's settings"); await adjustSettingToRemoteIfNeeded(host as any, log, null as any, config); @@ -1217,7 +1435,7 @@ describe("Red Flag Feature", () => { () => { return Promise.resolve(false); }, - true + "keep" ); expect(host.mocks.setting.currentSettings().suspendFileWatching).toBe(true); @@ -1438,6 +1656,93 @@ describe("Red Flag Feature", () => { }); describe("flagHandlerToEventHandler integration", () => { + it("should stop a detailed Fetch when remote-setting initialisation is cancelled", async () => { + const host = createHostMock(); + host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL); + host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED); + host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({ + vault: "independent", + extra: { preventFetchingConfig: false }, + }); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ + status: "not-configured", + reason: "milestone-missing", + }); + host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Cancel"); + + const result = await createFetchAllFlagHandler(host as any, createLoggerMock()).handle(); + + expect(result).toBe(false); + expect(host.mocks.rebuilder.$fetchLocal).not.toHaveBeenCalled(); + expect(host.mocks.setting.suspendAllSync).not.toHaveBeenCalled(); + expect(host.mocks.setting.applyPartial).toHaveBeenCalledWith( + { + suspendFileWatching: true, + suspendParseReplicationResult: true, + }, + true + ); + expect(host.mocks.storageAccess.files.has(FlagFilesOriginal.FETCH_ALL)).toBe(false); + expect(host.mocks.appLifecycle.performRestart).toHaveBeenCalledOnce(); + }); + + it("should stop Rebuild before deleting local data when remote-setting initialisation is cancelled", async () => { + const host = createHostMock(); + host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL); + host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({ + extra: { preventFetchingConfig: false }, + }); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ + status: "not-configured", + reason: "milestone-missing", + }); + host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Cancel"); + + const result = await createRebuildFlagHandler(host as any, createLoggerMock()).handle(); + + expect(result).toBe(false); + expect(host.mocks.rebuilder.$rebuildEverything).not.toHaveBeenCalled(); + expect(host.mocks.setting.suspendAllSync).not.toHaveBeenCalled(); + expect(host.mocks.setting.applyPartial).toHaveBeenCalledWith( + { + suspendFileWatching: true, + suspendParseReplicationResult: true, + }, + true + ); + expect(host.mocks.storageAccess.files.has(FlagFilesOriginal.REBUILD_ALL)).toBe(false); + expect(host.mocks.appLifecycle.performRestart).toHaveBeenCalledOnce(); + }); + + it("should let Rebuild use this device's settings when remote settings are unavailable", async () => { + const host = createHostMock(); + host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL); + host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({ + extra: { preventFetchingConfig: false }, + }); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ + status: "unavailable", + error: new Error("network failed"), + }); + host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Use this device's settings"); + + const result = await createRebuildFlagHandler(host as any, createLoggerMock()).handle(); + + expect(result).toBe(true); + expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalledWith( + "Could not read the remote's synchronisation settings. Retry, or continue the overwrite with this device's settings. A working connection is still required.", + ["Retry", "Use this device's settings", "Cancel"], + { + defaultAction: "Retry", + timeout: 0, + title: "Could Not Read Synchronisation Settings", + } + ); + expect(host.mocks.rebuilder.$rebuildEverything).toHaveBeenCalledOnce(); + expect(host.mocks.storageAccess.files.has(FlagFilesOriginal.REBUILD_ALL)).toBe(false); + expect(host.mocks.appLifecycle.performRestart).not.toHaveBeenCalled(); + }); + it("should return true when flag does not exist", async () => { const host = createHostMock(); const log = createLoggerMock(); @@ -1454,8 +1759,8 @@ describe("Red Flag Feature", () => { const log = createLoggerMock(); host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL); - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({}); - host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_LEGACY); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(availableRemoteTweaks({})); + host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED); host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled"); const handler = createFetchAllFlagHandler(host as any, log); @@ -1532,12 +1837,10 @@ describe("Red Flag Feature", () => { it("should handle fetchAll flag with flagHandlerToEventHandler identical", async () => { const host = createHostMock(); const log = createLoggerMock(); - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue({ - customChunkSize: 1, - } as any); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({ customChunkSize: 1 })); host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL); - host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_LEGACY); + host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED); host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({ vault: "identical", extra: {} }); host.mocks.rebuilder.$fetchLocal.mockResolvedValueOnce(); const handler = createFetchAllFlagHandler(host as any, log); @@ -1552,9 +1855,9 @@ describe("Red Flag Feature", () => { it("should handle rebuildAll flag with flagHandlerToEventHandler", async () => { const host = createHostMock(); const log = createLoggerMock(); - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ - customChunkSize: 1, - } as any); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce( + availableRemoteTweaks({ customChunkSize: 1 }) + ); host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL); host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({ extra: {} }); diff --git a/src/serviceFeatures/setupObsidian/qrCode.ts b/src/serviceFeatures/setupObsidian/qrCode.ts new file mode 100644 index 00000000..844719c2 --- /dev/null +++ b/src/serviceFeatures/setupObsidian/qrCode.ts @@ -0,0 +1,79 @@ +import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; +import { + encodeQR, + encodeSettingsToQRCodeData, + OutputFormat, +} from "@vrtmrz/livesync-commonlib/compat/API/processSetting"; +import { EVENT_REQUEST_SHOW_SETUP_QR } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; +import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import type { SetupFeatureHost } from "./types"; + +export async function encodeSetupSettingsAsQR(host: SetupFeatureHost) { + const settingString = encodeSettingsToQRCodeData(host.services.setting.currentSettings()); + const result = encodeQR(settingString, OutputFormat.SVG); + if (result === "") { + return ""; + } + + if (typeof result === "string") { + const msg = host.services.context.translate("Setup.QRCode", { qr_image: result }); + await host.services.UI.confirm.confirmWithMessage("Settings QR Code", msg, ["OK"], "OK"); + return result; + } else { + // Multi-page QR code + let currentIndex = 0; + while (currentIndex < result.total) { + const msg = `The setting is too large for a single QR code. +We are using the aggregator to combine multiple QR codes. +Your settings will not be sent to any server; they will be processed only on your device. +Please scan this QR code with your mobile's camera, and open the page in your browser. +After all parts are collected, the page will navigate you back to Obsidian with the aggregated settings. + +Progress: ${currentIndex + 1} / ${result.total} +${result.parts[currentIndex]}`; + + const buttons = []; + if (currentIndex > 0) buttons.push("Back"); + if (currentIndex < result.total - 1) { + buttons.push("Next"); + buttons.push("Cancel"); + } else { + buttons.push("Done"); + } + + const choice = await host.services.UI.confirm.confirmWithMessage( + "Settings QR Code (Aggregated)", + msg, + buttons, + buttons[buttons.indexOf("Next") !== -1 ? buttons.indexOf("Next") : buttons.indexOf("Done")] + ); + + if (choice === "Next") { + currentIndex++; + } else if (choice === "Back") { + currentIndex--; + } else { + break; + } + } + return result.parts[0]; // Return the first one for compatibility + } +} + +export function useSetupQRCodeFeature(host: NecessaryServices<"API" | "UI" | "setting" | "appLifecycle", never>) { + host.services.appLifecycle.onLoaded.addHandler(() => { + host.services.API.addCommand({ + id: "livesync-setting-qr", + name: "Show settings as a QR code", + checkCallback: (checking) => { + if (!host.services.setting.currentSettings().isConfigured) return false; + if (!checking) fireAndForget(encodeSetupSettingsAsQR(host)); + return true; + }, + }); + host.services.context.events.onEvent(EVENT_REQUEST_SHOW_SETUP_QR, () => + fireAndForget(() => encodeSetupSettingsAsQR(host)) + ); + return Promise.resolve(true); + }); +} diff --git a/src/serviceFeatures/setupObsidian/qrCode.unit.spec.ts b/src/serviceFeatures/setupObsidian/qrCode.unit.spec.ts new file mode 100644 index 00000000..61244f7c --- /dev/null +++ b/src/serviceFeatures/setupObsidian/qrCode.unit.spec.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { EVENT_REQUEST_SHOW_SETUP_QR } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; +import { createServiceContext } from "@vrtmrz/livesync-commonlib/context"; +import { encodeSetupSettingsAsQR, useSetupQRCodeFeature } from "./qrCode"; +import { encodeQR, encodeSettingsToQRCodeData } from "@vrtmrz/livesync-commonlib/compat/API/processSetting"; + +vi.mock("@vrtmrz/livesync-commonlib/compat/API/processSetting", () => { + return { + encodeQR: vi.fn(), + encodeSettingsToQRCodeData: vi.fn(), + OutputFormat: { + SVG: "svg", + }, + }; +}); + +describe("setupObsidian/qrCode", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + it("encodeSetupSettingsAsQR should return empty string when QR generation fails", async () => { + const confirmWithMessage = vi.fn(); + const host = { + services: { + context: createServiceContext(), + setting: { + currentSettings: vi.fn(() => ({ any: "settings" })), + }, + UI: { + confirm: { + confirmWithMessage, + }, + }, + }, + } as any; + + vi.mocked(encodeSettingsToQRCodeData).mockReturnValue("encoded-settings"); + vi.mocked(encodeQR).mockReturnValue(""); + + const result = await encodeSetupSettingsAsQR(host); + + expect(result).toBe(""); + expect(confirmWithMessage).not.toHaveBeenCalled(); + }); + + it("encodeSetupSettingsAsQR should show confirm dialog when QR is generated", async () => { + const confirmWithMessage = vi.fn(() => true); + const translate = vi.fn(() => "qr-message"); + const host = { + services: { + context: createServiceContext({ translate }), + setting: { + currentSettings: vi.fn(() => ({ any: "settings" })), + }, + UI: { + confirm: { + confirmWithMessage, + }, + }, + }, + } as any; + + vi.mocked(encodeSettingsToQRCodeData).mockReturnValue("encoded-settings"); + vi.mocked(encodeQR).mockReturnValue(""); + + const result = await encodeSetupSettingsAsQR(host); + + expect(result).toBe(""); + expect(translate).toHaveBeenCalledWith("Setup.QRCode", { qr_image: "" }); + expect(confirmWithMessage).toHaveBeenCalledWith("Settings QR Code", "qr-message", ["OK"], "OK"); + }); + + it("useSetupQRCodeFeature should register onLoaded handler that wires command and event", async () => { + const addHandler = vi.fn(); + const addCommand = vi.fn(); + const context = createServiceContext(); + const onEventSpy = vi.spyOn(context.events, "onEvent"); + + const host = { + services: { + context, + API: { + addCommand, + }, + appLifecycle: { + onLoaded: { + addHandler, + }, + }, + setting: { + currentSettings: vi.fn(() => ({ any: "settings" })), + }, + UI: { + confirm: { + confirmWithMessage: vi.fn(), + }, + }, + }, + } as any; + + useSetupQRCodeFeature(host); + expect(addHandler).toHaveBeenCalledTimes(1); + + const loadedHandler = addHandler.mock.calls[0][0] as () => Promise; + await loadedHandler(); + + expect(addCommand).toHaveBeenCalledWith( + expect.objectContaining({ + id: "livesync-setting-qr", + name: "Show settings as a QR code", + }) + ); + expect(onEventSpy).toHaveBeenCalledWith(EVENT_REQUEST_SHOW_SETUP_QR, expect.any(Function)); + }); + + it("keeps the QR command out of the palette until setup is complete", async () => { + const addHandler = vi.fn(); + const commands: Array<{ + id: string; + checkCallback?: (checking: boolean) => boolean | void; + }> = []; + const settings = { isConfigured: false }; + const host = { + services: { + context: createServiceContext(), + API: { + addCommand: vi.fn((command) => commands.push(command)), + }, + appLifecycle: { + onLoaded: { + addHandler, + }, + }, + setting: { + currentSettings: vi.fn(() => settings), + }, + UI: { + confirm: { + confirmWithMessage: vi.fn(), + }, + }, + }, + } as any; + + useSetupQRCodeFeature(host); + const loadedHandler = addHandler.mock.calls[0][0] as () => Promise; + await loadedHandler(); + + const command = commands.find((candidate) => candidate.id === "livesync-setting-qr")!; + expect(command.checkCallback?.(true)).toBe(false); + + settings.isConfigured = true; + expect(command.checkCallback?.(true)).toBe(true); + }); +}); diff --git a/src/serviceFeatures/setupObsidian/settingsReset.ts b/src/serviceFeatures/setupObsidian/settingsReset.ts new file mode 100644 index 00000000..10862195 --- /dev/null +++ b/src/serviceFeatures/setupObsidian/settingsReset.ts @@ -0,0 +1,10 @@ +import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings"; + +export function createEditingSettingsAfterFullReset(editingSettings: T): T { + return { ...editingSettings, ...createNewVaultSettings(), isConfigured: false }; +} + +export function createCoreSettingsAfterFullReset(): ObsidianLiveSyncSettings { + return { ...createNewVaultSettings(), isConfigured: false }; +} diff --git a/src/serviceFeatures/setupObsidian/settingsReset.unit.spec.ts b/src/serviceFeatures/setupObsidian/settingsReset.unit.spec.ts new file mode 100644 index 00000000..4d0a5cf6 --- /dev/null +++ b/src/serviceFeatures/setupObsidian/settingsReset.unit.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings"; +import { createCoreSettingsAfterFullReset, createEditingSettingsAfterFullReset } from "./settingsReset.ts"; + +describe("full settings reset", () => { + it("resets the persisted settings to the recommended new-Vault values", () => { + const settings = createCoreSettingsAfterFullReset(); + expect(settings).toEqual({ + ...createNewVaultSettings(), + isConfigured: false, + }); + }); + + it("preserves settings-dialog fields while applying the new-Vault values", () => { + const editing = { + ...DEFAULT_SETTINGS, + configPassphrase: "dialog-only", + } as ObsidianLiveSyncSettings & { configPassphrase: string }; + + expect(createEditingSettingsAfterFullReset(editing)).toEqual({ + ...editing, + ...createNewVaultSettings(), + isConfigured: false, + }); + }); +}); diff --git a/src/serviceFeatures/setupObsidian/setupActivationLifecycle.ts b/src/serviceFeatures/setupObsidian/setupActivationLifecycle.ts new file mode 100644 index 00000000..3b4f2a25 --- /dev/null +++ b/src/serviceFeatures/setupObsidian/setupActivationLifecycle.ts @@ -0,0 +1,35 @@ +export type SetupInitialisationMode = "fetch" | "rebuild"; + +export interface SetupInitialisationScheduler { + scheduleFetch(prepareBeforeRestart?: () => Promise): Promise; + scheduleRebuild(prepareBeforeRestart?: () => Promise): Promise; +} + +/** + * Reserves the next-start initialisation operation before enabling settings. + * The scheduler owns suspension, rollback, and restart ordering. + */ +export function applySettingsWithScheduledInitialisation( + scheduler: SetupInitialisationScheduler, + mode: SetupInitialisationMode, + applySettings: () => Promise +): Promise { + return mode === "fetch" ? scheduler.scheduleFetch(applySettings) : scheduler.scheduleRebuild(applySettings); +} + +/** + * Uses Fetch only for the transition from an unconfigured device to an + * explicitly configured existing device. Ordinary edits apply immediately. + */ +export async function applySettingsAndFetchOnActivation( + scheduler: SetupInitialisationScheduler, + wasConfigured: boolean | undefined, + willBeConfigured: boolean | undefined, + applySettings: () => Promise +): Promise { + if (!wasConfigured && willBeConfigured) { + return await applySettingsWithScheduledInitialisation(scheduler, "fetch", applySettings); + } + await applySettings(); + return true; +} diff --git a/src/serviceFeatures/setupObsidian/setupActivationLifecycle.unit.spec.ts b/src/serviceFeatures/setupObsidian/setupActivationLifecycle.unit.spec.ts new file mode 100644 index 00000000..89b1bc74 --- /dev/null +++ b/src/serviceFeatures/setupObsidian/setupActivationLifecycle.unit.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; +import { + applySettingsAndFetchOnActivation, + applySettingsWithScheduledInitialisation, + type SetupInitialisationScheduler, +} from "./setupActivationLifecycle"; + +function createScheduler() { + const events: string[] = []; + const scheduler: SetupInitialisationScheduler = { + scheduleFetch: vi.fn(async (prepare) => { + events.push("fetch-reserved"); + await prepare?.(); + return true; + }), + scheduleRebuild: vi.fn(async (prepare) => { + events.push("rebuild-reserved"); + await prepare?.(); + return true; + }), + }; + const applySettings = vi.fn(async () => { + events.push("settings-applied"); + }); + return { applySettings, events, scheduler }; +} + +describe("setup activation lifecycle", () => { + it.each([ + ["fetch", "fetch-reserved"], + ["rebuild", "rebuild-reserved"], + ] as const)("reserves %s before applying settings", async (mode, reservedEvent) => { + const { applySettings, events, scheduler } = createScheduler(); + + await expect(applySettingsWithScheduledInitialisation(scheduler, mode, applySettings)).resolves.toBe(true); + + expect(events).toEqual([reservedEvent, "settings-applied"]); + }); + + it("reserves Fetch when existing settings activate an unconfigured device", async () => { + const { applySettings, events, scheduler } = createScheduler(); + + await expect(applySettingsAndFetchOnActivation(scheduler, false, true, applySettings)).resolves.toBe(true); + + expect(events).toEqual(["fetch-reserved", "settings-applied"]); + }); + + it("applies an ordinary configured-device edit without scheduling initialisation", async () => { + const { applySettings, events, scheduler } = createScheduler(); + + await expect(applySettingsAndFetchOnActivation(scheduler, true, true, applySettings)).resolves.toBe(true); + + expect(events).toEqual(["settings-applied"]); + expect(scheduler.scheduleFetch).not.toHaveBeenCalled(); + expect(scheduler.scheduleRebuild).not.toHaveBeenCalled(); + }); + + it("does not apply settings when the scheduler cannot reserve its flag", async () => { + const { applySettings, scheduler } = createScheduler(); + vi.mocked(scheduler.scheduleFetch).mockResolvedValueOnce(false); + + await expect(applySettingsWithScheduledInitialisation(scheduler, "fetch", applySettings)).resolves.toBe(false); + + expect(applySettings).not.toHaveBeenCalled(); + }); +}); diff --git a/src/serviceFeatures/setupObsidian/setupManagerHandlers.ts b/src/serviceFeatures/setupObsidian/setupManagerHandlers.ts index 4eb9e425..d745a563 100644 --- a/src/serviceFeatures/setupObsidian/setupManagerHandlers.ts +++ b/src/serviceFeatures/setupObsidian/setupManagerHandlers.ts @@ -1,9 +1,38 @@ import { type SetupManager, UserMode } from "@/modules/features/SetupManager"; -import type { SetupFeatureHost } from "@lib/serviceFeatures/setupObsidian/types"; -import { EVENT_REQUEST_OPEN_P2P_SETTINGS, EVENT_REQUEST_OPEN_SETUP_URI } from "@lib/events/coreEvents"; -import { eventHub } from "@lib/hub/hub"; -import { fireAndForget } from "@lib/common/utils"; -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; +import type { SetupFeatureHost } from "@/serviceFeatures/setupObsidian/types"; +import { + EVENT_REQUEST_OPEN_P2P_SETTINGS, + EVENT_REQUEST_OPEN_SETUP_URI, +} from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; +import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; +import { $msg } from "@/common/translation"; + +const ONBOARDING_NOTICE_DURATION_MS = 60_000; + +export async function openOnboarding(setupManager: SetupManager) { + return await setupManager.startOnBoarding(); +} + +export function showOnboardingInvitation(host: NecessaryServices<"UI", never>, setupManager: SetupManager): void { + const message = `${$msg("Welcome to Self-hosted LiveSync")} ${$msg( + "We will now guide you through a few questions to simplify the synchronisation setup." + )} {HERE}`; + host.services.UI.confirm.askInPopup( + "initial-onboarding", + message, + (anchor) => { + anchor.href = "#"; + anchor.classList.add("sls-onboarding-invitation-action"); + anchor.textContent = $msg("Ui.SetupWizard.Invitation.Start"); + anchor.addEventListener("click", (event) => { + event.preventDefault(); + fireAndForget(() => openOnboarding(setupManager)); + }); + }, + ONBOARDING_NOTICE_DURATION_MS + ); +} export async function openSetupURI(setupManager: SetupManager) { await setupManager.onUseSetupURI(UserMode.Unknown); @@ -24,8 +53,10 @@ export function useSetupManagerHandlersFeature( callback: () => fireAndForget(openSetupURI(setupManager)), }); - eventHub.onEvent(EVENT_REQUEST_OPEN_SETUP_URI, () => fireAndForget(() => openSetupURI(setupManager))); - eventHub.onEvent(EVENT_REQUEST_OPEN_P2P_SETTINGS, () => + host.services.context.events.onEvent(EVENT_REQUEST_OPEN_SETUP_URI, () => + fireAndForget(() => openSetupURI(setupManager)) + ); + host.services.context.events.onEvent(EVENT_REQUEST_OPEN_P2P_SETTINGS, () => fireAndForget(() => openP2PSettings(host, setupManager)) ); diff --git a/src/serviceFeatures/setupObsidian/setupManagerHandlers.unit.spec.ts b/src/serviceFeatures/setupObsidian/setupManagerHandlers.unit.spec.ts index 067d860c..bdae06ca 100644 --- a/src/serviceFeatures/setupObsidian/setupManagerHandlers.unit.spec.ts +++ b/src/serviceFeatures/setupObsidian/setupManagerHandlers.unit.spec.ts @@ -1,7 +1,15 @@ import { describe, expect, it, vi, afterEach } from "vitest"; -import { eventHub } from "@lib/hub/hub"; -import { EVENT_REQUEST_OPEN_P2P_SETTINGS, EVENT_REQUEST_OPEN_SETUP_URI } from "@lib/events/coreEvents"; -import { openP2PSettings, openSetupURI, useSetupManagerHandlersFeature } from "./setupManagerHandlers"; +import { + EVENT_REQUEST_OPEN_P2P_SETTINGS, + EVENT_REQUEST_OPEN_SETUP_URI, +} from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; +import { + openOnboarding, + openP2PSettings, + openSetupURI, + showOnboardingInvitation, + useSetupManagerHandlersFeature, +} from "./setupManagerHandlers"; vi.mock("@/modules/features/SetupManager", () => { return { @@ -44,16 +52,79 @@ describe("setupObsidian/setupManagerHandlers", () => { expect(setupManager.onP2PManualSetup).toHaveBeenCalledWith("unknown", settings, false); }); - it("useSetupManagerHandlersFeature should register onLoaded handler that wires command and events", async () => { + it("openOnboarding should delegate to SetupManager.startOnBoarding", async () => { + const setupManager = { + startOnBoarding: vi.fn(async () => await Promise.resolve(false)), + } as any; + + await openOnboarding(setupManager); + + expect(setupManager.startOnBoarding).toHaveBeenCalledOnce(); + }); + + it("showOnboardingInvitation should wait for its fixed action link before opening onboarding", async () => { + let configureAnchor: ((anchor: HTMLAnchorElement) => void) | undefined; + const askInPopup = vi.fn( + (_key: string, _text: string, callback: (anchor: HTMLAnchorElement) => void, _durationMs?: number) => { + configureAnchor = callback; + } + ); + const host = { + services: { + UI: { confirm: { askInPopup } }, + }, + } as any; + const setupManager = { + startOnBoarding: vi.fn(async () => await Promise.resolve(false)), + } as any; + + showOnboardingInvitation(host, setupManager); + + expect(setupManager.startOnBoarding).not.toHaveBeenCalled(); + expect(askInPopup).toHaveBeenCalledWith( + "initial-onboarding", + expect.stringContaining("{HERE}"), + expect.any(Function), + 60_000 + ); + + let click: ((event: { preventDefault(): void }) => void) | undefined; + const addClass = vi.fn(); + const anchor = { + href: "", + textContent: "", + classList: { add: addClass }, + addEventListener: vi.fn((_name: string, listener: typeof click) => { + click = listener; + }), + } as unknown as HTMLAnchorElement; + configureAnchor!(anchor); + + expect(anchor.href).toBe("#"); + expect(anchor.textContent).toBe("Start setup"); + expect(addClass).toHaveBeenCalledWith("sls-onboarding-invitation-action"); + const preventDefault = vi.fn(); + click!({ preventDefault }); + await vi.waitFor(() => expect(setupManager.startOnBoarding).toHaveBeenCalledOnce()); + expect(preventDefault).toHaveBeenCalledOnce(); + }); + + it("keeps onboarding out of the command palette while wiring the setup URI command and events", async () => { const addHandler = vi.fn(); const addCommand = vi.fn(); - const onEventSpy = vi.spyOn(eventHub, "onEvent"); + const events = { onEvent: vi.fn() }; const host = { services: { + context: { events }, API: { addCommand, }, + UI: { + confirm: { + askInPopup: vi.fn(), + }, + }, appLifecycle: { onLoaded: { addHandler, @@ -65,6 +136,7 @@ describe("setupObsidian/setupManagerHandlers", () => { }, } as any; const setupManager = { + startOnBoarding: vi.fn(async () => await Promise.resolve(false)), onUseSetupURI: vi.fn(async () => await Promise.resolve(true)), onP2PManualSetup: vi.fn(async () => await Promise.resolve(true)), } as any; @@ -75,13 +147,18 @@ describe("setupObsidian/setupManagerHandlers", () => { const loadedHandler = addHandler.mock.calls[0][0] as () => Promise; await loadedHandler(); + expect(addCommand).not.toHaveBeenCalledWith( + expect.objectContaining({ + id: "livesync-open-onboarding", + }) + ); expect(addCommand).toHaveBeenCalledWith( expect.objectContaining({ id: "livesync-opensetupuri", name: "Use the copied setup URI (Formerly Open setup URI)", }) ); - expect(onEventSpy).toHaveBeenCalledWith(EVENT_REQUEST_OPEN_SETUP_URI, expect.any(Function)); - expect(onEventSpy).toHaveBeenCalledWith(EVENT_REQUEST_OPEN_P2P_SETTINGS, expect.any(Function)); + expect(events.onEvent).toHaveBeenCalledWith(EVENT_REQUEST_OPEN_SETUP_URI, expect.any(Function)); + expect(events.onEvent).toHaveBeenCalledWith(EVENT_REQUEST_OPEN_P2P_SETTINGS, expect.any(Function)); }); }); diff --git a/src/serviceFeatures/setupObsidian/setupProtocol.ts b/src/serviceFeatures/setupObsidian/setupProtocol.ts index 5310fbf8..3c3566ff 100644 --- a/src/serviceFeatures/setupObsidian/setupProtocol.ts +++ b/src/serviceFeatures/setupObsidian/setupProtocol.ts @@ -1,9 +1,9 @@ -import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@lib/common/types"; -import type { LogFunction } from "@lib/services/lib/logUtils"; -import { createInstanceLogFunction } from "@lib/services/lib/logUtils"; -import type { SetupFeatureHost } from "@lib/serviceFeatures/setupObsidian/types"; +import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils"; +import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils"; +import type { SetupFeatureHost } from "@/serviceFeatures/setupObsidian/types"; import { configURIBase } from "@/common/types"; -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; +import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; import { type SetupManager, UserMode } from "@/modules/features/SetupManager"; async function handleSetupProtocol(setupManager: SetupManager, conf: Record) { diff --git a/src/serviceFeatures/setupObsidian/setupUri.ts b/src/serviceFeatures/setupObsidian/setupUri.ts new file mode 100644 index 00000000..a4fe4aec --- /dev/null +++ b/src/serviceFeatures/setupObsidian/setupUri.ts @@ -0,0 +1,87 @@ +import { LOG_LEVEL_NOTICE, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils"; +import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils"; +import { encodeSettingsToSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting"; +import { EVENT_REQUEST_COPY_SETUP_URI } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; +import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; +import type { SetupFeatureHost } from "./types"; + +export async function askEncryptingPassphrase(host: SetupFeatureHost): Promise { + return await host.services.UI.confirm.askString( + "Encrypt your settings", + "The passphrase to encrypt the setup URI", + "", + true + ); +} + +export async function copySetupURI(host: SetupFeatureHost, log: LogFunction, stripExtra = true) { + const encryptingPassphrase = await askEncryptingPassphrase(host); + if (encryptingPassphrase === false) return; + const encryptedURI = await encodeSettingsToSetupURI( + host.services.setting.currentSettings(), + encryptingPassphrase, + [...((stripExtra ? ["pluginSyncExtendedSetting"] : []) as (keyof ObsidianLiveSyncSettings)[])], + true + ); + if (await host.services.UI.promptCopyToClipboard("Setup URI", encryptedURI)) { + log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE); + } +} + +export async function copySetupURIFull(host: SetupFeatureHost, log: LogFunction) { + const encryptingPassphrase = await askEncryptingPassphrase(host); + if (encryptingPassphrase === false) return; + const encryptedURI = await encodeSettingsToSetupURI( + host.services.setting.currentSettings(), + encryptingPassphrase, + [], + false + ); + if (await host.services.UI.promptCopyToClipboard("Setup URI", encryptedURI)) { + log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE); + } +} + +export function useSetupURIFeature(host: NecessaryServices<"API" | "UI" | "setting" | "appLifecycle", never>) { + const log = createInstanceLogFunction("SF:SetupURI", host.services.API); + host.services.appLifecycle.onLoaded.addHandler(() => { + host.services.API.addCommand({ + id: "livesync-copysetupuri", + name: "Copy settings as a new setup URI", + checkCallback: (checking) => { + if (!host.services.setting.currentSettings().isConfigured) return false; + if (!checking) fireAndForget(copySetupURI(host, log)); + return true; + }, + }); + + host.services.API.addCommand({ + id: "livesync-copysetupuri-short", + name: "Copy settings as a new setup URI (With customization sync)", + checkCallback: (checking) => { + const settings = host.services.setting.currentSettings(); + if (!settings.isConfigured || !settings.usePluginSync) return false; + if (!checking) fireAndForget(copySetupURI(host, log, false)); + return true; + }, + }); + + host.services.API.addCommand({ + id: "livesync-copysetupurifull", + name: "Copy settings as a new setup URI (Full)", + checkCallback: (checking) => { + const settings = host.services.setting.currentSettings(); + if (!settings.isConfigured || !settings.useAdvancedMode) return false; + if (!checking) fireAndForget(copySetupURIFull(host, log)); + return true; + }, + }); + + host.services.context.events.onEvent(EVENT_REQUEST_COPY_SETUP_URI, () => + fireAndForget(() => copySetupURI(host, log)) + ); + return Promise.resolve(true); + }); +} diff --git a/src/serviceFeatures/setupObsidian/setupUri.unit.spec.ts b/src/serviceFeatures/setupObsidian/setupUri.unit.spec.ts new file mode 100644 index 00000000..27ec04ef --- /dev/null +++ b/src/serviceFeatures/setupObsidian/setupUri.unit.spec.ts @@ -0,0 +1,212 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { EVENT_REQUEST_COPY_SETUP_URI } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; +import { createServiceContext } from "@vrtmrz/livesync-commonlib/context"; +import { askEncryptingPassphrase, copySetupURI, copySetupURIFull, useSetupURIFeature } from "./setupUri"; +import { encodeSettingsToSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting"; + +vi.mock("@vrtmrz/livesync-commonlib/compat/API/processSetting", () => { + return { + encodeSettingsToSetupURI: vi.fn(), + }; +}); + +describe("setupObsidian/setupUri", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + it("askEncryptingPassphrase should delegate to confirm.askString", async () => { + const askString = vi.fn(() => "secret"); + const host = { + services: { + UI: { + confirm: { + askString, + }, + }, + }, + } as any; + + const result = await askEncryptingPassphrase(host); + expect(result).toBe("secret"); + expect(askString).toHaveBeenCalled(); + }); + + it("copySetupURI should return early when user cancels passphrase", async () => { + const promptCopyToClipboard = vi.fn(); + const host = { + services: { + setting: { + currentSettings: vi.fn(() => ({ foo: "bar" })), + }, + UI: { + confirm: { + askString: vi.fn(() => false), + }, + promptCopyToClipboard, + }, + }, + } as any; + const log = vi.fn(); + + await copySetupURI(host, log); + + expect(encodeSettingsToSetupURI).not.toHaveBeenCalled(); + expect(promptCopyToClipboard).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + }); + + it("copySetupURI should encode with short mode by default", async () => { + const promptCopyToClipboard = vi.fn(() => true); + const currentSettings = { pluginSyncExtendedSetting: true, x: 1 }; + const host = { + services: { + setting: { + currentSettings: vi.fn(() => currentSettings), + }, + UI: { + confirm: { + askString: vi.fn(() => "pass"), + }, + promptCopyToClipboard, + }, + }, + } as any; + const log = vi.fn(); + vi.mocked(encodeSettingsToSetupURI).mockResolvedValue("uri://value" as any); + + await copySetupURI(host, log); + + expect(encodeSettingsToSetupURI).toHaveBeenCalledWith( + currentSettings, + "pass", + ["pluginSyncExtendedSetting"], + true + ); + expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "uri://value"); + expect(log).toHaveBeenCalled(); + }); + + it("copySetupURIFull should encode with full mode", async () => { + const promptCopyToClipboard = vi.fn(() => true); + const currentSettings = { pluginSyncExtendedSetting: true, x: 1 }; + const host = { + services: { + setting: { + currentSettings: vi.fn(() => currentSettings), + }, + UI: { + confirm: { + askString: vi.fn(() => "pass-full"), + }, + promptCopyToClipboard, + }, + }, + } as any; + const log = vi.fn(); + vi.mocked(encodeSettingsToSetupURI).mockResolvedValue("uri://full" as any); + + await copySetupURIFull(host, log); + + expect(encodeSettingsToSetupURI).toHaveBeenCalledWith(currentSettings, "pass-full", [], false); + expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "uri://full"); + expect(log).toHaveBeenCalled(); + }); + + it("useSetupURIFeature should register onLoaded handler that wires commands and event", async () => { + const addHandler = vi.fn(); + const addCommand = vi.fn(); + const context = createServiceContext(); + const onEventSpy = vi.spyOn(context.events, "onEvent"); + + const host = { + services: { + context, + API: { + addCommand, + addLog: vi.fn(), + }, + appLifecycle: { + onLoaded: { + addHandler, + }, + }, + setting: { + currentSettings: vi.fn(() => ({ x: 1 })), + }, + UI: { + confirm: { + askString: vi.fn(() => "pass"), + }, + promptCopyToClipboard: vi.fn(() => true), + }, + }, + } as any; + + useSetupURIFeature(host); + expect(addHandler).toHaveBeenCalledTimes(1); + + const loadedHandler = addHandler.mock.calls[0][0] as () => Promise; + await loadedHandler(); + + expect(addCommand).toHaveBeenCalledTimes(3); + expect(addCommand).toHaveBeenCalledWith(expect.objectContaining({ id: "livesync-copysetupuri" })); + expect(addCommand).toHaveBeenCalledWith(expect.objectContaining({ id: "livesync-copysetupuri-short" })); + expect(addCommand).toHaveBeenCalledWith(expect.objectContaining({ id: "livesync-copysetupurifull" })); + expect(onEventSpy).toHaveBeenCalledWith(EVENT_REQUEST_COPY_SETUP_URI, expect.any(Function)); + }); + + it("shows Setup URI variants only when their configuration level is relevant", async () => { + const addHandler = vi.fn(); + const commands: Array<{ + id: string; + checkCallback?: (checking: boolean) => boolean | void; + }> = []; + const settings = { + isConfigured: false, + usePluginSync: false, + useAdvancedMode: false, + }; + const host = { + services: { + context: createServiceContext(), + API: { + addCommand: vi.fn((command) => commands.push(command)), + addLog: vi.fn(), + }, + appLifecycle: { + onLoaded: { + addHandler, + }, + }, + setting: { + currentSettings: vi.fn(() => settings), + }, + UI: { + confirm: { + askString: vi.fn(() => "pass"), + }, + promptCopyToClipboard: vi.fn(() => true), + }, + }, + } as any; + + useSetupURIFeature(host); + const loadedHandler = addHandler.mock.calls[0][0] as () => Promise; + await loadedHandler(); + + const command = (id: string) => commands.find((candidate) => candidate.id === id)!; + expect(command("livesync-copysetupuri").checkCallback?.(true)).toBe(false); + + settings.isConfigured = true; + expect(command("livesync-copysetupuri").checkCallback?.(true)).toBe(true); + expect(command("livesync-copysetupuri-short").checkCallback?.(true)).toBe(false); + expect(command("livesync-copysetupurifull").checkCallback?.(true)).toBe(false); + + settings.usePluginSync = true; + settings.useAdvancedMode = true; + expect(command("livesync-copysetupuri-short").checkCallback?.(true)).toBe(true); + expect(command("livesync-copysetupurifull").checkCallback?.(true)).toBe(true); + }); +}); diff --git a/src/serviceFeatures/setupObsidian/types.ts b/src/serviceFeatures/setupObsidian/types.ts new file mode 100644 index 00000000..0e15898e --- /dev/null +++ b/src/serviceFeatures/setupObsidian/types.ts @@ -0,0 +1,3 @@ +import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; + +export type SetupFeatureHost = NecessaryServices<"API" | "UI" | "setting", never>; diff --git a/src/serviceFeatures/useP2PReplicatorUI.ts b/src/serviceFeatures/useP2PReplicatorUI.ts index 4618790d..a30f6182 100644 --- a/src/serviceFeatures/useP2PReplicatorUI.ts +++ b/src/serviceFeatures/useP2PReplicatorUI.ts @@ -1,24 +1,48 @@ import { eventHub, EVENT_REQUEST_OPEN_P2P } from "@/common/events"; import { reactiveSource } from "octagonal-wheels/dataobject/reactive_v2"; -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; -import { type UseP2PReplicatorResult } from "@lib/replication/trystero/UseP2PReplicatorResult"; -import { P2PLogCollector } from "@lib/replication/trystero/P2PLogCollector"; -import { P2PReplicatorPaneView, VIEW_TYPE_P2P } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneView"; +import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; +import { type UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult"; +import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector"; import { P2PServerStatusPaneView, VIEW_TYPE_P2P_SERVER_STATUS, } from "@/features/P2PSync/P2PReplicator/P2PServerStatusPaneView"; import type { LiveSyncCore } from "@/main"; import type { WorkspaceLeaf } from "@/deps"; -import { REMOTE_P2P } from "@lib/common/models/setting.const"; +import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const"; +import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type"; +import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString"; + +export const LEGACY_VIEW_TYPE_P2P = "p2p-replicator"; + +class LegacyP2PStatusPaneView extends P2PServerStatusPaneView { + override getViewType() { + return LEGACY_VIEW_TYPE_P2P; + } +} + +export function hasP2PConfiguration(settings: Partial): boolean { + if ( + settings.remoteType === REMOTE_P2P || + settings.P2P_Enabled === true || + (settings.P2P_roomID ?? "").trim() !== "" || + (settings.P2P_passphrase ?? "").trim() !== "" + ) { + return true; + } + return Object.values(settings.remoteConfigurations ?? {}).some((configuration) => { + try { + return ConnectionStringParser.parse(configuration.uri).type === "p2p"; + } catch { + return false; + } + }); +} /** - * ServiceFeature: P2P Replicator lifecycle management. - * Binds a LiveSyncTrysteroReplicator to the host's lifecycle events, - * following the same middleware style as useOfflineScanner. - * - * @param viewTypeAndFactory Optional [viewType, factory] pair for registering the P2P pane view. - * When provided, also registers commands and ribbon icon via services.API. + * Obsidian-specific P2P views, commands, status collection, and ribbon wiring. + * Replicator ownership and lifecycle remain in Commonlib's + * `useP2PReplicatorFeature`; this feature only consumes its current result. */ export function useP2PReplicatorUI( @@ -43,40 +67,40 @@ export function useP2PReplicatorUI( showWindow: (type: string) => Promise; showWindowOnRight?: (type: string) => Promise; registerWindow: (type: string, factory: (leaf: WorkspaceLeaf) => unknown) => void; - addCommand: (command: { id: string; name: string; callback: () => void }) => unknown; + addCommand: (command: { + id: string; + name: string; + callback?: () => void; + checkCallback?: (checking: boolean) => boolean | void; + }) => unknown; addRibbonIcon: ( icon: string, title: string, callback: () => void - ) => { addClass?: (name: string) => unknown } | undefined; - getPlatform: () => string; + ) => { addClass?: (name: string) => unknown; remove?: () => void } | undefined; }; // const env: LiveSyncTrysteroReplicatorEnv = { services: host.services as any }; const getReplicator = () => replicator.replicator; - const p2pLogCollector = new P2PLogCollector(); + const p2pLogCollector = new P2PLogCollector(host.services.context.events); const storeP2PStatusLine = reactiveSource(""); p2pLogCollector.p2pReplicationLine.onChanged((line) => { storeP2PStatusLine.value = line.value; }); + const p2pParams = { + get replicator() { + return getReplicator(); + }, + p2pLogCollector, + storeP2PStatusLine, + }; - // Register view, commands and ribbon if a view factory is provided - const viewType = VIEW_TYPE_P2P; - const factory = (leaf: WorkspaceLeaf) => { - return new P2PReplicatorPaneView(leaf, core, { - replicator: getReplicator(), - p2pLogCollector, - storeP2PStatusLine, - }); - }; const statusFactory = (leaf: WorkspaceLeaf) => { - return new P2PServerStatusPaneView(leaf, core, { - replicator: getReplicator(), - p2pLogCollector, - storeP2PStatusLine, - }); + return new P2PServerStatusPaneView(leaf, core, p2pParams); + }; + const legacyStatusFactory = (leaf: WorkspaceLeaf) => { + return new LegacyP2PStatusPaneView(leaf, core, p2pParams); }; - const openPane = () => api.showWindow(viewType); const openStatusPane = () => { if (api.showWindowOnRight) { return api.showWindowOnRight(VIEW_TYPE_P2P_SERVER_STATUS); @@ -87,32 +111,52 @@ export function useP2PReplicatorUI( const activeReplicator = replicator.replicator; if (!activeReplicator) return; const settings = host.services.setting.currentSettings(); - void host.services.replicator.runBoundedRemoteActivity( + void host.services.replicator.runFiniteReplicationActivity( () => activeReplicator.openReplication(settings, false, true, false), { label: "replication" } ); }; - api.registerWindow(viewType, factory); + // Keep the retired view type registered only long enough to restore an + // existing workspace leaf with the current status UI. Layout-ready + // migration below rewrites it to the current type without opening a leaf. + api.registerWindow(LEGACY_VIEW_TYPE_P2P, legacyStatusFactory); api.registerWindow(VIEW_TYPE_P2P_SERVER_STATUS, statusFactory); + let ribbonElement: { addClass?: (name: string) => unknown; remove?: () => void } | undefined; + const updateRibbon = (settings: Partial) => { + if (hasP2PConfiguration(settings)) { + if (ribbonElement) return; + ribbonElement = api.addRibbonIcon("waypoints", "P2P Status", () => { + void openStatusPane(); + }); + ribbonElement?.addClass?.("livesync-ribbon-p2p-server-status"); + return; + } + ribbonElement?.remove?.(); + ribbonElement = undefined; + }; + + // Settings are loaded after onInitialise. Reading them from the earlier + // phase aborts the plug-in lifecycle before the local database can open. + host.services.appLifecycle.onSettingLoaded.addHandler(() => { + updateRibbon(host.services.setting.currentSettings()); + return Promise.resolve(true); + }); + host.services.appLifecycle.onInitialise.addHandler(() => { eventHub.onEvent(EVENT_REQUEST_OPEN_P2P, () => { - void openPane(); - }); - - api.addCommand({ - id: "open-p2p-replicator", - name: "P2P Sync : Open P2P Replicator (Old UI)", - callback: () => { - void openPane(); - }, + void openStatusPane(); }); api.addCommand({ id: "open-p2p-server-status", name: "P2P Sync : Open P2P Status", - callback: () => { - void openStatusPane(); + checkCallback: (checking) => { + if (!hasP2PConfiguration(host.services.setting.currentSettings())) return false; + if (!checking) { + void openStatusPane(); + } + return true; }, }); host.services.API.addCommand({ @@ -120,11 +164,15 @@ export function useP2PReplicatorUI( name: "Replicate P2P to default peer", checkCallback: (isChecking: boolean) => { const settings = host.services.setting.currentSettings(); - if (isChecking) { - if (settings.remoteType == REMOTE_P2P) return false; - return replicator.replicator?.server?.isServing ?? false; + const isAvailable = + hasP2PConfiguration(settings) && + settings.remoteType !== REMOTE_P2P && + (replicator.replicator?.server?.isServing ?? false); + if (!isAvailable) return false; + if (!isChecking) { + runOpenReplication(); } - runOpenReplication(); + return true; }, }); host.services.API.addCommand({ @@ -132,11 +180,15 @@ export function useP2PReplicatorUI( name: "Replicate now by P2P", checkCallback: (isChecking: boolean) => { const settings = host.services.setting.currentSettings(); - if (isChecking) { - if (settings.remoteType == REMOTE_P2P) return false; - return replicator.replicator?.server?.isServing ?? false; + const isAvailable = + hasP2PConfiguration(settings) && + settings.remoteType !== REMOTE_P2P && + (replicator.replicator?.server?.isServing ?? false); + if (!isAvailable) return false; + if (!isChecking) { + runOpenReplication(); } - runOpenReplication(); + return true; }, }); @@ -144,34 +196,48 @@ export function useP2PReplicatorUI( id: "p2p-sync-targets", name: "P2P: Sync with targets", checkCallback: (isChecking: boolean) => { - if (isChecking) { - return replicator.replicator?.server?.isServing ?? false; + const isAvailable = + hasP2PConfiguration(host.services.setting.currentSettings()) && + (replicator.replicator?.server?.isServing ?? false); + if (!isAvailable) return false; + if (!isChecking) { + void replicator.replicator?.replicateFromCommand(true); } - void replicator.replicator?.replicateFromCommand(true); + return true; }, }); - // api.addRibbonIcon("waypoints", "P2P Replicator", () => { - // void openPane(); - // })?.addClass?.("livesync-ribbon-replicate-p2p"); - - api.addRibbonIcon("waypoints", "P2P Status", () => { - void openStatusPane(); - })?.addClass?.("livesync-ribbon-p2p-server-status"); - - return Promise.resolve(true); - }); - - host.services.appLifecycle.onLayoutReady.addHandler(() => { - if (api.getPlatform() !== "obsidian") { + host.services.setting.onSettingSaved?.addHandler((settings) => { + updateRibbon(settings); return Promise.resolve(true); - } - if (api.showWindowOnRight) { - void api.showWindowOnRight(VIEW_TYPE_P2P_SERVER_STATUS); - } else { - void api.showWindow(VIEW_TYPE_P2P_SERVER_STATUS); - } + }); + return Promise.resolve(true); }); - return { replicator: getReplicator(), p2pLogCollector, storeP2PStatusLine }; + + host.services.appLifecycle.onLayoutReady.addHandler(async () => { + const workspace = ( + host.services.context as { + app?: { + workspace?: { + getLeavesOfType(type: string): WorkspaceLeaf[]; + }; + }; + } + ).app?.workspace; + if (!workspace) { + return true; + } + const legacyLeaves = workspace.getLeavesOfType(LEGACY_VIEW_TYPE_P2P); + await Promise.all( + legacyLeaves.map((leaf) => + leaf.setViewState({ + type: VIEW_TYPE_P2P_SERVER_STATUS, + active: false, + }) + ) + ); + return true; + }); + return p2pParams; } diff --git a/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts b/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts index e93d774f..b37e3944 100644 --- a/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts +++ b/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts @@ -1,24 +1,74 @@ import { describe, expect, it, vi } from "vitest"; +import { createServiceContext } from "@vrtmrz/livesync-commonlib/context"; +import { eventHub, EVENT_REQUEST_OPEN_P2P } from "@/common/events"; -vi.mock("@/features/P2PSync/P2PReplicator/P2PReplicatorPaneView", () => ({ - P2PReplicatorPaneView: class {}, - VIEW_TYPE_P2P: "p2p", -})); vi.mock("@/features/P2PSync/P2PReplicator/P2PServerStatusPaneView", () => ({ - P2PServerStatusPaneView: class {}, + P2PServerStatusPaneView: class { + getViewType() { + return "p2p-status"; + } + }, VIEW_TYPE_P2P_SERVER_STATUS: "p2p-status", })); import { useP2PReplicatorUI } from "./useP2PReplicatorUI"; describe("useP2PReplicatorUI commands", () => { - it("runs a direct modal P2P replication command through the shared activity boundary", async () => { + it("waits for settings to load before deciding whether to show the P2P ribbon", async () => { + let initialise: (() => Promise) | undefined; + let settingLoaded: (() => Promise) | undefined; + let settings: Record | undefined; + const currentSettings = vi.fn(() => settings); + const host = { + services: { + context: createServiceContext(), + API: { + showWindow: vi.fn(async () => undefined), + registerWindow: vi.fn(), + addCommand: vi.fn(), + addRibbonIcon: vi.fn(), + }, + appLifecycle: { + onInitialise: { + addHandler: vi.fn((handler) => { + initialise = handler; + }), + }, + onSettingLoaded: { + addHandler: vi.fn((handler) => { + settingLoaded = handler; + }), + }, + onLayoutReady: { addHandler: vi.fn() }, + }, + setting: { + currentSettings, + onSettingSaved: { addHandler: vi.fn() }, + }, + replicator: { runFiniteReplicationActivity: vi.fn() }, + }, + } as any; + + useP2PReplicatorUI(host, {} as any, { replicator: undefined } as any); + + await expect(initialise?.()).resolves.toBe(true); + expect(currentSettings).not.toHaveBeenCalled(); + settings = { + remoteType: "COUCHDB", + remoteConfigurations: {}, + }; + await expect(settingLoaded?.()).resolves.toBe(true); + expect(currentSettings).toHaveBeenCalledOnce(); + }); + + it("exposes a direct modal P2P replication command as finite replication activity", async () => { const commands: Array<{ id: string; checkCallback?: (isChecking: boolean) => unknown }> = []; let initialise: (() => Promise) | undefined; const openReplication = vi.fn(async () => true); - const runBoundedRemoteActivity = vi.fn(async (task: () => unknown) => await task()); + const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task()); const host = { services: { + context: createServiceContext(), API: { showWindow: vi.fn(async () => undefined), registerWindow: vi.fn(), @@ -32,10 +82,16 @@ describe("useP2PReplicatorUI commands", () => { initialise = handler; }), }, + onSettingLoaded: { addHandler: vi.fn() }, onLayoutReady: { addHandler: vi.fn() }, }, - setting: { currentSettings: vi.fn(() => ({ remoteType: "COUCHDB" })) }, - replicator: { runBoundedRemoteActivity }, + setting: { + currentSettings: vi.fn(() => ({ + remoteType: "COUCHDB", + P2P_Enabled: true, + })), + }, + replicator: { runFiniteReplicationActivity }, }, } as any; const p2p = { @@ -51,8 +107,347 @@ describe("useP2PReplicatorUI commands", () => { commands.find((command) => command.id === "replicate-now-by-p2p")?.checkCallback?.(false); await vi.waitFor(() => expect(openReplication).toHaveBeenCalledOnce()); - expect(runBoundedRemoteActivity).toHaveBeenCalledWith(expect.any(Function), { + expect(runFiniteReplicationActivity).toHaveBeenCalledWith(expect.any(Function), { label: "replication", }); }); + + it("keeps the current replicator in the pane parameters after replacement", () => { + const first = { id: "first" }; + const second = { id: "second" }; + let current = first; + const p2p = { + get replicator() { + return current; + }, + } as any; + const host = { + services: { + context: createServiceContext(), + API: { + showWindow: vi.fn(async () => undefined), + registerWindow: vi.fn(), + addCommand: vi.fn(), + addRibbonIcon: vi.fn(), + getPlatform: vi.fn(() => "obsidian"), + }, + appLifecycle: { + onInitialise: { addHandler: vi.fn() }, + onSettingLoaded: { addHandler: vi.fn() }, + onLayoutReady: { addHandler: vi.fn() }, + }, + setting: { currentSettings: vi.fn(() => ({ remoteType: "COUCHDB" })) }, + replicator: { runFiniteReplicationActivity: vi.fn() }, + }, + } as any; + + const paneParams = useP2PReplicatorUI(host, {} as any, p2p); + current = second; + + expect(paneParams.replicator).toBe(second); + }); + + it("retains only the current P2P status command and routes existing open requests to it", async () => { + const commands: Array<{ + id: string; + callback?: () => void; + checkCallback?: (checking: boolean) => boolean | void; + }> = []; + let initialise: (() => Promise) | undefined; + const showWindow = vi.fn(async () => undefined); + const showWindowOnRight = vi.fn(async () => undefined); + const host = { + services: { + context: createServiceContext(), + API: { + showWindow, + showWindowOnRight, + registerWindow: vi.fn(), + addCommand: vi.fn((command) => commands.push(command)), + addRibbonIcon: vi.fn(), + getPlatform: vi.fn(() => "desktop"), + }, + appLifecycle: { + onInitialise: { + addHandler: vi.fn((handler) => { + initialise = handler; + }), + }, + onSettingLoaded: { addHandler: vi.fn() }, + onLayoutReady: { addHandler: vi.fn() }, + }, + setting: { + currentSettings: vi.fn(() => ({ + remoteType: "COUCHDB", + remoteConfigurations: {}, + })), + }, + replicator: { runFiniteReplicationActivity: vi.fn() }, + }, + } as any; + const p2p = { replicator: undefined } as any; + + useP2PReplicatorUI(host, {} as any, p2p); + await initialise?.(); + + expect(commands.map((command) => command.id)).not.toContain("open-p2p-replicator"); + expect(commands.map((command) => command.id)).toContain("open-p2p-server-status"); + expect(commands.find((command) => command.id === "open-p2p-server-status")?.checkCallback?.(true)).toBe(false); + + eventHub.emitEvent(EVENT_REQUEST_OPEN_P2P); + await vi.waitFor(() => expect(showWindowOnRight).toHaveBeenCalledWith("p2p-status")); + expect(showWindow).not.toHaveBeenCalledWith("p2p"); + }); + + it("shows P2P commands only when a P2P configuration exists and their runtime prerequisites are met", async () => { + const commands: Array<{ + id: string; + checkCallback?: (checking: boolean) => boolean | void; + }> = []; + let initialise: (() => Promise) | undefined; + let settings: Record = { + remoteType: "COUCHDB", + remoteConfigurations: {}, + }; + const host = { + services: { + context: createServiceContext(), + API: { + showWindow: vi.fn(async () => undefined), + showWindowOnRight: vi.fn(async () => undefined), + registerWindow: vi.fn(), + addCommand: vi.fn((command) => commands.push(command)), + addRibbonIcon: vi.fn(), + }, + appLifecycle: { + onInitialise: { + addHandler: vi.fn((handler) => { + initialise = handler; + }), + }, + onSettingLoaded: { addHandler: vi.fn() }, + onLayoutReady: { addHandler: vi.fn() }, + }, + setting: { + currentSettings: vi.fn(() => settings), + onSettingSaved: { addHandler: vi.fn() }, + }, + replicator: { runFiniteReplicationActivity: vi.fn() }, + }, + } as any; + const p2p = { + replicator: { + server: { isServing: true }, + openReplication: vi.fn(), + replicateFromCommand: vi.fn(), + }, + } as any; + + useP2PReplicatorUI(host, {} as any, p2p); + await initialise?.(); + + for (const commandId of [ + "open-p2p-server-status", + "replicate-now-by-p2p-default-peer", + "replicate-now-by-p2p", + "p2p-sync-targets", + ]) { + expect(commands.find(({ id }) => id === commandId)?.checkCallback?.(true)).toBe(false); + } + + settings = { + ...settings, + remoteConfigurations: { + peer: { + id: "peer", + name: "Peer", + uri: "sls+p2p://room?passphrase=secret", + isEncrypted: false, + }, + }, + }; + for (const commandId of [ + "open-p2p-server-status", + "replicate-now-by-p2p-default-peer", + "replicate-now-by-p2p", + "p2p-sync-targets", + ]) { + expect(commands.find(({ id }) => id === commandId)?.checkCallback?.(true)).toBe(true); + } + }); + + it("does not open the P2P status pane automatically when the workspace becomes ready", async () => { + let layoutReady: (() => Promise) | undefined; + const showWindow = vi.fn(async () => undefined); + const showWindowOnRight = vi.fn(async () => undefined); + const host = { + services: { + context: createServiceContext(), + API: { + showWindow, + showWindowOnRight, + registerWindow: vi.fn(), + addCommand: vi.fn(), + addRibbonIcon: vi.fn(), + getPlatform: vi.fn(() => "obsidian"), + }, + appLifecycle: { + onInitialise: { addHandler: vi.fn() }, + onSettingLoaded: { addHandler: vi.fn() }, + onLayoutReady: { + addHandler: vi.fn((handler) => { + layoutReady = handler; + }), + }, + }, + setting: { + currentSettings: vi.fn(() => ({ + remoteType: "COUCHDB", + remoteConfigurations: {}, + })), + }, + replicator: { runFiniteReplicationActivity: vi.fn() }, + }, + } as any; + + useP2PReplicatorUI(host, {} as any, { replicator: undefined } as any); + await layoutReady?.(); + + expect(showWindow).not.toHaveBeenCalled(); + expect(showWindowOnRight).not.toHaveBeenCalled(); + }); + + it("shows the ribbon only whilst a P2P configuration exists", async () => { + let initialise: (() => Promise) | undefined; + let settingLoaded: (() => Promise) | undefined; + let onSettingSaved: ((settings: unknown) => Promise) | undefined; + let currentSettings: any = { + remoteType: "COUCHDB", + remoteConfigurations: {}, + P2P_Enabled: false, + P2P_roomID: "", + P2P_passphrase: "", + }; + const ribbon = { addClass: vi.fn(), remove: vi.fn() }; + const addRibbonIcon = vi.fn(() => ribbon); + const host = { + services: { + context: createServiceContext(), + API: { + showWindow: vi.fn(async () => undefined), + showWindowOnRight: vi.fn(async () => undefined), + registerWindow: vi.fn(), + addCommand: vi.fn(), + addRibbonIcon, + getPlatform: vi.fn(() => "desktop"), + }, + appLifecycle: { + onInitialise: { + addHandler: vi.fn((handler) => { + initialise = handler; + }), + }, + onSettingLoaded: { + addHandler: vi.fn((handler) => { + settingLoaded = handler; + }), + }, + onLayoutReady: { addHandler: vi.fn() }, + }, + setting: { + currentSettings: vi.fn(() => currentSettings), + onSettingSaved: { + addHandler: vi.fn((handler) => { + onSettingSaved = handler; + }), + }, + }, + replicator: { runFiniteReplicationActivity: vi.fn() }, + }, + } as any; + + useP2PReplicatorUI(host, {} as any, { replicator: undefined } as any); + await initialise?.(); + await settingLoaded?.(); + expect(addRibbonIcon).not.toHaveBeenCalled(); + + currentSettings = { + ...currentSettings, + remoteConfigurations: { + peer: { + id: "peer", + name: "Peer", + uri: "sls+p2p://room?passphrase=secret", + isEncrypted: false, + }, + }, + }; + await onSettingSaved?.(currentSettings); + expect(addRibbonIcon).toHaveBeenCalledOnce(); + + await onSettingSaved?.(currentSettings); + expect(addRibbonIcon).toHaveBeenCalledOnce(); + + currentSettings = { + ...currentSettings, + remoteConfigurations: {}, + }; + await onSettingSaved?.(currentSettings); + expect(ribbon.remove).toHaveBeenCalledOnce(); + }); + + it("compatibility: migrates a restored P2P leaf to the current status view without opening another leaf", async () => { + let layoutReady: (() => Promise) | undefined; + const legacyLeaf = { + setViewState: vi.fn(async () => undefined), + }; + const workspace = { + getLeavesOfType: vi.fn((type: string) => (type === "p2p-replicator" ? [legacyLeaf] : [])), + }; + const context = createServiceContext() as ReturnType & { + app: { workspace: typeof workspace }; + }; + context.app = { workspace }; + const showWindow = vi.fn(async () => undefined); + const showWindowOnRight = vi.fn(async () => undefined); + const host = { + services: { + context, + API: { + showWindow, + showWindowOnRight, + registerWindow: vi.fn(), + addCommand: vi.fn(), + addRibbonIcon: vi.fn(), + getPlatform: vi.fn(() => "desktop"), + }, + appLifecycle: { + onInitialise: { addHandler: vi.fn() }, + onSettingLoaded: { addHandler: vi.fn() }, + onLayoutReady: { + addHandler: vi.fn((handler) => { + layoutReady = handler; + }), + }, + }, + setting: { + currentSettings: vi.fn(() => ({ + remoteType: "COUCHDB", + remoteConfigurations: {}, + })), + }, + replicator: { runFiniteReplicationActivity: vi.fn() }, + }, + } as any; + + useP2PReplicatorUI(host, {} as any, { replicator: undefined } as any); + await layoutReady?.(); + + expect(legacyLeaf.setViewState).toHaveBeenCalledWith({ + type: "p2p-status", + active: false, + }); + expect(showWindow).not.toHaveBeenCalled(); + expect(showWindowOnRight).not.toHaveBeenCalled(); + }); }); diff --git a/src/serviceFeatures/useReviewHarness.ts b/src/serviceFeatures/useReviewHarness.ts new file mode 100644 index 00000000..04d42755 --- /dev/null +++ b/src/serviceFeatures/useReviewHarness.ts @@ -0,0 +1,125 @@ +import { NEW_VAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/settings"; +import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger"; +import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult"; +import type ObsidianLiveSyncPlugin from "@/main"; +import type { LiveSyncCore } from "@/main"; +import type { WorkspaceLeaf } from "@/deps"; +import { + ReviewHarnessController, + REVIEW_HARNESS_STATE_KEY, + type ReviewHarnessRuntime, +} from "@/features/ReviewHarness/reviewHarnessController"; +import { ReviewHarnessView, VIEW_TYPE_REVIEW_HARNESS } from "@/features/ReviewHarness/ReviewHarnessView"; +import type { ReviewHarnessScenarioResult } from "@/features/ReviewHarness/reviewHarnessContract"; +import { + REVIEW_HARNESS_FIXTURE_ROOT, + runReviewHarnessVaultRoundTrip, +} from "@/features/ReviewHarness/reviewHarnessVaultFixture"; +import type { CompatibilityReviewController } from "./compatibilityReview"; + +async function runVaultRoundTrip(plugin: ObsidianLiveSyncPlugin): Promise { + const vault = plugin.app.vault; + return runReviewHarnessVaultRoundTrip({ + confirmFixtureAccess: async () => + (await plugin.core.services.UI.confirm.askYesNoDialog( + "This scenario creates, reads, modifies, renames, and removes one owned fixture tree. Use a dedicated test Vault. Continue?", + { + title: "Review Harness: Vault fixture access", + defaultOption: "No", + } + )) === "yes", + fixtureRootExists: () => vault.getAbstractFileByPath(REVIEW_HARNESS_FIXTURE_ROOT) !== null, + createFixtureRoot: async () => { + await vault.createFolder(REVIEW_HARNESS_FIXTURE_ROOT); + }, + createFile: (path, content) => vault.create(path, content), + readFile: (file) => vault.read(file), + modifyFile: (file, content) => vault.modify(file, content), + renameFile: (file, path) => vault.rename(file, path), + filePath: (file) => file.path, + removeFixtureRoot: async () => { + const fixtureRoot = vault.getAbstractFileByPath(REVIEW_HARNESS_FIXTURE_ROOT); + if (fixtureRoot) await plugin.app.fileManager.trashFile(fixtureRoot); + }, + }); +} + +export function useReviewHarness( + core: LiveSyncCore, + plugin: ObsidianLiveSyncPlugin, + p2p: UseP2PReplicatorResult, + compatibilityReview: CompatibilityReviewController +): ReviewHarnessController { + const services = core.services; + const runtime: ReviewHarnessRuntime = { + now: () => new Date(), + getSettings: () => services.setting.currentSettings(), + getNewVaultSettings: () => NEW_VAULT_SETTINGS, + getSettingsMigrationState: () => services.setting.getSettingsMigrationState(), + isCompatibilityReviewInitialised: () => compatibilityReview.initialised, + getCompatibilityPause: () => compatibilityReview.pendingPause, + openCompatibilityReview: () => compatibilityReview.openReview(), + getP2PComposition: () => ({ + first: p2p.replicator, + second: p2p.replicator, + expectedServices: services, + }), + runVaultRoundTrip: () => runVaultRoundTrip(plugin), + readContinuation: () => services.setting.getSmallConfig(REVIEW_HARNESS_STATE_KEY), + writeContinuation: (value) => services.setting.setSmallConfig(REVIEW_HARNESS_STATE_KEY, value), + deleteContinuation: () => services.setting.deleteSmallConfig(REVIEW_HARNESS_STATE_KEY), + restart: () => services.appLifecycle.performRestart(), + reportError: (error) => services.API.addLog(error, LOG_LEVEL_NOTICE), + copyText: async (value) => { + if (!activeWindow.navigator.clipboard) throw new Error("Clipboard access is unavailable on this device."); + await activeWindow.navigator.clipboard.writeText(value); + }, + getEnvironment: () => ({ + pluginVersion: services.API.getPluginVersion(), + obsidianVersion: services.API.getAppVersion(), + platform: services.API.getPlatform(), + userAgent: activeWindow.navigator.userAgent || "unavailable", + viewport: + typeof activeWindow.innerWidth === "number" && typeof activeWindow.innerHeight === "number" + ? `${activeWindow.innerWidth}x${activeWindow.innerHeight}` + : "unavailable", + }), + }; + const controller = new ReviewHarnessController(runtime); + let continuationConsumed = false; + let registered = false; + let openAfterLayout = false; + + services.appLifecycle.onSettingLoaded.addHandler(() => { + if (!continuationConsumed) { + continuationConsumed = true; + controller.consumeContinuation(); + } + const snapshot = controller.snapshot(); + openAfterLayout = snapshot.resumedRequestId !== null || snapshot.continuationError !== null; + if (!services.setting.currentSettings().enableDebugTools || registered) return Promise.resolve(true); + + registered = true; + services.API.registerWindow( + VIEW_TYPE_REVIEW_HARNESS, + (leaf: WorkspaceLeaf) => new ReviewHarnessView(leaf, controller) + ); + services.API.addCommand({ + id: "open-review-harness", + name: "Open review harness", + callback: () => { + void services.API.showWindow(VIEW_TYPE_REVIEW_HARNESS); + }, + }); + return Promise.resolve(true); + }); + + services.appLifecycle.onLayoutReady.addHandler(() => { + if (openAfterLayout && services.setting.currentSettings().enableDebugTools) { + void services.API.showWindow(VIEW_TYPE_REVIEW_HARNESS); + } + return Promise.resolve(true); + }); + + return controller; +} diff --git a/src/serviceFeatures/useReviewHarness.unit.spec.ts b/src/serviceFeatures/useReviewHarness.unit.spec.ts new file mode 100644 index 00000000..1b3f48e4 --- /dev/null +++ b/src/serviceFeatures/useReviewHarness.unit.spec.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from "vitest"; +import type { CompatibilityPause } from "@/common/databaseCompatibility.ts"; +import { REVIEW_HARNESS_STATE_KEY } from "@/features/ReviewHarness/reviewHarnessController.ts"; +import { useReviewHarness } from "./useReviewHarness.ts"; + +const VIEW_TYPE_REVIEW_HARNESS = "self-hosted-livesync-review-harness"; + +vi.mock("@/features/ReviewHarness/ReviewHarnessView", () => ({ + VIEW_TYPE_REVIEW_HARNESS: "self-hosted-livesync-review-harness", + ReviewHarnessView: class {}, +})); + +function compatibilityPause(): CompatibilityPause { + return { + resumable: true, + reasons: [ + { + source: "settings-schema", + sourceVersion: 9, + currentVersion: 10, + isFromFutureSchema: false, + resumable: true, + reviewReasons: [], + }, + ], + }; +} + +function createFixture(options: { enableDebugTools?: boolean; continuation?: string } = {}) { + const settingLoadedHandlers: Array<() => Promise> = []; + const layoutReadyHandlers: Array<() => Promise> = []; + const local = new Map(); + if (options.continuation) local.set(REVIEW_HARNESS_STATE_KEY, options.continuation); + const settings = { + enableDebugTools: options.enableDebugTools ?? true, + liveSync: false, + syncOnSave: false, + syncOnEditorSave: true, + syncOnStart: false, + syncOnFileOpen: true, + syncAfterMerge: false, + periodicReplication: true, + }; + const services = {} as Record; + const replicator = { env: { services } }; + const api = { + registerWindow: vi.fn(), + addCommand: vi.fn(), + showWindow: vi.fn().mockResolvedValue(undefined), + addLog: vi.fn(), + getPluginVersion: () => "1.0.0-rc.0", + getAppVersion: () => "1.12.7", + getPlatform: () => "desktop", + }; + Object.assign(services, { + setting: { + currentSettings: () => settings, + getSettingsMigrationState: () => ({ + sourceVersion: 9, + targetVersion: 10, + isNewVault: false, + isFromFutureSchema: false, + changed: true, + requiresSyncReview: true, + reviewReasons: [], + }), + getSmallConfig: (key: string) => local.get(key) ?? "", + setSmallConfig: (key: string, value: string) => local.set(key, value), + deleteSmallConfig: (key: string) => local.delete(key), + }, + appLifecycle: { + onSettingLoaded: { + addHandler: vi.fn((handler: () => Promise) => settingLoadedHandlers.push(handler)), + }, + onLayoutReady: { + addHandler: vi.fn((handler: () => Promise) => layoutReadyHandlers.push(handler)), + }, + performRestart: vi.fn(), + }, + API: api, + UI: { + confirm: { askYesNoDialog: vi.fn().mockResolvedValue("no") }, + }, + }); + let pause: CompatibilityPause | undefined = compatibilityPause(); + const compatibilityReview = { + initialised: true, + get pendingPause() { + return pause; + }, + openReview: vi.fn(async () => { + pause = undefined; + }), + }; + const core = { services }; + const plugin = { + core, + app: { + vault: {}, + fileManager: {}, + }, + }; + + const controller = useReviewHarness(core as never, plugin as never, { replicator } as never, compatibilityReview as never); + return { + controller, + api, + settings, + local, + settingLoadedHandlers, + layoutReadyHandlers, + compatibilityReview, + }; +} + +describe("Review Harness composition", () => { + it("does not register the command or view when developer debug tools are disabled", async () => { + const fixture = createFixture({ enableDebugTools: false }); + + await fixture.settingLoadedHandlers[0](); + + expect(fixture.api.registerWindow).not.toHaveBeenCalled(); + expect(fixture.api.addCommand).not.toHaveBeenCalled(); + }); + + it("removes a one-shot continuation before reopening the Harness after layout", async () => { + const requestedAt = "2026-07-18T11:59:00.000Z"; + const continuation = JSON.stringify({ + formatVersion: 1, + requestId: `compatibility-review-${requestedAt}`, + scenarioId: "compatibility-review", + stage: "awaiting-restart", + requestedAt, + }); + const fixture = createFixture({ continuation }); + + await fixture.settingLoadedHandlers[0](); + + expect(fixture.local.has(REVIEW_HARNESS_STATE_KEY)).toBe(false); + expect(fixture.controller.snapshot().resumedRequestId).toBe(`compatibility-review-${requestedAt}`); + expect(fixture.api.registerWindow).toHaveBeenCalledWith(VIEW_TYPE_REVIEW_HARNESS, expect.any(Function)); + + await fixture.layoutReadyHandlers[0](); + + expect(fixture.api.showWindow).toHaveBeenCalledWith(VIEW_TYPE_REVIEW_HARNESS); + }); + + it("uses the actual compatibility controller as the guided review boundary", async () => { + const fixture = createFixture(); + + await fixture.controller.runScenario("compatibility-review"); + expect(fixture.controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user"); + + await fixture.controller.openCompatibilityReview(); + + expect(fixture.compatibilityReview.openReview).toHaveBeenCalledOnce(); + expect(fixture.controller.snapshot().results["compatibility-review"].status).toBe("passed"); + }); +}); diff --git a/src/serviceModules/DatabaseFileAccess.ts b/src/serviceModules/DatabaseFileAccess.ts index 645888ef..93bdb0a3 100644 --- a/src/serviceModules/DatabaseFileAccess.ts +++ b/src/serviceModules/DatabaseFileAccess.ts @@ -1,8 +1,8 @@ -import type { DatabaseFileAccess } from "@lib/interfaces/DatabaseFileAccess.ts"; -import { ServiceDatabaseFileAccessBase } from "@lib/serviceModules/ServiceDatabaseFileAccessBase"; +import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess"; +import { ServiceDatabaseFileAccessBase } from "@vrtmrz/livesync-commonlib/compat/serviceModules/ServiceDatabaseFileAccessBase"; // markChangesAreSame uses persistent data implicitly, we should refactor it too. // For now, to make the refactoring done once, we just use them directly. -// Hence it is not on /src/lib/src/serviceModules. (markChangesAreSame is using indexedDB). +// Hence it remains in the plug-in rather than Commonlib. (markChangesAreSame is using indexedDB). // Refactored, now migrating... export class ServiceDatabaseFileAccess extends ServiceDatabaseFileAccessBase implements DatabaseFileAccess {} diff --git a/src/serviceModules/FileAccessObsidian.ts b/src/serviceModules/FileAccessObsidian.ts index 5b318cc8..2d5cacb0 100644 --- a/src/serviceModules/FileAccessObsidian.ts +++ b/src/serviceModules/FileAccessObsidian.ts @@ -1,5 +1,5 @@ import { type App } from "@/deps"; -import { FileAccessBase, type FileAccessBaseDependencies } from "@lib/serviceModules/FileAccessBase.ts"; +import { FileAccessBase, type FileAccessBaseDependencies } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase"; import { ObsidianFileSystemAdapter } from "./FileSystemAdapters/ObsidianFileSystemAdapter"; /** diff --git a/src/serviceModules/FileHandler.ts b/src/serviceModules/FileHandler.ts index c47cc337..46febb4a 100644 --- a/src/serviceModules/FileHandler.ts +++ b/src/serviceModules/FileHandler.ts @@ -1,7 +1,7 @@ -import { ServiceFileHandlerBase } from "@lib/serviceModules/ServiceFileHandlerBase"; +import { ServiceFileHandlerBase } from "@vrtmrz/livesync-commonlib/compat/serviceModules/ServiceFileHandlerBase"; // markChangesAreSame uses persistent data implicitly, we should refactor it too. // also, compareFileFreshness depends on marked changes, so we should refactor it as well. For now, to make the refactoring done once, we just use them directly. -// Hence it is not on /src/lib/src/serviceModules. (markChangesAreSame is using indexedDB). +// Hence it remains in the plug-in rather than Commonlib. (markChangesAreSame is using indexedDB). // Refactored: markChangesAreSame, unmarkChanges, compareFileFreshness, isMarkedAsSameChanges are now moved to PathService export class ServiceFileHandler extends ServiceFileHandlerBase {} diff --git a/src/serviceModules/FileReflectionProvenance.ts b/src/serviceModules/FileReflectionProvenance.ts new file mode 100644 index 00000000..f07ef4b6 --- /dev/null +++ b/src/serviceModules/FileReflectionProvenance.ts @@ -0,0 +1,27 @@ +import { + StoredFileReflectionProvenance, + type FileReflectionProvenanceRecord, +} from "@vrtmrz/livesync-commonlib/compat/interfaces/FileReflectionProvenance"; +import type { SimpleStore } from "@vrtmrz/livesync-commonlib/compat/common/utils"; + +export const FILE_REFLECTION_PROVENANCE_STORE = "file-reflection-provenance-v1"; + +export type FileReflectionProvenanceStoreFactory = { + openSimpleStore(kind: string): SimpleStore; +}; + +/** + * Create the device-local record which links a Vault file to the exact + * database revision most recently reflected in that Vault. + * + * This runs during service composition, before KeyValueDB is opened. The + * returned namespaced handle is inert until its first operation; normal hosts + * complete the sequential onSettingLoaded lifecycle before Vault scanning, + * watching, or replication can invoke it. Operations are never held waiting for + * readiness; they fail on a lifecycle violation and may fail during reset. + */ +export function createFileReflectionProvenance(keyValueDB: FileReflectionProvenanceStoreFactory) { + return new StoredFileReflectionProvenance( + keyValueDB.openSimpleStore(FILE_REFLECTION_PROVENANCE_STORE) + ); +} diff --git a/src/serviceModules/FileReflectionProvenance.unit.spec.ts b/src/serviceModules/FileReflectionProvenance.unit.spec.ts new file mode 100644 index 00000000..6eadf3dc --- /dev/null +++ b/src/serviceModules/FileReflectionProvenance.unit.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import type { SimpleStore } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import type { FileReflectionProvenanceRecord } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileReflectionProvenance"; +import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { + createFileReflectionProvenance, + FILE_REFLECTION_PROVENANCE_STORE, +} from "./FileReflectionProvenance"; + +describe("createFileReflectionProvenance", () => { + it("uses one reset-scoped host store for exact reflected revisions", async () => { + const values = new Map(); + const store = { + get: vi.fn(async (key: string) => values.get(key)), + set: vi.fn(async (key: string, value: FileReflectionProvenanceRecord) => { + values.set(key, value); + }), + delete: vi.fn(async (key: string) => { + values.delete(key); + }), + keys: vi.fn(async () => [...values.keys()]), + db: undefined, + } as unknown as SimpleStore; + const openSimpleStore = vi.fn().mockReturnValue(store); + const path = "note.md" as FilePathWithPrefix; + + const provenance = createFileReflectionProvenance({ openSimpleStore }); + expect(openSimpleStore).toHaveBeenCalledWith(FILE_REFLECTION_PROVENANCE_STORE); + await provenance.set(path, { revision: "3-displayed", observedStorageMtime: 123.456 }); + + expect(openSimpleStore).toHaveBeenCalledTimes(1); + await expect(provenance.get(path)).resolves.toEqual({ + revision: "3-displayed", + observedStorageMtime: 123.456, + }); + }); +}); diff --git a/src/serviceModules/FileSystemAdapters/ObsidianConversionAdapter.ts b/src/serviceModules/FileSystemAdapters/ObsidianConversionAdapter.ts index 02957595..179d4d9d 100644 --- a/src/serviceModules/FileSystemAdapters/ObsidianConversionAdapter.ts +++ b/src/serviceModules/FileSystemAdapters/ObsidianConversionAdapter.ts @@ -1,5 +1,5 @@ -import type { UXFileInfoStub, UXFolderInfo } from "@lib/common/types"; -import type { IConversionAdapter } from "@lib/serviceModules/adapters"; +import type { UXFileInfoStub, UXFolderInfo } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { IConversionAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters"; import { TFileToUXFileInfoStub, TFolderToUXFileInfoStub } from "@/modules/coreObsidian/storageLib/utilObsidian"; import type { TFile, TFolder } from "obsidian"; diff --git a/src/serviceModules/FileSystemAdapters/ObsidianFileSystemAdapter.ts b/src/serviceModules/FileSystemAdapters/ObsidianFileSystemAdapter.ts index be527df5..534d1063 100644 --- a/src/serviceModules/FileSystemAdapters/ObsidianFileSystemAdapter.ts +++ b/src/serviceModules/FileSystemAdapters/ObsidianFileSystemAdapter.ts @@ -1,4 +1,4 @@ -import type { FilePath, UXStat } from "@lib/common/types"; +import type { FilePath, UXStat } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { IFileSystemAdapter, IPathAdapter, @@ -6,7 +6,7 @@ import type { IConversionAdapter, IStorageAdapter, IVaultAdapter, -} from "@lib/serviceModules/adapters"; +} from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters"; import type { TAbstractFile, TFile, TFolder, Stat, App } from "obsidian"; import { ObsidianConversionAdapter } from "./ObsidianConversionAdapter"; import { ObsidianPathAdapter } from "./ObsidianPathAdapter"; @@ -54,6 +54,11 @@ export class ObsidianFileSystemAdapter implements IFileSystemAdapter { + await this.vault.rename(file, newPath); + return file; + } + statFromNative(file: TFile): Promise { return Promise.resolve({ ...file.stat, type: "file" }); } diff --git a/src/serviceModules/FileSystemAdapters/ObsidianPathAdapter.ts b/src/serviceModules/FileSystemAdapters/ObsidianPathAdapter.ts index a21ced14..6557c7b0 100644 --- a/src/serviceModules/FileSystemAdapters/ObsidianPathAdapter.ts +++ b/src/serviceModules/FileSystemAdapters/ObsidianPathAdapter.ts @@ -1,6 +1,6 @@ import { type TAbstractFile, normalizePath } from "@/deps"; -import type { FilePath } from "@lib/common/types"; -import type { IPathAdapter } from "@lib/serviceModules/adapters"; +import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { IPathAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters"; /** * Path adapter implementation for Obsidian diff --git a/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.ts b/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.ts index 508b64a9..0109e282 100644 --- a/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.ts +++ b/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.ts @@ -1,6 +1,6 @@ -import type { UXDataWriteOptions } from "@lib/common/types"; -import type { IStorageAdapter } from "@lib/serviceModules/adapters"; -import { toArrayBuffer } from "@lib/serviceModules/FileAccessBase"; +import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { IStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters"; +import { toArrayBuffer } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase"; import type { Stat, App } from "obsidian"; import { toIntegerTimestamps } from "./sanitizeWriteOptions"; diff --git a/src/serviceModules/FileSystemAdapters/ObsidianTypeGuardAdapter.ts b/src/serviceModules/FileSystemAdapters/ObsidianTypeGuardAdapter.ts index 74e05bd5..656fabc2 100644 --- a/src/serviceModules/FileSystemAdapters/ObsidianTypeGuardAdapter.ts +++ b/src/serviceModules/FileSystemAdapters/ObsidianTypeGuardAdapter.ts @@ -1,4 +1,4 @@ -import type { ITypeGuardAdapter } from "@lib/serviceModules/adapters"; +import type { ITypeGuardAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters"; import { TFile, TFolder } from "obsidian"; /** diff --git a/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.ts b/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.ts index 6a5c021b..96486a78 100644 --- a/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.ts +++ b/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.ts @@ -1,6 +1,6 @@ -import type { UXDataWriteOptions } from "@lib/common/types"; -import type { IVaultAdapter } from "@lib/serviceModules/adapters"; -import { toArrayBuffer } from "@lib/serviceModules/FileAccessBase"; +import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { IVaultAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters"; +import { toArrayBuffer } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase"; import type { TFile, App, TFolder } from "obsidian"; import { toIntegerTimestamps } from "./sanitizeWriteOptions"; @@ -11,7 +11,8 @@ export class ObsidianVaultAdapter implements IVaultAdapter { constructor(private app: App) {} async read(file: TFile): Promise { - return await this.app.vault.read(file); + // Vault.read strips a leading UTF-8 BOM, leaving the content size inconsistent with TFile.stat. + return await this.app.vault.adapter.read(file.path); } async cachedRead(file: TFile): Promise { @@ -38,22 +39,16 @@ export class ObsidianVaultAdapter implements IVaultAdapter { return await this.app.vault.createBinary(path, toArrayBuffer(data), toIntegerTimestamps(options)); } - async delete(file: TFile | TFolder, force = false): Promise { - if ("trashFile" in this.app.fileManager) { - // eslint-disable-next-line obsidianmd/no-unsupported-api - return await this.app.fileManager.trashFile(file); - } - // eslint-disable-next-line obsidianmd/prefer-file-manager-trash-file -- Fallback for older versions of Obsidian without trashFile support - return await this.app.vault.delete(file, force); + async rename(file: TFile, newPath: string): Promise { + return await this.app.vault.rename(file, newPath); } - async trash(file: TFile | TFolder, force = false): Promise { - if ("trashFile" in this.app.fileManager) { - // eslint-disable-next-line obsidianmd/no-unsupported-api - return await this.app.fileManager.trashFile(file); - } - // eslint-disable-next-line obsidianmd/prefer-file-manager-trash-file -- Fallback for older versions of Obsidian without trashFile support - return await this.app.vault.trash(file, force); + async delete(file: TFile | TFolder): Promise { + return await this.app.fileManager.trashFile(file); + } + + async trash(file: TFile | TFolder): Promise { + return await this.app.fileManager.trashFile(file); } trigger(name: string, ...data: unknown[]): void { diff --git a/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.unit.spec.ts b/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.unit.spec.ts new file mode 100644 index 00000000..5a307d52 --- /dev/null +++ b/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.unit.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import type { App, TFile } from "obsidian"; +import { ObsidianVaultAdapter } from "./ObsidianVaultAdapter"; + +describe("ObsidianVaultAdapter.read", () => { + it("preserves a UTF-8 BOM so the content size matches the file stat", async () => { + const path = "Transcripts/å­—åš•.md"; + const contentWithoutBom = "字嚕ぎ検č¨ŧčĄŒã§ã™ã€‚\n"; + const contentWithBom = `\ufeff${contentWithoutBom}`; + const read = vi.fn().mockResolvedValue(contentWithoutBom); + const adapterRead = vi.fn().mockResolvedValue(contentWithBom); + const app = { + vault: { + read, + adapter: { + read: adapterRead, + }, + }, + } as unknown as App; + const file = { + path, + stat: { + ctime: 1, + mtime: 2, + size: new Blob([contentWithBom]).size, + }, + } as TFile; + const adapter = new ObsidianVaultAdapter(app); + + const result = await adapter.read(file); + + expect(new Blob([result]).size).toBe(file.stat.size); + expect(result.charCodeAt(0)).toBe(0xfeff); + expect(adapterRead).toHaveBeenCalledWith(path); + expect(read).not.toHaveBeenCalled(); + }); +}); diff --git a/src/serviceModules/ServiceFileAccessImpl.ts b/src/serviceModules/ServiceFileAccessImpl.ts index 204855ef..c2b40187 100644 --- a/src/serviceModules/ServiceFileAccessImpl.ts +++ b/src/serviceModules/ServiceFileAccessImpl.ts @@ -1,4 +1,4 @@ -import { ServiceFileAccessBase } from "@lib/serviceModules/ServiceFileAccessBase"; +import { ServiceFileAccessBase } from "@vrtmrz/livesync-commonlib/compat/serviceModules/ServiceFileAccessBase"; import type { ObsidianFileSystemAdapter } from "./FileSystemAdapters/ObsidianFileSystemAdapter"; // For now, this is just a re-export of ServiceFileAccess with the Obsidian-specific adapter type. diff --git a/src/types.ts b/src/types.ts index b4c40bfd..bb3bb5c5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,8 @@ -import type { DatabaseFileAccess } from "@lib/interfaces/DatabaseFileAccess"; -import type { Rebuilder } from "@lib/interfaces/DatabaseRebuilder"; -import type { IFileHandler } from "@lib/interfaces/FileHandler"; -import type { StorageAccess } from "@lib/interfaces/StorageAccess"; -import type { IServiceHub } from "@lib/services/base/IService"; +import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess"; +import type { Rebuilder } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseRebuilder"; +import type { IFileHandler } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileHandler"; +import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess"; +import type { IServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/base/IService"; export interface ServiceModules { storageAccess: StorageAccess; diff --git a/styles.css b/styles.css index e478615e..b5bf7ac6 100644 --- a/styles.css +++ b/styles.css @@ -12,13 +12,26 @@ background-color: var(--text-muted); } +.vpk-action-dialog__message { + user-select: text; + -webkit-user-select: text; +} + .conflict-dev-name { display: inline-block; min-width: 5em; } +.conflict-action-container { + display: flex; + flex-direction: column; + gap: var(--size-4-2); + margin-top: var(--size-4-2); +} + .conflict-action-button { - margin-right: 4px; + width: 100%; + margin: 0; } .op-scrollable { @@ -138,8 +151,8 @@ div.sls-setting-menu-btn { /* width: 100%; */ } -.sls-setting-tab:hover~div.sls-setting-menu-btn, -.sls-setting-label.selected .sls-setting-tab:checked~div.sls-setting-menu-btn { +.sls-setting-tab:hover ~ div.sls-setting-menu-btn, +.sls-setting-label.selected .sls-setting-tab:checked ~ div.sls-setting-menu-btn { background-color: var(--interactive-accent); color: var(--text-on-accent); } @@ -174,9 +187,16 @@ body { /* padding: 2px; */ margin: 1px; border-radius: 4px; - 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)); + 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) + ); background-size: 30px 30px; display: flex; flex-direction: row; @@ -196,7 +216,6 @@ body { 100% { background-position: 30px 0; } - } .sls-setting-menu-buttons label { @@ -301,33 +320,95 @@ body { background-color: rgba(var(--background-modifier-error-rgb), 0.3); } -.sls-setting-disabled input[type=text], -.sls-setting-disabled input[type=number], -.sls-setting-disabled input[type=password] { +.sls-setting-disabled input[type="text"], +.sls-setting-disabled input[type="number"], +.sls-setting-disabled input[type="password"] { filter: brightness(80%); color: var(--text-muted); - } .sls-setting-hidden { display: none; } - - .sls-setting-obsolete { /* 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)); */ - 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-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; } -.password-input>.setting-item-control>input { +.password-input > .setting-item-control > input { -webkit-text-security: disc; } +.sls-onboarding-invitation-action { + display: inline-flex; + min-width: 44px; + min-height: 44px; + align-items: center; + justify-content: center; +} + +.sls-review-harness { + box-sizing: border-box; + max-width: 100%; + overflow-x: clip; + padding-bottom: max(1rem, env(safe-area-inset-bottom)); +} + +.sls-review-harness .setting-item, +.sls-review-harness .setting-item-control { + flex-wrap: wrap; + max-width: 100%; +} + +.sls-review-harness .setting-item-info { + min-width: min(16rem, 100%); +} + +.sls-review-harness .setting-item-control { + gap: 0.5rem; +} + +.sls-review-harness button { + min-height: 44px; + max-width: 100%; + white-space: normal; +} + +.sls-review-harness__result, +.sls-review-harness__warning, +.sls-review-harness__privacy, +.sls-review-harness__error, +.sls-review-harness__resumed { + max-width: 100%; + overflow-wrap: anywhere; +} + +.sls-review-harness__result { + margin: 0 0 1rem; +} + +.sls-review-harness__warning, +.sls-review-harness__error { + color: var(--text-error); +} + +.sls-review-harness__resumed { + color: var(--text-success); +} + span.ls-mark-cr::after { user-select: none; content: "↲"; @@ -378,7 +459,6 @@ span.ls-mark-cr::after { } } - .livesync-status { user-select: none; pointer-events: none; @@ -401,18 +481,30 @@ span.ls-mark-cr::after { font-size: 80%; } -div.workspace-leaf-content[data-type=bases] .livesync-status { +.is-mobile.is-ios .livesync-status { + box-sizing: border-box; + top: calc(var(--header-height) + var(--view-header-height, var(--header-height)) + var(--size-4-2)); +} + +div.workspace-leaf-content[data-type="bases"] .livesync-status { top: calc(var(--bases-header-height) + var(--header-height)); padding: 5px; padding-right: 18px; } -.is-mobile div.workspace-leaf-content[data-type=bases] .livesync-status { +.is-mobile div.workspace-leaf-content[data-type="bases"] .livesync-status { top: calc(var(--bases-header-height) + var(--view-header-height)); padding: 6px; padding-right: 18px; } +.is-mobile.is-ios div.workspace-leaf-content[data-type="bases"] .livesync-status { + top: calc( + var(--bases-header-height) + var(--header-height) + var(--view-header-height, var(--header-height)) + + var(--size-4-2) + ); +} + .livesync-status div { opacity: 0.6; -webkit-filter: grayscale(100%); @@ -422,7 +514,6 @@ div.workspace-leaf-content[data-type=bases] .livesync-status { .livesync-status .livesync-status-loghistory { text-align: left; opacity: 0.4; - } .livesync-status div.livesync-status-messagearea:empty { @@ -440,7 +531,6 @@ div.workspace-leaf-content[data-type=bases] .livesync-status { margin-left: auto; } - .menu-setting-poweruser-disabled .sls-setting-poweruser { display: none; } @@ -459,7 +549,7 @@ div.workspace-leaf-content[data-type=bases] .livesync-status { top: 2.5em; background-color: var(--background-secondary-alt); border-radius: 10px; - padding: 0.5em 1.0em; + padding: 0.5em 1em; } .active-pane .sls-setting-panel-title { @@ -476,6 +566,34 @@ div.workspace-leaf-content[data-type=bases] .livesync-status { font-size: 0.8em; } +body.is-mobile .livesync-message-box-container { + box-sizing: border-box; + padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px)); + padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px)); + padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)); + padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px)); +} + +body.is-mobile .livesync-message-box-container .modal { + max-height: 100%; +} + +body.is-mobile .livesync-message-box-container button { + height: auto; + min-height: var(--input-height); + white-space: normal; +} + +body.is-mobile .livesync-compatibility-review-notice { + box-sizing: border-box; + margin-right: calc(64px + var(--safe-area-inset-right, env(safe-area-inset-right, 0px))); + min-width: 0; + width: calc( + 100vw - 88px - var(--safe-area-inset-left, env(safe-area-inset-left, 0px)) - + var(--safe-area-inset-right, env(safe-area-inset-right, 0px)) + ); +} + .sls-qr { display: flex; justify-content: center; @@ -488,7 +606,98 @@ div.workspace-leaf-content[data-type=bases] .livesync-status { overflow-x: auto; white-space: pre-wrap; word-break: break-all; +} +.sls-repair-results { + display: grid; + gap: var(--size-4-3); +} + +.sls-repair-result { + padding: var(--size-4-3); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + background: var(--background-secondary); +} + +.sls-repair-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--size-4-2); + min-width: 0; +} + +.sls-repair-header > :first-child { + flex: 1 1 auto; + min-width: 0; +} + +.sls-repair-header h6 { + margin: 0; + overflow-wrap: anywhere; +} + +.sls-repair-status { + display: flex; + flex-wrap: wrap; + gap: var(--size-4-2); + margin-top: var(--size-4-1); + font-size: var(--font-ui-smaller); +} + +.sls-repair-status-ok { + color: var(--text-success); +} + +.sls-repair-status-warning { + color: var(--text-warning); +} + +.sls-repair-metric { + margin-top: var(--size-4-1); + font-size: var(--font-ui-smaller); + line-height: var(--line-height-tight); + overflow-wrap: anywhere; +} + +.sls-repair-action-menu { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: var(--clickable-icon-size); + width: var(--clickable-icon-size); + height: var(--clickable-icon-size); + padding: 0; +} + +.sls-repair-action-menu .svg-icon { + width: 18px; + height: 18px; +} + +.sls-repair-revision { + margin-top: var(--size-4-2); + padding: var(--size-4-2); + border-left: 3px solid var(--background-modifier-border); + background: var(--background-primary-alt); +} + +.sls-repair-revision-title { + font-weight: var(--font-semibold); + overflow-wrap: anywhere; +} + +.sls-repair-revision code { + display: block; + margin-top: var(--size-4-1); + overflow-wrap: anywhere; + white-space: normal; +} + +.sls-repair-ancestor-warning { + margin-top: var(--size-4-2); + color: var(--text-warning); } /* Diff navigation */ @@ -551,6 +760,51 @@ div.workspace-leaf-content[data-type=bases] .livesync-status { color: var(--text-muted); } +.history-search-row button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.history-rev-nav-row { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} + +.history-rev-nav-row input[type="range"] { + flex-grow: 1; + margin-bottom: 0; +} + +.history-rev-nav-btn { + flex-shrink: 0; + padding: 2px 10px; + font-size: 0.9em; + cursor: pointer; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background-color: var(--background-secondary); + color: var(--text-normal); +} + +.history-rev-nav-btn:hover:not(:disabled) { + background-color: var(--background-modifier-hover); +} + +.history-rev-nav-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.history-rev-indicator { + flex-shrink: 0; + font-size: 0.85em; + color: var(--text-muted); + min-width: 4.5em; + text-align: right; +} + .history-diff-options-row { justify-content: space-between; } @@ -570,4 +824,3 @@ div.workspace-leaf-content[data-type=bases] .livesync-status { outline-offset: 1px; border-radius: 2px; } - diff --git a/test/apps/browser/SvelteDialogSession.unit.spec.ts b/test/apps/browser/SvelteDialogSession.unit.spec.ts new file mode 100644 index 00000000..f87fe25f --- /dev/null +++ b/test/apps/browser/SvelteDialogSession.unit.spec.ts @@ -0,0 +1,88 @@ +import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; +import type { + ComponentHasResult, + DialogHostProps, + SvelteDialogManagerDependencies, +} from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog"; +import { createServiceContext } from "@vrtmrz/livesync-commonlib/context"; +import type { Component } from "svelte"; +import { describe, expect, it, vi } from "vitest"; + +import { + SvelteDialogSession, + type SvelteDialogRenderer, + type SvelteDialogSurface, +} from "@/modules/services/SvelteDialogSession"; + +describe("SvelteDialogSession", () => { + it("mounts, resolves the selected result, and unmounts through the owning surface", async () => { + const context = createServiceContext(); + const replaceChildren = vi.fn(); + const setTitle = vi.fn(); + const close = vi.fn(); + const mounted = {}; + const unmount = vi.fn().mockResolvedValue(undefined); + let mountedProps: DialogHostProps | undefined; + const renderer: SvelteDialogRenderer = { + mount(_dialogHost, _target, props) { + mountedProps = props as DialogHostProps; + return mounted as never; + }, + unmount, + }; + const session = new SvelteDialogSession({ + surface: { + contentEl: { replaceChildren } as unknown as HTMLElement, + close, + setTitle, + }, + context, + dependencies: {} as SvelteDialogManagerDependencies, + dialogHost: (() => {}) as unknown as Component, + component: (() => {}) as unknown as ComponentHasResult, + initialData: "initial", + renderer, + }); + + session.onOpen(); + const result = session.waitForClose(); + mountedProps?.setTitle("Dialogue title"); + mountedProps?.setResult("selected"); + session.onClose(); + + await expect(result).resolves.toBe("selected"); + expect(replaceChildren).toHaveBeenCalledOnce(); + expect(setTitle).toHaveBeenCalledWith("Dialogue title"); + const getInitialData = mountedProps?.getInitialData; + expect(getInitialData).toBeTypeOf("function"); + expect(getInitialData?.()).toBe("initial"); + await vi.waitFor(() => expect(unmount).toHaveBeenCalledWith(mounted)); + }); + + it("rejects the pending result and closes the surface when the owning context unloads", async () => { + const context = createServiceContext(); + const close = vi.fn(); + const session = new SvelteDialogSession({ + surface: { + contentEl: { replaceChildren: vi.fn() } as unknown as HTMLElement, + close, + setTitle: vi.fn(), + }, + context, + dependencies: {} as SvelteDialogManagerDependencies, + dialogHost: (() => {}) as unknown as Component, + component: (() => {}) as unknown as ComponentHasResult, + renderer: { + mount: () => ({}) as never, + unmount: vi.fn().mockResolvedValue(undefined), + }, + }); + + session.onOpen(); + const result = session.waitForClose(); + context.events.emitEvent(EVENT_PLUGIN_UNLOADED); + + await expect(result).rejects.toThrow("Plug-in unloaded"); + expect(close).toHaveBeenCalledOnce(); + }); +}); diff --git a/test/apps/webapp/FSAPIStorageEventManagerAdapter.unit.spec.ts b/test/apps/webapp/FSAPIStorageEventManagerAdapter.unit.spec.ts new file mode 100644 index 00000000..820b40f0 --- /dev/null +++ b/test/apps/webapp/FSAPIStorageEventManagerAdapter.unit.spec.ts @@ -0,0 +1,34 @@ +import { LOG_LEVEL_INFO } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { IStorageEventWatchHandlers } from "@vrtmrz/livesync-commonlib/compat/managers/adapters"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { FSAPIStorageEventManagerAdapter } from "@/apps/webapp/managers/FSAPIStorageEventManagerAdapter"; +import type { FSAPIFile } from "@/apps/webapp/adapters/FSAPITypes"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("WebApp file watching guidance", () => { + it("offers a capability-based manual fallback when FileSystemObserver is unavailable", async () => { + vi.stubGlobal("FileSystemObserver", undefined); + const addLog = vi.fn(); + const adapter = new FSAPIStorageEventManagerAdapter({} as FileSystemDirectoryHandle, addLog); + const handlers = { + onCreate: vi.fn(), + onChange: vi.fn(), + onDelete: vi.fn(), + onRename: vi.fn(), + onRaw: vi.fn(), + } satisfies IStorageEventWatchHandlers; + + await adapter.watch.beginWatch(handlers); + + expect(addLog).toHaveBeenCalledWith( + "Use 'Scan local files' after external changes", + LOG_LEVEL_INFO, + "fsapi-watch" + ); + expect(addLog.mock.calls.map(([message]) => String(message)).join("\n")).not.toMatch(/Chrome \d+/); + }); +}); diff --git a/test/apps/webapp/WebAppRuntime.unit.spec.ts b/test/apps/webapp/WebAppRuntime.unit.spec.ts new file mode 100644 index 00000000..423bd321 --- /dev/null +++ b/test/apps/webapp/WebAppRuntime.unit.spec.ts @@ -0,0 +1,242 @@ +import { LOG_LEVEL_INFO, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { LiveSyncBrowserServiceHubOptions } from "@/apps/browser/createLiveSyncBrowserServiceHub"; + +const runtimeMocks = vi.hoisted(() => { + const addLog = vi.fn(); + const cleanup = vi.fn(); + const currentSettings = vi.fn(); + const getFiles = vi.fn(); + const onLoad = vi.fn(); + const onReady = vi.fn(); + const onUnload = vi.fn(); + const scanVault = vi.fn(); + const scanDirectory = vi.fn(); + const clearCache = vi.fn(); + const collectFilesOnStorage = vi.fn(); + const updateToDatabase = vi.fn(); + const p2p = { + replicator: {}, + }; + + const serviceHub = { + API: { addLog }, + control: { onLoad, onReady, onUnload }, + setting: { currentSettings }, + vault: { scanVault }, + }; + const platformModules = { + fileHandler: {}, + storageAccess: {}, + storageEventManager: { cleanup }, + vaultAccess: { + fsapiAdapter: { + clearCache, + getFiles, + scanDirectory, + }, + }, + }; + + return { + addLog, + options: undefined as LiveSyncBrowserServiceHubOptions | undefined, + clearCache, + cleanup, + collectFilesOnStorage, + currentSettings, + getFiles, + onLoad, + onReady, + onUnload, + platformModules, + p2p, + scanDirectory, + scanVault, + serviceHub, + updateToDatabase, + }; +}); + +vi.mock("@/apps/browser/createLiveSyncBrowserServiceHub", () => ({ + createLiveSyncBrowserServiceHub: vi.fn((options: LiveSyncBrowserServiceHubOptions) => { + runtimeMocks.options = options; + return runtimeMocks.serviceHub; + }), +})); + +vi.mock("@/apps/webapp/serviceModules/FSAPIServiceModules", () => ({ + initialiseServiceModulesFSAPI: vi.fn(() => runtimeMocks.platformModules), +})); + +vi.mock("@/LiveSyncBaseCore", () => ({ + LiveSyncBaseCore: class { + readonly serviceModules; + readonly services; + + constructor( + services: typeof runtimeMocks.serviceHub, + initialisePlatformModules: (core: unknown, serviceHub: typeof runtimeMocks.serviceHub) => unknown, + _initialiseCoreModules: () => unknown[], + _getAddOns: () => unknown[], + initialiseFeatures: (core: unknown) => void + ) { + this.services = services; + this.serviceModules = initialisePlatformModules(this, services); + initialiseFeatures(this); + } + }, +})); + +vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner", () => ({ + collectFilesOnStorage: runtimeMocks.collectFilesOnStorage, + updateToDatabase: runtimeMocks.updateToDatabase, + useOfflineScanner: vi.fn(), +})); +vi.mock("@/serviceFeatures/redFlag", () => ({ + useRedFlagFeatures: vi.fn(), +})); +vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/checkRemoteSize", () => ({ + useCheckRemoteSize: vi.fn(), +})); +vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig", () => ({ + useRemoteConfiguration: vi.fn(), +})); +vi.mock("@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature", () => ({ + useP2PReplicatorFeature: vi.fn(() => runtimeMocks.p2p), +})); + +import { WebAppRuntime } from "@/apps/webapp/WebAppRuntime"; + +const unconfiguredSettings = { + couchDB_DBNAME: "", + isConfigured: false, +} as ObsidianLiveSyncSettings; + +function createRootHandle(name = "runtime-vault"): FileSystemDirectoryHandle { + return { name } as FileSystemDirectoryHandle; +} + +async function waitForMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("WebAppRuntime lifecycle", () => { + beforeEach(() => { + vi.clearAllMocks(); + runtimeMocks.options = undefined; + runtimeMocks.currentSettings.mockReturnValue(unconfiguredSettings); + runtimeMocks.getFiles.mockResolvedValue([{ path: "one.md" }]); + runtimeMocks.onLoad.mockResolvedValue(true); + runtimeMocks.onReady.mockResolvedValue(undefined); + runtimeMocks.onUnload.mockResolvedValue(undefined); + runtimeMocks.cleanup.mockResolvedValue(undefined); + runtimeMocks.clearCache.mockReturnValue(undefined); + runtimeMocks.collectFilesOnStorage.mockResolvedValue({ + storageFileNameMap: { + "one.md": { + path: "one.md", + stat: { ctime: 1, mtime: 1, size: 3, type: "file" }, + }, + }, + storageFileNames: ["one.md"], + storageFileNameCI2CS: { "one.md": "one.md" }, + }); + runtimeMocks.scanDirectory.mockResolvedValue(undefined); + runtimeMocks.scanVault.mockResolvedValue(false); + runtimeMocks.updateToDatabase.mockResolvedValue(undefined); + }); + + it("owns core start, directory scan, status reporting, and shutdown", async () => { + const reportStatus = vi.fn(); + const runtime = new WebAppRuntime(createRootHandle(), { reportStatus }); + + await runtime.start(); + + expect(runtimeMocks.onLoad).toHaveBeenCalledOnce(); + expect(runtimeMocks.onReady).toHaveBeenCalledOnce(); + expect(runtimeMocks.scanDirectory).toHaveBeenCalledOnce(); + expect(runtimeMocks.getFiles).toHaveBeenCalledOnce(); + expect(runtimeMocks.addLog).toHaveBeenCalledWith("Found 1 files", expect.anything(), "scan"); + expect(reportStatus).toHaveBeenCalledWith( + "warning", + "Warning: Please configure CouchDB connection in settings" + ); + expect(runtime.p2pPaneHost.services).toBe(runtimeMocks.serviceHub); + expect(runtime.p2pPaneHost.p2p).toBe(runtimeMocks.p2p); + expect(runtime.p2pPaneHost.showPeerMenu).toBeUndefined(); + expect("p2pController" in runtime).toBe(false); + + await runtime.shutdown(); + + expect(runtimeMocks.cleanup).toHaveBeenCalledOnce(); + expect(runtimeMocks.onUnload).toHaveBeenCalledOnce(); + }); + + it("imports local files for optional P2P while the main remote remains unconfigured", async () => { + const runtime = new WebAppRuntime(createRootHandle()); + await runtime.start(); + vi.clearAllMocks(); + runtimeMocks.currentSettings.mockReturnValue(unconfiguredSettings); + runtimeMocks.collectFilesOnStorage.mockResolvedValue({ + storageFileNameMap: { + "one.md": { + path: "one.md", + stat: { ctime: 1, mtime: 1, size: 3, type: "file" }, + }, + }, + storageFileNames: ["one.md"], + storageFileNameCI2CS: { "one.md": "one.md" }, + }); + + await expect(runtime.scanLocalFiles()).resolves.toBe(true); + + expect(runtimeMocks.clearCache).toHaveBeenCalledOnce(); + expect(runtimeMocks.scanDirectory).toHaveBeenCalledOnce(); + expect(runtimeMocks.collectFilesOnStorage).toHaveBeenCalledWith( + expect.objectContaining({ + services: runtimeMocks.serviceHub, + serviceModules: runtimeMocks.platformModules, + }), + unconfiguredSettings, + expect.any(Function) + ); + expect(runtimeMocks.updateToDatabase).toHaveBeenCalledOnce(); + expect(runtimeMocks.scanVault).not.toHaveBeenCalled(); + expect(runtimeMocks.currentSettings()).toBe(unconfiguredSettings); + expect(unconfiguredSettings.isConfigured).toBe(false); + }); + + it("rejects a failed core start after cleaning up the partial runtime", async () => { + runtimeMocks.onLoad.mockResolvedValue(false); + const reportStatus = vi.fn(); + const runtime = new WebAppRuntime(createRootHandle(), { reportStatus }); + + await expect(runtime.start()).rejects.toThrow("Failed to initialise LiveSync"); + + expect(runtimeMocks.cleanup).toHaveBeenCalledOnce(); + expect(runtimeMocks.onUnload).toHaveBeenCalledOnce(); + expect(reportStatus).toHaveBeenCalledWith( + "error", + "Error: Failed to start: Error: Failed to initialise LiveSync" + ); + }); + + it("shuts down once before scheduling a host reload", async () => { + const scheduleReload = vi.fn(); + const runtime = new WebAppRuntime(createRootHandle(), { scheduleReload }); + await runtime.start(); + + runtimeMocks.options?.restart?.schedule(); + runtimeMocks.options?.restart?.schedule(); + await waitForMicrotasks(); + + expect(runtimeMocks.options?.restart?.isScheduled?.()).toBe(true); + expect(runtimeMocks.cleanup).toHaveBeenCalledOnce(); + expect(runtimeMocks.onUnload).toHaveBeenCalledOnce(); + expect(scheduleReload).toHaveBeenCalledOnce(); + expect(scheduleReload).toHaveBeenCalledWith(1_000); + expect(runtimeMocks.addLog).toHaveBeenCalledWith("Restart requested", LOG_LEVEL_INFO, "app-lifecycle"); + }); +}); diff --git a/test/apps/webpeer/P2PCheckSetup.unit.spec.ts b/test/apps/webpeer/P2PCheckSetup.unit.spec.ts new file mode 100644 index 00000000..a3114ada --- /dev/null +++ b/test/apps/webpeer/P2PCheckSetup.unit.spec.ts @@ -0,0 +1,131 @@ +import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting"; +import { DEFAULT_SETTINGS, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString"; +import { describe, expect, it } from "vitest"; + +import { + P2P_CHECK_APP_ID, + generateP2PCheckSetup, + resolveLocalP2PCheckRelayOverride, +} from "@/apps/webpeer/src/P2PCheckSetup"; + +describe("P2P connection-check setup", () => { + it.each(["desktop", "mobile"] as const)( + "creates an isolated, disposable %s Setup URI and diagnostic browser peer", + async (target) => { + const generated = await generateP2PCheckSetup(target); + const decoded = await decodeSettingsFromSetupURI(generated.setupURI, generated.setupPassphrase); + + expect(decoded).not.toBe(false); + if (decoded === false) { + throw new Error("The generated Setup URI could not be decoded"); + } + const effective = { ...DEFAULT_SETTINGS, ...decoded }; + + expect(generated.target).toBe(target); + expect(generated.setupPassphrase).toMatch(/^[a-z2-9]{4}(?:-[a-z2-9]{4}){3}$/); + expect(generated.setupURI).toMatch(/^obsidian:\/\/setuplivesync\?settings=/); + expect(effective).toEqual( + expect.objectContaining({ + remoteType: REMOTE_P2P, + isConfigured: true, + encrypt: true, + usePathObfuscation: true, + P2P_Enabled: true, + P2P_AppID: P2P_CHECK_APP_ID, + P2P_roomID: generated.groupId, + P2P_AutoStart: true, + P2P_AutoBroadcast: false, + }) + ); + expect(decoded.P2P_DevicePeerName).toBeUndefined(); + expect(decoded.P2P_useDiagRTC).toBeUndefined(); + expect(effective.passphrase).toHaveLength(32); + expect(effective.P2P_passphrase).toHaveLength(32); + expect(effective.passphrase).not.toBe(effective.P2P_passphrase); + expect(effective.P2P_AutoAccepting).toBe(DEFAULT_SETTINGS.P2P_AutoAccepting); + expect(effective.P2P_AutoSyncPeers).toBe(""); + expect(effective.P2P_AutoWatchPeers).toBe(""); + expect(effective.P2P_SyncOnReplication).toBe(""); + + const remoteConfigurations = Object.values(decoded.remoteConfigurations ?? {}); + expect(remoteConfigurations).toHaveLength(1); + expect(decoded.activeConfigurationId).toBe(remoteConfigurations[0].id); + expect(decoded.P2P_ActiveRemoteConfigurationId).toBe(remoteConfigurations[0].id); + const deviceRemote = ConnectionStringParser.parse(remoteConfigurations[0].uri); + expect(deviceRemote).toEqual( + expect.objectContaining({ + type: "p2p", + settings: expect.objectContaining({ + P2P_AutoStart: true, + P2P_AutoBroadcast: false, + }), + }) + ); + expect("P2P_useDiagRTC" in deviceRemote.settings).toBe(false); + + expect(generated.browserSettings).toEqual( + expect.objectContaining({ + remoteType: REMOTE_P2P, + P2P_Enabled: true, + P2P_AppID: P2P_CHECK_APP_ID, + P2P_roomID: effective.P2P_roomID, + P2P_passphrase: effective.P2P_passphrase, + passphrase: effective.passphrase, + P2P_AutoStart: false, + P2P_AutoBroadcast: false, + P2P_useDiagRTC: true, + }) + ); + expect(generated.browserDeviceName).toMatch(/^p2p-check-browser-(?:desktop|mobile)-/); + const browserConfigurations = Object.values(generated.browserSettings.remoteConfigurations ?? {}); + expect(browserConfigurations).toHaveLength(1); + const browserRemote = ConnectionStringParser.parse(browserConfigurations[0].uri); + expect(browserRemote).toEqual( + expect.objectContaining({ + type: "p2p", + settings: expect.objectContaining({ + P2P_AutoStart: false, + P2P_AutoBroadcast: false, + }), + }) + ); + expect("P2P_useDiagRTC" in browserRemote.settings).toBe(false); + } + ); + + it("creates independent rooms and secrets for separate checks", async () => { + const first = await generateP2PCheckSetup("desktop"); + const second = await generateP2PCheckSetup("desktop"); + + expect(second.groupId).not.toBe(first.groupId); + expect(second.setupPassphrase).not.toBe(first.setupPassphrase); + expect(second.browserSettings.P2P_passphrase).not.toBe(first.browserSettings.P2P_passphrase); + expect(second.browserSettings.passphrase).not.toBe(first.browserSettings.passphrase); + }); + + it("uses the same explicitly selected relay for the Setup URI and browser peer", async () => { + const relay = "ws://127.0.0.1:4010/"; + const generated = await generateP2PCheckSetup("desktop", { relay }); + const decoded = await decodeSettingsFromSetupURI(generated.setupURI, generated.setupPassphrase); + + expect(decoded).not.toBe(false); + if (decoded === false) { + throw new Error("The generated Setup URI could not be decoded"); + } + expect(generated.relay).toBe(relay); + expect(decoded.P2P_relays).toBe(relay); + expect(generated.browserSettings.P2P_relays).toBe(relay); + }); + + it("accepts a relay query override only from a loopback-served check page", () => { + const search = "?relay=ws%3A%2F%2F127.0.0.1%3A4010%2F"; + + expect(resolveLocalP2PCheckRelayOverride({ hostname: "127.0.0.1", search })).toBe("ws://127.0.0.1:4010/"); + expect(resolveLocalP2PCheckRelayOverride({ hostname: "localhost", search })).toBe("ws://127.0.0.1:4010/"); + expect(resolveLocalP2PCheckRelayOverride({ hostname: "example.com", search })).toBeUndefined(); + expect( + resolveLocalP2PCheckRelayOverride({ hostname: "127.0.0.1", search: "?relay=https%3A%2F%2Fexample.com" }) + ).toBeUndefined(); + }); +}); diff --git a/test/apps/webpeer/P2PCheckState.unit.spec.ts b/test/apps/webpeer/P2PCheckState.unit.spec.ts new file mode 100644 index 00000000..d22bf296 --- /dev/null +++ b/test/apps/webpeer/P2PCheckState.unit.spec.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; + +import { + EMPTY_P2P_CHECK_DIAGNOSTICS, + P2P_CHECK_OBSERVATION_MILLISECONDS, + captureP2PAdditionalCheckBaseline, + deriveP2PAdditionalCheckProgress, + deriveP2PCheckOutcome, +} from "@/apps/webpeer/src/P2PCheckState"; + +describe("P2P connection-check outcome", () => { + it("waits for a target after the browser monitor joins the room", () => { + expect(deriveP2PCheckOutcome(EMPTY_P2P_CHECK_DIAGNOSTICS, true, 5_000)).toBe("waiting"); + }); + + it("reports an active negotiation before any connection succeeds", () => { + expect( + deriveP2PCheckOutcome( + { + ...EMPTY_P2P_CHECK_DIAGNOSTICS, + totalNewConnections: 1, + details: { + "rtc-1": { + connectionState: "connecting", + iceConnectionState: "checking", + }, + }, + }, + true, + 10_000 + ) + ).toBe("connecting"); + }); + + it("keeps a failed attempt retryable until the observation period expires", () => { + const diagnostics = { + ...EMPTY_P2P_CHECK_DIAGNOSTICS, + totalNewConnections: 1, + totalFailedConnections: 1, + }; + + expect(deriveP2PCheckOutcome(diagnostics, true, 15_000)).toBe("retrying"); + expect(deriveP2PCheckOutcome(diagnostics, true, P2P_CHECK_OBSERVATION_MILLISECONDS)).toBe("inconclusive"); + }); + + it("lets a later success take precedence over failures and closure", () => { + expect( + deriveP2PCheckOutcome( + { + ...EMPTY_P2P_CHECK_DIAGNOSTICS, + totalNewConnections: 2, + totalFailedConnections: 1, + totalSuccessfulConnections: 1, + totalClosedConnections: 1, + }, + true, + P2P_CHECK_OBSERVATION_MILLISECONDS * 2 + ) + ).toBe("connected"); + }); + + it("distinguishes an idle page and a monitor-start error", () => { + expect(deriveP2PCheckOutcome(EMPTY_P2P_CHECK_DIAGNOSTICS, false, 0)).toBe("idle"); + expect(deriveP2PCheckOutcome(EMPTY_P2P_CHECK_DIAGNOSTICS, false, 0, true)).toBe("error"); + }); +}); + +describe("same-room additional-device progress", () => { + const firstDeviceDiagnostics = { + ...EMPTY_P2P_CHECK_DIAGNOSTICS, + totalNewConnections: 1, + totalSuccessfulConnections: 1, + details: { + "rtc-a": { + connectionState: "connected" as const, + iceConnectionState: "connected" as const, + }, + }, + }; + + it("captures the counter and currently active connection baseline", () => { + expect(captureP2PAdditionalCheckBaseline(firstDeviceDiagnostics)).toEqual({ + activeConnectionIds: ["rtc-a"], + totalClosedConnections: 0, + totalFailedConnections: 0, + totalNewConnections: 1, + totalSuccessfulConnections: 1, + }); + }); + + it("does not mistake a reconnect from the first peer for an additional device", () => { + const baseline = captureP2PAdditionalCheckBaseline(firstDeviceDiagnostics); + const progress = deriveP2PAdditionalCheckProgress( + { + ...firstDeviceDiagnostics, + totalNewConnections: 2, + totalSuccessfulConnections: 2, + details: { + "rtc-a": { + connectionState: "closed", + iceConnectionState: "closed", + }, + "rtc-b": { + connectionState: "connected", + iceConnectionState: "connected", + }, + }, + }, + baseline, + 10_000 + ); + + expect(progress).toEqual({ + activeConnections: 0, + closedConnections: 0, + failedConnections: 0, + newConnections: 1, + newActiveConnectionIds: ["rtc-b"], + outcome: "negotiating", + successfulConnections: 1, + }); + }); + + it("requires both a new successful state and another simultaneous active connection", () => { + const baseline = captureP2PAdditionalCheckBaseline(firstDeviceDiagnostics); + + expect( + deriveP2PAdditionalCheckProgress( + { + ...firstDeviceDiagnostics, + details: { + ...firstDeviceDiagnostics.details, + "rtc-b": { + connectionState: "connected", + iceConnectionState: "connected", + }, + }, + }, + baseline, + 10_000 + ).outcome + ).toBe("negotiating"); + + expect( + deriveP2PAdditionalCheckProgress( + { + ...firstDeviceDiagnostics, + totalNewConnections: 2, + totalSuccessfulConnections: 2, + details: { + ...firstDeviceDiagnostics.details, + "rtc-b": { + connectionState: "connected", + iceConnectionState: "connected", + }, + }, + }, + baseline, + 10_000 + ) + ).toEqual({ + activeConnections: 1, + closedConnections: 0, + failedConnections: 0, + newConnections: 1, + newActiveConnectionIds: ["rtc-b"], + outcome: "connected", + successfulConnections: 1, + }); + }); + + it("marks an additional-device attempt inconclusive after its own observation period", () => { + const baseline = captureP2PAdditionalCheckBaseline(firstDeviceDiagnostics); + expect( + deriveP2PAdditionalCheckProgress(firstDeviceDiagnostics, baseline, P2P_CHECK_OBSERVATION_MILLISECONDS) + .outcome + ).toBe("inconclusive"); + }); +}); diff --git a/test/apps/webpeer/WebPeerPersistence.unit.spec.ts b/test/apps/webpeer/WebPeerPersistence.unit.spec.ts new file mode 100644 index 00000000..a81ab954 --- /dev/null +++ b/test/apps/webpeer/WebPeerPersistence.unit.spec.ts @@ -0,0 +1,58 @@ +import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase"; +import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { describe, expect, it } from "vitest"; + +import { + WEBPEER_SETTINGS_KEY, + createWebPeerPersistence, +} from "@/apps/webpeer/src/WebPeerPersistence"; + +function createMemoryStore(initial: Record = {}): SimpleStore { + const values = new Map(Object.entries(initial)); + return { + db: Promise.resolve(undefined), + get: async (key) => values.get(key), + set: async (key, value) => { + values.set(key, value); + }, + delete: async (key) => { + values.delete(key); + }, + keys: async (from, to, count) => { + const selected = [...values.keys()] + .sort() + .filter((key) => (from === undefined || key >= from) && (to === undefined || key <= to)); + return count === undefined ? selected : selected.slice(0, count); + }, + }; +} + +describe("WebPeer settings persistence", () => { + it("loads P2P-only defaults and saves the complete settings object", async () => { + const store = createMemoryStore({ + [WEBPEER_SETTINGS_KEY]: { + P2P_AppID: "custom-app", + P2P_roomID: "room-42", + P2P_Enabled: true, + }, + }); + const persistence = createWebPeerPersistence(store); + + const loaded = await persistence.settings.load(); + + expect(loaded).toEqual( + expect.objectContaining({ + P2P_AppID: "custom-app", + P2P_roomID: "room-42", + P2P_Enabled: true, + remoteType: REMOTE_P2P, + isConfigured: true, + additionalSuffixOfDatabaseName: "", + suspendParseReplicationResult: true, + }) + ); + const updated = { ...loaded!, P2P_AutoStart: true }; + await persistence.settings.save(updated); + await expect(store.get(WEBPEER_SETTINGS_KEY)).resolves.toEqual(updated); + }); +}); diff --git a/test/apps/webpeer/WebPeerRuntime.unit.spec.ts b/test/apps/webpeer/WebPeerRuntime.unit.spec.ts new file mode 100644 index 00000000..b92f816f --- /dev/null +++ b/test/apps/webpeer/WebPeerRuntime.unit.spec.ts @@ -0,0 +1,100 @@ +import { createServiceContext } from "@vrtmrz/livesync-commonlib/context"; +import { DEFAULT_SETTINGS, SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase"; +import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; +import { describe, expect, it, vi } from "vitest"; + +import { WebPeerRuntime } from "@/apps/webpeer/src/WebPeerRuntime"; + +function createMemoryStore(): SimpleStore { + const values = new Map(); + return { + db: Promise.resolve(undefined), + get: async (key) => values.get(key), + set: async (key, value) => { + values.set(key, value); + }, + delete: async (key) => { + values.delete(key); + }, + keys: async () => [...values.keys()], + }; +} + +describe("WebPeer runtime composition", () => { + it("preserves one context and live P2P result across the service graph and pane host", () => { + const context = createServiceContext({ + translate: (key) => `webpeer:${key}`, + }); + const runtime = new WebPeerRuntime({ + context, + store: createMemoryStore(), + }); + + expect(runtime.context).toBe(context); + expect(runtime.services.context).toBe(context); + expect(runtime.events).toBe(context.events); + expect(runtime.currentReplicator).toBe(runtime.p2p.replicator); + expect(runtime.paneHost.services).toBe(runtime.services); + expect(runtime.paneHost.p2p).toBe(runtime.p2p); + expect(runtime.paneHost.showPeerMenu).toBeTypeOf("function"); + expect("controller" in runtime).toBe(false); + }); + + it("loads settings and opens the local database before announcing layout readiness", async () => { + const runtime = new WebPeerRuntime({ + store: createMemoryStore(), + }); + const loadSettings = vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined); + vi.spyOn(runtime.services.setting, "currentSettings").mockReturnValue({ + ...DEFAULT_SETTINGS, + P2P_AutoStart: false, + P2P_Enabled: false, + }); + const openDatabase = vi.spyOn(runtime.services.database, "openDatabase").mockResolvedValue(true); + const markIsReady = vi.spyOn(runtime.services.appLifecycle, "markIsReady"); + const layoutReady = vi.fn(); + runtime.events.onEvent(EVENT_LAYOUT_READY, layoutReady); + + await expect(runtime.start()).resolves.toBe(runtime); + + expect(loadSettings).toHaveBeenCalledOnce(); + expect(openDatabase).toHaveBeenCalledOnce(); + expect(markIsReady).toHaveBeenCalledOnce(); + expect(layoutReady).toHaveBeenCalledOnce(); + }); + + it("rejects start when the browser-local database cannot be opened", async () => { + const runtime = new WebPeerRuntime({ + store: createMemoryStore(), + }); + vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined); + vi.spyOn(runtime.services.database, "openDatabase").mockResolvedValue(false); + + await expect(runtime.start()).rejects.toThrow("WebPeer local database could not be opened"); + }); + + it("isolates a specialised runtime and applies its device name before opening the database", async () => { + const runtime = new WebPeerRuntime({ + store: createMemoryStore(), + deviceName: " p2p-check-browser-desktop-abc ", + systemVaultName: "p2p-check-vault", + }); + vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined); + vi.spyOn(runtime.services.setting, "currentSettings").mockReturnValue({ + ...DEFAULT_SETTINGS, + P2P_AutoStart: false, + P2P_Enabled: false, + }); + const setSmallConfig = vi.spyOn(runtime.services.config, "setSmallConfig").mockImplementation(() => undefined); + const openDatabase = vi.spyOn(runtime.services.database, "openDatabase").mockImplementation(async () => { + expect(setSmallConfig).toHaveBeenCalledWith(SETTING_KEY_P2P_DEVICE_NAME, "p2p-check-browser-desktop-abc"); + return true; + }); + + await runtime.start(); + + expect(runtime.services.API.getSystemVaultName()).toBe("p2p-check-vault"); + expect(openDatabase).toHaveBeenCalledOnce(); + }); +}); diff --git a/test/bench-network/Dockerfile.runner b/test/bench-network/Dockerfile.runner index 04d387c3..c04677bb 100644 --- a/test/bench-network/Dockerfile.runner +++ b/test/bench-network/Dockerfile.runner @@ -3,7 +3,7 @@ FROM node:24-slim RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl unzip python3 make g++ iproute2 \ + && apt-get install -y --no-install-recommends ca-certificates curl unzip python3 make g++ iproute2 libjpeg-turbo-progs time \ && rm -rf /var/lib/apt/lists/* ENV DENO_INSTALL=/usr/local @@ -18,7 +18,7 @@ COPY src/apps/webpeer/package.json ./src/apps/webpeer/package.json RUN npm ci COPY . . -RUN npm run build -w self-hosted-livesync-cli +RUN LIVESYNC_CLI_TEST_SUPPORT=1 npm run build -w self-hosted-livesync-cli WORKDIR /workspace/src/apps/cli/testdeno @@ -28,7 +28,10 @@ RUN deno cache --lock=deno.lock \ bench-p2p-split-node.ts \ bench-p2p.ts \ bench-couchdb.ts \ - test-p2p-sync.ts + bench-compression.ts \ + test-p2p-sync.ts \ + test-p2p-replicator-replacement.ts \ + test-p2p-relay-disconnect.ts COPY test/bench-network/run-bench.sh /usr/local/bin/run-livesync-bench COPY src/apps/cli/testdeno/run-cli-e2e.sh /usr/local/bin/run-livesync-cli-e2e diff --git a/test/bench-network/README.md b/test/bench-network/README.md index 330b48a1..537f9b18 100644 --- a/test/bench-network/README.md +++ b/test/bench-network/README.md @@ -127,6 +127,49 @@ This sweep is useful for finding where the remote CouchDB path falls behind the local direct P2P path in the current HTTP-proxy latency model. It should not be presented as a full smartphone/VPN model. +## Data Compression benchmark + +The [Data Compression specification](../../docs/specs_data_compression.md) records the current storage contract, 1.0 decision, measured result, and follow-up optimisation candidates. This section is the benchmark runbook. + +The compression benchmark compares the exact CLI and Commonlib CouchDB path in +four configurations: E2EE off or on, each with Data Compression off or on. It +uses the normal CLI `mirror` and `sync` commands, so the recorded CouchDB +documents have passed through the current Rabin–Karp chunk splitter, optional +fflate compression, and E2EE V2 rather than a synthetic document transform. + +```bash +BENCH_COMMAND=compression \ +BENCH_COMPRESSION_REPEAT_COUNT=3 \ +BENCH_COUCHDB_RTT_MS=1 \ +docker compose -f test/bench-network/compose.yml run --build --rm bench-runner +``` + +The fixture contains current repository Markdown, PNG, JSON, and TypeScript +files; two deterministic JPEGs generated with `cjpeg`; a gzip-compressed +Markdown file; and deterministic high-entropy binary data. Every run verifies +all files after the second client has synchronised them. The JSON result under +`test/bench-network/bench-results/` records: + +- source bytes and stored chunk bytes by file kind; +- raw CouchDB external, active, and file sizes; +- request and response body bytes observed by the local HTTP proxy, including + the combined initial sync and full materialisation download; +- upload and download wall time, user and system CPU time, and maximum resident + memory for each CLI process; and +- percentage changes caused by enabling compression with E2EE both off and on. + +Use at least three repeats when making a default-setting decision. A `1 ms` +requested RTT keeps the local run focused on transform and storage costs. Run a +separate representative RTT when evaluating whether reduced request bodies +outweigh compression CPU on the intended network. HTTP byte counters cover +decoded bodies and exclude headers, while process timings include CLI start-up. +Full materialisation starts one CLI process per file and can repeat lazy chunk +fetches, so treat that phase as a CLI workflow measurement rather than a raw +download lower bound. Stored chunk size and upload request size are the more +direct transform comparisons. +The generated JPEGs are deterministic image-like fixtures, not a photographic +corpus, so broader media conclusions require a separately reviewed corpus. + ## Network emulation smoke The optional `netem` profile checks whether a Linux runner can apply traffic diff --git a/test/bench-network/compose.yml b/test/bench-network/compose.yml index c7d53d36..2c2b2407 100644 --- a/test/bench-network/compose.yml +++ b/test/bench-network/compose.yml @@ -75,6 +75,8 @@ services: BENCH_CASES: ${BENCH_CASES:-couchdb-baseline,p2p-direct-local} BENCH_REPEAT_COUNT: ${BENCH_REPEAT_COUNT:-1} BENCH_CASES_ROOT: /workspace/src/apps/cli/testdeno/bench-results + BENCH_COMPRESSION_RESULT_ROOT: /workspace/src/apps/cli/testdeno/bench-results + BENCH_COMPRESSION_REPEAT_COUNT: ${BENCH_COMPRESSION_REPEAT_COUNT:-1} BENCH_SWEEP_ROOT: /workspace/src/apps/cli/testdeno/bench-results BENCH_SWEEP_RTT_MS: ${BENCH_SWEEP_RTT_MS:-20,50,100,150,300} BENCH_SWEEP_INCLUDE_P2P: ${BENCH_SWEEP_INCLUDE_P2P:-true} @@ -97,7 +99,10 @@ services: BENCH_PEERS_TIMEOUT: ${BENCH_PEERS_TIMEOUT:-60} LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: ${LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS:-60000} BENCH_LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0} - CLI_E2E_TASK: ${CLI_E2E_TASK:-test:p2p-sync} + LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0} + LIVESYNC_CLI_DEBUG: ${LIVESYNC_CLI_DEBUG:-0} + LIVESYNC_CLI_VERBOSE: ${LIVESYNC_CLI_VERBOSE:-0} + CLI_E2E_TASK: ${CLI_E2E_TASK:-test:p2p:ci} RELAY: ${RELAY:-ws://nostr-relay:7777/} PEERS_TIMEOUT: ${PEERS_TIMEOUT:-20} SYNC_TIMEOUT: ${SYNC_TIMEOUT:-60} diff --git a/test/bench-network/run-bench.sh b/test/bench-network/run-bench.sh index 32272054..5cb158f0 100644 --- a/test/bench-network/run-bench.sh +++ b/test/bench-network/run-bench.sh @@ -8,12 +8,15 @@ case "${BENCH_COMMAND:-cases}" in latency-sweep) exec deno task bench:latency-sweep ;; + compression) + exec deno task bench:compression + ;; p2p-split-node) exec deno task bench:p2p-split-node ;; *) echo "Unknown BENCH_COMMAND: ${BENCH_COMMAND}" >&2 - echo "Expected one of: cases, latency-sweep, p2p-split-node" >&2 + echo "Expected one of: cases, latency-sweep, compression, p2p-split-node" >&2 exit 2 ;; esac diff --git a/test/browser-apps/Dockerfile.interop b/test/browser-apps/Dockerfile.interop new file mode 100644 index 00000000..1b49869a --- /dev/null +++ b/test/browser-apps/Dockerfile.interop @@ -0,0 +1,21 @@ +# syntax=docker/dockerfile:1 + +FROM denoland/deno:bin-2.9.2 AS deno + +FROM mcr.microsoft.com/playwright:v1.62.0-noble + +COPY --from=deno /deno /usr/local/bin/deno + +ENV DENO_DIR=/deno-dir + +WORKDIR /opt/livesync-browser-apps-interop + +COPY test/browser-apps/deno.json test/browser-apps/deno.lock ./ +COPY test/browser-apps/helpers ./helpers +COPY test/browser-apps/interop.test.ts ./ + +RUN deno cache --frozen --config deno.json --lock deno.lock interop.test.ts + +WORKDIR /workspace + +CMD ["deno", "test", "-A", "--no-check", "--frozen", "--config", "test/browser-apps/deno.json", "--lock", "test/browser-apps/deno.lock", "test/browser-apps/interop.test.ts"] diff --git a/test/browser-apps/README.md b/test/browser-apps/README.md new file mode 100644 index 00000000..2c758d71 --- /dev/null +++ b/test/browser-apps/README.md @@ -0,0 +1,35 @@ +# Browser application tests + +Browser application tests use Deno and headless Chromium. They do not depend on Obsidian or the retired browser Harness. + +Application unit tests are stored in `test/apps/webapp/` and `test/apps/webpeer/`. Both the unit and browser tests remain outside `src`, because test-file ignores do not apply to the Community Review source boundary. + +Each application has its own production-bundle smoke-test directory outside `src`: + +- `test/browser-apps/webapp/` covers Vault selection, OPFS start-up, and isolation between optional P2P settings and the main remote. +- `test/browser-apps/webpeer/` covers start-up, settings persistence, and reload. +- `test/browser-apps/pages/` covers the final GitHub Pages layout, application subpaths, and relative assets. + +Run both app-owned tests with: + +```bash +npm run test:browser-apps +``` + +`test/browser-apps/interop.test.ts` is the only cross-application scenario. It configures the real WebApp and WebPeer interfaces, transfers a file from WebApp to WebPeer, then starts the built CLI as the final peer and verifies the file through the production CLI. The CLI is a test fixture in this scenario; the PoC test and its support code are not owned by the CLI package. + +Run the isolated relay and TURN scenario in Compose with: + +```bash +npm run test:e2e:browser-apps:interop +``` + +After assembling the GitHub Pages files in `_site`, run the package smoke test with: + +```bash +npm run test:browser-apps:pages +``` + +Set `PAGES_SITE_ROOT` to test an assembled site in another directory. + +The Deno dependency lock is shared only by these browser application tests. diff --git a/test/browser-apps/compose.yml b/test/browser-apps/compose.yml new file mode 100644 index 00000000..146e3854 --- /dev/null +++ b/test/browser-apps/compose.yml @@ -0,0 +1,65 @@ +name: livesync-browser-apps-interop + +services: + nostr-relay: + image: ghcr.io/hoytech/strfry:latest + entrypoint: sh + command: + - -lc + - | + cat > /tmp/strfry.conf <<'EOF' + db = "./strfry-db/" + + relay { + bind = "0.0.0.0" + port = 7777 + nofiles = 65536 + + info { + name = "livesync browser application test relay" + description = "local relay for the browser application interoperability test" + } + + maxWebsocketPayloadSize = 131072 + autoPingSeconds = 55 + + writePolicy { + plugin = "" + } + } + EOF + exec /app/strfry --config /tmp/strfry.conf relay + tmpfs: + - /app/strfry-db:rw,size=256m,mode=1777 + healthcheck: + test: ["CMD-SHELL", "nc -z 127.0.0.1 7777"] + interval: 2s + timeout: 5s + retries: 30 + + coturn: + image: coturn/coturn:latest + command: + - --log-file=stdout + - --listening-port=3478 + - --user=testuser:testpass + - --realm=livesync.test + + browser-apps-interop: + build: + context: ../.. + dockerfile: test/browser-apps/Dockerfile.interop + depends_on: + nostr-relay: + condition: service_healthy + coturn: + condition: service_started + environment: + BROWSER_APPS_P2P_RELAY_URL: ws://nostr-relay:7777/ + BROWSER_APPS_P2P_TURN_SERVERS: turn:coturn:3478 + BROWSER_APPS_P2P_TURN_USERNAME: testuser + BROWSER_APPS_P2P_TURN_CREDENTIAL: testpass + init: true + ipc: host + volumes: + - ../..:/workspace diff --git a/test/browser-apps/deno.json b/test/browser-apps/deno.json new file mode 100644 index 00000000..b5726217 --- /dev/null +++ b/test/browser-apps/deno.json @@ -0,0 +1,7 @@ +{ + "imports": { + "@std/assert": "jsr:@std/assert@^1.0.13", + "@std/path": "jsr:@std/path@^1.0.9", + "playwright": "npm:playwright@1.62.0" + } +} diff --git a/test/browser-apps/deno.lock b/test/browser-apps/deno.lock new file mode 100644 index 00000000..3e3204df --- /dev/null +++ b/test/browser-apps/deno.lock @@ -0,0 +1,55 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@^1.0.13": "1.0.19", + "jsr:@std/internal@^1.0.12": "1.0.14", + "jsr:@std/internal@^1.0.14": "1.0.14", + "jsr:@std/path@^1.0.9": "1.1.6", + "npm:playwright@1.62.0": "1.62.0" + }, + "jsr": { + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + }, + "@std/path@1.1.6": { + "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", + "dependencies": [ + "jsr:@std/internal@^1.0.14" + ] + } + }, + "npm": { + "fsevents@2.3.2": { + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "os": ["darwin"], + "scripts": true + }, + "playwright-core@1.62.0": { + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "bin": true + }, + "playwright@1.62.0": { + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "dependencies": [ + "playwright-core" + ], + "optionalDependencies": [ + "fsevents" + ], + "bin": true + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@^1.0.13", + "jsr:@std/path@^1.0.9", + "npm:playwright@1.62.0" + ] + } +} diff --git a/test/browser-apps/helpers/browser.ts b/test/browser-apps/helpers/browser.ts new file mode 100644 index 00000000..699f200c --- /dev/null +++ b/test/browser-apps/helpers/browser.ts @@ -0,0 +1,103 @@ +import { assertEquals } from "@std/assert"; +import { extname, relative, resolve } from "@std/path"; +import type { Page } from "playwright"; + +function contentType(path: string): string { + switch (extname(path)) { + case ".css": + return "text/css; charset=utf-8"; + case ".html": + return "text/html; charset=utf-8"; + case ".js": + return "text/javascript; charset=utf-8"; + case ".json": + case ".map": + return "application/json; charset=utf-8"; + case ".svg": + return "image/svg+xml"; + default: + return "application/octet-stream"; + } +} + +function resolveStaticPath(root: string, pathname: string): string | undefined { + const requested = decodeURIComponent(pathname).replace(/^\/+/, "") || "index.html"; + const candidate = resolve(root, requested); + const relativePath = relative(root, candidate); + if (relativePath.startsWith("..") || relativePath.includes("\0")) { + return undefined; + } + return candidate; +} + +export async function startStaticServer(root: string): Promise<{ + readonly baseUrl: string; + close(): Promise; +}> { + let reportListening!: (port: number) => void; + const listening = new Promise((resolvePromise) => { + reportListening = resolvePromise; + }); + const server = Deno.serve( + { + hostname: "127.0.0.1", + onListen: ({ port }) => reportListening(port), + port: 0, + }, + async (request) => { + const path = resolveStaticPath(root, new URL(request.url).pathname); + if (path === undefined) { + return new Response("Not found", { status: 404 }); + } + try { + const stat = await Deno.stat(path); + const filePath = stat.isDirectory ? resolve(path, "index.html") : path; + const file = await Deno.open(filePath, { read: true }); + return new Response(file.readable, { + headers: { + "content-type": contentType(filePath), + }, + }); + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + return new Response("Not found", { status: 404 }); + } + throw error; + } + } + ); + const port = await listening; + return { + baseUrl: `http://127.0.0.1:${port}/`, + close: async () => { + await server.shutdown(); + }, + }; +} + +export function observePageFailures(page: Page): () => void { + const failures: string[] = []; + page.on("pageerror", (error) => { + failures.push(`pageerror: ${error.stack ?? error.message}`); + }); + page.on("console", (message) => { + if (message.type() === "error") { + failures.push(`console.error: ${message.text()}`); + } + }); + return () => assertEquals(failures, [], failures.join("\n")); +} + +export async function waitFor( + predicate: () => Promise, + message: string, + timeoutMilliseconds = 5_000 +): Promise { + const deadline = Date.now() + timeoutMilliseconds; + while (!(await predicate())) { + if (Date.now() >= deadline) { + throw new Error(message); + } + await new Promise((resolvePromise) => setTimeout(resolvePromise, 25)); + } +} diff --git a/test/browser-apps/helpers/cli-fixture.ts b/test/browser-apps/helpers/cli-fixture.ts new file mode 100644 index 00000000..4746d946 --- /dev/null +++ b/test/browser-apps/helpers/cli-fixture.ts @@ -0,0 +1,172 @@ +import { resolve } from "@std/path"; + +const repositoryRoot = resolve(import.meta.dirname!, "../../.."); +const cliDirectory = resolve(repositoryRoot, "src/apps/cli"); +const cliDist = resolve(cliDirectory, "dist/index.cjs"); + +export interface CliResult { + readonly code: number; + readonly combined: string; + readonly stderr: string; + readonly stdout: string; +} + +export class TemporaryDirectory implements AsyncDisposable { + private constructor(readonly path: string) {} + + static async create(prefix: string): Promise { + return new TemporaryDirectory(await Deno.makeTempDir({ prefix: `${prefix}.` })); + } + + resolve(...parts: string[]): string { + return resolve(this.path, ...parts); + } + + async [Symbol.asyncDispose](): Promise { + await Deno.remove(this.path, { recursive: true }).catch(() => {}); + } +} + +async function collectStream(stream: ReadableStream, append: (text: string) => void): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + append(decoder.decode()); + return; + } + if (value !== undefined) { + append(decoder.decode(value, { stream: true })); + } + } + } finally { + reader.releaseLock(); + } +} + +export async function runCli(...args: string[]): Promise { + const output = await new Deno.Command("node", { + args: [cliDist, ...args], + cwd: cliDirectory, + stdin: "null", + stdout: "piped", + stderr: "piped", + }).output(); + const decoder = new TextDecoder(); + const stdout = decoder.decode(output.stdout); + const stderr = decoder.decode(output.stderr); + return { + code: output.code, + combined: stdout + stderr, + stderr, + stdout, + }; +} + +export async function initSettingsFile(path: string): Promise { + const result = await runCli("init-settings", "--force", path); + if (result.code !== 0) { + throw new Error(`CLI settings initialisation failed with code ${result.code}\n${result.combined}`); + } +} + +export function sanitiseCatStdout(raw: string): string { + return raw + .split("\n") + .filter((line) => line !== "[CLIWatchAdapter] File watching is not enabled in CLI version") + .join("\n"); +} + +export class BackgroundCliProcess { + #stdout = ""; + #stderr = ""; + readonly #stdoutDone: Promise; + readonly #stderrDone: Promise; + + constructor(readonly child: Deno.ChildProcess) { + this.#stdoutDone = collectStream(child.stdout, (text) => { + this.#stdout += text; + }); + this.#stderrDone = collectStream(child.stderr, (text) => { + this.#stderr += text; + }); + } + + get combined(): string { + return this.#stdout + this.#stderr; + } + + async stop(): Promise { + try { + this.child.kill("SIGTERM"); + } catch { + // The process has already exited. + } + const status = await this.child.status; + await Promise.all([this.#stdoutDone, this.#stderrDone]); + return status.code; + } +} + +export function startCliInBackground(...args: string[]): BackgroundCliProcess { + return new BackgroundCliProcess( + new Deno.Command("node", { + args: [cliDist, ...args], + cwd: cliDirectory, + stdin: "null", + stdout: "piped", + stderr: "piped", + }).spawn() + ); +} + +type WaitForPortOptions = { + timeoutMs?: number; + intervalMs?: number; + connectTimeoutMs?: number; +}; + +async function connectWithTimeout(hostname: string, port: number, timeoutMs: number): Promise { + let timeout: ReturnType | undefined; + try { + const connection = await Promise.race([ + Deno.connect({ hostname, port }), + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(`Connection timed out after ${timeoutMs} ms`)), timeoutMs); + }), + ]); + connection.close(); + } finally { + if (timeout !== undefined) { + clearTimeout(timeout); + } + } +} + +export async function waitForPort( + hostname: string, + port: number, + options: WaitForPortOptions = {} +): Promise { + const timeoutMs = options.timeoutMs ?? 15_000; + const intervalMs = options.intervalMs ?? 250; + const connectTimeoutMs = options.connectTimeoutMs ?? 1_000; + const started = Date.now(); + let lastError: unknown; + + while (Date.now() - started < timeoutMs) { + try { + await connectWithTimeout(hostname, port, connectTimeoutMs); + return; + } catch (error) { + lastError = error; + await new Promise((resolvePromise) => setTimeout(resolvePromise, intervalMs)); + } + } + throw new Error( + `Port ${hostname}:${port} did not become ready within ${timeoutMs} ms` + + (lastError === undefined ? "" : ` (last error: ${String(lastError)})`) + ); +} diff --git a/test/browser-apps/interop.test.ts b/test/browser-apps/interop.test.ts new file mode 100644 index 00000000..e7397016 --- /dev/null +++ b/test/browser-apps/interop.test.ts @@ -0,0 +1,612 @@ +import { assertEquals, assertStringIncludes } from "@std/assert"; +import { extname, relative, resolve } from "@std/path"; +import { chromium, type Locator, type Page } from "playwright"; + +import { + type BackgroundCliProcess, + initSettingsFile, + runCli, + sanitiseCatStdout, + startCliInBackground, + TemporaryDirectory, + waitForPort, +} from "./helpers/cli-fixture.ts"; + +const repositoryRoot = resolve(import.meta.dirname!, "../.."); +const webAppDist = resolve(repositoryRoot, "src/apps/webapp/dist"); +const webPeerDist = resolve(repositoryRoot, "src/apps/webpeer/dist"); + +function contentType(path: string): string { + switch (extname(path)) { + case ".css": + return "text/css; charset=utf-8"; + case ".html": + return "text/html; charset=utf-8"; + case ".js": + return "text/javascript; charset=utf-8"; + case ".json": + return "application/json; charset=utf-8"; + case ".map": + return "application/json; charset=utf-8"; + default: + return "application/octet-stream"; + } +} + +function resolveStaticPath(pathname: string): string | undefined { + const decodedPath = decodeURIComponent(pathname); + const routes: Array<[string, string]> = [ + ["/webapp/", webAppDist], + ["/webpeer/", webPeerDist], + ]; + for (const [prefix, root] of routes) { + if (!decodedPath.startsWith(prefix)) { + continue; + } + const requested = decodedPath.slice(prefix.length) || "index.html"; + const candidate = resolve(root, requested); + const relativePath = relative(root, candidate); + if (relativePath.startsWith("..") || relativePath.includes("\0")) { + return undefined; + } + return candidate; + } + return undefined; +} + +async function serveBrowserApplications(request: Request): Promise { + const filePath = resolveStaticPath(new URL(request.url).pathname); + if (!filePath) { + return new Response("Not found", { status: 404 }); + } + try { + const stat = await Deno.stat(filePath); + const resolvedFile = stat.isDirectory + ? resolve(filePath, "index.html") + : filePath; + const file = await Deno.open(resolvedFile, { read: true }); + return new Response(file.readable, { + headers: { + "content-type": contentType(resolvedFile), + }, + }); + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + return new Response("Not found", { status: 404 }); + } + throw error; + } +} + +function observePageFailures(page: Page): () => void { + const failures: string[] = []; + page.on("pageerror", (error) => { + failures.push(`pageerror: ${error.stack ?? error.message}`); + }); + page.on("console", (message) => { + if (message.type() === "error") { + failures.push(`console.error: ${message.text()}`); + } + }); + return () => assertEquals(failures, [], failures.join("\n")); +} + +function formatError(error: unknown): string { + return error instanceof Error + ? (error.stack ?? error.message) + : String(error); +} + +async function captureBrowserPage( + page: Page, + screenshotDirectory: string | undefined, + filename: string, + fullPage = true, +): Promise { + if (screenshotDirectory === undefined) { + return; + } + const screenshotPath = resolve(screenshotDirectory, filename); + await page.screenshot({ + animations: "disabled", + caret: "hide", + fullPage, + path: screenshotPath, + }); + console.log(`[Browser applications E2E] Captured ${screenshotPath}`); +} + +async function webPeerLogs(page: Page): Promise { + return await page + .locator(".logslist") + .innerText() + .catch(() => ""); +} + +async function waitForWebPeerLog( + page: Page, + message: string, + timeoutMilliseconds: number, +): Promise { + try { + await page.locator(".logslist").filter({ hasText: message }).waitFor({ + timeout: timeoutMilliseconds, + }); + } catch (error) { + throw new Error( + `${formatError(error)}\n\nWebPeer logs:\n${await webPeerLogs(page)}`, + ); + } +} + +async function waitFor( + predicate: () => Promise, + message: string, + timeoutMilliseconds = 5_000, +): Promise { + const deadline = Date.now() + timeoutMilliseconds; + while (!(await predicate())) { + if (Date.now() >= deadline) { + throw new Error(message); + } + await new Promise((resolvePromise) => setTimeout(resolvePromise, 25)); + } +} + +async function configureP2PPane( + root: Locator, + settings: { + deviceName: string; + passphrase: string; + relay: string; + room: string; + turnCredential: string; + turnServers: string; + turnUsername: string; + }, +): Promise { + const pane = root.locator("article"); + const transportSettings = root.locator(".browser-p2p-transport-settings"); + await pane.locator("input[type='checkbox']").first().check(); + await pane.getByPlaceholder("wss://exp-relay.vrtmrz.net, wss://xxxxx").fill( + settings.relay, + ); + await pane.getByPlaceholder("anything-you-like").fill(settings.room); + await pane.getByPlaceholder("password").fill(settings.passphrase); + await pane.getByPlaceholder("iphone-16").fill(settings.deviceName); + if (settings.turnServers !== "") { + await transportSettings.getByText("Optional TURN server settings", { + exact: true, + }).click(); + await transportSettings.getByPlaceholder("turn:turn.example.com:3478").fill( + settings.turnServers, + ); + await transportSettings.getByPlaceholder("Enter TURN username").fill( + settings.turnUsername, + ); + await transportSettings.getByPlaceholder("Enter TURN credential").fill( + settings.turnCredential, + ); + const saveTurn = transportSettings.getByRole("button", { + name: "Save TURN settings", + exact: true, + }); + await saveTurn.click(); + await waitFor( + async () => await saveTurn.isDisabled(), + `${settings.deviceName} did not apply its TURN settings`, + ); + } + const save = pane.getByRole("button", { + name: "Save and Apply", + exact: true, + }); + await save.click(); + await waitFor( + async () => await save.isDisabled(), + `${settings.deviceName} did not apply its P2P settings`, + ); +} + +async function configureCliSettings( + settingsPath: string, + settings: { + deviceName: string; + passphrase: string; + relay: string; + room: string; + turnCredential: string; + turnServers: string; + turnUsername: string; + }, +): Promise { + await initSettingsFile(settingsPath); + const current = JSON.parse(await Deno.readTextFile(settingsPath)) as Record< + string, + unknown + >; + Object.assign(current, { + P2P_AppID: "self-hosted-livesync", + P2P_AutoAcceptingPeers: "~.*", + P2P_AutoBroadcast: false, + P2P_AutoDenyingPeers: "", + P2P_AutoStart: false, + P2P_DevicePeerName: settings.deviceName, + P2P_Enabled: true, + P2P_IsHeadless: true, + P2P_passphrase: settings.passphrase, + P2P_relays: settings.relay, + P2P_roomID: settings.room, + P2P_turnCredential: settings.turnCredential, + P2P_turnServers: settings.turnServers, + P2P_turnUsername: settings.turnUsername, + encrypt: false, + isConfigured: true, + passphrase: "", + remoteType: "ONLY_P2P", + usePathObfuscation: false, + }); + await Deno.writeTextFile(settingsPath, JSON.stringify(current, null, 2)); +} + +async function waitForBackgroundExit( + process: BackgroundCliProcess, + timeoutMilliseconds: number, +): Promise { + let timeout: ReturnType | undefined; + try { + const status = await Promise.race([ + process.child.status, + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error(`CLI process timed out\n${process.combined}`)), + timeoutMilliseconds, + ); + }), + ]); + await process.stop(); + return status.code; + } catch (error) { + await process.stop(); + throw error; + } finally { + if (timeout !== undefined) { + clearTimeout(timeout); + } + } +} + +Deno.test({ + name: "browser applications: WebApp to WebPeer to CLI", + sanitizeOps: false, + sanitizeResources: false, + async fn() { + const relay = Deno.env.get("BROWSER_APPS_P2P_RELAY_URL") ?? + "ws://127.0.0.1:4000/"; + const turnServers = Deno.env.get("BROWSER_APPS_P2P_TURN_SERVERS") ?? ""; + const turnUsername = Deno.env.get("BROWSER_APPS_P2P_TURN_USERNAME") ?? ""; + const turnCredential = Deno.env.get("BROWSER_APPS_P2P_TURN_CREDENTIAL") ?? + ""; + const port = Number(Deno.env.get("BROWSER_APPS_INTEROP_PORT") ?? "43920"); + const screenshotDirectory = + Deno.env.get("BROWSER_APPS_SCREENSHOT_DIR")?.trim() || undefined; + const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`; + const room = `browser-interop-${nonce}`; + const p2pPassphrase = `browser-interop-passphrase-${nonce}`; + const webAppDeviceName = `webapp-${nonce}`; + const webPeerDeviceName = `webpeer-${nonce}`; + const cliDeviceName = `cli-${nonce}`; + const rootName = `browser-interop-root-${nonce}`; + const notePath = "webapp-to-cli.md"; + const noteContent = + `# Browser interoperability\n\nTransferred through WebApp, WebPeer, and CLI.\n${nonce}\n`; + + if (screenshotDirectory !== undefined) { + await Deno.mkdir(screenshotDirectory, { recursive: true }); + } + await using workDir = await TemporaryDirectory.create( + "livesync-browser-apps-interop", + ); + const cliDatabasePath = workDir.resolve("cli-database"); + const cliSettingsPath = workDir.resolve("cli-settings.json"); + await Deno.mkdir(cliDatabasePath, { recursive: true }); + await configureCliSettings(cliSettingsPath, { + deviceName: cliDeviceName, + passphrase: p2pPassphrase, + relay, + room, + turnCredential, + turnServers, + turnUsername, + }); + + const relayUrl = new URL(relay); + await waitForPort( + relayUrl.hostname, + Number(relayUrl.port || (relayUrl.protocol === "wss:" ? 443 : 80)), + { + timeoutMs: 30_000, + }, + ); + if (turnServers.startsWith("turn:")) { + const [hostnameAndPort] = turnServers.slice("turn:".length).split("?"); + const separatorIndex = hostnameAndPort.lastIndexOf(":"); + const hostname = hostnameAndPort.slice(0, separatorIndex); + const turnPort = Number(hostnameAndPort.slice(separatorIndex + 1)); + if (hostname === "" || !Number.isFinite(turnPort)) { + throw new Error(`Unsupported TURN server URL: ${turnServers}`); + } + await waitForPort(hostname, turnPort, { timeoutMs: 30_000 }); + } + const server = Deno.serve( + { + hostname: "127.0.0.1", + onListen: () => {}, + port, + }, + serveBrowserApplications, + ); + const browser = await chromium.launch({ headless: true }); + const webAppContext = await browser.newContext(); + const webPeerContext = await browser.newContext(); + const webAppPage = await webAppContext.newPage(); + const webPeerPage = await webPeerContext.newPage(); + const assertNoWebAppFailures = observePageFailures(webAppPage); + const assertNoWebPeerFailures = observePageFailures(webPeerPage); + const baseUrl = `http://127.0.0.1:${port}/`; + + const pickerScript = ` + const rootName = ${JSON.stringify(rootName)}; + const notePath = ${JSON.stringify(notePath)}; + const noteContent = ${JSON.stringify(noteContent)}; + Object.defineProperty(globalThis, "showDirectoryPicker", { + configurable: true, + value: async () => { + const originRoot = await navigator.storage.getDirectory(); + try { + await originRoot.removeEntry(rootName, { recursive: true }); + } catch (error) { + if (error?.name !== "NotFoundError") throw error; + } + const selectedRoot = await originRoot.getDirectoryHandle(rootName, { create: true }); + const note = await selectedRoot.getFileHandle(notePath, { create: true }); + const writable = await note.createWritable(); + await writable.write(noteContent); + await writable.close(); + return selectedRoot; + }, + }); + `; + await webAppPage.addInitScript(pickerScript); + + try { + await webPeerPage.goto(new URL("webpeer/", baseUrl).href); + await webPeerPage.getByRole("heading", { + name: "Peer to Peer Replicator", + exact: true, + }).waitFor({ + timeout: 30_000, + }); + await captureBrowserPage( + webPeerPage, + screenshotDirectory, + "webpeer-initial.png", + ); + await configureP2PPane( + webPeerPage.getByRole("region", { name: "WebPeer controls" }), + { + deviceName: webPeerDeviceName, + passphrase: p2pPassphrase, + relay, + room, + turnCredential, + turnServers, + turnUsername, + }, + ); + await webPeerPage.getByRole("button", { name: "Connect", exact: true }) + .click(); + await webPeerPage.getByText("Connected to Signaling Server", { + exact: false, + }).waitFor({ + timeout: 30_000, + }); + await captureBrowserPage( + webPeerPage, + screenshotDirectory, + "webpeer-configured.png", + ); + + await webAppPage.goto(new URL("webapp/webapp.html", baseUrl).href); + await webAppPage.locator("#vault-selector").waitFor({ + state: "visible", + timeout: 30_000, + }); + await captureBrowserPage( + webAppPage, + screenshotDirectory, + "webapp-root-selection.png", + ); + await webAppPage.getByRole("button", { + name: "Choose new vault folder", + exact: true, + }).click(); + await webAppPage.locator("#vault-selector").waitFor({ + state: "hidden", + timeout: 30_000, + }); + const webAppP2P = webAppPage.locator(".p2p-control"); + await webAppP2P.getByRole("heading", { + name: "Peer to Peer Replicator", + exact: true, + }).waitFor(); + await configureP2PPane(webAppP2P, { + deviceName: webAppDeviceName, + passphrase: p2pPassphrase, + relay, + room, + turnCredential, + turnServers, + turnUsername, + }); + await webAppP2P.getByRole("button", { + name: "Scan local files", + exact: true, + }).click(); + await webAppP2P + .getByRole("status") + .filter({ hasText: "Local files are ready for synchronisation." }) + .waitFor({ timeout: 30_000 }); + await webAppP2P.getByRole("button", { name: "Connect", exact: true }) + .click(); + await webAppP2P.getByText("Connected to Signaling Server", { + exact: false, + }).waitFor({ + timeout: 30_000, + }); + await captureBrowserPage( + webAppPage, + screenshotDirectory, + "webapp-configured.png", + ); + + const webAppPeerRow = webPeerPage.locator("table.peers tbody tr").filter({ + hasText: webAppDeviceName, + }); + await webAppPeerRow.waitFor({ timeout: 30_000 }); + await webAppPeerRow.getByRole("button", { name: "Accept", exact: true }) + .click(); + await webAppPeerRow.getByRole("button", { name: "🔄", exact: true }) + .click(); + + const webAppConnectionRequest = webAppPage.locator( + "dialog.vpk-browser-dialog[open]", + ); + await webAppConnectionRequest.waitFor({ + state: "visible", + timeout: 30_000, + }); + assertEquals( + await webAppConnectionRequest.getByRole("heading").innerText(), + "P2P Connection Request", + ); + await captureBrowserPage( + webAppPage, + screenshotDirectory, + "webapp-connection-request.png", + false, + ); + await webAppConnectionRequest + .getByRole("button", { name: "Accept", exact: true }) + .evaluate((button: { click(): void }) => button.click()); + await webAppConnectionRequest.waitFor({ + state: "hidden", + timeout: 5_000, + }); + try { + await waitForWebPeerLog( + webPeerPage, + "P2P Replication has been done", + 60_000, + ); + } catch (error) { + throw new Error( + `${formatError(error)}\n\nWebApp state:\n${await webAppPage.locator( + "body", + ).innerText()}`, + ); + } + await captureBrowserPage( + webAppPage, + screenshotDirectory, + "webapp-webpeer-replicated.png", + ); + await captureBrowserPage( + webPeerPage, + screenshotDirectory, + "webpeer-webapp-replicated.png", + ); + + await webAppP2P.getByRole("button", { name: "Disconnect", exact: true }) + .click(); + await webAppP2P.getByText("No Connection", { exact: true }).waitFor({ + timeout: 30_000, + }); + await webAppContext.close(); + + const cliSync = startCliInBackground( + cliDatabasePath, + "--settings", + cliSettingsPath, + "p2p-sync", + webPeerDeviceName, + "20", + ); + const cliPeerRow = webPeerPage.locator("table.peers tbody tr").filter({ + hasText: cliDeviceName, + }); + try { + await cliPeerRow.waitFor({ timeout: 20_000 }); + const webPeerConnectionRequest = webPeerPage.locator( + "dialog.vpk-browser-dialog[open]", + ); + await webPeerConnectionRequest.waitFor({ + state: "visible", + timeout: 20_000, + }); + assertEquals( + await webPeerConnectionRequest.getByRole("heading").innerText(), + "P2P Connection Request", + ); + await captureBrowserPage( + webPeerPage, + screenshotDirectory, + "webpeer-cli-connection-request.png", + false, + ); + await webPeerConnectionRequest + .getByRole("button", { name: "Accept", exact: true }) + .evaluate((button: { click(): void }) => button.click()); + await webPeerConnectionRequest.waitFor({ + state: "hidden", + timeout: 5_000, + }); + const syncExitCode = await waitForBackgroundExit(cliSync, 45_000); + assertEquals(syncExitCode, 0, cliSync.combined); + } catch (error) { + await cliSync.stop(); + throw new Error( + `${ + formatError(error) + }\n\nCLI output:\n${cliSync.combined}\n\nWebPeer logs:\n${await webPeerLogs( + webPeerPage, + )}`, + ); + } + + const catResult = await runCli( + cliDatabasePath, + "--settings", + cliSettingsPath, + "cat", + notePath, + ); + assertEquals(catResult.code, 0, catResult.combined); + assertEquals(sanitiseCatStdout(catResult.stdout), noteContent); + assertStringIncludes(catResult.stderr, "[Command] cat"); + await captureBrowserPage( + webPeerPage, + screenshotDirectory, + "webpeer-cli-completed.png", + ); + + assertNoWebAppFailures(); + assertNoWebPeerFailures(); + } finally { + await webAppContext.close().catch(() => {}); + await webPeerContext.close().catch(() => {}); + await browser.close(); + await server.shutdown(); + } + }, +}); diff --git a/test/browser-apps/pages/browser-smoke.test.ts b/test/browser-apps/pages/browser-smoke.test.ts new file mode 100644 index 00000000..3be1286d --- /dev/null +++ b/test/browser-apps/pages/browser-smoke.test.ts @@ -0,0 +1,86 @@ +import { assertEquals } from "@std/assert"; +import { resolve } from "@std/path"; +import { chromium, type Page } from "playwright"; + +import { observePageFailures, startStaticServer } from "../helpers/browser.ts"; + +const pagesSiteRoot = resolve(Deno.env.get("PAGES_SITE_ROOT") ?? resolve(import.meta.dirname!, "../../../_site")); + +function observeNetworkFailures(page: Page): () => void { + const failures: string[] = []; + page.on("requestfailed", (request) => { + failures.push(`${request.method()} ${request.url()}: ${request.failure()?.errorText ?? "request failed"}`); + }); + page.on("response", (response) => { + if (response.status() >= 400) { + failures.push(`${response.request().method()} ${response.url()}: HTTP ${response.status()}`); + } + }); + return () => assertEquals(failures, [], failures.join("\n")); +} + +Deno.test({ + name: "GitHub Pages package serves browser applications from their final subpaths", + sanitizeOps: false, + sanitizeResources: false, + async fn() { + const server = await startStaticServer(pagesSiteRoot); + const browser = await chromium.launch({ headless: true }); + try { + const aggregator = await browser.newPage(); + const assertNoAggregatorFailures = observePageFailures(aggregator); + const assertNoAggregatorNetworkFailures = observeNetworkFailures(aggregator); + await aggregator.goto(new URL("aggregator.html#id=pages-smoke&n=2&i=0&d=before%2", server.baseUrl).href); + await aggregator.getByText("1 / 2 Loaded", { exact: true }).waitFor(); + await aggregator.goto( + new URL("aggregator.html#id=pages-smoke&n=2&i=1&d=3after%26amp%2Bplus%25percent", server.baseUrl) + .href + ); + assertEquals( + await aggregator.getByRole("link", { name: "Open Obsidian to complete setup" }).getAttribute("href"), + "obsidian://setuplivesync?settingsQR=before%23after%26amp%2Bplus%25percent" + ); + assertNoAggregatorFailures(); + assertNoAggregatorNetworkFailures(); + await aggregator.close(); + + const webApp = await browser.newPage(); + const assertNoWebAppFailures = observePageFailures(webApp); + const assertNoWebAppNetworkFailures = observeNetworkFailures(webApp); + await webApp.addInitScript(` + Object.defineProperty(globalThis, "showDirectoryPicker", { + configurable: true, + value: async () => { + throw new DOMException("Cancelled by Pages browser test", "AbortError"); + }, + }); + `); + await webApp.goto(new URL("webapp/", server.baseUrl).href); + await webApp.waitForURL(/\/webapp\/webapp\.html$/); + const vaultPicker = webApp.getByRole("button", { name: "Choose new vault folder" }); + await vaultPicker.click(); + await webApp.locator("#status.warning").filter({ hasText: "Vault selection was cancelled" }).waitFor(); + assertEquals(await vaultPicker.isEnabled(), true); + assertNoWebAppFailures(); + assertNoWebAppNetworkFailures(); + await webApp.close(); + + const webPeer = await browser.newPage(); + const assertNoWebPeerFailures = observePageFailures(webPeer); + const assertNoWebPeerNetworkFailures = observeNetworkFailures(webPeer); + await webPeer.goto(new URL("webpeer/", server.baseUrl).href); + await webPeer.getByRole("heading", { name: "Peer to Peer Replicator", exact: true }).waitFor({ + timeout: 30_000, + }); + await webPeer.getByText("No Connection", { exact: true }).waitFor(); + assertEquals(new URL(webPeer.url()).pathname, "/webpeer/"); + const manifest = await webPeer.request.get(new URL("webpeer/manifest.json", server.baseUrl).href); + assertEquals(manifest.status(), 200); + assertNoWebPeerFailures(); + assertNoWebPeerNetworkFailures(); + } finally { + await browser.close(); + await server.close(); + } + }, +}); diff --git a/test/browser-apps/run-compose-interop.ts b/test/browser-apps/run-compose-interop.ts new file mode 100644 index 00000000..45e7a546 --- /dev/null +++ b/test/browser-apps/run-compose-interop.ts @@ -0,0 +1,33 @@ +const repositoryRoot = await Deno.realPath(new URL("../../", import.meta.url)); +const composeArgs = ["compose", "-f", "test/browser-apps/compose.yml"]; + +async function runDocker(args: string[]): Promise { + return await new Deno.Command("docker", { + args, + cwd: repositoryRoot, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }).spawn().status; +} + +let testStatus: Deno.CommandStatus | undefined; +try { + testStatus = await runDocker([...composeArgs, "run", "--build", "--rm", "browser-apps-interop"]); +} finally { + const cleanupStatus = await runDocker([...composeArgs, "down", "-v", "--remove-orphans"]); + if (!cleanupStatus.success) { + console.error(`[Browser applications E2E] Compose cleanup failed with exit code ${cleanupStatus.code}.`); + if (testStatus?.success) { + Deno.exit(cleanupStatus.code); + } + } +} + +if (!testStatus?.success) { + const code = testStatus?.code ?? 1; + console.error(`[Browser applications E2E] Compose interoperability test failed with exit code ${code}.`); + Deno.exit(code); +} + +console.log("\n[Browser applications E2E] Compose interoperability test passed."); diff --git a/test/browser-apps/webapp/browser-smoke.test.ts b/test/browser-apps/webapp/browser-smoke.test.ts new file mode 100644 index 00000000..e9ffc165 --- /dev/null +++ b/test/browser-apps/webapp/browser-smoke.test.ts @@ -0,0 +1,161 @@ +import { assertEquals } from "@std/assert"; +import { resolve } from "@std/path"; +import { chromium } from "playwright"; + +import { observePageFailures, startStaticServer, waitFor } from "../helpers/browser.ts"; + +const webAppDist = resolve(import.meta.dirname!, "../../../src/apps/webapp/dist"); + +Deno.test({ + name: "WebApp: production bundle selects a Vault and preserves the main remote while saving P2P settings", + sanitizeOps: false, + sanitizeResources: false, + async fn() { + const server = await startStaticServer(webAppDist); + const browser = await chromium.launch({ headless: true }); + try { + const cancellationPage = await browser.newPage(); + const assertNoCancellationFailures = observePageFailures(cancellationPage); + await cancellationPage.addInitScript(` + Object.defineProperty(globalThis, "showDirectoryPicker", { + configurable: true, + value: async () => { + throw new DOMException("Cancelled by WebApp browser test", "AbortError"); + }, + }); + `); + await cancellationPage.goto(new URL("webapp.html", server.baseUrl).href); + await cancellationPage.locator("#status").filter({ hasText: "Select a vault folder" }).waitFor(); + const picker = cancellationPage.getByRole("button", { name: "Choose new vault folder" }); + await picker.click(); + await cancellationPage + .locator("#status.warning") + .filter({ hasText: "Vault selection was cancelled" }) + .waitFor(); + assertEquals(await picker.isEnabled(), true); + assertEquals(await cancellationPage.locator("#vault-selector").isVisible(), true); + assertNoCancellationFailures(); + await cancellationPage.close(); + + const runtimePage = await browser.newPage(); + const assertNoRuntimeFailures = observePageFailures(runtimePage); + await runtimePage.addInitScript(` + Object.defineProperty(globalThis, "showDirectoryPicker", { + configurable: true, + value: async () => { + const originRoot = await navigator.storage.getDirectory(); + try { + await originRoot.removeEntry("livesync-webapp-smoke", { recursive: true }); + } catch (error) { + if (error?.name !== "NotFoundError") throw error; + } + return await originRoot.getDirectoryHandle("livesync-webapp-smoke", { create: true }); + }, + }); + `); + await runtimePage.goto(new URL("webapp.html", server.baseUrl).href); + await runtimePage.getByRole("button", { name: "Choose new vault folder" }).click(); + await runtimePage.locator("#vault-selector").waitFor({ state: "hidden", timeout: 30_000 }); + await runtimePage.waitForFunction("globalThis.livesyncApp?.getRuntime() != null"); + await runtimePage.locator("#status.warning").filter({ hasText: "Please configure CouchDB" }).waitFor(); + + const state = await runtimePage.evaluate(async () => { + const app = ( + globalThis as typeof globalThis & { + livesyncApp?: { + getRuntime(): unknown; + historyStore: { + getVaultHistory(): Promise>; + }; + }; + } + ).livesyncApp; + return { + historyNames: (await app!.historyStore.getVaultHistory()).map((item) => item.name), + runtimeStarted: app!.getRuntime() != null, + }; + }); + assertEquals(state, { + historyNames: ["livesync-webapp-smoke"], + runtimeStarted: true, + }); + + await runtimePage.getByRole("heading", { name: "Peer to Peer Replicator", exact: true }).waitFor(); + await runtimePage.evaluate(() => { + const runtime = ( + globalThis as typeof globalThis & { + livesyncApp?: { + getRuntime(): { + events: { + emitEvent(name: string, payload: unknown): void; + }; + } | null; + }; + } + ).livesyncApp?.getRuntime(); + runtime?.events.emitEvent("p2p-server-status", { + isConnected: true, + knownAdvertisements: [ + { + isAccepted: true, + name: "Peer without WebApp menu", + peerId: "peer-without-webapp-menu", + }, + ], + serverPeerId: "webapp-browser-smoke", + }); + }); + await runtimePage.getByText("Peer without WebApp menu", { exact: true }).waitFor(); + assertEquals( + await runtimePage.getByRole("button", { name: "...", exact: true }).count(), + 0, + "WebApp must not render a peer-menu action without a menu capability" + ); + await runtimePage.locator(".p2p-control input[type='checkbox']").first().check(); + await runtimePage + .getByPlaceholder("wss://exp-relay.vrtmrz.net, wss://xxxxx") + .fill("ws://127.0.0.1:4010/"); + await runtimePage.getByPlaceholder("anything-you-like").fill("browser-e2e-room"); + await runtimePage.getByPlaceholder("password").fill("browser-e2e-passphrase"); + await runtimePage.getByPlaceholder("iphone-16").fill("browser-e2e-webapp"); + const save = runtimePage.getByRole("button", { name: "Save and Apply", exact: true }); + await save.click(); + await waitFor(async () => await save.isDisabled(), "WebApp did not finish applying P2P settings"); + + const primaryRemote = await runtimePage.evaluate(() => { + const runtime = ( + globalThis as typeof globalThis & { + livesyncApp?: { + getRuntime(): { + p2pPaneHost: { + services: { + setting: { + currentSettings(): { + isConfigured: boolean; + remoteType: string; + }; + }; + }; + }; + } | null; + }; + } + ).livesyncApp?.getRuntime(); + const settings = runtime?.p2pPaneHost.services.setting.currentSettings(); + return { + isConfigured: settings?.isConfigured, + remoteType: settings?.remoteType, + }; + }); + assertEquals(primaryRemote, { + isConfigured: false, + remoteType: "", + }); + await runtimePage.getByRole("button", { name: "Scan local files", exact: true }).waitFor(); + assertNoRuntimeFailures(); + } finally { + await browser.close(); + await server.close(); + } + }, +}); diff --git a/test/browser-apps/webpeer/browser-smoke.test.ts b/test/browser-apps/webpeer/browser-smoke.test.ts new file mode 100644 index 00000000..79800a77 --- /dev/null +++ b/test/browser-apps/webpeer/browser-smoke.test.ts @@ -0,0 +1,140 @@ +import { assertEquals } from "@std/assert"; +import { resolve } from "@std/path"; +import { chromium } from "playwright"; + +import { observePageFailures, startStaticServer, waitFor } from "../helpers/browser.ts"; + +const webPeerDist = resolve(import.meta.dirname!, "../../../src/apps/webpeer/dist"); + +Deno.test({ + name: "WebPeer: production bundle starts and reloads its persisted settings", + sanitizeOps: false, + sanitizeResources: false, + async fn() { + const server = await startStaticServer(webPeerDist); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + const assertNoPageFailures = observePageFailures(page); + await page.goto(server.baseUrl); + await page.getByRole("heading", { name: "Peer to Peer Replicator", exact: true }).waitFor({ + timeout: 30_000, + }); + await page.getByText("No Connection", { exact: true }).waitFor(); + assertEquals( + await page + .getByRole("link", { name: "Try the P2P connection check", exact: true }) + .getAttribute("href"), + "./check.html" + ); + + await page.getByPlaceholder("anything-you-like").fill("browser-e2e-room"); + await page.getByPlaceholder("iphone-16").fill("browser-e2e-peer"); + await page.getByText("Optional TURN server settings", { exact: true }).click(); + await page.getByPlaceholder("turn:turn.example.com:3478").fill("turn:127.0.0.1:3478"); + await page.getByPlaceholder("Enter TURN username").fill("browser-turn-user"); + await page.getByPlaceholder("Enter TURN credential").fill("browser-turn-credential"); + const saveTurn = page.getByRole("button", { name: "Save TURN settings", exact: true }); + await saveTurn.click(); + await waitFor(async () => await saveTurn.isDisabled(), "WebPeer did not finish applying its TURN settings"); + const save = page.getByRole("button", { name: "Save and Apply", exact: true }); + await save.click(); + await waitFor(async () => await save.isDisabled(), "WebPeer did not finish applying its settings"); + + await page.reload(); + await page.getByRole("heading", { name: "Peer to Peer Replicator", exact: true }).waitFor({ + timeout: 30_000, + }); + assertEquals(await page.getByPlaceholder("anything-you-like").inputValue(), "browser-e2e-room"); + assertEquals(await page.getByPlaceholder("iphone-16").inputValue(), "browser-e2e-peer"); + await page.getByText("Optional TURN server settings", { exact: true }).click(); + assertEquals(await page.getByPlaceholder("turn:turn.example.com:3478").inputValue(), "turn:127.0.0.1:3478"); + assertEquals(await page.getByPlaceholder("Enter TURN username").inputValue(), "browser-turn-user"); + assertEquals(await page.getByPlaceholder("Enter TURN credential").inputValue(), "browser-turn-credential"); + assertEquals(await page.getByRole("button", { name: "Connect", exact: true }).isVisible(), true); + assertNoPageFailures(); + } finally { + await browser.close(); + await server.close(); + } + }, +}); + +Deno.test({ + name: "WebPeer: P2P connection check prepares a local Setup URI and zeroed diagnostics", + sanitizeOps: false, + sanitizeResources: false, + async fn() { + const server = await startStaticServer(webPeerDist); + const browser = await chromium.launch({ headless: true }); + try { + for (const target of ["desktop", "mobile"] as const) { + const page = await browser.newPage(); + const assertNoPageFailures = observePageFailures(page); + try { + await page.goto(`${server.baseUrl}check.html`); + await page.getByRole("heading", { name: "P2P connection check", exact: true }).waitFor({ + timeout: 30_000, + }); + if (target === "mobile") { + await page.locator('input[type="radio"][value="mobile"]').check(); + } + + await page.getByRole("button", { name: `Prepare ${target} check`, exact: true }).click(); + await page.getByAltText(`Setup URI QR code for the ${target} check`, { exact: true }).waitFor({ + timeout: 30_000, + }); + + const setupURI = await page.getByLabel("Setup URI", { exact: true }).inputValue(); + const passphrase = await page.getByLabel("Setup URI passphrase", { exact: true }).inputValue(); + const qrSource = await page + .getByAltText(`Setup URI QR code for the ${target} check`, { exact: true }) + .getAttribute("src"); + + assertEquals(setupURI.startsWith("obsidian://setuplivesync?settings="), true); + assertEquals(/^[a-z2-9]{4}(?:-[a-z2-9]{4}){3}$/.test(passphrase), true); + assertEquals(qrSource?.startsWith("data:image/"), true); + for (const label of ["Setup URI", "Setup URI passphrase"] as const) { + const credentialField = page.getByLabel(label, { exact: true }); + assertEquals(await credentialField.getAttribute("autocomplete"), "off"); + assertEquals(await credentialField.getAttribute("spellcheck"), "false"); + } + assertEquals(await page.getByTestId("diag-new").textContent(), "0"); + assertEquals(await page.getByTestId("diag-successful").textContent(), "0"); + assertEquals(await page.getByTestId("diag-failed").textContent(), "0"); + assertEquals(await page.getByTestId("diag-closed").textContent(), "0"); + assertEquals( + await page.getByRole("button", { name: "Start connection monitor", exact: true }).isVisible(), + true + ); + assertEquals( + await page + .getByRole("button", { name: "Try another device without resetting", exact: true }) + .count(), + 0 + ); + + await page.locator(".results-card").scrollIntoViewIfNeeded(); + await page.getByRole("button", { name: "Show the Setup QR again", exact: true }).click(); + await waitFor( + async () => + await page + .getByAltText(`Setup URI QR code for the ${target} check`, { exact: true }) + .evaluate((element) => { + const rect = element.getBoundingClientRect(); + return rect.top >= 0 && rect.bottom <= document.documentElement.clientHeight; + }), + `The ${target} Setup QR did not return to the viewport` + ); + assertEquals(await page.getByLabel("Setup URI", { exact: true }).inputValue(), setupURI); + assertNoPageFailures(); + } finally { + await page.close(); + } + } + } finally { + await browser.close(); + await server.close(); + } + }, +}); diff --git a/test/contracts/serviceContext.ts b/test/contracts/serviceContext.ts new file mode 100644 index 00000000..ddfb6ce4 --- /dev/null +++ b/test/contracts/serviceContext.ts @@ -0,0 +1,78 @@ +import type { CommonlibMessageKey, ServiceContextContract } from "@vrtmrz/livesync-commonlib/context"; +import type { ServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/ServiceHub"; + +export const SERVICE_CONTEXT_MEMBERS = [ + "API", + "path", + "database", + "databaseEvents", + "replicator", + "fileProcessing", + "replication", + "remote", + "conflict", + "appLifecycle", + "setting", + "tweakValue", + "vault", + "test", + "UI", + "config", + "keyValueDB", + "control", +] as const satisfies readonly Exclude[]; + +export type ServiceContextMember = (typeof SERVICE_CONTEXT_MEMBERS)[number]; +type MissingServiceContextMember = Exclude, ServiceContextMember>; +const serviceContextMembersAreExhaustive: [MissingServiceContextMember] extends [never] ? true : never = true; +void serviceContextMembersAreExhaustive; + +export type ServiceContextResult = { + translation: string; + receivedEvents: string[]; +}; + +export type ServiceCompositionResult = { + hubUsesExpectedContext: boolean; + servicesUsingExpectedContext: Record; +}; + +/** + * Observe the host-neutral results promised by ServiceContextContract. + * + * The caller chooses the translation key because translated text is + * host-configured. Event delivery itself is shared behaviour. + */ +export function observeServiceContext( + context: ServiceContextContract, + translationKey: CommonlibMessageKey +): ServiceContextResult { + const receivedEvents: string[] = []; + const unsubscribe = context.events.onEvent("hello", (value) => receivedEvents.push(value)); + try { + context.events.emitEvent("hello", "context-contract-event"); + } finally { + unsubscribe(); + } + return { + translation: context.translate(translationKey), + receivedEvents, + }; +} + +/** + * Inspect whether a Service Hub and all public services preserve one exact + * context object instead of silently constructing or substituting another. + */ +export function observeServiceComposition( + hub: { readonly context: ServiceContextContract }, + expectedContext: ServiceContextContract +): ServiceCompositionResult { + const members = hub as unknown as Record; + return { + hubUsesExpectedContext: hub.context === expectedContext, + servicesUsingExpectedContext: Object.fromEntries( + SERVICE_CONTEXT_MEMBERS.map((member) => [member, members[member].context === expectedContext]) + ) as Record, + }; +} diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 746d8b85..864da52f 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -1,20 +1,22 @@ # Real Obsidian E2E Runner -This directory contains the experimental real Obsidian end-to-end runner. +This directory contains the maintained real Obsidian end-to-end runner. The generic application discovery, isolated-vault, plug-in installation, process lifecycle, CLI, CDP, and readiness implementation comes from `@vrtmrz/obsidian-test-session`. The small modules under `runner/` preserve LiveSync's existing imports and supply its plug-in ID and artefact location. LiveSync-specific fixtures, services, settings, workflows, and assertions remain in this repository. -The current smoke runner verifies only the launch path: +The current smoke runner verifies the launch path and the loaded plug-in's Service Context composition: 1. create a temporary vault, -2. install the built Self-hosted LiveSync plug-in artifacts, +2. install the built Self-hosted LiveSync plug-in artefacts, 3. launch real Obsidian, 4. open the temporary vault through `obsidian-cli`, -5. enable Obsidian community plug-ins for the temporary app profile, -6. reload Self-hosted LiveSync through `obsidian-cli`, -7. verify through `obsidian-cli eval` that the plug-in is loaded, -8. optionally drive a real vault or CouchDB workflow through Obsidian's own API, -9. terminate Obsidian and remove the temporary vault. +5. prepare the isolated Vault trust state and handle any Obsidian trust prompt, +6. preserve natural plug-in loading, or complete requested pre-load work before loading the plug-in once in controlled start-up, +7. verify through the active renderer that the plug-in is loaded, +8. observe event and translation results from the actual `ObsidianServiceContext`, +9. verify that the Service Hub and every exposed service retain that exact Context, +10. optionally drive a real vault or CouchDB workflow through Obsidian's own API, and +11. terminate Obsidian and remove the temporary vault. The runner does not require Self-hosted LiveSync to expose an E2E-only bridge. Readiness is checked from outside the plug-in through Obsidian's own CLI. @@ -22,11 +24,35 @@ Obsidian 1.12 stores the global community plug-in switch outside `.obsidian/comm Future workflows should use `startObsidianLiveSyncSession()` from `runner/session.ts` rather than repeating the launch and plug-in readiness sequence. Add generic Obsidian bootstrap improvements to Fancy Kit; keep LiveSync behaviour and scenario helpers here. +When a LiveSync-owned scenario must establish application state before the plug-in's first load, pass an instance-scoped `lifecycle.beforePluginStart` callback through that wrapper. For example, the P2P pane scenario calls `setObsidianMobileTestModeBeforePluginStart()` there so LiveSync observes the mobile application state while registering its command and view. Mobile emulation reopens Obsidian's workspace layout; this helper waits for both the `is-mobile` body state and `workspace.layoutReady` before controlled loading continues. The shared package owns the controlled start-up order and guarantees that the plug-in loads once; the LiveSync scenario owns the resulting command, workspace placement, and visible UI assertions. Changing the state only after loading the plug-in is not evidence of its mobile start-up behaviour. + Each test vault uses an isolated Obsidian profile. The runner creates temporary directories for `HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, and Electron `--user-data-dir`, writes the vault registry into those directories, pre-seeds the temporary Chromium local storage so community plug-ins are trusted for that generated vault ID, and passes the same environment to `obsidian-cli`. This is intended to keep real Obsidian E2E runs separate from a developer's daily Obsidian profile and vault registry. +On macOS, `@vrtmrz/obsidian-test-session` keeps the generated Vault and profile below `/tmp` so Obsidian's Unix-domain CLI socket remains below the platform path limit. It also gives only the isolated Obsidian process Chromium's mock-keychain flag, preventing the empty test HOME from opening a blocking login-keychain dialogue. LiveSync's deterministic fixture selects the built-in default language so a host-language translation prompt cannot pause plug-in readiness. The case-only rename check enumerates the parent directory and compares exact spellings because an old-path lookup still resolves the renamed file on the default case-insensitive macOS filesystem. + +Multi-session workflows must keep each started Obsidian session tracked until its stop operation completes. If a scenario throws, teardown stops every active session before disposing its temporary Vault and profile, so a failed CLI or synchronisation operation cannot leave Obsidian using directories which have already been removed. + +## Observing and diagnosing a scenario + +Use externally visible behaviour as the pass condition: Vault files, remote-service state, revision data, or visible Obsidian UI. A log line can explain a failure, but should not replace an assertion about the resulting behaviour. + +The maintained runner provides several complementary observation paths: + +- `evalObsidianJson()` and `obsidian-cli eval` can read a small, explicitly selected piece of LiveSync or Obsidian state. +- `withObsidianPage()` can inspect the active renderer, invoke a registered command, or interact with visible UI through CDP. `captureObsidianPage()`, `captureObsidianDialogue()`, and `captureObsidianElement()` retain screenshots; the capture helpers also write a full-page `.failure.png` before rethrowing a UI assertion failure. +- `session.app.output()` returns the standard output and standard error captured from the isolated Obsidian process. This is especially useful when the renderer or CLI becomes unreachable. +- **Show log** (`obsidian-livesync:view-log`) exposes the recent LiveSync log, while **Copy full report to clipboard** (`obsidian-livesync:dump-debug-info`) opens the generated diagnostic report. `dialog-mounts.ts` verifies both surfaces, and focused scenarios may inspect the log pane and `appLifecycle.getUnresolvedMessages()` for a bounded set of expected errors. +- Renderer `console` messages and uncaught page errors are not retained automatically. A focused investigation can attach `page.on("console", ...)` and `page.on("pageerror", ...)` while it owns a `withObsidianPage()` callback. That observer ends when the callback closes its CDP connection, so use it around the action under investigation rather than treating it as a session-wide audit trail. + +If a scenario times out or appears to do nothing, capture the visible page before teardown, then record a bounded state snapshot and the relevant tail of the LiveSync log, unresolved messages, and process output. If an unexplained Notice appears, retain a screenshot while it is still visible before opening or dismissing it, then use the log or full report to identify its source. A Notice alone is not enough evidence for its cause. + +Set `showVerboseLog: true` only in isolated plug-in data when a focused investigation needs it. Keep captured output short and redact it before retaining or sharing it: logs and reports can contain Vault paths, document names, endpoints, credentials, Setup URIs, passphrases, or Security Seed material. Do not collect verbose logs from an ordinary user Vault. + +Collect evidence before cleanup, and keep process, Vault, profile, and remote-fixture cleanup in `finally`. After `app.emulateMobile(true)`, use the active CDP renderer for fixture operations because Obsidian may remove desktop-only CLI commands. Visually inspect screenshots before copying selected images into user documentation; a passing locator assertion does not establish that a dialogue is readable or unobstructed. + ## Local Setup -Set `OBSIDIAN_BINARY` when Obsidian is not installed in a standard location. +Set `OBSIDIAN_BINARY` when Obsidian is not installed in a standard location. Set `OBSIDIAN_CLI` as well when its companion executable is outside the built-in discovery paths. For an AppImage on Linux without FUSE, use the helper script: @@ -44,30 +70,66 @@ These tests are intended for local verification, not the default CI gate. Reuse ## Commands +After changing plug-in source, use the focused wrapper rather than invoking a scenario directly. It always rebuilds `main.js` before launching real Obsidian, and it builds the local CLI too when the CLI-to-Obsidian scenario needs it: + ```bash +npm run test:e2e:obsidian:focused -- settings-ui +npm run test:e2e:obsidian:focused -- two-vault-sync +npm run test:e2e:obsidian:focused -- security-seed-reconnect +``` + +The wrapper accepts only maintained real-Obsidian scenario names; run it with `--help` for the current list. It deliberately does not manage CouchDB, Object Storage, or the P2P signalling relay. Start the required fixture first, or use the complete service-managed suite. + +The principal entry points are: + +```bash +npm run test:contract:contexts +npm run test:contract:context:webapp +npm run test:contract:context:cli +npm run test:contract:context:obsidian +npm run test:e2e:obsidian:runner npm run test:e2e:obsidian:install-appimage npm run test:e2e:obsidian:discover npm run test:e2e:obsidian:cli-help -- vaults verbose -npm run test:e2e:obsidian:smoke -npm run test:e2e:obsidian:vault-reflection -npm run test:e2e:obsidian:couchdb-upload -npm run test:e2e:obsidian:cli-to-obsidian-sync -npm run test:e2e:obsidian:minio-upload -npm run test:e2e:obsidian:startup-scan -npm run test:e2e:obsidian:two-vault-sync -npm run test:e2e:obsidian:hidden-file-snippet-sync -npm run test:e2e:obsidian:customisation-sync -npm run test:e2e:obsidian:setting-markdown-export +npm run test:e2e:obsidian:upgrade-from-stable -- --transport all npm run test:e2e:obsidian:local-suite npm run test:e2e:obsidian:local-suite:services ``` -`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, vault reflection, CouchDB upload, CLI-to-Obsidian synchronisation, Object Storage upload, startup scan, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export in sequence. Start the local CouchDB and MinIO fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run. +The underlying `test:e2e:obsidian:` scripts remain available for an immediate rerun against an already built, unchanged bundle. They do not build `main.js`; do not use them as the first verification after a source change. The complete local suite performs its own build. -`test:e2e:obsidian:couchdb-upload` reuses the CouchDB variables from `.test.env` or the process environment. It expects a reachable CouchDB service, creates a unique database, configures Self-hosted LiveSync through `obsidian-cli eval`, creates a note in real Obsidian, commits the note into the local database, runs one-shot synchronisation, and verifies that the remote database contains both the metadata document and its chunk documents. +`test:contract:contexts` runs the directly observable host contract against the Obsidian, CLI, and Webapp compositions. It verifies event and translation results, host-specific capabilities, and that the CLI and Webapp Service Hubs pass one exact Context to all exposed services. `test:contract:context:webapp` runs only the Webapp part. + +`test:contract:context:cli` builds the Node CLI and runs its existing Deno setup, put, read, list, information, remove, conflict-resolution, and revision workflow. `test:contract:context:obsidian` builds the plug-in and runs the real-Obsidian smoke test, including the Context inspection. These runtime scripts are local validation entry points and are not added to the default CI gate by this change. + +`test:e2e:obsidian:onboarding-invitation` starts an unconfigured temporary Vault with no plug-in data and verifies that startup selects Commonlib's new-Vault recommendations, offers the setup wizard without opening it, and does not scan Vault files automatically. It checks the invitation action and introduction in mobile test mode, then reopens the wizard from **Self-hosted LiveSync settings** → **Setup** on the desktop. This scenario owns the unconfigured-startup boundary only; configured compatibility review remains covered by `settings-ui`, and the setup workflows remain covered by their dedicated scenarios. + +`test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises remote selection and CouchDB settings through `SetupManager`, plus Setup URI entry through the registered command. It verifies the compatibility pause and remote-size review, the distinction between a central data-storage server and P2P signalling, the explicit tested and untested CouchDB save actions, the internal-API warning, the Setup URI controls, automatic adjustment when differences are limited to compatible chunk settings, and both manual configuration-mismatch routes. The same session opens the live log and generated full report, reaches the `Hatch` recovery controls, writes and removes its own persistent log, and runs the missing-chunk recreation and file-verification actions against the empty disposable Vault. It captures representative desktop and mobile dialogues, checks the mobile layout and vertically stacked actions, closes each route through its normal controls, and verifies that each mounted operation settles without an error. It does not apply a remote configuration, contact a remote service, or claim to repair a deliberately damaged database. + +`test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then selects the Synchronisation Settings pane and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour. + +The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixel viewport, and explicit iPhone-style safe-area insets of 47 pixels at the top and 34 pixels at the bottom. The public `@vrtmrz/obsidian-test-session` layout assertions require each modal to remain within the viewport and safe area without horizontal overflow. They also require the Obsidian Close control to remain within the safe area and provide at least a 44 by 44 CSS-pixel touch target. The runner clicks that control to verify actionability, then completes the explicit cancellation path. These simulated checks cover deterministic layout and interaction boundaries; they do not claim to reproduce a native operating-system overlay. + +`test:e2e:obsidian:review-harness` exercises only the boundaries owned by the opt-in maintainer Harness. It retains a real compatibility pause, uses the fixed Harness restart action to persist a device-local continuation and reload Obsidian, and requires the Harness to delete that state before reopening. It also runs the bounded local observations, confirms the dedicated Vault fixture root is removed, captures the copied privacy-bounded Markdown report, and checks the Harness layout and touch targets in mobile test mode. Compatibility explanation and persistence details remain owned by `settings-ui`, real P2P transfer remains owned by the dedicated P2P suites, and general Vault reflection remains owned by `vault-reflection`; the Harness test does not duplicate those workflows. + +`test:e2e:obsidian:p2p-pane` starts one configured CouchDB-only session with no P2P profile and separate configured P2P sessions for desktop and mobile. It proves that the command remains registered while the retired command, automatic pane, and ribbon entry without a P2P configuration are absent. For the configured P2P profiles, it verifies that the desktop ribbon is available, the current status command reaches the pane without it opening at start-up, checks its connection control and horizontal layout, and captures unobstructed desktop and mobile screenshots. The mobile session uses a fresh Vault, profile, and Obsidian process, enters `app.emulateMobile(true)` through `lifecycle.beforePluginStart`, and requires the P2P view to belong to the right drawer rather than inheriting desktop workspace state. It deliberately uses no relay or peer: replacement of the active replicator is covered by focused unit tests, the Deno and Compose CLI P2P lifecycle suite covers the headless transport, and `p2p-setup-uri-workflow` owns the visible transfer path between two real Obsidian sessions. + +`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, revision repair, settings UI, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload and manual setup, CLI-to-Obsidian synchronisation, Object Storage upload and Setup URI round-trip, P2P Setup URI round-trip, startup scan, provisioned CouchDB Setup URI, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export in sequence. Start the local CouchDB, MinIO, and P2P relay fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run. + +`test:e2e:obsidian:couchdb-upload` reuses the CouchDB variables from `.test.env` or the process environment. It expects a reachable CouchDB service, creates a unique database, starts from configured plug-in data without the device-local compatibility marker, and verifies the copied-or-restored Vault explanation in the actual compatibility dialogue. It captures the summary and details, resumes explicitly, confirms that the marker was recorded, creates a note in real Obsidian, commits the note into the local database, runs one-shot synchronisation, and verifies that the remote database contains both the metadata document and its chunk documents. + +The same workflow checks the two remote-activity status boundaries. It first holds a real CouchDB request at the selected fetch implementation and confirms that `🌐N` is visible while `📲` is absent. It then holds the real one-shot replication immediately before its replicator call, confirms that `📲` is visible while no physical request is active, releases it, and requires the finite and bounded activity counts to return to zero, the request and response counts to balance, and both indicators to disappear. Finally, it creates a remote-only chunk, holds the real on-demand fetch immediately before its remote call, makes the same logical active and idle assertions, and verifies that the fetched chunk is written into the local database. These gates make the active states deterministic without replacing the remote request or operation. + +`test:e2e:obsidian:couchdb-manual-setup-workflow` follows the visible onboarding path for the first device when no Setup URI is available. It enters end-to-end encryption and CouchDB details, runs the read-only `Check server requirements` step, requires the prepared fixture to pass without applying a server fix, and lets the onboarding connection test create the named database. After Rebuild completes on the first device, it creates an ordinary note, asks that working device to generate a Setup URI for a second device, completes Fetch there, and verifies a bidirectional note round-trip. The workflow captures each decision point and the expanded server-check result; password controls remain visually masked. + +If this status workflow fails while Obsidian is running, it writes a full-page screenshot and a JSON snapshot of the status text and counters under `/tmp/obsidian-livesync-e2e`. The dialogue-mount workflow leaves desktop and mobile screenshots for both representative Svelte routes, the Hidden File Sync workflow captures the successfully displayed JSON Resolve dialogue before selecting an option, and the Security Seed reconnect workflow captures each significant application state. The suite therefore records representative evidence without capturing every interaction. Set `E2E_OBSIDIAN_DIAGNOSTICS_DIR` to use another directory. + +The two-Vault workflow performs the missing-marker review once for each isolated Vault. Later process launches reuse the same profile-backed acknowledgement, rather than seeding a replacement or repeatedly applying a decision for the first device. The Hidden File Sync scenario is narrower: it starts from an explicitly acknowledged marker because it tests consumer-owned hidden-file behaviour, JSON resolution, target filtering, and grouped mobile Notices rather than duplicating the compatibility workflow. After `app.emulateMobile(true)`, its fixture operations use the active DevTools renderer because Obsidian can remove desktop-only CLI commands in mobile mode. `test:e2e:obsidian:cli-to-obsidian-sync` is the cross-runtime compatibility check for the official LiveSync CLI and the real Obsidian plug-in. Build the plug-in first, and build the local CLI too when no external CLI command is selected. The script uses E2EE, Path Obfuscation, and the current preferred chunk settings to create and synchronise a note through the CLI, starts real Obsidian with an isolated Vault and profile, synchronises the same CouchDB database, and verifies that the plug-in materialises identical note content. This covers the boundary that CLI-only and plug-in-only round trips do not exercise. +The isolated Obsidian session starts with its CouchDB settings and device-local compatibility acknowledgement already in place. This keeps the scenario focused on cross-runtime data compatibility; unconfigured start-up and visible CouchDB onboarding are covered by their dedicated workflows. + By default, the compatibility check runs `node src/apps/cli/dist/index.cjs`. Set `LIVESYNC_CLI_COMMAND` to test another CLI build or distribution. The value may be a quoted command line or a JSON array of executable and prefix arguments; the scenario arguments are appended without going through a shell. For example, to test an executable on `PATH`: @@ -83,23 +145,60 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) -- npm run test:e2e:obsidian:cli-to-obsidian-sync ``` -`test:e2e:obsidian:minio-upload` reuses the Object Storage variables from `.test.env` or the process environment. It expects a reachable S3-compatible service, configures Self-hosted LiveSync for Object Storage through `obsidian-cli eval`, creates a note in real Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written under a unique bucket prefix. +`test:e2e:obsidian:minio-upload` reuses the Object Storage variables from `.test.env` or the process environment. It expects a reachable S3-compatible service and starts with isolated Object Storage settings and the device-local compatibility acknowledgement already in place, keeping the scenario focused on upload rather than unconfigured start-up or setup. It confirms those settings through `obsidian-cli eval`, creates a note in real Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written under a unique bucket prefix. Adapter tests separately observe an in-progress SDK command, while this real-runtime workflow verifies the resulting request counters advance and rebalance. -`test:e2e:obsidian:startup-scan` configures a temporary CouchDB database, stops Obsidian, writes a note directly into the vault, restarts Obsidian, and verifies from CouchDB that the boot-time scan picked up the offline file. +`test:e2e:obsidian:object-storage-setup-uri-workflow` uses the public Commonlib-backed tool to generate the initial Setup URI for a unique MinIO prefix, completes visible initialisation on the first device, and then asks that working real Obsidian device to create a new Setup URI through the registered command. A second real Obsidian device imports only the device-generated URI. The workflow verifies A-to-B and B-to-A notes, captures the documented onboarding choices, and removes the Object Storage prefix only after both sessions have stopped. -`test:e2e:obsidian:two-vault-sync` runs a two-vault note synchronisation workflow. It verifies note creation, update, rename, deletion, per-device target filters where one vault ignores a note that the other vault synchronises, and a separate encrypted round-trip with Path Obfuscation enabled. The optional Markdown conflict automatic merge check can be enabled with `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true`, but it is not part of the default local suite. +`test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Compose Nostr relay fixture. The first device imports a generated initial Setup URI and completes its signalling test with zero peers, creates a Setup URI for the second device through the registered command, and remains online while the second device imports it. The second device must select the expected online source before Fetch can rebuild its local database. The workflow accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, checks that the menu for the three persistent per-peer actions remains within the viewport, reconnects both P2P sessions in join order, and verifies the B-to-A return journey. Every started session remains tracked until teardown completes. -`test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. +`test:e2e:obsidian:p2p-connection-check` owns the browser-to-Obsidian preflight path. It serves the WebPeer production build from loopback, asks the page to generate a disposable Setup URI using the local relay, starts its browser reference peer, and applies that exact URI through visible onboarding in an isolated empty real Obsidian Vault. After the first successful WebRTC diagnostic appears, it selects the action for another device in the same room, proves that the Setup URI was not regenerated, applies it to a second isolated empty real Obsidian Vault, and requires both the successful total and the baseline number of simultaneous active connections to advance. It captures the result card without Setup URI credentials and does not claim to verify note synchronisation. Run `test:e2e:obsidian:p2p-connection-check:services` to build both production artefacts and let the scenario start and stop the Compose relay. + +`test:e2e:obsidian:startup-scan` starts from a CouchDB fixture using current settings with its device-local compatibility marker already acknowledged, stops Obsidian, writes a note directly into the Vault, restarts the same isolated Vault and profile without rewriting its plug-in data, and verifies from CouchDB that the start-up scan picked up the offline file. Onboarding remains covered by `onboarding-invitation`; this scenario owns the ordinary configured restart and start-up scan. + +`test:e2e:obsidian:setup-uri-workflow` runs the repository's public Commonlib-backed CouchDB provisioning and Setup URI tools against the local CouchDB fixture. It configures a new, empty Vault in the first real Obsidian session through the visible onboarding wizard and uses Rebuild. After that device is working, it generates a new Setup URI through the registered command; the second real Obsidian Vault uses that URI for Fetch instead of reusing the initial Setup URI produced by the provisioning tool. The workflow verifies ordinary notes from the first device to the second and back again, independently enables Hidden File Sync on each device, and verifies a snippet. The retained Setup URI screenshots show only encrypted URIs and visually masked Setup URI passphrases; plaintext credentials are not captured. Files prefixed with `guide-` capture the relevant dialogue, settings panel, or workspace leaf without transient Notices. Public documentation copies selected images only after visual inspection; the E2E run does not overwrite repository documentation assets. + +`test:e2e:obsidian:two-vault-sync` runs a two-vault note synchronisation workflow. It verifies note creation, update, ordinary rename, a case-only file name change within the same directory, deletion, and a separate encrypted round-trip with Path Obfuscation enabled. Its target-filter scenario confirms that one Vault receives and checkpoints a remote document without reflecting it, restarts with the same profile and filter, and then reflects the stored document after the filter is broadened through the settings service. Directory case changes deliberately remain outside this scenario because they require directory-aware rename handling. The optional Markdown conflict check can be enabled with `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true`. It creates divergent revisions in two separate Vaults, performs a conservative merge on one Vault, edits that result again, and requires the other Vault to replace its known deleted losing revision without recreating the conflict. The separate `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` check keeps four conflicts active while one Vault edits, deletes, performs a case-only rename, and performs a cross-path rename. It asserts that each operation extends the revision displayed on that device, replicates the exact resulting revision tree, and preserves the other live branch. During focused development, `E2E_OBSIDIAN_ONLY_CONFLICT_OPERATIONS=true` runs that self-contained scope without the ordinary, target-filter, or encrypted scenarios. Both conflict checks remain outside the default local suite. + +`test:e2e:obsidian:security-seed-reconnect` is a focused CouchDB release-acceptance workflow. Device A first recognises an initial remote Security Seed, stops automatic replication while remaining open, and creates an unsent note. The runner replaces only the Security Seed in the managed remote synchronisation-parameter fixture. Device A must retain its deliberately stale cached value until the next one-shot synchronisation, refresh it before sending, and upload an HKDF-encrypted payload which uses the replacement value. A fresh device B must decrypt that note and send an encrypted note back; the original device A then receives the return journey with its Vault and isolated profile preserved. Desktop Obsidian may enforce a single application instance, so the two device sessions run sequentially after the same-process stale-cache assertion has completed. + +The workflow creates a random dedicated database, records only SHA-256 Seed fingerprints, and never writes a Seed, passphrase, or CouchDB credentials to its result. It also requires the remote Seed and all other synchronisation parameters to remain unchanged after the replacement revision, rejects HKDF and Seed errors from either session, writes `security-seed-reconnect-result.json`, and verifies that every Obsidian process, temporary Vault, isolated profile, and database has been removed. The result file and stage screenshots are retained in `E2E_OBSIDIAN_DIAGNOSTICS_DIR`; the screenshots show ordinary Vault content, not settings or secrets. The strict cleanup workflow rejects `E2E_OBSIDIAN_KEEP_VAULT` and `E2E_OBSIDIAN_KEEP_COUCHDB`. + +This proves in real Obsidian the plug-in behaviour shared by supported platforms, including the encrypted bidirectional round-trip and protection against a stale client restoring the old remote Seed. It does not verify iPadOS-specific background or reconnect lifecycle behaviour, and it does not count as Android device evidence. The workflow remains outside `test:e2e:obsidian:local-suite` because it is a focused release-acceptance check. + +`test:e2e:obsidian:conflict-dialog-policy` creates three real local revision leaves without a remote service and opens the pairwise merge dialogue in Obsidian. It verifies the three-version count, requires the four decision buttons to be stacked vertically, concatenates the displayed pair as a child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the same isolated Vault and profile, and confirms that only the two live versions are reconstructed. It also verifies that an ordinary repeated conflict check does not reopen a postponed dialogue, that **Resolve if conflicted.** explicitly reopens it, and that the active editor retains the appropriate unresolved-conflict warning. The scenario then invokes the same Commonlib consumer boundary used for an incoming replicated document and checks that a postponed warning disappears, an open stale dialogue closes, and the conflict-processing queue completes even when the dialogue closes immediately. This isolates the Obsidian UI contract from transport and second-device setup. The fixture owns one temporary Vault and profile, and the session runner stops Obsidian before removing them. + +`test:e2e:obsidian:revision-repair` creates an ordinary healthy logical deletion and two conflicting live revisions in a temporary real Obsidian Vault, then removes a chunk used only by the non-winning revision. It proves that automatic conflict checking does not discard the unreadable branch, and that a healthy logical deletion with no Vault file is neither reported nor retained as Vault provenance. **Inspect conflicts and file/database differences** must show the winner and conflict separately, identify the exact unreadable revision and missing chunk, show the compact `Δsize` and `Δtime` diagnostics, and expose a wrench menu with the appropriate actions for each branch. The scenario opens the existing comparison dialogue in read-only mode, applies the readable winner to the Vault, shows the compact matching-winner and remaining-conflict status, records the exact winner as Vault provenance without creating a child, and confirms that retrying the unreadable branch leaves the revision tree unchanged. It then verifies both the cancellation path and the explicit confirmation path for discarding only that selected live branch, requires the winner and its Vault provenance to remain unchanged, and captures the repair card, a 360-pixel-wide reflow check, the matching-winner status, both revision menus, and the read-only comparison. The narrow capture checks responsive layout, not a mobile operating-system lifecycle. The scenario uses no remote service; a retry is therefore expected to remain unreadable unless the chunk is already available locally. + +`test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. Initial enablement must open one user-visible progress Notice before the enabled setting is saved, then retain that Notice while its nested rebuild and scan phases continue in the ordinary log. The configured fixture starts with a current CouchDB remote profile, so migration from legacy remote settings remains the responsibility of the upgrade scenarios and cannot add unrelated Notices to this check. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one separate action Notice whose controls remain usable in mobile layouts; a manually dismissed group must not repeat its acknowledged rows when a later change arrives. `test:e2e:obsidian:customisation-sync` runs a two-vault Customisation Sync workflow. It scans a real snippet CSS file, config JSON file, and sample plug-in fixture into per-file Customisation Sync data, synchronises the entries through CouchDB, applies them on the second vault, verifies the resulting `.obsidian` files, propagates a snippet update, and verifies deletion of the source-vault snippet sync data without confusing it with the target vault's own applied copy. `test:e2e:obsidian:setting-markdown-export` enables setting Markdown export, waits for the generated Markdown file in the vault, and verifies that credentials are omitted when `writeCredentialsForSettingSync=false`. +`test:e2e:obsidian:upgrade-from-stable` is the release-acceptance upgrade workflow. It installs the exact published 0.25.83 artefacts into an isolated Vault, verifies their pinned SHA-256 values, and then replaces only the plug-in artefacts with the current target while retaining the same Vault and isolated Obsidian profile. The first run downloads the old release into the ignored `_testdata/releases` cache; every later run verifies the cached bytes before use. + +The workflow first exercises a non-empty legacy settings document which has no `isConfigured` or file-name case value. It verifies that 0.25.83 treats a default-equivalent document as unconfigured. That release can persist the inferred boolean during a later, unrelated settings-save event, so the runner accepts either an absent value or the inferred `false` on disk, then restores the same minimal pre-flag document deliberately before installing 1.0. The target independently proves its direct migration: the Vault remains unconfigured instead of receiving new-Vault recommendations, case-insensitive handling becomes explicit, no compatibility pause or acknowledgement marker is created while onboarding remains pending, and a second 1.0 start is idempotent. The absent marker is deliberately deferred rather than accepted; a later configured start must evaluate it. This fixture rewrite is limited to the missing-flag boundary; the configured transport upgrades use only state created and saved by 0.25.83 itself. + +For CouchDB and Object Storage, the workflow then configures 0.25.83 from its own defaults, saves the selected remote, and restarts that release with the same profile before creating history. This both verifies that the old settings persist and lets the old release initialise its replicator from the same saved state as an ordinary existing Vault. The runner waits for that release's asynchronously initialised persistent node identity, creates, edits, renames, and deletes notes, and synchronises each transition before installing the target. Every launch of the upgraded device uses the same isolated Obsidian profile. The session layer closes the renderer before its process-tree fallback, so Chromium persists the legacy compatibility marker naturally; the target must read and migrate that actual profile state to its current namespaced key. The final target restart likewise consumes the marker persisted by the preceding target session. The runner does not reconstruct that device's Vault data, plug-in settings, local database files, device-local state, or remote state. Before the target performs any synchronisation, it must retain the same Vault profile, local database, node identity, remote profile, local checkpoint, and remote milestone. The local node-info document is the identity source of truth; a transient replicator field is used only to confirm that the old asynchronous initialisation has completed. Its first synchronisation must be a no-op: CouchDB document revisions and `update_seq` must remain unchanged, while Object Storage must neither upload nor download journal bodies. The upgraded device then sends a new delta. A separate fresh 1.0 verifier starts from an explicit fixture containing settings and compatibility state for the current version, receives the complete surviving history, and returns another delta; it is not part of the migration assertion for legacy remote settings. The upgraded Vault receives that return journey and retains it across restart. + +Before creating stable-release history, the runner waits until the remote Security Seed can be read and only then marks the remote as resolved. Completion of the old release's remote-creation method alone does not prove that this asynchronous fixture boundary is ready. + +Run the focused wrapper after source changes so that the target plug-in is rebuilt first: + +```bash +npm run test:e2e:obsidian:focused -- upgrade-from-stable --transport all --manage-services +``` + +Use `--transport couchdb` or `--transport object-storage` for a focused rerun. `--manage-services` starts and stops the required local fixture or fixtures; add `--keep-services` only when they should remain available for inspection. Set `E2E_LIVESYNC_TARGET_ARTIFACT_ROOT` to validate another already-built target directory, or `E2E_LIVESYNC_SOURCE_ARTIFACT_ROOT` to use an explicit cache directory whose files still match the pinned release hashes. + +This workflow is deliberately excluded from `local-suite`. It downloads a published historical artefact, reuses one profile across multiple application versions, and is an expensive release-acceptance gate rather than a routine current-version scenario. P2P is also excluded because cross-version P2P interoperability is a separate physical validation boundary. + Start the local fixtures first when they are not already running: ```bash npm run test:docker-couchdb:start npm run test:docker-s3:start +npm run test:docker-p2p:start npm run test:e2e:obsidian:local-suite ``` @@ -112,6 +211,7 @@ npm run test:e2e:obsidian:local-suite:services Useful environment variables: - `OBSIDIAN_BINARY`: explicit Obsidian executable path. +- `OBSIDIAN_CLI`: explicit companion `obsidian-cli` executable path. - `E2E_OBSIDIAN_VERSION`: Obsidian AppImage version for `test:e2e:obsidian:install-appimage`; default is `1.12.7`. - `E2E_OBSIDIAN_APPIMAGE_ARCH`: AppImage architecture override, such as `arm64` or `x86_64`. - `E2E_OBSIDIAN_APPIMAGE_URL`: explicit AppImage URL override. @@ -119,15 +219,32 @@ Useful environment variables: - `E2E_OBSIDIAN_FORCE_DOWNLOAD=true`: re-download the AppImage even when it exists. - `E2E_OBSIDIAN_SKIP_EXTRACT=true`: download the AppImage without extracting it. - `E2E_OBSIDIAN_SMOKE_TIMEOUT_MS`: smoke timeout in milliseconds. +- `E2E_OBSIDIAN_DIALOG_TIMEOUT_MS`: timeout for a representative Svelte dialogue to mount, expose its principal controls, and close; default is 10 seconds. +- `E2E_OBSIDIAN_REVISION_REPAIR_TIMEOUT_MS`: timeout for each visible revision-repair control and result; default is 15 seconds. +- `E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS`: timeout for the settings pane and its deletion controls to become visible; default is 10 seconds. +- `E2E_OBSIDIAN_REVIEW_HARNESS_TIMEOUT_MS`: timeout for Review Harness view and action boundaries; default is 15 seconds. +- `E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS`: timeout for the P2P status pane and its principal connection control; default is 10 seconds. +- `E2E_OBSIDIAN_P2P_WORKFLOW_TIMEOUT_MS`: timeout for each visible P2P Setup URI, peer-discovery, approval, and replication control; default is 60 seconds. +- `E2E_P2P_CHECK_CONNECTION_TIMEOUT_MS`: timeout for the browser-to-Obsidian successful WebRTC diagnostic; default is 60 seconds. +- `E2E_P2P_CHECK_SCREENSHOT`: explicit path for the successful browser result screenshot; default is `p2p-connection-check-browser-success.png` under `E2E_OBSIDIAN_DIAGNOSTICS_DIR`. +- `E2E_P2P_RELAY_URL`: signalling relay used by the real-Obsidian P2P workflow; default is the local relay at `ws://127.0.0.1:4010/`. +- `E2E_P2P_RELAY_PORT`: host port for the local P2P relay fixture; default is `4010`. +- `E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT`: CDP port for the second concurrent real Obsidian session; default is one greater than the primary port. - `E2E_OBSIDIAN_READY_TIMEOUT_MS`: plug-in readiness timeout in milliseconds. - `E2E_OBSIDIAN_CLI_READY_TIMEOUT_MS`: timeout for waiting until the vault-side Obsidian CLI exposes the plug-in catalogue. - `E2E_OBSIDIAN_CLI_TIMEOUT_MS`: timeout for each `obsidian-cli` invocation. - `E2E_LIVESYNC_CLI_TIMEOUT_MS`: timeout for each official LiveSync CLI invocation in the CLI-to-Obsidian compatibility check; default is 60 seconds. - `LIVESYNC_CLI_COMMAND`: optional LiveSync CLI executable and prefix arguments used by the CLI-to-Obsidian compatibility check. The default is the locally built CLI. +- `E2E_LIVESYNC_SOURCE_ARTIFACT_ROOT`: optional cache directory containing the exact pinned 0.25.83 plug-in artefacts. Cached files are always checksum-verified. +- `E2E_LIVESYNC_TARGET_ARTIFACT_ROOT`: directory containing the built 1.0 target `main.js`, `manifest.json`, and `styles.css`; default is the repository root. +- `E2E_OBSIDIAN_ARTIFACT_ROOT`: directory containing the plug-in artefact installed by a direct scenario invocation; default is the repository root. +- `E2E_OBSIDIAN_ARTIFACT_REVISION`: exact source commit recorded by the Security Seed reconnect result when `E2E_OBSIDIAN_ARTIFACT_ROOT` is a downloaded artefact rather than a Git worktree. - `E2E_OBSIDIAN_FILE_TIMEOUT_MS`: timeout for waiting until a note created through Obsidian's vault API is reflected to disk. - `E2E_OBSIDIAN_CORE_READY_TIMEOUT_MS`: timeout for waiting until Self-hosted LiveSync reports that its core lifecycle and local database are ready. - `E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS`: timeout for waiting until a file appears in Self-hosted LiveSync's local database. - `E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS`: timeout for waiting until CouchDB contains uploaded E2E documents. +- `E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS`: timeout for an observed remote activity to enter or leave its status boundary; default is 30 seconds. +- `E2E_OBSIDIAN_DIAGNOSTICS_DIR`: directory for screenshots and status snapshots, including the Security Seed reconnect stages; default is `/tmp/obsidian-livesync-e2e`. - `E2E_OBSIDIAN_OBJECT_STORAGE_TIMEOUT_MS`: timeout for waiting until Object Storage contains uploaded E2E objects. - `E2E_OBSIDIAN_KEEP_COUCHDB=true`: keep the temporary CouchDB database for inspection. - `E2E_OBSIDIAN_KEEP_OBJECT_STORAGE=true`: keep the temporary Object Storage prefix for inspection. diff --git a/test/e2e-obsidian/runner/couchdb.ts b/test/e2e-obsidian/runner/couchdb.ts index 753d4220..1ed61f88 100644 --- a/test/e2e-obsidian/runner/couchdb.ts +++ b/test/e2e-obsidian/runner/couchdb.ts @@ -26,6 +26,28 @@ export type CouchDbAllDocsResponse = { }>; }; +export type CouchDbLocalDocsResponse = { + rows: Array<{ + id: string; + key: string; + value: { rev: string }; + doc?: CouchDbDocument; + }>; +}; + +export type CouchDbDatabaseInfo = { + db_name: string; + doc_count: number; + doc_del_count: number; + update_seq: number | string; +}; + +export type CouchDbPutResponse = { + ok: boolean; + id: string; + rev: string; +}; + function parseEnvFile(content: string): Record { const entries = content .split(/\r?\n/u) @@ -63,6 +85,14 @@ function databaseUrl(config: Pick, dbName: string, suffix return `${config.uri.replace(/\/+$/u, "")}/${encodeURIComponent(dbName)}${suffix}`; } +function documentSuffix(documentId: string): string { + const localPrefix = "_local/"; + if (documentId.startsWith(localPrefix)) { + return `/_local/${encodeURIComponent(documentId.slice(localPrefix.length))}`; + } + return `/${encodeURIComponent(documentId)}`; +} + async function couchDbRequest( config: Pick, path: string, @@ -126,6 +156,43 @@ export async function createCouchDbDatabase(config: CouchDbConfig, dbName: strin } } +export async function putCouchDbDocument( + config: CouchDbConfig, + dbName: string, + document: CouchDbDocument +): Promise { + const response = await fetch(databaseUrl(config, dbName, documentSuffix(document._id)), { + method: "PUT", + headers: { + authorization: authHeader(config), + "content-type": "application/json", + }, + body: JSON.stringify(document), + }); + if (!response.ok) { + throw new Error( + `Failed to write CouchDB document ${document._id}. HTTP ${response.status}: ${await response.text()}` + ); + } + return (await response.json()) as CouchDbPutResponse; +} + +export async function fetchCouchDbDocument( + config: CouchDbConfig, + dbName: string, + documentId: string +): Promise { + const response = await fetch(databaseUrl(config, dbName, documentSuffix(documentId)), { + headers: { authorization: authHeader(config) }, + }); + if (!response.ok) { + throw new Error( + `Failed to read CouchDB document ${documentId}. HTTP ${response.status}: ${await response.text()}` + ); + } + return (await response.json()) as CouchDbDocument; +} + export async function deleteCouchDbDatabase(config: CouchDbConfig, dbName: string): Promise { const response = await fetch(databaseUrl(config, dbName), { method: "DELETE", @@ -138,6 +205,19 @@ export async function deleteCouchDbDatabase(config: CouchDbConfig, dbName: strin } } +export async function couchDbDatabaseExists(config: CouchDbConfig, dbName: string): Promise { + const response = await fetch(databaseUrl(config, dbName), { + headers: { authorization: authHeader(config) }, + }); + if (response.status === 404) { + return false; + } + if (!response.ok) { + throw new Error(`Failed to inspect CouchDB ${dbName}. HTTP ${response.status}: ${await response.text()}`); + } + return true; +} + export async function fetchAllCouchDbDocs(config: CouchDbConfig, dbName: string): Promise { const response = await fetch(databaseUrl(config, dbName, "/_all_docs?include_docs=true"), { headers: { authorization: authHeader(config) }, @@ -150,6 +230,28 @@ export async function fetchAllCouchDbDocs(config: CouchDbConfig, dbName: string) return (await response.json()) as CouchDbAllDocsResponse; } +export async function fetchCouchDbLocalDocs(config: CouchDbConfig, dbName: string): Promise { + const response = await fetch(databaseUrl(config, dbName, "/_local_docs?include_docs=true"), { + headers: { authorization: authHeader(config) }, + }); + if (!response.ok) { + throw new Error( + `Failed to read CouchDB local documents from ${dbName}. HTTP ${response.status}: ${await response.text()}` + ); + } + return (await response.json()) as CouchDbLocalDocsResponse; +} + +export async function fetchCouchDbDatabaseInfo(config: CouchDbConfig, dbName: string): Promise { + const response = await fetch(databaseUrl(config, dbName), { + headers: { authorization: authHeader(config) }, + }); + if (!response.ok) { + throw new Error(`Failed to inspect CouchDB ${dbName}. HTTP ${response.status}: ${await response.text()}`); + } + return (await response.json()) as CouchDbDatabaseInfo; +} + export async function waitForCouchDbDocs( config: CouchDbConfig, dbName: string, diff --git a/test/e2e-obsidian/runner/liveSyncWorkflow.test.ts b/test/e2e-obsidian/runner/liveSyncWorkflow.test.ts new file mode 100644 index 00000000..85f64421 --- /dev/null +++ b/test/e2e-obsidian/runner/liveSyncWorkflow.test.ts @@ -0,0 +1,95 @@ +import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { describe, expect, it, vi } from "vitest"; + +const { evalObsidianJson } = vi.hoisted(() => ({ + evalObsidianJson: vi.fn(), +})); + +vi.mock("./cli.ts", () => ({ evalObsidianJson })); + +import { + assertE2eCompatibilityMarker, + createE2eCouchDbPluginData, + prepareRemote, + waitForLiveSyncCoreReady, + type CompatibilityMarkerState, +} from "./liveSyncWorkflow.ts"; + +describe("compatibility marker persistence", () => { + it("waits for an accepted review to reach device-local storage", async () => { + const pending: CompatibilityMarkerState = { + vaultName: "fixture", + additionalSuffix: "-", + expectedStorageKey: "fixture--database-compatibility-version", + rawStorageValue: null, + serviceValue: "", + versionUpFlash: "", + }; + const persisted: CompatibilityMarkerState = { + ...pending, + rawStorageValue: `${VER}`, + serviceValue: `${VER}`, + }; + evalObsidianJson.mockResolvedValueOnce(pending).mockResolvedValueOnce(persisted); + + await expect( + assertE2eCompatibilityMarker("obsidian-cli", {}, { timeoutMs: 100, intervalMs: 0 }) + ).resolves.toEqual(persisted); + expect(evalObsidianJson).toHaveBeenCalledTimes(2); + }); +}); + +describe("configured CouchDB fixture", () => { + it("uses a current remote profile for ordinary configured fixtures", () => { + const pluginData = createE2eCouchDbPluginData({ + uri: "https://couch.example", + username: "alice", + password: "secret", + dbName: "notes", + }); + const remoteConfigurations = pluginData.remoteConfigurations as + | Record + | undefined; + + expect(remoteConfigurations).toBeDefined(); + expect(Object.keys(remoteConfigurations ?? {})).toHaveLength(1); + expect(pluginData.activeConfigurationId).toBe(Object.keys(remoteConfigurations ?? {})[0]); + }); +}); + +describe("Real Obsidian core readiness", () => { + it("retries while the plug-in core is temporarily unavailable during reload", async () => { + evalObsidianJson.mockReset(); + evalObsidianJson + .mockRejectedValueOnce(new Error("Cannot read properties of undefined (reading 'core')")) + .mockResolvedValueOnce({ + databaseReady: true, + appReady: true, + configured: true, + remoteType: "", + settingVersion: 10, + suspended: false, + }); + + await expect(waitForLiveSyncCoreReady("obsidian-cli", {}, 1000)).resolves.toMatchObject({ + databaseReady: true, + appReady: true, + }); + expect(evalObsidianJson).toHaveBeenCalledTimes(2); + }); +}); + +describe("remote fixture preparation", () => { + it("waits for the remote Security Seed after resolving a new remote", async () => { + evalObsidianJson.mockReset(); + evalObsidianJson.mockResolvedValueOnce({ status: "resolved", securitySeedReady: true }); + + await prepareRemote("obsidian-cli", {}); + + const evaluatedCode = String(evalObsidianJson.mock.calls[0]?.[1] ?? ""); + expect(evaluatedCode.indexOf("markRemoteResolved")).toBeLessThan( + evaluatedCode.indexOf("ensurePBKDF2Salt") + ); + expect(evaluatedCode).toContain("Timed out preparing the remote Security Seed"); + }); +}); diff --git a/test/e2e-obsidian/runner/liveSyncWorkflow.ts b/test/e2e-obsidian/runner/liveSyncWorkflow.ts index ef842c30..896fb058 100644 --- a/test/e2e-obsidian/runner/liveSyncWorkflow.ts +++ b/test/e2e-obsidian/runner/liveSyncWorkflow.ts @@ -1,6 +1,12 @@ import { evalObsidianJson } from "./cli.ts"; +import { SERVICE_CONTEXT_MEMBERS } from "../../contracts/serviceContext.ts"; +import { DATABASE_COMPATIBILITY_VERSION_KEY } from "../../../src/common/databaseCompatibility.ts"; +import { CURRENT_SETTING_VERSION } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const"; +import { type ObsidianLiveSyncSettings, VER } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations"; import type { CouchDbConfig } from "./couchdb.ts"; import type { ObjectStorageConfig } from "./objectStorage.ts"; +import { captureObsidianDialogue, withObsidianPage } from "./ui.ts"; export type ConfiguredSettings = { isConfigured: boolean; @@ -18,6 +24,48 @@ export type ConfiguredSettings = { export type CoreReadiness = { databaseReady: boolean; appReady: boolean; + configured?: boolean; + remoteType?: string; + settingVersion?: number; + suspended?: boolean; +}; + +export type ReplicationAttempt = CoreReadiness & { + succeeded: boolean; + isOnline: boolean; + activeReplicator: string; + versionUpFlash: string; + unresolvedMessages: unknown[]; +}; + +export type CompatibilityMarkerState = { + vaultName: string; + additionalSuffix: string; + expectedStorageKey: string; + rawStorageValue: string | null; + serviceValue: string; + versionUpFlash: string; +}; + +export type CompatibilityMarkerWaitOptions = { + timeoutMs?: number; + intervalMs?: number; +}; + +export type ResumeCompatibilityReviewOptions = { + verifyMissingDeviceMarkerExplanation?: boolean; + screenshotPrefix?: string; +}; + +export type ObsidianServiceContextContractResult = { + contextType: string; + eventResult: string[]; + translationResult: string; + hubUsesContext: boolean; + serviceContextMismatches: string[]; + appCapabilityMatches: boolean; + pluginCapabilityMatches: boolean; + liveSyncPluginCapabilityMatches: boolean; }; export type LocalDatabaseEntry = { @@ -28,28 +76,182 @@ export type LocalDatabaseEntry = { children: string[]; }; -function e2ePreferredSettingsSource(): string[] { - return [ - "liveSync:false,", - "syncOnStart:false,", - "syncOnSave:false,", - "usePluginSync:false,", - "usePluginSyncV2:true,", - "useEden:false,", - "customChunkSize:60,", - "sendChunksBulk:false,", - "sendChunksBulkMaxSize:1,", - "chunkSplitterVersion:'v3-rabin-karp',", - "readChunksOnline:true,", - "disableCheckingConfigMismatch:false,", - "enableCompression:false,", - "hashAlg:'xxhash64',", - "handleFilenameCaseSensitive:false,", - "doNotUseFixedRevisionForChunks:true,", - "E2EEAlgorithm:'v2',", - "doctorProcessedVersion:'0.25.27',", - "isConfigured:true,", - ]; +const E2E_PREFERRED_SETTINGS = { + displayLanguage: "def", + liveSync: false, + syncOnStart: false, + syncOnSave: false, + usePluginSync: false, + usePluginSyncV2: true, + useEden: false, + customChunkSize: 60, + sendChunksBulk: false, + sendChunksBulkMaxSize: 1, + chunkSplitterVersion: "v3-rabin-karp", + readChunksOnline: true, + disableCheckingConfigMismatch: false, + enableCompression: false, + hashAlg: "xxhash64", + handleFilenameCaseSensitive: false, + doNotUseFixedRevisionForChunks: true, + E2EEAlgorithm: "v2", + doctorProcessedVersion: "0.25.27", + settingVersion: CURRENT_SETTING_VERSION, + isConfigured: true, +} as const; + +export function createE2eObsidianDeviceLocalState( + vaultName: string, + additionalSuffixOfDatabaseName = "" +): Readonly> { + return { + [`${vaultName}-${additionalSuffixOfDatabaseName}-${DATABASE_COMPATIBILITY_VERSION_KEY}`]: `${VER}`, + }; +} + +export async function readE2eCompatibilityMarker( + cliBinary: string, + env: NodeJS.ProcessEnv +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const setting=core.services.setting;", + "const settings=setting.currentSettings();", + "const vaultName=core.services.API.getSystemVaultName();", + `const markerKey=${JSON.stringify(DATABASE_COMPATIBILITY_VERSION_KEY)};`, + "const additionalSuffix=`-${settings.additionalSuffixOfDatabaseName??''}`;", + "const expectedStorageKey=`${vaultName}${additionalSuffix}-${markerKey}`;", + "return JSON.stringify({", + "vaultName,additionalSuffix,expectedStorageKey,", + "rawStorageValue:localStorage.getItem(expectedStorageKey),", + "serviceValue:setting.getSmallConfig(markerKey),", + "versionUpFlash:settings.versionUpFlash,", + "});", + "})()", + ].join(""), + env + ); +} + +export async function assertE2eCompatibilityMarker( + cliBinary: string, + env: NodeJS.ProcessEnv, + options: CompatibilityMarkerWaitOptions = {} +): Promise { + const timeoutMs = options.timeoutMs ?? Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000); + const intervalMs = options.intervalMs ?? 100; + const deadline = Date.now() + timeoutMs; + let state = await readE2eCompatibilityMarker(cliBinary, env); + while (state.serviceValue !== `${VER}` && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + state = await readE2eCompatibilityMarker(cliBinary, env); + } + if (state.serviceValue !== `${VER}`) + throw new Error(`The E2E compatibility marker was not persisted before timeout: ${JSON.stringify(state)}`); + return state; +} + +export async function assertE2eCompatibilityReviewPending( + cliBinary: string, + env: NodeJS.ProcessEnv +): Promise { + const state = await readE2eCompatibilityMarker(cliBinary, env); + if (state.serviceValue !== "" || state.rawStorageValue !== null || state.versionUpFlash === "") { + throw new Error(`The copied-Vault compatibility review was not pending: ${JSON.stringify(state)}`); + } + return state; +} + +export async function resumeCompatibilityReview( + port: number, + options: ResumeCompatibilityReviewOptions = {} +): Promise { + const timeoutMs = Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000); + const title = "Synchronisation paused for compatibility review"; + const summaryLocator = (page: Parameters[1]>[0]) => + page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: title }), + }); + + if (options.screenshotPrefix) { + const summaryScreenshot = await captureObsidianDialogue( + port, + `${options.screenshotPrefix}-summary.png`, + async (page) => { + await summaryLocator(page).waitFor({ state: "visible", timeout: timeoutMs }); + } + ); + console.log(`Compatibility review summary screenshot: ${summaryScreenshot}`); + } + + if (options.verifyMissingDeviceMarkerExplanation === true) { + await withObsidianPage(port, async (page) => { + const summary = summaryLocator(page); + await summary.waitFor({ state: "visible", timeout: timeoutMs }); + await summary.getByRole("button", { name: "Review compatibility details" }).click(); + }); + const detailsScreenshot = options.screenshotPrefix + ? await captureObsidianDialogue(port, `${options.screenshotPrefix}-details.png`, async (page) => { + const details = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Compatibility review details" }), + }); + await details.waitFor({ state: "visible", timeout: timeoutMs }); + await details.getByText("copied or restored", { exact: false }).waitFor({ + state: "visible", + timeout: timeoutMs, + }); + await details.getByText("new Obsidian profile", { exact: false }).waitFor({ + state: "visible", + timeout: timeoutMs, + }); + await details + .getByText("does not mean that it is safe to resume automatically", { exact: false }) + .waitFor({ + state: "visible", + timeout: timeoutMs, + }); + }) + : undefined; + if (detailsScreenshot) console.log(`Compatibility review details screenshot: ${detailsScreenshot}`); + await withObsidianPage(port, async (page) => { + const details = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Compatibility review details" }), + }); + await details.getByRole("button", { name: "Back to compatibility review" }).click(); + await summaryLocator(page).waitFor({ state: "visible", timeout: timeoutMs }); + }); + } + + await withObsidianPage(port, async (page) => { + const summary = summaryLocator(page); + await summary.waitFor({ state: "visible", timeout: timeoutMs }); + await summary.getByRole("button", { name: "Resume synchronisation" }).click(); + await summary.waitFor({ state: "hidden", timeout: timeoutMs }); + }); +} + +export function createE2eCouchDbPluginData( + settings: Pick & { dbName: string }, + overrides: Record = {} +): Record { + const pluginData = { + couchDB_URI: settings.uri, + couchDB_USER: settings.username, + couchDB_PASSWORD: settings.password, + couchDB_DBNAME: settings.dbName, + remoteType: "", + ...E2E_PREFERRED_SETTINGS, + ...overrides, + }; + upsertRemoteConfigurationInPlace(pluginData as ObsidianLiveSyncSettings, "couchdb", { + id: "e2e-couchdb", + name: "E2E CouchDB", + activate: true, + }); + return pluginData; } export function assertEqual(actual: unknown, expected: unknown, message: string): void { @@ -64,21 +266,14 @@ export async function configureCouchDb( settings: Pick & { dbName: string }, overrides: Record = {} ): Promise { + const nextSettings = createE2eCouchDbPluginData(settings, overrides); return await evalObsidianJson( cliBinary, [ "(async()=>{", "const plugin=app.plugins.plugins['obsidian-livesync'];", "const core=plugin.core;", - "const nextSettings={", - `couchDB_URI:${JSON.stringify(settings.uri)},`, - `couchDB_USER:${JSON.stringify(settings.username)},`, - `couchDB_PASSWORD:${JSON.stringify(settings.password)},`, - `couchDB_DBNAME:${JSON.stringify(settings.dbName)},`, - "remoteType:'',", - ...e2ePreferredSettingsSource(), - ...Object.entries(overrides).map(([key, value]) => `${JSON.stringify(key)}:${JSON.stringify(value)},`), - "};", + `const nextSettings=${JSON.stringify(nextSettings)};`, "await core.services.setting.applyExternalSettings(nextSettings,true);", "await core.services.control.applySettings();", "const current=core.services.setting.currentSettings();", @@ -103,25 +298,14 @@ export async function configureObjectStorage( settings: ObjectStorageConfig & { bucketPrefix: string }, overrides: Record = {} ): Promise { + const nextSettings = createE2eObjectStoragePluginData(settings, overrides); return await evalObsidianJson( cliBinary, [ "(async()=>{", "const plugin=app.plugins.plugins['obsidian-livesync'];", "const core=plugin.core;", - "const nextSettings={", - "remoteType:'MINIO',", - `endpoint:${JSON.stringify(settings.endpoint)},`, - `accessKey:${JSON.stringify(settings.accessKey)},`, - `secretKey:${JSON.stringify(settings.secretKey)},`, - `bucket:${JSON.stringify(settings.bucket)},`, - `region:${JSON.stringify(settings.region)},`, - `forcePathStyle:${JSON.stringify(settings.forcePathStyle)},`, - `bucketPrefix:${JSON.stringify(settings.bucketPrefix)},`, - "bucketCustomHeaders:'',", - ...e2ePreferredSettingsSource(), - ...Object.entries(overrides).map(([key, value]) => `${JSON.stringify(key)}:${JSON.stringify(value)},`), - "};", + `const nextSettings=${JSON.stringify(nextSettings)};`, "await core.services.setting.applyExternalSettings(nextSettings,true);", "await core.services.control.applySettings();", "const current=core.services.setting.currentSettings();", @@ -143,6 +327,25 @@ export async function configureObjectStorage( ); } +export function createE2eObjectStoragePluginData( + settings: ObjectStorageConfig & { bucketPrefix: string }, + overrides: Record = {} +): Record { + return { + remoteType: "MINIO", + endpoint: settings.endpoint, + accessKey: settings.accessKey, + secretKey: settings.secretKey, + bucket: settings.bucket, + region: settings.region, + forcePathStyle: settings.forcePathStyle, + bucketPrefix: settings.bucketPrefix, + bucketCustomHeaders: "", + ...E2E_PREFERRED_SETTINGS, + ...overrides, + }; +} + export async function waitForLiveSyncCoreReady( cliBinary: string, env: NodeJS.ProcessEnv, @@ -150,29 +353,116 @@ export async function waitForLiveSyncCoreReady( ): Promise { const deadline = Date.now() + timeoutMs; let lastReadiness: CoreReadiness | undefined; + let lastError: unknown; while (Date.now() < deadline) { - lastReadiness = await evalObsidianJson( - cliBinary, - [ - "(async()=>{", - "const core=app.plugins.plugins['obsidian-livesync'].core;", - "return JSON.stringify({", - "databaseReady:core.services.database.isDatabaseReady(),", - "appReady:core.services.appLifecycle.isReady(),", - "});", - "})()", - ].join(""), - env - ); + try { + lastReadiness = await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync']?.core;", + "if(!core) return JSON.stringify({databaseReady:false,appReady:false});", + "const settings=core.services.setting.currentSettings();", + "return JSON.stringify({", + "databaseReady:core.services.database.isDatabaseReady(),", + "appReady:core.services.appLifecycle.isReady(),", + "configured:settings?.isConfigured===true,", + "remoteType:settings?.remoteType??'',", + "settingVersion:settings?.settingVersion,", + "suspended:core.services.appLifecycle.isSuspended(),", + "});", + "})()", + ].join(""), + env + ); + lastError = undefined; + } catch (error) { + // Obsidian reloads the renderer while enabling the plug-in. During + // that short window the CLI can reach the Vault before the plug-in + // catalogue has exposed its core. This is a readiness state, not a + // failed scenario, so retain the error for the eventual timeout. + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 500)); + continue; + } if (lastReadiness.databaseReady && lastReadiness.appReady) { return lastReadiness; } await new Promise((resolve) => setTimeout(resolve, 500)); } - throw new Error(`Timed out waiting for Self-hosted LiveSync core readiness: ${JSON.stringify(lastReadiness)}`); + const errorSuffix = + lastError === undefined + ? "" + : ` Last error: ${lastError instanceof Error ? lastError.message : String(lastError)}`; + throw new Error( + `Timed out waiting for Self-hosted LiveSync core readiness: ${JSON.stringify(lastReadiness)}${errorSuffix}` + ); +} + +/** + * Inspect the actual Obsidian composition through Obsidian's CLI. + * + * This observes public Context results and verifies that the Hub and every + * exposed service retain the exact Context created by the plug-in host. + */ +export async function inspectObsidianServiceContextContract( + cliBinary: string, + env: NodeJS.ProcessEnv +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const plugin=app.plugins.plugins['obsidian-livesync'];", + "const services=plugin.core.services;", + "const context=services.context;", + `const serviceNames=${JSON.stringify(SERVICE_CONTEXT_MEMBERS)};`, + "const eventResult=[];", + "const unsubscribe=context.events.onEvent('hello',(value)=>eventResult.push(value));", + "try{context.events.emitEvent('hello','context-contract-event');}finally{unsubscribe();}", + "return JSON.stringify({", + "contextType:context.constructor.name,", + "eventResult,", + "translationResult:context.translate('Replicator.Message.InitialiseFatalError'),", + "hubUsesContext:services.context===context,", + "serviceContextMismatches:serviceNames.filter((name)=>services[name].context!==context),", + "appCapabilityMatches:context.app===app,", + "pluginCapabilityMatches:context.plugin===plugin,", + "liveSyncPluginCapabilityMatches:context.liveSyncPlugin===plugin,", + "});", + "})()", + ].join(""), + env + ); +} + +export function assertObsidianServiceContextContract(result: ObsidianServiceContextContractResult): void { + assertEqual(result.contextType, "ObsidianServiceContext", "Unexpected Obsidian service Context type."); + assertEqual(result.hubUsesContext, true, "The Obsidian Service Hub substituted its host Context."); + assertEqual( + result.serviceContextMismatches.length, + 0, + `Services used a different Context: ${result.serviceContextMismatches.join(", ")}` + ); + assertEqual( + JSON.stringify(result.eventResult), + JSON.stringify(["context-contract-event"]), + "The Obsidian Context event API returned an unexpected result." + ); + if (result.translationResult.length === 0) { + throw new Error("The Obsidian Context translator returned an empty result."); + } + assertEqual(result.appCapabilityMatches, true, "The Obsidian Context lost its App capability."); + assertEqual(result.pluginCapabilityMatches, true, "The Obsidian Context lost its Plugin capability."); + assertEqual( + result.liveSyncPluginCapabilityMatches, + true, + "The Obsidian Context lost its Self-hosted LiveSync plug-in capability." + ); } export async function prepareRemote(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + const timeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_PREPARE_TIMEOUT_MS ?? 20000); await evalObsidianJson( cliBinary, [ @@ -182,8 +472,16 @@ export async function prepareRemote(cliBinary: string, env: NodeJS.ProcessEnv): "const replicator=core.services.replicator.getActiveReplicator();", "await replicator.tryCreateRemoteDatabase(settings);", "await replicator.markRemoteResolved(settings);", + `const deadline=Date.now()+${JSON.stringify(timeoutMs)};`, + "let securitySeedReady=false;", + "do{", + "securitySeedReady=await replicator.ensurePBKDF2Salt(settings,false,false);", + "if(securitySeedReady) break;", + "await new Promise((resolve)=>setTimeout(resolve,250));", + "}while(Date.now() { - await evalObsidianJson( + const attempt = await evalObsidianJson( cliBinary, [ "(async()=>{", "const core=app.plugins.plugins['obsidian-livesync'].core;", "await core.services.fileProcessing.commitPendingFileEvents();", "const result=await core.services.replication.replicate(true);", - "return JSON.stringify({result:!!result});", + "const settings=core.services.setting.currentSettings();", + "const activeReplicator=core.services.replicator.getActiveReplicator();", + "return JSON.stringify({", + "succeeded:!!result,", + "databaseReady:core.services.database.isDatabaseReady(),", + "appReady:core.services.appLifecycle.isReady(),", + "isOnline:core.services.API.isOnline,", + "activeReplicator:activeReplicator?.constructor?.name??'(none)',", + "versionUpFlash:settings.versionUpFlash,", + "unresolvedMessages:(await core.services.appLifecycle.getUnresolvedMessages()).flat(),", + "});", "})()", ].join(""), env ); + if (!attempt.succeeded) { + throw new Error(`Finite replication did not start or complete: ${JSON.stringify(attempt)}`); + } } export async function waitForLocalDatabaseEntry( diff --git a/test/e2e-obsidian/runner/mobileUi.ts b/test/e2e-obsidian/runner/mobileUi.ts new file mode 100644 index 00000000..d492092d --- /dev/null +++ b/test/e2e-obsidian/runner/mobileUi.ts @@ -0,0 +1,163 @@ +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { + assertLocatorHasMinimumTouchTarget, + assertLocatorWithinSafeArea, + assertLocatorWithinViewport, + assertNoHorizontalOverflow, +} from "@vrtmrz/obsidian-test-session"; +import type { Locator, Page } from "playwright"; +import { withObsidianPage } from "./ui.ts"; + +export const mobileViewport = { width: 390, height: 844 } as const; +export const desktopViewport = { width: 1024, height: 768 } as const; +export const iPhoneSafeArea = { top: 47, right: 0, bottom: 34, left: 0 } as const; + +type ObsidianTestApp = { + isMobile?: boolean; + emulateMobile?: (mobile: boolean) => void; + plugins?: { plugins: Record }; + workspace?: { layoutReady?: boolean }; +}; + +type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp }; + +async function applyObsidianMobileTestMode( + port: number, + enabled: boolean, + timeoutMs: number, + waitForLiveSync: boolean +): Promise { + await withObsidianPage(port, async (page) => { + await page.setViewportSize(enabled ? mobileViewport : desktopViewport); + await page.evaluate((nextEnabled) => { + const obsidianApp = (globalThis as ObsidianTestGlobal).app; + if (typeof obsidianApp?.emulateMobile !== "function") { + throw new Error("app.emulateMobile is unavailable"); + } + obsidianApp.emulateMobile(nextEnabled); + }, enabled); + // Obsidian reopens its workspace layout when platform emulation + // changes. Loading a controlled plug-in before that transition has + // completed can leave the plug-in enabled but absent from the active + // renderer. + try { + await page.waitForFunction( + ({ nextEnabled, waitForLiveSync }) => { + const obsidianApp = (globalThis as ObsidianTestGlobal).app; + return ( + document.body.classList.contains("is-mobile") === nextEnabled && + obsidianApp?.workspace?.layoutReady === true && + (!waitForLiveSync || obsidianApp?.plugins?.plugins["obsidian-livesync"] !== undefined) + ); + }, + { nextEnabled: enabled, waitForLiveSync }, + { timeout: timeoutMs } + ); + } catch (error) { + const state = await page.evaluate(() => { + const obsidianApp = (globalThis as ObsidianTestGlobal).app; + return { + appIsMobile: obsidianApp?.isMobile ?? null, + bodyClasses: document.body.className, + documentReadyState: document.readyState, + liveSyncLoaded: obsidianApp?.plugins?.plugins["obsidian-livesync"] !== undefined, + viewport: { width: window.innerWidth, height: window.innerHeight }, + workspaceLayoutReady: obsidianApp?.workspace?.layoutReady ?? null, + }; + }); + const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e"; + await mkdir(outputDirectory, { recursive: true }); + const screenshotPath = join( + outputDirectory, + waitForLiveSync + ? "mobile-mode-transition.failure.png" + : "mobile-mode-before-plugin-start.failure.png" + ); + await page.screenshot({ path: screenshotPath, fullPage: true }); + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + `Obsidian mobile-mode transition did not settle: ${JSON.stringify(state)}; screenshot=${screenshotPath}; cause=${detail}` + ); + } + await page.evaluate( + (safeArea) => { + for (const edge of ["top", "right", "bottom", "left"] as const) { + const property = `--safe-area-inset-${edge}`; + if (safeArea === null) document.body.style.removeProperty(property); + else document.body.style.setProperty(property, `${safeArea[edge]}px`); + } + }, + enabled ? iPhoneSafeArea : null + ); + }); +} + +/** Enters mobile emulation before LiveSync's first load in a controlled session. */ +export async function setObsidianMobileTestModeBeforePluginStart( + port: number, + enabled: boolean, + timeoutMs: number +): Promise { + await applyObsidianMobileTestMode(port, enabled, timeoutMs, false); +} + +export async function setObsidianMobileTestMode(port: number, enabled: boolean, timeoutMs: number): Promise { + await applyObsidianMobileTestMode(port, enabled, timeoutMs, true); +} + +export async function assertMobileDialogueLayout(page: Page, container: Locator, label: string): Promise { + const dialogue = container.locator(".modal").last(); + const closeButton = dialogue.locator(".modal-close-button"); + await assertLocatorWithinViewport(page, dialogue, { label }); + await assertNoHorizontalOverflow(page, dialogue, { label }); + await assertLocatorWithinSafeArea(page, dialogue, { + label, + safeAreaInsets: iPhoneSafeArea, + }); + await assertLocatorWithinSafeArea(page, closeButton, { + label: `${label} close button`, + safeAreaInsets: iPhoneSafeArea, + }); + await assertLocatorHasMinimumTouchTarget(page, closeButton, { + label: `${label} close button`, + }); + + const visibleButtons = dialogue.locator("button:visible"); + for (let index = 0; index < (await visibleButtons.count()); index++) { + const button = visibleButtons.nth(index); + const buttonLabel = (await button.innerText()).trim() || `button ${index + 1}`; + await assertLocatorWithinViewport(page, button, { label: `${label}: ${buttonLabel}` }); + await assertLocatorWithinSafeArea(page, button, { + label: `${label}: ${buttonLabel}`, + safeAreaInsets: iPhoneSafeArea, + }); + await assertNoHorizontalOverflow(page, button, { label: `${label}: ${buttonLabel}` }); + await assertLocatorHasMinimumTouchTarget(page, button, { label: `${label}: ${buttonLabel}` }); + } +} + +export async function assertMobileNoticeLayout( + page: Page, + notice: Locator, + label: string, + reservedRightPx = 56 +): Promise { + await assertLocatorWithinViewport(page, notice, { label }); + await assertNoHorizontalOverflow(page, notice, { label }); + await assertLocatorWithinSafeArea(page, notice, { + label, + safeAreaInsets: iPhoneSafeArea, + }); + const box = await notice.boundingBox(); + if (box === null) { + throw new Error(`${label} did not expose a measurable viewport rectangle.`); + } + const viewportWidth = await page.evaluate(() => window.innerWidth); + const rightEdge = box.x + box.width; + if (rightEdge > viewportWidth - reservedRightPx) { + throw new Error( + `${label} overlaps the reserved close-control column: right edge ${rightEdge}, limit ${viewportWidth - reservedRightPx}.` + ); + } +} diff --git a/test/e2e-obsidian/runner/objectStorage.ts b/test/e2e-obsidian/runner/objectStorage.ts index f0ea8455..01fee5cf 100644 --- a/test/e2e-obsidian/runner/objectStorage.ts +++ b/test/e2e-obsidian/runner/objectStorage.ts @@ -1,6 +1,7 @@ import { CreateBucketCommand, DeleteObjectsCommand, + GetObjectCommand, ListObjectsV2Command, S3Client, type _Object, @@ -120,6 +121,22 @@ export async function listObjectStorageObjects(config: ObjectStorageConfig, pref } } +export async function readObjectStorageObject(config: ObjectStorageConfig, key: string): Promise { + const client = createObjectStorageClient(config); + try { + const response = await client.send(new GetObjectCommand({ Bucket: config.bucket, Key: key })); + if (!response.Body) throw new Error(`Object Storage returned an empty body for ${key}.`); + return await response.Body.transformToByteArray(); + } finally { + client.destroy(); + } +} + +export async function readObjectStorageJson(config: ObjectStorageConfig, key: string): Promise { + const bytes = await readObjectStorageObject(config, key); + return JSON.parse(new TextDecoder().decode(bytes)) as T; +} + export async function deleteObjectStoragePrefix(config: ObjectStorageConfig, prefix: string): Promise { const client = createObjectStorageClient(config); try { diff --git a/test/e2e-obsidian/runner/pathAssertions.test.ts b/test/e2e-obsidian/runner/pathAssertions.test.ts new file mode 100644 index 00000000..c7306737 --- /dev/null +++ b/test/e2e-obsidian/runner/pathAssertions.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { hasExactCaseOnlyRename } from "./pathEntries.ts"; + +describe("case-only rename assertions", () => { + it("accepts only the exact new spelling", () => { + expect(hasExactCaseOnlyRename(["case-rename.md"], "Case-Rename.md", "case-rename.md")).toBe(true); + }); + + it("rejects the old spelling even when a case-insensitive lookup would resolve it", () => { + expect(hasExactCaseOnlyRename(["Case-Rename.md"], "Case-Rename.md", "case-rename.md")).toBe(false); + }); + + it("rejects an ambiguous directory containing both spellings", () => { + expect(hasExactCaseOnlyRename(["Case-Rename.md", "case-rename.md"], "Case-Rename.md", "case-rename.md")).toBe( + false + ); + }); +}); diff --git a/test/e2e-obsidian/runner/pathAssertions.ts b/test/e2e-obsidian/runner/pathAssertions.ts new file mode 100644 index 00000000..4d9730d9 --- /dev/null +++ b/test/e2e-obsidian/runner/pathAssertions.ts @@ -0,0 +1,30 @@ +import { readdir } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import { hasExactCaseOnlyRename } from "./pathEntries.ts"; + +export async function waitForExactCaseOnlyRename( + vaultPath: string, + oldPath: string, + newPath: string, + timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 10000) +): Promise { + const oldDirectory = dirname(oldPath); + const newDirectory = dirname(newPath); + if (oldDirectory !== newDirectory) { + throw new Error(`Case-only rename paths must share one parent directory: ${oldPath} -> ${newPath}`); + } + + const oldName = basename(oldPath); + const newName = basename(newPath); + const directoryPath = join(vaultPath, newDirectory); + const deadline = Date.now() + timeoutMs; + let lastEntries: string[] = []; + while (Date.now() < deadline) { + lastEntries = await readdir(directoryPath); + if (hasExactCaseOnlyRename(lastEntries, oldName, newName)) return; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error( + `Timed out waiting for exact case-only rename: ${oldPath} -> ${newPath}. Directory entries: ${JSON.stringify(lastEntries)}` + ); +} diff --git a/test/e2e-obsidian/runner/pathEntries.ts b/test/e2e-obsidian/runner/pathEntries.ts new file mode 100644 index 00000000..241061c7 --- /dev/null +++ b/test/e2e-obsidian/runner/pathEntries.ts @@ -0,0 +1,3 @@ +export function hasExactCaseOnlyRename(entries: readonly string[], oldName: string, newName: string): boolean { + return entries.includes(newName) && !entries.includes(oldName); +} diff --git a/test/e2e-obsidian/runner/releaseArtifact.test.ts b/test/e2e-obsidian/runner/releaseArtifact.test.ts new file mode 100644 index 00000000..ef76b7ce --- /dev/null +++ b/test/e2e-obsidian/runner/releaseArtifact.test.ts @@ -0,0 +1,76 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + ensurePinnedReleaseArtifact, + type PinnedPluginRelease, +} from "./releaseArtifact.ts"; + +const temporaryDirectories: string[] = []; + +function sha256(content: string): string { + return createHash("sha256").update(content).digest("hex"); +} + +function fixtureRelease(contents: Record<"main.js" | "manifest.json" | "styles.css", string>): PinnedPluginRelease { + return { + pluginId: "fixture-plugin", + version: "1.2.3", + files: (Object.keys(contents) as Array).map((name) => ({ + name, + url: `https://example.invalid/${name}`, + sha256: sha256(contents[name]), + })), + }; +} + +afterEach(async () => { + for (const path of temporaryDirectories.splice(0)) { + await rm(path, { recursive: true, force: true }); + } +}); + +describe("pinned plug-in release artefacts", () => { + it("downloads, verifies, and reuses an immutable release cache", async () => { + const root = await mkdtemp(join(tmpdir(), "livesync-release-artifact-")); + temporaryDirectories.push(root); + const contents = { + "main.js": "console.log('fixture');\n", + "manifest.json": '{"id":"fixture-plugin","version":"1.2.3"}\n', + "styles.css": ".fixture {}\n", + }; + const release = fixtureRelease(contents); + const fetchImplementation = vi.fn(async (input: string | URL | Request) => { + const name = new URL(String(input)).pathname.split("/").pop() as keyof typeof contents; + return new Response(contents[name], { status: 200 }); + }) as unknown as typeof fetch; + + await expect( + ensurePinnedReleaseArtifact(release, { artifactRoot: root, fetchImplementation }) + ).resolves.toBe(root); + await expect(readFile(join(root, "main.js"), "utf8")).resolves.toBe(contents["main.js"]); + expect(fetchImplementation).toHaveBeenCalledTimes(3); + + await ensurePinnedReleaseArtifact(release, { artifactRoot: root, fetchImplementation }); + expect(fetchImplementation).toHaveBeenCalledTimes(3); + }); + + it("rejects a downloaded file before it enters the release cache when its checksum differs", async () => { + const root = await mkdtemp(join(tmpdir(), "livesync-release-artifact-")); + temporaryDirectories.push(root); + const contents = { + "main.js": "expected\n", + "manifest.json": '{"id":"fixture-plugin","version":"1.2.3"}\n', + "styles.css": ".fixture {}\n", + }; + const release = fixtureRelease(contents); + const fetchImplementation = vi.fn(async () => new Response("tampered\n", { status: 200 })) as unknown as typeof fetch; + + await expect( + ensurePinnedReleaseArtifact(release, { artifactRoot: root, fetchImplementation }) + ).rejects.toThrow("checksum mismatch"); + await expect(readFile(join(root, "main.js"))).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); diff --git a/test/e2e-obsidian/runner/releaseArtifact.ts b/test/e2e-obsidian/runner/releaseArtifact.ts new file mode 100644 index 00000000..2c0e62e1 --- /dev/null +++ b/test/e2e-obsidian/runner/releaseArtifact.ts @@ -0,0 +1,128 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +export type PinnedReleaseArtifactFile = { + name: "main.js" | "manifest.json" | "styles.css"; + url: string; + sha256: string; +}; + +export type PinnedPluginRelease = { + pluginId: string; + version: string; + files: readonly PinnedReleaseArtifactFile[]; +}; + +export type EnsurePinnedReleaseArtifactOptions = { + artifactRoot?: string; + fetchImplementation?: typeof fetch; +}; + +export const UPGRADE_SOURCE_RELEASE: PinnedPluginRelease = { + pluginId: "obsidian-livesync", + version: "0.25.83", + files: [ + { + name: "main.js", + url: "https://github.com/vrtmrz/obsidian-livesync/releases/download/0.25.83/main.js", + sha256: "5e57f990635ab0cf2ff3879f3c6cb91ddfdbc146958d33d1e5d21f1869dff6a4", + }, + { + name: "manifest.json", + url: "https://github.com/vrtmrz/obsidian-livesync/releases/download/0.25.83/manifest.json", + sha256: "4944f5665c94bcbb58db0e3708ec2bd8ee36118791271c01d085668876dc8ba6", + }, + { + name: "styles.css", + url: "https://github.com/vrtmrz/obsidian-livesync/releases/download/0.25.83/styles.css", + sha256: "37d31798186d7e97ea979e6d2aae8021ea1ac1df2c3b9d2b03dce269959c27f3", + }, + ], +}; + +function digest(content: Uint8Array): string { + return createHash("sha256").update(content).digest("hex"); +} + +function assertDigest(file: PinnedReleaseArtifactFile, content: Uint8Array): void { + const actual = digest(content); + if (actual !== file.sha256) { + throw new Error( + `Release artefact checksum mismatch for ${file.name}. Expected ${file.sha256}, received ${actual}.` + ); + } +} + +async function readCachedFile( + path: string, + file: PinnedReleaseArtifactFile +): Promise | undefined> { + try { + const content = new Uint8Array(await readFile(path)); + assertDigest(file, content); + return content; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +async function downloadVerifiedFile( + root: string, + file: PinnedReleaseArtifactFile, + fetchImplementation: typeof fetch +): Promise> { + const path = join(root, file.name); + const cached = await readCachedFile(path, file); + if (cached) return cached; + + const response = await fetchImplementation(file.url, { redirect: "follow" }); + if (!response.ok) { + throw new Error(`Could not download ${file.url}. HTTP ${response.status}: ${await response.text()}`); + } + const content = new Uint8Array(await response.arrayBuffer()); + assertDigest(file, content); + + const temporaryPath = `${path}.download-${process.pid}-${Date.now()}`; + try { + await writeFile(temporaryPath, content, { flag: "wx" }); + await rename(temporaryPath, path); + } finally { + await rm(temporaryPath, { force: true }); + } + return content; +} + +/** + * Materialise one immutable published plug-in release in the ignored E2E cache. + * + * Existing files are always verified before use. A mismatched cache is left in + * place for inspection and must be removed explicitly by the operator. + */ +export async function ensurePinnedReleaseArtifact( + release: PinnedPluginRelease = UPGRADE_SOURCE_RELEASE, + options: EnsurePinnedReleaseArtifactOptions = {} +): Promise { + const root = resolve( + options.artifactRoot ?? + process.env.E2E_LIVESYNC_SOURCE_ARTIFACT_ROOT?.trim() ?? + join("_testdata", "releases", release.pluginId, release.version) + ); + await mkdir(root, { recursive: true }); + + const fetched = new Map>(); + for (const file of release.files) { + fetched.set(file.name, await downloadVerifiedFile(root, file, options.fetchImplementation ?? fetch)); + } + + const manifestBytes = fetched.get("manifest.json"); + if (!manifestBytes) throw new Error("The pinned release does not define manifest.json."); + const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as { id?: unknown; version?: unknown }; + if (manifest.id !== release.pluginId || manifest.version !== release.version) { + throw new Error( + `Release manifest identity mismatch. Expected ${release.pluginId}@${release.version}, received ${String(manifest.id)}@${String(manifest.version)}.` + ); + } + return root; +} diff --git a/test/e2e-obsidian/runner/remoteActivity.ts b/test/e2e-obsidian/runner/remoteActivity.ts new file mode 100644 index 00000000..355d7fa7 --- /dev/null +++ b/test/e2e-obsidian/runner/remoteActivity.ts @@ -0,0 +1,237 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { Page } from "playwright"; +import { withObsidianPage } from "./ui.ts"; +import { + REMOTE_OPERATION_ACTIVITY_ICON, + REMOTE_REQUEST_ACTIVITY_ICON, +} from "../../../src/modules/features/RemoteActivityStatus.ts"; + +export const REMOTE_ACTIVITY_E2E_STATE_KEY = "__livesyncE2ERemoteActivity"; +export const REMOTE_ACTIVITY_GATE_KIND = { + chunkFetch: "chunk-fetch", + oneShot: "one-shot", + trackedRequest: "tracked-request", +} as const; +export const REMOTE_ACTIVITY_EXPECTED_STATE = { + chunkFetchActive: "chunk-fetch-active", + finiteReplicationActive: "finite-replication-active", + idle: "idle", + trackedRequestActive: "tracked-request-active", +} as const; +export type RemoteActivityGateKind = (typeof REMOTE_ACTIVITY_GATE_KIND)[keyof typeof REMOTE_ACTIVITY_GATE_KIND]; + +export type RemoteActivitySnapshot = { + boundedRemoteActivityCount: number; + finiteReplicationActivityCount: number; + gateDone?: boolean; + gateEntered?: boolean; + gateError?: string; + gateKind?: RemoteActivityGateKind; + requestCount: number; + remoteOperationIndicatorVisible: boolean; + remoteRequestIndicatorVisible: boolean; + responseCount: number; + statusBarFound: boolean; + statusBarText: string; +}; + +export type ExpectedRemoteActivityState = + (typeof REMOTE_ACTIVITY_EXPECTED_STATE)[keyof typeof REMOTE_ACTIVITY_EXPECTED_STATE]; + +type RuntimeCounter = { value?: number }; + +type RuntimeCore = { + services?: { + API?: { + requestCount?: RuntimeCounter; + responseCount?: RuntimeCounter; + }; + replicator?: { + boundedRemoteActivityCount?: RuntimeCounter; + finiteReplicationActivityCount?: RuntimeCounter; + }; + }; +}; + +type RuntimeGate = { + done?: boolean; + entered?: boolean; + error?: string; + kind?: RemoteActivityGateKind; +}; + +type RendererGlobals = typeof globalThis & { + app?: { + plugins?: { + plugins?: Record; + }; + }; +}; + +async function readRemoteActivitySnapshotFromPage(page: Page): Promise { + return await page.evaluate( + ({ operationIcon, pluginId, requestIcon, stateKey }) => { + const globals = globalThis as RendererGlobals; + const core = globals.app?.plugins?.plugins?.[pluginId]?.core; + if (!core) throw new Error(`Obsidian plug-in is not loaded: ${pluginId}`); + const gate = (globalThis as unknown as Record)[stateKey]; + const statusBars = Array.from(document.querySelectorAll(".syncstatusbar")); + return { + boundedRemoteActivityCount: Number(core.services?.replicator?.boundedRemoteActivityCount?.value ?? -1), + finiteReplicationActivityCount: Number( + core.services?.replicator?.finiteReplicationActivityCount?.value ?? -1 + ), + gateDone: gate?.done, + gateEntered: gate?.entered, + gateError: gate?.error, + gateKind: gate?.kind, + requestCount: Number(core.services?.API?.requestCount?.value ?? -1), + remoteOperationIndicatorVisible: statusBars.some((element) => + (element.textContent ?? "").includes(operationIcon) + ), + remoteRequestIndicatorVisible: statusBars.some((element) => + (element.textContent ?? "").includes(requestIcon) + ), + responseCount: Number(core.services?.API?.responseCount?.value ?? -1), + statusBarFound: statusBars.length > 0, + statusBarText: statusBars.map((element) => element.textContent ?? "").join("\n"), + } satisfies RemoteActivitySnapshot; + }, + { + operationIcon: REMOTE_OPERATION_ACTIVITY_ICON, + pluginId: "obsidian-livesync", + requestIcon: REMOTE_REQUEST_ACTIVITY_ICON, + stateKey: REMOTE_ACTIVITY_E2E_STATE_KEY, + } + ); +} + +export async function readRemoteActivitySnapshot(port: number): Promise { + return await withObsidianPage(port, async (page) => await readRemoteActivitySnapshotFromPage(page)); +} + +function formatWaitFailure( + expected: ExpectedRemoteActivityState, + snapshot: RemoteActivitySnapshot | undefined, + error: unknown +): Error { + return new Error( + [ + `Timed out waiting for remote activity state: ${expected}`, + snapshot ? `Last snapshot: ${JSON.stringify(snapshot)}` : undefined, + error instanceof Error ? error.message : String(error), + ] + .filter((line): line is string => line !== undefined) + .join("\n") + ); +} + +export async function waitForRemoteActivityState( + port: number, + expected: ExpectedRemoteActivityState, + timeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS ?? 30000) +): Promise { + try { + return await withObsidianPage(port, async (page) => { + await page.waitForFunction( + ({ expectedState, expectedStates, gateKinds, operationIcon, pluginId, requestIcon, stateKey }) => { + const globals = globalThis as RendererGlobals; + const core = globals.app?.plugins?.plugins?.[pluginId]?.core; + if (!core) return false; + const gate = (globalThis as unknown as Record)[stateKey]; + const statusBarText = Array.from(document.querySelectorAll(".syncstatusbar")) + .map((element) => element.textContent ?? "") + .join("\n"); + const bounded = Number(core.services?.replicator?.boundedRemoteActivityCount?.value ?? -1); + const finite = Number(core.services?.replicator?.finiteReplicationActivityCount?.value ?? -1); + const requests = Number(core.services?.API?.requestCount?.value ?? -1); + const responses = Number(core.services?.API?.responseCount?.value ?? -1); + const operationIconVisible = statusBarText.includes(operationIcon); + const requestIconVisible = statusBarText.includes(requestIcon); + const trackedRequests = Math.max(0, requests - responses); + + if (expectedState === expectedStates.finiteReplicationActive) { + return ( + gate?.kind === gateKinds.oneShot && + gate.entered === true && + bounded > 0 && + finite > 0 && + operationIconVisible && + !requestIconVisible && + trackedRequests === 0 + ); + } + if (expectedState === expectedStates.chunkFetchActive) { + return ( + gate?.kind === gateKinds.chunkFetch && + gate.entered === true && + bounded > 0 && + finite === 0 && + operationIconVisible && + !requestIconVisible && + trackedRequests === 0 + ); + } + if (expectedState === expectedStates.trackedRequestActive) { + return ( + gate?.kind === gateKinds.trackedRequest && + gate.entered === true && + bounded === 0 && + finite === 0 && + trackedRequests > 0 && + !operationIconVisible && + statusBarText.includes(`${requestIcon}${trackedRequests}`) + ); + } + return ( + bounded === 0 && + finite === 0 && + requests === responses && + !operationIconVisible && + !requestIconVisible + ); + }, + { + expectedState: expected, + expectedStates: REMOTE_ACTIVITY_EXPECTED_STATE, + gateKinds: REMOTE_ACTIVITY_GATE_KIND, + operationIcon: REMOTE_OPERATION_ACTIVITY_ICON, + pluginId: "obsidian-livesync", + requestIcon: REMOTE_REQUEST_ACTIVITY_ICON, + stateKey: REMOTE_ACTIVITY_E2E_STATE_KEY, + }, + { timeout: timeoutMs } + ); + return await readRemoteActivitySnapshotFromPage(page); + }); + } catch (error) { + const snapshot = await readRemoteActivitySnapshot(port).catch(() => undefined); + throw formatWaitFailure(expected, snapshot, error); + } +} + +export type RemoteActivityDiagnostics = { + screenshotPath: string; + snapshot: RemoteActivitySnapshot; + snapshotPath: string; +}; + +export async function captureRemoteActivityDiagnostics( + port: number, + label: string +): Promise { + const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e"; + await mkdir(outputDirectory, { recursive: true }); + const safeLabel = label.replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "") || "remote-activity"; + const prefix = `${safeLabel}-${new Date().toISOString().replace(/[:.]/g, "-")}`; + const screenshotPath = join(outputDirectory, `${prefix}.png`); + const snapshotPath = join(outputDirectory, `${prefix}.json`); + const snapshot = await withObsidianPage(port, async (page) => { + const current = await readRemoteActivitySnapshotFromPage(page); + await page.screenshot({ path: screenshotPath, fullPage: true }); + return current; + }); + await writeFile(snapshotPath, `${JSON.stringify(snapshot, undefined, 2)}\n`, "utf8"); + return { screenshotPath, snapshot, snapshotPath }; +} diff --git a/test/e2e-obsidian/runner/remoteActivityWorkflow.ts b/test/e2e-obsidian/runner/remoteActivityWorkflow.ts new file mode 100644 index 00000000..376d338c --- /dev/null +++ b/test/e2e-obsidian/runner/remoteActivityWorkflow.ts @@ -0,0 +1,236 @@ +import { evalObsidianJson } from "./cli.ts"; +import { + REMOTE_ACTIVITY_E2E_STATE_KEY, + REMOTE_ACTIVITY_GATE_KIND, + type RemoteActivityGateKind, +} from "./remoteActivity.ts"; + +export type HeldRemoteActivityResult = { + done: boolean; + entered: boolean; + error?: string; + kind: RemoteActivityGateKind; + requestedIds?: string[]; + result?: boolean; + resultCount?: number; +}; + +const stateKeySource = JSON.stringify(REMOTE_ACTIVITY_E2E_STATE_KEY); + +export async function startHeldOneShotReplication(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson<{ started: boolean }>( + cliBinary, + [ + "(async()=>{", + `const stateKey=${stateKeySource};`, + "const host=globalThis;", + "if(host[stateKey]) throw new Error('A remote activity E2E gate is already installed.');", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const replicator=core.services.replicator.getActiveReplicator();", + "if(!replicator) throw new Error('No active replicator is available.');", + "const original=replicator.openReplication;", + "let releaseGate;", + "const gate=new Promise((resolve)=>{releaseGate=resolve;});", + `const state={kind:${JSON.stringify(REMOTE_ACTIVITY_GATE_KIND.oneShot)},entered:false,done:false,released:false,error:undefined,result:undefined,promise:undefined,release:undefined,restore:undefined};`, + "state.release=()=>{if(!state.released){state.released=true;releaseGate();}};", + "state.restore=()=>{replicator.openReplication=original;};", + "host[stateKey]=state;", + "replicator.openReplication=async function(...args){", + "state.entered=true;", + "await gate;", + "return await original.apply(this,args);", + "};", + "state.promise=(async()=>{", + "try{", + "if(!(await core.services.fileProcessing.commitPendingFileEvents())) throw new Error('Pending file events could not be committed.');", + "state.result=!!(await core.services.replication.replicate(true));", + "}catch(error){", + "state.error=error instanceof Error?error.message:String(error);", + "}finally{", + "state.restore();", + "state.done=true;", + "}", + "})();", + "return JSON.stringify({started:true});", + "})()", + ].join(""), + env + ); +} + +export async function startHeldChunkFetch(cliBinary: string, env: NodeJS.ProcessEnv, chunkId: string): Promise { + await evalObsidianJson<{ started: boolean }>( + cliBinary, + [ + "(async()=>{", + `const stateKey=${stateKeySource};`, + `const chunkId=${JSON.stringify(chunkId)};`, + "const host=globalThis;", + "if(host[stateKey]) throw new Error('A remote activity E2E gate is already installed.');", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const replicator=core.services.replicator.getActiveReplicator();", + "if(!replicator) throw new Error('No active replicator is available.');", + "const localDb=core.localDatabase.localDatabase;", + "const existing=await localDb.get(chunkId).catch(()=>undefined);", + "if(existing&&!existing._deleted) throw new Error(`The remote-only chunk already exists locally: ${chunkId}`);", + "const original=replicator.fetchRemoteChunks;", + "let releaseGate;", + "let resolveDone;", + "const gate=new Promise((resolve)=>{releaseGate=resolve;});", + "const donePromise=new Promise((resolve)=>{resolveDone=resolve;});", + `const state={kind:${JSON.stringify(REMOTE_ACTIVITY_GATE_KIND.chunkFetch)},entered:false,done:false,released:false,error:undefined,resultCount:undefined,requestedIds:undefined,promise:donePromise,release:undefined,restore:undefined};`, + "state.release=()=>{if(!state.released){state.released=true;releaseGate();}};", + "state.restore=()=>{replicator.fetchRemoteChunks=original;};", + "host[stateKey]=state;", + "replicator.fetchRemoteChunks=async function(...args){", + "state.entered=true;", + "state.requestedIds=Array.isArray(args[0])?[...args[0]]:[];", + "await gate;", + "try{", + "const result=await original.apply(this,args);", + "state.resultCount=Array.isArray(result)?result.length:0;", + "return result;", + "}catch(error){", + "state.error=error instanceof Error?error.message:String(error);", + "throw error;", + "}finally{", + "state.restore();", + "state.done=true;", + "resolveDone();", + "}", + "};", + "core.localDatabase.managers.chunkFetcher.onEvent([chunkId]);", + "return JSON.stringify({started:true});", + "})()", + ].join(""), + env + ); +} + +export async function startHeldTrackedRequest(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson<{ started: boolean }>( + cliBinary, + [ + "(async()=>{", + `const stateKey=${stateKeySource};`, + "const host=globalThis;", + "if(host[stateKey]) throw new Error('A remote activity E2E gate is already installed.');", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const remote=core.services.remote;", + "const api=core.services.API;", + "const settings=core.services.setting.currentSettings();", + "const original=api.webCompatFetch;", + "let releaseGate;", + "const gate=new Promise((resolve)=>{releaseGate=resolve;});", + `const state={kind:${JSON.stringify(REMOTE_ACTIVITY_GATE_KIND.trackedRequest)},entered:false,done:false,released:false,error:undefined,result:undefined,promise:undefined,release:undefined,restore:undefined};`, + "state.release=()=>{if(!state.released){state.released=true;releaseGate();}};", + "state.restore=()=>{api.webCompatFetch=original;};", + "host[stateKey]=state;", + "api.webCompatFetch=async function(...args){", + "state.entered=true;", + "await gate;", + "return await original.apply(this,args);", + "};", + "state.promise=(async()=>{", + "try{", + "const base=String(settings.couchDB_URI).replace(/\\/$/,'');", + "const database=encodeURIComponent(settings.couchDB_DBNAME);", + "const credentials=btoa(`${settings.couchDB_USER}:${settings.couchDB_PASSWORD}`);", + "const response=await remote.performFetch(`${base}/${database}/_all_docs?limit=0`,{headers:{Authorization:`Basic ${credentials}`}});", + "state.result=response.ok;", + "}catch(error){", + "state.error=error instanceof Error?error.message:String(error);", + "}finally{", + "state.restore();", + "state.done=true;", + "}", + "})();", + "return JSON.stringify({started:true});", + "})()", + ].join(""), + env + ); +} + +export async function finishHeldRemoteActivity( + cliBinary: string, + env: NodeJS.ProcessEnv +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const stateKey=${stateKeySource};`, + "const state=globalThis[stateKey];", + "if(!state) throw new Error('No remote activity E2E gate is installed.');", + "state.release();", + "await state.promise;", + "return JSON.stringify({kind:state.kind,entered:state.entered,done:state.done,error:state.error,result:state.result,resultCount:state.resultCount,requestedIds:state.requestedIds});", + "})()", + ].join(""), + env + ); +} + +export async function waitForRestoredChunk( + cliBinary: string, + env: NodeJS.ProcessEnv, + chunkId: string, + timeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS ?? 30000) +): Promise<{ id: string; type: string }> { + return await evalObsidianJson<{ id: string; type: string }>( + cliBinary, + [ + "(async()=>{", + `const chunkId=${JSON.stringify(chunkId)};`, + `const deadline=Date.now()+${JSON.stringify(timeoutMs)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const localDb=core.localDatabase.localDatabase;", + "const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));", + "while(Date.now()undefined);", + "if(chunk&&!chunk._deleted&&chunk.type==='leaf'&&typeof chunk.data==='string') return JSON.stringify({id:chunk._id,type:chunk.type});", + "await sleep(100);", + "}", + "throw new Error(`Timed out waiting for the fetched chunk to return: ${chunkId}`);", + "})()", + ].join(""), + env + ); +} + +export async function clearHeldRemoteActivity(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson<{ cleared: boolean }>( + cliBinary, + [ + "(async()=>{", + `const stateKey=${stateKeySource};`, + "const state=globalThis[stateKey];", + "if(!state) return JSON.stringify({cleared:false});", + "if(!state.done) throw new Error('The remote activity E2E gate is still running.');", + "delete globalThis[stateKey];", + "return JSON.stringify({cleared:true});", + "})()", + ].join(""), + env + ); +} + +export async function cleanUpHeldRemoteActivity(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson<{ cleared: boolean }>( + cliBinary, + [ + "(async()=>{", + `const stateKey=${stateKeySource};`, + "const state=globalThis[stateKey];", + "if(!state) return JSON.stringify({cleared:false});", + "state.release?.();", + "await Promise.race([Promise.resolve(state.promise),new Promise((resolve)=>setTimeout(resolve,5000))]);", + "state.restore?.();", + "delete globalThis[stateKey];", + "return JSON.stringify({cleared:true});", + "})()", + ].join(""), + env + ); +} diff --git a/test/e2e-obsidian/runner/securitySeed.test.ts b/test/e2e-obsidian/runner/securitySeed.test.ts new file mode 100644 index 00000000..02966683 --- /dev/null +++ b/test/e2e-obsidian/runner/securitySeed.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + SECURITY_SEED_DOCUMENT_ID, + changedSynchronisationParameterFields, + fingerprintSecuritySeed, + replaceSecuritySeed, + requireSecuritySeedDocument, + snapshotSecuritySeedDocument, +} from "./securitySeed.ts"; + +const seedA = Buffer.alloc(32, 1).toString("base64"); +const seedB = Buffer.alloc(32, 2).toString("base64"); + +describe("Security Seed E2E evidence", () => { + it("reports stable, non-secret fingerprints", () => { + expect(fingerprintSecuritySeed(seedA)).toMatch(/^sha256:[0-9a-f]{16}$/u); + expect(fingerprintSecuritySeed(seedA)).toBe(fingerprintSecuritySeed(seedA)); + expect(fingerprintSecuritySeed(seedA)).not.toBe(fingerprintSecuritySeed(seedB)); + }); + + it("redacts the Seed from the machine-readable document snapshot", () => { + const document = requireSecuritySeedDocument({ + _id: SECURITY_SEED_DOCUMENT_ID, + _rev: "0-1", + type: "syncinfo", + protocolVersion: 2, + pbkdf2salt: seedA, + }); + + const snapshot = snapshotSecuritySeedDocument(document); + + expect(snapshot).toEqual({ + id: SECURITY_SEED_DOCUMENT_ID, + revision: "0-1", + fingerprint: fingerprintSecuritySeed(seedA), + fields: { + type: "syncinfo", + protocolVersion: 2, + }, + }); + expect(JSON.stringify(snapshot)).not.toContain(seedA); + }); + + it("replaces only the Seed and identifies later synchronisation-parameter changes", () => { + const before = requireSecuritySeedDocument({ + _id: SECURITY_SEED_DOCUMENT_ID, + _rev: "0-1", + type: "syncinfo", + protocolVersion: 2, + pbkdf2salt: seedA, + }); + const replaced = replaceSecuritySeed(before, seedB); + const laterRevision = { + ...replaced, + _rev: "0-3", + }; + + expect(before.pbkdf2salt).toBe(seedA); + expect(replaced.pbkdf2salt).toBe(seedB); + expect(changedSynchronisationParameterFields(before, replaced)).toEqual(["pbkdf2salt"]); + expect(changedSynchronisationParameterFields(replaced, laterRevision)).toEqual([]); + }); +}); diff --git a/test/e2e-obsidian/runner/securitySeed.ts b/test/e2e-obsidian/runner/securitySeed.ts new file mode 100644 index 00000000..6892f424 --- /dev/null +++ b/test/e2e-obsidian/runner/securitySeed.ts @@ -0,0 +1,88 @@ +/** + * Supplies the runner-owned CouchDB fixture operations for the Security Seed + * reconnect scenario. Production code remains responsible for fetching, + * caching, and applying the Seed; this helper only validates the managed + * synchronisation-parameter document, replaces the one intended field, and + * compares document snapshots. + * + * Callers expose only short SHA-256 fingerprints. The original and replacement + * Seed values must stay inside the isolated test process and must not be + * written to diagnostics, screenshots, or machine-readable results. + */ +import { createHash, randomBytes } from "node:crypto"; +import type { CouchDbDocument } from "./couchdb.ts"; + +export const SECURITY_SEED_DOCUMENT_ID = "_local/obsidian_livesync_sync_parameters"; + +export type SecuritySeedDocument = CouchDbDocument & { + _id: typeof SECURITY_SEED_DOCUMENT_ID; + _rev: string; + pbkdf2salt: string; +}; + +export type SecuritySeedDocumentSnapshot = { + id: string; + revision: string; + fingerprint: string; + fields: Record; +}; + +function decodeSecuritySeed(seed: string): Buffer { + const bytes = Buffer.from(seed, "base64"); + if (seed.length === 0 || bytes.length === 0) { + throw new Error("The Security Seed is empty or is not valid base64."); + } + return bytes; +} + +export function createSecuritySeed(): string { + return randomBytes(32).toString("base64"); +} + +export function fingerprintSecuritySeed(seed: string): string { + const bytes = Uint8Array.from(decodeSecuritySeed(seed)); + return `sha256:${createHash("sha256").update(bytes).digest("hex").slice(0, 16)}`; +} + +export function requireSecuritySeedDocument(document: CouchDbDocument): SecuritySeedDocument { + if (document._id !== SECURITY_SEED_DOCUMENT_ID) { + throw new Error(`Unexpected synchronisation-parameter document: ${document._id}`); + } + if (typeof document._rev !== "string" || document._rev.length === 0) { + throw new Error("The synchronisation-parameter document does not have a revision."); + } + if (typeof document.pbkdf2salt !== "string") { + throw new Error("The synchronisation-parameter document does not have a Security Seed."); + } + decodeSecuritySeed(document.pbkdf2salt); + return document as SecuritySeedDocument; +} + +export function replaceSecuritySeed(document: SecuritySeedDocument, replacementSeed: string): SecuritySeedDocument { + decodeSecuritySeed(replacementSeed); + return { + ...document, + pbkdf2salt: replacementSeed, + }; +} + +export function snapshotSecuritySeedDocument(document: SecuritySeedDocument): SecuritySeedDocumentSnapshot { + const { _id, _rev, pbkdf2salt, ...fields } = document; + return { + id: _id, + revision: _rev, + fingerprint: fingerprintSecuritySeed(pbkdf2salt), + fields, + }; +} + +export function changedSynchronisationParameterFields( + before: SecuritySeedDocument, + after: SecuritySeedDocument +): string[] { + const ignoredFields = new Set(["_rev"]); + return [...new Set([...Object.keys(before), ...Object.keys(after)])] + .filter((key) => !ignoredFields.has(key)) + .filter((key) => JSON.stringify(before[key]) !== JSON.stringify(after[key])) + .sort(); +} diff --git a/test/e2e-obsidian/runner/session.test.ts b/test/e2e-obsidian/runner/session.test.ts new file mode 100644 index 00000000..8977cb96 --- /dev/null +++ b/test/e2e-obsidian/runner/session.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { startObsidianPluginSession } from "@vrtmrz/obsidian-test-session"; +import { + startObsidianLiveSyncSession, + type StartObsidianLiveSyncSessionOptions, +} from "./session.ts"; + +vi.mock("@vrtmrz/obsidian-test-session", () => ({ + startObsidianPluginSession: vi.fn(async () => ({ + app: {}, + cliEnv: {}, + install: {}, + readiness: {}, + pluginId: "obsidian-livesync", + remoteDebuggingPort: 28052, + })), +})); + +describe("LiveSync real-Obsidian session", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("installs an explicitly selected plug-in artefact while retaining the supplied Vault and profile", async () => { + const vault = { + path: "/tmp/upgrade-vault", + statePath: "/tmp/upgrade-state", + name: "upgrade-vault", + id: "upgrade-vault-id", + homePath: "/tmp/upgrade-state/home", + xdgConfigPath: "/tmp/upgrade-state/xdg-config", + xdgCachePath: "/tmp/upgrade-state/xdg-cache", + xdgDataPath: "/tmp/upgrade-state/xdg-data", + userDataPath: "/tmp/upgrade-state/user-data", + processMarker: "/tmp/upgrade-state", + dispose: vi.fn(async () => undefined), + }; + const options: StartObsidianLiveSyncSessionOptions & { artifactRoot: string } = { + binary: "/Applications/Obsidian", + cliBinary: "obsidian-cli", + vault, + artifactRoot: "/tmp/obsidian-livesync-0.25.83", + }; + + await startObsidianLiveSyncSession(options); + + expect(startObsidianPluginSession).toHaveBeenCalledWith( + expect.objectContaining({ + artifactRoot: options.artifactRoot, + pluginId: "obsidian-livesync", + vault, + }) + ); + }); + + it("forwards instance-scoped lifecycle hooks and the selected plug-in start mode", async () => { + const beforePluginStart = vi.fn(async () => undefined); + const vault = { + path: "/tmp/mobile-vault", + statePath: "/tmp/mobile-state", + name: "mobile-vault", + id: "mobile-vault-id", + homePath: "/tmp/mobile-state/home", + xdgConfigPath: "/tmp/mobile-state/xdg-config", + xdgCachePath: "/tmp/mobile-state/xdg-cache", + xdgDataPath: "/tmp/mobile-state/xdg-data", + userDataPath: "/tmp/mobile-state/user-data", + processMarker: "/tmp/mobile-state", + dispose: vi.fn(async () => undefined), + }; + + await startObsidianLiveSyncSession({ + binary: "/Applications/Obsidian", + cliBinary: "obsidian-cli", + vault, + pluginStartup: "controlled", + lifecycle: { beforePluginStart }, + }); + + expect(startObsidianPluginSession).toHaveBeenCalledWith( + expect.objectContaining({ + lifecycle: { beforePluginStart }, + pluginStartup: "controlled", + }) + ); + }); +}); diff --git a/test/e2e-obsidian/runner/session.ts b/test/e2e-obsidian/runner/session.ts index ede769da..8347f5e3 100644 --- a/test/e2e-obsidian/runner/session.ts +++ b/test/e2e-obsidian/runner/session.ts @@ -1,4 +1,9 @@ -import { startObsidianPluginSession, type ObsidianPluginSession } from "@vrtmrz/obsidian-test-session"; +import { + startObsidianPluginSession, + type ObsidianPluginSession, + type ObsidianPluginSessionLifecycle, + type ObsidianPluginStartupMode, +} from "@vrtmrz/obsidian-test-session"; import type { TemporaryVault } from "./vault.ts"; export type ObsidianLiveSyncSession = ObsidianPluginSession; @@ -7,7 +12,13 @@ export type StartObsidianLiveSyncSessionOptions = { binary: string; cliBinary: string; vault: TemporaryVault; + artifactRoot?: string; startupGraceMs?: number; + pluginData?: Record; + localStorageEntries?: Readonly>; + pluginStartup?: ObsidianPluginStartupMode; + lifecycle?: ObsidianPluginSessionLifecycle; + env?: NodeJS.ProcessEnv; }; export async function startObsidianLiveSyncSession( @@ -18,7 +29,12 @@ export async function startObsidianLiveSyncSession( cliBinary: options.cliBinary, vault: options.vault, pluginId: "obsidian-livesync", - artifactRoot: process.cwd(), + artifactRoot: options.artifactRoot ?? process.cwd(), startupGraceMs: options.startupGraceMs, + pluginData: options.pluginData, + localStorageEntries: options.localStorageEntries, + pluginStartup: options.pluginStartup, + lifecycle: options.lifecycle, + env: options.env, }); } diff --git a/test/e2e-obsidian/runner/setupUri.ts b/test/e2e-obsidian/runner/setupUri.ts new file mode 100644 index 00000000..10764fbc --- /dev/null +++ b/test/e2e-obsidian/runner/setupUri.ts @@ -0,0 +1,445 @@ +import type { Locator, Page } from "playwright"; +import { evalObsidianJson } from "./cli.ts"; +import { captureObsidianDialogue, captureObsidianElement, withObsidianPage } from "./ui.ts"; + +export type SetupArtifact = { + setupURI: string; + setupPassphrase: string; +}; + +export type SetupState = { + configured: boolean; + databaseReady: boolean; + appReady: boolean; + suspended: boolean; + remoteType: string; + activeConfigurationId: string; + remoteConfigurationCount: number; + endpoint: string; + bucket: string; + bucketPrefix: string; + p2pEnabled: boolean; + p2pRelays: string; + p2pRoomId: string; +}; + +export type SetupCaptureNames = { + scenario: string; + guide: string; +}; + +const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 30000); +const initialisationTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_INITIALISATION_TIMEOUT_MS ?? 120000); + +export function modalByTitle(page: Page, title: string): Locator { + return page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: title }), + }); +} + +export async function captureGuideDialogue(port: number, filename: string, title: string): Promise { + return await captureObsidianElement(port, filename, (page) => modalByTitle(page, title).locator(".modal").first()); +} + +export async function assertVerticalActionLayout(port: number, title: string): Promise { + await withObsidianPage(port, async (page) => { + const actions = modalByTitle(page, title).locator(".vpk-action-dialog__actions").first(); + await actions.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const flexDirection = await actions.evaluate((element) => getComputedStyle(element).flexDirection); + if (flexDirection !== "column") { + throw new Error(`Expected vertically stacked actions in '${title}', received '${flexDirection}'.`); + } + }); +} + +export async function selectRadioOption(modal: Locator, title: string): Promise { + const radio = modal.locator("label").filter({ hasText: title }).locator('input[type="radio"]').first(); + await radio.check({ timeout: uiTimeoutMs }); +} + +export async function selectCheckbox(modal: Locator, title: string): Promise { + const checkbox = modal.locator("label").filter({ hasText: title }).locator('input[type="checkbox"]').first(); + await checkbox.check({ timeout: uiTimeoutMs }); +} + +export async function enterSetupURI( + port: number, + mode: "new" | "existing", + artifact: SetupArtifact, + captures: SetupCaptureNames +): Promise { + await withObsidianPage(port, async (page) => { + const invitation = page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" }); + await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await invitation.locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs }); + + const intro = modalByTitle(page, "Welcome to Self-hosted LiveSync"); + await intro.waitFor({ state: "visible", timeout: uiTimeoutMs }); + if (mode === "new") { + await selectRadioOption(intro, "I am setting this up for the first time"); + await intro + .getByRole("button", { name: "Yes, I want to set up a new synchronisation" }) + .click({ timeout: uiTimeoutMs }); + } else { + await selectRadioOption(intro, "I am adding a device to an existing synchronisation setup"); + await intro + .getByRole("button", { name: "Yes, I want to add this device to my existing synchronisation" }) + .click({ timeout: uiTimeoutMs }); + } + + const method = modalByTitle(page, mode === "new" ? "Connection Method" : "Device Setup Method"); + await method.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await selectRadioOption(method, "Use a Setup URI (Recommended)"); + await method.getByRole("button", { name: "Proceed with Setup URI" }).click({ timeout: uiTimeoutMs }); + + const setup = modalByTitle(page, "Enter Setup URI"); + await setup.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await setup.locator('input[placeholder^="obsidian://setuplivesync"]').fill(artifact.setupURI); + await setup.locator('input[name="password"]').fill(artifact.setupPassphrase); + }); + const screenshot = await captureGuideDialogue( + port, + `guide-${captures.guide}-${mode === "new" ? "first" : "second"}-setup-uri.png`, + "Enter Setup URI" + ); + await withObsidianPage(port, async (page) => { + await modalByTitle(page, "Enter Setup URI") + .getByRole("button", { name: "Test Settings and Continue" }) + .click({ timeout: uiTimeoutMs }); + }); + return screenshot; +} + +export async function generateSetupURIFromDevice( + port: number, + setupPassphrase: string, + captures: SetupCaptureNames +): Promise<{ artifact: SetupArtifact; screenshots: string[] }> { + const opened = await withObsidianPage(port, async (page) => { + return await page.evaluate( + (commandId) => + ( + globalThis as typeof globalThis & { + app?: { commands?: { executeCommandById(id: string): boolean } }; + } + ).app?.commands?.executeCommandById(commandId) === true, + "obsidian-livesync:livesync-copysetupuri" + ); + }); + if (!opened) throw new Error("The command for generating a Setup URI was not registered."); + + const promptTitle = "Encrypt your settings"; + await withObsidianPage(port, async (page) => { + const prompt = modalByTitle(page, promptTitle); + await prompt.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await prompt.locator('input[type="password"]').fill(setupPassphrase); + }); + const promptScreenshot = await captureGuideDialogue( + port, + `guide-${captures.guide}-copy-setup-uri-passphrase.png`, + promptTitle + ); + await withObsidianPage(port, async (page) => { + const prompt = modalByTitle(page, promptTitle); + await prompt.getByRole("button", { name: "OK", exact: true }).click({ timeout: uiTimeoutMs }); + await prompt.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + const resultTitle = "Your Setup URI is ready to be copied"; + const setupURI = await withObsidianPage(port, async (page) => { + const result = modalByTitle(page, resultTitle); + await result.waitFor({ state: "visible", timeout: uiTimeoutMs }); + return await result.locator("textarea[readonly]").inputValue(); + }); + if (!setupURI.startsWith("obsidian://setuplivesync?settings=")) { + throw new Error("The first device did not generate a valid Setup URI."); + } + const resultScreenshot = await captureGuideDialogue( + port, + `guide-${captures.guide}-copy-setup-uri-result.png`, + resultTitle + ); + await withObsidianPage(port, async (page) => { + const result = modalByTitle(page, resultTitle); + await result.getByRole("button", { name: "OK", exact: true }).click({ timeout: uiTimeoutMs }); + await result.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + return { + artifact: { setupURI, setupPassphrase }, + screenshots: [promptScreenshot, resultScreenshot], + }; +} + +export async function captureAndStartInitialisation( + port: number, + mode: "new" | "existing", + captures: SetupCaptureNames +): Promise { + const p2pFirstDevice = mode === "new" && captures.guide === "p2p-setup"; + const p2pAdditionalDevice = mode === "existing" && captures.guide === "p2p-setup"; + const title = p2pFirstDevice + ? "Setup Complete: Preparing This P2P Device" + : p2pAdditionalDevice + ? "Setup Complete: Preparing to Fetch from Another Device" + : mode === "new" + ? "Setup Complete: Preparing to Initialise Server" + : "Setup Complete: Preparing to Fetch Synchronisation Data"; + const button = p2pFirstDevice + ? "Restart and Prepare This Device" + : p2pAdditionalDevice + ? "Restart and Select Source Device" + : mode === "new" + ? "Restart and Initialise Server" + : "Restart and Fetch Data"; + if (p2pAdditionalDevice) { + await withObsidianPage(port, async (page) => { + const modal = modalByTitle(page, title); + await modal + .getByText("After restarting, select an online source device for the initial Fetch.", { + exact: false, + }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + if ((await modal.getByText("downloaded from the server", { exact: false }).count()) !== 0) { + throw new Error("P2P additional-device setup still describes the initial Fetch as a server download."); + } + }); + } + const screenshot = await captureGuideDialogue( + port, + `guide-${captures.guide}-${mode === "new" ? "first-initialise" : "second-fetch"}.png`, + title + ); + await withObsidianPage(port, async (page) => { + await modalByTitle(page, title).getByRole("button", { name: button }).click({ timeout: uiTimeoutMs }); + }); + return screenshot; +} + +export async function confirmRebuild(port: number, captures: SetupCaptureNames): Promise { + const isP2P = captures.guide === "p2p-setup"; + const title = isP2P + ? "Final Confirmation: Prepare This Device for P2P" + : "Final Confirmation: Overwrite Server Data with This Device's Files"; + const screenshot = await captureGuideDialogue( + port, + `guide-${captures.guide}-first-rebuild-confirmation.png`, + title + ); + await withObsidianPage(port, async (page) => { + const modal = modalByTitle(page, title); + if (isP2P) { + await selectCheckbox( + modal, + "I understand that this resets only this device's local synchronisation database." + ); + await selectRadioOption(modal, "I understand the risks and will proceed without a backup."); + await modal + .getByRole("button", { name: "I Understand, Prepare This Device" }) + .click({ timeout: uiTimeoutMs }); + return; + } + await selectCheckbox( + modal, + "I understand that all changes made on other smartphones or computers possibly could be lost." + ); + await selectCheckbox( + modal, + "I understand that other devices will no longer be able to synchronise, and will need to be reset the synchronisation information." + ); + await selectCheckbox(modal, "I understand that this action is irreversible once performed."); + await selectRadioOption(modal, "I understand the risks and will proceed without a backup."); + await modal.getByRole("button", { name: "I Understand, Overwrite Server" }).click({ timeout: uiTimeoutMs }); + }); + return screenshot; +} + +export async function continueWithoutRemoteSettings(port: number, captures: SetupCaptureNames): Promise { + const title = "No Synchronisation Settings Found"; + const screenshot = await captureGuideDialogue( + port, + `guide-${captures.guide}-missing-remote-configuration.png`, + title + ); + await withObsidianPage(port, async (page) => { + const modal = modalByTitle(page, title); + await modal + .getByText("This is normal for a new remote.", { exact: false }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal.getByRole("button", { name: "Cancel", exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await modal.getByRole("button", { name: "Use this device's settings" }).click({ timeout: uiTimeoutMs }); + }); + return screenshot; +} + +export async function acknowledgeDisabledOptionalFeatures(port: number, captures: SetupCaptureNames): Promise { + const title = "All optional features are disabled"; + const screenshot = await captureGuideDialogue( + port, + `guide-${captures.guide}-optional-features-disabled.png`, + title + ); + await withObsidianPage(port, async (page) => { + const modal = modalByTitle(page, title); + await modal.getByRole("button", { name: "OK" }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + return screenshot; +} + +export async function confirmFastFetch(port: number, captures: SetupCaptureNames): Promise { + const firstTitle = "Data retrieval scheduled"; + await assertVerticalActionLayout(port, firstTitle); + const firstScreenshot = await captureGuideDialogue( + port, + `guide-${captures.guide}-retrieval-method.png`, + firstTitle + ); + await withObsidianPage(port, async (page) => { + await modalByTitle(page, firstTitle) + .getByRole("button", { name: "Overwrite all with remote files" }) + .click({ timeout: uiTimeoutMs }); + }); + + const secondTitle = "How to handle extra existing local files?"; + await assertVerticalActionLayout(port, secondTitle); + const secondScreenshot = await captureGuideDialogue( + port, + `guide-${captures.guide}-local-file-policy.png`, + secondTitle + ); + await withObsidianPage(port, async (page) => { + await modalByTitle(page, secondTitle) + .getByRole("button", { name: "Keep local files even if not on remote" }) + .click({ timeout: uiTimeoutMs }); + }); + return [firstScreenshot, secondScreenshot]; +} + +function isConfiguredSetupReady(state: SetupState): boolean { + return ( + state.configured && + state.databaseReady && + state.appReady && + !state.suspended && + state.activeConfigurationId !== "" && + state.remoteConfigurationCount === 1 + ); +} + +export async function readSetupState(cliBinary: string, environment: NodeJS.ProcessEnv): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const settings=core.services.setting.currentSettings();", + "return JSON.stringify({", + "configured:settings.isConfigured===true,", + "databaseReady:core.services.database.isDatabaseReady(),", + "appReady:core.services.appLifecycle.isReady(),", + "suspended:core.services.appLifecycle.isSuspended(),", + "remoteType:settings.remoteType||'',", + "activeConfigurationId:settings.activeConfigurationId||'',", + "remoteConfigurationCount:Object.keys(settings.remoteConfigurations||{}).length,", + "endpoint:settings.endpoint||'',", + "bucket:settings.bucket||'',", + "bucketPrefix:settings.bucketPrefix||'',", + "p2pEnabled:settings.P2P_Enabled===true,", + "p2pRelays:settings.P2P_relays||'',", + "p2pRoomId:settings.P2P_roomID||'',", + "});", + "})()", + ].join(""), + environment + ); +} + +export async function waitForConfiguredSetup( + cliBinary: string, + environment: NodeJS.ProcessEnv, + timeoutMs = initialisationTimeoutMs +): Promise { + const deadline = Date.now() + timeoutMs; + let lastState: SetupState | undefined; + let lastError: unknown; + while (Date.now() < deadline) { + try { + lastState = await readSetupState(cliBinary, environment); + if (isConfiguredSetupReady(lastState)) return lastState; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error( + `Timed out waiting for configured Setup URI state: ${JSON.stringify(lastState)}${ + lastError instanceof Error ? `; last error: ${lastError.message}` : "" + }` + ); +} + +export async function finishInitialisation( + port: number, + cliBinary: string, + environment: NodeJS.ProcessEnv +): Promise { + const message = "Do you want to resume file and database processing, and restart obsidian now?"; + const deadline = Date.now() + initialisationTimeoutMs; + let readySince: number | undefined; + while (Date.now() < deadline) { + const resumeVisible = await withObsidianPage(port, async (page) => { + return await modalByTitle(page, "Confirmation").filter({ hasText: message }).isVisible(); + }).catch(() => false); + if (resumeVisible) { + await withObsidianPage(port, async (page) => { + const modal = modalByTitle(page, "Confirmation").filter({ hasText: message }); + await modal.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + return await waitForConfiguredSetup(cliBinary, environment); + } + try { + const state = await readSetupState(cliBinary, environment); + if (isConfiguredSetupReady(state)) { + readySince ??= Date.now(); + if (Date.now() - readySince >= 1000) return state; + } else { + readySince = undefined; + } + } catch { + // Obsidian may be reloading while the scheduled operation runs. + readySince = undefined; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error("Timed out waiting for Setup URI initialisation to finish."); +} + +export async function resumeCompatibilityReviewIfShown(port: number): Promise { + const title = "Synchronisation paused for compatibility review"; + const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000); + let available = false; + while (Date.now() < deadline && !available) { + available = await withObsidianPage(port, async (page) => { + const modal = modalByTitle(page, title); + if (await modal.isVisible()) return true; + const reminder = page.locator(".notice.livesync-compatibility-review-notice"); + if (!(await reminder.isVisible())) return false; + await reminder.getByRole("link", { name: "Review why" }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + return true; + }).catch(() => false); + if (!available) await new Promise((resolve) => setTimeout(resolve, 200)); + } + if (!available) return false; + await withObsidianPage(port, async (page) => { + const modal = modalByTitle(page, title); + await modal.getByRole("button", { name: "Resume synchronisation" }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + return true; +} diff --git a/test/e2e-obsidian/runner/twoVaultSyncLifecycle.test.ts b/test/e2e-obsidian/runner/twoVaultSyncLifecycle.test.ts new file mode 100644 index 00000000..7ee47ffd --- /dev/null +++ b/test/e2e-obsidian/runner/twoVaultSyncLifecycle.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + events: [] as string[], + sessions: [] as Array<{ app: { stop: ReturnType } }>, + vaultCount: 0, +})); + +vi.mock("./cli.ts", () => ({ + evalObsidianJson: vi.fn(async () => ({ ok: true })), +})); + +vi.mock("./couchdb.ts", () => ({ + assertCouchDbReachable: vi.fn(async () => undefined), + createCouchDbDatabase: vi.fn(async () => undefined), + deleteCouchDbDatabase: vi.fn(async () => undefined), + loadCouchDbConfig: vi.fn(async () => ({ + uri: "http://localhost:5984", + username: "admin", + password: "password", + dbPrefix: "e2e", + })), + makeUniqueDatabaseName: vi.fn((_prefix: string, suffix: string) => suffix), + waitForCouchDbDocs: vi.fn(async () => undefined), +})); + +vi.mock("./environment.ts", () => ({ + discoverObsidianCli: vi.fn(() => ({ binary: "obsidian-cli", checked: [] })), + requireObsidianBinary: vi.fn(() => "Obsidian"), +})); + +vi.mock("./pathAssertions.ts", () => ({ + waitForExactCaseOnlyRename: vi.fn(async () => undefined), +})); + +vi.mock("./liveSyncWorkflow.ts", () => ({ + assertEqual: vi.fn(), + assertE2eCompatibilityMarker: vi.fn(async () => undefined), + assertE2eCompatibilityReviewPending: vi.fn(async () => undefined), + configureCouchDb: vi.fn(async () => undefined), + createE2eCouchDbPluginData: vi.fn(() => ({})), + prepareRemote: vi.fn(async () => undefined), + pushLocalChanges: vi.fn(async () => { + throw new Error("simulated Obsidian CLI timeout"); + }), + resumeCompatibilityReview: vi.fn(async () => undefined), + waitForLiveSyncCoreReady: vi.fn(async () => undefined), + waitForLocalDatabaseEntry: vi.fn(async () => ({ id: "note-id", children: [] })), +})); + +vi.mock("./session.ts", () => ({ + startObsidianLiveSyncSession: vi.fn(async () => { + const session = { + app: { + stop: vi.fn(async () => { + state.events.push("session:stop"); + }), + }, + cliEnv: {}, + remoteDebuggingPort: 28052, + }; + state.sessions.push(session); + return session; + }), +})); + +vi.mock("./vault.ts", () => ({ + createTemporaryVault: vi.fn(async () => { + state.vaultCount += 1; + const name = `vault-${state.vaultCount}`; + return { + name, + path: `/tmp/${name}`, + dispose: vi.fn(async () => { + state.events.push(`${name}:dispose`); + }), + }; + }), +})); + +describe("two-vault runner lifecycle", () => { + beforeEach(() => { + vi.resetModules(); + state.events.length = 0; + state.sessions.length = 0; + state.vaultCount = 0; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("stops the active Obsidian session before disposing temporary Vaults when synchronisation fails", async () => { + let resolveExit!: (code: number) => void; + const exitCode = new Promise((resolve) => { + resolveExit = resolve; + }); + vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(process, "exit").mockImplementation((code) => { + resolveExit(Number(code)); + return undefined as never; + }); + + await import("../scripts/two-vault-sync.ts"); + + expect(await exitCode).toBe(1); + expect(state.sessions).toHaveLength(1); + expect(state.sessions[0].app.stop).toHaveBeenCalledOnce(); + expect(state.events.indexOf("session:stop")).toBeLessThan(state.events.indexOf("vault-1:dispose")); + }); +}); diff --git a/test/e2e-obsidian/runner/ui.ts b/test/e2e-obsidian/runner/ui.ts index 8c4f36d5..27efa226 100644 --- a/test/e2e-obsidian/runner/ui.ts +++ b/test/e2e-obsidian/runner/ui.ts @@ -1,4 +1,7 @@ +import { mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; import { withObsidianPage } from "@vrtmrz/obsidian-test-session"; +import type { Locator, Page } from "playwright"; export { obsidianRemoteDebuggingPort, @@ -7,6 +10,78 @@ export { withObsidianPage, } from "@vrtmrz/obsidian-test-session"; +export async function captureObsidianPage( + port: number, + filename: string, + assertReady: (page: Page) => Promise +): Promise { + const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e"; + const screenshotPath = join(outputDirectory, filename); + await mkdir(dirname(screenshotPath), { recursive: true }); + + await withObsidianPage(port, async (page) => { + try { + await assertReady(page); + } catch (error) { + const failurePath = screenshotPath.replace(/\.png$/u, ".failure.png"); + await page.screenshot({ path: failurePath, fullPage: true }); + console.error(`UI failure screenshot: ${failurePath}`); + throw error; + } + await page.screenshot({ path: screenshotPath, fullPage: true }); + }); + + return screenshotPath; +} + +export async function captureObsidianDialogue( + port: number, + filename: string, + assertReady: (page: Page) => Promise +): Promise { + return await captureObsidianPage(port, filename, assertReady); +} + +export async function captureObsidianElement( + port: number, + filename: string, + resolveElement: (page: Page) => Locator | Promise +): Promise { + const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e"; + const screenshotPath = join(outputDirectory, filename); + await mkdir(dirname(screenshotPath), { recursive: true }); + + await withObsidianPage(port, async (page) => { + try { + const element = await resolveElement(page); + await element.waitFor({ state: "visible", timeout: 10000 }); + await element.screenshot({ + path: screenshotPath, + animations: "disabled", + style: ".notice-container { visibility: hidden !important; }", + }); + } catch (error) { + const failurePath = screenshotPath.replace(/\.png$/u, ".failure.png"); + await page.screenshot({ path: failurePath, fullPage: true }); + console.error(`UI element failure screenshot: ${failurePath}`); + throw error; + } + }); + + return screenshotPath; +} + +export async function captureJsonResolveDialogue(port: number): Promise { + return await captureObsidianDialogue(port, "hidden-file-json-resolve-dialogue.png", async (page) => { + const optionAB = page.locator('label:has(input[name="disp"][value="AB"])'); + const optionBA = page.locator('label:has(input[name="disp"][value="BA"])'); + const applyButton = page.getByRole("button", { name: "Apply" }); + await optionAB.waitFor({ state: "visible", timeout: 10000 }); + await optionBA.waitFor({ state: "visible", timeout: 10000 }); + await applyButton.waitFor({ state: "visible", timeout: 10000 }); + }); +} + export async function clickJsonResolveOption(port: number, mode: "AB" | "BA"): Promise { await withObsidianPage(port, async (page) => { const option = page.locator(`label:has(input[name="disp"][value="${mode}"])`); diff --git a/test/e2e-obsidian/runner/upgradeContinuity.test.ts b/test/e2e-obsidian/runner/upgradeContinuity.test.ts new file mode 100644 index 00000000..505cecc8 --- /dev/null +++ b/test/e2e-obsidian/runner/upgradeContinuity.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + assertCouchDbCheckpointContinuity, + assertJournalCheckpointLoaded, + assertNoJournalReplay, + type JournalCheckpointSnapshot, +} from "./upgradeContinuity.ts"; + +const journalCheckpoint: JournalCheckpointSnapshot = { + remoteKey: "remote-a", + lastLocalSeq: 42, + journalEpoch: "2:salt", + knownIDs: ["known-a"], + sentIDs: ["sent-a"], + receivedFiles: ["100-docs.jsonl.gz"], + sentFiles: ["101-docs.jsonl.gz"], +}; + +describe("upgrade synchronisation continuity assertions", () => { + it("rejects a fresh CouchDB checkpoint lineage even when final documents could still converge", () => { + expect(() => + assertCouchDbCheckpointContinuity( + [{ id: "_local/original", lastSequence: 42 }], + [{ id: "_local/replacement", lastSequence: 42 }] + ) + ).toThrow("checkpoint identity changed"); + }); + + it("rejects an Object Storage checkpoint which was reset to its initial state", () => { + expect(() => + assertJournalCheckpointLoaded(journalCheckpoint, { + remoteKey: journalCheckpoint.remoteKey, + lastLocalSeq: 0, + journalEpoch: "", + knownIDs: [], + sentIDs: [], + receivedFiles: [], + sentFiles: [], + }) + ).toThrow(/lastLocalSeq regressed|history was lost/u); + }); + + it("rejects hidden Object Storage replay during an otherwise unchanged sync", () => { + expect(() => + assertNoJournalReplay( + journalCheckpoint, + journalCheckpoint, + [{ key: "101-docs.jsonl.gz", size: 10, etag: "etag" }], + [{ key: "101-docs.jsonl.gz", size: 10, etag: "etag" }], + { downloadedJournalKeys: ["101-docs.jsonl.gz"], uploadedJournalKeys: [] } + ) + ).toThrow("downloaded previously processed journals"); + }); +}); diff --git a/test/e2e-obsidian/runner/upgradeContinuity.ts b/test/e2e-obsidian/runner/upgradeContinuity.ts new file mode 100644 index 00000000..b5426265 --- /dev/null +++ b/test/e2e-obsidian/runner/upgradeContinuity.ts @@ -0,0 +1,199 @@ +export type CouchDbCheckpointSnapshot = { + id: string; + lastSequence: unknown; +}; + +export type CouchDbDocumentRevision = { + id: string; + revision: string; + deleted: boolean; +}; + +export type JournalCheckpointSnapshot = { + remoteKey: string; + lastLocalSeq: number | string; + journalEpoch: string; + knownIDs: readonly string[]; + sentIDs: readonly string[]; + receivedFiles: readonly string[]; + sentFiles: readonly string[]; +}; + +export type JournalIoObservation = { + downloadedJournalKeys: readonly string[]; + uploadedJournalKeys: readonly string[]; +}; + +export type RemoteObjectSnapshot = { + key: string; + size: number; + etag: string; +}; + +export type MilestoneIdentity = { + created: unknown; + locked: boolean; + acceptedNodes: readonly string[]; +}; + +function sorted(values: readonly string[]): string[] { + return [...values].sort((left, right) => left.localeCompare(right)); +} + +function assertEqualStrings(actual: readonly string[], expected: readonly string[], message: string): void { + const actualSorted = sorted(actual); + const expectedSorted = sorted(expected); + if (JSON.stringify(actualSorted) !== JSON.stringify(expectedSorted)) { + throw new Error(`${message}\nExpected: ${JSON.stringify(expectedSorted)}\nActual: ${JSON.stringify(actualSorted)}`); + } +} + +function assertSubset(previous: readonly string[], current: readonly string[], message: string): void { + const currentSet = new Set(current); + const missing = previous.filter((value) => !currentSet.has(value)); + if (missing.length > 0) throw new Error(`${message}: ${missing.join(", ")}`); +} + +function sequenceNumber(sequence: unknown): number | undefined { + if (typeof sequence === "number" && Number.isFinite(sequence)) return sequence; + if (typeof sequence !== "string") return undefined; + const match = /^(\d+)/u.exec(sequence); + return match ? Number(match[1]) : undefined; +} + +function assertSequenceDidNotRegress(before: unknown, after: unknown, label: string): void { + const beforeNumber = sequenceNumber(before); + const afterNumber = sequenceNumber(after); + if (beforeNumber !== undefined && afterNumber !== undefined) { + if (afterNumber < beforeNumber) { + throw new Error(`${label} regressed from ${String(before)} to ${String(after)}.`); + } + return; + } + if (before !== after) { + throw new Error(`${label} changed from an opaque sequence ${String(before)} to ${String(after)}.`); + } +} + +export function assertCouchDbCheckpointContinuity( + before: readonly CouchDbCheckpointSnapshot[], + after: readonly CouchDbCheckpointSnapshot[] +): void { + if (before.length === 0) throw new Error("The stable release did not create a CouchDB replication checkpoint."); + assertEqualStrings( + after.map(({ id }) => id), + before.map(({ id }) => id), + "The CouchDB replication checkpoint identity changed during the upgrade." + ); + const afterById = new Map(after.map((checkpoint) => [checkpoint.id, checkpoint])); + for (const checkpoint of before) { + assertSequenceDidNotRegress( + checkpoint.lastSequence, + afterById.get(checkpoint.id)?.lastSequence, + `CouchDB checkpoint ${checkpoint.id}` + ); + } +} + +export function assertSomeCouchDbCheckpointAdvanced( + before: readonly CouchDbCheckpointSnapshot[], + after: readonly CouchDbCheckpointSnapshot[] +): void { + assertCouchDbCheckpointContinuity(before, after); + const afterById = new Map(after.map((checkpoint) => [checkpoint.id, checkpoint])); + const advanced = before.some((checkpoint) => { + const previous = sequenceNumber(checkpoint.lastSequence); + const current = sequenceNumber(afterById.get(checkpoint.id)?.lastSequence); + return previous !== undefined && current !== undefined && current > previous; + }); + if (!advanced) throw new Error("No CouchDB replication checkpoint advanced after the post-upgrade change."); +} + +export function assertCouchDbDocumentsUnchanged( + before: readonly CouchDbDocumentRevision[], + after: readonly CouchDbDocumentRevision[] +): void { + const serialise = (documents: readonly CouchDbDocumentRevision[]) => + [...documents].sort((left, right) => left.id.localeCompare(right.id)); + if (JSON.stringify(serialise(before)) !== JSON.stringify(serialise(after))) { + throw new Error("A no-op post-upgrade CouchDB synchronisation changed ordinary remote documents."); + } +} + +export function assertJournalCheckpointLoaded( + before: JournalCheckpointSnapshot, + after: JournalCheckpointSnapshot +): void { + if (sequenceNumber(before.lastLocalSeq) === 0) { + throw new Error("The stable release did not advance the Object Storage local checkpoint."); + } + if (after.remoteKey !== before.remoteKey) { + throw new Error(`The Object Storage checkpoint key changed from ${before.remoteKey} to ${after.remoteKey}.`); + } + assertSequenceDidNotRegress(before.lastLocalSeq, after.lastLocalSeq, "Object Storage lastLocalSeq"); + assertSubset(before.knownIDs, after.knownIDs, "Object Storage known revision history was lost"); + assertSubset(before.sentIDs, after.sentIDs, "Object Storage sent revision history was lost"); + assertSubset(before.receivedFiles, after.receivedFiles, "Object Storage received journal history was lost"); + assertSubset(before.sentFiles, after.sentFiles, "Object Storage sent journal history was lost"); + if (before.journalEpoch && after.journalEpoch !== before.journalEpoch) { + throw new Error( + `The Object Storage journal epoch changed from ${before.journalEpoch} to ${after.journalEpoch}.` + ); + } +} + +export function assertNoJournalReplay( + beforeCheckpoint: JournalCheckpointSnapshot, + afterCheckpoint: JournalCheckpointSnapshot, + beforeObjects: readonly RemoteObjectSnapshot[], + afterObjects: readonly RemoteObjectSnapshot[], + observation: JournalIoObservation +): void { + assertJournalCheckpointLoaded(beforeCheckpoint, afterCheckpoint); + assertEqualStrings( + afterObjects.map(({ key }) => key), + beforeObjects.map(({ key }) => key), + "A no-op post-upgrade Object Storage synchronisation changed the journal object set." + ); + if (observation.downloadedJournalKeys.length > 0) { + throw new Error( + `The no-op synchronisation downloaded previously processed journals: ${observation.downloadedJournalKeys.join(", ")}` + ); + } + if (observation.uploadedJournalKeys.length > 0) { + throw new Error( + `The no-op synchronisation uploaded replay journals: ${observation.uploadedJournalKeys.join(", ")}` + ); + } +} + +export function assertJournalCheckpointAdvanced( + before: JournalCheckpointSnapshot, + after: JournalCheckpointSnapshot, + observation: JournalIoObservation +): void { + assertJournalCheckpointLoaded(before, after); + const beforeSequence = sequenceNumber(before.lastLocalSeq); + const afterSequence = sequenceNumber(after.lastLocalSeq); + if (beforeSequence === undefined || afterSequence === undefined || afterSequence <= beforeSequence) { + throw new Error( + `The Object Storage checkpoint did not advance after the post-upgrade change (${String(before.lastLocalSeq)} -> ${String(after.lastLocalSeq)}).` + ); + } + if (observation.uploadedJournalKeys.length === 0) { + throw new Error("The post-upgrade Object Storage change did not create a new journal."); + } +} + +export function assertMilestoneContinuity(before: MilestoneIdentity, after: MilestoneIdentity): void { + if (before.created === undefined || before.created === null) { + throw new Error("The stable release milestone does not expose a remote generation identity."); + } + if (after.created !== before.created) { + throw new Error(`The remote milestone generation changed from ${String(before.created)} to ${String(after.created)}.`); + } + if (after.locked !== before.locked) { + throw new Error(`The remote milestone lock changed from ${String(before.locked)} to ${String(after.locked)}.`); + } + assertSubset(before.acceptedNodes, after.acceptedNodes, "The remote milestone lost an accepted device"); +} diff --git a/test/e2e-obsidian/runner/upgradeWorkflow.test.ts b/test/e2e-obsidian/runner/upgradeWorkflow.test.ts new file mode 100644 index 00000000..efe4d3da --- /dev/null +++ b/test/e2e-obsidian/runner/upgradeWorkflow.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { evalObsidianJson } = vi.hoisted(() => ({ + evalObsidianJson: vi.fn(), +})); + +vi.mock("./cli.ts", () => ({ evalObsidianJson })); + +import { prepareStableRemote } from "./upgradeWorkflow.ts"; + +describe("stable remote preparation", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); + }); + + it("waits for a readable Security Seed before marking the remote as resolved", async () => { + vi.stubEnv("E2E_OBSIDIAN_REMOTE_READY_INTERVAL_MS", "0"); + vi.stubEnv("E2E_OBSIDIAN_REMOTE_READY_TIMEOUT_MS", "100"); + evalObsidianJson + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce({ ok: true }); + + await prepareStableRemote("obsidian-cli", {}); + + expect(evalObsidianJson).toHaveBeenCalledTimes(4); + const scripts = evalObsidianJson.mock.calls.map(([, script]) => String(script)); + expect(scripts[0]).toContain("tryCreateRemoteDatabase"); + expect(scripts[0]).not.toContain("markRemoteResolved"); + expect(scripts[1]).toContain("ensurePBKDF2Salt"); + expect(scripts[2]).toContain("ensurePBKDF2Salt"); + expect(scripts[3]).toContain("markRemoteResolved"); + }); +}); diff --git a/test/e2e-obsidian/runner/upgradeWorkflow.ts b/test/e2e-obsidian/runner/upgradeWorkflow.ts new file mode 100644 index 00000000..76b6c7aa --- /dev/null +++ b/test/e2e-obsidian/runner/upgradeWorkflow.ts @@ -0,0 +1,874 @@ +import { mkdir, readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { evalObsidianJson } from "./cli.ts"; +import type { CouchDbConfig } from "./couchdb.ts"; +import type { ObjectStorageConfig } from "./objectStorage.ts"; +import { withObsidianPage } from "./ui.ts"; +import type { + CouchDbCheckpointSnapshot, + JournalCheckpointSnapshot, + JournalIoObservation, +} from "./upgradeContinuity.ts"; +import { waitForLocalDatabaseEntry } from "./liveSyncWorkflow.ts"; +import type { TemporaryVault } from "./vault.ts"; + +export const STABLE_RELEASE_VERSION = "0.25.83"; + +export type UpgradeTransportConfiguration = + | { + kind: "couchdb"; + config: CouchDbConfig; + databaseName: string; + } + | { + kind: "object-storage"; + config: ObjectStorageConfig; + bucketPrefix: string; + }; + +export type UpgradeScenarioPaths = { + original: string; + renamed: string; + deleted: string; + postUpgrade: string; + returnFromVerifier: string; +}; + +export type RuntimeUpgradeState = { + pluginVersion: string; + vaultName: string; + localDatabaseName: string; + localDatabaseUpdateSequence: number | string; + localDatabaseDocumentCount: number; + nodeId: string; + legacyCompatibilityMarker: string | null; + compatibilityMarker: string; + compatibilityStorageEntries: Record; + migrationState?: { + sourceVersion: number; + targetVersion: number; + isNewVault: boolean; + isFromFutureSchema: boolean; + changed: boolean; + requiresSyncReview: boolean; + reviewReasons: Array<{ code: string; fromVersion: number; toVersion: number }>; + }; + settings: { + isConfigured: boolean | undefined; + settingVersion: number; + versionUpFlash: string; + liveSync: boolean; + syncOnStart: boolean; + syncOnSave: boolean; + syncOnEditorSave: boolean; + syncOnFileOpen: boolean; + syncAfterMerge: boolean; + periodicReplication: boolean; + encrypt: boolean; + usePathObfuscation: boolean; + syncInternalFiles: boolean; + customChunkSize: number; + usePluginSyncV2: boolean; + enableCompression: boolean; + useEden: boolean; + filenameCaseType: string; + handleFilenameCaseSensitive?: boolean; + doNotUseFixedRevisionForChunks: boolean; + chunkSplitterVersion: string; + E2EEAlgorithm: string; + additionalSuffixOfDatabaseName: string; + remoteType: string; + couchDB_DBNAME: string; + endpoint: string; + bucket: string; + bucketPrefix: string; + activeConfigurationId: string; + remoteConfigurationIds: string[]; + doctorProcessedVersion: string; + }; +}; + +export type RuntimeSettingsUpgradeState = Pick & { + compatibilityMarker: string; +}; + +export type CouchDbReplicationObservation = { + succeeded: boolean; + sentDocuments: number; + arrivedDocuments: number; +}; + +export type JournalReplicationObservation = JournalIoObservation & { + succeeded: boolean; +}; + +const firstContent = "# Stable release history\n\nCreated before the 1.0 upgrade.\n"; +const editedContent = "# Stable release history\n\nEdited and renamed before the 1.0 upgrade.\n"; +const deletedContent = "# Deleted before upgrade\n\nThis note must not be resurrected.\n"; +const postUpgradeContent = "# Post-upgrade delta\n\nCreated by the upgraded 1.0 device.\n"; +const returnContent = "# Return journey\n\nCreated by a fresh 1.0 verifier device.\n"; + +function assertEqual(actual: unknown, expected: unknown, message: string): void { + if (actual !== expected) { + throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`); + } +} + +function assertStringArraysEqual(actual: readonly string[], expected: readonly string[], message: string): void { + const actualSorted = [...actual].sort(); + const expectedSorted = [...expected].sort(); + if (JSON.stringify(actualSorted) !== JSON.stringify(expectedSorted)) { + throw new Error( + `${message}\nExpected: ${JSON.stringify(expectedSorted)}\nActual: ${JSON.stringify(actualSorted)}` + ); + } +} + +export function createUpgradeScenarioPaths(label: string): UpgradeScenarioPaths { + const root = `E2E/upgrade-from-${STABLE_RELEASE_VERSION}/${label}`; + return { + original: `${root}/rename-source.md`, + renamed: `${root}/renamed.md`, + deleted: `${root}/deleted.md`, + postUpgrade: `${root}/post-upgrade.md`, + returnFromVerifier: `${root}/return-from-verifier.md`, + }; +} + +function remoteSettings(configuration: UpgradeTransportConfiguration): Record { + if (configuration.kind === "couchdb") { + return { + remoteType: "", + couchDB_URI: configuration.config.uri, + couchDB_USER: configuration.config.username, + couchDB_PASSWORD: configuration.config.password, + couchDB_DBNAME: configuration.databaseName, + isConfigured: true, + }; + } + return { + remoteType: "MINIO", + endpoint: configuration.config.endpoint, + accessKey: configuration.config.accessKey, + secretKey: configuration.config.secretKey, + bucket: configuration.config.bucket, + region: configuration.config.region, + forcePathStyle: configuration.config.forcePathStyle, + bucketPrefix: configuration.bucketPrefix, + bucketCustomHeaders: "", + isConfigured: true, + }; +} + +export async function configureStableRelease( + cliBinary: string, + environment: NodeJS.ProcessEnv, + configuration: UpgradeTransportConfiguration +): Promise { + const partial = remoteSettings(configuration); + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + `const partial=${JSON.stringify(partial)};`, + "await core.services.setting.applyExternalSettings(partial,true);", + "await core.services.control.applySettings();", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + environment + ); +} + +export async function prepareStableRemote(cliBinary: string, environment: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const settings=core.services.setting.currentSettings();", + "const replicator=core.services.replicator.getActiveReplicator();", + "await replicator.tryCreateRemoteDatabase(settings);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + environment + ); + + const timeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_READY_TIMEOUT_MS ?? 15000); + const intervalMs = Number(process.env.E2E_OBSIDIAN_REMOTE_READY_INTERVAL_MS ?? 250); + const deadline = Date.now() + timeoutMs; + let securitySeedReady = false; + do { + securitySeedReady = await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const settings=core.services.setting.currentSettings();", + "const replicator=core.services.replicator.getActiveReplicator();", + "return JSON.stringify(!!(await replicator.ensurePBKDF2Salt(settings,true,false)));", + "})()", + ].join(""), + environment + ); + if (securitySeedReady) break; + if (Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } while (Date.now() < deadline); + if (!securitySeedReady) { + throw new Error(`Timed out waiting for the stable release Security Seed after ${timeoutMs}ms.`); + } + + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const settings=core.services.setting.currentSettings();", + "const replicator=core.services.replicator.getActiveReplicator();", + "await replicator.markRemoteResolved(settings);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + environment + ); +} + +export async function waitForPersistentNodeIdentity( + cliBinary: string, + environment: NodeJS.ProcessEnv, + timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000) +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const deadline=Date.now()+${JSON.stringify(timeoutMs)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const database=core.localDatabase.localDatabase;", + "const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));", + "let persistent='';let active='';", + "while(Date.now()null);", + "persistent=typeof nodeInfo?.nodeid==='string'?nodeInfo.nodeid:'';", + "active=core.services.replicator.getActiveReplicator()?.nodeid??'';", + "if(persistent!==''&&active===persistent) return JSON.stringify(persistent);", + "await sleep(100);", + "}", + "throw new Error(`Timed out waiting for persistent node identity: persistent=${persistent}, active=${active}`);", + "})()", + ].join(""), + environment + ); +} + +export async function readRuntimeUpgradeState( + cliBinary: string, + environment: NodeJS.ProcessEnv +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const plugin=app.plugins.plugins['obsidian-livesync'];", + "const core=plugin.core;", + "const setting=core.services.setting;", + "const settings=setting.currentSettings();", + "const vaultName=core.services.vault.getVaultName();", + "const replicator=core.services.replicator.getActiveReplicator();", + "const databaseInfo=await core.localDatabase.localDatabase.info();", + "const nodeInfo=await core.localDatabase.localDatabase.get('_local/obsydian_livesync_nodeinfo').catch(()=>null);", + "const migrationState=setting.getSettingsMigrationState?.();", + "return JSON.stringify({", + "pluginVersion:app.plugins.manifests['obsidian-livesync']?.version??'unknown',", + "vaultName,", + "localDatabaseName:databaseInfo.db_name,", + "localDatabaseUpdateSequence:databaseInfo.update_seq,", + "localDatabaseDocumentCount:databaseInfo.doc_count,", + "nodeId:nodeInfo?.nodeid??replicator?.nodeid??'',", + "legacyCompatibilityMarker:localStorage.getItem(`obsidian-live-sync-ver${vaultName}`),", + "compatibilityMarker:setting.getSmallConfig('database-compatibility-version')??'',", + "compatibilityStorageEntries:Object.fromEntries(Array.from({length:localStorage.length},(_,index)=>localStorage.key(index))", + ".filter((key)=>key!==null)", + ".filter(key=>key.startsWith('obsidian-live-sync-ver')||key.endsWith('-database-compatibility-version'))", + ".map(key=>[key,localStorage.getItem(key)??''])),", + "migrationState,", + "settings:{", + "isConfigured:settings.isConfigured,settingVersion:settings.settingVersion,", + "versionUpFlash:settings.versionUpFlash,", + "liveSync:settings.liveSync,syncOnStart:settings.syncOnStart,syncOnSave:settings.syncOnSave,", + "syncOnEditorSave:settings.syncOnEditorSave,syncOnFileOpen:settings.syncOnFileOpen,", + "syncAfterMerge:settings.syncAfterMerge,periodicReplication:settings.periodicReplication,", + "encrypt:settings.encrypt,usePathObfuscation:settings.usePathObfuscation,", + "syncInternalFiles:settings.syncInternalFiles,customChunkSize:settings.customChunkSize,", + "usePluginSyncV2:settings.usePluginSyncV2,enableCompression:settings.enableCompression,", + "useEden:settings.useEden,filenameCaseType:typeof settings.handleFilenameCaseSensitive,", + "handleFilenameCaseSensitive:settings.handleFilenameCaseSensitive,", + "doNotUseFixedRevisionForChunks:settings.doNotUseFixedRevisionForChunks,", + "chunkSplitterVersion:settings.chunkSplitterVersion,E2EEAlgorithm:settings.E2EEAlgorithm,", + "additionalSuffixOfDatabaseName:settings.additionalSuffixOfDatabaseName??'',", + "remoteType:settings.remoteType,couchDB_DBNAME:settings.couchDB_DBNAME??'',", + "endpoint:settings.endpoint??'',bucket:settings.bucket??'',bucketPrefix:settings.bucketPrefix??'',", + "activeConfigurationId:settings.activeConfigurationId??'',", + "remoteConfigurationIds:Object.keys(settings.remoteConfigurations??{}),", + "doctorProcessedVersion:settings.doctorProcessedVersion??'',", + "}", + "});", + "})()", + ].join(""), + environment + ); +} + +export async function readRuntimeSettingsUpgradeState( + cliBinary: string, + environment: NodeJS.ProcessEnv +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const plugin=app.plugins.plugins['obsidian-livesync'];", + "const setting=plugin.core.services.setting;", + "const settings=setting.currentSettings();", + "const migrationState=setting.getSettingsMigrationState?.();", + "return JSON.stringify({", + "pluginVersion:app.plugins.manifests['obsidian-livesync']?.version??'unknown',", + "compatibilityMarker:setting.getSmallConfig?.('database-compatibility-version')??'',", + "migrationState,", + "settings:{", + "isConfigured:settings.isConfigured,settingVersion:settings.settingVersion,", + "versionUpFlash:settings.versionUpFlash,", + "liveSync:settings.liveSync,syncOnStart:settings.syncOnStart,syncOnSave:settings.syncOnSave,", + "syncOnEditorSave:settings.syncOnEditorSave,syncOnFileOpen:settings.syncOnFileOpen,", + "syncAfterMerge:settings.syncAfterMerge,periodicReplication:settings.periodicReplication,", + "encrypt:settings.encrypt,usePathObfuscation:settings.usePathObfuscation,", + "syncInternalFiles:settings.syncInternalFiles,customChunkSize:settings.customChunkSize,", + "usePluginSyncV2:settings.usePluginSyncV2,enableCompression:settings.enableCompression,", + "useEden:settings.useEden,filenameCaseType:typeof settings.handleFilenameCaseSensitive,", + "handleFilenameCaseSensitive:settings.handleFilenameCaseSensitive,", + "doNotUseFixedRevisionForChunks:settings.doNotUseFixedRevisionForChunks,", + "chunkSplitterVersion:settings.chunkSplitterVersion,E2EEAlgorithm:settings.E2EEAlgorithm,", + "additionalSuffixOfDatabaseName:settings.additionalSuffixOfDatabaseName??'',", + "remoteType:settings.remoteType,couchDB_DBNAME:settings.couchDB_DBNAME??'',", + "endpoint:settings.endpoint??'',bucket:settings.bucket??'',bucketPrefix:settings.bucketPrefix??'',", + "activeConfigurationId:settings.activeConfigurationId??'',", + "remoteConfigurationIds:Object.keys(settings.remoteConfigurations??{}),", + "doctorProcessedVersion:settings.doctorProcessedVersion??'',", + "}", + "});", + "})()", + ].join(""), + environment + ); +} + +export function assertStableReleaseDefaults(state: RuntimeSettingsUpgradeState, configured: boolean): void { + assertEqual( + state.pluginVersion, + STABLE_RELEASE_VERSION, + "The source session did not load the pinned stable release." + ); + assertEqual(state.settings.isConfigured, configured, "The stable release configuration lifecycle was unexpected."); + assertEqual(state.settings.settingVersion, 10, "The stable release settings schema was not version 10."); + assertEqual(state.settings.liveSync, false, "The stable release LiveSync default changed."); + assertEqual(state.settings.syncOnStart, false, "The stable release sync-on-start default changed."); + assertEqual(state.settings.syncOnSave, false, "The stable release sync-on-save default changed."); + assertEqual(state.settings.syncOnEditorSave, false, "The stable release editor-save default changed."); + assertEqual(state.settings.syncOnFileOpen, false, "The stable release file-open default changed."); + assertEqual(state.settings.syncAfterMerge, false, "The stable release post-merge default changed."); + assertEqual(state.settings.periodicReplication, false, "The stable release periodic default changed."); + assertEqual(state.settings.encrypt, false, "The stable release encryption default changed."); + assertEqual(state.settings.usePathObfuscation, false, "The stable release path-obfuscation default changed."); + assertEqual(state.settings.syncInternalFiles, false, "The stable release Hidden File default changed."); + assertEqual(state.settings.customChunkSize, 0, "The stable release custom chunk default changed."); + assertEqual(state.settings.usePluginSyncV2, false, "The stable release Customisation Sync V2 default changed."); + assertEqual(state.settings.enableCompression, false, "The stable release compression default changed."); + assertEqual(state.settings.useEden, false, "The stable release Eden default changed."); + assertEqual( + state.settings.filenameCaseType, + "undefined", + "The stable release filename-case decision was preselected." + ); + assertEqual( + state.settings.doNotUseFixedRevisionForChunks, + true, + "The stable release fixed-revision compatibility value changed." + ); + assertEqual(state.settings.chunkSplitterVersion, "v3-rabin-karp", "The stable release chunk splitter changed."); + assertEqual(state.settings.E2EEAlgorithm, "v2", "The stable release E2EE algorithm changed."); + assertEqual( + state.settings.remoteConfigurationIds.length, + configured ? 1 : 0, + "The stable release remote-profile count was unexpected." + ); +} + +export function assertUnconfiguredUpgradeReady( + stable: RuntimeSettingsUpgradeState, + upgraded: RuntimeSettingsUpgradeState, + targetVersion: string +): void { + assertStableReleaseDefaults(stable, false); + assertEqual(upgraded.pluginVersion, targetVersion, "The unconfigured Vault did not load the target artefact."); + assertEqual(upgraded.settings.isConfigured, false, "The upgrade changed an unconfigured Vault to configured."); + assertEqual( + upgraded.settings.usePluginSyncV2, + stable.settings.usePluginSyncV2, + "The upgrade applied a new-Vault recommendation to a non-empty legacy store." + ); + assertEqual( + upgraded.settings.handleFilenameCaseSensitive, + false, + "The unconfigured legacy Vault did not retain case-insensitive handling." + ); + assertEqual(upgraded.settings.versionUpFlash, "", "The unconfigured Vault was paused for compatibility review."); + assertEqual( + upgraded.compatibilityMarker, + "", + "The unconfigured Vault acknowledged database compatibility before activation." + ); + if (!upgraded.migrationState) throw new Error("The unconfigured settings migration state was not available."); + assertEqual(upgraded.migrationState.isNewVault, false, "The non-empty legacy store was treated as a new store."); + // The real-session helper deliberately reloads an already enabled plug-in. + // The first target load performs and persists the migration; the observed + // post-reload state can therefore report changed=false. The workflow reads + // data.json after stopping the session to prove the persisted values. + assertEqual( + upgraded.migrationState.requiresSyncReview, + false, + "The unconfigured legacy settings unexpectedly required compatibility review." + ); + assertEqual(upgraded.migrationState.reviewReasons.length, 0, "The unconfigured migration emitted a review reason."); +} + +export function assertUnconfiguredUpgradeRestarted(state: RuntimeSettingsUpgradeState, targetVersion: string): void { + assertEqual(state.pluginVersion, targetVersion, "The unconfigured restart did not load the target artefact."); + assertEqual(state.settings.isConfigured, false, "The unconfigured state was not persisted across restart."); + assertEqual(state.settings.usePluginSyncV2, false, "Restart applied a new-Vault recommendation."); + assertEqual(state.settings.handleFilenameCaseSensitive, false, "Restart lost the case-insensitive policy."); + assertEqual( + state.compatibilityMarker, + "", + "Restart acknowledged database compatibility while the Vault remained unconfigured." + ); + if (!state.migrationState) throw new Error("The restarted settings migration state was not available."); + assertEqual(state.migrationState.changed, false, "The settings migration was not idempotent after restart."); + assertEqual(state.migrationState.requiresSyncReview, false, "Restart introduced a compatibility review."); +} + +export function assertStableRemoteSelection( + state: RuntimeUpgradeState, + configuration: UpgradeTransportConfiguration +): void { + assertEqual(state.settings.isConfigured, true, "The stable release was not marked as configured."); + if (!state.settings.activeConfigurationId) throw new Error("The stable release did not select its remote profile."); + if (!state.settings.remoteConfigurationIds.includes(state.settings.activeConfigurationId)) { + throw new Error("The stable release active remote profile was not persisted."); + } + if (configuration.kind === "couchdb") { + assertEqual(state.settings.remoteType, "", "The stable release did not select CouchDB."); + assertEqual( + state.settings.couchDB_DBNAME, + configuration.databaseName, + "The stable release CouchDB database changed." + ); + } else { + assertEqual(state.settings.remoteType, "MINIO", "The stable release did not select Object Storage."); + assertEqual(state.settings.endpoint, configuration.config.endpoint, "The Object Storage endpoint changed."); + assertEqual(state.settings.bucket, configuration.config.bucket, "The Object Storage bucket changed."); + assertEqual(state.settings.bucketPrefix, configuration.bucketPrefix, "The Object Storage prefix changed."); + } +} + +export function assertUpgradeCompatibilityReady( + stable: RuntimeUpgradeState, + upgraded: RuntimeUpgradeState, + targetVersion: string, + configuration: UpgradeTransportConfiguration +): void { + assertEqual(upgraded.pluginVersion, targetVersion, "The upgraded session did not load the target artefact."); + assertEqual( + upgraded.localDatabaseName, + stable.localDatabaseName, + "The upgrade opened a different local synchronisation database." + ); + if (upgraded.localDatabaseDocumentCount < stable.localDatabaseDocumentCount) { + throw new Error("The upgrade lost local synchronisation documents before its first sync."); + } + if (stable.nodeId.length === 0) { + throw new Error("The stable release did not persist a device node identity."); + } + assertEqual(upgraded.nodeId, stable.nodeId, "The upgrade changed the persistent device node identity."); + assertEqual( + upgraded.settings.additionalSuffixOfDatabaseName, + stable.settings.additionalSuffixOfDatabaseName, + "The upgrade changed the local database suffix." + ); + assertStringArraysEqual( + upgraded.settings.remoteConfigurationIds, + stable.settings.remoteConfigurationIds, + "The upgrade changed the stored remote-profile identities." + ); + assertEqual( + upgraded.settings.activeConfigurationId, + stable.settings.activeConfigurationId, + "The upgrade changed the active remote profile." + ); + assertStableRemoteSelection(upgraded, configuration); + for (const key of [ + "liveSync", + "syncOnStart", + "syncOnSave", + "syncOnEditorSave", + "syncOnFileOpen", + "syncAfterMerge", + "periodicReplication", + "customChunkSize", + "usePluginSyncV2", + "enableCompression", + "useEden", + "doNotUseFixedRevisionForChunks", + ] as const) { + assertEqual(upgraded.settings[key], stable.settings[key], `The upgrade rewrote the stored ${key} preference.`); + } + if (!upgraded.migrationState) throw new Error("The 1.0 settings migration state was not available."); + // The session helper reloads the enabled target after its first load has + // persisted the normalised case value. Runtime settings below and the + // later restart prove that persisted result without depending on whether + // this observation came from the first or second load. + assertEqual( + upgraded.migrationState.requiresSyncReview, + false, + "The legacy case-insensitive setting unexpectedly required compatibility review." + ); + assertEqual(upgraded.migrationState.reviewReasons.length, 0, "The settings migration emitted a spurious review."); + assertEqual(stable.legacyCompatibilityMarker, "12", "The stable release did not persist its legacy marker."); + assertEqual(upgraded.legacyCompatibilityMarker, null, "The upgrade did not retire the legacy marker."); + assertEqual( + upgraded.compatibilityMarker, + "12", + [ + "The upgrade did not migrate the legacy compatibility marker.", + `Vault: ${upgraded.vaultName}`, + `Database suffix: ${upgraded.settings.additionalSuffixOfDatabaseName}`, + `Device-local entries: ${JSON.stringify(upgraded.compatibilityStorageEntries)}`, + ].join("\n") + ); + assertEqual(upgraded.settings.versionUpFlash, "", "Synchronisation was unexpectedly paused after migration."); + assertEqual( + upgraded.settings.handleFilenameCaseSensitive, + false, + "The missing legacy filename-case value did not preserve case-insensitive handling." + ); +} + +export function assertUpgradeRemainsReady(state: RuntimeUpgradeState, targetVersion: string): void { + assertEqual(state.pluginVersion, targetVersion, "The upgraded session changed target artefact."); + assertEqual(state.settings.versionUpFlash, "", "A compatibility pause reappeared."); + assertEqual(state.compatibilityMarker, "12", "The compatibility acknowledgement was not persisted."); + assertEqual( + state.settings.handleFilenameCaseSensitive, + false, + "The migrated legacy case-insensitive policy was not persisted." + ); +} + +export async function dismissConfigDoctorIfShown(port: number): Promise { + const timeoutMs = Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000); + return await withObsidianPage(port, async (page) => { + const doctor = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Self-hosted LiveSync Config Doctor" }), + }); + const visible = await doctor + .waitFor({ state: "visible", timeout: Math.min(timeoutMs, 5000) }) + .then(() => true) + .catch(() => false); + if (!visible) return false; + await doctor.getByRole("button", { name: /No, and do not ask again/u }).click(); + await doctor.waitFor({ state: "hidden", timeout: timeoutMs }); + return true; + }); +} + +async function writeNote( + cliBinary: string, + environment: NodeJS.ProcessEnv, + path: string, + content: string +): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const content=${JSON.stringify(content)};`, + "const folder=path.split('/').slice(0,-1).join('/');", + "if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);", + "const existing=app.vault.getAbstractFileByPath(path);", + "if(existing) await app.vault.modify(existing,content); else await app.vault.create(path,content);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + environment + ); +} + +async function renameNote( + cliBinary: string, + environment: NodeJS.ProcessEnv, + fromPath: string, + toPath: string +): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const fromPath=${JSON.stringify(fromPath)};`, + `const toPath=${JSON.stringify(toPath)};`, + "const folder=toPath.split('/').slice(0,-1).join('/');", + "if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);", + "const existing=app.vault.getAbstractFileByPath(fromPath);", + "if(!existing) throw new Error(`Could not find note to rename: ${fromPath}`);", + "await app.vault.rename(existing,toPath);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + environment + ); +} + +async function deleteNote(cliBinary: string, environment: NodeJS.ProcessEnv, path: string): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + "const existing=app.vault.getAbstractFileByPath(path);", + "if(!existing) throw new Error(`Could not find note to delete: ${path}`);", + "await app.vault.delete(existing);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + environment + ); +} + +async function waitForChangedRevision( + cliBinary: string, + environment: NodeJS.ProcessEnv, + path: string, + previousRevision: string +): Promise { + const timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000); + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const previousRevision=${JSON.stringify(previousRevision)};`, + `const deadline=Date.now()+${JSON.stringify(timeoutMs)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));", + "while(Date.now()false);", + "if(entry&&entry._rev&&entry._rev!==previousRevision) return JSON.stringify({rev:entry._rev});", + "await sleep(250);", + "}", + "throw new Error(`Timed out waiting for a changed local revision: ${path}`);", + "})()", + ].join(""), + environment + ); +} + +export async function runStableFileHistory( + cliBinary: string, + environment: NodeJS.ProcessEnv, + paths: UpgradeScenarioPaths, + synchronise: () => Promise +): Promise { + await writeNote(cliBinary, environment, paths.original, firstContent); + await writeNote(cliBinary, environment, paths.deleted, deletedContent); + const originalEntry = await waitForLocalDatabaseEntry(cliBinary, environment, paths.original); + await waitForLocalDatabaseEntry(cliBinary, environment, paths.deleted); + await synchronise(); + + await writeNote(cliBinary, environment, paths.original, editedContent); + await waitForChangedRevision(cliBinary, environment, paths.original, originalEntry.rev); + await synchronise(); + + await renameNote(cliBinary, environment, paths.original, paths.renamed); + await waitForLocalDatabaseEntry(cliBinary, environment, paths.renamed); + await synchronise(); + + await deleteNote(cliBinary, environment, paths.deleted); + await synchronise(); +} + +export async function createPostUpgradeDelta( + cliBinary: string, + environment: NodeJS.ProcessEnv, + paths: UpgradeScenarioPaths +): Promise { + await writeNote(cliBinary, environment, paths.postUpgrade, postUpgradeContent); + await waitForLocalDatabaseEntry(cliBinary, environment, paths.postUpgrade); +} + +export async function createVerifierReturnDelta( + cliBinary: string, + environment: NodeJS.ProcessEnv, + paths: UpgradeScenarioPaths +): Promise { + await writeNote(cliBinary, environment, paths.returnFromVerifier, returnContent); + await waitForLocalDatabaseEntry(cliBinary, environment, paths.returnFromVerifier); +} + +async function pathExists(vault: TemporaryVault, path: string): Promise { + try { + await readFile(join(vault.path, path)); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +async function waitForPathContent(vault: TemporaryVault, path: string, content: string): Promise { + const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000); + let lastContent = ""; + while (Date.now() < deadline) { + try { + lastContent = await readFile(join(vault.path, path), "utf8"); + if (lastContent === content) return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`); +} + +export async function verifyPreUpgradeHistory(vault: TemporaryVault, paths: UpgradeScenarioPaths): Promise { + await waitForPathContent(vault, paths.renamed, editedContent); + if (await pathExists(vault, paths.original)) throw new Error(`Renamed source was resurrected: ${paths.original}`); + if (await pathExists(vault, paths.deleted)) throw new Error(`Deleted note was resurrected: ${paths.deleted}`); +} + +export async function verifyPostUpgradeHistory(vault: TemporaryVault, paths: UpgradeScenarioPaths): Promise { + await verifyPreUpgradeHistory(vault, paths); + await waitForPathContent(vault, paths.postUpgrade, postUpgradeContent); +} + +export async function verifyReturnDelta(vault: TemporaryVault, paths: UpgradeScenarioPaths): Promise { + await waitForPathContent(vault, paths.returnFromVerifier, returnContent); +} + +export async function ensureScenarioDirectory(vault: TemporaryVault, paths: UpgradeScenarioPaths): Promise { + await mkdir(dirname(join(vault.path, paths.original)), { recursive: true }); +} + +export async function runCouchDbReplicationObserved( + cliBinary: string, + environment: NodeJS.ProcessEnv +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const replicator=core.services.replicator.getActiveReplicator();", + "await core.services.fileProcessing.commitPendingFileEvents();", + "const beforeSent=Number(replicator.docSent??0);", + "const beforeArrived=Number(replicator.docArrived??0);", + "const result=await core.services.replication.replicate(true);", + "return JSON.stringify({", + "succeeded:!!result,", + "sentDocuments:Number(replicator.docSent??0)-beforeSent,", + "arrivedDocuments:Number(replicator.docArrived??0)-beforeArrived,", + "});", + "})()", + ].join(""), + environment + ); +} + +export async function runJournalReplicationObserved( + cliBinary: string, + environment: NodeJS.ProcessEnv +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const replicator=core.services.replicator.getActiveReplicator();", + "const client=replicator.client;", + "const storage=client.storage;", + "const originalDownload=storage.download.bind(storage);", + "const originalUpload=storage.upload.bind(storage);", + "const downloadedJournalKeys=[];const uploadedJournalKeys=[];", + "const isJournal=(key)=>!String(key).split('/').pop().startsWith('_');", + "storage.download=async(key,...args)=>{if(isJournal(key))downloadedJournalKeys.push(String(key));return await originalDownload(key,...args);};", + "storage.upload=async(key,...args)=>{if(isJournal(key))uploadedJournalKeys.push(String(key));return await originalUpload(key,...args);};", + "let succeeded=false;", + "try{", + "await core.services.fileProcessing.commitPendingFileEvents();", + "succeeded=!!(await core.services.replication.replicate(true));", + "}finally{storage.download=originalDownload;storage.upload=originalUpload;}", + "return JSON.stringify({succeeded,downloadedJournalKeys,uploadedJournalKeys});", + "})()", + ].join(""), + environment + ); +} + +export async function readJournalCheckpoint( + cliBinary: string, + environment: NodeJS.ProcessEnv +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const replicator=core.services.replicator.getActiveReplicator();", + "const client=replicator.client;", + "const checkpoint=await client.getCheckpointInfo();", + "const sorted=(value)=>[...(value??[])].sort();", + "return JSON.stringify({", + "remoteKey:client.getRemoteKey(),lastLocalSeq:checkpoint.lastLocalSeq,journalEpoch:checkpoint.journalEpoch,", + "knownIDs:sorted(checkpoint.knownIDs),sentIDs:sorted(checkpoint.sentIDs),", + "receivedFiles:sorted(checkpoint.receivedFiles),sentFiles:sorted(checkpoint.sentFiles),", + "});", + "})()", + ].join(""), + environment + ); +} + +export async function readLocalCouchDbCheckpoints( + cliBinary: string, + environment: NodeJS.ProcessEnv, + checkpointIds: readonly string[] +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const ids=${JSON.stringify(checkpointIds)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const database=core.localDatabase.localDatabase;", + "const checkpoints=[];", + "for(const id of ids){", + "const doc=await database.get(id).catch(()=>false);", + "if(doc&&Object.prototype.hasOwnProperty.call(doc,'last_seq')) checkpoints.push({id,lastSequence:doc.last_seq});", + "}", + "return JSON.stringify(checkpoints);", + "})()", + ].join(""), + environment + ); +} diff --git a/test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts b/test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts index b1edadef..d0a13a12 100644 --- a/test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts +++ b/test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts @@ -13,7 +13,8 @@ import { import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { assertEqual, - configureCouchDb, + createE2eCouchDbPluginData, + createE2eObsidianDeviceLocalState, prepareRemote, pushLocalChanges, waitForLiveSyncCoreReady, @@ -311,25 +312,23 @@ async function main(): Promise { cliBinary: obsidianCli.binary, vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: createE2eCouchDbPluginData( + { + uri: couchDb.uri, + username: couchDb.username, + password: couchDb.password, + dbName, + }, + { + encrypt: true, + passphrase: e2eePassphrase, + usePathObfuscation: true, + E2EEAlgorithm: "v2", + } + ), + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), }); await waitForLiveSyncCoreReady(obsidianCli.binary, session.cliEnv); - await configureCouchDb( - obsidianCli.binary, - session.cliEnv, - { - uri: couchDb.uri, - username: couchDb.username, - password: couchDb.password, - dbName, - }, - { - encrypt: true, - passphrase: e2eePassphrase, - usePathObfuscation: true, - E2EEAlgorithm: "v2", - } - ); - await waitForLiveSyncCoreReady(obsidianCli.binary, session.cliEnv); await prepareRemote(obsidianCli.binary, session.cliEnv); await pushLocalChanges(obsidianCli.binary, session.cliEnv); diff --git a/test/e2e-obsidian/scripts/conflict-dialog-policy.ts b/test/e2e-obsidian/scripts/conflict-dialog-policy.ts new file mode 100644 index 00000000..254256bc --- /dev/null +++ b/test/e2e-obsidian/scripts/conflict-dialog-policy.ts @@ -0,0 +1,434 @@ +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { + createE2eObsidianDeviceLocalState, + waitForLiveSyncCoreReady, + waitForLocalDatabaseEntry, +} from "../runner/liveSyncWorkflow.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { captureObsidianElement, withObsidianPage } from "../runner/ui.ts"; +import { createTemporaryVault } from "../runner/vault.ts"; + +const path = "conflict-dialog-policy.md"; +const baseContent = "Conflict dialogue policy\n\nShared base.\n"; +const leftContent = "Conflict dialogue policy\n\nChanged on the left.\n"; +const rightContent = "Conflict dialogue policy\n\nChanged on the right.\n"; +const thirdContent = "Conflict dialogue policy\n\nChanged on the third branch.\n"; +const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_CONFLICT_DIALOG_TIMEOUT_MS ?? 10000); + +type ConflictFixture = { + currentRev: string; + currentParentRev?: string; + conflicts: string[]; +}; + +type ObsidianTestApp = { + commands?: { executeCommandById(commandId: string): boolean }; +}; + +type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp }; + +async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const content=${JSON.stringify(baseContent)};`, + "let file=app.vault.getAbstractFileByPath(path);", + "if(!file) file=await app.vault.create(path,content);", + "await app.workspace.getLeaf(false).openFile(file);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); +} + +async function createManualConflict( + cliBinary: string, + env: NodeJS.ProcessEnv, + baseRev: string, + contents: readonly string[] +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const baseRev=${JSON.stringify(baseRev)};`, + `const contents=${JSON.stringify(contents)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const id=await core.services.path.path2id(path);", + "for(const [index,content] of contents.entries()){", + " const blob=new Blob([content],{type:'text/plain'});", + " const now=Date.now()+index;", + " const result=await core.localDatabase.putDBEntry({", + " _id:id,path,data:blob,ctime:now,mtime:now,", + " size:(await blob.arrayBuffer()).byteLength,children:[],", + " datatype:'plain',type:'plain',eden:{},", + " },false,baseRev);", + " if(!result?.ok) throw new Error(`Could not create conflict branch: ${path}`);", + "}", + "const meta=await core.localDatabase.getDBEntryMeta(path,{conflicts:true},true);", + "if(!meta?._rev||!meta._conflicts?.length){", + " throw new Error(`Conflict fixture did not produce multiple live leaves: ${path}`);", + "}", + "return JSON.stringify({currentRev:meta._rev,conflicts:meta._conflicts});", + "})()", + ].join(""), + env + ); +} + +async function readConflictFixture(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const meta=await core.localDatabase.getDBEntryMeta(path,{conflicts:true,revs:true},true);", + "if(!meta?._rev){", + " throw new Error(`Could not read the conflict fixture: ${path}`);", + "}", + "const revisions=meta._revisions;", + "const currentParentRev=revisions?.ids?.length>1", + " ? `${revisions.start-1}-${revisions.ids[1]}`", + " : undefined;", + "return JSON.stringify({currentRev:meta._rev,currentParentRev,conflicts:meta._conflicts??[]});", + "})()", + ].join(""), + env + ); +} + +async function waitForConflictCount( + cliBinary: string, + env: NodeJS.ProcessEnv, + expectedConflictCount: number +): Promise { + const deadline = Date.now() + uiTimeoutMs; + let fixture = await readConflictFixture(cliBinary, env); + while (fixture.conflicts.length !== expectedConflictCount && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)); + fixture = await readConflictFixture(cliBinary, env); + } + if (fixture.conflicts.length !== expectedConflictCount) { + throw new Error( + `Expected ${expectedConflictCount + 1} live version(s), but found ${fixture.conflicts.length + 1}: ${JSON.stringify(fixture)}` + ); + } + return fixture; +} + +async function requestConflictCheck(cliBinary: string, env: NodeJS.ProcessEnv, waitForCompletion = false) { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const waitForCompletion=${JSON.stringify(waitForCompletion)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "await core.services.conflict.queueCheckFor(path);", + "if(waitForCompletion){", + " await core.services.conflict.ensureAllProcessed();", + "}", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); +} + +async function waitForConflictChecks(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "await core.services.conflict.ensureAllProcessed();", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); +} + +async function applyReplicatedConflictResolution( + cliBinary: string, + env: NodeJS.ProcessEnv, + revisionToDelete: string, + expectedConflictCount = 0 +): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const revisionToDelete=${JSON.stringify(revisionToDelete)};`, + `const expectedConflictCount=${JSON.stringify(expectedConflictCount)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "if(!(await core.fileHandler.deleteRevisionFromDB(path,revisionToDelete))){", + " throw new Error(`Could not apply the replicated conflict resolution: ${path} ${revisionToDelete}`);", + "}", + "const entry=await core.databaseFileAccess.fetchEntryMeta(path,undefined,true);", + "if(!entry){", + " throw new Error(`Could not read the surviving revision after replicated resolution: ${path}`);", + "}", + // This is the same Commonlib consumer boundary invoked after a remote + // document has already entered the local database. Calling it here + // isolates the dialogue policy from transport and second-device setup. + "await core.fileHandler._anyProcessReplicatedDoc(entry);", + "const conflicts=await core.databaseFileAccess.getConflictedRevs(path);", + "if(conflicts.length!==expectedConflictCount){", + " throw new Error(`Replicated resolution left an unexpected conflict count: ${path} ${JSON.stringify(conflicts)}`);", + "}", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); +} + +function conflictDialogue(page: Parameters[1]>[0]) { + return page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Conflicting changes" }), + }); +} + +async function main(): Promise { + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) { + throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); + } + const cliBinary = cli.binary; + + const vault = await createTemporaryVault("obsidian-livesync-conflict-dialog-"); + let session: ObsidianLiveSyncSession | undefined; + try { + session = await startObsidianLiveSyncSession({ + binary, + cliBinary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: { + doctorProcessedVersion: "1.0.0", + isConfigured: true, + liveSync: false, + remoteType: "", + couchDB_URI: "http://127.0.0.1:5984", + couchDB_DBNAME: "conflict-dialog-policy", + couchDB_USER: "", + couchDB_PASSWORD: "", + notifyThresholdOfRemoteStorageSize: -1, + periodicReplication: false, + syncAfterMerge: false, + syncOnEditorSave: false, + syncOnFileOpen: false, + syncOnSave: false, + syncOnStart: false, + disableMarkdownAutoMerge: true, + showMergeDialogOnlyOnActive: true, + showStatusOnEditor: true, + }, + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), + }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + await createAndOpenBaseFile(cliBinary, session.cliEnv); + const base = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path); + const fixture = await createManualConflict(cliBinary, session.cliEnv, base.rev, [ + leftContent, + rightContent, + thirdContent, + ]); + if (fixture.conflicts.length !== 2) { + throw new Error(`Expected exactly three live leaves: ${JSON.stringify(fixture)}`); + } + + await requestConflictCheck(cliBinary, session.cliEnv); + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const modal = conflictDialogue(page); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await page + .locator(".livesync-status-messagearea") + .filter({ + hasText: "This file has 3 unresolved versions. They will be reviewed one pair at a time.", + }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal.getByRole("button", { name: "Concat both", exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + const actionButtonBounds = await modal.locator(".conflict-action-button").evaluateAll((buttons) => + buttons.map((button) => { + const bounds = button.getBoundingClientRect(); + return { top: bounds.top, bottom: bounds.bottom }; + }) + ); + if ( + actionButtonBounds.length !== 4 || + actionButtonBounds.some( + (bounds, index) => index > 0 && bounds.top < actionButtonBounds[index - 1].bottom + ) + ) { + throw new Error( + `Conflict action buttons are not stacked vertically: ${JSON.stringify(actionButtonBounds)}` + ); + } + }); + const firstDialogueScreenshot = await captureObsidianElement( + session.remoteDebuggingPort, + "conflict-dialog-three-versions.png", + (page) => conflictDialogue(page).locator(".modal").first() + ); + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const modal = conflictDialogue(page); + await modal.getByRole("button", { name: "Concat both", exact: true }).click({ timeout: uiTimeoutMs }); + }); + + const remainingAfterConcatenation = await waitForConflictCount(cliBinary, session.cliEnv, 1); + if ( + remainingAfterConcatenation.currentRev === fixture.currentRev || + remainingAfterConcatenation.currentParentRev !== fixture.currentRev + ) { + throw new Error( + `Concatenation did not extend the compared winner before retaining the remaining branch: ${JSON.stringify( + { + before: fixture, + after: remainingAfterConcatenation, + } + )}` + ); + } + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const modal = conflictDialogue(page); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const warning = page.locator(".livesync-status-messagearea").filter({ + hasText: "This file has unresolved conflicts.", + }); + await warning.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal.getByRole("button", { name: "Not now", exact: true }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + const warningScreenshot = await captureObsidianElement( + session.remoteDebuggingPort, + "conflict-dialog-postponed-warning.png", + (page) => + page.locator(".livesync-status-messagearea").filter({ + hasText: "This file has unresolved conflicts.", + }) + ); + + await session.app.stop(); + session = undefined; + session = await startObsidianLiveSyncSession({ + binary, + cliBinary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + await createAndOpenBaseFile(cliBinary, session.cliEnv); + const remainingAfterRestart = await waitForConflictCount(cliBinary, session.cliEnv, 1); + + await requestConflictCheck(cliBinary, session.cliEnv); + const restartedSession = session; + await withObsidianPage(restartedSession.remoteDebuggingPort, async (page) => { + const modal = conflictDialogue(page); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await page + .locator(".livesync-status-messagearea") + .filter({ hasText: "This file has unresolved conflicts." }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await applyReplicatedConflictResolution( + cliBinary, + restartedSession.cliEnv, + remainingAfterRestart.conflicts[0] + ); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + await page + .locator(".livesync-status-messagearea") + .filter({ hasText: "This file has unresolved conflicts." }) + .waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + // End the replicated-resolution episode before creating another + // conflict at the same path. This prevents a late cancellation event + // from the first episode from closing the later episode's dialogue. + await session.app.stop(); + session = undefined; + session = await startObsidianLiveSyncSession({ + binary, + cliBinary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + await createAndOpenBaseFile(cliBinary, session.cliEnv); + + const resolved = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path); + const laterFixture = await createManualConflict(cliBinary, session.cliEnv, resolved.rev, [ + leftContent, + rightContent, + ]); + if (laterFixture.conflicts.length !== 1) { + throw new Error(`Expected a later conflict with exactly two live leaves: ${JSON.stringify(laterFixture)}`); + } + await requestConflictCheck(cliBinary, session.cliEnv); + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const modal = conflictDialogue(page); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal.getByRole("button", { name: "Not now", exact: true }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + await waitForConflictChecks(cliBinary, session.cliEnv); + + await requestConflictCheck(cliBinary, session.cliEnv, true); + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + await page.waitForTimeout(1500); + if (await conflictDialogue(page).isVisible()) { + throw new Error("The postponed conflict dialogue reopened during an ordinary conflict check."); + } + }); + await waitForConflictCount(cliBinary, session.cliEnv, 1); + + const laterCommandExecuted = await withObsidianPage(session.remoteDebuggingPort, async (page) => { + return await page.evaluate( + (commandId) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true, + "obsidian-livesync:livesync-checkdoc-conflicted" + ); + }); + if (!laterCommandExecuted) { + throw new Error("The explicit conflict-resolution command was not registered for the active editor."); + } + const laterActiveSession = session; + await withObsidianPage(laterActiveSession.remoteDebuggingPort, async (page) => { + const modal = conflictDialogue(page); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await applyReplicatedConflictResolution(cliBinary, laterActiveSession.cliEnv, laterFixture.conflicts[0]); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + await page + .locator(".livesync-status-messagearea") + .filter({ hasText: "This file has unresolved conflicts." }) + .waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + console.log( + "Real Obsidian reviewed three versions pairwise, retained the completed stage across restart, suppressed an ordinary repeat prompt after Not now, reopened the dialogue after the explicit command, and cleared both postponed and open-dialogue states after replicated resolutions." + ); + console.log(`Dialogue screenshot: ${firstDialogueScreenshot}`); + console.log(`Postponed warning screenshot: ${warningScreenshot}`); + } finally { + if (session) { + await session.app.stop(); + } + await vault.dispose(); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/test/e2e-obsidian/scripts/couchdb-manual-setup-workflow.ts b/test/e2e-obsidian/scripts/couchdb-manual-setup-workflow.ts new file mode 100644 index 00000000..ef44ae87 --- /dev/null +++ b/test/e2e-obsidian/scripts/couchdb-manual-setup-workflow.ts @@ -0,0 +1,369 @@ +import { randomBytes } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { + assertCouchDbReachable, + deleteCouchDbDatabase, + loadCouchDbConfig, + makeUniqueDatabaseName, + waitForCouchDbDocs, + type CouchDbConfig, +} from "../runner/couchdb.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { assertEqual, pushLocalChanges, waitForLocalDatabaseEntry } from "../runner/liveSyncWorkflow.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { + acknowledgeDisabledOptionalFeatures, + captureAndStartInitialisation, + captureGuideDialogue, + confirmFastFetch, + confirmRebuild, + enterSetupURI, + finishInitialisation, + generateSetupURIFromDevice, + modalByTitle, + resumeCompatibilityReviewIfShown, + selectRadioOption, + continueWithoutRemoteSettings, + type SetupArtifact, +} from "../runner/setupUri.ts"; +import { captureObsidianPage, withObsidianPage } from "../runner/ui.ts"; +import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; + +process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000"; +process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "30000"; + +const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 30000); +const notePath = "E2E/manual-couchdb/from-first-device.md"; +const noteContent = "# Manual CouchDB setup\n\nThis note was sent by the manually configured first device.\n"; +const returnNotePath = "E2E/manual-couchdb/from-second-device.md"; +const returnNoteContent = + "# Manual CouchDB return journey\n\nThis note returned through a Setup URI generated by the first device.\n"; +const captures = { + scenario: "couchdb-manual-setup-workflow", + guide: "couchdb-manual", +} as const; + +type RunnerContext = { + binary: string; + cliBinary: string; + couchDb: CouchDbConfig; + dbName: string; + activeSessions: Set; +}; + +async function startUnconfiguredSession( + context: RunnerContext, + vault: TemporaryVault +): Promise { + const session = await startObsidianLiveSyncSession({ + binary: context.binary, + cliBinary: context.cliBinary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + }); + context.activeSessions.add(session); + return session; +} + +async function stopTrackedSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise { + if (!context.activeSessions.has(session)) return; + await session.app.stop(); + context.activeSessions.delete(session); +} + +async function stopTrackedSessions(context: RunnerContext): Promise { + for (const session of [...context.activeSessions]) { + await stopTrackedSession(context, session); + } +} + +async function captureFailure(session: ObsidianLiveSyncSession, label: string): Promise { + const screenshot = await captureObsidianPage( + session.remoteDebuggingPort, + `couchdb-manual-${label}-failure.png`, + async () => undefined + ).catch(() => undefined); + if (screenshot) { + console.error(`Manual CouchDB failure screenshot: ${screenshot}`); + } +} + +async function enterManualCouchDBSettings(port: number, couchDb: CouchDbConfig, dbName: string): Promise { + const screenshots: string[] = []; + await withObsidianPage(port, async (page) => { + const invitation = page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" }); + await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await invitation.locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs }); + + const intro = modalByTitle(page, "Welcome to Self-hosted LiveSync"); + await intro.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await selectRadioOption(intro, "I am setting this up for the first time"); + await intro + .getByRole("button", { name: "Yes, I want to set up a new synchronisation" }) + .click({ timeout: uiTimeoutMs }); + }); + + screenshots.push( + await captureGuideDialogue(port, "guide-couchdb-manual-connection-method.png", "Connection Method") + ); + await withObsidianPage(port, async (page) => { + const method = modalByTitle(page, "Connection Method"); + await selectRadioOption(method, "Configure a remote manually"); + await method + .getByRole("button", { name: "Proceed with manual configuration" }) + .click({ timeout: uiTimeoutMs }); + + const encryption = modalByTitle(page, "End-to-End Encryption"); + await encryption.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await encryption + .locator("label.row") + .filter({ hasText: "End-to-End Encryption" }) + .locator('input[type="checkbox"]') + .first() + .check({ timeout: uiTimeoutMs }); + await encryption + .locator("label.row") + .filter({ hasText: "Obfuscate Properties" }) + .locator('input[type="checkbox"]') + .first() + .check({ timeout: uiTimeoutMs }); + await encryption.locator('input[name="e2ee-passphrase"]').fill(randomBytes(24).toString("base64url")); + }); + screenshots.push(await captureGuideDialogue(port, "guide-couchdb-manual-encryption.png", "End-to-End Encryption")); + await withObsidianPage(port, async (page) => { + const encryption = modalByTitle(page, "End-to-End Encryption"); + await encryption.getByRole("button", { name: "Proceed", exact: true }).click({ timeout: uiTimeoutMs }); + }); + + screenshots.push( + await captureGuideDialogue(port, "guide-couchdb-manual-remote-selection.png", "Choose a synchronisation remote") + ); + await withObsidianPage(port, async (page) => { + const remoteSelection = modalByTitle(page, "Choose a synchronisation remote"); + await selectRadioOption(remoteSelection, "CouchDB"); + await remoteSelection + .getByRole("button", { name: "Continue to CouchDB setup", exact: true }) + .click({ timeout: uiTimeoutMs }); + + const couchDB = modalByTitle(page, "CouchDB Configuration"); + await couchDB.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await couchDB.locator('input[name="couchdb-url"]').fill(couchDb.uri); + await couchDB.locator('input[name="couchdb-username"]').fill(couchDb.username); + await couchDB.locator('input[name="couchdb-password"]').fill(couchDb.password); + await couchDB.locator('input[name="couchdb-database"]').fill(dbName); + }); + screenshots.push( + await captureGuideDialogue(port, "guide-couchdb-manual-connection-details.png", "CouchDB Configuration") + ); + + await withObsidianPage(port, async (page) => { + const couchDB = modalByTitle(page, "CouchDB Configuration"); + await couchDB + .getByRole("button", { name: "Check server requirements", exact: true }) + .click({ timeout: uiTimeoutMs }); + const summary = couchDB.locator(".check-results summary"); + await summary.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await summary + .filter({ hasText: /All checks passed successfully!|issue\(s\) detected!/u }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + const errors = couchDB.locator(".check-result.error"); + if ((await errors.count()) > 0) { + const messages = await errors.locator(".message").allTextContents(); + throw new Error(`The documented CouchDB fixture failed its server requirements: ${messages.join(" | ")}`); + } + const details = couchDB.locator(".check-results details"); + if (!(await details.evaluate((element) => (element as HTMLDetailsElement).open))) { + await summary.click({ timeout: uiTimeoutMs }); + } + }); + screenshots.push( + await captureGuideDialogue(port, "guide-couchdb-manual-server-requirements.png", "CouchDB Configuration") + ); + + await withObsidianPage(port, async (page) => { + const couchDB = modalByTitle(page, "CouchDB Configuration"); + await couchDB + .getByRole("button", { name: "Create or connect to database and continue", exact: true }) + .click({ timeout: uiTimeoutMs }); + await modalByTitle(page, "Setup Complete: Preparing to Initialise Server").waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + }); + return screenshots; +} + +async function writeNoteViaObsidian( + cliBinary: string, + environment: NodeJS.ProcessEnv, + path: string, + content: string +): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const content=${JSON.stringify(content)};`, + "const folder=path.split('/').slice(0,-1).join('/');", + "if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);", + "const existing=app.vault.getAbstractFileByPath(path);", + "if(existing) await app.vault.modify(existing,content);", + "else await app.vault.create(path,content);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + environment + ); +} + +async function waitForVaultFile( + vault: TemporaryVault, + path: string, + expected: string, + timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000) +): Promise { + const deadline = Date.now() + timeoutMs; + let lastContent = ""; + while (Date.now() < deadline) { + try { + lastContent = await readFile(join(vault.path, path), "utf8"); + if (lastContent === expected) return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`); +} + +async function waitForRemoteEntry(context: RunnerContext, entry: { id: string; children: string[] }): Promise { + await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => { + const ids = new Set(docs.map((doc) => doc._id)); + return ids.has(entry.id) && entry.children.every((childId) => ids.has(childId)); + }); +} + +async function main(): Promise { + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) { + throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); + } + const couchDb = await loadCouchDbConfig(); + const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "manual-setup"); + const vaultA = await createTemporaryVault(); + const vaultB = await createTemporaryVault(); + const context: RunnerContext = { + binary, + cliBinary: cli.binary, + couchDb, + dbName, + activeSessions: new Set(), + }; + const screenshots: string[] = []; + let secondDeviceArtifact: SetupArtifact | undefined; + + try { + await assertCouchDbReachable(couchDb); + console.log(`Using Obsidian executable: ${binary}`); + console.log(`Temporary Vault A: ${vaultA.path}`); + console.log(`Temporary Vault B: ${vaultB.path}`); + console.log(`CouchDB database to be created by the onboarding dialogue: ${dbName}`); + + let session = await startUnconfiguredSession(context, vaultA); + try { + screenshots.push(...(await enterManualCouchDBSettings(session.remoteDebuggingPort, couchDb, dbName))); + screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "new", captures)); + screenshots.push(await confirmRebuild(session.remoteDebuggingPort, captures)); + screenshots.push(await continueWithoutRemoteSettings(session.remoteDebuggingPort, captures)); + screenshots.push(await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort, captures)); + const state = await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv); + await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort); + assertEqual(state.activeConfigurationId !== "", true, "Manual CouchDB setup did not activate a profile."); + assertEqual( + state.remoteConfigurationCount, + 1, + "Manual CouchDB setup did not persist exactly one remote profile." + ); + + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, notePath, noteContent); + const entry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await waitForRemoteEntry(context, entry); + + const generated = await generateSetupURIFromDevice( + session.remoteDebuggingPort, + randomBytes(24).toString("base64url"), + captures + ); + secondDeviceArtifact = generated.artifact; + screenshots.push(...generated.screenshots); + } catch (error) { + await captureFailure(session, "first-device"); + throw error; + } finally { + await stopTrackedSession(context, session); + } + + session = await startUnconfiguredSession(context, vaultB); + try { + if (!secondDeviceArtifact) { + throw new Error("The manually configured first device did not generate a Setup URI."); + } + screenshots.push( + await enterSetupURI(session.remoteDebuggingPort, "existing", secondDeviceArtifact, captures) + ); + screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "existing", captures)); + screenshots.push(...(await confirmFastFetch(session.remoteDebuggingPort, captures))); + await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv); + await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await waitForVaultFile(vaultB, notePath, noteContent); + + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, returnNotePath, returnNoteContent); + const returnEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, returnNotePath); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await waitForRemoteEntry(context, returnEntry); + } catch (error) { + await captureFailure(session, "second-device"); + throw error; + } finally { + await stopTrackedSession(context, session); + } + + session = await startUnconfiguredSession(context, vaultA); + try { + await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await waitForVaultFile(vaultA, returnNotePath, returnNoteContent); + } catch (error) { + await captureFailure(session, "return-journey"); + throw error; + } finally { + await stopTrackedSession(context, session); + } + + console.log( + `Manual CouchDB onboarding created and tested its database, generated a second-device Setup URI, and completed a bidirectional note round-trip. Screenshots: ${screenshots.join(", ")}` + ); + } finally { + await stopTrackedSessions(context).catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + await vaultA.dispose(); + await vaultB.dispose(); + if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") { + await deleteCouchDbDatabase(couchDb, dbName).catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + } + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/test/e2e-obsidian/scripts/couchdb-upload.ts b/test/e2e-obsidian/scripts/couchdb-upload.ts index 70a53688..ee76f73c 100644 --- a/test/e2e-obsidian/scripts/couchdb-upload.ts +++ b/test/e2e-obsidian/scripts/couchdb-upload.ts @@ -5,17 +5,35 @@ import { deleteCouchDbDatabase, loadCouchDbConfig, makeUniqueDatabaseName, + putCouchDbDocument, waitForCouchDbDocs, } from "../runner/couchdb.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { assertEqual, + assertE2eCompatibilityMarker, + assertE2eCompatibilityReviewPending, configureCouchDb, + createE2eCouchDbPluginData, prepareRemote, - pushLocalChanges, + resumeCompatibilityReview, waitForLiveSyncCoreReady, type LocalDatabaseEntry, } from "../runner/liveSyncWorkflow.ts"; +import { + REMOTE_ACTIVITY_EXPECTED_STATE, + captureRemoteActivityDiagnostics, + waitForRemoteActivityState, +} from "../runner/remoteActivity.ts"; +import { + cleanUpHeldRemoteActivity, + clearHeldRemoteActivity, + finishHeldRemoteActivity, + startHeldChunkFetch, + startHeldOneShotReplication, + startHeldTrackedRequest, + waitForRestoredChunk, +} from "../runner/remoteActivityWorkflow.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { createTemporaryVault } from "../runner/vault.ts"; @@ -72,6 +90,7 @@ async function main(): Promise { const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "obsidian-upload"); const vault = await createTemporaryVault(); let session: ObsidianLiveSyncSession | undefined; + let activityStage = "session-startup"; try { await assertCouchDbReachable(couchDb); @@ -86,8 +105,20 @@ async function main(): Promise { cliBinary: cli.binary, vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: createE2eCouchDbPluginData({ + uri: couchDb.uri, + username: couchDb.username, + password: couchDb.password, + dbName, + }), }); await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + await assertE2eCompatibilityReviewPending(cli.binary, session.cliEnv); + await resumeCompatibilityReview(session.remoteDebuggingPort, { + verifyMissingDeviceMarkerExplanation: true, + screenshotPrefix: "compatibility-review-copied-vault", + }); + await assertE2eCompatibilityMarker(cli.binary, session.cliEnv); const configured = await configureCouchDb(cli.binary, session.cliEnv, { uri: couchDb.uri, @@ -104,8 +135,50 @@ async function main(): Promise { assertEqual(configured.syncOnSave, false, "Sync on save should remain disabled during this workflow."); await prepareRemote(cli.binary, session.cliEnv); + activityStage = "initial-idle"; + const initialIdle = await waitForRemoteActivityState( + session.remoteDebuggingPort, + REMOTE_ACTIVITY_EXPECTED_STATE.idle + ); const localEntry = await createNoteAndWaitForLocalDb(cli.binary, session.cliEnv); - await pushLocalChanges(cli.binary, session.cliEnv); + + activityStage = REMOTE_ACTIVITY_EXPECTED_STATE.trackedRequestActive; + await startHeldTrackedRequest(cli.binary, session.cliEnv); + const trackedRequestActive = await waitForRemoteActivityState( + session.remoteDebuggingPort, + REMOTE_ACTIVITY_EXPECTED_STATE.trackedRequestActive + ); + const trackedRequestResult = await finishHeldRemoteActivity(cli.binary, session.cliEnv); + assertEqual(trackedRequestResult.error, undefined, "The observed CouchDB request failed."); + assertEqual(trackedRequestResult.result, true, "The observed CouchDB request did not report success."); + activityStage = "tracked-request-idle"; + const trackedRequestIdle = await waitForRemoteActivityState( + session.remoteDebuggingPort, + REMOTE_ACTIVITY_EXPECTED_STATE.idle + ); + if (trackedRequestIdle.requestCount <= initialIdle.requestCount) { + throw new Error("The held CouchDB request did not advance the tracked remote-request count."); + } + await clearHeldRemoteActivity(cli.binary, session.cliEnv); + + activityStage = "one-shot-active"; + await startHeldOneShotReplication(cli.binary, session.cliEnv); + const oneShotActive = await waitForRemoteActivityState( + session.remoteDebuggingPort, + REMOTE_ACTIVITY_EXPECTED_STATE.finiteReplicationActive + ); + const oneShotResult = await finishHeldRemoteActivity(cli.binary, session.cliEnv); + assertEqual(oneShotResult.error, undefined, "One-shot replication failed while its activity was observed."); + assertEqual(oneShotResult.result, true, "One-shot replication did not report success."); + activityStage = "one-shot-idle"; + const oneShotIdle = await waitForRemoteActivityState( + session.remoteDebuggingPort, + REMOTE_ACTIVITY_EXPECTED_STATE.idle + ); + if (oneShotIdle.requestCount <= trackedRequestIdle.requestCount) { + throw new Error("One-shot replication did not make an observed remote request."); + } + await clearHeldRemoteActivity(cli.binary, session.cliEnv); const remoteDocs = await waitForCouchDbDocs(couchDb, dbName, (docs) => { const ids = new Set(docs.map((doc) => doc._id)); @@ -118,11 +191,80 @@ async function main(): Promise { "Remote metadata path did not match the local database entry." ); + const sourceChunkId = localEntry.children[0]; + if (!sourceChunkId) throw new Error("The uploaded note did not produce a chunk for the fetch workflow."); + const sourceChunk = remoteDocs.find((document) => document._id === sourceChunkId); + if (!sourceChunk || sourceChunk.type !== "leaf") { + throw new Error(`The uploaded source chunk was not found in CouchDB: ${sourceChunkId}`); + } + const { _rev: _sourceRevision, ...remoteOnlyChunk } = sourceChunk; + const chunkId = `h:e2e-remote-activity-${Date.now().toString(36)}`; + await putCouchDbDocument(couchDb, dbName, { ...remoteOnlyChunk, _id: chunkId }); + activityStage = REMOTE_ACTIVITY_EXPECTED_STATE.chunkFetchActive; + await startHeldChunkFetch(cli.binary, session.cliEnv, chunkId); + const chunkFetchActive = await waitForRemoteActivityState( + session.remoteDebuggingPort, + REMOTE_ACTIVITY_EXPECTED_STATE.chunkFetchActive + ); + const chunkFetchResult = await finishHeldRemoteActivity(cli.binary, session.cliEnv); + assertEqual( + chunkFetchResult.error, + undefined, + "On-demand chunk fetching failed while its activity was observed." + ); + if (!chunkFetchResult.requestedIds?.includes(chunkId)) { + throw new Error(`The on-demand chunk request did not include the selected chunk: ${chunkId}`); + } + if ((chunkFetchResult.resultCount ?? 0) < 1) { + throw new Error(`The remote did not return the selected chunk: ${chunkId}`); + } + const restoredChunk = await waitForRestoredChunk(cli.binary, session.cliEnv, chunkId); + assertEqual(restoredChunk.id, chunkId, "The restored chunk ID did not match the requested chunk."); + activityStage = "chunk-fetch-idle"; + const chunkFetchIdle = await waitForRemoteActivityState( + session.remoteDebuggingPort, + REMOTE_ACTIVITY_EXPECTED_STATE.idle + ); + if (chunkFetchIdle.requestCount <= oneShotIdle.requestCount) { + throw new Error("On-demand chunk fetching did not make an observed remote request."); + } + await clearHeldRemoteActivity(cli.binary, session.cliEnv); + console.log( `Uploaded metadata ${localEntry.id} and ${localEntry.children.length} chunk(s) to CouchDB database ${dbName}` ); + console.log( + [ + `Tracked request: ${trackedRequestActive.statusBarText.trim()} -> idle`, + `One-shot activity: ${oneShotActive.statusBarText.trim()} -> idle`, + `Chunk-fetch activity: ${chunkFetchActive.statusBarText.trim()} -> idle`, + `Balanced remote requests: ${chunkFetchIdle.requestCount}/${chunkFetchIdle.responseCount}`, + ].join("\n") + ); + } catch (error) { + if (session) { + const diagnostics = await captureRemoteActivityDiagnostics( + session.remoteDebuggingPort, + `couchdb-upload-${activityStage}` + ).catch((diagnosticError: unknown) => { + console.warn( + `Could not capture remote activity diagnostics: ${ + diagnosticError instanceof Error ? diagnosticError.message : String(diagnosticError) + }` + ); + return undefined; + }); + if (diagnostics) { + console.error(`Remote activity screenshot: ${diagnostics.screenshotPath}`); + console.error(`Remote activity snapshot: ${diagnostics.snapshotPath}`); + } + } + throw error; } finally { if (session) { + await cleanUpHeldRemoteActivity(cli.binary, session.cliEnv).catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); await session.app.stop(); } await vault.dispose(); diff --git a/test/e2e-obsidian/scripts/dialog-mounts.ts b/test/e2e-obsidian/scripts/dialog-mounts.ts new file mode 100644 index 00000000..86af117a --- /dev/null +++ b/test/e2e-obsidian/scripts/dialog-mounts.ts @@ -0,0 +1,1118 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { createE2eCouchDbPluginData, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts"; +import { assertMobileDialogueLayout, assertMobileNoticeLayout, setObsidianMobileTestMode } from "../runner/mobileUi.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { + captureObsidianDialogue, + captureObsidianElement, + captureObsidianPage, + obsidianRemoteDebuggingPort, + withObsidianPage, +} from "../runner/ui.ts"; +import { createTemporaryVault } from "../runner/vault.ts"; + +const dialogRunStateKey = "__livesyncE2EDialogMount"; +const repairRunStateKey = "__livesyncE2ETroubleshootingRepair"; +const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_DIALOG_TIMEOUT_MS ?? 10000); + +type DialogueMode = "desktop" | "mobile"; + +type DialogueRunState = { + done: boolean; + error?: string; + expected?: unknown; + kind: string; + result?: unknown; +}; + +type SetupManagerHandle = { + constructor: { name: string }; + onSelectServer?: (settings: unknown, remoteType: string) => Promise; + _askUseRemoteConfiguration?: (settings: unknown, preferred: unknown) => Promise; + _checkAndAskResolvingMismatchedTweaks?: (preferred: unknown) => Promise; + __addLog?: (message: string) => void; +}; + +type LiveSyncTestPlugin = { + core: { + fileHandler: { + createAllChunks(force: boolean): Promise; + }; + modules: SetupManagerHandle[]; + settings: Record; + }; +}; + +type ObsidianSettingsController = { + open(): void; + openTabById(tabId: string): void; +}; + +type ObsidianVaultFile = { + path: string; +}; + +type ObsidianTestApp = { + commands?: { executeCommandById(commandId: string): boolean }; + plugins?: { plugins: Record }; + setting?: ObsidianSettingsController; + vault?: { + delete(file: ObsidianVaultFile, force: boolean): Promise; + getFiles(): ObsidianVaultFile[]; + read(file: ObsidianVaultFile): Promise; + }; +}; + +type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp }; + +async function openRemoteSelectionDialogue(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + await page.evaluate((stateKey) => { + const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded"); + const manager = plugin.core.modules.find((module) => module.constructor.name === "SetupManager"); + if (typeof manager?.onSelectServer !== "function") throw new Error("Could not find SetupManager"); + const state: DialogueRunState = { kind: "remote-selection", done: false }; + (globalThis as unknown as Record)[stateKey] = state; + void manager.onSelectServer(plugin.core.settings, "unknown").then( + (result) => { + state.result = result; + state.done = true; + }, + (error: unknown) => { + state.error = error instanceof Error ? error.message : String(error); + state.done = true; + } + ); + }, dialogRunStateKey); + }); +} + +async function openSetupUriDialogue(): Promise { + const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + return await page.evaluate( + (commandId) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true, + "obsidian-livesync:livesync-opensetupuri" + ); + }); + if (!opened) { + throw new Error("The Setup URI command was not registered or could not be executed."); + } +} + +async function openConfigurationMismatchDialogue( + kind: "connected" | "connected-rebuild-recommended" | "remote-configuration" +): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + await page.evaluate( + ({ stateKey, kind }) => { + const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded"); + const resolver = plugin.core.modules.find( + (module) => module.constructor.name === "ModuleResolvingMismatchedTweaks" + ); + if (resolver === undefined) throw new Error("Could not find ModuleResolvingMismatchedTweaks"); + if (kind === "connected-rebuild-recommended") { + plugin.core.settings.autoAcceptCompatibleTweak = false; + } + + const preferred = + kind === "connected-rebuild-recommended" + ? { + ...plugin.core.settings, + hashAlg: plugin.core.settings.hashAlg === "xxhash32" ? "xxhash64" : "xxhash32", + } + : { + ...plugin.core.settings, + enableCompression: !Boolean(plugin.core.settings.enableCompression), + }; + const state: DialogueRunState = { + kind: `configuration-mismatch-${kind}`, + done: false, + expected: preferred, + }; + (globalThis as unknown as Record)[stateKey] = state; + const operation = + kind === "remote-configuration" + ? resolver._askUseRemoteConfiguration?.(plugin.core.settings, preferred) + : resolver._checkAndAskResolvingMismatchedTweaks?.(preferred); + if (operation === undefined) { + throw new Error(`The configuration mismatch resolver does not support ${kind}.`); + } + void operation.then( + (result) => { + state.result = result; + state.done = true; + }, + (error: unknown) => { + state.error = error instanceof Error ? error.message : String(error); + state.done = true; + } + ); + }, + { stateKey: dialogRunStateKey, kind } + ); + }); +} + +async function assertDialogueRunCompleted(): Promise { + const state = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + await page.waitForFunction( + (stateKey) => + (globalThis as unknown as Record)[stateKey]?.done === true, + dialogRunStateKey, + { timeout: uiTimeoutMs } + ); + return await page.evaluate( + (stateKey) => (globalThis as unknown as Record)[stateKey], + dialogRunStateKey + ); + }); + if (!state) { + throw new Error("The mounted dialogue did not record its completion state."); + } + if (state.error) { + throw new Error(`The mounted dialogue failed: ${state.error}`); + } + return state; +} + +async function verifyRemoteSizeNoticeAndDialogue(): Promise<{ + compatibilityReview: string; + notice: string; + dialogue: string; +}> { + const compatibilityReviewScreenshot = await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + "compatibility-review-dialogue.png", + async (page) => { + const compatibilityReview = page.locator(".modal-container").filter({ + has: page + .locator(".modal-title") + .filter({ hasText: "Synchronisation paused for compatibility review" }), + }); + await compatibilityReview.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const message = compatibilityReview.locator(".vpk-action-dialog__message"); + const textSelection = await message.evaluate((element) => { + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(element); + selection?.removeAllRanges(); + selection?.addRange(range); + const selectedText = selection?.toString() ?? ""; + selection?.removeAllRanges(); + return { + selectedText, + userSelect: getComputedStyle(element).userSelect, + }; + }); + if ( + textSelection.userSelect !== "text" || + !textSelection.selectedText.includes("Remote synchronisation is paused on this device") + ) { + throw new Error( + `Expected the action dialogue message to be selectable, received user-select=${textSelection.userSelect} and selected text '${textSelection.selectedText}'.` + ); + } + const actions = compatibilityReview.locator(".vpk-action-dialog__actions--vertical"); + await actions.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const flexDirection = await actions.evaluate((element) => getComputedStyle(element).flexDirection); + if (flexDirection !== "column") { + throw new Error(`Expected vertically stacked compatibility actions, received ${flexDirection}.`); + } + } + ); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const compatibilityReview = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Synchronisation paused for compatibility review" }), + }); + await compatibilityReview + .getByRole("button", { name: "Keep synchronisation paused" }) + .click({ timeout: uiTimeoutMs }); + await compatibilityReview.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + const noticeScreenshot = await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + "remote-size-startup-notice.png", + async (page) => { + const notice = page.locator(".notice").filter({ + hasText: "Remote storage size notifications are not configured.", + }); + await notice.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await notice.getByRole("link", { name: "Review options" }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + } + ); + + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const notice = page.locator(".notice").filter({ + hasText: "Remote storage size notifications are not configured.", + }); + await notice.getByRole("link", { name: "Review options" }).click({ timeout: uiTimeoutMs }); + await notice.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + const dialogueScreenshot = await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + "remote-size-review-dialogue.png", + async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Setting up database size notification" }), + }); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + for (const action of [ + "No, never warn please", + "800MB (Cloudant, fly.io)", + "2GB (Standard)", + "Ask me later", + ]) { + await modal.getByRole("button", { name: action }).waitFor({ state: "visible", timeout: uiTimeoutMs }); + } + } + ); + + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Setting up database size notification" }), + }); + await modal.getByRole("button", { name: "Ask me later" }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + return { + compatibilityReview: compatibilityReviewScreenshot, + notice: noticeScreenshot, + dialogue: dialogueScreenshot, + }; +} + +async function verifyRemoteSelectionDialogue(mode: DialogueMode): Promise { + await openRemoteSelectionDialogue(); + const screenshotPath = await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + `setup-remote-selection-dialogue${mode === "mobile" ? "-mobile" : ""}.png`, + async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Choose a synchronisation remote" }), + }); + const dialogue = modal.locator(".dialog-host"); + await modal.waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + for (const label of ["CouchDB", "S3-compatible Object Storage", "Peer-to-Peer (P2P)"]) { + await dialogue.getByText(label, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs }); + } + const p2pDescription = dialogue.getByText( + "No central data-storage server is required, but a signalling relay is required for peer discovery.", + { exact: false } + ); + await p2pDescription.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const textSelection = await p2pDescription.evaluate((element) => { + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(element); + selection?.removeAllRanges(); + selection?.addRange(range); + const selectedText = selection?.toString() ?? ""; + selection?.removeAllRanges(); + return { + selectedText, + userSelect: getComputedStyle(element).userSelect, + }; + }); + if ( + textSelection.userSelect !== "text" || + !textSelection.selectedText.includes("signalling relay is required for peer discovery") + ) { + throw new Error( + `Expected Svelte dialogue prose to be selectable, received user-select=${textSelection.userSelect} and selected text '${textSelection.selectedText}'.` + ); + } + await modal + .getByRole("button", { name: "No, please take me back" }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + if (mode === "mobile") { + await assertMobileDialogueLayout(page, modal, "remote selection dialogue"); + } + } + ); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Choose a synchronisation remote" }), + }); + if (mode === "mobile") { + const previousDialogue = await modal.locator(".modal").last().elementHandle(); + if (previousDialogue === null) { + throw new Error("The remote selection dialogue did not expose a close control."); + } + await modal.locator(".modal-close-button").click({ timeout: uiTimeoutMs }); + await page.waitForFunction((element) => !element.isConnected, previousDialogue, { timeout: uiTimeoutMs }); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + } + await modal.getByRole("button", { name: "No, please take me back" }).click({ timeout: uiTimeoutMs }); + }); + await assertDialogueRunCompleted(); + return screenshotPath; +} + +async function verifyCouchDBSettingsDialogue(mode: DialogueMode): Promise { + await openRemoteSelectionDialogue(); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const remoteSelection = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Choose a synchronisation remote" }), + }); + await remoteSelection + .locator("label") + .filter({ hasText: "CouchDB" }) + .locator('input[type="radio"]') + .first() + .check({ timeout: uiTimeoutMs }); + await remoteSelection + .getByRole("button", { name: "Continue to CouchDB setup", exact: true }) + .click({ timeout: uiTimeoutMs }); + }); + const screenshotPath = await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + `setup-couchdb-dialogue${mode === "mobile" ? "-mobile" : ""}.png`, + async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "CouchDB Configuration" }), + }); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + for (const label of [ + "Check server requirements", + "Test connection and save", + "Save without connecting", + "Cancel", + ]) { + await modal.getByRole("button", { name: label, exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + } + await modal + .getByText( + "This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server.", + { exact: false } + ) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal + .getByText("CouchDB validates the database name when you connect.", { exact: false }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + if ((await modal.getByRole("button", { name: "Continue anyway", exact: true }).count()) !== 0) { + throw new Error("CouchDB onboarding still exposes the ambiguous Continue anyway action."); + } + const buttonGroups = modal.locator(".button-group"); + for (let index = 0; index < (await buttonGroups.count()); index++) { + const flexDirection = await buttonGroups.nth(index).evaluate((element) => { + return getComputedStyle(element).flexDirection; + }); + if (flexDirection !== "column") { + throw new Error(`Expected vertical CouchDB actions, received ${flexDirection}.`); + } + } + if (mode === "mobile") { + await assertMobileDialogueLayout(page, modal, "CouchDB settings dialogue"); + } + } + ); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "CouchDB Configuration" }), + }); + await modal.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + await assertDialogueRunCompleted(); + return screenshotPath; +} + +async function verifySetupUriDialogue(mode: DialogueMode): Promise { + await openSetupUriDialogue(); + const screenshotPath = await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + `setup-uri-dialogue${mode === "mobile" ? "-mobile" : ""}.png`, + async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Enter Setup URI" }), + }); + await modal.waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await modal + .locator('input[placeholder^="obsidian://setuplivesync"]') + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal.locator('input[name="password"]').waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal + .getByRole("button", { name: "Test Settings and Continue" }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal.getByRole("button", { name: "Cancel" }).waitFor({ state: "visible", timeout: uiTimeoutMs }); + if (mode === "mobile") { + await assertMobileDialogueLayout(page, modal, "Setup URI dialogue"); + } + } + ); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Enter Setup URI" }), + }); + await modal.getByRole("button", { name: "Cancel" }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + return screenshotPath; +} + +async function verifyCompatibleMismatchAutoAdjustment(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + await page.evaluate((stateKey) => { + const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded"); + const resolver = plugin.core.modules.find( + (module) => module.constructor.name === "ModuleResolvingMismatchedTweaks" + ); + if (typeof resolver?._checkAndAskResolvingMismatchedTweaks !== "function") { + throw new Error("Could not find the configuration mismatch resolver"); + } + plugin.core.settings.autoAcceptCompatibleTweak = undefined; + const currentModified = + typeof plugin.core.settings.tweakModified === "number" ? plugin.core.settings.tweakModified : 0; + const preferred = { + ...plugin.core.settings, + hashAlg: plugin.core.settings.hashAlg === "xxhash32" ? "xxhash64" : "xxhash32", + tweakModified: currentModified + 1, + }; + const state: DialogueRunState = { + kind: "configuration-mismatch-compatible-auto-adjustment", + done: false, + expected: preferred, + }; + (globalThis as unknown as Record)[stateKey] = state; + void resolver._checkAndAskResolvingMismatchedTweaks(preferred).then( + (result) => { + state.result = result; + state.done = true; + }, + (error: unknown) => { + state.error = error instanceof Error ? error.message : String(error); + state.done = true; + } + ); + }, dialogRunStateKey); + }); + + const state = await assertDialogueRunCompleted(); + if (!Array.isArray(state.result) || state.result.length !== 2) { + throw new Error("The compatible mismatch did not return its settings and rebuild decision."); + } + const [appliedSettings, shouldRebuild] = state.result; + const expectedSettings = state.expected; + if ( + typeof appliedSettings !== "object" || + appliedSettings === null || + typeof expectedSettings !== "object" || + expectedSettings === null || + !("hashAlg" in appliedSettings) || + !("hashAlg" in expectedSettings) || + appliedSettings.hashAlg !== expectedSettings.hashAlg || + shouldRebuild !== false + ) { + throw new Error("The compatible mismatch was not adjusted to the newer setting without a rebuild."); + } + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const autoAcceptEnabled = await page.evaluate(() => { + const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + return plugin?.core.settings.autoAcceptCompatibleTweak; + }); + if (autoAcceptEnabled !== true) { + throw new Error("Compatible mismatch auto-adjustment was not persisted as the default."); + } + for (const title of ["Auto-Accept Available", "Configuration Mismatch Detected"]) { + const dialogue = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: title }), + }); + if ((await dialogue.count()) !== 0) { + throw new Error(`Compatible mismatch auto-adjustment unexpectedly opened '${title}'.`); + } + } + }); +} + +async function verifyCompatibleAlignmentSettingDefault(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const persistedValue = await page.evaluate(() => { + const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded"); + return plugin.core.settings.autoAcceptCompatibleTweak; + }); + if (persistedValue !== undefined) { + throw new Error( + `The default-display fixture expected an undefined preference, received ${persistedValue}.` + ); + } + + await page.evaluate(() => { + const setting = (globalThis as ObsidianTestGlobal).app?.setting; + if (setting === undefined) throw new Error("Obsidian settings are unavailable"); + setting.open(); + setting.openTabById("obsidian-livesync"); + }); + const liveSyncSettings = page.locator(".sls-setting"); + await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await liveSyncSettings.locator('.sls-setting-menu-btn[title="Advanced"]').click({ timeout: uiTimeoutMs }); + const settingItem = liveSyncSettings.locator(".setting-item").filter({ + has: page.getByText("Auto-accept compatible tweak mismatches", { exact: true }), + }); + await settingItem.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const toggle = settingItem.locator(".checkbox-container"); + if (!(await toggle.evaluate((element) => element.classList.contains("is-enabled")))) { + throw new Error("The automatic compatible-setting policy was displayed as disabled while still undefined."); + } + }); +} + +async function verifyConfigurationMismatchDialogues(): Promise<{ general: string; fetch: string }> { + await verifyCompatibleMismatchAutoAdjustment(); + await openConfigurationMismatchDialogue("remote-configuration"); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Use Remote Configuration" }), + }); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal + .getByRole("button", { name: "Use configured settings", exact: true }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal.getByRole("button", { name: "Dismiss", exact: true }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + await assertDialogueRunCompleted(); + + await openConfigurationMismatchDialogue("connected"); + const generalScreenshotPath = await captureObsidianElement( + obsidianRemoteDebuggingPort(), + "troubleshooting-configuration-mismatch-dialogue.png", + async (page) => { + const container = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Configuration Mismatch Detected" }), + }); + const modal = container.locator(".modal").last(); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + for (const action of ["Apply settings to this device", "Update remote database settings"]) { + await modal + .getByRole("button", { name: action, exact: true }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + } + await modal.getByRole("button", { name: /Dismiss$/u }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + for (const retiredAction of ["Use configured", "Update with mine"]) { + if ((await modal.getByRole("button", { name: retiredAction, exact: true }).count()) !== 0) { + throw new Error(`The mismatch dialogue still exposes the retired action '${retiredAction}'.`); + } + } + const actions = modal.locator(".setting-item-control").last(); + const flexDirection = await actions.evaluate((element) => getComputedStyle(element).flexDirection); + if (flexDirection !== "column") { + throw new Error(`Expected vertically stacked mismatch actions, received ${flexDirection}.`); + } + return modal; + } + ); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Configuration Mismatch Detected" }), + }); + await modal.getByRole("button", { name: /Dismiss$/u }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + await assertDialogueRunCompleted(); + + await openConfigurationMismatchDialogue("connected-rebuild-recommended"); + const fetchScreenshotPath = await captureObsidianElement( + obsidianRemoteDebuggingPort(), + "troubleshooting-configuration-mismatch-fetch-dialogue.png", + async (page) => { + const container = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Configuration Mismatch Detected" }), + }); + const modal = container.locator(".modal").last(); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal + .getByRole("button", { name: "Apply settings to this device, and fetch again", exact: true }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + return modal; + } + ); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Configuration Mismatch Detected" }), + }); + await modal + .getByRole("button", { name: "Apply settings to this device, and fetch again", exact: true }) + .click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + const fetchResult = await assertDialogueRunCompleted(); + if (!Array.isArray(fetchResult.result) || fetchResult.result.length !== 2) { + throw new Error("The configuration-mismatch Fetch action did not return its settings and Fetch decision."); + } + const [appliedSettings, shouldFetch] = fetchResult.result; + const expectedSettings = fetchResult.expected; + if ( + typeof appliedSettings !== "object" || + appliedSettings === null || + typeof expectedSettings !== "object" || + expectedSettings === null || + !("hashAlg" in appliedSettings) || + !("hashAlg" in expectedSettings) || + appliedSettings.hashAlg !== expectedSettings.hashAlg || + shouldFetch !== true + ) { + throw new Error("The configuration-mismatch Fetch action did not apply the remote setting before Fetch."); + } + + return { general: generalScreenshotPath, fetch: fetchScreenshotPath }; +} + +async function executeRegisteredCommand(commandId: string): Promise { + const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + return await page.evaluate( + (id) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(id) === true, + commandId + ); + }); + if (!opened) { + throw new Error(`The command was not registered or could not be executed: ${commandId}`); + } +} + +async function verifyLogAndReportSurfaces(): Promise<{ log: string; report: string }> { + await executeRegisteredCommand("obsidian-livesync:view-log"); + const logScreenshot = await captureObsidianElement( + obsidianRemoteDebuggingPort(), + "troubleshooting-show-log.png", + async (page) => { + const logPane = page.locator(".logpane"); + await logPane.waitFor({ state: "visible", timeout: uiTimeoutMs }); + for (const label of ["Wrap", "Auto scroll", "Pause"]) { + await logPane.getByText(label, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs }); + } + await logPane.getByRole("button", { name: "Close", exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + return logPane; + } + ); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const logPane = page.locator(".logpane"); + await logPane.getByRole("button", { name: "Close", exact: true }).click({ timeout: uiTimeoutMs }); + await logPane.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + await executeRegisteredCommand("obsidian-livesync:dump-debug-info"); + const reportScreenshot = await captureObsidianElement( + obsidianRemoteDebuggingPort(), + "troubleshooting-full-report.png", + async (page) => { + const modal = page.locator(".modal-container").filter({ + hasText: "Your Debug info is ready to be copied", + }); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const report = await modal.locator("textarea").inputValue({ timeout: uiTimeoutMs }); + if (!report.includes("# ---- Debug Info Dump ----")) { + throw new Error("The full-report dialogue did not contain the generated debug report."); + } + await modal.getByRole("button", { name: "OK", exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + return modal.locator(".modal").last(); + } + ); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const modal = page.locator(".modal-container").filter({ + hasText: "Your Debug info is ready to be copied", + }); + await modal.getByRole("button", { name: "OK", exact: true }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + return { log: logScreenshot, report: reportScreenshot }; +} + +async function verifyHatchSurfacesAndSafeActions(): Promise { + const screenshotPath = await captureObsidianElement( + obsidianRemoteDebuggingPort(), + "troubleshooting-hatch.png", + async (page) => { + await page.evaluate(() => { + const setting = (globalThis as ObsidianTestGlobal).app?.setting; + if (setting === undefined) throw new Error("Obsidian settings are unavailable"); + setting.open(); + setting.openTabById("obsidian-livesync"); + }); + const liveSyncSettings = page.locator(".sls-setting"); + await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await liveSyncSettings.locator('.sls-setting-menu-btn[title="Hatch"]').click({ timeout: uiTimeoutMs }); + for (const label of [ + "Write logs into the file", + "Recreate chunks for current Vault files", + "Inspect conflicts and file/database differences", + "Resolve All conflicted files by the newer one", + ]) { + await liveSyncSettings.locator(".setting-item-name", { hasText: label }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + } + const settingNames = await liveSyncSettings.locator(".setting-item-name").allTextContents(); + const recreateIndex = settingNames.findIndex((name) => + name.includes("Recreate chunks for current Vault files") + ); + const inspectIndex = settingNames.findIndex((name) => + name.includes("Inspect conflicts and file/database differences") + ); + const resolveIndex = settingNames.findIndex((name) => + name.includes("Resolve All conflicted files by the newer one") + ); + if ( + recreateIndex === -1 || + inspectIndex === -1 || + resolveIndex === -1 || + !(recreateIndex < inspectIndex && inspectIndex < resolveIndex) + ) { + throw new Error( + "Recovery actions are not ordered from chunk recreation through inspection to bulk conflict resolution" + ); + } + await liveSyncSettings.getByRole("button", { name: "Recreate current chunks", exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await liveSyncSettings.getByRole("button", { name: "Begin inspection", exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await liveSyncSettings + .locator(".setting-item-name", { hasText: "Recreate chunks for current Vault files" }) + .scrollIntoViewIfNeeded(); + return liveSyncSettings; + } + ); + + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const liveSyncSettings = page.locator(".sls-setting"); + const logSetting = liveSyncSettings.locator(".setting-item").filter({ + has: page.getByText("Write logs into the file", { exact: true }), + }); + await logSetting.locator(".checkbox-container").click({ timeout: uiTimeoutMs }); + await page.waitForFunction( + () => { + const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + return plugin?.core.settings.writeLogToTheFile === true; + }, + undefined, + { timeout: uiTimeoutMs } + ); + + const persistentLogMarker = "E2E persistent troubleshooting log"; + await page.evaluate((marker) => { + const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded"); + const module = plugin.core.modules.find((candidate) => candidate.constructor.name === "ModuleLog"); + if (typeof module?.__addLog !== "function") throw new Error("Could not find ModuleLog"); + module.__addLog(marker); + }, persistentLogMarker); + await page.waitForFunction( + async (marker) => { + const vault = (globalThis as ObsidianTestGlobal).app?.vault; + if (vault === undefined) return false; + const logFile = vault.getFiles().find((file) => file.path.startsWith("livesync_log_")); + if (logFile === undefined) return false; + return (await vault.read(logFile)).includes(marker); + }, + persistentLogMarker, + { timeout: uiTimeoutMs } + ); + + // Saving a toggle refreshes the settings pane. Resolve the visible control again so the + // second action does not target the detached pre-save element. + const refreshedLogToggle = page + .locator(".sls-setting:visible .setting-item:visible") + .filter({ + has: page.getByText("Write logs into the file", { exact: true }), + }) + .locator(".checkbox-container:visible") + .last(); + await refreshedLogToggle.click({ timeout: uiTimeoutMs }); + await page.waitForFunction( + () => { + const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + return plugin?.core.settings.writeLogToTheFile === false; + }, + undefined, + { timeout: uiTimeoutMs } + ); + await page.evaluate(async () => { + const vault = (globalThis as ObsidianTestGlobal).app?.vault; + if (vault === undefined) throw new Error("Obsidian Vault is unavailable"); + const logFile = vault.getFiles().find((file) => file.path.startsWith("livesync_log_")); + if (logFile === undefined) throw new Error("The persistent troubleshooting log was not created"); + await vault.delete(logFile, true); + }); + await page.waitForFunction( + () => + !(globalThis as ObsidianTestGlobal).app?.vault + ?.getFiles() + .some((file) => file.path.startsWith("livesync_log_")), + undefined, + { timeout: uiTimeoutMs } + ); + + await page.evaluate((stateKey) => { + const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded"); + const original = plugin.core.fileHandler.createAllChunks.bind(plugin.core.fileHandler); + const state: DialogueRunState = { kind: "recreate-missing-chunks", done: false }; + (globalThis as unknown as Record)[stateKey] = state; + plugin.core.fileHandler.createAllChunks = async (force) => { + try { + state.result = await original(force); + } catch (error) { + state.error = error instanceof Error ? error.message : String(error); + } finally { + state.done = true; + plugin.core.fileHandler.createAllChunks = original; + } + }; + }, repairRunStateKey); + await page + .locator(".sls-setting:visible") + .last() + .getByRole("button", { name: "Recreate current chunks", exact: true }) + .click({ + timeout: uiTimeoutMs, + }); + await page.waitForFunction( + (stateKey) => + (globalThis as unknown as Record)[stateKey]?.done === true, + repairRunStateKey, + { timeout: uiTimeoutMs } + ); + const repairState = await page.evaluate( + (stateKey) => (globalThis as unknown as Record)[stateKey], + repairRunStateKey + ); + if (repairState?.error) { + throw new Error(`Recreate missing chunks failed: ${repairState.error}`); + } + + await page + .locator(".sls-setting:visible") + .last() + .getByRole("button", { name: "Begin inspection", exact: true }) + .click({ + timeout: uiTimeoutMs, + }); + await page + .locator(".notice") + .filter({ hasText: /^done$/u }) + .waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + }); + + return screenshotPath; +} + +async function verifyMobileStartupReviews(): Promise<{ compatibilityReview: string; remoteSizeReview: string }> { + const compatibilityReviewScreenshot = await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + "compatibility-review-dialogue-mobile.png", + async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page + .locator(".modal-title") + .filter({ hasText: "Synchronisation paused for compatibility review" }), + }); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal.locator(".vpk-action-dialog__actions--vertical").waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await assertMobileDialogueLayout(page, modal, "compatibility review dialogue"); + } + ); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Synchronisation paused for compatibility review" }), + }); + await modal.getByRole("button", { name: "Keep synchronisation paused" }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + + const compatibilityReminder = page.locator(".livesync-compatibility-review-notice"); + await compatibilityReminder.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await assertMobileNoticeLayout(page, compatibilityReminder, "compatibility review reminder"); + + const notice = page.locator(".notice").filter({ + hasText: "Remote storage size notifications are not configured.", + }); + await notice.getByRole("link", { name: "Review options" }).click({ timeout: uiTimeoutMs }); + await notice.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + const remoteSizeReviewScreenshot = await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + "remote-size-review-dialogue-mobile.png", + async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Setting up database size notification" }), + }); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await assertMobileDialogueLayout(page, modal, "remote size review dialogue"); + } + ); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Setting up database size notification" }), + }); + await modal.getByRole("button", { name: "Ask me later" }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + return { + compatibilityReview: compatibilityReviewScreenshot, + remoteSizeReview: remoteSizeReviewScreenshot, + }; +} + +async function main(): Promise { + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) { + throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); + } + const vault = await createTemporaryVault(); + let session: ObsidianLiveSyncSession | undefined; + try { + session = await startObsidianLiveSyncSession({ + binary, + cliBinary: cli.binary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: createE2eCouchDbPluginData( + { + uri: "http://127.0.0.1:5984", + username: "", + password: "", + dbName: "dialog-mounts-ui-only", + }, + { + notifyThresholdOfRemoteStorageSize: -1, + syncOnStart: false, + syncOnSave: false, + syncOnEditorSave: false, + syncOnFileOpen: false, + syncAfterMerge: false, + periodicReplication: false, + useAdvancedMode: true, + } + ), + }); + try { + await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + } catch (error) { + const screenshot = await captureObsidianPage( + obsidianRemoteDebuggingPort(), + "dialog-mounts-core-not-ready.png", + async () => undefined + ); + console.error(`Core readiness diagnostic screenshot: ${screenshot}`); + const persistedSettings = JSON.parse( + await readFile(join(session.install.pluginDir, "data.json"), "utf8") + ) as Record; + console.error( + `Persisted readiness settings: ${JSON.stringify({ + isConfigured: persistedSettings.isConfigured, + remoteType: persistedSettings.remoteType, + settingVersion: persistedSettings.settingVersion, + couchDB_URI: persistedSettings.couchDB_URI, + couchDB_DBNAME: persistedSettings.couchDB_DBNAME, + remoteConfigurationCount: Object.keys( + (persistedSettings.remoteConfigurations as Record | undefined) ?? {} + ).length, + })}` + ); + throw error; + } + + const remoteSizeScreenshots = await verifyRemoteSizeNoticeAndDialogue(); + console.log( + `Compatibility review actions were stacked vertically, and the remote-size startup notice opened an untimed review dialogue successfully. Screenshots: ${remoteSizeScreenshots.compatibilityReview}, ${remoteSizeScreenshots.notice}, ${remoteSizeScreenshots.dialogue}` + ); + + const remoteScreenshot = await verifyRemoteSelectionDialogue("desktop"); + console.log(`Remote selection dialogue mounted and closed successfully. Screenshot: ${remoteScreenshot}`); + const couchDBScreenshot = await verifyCouchDBSettingsDialogue("desktop"); + console.log( + `CouchDB settings mode exposed explicit connection, unverified-save, and server-check actions. Screenshot: ${couchDBScreenshot}` + ); + const setupUriScreenshot = await verifySetupUriDialogue("desktop"); + console.log(`Setup URI dialogue mounted and closed successfully. Screenshot: ${setupUriScreenshot}`); + await verifyCompatibleAlignmentSettingDefault(); + console.log("The undefined compatible-setting preference is displayed with its effective enabled default."); + const mismatchScreenshots = await verifyConfigurationMismatchDialogues(); + console.log( + `A mismatch limited to compatible chunk settings was adjusted without a dialogue, current manual mismatch actions mounted successfully, and the Fetch action applied the remote setting before scheduling Fetch. Screenshots: ${mismatchScreenshots.general}, ${mismatchScreenshots.fetch}` + ); + const troubleshootingScreenshots = await verifyLogAndReportSurfaces(); + console.log( + `Show log and the generated full-report dialogue were reached through their registered commands. Screenshots: ${troubleshootingScreenshots.log}, ${troubleshootingScreenshots.report}` + ); + const hatchScreenshot = await verifyHatchSurfacesAndSafeActions(); + console.log( + `Hatch repair controls were reachable, safe empty-fixture runs completed, and persistent logging was enabled, verified, disabled, and removed. Screenshot: ${hatchScreenshot}` + ); + + await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), true, uiTimeoutMs); + try { + const mobileStartupScreenshots = await verifyMobileStartupReviews(); + console.log( + `Mobile compatibility and remote-size reviews passed viewport, safe-area, touch-target, and vertical-action checks. Screenshots: ${mobileStartupScreenshots.compatibilityReview}, ${mobileStartupScreenshots.remoteSizeReview}` + ); + const mobileRemoteScreenshot = await verifyRemoteSelectionDialogue("mobile"); + console.log( + `Mobile remote selection dialogue passed viewport, safe-area, touch-target, and close-control checks. Screenshot: ${mobileRemoteScreenshot}` + ); + const mobileCouchDBScreenshot = await verifyCouchDBSettingsDialogue("mobile"); + console.log( + `Mobile CouchDB settings dialogue passed viewport, touch-target, and vertical-action checks. Screenshot: ${mobileCouchDBScreenshot}` + ); + const mobileSetupUriScreenshot = await verifySetupUriDialogue("mobile"); + console.log( + `Mobile Setup URI dialogue passed viewport, safe-area, and touch-target checks. Screenshot: ${mobileSetupUriScreenshot}` + ); + } finally { + await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), false, uiTimeoutMs); + } + } finally { + if (session) { + await session.app.stop(); + } + await vault.dispose(); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/test/e2e-obsidian/scripts/document-history-nav.ts b/test/e2e-obsidian/scripts/document-history-nav.ts new file mode 100644 index 00000000..9daf0752 --- /dev/null +++ b/test/e2e-obsidian/scripts/document-history-nav.ts @@ -0,0 +1,293 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { createE2eObsidianDeviceLocalState, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { withObsidianPage } from "../runner/ui.ts"; +import { createTemporaryVault } from "../runner/vault.ts"; + +process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000"; +process.env.E2E_OBSIDIAN_CORE_READY_TIMEOUT_MS ??= "60000"; +process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ??= "30000"; + +const notePath = "E2E/document-history-nav.md"; +const revisions = ["Version one alpha", "Version two beta keyword", "Version three gamma"]; + +type RevisionInfo = { + revCount: number; + id: string; +}; + +type OpenHistoryResult = { + opened: boolean; + modalTitle: string | null; +}; + +function assertEqual(actual: unknown, expected: unknown, message: string): void { + if (actual !== expected) { + throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`); + } +} + +function assertTrue(value: boolean, message: string): void { + if (!value) { + throw new Error(message); + } +} + +async function dismissWelcomeWizard(port: number): Promise { + await withObsidianPage(port, async (page) => { + const cancel = page.getByText("No, please take me back"); + if (await cancel.isVisible({ timeout: 5000 }).catch(() => false)) { + await cancel.click(); + await page.waitForTimeout(500); + } + }); +} + +async function seedRevisions(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(notePath)};`, + `const revisions=${JSON.stringify(revisions)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));", + "if(!(await app.vault.adapter.exists('E2E'))) await app.vault.createFolder('E2E');", + "const existing=app.vault.getAbstractFileByPath(path);", + "if(existing) await app.vault.delete(existing);", + "const id=await core.services.path.path2id(path);", + "let baseRev='';", + "for(const content of revisions){", + " const blob=new Blob([content],{type:'text/plain'});", + " const now=Date.now();", + " const result=await core.localDatabase.putDBEntry({", + " _id:id,", + " path,", + " data:blob,", + " ctime:now,", + " mtime:now,", + " size:(await blob.arrayBuffer()).byteLength,", + " children:[],", + " datatype:'plain',", + " type:'plain',", + " eden:{},", + " },false,baseRev||undefined);", + " if(!result?.ok) throw new Error(`Could not store revision for ${path}`);", + " baseRev=result.rev;", + " await sleep(100);", + "}", + `await app.vault.create(path,revisions[revisions.length-1]);`, + "await core.services.fileProcessing.commitPendingFileEvents();", + "const raw=await core.localDatabase.getRaw(id,{revs_info:true});", + "const revCount=(raw._revs_info||[]).filter((e)=>e&&e.status==='available').length;", + "return JSON.stringify({revCount,id});", + "})()", + ].join(""), + env + ); +} + +async function openDocumentHistory(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(notePath)};`, + "const file=app.vault.getAbstractFileByPath(path);", + "if(!file) throw new Error('Note missing before opening history');", + "document.querySelectorAll('.modal-close-button').forEach((btn)=>btn.click());", + "await new Promise((resolve)=>setTimeout(resolve,300));", + "const leaf=app.workspace.getLeaf(false);", + "await leaf.openFile(file);", + "await new Promise((resolve)=>setTimeout(resolve,300));", + "await app.commands.executeCommandById('obsidian-livesync:livesync-history');", + "await new Promise((resolve)=>setTimeout(resolve,500));", + "const modal=document.querySelector('.modal-container .modal-title');", + "return JSON.stringify({opened:!!modal,modalTitle:modal?modal.textContent:null});", + "})()", + ].join(""), + env + ); +} + +async function main(): Promise { + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); + + const vault = await createTemporaryVault(); + let session: ObsidianLiveSyncSession | undefined; + + const screenshotDir = + process.env.E2E_OBSIDIAN_HISTORY_SCREENSHOT_DIR ?? + join(process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e", "document-history-nav"); + const reportPath = process.env.E2E_OBSIDIAN_HISTORY_REPORT ?? join(screenshotDir, "report.txt"); + + async function captureStep(page: import("playwright").Page, step: string): Promise { + await mkdir(screenshotDir, { recursive: true }); + const path = join(screenshotDir, `${step}.png`); + await page.screenshot({ path, fullPage: true }); + console.log(`Screenshot: ${path}`); + return path; + } + + try { + console.log(`Using Obsidian executable: ${binary}`); + console.log(`Temporary vault: ${vault.path}`); + + session = await startObsidianLiveSyncSession({ + binary, + cliBinary: cli.binary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: { + doctorProcessedVersion: "1.0.0", + isConfigured: true, + liveSync: false, + remoteType: "", + couchDB_URI: "", + couchDB_DBNAME: "", + couchDB_USER: "", + couchDB_PASSWORD: "", + remoteConfigurations: {}, + activeConfigurationId: "", + notifyThresholdOfRemoteStorageSize: -1, + periodicReplication: false, + syncAfterMerge: false, + syncOnEditorSave: false, + syncOnFileOpen: false, + syncOnSave: false, + syncOnStart: false, + }, + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), + }); + await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + + await dismissWelcomeWizard(session.remoteDebuggingPort); + + const revisionInfo = await seedRevisions(cli.binary, session.cliEnv); + console.log(`Seeded local history: ${revisionInfo.revCount} revisions for ${revisionInfo.id}`); + assertEqual(revisionInfo.revCount, revisions.length, "Unexpected number of seeded revisions."); + + const opened = await openDocumentHistory(cli.binary, session.cliEnv); + assertEqual(opened.opened, true, "Document History modal did not open."); + assertEqual(opened.modalTitle, "Document History", "Unexpected modal title."); + + const report = await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const modal = page.locator(".modal-container").filter({ hasText: "Document History" }); + await modal.waitFor({ state: "visible", timeout: 10000 }); + + const revNavRow = modal.locator(".history-rev-nav-row"); + await revNavRow.waitFor({ state: "visible", timeout: 10000 }); + + const indicator = revNavRow.locator(".history-rev-indicator"); + const initialIndicator = (await indicator.innerText()).trim(); + + const prevBtn = revNavRow.locator(".history-rev-nav-btn").first(); + const nextBtn = revNavRow.locator(".history-rev-nav-btn").last(); + const range = revNavRow.locator('input[type="range"]'); + + assertTrue(/Rev \d+\/\d+/.test(initialIndicator), `Unexpected initial indicator: ${initialIndicator}`); + + const initialRange = await range.inputValue(); + + const screenshotPaths: string[] = []; + screenshotPaths.push(await captureStep(page, "01-initial-latest-rev")); + assertEqual(initialIndicator, "Rev 3/3", "History did not open at the latest revision."); + assertEqual(initialRange, "2", "History slider did not open at the latest revision."); + assertTrue( + !(await prevBtn.isDisabled()), + "Older revision button should be enabled at the latest revision." + ); + assertTrue(await nextBtn.isDisabled(), "Newer revision button should be disabled at the latest revision."); + + await prevBtn.click(); + await page.waitForTimeout(1000); + const afterPrevIndicator = (await indicator.innerText()).trim(); + const afterPrevRange = await range.inputValue(); + assertTrue( + Number(afterPrevRange) < Number(initialRange), + `◀ did not move to an older revision. before=${initialRange}, after=${afterPrevRange}` + ); + assertTrue(afterPrevIndicator !== initialIndicator, "◀ did not update Rev indicator."); + assertTrue(!(await prevBtn.isDisabled()), "Older revision button was disabled before the oldest revision."); + assertTrue(!(await nextBtn.isDisabled()), "Newer revision button was not enabled after moving backwards."); + screenshotPaths.push(await captureStep(page, "02-after-click-older-rev")); + + await prevBtn.click(); + await page.waitForTimeout(1000); + assertEqual(await range.inputValue(), "0", "◀ did not reach the oldest revision."); + assertTrue(await prevBtn.isDisabled(), "Older revision button should be disabled at the oldest revision."); + assertTrue( + !(await nextBtn.isDisabled()), + "Newer revision button should be enabled at the oldest revision." + ); + screenshotPaths.push(await captureStep(page, "03-at-oldest-rev")); + + await nextBtn.click(); + await page.waitForTimeout(1000); + await nextBtn.click(); + await page.waitForTimeout(1000); + const afterNextIndicator = (await indicator.innerText()).trim(); + const afterNextRange = await range.inputValue(); + assertEqual(afterNextRange, initialRange, "â–ļ did not return to the original revision."); + assertEqual(afterNextIndicator, initialIndicator, "â–ļ did not restore the Rev indicator."); + assertTrue( + await nextBtn.isDisabled(), + "Newer revision button should be disabled after returning to latest." + ); + screenshotPaths.push(await captureStep(page, "04-after-click-newer-rev")); + + const searchInput = modal.locator(".history-search-input"); + await searchInput.fill("keyword"); + await page.waitForTimeout(1500); + + const searchIndicator = modal.locator(".history-search-result-indicator"); + const searchText = (await searchIndicator.innerText()).trim(); + assertTrue(/matches/.test(searchText), `Search indicator did not report matches: ${searchText}`); + screenshotPaths.push(await captureStep(page, "05-after-search-keyword")); + + const searchPrev = modal.locator(".history-search-row button").nth(0); + const searchNext = modal.locator(".history-search-row button").nth(1); + assertTrue(!(await searchPrev.isDisabled()), "Search ▲ should be enabled when matches exist."); + assertTrue(!(await searchNext.isDisabled()), "Search â–ŧ should be enabled when matches exist."); + + await searchNext.click(); + await page.waitForTimeout(1000); + + const afterSearchIndicator = (await searchIndicator.innerText()).trim(); + assertTrue( + /1\/\d+ matches/.test(afterSearchIndicator), + `Search navigation failed: ${afterSearchIndicator}` + ); + screenshotPaths.push(await captureStep(page, "06-after-search-next-match")); + + return [ + `initialIndicator: ${initialIndicator}`, + `afterPrevIndicator: ${afterPrevIndicator}`, + `afterNextIndicator: ${afterNextIndicator}`, + `searchIndicator: ${searchText}`, + `afterSearchIndicator: ${afterSearchIndicator}`, + "", + "Screenshots:", + ...screenshotPaths.map((p) => `- ${p}`), + ].join("\n"); + }); + + await writeFile(reportPath, report, "utf-8"); + console.log(`Document History UI test passed.`); + console.log(`Report: ${reportPath}`); + console.log(`Screenshots: ${screenshotDir}`); + } finally { + if (session) await session.app.stop(); + await vault.dispose(); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts b/test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts index ae645e1e..4af593c8 100644 --- a/test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts +++ b/test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts @@ -1,5 +1,11 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; +import { + assertLocatorHasMinimumTouchTarget, + assertLocatorWithinSafeArea, + assertLocatorWithinViewport, + assertNoHorizontalOverflow, +} from "@vrtmrz/obsidian-test-session"; import { evalObsidianJson } from "../runner/cli.ts"; import { assertCouchDbReachable, @@ -13,7 +19,10 @@ import { import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { assertEqual, + assertE2eCompatibilityMarker, configureCouchDb, + createE2eCouchDbPluginData, + createE2eObsidianDeviceLocalState, prepareRemote, pushLocalChanges, waitForLiveSyncCoreReady, @@ -21,7 +30,14 @@ import { type LocalDatabaseEntry, } from "../runner/liveSyncWorkflow.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; -import { clickJsonResolveOption, obsidianRemoteDebuggingPort } from "../runner/ui.ts"; +import { + captureObsidianPage, + captureJsonResolveDialogue, + clickJsonResolveOption, + obsidianRemoteDebuggingPort, + withObsidianPage, +} from "../runner/ui.ts"; +import { iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts"; import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000"; @@ -43,6 +59,7 @@ const mergeJsonPath = ".obsidian/livesync-e2e-merge.json"; const manualMergeJsonPath = ".obsidian/livesync-e2e-manual-merge.json"; const targetPath = ".obsidian/livesync-targeted/only-a.json"; const hiddenFileCliTimeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_CLI_TIMEOUT_MS ?? 90000); +const hiddenFileInitialisationStateKey = "__livesyncE2EHiddenFileInitialisation"; type RunnerContext = { binary: string; @@ -300,31 +317,33 @@ async function startConfiguredSession( vault: TemporaryVault, overrides: Record = {} ): Promise { + const couchDbSettings = { + uri: context.couchDb.uri, + username: context.couchDb.username, + password: context.couchDb.password, + dbName: context.dbName, + }; + const hiddenFileSettings = { + syncInternalFiles: true, + syncInternalFilesBeforeReplication: true, + watchInternalFileChanges: false, + syncInternalFilesTargetPatterns: "", + ...overrides, + }; const session = await startObsidianLiveSyncSession({ binary: context.binary, cliBinary: context.cliBinary, vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + // A fresh Vault waits for onboarding before opening its local database. + // Seed the same isolated settings used by configureCouchDb so that the + // application lifecycle can become ready without a user interaction. + pluginData: createE2eCouchDbPluginData(couchDbSettings, hiddenFileSettings), + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), }); await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); - await configureCouchDb( - context.cliBinary, - session.cliEnv, - { - uri: context.couchDb.uri, - username: context.couchDb.username, - password: context.couchDb.password, - dbName: context.dbName, - }, - { - syncInternalFiles: true, - syncInternalFilesBeforeReplication: true, - watchInternalFileChanges: false, - syncInternalFilesTargetPatterns: "", - ...overrides, - } - ); - await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); + await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv); + await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, hiddenFileSettings); await prepareRemote(context.cliBinary, session.cliEnv); return session; } @@ -431,6 +450,7 @@ async function runJsonManualConflictResolution(context: RunnerContext, vault: Te const session = await startConfiguredSession(context, vault); await createHiddenJsonConflict(context, session, vault, manualMergeJsonPath, base, left, right); await openHiddenJsonResolveModal(context.cliBinary, session.cliEnv, manualMergeJsonPath); + const screenshotPath = await captureJsonResolveDialogue(obsidianRemoteDebuggingPort()); await clickJsonResolveOption(obsidianRemoteDebuggingPort(), "AB"); const merged = await waitForPathContent(vault.path, manualMergeJsonPath, (content) => @@ -442,7 +462,7 @@ async function runJsonManualConflictResolution(context: RunnerContext, vault: Te assertEqual(parsed.shared, "right", "Manual JSON conflict resolution did not apply the selected merged result."); assertEqual(parsed.fromA, true, "Manual JSON conflict resolution lost the first-side value."); assertEqual(parsed.fromB, true, "Manual JSON conflict resolution lost the second-side value."); - console.log("Hidden JSON conflict modal applied the selected merged result."); + console.log(`Hidden JSON conflict modal applied the selected merged result. Screenshot: ${screenshotPath}`); } async function runTargetMismatch( @@ -489,6 +509,342 @@ async function runTargetMismatch( console.log("Hidden target mismatch respected per-device target patterns, then applied after enabling the target."); } +async function setHiddenFileNoticeFixtures(port: number, itemIds: string[], includeRestart: boolean): Promise { + await withObsidianPage(port, async (page) => { + await page.evaluate( + ({ nextItemIds, nextIncludeRestart }) => { + const obsidianApp = (globalThis as typeof globalThis & { app: any }).app; + const plugin = obsidianApp.plugins.plugins["obsidian-livesync"]; + const core = plugin.core; + const addOn = core.getAddOn("HiddenFileSync"); + for (const id of ["alpha", "beta", "gamma"]) { + const pluginId = `livesync-e2e-${id}`; + obsidianApp.plugins.manifests[pluginId] = { + id: pluginId, + name: `E2E ${id[0]?.toUpperCase()}${id.slice(1)}`, + version: "1.0.0", + minAppVersion: "1.0.0", + description: "E2E fixture", + author: "Self-hosted LiveSync", + isDesktopOnly: false, + dir: `.obsidian/plugins/${pluginId}`, + }; + obsidianApp.plugins.enabledPlugins.add(pluginId); + } + addOn.queuedNotificationFiles.clear(); + for (const id of nextItemIds) { + addOn.queuedNotificationFiles.add(`.obsidian/plugins/livesync-e2e-${id}`); + } + if (nextIncludeRestart) { + addOn.queuedNotificationFiles.add(core.services.API.getSystemConfigDir()); + } + addOn.notifyConfigChange(); + }, + { nextItemIds: itemIds, nextIncludeRestart: includeRestart } + ); + }); +} + +async function clearHiddenFileNoticeFixtures(port: number): Promise { + await withObsidianPage(port, async (page) => { + await page.evaluate(() => { + const obsidianApp = (globalThis as typeof globalThis & { app: any }).app; + const plugin = obsidianApp.plugins.plugins["obsidian-livesync"]; + plugin.core.services.context.noticeGroups.hide("hidden-file-changes"); + for (const id of ["alpha", "beta", "gamma"]) { + const pluginId = `livesync-e2e-${id}`; + obsidianApp.plugins.enabledPlugins.delete(pluginId); + delete obsidianApp.plugins.manifests[pluginId]; + } + }); + }); +} + +async function runInitialisationNoticeGrouping(context: RunnerContext, vault: TemporaryVault): Promise { + const session = await startConfiguredSession(context, vault, { + syncInternalFiles: false, + syncInternalFilesBeforeReplication: false, + }); + const port = session.remoteDebuggingPort; + const timeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_NOTICE_TIMEOUT_MS ?? 10_000); + try { + await withObsidianPage(port, async (page) => { + const deadline = Date.now() + timeoutMs; + while ((await page.locator(".notice:visible").count()) > 0 && Date.now() < deadline) { + await page.locator(".notice:visible").first().click({ + force: true, + position: { x: 2, y: 2 }, + timeout: timeoutMs, + }); + } + assertEqual( + await page.locator(".notice:visible").count(), + 0, + "Transient start-up Notices remained before the Hidden File Sync initialisation check." + ); + }); + await withObsidianPage(port, async (page) => { + await page.evaluate((stateKey) => { + const obsidianApp = (globalThis as typeof globalThis & { app: any }).app; + const plugin = obsidianApp.plugins.plugins["obsidian-livesync"]; + const core = plugin.core; + const addOn = core.getAddOn("HiddenFileSync"); + const setting = core.services.setting; + const originalApplyPartial = setting.applyPartial; + const originalRebuildMerging = addOn.rebuildMerging; + const state = { + done: false, + reachedPreparation: false, + reachedInitialisation: false, + maxVisibleProgressNotices: 0, + visibleProgressNoticeTexts: [] as string[], + sawStandaloneGatheringNotice: false, + sawStandaloneRestartNotice: false, + releasePreparation: undefined as (() => void) | undefined, + releaseInitialisation: undefined as (() => void) | undefined, + error: undefined as string | undefined, + }; + (globalThis as unknown as Record)[stateKey] = state; + + const observer = new MutationObserver(() => { + const notices = Array.from(document.querySelectorAll(".notice")); + const progressNotices = notices.filter((notice) => notice.textContent?.includes("[⚙")); + state.sawStandaloneGatheringNotice ||= notices.some((notice) => + notice.textContent?.includes("Gathering files for enabling Hidden File Sync") + ); + state.sawStandaloneRestartNotice ||= notices.some((notice) => + notice.textContent?.includes("Done! Restarting the app is strongly recommended!") + ); + if (progressNotices.length > state.maxVisibleProgressNotices) { + state.maxVisibleProgressNotices = progressNotices.length; + state.visibleProgressNoticeTexts = progressNotices.map( + (notice) => notice.textContent?.trim() ?? "" + ); + } + }); + observer.observe(document.body, { + childList: true, + subtree: true, + characterData: true, + }); + + setting.applyPartial = async (...args: unknown[]) => { + const update = args[0] as { syncInternalFiles?: unknown } | undefined; + if (update?.syncInternalFiles === true && !state.reachedPreparation) { + state.reachedPreparation = true; + await new Promise((resolve) => { + state.releasePreparation = resolve; + }); + } + return await originalApplyPartial.apply(setting, args); + }; + + addOn.rebuildMerging = async (...args: unknown[]) => { + state.reachedInitialisation = true; + await new Promise((resolve) => { + state.releaseInitialisation = resolve; + }); + return await originalRebuildMerging.apply(addOn, args); + }; + + void core.services.setting + .enableOptionalFeature("MERGE") + .then( + () => { + state.done = true; + }, + (error: unknown) => { + state.error = error instanceof Error ? error.message : String(error); + state.done = true; + } + ) + .finally(() => { + setting.applyPartial = originalApplyPartial; + addOn.rebuildMerging = originalRebuildMerging; + const notices = Array.from(document.querySelectorAll(".notice")); + const progressNotices = notices.filter((notice) => notice.textContent?.includes("[⚙")); + state.sawStandaloneGatheringNotice ||= notices.some((notice) => + notice.textContent?.includes("Gathering files for enabling Hidden File Sync") + ); + state.sawStandaloneRestartNotice ||= notices.some((notice) => + notice.textContent?.includes("Done! Restarting the app is strongly recommended!") + ); + if (progressNotices.length > state.maxVisibleProgressNotices) { + state.maxVisibleProgressNotices = progressNotices.length; + state.visibleProgressNoticeTexts = progressNotices.map( + (notice) => notice.textContent?.trim() ?? "" + ); + } + observer.disconnect(); + }); + }, hiddenFileInitialisationStateKey); + }); + + const screenshotPath = await captureObsidianPage( + port, + "hidden-file-initial-scan-progress.png", + async (page) => { + await page.waitForFunction( + (stateKey) => + (globalThis as unknown as Record)[ + stateKey + ]?.reachedPreparation === true, + hiddenFileInitialisationStateKey, + { timeout: timeoutMs } + ); + const progressNotices = page.locator(".notice").filter({ hasText: "[⚙" }); + await progressNotices.first().waitFor({ state: "visible", timeout: timeoutMs }); + assertEqual( + await progressNotices.count(), + 1, + "Hidden File Sync showed more than one progress Notice before saving its enabled setting." + ); + await progressNotices + .filter({ hasText: "Preparing Hidden File Sync..." }) + .waitFor({ state: "visible", timeout: timeoutMs }); + } + ); + + const result = await withObsidianPage(port, async (page) => { + await page.evaluate((stateKey) => { + const state = (globalThis as unknown as Record< + string, + { releasePreparation?: () => void } | undefined + >)[stateKey]; + state?.releasePreparation?.(); + }, hiddenFileInitialisationStateKey); + await page.waitForFunction( + (stateKey) => + (globalThis as unknown as Record)[ + stateKey + ]?.reachedInitialisation === true, + hiddenFileInitialisationStateKey, + { timeout: timeoutMs } + ); + const progressNotices = page.locator(".notice").filter({ hasText: "[⚙" }); + assertEqual( + await progressNotices.count(), + 1, + "Hidden File Sync replaced its parent progress Notice when the first child phase started." + ); + await page.evaluate((stateKey) => { + const state = ( + globalThis as unknown as Record void } | undefined> + )[stateKey]; + state?.releaseInitialisation?.(); + }, hiddenFileInitialisationStateKey); + await page.waitForFunction( + (stateKey) => + (globalThis as unknown as Record)[stateKey]?.done === true, + hiddenFileInitialisationStateKey, + { timeout: hiddenFileCliTimeoutMs } + ); + return await page.evaluate( + (stateKey) => + ( + globalThis as unknown as Record< + string, + { + error?: string; + maxVisibleProgressNotices: number; + visibleProgressNoticeTexts: string[]; + sawStandaloneGatheringNotice: boolean; + sawStandaloneRestartNotice: boolean; + } + > + )[stateKey], + hiddenFileInitialisationStateKey + ); + }); + if (result.error) { + throw new Error(`Hidden File Sync initialisation failed: ${result.error}`); + } + assertEqual( + result.maxVisibleProgressNotices, + 1, + `Hidden File Sync split initialisation across multiple progress Notices: ${JSON.stringify( + result.visibleProgressNoticeTexts + )}` + ); + assertEqual( + result.sawStandaloneGatheringNotice, + false, + "Hidden File Sync showed the old standalone gathering Notice." + ); + assertEqual( + result.sawStandaloneRestartNotice, + false, + "Hidden File Sync showed the old standalone restart recommendation." + ); + console.log( + `Hidden File Sync showed one progress Notice before settings were saved and retained it throughout initialisation. Screenshot: ${screenshotPath}` + ); + } finally { + await session.app.stop(); + } +} + +async function runConfigurationNoticeGrouping(context: RunnerContext, vault: TemporaryVault): Promise { + const session = await startConfiguredSession(context, vault); + const port = session.remoteDebuggingPort; + const timeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_NOTICE_TIMEOUT_MS ?? 10_000); + try { + await setObsidianMobileTestMode(port, true, timeoutMs); + await setHiddenFileNoticeFixtures(port, ["alpha", "beta"], true); + + await withObsidianPage(port, async (page) => { + const visibleGroups = page.locator(".notice:has(.vpk-keyed-notice-group):visible"); + await visibleGroups.first().waitFor({ state: "visible", timeout: timeoutMs }); + assertEqual(await visibleGroups.count(), 1, "Hidden File Sync created more than one visible Notice group."); + + const notice = visibleGroups.first(); + const rows = notice.locator(".vpk-keyed-notice-group__item"); + assertEqual(await rows.count(), 3, "Hidden File Sync did not group every configuration-change action."); + await notice.getByText("Files in E2E Alpha were updated.", { exact: true }).waitFor(); + await notice.getByText("Files in E2E Beta were updated.", { exact: true }).waitFor(); + await notice.getByText("Other Obsidian settings files were updated.", { exact: true }).waitFor(); + + await assertLocatorWithinViewport(page, notice, { label: "Hidden File Sync notification group" }); + await assertNoHorizontalOverflow(page, notice, { label: "Hidden File Sync notification group" }); + await assertLocatorWithinSafeArea(page, notice, { + label: "Hidden File Sync notification group", + safeAreaInsets: iPhoneSafeArea, + }); + const buttons = notice.getByRole("button"); + for (let index = 0; index < (await buttons.count()); index += 1) { + await assertLocatorHasMinimumTouchTarget(page, buttons.nth(index), { + label: `Hidden File Sync notification action ${index + 1}`, + }); + } + + const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e"; + const screenshotPath = join(outputDirectory, "hidden-file-notice-group-mobile.png"); + await mkdir(dirname(screenshotPath), { recursive: true }); + await page.screenshot({ path: screenshotPath, fullPage: true, animations: "disabled" }); + + await rows.first().getByText("Files in E2E Alpha were updated.", { exact: true }).click(); + await notice.waitFor({ state: "hidden", timeout: timeoutMs }); + }); + + await setHiddenFileNoticeFixtures(port, ["gamma"], false); + await withObsidianPage(port, async (page) => { + const notice = page.locator(".notice:has(.vpk-keyed-notice-group):visible").first(); + await notice.waitFor({ state: "visible", timeout: timeoutMs }); + const rows = notice.locator(".vpk-keyed-notice-group__item"); + assertEqual(await rows.count(), 1, "A dismissed Hidden File Sync Notice repeated acknowledged rows."); + await rows.getByText("Files in E2E Gamma were updated.", { exact: true }).waitFor(); + }); + + console.log( + "Hidden File Sync grouped configuration notifications passed the mobile regression for issue #555." + ); + } finally { + await clearHiddenFileNoticeFixtures(port).catch(() => undefined); + await setObsidianMobileTestMode(port, false, timeoutMs).catch(() => undefined); + await session.app.stop(); + } +} + async function main(): Promise { const binary = requireObsidianBinary(); const cli = discoverObsidianCli(); @@ -516,6 +872,8 @@ async function main(): Promise { await runJsonConflictRoundTrip(context, vaultA, vaultB); await runJsonManualConflictResolution(context, vaultB); await runTargetMismatch(context, vaultA, vaultB); + await runInitialisationNoticeGrouping(context, vaultB); + await runConfigurationNoticeGrouping(context, vaultB); } finally { await vaultA.dispose(); await vaultB.dispose(); diff --git a/test/e2e-obsidian/scripts/local-suite.ts b/test/e2e-obsidian/scripts/local-suite.ts index c509edc2..9dfc225f 100644 --- a/test/e2e-obsidian/scripts/local-suite.ts +++ b/test/e2e-obsidian/scripts/local-suite.ts @@ -13,14 +13,30 @@ const testSteps: Step[] = [ : []), { name: "discover", args: ["run", "test:e2e:obsidian:discover"] }, { name: "smoke", args: ["run", "test:e2e:obsidian:smoke"] }, + { name: "onboarding invitation", args: ["run", "test:e2e:obsidian:onboarding-invitation"] }, + { name: "Svelte dialogue mounts", args: ["run", "test:e2e:obsidian:dialog-mounts"] }, + { name: "revision repair", args: ["run", "test:e2e:obsidian:revision-repair"] }, + { name: "settings UI", args: ["run", "test:e2e:obsidian:settings-ui"] }, + { name: "Review Harness", args: ["run", "test:e2e:obsidian:review-harness"] }, + { name: "P2P status pane", args: ["run", "test:e2e:obsidian:p2p-pane"] }, { name: "vault reflection", args: ["run", "test:e2e:obsidian:vault-reflection"] }, { name: "CouchDB upload", args: ["run", "test:e2e:obsidian:couchdb-upload"] }, + { + name: "manual CouchDB setup workflow", + args: ["run", "test:e2e:obsidian:couchdb-manual-setup-workflow"], + }, { name: "CLI to real Obsidian synchronisation", args: ["run", "test:e2e:obsidian:cli-to-obsidian-sync"], }, { name: "Object Storage upload", args: ["run", "test:e2e:obsidian:minio-upload"] }, + { + name: "Object Storage Setup URI workflow", + args: ["run", "test:e2e:obsidian:object-storage-setup-uri-workflow"], + }, + { name: "P2P Setup URI workflow", args: ["run", "test:e2e:obsidian:p2p-setup-uri-workflow"] }, { name: "startup scan", args: ["run", "test:e2e:obsidian:startup-scan"] }, + { name: "provisioned Setup URI workflow", args: ["run", "test:e2e:obsidian:setup-uri-workflow"] }, { name: "two-vault synchronisation", args: ["run", "test:e2e:obsidian:two-vault-sync"] }, { name: "hidden file snippet synchronisation", args: ["run", "test:e2e:obsidian:hidden-file-snippet-sync"] }, { name: "Customisation Sync", args: ["run", "test:e2e:obsidian:customisation-sync"] }, @@ -29,9 +45,11 @@ const testSteps: Step[] = [ const manageCouchDb = process.argv.includes("--manage-couchdb") || process.argv.includes("--manage-services"); const manageMinio = process.argv.includes("--manage-minio") || process.argv.includes("--manage-services"); +const manageP2P = process.argv.includes("--manage-p2p") || process.argv.includes("--manage-services"); const keepServices = process.argv.includes("--keep-services"); const keepCouchDb = keepServices || process.argv.includes("--keep-couchdb"); const keepMinio = keepServices || process.argv.includes("--keep-minio"); +const keepP2P = keepServices || process.argv.includes("--keep-p2p"); function npmBinary(): string { return process.platform === "win32" ? "npm.cmd" : "npm"; @@ -78,9 +96,18 @@ async function stopManagedMinio(): Promise { }); } +async function stopManagedP2P(): Promise { + await runStep({ + name: "stop P2P relay fixture", + args: ["run", "test:docker-p2p:stop"], + optional: true, + }); +} + async function main(): Promise { let shouldStopCouchDb = false; let shouldStopMinio = false; + let shouldStopP2P = false; try { if (manageCouchDb) { await stopManagedCouchDb(); @@ -92,11 +119,19 @@ async function main(): Promise { await runStep({ name: "start MinIO fixture", args: ["run", "test:docker-s3:start"] }); shouldStopMinio = !keepMinio; } + if (manageP2P) { + await stopManagedP2P(); + await runStep({ name: "start P2P relay fixture", args: ["run", "test:docker-p2p:start"] }); + shouldStopP2P = !keepP2P; + } for (const step of testSteps) { await runStep(step); } } finally { + if (shouldStopP2P) { + await stopManagedP2P(); + } if (shouldStopMinio) { await stopManagedMinio(); } diff --git a/test/e2e-obsidian/scripts/minio-upload.ts b/test/e2e-obsidian/scripts/minio-upload.ts index 36733fc9..b0899f12 100644 --- a/test/e2e-obsidian/scripts/minio-upload.ts +++ b/test/e2e-obsidian/scripts/minio-upload.ts @@ -1,8 +1,27 @@ +/** + * Verifies one complete Object Storage upload from a real Obsidian Vault, + * through LiveSync's local database and Journal Sync, to an S3-compatible + * service observed independently through the AWS SDK. + * + * The isolated Vault starts with Object Storage settings and the device-local + * compatibility acknowledgement already in place. Unconfigured start-up is + * intentionally inert and belongs to the onboarding scenario; compatibility + * review and visible setup have their own dedicated workflows. Supplying those + * prerequisites here keeps this scenario focused on the upload boundary. + * + * Note creation, local-database observation, one-shot synchronisation, request + * accounting, remote-object inspection, and prefix cleanup remain in one + * scenario so that a pass proves the same payload crossed every boundary. + * Separate successes would not prove that those observations belonged to the + * same upload. + */ import { evalObsidianJson } from "../runner/cli.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { assertEqual, configureObjectStorage, + createE2eObjectStoragePluginData, + createE2eObsidianDeviceLocalState, prepareRemote, pushLocalChanges, waitForLiveSyncCoreReady, @@ -17,6 +36,7 @@ import { } from "../runner/objectStorage.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { createTemporaryVault } from "../runner/vault.ts"; +import { REMOTE_ACTIVITY_EXPECTED_STATE, waitForRemoteActivityState } from "../runner/remoteActivity.ts"; process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000"; @@ -99,6 +119,11 @@ async function main(): Promise { cliBinary: cli.binary, vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: createE2eObjectStoragePluginData({ + ...objectStorage, + bucketPrefix, + }), + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), }); await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); @@ -115,13 +140,29 @@ async function main(): Promise { assertEqual(configured.liveSync, false, "LiveSync should remain disabled during this one-shot workflow."); await prepareRemote(cli.binary, session.cliEnv); + const activityBeforeUpload = await waitForRemoteActivityState( + session.remoteDebuggingPort, + REMOTE_ACTIVITY_EXPECTED_STATE.idle + ); const localEntry = await createNoteAndWaitForLocalDb(cli.binary, session.cliEnv); await pushLocalChanges(cli.binary, session.cliEnv); + const activityAfterUpload = await waitForRemoteActivityState( + session.remoteDebuggingPort, + REMOTE_ACTIVITY_EXPECTED_STATE.idle + ); + if (activityAfterUpload.requestCount <= activityBeforeUpload.requestCount) { + throw new Error("Object Storage synchronisation did not advance the tracked remote-request count."); + } + assertEqual( + activityAfterUpload.responseCount, + activityAfterUpload.requestCount, + "Object Storage remote-request counters did not rebalance after synchronisation." + ); const keys = await waitForObjectStorageObjects(bucketPrefix); console.log( - `Uploaded ${localEntry.path} through Journal Sync to ${objectStorage.bucket}/${bucketPrefix} (${keys.length} object(s))` + `Uploaded ${localEntry.path} through Journal Sync to ${objectStorage.bucket}/${bucketPrefix} (${keys.length} object(s)); tracked requests: ${activityAfterUpload.requestCount - activityBeforeUpload.requestCount}` ); } finally { if (session) { diff --git a/test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts b/test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts new file mode 100644 index 00000000..40d1b63c --- /dev/null +++ b/test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts @@ -0,0 +1,324 @@ +import { execFile } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { + assertEqual, + pushLocalChanges, + waitForLiveSyncCoreReady, + waitForLocalDatabaseEntry, +} from "../runner/liveSyncWorkflow.ts"; +import { + deleteObjectStoragePrefix, + ensureObjectStorageBucket, + listObjectStorageObjects, + loadObjectStorageConfig, + makeUniqueBucketPrefix, + type ObjectStorageConfig, +} from "../runner/objectStorage.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { + acknowledgeDisabledOptionalFeatures, + captureAndStartInitialisation, + confirmFastFetch, + confirmRebuild, + enterSetupURI, + finishInitialisation, + generateSetupURIFromDevice, + resumeCompatibilityReviewIfShown, + continueWithoutRemoteSettings, + type SetupArtifact, + type SetupCaptureNames, +} from "../runner/setupUri.ts"; +import { + captureObsidianElement, + captureObsidianPage, + obsidianRemoteDebuggingPort, + withObsidianPage, +} from "../runner/ui.ts"; +import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; + +process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000"; + +const execFileAsync = promisify(execFile); +const captures: SetupCaptureNames = { scenario: "object-storage-setup-uri", guide: "object-storage-setup" }; +const noteFromFirst = "E2E/object-storage/from-first.md"; +const noteFromSecond = "E2E/object-storage/from-second.md"; +const firstContent = + "# Object Storage from the first device\n\nThis note travelled through the first device's Setup URI.\n"; +const secondContent = "# Object Storage from the second device\n\nThis note completed the return journey.\n"; + +type RunnerContext = { + binary: string; + cliBinary: string; + activeSessions: Set; +}; + +function sessionEnvironment(port: number): NodeJS.ProcessEnv { + return { ...process.env, E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) }; +} + +function sessionPorts(): readonly [number, number] { + const first = obsidianRemoteDebuggingPort(process.env); + const second = Number(process.env.E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT ?? first + 1); + if (!Number.isInteger(second) || second < 1 || second > 65535 || second === first) { + throw new Error(`Invalid secondary Obsidian remote debugging port: ${second}`); + } + return [first, second]; +} + +async function runDeno(script: string, environment: NodeJS.ProcessEnv): Promise { + const { stdout } = await execFileAsync( + "deno", + [ + "run", + "--minimum-dependency-age=0", + "--config=utils/flyio/deno.jsonc", + "--frozen", + "--lock=utils/flyio/deno.lock", + "--allow-env", + script, + ], + { cwd: process.cwd(), env: environment, maxBuffer: 4 * 1024 * 1024 } + ); + return stdout; +} + +async function generateBootstrapSetupURI( + objectStorage: ObjectStorageConfig, + bucketPrefix: string +): Promise { + const setupPassphrase = randomBytes(24).toString("base64url"); + const output = await runDeno("utils/setup/generate_setup_uri.ts", { + ...process.env, + remote_type: "s3", + endpoint: objectStorage.endpoint, + access_key: objectStorage.accessKey, + secret_key: objectStorage.secretKey, + bucket: objectStorage.bucket, + region: objectStorage.region, + force_path_style: String(objectStorage.forcePathStyle), + bucket_prefix: bucketPrefix, + passphrase: randomBytes(24).toString("base64url"), + uri_passphrase: setupPassphrase, + }); + const setupURI = output.split(/\r?\n/u).find((line) => line.startsWith("obsidian://setuplivesync?settings=")); + if (!setupURI) throw new Error("The public Setup URI generator did not emit an Object Storage Setup URI."); + return { setupURI, setupPassphrase }; +} + +async function startSession( + context: RunnerContext, + vault: TemporaryVault, + port: number +): Promise { + const session = await startObsidianLiveSyncSession({ + binary: context.binary, + cliBinary: context.cliBinary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + env: sessionEnvironment(port), + }); + context.activeSessions.add(session); + return session; +} + +async function stopSessions(context: RunnerContext): Promise { + for (const session of [...context.activeSessions]) { + await stopSession(context, session); + } +} + +async function stopSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise { + if (!context.activeSessions.has(session)) return; + await session.app.stop(); + context.activeSessions.delete(session); +} + +async function writeNote( + cliBinary: string, + environment: NodeJS.ProcessEnv, + path: string, + content: string +): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const content=${JSON.stringify(content)};`, + "const folder=path.split('/').slice(0,-1).join('/');", + "if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);", + "const existing=app.vault.getAbstractFileByPath(path);", + "if(existing) await app.vault.modify(existing,content);", + "else await app.vault.create(path,content);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + environment + ); + await waitForLocalDatabaseEntry(cliBinary, environment, path); +} + +async function waitForPathContent(vault: TemporaryVault, path: string, expected: string): Promise { + const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000); + let lastContent = ""; + while (Date.now() < deadline) { + try { + lastContent = await readFile(join(vault.path, path), "utf8"); + if (lastContent === expected) return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`); +} + +async function waitForObjectStorageData(config: ObjectStorageConfig, prefix: string): Promise { + const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_OBJECT_STORAGE_TIMEOUT_MS ?? 30000); + while (Date.now() < deadline) { + if ((await listObjectStorageObjects(config, prefix)).length > 0) return; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error(`Timed out waiting for Object Storage data under ${prefix}.`); +} + +async function captureNote(port: number, path: string, text: string, filename: string): Promise { + await withObsidianPage(port, async (page) => { + await page.evaluate((notePath) => { + const obsidian = globalThis as typeof globalThis & { + app?: { + workspace?: { openLinkText(path: string, sourcePath: string, newLeaf: boolean): Promise }; + }; + }; + return obsidian.app?.workspace?.openLinkText(notePath, "", false); + }, path); + }); + await captureObsidianPage(port, `${filename}.full.png`, async (page) => { + await page.getByText(text, { exact: false }).first().waitFor({ state: "visible", timeout: 30000 }); + }); + return await captureObsidianElement(port, filename, (page) => page.locator(".workspace-leaf.mod-active").first()); +} + +async function main(): Promise { + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); + + const objectStorage = await loadObjectStorageConfig(); + const bucketPrefix = makeUniqueBucketPrefix("setup-uri-workflow"); + const bootstrapArtifact = await generateBootstrapSetupURI(objectStorage, bucketPrefix); + const vaultA = await createTemporaryVault(); + const vaultB = await createTemporaryVault(); + const [portA, portB] = sessionPorts(); + const context: RunnerContext = { binary, cliBinary: cli.binary, activeSessions: new Set() }; + const screenshots: string[] = []; + + try { + await ensureObjectStorageBucket(objectStorage); + console.log(`Temporary Object Storage target: ${objectStorage.bucket}/${bucketPrefix}`); + + const sessionA = await startSession(context, vaultA, portA); + screenshots.push(await enterSetupURI(portA, "new", bootstrapArtifact, captures)); + screenshots.push(await captureAndStartInitialisation(portA, "new", captures)); + screenshots.push(await confirmRebuild(portA, captures)); + screenshots.push(await continueWithoutRemoteSettings(portA, captures)); + screenshots.push(await acknowledgeDisabledOptionalFeatures(portA, captures)); + const firstState = await finishInitialisation(portA, context.cliBinary, sessionA.cliEnv); + await resumeCompatibilityReviewIfShown(portA); + assertEqual( + firstState.endpoint, + objectStorage.endpoint, + "The first device did not activate the Object Storage endpoint." + ); + assertEqual( + firstState.bucket, + objectStorage.bucket, + "The first device did not activate the Object Storage bucket." + ); + assertEqual( + firstState.bucketPrefix, + bucketPrefix, + "The first device did not activate the unique bucket prefix." + ); + + await writeNote(context.cliBinary, sessionA.cliEnv, noteFromFirst, firstContent); + await pushLocalChanges(context.cliBinary, sessionA.cliEnv); + await waitForObjectStorageData(objectStorage, bucketPrefix); + const generated = await generateSetupURIFromDevice(portA, randomBytes(24).toString("base64url"), captures); + if (generated.artifact.setupURI === bootstrapArtifact.setupURI) { + throw new Error("The first device returned the bootstrap Setup URI instead of generating a new one."); + } + screenshots.push(...generated.screenshots); + await stopSession(context, sessionA); + + const sessionB = await startSession(context, vaultB, portB); + screenshots.push(await enterSetupURI(portB, "existing", generated.artifact, captures)); + screenshots.push(await captureAndStartInitialisation(portB, "existing", captures)); + screenshots.push(...(await confirmFastFetch(portB, captures))); + const secondState = await finishInitialisation(portB, context.cliBinary, sessionB.cliEnv); + await resumeCompatibilityReviewIfShown(portB); + assertEqual( + secondState.endpoint, + objectStorage.endpoint, + "The second device did not import the Object Storage endpoint." + ); + assertEqual( + secondState.bucketPrefix, + bucketPrefix, + "The second device did not import the unique bucket prefix." + ); + await pushLocalChanges(context.cliBinary, sessionB.cliEnv); + await waitForPathContent(vaultB, noteFromFirst, firstContent); + screenshots.push( + await captureNote( + portB, + noteFromFirst, + "Object Storage from the first device", + "guide-object-storage-setup-first-to-second.png" + ) + ); + + await writeNote(context.cliBinary, sessionB.cliEnv, noteFromSecond, secondContent); + await pushLocalChanges(context.cliBinary, sessionB.cliEnv); + await stopSession(context, sessionB); + + const returningSessionA = await startSession(context, vaultA, portA); + await waitForLiveSyncCoreReady(context.cliBinary, returningSessionA.cliEnv); + await resumeCompatibilityReviewIfShown(portA); + await pushLocalChanges(context.cliBinary, returningSessionA.cliEnv); + await waitForPathContent(vaultA, noteFromSecond, secondContent); + screenshots.push( + await captureNote( + portA, + noteFromSecond, + "Object Storage from the second device", + "guide-object-storage-setup-second-to-first.png" + ) + ); + + console.log( + `Object Storage Setup URI and two-device roundtrip succeeded. Screenshots: ${screenshots.join(", ")}` + ); + } finally { + await stopSessions(context).catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + await vaultA.dispose(); + await vaultB.dispose(); + if (process.env.E2E_OBSIDIAN_KEEP_OBJECT_STORAGE !== "true") { + await deleteObjectStoragePrefix(objectStorage, bucketPrefix).catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + } + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/test/e2e-obsidian/scripts/onboarding-invitation.ts b/test/e2e-obsidian/scripts/onboarding-invitation.ts new file mode 100644 index 00000000..fbc8189a --- /dev/null +++ b/test/e2e-obsidian/scripts/onboarding-invitation.ts @@ -0,0 +1,265 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { + assertLocatorHasMinimumTouchTarget, + assertLocatorWithinSafeArea, + assertNoHorizontalOverflow, +} from "@vrtmrz/obsidian-test-session"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { assertMobileDialogueLayout, iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { captureObsidianDialogue, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts"; +import { createTemporaryVault } from "../runner/vault.ts"; + +const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_ONBOARDING_TIMEOUT_MS ?? 15000); +const markerPath = "E2E/unconfigured-startup-must-not-scan.md"; + +type UnconfiguredStartupEvidence = { + configured: boolean; + markerInDatabase: boolean; + offlineScanInitialised: boolean; + recommendedDefaults: { + usePluginSyncV2: boolean; + handleFilenameCaseSensitive: boolean; + }; +}; + +type ObsidianTestApp = { + setting?: { + open(): void; + openTabById(tabId: string): void; + }; +}; + +type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp }; + +async function writeMarker(vaultPath: string): Promise { + const fullPath = join(vaultPath, markerPath); + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, "# This file must remain outside the database until setup completes.\n", "utf8"); +} + +async function inspectUnconfiguredStartup( + cliBinary: string, + env: NodeJS.ProcessEnv +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + `const markerPath=${JSON.stringify(markerPath)};`, + "let entry=false;", + "try{entry=await core.localDatabase.getDBEntry(markerPath,undefined,false,false);}catch{}", + "let initialised=false;", + "try{initialised=(await core.kvDB.get('initialized'))===true;}catch{}", + "const settings=core.services.setting.currentSettings();", + "return JSON.stringify({", + "configured:settings?.isConfigured===true,", + "markerInDatabase:Boolean(entry&&entry._id),", + "offlineScanInitialised:initialised,", + "recommendedDefaults:{", + "usePluginSyncV2:settings?.usePluginSyncV2,", + "handleFilenameCaseSensitive:settings?.handleFilenameCaseSensitive,", + "},", + "});", + "})()", + ].join(""), + env + ); +} + +function onboardingNotice(page: Parameters[1]>[0]) { + return page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" }); +} + +function onboardingDialogue(page: Parameters[1]>[0]) { + return page.locator(".modal-container").filter({ hasText: "Welcome to Self-hosted LiveSync" }); +} + +async function requireInvitationWithoutDialogue(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const invitation = onboardingNotice(page); + await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await invitation.locator(".sls-onboarding-invitation-action").waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + if ((await onboardingDialogue(page).count()) !== 0) { + throw new Error("The onboarding dialogue opened before the user selected the invitation."); + } + const compatibilityReview = page.locator(".modal-container").filter({ + hasText: "Synchronisation paused for compatibility review", + }); + if ((await compatibilityReview.count()) !== 0) { + throw new Error("A new unconfigured Vault was incorrectly treated as an existing compatibility state."); + } + }); +} + +async function captureDesktopInvitation(): Promise { + return await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + "onboarding-invitation-desktop.png", + async (page) => { + const invitation = onboardingNotice(page); + await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await assertNoHorizontalOverflow(page, invitation, { label: "desktop onboarding invitation" }); + } + ); +} + +async function captureAndSelectMobileInvitation(): Promise { + const port = obsidianRemoteDebuggingPort(); + await setObsidianMobileTestMode(port, true, uiTimeoutMs); + const screenshot = await captureObsidianDialogue(port, "onboarding-invitation-mobile.png", async (page) => { + const invitation = onboardingNotice(page); + const action = invitation.locator(".sls-onboarding-invitation-action"); + await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await assertLocatorWithinSafeArea(page, invitation, { + label: "mobile onboarding invitation", + safeAreaInsets: iPhoneSafeArea, + }); + await assertNoHorizontalOverflow(page, invitation, { label: "mobile onboarding invitation" }); + await assertLocatorHasMinimumTouchTarget(page, action, { + label: "mobile onboarding invitation action", + }); + }); + await withObsidianPage(port, async (page) => { + const invitation = onboardingNotice(page); + await invitation.locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs }); + await invitation.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + return screenshot; +} + +async function captureAndCloseIntro(filename: string, mobile: boolean): Promise { + const port = obsidianRemoteDebuggingPort(); + const screenshot = await captureObsidianDialogue(port, filename, async (page) => { + const container = onboardingDialogue(page); + await container.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await container.getByText("I am setting this up for the first time", { exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await container + .getByText("I am adding a device to an existing synchronisation setup", { exact: true }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + if (mobile) await assertMobileDialogueLayout(page, container, "mobile onboarding introduction"); + }); + await withObsidianPage(port, async (page) => { + const container = onboardingDialogue(page); + await container.getByRole("button", { name: "No, please take me back" }).click({ timeout: uiTimeoutMs }); + await container.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + return screenshot; +} + +async function openOnboardingFromSettings(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + await page.evaluate(() => { + const setting = (globalThis as ObsidianTestGlobal).app?.setting; + if (setting === undefined) throw new Error("Obsidian settings are unavailable"); + setting.open(); + setting.openTabById("obsidian-livesync"); + }); + + const liveSyncSettings = page.locator(".sls-setting"); + await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await liveSyncSettings.locator('.sls-setting-menu-btn[title="Setup"]').click({ timeout: uiTimeoutMs }); + + const onboardingSetting = liveSyncSettings.locator(".setting-item").filter({ + has: page.locator(".setting-item-name").filter({ hasText: "Rerun Onboarding Wizard" }), + }); + await onboardingSetting.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await onboardingSetting + .getByRole("button", { name: "Rerun Wizard", exact: true }) + .click({ timeout: uiTimeoutMs }); + await onboardingDialogue(page).waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); +} + +async function dismissVisibleNotices(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const notices = page.locator(".notice:visible"); + while ((await notices.count()) > 0) { + const noticeCount = await page.locator(".notice").count(); + await notices.first().click({ position: { x: 8, y: 8 }, timeout: uiTimeoutMs }); + await page.waitForFunction( + (previousCount) => document.querySelectorAll(".notice").length < previousCount, + noticeCount, + { timeout: uiTimeoutMs } + ); + } + }); +} + +async function closeSettings(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const settingsContainer = page.locator(".modal-container").filter({ + has: page.locator(".sls-setting"), + }); + await settingsContainer.locator(".modal-close-button").click({ timeout: uiTimeoutMs }); + await settingsContainer.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); +} + +async function main(): Promise { + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); + const vault = await createTemporaryVault(); + let session: ObsidianLiveSyncSession | undefined; + try { + await writeMarker(vault.path); + session = await startObsidianLiveSyncSession({ + binary, + cliBinary: cli.binary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + }); + + await requireInvitationWithoutDialogue(); + const evidence = await inspectUnconfiguredStartup(cli.binary, session.cliEnv); + if ( + evidence.configured || + evidence.markerInDatabase || + evidence.offlineScanInitialised || + evidence.recommendedDefaults.usePluginSyncV2 !== true || + evidence.recommendedDefaults.handleFilenameCaseSensitive !== false + ) { + throw new Error(`Fresh Vault startup state did not match its contract: ${JSON.stringify(evidence)}`); + } + console.log(`Fresh Vault startup evidence: ${JSON.stringify(evidence)}`); + + const desktopInvitation = await captureDesktopInvitation(); + const mobileInvitation = await captureAndSelectMobileInvitation(); + const mobileIntro = await captureAndCloseIntro("onboarding-intro-mobile.png", true); + await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), false, uiTimeoutMs); + await openOnboardingFromSettings(); + const settingsIntro = await captureAndCloseIntro("onboarding-intro-settings-desktop.png", false); + await dismissVisibleNotices(); + await closeSettings(); + + console.log( + `Onboarding remained opt-in and kept unconfigured startup inert. Screenshots: ${[ + desktopInvitation, + mobileInvitation, + mobileIntro, + settingsIntro, + ].join(", ")}` + ); + } finally { + if (session) { + await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), false, uiTimeoutMs).catch(() => undefined); + await session.app.stop(); + } + await vault.dispose(); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/test/e2e-obsidian/scripts/p2p-connection-check.ts b/test/e2e-obsidian/scripts/p2p-connection-check.ts new file mode 100644 index 00000000..b935e34a --- /dev/null +++ b/test/e2e-obsidian/scripts/p2p-connection-check.ts @@ -0,0 +1,406 @@ +import { spawn } from "node:child_process"; +import { mkdir, readFile, stat } from "node:fs/promises"; +import { createServer, type Server } from "node:http"; +import { connect } from "node:net"; +import { dirname, extname, relative, resolve } from "node:path"; +import { chromium, type Browser, type ConsoleMessage, type Page } from "playwright"; + +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { + acknowledgeDisabledOptionalFeatures, + captureAndStartInitialisation, + confirmRebuild, + enterSetupURI, + finishInitialisation, + resumeCompatibilityReviewIfShown, + type SetupArtifact, + type SetupCaptureNames, +} from "../runner/setupUri.ts"; +import { obsidianRemoteDebuggingPort } from "../runner/ui.ts"; +import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; + +process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000"; + +const captures: SetupCaptureNames = { scenario: "p2p-connection-check", guide: "p2p-setup" }; +const connectionTimeoutMs = Number(process.env.E2E_P2P_CHECK_CONNECTION_TIMEOUT_MS ?? 60000); +const webPeerDist = resolve(process.cwd(), "src/apps/webpeer/dist"); + +type StaticServer = { + readonly baseUrl: string; + close(): Promise; +}; + +function contentType(path: string): string { + switch (extname(path)) { + case ".css": + return "text/css; charset=utf-8"; + case ".html": + return "text/html; charset=utf-8"; + case ".js": + return "text/javascript; charset=utf-8"; + case ".json": + case ".map": + return "application/json; charset=utf-8"; + case ".svg": + return "image/svg+xml"; + default: + return "application/octet-stream"; + } +} + +function closeServer(server: Server): Promise { + return new Promise((resolveClose, reject) => { + server.close((error) => { + if (error) reject(error); + else resolveClose(); + }); + }); +} + +async function startStaticServer(): Promise { + const distribution = await stat(webPeerDist).catch(() => undefined); + if (!distribution?.isDirectory()) { + throw new Error( + `WebPeer production bundle was not found at ${webPeerDist}. Build the webpeer workspace first.` + ); + } + + const server = createServer((request, response) => { + void (async () => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + const requestedPath = decodeURIComponent(url.pathname).replace(/^\/+/, "") || "index.html"; + const candidate = resolve(webPeerDist, requestedPath); + const relativePath = relative(webPeerDist, candidate); + if (relativePath.startsWith("..") || relativePath.includes("\0")) { + response.writeHead(404).end("Not found"); + return; + } + + try { + const candidateStat = await stat(candidate); + const filePath = candidateStat.isDirectory() ? resolve(candidate, "index.html") : candidate; + const body = await readFile(filePath); + response.writeHead(200, { + "cache-control": "no-store", + "content-type": contentType(filePath), + }); + response.end(body); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + response.writeHead(404).end("Not found"); + return; + } + throw error; + } + })().catch((error: unknown) => { + response.writeHead(500).end("Internal server error"); + console.error(error instanceof Error ? error.stack : error); + }); + }); + + await new Promise((resolveListen, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolveListen(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") { + await closeServer(server); + throw new Error("The WebPeer static server did not expose a TCP port."); + } + + return { + baseUrl: `http://127.0.0.1:${address.port}/`, + close: async () => await closeServer(server), + }; +} + +async function waitForRelay(relay: string): Promise { + const endpoint = new URL(relay); + if (endpoint.protocol !== "ws:" && endpoint.protocol !== "wss:") { + throw new Error(`P2P relay must use ws: or wss:, received ${endpoint.protocol}`); + } + const port = Number(endpoint.port || (endpoint.protocol === "wss:" ? 443 : 80)); + const host = endpoint.hostname === "localhost" ? "127.0.0.1" : endpoint.hostname; + const deadline = Date.now() + Number(process.env.E2E_P2P_RELAY_READY_TIMEOUT_MS ?? 30000); + let lastError: unknown; + let consecutiveConnections = 0; + + while (Date.now() < deadline) { + try { + await new Promise((resolveConnection, reject) => { + const socket = connect({ host, port }); + socket.setTimeout(1000); + socket.once("connect", () => { + socket.destroy(); + resolveConnection(); + }); + socket.once("timeout", () => { + socket.destroy(); + reject(new Error("connection timed out")); + }); + socket.once("error", reject); + }); + consecutiveConnections += 1; + if (consecutiveConnections >= 3) return; + await new Promise((resolveDelay) => setTimeout(resolveDelay, 500)); + } catch (error) { + lastError = error; + consecutiveConnections = 0; + await new Promise((resolveDelay) => setTimeout(resolveDelay, 250)); + } + } + + throw new Error( + `P2P relay is not ready at ${relay}: ${lastError instanceof Error ? lastError.message : String(lastError)}` + ); +} + +async function readDiagnostics(page: Page): Promise> { + return { + new: (await page.getByTestId("diag-new").textContent())?.trim() ?? "", + successful: (await page.getByTestId("diag-successful").textContent())?.trim() ?? "", + failed: (await page.getByTestId("diag-failed").textContent())?.trim() ?? "", + closed: (await page.getByTestId("diag-closed").textContent())?.trim() ?? "", + }; +} + +async function waitForSuccessfulConnection(page: Page, consoleErrors: string[]): Promise> { + try { + await page.waitForFunction( + () => Number(document.querySelector('[data-testid="diag-successful"]')?.textContent ?? "0") > 0, + undefined, + { timeout: connectionTimeoutMs } + ); + } catch (error) { + throw new Error( + `${error instanceof Error ? error.message : String(error)}\n` + + `Browser diagnostics: ${JSON.stringify(await readDiagnostics(page))}\n` + + `Browser console errors: ${JSON.stringify(consoleErrors)}` + ); + } + await page.getByRole("heading", { name: "P2P connection observed", exact: true }).waitFor({ + timeout: 5000, + }); + return await readDiagnostics(page); +} + +function sessionPorts(): readonly [number, number] { + const first = obsidianRemoteDebuggingPort(); + const second = Number(process.env.E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT ?? first + 1); + if (!Number.isInteger(second) || second < 1 || second > 65535 || second === first) { + throw new Error(`Invalid secondary Obsidian remote debugging port: ${second}`); + } + return [first, second]; +} + +function sessionEnvironment(port: number): NodeJS.ProcessEnv { + return { ...process.env, E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) }; +} + +function npmBinary(): string { + return process.platform === "win32" ? "npm.cmd" : "npm"; +} + +function runNpmScript(script: string, optional = false): Promise { + return new Promise((resolveRun, reject) => { + const child = spawn(npmBinary(), ["run", script], { + cwd: process.cwd(), + env: process.env, + stdio: "inherit", + }); + child.on("error", reject); + child.on("exit", (code, signal) => { + if (code === 0 || optional) { + resolveRun(); + return; + } + reject(new Error(`${script} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`)); + }); + }); +} + +async function runScenario(): Promise { + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) { + throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); + } + + const relay = process.env.E2E_P2P_RELAY_URL ?? `ws://127.0.0.1:${process.env.E2E_P2P_RELAY_PORT ?? "4010"}/`; + await waitForRelay(relay); + + const server = await startStaticServer(); + let browser: Browser | undefined; + const vaults: TemporaryVault[] = []; + const sessions: ObsidianLiveSyncSession[] = []; + const pageErrors: string[] = []; + const consoleErrors: string[] = []; + + try { + browser = await chromium.launch({ headless: true }); + const firstVault = await createTemporaryVault("obsidian-livesync-p2p-check-first-e2e-"); + vaults.push(firstVault); + const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } }); + page.on("pageerror", (error) => pageErrors.push(error.stack ?? error.message)); + page.on("console", (message: ConsoleMessage) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + + const checkUrl = new URL("check.html", server.baseUrl); + checkUrl.searchParams.set("relay", relay); + await page.goto(checkUrl.href); + await page.getByRole("heading", { name: "P2P connection check", exact: true }).waitFor({ timeout: 30000 }); + await page.getByRole("button", { name: "Prepare desktop check", exact: true }).click(); + await page.getByAltText("Setup URI QR code for the desktop check", { exact: true }).waitFor({ + timeout: 30000, + }); + + const artifact: SetupArtifact = { + setupURI: await page.getByLabel("Setup URI", { exact: true }).inputValue(), + setupPassphrase: await page.getByLabel("Setup URI passphrase", { exact: true }).inputValue(), + }; + if (!artifact.setupURI.startsWith("obsidian://setuplivesync?settings=")) { + throw new Error("The browser did not generate a Setup URI."); + } + await page.getByText(relay, { exact: true }).waitFor({ timeout: 5000 }); + await page.getByRole("button", { name: "Start connection monitor", exact: true }).click(); + await page.getByRole("button", { name: "Monitoring is active", exact: true }).waitFor({ timeout: 30000 }); + + const [firstPort, secondPort] = sessionPorts(); + const firstSession = await startObsidianLiveSyncSession({ + binary, + cliBinary: cli.binary, + vault: firstVault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + env: sessionEnvironment(firstPort), + }); + sessions.push(firstSession); + + await enterSetupURI(firstPort, "new", artifact, captures); + await captureAndStartInitialisation(firstPort, "new", captures); + await confirmRebuild(firstPort, captures); + await acknowledgeDisabledOptionalFeatures(firstPort, captures); + const firstSetupState = await finishInitialisation(firstPort, cli.binary, firstSession.cliEnv); + await resumeCompatibilityReviewIfShown(firstPort); + if (!firstSetupState.p2pEnabled || firstSetupState.p2pRelays !== relay) { + throw new Error( + `The first Obsidian device did not apply the browser P2P setup: ${JSON.stringify(firstSetupState)}` + ); + } + + const firstDiagnostics = await waitForSuccessfulConnection(page, consoleErrors); + const tryAnotherDevice = page.getByRole("button", { + name: "Try another device without resetting", + exact: true, + }); + await tryAnotherDevice.click({ timeout: connectionTimeoutMs }); + await page + .getByRole("heading", { name: "Use this same one-off configuration on another device", exact: true }) + .waitFor({ timeout: 5000 }); + await page.getByAltText("Setup URI QR code for another device", { exact: true }).waitFor({ timeout: 5000 }); + if ((await page.getByLabel("Setup URI", { exact: true }).inputValue()) !== artifact.setupURI) { + throw new Error("The additional-device action regenerated or replaced the Setup URI."); + } + + const secondVault = await createTemporaryVault("obsidian-livesync-p2p-check-second-e2e-"); + vaults.push(secondVault); + const secondSession = await startObsidianLiveSyncSession({ + binary, + cliBinary: cli.binary, + vault: secondVault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + env: sessionEnvironment(secondPort), + }); + sessions.push(secondSession); + + await enterSetupURI(secondPort, "new", artifact, captures); + await captureAndStartInitialisation(secondPort, "new", captures); + await confirmRebuild(secondPort, captures); + await acknowledgeDisabledOptionalFeatures(secondPort, captures); + const secondSetupState = await finishInitialisation(secondPort, cli.binary, secondSession.cliEnv); + await resumeCompatibilityReviewIfShown(secondPort); + if ( + !secondSetupState.p2pEnabled || + secondSetupState.p2pRelays !== relay || + secondSetupState.p2pRoomId !== firstSetupState.p2pRoomId + ) { + throw new Error( + `The second Obsidian device did not reuse the browser P2P setup: ${JSON.stringify(secondSetupState)}` + ); + } + + await page.getByRole("heading", { name: "An additional connection was observed", exact: true }).waitFor({ + timeout: connectionTimeoutMs, + }); + const diagnostics = await readDiagnostics(page); + if (pageErrors.length > 0) { + throw new Error(`The browser page reported runtime errors: ${JSON.stringify(pageErrors)}`); + } + + const screenshotPath = resolve( + process.env.E2E_P2P_CHECK_SCREENSHOT ?? + resolve( + process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e", + "p2p-connection-check-browser-success.png" + ) + ); + await mkdir(dirname(screenshotPath), { recursive: true }); + await page.locator(".results-card").screenshot({ + animations: "disabled", + caret: "hide", + path: screenshotPath, + }); + + console.log( + `Browser-to-two-Obsidian P2P connection check succeeded through ${relay}. ` + + `First diagnostics: ${JSON.stringify(firstDiagnostics)}; final diagnostics: ${JSON.stringify(diagnostics)}` + ); + console.log(`Browser result screenshot: ${screenshotPath}`); + if (consoleErrors.length > 0) { + console.warn(`Browser console errors after successful connection: ${JSON.stringify(consoleErrors)}`); + } + } finally { + for (const session of sessions.reverse()) { + await session.app.stop().catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + } + for (const vault of vaults.reverse()) { + await vault.dispose().catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + } + await browser?.close().catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + await server.close().catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + } +} + +async function main(): Promise { + const manageP2P = process.argv.includes("--manage-p2p"); + let shouldStopP2P = false; + try { + if (manageP2P) { + await runNpmScript("test:docker-p2p:stop", true); + await runNpmScript("test:docker-p2p:start"); + shouldStopP2P = true; + } + await runScenario(); + } finally { + if (shouldStopP2P) { + await runNpmScript("test:docker-p2p:stop", true); + } + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/test/e2e-obsidian/scripts/p2p-pane.ts b/test/e2e-obsidian/scripts/p2p-pane.ts new file mode 100644 index 00000000..fc57b6c8 --- /dev/null +++ b/test/e2e-obsidian/scripts/p2p-pane.ts @@ -0,0 +1,415 @@ +/** + * Verifies the complete user-visible contract of the P2P status pane in real + * Obsidian: a configured CouchDB-only Vault with no P2P profile is not + * presented with P2P controls, while configured P2P devices can deliberately + * open the current pane in the appropriate workspace area. + * + * Desktop and mobile use separate Vaults, profiles, and Obsidian processes. + * Mobile mode is enabled before LiveSync's first load so that command and view + * registration observe the mobile application state, and no desktop workspace + * state can make a misplaced or restored pane appear correct. + * + * Command registration, automatic-opening policy, ribbon availability, + * workspace ownership, layout, and screenshots are kept in one scenario + * because together they describe one navigation path. Checking them in + * isolation could miss a pane which is registered correctly but opens in the + * wrong area, or one which is visible only because another session restored it. + */ +import { assertLocatorWithinViewport, assertNoHorizontalOverflow } from "@vrtmrz/obsidian-test-session"; +import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type"; +import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations"; +import type { ConsoleMessage, Page } from "playwright"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { + createE2eCouchDbPluginData, + createE2eObsidianDeviceLocalState, + waitForLiveSyncCoreReady, +} from "../runner/liveSyncWorkflow.ts"; +import { setObsidianMobileTestModeBeforePluginStart } from "../runner/mobileUi.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { captureObsidianPage, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts"; +import { createTemporaryVault } from "../runner/vault.ts"; + +const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS ?? 10000); + +type ObsidianTestLeaf = { + containerEl?: HTMLElement; + view?: { getViewType?: () => string }; +}; + +type ObsidianTestWorkspace = { + activeLeaf?: ObsidianTestLeaf; + getLeavesOfType?: (type: string) => ObsidianTestLeaf[]; + getRightLeaf?: (split: boolean) => ObsidianTestLeaf | null; + rightSplit?: { containerEl?: HTMLElement }; +}; + +type ObsidianTestApp = { + commands?: { + commands?: Record; + executeCommandById(commandId: string): boolean; + }; + isMobile?: boolean; + plugins?: { + plugins?: Record< + string, + { + core?: { + services?: { + API?: { + isMobile?: () => boolean; + }; + }; + }; + } + >; + }; + workspace?: ObsidianTestWorkspace; +}; + +type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp }; + +async function openP2PStatusPane(page: Page) { + return await page.evaluate((commandId) => { + const app = (globalThis as ObsidianTestGlobal).app; + const plugin = app?.plugins?.plugins?.["obsidian-livesync"]; + return { + opened: app?.commands?.executeCommandById(commandId) === true, + appIsMobile: app?.isMobile ?? null, + apiIsMobile: plugin?.core?.services?.API?.isMobile?.() ?? null, + bodyIsMobile: document.body.classList.contains("is-mobile"), + }; + }, "obsidian-livesync:open-p2p-server-status"); +} + +async function collectP2PWorkspaceState(page: Page) { + return await page.evaluate(() => { + const workspace = (globalThis as ObsidianTestGlobal).app?.workspace; + const activeLeaf = workspace?.activeLeaf; + const p2pLeaves = workspace?.getLeavesOfType?.("p2p-server-status") ?? []; + const rightLeaf = workspace?.getRightLeaf?.(false); + return { + bodyClasses: document.body.className, + activeLeaf: { + type: activeLeaf?.view?.getViewType?.() ?? null, + visible: activeLeaf?.containerEl?.checkVisibility?.() ?? null, + classes: activeLeaf?.containerEl?.className ?? null, + }, + p2pLeaves: p2pLeaves.map((leaf) => ({ + type: leaf.view?.getViewType?.() ?? null, + visible: leaf.containerEl?.checkVisibility?.() ?? null, + classes: leaf.containerEl?.className ?? null, + })), + rightLeaf: { + type: rightLeaf?.view?.getViewType?.() ?? null, + visible: rightLeaf?.containerEl?.checkVisibility?.() ?? null, + classes: rightLeaf?.containerEl?.className ?? null, + }, + visibleP2PContents: document.querySelectorAll( + ".workspace-leaf-content[data-type='p2p-server-status']:not(.is-hidden)" + ).length, + }; + }); +} + +async function assertMobileP2PPlacement(page: Page): Promise { + const placement = await page.evaluate(() => { + const workspace = (globalThis as ObsidianTestGlobal).app?.workspace; + const p2pLeaves = workspace?.getLeavesOfType?.("p2p-server-status") ?? []; + const rightSplit = workspace?.rightSplit?.containerEl; + const rightLeaf = workspace?.getRightLeaf?.(false); + return { + p2pLeafCount: p2pLeaves.length, + inRightSplit: p2pLeaves.some( + (leaf) => + (rightSplit?.contains(leaf.containerEl ?? null) ?? false) || + (leaf.containerEl?.closest(".mod-right-split, .workspace-drawer.mod-right") ?? null) !== null + ), + rightLeafType: rightLeaf?.view?.getViewType?.() ?? null, + p2pLeafClasses: p2pLeaves.map((leaf) => leaf.containerEl?.className ?? null), + rightSplitClasses: rightSplit?.className ?? null, + }; + }); + if (!placement.inRightSplit) { + throw new Error(`The mobile P2P status view was not opened in the right leaf: ${JSON.stringify(placement)}`); + } +} + +async function verifyP2PStatusPane(filename: string, mobile: boolean): Promise { + return await captureObsidianPage(obsidianRemoteDebuggingPort(), filename, async (page) => { + const runtimeErrors: string[] = []; + const onPageError = (error: Error) => runtimeErrors.push(`pageerror: ${error.message}`); + const onConsole = (message: ConsoleMessage) => { + if (message.type() === "error") runtimeErrors.push(`console: ${message.text()}`); + }; + page.on("pageerror", onPageError); + page.on("console", onConsole); + let dispatchState: Awaited> | undefined; + const heading = page.getByRole("heading", { name: "Signalling Status" }).last(); + try { + dispatchState = await openP2PStatusPane(page); + if (!dispatchState.opened) { + throw new Error("The P2P status command was not registered or could not be executed."); + } + if ( + mobile && + (dispatchState.appIsMobile !== true || + dispatchState.apiIsMobile !== true || + dispatchState.bodyIsMobile !== true) + ) { + throw new Error( + `The mobile P2P command did not observe a fully mobile application state: ${JSON.stringify(dispatchState)}` + ); + } + await heading.waitFor({ state: "visible", timeout: uiTimeoutMs }); + } catch (error) { + const workspaceState = await collectP2PWorkspaceState(page); + console.error( + `P2P command state after failed open: ${JSON.stringify({ dispatchState, runtimeErrors })}` + ); + console.error(`P2P workspace state after failed open: ${JSON.stringify(workspaceState)}`); + throw error; + } finally { + page.off("pageerror", onPageError); + page.off("console", onConsole); + } + if (mobile) { + await assertMobileP2PPlacement(page); + } + const pane = heading.locator( + "xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]" + ); + await pane.getByText("Connection:", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs }); + await pane.getByRole("button", { name: "Open connection" }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + const remoteSelector = pane.getByRole("combobox", { name: "Select active P2P remote" }); + await remoteSelector.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const remoteSelectionDeadline = Date.now() + uiTimeoutMs; + let remoteConfigurationId = ""; + while (Date.now() < remoteSelectionDeadline) { + remoteConfigurationId = (await remoteSelector.inputValue()).trim(); + if (remoteConfigurationId !== "") break; + await page.waitForTimeout(50); + } + if (remoteConfigurationId === "") { + throw new Error("The configured P2P status pane did not select an active P2P remote."); + } + if ( + (await pane.getByText("Please select an active P2P remote configuration to change P2P sync targets.").count()) !== + 0 + ) { + throw new Error("The configured P2P status pane still requested an active P2P remote."); + } + await assertNoHorizontalOverflow(page, pane, { label: "P2P status pane" }); + if (mobile) { + await assertLocatorWithinViewport(page, pane, { label: "mobile P2P status pane" }); + } + await dismissOpenNotices(page); + }); +} + +async function assertP2PUIIsOptIn(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const state = await page.evaluate(() => { + const commands = (globalThis as ObsidianTestGlobal).app?.commands?.commands ?? {}; + return { + currentCommand: commands["obsidian-livesync:open-p2p-server-status"] !== undefined, + legacyCommand: commands["obsidian-livesync:open-p2p-replicator"] !== undefined, + }; + }); + if (!state.currentCommand) { + throw new Error("The current P2P status command was not registered."); + } + if (state.legacyCommand) { + throw new Error("The retired P2P pane command is still exposed."); + } + if ((await page.locator(".workspace-leaf-content[data-type='p2p-server-status']:visible").count()) !== 0) { + throw new Error("The P2P status pane opened automatically for a CouchDB user without P2P configured."); + } + if ((await page.locator(".livesync-ribbon-p2p-server-status").count()) !== 0) { + throw new Error("The P2P ribbon icon was shown without a P2P configuration."); + } + }); +} + +async function assertConfiguredP2PUIIsAvailable(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + await page.locator(".livesync-ribbon-p2p-server-status").waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + if ((await page.locator(".workspace-leaf-content[data-type='p2p-server-status']:visible").count()) !== 0) { + throw new Error("The configured P2P status pane opened before the user requested it."); + } + }); +} + +async function assertConfiguredP2PCommandIsAvailable(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const state = await page.evaluate(() => { + const app = (globalThis as ObsidianTestGlobal).app; + const commands = app?.commands?.commands ?? {}; + return { + commandRegistered: commands["obsidian-livesync:open-p2p-server-status"] !== undefined, + openPaneCount: app?.workspace?.getLeavesOfType?.("p2p-server-status").length ?? 0, + }; + }); + if (!state.commandRegistered) { + throw new Error("The configured P2P status command was not registered in mobile mode."); + } + if (state.openPaneCount !== 0) { + throw new Error("The configured P2P status pane opened before the mobile user requested it."); + } + }); +} + +async function dismissOpenNotices(page: Page): Promise { + const deadline = Date.now() + uiTimeoutMs; + let quietSince = Date.now(); + while (Date.now() < deadline) { + const dismissed = await page.evaluate(() => { + const notices = (Array.from(document.querySelectorAll(".notice")) as HTMLElement[]).filter( + (notice) => notice.checkVisibility?.() ?? notice.offsetParent !== null + ); + for (const notice of notices) { + const closeButton = notice.querySelector(".notice-close-button") as HTMLElement | null; + // Obsidian 1.12 does not render a separate close control for + // every Notice; clicking the Notice itself is its standard + // dismiss action. + (closeButton ?? notice).click(); + } + return notices.length; + }); + if (dismissed === 0) { + if (Date.now() - quietSince >= 500) { + return; + } + await page.waitForTimeout(100); + continue; + } + quietSince = Date.now(); + await page.waitForTimeout(50); + } + throw new Error("Transient Obsidian notices did not become quiet before the P2P status screenshot."); +} + +function createBaseP2PPluginData(): Record { + return createE2eCouchDbPluginData( + { + uri: "http://127.0.0.1:5984", + username: "", + password: "", + dbName: "p2p-pane-ui-only", + }, + { + notifyThresholdOfRemoteStorageSize: -1, + periodicReplication: false, + P2P_Enabled: false, + P2P_AutoStart: false, + syncAfterMerge: false, + syncOnEditorSave: false, + syncOnFileOpen: false, + syncOnSave: false, + syncOnStart: false, + } + ); +} + +function createConfiguredP2PPluginData(): Record { + const pluginData = { + ...createBaseP2PPluginData(), + P2P_roomID: "configured-p2p-room", + P2P_passphrase: "configured-p2p-passphrase", + }; + upsertRemoteConfigurationInPlace(pluginData as ObsidianLiveSyncSettings, "p2p", { + id: "e2e-p2p", + name: "P2P Remote", + activateForP2P: true, + }); + return pluginData; +} + +async function withP2PSession( + binary: string, + cliBinary: string, + pluginData: Record, + verify: () => Promise, + options: { mobileBeforePluginStart?: boolean } = {} +): Promise { + const vault = await createTemporaryVault(); + let session: ObsidianLiveSyncSession | undefined; + try { + session = await startObsidianLiveSyncSession({ + binary, + cliBinary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData, + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), + lifecycle: options.mobileBeforePluginStart + ? { + beforePluginStart: async ({ remoteDebuggingPort }) => { + await setObsidianMobileTestModeBeforePluginStart( + remoteDebuggingPort, + true, + uiTimeoutMs + ); + }, + } + : undefined, + }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + await verify(); + } finally { + if (session) { + await session.app.stop(); + } + await vault.dispose(); + } +} + +async function main(): Promise { + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) { + throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); + } + + await withP2PSession(binary, cli.binary, createBaseP2PPluginData(), async () => { + await assertP2PUIIsOptIn(); + }); + + await withP2PSession( + binary, + cli.binary, + createConfiguredP2PPluginData(), + async () => { + await assertConfiguredP2PUIIsAvailable(); + const desktopScreenshot = await verifyP2PStatusPane("p2p-status-pane.png", false); + console.log( + `Configured P2P status UI remained opt-in and was reachable on desktop. Screenshot: ${desktopScreenshot}` + ); + } + ); + + await withP2PSession( + binary, + cli.binary, + createConfiguredP2PPluginData(), + async () => { + await assertConfiguredP2PCommandIsAvailable(); + const mobileScreenshot = await verifyP2PStatusPane("p2p-status-pane-mobile.png", true); + console.log( + `Configured P2P status UI remained opt-in and was reachable on mobile. Screenshot: ${mobileScreenshot}` + ); + }, + { mobileBeforePluginStart: true } + ); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/test/e2e-obsidian/scripts/p2p-setup-uri-workflow.ts b/test/e2e-obsidian/scripts/p2p-setup-uri-workflow.ts new file mode 100644 index 00000000..1a67edb6 --- /dev/null +++ b/test/e2e-obsidian/scripts/p2p-setup-uri-workflow.ts @@ -0,0 +1,589 @@ +import { execFile } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { connect } from "node:net"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { assertEqual, waitForLocalDatabaseEntry } from "../runner/liveSyncWorkflow.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { + acknowledgeDisabledOptionalFeatures, + captureAndStartInitialisation, + captureGuideDialogue, + confirmFastFetch, + confirmRebuild, + enterSetupURI, + finishInitialisation, + generateSetupURIFromDevice, + modalByTitle, + resumeCompatibilityReviewIfShown, + type SetupArtifact, + type SetupCaptureNames, +} from "../runner/setupUri.ts"; +import { + captureObsidianElement, + captureObsidianPage, + obsidianRemoteDebuggingPort, + withObsidianPage, +} from "../runner/ui.ts"; +import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; + +process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000"; + +const execFileAsync = promisify(execFile); +const captures: SetupCaptureNames = { scenario: "p2p-setup-uri", guide: "p2p-setup" }; +const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_P2P_WORKFLOW_TIMEOUT_MS ?? 60000); +const noteFromFirst = "E2E/p2p/from-first.md"; +const noteFromSecond = "E2E/p2p/from-second.md"; +const firstContent = "# P2P from the first device\n\nThis note was fetched directly from the first device.\n"; +const secondContent = "# P2P from the second device\n\nThis note completed the return journey.\n"; + +type RunnerContext = { + binary: string; + cliBinary: string; + activeSessions: Set; +}; + +function sessionEnvironment(port: number): NodeJS.ProcessEnv { + return { ...process.env, E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) }; +} + +function sessionPorts(): readonly [number, number] { + const first = obsidianRemoteDebuggingPort(process.env); + const second = Number(process.env.E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT ?? first + 1); + if (!Number.isInteger(second) || second < 1 || second > 65535 || second === first) { + throw new Error(`Invalid secondary Obsidian remote debugging port: ${second}`); + } + return [first, second]; +} + +async function runDeno(script: string, environment: NodeJS.ProcessEnv): Promise { + const { stdout } = await execFileAsync( + "deno", + [ + "run", + "--minimum-dependency-age=0", + "--config=utils/flyio/deno.jsonc", + "--frozen", + "--lock=utils/flyio/deno.lock", + "--allow-env", + script, + ], + { cwd: process.cwd(), env: environment, maxBuffer: 4 * 1024 * 1024 } + ); + return stdout; +} + +async function generateBootstrapSetupURI(relay: string): Promise { + const setupPassphrase = randomBytes(24).toString("base64url"); + const output = await runDeno("utils/setup/generate_setup_uri.ts", { + ...process.env, + remote_type: "p2p", + p2p_relays: relay, + p2p_room_id: `real-obsidian-${randomBytes(12).toString("hex")}`, + p2p_passphrase: randomBytes(24).toString("base64url"), + p2p_app_id: "self-hosted-livesync-real-obsidian-e2e", + p2p_auto_start: "false", + p2p_auto_broadcast: "false", + passphrase: randomBytes(24).toString("base64url"), + uri_passphrase: setupPassphrase, + }); + const setupURI = output.split(/\r?\n/u).find((line) => line.startsWith("obsidian://setuplivesync?settings=")); + if (!setupURI) throw new Error("The public Setup URI generator did not emit a P2P Setup URI."); + return { setupURI, setupPassphrase }; +} + +async function waitForRelay(relay: string): Promise { + const endpoint = new URL(relay); + const port = Number(endpoint.port || (endpoint.protocol === "wss:" ? 443 : 80)); + const host = endpoint.hostname === "localhost" ? "127.0.0.1" : endpoint.hostname; + const deadline = Date.now() + Number(process.env.E2E_P2P_RELAY_READY_TIMEOUT_MS ?? 30000); + let lastError: unknown; + while (Date.now() < deadline) { + try { + await new Promise((resolve, reject) => { + const socket = connect({ host, port }); + socket.setTimeout(1000); + socket.once("connect", () => { + socket.destroy(); + resolve(); + }); + socket.once("timeout", () => { + socket.destroy(); + reject(new Error("connection timed out")); + }); + socket.once("error", reject); + }); + await new Promise((resolve) => setTimeout(resolve, 1500)); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + throw new Error( + `P2P relay is not ready at ${relay}: ${lastError instanceof Error ? lastError.message : lastError}` + ); +} + +async function startSession( + context: RunnerContext, + vault: TemporaryVault, + port: number +): Promise { + const session = await startObsidianLiveSyncSession({ + binary: context.binary, + cliBinary: context.cliBinary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + env: sessionEnvironment(port), + }); + context.activeSessions.add(session); + return session; +} + +async function stopSessions(context: RunnerContext): Promise { + for (const session of [...context.activeSessions]) { + await session.app.stop(); + context.activeSessions.delete(session); + } +} + +async function writeNote( + cliBinary: string, + environment: NodeJS.ProcessEnv, + path: string, + content: string +): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const content=${JSON.stringify(content)};`, + "const folder=path.split('/').slice(0,-1).join('/');", + "if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);", + "const existing=app.vault.getAbstractFileByPath(path);", + "if(existing) await app.vault.modify(existing,content);", + "else await app.vault.create(path,content);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + environment + ); + await waitForLocalDatabaseEntry(cliBinary, environment, path); +} + +async function waitForPathContent(vault: TemporaryVault, path: string, expected: string): Promise { + const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 60000); + let lastContent = ""; + while (Date.now() < deadline) { + try { + lastContent = await readFile(join(vault.path, path), "utf8"); + if (lastContent === expected) return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`); +} + +async function readReflectionDiagnostics( + cliBinary: string, + environment: NodeJS.ProcessEnv, + path: string +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const settings=core.services.setting.currentSettings();", + "const entry=await core.localDatabase.getDBEntry(path,undefined,false,true).catch(()=>false);", + "const chunks=entry&&Array.isArray(entry.children)?await Promise.all(entry.children.map(async(id)=>{", + "const chunk=await core.localDatabase.getDBEntry(id,undefined,false,true).catch(()=>false);", + "return {id,found:!!chunk};", + "})):[];", + "return JSON.stringify({", + "suspendFileWatching:settings.suspendFileWatching,", + "suspendParseReplicationResult:settings.suspendParseReplicationResult,", + "configured:settings.isConfigured,", + "entry:entry?{id:entry._id,path:entry.path,children:entry.children||[]}:false,", + "chunks,", + "databaseQueueCount:core.services.replication.databaseQueueCount?.value,", + "storageApplyingCount:core.services.replication.storageApplyingCount?.value,", + "replicationResultCount:core.services.replication.replicationResultCount?.value,", + "});", + "})()", + ].join(""), + environment + ); +} + +async function executeCommand(port: number, commandId: string): Promise { + const opened = await withObsidianPage(port, async (page) => { + return await page.evaluate( + (id) => + ( + globalThis as typeof globalThis & { + app?: { commands?: { executeCommandById(commandId: string): boolean } }; + } + ).app?.commands?.executeCommandById(id) === true, + commandId + ); + }); + if (!opened) throw new Error(`Obsidian command was not available: ${commandId}`); +} + +async function openP2PStatus(port: number, filename: string): Promise { + await executeCommand(port, "obsidian-livesync:open-p2p-server-status"); + await withObsidianPage(port, async (page) => { + const heading = page.getByRole("heading", { name: "Signalling Status" }).last(); + await heading.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const pane = heading.locator( + "xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]" + ); + const open = pane.getByRole("button", { name: "Open connection" }); + if (await open.isVisible()) { + const blockingDialogues = await page.locator(".modal-container:visible").evaluateAll((elements) => + elements.map((element) => ({ + title: element.querySelector(".modal-title")?.textContent?.trim() ?? "", + text: element.textContent?.trim().replace(/\s+/gu, " ").slice(0, 240) ?? "", + })) + ); + if (blockingDialogues.length > 0) { + throw new Error( + `P2P connection control is blocked by a dialogue: ${JSON.stringify(blockingDialogues)}` + ); + } + await open.click({ timeout: uiTimeoutMs }); + } + await pane.locator(".status-value.connected").waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + return await captureObsidianElement(port, filename, (page) => { + const heading = page.getByRole("heading", { name: "Signalling Status" }).last(); + return heading.locator( + "xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]" + ); + }); +} + +async function reconnectP2PStatus(port: number): Promise { + await executeCommand(port, "obsidian-livesync:open-p2p-server-status"); + await withObsidianPage(port, async (page) => { + const heading = page.getByRole("heading", { name: "Signalling Status" }).last(); + await heading.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const pane = heading.locator( + "xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]" + ); + const disconnect = pane.getByRole("button", { name: "Disconnect", exact: true }); + if (await disconnect.isVisible()) { + await disconnect.click({ timeout: uiTimeoutMs }); + } + const open = pane.getByRole("button", { name: "Open connection", exact: true }); + await open.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await open.click({ timeout: uiTimeoutMs }); + await pane.locator(".status-value.connected").waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); +} + +async function acceptConnectionRequests( + ports: readonly number[], + stop: () => boolean, + screenshots: string[] +): Promise { + const captured = new Set(); + while (!stop()) { + for (const port of ports) { + const visible = await withObsidianPage(port, async (page) => { + return await modalByTitle(page, "P2P Connection Request").isVisible(); + }).catch(() => false); + if (!visible) continue; + if (!captured.has(port)) { + const requestNumber = + screenshots.filter((filename) => filename.includes("guide-p2p-setup-connection-request-")).length + + 1; + screenshots.push( + await captureGuideDialogue( + port, + `guide-p2p-setup-connection-request-${requestNumber}.png`, + "P2P Connection Request" + ) + ); + captured.add(port); + } + await withObsidianPage(port, async (page) => { + await modalByTitle(page, "P2P Connection Request") + .getByRole("button", { name: "Accept", exact: true }) + .click({ timeout: uiTimeoutMs }); + }); + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } +} + +async function fetchFromFirstPeer( + sessionA: ObsidianLiveSyncSession, + portA: number, + portB: number, + screenshots: string[] +): Promise { + try { + await withObsidianPage(portB, async (page) => { + const modal = modalByTitle(page, "P2P Rebuild"); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal.locator(".peer-item").first().waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + } catch (error) { + const firstDeviceAlive = sessionA.app.process.exitCode === null && sessionA.app.process.signalCode === null; + const firstDeviceUi = await withObsidianPage(portA, async (page) => { + return await page.locator("body").innerText(); + }).catch(() => undefined); + const secondDeviceDialogue = await withObsidianPage(portB, async (page) => { + return await modalByTitle(page, "P2P Rebuild").innerText(); + }).catch(() => undefined); + throw new Error( + [ + error instanceof Error ? error.message : String(error), + `First Obsidian process alive: ${firstDeviceAlive}`, + `First Obsidian CDP reachable: ${firstDeviceUi !== undefined}`, + firstDeviceUi === undefined ? undefined : `First device UI: ${firstDeviceUi.slice(0, 1_500)}`, + secondDeviceDialogue === undefined + ? undefined + : `Second-device P2P Rebuild dialogue: ${secondDeviceDialogue.slice(0, 1_500)}`, + sessionA.app.output().stderr + ? `First Obsidian stderr: ${sessionA.app.output().stderr.slice(-2_000)}` + : undefined, + ] + .filter(Boolean) + .join("\n") + ); + } + screenshots.push(await captureGuideDialogue(portB, "guide-p2p-setup-select-first-device.png", "P2P Rebuild")); + + let finished = false; + const acceptor = acceptConnectionRequests([portA, portB], () => finished, screenshots); + try { + await withObsidianPage(portB, async (page) => { + const modal = modalByTitle(page, "P2P Rebuild"); + await modal + .locator(".peer-item") + .first() + .getByRole("button", { name: "Sync", exact: true }) + .click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + } finally { + finished = true; + await acceptor; + } + + await withObsidianPage(portB, async (page) => { + const modal = modalByTitle(page, "P2P Rebuild"); + if (await modal.isVisible()) { + await modal.getByRole("button", { name: "Skip and close" }).click({ timeout: uiTimeoutMs }); + } + }); +} + +async function replicateFromStatusPane(port: number): Promise { + await withObsidianPage(port, async (page) => { + const heading = page.getByRole("heading", { name: "Detected Peers" }).last(); + await heading.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const pane = heading.locator( + "xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]" + ); + await pane.getByRole("button", { name: "Refresh", exact: true }).click({ timeout: uiTimeoutMs }); + const replicate = pane.getByRole("button", { name: "Replicate now" }).first(); + await replicate.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await replicate.click({ timeout: uiTimeoutMs }); + }); +} + +async function waitForDetectedPeer(port: number): Promise { + await withObsidianPage(port, async (page) => { + const heading = page.getByRole("heading", { name: "Detected Peers" }).last(); + await heading.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const pane = heading.locator( + "xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]" + ); + await pane.getByRole("button", { name: "Refresh", exact: true }).click({ timeout: uiTimeoutMs }); + await pane.getByRole("button", { name: "Replicate now" }).first().waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + }); +} + +async function capturePeerActionsMenu(port: number): Promise { + await withObsidianPage(port, async (page) => { + const moreActions = page.getByRole("button", { name: /^More actions for /u }).first(); + await moreActions.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await moreActions.click({ timeout: uiTimeoutMs }); + const menu = page.locator(".menu:visible").last(); + await menu.waitFor({ state: "visible", timeout: uiTimeoutMs }); + for (const label of [ + "Synchronise when this device connects", + "Follow whenever this device connects", + "Include in the P2P synchronisation command", + ]) { + await menu.getByText(label, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs }); + } + const layout = await menu.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + insideViewport: + rect.left >= 0 && + rect.top >= 0 && + rect.right <= document.documentElement.clientWidth && + rect.bottom <= document.documentElement.clientHeight, + hasHorizontalOverflow: element.scrollWidth > element.clientWidth, + }; + }); + if (!layout.insideViewport || layout.hasHorizontalOverflow) { + throw new Error(`P2P peer actions menu did not fit the viewport: ${JSON.stringify(layout)}`); + } + }); + const screenshot = await captureObsidianElement( + port, + "guide-p2p-setup-peer-actions-menu.png", + (page) => page.locator(".menu:visible").last() + ); + await withObsidianPage(port, async (page) => { + await page.keyboard.press("Escape"); + await page.locator(".menu:visible").waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + return screenshot; +} + +async function captureNote(port: number, path: string, text: string, filename: string): Promise { + await withObsidianPage(port, async (page) => { + await page.evaluate((notePath) => { + const obsidian = globalThis as typeof globalThis & { + app?: { + workspace?: { openLinkText(path: string, sourcePath: string, newLeaf: boolean): Promise }; + }; + }; + return obsidian.app?.workspace?.openLinkText(notePath, "", false); + }, path); + }); + await captureObsidianPage(port, `${filename}.full.png`, async (page) => { + await page.getByText(text, { exact: false }).first().waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + return await captureObsidianElement(port, filename, (page) => page.locator(".workspace-leaf.mod-active").first()); +} + +async function main(): Promise { + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); + + const relay = process.env.E2E_P2P_RELAY_URL ?? `ws://127.0.0.1:${process.env.E2E_P2P_RELAY_PORT ?? "4010"}/`; + await waitForRelay(relay); + const bootstrapArtifact = await generateBootstrapSetupURI(relay); + const vaultA = await createTemporaryVault(); + const vaultB = await createTemporaryVault(); + const [portA, portB] = sessionPorts(); + const context: RunnerContext = { binary, cliBinary: cli.binary, activeSessions: new Set() }; + const screenshots: string[] = []; + + try { + console.log(`Temporary P2P relay: ${relay}`); + console.log(`Temporary P2P devices: ${vaultA.name}, ${vaultB.name}`); + + const sessionA = await startSession(context, vaultA, portA); + screenshots.push(await enterSetupURI(portA, "new", bootstrapArtifact, captures)); + screenshots.push(await captureAndStartInitialisation(portA, "new", captures)); + screenshots.push(await confirmRebuild(portA, captures)); + screenshots.push(await acknowledgeDisabledOptionalFeatures(portA, captures)); + const firstState = await finishInitialisation(portA, context.cliBinary, sessionA.cliEnv); + await resumeCompatibilityReviewIfShown(portA); + assertEqual(firstState.p2pEnabled, true, "The first device did not enable P2P."); + assertEqual(firstState.p2pRelays, relay, "The first device did not activate the P2P relay."); + await writeNote(context.cliBinary, sessionA.cliEnv, noteFromFirst, firstContent); + + const generated = await generateSetupURIFromDevice(portA, randomBytes(24).toString("base64url"), captures); + if (generated.artifact.setupURI === bootstrapArtifact.setupURI) { + throw new Error("The first device returned the bootstrap Setup URI instead of generating a new one."); + } + screenshots.push(...generated.screenshots); + screenshots.push(await openP2PStatus(portA, "guide-p2p-setup-first-device-connected.png")); + + const sessionB = await startSession(context, vaultB, portB); + screenshots.push(await enterSetupURI(portB, "existing", generated.artifact, captures)); + screenshots.push(await captureAndStartInitialisation(portB, "existing", captures)); + screenshots.push(...(await confirmFastFetch(portB, captures))); + await fetchFromFirstPeer(sessionA, portA, portB, screenshots); + await waitForLocalDatabaseEntry(context.cliBinary, sessionB.cliEnv, noteFromFirst, { + timeoutMs: uiTimeoutMs, + }); + const secondState = await finishInitialisation(portB, context.cliBinary, sessionB.cliEnv); + await resumeCompatibilityReviewIfShown(portB); + assertEqual(secondState.p2pEnabled, true, "The second device did not enable P2P."); + assertEqual(secondState.p2pRelays, relay, "The second device did not import the P2P relay."); + assertEqual(secondState.p2pRoomId, firstState.p2pRoomId, "The two devices did not join the same P2P room."); + try { + await waitForPathContent(vaultB, noteFromFirst, firstContent); + } catch (error) { + const diagnostics = await readReflectionDiagnostics(context.cliBinary, sessionB.cliEnv, noteFromFirst); + throw new Error( + `${error instanceof Error ? error.message : String(error)}\nReflection diagnostics: ${JSON.stringify(diagnostics)}` + ); + } + screenshots.push( + await captureNote(portB, noteFromFirst, "P2P from the first device", "guide-p2p-setup-first-to-second.png") + ); + console.log("P2P workflow: initial Fetch from the first device completed."); + + await writeNote(context.cliBinary, sessionB.cliEnv, noteFromSecond, secondContent); + await reconnectP2PStatus(portA); + await reconnectP2PStatus(portB); + await waitForDetectedPeer(portA); + screenshots.push(await openP2PStatus(portA, "guide-p2p-setup-devices-connected.png")); + screenshots.push(await capturePeerActionsMenu(portA)); + console.log("P2P workflow: peer actions menu verified; starting the return journey."); + let returnJourneyFinished = false; + const returnJourneyAcceptor = acceptConnectionRequests( + [portA, portB], + () => returnJourneyFinished, + screenshots + ); + try { + await replicateFromStatusPane(portA); + console.log("P2P workflow: return replication requested; waiting for the second device's note."); + await waitForPathContent(vaultA, noteFromSecond, secondContent); + console.log("P2P workflow: return note reached the first device."); + } finally { + returnJourneyFinished = true; + await returnJourneyAcceptor; + console.log("P2P workflow: return connection approval loop stopped."); + } + screenshots.push( + await captureNote( + portA, + noteFromSecond, + "P2P from the second device", + "guide-p2p-setup-second-to-first.png" + ) + ); + + console.log(`P2P Setup URI and two-device roundtrip succeeded. Screenshots: ${screenshots.join(", ")}`); + } finally { + console.log("P2P workflow: stopping tracked Obsidian sessions."); + await stopSessions(context).catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + console.log("P2P workflow: disposing temporary Vaults."); + await vaultA.dispose(); + await vaultB.dispose(); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/test/e2e-obsidian/scripts/review-harness.ts b/test/e2e-obsidian/scripts/review-harness.ts new file mode 100644 index 00000000..3c7fb96f --- /dev/null +++ b/test/e2e-obsidian/scripts/review-harness.ts @@ -0,0 +1,469 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + assertLocatorHasMinimumTouchTarget, + assertLocatorWithinSafeArea, + assertNoHorizontalOverflow, +} from "@vrtmrz/obsidian-test-session"; +import { CURRENT_SETTING_VERSION } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const"; +import { REVIEW_HARNESS_STATE_KEY } from "../../../src/features/ReviewHarness/reviewHarnessController.ts"; +import { REVIEW_HARNESS_FIXTURE_ROOT } from "../../../src/features/ReviewHarness/reviewHarnessVaultFixture.ts"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts"; +import { iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { + captureObsidianDialogue, + captureObsidianPage, + obsidianRemoteDebuggingPort, + withObsidianPage, +} from "../runner/ui.ts"; +import { createTemporaryVault } from "../runner/vault.ts"; + +const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_REVIEW_HARNESS_TIMEOUT_MS ?? 15000); + +type ObsidianTestApp = { + commands?: { executeCommandById(commandId: string): boolean }; + plugins?: { plugins: Record }; + vault?: { getAbstractFileByPath(path: string): unknown | null }; +}; + +type ReviewHarnessTestGlobal = typeof globalThis & { + app?: ObsidianTestApp; + reviewHarnessCopiedReport?: string; +}; + +type ReviewHarnessReadinessSnapshot = { + coreAvailable: boolean; + databaseReady?: boolean; + appReady?: boolean; + configured?: boolean; + remoteType?: string; + settingVersion?: number; + suspended?: boolean; + unresolvedMessages: string[]; +}; + +const sensitiveDiagnosticLine = + /security seed|passphrase|password|credential|secret|access.?key|jwt.?key|authori[sz]ation|obsidian:\/\/setuplivesync|sls\+/iu; +const interruptedStartupMessages = [ + "No replicator has been activated or has not been initialised yet.", + "Self-hosted LiveSync cannot be initialised, exiting loading.", +]; + +function redactDiagnosticLine(line: string): string { + if (sensitiveDiagnosticLine.test(line)) return "[REDACTED SENSITIVE LOG LINE]"; + return line.replace(/\bhttps?:\/\/[^/\s:@]+:[^@\s/]+@/giu, "https://[REDACTED]@"); +} + +async function assertNoInterruptedStartupNotice(stage: string): Promise { + const notices = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + await page.waitForTimeout(1500); + return await page.locator(".notice").allTextContents(); + }); + const interrupted = notices.filter((notice) => + interruptedStartupMessages.some((message) => notice.includes(message)) + ); + if (interrupted.length > 0) { + throw new Error(`LiveSync emitted an interrupted-startup Notice during ${stage}: ${interrupted.join(" | ")}`); + } + console.log(`No interrupted-startup Notice observed during ${stage}.`); +} + +async function captureReadinessFailure( + cliBinary: string, + session: ObsidianLiveSyncSession, + readinessError: unknown +): Promise { + const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e"; + await mkdir(outputDirectory, { recursive: true }); + + const captureErrors: string[] = []; + let screenshotPath: string | undefined; + try { + screenshotPath = await captureObsidianPage( + obsidianRemoteDebuggingPort(), + "review-harness-core-not-ready.png", + async () => undefined + ); + } catch (error) { + captureErrors.push(`screenshot: ${error instanceof Error ? error.message : String(error)}`); + } + + let readiness: ReviewHarnessReadinessSnapshot | undefined; + try { + readiness = await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync']?.core;", + "if(!core)return JSON.stringify({coreAvailable:false,unresolvedMessages:[]});", + "const settings=core.services.setting.currentSettings();", + "let unresolvedMessages=[];", + "try{", + "unresolvedMessages=(await core.services.appLifecycle.getUnresolvedMessages()).flat()", + ".filter((message)=>message!==undefined&&message!==null)", + ".map((message)=>String(message)).slice(-50);", + "}catch(error){unresolvedMessages=[`Could not inspect unresolved messages: ${String(error)}`];}", + "return JSON.stringify({", + "coreAvailable:true,", + "databaseReady:core.services.database.isDatabaseReady(),", + "appReady:core.services.appLifecycle.isReady(),", + "configured:settings?.isConfigured===true,", + "remoteType:settings?.remoteType??'',", + "settingVersion:settings?.settingVersion,", + "suspended:core.services.appLifecycle.isSuspended(),", + "unresolvedMessages,", + "});", + "})()", + ].join(""), + session.cliEnv + ); + readiness.unresolvedMessages = readiness.unresolvedMessages.map(redactDiagnosticLine); + } catch (error) { + captureErrors.push(`readiness snapshot: ${error instanceof Error ? error.message : String(error)}`); + } + + let recentLog: string[] = []; + try { + recentLog = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const opened = await page.evaluate( + (commandId) => + (globalThis as ReviewHarnessTestGlobal).app?.commands?.executeCommandById(commandId) === true, + "obsidian-livesync:view-log" + ); + if (!opened) throw new Error("The Show log command was not registered."); + const logPane = page.locator(".logpane"); + await logPane.waitFor({ state: "visible", timeout: 5000 }); + return (await logPane.locator(".log pre").allTextContents()).slice(-80).map(redactDiagnosticLine); + }); + } catch (error) { + captureErrors.push(`recent log: ${error instanceof Error ? error.message : String(error)}`); + } + + const resultPath = join(outputDirectory, "review-harness-core-not-ready.json"); + await writeFile( + resultPath, + `${JSON.stringify( + { + capturedAt: new Date().toISOString(), + failure: readinessError instanceof Error ? readinessError.message : String(readinessError), + screenshotPath, + readiness, + recentLog, + captureErrors, + }, + null, + 2 + )}\n`, + "utf8" + ); + if (screenshotPath) console.error(`Review Harness core readiness screenshot: ${screenshotPath}`); + console.error(`Review Harness core readiness diagnostics: ${resultPath}`); +} + +async function openHarness(): Promise { + const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + return await page.evaluate( + (commandId) => (globalThis as ReviewHarnessTestGlobal).app?.commands?.executeCommandById(commandId) === true, + "obsidian-livesync:open-review-harness" + ); + }); + if (!opened) throw new Error("The Review Harness command was not registered."); +} + +async function waitForHarness(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + await page.locator('[data-testid="review-harness"]').waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); +} + +async function keepCompatibilityPaused(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const summary = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ + hasText: "Synchronisation paused for compatibility review", + }), + }); + await summary.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await summary.getByRole("button", { name: "Keep synchronisation paused" }).click({ timeout: uiTimeoutMs }); + await summary.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); +} + +async function runAutomaticScenarios(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const harness = page.locator('[data-testid="review-harness"]'); + await harness.locator('[data-testid="review-harness-run-automatic"]').click({ timeout: uiTimeoutMs }); + for (const id of ["settings-lifecycle", "p2p-composition"]) { + await harness + .locator(`[data-testid="review-harness-result-${id}"]`) + .getByText("Passed:", { exact: false }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + } + }); +} + +async function runVaultFixture(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + await page + .locator('[data-testid="review-harness-run-vault-round-trip"]') + .click({ timeout: uiTimeoutMs }); + const confirmation = page.locator(".modal-container").filter({ + has: page.getByText("Review Harness: Vault fixture access", { exact: true }), + }); + await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + const screenshot = await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + "review-harness-vault-confirmation.png", + async (page) => { + const confirmation = page.locator(".modal-container").filter({ + has: page.getByText("Review Harness: Vault fixture access", { exact: true }), + }); + await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await assertNoHorizontalOverflow(page, confirmation, { label: "Vault fixture confirmation" }); + } + ); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const harness = page.locator('[data-testid="review-harness"]'); + const confirmation = page.locator(".modal-container").filter({ + has: page.getByText("Review Harness: Vault fixture access", { exact: true }), + }); + await confirmation.getByRole("button", { name: "Yes" }).click({ timeout: uiTimeoutMs }); + await harness + .locator('[data-testid="review-harness-result-vault-round-trip"]') + .getByText("Passed:", { exact: false }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + const fixtureRemoved = await page.evaluate( + (root) => (globalThis as ReviewHarnessTestGlobal).app?.vault?.getAbstractFileByPath(root) === null, + REVIEW_HARNESS_FIXTURE_ROOT + ); + if (!fixtureRemoved) throw new Error("The Review Harness fixture root remained after the scenario."); + }); + return screenshot; +} + +async function restartAndResumeHarness(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const harness = page.locator('[data-testid="review-harness"]'); + await harness + .locator('[data-testid="review-harness-run-compatibility-review"]') + .click({ timeout: uiTimeoutMs }); + await harness + .locator('[data-testid="review-harness-result-compatibility-review"]') + .getByText("Waiting for review:", { exact: false }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await harness.locator('[data-testid="review-harness-restart"]').click({ timeout: uiTimeoutMs }); + }); + + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + await page.waitForFunction( + () => { + const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + if (typeof plugin !== "object" || plugin === null || !("core" in plugin)) return false; + const core = (plugin as { core: { services: { appLifecycle: { isReady(): boolean } } } }).core; + return core.services.appLifecycle.isReady(); + }, + undefined, + { timeout: uiTimeoutMs * 2 } + ); + }); + await keepCompatibilityPaused(); + await waitForHarness(); + return await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + "review-harness-resumed.png", + async (page) => { + const harness = page.locator('[data-testid="review-harness"]'); + await harness + .locator('[data-testid="review-harness-resumed"]') + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + const continuationRemoved = await page.evaluate((stateKey) => { + const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + if (typeof plugin !== "object" || plugin === null || !("core" in plugin)) { + throw new Error("Self-hosted LiveSync is unavailable after restart."); + } + const core = (plugin as { core: { services: { setting: { getSmallConfig(key: string): string } } } }) + .core; + return core.services.setting.getSmallConfig(stateKey) === ""; + }, REVIEW_HARNESS_STATE_KEY); + if (!continuationRemoved) throw new Error("The one-shot continuation was not removed before use."); + await assertNoHorizontalOverflow(page, harness, { label: "resumed Review Harness" }); + } + ); +} + +async function completeResumedCompatibilityStep(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const harness = page.locator('[data-testid="review-harness"]'); + await harness + .locator('[data-testid="review-harness-open-compatibility-review"]') + .click({ timeout: uiTimeoutMs }); + const summary = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ + hasText: "Synchronisation paused for compatibility review", + }), + }); + await summary.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await summary.getByRole("button", { name: "Resume synchronisation" }).click({ timeout: uiTimeoutMs }); + await summary.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + await harness + .locator('[data-testid="review-harness-result-compatibility-review"]') + .getByText("The device-local compatibility pause was reviewed and cleared.", { exact: false }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); +} + +async function copyAndReadReport(): Promise { + return await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + await page.evaluate(` + globalThis.reviewHarnessCopiedReport = undefined; + navigator.clipboard.writeText = function (value) { + globalThis.reviewHarnessCopiedReport = value; + return Promise.resolve(); + }; + `); + await page.locator('[data-testid="review-harness-copy-report"]').click({ timeout: uiTimeoutMs }); + await page.waitForFunction( + () => typeof (globalThis as ReviewHarnessTestGlobal).reviewHarnessCopiedReport === "string", + undefined, + { timeout: uiTimeoutMs } + ); + return await page.evaluate( + () => (globalThis as ReviewHarnessTestGlobal).reviewHarnessCopiedReport ?? "" + ); + }); +} + +async function verifyMobileHarness(): Promise { + await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), true, uiTimeoutMs); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const harness = page.locator('[data-testid="review-harness"]'); + if (await harness.isVisible()) return; + await page.evaluate(async (viewType) => { + const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + if (typeof plugin !== "object" || plugin === null || !("core" in plugin)) { + throw new Error("Self-hosted LiveSync is unavailable in mobile test mode."); + } + const core = (plugin as { + core: { services: { API: { showWindow(type: string): Promise } } }; + }).core; + await core.services.API.showWindow(viewType); + }, "self-hosted-livesync-review-harness"); + }); + return await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + "review-harness-mobile.png", + async (page) => { + const harness = page.locator('[data-testid="review-harness"]'); + await harness.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await assertNoHorizontalOverflow(page, harness, { label: "mobile Review Harness" }); + const heading = harness.getByRole("heading", { name: "Self-hosted LiveSync review harness" }); + await assertLocatorWithinSafeArea(page, heading, { + label: "mobile Review Harness heading", + safeAreaInsets: iPhoneSafeArea, + }); + for (const testId of [ + "review-harness-run-automatic", + "review-harness-run-full", + "review-harness-copy-report", + ]) { + await assertLocatorHasMinimumTouchTarget(page, harness.locator(`[data-testid="${testId}"]`), { + label: testId, + }); + } + } + ); +} + +async function main(): Promise { + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); + const vault = await createTemporaryVault(); + let session: ObsidianLiveSyncSession | undefined; + try { + session = await startObsidianLiveSyncSession({ + binary, + cliBinary: cli.binary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: { + doctorProcessedVersion: "1.0.0", + settingVersion: CURRENT_SETTING_VERSION, + isConfigured: true, + additionalSuffixOfDatabaseName: "", + enableDebugTools: true, + notifyThresholdOfRemoteStorageSize: 0, + P2P_Enabled: false, + P2P_AutoStart: false, + liveSync: false, + syncOnSave: false, + syncOnEditorSave: true, + syncOnStart: false, + syncOnFileOpen: true, + syncAfterMerge: false, + periodicReplication: true, + }, + }); + await assertNoInterruptedStartupNotice("plug-in session start"); + try { + await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + } catch (error) { + await captureReadinessFailure(cli.binary, session, error).catch((diagnosticError: unknown) => { + console.error( + `Could not capture Review Harness readiness diagnostics: ${ + diagnosticError instanceof Error ? diagnosticError.message : String(diagnosticError) + }` + ); + }); + throw error; + } + await assertNoInterruptedStartupNotice("core readiness"); + await keepCompatibilityPaused(); + await openHarness(); + await waitForHarness(); + + const initialScreenshot = await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + "review-harness-initial.png", + async (page) => { + const harness = page.locator('[data-testid="review-harness"]'); + await harness.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await assertNoHorizontalOverflow(page, harness, { label: "Review Harness" }); + } + ); + + await runAutomaticScenarios(); + const vaultConfirmationScreenshot = await runVaultFixture(); + const resumedScreenshot = await restartAndResumeHarness(); + await completeResumedCompatibilityStep(); + const report = await copyAndReadReport(); + if (!report.includes("## Self-hosted LiveSync Review Harness report")) { + throw new Error("The copied Review Harness report was not Markdown evidence."); + } + for (const forbidden of [vault.name, REVIEW_HARNESS_FIXTURE_ROOT]) { + if (report.includes(forbidden)) throw new Error(`The Review Harness report exposed local state: ${forbidden}`); + } + + const mobileScreenshot = await verifyMobileHarness(); + console.log( + `Review Harness passed one-shot, fixture, report, and mobile checks. Screenshots: ${[ + initialScreenshot, + vaultConfirmationScreenshot, + resumedScreenshot, + mobileScreenshot, + ].join(", ")}` + ); + } finally { + if (session) await session.app.stop(); + await vault.dispose(); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/test/e2e-obsidian/scripts/revision-repair.ts b/test/e2e-obsidian/scripts/revision-repair.ts new file mode 100644 index 00000000..8dbab659 --- /dev/null +++ b/test/e2e-obsidian/scripts/revision-repair.ts @@ -0,0 +1,735 @@ +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { + createE2eObsidianDeviceLocalState, + waitForLiveSyncCoreReady, + waitForLocalDatabaseEntry, +} from "../runner/liveSyncWorkflow.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { captureObsidianElement, withObsidianPage } from "../runner/ui.ts"; +import { createTemporaryVault } from "../runner/vault.ts"; +import type { Locator, Page } from "playwright"; + +const path = "revision-repair.md"; +const healthyDeletedPath = "healthy-logical-deletion.md"; +const baseContent = "Revision repair\n\nShared base.\n"; +const branchContents = [ + `Revision repair\n\nLeft branch.\n${"L".repeat(4096)}\n`, + `Revision repair\n\nRight branch.\n${"R".repeat(4096)}\n`, +] as const; +const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_REVISION_REPAIR_TIMEOUT_MS ?? 15000); + +type BrokenRevisionFixture = { + winnerRevision: string; + conflictRevision: string; + missingChunkId: string; +}; + +type RevisionTree = { + winnerRevision: string; + conflictRevisions: string[]; +}; + +type VaultWinnerState = { + matches: boolean; + winnerRevision: string; +}; + +type ObsidianSettingsController = { + open(): void; + openTabById(tabId: string): void; +}; + +type ObsidianTestGlobal = typeof globalThis & { + app?: { + setting?: ObsidianSettingsController; + }; +}; + +async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const content=${JSON.stringify(baseContent)};`, + "let file=app.vault.getAbstractFileByPath(path);", + "if(!file) file=await app.vault.create(path,content);", + "await app.workspace.getLeaf(false).openFile(file);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); +} + +async function createHealthyLogicalDeletion(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(healthyDeletedPath)};`, + `const content=${JSON.stringify(`Healthy logical deletion\n\n${"D".repeat(4096)}\n`)};`, + "let file=app.vault.getAbstractFileByPath(path);", + "if(!file) file=await app.vault.create(path,content);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); + await waitForLocalDatabaseEntry(cliBinary, env, healthyDeletedPath); + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(healthyDeletedPath)};`, + `const timeoutMs=${JSON.stringify(uiTimeoutMs)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const file=app.vault.getAbstractFileByPath(path);", + "if(!file) throw new Error(`Logical-deletion fixture is missing from the Vault: ${path}`);", + "await app.vault.delete(file);", + "const id=await core.services.path.path2id(path);", + "const deadline=Date.now()+timeoutMs;", + "const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));", + "while(Date.now()false);", + " if(!app.vault.getAbstractFileByPath(path)&&doc?.deleted&&(doc._conflicts??[]).length===0){", + " return JSON.stringify(doc._rev);", + " }", + " await sleep(250);", + "}", + "throw new Error(`Timed out waiting for a healthy logical deletion: ${path}`);", + "})()", + ].join(""), + env + ); +} + +async function createBrokenConflict( + cliBinary: string, + env: NodeJS.ProcessEnv, + baseRevision: string +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const baseRevision=${JSON.stringify(baseRevision)};`, + `const contents=${JSON.stringify(branchContents)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const id=await core.services.path.path2id(path);", + "for(const [index,content] of contents.entries()){", + " const blob=new Blob([content],{type:'text/plain'});", + " const now=Date.now()+index;", + " const result=await core.localDatabase.putDBEntry({", + " _id:id,path,data:blob,ctime:now,mtime:now,", + " size:(await blob.arrayBuffer()).byteLength,children:[],", + " datatype:'plain',type:'plain',eden:{},", + " },false,baseRevision);", + " if(!result?.ok) throw new Error(`Could not create repair conflict: ${path}`);", + "}", + "const tree=await core.localDatabase.localDatabase.get(id,{conflicts:true});", + "const conflictRevision=tree._conflicts?.[0];", + "if(!tree._rev||!conflictRevision){", + " throw new Error(`Repair fixture did not produce two live revisions: ${path}`);", + "}", + "const conflict=await core.localDatabase.localDatabase.get(id,{rev:conflictRevision});", + "const embedded=new Set(Object.keys(conflict.eden??{}));", + "const missingChunkId=(conflict.children??[]).find((child)=>!embedded.has(child));", + "if(!missingChunkId){", + " throw new Error(`Repair fixture did not create an independent chunk: ${conflictRevision}`);", + "}", + "const chunk=await core.localDatabase.localDatabase.get(missingChunkId);", + "await core.localDatabase.localDatabase.remove(chunk);", + "core.localDatabase.clearCaches();", + "const unreadable=await core.localDatabase.getDBEntry(path,{rev:conflictRevision},false,true,true);", + "if(unreadable!==false){", + " throw new Error(`The selected revision remained readable after its chunk was removed: ${conflictRevision}`);", + "}", + "return JSON.stringify({", + " winnerRevision:tree._rev,", + " conflictRevision,", + " missingChunkId,", + "});", + "})()", + ].join(""), + env + ); +} + +async function readRevisionTree(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const id=await core.services.path.path2id(path);", + "const tree=await core.localDatabase.localDatabase.get(id,{conflicts:true});", + "return JSON.stringify({", + " winnerRevision:tree._rev,", + " conflictRevisions:tree._conflicts??[],", + "});", + "})()", + ].join(""), + env + ); +} + +async function readVaultWinnerState(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const file=app.vault.getAbstractFileByPath(path);", + "if(!file) throw new Error(`Vault file is missing: ${path}`);", + "const entry=await core.localDatabase.getDBEntry(path,undefined,false,true,true);", + "if(!entry||!entry._rev) throw new Error(`Database winner is missing: ${path}`);", + "const vaultContent=await app.vault.read(file);", + "const data=Array.isArray(entry.data)?entry.data:[entry.data];", + "const databaseContent=await new Blob(data).text();", + "return JSON.stringify({", + " matches:vaultContent===databaseContent,", + " winnerRevision:entry._rev,", + "});", + "})()", + ].join(""), + env + ); +} + +async function readFileReflectionProvenance( + cliBinary: string, + env: NodeJS.ProcessEnv, + targetPath = path +): Promise<{ revision: string; observedStorageMtime?: number } | null> { + return await evalObsidianJson<{ revision: string; observedStorageMtime?: number } | null>( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(targetPath)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const store=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');", + "return JSON.stringify((await store.get(path))??null);", + "})()", + ].join(""), + env + ); +} + +function repairCard(settings: Locator): Locator { + return settings.locator(".sls-repair-result").filter({ hasText: path }); +} + +function revisionCard(settings: Locator, revision: string): Locator { + return repairCard(settings).locator(".sls-repair-revision").filter({ hasText: revision }); +} + +async function openRevisionActionMenu(page: Page, settings: Locator, revision: string): Promise { + const actionButton = revisionCard(settings, revision).getByRole("button", { + name: `More actions for revision ${revision}`, + exact: true, + }); + await actionButton.locator("svg.lucide-wrench").waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await actionButton.click({ timeout: uiTimeoutMs }); + const menu = page.locator(".menu:visible").last(); + await menu.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const box = await menu.boundingBox(); + const viewport = await page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight, + })); + if ( + box === null || + box.y < 0 || + box.y + box.height > viewport.height - 4 + ) { + throw new Error( + `Revision action menu is outside the viewport: ${JSON.stringify({ + box, + viewport, + })}` + ); + } + return menu; +} + +async function selectRevisionAction(page: Page, settings: Locator, revision: string, action: string): Promise { + const menu = await openRevisionActionMenu(page, settings, revision); + const item = menu.getByText(action, { exact: true }); + await item.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await item.click({ timeout: uiTimeoutMs }); +} + +async function requestConflictCheck(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "core.localDatabase.clearCaches();", + "await core.services.conflict.queueCheckFor(path);", + "await core.services.conflict.ensureAllProcessed();", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); +} + +async function main(): Promise { + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) { + throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); + } + const cliBinary = cli.binary; + const vault = await createTemporaryVault("obsidian-livesync-revision-repair-"); + let session: ObsidianLiveSyncSession | undefined; + try { + session = await startObsidianLiveSyncSession({ + binary, + cliBinary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: { + doctorProcessedVersion: "1.0.0", + isConfigured: true, + liveSync: false, + remoteType: "", + couchDB_URI: "", + couchDB_DBNAME: "", + couchDB_USER: "", + couchDB_PASSWORD: "", + remoteConfigurations: {}, + activeConfigurationId: "", + notifyThresholdOfRemoteStorageSize: -1, + periodicReplication: false, + syncAfterMerge: false, + syncOnEditorSave: false, + syncOnFileOpen: false, + syncOnSave: false, + syncOnStart: false, + disableMarkdownAutoMerge: true, + showMergeDialogOnlyOnActive: true, + useEden: false, + }, + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), + }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + await createAndOpenBaseFile(cliBinary, session.cliEnv); + const base = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path); + const healthyDeletionRevision = await createHealthyLogicalDeletion(cliBinary, session.cliEnv); + const fixture = await createBrokenConflict(cliBinary, session.cliEnv, base.rev); + const healthyDeletionProvenance = await readFileReflectionProvenance( + cliBinary, + session.cliEnv, + healthyDeletedPath + ); + if (healthyDeletionProvenance !== null) { + throw new Error( + `A healthy logical deletion retained Vault provenance indefinitely: ${JSON.stringify({ + healthyDeletedPath, + healthyDeletionRevision, + healthyDeletionProvenance, + })}` + ); + } + + await requestConflictCheck(cliBinary, session.cliEnv); + const afterAutomaticCheck = await readRevisionTree(cliBinary, session.cliEnv); + if ( + afterAutomaticCheck.winnerRevision !== fixture.winnerRevision || + !afterAutomaticCheck.conflictRevisions.includes(fixture.conflictRevision) + ) { + throw new Error( + `Automatic conflict checking discarded the unreadable revision: ${JSON.stringify({ + fixture, + afterAutomaticCheck, + })}` + ); + } + + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + await page.evaluate(() => { + const setting = (globalThis as ObsidianTestGlobal).app?.setting; + if (setting === undefined) throw new Error("Obsidian settings are unavailable"); + setting.open(); + setting.openTabById("obsidian-livesync"); + }); + const settings = page.locator(".sls-setting"); + await settings.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await settings.locator('.sls-setting-menu-btn[title="Hatch"]').click({ timeout: uiTimeoutMs }); + const verifySetting = settings.locator(".setting-item").filter({ + has: page.getByText("Inspect conflicts and file/database differences", { + exact: true, + }), + }); + await verifySetting.getByRole("button", { name: "Begin inspection", exact: true }).click({ + timeout: uiTimeoutMs, + }); + const card = repairCard(settings); + await card.waitFor({ state: "visible", timeout: uiTimeoutMs }); + if ((await settings.locator(".sls-repair-result").filter({ hasText: healthyDeletedPath }).count()) !== 0) { + throw new Error( + `File/database inspection reported the healthy logical deletion ${healthyDeletedPath} (${healthyDeletionRevision}).` + ); + } + const winnerRevision = revisionCard(settings, fixture.winnerRevision); + const brokenRevision = revisionCard(settings, fixture.conflictRevision); + await brokenRevision + .getByText(/🧩 Missing chunks: 1/u) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await brokenRevision.getByText(fixture.missingChunkId, { exact: false }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + if ((await card.locator(".sls-repair-revision").count()) !== 2) { + throw new Error("Verify and Repair did not render the winner and conflict revision separately."); + } + for (const label of [ + /đŸ“Ļ DB: recorded/u, + /📁 Vault:/u, + /Δsize vs DB/u, + /🕒 DB /u, + /Δtime /u, + /âš ī¸ Differs from Vault/u, + ]) { + await winnerRevision.getByText(label).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + } + await brokenRevision.getByText(/decoded unavailable/u).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + const winnerMenu = await openRevisionActionMenu(page, settings, fixture.winnerRevision); + for (const label of [ + "Compare with Vault", + "Apply this revision to Vault", + "Store Vault file as a child of this revision", + "Discard this branch", + ]) { + await winnerMenu.getByText(label, { exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + } + if ( + (await winnerMenu + .getByText("Mark this revision as the Vault version", { + exact: true, + }) + .count()) !== 0 + ) { + throw new Error("A differing revision incorrectly offered to record an exact Vault match."); + } + await page.keyboard.press("Escape"); + }); + + const repairCardScreenshot = await captureObsidianElement( + session.remoteDebuggingPort, + "revision-repair-unreadable-conflict.png", + (page) => page.locator(".sls-repair-result").filter({ hasText: path }) + ); + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const card = page.locator(".sls-repair-result").filter({ hasText: path }); + await card.evaluate((element) => { + const htmlElement = element as HTMLElement; + htmlElement.dataset.e2eOriginalStyle = htmlElement.getAttribute("style") ?? ""; + htmlElement.style.width = "360px"; + htmlElement.style.maxWidth = "100%"; + }); + const dimensions = await card.evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })); + if (dimensions.scrollWidth > dimensions.clientWidth + 1) { + throw new Error( + `Revision repair card overflowed at mobile width: ${JSON.stringify(dimensions)}` + ); + } + }); + const mobileWidthScreenshot = await captureObsidianElement( + session.remoteDebuggingPort, + "revision-repair-mobile-width.png", + (page) => page.locator(".sls-repair-result").filter({ hasText: path }) + ); + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const card = page.locator(".sls-repair-result").filter({ hasText: path }); + await card.evaluate((element) => { + const htmlElement = element as HTMLElement; + const originalStyle = htmlElement.dataset.e2eOriginalStyle ?? ""; + if (originalStyle.length > 0) { + htmlElement.setAttribute("style", originalStyle); + } else { + htmlElement.removeAttribute("style"); + } + delete htmlElement.dataset.e2eOriginalStyle; + }); + }); + + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const settings = page.locator(".sls-setting"); + await openRevisionActionMenu(page, settings, fixture.winnerRevision); + }); + const readableMenuScreenshot = await captureObsidianElement( + session.remoteDebuggingPort, + "revision-repair-readable-actions.png", + (page) => page.locator(".menu:visible").last() + ); + + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + await page.keyboard.press("Escape"); + const settings = page.locator(".sls-setting"); + await selectRevisionAction(page, settings, fixture.winnerRevision, "Compare with Vault"); + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ + hasText: "Vault and database revision", + }), + }); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal.getByText(path, { exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await modal.getByText(/Vault file:/u).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await modal.getByText(/Database revision:/u).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + const actions = modal.locator(".conflict-action-container"); + await actions.getByRole("button", { name: "Close", exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + for (const action of ["Use Vault file", "Use Database revision", "Concat both", "Not now"]) { + if ((await actions.getByRole("button", { name: action, exact: true }).count()) !== 0) { + throw new Error(`Read-only comparison exposed the resolution action '${action}'.`); + } + } + }); + const comparisonScreenshot = await captureObsidianElement( + session.remoteDebuggingPort, + "revision-repair-read-only-comparison.png", + (page) => + page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ + hasText: "Vault and database revision", + }), + }) + ); + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ + hasText: "Vault and database revision", + }), + }); + await modal + .locator(".conflict-action-container") + .getByRole("button", { name: "Close", exact: true }) + .click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + const beforeApply = await readRevisionTree(cliBinary, session.cliEnv); + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const settings = page.locator(".sls-setting"); + await selectRevisionAction(page, settings, fixture.winnerRevision, "Apply this revision to Vault"); + const confirmation = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ + hasText: "Apply database revision to Vault", + }), + }); + await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await confirmation.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs }); + await revisionCard(settings, fixture.winnerRevision) + .getByText("✅ Matches Vault", { exact: true }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + const status = repairCard(settings).locator(".sls-repair-status"); + await status + .getByText("✅ Vault matches winner", { exact: true }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await status + .getByText("âš ī¸ Conflicts: 1", { exact: true }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + const matchedWinnerWithConflictScreenshot = await captureObsidianElement( + session.remoteDebuggingPort, + "revision-repair-winner-match-with-conflict.png", + (page) => page.locator(".sls-repair-result").filter({ hasText: path }) + ); + const afterApply = await readRevisionTree(cliBinary, session.cliEnv); + if (JSON.stringify(afterApply) !== JSON.stringify(beforeApply)) { + throw new Error( + `Applying a live revision to the Vault changed the revision tree: ${JSON.stringify({ + beforeApply, + afterApply, + })}` + ); + } + const vaultWinner = await readVaultWinnerState(cliBinary, session.cliEnv); + const appliedProvenance = await readFileReflectionProvenance(cliBinary, session.cliEnv); + if ( + !vaultWinner.matches || + vaultWinner.winnerRevision !== fixture.winnerRevision || + appliedProvenance?.revision !== fixture.winnerRevision + ) { + throw new Error( + `Applying the winner did not preserve exact Vault provenance: ${JSON.stringify({ + vaultWinner, + appliedProvenance, + fixture, + })}` + ); + } + + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const settings = page.locator(".sls-setting"); + const menu = await openRevisionActionMenu(page, settings, fixture.winnerRevision); + await menu + .getByText("Mark this revision as the Vault version", { exact: true }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await menu + .getByText("Discard this branch", { exact: true }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await menu + .getByText("Mark this revision as the Vault version", { exact: true }) + .click({ timeout: uiTimeoutMs }); + await revisionCard(settings, fixture.winnerRevision) + .getByText("✅ Matches Vault", { exact: true }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + const afterExactMark = await readRevisionTree(cliBinary, session.cliEnv); + const markedProvenance = await readFileReflectionProvenance(cliBinary, session.cliEnv); + if ( + JSON.stringify(afterExactMark) !== JSON.stringify(beforeApply) || + markedProvenance?.revision !== fixture.winnerRevision + ) { + throw new Error( + `Recording an exact Vault match changed the tree or lost provenance: ${JSON.stringify({ + beforeApply, + afterExactMark, + markedProvenance, + })}` + ); + } + + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const settings = page.locator(".sls-setting"); + const menu = await openRevisionActionMenu(page, settings, fixture.conflictRevision); + for (const label of [ + "Store Vault file as a child of this revision", + "Retry reading revision", + "Discard this branch", + ]) { + await menu.getByText(label, { exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + } + }); + const unreadableMenuScreenshot = await captureObsidianElement( + session.remoteDebuggingPort, + "revision-repair-unreadable-actions-context.png", + (page) => page.locator("body") + ); + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + await page.keyboard.press("Escape"); + const settings = page.locator(".sls-setting"); + await selectRevisionAction(page, settings, fixture.conflictRevision, "Retry reading revision"); + await revisionCard(settings, fixture.conflictRevision) + .getByText(/🧩 Missing chunks:/u) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + + const afterRetry = await readRevisionTree(cliBinary, session.cliEnv); + if (JSON.stringify(afterRetry) !== JSON.stringify(beforeApply)) { + throw new Error(`Retry changed the revision tree: ${JSON.stringify(afterRetry)}`); + } + + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const settings = page.locator(".sls-setting"); + await selectRevisionAction(page, settings, fixture.conflictRevision, "Discard this branch"); + const confirmation = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Discard branch" }), + }); + await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await confirmation.getByRole("button", { name: "No", exact: true }).click({ timeout: uiTimeoutMs }); + await confirmation.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + const afterCancellation = await readRevisionTree(cliBinary, session.cliEnv); + if (JSON.stringify(afterCancellation) !== JSON.stringify(beforeApply)) { + throw new Error(`Cancelling discard changed the revision tree: ${JSON.stringify(afterCancellation)}`); + } + + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const settings = page.locator(".sls-setting"); + await selectRevisionAction(page, settings, fixture.conflictRevision, "Discard this branch"); + const confirmation = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Discard branch" }), + }); + await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await confirmation.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs }); + await repairCard(settings).waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + const afterDiscard = await readRevisionTree(cliBinary, session.cliEnv); + if (afterDiscard.winnerRevision !== fixture.winnerRevision || afterDiscard.conflictRevisions.length !== 0) { + throw new Error( + `Explicit discard did not remove only the selected unreadable revision: ${JSON.stringify({ + fixture, + afterDiscard, + })}` + ); + } + const finalVaultWinner = await readVaultWinnerState(cliBinary, session.cliEnv); + const finalProvenance = await readFileReflectionProvenance(cliBinary, session.cliEnv); + if ( + !finalVaultWinner.matches || + finalVaultWinner.winnerRevision !== fixture.winnerRevision || + finalProvenance?.revision !== fixture.winnerRevision + ) { + throw new Error( + `Discarding the unreadable branch disturbed the healthy Vault reflection: ${JSON.stringify({ + finalVaultWinner, + finalProvenance, + fixture, + })}` + ); + } + + console.log( + "Real Obsidian omitted a healthy logical deletion; rendered each live revision with compact actions and diagnostics; showed that the Vault matched the winner while one conflict remained; compared and applied an exact readable revision without changing the tree; preserved Vault provenance; kept an unreadable branch through automatic checking, retry, and cancelled discard; and discarded only the selected branch after confirmation." + ); + console.log(`Repair card screenshot: ${repairCardScreenshot}`); + console.log(`Mobile-width repair card screenshot: ${mobileWidthScreenshot}`); + console.log(`Readable revision actions screenshot: ${readableMenuScreenshot}`); + console.log(`Read-only comparison screenshot: ${comparisonScreenshot}`); + console.log(`Matching winner with conflict screenshot: ${matchedWinnerWithConflictScreenshot}`); + console.log(`Unreadable revision actions screenshot: ${unreadableMenuScreenshot}`); + } finally { + if (session) { + await session.app.stop(); + } + await vault.dispose(); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/test/e2e-obsidian/scripts/run-focused.ts b/test/e2e-obsidian/scripts/run-focused.ts new file mode 100644 index 00000000..957d6fd8 --- /dev/null +++ b/test/e2e-obsidian/scripts/run-focused.ts @@ -0,0 +1,90 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +// Keep the public wrapper deliberately narrower than package.json. Discovery, +// installation, runner contracts, and the complete suite have different setup +// requirements and remain separate entry points. +const focusedScenarios = new Set([ + "smoke", + "onboarding-invitation", + "dialog-mounts", + "revision-repair", + "document-history-nav", + "settings-ui", + "review-harness", + "p2p-pane", + "vault-reflection", + "couchdb-upload", + "couchdb-manual-setup-workflow", + "cli-to-obsidian-sync", + "minio-upload", + "object-storage-setup-uri-workflow", + "p2p-setup-uri-workflow", + "startup-scan", + "setup-uri-workflow", + "two-vault-sync", + "security-seed-reconnect", + "hidden-file-snippet-sync", + "customisation-sync", + "setting-markdown-export", + "upgrade-from-stable", +]); + +function usage(): string { + return `Usage: npm run test:e2e:obsidian:focused -- [scenario arguments] + +Builds the current Self-hosted LiveSync plug-in before running one maintained +real-Obsidian scenario. Supported scenarios: + +${[...focusedScenarios].map((scenario) => ` ${scenario}`).join("\n")} + +This wrapper does not start CouchDB, Object Storage, or the P2P signalling +relay. Use the documented service commands or the complete +local-suite:services wrapper when required.`; +} + +// npm receives each argument directly. In particular, environment values and +// scenario arguments never pass through a shell for re-interpretation. +function runNpm(args: string[]): void { + const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + const result = spawnSync(npm, args, { + cwd: fileURLToPath(new URL("../../..", import.meta.url)), + stdio: "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`npm ${args.join(" ")} failed with exit code ${result.status ?? "unknown"}.`); + } +} + +function main(): void { + const [scenario, ...scenarioArguments] = process.argv.slice(2); + if (!scenario || scenario === "-h" || scenario === "--help") { + process.stdout.write(`${usage()}\n`); + return; + } + if (!focusedScenarios.has(scenario)) { + throw new Error(`Unsupported focused real-Obsidian scenario: ${scenario}\n\n${usage()}`); + } + + // Individual scenario scripts intentionally remain fast, raw entry points. + // The wrapper owns the freshness guarantee which was previously easy to + // miss after changing TypeScript source. + runNpm(["run", "build"]); + + // The compatibility scenario defaults to the repository CLI. Build it only + // when the caller has not selected an external CLI distribution. + if (scenario === "cli-to-obsidian-sync" && !process.env.LIVESYNC_CLI_COMMAND) { + runNpm(["run", "build", "--workspace", "self-hosted-livesync-cli"]); + } + + const script = `test:e2e:obsidian:${scenario}`; + runNpm(["run", script, ...(scenarioArguments.length > 0 ? ["--", ...scenarioArguments] : [])]); +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +} diff --git a/test/e2e-obsidian/scripts/security-seed-reconnect.ts b/test/e2e-obsidian/scripts/security-seed-reconnect.ts new file mode 100644 index 00000000..d75ffc21 --- /dev/null +++ b/test/e2e-obsidian/scripts/security-seed-reconnect.ts @@ -0,0 +1,768 @@ +/** + * Provides release evidence for the Security Seed refresh behaviour shared by + * supported platforms in real Obsidian. It verifies that an already-open + * device keeps its deliberately stale cached Seed until replication, refreshes + * from the managed CouchDB fixture before encrypting, and never restores the + * old Seed to the remote synchronisation-parameter document. + * + * The scenario uses isolated Vaults, profiles, and a random database because + * settings, the local database, the renderer process, and CouchDB must all + * participate in the result. Device A is restarted with the same Vault and + * profile, while device B is fresh. The devices run sequentially after the + * same-process stale-cache assertion because desktop Obsidian may enforce a + * single application instance; running them concurrently would test launcher + * behaviour rather than LiveSync's shared plug-in implementation. + * + * Seed replacement, A-to-B decryption, B-to-A return synchronisation, final + * remote-document comparison, error-log inspection, screenshots, and strict + * teardown remain one scenario. Together they prove that the same replacement + * Seed was used across the complete encrypted round trip and was not later + * rolled back. Independent passing checks would not establish that continuity. + * The result records fingerprints only and does not claim to cover an + * iPadOS-specific background or reconnect lifecycle. + */ +import { execFileSync } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { + assertCouchDbReachable, + couchDbDatabaseExists, + createCouchDbDatabase, + deleteCouchDbDatabase, + fetchAllCouchDbDocs, + fetchCouchDbDocument, + loadCouchDbConfig, + makeUniqueDatabaseName, + putCouchDbDocument, + waitForCouchDbDocs, + type CouchDbConfig, + type CouchDbDocument, +} from "../runner/couchdb.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { + createE2eCouchDbPluginData, + createE2eObsidianDeviceLocalState, + prepareRemote, + pushLocalChanges, + waitForLiveSyncCoreReady, + waitForLocalDatabaseEntry, + type LocalDatabaseEntry, +} from "../runner/liveSyncWorkflow.ts"; +import { + SECURITY_SEED_DOCUMENT_ID, + changedSynchronisationParameterFields, + createSecuritySeed, + fingerprintSecuritySeed, + replaceSecuritySeed, + requireSecuritySeedDocument, + snapshotSecuritySeedDocument, + type SecuritySeedDocument, + type SecuritySeedDocumentSnapshot, +} from "../runner/securitySeed.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { captureObsidianPage } from "../runner/ui.ts"; +import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; + +process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000"; +process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "20000"; +process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ??= "15000"; + +const outboundPath = "E2E/security-seed/device-a.md"; +const returnPath = "E2E/security-seed/device-b.md"; +const hkdfErrorMessages = [ + "Encryption with HKDF failed", + "Decryption with HKDF failed", + "Failed to initialise the encryption key", + "Failed to obtain PBKDF2 salt", +] as const; + +type RunnerContext = { + binary: string; + cliBinary: string; + artifactRoot: string; + couchDb: CouchDbConfig; + dbName: string; + activeSessions: Set; + allSessions: ObsidianLiveSyncSession[]; + screenshots: string[]; +}; + +type DeviceLabel = "device-a" | "device-a-return" | "device-b"; + +type SourceEvidence = { + exactCommit: string; + revisionSource: "git-worktree" | "provided-artifact"; + workingTreeClean: boolean | null; + pluginVersion: string; + pluginArtifactSha256: string; +}; + +type ReplicationSettingsState = { + liveSync: boolean; + syncOnStart: boolean; + syncOnSave: boolean; + periodicReplication: boolean; + syncOnFileOpen: boolean; + syncOnEditorSave: boolean; +}; + +type SessionHealth = { + matchingErrorMessages: string[]; +}; + +type ScenarioEvidence = { + source: SourceEvidence; + securitySeed: { + initial: SecuritySeedDocumentSnapshot; + replacement: SecuritySeedDocumentSnapshot; + final: SecuritySeedDocumentSnapshot; + cachedBeforeReplacement: string; + cachedAfterRemoteReplacement: string; + cachedAfterReplication: string; + replacementChangedFields: string[]; + finalChangedFields: string[]; + }; + synchronisation: { + deviceAToDeviceB: boolean; + deviceBToDeviceA: boolean; + deviceAEncryptedPayload: boolean; + deviceBEncryptedPayload: boolean; + }; + health: { + deviceA: SessionHealth; + deviceB: SessionHealth; + }; + screenshots: string[]; +}; + +type TeardownEvidence = { + sessionsStopped: boolean; + vaultRemoved: boolean; + profileRemoved: boolean; + databaseRemoved: boolean; + remainingTrackedSessions: number; +}; + +class MultipleErrors extends Error { + readonly errors: unknown[]; + + constructor(message: string, errors: unknown[]) { + super(message); + this.name = "MultipleErrors"; + this.errors = errors; + } +} + +function assertEqual(actual: unknown, expected: unknown, message: string): void { + if (actual !== expected) { + throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`); + } +} + +function inspectGitRevision( + artifactRoot: string +): Pick { + try { + const exactCommit = execFileSync("git", ["-C", artifactRoot, "rev-parse", "HEAD"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + const status = execFileSync("git", ["-C", artifactRoot, "status", "--porcelain"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return { + exactCommit, + revisionSource: "git-worktree", + workingTreeClean: status.length === 0, + }; + } catch { + const exactCommit = process.env.E2E_OBSIDIAN_ARTIFACT_REVISION?.trim(); + if (!exactCommit) { + throw new Error( + "E2E_OBSIDIAN_ARTIFACT_REVISION is required when the plug-in artefact is not in a Git worktree." + ); + } + return { + exactCommit, + revisionSource: "provided-artifact", + workingTreeClean: null, + }; + } +} + +async function inspectSourceEvidence(artifactRoot: string): Promise { + const manifest = JSON.parse(await readFile(join(artifactRoot, "manifest.json"), "utf-8")) as { + version?: unknown; + }; + if (typeof manifest.version !== "string" || manifest.version.length === 0) { + throw new Error("The plug-in manifest does not have a version."); + } + const mainJs = await readFile(join(artifactRoot, "main.js")); + return { + ...inspectGitRevision(artifactRoot), + pluginVersion: manifest.version, + pluginArtifactSha256: createHash("sha256").update(Uint8Array.from(mainJs)).digest("hex"), + }; +} + +function e2eeSettings(passphrase: string): Record { + return { + encrypt: true, + passphrase, + usePathObfuscation: true, + E2EEAlgorithm: "v2", + }; +} + +async function captureStage(context: RunnerContext, session: ObsidianLiveSyncSession, filename: string): Promise { + const screenshot = await captureObsidianPage(session.remoteDebuggingPort, filename, async () => undefined); + context.screenshots.push(screenshot); + console.log(`Security Seed E2E screenshot: ${screenshot}`); +} + +async function startConfiguredSession( + context: RunnerContext, + vault: TemporaryVault, + passphrase: string, + deviceLabel: DeviceLabel +): Promise { + const couchDbSettings = { + uri: context.couchDb.uri, + username: context.couchDb.username, + password: context.couchDb.password, + dbName: context.dbName, + }; + const overrides = e2eeSettings(passphrase); + const session = await startObsidianLiveSyncSession({ + binary: context.binary, + cliBinary: context.cliBinary, + artifactRoot: context.artifactRoot, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: createE2eCouchDbPluginData(couchDbSettings, overrides), + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), + }); + context.activeSessions.add(session); + context.allSessions.push(session); + try { + await captureStage(context, session, `security-seed-${deviceLabel}-startup.png`); + await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); + await prepareRemote(context.cliBinary, session.cliEnv); + await captureStage(context, session, `security-seed-${deviceLabel}-configured.png`); + return session; + } catch (error) { + await captureStage(context, session, `security-seed-${deviceLabel}-setup-failure.png`).catch(() => undefined); + await stopTrackedSession(context, session); + throw error; + } +} + +async function stopTrackedSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise { + if (!context.activeSessions.has(session)) { + return; + } + await session.app.stop(); + context.activeSessions.delete(session); +} + +async function stopTrackedSessions(context: RunnerContext): Promise { + const errors: unknown[] = []; + for (const session of [...context.activeSessions]) { + try { + await stopTrackedSession(context, session); + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) { + throw new MultipleErrors("Could not stop every Real Obsidian session.", errors); + } +} + +async function pauseAutomaticReplication(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + const state = await evalObsidianJson( + cliBinary, + [ + "(()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "core.services.replicator.getActiveReplicator()?.closeReplication();", + "const settings=core.services.setting.currentSettings();", + "return JSON.stringify({", + "liveSync:Boolean(settings.liveSync),", + "syncOnStart:Boolean(settings.syncOnStart),", + "syncOnSave:Boolean(settings.syncOnSave),", + "periodicReplication:Boolean(settings.periodicReplication),", + "syncOnFileOpen:Boolean(settings.syncOnFileOpen),", + "syncOnEditorSave:Boolean(settings.syncOnEditorSave),", + "});", + "})()", + ].join(""), + env + ); + for (const [name, enabled] of Object.entries(state)) { + if (enabled) { + throw new Error(`Automatic replication remained enabled through ${name}.`); + } + } + return state; +} + +async function cachedSecuritySeedFingerprint(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + const result = await evalObsidianJson<{ fingerprint: string }>( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const settings=core.services.setting.currentSettings();", + "const replicator=core.services.replicator.getActiveReplicator();", + "const seed=await replicator.getReplicationPBKDF2Salt(settings,false);", + "const digest=await crypto.subtle.digest('SHA-256',seed);", + "const fingerprint='sha256:'+Array.from(new Uint8Array(digest))", + ".map((value)=>value.toString(16).padStart(2,'0')).join('').slice(0,16);", + "return JSON.stringify({fingerprint});", + "})()", + ].join(""), + env + ); + return result.fingerprint; +} + +async function writeNoteViaObsidian( + cliBinary: string, + env: NodeJS.ProcessEnv, + path: string, + content: string +): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const content=${JSON.stringify(content)};`, + "const folder=path.split('/').slice(0,-1).join('/');", + "if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);", + "const existing=app.vault.getAbstractFileByPath(path);", + "if(existing) await app.vault.modify(existing,content);", + "else await app.vault.create(path,content);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); +} + +async function openNoteViaObsidian(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + "const file=app.vault.getAbstractFileByPath(path);", + "if(!file) throw new Error(`Could not find note to open: ${path}`);", + "await app.workspace.getLeaf(false).openFile(file);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); +} + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw error; + } +} + +async function waitForPathContent( + vaultPath: string, + path: string, + expected: string, + timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 15000) +): Promise { + const fullPath = join(vaultPath, path); + const deadline = Date.now() + timeoutMs; + let lastContent = ""; + while (Date.now() < deadline) { + if (await pathExists(fullPath)) { + lastContent = await readFile(fullPath, "utf-8"); + if (lastContent === expected) { + return; + } + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`); +} + +function remoteContainsEntry(documents: CouchDbDocument[], entry: LocalDatabaseEntry): boolean { + const ids = new Set(documents.map((document) => document._id)); + return ids.has(entry.id) && entry.children.every((childId) => ids.has(childId)); +} + +async function assertEntryNotRemote(context: RunnerContext, entry: LocalDatabaseEntry): Promise { + const response = await fetchAllCouchDbDocs(context.couchDb, context.dbName); + const documents = response.rows.flatMap((row) => (row.doc ? [row.doc] : [])); + if (remoteContainsEntry(documents, entry)) { + throw new Error("The pending device-A document reached CouchDB before the Security Seed replacement."); + } +} + +async function waitForEncryptedRemoteEntry(context: RunnerContext, entry: LocalDatabaseEntry): Promise { + const documents = await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => + remoteContainsEntry(docs, entry) + ); + const byId = new Map(documents.map((document) => [document._id, document])); + const encrypted = entry.children.every((childId) => { + const data = byId.get(childId)?.data; + return typeof data === "string" && data.startsWith("%="); + }); + if (!encrypted) { + throw new Error("A replicated chunk did not use the expected HKDF-encrypted payload format."); + } + return true; +} + +async function inspectSessionHealth(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const patterns=${JSON.stringify(hkdfErrorMessages)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "await core.services.API.showWindow('log-log');", + "const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));", + "let text='';", + "for(let i=0;i<20;i++){", + "text=Array.from(document.querySelectorAll('.logpane .log pre'))", + ".map((element)=>element.textContent??'').join('\\n');", + "if(text.length>0) break;", + "await sleep(50);", + "}", + "const unresolved=JSON.stringify((await core.services.appLifecycle.getUnresolvedMessages()).flat());", + "const matchingErrorMessages=patterns.filter((pattern)=>text.includes(pattern)||unresolved.includes(pattern));", + "for(const leaf of app.workspace.getLeavesOfType('log-log')) leaf.detach();", + "return JSON.stringify({matchingErrorMessages});", + "})()", + ].join(""), + env + ); +} + +async function fetchSecuritySeedDocument(context: RunnerContext): Promise { + return requireSecuritySeedDocument( + await fetchCouchDbDocument(context.couchDb, context.dbName, SECURITY_SEED_DOCUMENT_ID) + ); +} + +async function replaceRemoteSecuritySeed( + context: RunnerContext, + before: SecuritySeedDocument, + replacementSeed: string +): Promise { + const replacement = replaceSecuritySeed(before, replacementSeed); + const putResult = await putCouchDbDocument(context.couchDb, context.dbName, replacement); + const after = await fetchSecuritySeedDocument(context); + assertEqual(after._rev, putResult.rev, "The replacement Security Seed revision was not stored."); + assertEqual( + fingerprintSecuritySeed(after.pbkdf2salt), + fingerprintSecuritySeed(replacementSeed), + "The replacement Security Seed was not stored." + ); + const changedFields = changedSynchronisationParameterFields(before, after); + assertEqual( + JSON.stringify(changedFields), + JSON.stringify(["pbkdf2salt"]), + "Replacing the remote Security Seed changed another synchronisation parameter." + ); + return after; +} + +async function runScenario( + context: RunnerContext, + vaultA: TemporaryVault, + vaultB: TemporaryVault +): Promise { + const passphrase = `security-seed-e2e-${randomUUID()}`; + const source = await inspectSourceEvidence(context.artifactRoot); + let sessionA = await startConfiguredSession(context, vaultA, passphrase, "device-a"); + + await pushLocalChanges(context.cliBinary, sessionA.cliEnv); + await captureStage(context, sessionA, "security-seed-device-a-initial-sync.png"); + const initialDocument = await fetchSecuritySeedDocument(context); + const initial = snapshotSecuritySeedDocument(initialDocument); + const cachedBeforeReplacement = await cachedSecuritySeedFingerprint(context.cliBinary, sessionA.cliEnv); + assertEqual( + cachedBeforeReplacement, + initial.fingerprint, + "Device A did not cache the initial remote Security Seed." + ); + + await pauseAutomaticReplication(context.cliBinary, sessionA.cliEnv); + const outboundContent = `Encrypted from device A: ${randomUUID()}\n`; + await writeNoteViaObsidian(context.cliBinary, sessionA.cliEnv, outboundPath, outboundContent); + const outboundEntry = await waitForLocalDatabaseEntry(context.cliBinary, sessionA.cliEnv, outboundPath); + await assertEntryNotRemote(context, outboundEntry); + + const replacementSeed = createSecuritySeed(); + const replacementDocument = await replaceRemoteSecuritySeed(context, initialDocument, replacementSeed); + const replacement = snapshotSecuritySeedDocument(replacementDocument); + await openNoteViaObsidian(context.cliBinary, sessionA.cliEnv, outboundPath); + await captureStage(context, sessionA, "security-seed-device-a-replacement-pending.png"); + const cachedAfterRemoteReplacement = await cachedSecuritySeedFingerprint(context.cliBinary, sessionA.cliEnv); + assertEqual( + cachedAfterRemoteReplacement, + initial.fingerprint, + "Device A did not retain the deliberately stale Security Seed before replication." + ); + assertEqual( + replacement.fingerprint, + fingerprintSecuritySeed(replacementSeed), + "The runner did not install the intended replacement Security Seed." + ); + + await pushLocalChanges(context.cliBinary, sessionA.cliEnv); + const cachedAfterReplication = await cachedSecuritySeedFingerprint(context.cliBinary, sessionA.cliEnv); + assertEqual( + cachedAfterReplication, + replacement.fingerprint, + "Device A did not refresh the Security Seed before replication." + ); + const deviceAEncryptedPayload = await waitForEncryptedRemoteEntry(context, outboundEntry); + await captureStage(context, sessionA, "security-seed-device-a-refreshed-sync.png"); + const deviceAHealthBeforeRestart = await inspectSessionHealth(context.cliBinary, sessionA.cliEnv); + await stopTrackedSession(context, sessionA); + + const sessionB = await startConfiguredSession(context, vaultB, passphrase, "device-b"); + await pushLocalChanges(context.cliBinary, sessionB.cliEnv); + await waitForPathContent(vaultB.path, outboundPath, outboundContent); + await openNoteViaObsidian(context.cliBinary, sessionB.cliEnv, outboundPath); + await captureStage(context, sessionB, "security-seed-device-b-received.png"); + + await pauseAutomaticReplication(context.cliBinary, sessionB.cliEnv); + const returnContent = `Encrypted from device B: ${randomUUID()}\n`; + await writeNoteViaObsidian(context.cliBinary, sessionB.cliEnv, returnPath, returnContent); + const returnEntry = await waitForLocalDatabaseEntry(context.cliBinary, sessionB.cliEnv, returnPath); + await pushLocalChanges(context.cliBinary, sessionB.cliEnv); + const deviceBEncryptedPayload = await waitForEncryptedRemoteEntry(context, returnEntry); + const deviceBHealth = await inspectSessionHealth(context.cliBinary, sessionB.cliEnv); + await stopTrackedSession(context, sessionB); + + sessionA = await startConfiguredSession(context, vaultA, passphrase, "device-a-return"); + await pushLocalChanges(context.cliBinary, sessionA.cliEnv); + await waitForPathContent(vaultA.path, returnPath, returnContent); + await openNoteViaObsidian(context.cliBinary, sessionA.cliEnv, returnPath); + await captureStage(context, sessionA, "security-seed-device-a-return-received.png"); + + const finalDocument = await fetchSecuritySeedDocument(context); + const final = snapshotSecuritySeedDocument(finalDocument); + assertEqual( + final.fingerprint, + replacement.fingerprint, + "A client rolled the remote Security Seed back after reconnecting." + ); + const finalChangedFields = changedSynchronisationParameterFields(replacementDocument, finalDocument); + if (finalChangedFields.length > 0) { + throw new Error( + `A client rewrote unexpected synchronisation-parameter fields: ${finalChangedFields.join(", ")}` + ); + } + + const deviceAHealthAfterRestart = await inspectSessionHealth(context.cliBinary, sessionA.cliEnv); + const deviceAHealth = { + matchingErrorMessages: [ + ...new Set([ + ...deviceAHealthBeforeRestart.matchingErrorMessages, + ...deviceAHealthAfterRestart.matchingErrorMessages, + ]), + ], + }; + if (deviceAHealth.matchingErrorMessages.length > 0 || deviceBHealth.matchingErrorMessages.length > 0) { + throw new Error( + `HKDF or Security Seed errors were logged: ${JSON.stringify({ + deviceA: deviceAHealth.matchingErrorMessages, + deviceB: deviceBHealth.matchingErrorMessages, + })}` + ); + } + + return { + source, + securitySeed: { + initial, + replacement, + final, + cachedBeforeReplacement, + cachedAfterRemoteReplacement, + cachedAfterReplication, + replacementChangedFields: changedSynchronisationParameterFields(initialDocument, replacementDocument), + finalChangedFields, + }, + synchronisation: { + deviceAToDeviceB: true, + deviceBToDeviceA: true, + deviceAEncryptedPayload, + deviceBEncryptedPayload, + }, + health: { + deviceA: deviceAHealth, + deviceB: deviceBHealth, + }, + screenshots: [...context.screenshots], + }; +} + +async function cleanupResources( + context: RunnerContext, + vaults: TemporaryVault[], + databaseCreated: boolean +): Promise { + const errors: unknown[] = []; + try { + await stopTrackedSessions(context); + } catch (error) { + errors.push(error); + } + for (const vault of vaults) { + try { + await vault.dispose(); + } catch (error) { + errors.push(error); + } + } + if (databaseCreated) { + try { + await deleteCouchDbDatabase(context.couchDb, context.dbName); + } catch (error) { + errors.push(error); + } + } + + const sessionsStopped = context.allSessions.every( + (session) => session.app.process.exitCode !== null || session.app.process.signalCode !== null + ); + const vaultRemoved = (await Promise.all(vaults.map(async (vault) => !(await pathExists(vault.path))))).every( + Boolean + ); + const profileRemoved = (await Promise.all(vaults.map(async (vault) => !(await pathExists(vault.statePath))))).every( + Boolean + ); + let databaseRemoved = !databaseCreated; + if (databaseCreated) { + try { + databaseRemoved = !(await couchDbDatabaseExists(context.couchDb, context.dbName)); + } catch (error) { + errors.push(error); + } + } + const evidence = { + sessionsStopped, + vaultRemoved, + profileRemoved, + databaseRemoved, + remainingTrackedSessions: context.activeSessions.size, + }; + if (!sessionsStopped || !vaultRemoved || !profileRemoved || !databaseRemoved || context.activeSessions.size > 0) { + errors.push(new Error(`Security Seed E2E teardown was incomplete: ${JSON.stringify(evidence)}`)); + } + if (errors.length > 0) { + throw Object.assign(new MultipleErrors("Security Seed E2E teardown failed.", errors), { + evidence, + }); + } + return evidence; +} + +async function writeResult(result: unknown): Promise { + const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e"; + const resultPath = join(outputDirectory, "security-seed-reconnect-result.json"); + await mkdir(outputDirectory, { recursive: true }); + await writeFile(resultPath, `${JSON.stringify(result, null, 2)}\n`, "utf-8"); + return resultPath; +} + +async function main(): Promise { + if (process.env.E2E_OBSIDIAN_KEEP_VAULT === "true" || process.env.E2E_OBSIDIAN_KEEP_COUCHDB === "true") { + throw new Error("The Security Seed reconnect scenario requires strict Vault, profile, and database cleanup."); + } + + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) { + throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); + } + const artifactRoot = resolve(process.env.E2E_OBSIDIAN_ARTIFACT_ROOT ?? process.cwd()); + const couchDb = await loadCouchDbConfig(); + const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "security-seed-reconnect"); + const context: RunnerContext = { + binary, + cliBinary: cli.binary, + artifactRoot, + couchDb, + dbName, + activeSessions: new Set(), + allSessions: [], + screenshots: [], + }; + const vaults: TemporaryVault[] = []; + let databaseCreated = false; + let evidence: ScenarioEvidence | undefined; + let scenarioError: unknown; + let teardown: TeardownEvidence | undefined; + let teardownError: unknown; + + try { + await assertCouchDbReachable(couchDb); + await createCouchDbDatabase(couchDb, dbName); + databaseCreated = true; + vaults.push(await createTemporaryVault("obsidian-livesync-security-seed-a-")); + vaults.push(await createTemporaryVault("obsidian-livesync-security-seed-b-")); + evidence = await runScenario(context, vaults[0], vaults[1]); + } catch (error) { + scenarioError = error; + } finally { + try { + teardown = await cleanupResources(context, vaults, databaseCreated); + } catch (error) { + teardownError = error; + } + } + + if (scenarioError !== undefined || teardownError !== undefined) { + const errors = [scenarioError, teardownError].filter((error) => error !== undefined); + if (errors.length === 1) { + throw errors[0]; + } + throw new MultipleErrors("Security Seed reconnect scenario and teardown both failed.", errors); + } + if (!evidence || !teardown) { + throw new Error("Security Seed reconnect evidence was not produced."); + } + + const result = { + scenario: "security-seed-reconnect", + ...evidence, + teardown, + limitations: { + platformCommonRealObsidian: true, + iPadOsBackgroundReconnect: false, + androidDeviceLifecycle: false, + }, + }; + const resultPath = await writeResult(result); + console.log(`Security Seed E2E result: ${resultPath}`); + console.log(JSON.stringify(result, null, 2)); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exitCode = 1; +}); diff --git a/test/e2e-obsidian/scripts/settings-ui.ts b/test/e2e-obsidian/scripts/settings-ui.ts new file mode 100644 index 00000000..3afe2010 --- /dev/null +++ b/test/e2e-obsidian/scripts/settings-ui.ts @@ -0,0 +1,365 @@ +import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts"; +import { assertMobileDialogueLayout, setObsidianMobileTestMode } from "../runner/mobileUi.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { captureObsidianDialogue, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts"; +import { createTemporaryVault } from "../runner/vault.ts"; + +const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS ?? 10000); +const compatibilityReviewMessage = "Review the internal database compatibility change before synchronisation resumes."; + +type ObsidianSettingsController = { + open(): void; + openTabById(tabId: string): void; +}; + +type LiveSyncTestPlugin = { + core: { + services: { + setting: { + currentSettings(): { + versionUpFlash: string; + allowSleepDuringSynchronisation: boolean; + allowSleepDuringSynchronisationOnDesktop: boolean; + useAdvancedMode: boolean; + usePowerUserMode: boolean; + useEdgeCaseMode: boolean; + }; + getSmallConfig(key: string): string | null; + }; + }; + }; +}; + +type ObsidianTestApp = { + setting?: ObsidianSettingsController; + plugins?: { plugins: Record }; +}; + +type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp }; + +async function verifyCompatibilityReview(): Promise { + const port = obsidianRemoteDebuggingPort(); + const summaryScreenshot = await captureObsidianDialogue(port, "compatibility-review-summary.png", async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ + hasText: "Synchronisation paused for compatibility review", + }), + }); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal + .getByText("Your automatic synchronisation preferences have not been changed.", { exact: false }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal + .getByRole("button", { name: "Review compatibility details" }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal + .getByRole("button", { + name: "Resume synchronisation", + }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal + .getByRole("button", { name: "Keep synchronisation paused" }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + + await withObsidianPage(port, async (page) => { + const markerBeforeAcknowledgement = await page.evaluate(() => { + const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable"); + return plugin.core.services.setting.getSmallConfig("database-compatibility-version"); + }); + if (markerBeforeAcknowledgement !== null && markerBeforeAcknowledgement !== "") { + throw new Error( + `The database version was marked as acknowledged before review: ${markerBeforeAcknowledgement}` + ); + } + }); + + await setObsidianMobileTestMode(port, true, uiTimeoutMs); + const mobileSummaryScreenshot = await captureObsidianDialogue( + port, + "compatibility-review-summary-mobile.png", + async (page) => { + const summary = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ + hasText: "Synchronisation paused for compatibility review", + }), + }); + await summary.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await assertMobileDialogueLayout(page, summary, "compatibility review summary"); + const doctor = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Self-hosted LiveSync Config Doctor" }), + }); + if (await doctor.isVisible()) { + throw new Error("Config Doctor must wait until the initial compatibility review has closed."); + } + } + ); + + await withObsidianPage(port, async (page) => { + const summary = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ + hasText: "Synchronisation paused for compatibility review", + }), + }); + await summary.getByRole("button", { name: "Review compatibility details" }).click(); + }); + + const detailsScreenshot = await captureObsidianDialogue( + port, + "compatibility-review-details-mobile.png", + async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Compatibility review details" }), + }); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal.getByText("Why synchronisation is paused", { exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await modal.getByText("Remote replication is blocked before work begins.", { exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await modal + .getByRole("button", { name: "Back to compatibility review" }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + if ((await modal.getByRole("button", { name: "Keep synchronisation paused" }).count()) !== 0) { + throw new Error("The explanatory details dialogue must not make the pause decision."); + } + await assertMobileDialogueLayout(page, modal, "compatibility review details"); + } + ); + + await withObsidianPage(port, async (page) => { + const details = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Compatibility review details" }), + }); + await details.getByRole("button", { name: "Back to compatibility review" }).click(); + const summary = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ + hasText: "Synchronisation paused for compatibility review", + }), + }); + await summary.waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + + await setObsidianMobileTestMode(port, false, uiTimeoutMs); + await withObsidianPage(port, async (page) => { + const summary = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ + hasText: "Synchronisation paused for compatibility review", + }), + }); + await summary + .getByRole("button", { + name: "Resume synchronisation", + }) + .click(); + await summary.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + await page.waitForFunction( + (expectedVersion) => { + const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + if (plugin === undefined) return false; + const setting = plugin.core.services.setting; + return ( + setting.getSmallConfig("database-compatibility-version") === expectedVersion && + setting.currentSettings().versionUpFlash === "" + ); + }, + `${VER}`, + { timeout: uiTimeoutMs } + ); + }); + + console.log( + `Compatibility review screenshots: ${summaryScreenshot}, ${mobileSummaryScreenshot}, ${detailsScreenshot}` + ); +} + +async function verifyConfigDoctorFollowsCompatibilityReview(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const doctor = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Self-hosted LiveSync Config Doctor" }), + }); + await doctor.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await doctor.getByText("Per-file-saved customization sync", { exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await doctor.getByText("Enhance chunk size", { exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + if ((await doctor.getByText("Data Compression", { exact: true }).count()) !== 0) { + throw new Error("Config Doctor still treats supported Data Compression as a problem."); + } + await doctor.getByRole("button", { name: /No, and do not ask again/u }).click(); + await doctor.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); +} + +async function verifyEffectiveSettings(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const sleepPreferences = await page.evaluate(() => { + const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable"); + const settings = plugin.core.services.setting.currentSettings(); + return { + general: settings.allowSleepDuringSynchronisation, + desktop: settings.allowSleepDuringSynchronisationOnDesktop, + advancedMode: settings.useAdvancedMode, + powerUserMode: settings.usePowerUserMode, + edgeCaseMode: settings.useEdgeCaseMode, + }; + }); + if ( + sleepPreferences.general !== false || + sleepPreferences.desktop !== true || + sleepPreferences.advancedMode !== false || + sleepPreferences.powerUserMode !== false || + sleepPreferences.edgeCaseMode !== false + ) { + throw new Error( + `Unexpected effective sleep preferences: ${JSON.stringify(sleepPreferences)}` + ); + } + + await page.evaluate(() => { + const setting = (globalThis as ObsidianTestGlobal).app?.setting; + if (setting === undefined) throw new Error("Obsidian settings are unavailable"); + setting.open(); + setting.openTabById("obsidian-livesync"); + }); + + const liveSyncSettings = page.locator(".sls-setting"); + await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const settingsClass = await liveSyncSettings.getAttribute("class"); + for (const modeClass of [ + "menu-setting-advanced-disabled", + "menu-setting-poweruser-disabled", + "menu-setting-edgecase-disabled", + ]) { + if (!settingsClass?.split(/\s+/u).includes(modeClass)) { + throw new Error(`The settings UI did not disable ${modeClass}.`); + } + } + + await liveSyncSettings.locator('.sls-setting-menu-btn[title="Change Log"]').click(); + const removedAcknowledgements = liveSyncSettings.getByRole("button", { + name: /I got it and updated|OK, I have read everything/u, + }); + if ((await removedAcknowledgements.count()) !== 0) { + throw new Error("The Change Log still contains a compatibility or release-note acknowledgement control."); + } + + await liveSyncSettings.locator('.sls-setting-menu-btn[title="Remote Configuration"]').click(); + const connectionPanel = liveSyncSettings + .locator("h4.sls-setting-panel-title") + .filter({ hasText: "Connection settings" }) + .locator(".."); + await connectionPanel.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await connectionPanel.getByText("Saved connections", { exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + + await liveSyncSettings.locator('.sls-setting-menu-btn[title="Sync Settings"]').click(); + const generalSleepSetting = liveSyncSettings.locator(".setting-item").filter({ + has: page.getByText("Allow sleep during synchronisation", { exact: true }), + }); + await generalSleepSetting.waitFor({ state: "visible", timeout: uiTimeoutMs }); + if ((await generalSleepSetting.locator(".checkbox-container.is-enabled").count()) !== 0) { + throw new Error("The general sleep preference must be disabled by default."); + } + + const desktopSleepSetting = liveSyncSettings.locator(".setting-item").filter({ + has: page.getByText("Allow sleep during synchronisation on the desktop", { exact: true }), + }); + await desktopSleepSetting.waitFor({ state: "visible", timeout: uiTimeoutMs }); + if ((await desktopSleepSetting.locator(".checkbox-container.is-enabled").count()) !== 1) { + throw new Error("The desktop sleep preference must be enabled by default."); + } + + await liveSyncSettings.locator('.sls-setting-menu-btn[title="Setup"]').click(); + const advancedModeSetting = liveSyncSettings.locator(".setting-item").filter({ + has: page.getByText("Enable advanced features", { exact: true }), + }); + await advancedModeSetting.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await advancedModeSetting.locator(".checkbox-container").click(); + await page.waitForFunction( + () => document.querySelector(".sls-setting")?.classList.contains("menu-setting-advanced-enabled") === true, + undefined, + { timeout: uiTimeoutMs } + ); + await liveSyncSettings.locator('.sls-setting-menu-btn[title="Sync Settings"]').click(); + + const deletionPanel = liveSyncSettings + .locator("h4.sls-setting-panel-title") + .filter({ hasText: "Deletion Propagation" }) + .locator(".."); + await deletionPanel + .getByText("Keep empty folder", { exact: true }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + + // Retirement guard: the removed toggle must not reappear in the current settings pane. + const obsoleteToggleCount = await deletionPanel.getByText("Use the trash bin", { exact: true }).count(); + if (obsoleteToggleCount !== 0) { + throw new Error( + `The obsolete LiveSync trash toggle is still present in the settings UI (${obsoleteToggleCount} found).` + ); + } + }); +} + +async function main(): Promise { + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) { + throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); + } + const vault = await createTemporaryVault(); + let session: ObsidianLiveSyncSession | undefined; + try { + session = await startObsidianLiveSyncSession({ + binary, + cliBinary: cli.binary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: { + doctorProcessedVersion: "0.25.27", + isConfigured: true, + liveSync: false, + versionUpFlash: compatibilityReviewMessage, + notifyThresholdOfRemoteStorageSize: 0, + syncOnStart: false, + syncOnSave: false, + syncOnEditorSave: false, + syncOnFileOpen: false, + syncAfterMerge: false, + periodicReplication: false, + handleFilenameCaseSensitive: false, + useAdvancedMode: false, + usePowerUserMode: false, + useEdgeCaseMode: false, + }, + }); + await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + await verifyCompatibilityReview(); + await verifyConfigDoctorFollowsCompatibilityReview(); + await verifyEffectiveSettings(); + console.log("Compatibility review and settings expose only effective user controls."); + } finally { + if (session) { + await session.app.stop(); + } + await vault.dispose(); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/test/e2e-obsidian/scripts/setup-uri-workflow.ts b/test/e2e-obsidian/scripts/setup-uri-workflow.ts new file mode 100644 index 00000000..3a42db44 --- /dev/null +++ b/test/e2e-obsidian/scripts/setup-uri-workflow.ts @@ -0,0 +1,863 @@ +import { execFile } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { Locator, Page } from "playwright"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { + assertCouchDbReachable, + deleteCouchDbDatabase, + loadCouchDbConfig, + makeUniqueDatabaseName, + waitForCouchDbDocs, + type CouchDbConfig, +} from "../runner/couchdb.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { + assertEqual, + pushLocalChanges, + waitForLocalDatabaseEntry, + type LocalDatabaseEntry, +} from "../runner/liveSyncWorkflow.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { + assertVerticalActionLayout, + generateSetupURIFromDevice, + resumeCompatibilityReviewIfShown, +} from "../runner/setupUri.ts"; +import { + captureObsidianDialogue, + captureObsidianElement, + captureObsidianPage, + withObsidianPage, +} from "../runner/ui.ts"; +import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; + +process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000"; +process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "30000"; + +const execFileAsync = promisify(execFile); +const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 30000); +const initialisationTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_INITIALISATION_TIMEOUT_MS ?? 120000); +const hiddenFileCliTimeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_CLI_TIMEOUT_MS ?? 90000); +const notePath = "E2E/setup-uri/provisioned-workflow.md"; +const noteContent = "# Provisioned Setup URI\n\nThis note travelled through the generated CouchDB Setup URI.\n"; +const returnNotePath = "E2E/setup-uri/from-second-device.md"; +const returnNoteContent = + "# CouchDB from the second device\n\nThis note completed the return journey through CouchDB.\n"; +const snippetPath = ".obsidian/snippets/setup-uri-workflow.css"; +const snippetContent = [ + "body {", + " --setup-uri-workflow-colour: #245a70;", + "}", + "", + ".setup-uri-workflow {", + " color: var(--setup-uri-workflow-colour);", + "}", + "", +].join("\n"); + +type SetupArtifact = { + setupURI: string; + setupPassphrase: string; +}; + +type SetupState = { + configured: boolean; + databaseReady: boolean; + appReady: boolean; + suspended: boolean; + remoteType: string; + activeConfigurationId: string; + remoteConfigurationCount: number; + syncInternalFiles: boolean; + syncInternalFilesBeforeReplication: boolean; +}; + +type RunnerContext = { + binary: string; + cliBinary: string; + couchDb: CouchDbConfig; + dbName: string; + activeSessions: Set; +}; + +function modalByTitle(page: Page, title: string): Locator { + return page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: title }), + }); +} + +function settingPanelByTitle(page: Page, title: string): Locator { + return page + .locator(".sls-setting") + .locator("h4.sls-setting-panel-title:visible") + .filter({ hasText: title }) + .locator(".."); +} + +async function captureGuideDialogue(port: number, filename: string, title: string): Promise { + return await captureObsidianElement(port, filename, (page) => modalByTitle(page, title).locator(".modal").first()); +} + +async function selectRadioOption(modal: Locator, title: string): Promise { + const radio = modal.locator("label").filter({ hasText: title }).locator('input[type="radio"]').first(); + await radio.check({ timeout: uiTimeoutMs }); +} + +async function selectCheckbox(modal: Locator, title: string): Promise { + const checkbox = modal.locator("label").filter({ hasText: title }).locator('input[type="checkbox"]').first(); + await checkbox.check({ timeout: uiTimeoutMs }); +} + +async function writeVaultFile(vaultPath: string, path: string, content: string): Promise { + const fullPath = join(vaultPath, path); + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, content, "utf8"); +} + +async function readVaultFile(vaultPath: string, path: string): Promise { + return await readFile(join(vaultPath, path), "utf8"); +} + +async function waitForPathContent( + vaultPath: string, + path: string, + expected: string, + timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000) +): Promise { + const deadline = Date.now() + timeoutMs; + let lastContent = ""; + while (Date.now() < deadline) { + try { + lastContent = await readVaultFile(vaultPath, path); + if (lastContent === expected) return lastContent; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`); +} + +async function runDeno(script: string, permissions: string[], environment: NodeJS.ProcessEnv): Promise { + const { stdout } = await execFileAsync( + "deno", + [ + "run", + "--minimum-dependency-age=0", + "--config=utils/flyio/deno.jsonc", + "--frozen", + "--lock=utils/flyio/deno.lock", + ...permissions, + script, + ], + { + cwd: process.cwd(), + env: environment, + maxBuffer: 4 * 1024 * 1024, + } + ); + return stdout; +} + +async function provisionAndGenerateSetupURI(couchDb: CouchDbConfig, dbName: string): Promise { + const setupPassphrase = randomBytes(24).toString("base64url"); + const environment = { + ...process.env, + hostname: couchDb.uri, + username: couchDb.username, + password: couchDb.password, + database: dbName, + passphrase: randomBytes(24).toString("base64url"), + uri_passphrase: setupPassphrase, + remote_type: "couchdb", + retry_count: "3", + retry_delay_ms: "250", + }; + + await runDeno("utils/couchdb/provision.ts", ["--allow-env", "--allow-net"], environment); + const output = await runDeno("utils/setup/generate_setup_uri.ts", ["--allow-env"], environment); + const setupURI = output.split(/\r?\n/u).find((line) => line.startsWith("obsidian://setuplivesync?settings=")); + if (!setupURI) throw new Error("The public Setup URI generator did not emit a Setup URI."); + return { setupURI, setupPassphrase }; +} + +async function startUnconfiguredSession( + context: RunnerContext, + vault: TemporaryVault +): Promise { + const session = await startObsidianLiveSyncSession({ + binary: context.binary, + cliBinary: context.cliBinary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + }); + context.activeSessions.add(session); + return session; +} + +async function stopTrackedSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise { + if (!context.activeSessions.has(session)) return; + await session.app.stop(); + context.activeSessions.delete(session); +} + +async function stopTrackedSessions(context: RunnerContext): Promise { + for (const session of [...context.activeSessions]) { + await stopTrackedSession(context, session); + } +} + +async function enterSetupURI(port: number, mode: "new" | "existing", artifact: SetupArtifact): Promise { + await withObsidianPage(port, async (page) => { + const invitation = page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" }); + await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await invitation.locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs }); + + const intro = modalByTitle(page, "Welcome to Self-hosted LiveSync"); + await intro.waitFor({ state: "visible", timeout: uiTimeoutMs }); + if (mode === "new") { + await selectRadioOption(intro, "I am setting this up for the first time"); + await intro + .getByRole("button", { name: "Yes, I want to set up a new synchronisation" }) + .click({ timeout: uiTimeoutMs }); + } else { + await selectRadioOption(intro, "I am adding a device to an existing synchronisation setup"); + await intro + .getByRole("button", { name: "Yes, I want to add this device to my existing synchronisation" }) + .click({ timeout: uiTimeoutMs }); + } + + const method = modalByTitle(page, mode === "new" ? "Connection Method" : "Device Setup Method"); + await method.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await selectRadioOption(method, "Use a Setup URI (Recommended)"); + await method.getByRole("button", { name: "Proceed with Setup URI" }).click({ timeout: uiTimeoutMs }); + + const setup = modalByTitle(page, "Enter Setup URI"); + await setup.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await setup.locator('input[placeholder^="obsidian://setuplivesync"]').fill(artifact.setupURI); + await setup.locator('input[name="password"]').fill(artifact.setupPassphrase); + }); + await captureGuideDialogue( + port, + `guide-quick-setup-${mode === "new" ? "first" : "second"}-setup-uri.png`, + "Enter Setup URI" + ); + await withObsidianPage(port, async (page) => { + await modalByTitle(page, "Enter Setup URI") + .getByRole("button", { name: "Test Settings and Continue" }) + .click({ timeout: uiTimeoutMs }); + }); +} + +async function captureAndStartInitialisation(port: number, mode: "new" | "existing"): Promise { + const title = + mode === "new" + ? "Setup Complete: Preparing to Initialise Server" + : "Setup Complete: Preparing to Fetch Synchronisation Data"; + const button = mode === "new" ? "Restart and Initialise Server" : "Restart and Fetch Data"; + const screenshot = await captureObsidianDialogue( + port, + `setup-uri-${mode === "new" ? "first-initialise" : "second-fetch"}.png`, + async (page) => { + await modalByTitle(page, title).waitFor({ state: "visible", timeout: uiTimeoutMs }); + } + ); + await captureGuideDialogue( + port, + `guide-quick-setup-${mode === "new" ? "first-initialise" : "second-fetch"}.png`, + title + ); + await withObsidianPage(port, async (page) => { + await modalByTitle(page, title).getByRole("button", { name: button }).click({ timeout: uiTimeoutMs }); + }); + return screenshot; +} + +async function confirmRebuild(port: number): Promise { + const title = "Final Confirmation: Overwrite Server Data with This Device's Files"; + const screenshot = await captureObsidianDialogue(port, "setup-uri-first-rebuild-confirmation.png", async (page) => { + await modalByTitle(page, title).waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + await captureGuideDialogue(port, "guide-quick-setup-first-rebuild-confirmation.png", title); + await withObsidianPage(port, async (page) => { + const modal = modalByTitle(page, title); + await selectCheckbox( + modal, + "I understand that all changes made on other smartphones or computers possibly could be lost." + ); + await selectCheckbox( + modal, + "I understand that other devices will no longer be able to synchronise, and will need to be reset the synchronisation information." + ); + await selectCheckbox(modal, "I understand that this action is irreversible once performed."); + await selectRadioOption(modal, "I understand the risks and will proceed without a backup."); + await modal.getByRole("button", { name: "I Understand, Overwrite Server" }).click({ timeout: uiTimeoutMs }); + }); + return screenshot; +} + +async function continueWithoutRemoteSettings(port: number): Promise { + const title = "No Synchronisation Settings Found"; + const screenshot = await captureObsidianDialogue( + port, + "setup-uri-first-missing-remote-configuration.png", + async (page) => { + const modal = modalByTitle(page, title); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal + .getByText("This is normal for a new remote.", { + exact: false, + }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal.getByRole("button", { name: "Cancel", exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + } + ); + await captureGuideDialogue(port, "guide-quick-setup-missing-remote-configuration.png", title); + await withObsidianPage(port, async (page) => { + await modalByTitle(page, title) + .getByRole("button", { name: "Use this device's settings" }) + .click({ timeout: uiTimeoutMs }); + }); + return screenshot; +} + +async function acknowledgeDisabledOptionalFeatures(port: number): Promise { + const title = "All optional features are disabled"; + const screenshot = await captureObsidianDialogue( + port, + "setup-uri-first-optional-features-disabled.png", + async (page) => { + const modal = modalByTitle(page, title); + await modal.waitFor({ state: "visible", timeout: initialisationTimeoutMs }); + await modal + .getByText("Please enable them from the settings screen after setup is complete.", { + exact: false, + }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + } + ); + await captureGuideDialogue(port, "guide-quick-setup-optional-features-disabled.png", title); + await withObsidianPage(port, async (page) => { + await modalByTitle(page, title).getByRole("button", { name: "OK" }).click({ timeout: uiTimeoutMs }); + }); + return screenshot; +} + +async function confirmFastFetch(port: number): Promise { + const firstTitle = "Data retrieval scheduled"; + await assertVerticalActionLayout(port, firstTitle); + const firstScreenshot = await captureObsidianDialogue( + port, + "setup-uri-second-retrieval-method.png", + async (page) => { + await modalByTitle(page, firstTitle).waitFor({ state: "visible", timeout: uiTimeoutMs }); + } + ); + await captureGuideDialogue(port, "guide-quick-setup-retrieval-method.png", firstTitle); + await withObsidianPage(port, async (page) => { + await modalByTitle(page, firstTitle) + .getByRole("button", { name: "Overwrite all with remote files" }) + .click({ timeout: uiTimeoutMs }); + }); + + const secondTitle = "How to handle extra existing local files?"; + await assertVerticalActionLayout(port, secondTitle); + const secondScreenshot = await captureObsidianDialogue( + port, + "setup-uri-second-local-file-policy.png", + async (page) => { + await modalByTitle(page, secondTitle).waitFor({ state: "visible", timeout: uiTimeoutMs }); + } + ); + await captureGuideDialogue(port, "guide-quick-setup-local-file-policy.png", secondTitle); + await withObsidianPage(port, async (page) => { + await modalByTitle(page, secondTitle) + .getByRole("button", { name: "Keep local files even if not on remote" }) + .click({ timeout: uiTimeoutMs }); + }); + return [firstScreenshot, secondScreenshot]; +} + +function isConfiguredSetupReady(state: SetupState): boolean { + return ( + state.configured && + state.databaseReady && + state.appReady && + !state.suspended && + state.activeConfigurationId !== "" && + state.remoteConfigurationCount === 1 + ); +} + +async function finishInitialisation( + port: number, + filename: string, + cliBinary: string, + environment: NodeJS.ProcessEnv +): Promise<{ state: SetupState; screenshot?: string }> { + const message = "Do you want to resume file and database processing, and restart obsidian now?"; + const deadline = Date.now() + initialisationTimeoutMs; + let lastState: SetupState | undefined; + let lastError: unknown; + while (Date.now() < deadline) { + const resumeVisible = await withObsidianPage(port, async (page) => { + return await modalByTitle(page, "Confirmation").filter({ hasText: message }).isVisible(); + }).catch(() => false); + if (resumeVisible) { + const screenshot = await captureObsidianDialogue(port, filename, async (page) => { + await modalByTitle(page, "Confirmation") + .filter({ hasText: message }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + await withObsidianPage(port, async (page) => { + const modal = modalByTitle(page, "Confirmation").filter({ hasText: message }); + await modal.getByText(message, { exact: true }).click({ timeout: uiTimeoutMs }); + await modal.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs }); + }); + return { + state: await waitForConfiguredSetup(cliBinary, environment, initialisationTimeoutMs), + screenshot, + }; + } + try { + lastState = await readSetupState(cliBinary, environment); + if (isConfiguredSetupReady(lastState)) return { state: lastState }; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error( + `Timed out waiting for Setup URI initialisation to finish: ${JSON.stringify(lastState)}${ + lastError instanceof Error ? `; last error: ${lastError.message}` : "" + }` + ); +} + +async function readSetupState(cliBinary: string, environment: NodeJS.ProcessEnv): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const settings=core.services.setting.currentSettings();", + "return JSON.stringify({", + "configured:settings.isConfigured===true,", + "databaseReady:core.services.database.isDatabaseReady(),", + "appReady:core.services.appLifecycle.isReady(),", + "suspended:core.services.appLifecycle.isSuspended(),", + "remoteType:settings.remoteType,", + "activeConfigurationId:settings.activeConfigurationId||'',", + "remoteConfigurationCount:Object.keys(settings.remoteConfigurations||{}).length,", + "syncInternalFiles:settings.syncInternalFiles===true,", + "syncInternalFilesBeforeReplication:settings.syncInternalFilesBeforeReplication===true,", + "});", + "})()", + ].join(""), + environment + ); +} + +async function waitForConfiguredSetup( + cliBinary: string, + environment: NodeJS.ProcessEnv, + timeoutMs = initialisationTimeoutMs +): Promise { + const deadline = Date.now() + timeoutMs; + let lastState: SetupState | undefined; + let lastError: unknown; + while (Date.now() < deadline) { + try { + lastState = await readSetupState(cliBinary, environment); + if (isConfiguredSetupReady(lastState)) { + return lastState; + } + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error( + `Timed out waiting for configured Setup URI state: ${JSON.stringify(lastState)}${ + lastError instanceof Error ? `; last error: ${lastError.message}` : "" + }` + ); +} + +async function enableHiddenFileSync(cliBinary: string, environment: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "await core.services.setting.applyPartial({", + "syncInternalFiles:true,", + "syncInternalFilesBeforeReplication:true,", + "},true);", + "await core.services.control.applySettings();", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + environment + ); + const state = await waitForConfiguredSetup(cliBinary, environment); + if (!state.syncInternalFiles || !state.syncInternalFilesBeforeReplication) { + throw new Error(`Hidden File Sync was not enabled after setup: ${JSON.stringify(state)}`); + } + return state; +} + +async function captureHiddenFileGuideSettings( + port: number, + cliBinary: string, + environment: NodeJS.ProcessEnv +): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "await core.services.setting.applyPartial({", + "useAdvancedMode:true,", + "syncInternalFilesTargetPatterns:'^\\\\.obsidian(?:$|/snippets(?:/|$))',", + "},true);", + "await core.services.control.applySettings();", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + environment + ); + + await withObsidianPage(port, async (page) => { + await page.evaluate(() => { + const obsidian = globalThis as typeof globalThis & { + app?: { + setting?: { + open(): void; + openTabById(tabId: string): void; + }; + }; + }; + const setting = obsidian.app?.setting; + if (!setting) throw new Error("Obsidian settings are unavailable"); + setting.open(); + setting.openTabById("obsidian-livesync"); + }); + const settings = page.locator(".sls-setting"); + await settings.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await settings.locator('.sls-setting-menu-btn[title="Setup"]').click({ timeout: uiTimeoutMs }); + }); + + const screenshots = [ + await captureObsidianElement(port, "guide-hidden-file-advanced-features.png", (page) => + settingPanelByTitle(page, "Enable extra and advanced features") + ), + ]; + + await withObsidianPage(port, async (page) => { + await page + .locator(".sls-setting") + .locator('.sls-setting-menu-btn[title="Selector"]') + .click({ timeout: uiTimeoutMs }); + }); + screenshots.push( + await captureObsidianElement(port, "guide-hidden-file-selector.png", (page) => + settingPanelByTitle(page, "Hidden Files") + ) + ); + + await withObsidianPage(port, async (page) => { + await page + .locator(".sls-setting") + .locator('.sls-setting-menu-btn[title="Sync Settings"]') + .click({ timeout: uiTimeoutMs }); + }); + screenshots.push( + await captureObsidianElement(port, "guide-hidden-file-enable.png", (page) => + settingPanelByTitle(page, "Hidden Files") + ) + ); + + await withObsidianPage(port, async (page) => { + await page.keyboard.press("Escape"); + }); + return screenshots; +} + +async function writeNoteViaObsidian( + cliBinary: string, + environment: NodeJS.ProcessEnv, + path: string, + content: string +): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const content=${JSON.stringify(content)};`, + "const folder=path.split('/').slice(0,-1).join('/');", + "if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);", + "const existing=app.vault.getAbstractFileByPath(path);", + "if(existing) await app.vault.modify(existing,content);", + "else await app.vault.create(path,content);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + environment + ); +} + +async function scanHiddenStorage(cliBinary: string, environment: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const addOn=core.getAddOn('HiddenFileSync');", + "await addOn.scanAllStorageChanges(true);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + environment, + hiddenFileCliTimeoutMs + ); +} + +async function scanHiddenDatabase(cliBinary: string, environment: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const addOn=core.getAddOn('HiddenFileSync');", + "await addOn.scanAllDatabaseChanges(true);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + environment, + hiddenFileCliTimeoutMs + ); +} + +async function waitForRemoteEntry(context: RunnerContext, entry: LocalDatabaseEntry): Promise { + await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => { + const ids = new Set(docs.map((doc) => doc._id)); + return ids.has(entry.id) && entry.children.every((childId) => ids.has(childId)); + }); +} + +async function uploadWorkflowFiles( + context: RunnerContext, + session: ObsidianLiveSyncSession, + vault: TemporaryVault +): Promise { + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, notePath, noteContent); + await writeVaultFile(vault.path, snippetPath, snippetContent); + await scanHiddenStorage(context.cliBinary, session.cliEnv); + const noteEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath); + const snippetEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, snippetPath, { + hidden: true, + }); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await waitForRemoteEntry(context, noteEntry); + await waitForRemoteEntry(context, snippetEntry); +} + +async function captureSynchronisedNote(port: number): Promise { + await withObsidianPage(port, async (page) => { + await page.evaluate((path) => { + const obsidian = globalThis as typeof globalThis & { + app?: { + workspace?: { openLinkText(path: string, sourcePath: string, newLeaf: boolean): Promise }; + }; + }; + return obsidian.app?.workspace?.openLinkText(path, "", false); + }, notePath); + }); + await captureObsidianPage(port, "setup-uri-synchronised-note.png", async (page) => { + await page.getByText("Provisioned Setup URI", { exact: false }).first().waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + }); + return await captureObsidianElement(port, "guide-quick-setup-synchronised-note.png", (page) => + page.locator(".workspace-leaf.mod-active").first() + ); +} + +async function captureFailure(session: ObsidianLiveSyncSession): Promise { + await captureObsidianPage( + session.remoteDebuggingPort, + "setup-uri-workflow.failure.png", + async () => undefined + ).catch(() => undefined); +} + +async function main(): Promise { + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); + + const couchDb = await loadCouchDbConfig(); + const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "setup-uri-workflow"); + const vaultA = await createTemporaryVault(); + const vaultB = await createTemporaryVault(); + const context: RunnerContext = { + binary, + cliBinary: cli.binary, + couchDb, + dbName, + activeSessions: new Set(), + }; + const screenshots: string[] = []; + let secondDeviceArtifact: SetupArtifact | undefined; + + try { + await assertCouchDbReachable(couchDb); + const artifact = await provisionAndGenerateSetupURI(couchDb, dbName); + const provisionedDocs = await waitForCouchDbDocs(couchDb, dbName, (docs) => + docs.some((doc) => doc._id === "obsydian_livesync_version" && doc.version === VER) + ); + if (!provisionedDocs.some((doc) => doc._id === "obsydian_livesync_version" && doc.version === VER)) { + throw new Error("The public provisioning tool did not initialise the Commonlib database version."); + } + + console.log(`Using Obsidian executable: ${binary}`); + console.log(`Temporary vault A: ${vaultA.path}`); + console.log(`Temporary vault B: ${vaultB.path}`); + console.log(`Temporary provisioned CouchDB database: ${dbName}`); + + let session = await startUnconfiguredSession(context, vaultA); + try { + await enterSetupURI(session.remoteDebuggingPort, "new", artifact); + screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "new")); + screenshots.push(await confirmRebuild(session.remoteDebuggingPort)); + screenshots.push(await continueWithoutRemoteSettings(session.remoteDebuggingPort)); + screenshots.push(await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort)); + const firstCompletion = await finishInitialisation( + session.remoteDebuggingPort, + "setup-uri-first-initialisation-complete.png", + context.cliBinary, + session.cliEnv + ); + if (firstCompletion.screenshot) screenshots.push(firstCompletion.screenshot); + const firstState = firstCompletion.state; + await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort); + assertEqual(firstState.remoteType, "", "The first device did not activate the CouchDB remote profile."); + assertEqual( + firstState.syncInternalFiles, + false, + "Rebuild did not retain the documented optional-feature safety boundary." + ); + screenshots.push( + ...(await captureHiddenFileGuideSettings( + session.remoteDebuggingPort, + context.cliBinary, + session.cliEnv + )) + ); + await enableHiddenFileSync(context.cliBinary, session.cliEnv); + await uploadWorkflowFiles(context, session, vaultA); + const generated = await generateSetupURIFromDevice( + session.remoteDebuggingPort, + randomBytes(24).toString("base64url"), + { scenario: "setup-uri-workflow", guide: "quick-setup" } + ); + if (generated.artifact.setupURI === artifact.setupURI) { + throw new Error("The first device returned the bootstrap Setup URI instead of generating a new one."); + } + secondDeviceArtifact = generated.artifact; + screenshots.push(...generated.screenshots); + } catch (error) { + await captureFailure(session); + throw error; + } finally { + await stopTrackedSession(context, session); + } + + session = await startUnconfiguredSession(context, vaultB); + try { + if (!secondDeviceArtifact) + throw new Error("The first device did not generate the second-device Setup URI."); + await enterSetupURI(session.remoteDebuggingPort, "existing", secondDeviceArtifact); + screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "existing")); + screenshots.push(...(await confirmFastFetch(session.remoteDebuggingPort))); + const secondCompletion = await finishInitialisation( + session.remoteDebuggingPort, + "setup-uri-second-initialisation-complete.png", + context.cliBinary, + session.cliEnv + ); + if (secondCompletion.screenshot) screenshots.push(secondCompletion.screenshot); + const secondState = secondCompletion.state; + await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort); + assertEqual(secondState.remoteType, "", "The second device did not activate the CouchDB remote profile."); + await enableHiddenFileSync(context.cliBinary, session.cliEnv); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await scanHiddenDatabase(context.cliBinary, session.cliEnv); + const receivedNote = await waitForPathContent(vaultB.path, notePath, noteContent); + const receivedSnippet = await waitForPathContent(vaultB.path, snippetPath, snippetContent); + assertEqual(receivedNote, noteContent, "The ordinary note did not reach the second Setup URI device."); + assertEqual( + receivedSnippet, + snippetContent, + "The hidden snippet did not reach the second Setup URI device." + ); + screenshots.push(await captureSynchronisedNote(session.remoteDebuggingPort)); + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, returnNotePath, returnNoteContent); + const returnEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, returnNotePath); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await waitForRemoteEntry(context, returnEntry); + } catch (error) { + await captureFailure(session); + throw error; + } finally { + await stopTrackedSession(context, session); + } + + session = await startUnconfiguredSession(context, vaultA); + try { + await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort); + await pushLocalChanges(context.cliBinary, session.cliEnv); + const receivedReturnNote = await waitForPathContent(vaultA.path, returnNotePath, returnNoteContent); + assertEqual( + receivedReturnNote, + returnNoteContent, + "The second device's ordinary note did not return to the first Setup URI device." + ); + } catch (error) { + await captureFailure(session); + throw error; + } finally { + await stopTrackedSession(context, session); + } + + console.log( + `The public provisioning and first-device-generated Setup URI workflow configured two fresh devices, completed an ordinary-note round-trip, and synchronised a hidden snippet. Screenshots: ${screenshots.join(", ")}` + ); + } finally { + await stopTrackedSessions(context).catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + await vaultA.dispose(); + await vaultB.dispose(); + if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") { + await deleteCouchDbDatabase(couchDb, dbName).catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + } + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/test/e2e-obsidian/scripts/smoke.ts b/test/e2e-obsidian/scripts/smoke.ts index c00644f3..8dee624a 100644 --- a/test/e2e-obsidian/scripts/smoke.ts +++ b/test/e2e-obsidian/scripts/smoke.ts @@ -1,4 +1,8 @@ import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { + assertObsidianServiceContextContract, + inspectObsidianServiceContextContract, +} from "../runner/liveSyncWorkflow.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { createTemporaryVault } from "../runner/vault.ts"; @@ -25,6 +29,11 @@ async function main(): Promise { console.log( `Obsidian plug-in ready: ${readiness.pluginId}@${readiness.pluginVersion} in ${readiness.vaultName}` ); + const contextContract = await inspectObsidianServiceContextContract(cli.binary, session.cliEnv); + assertObsidianServiceContextContract(contextContract); + console.log( + `Obsidian service Context contract passed: ${contextContract.contextType}, ${contextContract.serviceContextMismatches.length} mismatches.` + ); await new Promise((resolve) => setTimeout(resolve, Number(process.env.E2E_OBSIDIAN_SMOKE_TIMEOUT_MS ?? 1000))); console.log("Obsidian stayed alive after the plug-in readiness check."); } finally { diff --git a/test/e2e-obsidian/scripts/startup-scan.ts b/test/e2e-obsidian/scripts/startup-scan.ts index 9aaa4de0..432145a3 100644 --- a/test/e2e-obsidian/scripts/startup-scan.ts +++ b/test/e2e-obsidian/scripts/startup-scan.ts @@ -1,3 +1,13 @@ +/** + * Proves that a configured LiveSync Vault scans files created while Obsidian + * was stopped. The first launch receives a CouchDB profile using current + * settings and its acknowledged device-local compatibility marker before the + * plug-in loads. + * + * The second launch reuses the same Vault, profile, local database, and + * settings without rewriting plug-in data, so the assertion covers an + * ordinary configured restart rather than the separate onboarding flow. + */ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { @@ -11,7 +21,8 @@ import { import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { assertEqual, - configureCouchDb, + createE2eCouchDbPluginData, + createE2eObsidianDeviceLocalState, prepareRemote, pushLocalChanges, waitForLiveSyncCoreReady, @@ -47,6 +58,12 @@ async function main(): Promise { const couchDb = await loadCouchDbConfig(); const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "startup-scan"); + const couchDbSettings = { + uri: couchDb.uri, + username: couchDb.username, + password: couchDb.password, + dbName, + }; const vault = await createTemporaryVault(); let session: ObsidianLiveSyncSession | undefined; @@ -63,15 +80,11 @@ async function main(): Promise { cliBinary: cli.binary, vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: createE2eCouchDbPluginData(couchDbSettings), + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), }); - await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); - const configured = await configureCouchDb(cli.binary, session.cliEnv, { - uri: couchDb.uri, - username: couchDb.username, - password: couchDb.password, - dbName, - }); - assertEqual(configured.isConfigured, true, "Self-hosted LiveSync was not configured."); + const initialReadiness = await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + assertEqual(initialReadiness.configured, true, "Self-hosted LiveSync did not start configured."); await prepareRemote(cli.binary, session.cliEnv); await session.app.stop(); session = undefined; @@ -84,7 +97,8 @@ async function main(): Promise { vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), }); - await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + const restartedReadiness = await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + assertEqual(restartedReadiness.configured, true, "Self-hosted LiveSync lost its configuration on restart."); const localEntry = await waitForLocalDatabaseEntry(cli.binary, session.cliEnv, notePath); await pushLocalChanges(cli.binary, session.cliEnv); diff --git a/test/e2e-obsidian/scripts/two-vault-sync.ts b/test/e2e-obsidian/scripts/two-vault-sync.ts index f1c23b8c..ca69c038 100644 --- a/test/e2e-obsidian/scripts/two-vault-sync.ts +++ b/test/e2e-obsidian/scripts/two-vault-sync.ts @@ -11,11 +11,16 @@ import { type CouchDbConfig, } from "../runner/couchdb.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { waitForExactCaseOnlyRename } from "../runner/pathAssertions.ts"; import { assertEqual, + assertE2eCompatibilityMarker, + assertE2eCompatibilityReviewPending, configureCouchDb, + createE2eCouchDbPluginData, prepareRemote, pushLocalChanges, + resumeCompatibilityReview, waitForLiveSyncCoreReady, waitForLocalDatabaseEntry, type LocalDatabaseEntry, @@ -31,7 +36,15 @@ const updatePath = "E2E/two-vault/update.md"; const deletePath = "E2E/two-vault/delete.md"; const renameFromPath = "E2E/two-vault/rename-source.md"; const renameToPath = "E2E/two-vault/renamed/rename-target.md"; +const caseRenameFromPath = "E2E/two-vault/Case-Rename.md"; +const caseRenameToPath = "E2E/two-vault/case-rename.md"; const conflictPath = "E2E/two-vault/conflict.md"; +const conflictEditPath = "E2E/two-vault/conflict-operations/edit.md"; +const conflictDeletePath = "E2E/two-vault/conflict-operations/delete.md"; +const conflictCaseFromPath = "E2E/two-vault/conflict-operations/Case-Rename.md"; +const conflictCaseToPath = "E2E/two-vault/conflict-operations/case-rename.md"; +const conflictRenameFromPath = "E2E/two-vault/conflict-operations/rename-source.md"; +const conflictRenameToPath = "E2E/two-vault/conflict-operations/renamed/rename-target.md"; const targetMismatchPath = "E2E/two-vault/target-mismatch.md"; const encryptedPath = "E2E/two-vault/encrypted.md"; @@ -40,6 +53,19 @@ type RunnerContext = { cliBinary: string; couchDb: CouchDbConfig; dbName: string; + reviewedVaults: Set; + activeSessions: Set; +}; + +type FileConflictState = { + currentRev: string; + branches: { + rev: string; + parentRev?: string; + content: string; + deleted: boolean; + path: string; + }[]; }; async function writeVaultFile(vaultPath: string, path: string, content: string): Promise { @@ -68,6 +94,18 @@ async function pathExists(vaultPath: string, path: string): Promise { } } +async function stopTrackedSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise { + if (!context.activeSessions.has(session)) return; + await session.app.stop(); + context.activeSessions.delete(session); +} + +async function stopTrackedSessions(context: RunnerContext): Promise { + for (const session of [...context.activeSessions]) { + await stopTrackedSession(context, session); + } +} + async function waitForPathContent( vaultPath: string, path: string, @@ -161,27 +199,44 @@ async function startConfiguredSession( vault: TemporaryVault, overrides: Record = {} ): Promise { + const couchDbSettings = { + uri: context.couchDb.uri, + username: context.couchDb.username, + password: context.couchDb.password, + dbName: context.dbName, + }; + const reviewAlreadyCompleted = context.reviewedVaults.has(vault.path); const session = await startObsidianLiveSyncSession({ binary: context.binary, cliBinary: context.cliBinary, vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: createE2eCouchDbPluginData(couchDbSettings, overrides), }); - await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); - await configureCouchDb( - context.cliBinary, - session.cliEnv, - { - uri: context.couchDb.uri, - username: context.couchDb.username, - password: context.couchDb.password, - dbName: context.dbName, - }, - overrides - ); - await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); - await prepareRemote(context.cliBinary, session.cliEnv); - return session; + context.activeSessions.add(session); + try { + await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); + if (!reviewAlreadyCompleted) { + await assertE2eCompatibilityReviewPending(context.cliBinary, session.cliEnv); + await resumeCompatibilityReview(session.remoteDebuggingPort); + } + await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv); + if (!reviewAlreadyCompleted) context.reviewedVaults.add(vault.path); + await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, overrides); + await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); + await prepareRemote(context.cliBinary, session.cliEnv); + return session; + } catch (error) { + try { + await stopTrackedSession(context, session); + } catch (stopError) { + throw Object.assign(new Error("Could not stop Obsidian after session setup failed."), { + cause: error, + stopError, + }); + } + throw error; + } } async function uploadNote( @@ -241,25 +296,116 @@ async function storeFileRevision( return result.rev; } -async function createMarkdownConflict( - context: RunnerContext, - session: ObsidianLiveSyncSession, - vault: TemporaryVault, - path: string, - base: string, - left: string, - right: string -): Promise { - const baseRev = await storeFileRevision(context.cliBinary, session.cliEnv, path, base); - await pushLocalChanges(context.cliBinary, session.cliEnv); - await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path); - await storeFileRevision(context.cliBinary, session.cliEnv, path, left, baseRev); - await storeFileRevision(context.cliBinary, session.cliEnv, path, right, baseRev); - await writeVaultFile(vault.path, path, right); +async function readFileConflictState( + cliBinary: string, + env: NodeJS.ProcessEnv, + path: string +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const meta=await core.localDatabase.getDBEntryMeta(path,{conflicts:true},true);", + "if(!meta) throw new Error(`Could not find conflict metadata: ${path}`);", + "const revisions=[meta._rev,...(meta._conflicts??[])];", + "const branches=[];", + "for(const rev of revisions){", + " const branchMeta=await core.localDatabase.getDBEntryMeta(path,{rev,revs:true},true);", + " const entry=await core.localDatabase.getDBEntry(path,{rev},false,true,true);", + " if(!branchMeta||!entry) throw new Error(`Could not read conflict revision: ${path} ${rev}`);", + " const content=Array.isArray(entry.data)?entry.data.join(''):entry.data;", + " if(typeof content!=='string') throw new Error(`Conflict revision was not text: ${path} ${rev}`);", + " const ids=branchMeta._revisions?.ids??[];", + " const parentRev=ids[1]?`${branchMeta._revisions.start-1}-${ids[1]}`:undefined;", + " branches.push({rev,parentRev,content,deleted:Boolean(branchMeta.deleted||branchMeta._deleted),path:branchMeta.path});", + "}", + "return JSON.stringify({currentRev:meta._rev,branches});", + "})()", + ].join(""), + env + ); } -async function autoMergeMarkdownConflict(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise { - await evalObsidianJson( +async function waitForFileConflict( + cliBinary: string, + env: NodeJS.ProcessEnv, + path: string +): Promise { + const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000); + let state = await readFileConflictState(cliBinary, env, path); + while (state.branches.length < 2 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 250)); + state = await readFileConflictState(cliBinary, env, path); + } + if (state.branches.length < 2) { + throw new Error(`Timed out waiting for a file conflict: ${path}`); + } + return state; +} + +async function waitForConflictBranch( + cliBinary: string, + env: NodeJS.ProcessEnv, + path: string, + predicate: (branch: FileConflictState["branches"][number]) => boolean +): Promise { + const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000); + let state = await readFileConflictState(cliBinary, env, path); + while (Date.now() < deadline) { + const branch = state.branches.find(predicate); + if (branch) return branch; + await new Promise((resolve) => setTimeout(resolve, 250)); + state = await readFileConflictState(cliBinary, env, path); + } + throw new Error(`Timed out waiting for the expected conflict branch: ${path}; ${JSON.stringify(state)}`); +} + +async function readFileReflectionProvenance( + cliBinary: string, + env: NodeJS.ProcessEnv, + path: string +): Promise<{ revision: string; observedStorageMtime?: number } | null> { + return await evalObsidianJson<{ revision: string; observedStorageMtime?: number } | null>( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const store=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');", + "return JSON.stringify((await store.get(path))??null);", + "})()", + ].join(""), + env + ); +} + +async function readPathIdentity( + cliBinary: string, + env: NodeJS.ProcessEnv, + paths: readonly string[] +): Promise<{ caseSensitive: boolean; ids: Record }> { + return await evalObsidianJson<{ caseSensitive: boolean; ids: Record }>( + cliBinary, + [ + "(async()=>{", + `const paths=${JSON.stringify(paths)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const ids={};", + "for(const path of paths) ids[path]=await core.services.path.path2id(path);", + "return JSON.stringify({", + " caseSensitive:Boolean(core.services.setting.currentSettings().handleFilenameCaseSensitive),", + " ids,", + "});", + "})()", + ].join(""), + env + ); +} + +async function calculateMarkdownAutoMerge(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise { + const result = await evalObsidianJson<{ content: string }>( cliBinary, [ "(async()=>{", @@ -269,11 +415,29 @@ async function autoMergeMarkdownConflict(cliBinary: string, env: NodeJS.ProcessE "if(!('result' in result)){", " throw new Error(`Markdown conflict was not auto-mergeable: ${path}; ${JSON.stringify(result)}`);", "}", - "if(!(await core.databaseFileAccess.storeContent(path,result.result))){", - " throw new Error(`Could not store merged Markdown content: ${path}`);", - "}", - "if(!(await core.fileHandler.deleteRevisionFromDB(path,result.conflictedRev))){", - " throw new Error(`Could not delete conflicted revision: ${path}`);", + "return JSON.stringify({content:result.result});", + "})()", + ].join(""), + env + ); + return result.content; +} + +async function deleteRevisionAndReflect( + cliBinary: string, + env: NodeJS.ProcessEnv, + path: string, + revision: string +): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const revision=${JSON.stringify(revision)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "if(!(await core.fileHandler.deleteRevisionFromDB(path,revision))){", + " throw new Error(`Could not delete conflicted revision: ${path} ${revision}`);", "}", "if(!(await core.fileHandler.dbToStorage(path,path,true))){", " throw new Error(`Could not reflect merged Markdown content: ${path}`);", @@ -294,12 +458,12 @@ async function runCreateUpdateDelete( let session = await startConfiguredSession(context, vaultA); await writeNoteViaObsidian(context.cliBinary, session.cliEnv, createPath, createdContent); await uploadNote(context, session, createPath); - await session.app.stop(); + await stopTrackedSession(context, session); session = await startConfiguredSession(context, vaultB); await syncAndApply(context, session); const createdOnB = await waitForPathContent(vaultB.path, createPath, (content) => content === createdContent); - await session.app.stop(); + await stopTrackedSession(context, session); assertEqual(createdOnB, createdContent, "Created note did not round-trip to the second vault."); const initialUpdateContent = "# Update target\n\nInitial content.\n"; @@ -309,34 +473,34 @@ async function runCreateUpdateDelete( await uploadNote(context, session, updatePath); await writeNoteViaObsidian(context.cliBinary, session.cliEnv, updatePath, updatedContent); await uploadNote(context, session, updatePath); - await session.app.stop(); + await stopTrackedSession(context, session); session = await startConfiguredSession(context, vaultB); await syncAndApply(context, session); const updatedOnB = await waitForPathContent(vaultB.path, updatePath, (content) => content === updatedContent); - await session.app.stop(); + await stopTrackedSession(context, session); assertEqual(updatedOnB, updatedContent, "Updated note content did not round-trip to the second vault."); const deleteContent = "# Delete target\n\nThis note should be removed from B.\n"; session = await startConfiguredSession(context, vaultA); await writeNoteViaObsidian(context.cliBinary, session.cliEnv, deletePath, deleteContent); await uploadNote(context, session, deletePath); - await session.app.stop(); + await stopTrackedSession(context, session); session = await startConfiguredSession(context, vaultB); await syncAndApply(context, session); await waitForPathContent(vaultB.path, deletePath, (content) => content === deleteContent); - await session.app.stop(); + await stopTrackedSession(context, session); session = await startConfiguredSession(context, vaultA); await deleteNoteViaObsidian(context.cliBinary, session.cliEnv, deletePath); await pushLocalChanges(context.cliBinary, session.cliEnv); - await session.app.stop(); + await stopTrackedSession(context, session); session = await startConfiguredSession(context, vaultB); await syncAndApply(context, session); await waitForPathDeleted(vaultB.path, deletePath); - await session.app.stop(); + await stopTrackedSession(context, session); console.log("Two-vault note creation, update, and deletion round-tripped."); } @@ -350,18 +514,51 @@ async function runRename(context: RunnerContext, vaultA: TemporaryVault, vaultB: await renameNoteViaObsidian(context.cliBinary, session.cliEnv, renameFromPath, renameToPath); await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, renameToPath); await pushLocalChanges(context.cliBinary, session.cliEnv); - await session.app.stop(); + await stopTrackedSession(context, session); session = await startConfiguredSession(context, vaultB); await syncAndApply(context, session); const renamedOnB = await waitForPathContent(vaultB.path, renameToPath, (content) => content === renamedContent); await waitForPathDeleted(vaultB.path, renameFromPath); - await session.app.stop(); + await stopTrackedSession(context, session); assertEqual(renamedOnB, renamedContent, "Renamed note content did not round-trip to the second vault."); console.log("Two-vault note rename round-tripped."); } +async function runCaseOnlyRename( + context: RunnerContext, + vaultA: TemporaryVault, + vaultB: TemporaryVault +): Promise { + const fileContent = "# Case-only rename\n\nThe document ID should remain live.\n"; + + let session = await startConfiguredSession(context, vaultA); + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, caseRenameFromPath, fileContent); + await uploadNote(context, session, caseRenameFromPath); + await stopTrackedSession(context, session); + + session = await startConfiguredSession(context, vaultB); + await syncAndApply(context, session); + await waitForPathContent(vaultB.path, caseRenameFromPath, (content) => content === fileContent); + await stopTrackedSession(context, session); + + session = await startConfiguredSession(context, vaultA); + await renameNoteViaObsidian(context.cliBinary, session.cliEnv, caseRenameFromPath, caseRenameToPath); + await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, caseRenameToPath); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await stopTrackedSession(context, session); + + session = await startConfiguredSession(context, vaultB); + await syncAndApply(context, session); + const renamedOnB = await waitForPathContent(vaultB.path, caseRenameToPath, (content) => content === fileContent); + await waitForExactCaseOnlyRename(vaultB.path, caseRenameFromPath, caseRenameToPath); + await stopTrackedSession(context, session); + + assertEqual(renamedOnB, fileContent, "Case-only note rename did not round-trip to the second vault."); + console.log("Two-vault case-only note rename round-tripped without a tombstone."); +} + async function runEncryptedRoundTrip( context: RunnerContext, vaultA: TemporaryVault, @@ -378,12 +575,12 @@ async function runEncryptedRoundTrip( let session = await startConfiguredSession(context, vaultA, encryptedOverrides); await writeNoteViaObsidian(context.cliBinary, session.cliEnv, encryptedPath, encryptedContent); await uploadNote(context, session, encryptedPath); - await session.app.stop(); + await stopTrackedSession(context, session); session = await startConfiguredSession(context, vaultB, encryptedOverrides); await syncAndApply(context, session); const received = await waitForPathContent(vaultB.path, encryptedPath, (content) => content === encryptedContent); - await session.app.stop(); + await stopTrackedSession(context, session); assertEqual(received, encryptedContent, "Encrypted note did not round-trip to the second vault."); console.log("Two-vault encrypted note synchronisation round-tripped."); @@ -397,31 +594,290 @@ async function runMarkdownAutoMerge( const base = "# Conflict\n\nTop anchor\n\nMiddle anchor\n\nBottom anchor\n"; const left = "# Conflict\n\nTop anchor\n\nLeft line\n\nMiddle anchor\n\nBottom anchor\n"; const right = "# Conflict\n\nTop anchor\n\nMiddle anchor\n\nRight tail\n\nBottom anchor\n"; + const conflictOverrides = { + disableMarkdownAutoMerge: true, + checkConflictOnlyOnOpen: true, + showMergeDialogOnlyOnActive: true, + }; - let session = await startConfiguredSession(context, vaultB); - await createMarkdownConflict(context, session, vaultB, conflictPath, base, left, right); - await autoMergeMarkdownConflict(context.cliBinary, session.cliEnv, conflictPath); + let session = await startConfiguredSession(context, vaultA, conflictOverrides); + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, conflictPath, base); + await uploadNote(context, session, conflictPath); + await stopTrackedSession(context, session); + + session = await startConfiguredSession(context, vaultB, conflictOverrides); + await syncAndApply(context, session); + const baseOnB = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, conflictPath); + await waitForPathContent(vaultB.path, conflictPath, (content) => content === base); + await stopTrackedSession(context, session); + + session = await startConfiguredSession(context, vaultA, conflictOverrides); + const baseOnA = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, conflictPath); + await storeFileRevision(context.cliBinary, session.cliEnv, conflictPath, left, baseOnA.rev); + await writeVaultFile(vaultA.path, conflictPath, left); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await stopTrackedSession(context, session); + + session = await startConfiguredSession(context, vaultB, conflictOverrides); + await storeFileRevision(context.cliBinary, session.cliEnv, conflictPath, right, baseOnB.rev); + await writeVaultFile(vaultB.path, conflictPath, right); + await pushLocalChanges(context.cliBinary, session.cliEnv); + const conflict = await waitForFileConflict(context.cliBinary, session.cliEnv, conflictPath); + const leftBranch = conflict.branches.find((branch) => branch.content === left); + const rightBranch = conflict.branches.find((branch) => branch.content === right); + if (!leftBranch || !rightBranch) { + throw new Error(`The two Vault edits did not form the expected conflict: ${JSON.stringify(conflict)}`); + } + + const merged = await calculateMarkdownAutoMerge(context.cliBinary, session.cliEnv, conflictPath); + if (!merged.includes("Left line") || !merged.includes("Right tail")) { + throw new Error(`Markdown auto-merge discarded a non-overlapping edit: ${JSON.stringify({ merged })}`); + } + const mergedRev = await storeFileRevision(context.cliBinary, session.cliEnv, conflictPath, merged, rightBranch.rev); + await deleteRevisionAndReflect(context.cliBinary, session.cliEnv, conflictPath, leftBranch.rev); await pushLocalChanges(context.cliBinary, session.cliEnv); const mergedOnB = await waitForPathContent( vaultB.path, conflictPath, - (content) => content.includes("Left line") && content.includes("Right tail"), + (content) => content === merged, Number(process.env.E2E_OBSIDIAN_MERGE_FILE_TIMEOUT_MS ?? 30000) ); - await session.app.stop(); - session = await startConfiguredSession(context, vaultA); + const afterResolution = `${merged.trimEnd()}\n\nPost-resolution edit on B.\n`; + await storeFileRevision(context.cliBinary, session.cliEnv, conflictPath, afterResolution, mergedRev); + await writeVaultFile(vaultB.path, conflictPath, afterResolution); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await stopTrackedSession(context, session); + + session = await startConfiguredSession(context, vaultA, conflictOverrides); await syncAndApply(context, session); - const mergedOnA = await waitForPathContent( + const resolvedOnA = await waitForPathContent( vaultA.path, conflictPath, - (content) => content.includes("Left line") && content.includes("Right tail"), + (content) => content === afterResolution, Number(process.env.E2E_OBSIDIAN_MERGE_FILE_TIMEOUT_MS ?? 30000) ); - await session.app.stop(); + const resolvedState = await readFileConflictState(context.cliBinary, session.cliEnv, conflictPath); + await stopTrackedSession(context, session); - assertEqual(mergedOnA, mergedOnB, "Merged Markdown content was not consistent across both vaults."); - console.log("Markdown conflict was automatically merged and propagated by the next synchronisation."); + assertEqual(mergedOnB, merged, "The resolving Vault did not reflect the merged Markdown content."); + assertEqual( + resolvedOnA, + afterResolution, + "The resolved Markdown content did not replace the known losing revision." + ); + assertEqual( + resolvedState.branches.length, + 1, + "The receiving Vault recreated a conflict from the known losing revision." + ); + console.log( + "A two-Vault Markdown conflict was merged, edited again, and propagated to the Vault holding the resolved losing revision." + ); +} + +async function runConflictTimeStorageOperations( + context: RunnerContext, + vaultA: TemporaryVault, + vaultB: TemporaryVault +): Promise { + const paths = [conflictEditPath, conflictDeletePath, conflictCaseFromPath, conflictRenameFromPath] as const; + const conflictOverrides = { + disableMarkdownAutoMerge: true, + checkConflictOnlyOnOpen: true, + showMergeDialogOnlyOnActive: true, + handleFilenameCaseSensitive: false, + }; + const baseContent = Object.fromEntries(paths.map((path) => [path, `# Conflict operation\n\nBase for ${path}.\n`])) as Record< + (typeof paths)[number], + string + >; + const leftContent = Object.fromEntries( + paths.map((path) => [path, `${baseContent[path]}\nEdit made on Vault A.\n`]) + ) as Record<(typeof paths)[number], string>; + const rightContent = Object.fromEntries( + paths.map((path) => [path, `${baseContent[path]}\nDisplayed edit made on Vault B.\n`]) + ) as Record<(typeof paths)[number], string>; + + let session = await startConfiguredSession(context, vaultA, conflictOverrides); + for (const path of paths) { + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, path, baseContent[path]); + await uploadNote(context, session, path); + } + await stopTrackedSession(context, session); + + session = await startConfiguredSession(context, vaultB, conflictOverrides); + await syncAndApply(context, session); + for (const path of paths) { + await waitForPathContent(vaultB.path, path, (content) => content === baseContent[path]); + } + await stopTrackedSession(context, session); + + session = await startConfiguredSession(context, vaultA, conflictOverrides); + for (const path of paths) { + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, path, leftContent[path]); + await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path); + } + await pushLocalChanges(context.cliBinary, session.cliEnv); + await stopTrackedSession(context, session); + + session = await startConfiguredSession(context, vaultB, conflictOverrides); + for (const path of paths) { + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, path, rightContent[path]); + await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path); + } + await pushLocalChanges(context.cliBinary, session.cliEnv); + + const displayedRevisions = new Map(); + const initialBranchRevisions = new Map>(); + for (const path of paths) { + const state = await waitForFileConflict(context.cliBinary, session.cliEnv, path); + const displayedBranch = state.branches.find((branch) => branch.content === rightContent[path] && !branch.deleted); + if (!displayedBranch) { + throw new Error(`Could not identify the branch displayed by Vault B: ${path}; ${JSON.stringify(state)}`); + } + const provenance = await readFileReflectionProvenance(context.cliBinary, session.cliEnv, path); + assertEqual( + provenance?.revision, + displayedBranch.rev, + `Vault B did not retain the exact displayed revision for ${path}.` + ); + displayedRevisions.set(path, displayedBranch.rev); + initialBranchRevisions.set(path, new Set(state.branches.map((branch) => branch.rev))); + } + + const editedAgain = `${rightContent[conflictEditPath]}\nSecond edit while the conflict is active.\n`; + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, conflictEditPath, editedAgain); + const editedBranch = await waitForConflictBranch( + context.cliBinary, + session.cliEnv, + conflictEditPath, + (branch) => branch.content === editedAgain + ); + assertEqual( + editedBranch.parentRev, + displayedRevisions.get(conflictEditPath), + "A conflict-time edit did not extend the displayed revision." + ); + + await deleteNoteViaObsidian(context.cliBinary, session.cliEnv, conflictDeletePath); + const deletedBranch = await waitForConflictBranch( + context.cliBinary, + session.cliEnv, + conflictDeletePath, + (branch) => branch.deleted + ); + assertEqual( + deletedBranch.parentRev, + displayedRevisions.get(conflictDeletePath), + "A conflict-time deletion did not extend the displayed revision." + ); + + await renameNoteViaObsidian( + context.cliBinary, + session.cliEnv, + conflictCaseFromPath, + conflictCaseToPath + ); + const caseRenamedBranch = await waitForConflictBranch( + context.cliBinary, + session.cliEnv, + conflictCaseToPath, + (branch) => + !initialBranchRevisions.get(conflictCaseFromPath)?.has(branch.rev) && + branch.path === conflictCaseToPath && + branch.content === rightContent[conflictCaseFromPath] && + !branch.deleted + ); + const expectedCaseParent = displayedRevisions.get(conflictCaseFromPath); + if (caseRenamedBranch.parentRev !== expectedCaseParent) { + const [state, oldProvenance, newProvenance, identity] = await Promise.all([ + readFileConflictState(context.cliBinary, session.cliEnv, conflictCaseToPath), + readFileReflectionProvenance(context.cliBinary, session.cliEnv, conflictCaseFromPath), + readFileReflectionProvenance(context.cliBinary, session.cliEnv, conflictCaseToPath), + readPathIdentity(context.cliBinary, session.cliEnv, [conflictCaseFromPath, conflictCaseToPath]), + ]); + throw new Error( + `A conflict-time case-only rename did not extend the displayed revision: ${JSON.stringify({ + expectedCaseParent, + caseRenamedBranch, + state, + oldProvenance, + newProvenance, + identity, + })}` + ); + } + const [oldCaseProvenance, newCaseProvenance] = await Promise.all([ + readFileReflectionProvenance(context.cliBinary, session.cliEnv, conflictCaseFromPath), + readFileReflectionProvenance(context.cliBinary, session.cliEnv, conflictCaseToPath), + ]); + assertEqual(oldCaseProvenance, null, "A conflict-time case-only rename retained the old provenance path."); + assertEqual( + newCaseProvenance?.revision, + caseRenamedBranch.rev, + "A conflict-time case-only rename did not record the new displayed revision." + ); + + await renameNoteViaObsidian( + context.cliBinary, + session.cliEnv, + conflictRenameFromPath, + conflictRenameToPath + ); + const renamedTarget = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, conflictRenameToPath); + const renamedSourceDeletion = await waitForConflictBranch( + context.cliBinary, + session.cliEnv, + conflictRenameFromPath, + (branch) => branch.deleted + ); + assertEqual( + renamedSourceDeletion.parentRev, + displayedRevisions.get(conflictRenameFromPath), + "A conflict-time cross-path rename did not soft-delete the displayed source revision." + ); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await stopTrackedSession(context, session); + + session = await startConfiguredSession(context, vaultA, conflictOverrides); + await syncAndApply(context, session); + const replicatedBranches = [ + [conflictEditPath, editedBranch], + [conflictDeletePath, deletedBranch], + [conflictCaseToPath, caseRenamedBranch], + [conflictRenameFromPath, renamedSourceDeletion], + ] as const; + for (const [path, expectedBranch] of replicatedBranches) { + const replicated = await waitForConflictBranch( + context.cliBinary, + session.cliEnv, + path, + (branch) => branch.rev === expectedBranch.rev + ); + assertEqual( + replicated.parentRev, + expectedBranch.parentRev, + `The exact conflict-operation revision tree did not replicate for ${path}.` + ); + } + await waitForPathContent( + vaultA.path, + conflictRenameToPath, + (content) => content === rightContent[conflictRenameFromPath] + ); + const targetOnA = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, conflictRenameToPath); + assertEqual(targetOnA.id, renamedTarget.id, "The cross-path rename target did not replicate as the same document."); + assertEqual( + await readVaultFile(vaultA.path, conflictDeletePath), + leftContent[conflictDeletePath], + "A logical deletion from one conflict branch removed the other Vault's live branch." + ); + await stopTrackedSession(context, session); + + console.log( + "Conflict-time edit, logical deletion, case-only rename, and cross-path rename extended the displayed branches and replicated their revision trees." + ); } async function runTargetMismatch( @@ -435,23 +891,57 @@ async function runTargetMismatch( let session = await startConfiguredSession(context, vaultA); await writeNoteViaObsidian(context.cliBinary, session.cliEnv, targetMismatchPath, ignoredContent); await uploadNote(context, session, targetMismatchPath); - await session.app.stop(); + await stopTrackedSession(context, session); session = await startConfiguredSession(context, vaultB, { syncOnlyRegEx: "^E2E/two-vault/allowed/.*", }); await syncAndApply(context, session); + await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, targetMismatchPath); assertEqual( await pathExists(vaultB.path, targetMismatchPath), false, "A note was reflected on a device where it was not a target file." ); - await session.app.stop(); + await stopTrackedSession(context, session); + + session = await startConfiguredSession(context, vaultB, { + syncOnlyRegEx: "^E2E/two-vault/allowed/.*", + }); + assertEqual( + await pathExists(vaultB.path, targetMismatchPath), + false, + "A checkpointed non-target note was reflected before its target filter changed." + ); + await configureCouchDb( + context.cliBinary, + session.cliEnv, + { + uri: context.couchDb.uri, + username: context.couchDb.username, + password: context.couchDb.password, + dbName: context.dbName, + }, + { syncOnlyRegEx: "" } + ); + await syncAndApply(context, session); + const reflectedAfterEnabling = await waitForPathContent( + vaultB.path, + targetMismatchPath, + (content) => content === ignoredContent + ); + await stopTrackedSession(context, session); + + assertEqual( + reflectedAfterEnabling, + ignoredContent, + "Target file was not reflected after the device accepted the path." + ); session = await startConfiguredSession(context, vaultA); await writeNoteViaObsidian(context.cliBinary, session.cliEnv, targetMismatchPath, acceptedContent); await uploadNote(context, session, targetMismatchPath); - await session.app.stop(); + await stopTrackedSession(context, session); session = await startConfiguredSession(context, vaultB, { syncOnlyRegEx: "", @@ -462,10 +952,12 @@ async function runTargetMismatch( targetMismatchPath, (content) => content === acceptedContent ); - await session.app.stop(); + await stopTrackedSession(context, session); - assertEqual(received, acceptedContent, "Target file was not reflected after the device accepted the path."); - console.log("Two-vault target mismatch skipped a non-target note, then reflected it after enabling the target."); + assertEqual(received, acceptedContent, "Target file update was not reflected after the device accepted the path."); + console.log( + "Two-vault target mismatch skipped a non-target note, reflected it after enabling the target, and accepted a later update." + ); } async function main(): Promise { @@ -482,8 +974,22 @@ async function main(): Promise { const vaultB = await createTemporaryVault(); const encryptedVaultA = await createTemporaryVault(); const encryptedVaultB = await createTemporaryVault(); - const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName }; - const encryptedContext: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName: encryptedDbName }; + const context: RunnerContext = { + binary, + cliBinary: cli.binary, + couchDb, + dbName, + reviewedVaults: new Set(), + activeSessions: new Set(), + }; + const encryptedContext: RunnerContext = { + binary, + cliBinary: cli.binary, + couchDb, + dbName: encryptedDbName, + reviewedVaults: new Set(), + activeSessions: new Set(), + }; try { await assertCouchDbReachable(couchDb); @@ -496,14 +1002,25 @@ async function main(): Promise { console.log(`Temporary CouchDB database: ${dbName}`); console.log(`Temporary encrypted CouchDB database: ${encryptedDbName}`); - await runCreateUpdateDelete(context, vaultA, vaultB); - await runRename(context, vaultA, vaultB); - if (process.env.E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT === "true") { - await runMarkdownAutoMerge(context, vaultA, vaultB); + const onlyConflictOperations = process.env.E2E_OBSIDIAN_ONLY_CONFLICT_OPERATIONS === "true"; + if (!onlyConflictOperations) { + await runCreateUpdateDelete(context, vaultA, vaultB); + await runRename(context, vaultA, vaultB); + await runCaseOnlyRename(context, vaultA, vaultB); + if (process.env.E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT === "true") { + await runMarkdownAutoMerge(context, vaultA, vaultB); + } + } + if (onlyConflictOperations || process.env.E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS === "true") { + await runConflictTimeStorageOperations(context, vaultA, vaultB); + } + if (!onlyConflictOperations) { + await runTargetMismatch(context, vaultA, vaultB); + await runEncryptedRoundTrip(encryptedContext, encryptedVaultA, encryptedVaultB); } - await runTargetMismatch(context, vaultA, vaultB); - await runEncryptedRoundTrip(encryptedContext, encryptedVaultA, encryptedVaultB); } finally { + await stopTrackedSessions(context); + await stopTrackedSessions(encryptedContext); await vaultA.dispose(); await vaultB.dispose(); await encryptedVaultA.dispose(); diff --git a/test/e2e-obsidian/scripts/upgrade-from-stable.ts b/test/e2e-obsidian/scripts/upgrade-from-stable.ts new file mode 100644 index 00000000..fb7114e5 --- /dev/null +++ b/test/e2e-obsidian/scripts/upgrade-from-stable.ts @@ -0,0 +1,756 @@ +import { spawn } from "node:child_process"; +import { access, readFile, writeFile } from "node:fs/promises"; +import { basename, resolve } from "node:path"; +import { + assertCouchDbReachable, + createCouchDbDatabase, + deleteCouchDbDatabase, + fetchAllCouchDbDocs, + fetchCouchDbDatabaseInfo, + fetchCouchDbLocalDocs, + loadCouchDbConfig, + makeUniqueDatabaseName, + type CouchDbConfig, + type CouchDbDatabaseInfo, + type CouchDbDocument, +} from "../runner/couchdb.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { + configureCouchDb, + configureObjectStorage, + createE2eCouchDbPluginData, + createE2eObjectStoragePluginData, + createE2eObsidianDeviceLocalState, + prepareRemote, + pushLocalChanges, + waitForLiveSyncCoreReady, +} from "../runner/liveSyncWorkflow.ts"; +import { + deleteObjectStoragePrefix, + ensureObjectStorageBucket, + listObjectStorageObjects, + loadObjectStorageConfig, + makeUniqueBucketPrefix, + readObjectStorageJson, + type ObjectStorageConfig, +} from "../runner/objectStorage.ts"; +import { ensurePinnedReleaseArtifact, UPGRADE_SOURCE_RELEASE } from "../runner/releaseArtifact.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { + assertCouchDbCheckpointContinuity, + assertCouchDbDocumentsUnchanged, + assertJournalCheckpointAdvanced, + assertJournalCheckpointLoaded, + assertMilestoneContinuity, + assertNoJournalReplay, + assertSomeCouchDbCheckpointAdvanced, + type CouchDbCheckpointSnapshot, + type CouchDbDocumentRevision, + type MilestoneIdentity, + type RemoteObjectSnapshot, +} from "../runner/upgradeContinuity.ts"; +import { + assertStableReleaseDefaults, + assertStableRemoteSelection, + assertUnconfiguredUpgradeReady, + assertUnconfiguredUpgradeRestarted, + assertUpgradeCompatibilityReady, + assertUpgradeRemainsReady, + configureStableRelease, + createPostUpgradeDelta, + createUpgradeScenarioPaths, + createVerifierReturnDelta, + dismissConfigDoctorIfShown, + prepareStableRemote, + readJournalCheckpoint, + readLocalCouchDbCheckpoints, + readRuntimeUpgradeState, + readRuntimeSettingsUpgradeState, + runCouchDbReplicationObserved, + runJournalReplicationObserved, + runStableFileHistory, + STABLE_RELEASE_VERSION, + verifyPostUpgradeHistory, + verifyPreUpgradeHistory, + verifyReturnDelta, + waitForPersistentNodeIdentity, + type CouchDbReplicationObservation, + type RuntimeUpgradeState, + type UpgradeTransportConfiguration, +} from "../runner/upgradeWorkflow.ts"; +import { obsidianRemoteDebuggingPort } from "../runner/ui.ts"; +import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; + +process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000"; + +type Transport = "couchdb" | "object-storage"; + +type RemoteMilestone = CouchDbDocument & { + created?: unknown; + locked?: unknown; + accepted_nodes?: unknown; + tweak_values?: unknown; +}; + +type CouchDbRemoteSnapshot = { + checkpoints: CouchDbCheckpointSnapshot[]; + documents: CouchDbDocumentRevision[]; + info: CouchDbDatabaseInfo; + milestone: MilestoneIdentity; + preferredTweaks: Record; +}; + +type ObjectStorageRemoteSnapshot = { + journalObjects: RemoteObjectSnapshot[]; + milestone: MilestoneIdentity; + preferredTweaks: Record; +}; + +type RunnerContext = { + binary: string; + cliBinary: string; + sourceArtifactRoot: string; + targetArtifactRoot: string; + targetVersion: string; + activeSessions: Set; +}; + +type ParsedArguments = { + transports: Transport[]; + manageServices: boolean; + keepServices: boolean; +}; + +type StartSessionOptions = { + pluginData?: Record; + localStorageEntries?: Readonly>; + waitForCoreReady?: boolean; +}; + +const MILESTONE_ID = "_local/obsydian_livesync_milestone"; +const JOURNAL_MILESTONE_NAME = "_00000000-milestone.json"; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function assertEqual(actual: unknown, expected: unknown, message: string): void { + if (actual !== expected) { + throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`); + } +} + +function parseArguments(argv: readonly string[]): ParsedArguments { + let transportValue = "all"; + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]; + if (argument === "--transport") { + transportValue = argv[index + 1] ?? ""; + index++; + } else if (argument.startsWith("--transport=")) { + transportValue = argument.slice("--transport=".length); + } + } + const transports: Transport[] = + transportValue === "all" + ? ["couchdb", "object-storage"] + : transportValue === "couchdb" || transportValue === "object-storage" + ? [transportValue] + : (() => { + throw new Error(`Unsupported transport '${transportValue}'. Use couchdb, object-storage, or all.`); + })(); + return { + transports, + manageServices: argv.includes("--manage-services"), + keepServices: argv.includes("--keep-services"), + }; +} + +function sessionEnvironment(port: number): NodeJS.ProcessEnv { + return { ...process.env, E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) }; +} + +function sessionPorts(): readonly [number, number] { + const first = obsidianRemoteDebuggingPort(process.env); + const second = Number(process.env.E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT ?? first + 1); + if (!Number.isInteger(second) || second < 1 || second > 65535 || second === first) { + throw new Error(`Invalid secondary Obsidian remote debugging port: ${second}`); + } + return [first, second]; +} + +function npmBinary(): string { + return process.platform === "win32" ? "npm.cmd" : "npm"; +} + +function runNpmScript(name: string, optional = false): Promise { + return new Promise((resolvePromise, reject) => { + console.log(`\n# ${name}`); + const child = spawn(npmBinary(), ["run", name], { + cwd: process.cwd(), + env: process.env, + stdio: "inherit", + }); + child.on("error", reject); + child.on("exit", (code, signal) => { + if (code === 0 || optional) { + if (code !== 0) { + console.warn(`${name} did not complete cleanly (${signal ? `signal ${signal}` : `exit ${code}`}).`); + } + resolvePromise(); + return; + } + reject(new Error(`${name} failed (${signal ? `signal ${signal}` : `exit ${code}`}).`)); + }); + }); +} + +async function validateTargetArtifact(root: string): Promise { + await Promise.all( + ["main.js", "manifest.json", "styles.css"].map(async (name) => await access(resolve(root, name))) + ); + const manifest = JSON.parse(await readFile(resolve(root, "manifest.json"), "utf8")) as { + id?: unknown; + version?: unknown; + }; + assertEqual(manifest.id, UPGRADE_SOURCE_RELEASE.pluginId, "The target artefact has an unexpected plug-in id."); + assert(typeof manifest.version === "string" && manifest.version.length > 0, "The target manifest has no version."); + assert( + manifest.version !== STABLE_RELEASE_VERSION, + `The target artefact is still the source release ${STABLE_RELEASE_VERSION}.` + ); + return manifest.version; +} + +async function startSession( + context: RunnerContext, + vault: TemporaryVault, + port: number, + artifactRoot: string, + options: StartSessionOptions = {} +): Promise { + const session = await startObsidianLiveSyncSession({ + binary: context.binary, + cliBinary: context.cliBinary, + vault, + artifactRoot, + pluginData: options.pluginData, + localStorageEntries: options.localStorageEntries, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + env: sessionEnvironment(port), + }); + context.activeSessions.add(session); + try { + if (options.waitForCoreReady !== false) { + await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); + } + return session; + } catch (error) { + await stopSession(context, session).catch(() => undefined); + throw error; + } +} + +async function readStoredPluginData(vault: TemporaryVault): Promise> { + const path = resolve(vault.path, ".obsidian", "plugins", "obsidian-livesync", "data.json"); + return JSON.parse(await readFile(path, "utf8")) as Record; +} + +async function writeStoredPluginData(vault: TemporaryVault, data: Record): Promise { + const path = resolve(vault.path, ".obsidian", "plugins", "obsidian-livesync", "data.json"); + await writeFile(path, `${JSON.stringify(data, null, 2)}\n`); +} + +async function runUnconfiguredSettingsUpgrade(context: RunnerContext, port: number): Promise { + console.log(`\n# Upgrade from ${STABLE_RELEASE_VERSION}: unconfigured legacy settings`); + const vault = await createTemporaryVault("obsidian-livesync-upgrade-unconfigured-"); + + try { + let session = await startSession(context, vault, port, context.sourceArtifactRoot, { + pluginData: { liveSync: false }, + waitForCoreReady: false, + }); + const stableState = await readRuntimeSettingsUpgradeState(context.cliBinary, session.cliEnv); + assertStableReleaseDefaults(stableState, false); + await stopSession(context, session); + + const stableData = await readStoredPluginData(vault); + if (stableData.isConfigured !== undefined) { + assertEqual( + stableData.isConfigured, + false, + "The stable release persisted a configured state for its default-equivalent settings." + ); + } + + // 0.25.83 infers the runtime boolean, but persistence depends on an + // unrelated settings-save event. Restore the pre-flag document + // explicitly so the target proves the direct legacy migration in + // either case rather than depending on that timing. + await writeStoredPluginData(vault, { liveSync: false }); + + session = await startSession(context, vault, port, context.targetArtifactRoot, { + waitForCoreReady: false, + }); + const upgradedState = await readRuntimeSettingsUpgradeState(context.cliBinary, session.cliEnv); + assertUnconfiguredUpgradeReady(stableState, upgradedState, context.targetVersion); + await stopSession(context, session); + + const migratedData = await readStoredPluginData(vault); + assertEqual(migratedData.isConfigured, false, "The inferred unconfigured state was not saved."); + assertEqual( + migratedData.handleFilenameCaseSensitive, + false, + "The inferred case-insensitive setting was not saved." + ); + + session = await startSession(context, vault, port, context.targetArtifactRoot, { + waitForCoreReady: false, + }); + const restartedState = await readRuntimeSettingsUpgradeState(context.cliBinary, session.cliEnv); + assertUnconfiguredUpgradeRestarted(restartedState, context.targetVersion); + await stopSession(context, session); + + console.log( + `PASS unconfigured settings: ${STABLE_RELEASE_VERSION} -> ${context.targetVersion}; legacy inference, persistence, and restart idempotence verified.` + ); + } finally { + await stopSessions(context); + await vault.dispose(); + } +} + +async function stopSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise { + if (!context.activeSessions.has(session)) return; + await session.app.stop(); + context.activeSessions.delete(session); +} + +async function stopSessions(context: RunnerContext): Promise { + for (const session of [...context.activeSessions]) await stopSession(context, session); +} + +function milestoneIdentity(document: RemoteMilestone): MilestoneIdentity { + assert(document.created !== undefined && document.created !== null, "The remote milestone has no generation."); + assert(typeof document.locked === "boolean", "The remote milestone has no lock state."); + assert(Array.isArray(document.accepted_nodes), "The remote milestone has no accepted-device list."); + assert( + document.accepted_nodes.every((value) => typeof value === "string"), + "The remote milestone accepted-device list is malformed." + ); + return { + created: document.created, + locked: document.locked, + acceptedNodes: document.accepted_nodes, + }; +} + +function preferredTweaks(document: RemoteMilestone): Record { + const values = document.tweak_values; + assert(values !== null && typeof values === "object" && !Array.isArray(values), "The remote has no tweak map."); + const preferred = (values as Record).PREFERRED; + assert( + preferred !== null && typeof preferred === "object" && !Array.isArray(preferred), + "The remote has no preferred tweak settings." + ); + return { ...(preferred as Record) }; +} + +async function readCouchDbRemoteSnapshot(config: CouchDbConfig, databaseName: string): Promise { + const [allDocs, localDocs, info] = await Promise.all([ + fetchAllCouchDbDocs(config, databaseName), + fetchCouchDbLocalDocs(config, databaseName), + fetchCouchDbDatabaseInfo(config, databaseName), + ]); + const milestone = localDocs.rows.find(({ id }) => id === MILESTONE_ID)?.doc as RemoteMilestone | undefined; + assert(milestone, "The CouchDB remote milestone is missing."); + const checkpoints = localDocs.rows.flatMap(({ id, doc }) => + doc && Object.prototype.hasOwnProperty.call(doc, "last_seq") ? [{ id, lastSequence: doc.last_seq }] : [] + ); + const documents = allDocs.rows.map(({ id, value }) => ({ + id, + revision: value.rev, + deleted: value.deleted === true, + })); + return { + checkpoints, + documents, + info, + milestone: milestoneIdentity(milestone), + preferredTweaks: preferredTweaks(milestone), + }; +} + +async function readObjectStorageRemoteSnapshot( + config: ObjectStorageConfig, + prefix: string +): Promise { + const [objects, milestone] = await Promise.all([ + listObjectStorageObjects(config, prefix), + readObjectStorageJson(config, `${prefix}${JOURNAL_MILESTONE_NAME}`), + ]); + const journalObjects = objects.flatMap((object) => { + if (!object.Key || basename(object.Key).startsWith("_")) return []; + return [ + { + key: object.Key, + size: object.Size ?? 0, + etag: object.ETag ?? "", + }, + ]; + }); + return { + journalObjects, + milestone: milestoneIdentity(milestone), + preferredTweaks: preferredTweaks(milestone), + }; +} + +function assertNoOpCouchDbObservation(observation: CouchDbReplicationObservation): void { + assert(observation.succeeded, "The first post-upgrade CouchDB synchronisation failed."); + assertEqual(observation.sentDocuments, 0, "The no-op CouchDB synchronisation resent documents."); + assertEqual(observation.arrivedDocuments, 0, "The no-op CouchDB synchronisation refetched documents."); +} + +function assertNoOpCouchDbDatabase(before: CouchDbRemoteSnapshot, after: CouchDbRemoteSnapshot): void { + assertCouchDbCheckpointContinuity(before.checkpoints, after.checkpoints); + assertCouchDbDocumentsUnchanged(before.documents, after.documents); + assertEqual( + after.info.update_seq, + before.info.update_seq, + "The no-op CouchDB synchronisation advanced update_seq." + ); + assertEqual(after.info.doc_count, before.info.doc_count, "The no-op CouchDB synchronisation changed doc_count."); + assertMilestoneContinuity(before.milestone, after.milestone); +} + +function assertRestartContinuity(before: RuntimeUpgradeState, after: RuntimeUpgradeState): void { + assertEqual(after.localDatabaseName, before.localDatabaseName, "Restart opened a different local database."); + assertEqual(after.nodeId, before.nodeId, "Restart changed the device node identity."); + assertEqual( + after.settings.activeConfigurationId, + before.settings.activeConfigurationId, + "Restart changed the active remote profile." + ); +} + +async function configureFreshCouchDbVerifier( + context: RunnerContext, + session: ObsidianLiveSyncSession, + config: CouchDbConfig, + databaseName: string, + tweaks: Record +): Promise { + await configureCouchDb( + context.cliBinary, + session.cliEnv, + { uri: config.uri, username: config.username, password: config.password, dbName: databaseName }, + tweaks + ); + await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); + await prepareRemote(context.cliBinary, session.cliEnv); +} + +async function configureFreshObjectStorageVerifier( + context: RunnerContext, + session: ObsidianLiveSyncSession, + config: ObjectStorageConfig, + prefix: string, + tweaks: Record +): Promise { + await configureObjectStorage(context.cliBinary, session.cliEnv, { ...config, bucketPrefix: prefix }, tweaks); + await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); + await prepareRemote(context.cliBinary, session.cliEnv); +} + +async function runCouchDbUpgrade(context: RunnerContext, ports: readonly [number, number]): Promise { + console.log(`\n# Upgrade from ${STABLE_RELEASE_VERSION}: CouchDB`); + const config = await loadCouchDbConfig(); + const databaseName = makeUniqueDatabaseName(config.dbPrefix, "upgrade-from-stable"); + const remote: UpgradeTransportConfiguration = { kind: "couchdb", config, databaseName }; + const paths = createUpgradeScenarioPaths("couchdb"); + const upgradeVault = await createTemporaryVault("obsidian-livesync-upgrade-couchdb-"); + const verifierVault = await createTemporaryVault("obsidian-livesync-upgrade-couchdb-verifier-"); + let upgradedSession: ObsidianLiveSyncSession | undefined; + + try { + await assertCouchDbReachable(config); + await createCouchDbDatabase(config, databaseName); + + let session = await startSession(context, upgradeVault, ports[0], context.sourceArtifactRoot); + assertStableReleaseDefaults(await readRuntimeUpgradeState(context.cliBinary, session.cliEnv), false); + await configureStableRelease(context.cliBinary, session.cliEnv, remote); + const configuredStable = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv); + assertStableReleaseDefaults(configuredStable, true); + assertStableRemoteSelection(configuredStable, remote); + await stopSession(context, session); + + session = await startSession(context, upgradeVault, ports[0], context.sourceArtifactRoot); + const restartedStable = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv); + assertStableReleaseDefaults(restartedStable, true); + assertStableRemoteSelection(restartedStable, remote); + await waitForPersistentNodeIdentity(context.cliBinary, session.cliEnv); + await prepareStableRemote(context.cliBinary, session.cliEnv); + await runStableFileHistory(context.cliBinary, session.cliEnv, paths, async () => { + const result = await runCouchDbReplicationObserved(context.cliBinary, session.cliEnv); + assert(result.succeeded, "The stable CouchDB synchronisation failed."); + }); + await verifyPreUpgradeHistory(upgradeVault, paths); + + const stableState = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv); + const stableRemote = await readCouchDbRemoteSnapshot(config, databaseName); + const stableLocalCheckpoints = await readLocalCouchDbCheckpoints( + context.cliBinary, + session.cliEnv, + stableRemote.checkpoints.map(({ id }) => id) + ); + assertCouchDbCheckpointContinuity(stableRemote.checkpoints, stableLocalCheckpoints); + await stopSession(context, session); + + session = await startSession(context, upgradeVault, ports[0], context.targetArtifactRoot); + upgradedSession = session; + await dismissConfigDoctorIfShown(session.remoteDebuggingPort); + const upgradedState = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv); + assertUpgradeCompatibilityReady(stableState, upgradedState, context.targetVersion, remote); + await verifyPreUpgradeHistory(upgradeVault, paths); + + const loadedLocalCheckpoints = await readLocalCouchDbCheckpoints( + context.cliBinary, + session.cliEnv, + stableRemote.checkpoints.map(({ id }) => id) + ); + assertCouchDbCheckpointContinuity(stableLocalCheckpoints, loadedLocalCheckpoints); + const noOpObservation = await runCouchDbReplicationObserved(context.cliBinary, session.cliEnv); + assertNoOpCouchDbObservation(noOpObservation); + const noOpRemote = await readCouchDbRemoteSnapshot(config, databaseName); + assertNoOpCouchDbDatabase(stableRemote, noOpRemote); + + await createPostUpgradeDelta(context.cliBinary, session.cliEnv, paths); + const deltaObservation = await runCouchDbReplicationObserved(context.cliBinary, session.cliEnv); + assert(deltaObservation.succeeded, "The post-upgrade CouchDB delta failed."); + assert(deltaObservation.sentDocuments > 0, "The post-upgrade CouchDB delta sent no documents."); + const deltaRemote = await readCouchDbRemoteSnapshot(config, databaseName); + assertSomeCouchDbCheckpointAdvanced(noOpRemote.checkpoints, deltaRemote.checkpoints); + assertMilestoneContinuity(noOpRemote.milestone, deltaRemote.milestone); + + const verifierSettings = { + uri: config.uri, + username: config.username, + password: config.password, + dbName: databaseName, + }; + const verifier = await startSession(context, verifierVault, ports[1], context.targetArtifactRoot, { + pluginData: createE2eCouchDbPluginData(verifierSettings, deltaRemote.preferredTweaks), + localStorageEntries: createE2eObsidianDeviceLocalState(verifierVault.name), + }); + await configureFreshCouchDbVerifier(context, verifier, config, databaseName, deltaRemote.preferredTweaks); + await pushLocalChanges(context.cliBinary, verifier.cliEnv); + await verifyPostUpgradeHistory(verifierVault, paths); + await createVerifierReturnDelta(context.cliBinary, verifier.cliEnv, paths); + await pushLocalChanges(context.cliBinary, verifier.cliEnv); + + const returnObservation = await runCouchDbReplicationObserved(context.cliBinary, session.cliEnv); + assert(returnObservation.succeeded, "The upgraded CouchDB device could not receive the verifier delta."); + assert(returnObservation.arrivedDocuments > 0, "The verifier CouchDB delta did not arrive."); + await verifyReturnDelta(upgradeVault, paths); + await stopSession(context, verifier); + await stopSession(context, session); + upgradedSession = undefined; + + const restarted = await startSession(context, upgradeVault, ports[0], context.targetArtifactRoot); + const restartedState = await readRuntimeUpgradeState(context.cliBinary, restarted.cliEnv); + assertUpgradeRemainsReady(restartedState, context.targetVersion); + assertRestartContinuity(upgradedState, restartedState); + await verifyReturnDelta(upgradeVault, paths); + await stopSession(context, restarted); + + console.log( + `PASS CouchDB: ${STABLE_RELEASE_VERSION} -> ${context.targetVersion}; checkpoint lineage, no-op sync, delta sync, fresh-device round-trip, and restart continuity verified.` + ); + } finally { + if (upgradedSession) await stopSession(context, upgradedSession).catch(() => undefined); + await stopSessions(context); + await Promise.all([upgradeVault.dispose(), verifierVault.dispose()]); + if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") { + await deleteCouchDbDatabase(config, databaseName).catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + } + } +} + +async function runObjectStorageUpgrade(context: RunnerContext, ports: readonly [number, number]): Promise { + console.log(`\n# Upgrade from ${STABLE_RELEASE_VERSION}: Object Storage`); + const config = await loadObjectStorageConfig(); + const prefix = makeUniqueBucketPrefix("upgrade-from-stable"); + const remote: UpgradeTransportConfiguration = { kind: "object-storage", config, bucketPrefix: prefix }; + const paths = createUpgradeScenarioPaths("object-storage"); + const upgradeVault = await createTemporaryVault("obsidian-livesync-upgrade-object-storage-"); + const verifierVault = await createTemporaryVault("obsidian-livesync-upgrade-object-storage-verifier-"); + let upgradedSession: ObsidianLiveSyncSession | undefined; + + try { + await ensureObjectStorageBucket(config); + + let session = await startSession(context, upgradeVault, ports[0], context.sourceArtifactRoot); + assertStableReleaseDefaults(await readRuntimeUpgradeState(context.cliBinary, session.cliEnv), false); + await configureStableRelease(context.cliBinary, session.cliEnv, remote); + const configuredStable = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv); + assertStableReleaseDefaults(configuredStable, true); + assertStableRemoteSelection(configuredStable, remote); + await stopSession(context, session); + + session = await startSession(context, upgradeVault, ports[0], context.sourceArtifactRoot); + const restartedStable = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv); + assertStableReleaseDefaults(restartedStable, true); + assertStableRemoteSelection(restartedStable, remote); + await waitForPersistentNodeIdentity(context.cliBinary, session.cliEnv); + await prepareStableRemote(context.cliBinary, session.cliEnv); + await runStableFileHistory(context.cliBinary, session.cliEnv, paths, async () => { + const result = await runJournalReplicationObserved(context.cliBinary, session.cliEnv); + assert( + result.succeeded, + `The stable Object Storage synchronisation failed.\nObservation: ${JSON.stringify(result)}` + ); + }); + await verifyPreUpgradeHistory(upgradeVault, paths); + + const stableState = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv); + const stableCheckpoint = await readJournalCheckpoint(context.cliBinary, session.cliEnv); + const stableRemote = await readObjectStorageRemoteSnapshot(config, prefix); + await stopSession(context, session); + + session = await startSession(context, upgradeVault, ports[0], context.targetArtifactRoot); + upgradedSession = session; + await dismissConfigDoctorIfShown(session.remoteDebuggingPort); + const upgradedState = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv); + assertUpgradeCompatibilityReady(stableState, upgradedState, context.targetVersion, remote); + await verifyPreUpgradeHistory(upgradeVault, paths); + + const loadedCheckpoint = await readJournalCheckpoint(context.cliBinary, session.cliEnv); + assertJournalCheckpointLoaded(stableCheckpoint, loadedCheckpoint); + const noOpObservation = await runJournalReplicationObserved(context.cliBinary, session.cliEnv); + assert(noOpObservation.succeeded, "The first post-upgrade Object Storage synchronisation failed."); + const noOpCheckpoint = await readJournalCheckpoint(context.cliBinary, session.cliEnv); + const noOpRemote = await readObjectStorageRemoteSnapshot(config, prefix); + assertNoJournalReplay( + stableCheckpoint, + noOpCheckpoint, + stableRemote.journalObjects, + noOpRemote.journalObjects, + noOpObservation + ); + assertMilestoneContinuity(stableRemote.milestone, noOpRemote.milestone); + + await createPostUpgradeDelta(context.cliBinary, session.cliEnv, paths); + const deltaObservation = await runJournalReplicationObserved(context.cliBinary, session.cliEnv); + assert(deltaObservation.succeeded, "The post-upgrade Object Storage delta failed."); + const deltaCheckpoint = await readJournalCheckpoint(context.cliBinary, session.cliEnv); + assertJournalCheckpointAdvanced(noOpCheckpoint, deltaCheckpoint, deltaObservation); + const deltaRemote = await readObjectStorageRemoteSnapshot(config, prefix); + assertMilestoneContinuity(noOpRemote.milestone, deltaRemote.milestone); + + const verifierSettings = { ...config, bucketPrefix: prefix }; + const verifier = await startSession(context, verifierVault, ports[1], context.targetArtifactRoot, { + pluginData: createE2eObjectStoragePluginData(verifierSettings, deltaRemote.preferredTweaks), + localStorageEntries: createE2eObsidianDeviceLocalState(verifierVault.name), + }); + await configureFreshObjectStorageVerifier(context, verifier, config, prefix, deltaRemote.preferredTweaks); + await pushLocalChanges(context.cliBinary, verifier.cliEnv); + await verifyPostUpgradeHistory(verifierVault, paths); + await createVerifierReturnDelta(context.cliBinary, verifier.cliEnv, paths); + await pushLocalChanges(context.cliBinary, verifier.cliEnv); + + const returnObservation = await runJournalReplicationObserved(context.cliBinary, session.cliEnv); + assert(returnObservation.succeeded, "The upgraded Object Storage device could not receive the verifier delta."); + assert(returnObservation.downloadedJournalKeys.length > 0, "The verifier Object Storage delta did not arrive."); + await verifyReturnDelta(upgradeVault, paths); + await stopSession(context, verifier); + await stopSession(context, session); + upgradedSession = undefined; + + const restarted = await startSession(context, upgradeVault, ports[0], context.targetArtifactRoot); + const restartedState = await readRuntimeUpgradeState(context.cliBinary, restarted.cliEnv); + assertUpgradeRemainsReady(restartedState, context.targetVersion); + assertRestartContinuity(upgradedState, restartedState); + await verifyReturnDelta(upgradeVault, paths); + await stopSession(context, restarted); + + console.log( + `PASS Object Storage: ${STABLE_RELEASE_VERSION} -> ${context.targetVersion}; checkpoint lineage, no replay, delta sync, fresh-device round-trip, and restart continuity verified.` + ); + } finally { + if (upgradedSession) await stopSession(context, upgradedSession).catch(() => undefined); + await stopSessions(context); + await Promise.all([upgradeVault.dispose(), verifierVault.dispose()]); + if (process.env.E2E_OBSIDIAN_KEEP_OBJECT_STORAGE !== "true") { + await deleteObjectStoragePrefix(config, prefix).catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + } + } +} + +async function startManagedServices(transports: readonly Transport[]): Promise { + if (transports.includes("couchdb")) { + await runNpmScript("test:docker-couchdb:stop", true); + await runNpmScript("test:docker-couchdb:start"); + } + if (transports.includes("object-storage")) { + await runNpmScript("test:docker-s3:stop", true); + await runNpmScript("test:docker-s3:start"); + } +} + +async function stopManagedServices(transports: readonly Transport[]): Promise { + if (transports.includes("object-storage")) await runNpmScript("test:docker-s3:stop", true); + if (transports.includes("couchdb")) await runNpmScript("test:docker-couchdb:stop", true); +} + +async function main(): Promise { + const arguments_ = parseArguments(process.argv.slice(2)); + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); + + const targetArtifactRoot = resolve(process.env.E2E_LIVESYNC_TARGET_ARTIFACT_ROOT?.trim() || process.cwd()); + const targetVersion = await validateTargetArtifact(targetArtifactRoot); + const sourceArtifactRoot = await ensurePinnedReleaseArtifact(); + const context: RunnerContext = { + binary, + cliBinary: cli.binary, + sourceArtifactRoot, + targetArtifactRoot, + targetVersion, + activeSessions: new Set(), + }; + const ports = sessionPorts(); + let managedServicesStarted = false; + + console.log(`Using exact source release: ${STABLE_RELEASE_VERSION}`); + console.log(`Using target release candidate: ${targetVersion}`); + console.log(`Source artefact cache: ${sourceArtifactRoot}`); + console.log(`Target artefact root: ${targetArtifactRoot}`); + + try { + await runUnconfiguredSettingsUpgrade(context, ports[0]); + if (arguments_.manageServices) { + await startManagedServices(arguments_.transports); + managedServicesStarted = true; + } + for (const transport of arguments_.transports) { + if (transport === "couchdb") await runCouchDbUpgrade(context, ports); + else await runObjectStorageUpgrade(context, ports); + } + } finally { + await stopSessions(context); + if (managedServicesStarted && !arguments_.keepServices) { + await stopManagedServices(arguments_.transports); + } + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/test/fixtures/p2p-relay/compose.yml b/test/fixtures/p2p-relay/compose.yml new file mode 100644 index 00000000..44ab17ce --- /dev/null +++ b/test/fixtures/p2p-relay/compose.yml @@ -0,0 +1,12 @@ +services: + p2p-relay: + image: ghcr.io/hoytech/strfry:latest + container_name: livesync-e2e-p2p-relay + entrypoint: ["/app/strfry"] + command: ["--config", "/etc/strfry/strfry.conf", "relay"] + ports: + - "${E2E_P2P_RELAY_PORT:-4010}:7777" + volumes: + - ./strfry.conf:/etc/strfry/strfry.conf:ro + tmpfs: + - /app/strfry-db:rw,size=256m,mode=1777 diff --git a/test/fixtures/p2p-relay/strfry.conf b/test/fixtures/p2p-relay/strfry.conf new file mode 100644 index 00000000..5e91d3e7 --- /dev/null +++ b/test/fixtures/p2p-relay/strfry.conf @@ -0,0 +1,19 @@ +db = "./strfry-db/" + +relay { + bind = "0.0.0.0" + port = 7777 + nofiles = 100000 + + info { + name = "Self-hosted LiveSync E2E relay" + description = "Local Nostr signalling fixture for real-Obsidian P2P tests" + } + + maxWebsocketPayloadSize = 131072 + autoPingSeconds = 55 + + writePolicy { + plugin = "" + } +} diff --git a/test/harness/harness.ts b/test/harness/harness.ts deleted file mode 100644 index ef2d20eb..00000000 --- a/test/harness/harness.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { App } from "@/deps.ts"; -import ObsidianLiveSyncPlugin from "@/main"; -import { DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@/lib/src/common/types"; -import { LOG_LEVEL_VERBOSE, setGlobalLogFunction } from "@lib/common/logger"; -import { SettingCache } from "./obsidian-mock"; -import { delay, fireAndForget, promiseWithResolvers } from "octagonal-wheels/promises"; -import { EVENT_PLATFORM_UNLOADED } from "@lib/events/coreEvents"; -import { EVENT_LAYOUT_READY, eventHub } from "@/common/events"; - -import { env } from "../suite/variables"; - -export type LiveSyncHarness = { - app: App; - plugin: ObsidianLiveSyncPlugin; - dispose: () => Promise; - disposalPromise: Promise; - isDisposed: () => boolean; -}; -const isLiveSyncLogEnabled = env?.PRINT_LIVESYNC_LOGS === "true"; -function overrideLogFunction(vaultName: string) { - setGlobalLogFunction((msg, level, key) => { - if (!isLiveSyncLogEnabled) { - return; - } - if (level && level < LOG_LEVEL_VERBOSE) { - return; - } - if (msg instanceof Error) { - console.error(msg.stack); - } else { - console.log( - `[${vaultName}] :: [${key ?? "Global"}][${level ?? 1}]: ${msg instanceof Error ? msg.stack : msg}` - ); - } - }); -} - -export async function generateHarness( - paramVaultName?: string, - settings?: Partial -): Promise { - // return await serialized("harness-generation-lock", async () => { - // Dispose previous harness to avoid multiple harness running at the same time - // if (previousHarness && !previousHarness.isDisposed()) { - // console.log(`Previous harness detected, waiting for disposal...`); - // await previousHarness.disposalPromise; - // previousHarness = null; - // await delay(100); - // } - const vaultName = paramVaultName ?? "TestVault" + Date.now(); - const setting = { - ...DEFAULT_SETTINGS, - ...settings, - }; - overrideLogFunction(vaultName); - //@ts-ignore Mocked in harness - const app = new App(vaultName); - // setting and vault name - SettingCache.set(app, setting); - SettingCache.set(app.vault, vaultName); - - //@ts-ignore - const manifest_version = `${MANIFEST_VERSION || "0.0.0-harness"}`; - overrideLogFunction(vaultName); - const manifest = { - id: "obsidian-livesync", - name: "Self-hosted LiveSync (Harnessed)", - version: manifest_version, - minAppVersion: "0.15.0", - description: "Testing", - author: "vrtmrz", - authorUrl: "", - isDesktopOnly: false, - }; - - const plugin = new ObsidianLiveSyncPlugin(app, manifest); - overrideLogFunction(vaultName); - // Initial load - await delay(100); - await plugin.onload(); - let isDisposed = false; - const waitPromise = promiseWithResolvers(); - eventHub.once(EVENT_PLATFORM_UNLOADED, () => { - fireAndForget(async () => { - console.log(`Harness for vault '${vaultName}' disposed.`); - await delay(100); - eventHub.offAll(); - isDisposed = true; - waitPromise.resolve(); - }); - }); - eventHub.once(EVENT_LAYOUT_READY, () => { - plugin.app.vault.trigger("layout-ready"); - }); - const harness: LiveSyncHarness = { - app, - plugin, - dispose: async () => { - await plugin.onunload(); - return waitPromise.promise; - }, - disposalPromise: waitPromise.promise, - isDisposed: () => isDisposed, - }; - await delay(100); - console.log(`Harness for vault '${vaultName}' is ready.`); - // previousHarness = harness; - return harness; -} -export async function waitForReady(harness: LiveSyncHarness): Promise { - for (let i = 0; i < 10; i++) { - if (harness.plugin.core.services.appLifecycle.isReady()) { - console.log("App Lifecycle is ready"); - return; - } - await delay(100); - } - throw new Error(`Initialisation Timed out!`); -} - -export async function waitForIdle(harness: LiveSyncHarness): Promise { - for (let i = 0; i < 20; i++) { - await delay(25); - const processing = - harness.plugin.core.services.replication.databaseQueueCount.value + - harness.plugin.core.services.fileProcessing.totalQueued.value + - harness.plugin.core.services.fileProcessing.batched.value + - harness.plugin.core.services.fileProcessing.processing.value + - harness.plugin.core.services.replication.storageApplyingCount.value; - - if (processing === 0) { - if (i > 0) { - console.log(`Idle after ${i} loops`); - } - return; - } - } -} -export async function waitForClosed(harness: LiveSyncHarness): Promise { - await delay(100); - for (let i = 0; i < 10; i++) { - if (harness.plugin.core.services.control.hasUnloaded()) { - console.log("App has unloaded"); - return; - } - await delay(100); - } -} diff --git a/test/harness/obsidian-mock.ts b/test/harness/obsidian-mock.ts deleted file mode 100644 index a07f9025..00000000 --- a/test/harness/obsidian-mock.ts +++ /dev/null @@ -1,1001 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-function-type */ -export const SettingCache = new Map(); -//@ts-ignore obsidian global -globalThis.activeDocument = document; - -declare const hostPlatform: string | undefined; - -// import { interceptFetchForLogging } from "../harness/utils/intercept"; -// interceptFetchForLogging(); -globalThis.process = { - platform: (hostPlatform || "win32") as any, -} as any; -console.warn(`[Obsidian Mock] process.platform is set to ${globalThis.process.platform}`); -export class TAbstractFile { - vault: Vault; - path: string; - name: string; - parent: TFolder | null; - - constructor(vault: Vault, path: string, name: string, parent: TFolder | null) { - this.vault = vault; - this.path = path; - this.name = name; - this.parent = parent; - } -} - -export class TFile extends TAbstractFile { - stat: { - ctime: number; - mtime: number; - size: number; - } = { ctime: Date.now(), mtime: Date.now(), size: 0 }; - - get extension(): string { - return this.name.split(".").pop() || ""; - } - - get basename(): string { - const parts = this.name.split("."); - if (parts.length > 1) parts.pop(); - return parts.join("."); - } -} - -export class TFolder extends TAbstractFile { - children: TAbstractFile[] = []; - - get isRoot(): boolean { - return this.path === "" || this.path === "/"; - } -} - -export class EventRef {} - -// class StorageMap extends Map { -// constructor(saveName?: string) { -// super(); -// if (saveName) { -// this.saveName = saveName; -// void this.restore(saveName); -// } -// } -// private saveName: string = ""; -// async restore(saveName: string) { -// this.saveName = saveName; -// const db = await OpenKeyValueDatabase(saveName); -// const data = await db.get<{ [key: string]: U }>("data"); -// if (data) { -// for (const key of Object.keys(data)) { -// this.set(key as any as T, data[key]); -// } -// } -// db.close(); -// return this; -// } -// saving: boolean = false; -// async save() { -// if (this.saveName === "") { -// return; -// } -// if (this.saving) { -// return; -// } -// try { -// this.saving = true; - -// const db = await OpenKeyValueDatabase(this.saveName); -// const data: { [key: string]: U } = {}; -// for (const [key, value] of this.entries()) { -// data[key as any as string] = value; -// } -// await db.set("data", data); -// db.close(); -// } finally { -// this.saving = false; -// } -// } -// set(key: T, value: U): this { -// super.set(key, value); -// void this.save(); -// return this; -// } - -// } - -export class Vault { - adapter: DataAdapter; - vaultName: string = "MockVault"; - private files: Map = new Map(); - private contents: Map = new Map(); - private root: TFolder; - private listeners: Map> = new Map(); - - constructor(vaultName?: string) { - if (vaultName) { - this.vaultName = vaultName; - } - this.files = new Map(); - this.contents = new Map(); - this.adapter = new DataAdapter(this); - this.root = new TFolder(this, "", "", null); - this.files.set("", this.root); - } - - getAbstractFileByPath(path: string): TAbstractFile | null { - if (path === "/") path = ""; - const file = this.files.get(path); - return file || null; - } - getAbstractFileByPathInsensitive(path: string): TAbstractFile | null { - const lowerPath = path.toLowerCase(); - for (const [p, file] of this.files.entries()) { - if (p.toLowerCase() === lowerPath) { - return file; - } - } - return null; - } - - getFiles(): TFile[] { - return Array.from(this.files.values()).filter((f) => f instanceof TFile); - } - - async _adapterRead(path: string): Promise { - await Promise.resolve(); - const file = this.contents.get(path); - if (typeof file === "string") { - return file; - } - if (file instanceof ArrayBuffer) { - return new TextDecoder().decode(file); - } - return null; - } - - async _adapterReadBinary(path: string): Promise { - await Promise.resolve(); - const file = this.contents.get(path); - if (file instanceof ArrayBuffer) { - return file; - } - if (typeof file === "string") { - return new TextEncoder().encode(file).buffer; - } - return null; - } - - async read(file: TFile): Promise { - await Promise.resolve(); - const content = this.contents.get(file.path); - if (typeof content === "string") return content; - if (content instanceof ArrayBuffer) { - return new TextDecoder().decode(content); - } - return ""; - } - - async readBinary(file: TFile): Promise { - await Promise.resolve(); - const content = this.contents.get(file.path); - if (content instanceof ArrayBuffer) return content; - if (typeof content === "string") { - return new TextEncoder().encode(content).buffer; - } - return new ArrayBuffer(0); - } - - private async _create(path: string, data: string | ArrayBuffer, options?: DataWriteOptions): Promise { - if (this.files.has(path)) throw new Error("File already exists"); - const name = path.split("/").pop() || ""; - const parentPath = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : ""; - let parent = this.getAbstractFileByPath(parentPath); - if (!parent || !(parent instanceof TFolder)) { - parent = await this.createFolder(parentPath); - } - - const file = new TFile(this, path, name, parent as TFolder); - file.stat.size = typeof data === "string" ? new TextEncoder().encode(data).length : data.byteLength; - file.stat.ctime = options?.ctime ?? Date.now(); - file.stat.mtime = options?.mtime ?? Date.now(); - this.files.set(path, file); - this.contents.set(path, data); - (parent as TFolder).children.push(file); - // console.dir(this.files); - - this.trigger("create", file); - return file; - } - async create(path: string, data: string, options?: DataWriteOptions): Promise { - return await this._create(path, data, options); - } - async createBinary(path: string, data: ArrayBuffer, options?: DataWriteOptions): Promise { - return await this._create(path, data, options); - } - - async _modify(file: TFile, data: string | ArrayBuffer, options?: DataWriteOptions): Promise { - await Promise.resolve(); - this.contents.set(file.path, data); - file.stat.mtime = options?.mtime ?? Date.now(); - file.stat.ctime = options?.ctime ?? file.stat.ctime ?? Date.now(); - file.stat.size = typeof data === "string" ? data.length : data.byteLength; - console.warn(`[Obsidian Mock ${this.vaultName}] Modified file at path: '${file.path}'`); - this.files.set(file.path, file); - this.trigger("modify", file); - } - async modify(file: TFile, data: string, options?: DataWriteOptions): Promise { - return await this._modify(file, data, options); - } - async modifyBinary(file: TFile, data: ArrayBuffer, options?: DataWriteOptions): Promise { - return await this._modify(file, data, options); - } - - async createFolder(path: string): Promise { - if (path === "") return this.root; - if (this.files.has(path)) { - const f = this.files.get(path); - if (f instanceof TFolder) return f; - throw new Error("Path is a file"); - } - const name = path.split("/").pop() || ""; - const parentPath = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : ""; - const parent = await this.createFolder(parentPath); - const folder = new TFolder(this, path, name, parent); - this.files.set(path, folder); - parent.children.push(folder); - return folder; - } - - async delete(file: TAbstractFile, force?: boolean): Promise { - await Promise.resolve(); - this.files.delete(file.path); - this.contents.delete(file.path); - if (file.parent) { - file.parent.children = file.parent.children.filter((c) => c !== file); - } - this.trigger("delete", file); - } - - async trash(file: TAbstractFile, system: boolean): Promise { - await Promise.resolve(); - return this.delete(file); - } - - on(name: string, callback: (...args: any[]) => any, ctx?: any): EventRef { - if (!this.listeners.has(name)) { - this.listeners.set(name, new Set()); - } - const boundCallback = ctx ? callback.bind(ctx) : callback; - this.listeners.get(name)!.add(boundCallback); - return { name, callback: boundCallback } as any; - } - - off(name: string, callback: any) { - this.listeners.get(name)?.delete(callback); - } - - offref(ref: EventRef) { - const { name, callback } = ref as any; - this.off(name, callback); - } - - trigger(name: string, ...args: any[]) { - this.listeners.get(name)?.forEach((cb) => cb(...args)); - } - - getName(): string { - return this.vaultName; - } -} - -export class DataAdapter { - vault: Vault; - constructor(vault: Vault) { - this.vault = vault; - } - stat(path: string): Promise<{ ctime: number; mtime: number; size: number }> { - const file = this.vault.getAbstractFileByPath(path); - if (file && file instanceof TFile) { - return Promise.resolve({ - ctime: file.stat.ctime, - mtime: file.stat.mtime, - size: file.stat.size, - }); - } - return Promise.reject(new Error("File not found")); - } - async list(path: string): Promise<{ files: string[]; folders: string[] }> { - await Promise.resolve(); - const abstractFile = this.vault.getAbstractFileByPath(path); - if (abstractFile instanceof TFolder) { - const files: string[] = []; - const folders: string[] = []; - for (const child of abstractFile.children) { - if (child instanceof TFile) files.push(child.path); - else if (child instanceof TFolder) folders.push(child.path); - } - return { files, folders }; - } - return { files: [], folders: [] }; - } - async _write(path: string, data: string | ArrayBuffer, options?: DataWriteOptions): Promise { - const file = this.vault.getAbstractFileByPath(path); - if (file instanceof TFile) { - if (typeof data === "string") { - await this.vault.modify(file, data, options); - } else { - await this.vault.modifyBinary(file, data, options); - } - } else { - if (typeof data === "string") { - await this.vault.create(path, data, options); - } else { - await this.vault.createBinary(path, data, options); - } - } - } - async write(path: string, data: string, options?: DataWriteOptions): Promise { - return await this._write(path, data, options); - } - async writeBinary(path: string, data: ArrayBuffer, options?: DataWriteOptions): Promise { - return await this._write(path, data, options); - } - - async read(path: string): Promise { - const file = this.vault.getAbstractFileByPath(path); - if (file instanceof TFile) return await this.vault.read(file); - throw new Error("File not found"); - } - async readBinary(path: string): Promise { - const file = this.vault.getAbstractFileByPath(path); - if (file instanceof TFile) return await this.vault.readBinary(file); - throw new Error("File not found"); - } - - async exists(path: string): Promise { - await Promise.resolve(); - return this.vault.getAbstractFileByPath(path) !== null; - } - async mkdir(path: string): Promise { - await this.vault.createFolder(path); - } - async remove(path: string): Promise { - const file = this.vault.getAbstractFileByPath(path); - if (file) await this.vault.delete(file); - } -} - -class Events { - _eventEmitter = new EventTarget(); - _events = new Map(); - _eventTarget(cb: any) { - const x = this._events.get(cb); - if (x) { - return x; - } - const callback = (evt: any) => { - x(evt?.detail ?? undefined); - }; - this._events.set(cb, callback); - return callback; - } - on(name: string, cb: any, ctx?: any) { - this._eventEmitter.addEventListener(name, this._eventTarget(cb)); - } - trigger(name: string, args: any) { - const evt = new CustomEvent(name, { - detail: args, - }); - this._eventEmitter.dispatchEvent(evt); - } -} - -class Workspace extends Events { - getActiveFile() { - return null; - } - getMostRecentLeaf() { - return null; - } - - onLayoutReady(cb: any) { - // cb(); - // console.log("[Obsidian Mock] Workspace onLayoutReady registered"); - // this._eventEmitter.addEventListener("layout-ready", () => { - // console.log("[Obsidian Mock] Workspace layout-ready event triggered"); - setTimeout(() => { - cb(); - }, 200); - // }); - } - getLeavesOfType() { - return []; - } - getLeaf() { - return { setViewState: () => Promise.resolve(), revealLeaf: () => Promise.resolve() }; - } - revealLeaf() { - return Promise.resolve(); - } - containerEl: HTMLElement = document.createElement("div"); -} -export class App { - vaultName: string = "MockVault"; - constructor(vaultName?: string) { - if (vaultName) { - this.vaultName = vaultName; - } - this.vault = new Vault(this.vaultName); - } - vault: Vault; - workspace: Workspace = new Workspace(); - metadataCache: any = { - on: (name: string, cb: any, ctx?: any) => {}, - getFileCache: () => null, - }; -} - -export class Plugin { - app: App; - manifest: any; - settings: any; - commands: Map = new Map(); - constructor(app: App, manifest: any) { - this.app = app; - this.manifest = manifest; - } - async loadData(): Promise { - await Promise.resolve(); - return SettingCache.get(this.app) ?? {}; - } - async saveData(data: any): Promise { - await Promise.resolve(); - SettingCache.set(this.app, data); - } - onload() {} - onunload() {} - addSettingTab(tab: any) {} - addCommand(command: any) { - this.commands.set(command.id, command); - } - addStatusBarItem() { - return { - setText: () => {}, - setClass: () => {}, - addClass: () => {}, - }; - } - addRibbonIcon() { - const icon = { - setAttribute: () => icon, - addClass: () => icon, - onclick: () => {}, - }; - return icon; - } - registerView(type: string, creator: any) {} - registerObsidianProtocolHandler(handler: any) {} - registerEvent(handler: any) {} - registerDomEvent(target: any, eventName: string, handler: any) {} -} - -export class Notice { - private _key: number; - private static _counter = 0; - constructor(message: string) { - this._key = Notice._counter++; - console.log(`Notice [${this._key}]:`, message); - } - setMessage(message: string) { - console.log(`Notice [${this._key}]:`, message); - } -} - -export class Modal { - app: App; - contentEl: HTMLElement; - titleEl: HTMLElement; - modalEl: HTMLElement; - isOpen: boolean = false; - - constructor(app: App) { - this.app = app; - this.contentEl = document.createElement("div"); - this.contentEl.className = "modal-content"; - this.titleEl = document.createElement("div"); - this.titleEl.className = "modal-title"; - this.modalEl = document.createElement("div"); - this.modalEl.className = "modal"; - this.modalEl.style.display = "none"; - this.modalEl.appendChild(this.titleEl); - this.modalEl.appendChild(this.contentEl); - } - open() { - this.isOpen = true; - this.modalEl.style.display = "block"; - if (!this.modalEl.parentElement) { - document.body.appendChild(this.modalEl); - } - this.onOpen(); - } - close() { - this.isOpen = false; - this.modalEl.style.display = "none"; - this.onClose(); - } - onOpen() {} - onClose() {} - setPlaceholder(p: string) {} - setTitle(t: string) { - this.titleEl.textContent = t; - } -} - -export class PluginSettingTab { - app: App; - plugin: Plugin; - containerEl: HTMLElement; - constructor(app: App, plugin: Plugin) { - this.app = app; - this.plugin = plugin; - this.containerEl = document.createElement("div"); - } - display() {} -} - -export function normalizePath(path: string): string { - return path.replace(/\\/g, "/").replace(/\/+$/, ""); -} - -export const Platform = { - isDesktop: true, - isMobile: false, -}; - -export class Menu { - addItem(cb: (item: MenuItem) => any) { - cb(new MenuItem()); - return this; - } - showAtMouseEvent(evt: MouseEvent) {} -} -export class MenuItem { - setTitle(title: string) { - return this; - } - setIcon(icon: string) { - return this; - } - onClick(cb: (evt: MouseEvent) => any) { - return this; - } -} -export class MenuSeparator {} - -export class Component { - load() {} - unload() {} -} - -export class ButtonComponent extends Component { - buttonEl: HTMLButtonElement = document.createElement("button"); - private clickHandler: ((evt: MouseEvent) => any) | null = null; - - constructor() { - super(); - this.buttonEl = document.createElement("button"); - this.buttonEl.type = "button"; - } - - setButtonText(text: string) { - this.buttonEl.textContent = text; - return this; - } - - setCta() { - this.buttonEl.classList.add("mod-cta"); - return this; - } - - onClick(cb: (evt: MouseEvent) => any) { - this.clickHandler = cb; - this.buttonEl.removeEventListener("click", this.clickHandler); - this.buttonEl.addEventListener("click", (evt) => cb(evt as MouseEvent)); - return this; - } - - setClass(c: string) { - this.buttonEl.classList.add(c); - return this; - } - - setTooltip(tooltip: string) { - this.buttonEl.title = tooltip; - return this; - } - - setDisabled(disabled: boolean) { - this.buttonEl.disabled = disabled; - return this; - } -} - -export class TextComponent extends Component { - inputEl: HTMLInputElement = document.createElement("input"); - private changeHandler: ((value: string) => any) | null = null; - - constructor() { - super(); - this.inputEl = document.createElement("input"); - this.inputEl.type = "text"; - } - - onChange(cb: (value: string) => any) { - this.changeHandler = cb; - this.inputEl.removeEventListener("change", this.handleChange); - this.inputEl.addEventListener("change", this.handleChange); - this.inputEl.addEventListener("input", (evt) => { - const target = evt.target as HTMLInputElement; - cb(target.value); - }); - return this; - } - - private handleChange = (evt: Event) => { - if (this.changeHandler) { - const target = evt.target as HTMLInputElement; - this.changeHandler(target.value); - } - }; - - setValue(v: string) { - this.inputEl.value = v; - return this; - } - - setPlaceholder(p: string) { - this.inputEl.placeholder = p; - return this; - } - - setDisabled(disabled: boolean) { - this.inputEl.disabled = disabled; - return this; - } -} - -export class ToggleComponent extends Component { - inputEl: HTMLInputElement = document.createElement("input"); - private changeHandler: ((value: boolean) => any) | null = null; - - constructor() { - super(); - this.inputEl = document.createElement("input"); - this.inputEl.type = "checkbox"; - } - - onChange(cb: (value: boolean) => any) { - this.changeHandler = cb; - this.inputEl.addEventListener("change", (evt) => { - const target = evt.target as HTMLInputElement; - cb(target.checked); - }); - return this; - } - - setValue(v: boolean) { - this.inputEl.checked = v; - return this; - } - - setDisabled(disabled: boolean) { - this.inputEl.disabled = disabled; - return this; - } -} - -export class DropdownComponent extends Component { - selectEl: HTMLSelectElement = document.createElement("select"); - private changeHandler: ((value: string) => any) | null = null; - - constructor() { - super(); - this.selectEl = document.createElement("select"); - } - - addOption(v: string, d: string) { - const option = document.createElement("option"); - option.value = v; - option.textContent = d; - this.selectEl.appendChild(option); - return this; - } - - addOptions(o: Record) { - for (const [value, display] of Object.entries(o)) { - this.addOption(value, display); - } - return this; - } - - onChange(cb: (value: string) => any) { - this.changeHandler = cb; - this.selectEl.addEventListener("change", (evt) => { - const target = evt.target as HTMLSelectElement; - cb(target.value); - }); - return this; - } - - setValue(v: string) { - this.selectEl.value = v; - return this; - } - - setDisabled(disabled: boolean) { - this.selectEl.disabled = disabled; - return this; - } -} - -export class SliderComponent extends Component { - inputEl: HTMLInputElement = document.createElement("input"); - private changeHandler: ((value: number) => any) | null = null; - - constructor() { - super(); - this.inputEl = document.createElement("input"); - this.inputEl.type = "range"; - } - - onChange(cb: (value: number) => any) { - this.changeHandler = cb; - this.inputEl.addEventListener("change", (evt) => { - const target = evt.target as HTMLInputElement; - cb(parseFloat(target.value)); - }); - this.inputEl.addEventListener("input", (evt) => { - const target = evt.target as HTMLInputElement; - cb(parseFloat(target.value)); - }); - return this; - } - - setValue(v: number) { - this.inputEl.value = String(v); - return this; - } - - setMin(min: number) { - this.inputEl.min = String(min); - return this; - } - - setMax(max: number) { - this.inputEl.max = String(max); - return this; - } - - setStep(step: number) { - this.inputEl.step = String(step); - return this; - } - - setDisabled(disabled: boolean) { - this.inputEl.disabled = disabled; - return this; - } -} - -export class Setting { - nameEl: HTMLElement; - descEl: HTMLElement; - controlEl: HTMLElement; - infoEl: HTMLElement; - - constructor(containerEl: HTMLElement) { - this.nameEl = containerEl.createDiv(); - this.descEl = containerEl.createDiv(); - this.controlEl = containerEl.createDiv(); - this.infoEl = containerEl.createDiv(); - } - setName(name: string) { - this.nameEl.setText(name); - return this; - } - setDesc(desc: string) { - this.descEl.setText(desc); - return this; - } - setClass(c: string) { - this.controlEl.addClass(c); - return this; - } - addText(cb: (text: TextComponent) => any) { - const component = new TextComponent(); - this.controlEl.appendChild(component.inputEl); - cb(component); - return this; - } - addToggle(cb: (toggle: ToggleComponent) => any) { - const component = new ToggleComponent(); - cb(component); - return this; - } - addButton(cb: (btn: ButtonComponent) => any) { - const btn = new ButtonComponent(); - this.controlEl.appendChild(btn.buttonEl); - cb(btn); - return this; - } - addDropdown(cb: (dropdown: DropdownComponent) => any) { - const component = new DropdownComponent(); - cb(component); - return this; - } - addSlider(cb: (slider: SliderComponent) => any) { - const component = new SliderComponent(); - cb(component); - return this; - } -} - -// HTMLElement extensions -if (typeof HTMLElement !== "undefined") { - const proto = HTMLElement.prototype as any; - proto.createDiv = function (o?: any) { - const div = document.createElement("div"); - if (o?.cls) div.addClass(o.cls); - if (o?.text) div.setText(o.text); - this.appendChild(div); - return div; - }; - proto.createEl = function (tag: string, o?: any) { - const el = document.createElement(tag); - if (o?.cls) el.addClass(o.cls); - if (o?.text) el.setText(o.text); - this.appendChild(el); - return el; - }; - proto.createSpan = function (o?: any) { - return this.createEl("span", o); - }; - proto.empty = function () { - this.innerHTML = ""; - }; - proto.setText = function (t: string) { - this.textContent = t; - }; - proto.addClass = function (c: string) { - this.classList.add(c); - }; - proto.removeClass = function (c: string) { - this.classList.remove(c); - }; - proto.toggleClass = function (c: string, b: boolean) { - this.classList.toggle(c, b); - }; - proto.hasClass = function (c: string) { - return this.classList.contains(c); - }; -} - -export class Editor {} - -export class FuzzySuggestModal { - constructor(app: App) {} - setPlaceholder(p: string) {} - open() {} - close() {} - private __dummy(_: T): never { - throw new Error("Not implemented."); - } -} -export class MarkdownRenderer { - static render(app: App, md: string, el: HTMLElement, path: string, component: Component) { - el.innerHTML = md; - return Promise.resolve(); - } -} -export class MarkdownView {} -export class TextAreaComponent extends Component {} -export class ItemView {} -export class WorkspaceLeaf {} - -export function sanitizeHTMLToDom(html: string) { - const div = document.createElement("div"); - div.innerHTML = html; - return div; -} - -export function addIcon() {} -export const debounce = (fn: any) => fn; -export async function request(options: any) { - const result = await requestUrl(options); - return result.text; -} - -export async function requestUrl({ - body, - headers, - method, - url, - contentType, -}: RequestUrlParam): Promise { - // console.log("[requestUrl] Mock called:", { method, url, contentType }); - const reqHeadersObj: Record = {}; - for (const key of Object.keys(headers || {})) { - reqHeadersObj[key.toLowerCase()] = headers[key]; - } - if (contentType) { - reqHeadersObj["content-type"] = contentType; - } - reqHeadersObj["Cache-Control"] = "no-cache, no-store, must-revalidate"; - reqHeadersObj["Pragma"] = "no-cache"; - reqHeadersObj["Expires"] = "0"; - const result = await fetch(url, { - method: method, - headers: { - ...reqHeadersObj, - }, - - body: body, - }); - const headersObj: Record = {}; - result.headers.forEach((value, key) => { - headersObj[key] = value; - }); - let json = undefined; - let text = undefined; - let arrayBuffer = undefined; - try { - const isJson = result.headers.get("content-type")?.includes("application/json"); - arrayBuffer = await result.arrayBuffer(); - const isText = result.headers.get("content-type")?.startsWith("text/"); - if (isText || isJson) { - text = new TextDecoder().decode(arrayBuffer); - } - if (isJson) { - json = await JSON.parse(text || "{}"); - } - } catch (e) { - console.warn("Failed to parse response:", e); - // ignore - } - return { - status: result.status, - headers: headersObj, - text: text, - json: json, - arrayBuffer: arrayBuffer, - }; -} -export function stringifyYaml(obj: any) { - return JSON.stringify(obj); -} -export function parseYaml(s: string) { - return JSON.parse(s); -} -export function getLanguage() { - return "en"; -} -export function setIcon(el: HTMLElement, icon: string) {} -export function arrayBufferToBase64(buffer: ArrayBuffer): string { - return btoa(String.fromCharCode(...new Uint8Array(buffer))); -} -export function base64ToArrayBuffer(base64: string): ArrayBuffer { - return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)).buffer; -} - -export type DataWriteOptions = any; -export type PluginManifest = any; -export type RequestUrlParam = any; -export type RequestUrlResponse = any; -export type MarkdownFileInfo = any; -export type ListedFiles = { - files: string[]; - folders: string[]; -}; - -export type ValueComponent = any; diff --git a/test/harness/utils/intercept.ts b/test/harness/utils/intercept.ts deleted file mode 100644 index 098a924e..00000000 --- a/test/harness/utils/intercept.ts +++ /dev/null @@ -1,51 +0,0 @@ -export function interceptFetchForLogging() { - const originalFetch = globalThis.fetch; - globalThis.fetch = async (...params: any[]) => { - const paramObj = params[0]; - const initObj = params[1]; - const url = typeof paramObj === "string" ? paramObj : paramObj.url; - const method = initObj?.method || "GET"; - const headers = initObj?.headers || {}; - const body = initObj?.body || null; - const headersObj: Record = {}; - if (headers instanceof Headers) { - headers.forEach((value, key) => { - headersObj[key] = value; - }); - } - console.dir({ - mockedFetch: { - url, - method, - headers: headersObj, - }, - }); - try { - const res = await originalFetch.apply(globalThis, params as any); - console.log(`[Obsidian Mock] Fetch response: ${res.status} ${res.statusText} for ${method} ${url}`); - const resClone = res.clone(); - const contentType = resClone.headers.get("content-type") || ""; - const isJson = contentType.includes("application/json"); - if (isJson) { - const data = await resClone.json(); - console.dir({ mockedFetchResponseJson: data }); - } else { - const ab = await resClone.arrayBuffer(); - const text = new TextDecoder().decode(ab); - const isText = /^text\//.test(contentType); - if (isText) { - console.dir({ - mockedFetchResponseText: ab.byteLength < 1000 ? text : text.slice(0, 1000) + "...(truncated)", - }); - } else { - console.log(`[Obsidian Mock] Fetch response is of content-type ${contentType}, not logging body.`); - } - } - return res; - } catch (e) { - // console.error("[Obsidian Mock] Fetch error:", e); - console.error(`[Obsidian Mock] Fetch failed for ${method} ${url}, error:`, e); - throw e; - } - }; -} diff --git a/test/lib/commands.ts b/test/lib/commands.ts deleted file mode 100644 index 762b5c0c..00000000 --- a/test/lib/commands.ts +++ /dev/null @@ -1,165 +0,0 @@ -import type { P2PSyncSetting } from "@/lib/src/common/types"; -import { delay } from "octagonal-wheels/promises"; -import type { BrowserContext, Page } from "playwright"; -import type { Plugin } from "vitest/config"; -import type { BrowserCommand } from "vitest/node"; -import { serialized } from "octagonal-wheels/concurrency/lock"; -export const grantClipboardPermissions: BrowserCommand = async (ctx) => { - if (ctx.provider.name === "playwright") { - await ctx.context.grantPermissions(["clipboard-read", "clipboard-write"]); - console.log("Granted clipboard permissions"); - return; - } -}; -let peerPage: Page | undefined; -let peerPageContext: BrowserContext | undefined; -let previousName = ""; -async function setValue(page: Page, selector: string, value: string) { - const e = await page.waitForSelector(selector); - await e.fill(value); -} -async function closePeerContexts() { - const peerPageLocal = peerPage; - const peerPageContextLocal = peerPageContext; - if (peerPageLocal) { - await peerPageLocal.close(); - } - if (peerPageContextLocal) { - await peerPageContextLocal.close(); - } -} -export const openWebPeer: BrowserCommand<[P2PSyncSetting, serverPeerName: string]> = async ( - ctx, - setting: P2PSyncSetting, - serverPeerName: string = "p2p-livesync-web-peer" -) => { - if (ctx.provider.name === "playwright") { - const previousPage = ctx.page; - if (peerPage !== undefined) { - if (previousName === serverPeerName) { - console.log(`WebPeer for ${serverPeerName} already opened`); - return; - } - console.log(`Closing previous WebPeer for ${previousName}`); - await closePeerContexts(); - } - console.log(`Opening webPeer`); - return serialized("webpeer", async () => { - const browser = ctx.context.browser()!; - const context = await browser.newContext(); - peerPageContext = context; - peerPage = await context.newPage(); - previousName = serverPeerName; - console.log(`Navigating...`); - await peerPage.goto("http://localhost:8081"); - await peerPage.waitForLoadState(); - console.log(`Navigated!`); - await setValue(peerPage, "#app > main [placeholder*=wss]", setting.P2P_relays); - await setValue(peerPage, "#app > main [placeholder*=anything]", setting.P2P_roomID); - await setValue(peerPage, "#app > main [placeholder*=password]", setting.P2P_passphrase); - await setValue(peerPage, "#app > main [placeholder*=iphone]", serverPeerName); - // await peerPage.getByTitle("Enable P2P Replicator").setChecked(true); - await peerPage.getByRole("checkbox").first().setChecked(true); - // (await peerPage.waitForSelector("Save and Apply")).click(); - await peerPage.getByText("Save and Apply").click(); - await delay(100); - await peerPage.reload(); - await delay(500); - for (let i = 0; i < 10; i++) { - await delay(100); - const btn = peerPage.getByRole("button").filter({ hasText: /^connect/i }); - if ((await peerPage.getByText(/disconnect/i).count()) > 0) { - break; - } - await btn.click(); - } - await previousPage.bringToFront(); - ctx.context.on("close", async () => { - console.log("Browser context is closing, closing peer page if exists"); - await closePeerContexts(); - }); - console.log("Web peer page opened"); - }); - } -}; - -export const closeWebPeer: BrowserCommand = async (ctx) => { - if (ctx.provider.name === "playwright") { - return serialized("webpeer", async () => { - await closePeerContexts(); - peerPage = undefined; - peerPageContext = undefined; - previousName = ""; - console.log("Web peer page closed"); - }); - } -}; -export const acceptWebPeer: BrowserCommand = async (ctx) => { - if (peerPage) { - // Detect dialogue - const buttonsOnDialogs = await peerPage.$$("popup .buttons button"); - for (const b of buttonsOnDialogs) { - const text = (await b.innerText()).toLowerCase(); - // console.log(`Dialog button found: ${text}`); - if (text === "accept") { - console.log("Accepting dialog"); - await b.click({ timeout: 300 }); - await delay(500); - } - } - const buttons = peerPage.getByRole("button").filter({ hasText: /^accept$/i }); - const a = await buttons.all(); - for (const b of a) { - await b.click({ timeout: 300 }); - } - } - return false; -}; - -/** Write arbitrary text to a file on the Node.js host (used for phase handoff). */ -export const writeHandoffFile: BrowserCommand<[filePath: string, content: string]> = async ( - _ctx, - filePath: string, - content: string -) => { - const fs = await import("node:fs/promises"); - await fs.writeFile(filePath, content, "utf-8"); -}; - -/** Read a file from the Node.js host (used for phase handoff). */ -export const readHandoffFile: BrowserCommand<[filePath: string]> = async (_ctx, filePath: string): Promise => { - const fs = await import("node:fs/promises"); - return fs.readFile(filePath, "utf-8"); -}; - -export default function BrowserCommands(): Plugin { - return { - name: "vitest:custom-commands", - config() { - return { - test: { - browser: { - commands: { - grantClipboardPermissions, - openWebPeer, - closeWebPeer, - acceptWebPeer, - writeHandoffFile, - readHandoffFile, - }, - }, - }, - }; - }, - }; -} -declare module "vitest/browser" { - interface BrowserCommands { - grantClipboardPermissions: () => Promise; - openWebPeer: (setting: P2PSyncSetting, serverPeerName: string) => Promise; - closeWebPeer: () => Promise; - acceptWebPeer: () => Promise; - writeHandoffFile: (filePath: string, content: string) => Promise; - readHandoffFile: (filePath: string) => Promise; - } -} diff --git a/test/lib/ui.ts b/test/lib/ui.ts deleted file mode 100644 index 3d2381a6..00000000 --- a/test/lib/ui.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { page } from "vitest/browser"; -import { delay } from "@/lib/src/common/utils"; - -export async function waitForDialogShown(dialogText: string, timeout = 500) { - const ttl = Date.now() + timeout; - while (Date.now() < ttl) { - try { - await delay(50); - const dialog = page - .getByText(dialogText) - .elements() - .filter((e) => e.classList.contains("modal-title")) - .filter((e) => e.checkVisibility()); - if (dialog.length === 0) { - continue; - } - return true; - } catch (e) { - // Ignore - } - } - return false; -} -export async function waitForDialogHidden(dialogText: string | RegExp, timeout = 500) { - const ttl = Date.now() + timeout; - while (Date.now() < ttl) { - try { - await delay(50); - const dialog = page - .getByText(dialogText) - .elements() - .filter((e) => e.classList.contains("modal-title")) - .filter((e) => e.checkVisibility()); - if (dialog.length > 0) { - // console.log(`Still exist ${dialogText.toString()}`); - continue; - } - return true; - } catch (e) { - // Ignore - } - } - return false; -} - -export async function waitForButtonClick(buttonText: string | RegExp, timeout = 500) { - const ttl = Date.now() + timeout; - while (Date.now() < ttl) { - try { - await delay(100); - const buttons = page - .getByText(buttonText) - .elements() - .filter((e) => e.checkVisibility() && e.tagName.toLowerCase() == "button"); - if (buttons.length == 0) { - // console.log(`Could not found ${buttonText.toString()}`); - continue; - } - console.log(`Button detected: ${buttonText.toString()}`); - // console.dir(buttons[0]) - await page.elementLocator(buttons[0]).click(); - await delay(100); - return true; - } catch (e) { - console.error(e); - // Ignore - } - } - return false; -} diff --git a/test/lib/util.ts b/test/lib/util.ts deleted file mode 100644 index 502d0d2c..00000000 --- a/test/lib/util.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { delay } from "@/lib/src/common/utils"; - -export async function waitTaskWithFollowups( - task: Promise, - followup: () => Promise, - timeout: number = 10000, - interval: number = 1000 -): Promise { - const symbolNotCompleted = Symbol("notCompleted"); - const isCompleted = () => Promise.race([task, Promise.resolve(symbolNotCompleted)]); - const ttl = Date.now() + timeout; - do { - const state = await isCompleted(); - if (state !== symbolNotCompleted) { - return state; - } - await followup(); - await delay(interval); - } while (Date.now() < ttl); - throw new Error("Task did not complete in time"); -} diff --git a/test/shell/p2p-init.sh b/test/shell/p2p-init.sh deleted file mode 100755 index dd865c9c..00000000 --- a/test/shell/p2p-init.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/bash -echo "P2P Init - No additional initialization required." \ No newline at end of file diff --git a/test/shell/p2p-start.sh b/test/shell/p2p-start.sh deleted file mode 100755 index 8c86a45c..00000000 --- a/test/shell/p2p-start.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/bash -set -e -script_dir=$(dirname "$0") -webpeer_dir=$script_dir/../../src/apps/webpeer - -docker run -d --name relay-test -p 4000:7777 \ - --tmpfs /app/strfry-db:rw,size=256m \ - --entrypoint sh \ - ghcr.io/hoytech/strfry:latest \ - -lc 'cat > /tmp/strfry.conf <<"EOF" -db = "./strfry-db/" - -relay { - bind = "0.0.0.0" - port = 7777 - nofiles = 100000 - - info { - name = "livesync test relay" - description = "local relay for livesync p2p tests" - } - - maxWebsocketPayloadSize = 131072 - autoPingSeconds = 55 - - writePolicy { - plugin = "" - } -} -EOF -exec /app/strfry --config /tmp/strfry.conf relay' -npm run --prefix $webpeer_dir build -docker run -d --name webpeer-test -p 8081:8043 -v $webpeer_dir/dist:/srv/http pierrezemb/gostatic \ No newline at end of file diff --git a/test/shell/p2p-stop.sh b/test/shell/p2p-stop.sh deleted file mode 100755 index 22925ad4..00000000 --- a/test/shell/p2p-stop.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash -docker stop relay-test -docker rm relay-test -docker stop webpeer-test -docker rm webpeer-test \ No newline at end of file diff --git a/test/suite/db_common.ts b/test/suite/db_common.ts deleted file mode 100644 index f81f084f..00000000 --- a/test/suite/db_common.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { compareMTime, EVEN } from "@/common/utils"; -import { TFile, type DataWriteOptions } from "@/deps"; -import type { FilePath } from "@/lib/src/common/types"; -import { isDocContentSame, readContent } from "@/lib/src/common/utils"; -import { waitForIdle, type LiveSyncHarness } from "../harness/harness"; -import { expect } from "vitest"; - -export const defaultFileOption = { - mtime: new Date(2026, 0, 1, 0, 1, 2, 3).getTime(), -} as const satisfies DataWriteOptions; -export async function storeFile( - harness: LiveSyncHarness, - path: string, - content: string | Blob, - deleteBeforeSend = false, - fileOptions = defaultFileOption -) { - if (deleteBeforeSend && harness.app.vault.getAbstractFileByPath(path)) { - console.log(`Deleting existing file ${path}`); - await harness.app.vault.delete(harness.app.vault.getAbstractFileByPath(path) as TFile); - } - // Create file via vault - if (content instanceof Blob) { - console.log(`Creating binary file ${path}`); - await harness.app.vault.createBinary(path, await content.arrayBuffer(), fileOptions); - } else { - await harness.app.vault.create(path, content, fileOptions); - } - - // Ensure file is created - const file = harness.app.vault.getAbstractFileByPath(path); - expect(file).toBeInstanceOf(TFile); - if (file instanceof TFile) { - expect(compareMTime(file.stat.mtime, fileOptions?.mtime ?? defaultFileOption.mtime)).toBe(EVEN); - if (content instanceof Blob) { - const readContent = await harness.app.vault.readBinary(file); - expect(await isDocContentSame(readContent, content)).toBe(true); - } else { - const readContent = await harness.app.vault.read(file); - expect(readContent).toBe(content); - } - } - await harness.plugin.core.services.fileProcessing.commitPendingFileEvents(); - await waitForIdle(harness); - return file; -} -export async function readFromLocalDB(harness: LiveSyncHarness, path: string) { - const entry = await harness.plugin.core.localDatabase.getDBEntry(path as FilePath); - expect(entry).not.toBe(false); - return entry; -} -export async function readFromVault( - harness: LiveSyncHarness, - path: string, - isBinary: boolean = false, - fileOptions = defaultFileOption -): Promise { - const file = harness.app.vault.getAbstractFileByPath(path); - expect(file).toBeInstanceOf(TFile); - if (file instanceof TFile) { - // console.log(`MTime: ${file.stat.mtime}, Expected: ${fileOptions.mtime}`); - if (fileOptions.mtime !== undefined) { - expect(compareMTime(file.stat.mtime, fileOptions.mtime)).toBe(EVEN); - } - const content = isBinary ? await harness.app.vault.readBinary(file) : await harness.app.vault.read(file); - return content; - } - - throw new Error("File not found in vault"); -} -export async function checkStoredFileInDB( - harness: LiveSyncHarness, - path: string, - content: string | Blob, - fileOptions = defaultFileOption -) { - const entry = await readFromLocalDB(harness, path); - if (entry === false) { - throw new Error("DB Content not found"); - } - const contentToCheck = content instanceof Blob ? await content.arrayBuffer() : content; - const isDocSame = await isDocContentSame(readContent(entry), contentToCheck); - if (fileOptions.mtime !== undefined) { - expect(compareMTime(entry.mtime, fileOptions.mtime)).toBe(EVEN); - } - expect(isDocSame).toBe(true); - return Promise.resolve(); -} -export async function testFileWrite( - harness: LiveSyncHarness, - path: string, - content: string | Blob, - skipCheckToBeWritten = false, - fileOptions = defaultFileOption -) { - const file = await storeFile(harness, path, content, false, fileOptions); - expect(file).toBeInstanceOf(TFile); - await harness.plugin.core.services.fileProcessing.commitPendingFileEvents(); - await waitForIdle(harness); - const vaultFile = await readFromVault(harness, path, content instanceof Blob, fileOptions); - expect(await isDocContentSame(vaultFile, content)).toBe(true); - await harness.plugin.core.services.fileProcessing.commitPendingFileEvents(); - await waitForIdle(harness); - if (skipCheckToBeWritten) { - return Promise.resolve(); - } - await checkStoredFileInDB(harness, path, content); - return Promise.resolve(); -} -export async function testFileRead( - harness: LiveSyncHarness, - path: string, - expectedContent: string | Blob, - fileOptions = defaultFileOption -) { - await waitForIdle(harness); - const file = await readFromVault(harness, path, expectedContent instanceof Blob, fileOptions); - const isDocSame = await isDocContentSame(file, expectedContent); - expect(isDocSame).toBe(true); - // Check local database entry - const entry = await readFromLocalDB(harness, path); - expect(entry).not.toBe(false); - if (entry === false) { - throw new Error("DB Content not found"); - } - const isDBDocSame = await isDocContentSame(readContent(entry), expectedContent); - expect(isDBDocSame).toBe(true); - return await Promise.resolve(); -} diff --git a/test/suite/onlylocaldb.test.ts b/test/suite/onlylocaldb.test.ts deleted file mode 100644 index acfbb65b..00000000 --- a/test/suite/onlylocaldb.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { beforeAll, describe, expect, it, test } from "vitest"; -import { generateHarness, waitForIdle, waitForReady, type LiveSyncHarness } from "../harness/harness"; -import { TFile } from "@/deps.ts"; -import { DEFAULT_SETTINGS, type FilePath, type ObsidianLiveSyncSettings } from "@/lib/src/common/types"; -import { isDocContentSame, readContent } from "@/lib/src/common/utils"; -import { DummyFileSourceInisialised, generateBinaryFile, generateFile, init } from "../utils/dummyfile"; - -const localdb_test_setting = { - ...DEFAULT_SETTINGS, - isConfigured: true, - handleFilenameCaseSensitive: false, -} as ObsidianLiveSyncSettings; - -describe.skip("Plugin Integration Test (Local Database)", async () => { - let harness: LiveSyncHarness; - const vaultName = "TestVault" + Date.now(); - - beforeAll(async () => { - await DummyFileSourceInisialised; - harness = await generateHarness(vaultName, localdb_test_setting); - await waitForReady(harness); - }); - - it("should be instantiated and defined", async () => { - expect(harness.plugin).toBeDefined(); - expect(harness.plugin.app).toBe(harness.app); - return await Promise.resolve(); - }); - - it("should have services initialized", async () => { - expect(harness.plugin.core.services).toBeDefined(); - return await Promise.resolve(); - }); - it("should have local database initialized", async () => { - expect(harness.plugin.core.localDatabase).toBeDefined(); - expect(harness.plugin.core.localDatabase.isReady).toBe(true); - return await Promise.resolve(); - }); - - it("should store the changes into the local database", async () => { - const path = "test-store6.md"; - const content = "Hello, World!"; - if (harness.app.vault.getAbstractFileByPath(path)) { - console.log(`Deleting existing file ${path}`); - await harness.app.vault.delete(harness.app.vault.getAbstractFileByPath(path) as TFile); - } - // Create file via vault - await harness.app.vault.create(path, content); - - const file = harness.app.vault.getAbstractFileByPath(path); - expect(file).toBeInstanceOf(TFile); - - if (file instanceof TFile) { - const readContent = await harness.app.vault.read(file); - expect(readContent).toBe(content); - } - await harness.plugin.core.services.fileProcessing.commitPendingFileEvents(); - await waitForIdle(harness); - // await delay(100); // Wait a bit for the local database to process - - const entry = await harness.plugin.core.localDatabase.getDBEntry(path as FilePath); - expect(entry).not.toBe(false); - if (entry) { - expect(readContent(entry)).toBe(content); - } - return await Promise.resolve(); - }); - test.each([10, 100, 1000, 10000, 50000, 100000])("should handle large file of size %i bytes", async (size) => { - const path = `test-large-file-${size}.md`; - const content = Array.from(generateFile(size)).join(""); - if (harness.app.vault.getAbstractFileByPath(path)) { - console.log(`Deleting existing file ${path}`); - await harness.app.vault.delete(harness.app.vault.getAbstractFileByPath(path) as TFile); - } - // Create file via vault - await harness.app.vault.create(path, content); - const file = harness.app.vault.getAbstractFileByPath(path); - expect(file).toBeInstanceOf(TFile); - if (file instanceof TFile) { - const readContent = await harness.app.vault.read(file); - expect(readContent).toBe(content); - } - await harness.plugin.core.services.fileProcessing.commitPendingFileEvents(); - await waitForIdle(harness); - - const entry = await harness.plugin.core.localDatabase.getDBEntry(path as FilePath); - expect(entry).not.toBe(false); - if (entry) { - expect(readContent(entry)).toBe(content); - } - return await Promise.resolve(); - }); - - const binaryMap = Array.from({ length: 7 }, (_, i) => Math.pow(2, i * 4)); - test.each(binaryMap)("should handle binary file of size %i bytes", async (size) => { - const path = `test-binary-file-${size}.bin`; - const content = new Blob([...generateBinaryFile(size)], { type: "application/octet-stream" }); - if (harness.app.vault.getAbstractFileByPath(path)) { - console.log(`Deleting existing file ${path}`); - await harness.app.vault.delete(harness.app.vault.getAbstractFileByPath(path) as TFile); - } - // Create file via vault - await harness.app.vault.createBinary(path, await content.arrayBuffer()); - const file = harness.app.vault.getAbstractFileByPath(path); - expect(file).toBeInstanceOf(TFile); - if (file instanceof TFile) { - const readContent = await harness.app.vault.readBinary(file); - expect(await isDocContentSame(readContent, content)).toBe(true); - } - - await harness.plugin.core.services.fileProcessing.commitPendingFileEvents(); - await waitForIdle(harness); - const entry = await harness.plugin.core.localDatabase.getDBEntry(path as FilePath); - expect(entry).not.toBe(false); - if (entry) { - const entryContent = await readContent(entry); - if (!(entryContent instanceof ArrayBuffer)) { - throw new Error("Entry content is not an ArrayBuffer"); - } - // const expectedContent = await content.arrayBuffer(); - expect(await isDocContentSame(entryContent, content)).toBe(true); - } - return await Promise.resolve(); - }); -}); diff --git a/test/suite/sync.senario.basic.ts b/test/suite/sync.senario.basic.ts deleted file mode 100644 index 3eafe3cb..00000000 --- a/test/suite/sync.senario.basic.ts +++ /dev/null @@ -1,275 +0,0 @@ -// Functional Test on Main Cases -// This test suite only covers main functional cases of synchronisation. Event handling, error cases, -// and edge, resolving conflicts, etc. will be covered in separate test suites. -import { afterAll, beforeAll, describe, expect, it, test } from "vitest"; -import { generateHarness, waitForIdle, waitForReady, type LiveSyncHarness } from "../harness/harness"; -import { RemoteTypes, type FilePath, type ObsidianLiveSyncSettings } from "@/lib/src/common/types"; - -import { - DummyFileSourceInisialised, - FILE_SIZE_BINS, - FILE_SIZE_MD, - generateBinaryFile, - generateFile, -} from "../utils/dummyfile"; -import { checkStoredFileInDB, testFileRead, testFileWrite } from "./db_common"; -import { delay } from "@/lib/src/common/utils"; -import { commands } from "vitest/browser"; -import { closeReplication, performReplication, prepareRemote } from "./sync_common"; -import type { DataWriteOptions } from "@/deps.ts"; - -type MTimedDataWriteOptions = DataWriteOptions & { mtime: number }; -export type TestOptions = { - setting: ObsidianLiveSyncSettings; - fileOptions: MTimedDataWriteOptions; -}; -function generateName(prefix: string, type: string, ext: string, size: number) { - return `${prefix}-${type}-file-${size}.${ext}`; -} -export function syncBasicCase(label: string, { setting, fileOptions }: TestOptions) { - describe("Replication Suite Tests - " + label, () => { - const nameFile = (type: string, ext: string, size: number) => generateName("sync-test", type, ext, size); - let serverPeerName = ""; - // TODO: Harness disposal may broke the event loop of P2P replication - // so we keep the harnesses alive until all tests are done. - // It may trystero's somethong, or not. - let harnessUpload: LiveSyncHarness; - let harnessDownload: LiveSyncHarness; - beforeAll(async () => { - await DummyFileSourceInisialised; - if (setting.remoteType === RemoteTypes.REMOTE_P2P) { - // await commands.closeWebPeer(); - serverPeerName = "t-" + Date.now(); - setting.P2P_AutoAcceptingPeers = serverPeerName; - setting.P2P_AutoSyncPeers = serverPeerName; - setting.P2P_DevicePeerName = "client-" + Date.now(); - await commands.openWebPeer(setting, serverPeerName); - } - }); - afterAll(async () => { - if (setting.remoteType === RemoteTypes.REMOTE_P2P) { - await commands.closeWebPeer(); - // await closeP2PReplicatorConnections(harnessUpload); - } - }); - - describe("Remote Database Initialization", () => { - let harnessInit: LiveSyncHarness; - const sync_test_setting_init = { - ...setting, - } as ObsidianLiveSyncSettings; - beforeAll(async () => { - const vaultName = "TestVault" + Date.now(); - console.log(`BeforeAll - Remote Database Initialization - Vault: ${vaultName}`); - harnessInit = await generateHarness(vaultName, sync_test_setting_init); - await waitForReady(harnessInit); - expect(harnessInit.plugin).toBeDefined(); - expect(harnessInit.plugin.app).toBe(harnessInit.app); - await waitForIdle(harnessInit); - }); - afterAll(async () => { - await harnessInit.plugin.core.services.replicator.getActiveReplicator()?.closeReplication(); - await harnessInit.dispose(); - await delay(1000); - }); - - it("should reset remote database", async () => { - // harnessInit = await generateHarness(vaultName, sync_test_setting_init); - await waitForReady(harnessInit); - await prepareRemote(harnessInit, sync_test_setting_init, true); - }); - it("should be prepared for replication", async () => { - await waitForReady(harnessInit); - if (setting.remoteType !== RemoteTypes.REMOTE_P2P) { - const status = await harnessInit.plugin.core.services.replicator - .getActiveReplicator() - ?.getRemoteStatus(sync_test_setting_init); - console.log("Connected devices after reset:", status); - expect(status).not.toBeFalsy(); - } - }); - }); - - describe("Replication - Upload", () => { - const sync_test_setting_upload = { - ...setting, - } as ObsidianLiveSyncSettings; - - beforeAll(async () => { - const vaultName = "TestVault" + Date.now(); - console.log(`BeforeAll - Replication Upload - Vault: ${vaultName}`); - if (setting.remoteType === RemoteTypes.REMOTE_P2P) { - sync_test_setting_upload.P2P_AutoAcceptingPeers = serverPeerName; - sync_test_setting_upload.P2P_AutoSyncPeers = serverPeerName; - sync_test_setting_upload.P2P_DevicePeerName = "up-" + Date.now(); - } - harnessUpload = await generateHarness(vaultName, sync_test_setting_upload); - await waitForReady(harnessUpload); - expect(harnessUpload.plugin).toBeDefined(); - expect(harnessUpload.plugin.app).toBe(harnessUpload.app); - await waitForIdle(harnessUpload); - }); - - afterAll(async () => { - await closeReplication(harnessUpload); - }); - - it("should be instantiated and defined", () => { - expect(harnessUpload.plugin).toBeDefined(); - expect(harnessUpload.plugin.app).toBe(harnessUpload.app); - }); - - it("should have services initialized", () => { - expect(harnessUpload.plugin.core.services).toBeDefined(); - }); - - it("should have local database initialized", () => { - expect(harnessUpload.plugin.core.localDatabase).toBeDefined(); - expect(harnessUpload.plugin.core.localDatabase.isReady).toBe(true); - }); - - it("should prepare remote database", async () => { - await prepareRemote(harnessUpload, sync_test_setting_upload, false); - }); - - // describe("File Creation", async () => { - it("should a file has been created", async () => { - const content = "Hello, World!"; - const path = nameFile("store", "md", 0); - await testFileWrite(harnessUpload, path, content, false, fileOptions); - // Perform replication - // await harness.plugin.core.services.replication.replicate(true); - }); - it("should different content of several files have been created correctly", async () => { - await testFileWrite(harnessUpload, nameFile("test-diff-1", "md", 0), "Content A", false, fileOptions); - await testFileWrite(harnessUpload, nameFile("test-diff-2", "md", 0), "Content B", false, fileOptions); - await testFileWrite(harnessUpload, nameFile("test-diff-3", "md", 0), "Content C", false, fileOptions); - }); - - test.each(FILE_SIZE_MD)("should large file of size %i bytes has been created", async (size) => { - const content = Array.from(generateFile(size)).join(""); - const path = nameFile("large", "md", size); - const isTooLarge = harnessUpload.plugin.core.services.vault.isFileSizeTooLarge(size); - if (isTooLarge) { - console.log(`Skipping file of size ${size} bytes as it is too large to sync.`); - expect(true).toBe(true); - } else { - await testFileWrite(harnessUpload, path, content, false, fileOptions); - } - }); - - test.each(FILE_SIZE_BINS)("should binary file of size %i bytes has been created", async (size) => { - const content = new Blob([...generateBinaryFile(size)], { type: "application/octet-stream" }); - const path = nameFile("binary", "bin", size); - await testFileWrite(harnessUpload, path, content, true, fileOptions); - const isTooLarge = harnessUpload.plugin.core.services.vault.isFileSizeTooLarge(size); - if (isTooLarge) { - console.log(`Skipping file of size ${size} bytes as it is too large to sync.`); - expect(true).toBe(true); - } else { - await checkStoredFileInDB(harnessUpload, path, content, fileOptions); - } - }); - - it("Replication after uploads", async () => { - await performReplication(harnessUpload); - await performReplication(harnessUpload); - }); - }); - - describe("Replication - Download", () => { - // Download into a new vault - const sync_test_setting_download = { - ...setting, - } as ObsidianLiveSyncSettings; - beforeAll(async () => { - const vaultName = "TestVault" + Date.now(); - console.log(`BeforeAll - Replication Download - Vault: ${vaultName}`); - if (setting.remoteType === RemoteTypes.REMOTE_P2P) { - sync_test_setting_download.P2P_AutoAcceptingPeers = serverPeerName; - sync_test_setting_download.P2P_AutoSyncPeers = serverPeerName; - sync_test_setting_download.P2P_DevicePeerName = "down-" + Date.now(); - } - harnessDownload = await generateHarness(vaultName, sync_test_setting_download); - await waitForReady(harnessDownload); - await prepareRemote(harnessDownload, sync_test_setting_download, false); - - await performReplication(harnessDownload); - await waitForIdle(harnessDownload); - await delay(1000); - await performReplication(harnessDownload); - await waitForIdle(harnessDownload); - }); - afterAll(async () => { - await closeReplication(harnessDownload); - }); - - it("should be instantiated and defined", () => { - expect(harnessDownload.plugin).toBeDefined(); - expect(harnessDownload.plugin.app).toBe(harnessDownload.app); - }); - - it("should have services initialized", () => { - expect(harnessDownload.plugin.core.services).toBeDefined(); - }); - - it("should have local database initialized", () => { - expect(harnessDownload.plugin.core.localDatabase).toBeDefined(); - expect(harnessDownload.plugin.core.localDatabase.isReady).toBe(true); - }); - - it("should a file has been synchronised", async () => { - const expectedContent = "Hello, World!"; - const path = nameFile("store", "md", 0); - await testFileRead(harnessDownload, path, expectedContent, fileOptions); - }); - it("should different content of several files have been synchronised", async () => { - await testFileRead(harnessDownload, nameFile("test-diff-1", "md", 0), "Content A", fileOptions); - await testFileRead(harnessDownload, nameFile("test-diff-2", "md", 0), "Content B", fileOptions); - await testFileRead(harnessDownload, nameFile("test-diff-3", "md", 0), "Content C", fileOptions); - }); - - test.each(FILE_SIZE_MD)("should the file %i bytes had been synchronised", async (size) => { - const content = Array.from(generateFile(size)).join(""); - const path = nameFile("large", "md", size); - const isTooLarge = harnessDownload.plugin.core.services.vault.isFileSizeTooLarge(size); - if (isTooLarge) { - const entry = await harnessDownload.plugin.core.localDatabase.getDBEntry(path as FilePath); - console.log(`Skipping file of size ${size} bytes as it is too large to sync.`); - expect(entry).toBe(false); - } else { - await testFileRead(harnessDownload, path, content, fileOptions); - } - }); - - test.each(FILE_SIZE_BINS)("should binary file of size %i bytes had been synchronised", async (size) => { - const path = nameFile("binary", "bin", size); - - const isTooLarge = harnessDownload.plugin.core.services.vault.isFileSizeTooLarge(size); - if (isTooLarge) { - const entry = await harnessDownload.plugin.core.localDatabase.getDBEntry(path as FilePath); - console.log(`Skipping file of size ${size} bytes as it is too large to sync.`); - expect(entry).toBe(false); - } else { - const content = new Blob([...generateBinaryFile(size)], { type: "application/octet-stream" }); - await testFileRead(harnessDownload, path, content, fileOptions); - } - }); - }); - afterAll(async () => { - if (harnessDownload) { - await closeReplication(harnessDownload); - await harnessDownload.dispose(); - await delay(1000); - } - if (harnessUpload) { - await closeReplication(harnessUpload); - await harnessUpload.dispose(); - await delay(1000); - } - }); - it("Wait for idle state", async () => { - await delay(100); - }); - }); -} diff --git a/test/suite/sync.single.test.ts b/test/suite/sync.single.test.ts deleted file mode 100644 index 9be98b44..00000000 --- a/test/suite/sync.single.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Functional Test on Main Cases -// This test suite only covers main functional cases of synchronisation. Event handling, error cases, -// and edge, resolving conflicts, etc. will be covered in separate test suites. -import { describe } from "vitest"; -import { - PREFERRED_JOURNAL_SYNC, - PREFERRED_SETTING_SELF_HOSTED, - RemoteTypes, - type ObsidianLiveSyncSettings, -} from "@/lib/src/common/types"; - -import { defaultFileOption } from "./db_common"; -import { syncBasicCase } from "./sync.senario.basic.ts"; -import { settingBase } from "./variables.ts"; -const sync_test_setting_base = settingBase; -export const env = (import.meta as any).env; -function* generateCase() { - const passpharse = "thetest-Passphrase3+9-for-e2ee!"; - const REMOTE_RECOMMENDED = { - [RemoteTypes.REMOTE_COUCHDB]: PREFERRED_SETTING_SELF_HOSTED, - [RemoteTypes.REMOTE_MINIO]: PREFERRED_JOURNAL_SYNC, - [RemoteTypes.REMOTE_P2P]: PREFERRED_SETTING_SELF_HOSTED, - }; - const remoteTypes = [RemoteTypes.REMOTE_COUCHDB]; - // const remoteTypes = [RemoteTypes.REMOTE_P2P]; - const e2eeOptions = [false]; - // const e2eeOptions = [true]; - for (const remoteType of remoteTypes) { - for (const useE2EE of e2eeOptions) { - yield { - setting: { - ...sync_test_setting_base, - ...REMOTE_RECOMMENDED[remoteType], - remoteType, - encrypt: useE2EE, - passphrase: useE2EE ? passpharse : "", - usePathObfuscation: useE2EE, - } as ObsidianLiveSyncSettings, - }; - } - } -} - -describe.skip("Replication Suite Tests (Single)", async () => { - const cases = Array.from(generateCase()); - const fileOptions = defaultFileOption; - describe.each(cases)("Replication Tests - Remote: $setting.remoteType, E2EE: $setting.encrypt", ({ setting }) => { - syncBasicCase(`Remote: ${setting.remoteType}, E2EE: ${setting.encrypt}`, { setting, fileOptions }); - }); -}); diff --git a/test/suite/sync.test.ts b/test/suite/sync.test.ts deleted file mode 100644 index aa284c17..00000000 --- a/test/suite/sync.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Functional Test on Main Cases -// This test suite only covers main functional cases of synchronisation. Event handling, error cases, -// and edge, resolving conflicts, etc. will be covered in separate test suites. -import { describe } from "vitest"; -import { - PREFERRED_JOURNAL_SYNC, - PREFERRED_SETTING_SELF_HOSTED, - RemoteTypes, - type ObsidianLiveSyncSettings, -} from "@/lib/src/common/types"; - -import { defaultFileOption } from "./db_common"; -import { syncBasicCase } from "./sync.senario.basic.ts"; -import { settingBase } from "./variables.ts"; -const sync_test_setting_base = settingBase; -export const env = (import.meta as any).env; -function* generateCase() { - const passpharse = "thetest-Passphrase3+9-for-e2ee!"; - const REMOTE_RECOMMENDED = { - [RemoteTypes.REMOTE_COUCHDB]: PREFERRED_SETTING_SELF_HOSTED, - [RemoteTypes.REMOTE_MINIO]: PREFERRED_JOURNAL_SYNC, - [RemoteTypes.REMOTE_P2P]: PREFERRED_SETTING_SELF_HOSTED, - }; - const remoteTypes = [RemoteTypes.REMOTE_COUCHDB, RemoteTypes.REMOTE_MINIO]; - // const remoteTypes = [RemoteTypes.REMOTE_P2P]; - const e2eeOptions = [false, true]; - // const e2eeOptions = [true]; - for (const remoteType of remoteTypes) { - for (const useE2EE of e2eeOptions) { - yield { - setting: { - ...sync_test_setting_base, - ...REMOTE_RECOMMENDED[remoteType], - remoteType, - encrypt: useE2EE, - passphrase: useE2EE ? passpharse : "", - usePathObfuscation: useE2EE, - } as ObsidianLiveSyncSettings, - }; - } - } -} - -describe("Replication Suite Tests (Normal)", async () => { - const cases = Array.from(generateCase()); - const fileOptions = defaultFileOption; - describe.each(cases)("Replication Tests - Remote: $setting.remoteType, E2EE: $setting.encrypt", ({ setting }) => { - syncBasicCase(`Remote: ${setting.remoteType}, E2EE: ${setting.encrypt}`, { setting, fileOptions }); - }); -}); diff --git a/test/suite/sync_common.ts b/test/suite/sync_common.ts deleted file mode 100644 index 74da8664..00000000 --- a/test/suite/sync_common.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { expect } from "vitest"; -import { waitForIdle, type LiveSyncHarness } from "../harness/harness"; -import { RemoteTypes, type ObsidianLiveSyncSettings } from "@/lib/src/common/types"; - -import { delay, fireAndForget } from "@/lib/src/common/utils"; -import { commands } from "vitest/browser"; -import { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator"; -import { waitTaskWithFollowups } from "../lib/util"; -async function waitForP2PPeers(harness: LiveSyncHarness) { - if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) { - // Wait for peers to connect - const maxRetries = 20; - let retries = maxRetries; - const replicator = await harness.plugin.core.services.replicator.getActiveReplicator(); - if (!(replicator instanceof LiveSyncTrysteroReplicator)) { - throw new Error("Replicator is not an instance of LiveSyncTrysteroReplicator"); - } - while (retries-- > 0) { - fireAndForget(() => commands.acceptWebPeer()); - await delay(1000); - const peers = replicator.knownAdvertisements; - - if (peers && peers.length > 0) { - console.log("P2P peers connected:", peers); - return; - } - fireAndForget(() => commands.acceptWebPeer()); - console.log(`Waiting for any P2P peers to be connected... ${maxRetries - retries}/${maxRetries}`); - console.dir(peers); - await delay(1000); - } - console.log("Failed to connect P2P peers after retries"); - throw new Error("P2P peers did not connect in time."); - } -} -export async function closeP2PReplicatorConnections(harness: LiveSyncHarness) { - if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) { - const replicator = await harness.plugin.core.services.replicator.getActiveReplicator(); - if (!(replicator instanceof LiveSyncTrysteroReplicator)) { - throw new Error("Replicator is not an instance of LiveSyncTrysteroReplicator"); - } - replicator.closeReplication(); - await delay(30); - replicator.closeReplication(); - await delay(1000); - console.log("P2P replicator connections closed"); - // if (replicator instanceof LiveSyncTrysteroReplicator) { - // replicator.closeReplication(); - // await delay(1000); - // } - } -} - -export async function performReplication(harness: LiveSyncHarness) { - await waitForP2PPeers(harness); - await delay(500); - const p = harness.plugin.core.services.replication.replicate(true); - const task = - harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P - ? waitTaskWithFollowups( - p, - () => { - // Accept any peer dialogs during replication (fire and forget) - fireAndForget(() => commands.acceptWebPeer()); - return Promise.resolve(); - }, - 30000, - 500 - ) - : p; - const result = await task; - // await waitForIdle(harness); - // if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) { - // await closeP2PReplicatorConnections(harness); - // } - return result; -} - -export async function closeReplication(harness: LiveSyncHarness) { - if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) { - return await closeP2PReplicatorConnections(harness); - } - const replicator = await harness.plugin.core.services.replicator.getActiveReplicator(); - if (!replicator) { - console.log("No active replicator to close"); - return; - } - await replicator.closeReplication(); - await waitForIdle(harness); - console.log("Replication closed"); -} - -export async function prepareRemote(harness: LiveSyncHarness, setting: ObsidianLiveSyncSettings, shouldReset = false) { - if (setting.remoteType !== RemoteTypes.REMOTE_P2P) { - if (shouldReset) { - await delay(1000); - await harness.plugin.core.services.replicator - .getActiveReplicator() - ?.tryResetRemoteDatabase(harness.plugin.core.settings); - } else { - await harness.plugin.core.services.replicator - .getActiveReplicator() - ?.tryCreateRemoteDatabase(harness.plugin.core.settings); - } - await harness.plugin.core.services.replicator - .getActiveReplicator() - ?.markRemoteResolved(harness.plugin.core.settings); - // No exceptions should be thrown - const status = await harness.plugin.core.services.replicator - .getActiveReplicator() - ?.getRemoteStatus(harness.plugin.core.settings); - console.log("Remote status:", status); - expect(status).not.toBeFalsy(); - } -} diff --git a/test/suite/variables.ts b/test/suite/variables.ts deleted file mode 100644 index f55cce26..00000000 --- a/test/suite/variables.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { DoctorRegulation } from "@/lib/src/common/configForDoc"; -import { - DEFAULT_SETTINGS, - ChunkAlgorithms, - AutoAccepting, - type ObsidianLiveSyncSettings, -} from "@/lib/src/common/types"; -export const env = (import.meta as any).env; -export const settingBase = { - ...DEFAULT_SETTINGS, - isConfigured: true, - handleFilenameCaseSensitive: false, - couchDB_URI: `${env.hostname}`, - couchDB_DBNAME: `${env.dbname}`, - couchDB_USER: `${env.username}`, - couchDB_PASSWORD: `${env.password}`, - bucket: `${env.bucketName}`, - region: "us-east-1", - endpoint: `${env.minioEndpoint}`, - accessKey: `${env.accessKey}`, - secretKey: `${env.secretKey}`, - useCustomRequestHandler: true, - forcePathStyle: true, - bucketPrefix: "", - usePluginSyncV2: true, - chunkSplitterVersion: ChunkAlgorithms.RabinKarp, - doctorProcessedVersion: DoctorRegulation.version, - notifyThresholdOfRemoteStorageSize: 800, - P2P_AutoAccepting: AutoAccepting.ALL, - P2P_AutoBroadcast: true, - P2P_AutoStart: true, - P2P_Enabled: true, - P2P_passphrase: "p2psync-test", - P2P_roomID: "p2psync-test", - P2P_DevicePeerName: "p2psync-test", - P2P_relays: "ws://localhost:4000/", - P2P_AutoAcceptingPeers: "p2p-livesync-web-peer", - P2P_SyncOnReplication: "p2p-livesync-web-peer", -} as ObsidianLiveSyncSettings; diff --git a/test/suitep2p/run-p2p-tests.sh b/test/suitep2p/run-p2p-tests.sh deleted file mode 100755 index 4d8a50c0..00000000 --- a/test/suitep2p/run-p2p-tests.sh +++ /dev/null @@ -1,194 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)" -CLI_DIR="$REPO_ROOT/src/apps/cli" -CLI_TEST_HELPERS="$CLI_DIR/test/test-helpers.sh" - -source "$CLI_TEST_HELPERS" - -RUN_BUILD="${RUN_BUILD:-1}" -KEEP_TEST_DATA="${KEEP_TEST_DATA:-1}" -VERBOSE_TEST_LOGGING="${VERBOSE_TEST_LOGGING:-1}" - -RELAY="${RELAY:-ws://localhost:4000/}" -USE_INTERNAL_RELAY="${USE_INTERNAL_RELAY:-1}" -APP_ID="${APP_ID:-self-hosted-livesync-vitest-p2p}" -HOST_PEER_NAME="${HOST_PEER_NAME:-p2p-cli-host}" - -ROOM_ID="p2p-room-$(date +%s)-$RANDOM-$RANDOM" -PASSPHRASE="p2p-pass-$(date +%s)-$RANDOM-$RANDOM" -UPLOAD_PEER_NAME="p2p-upload-$(date +%s)-$RANDOM" -DOWNLOAD_PEER_NAME="p2p-download-$(date +%s)-$RANDOM" -UPLOAD_VAULT_NAME="TestVaultUpload-$(date +%s)-$RANDOM" -DOWNLOAD_VAULT_NAME="TestVaultDownload-$(date +%s)-$RANDOM" - -# ---- Build CLI ---- -if [[ "$RUN_BUILD" == "1" ]]; then - echo "[INFO] building CLI" - (cd "$CLI_DIR" && npm run build) -fi - -# ---- Temp directory ---- -WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/livesync-vitest-p2p.XXXXXX")" -VAULT_HOST="$WORK_DIR/vault-host" -SETTINGS_HOST="$WORK_DIR/settings-host.json" -HOST_LOG="$WORK_DIR/p2p-host.log" -# Handoff file: upload phase writes this; download phase reads it. -HANDOFF_FILE="$WORK_DIR/p2p-test-handoff.json" -mkdir -p "$VAULT_HOST" - -# ---- Setup CLI command (uses npm run cli from CLI_DIR) ---- -# Override run_cli to invoke the built binary directly from CLI_DIR -run_cli() { - (cd "$CLI_DIR" && node dist/index.cjs "$@") -} - -# ---- Create host settings ---- -echo "[INFO] relay=$RELAY room=$ROOM_ID app=$APP_ID host=$HOST_PEER_NAME" -cli_test_init_settings_file "$SETTINGS_HOST" -cli_test_apply_p2p_settings "$SETTINGS_HOST" "$ROOM_ID" "$PASSPHRASE" "$APP_ID" "$RELAY" "~.*" - -# Set host peer name -SETTINGS_HOST_FILE="$SETTINGS_HOST" HOST_PEER_NAME_VAL="$HOST_PEER_NAME" HOST_PASSPHRASE_VAL="$PASSPHRASE" node <<'NODE' -const fs = require("node:fs"); -const data = JSON.parse(fs.readFileSync(process.env.SETTINGS_HOST_FILE, "utf-8")); - -// Keep tweak values aligned with browser-side P2P test settings. -data.remoteType = "ONLY_P2P"; -data.encrypt = true; -data.passphrase = process.env.HOST_PASSPHRASE_VAL; -data.usePathObfuscation = true; -data.handleFilenameCaseSensitive = false; -data.customChunkSize = 50; -data.usePluginSyncV2 = true; -data.doNotUseFixedRevisionForChunks = false; - -data.P2P_DevicePeerName = process.env.HOST_PEER_NAME_VAL; -fs.writeFileSync(process.env.SETTINGS_HOST_FILE, JSON.stringify(data, null, 2), "utf-8"); -NODE - -# ---- Cleanup trap ---- -cleanup() { - local exit_code=$? - if [[ -n "${HOST_PID:-}" ]] && kill -0 "$HOST_PID" >/dev/null 2>&1; then - echo "[INFO] stopping CLI host (PID=$HOST_PID)" - kill -TERM "$HOST_PID" >/dev/null 2>&1 || true - wait "$HOST_PID" >/dev/null 2>&1 || true - fi - - if [[ "${P2P_RELAY_STARTED:-0}" == "1" ]]; then - cli_test_stop_p2p_relay - fi - - if [[ "$KEEP_TEST_DATA" != "1" ]]; then - rm -rf "$WORK_DIR" - else - echo "[INFO] KEEP_TEST_DATA=1, preserving artefacts at $WORK_DIR" - fi - - exit "$exit_code" -} -trap cleanup EXIT - -start_host() { - local attempt=0 - while [[ "$attempt" -lt 5 ]]; do - attempt=$((attempt + 1)) - echo "[INFO] starting CLI p2p-host (attempt $attempt/5)" - : >"$HOST_LOG" - (cd "$CLI_DIR" && node dist/index.cjs "$VAULT_HOST" --settings "$SETTINGS_HOST" -d p2p-host) >"$HOST_LOG" 2>&1 & - HOST_PID=$! - - local host_ready=0 - local exited_early=0 - for i in $(seq 1 30); do - if grep -qF "P2P host is running" "$HOST_LOG" 2>/dev/null; then - host_ready=1 - break - fi - if ! kill -0 "$HOST_PID" >/dev/null 2>&1; then - exited_early=1 - break - fi - echo "[INFO] waiting for p2p-host to be ready... ($i/30)" - sleep 1 - done - - if [[ "$host_ready" == "1" ]]; then - echo "[INFO] p2p-host is ready (PID=$HOST_PID)" - return 0 - fi - - wait "$HOST_PID" >/dev/null 2>&1 || true - HOST_PID= - - if grep -qF "Resource temporarily unavailable" "$HOST_LOG" 2>/dev/null; then - echo "[INFO] p2p-host database lock is still being released, retrying..." - sleep 2 - continue - fi - - if [[ "$exited_early" == "1" ]]; then - echo "[FAIL] CLI host process exited unexpectedly" >&2 - else - echo "[FAIL] p2p-host did not become ready within 30 seconds" >&2 - fi - cat "$HOST_LOG" >&2 - exit 1 - done - - echo "[FAIL] p2p-host could not be restarted after multiple attempts" >&2 - cat "$HOST_LOG" >&2 - exit 1 -} - -# ---- Start local relay if needed ---- -if [[ "$USE_INTERNAL_RELAY" == "1" ]]; then - if cli_test_is_local_p2p_relay "$RELAY"; then - cli_test_start_p2p_relay - P2P_RELAY_STARTED=1 - else - echo "[INFO] USE_INTERNAL_RELAY=1 but RELAY is not local ($RELAY), skipping" - fi -fi - -start_host - -# Common env vars passed to both vitest runs -P2P_ENV=( - P2P_TEST_ROOM_ID="$ROOM_ID" - P2P_TEST_PASSPHRASE="$PASSPHRASE" - P2P_TEST_HOST_PEER_NAME="$HOST_PEER_NAME" - P2P_TEST_RELAY="$RELAY" - P2P_TEST_APP_ID="$APP_ID" - P2P_TEST_HANDOFF_FILE="$HANDOFF_FILE" - P2P_TEST_UPLOAD_PEER_NAME="$UPLOAD_PEER_NAME" - P2P_TEST_DOWNLOAD_PEER_NAME="$DOWNLOAD_PEER_NAME" - P2P_TEST_UPLOAD_VAULT_NAME="$UPLOAD_VAULT_NAME" - P2P_TEST_DOWNLOAD_VAULT_NAME="$DOWNLOAD_VAULT_NAME" -) - -cd "$REPO_ROOT" - -# ---- Phase 1: Upload ---- -# Each vitest run gets a fresh browser process, so Trystero's module-level -# global state (occupiedRooms, didInit, etc.) is clean for every phase. -echo "[INFO] running P2P vitest — upload phase" -env "${P2P_ENV[@]}" \ - npx dotenv-cli -e .env -e .test.env -- \ - vitest run --config vitest.config.p2p.ts test/suitep2p/syncp2p.p2p-up.test.ts -echo "[INFO] upload phase completed" - -# ---- Phase 2: Download ---- -# Keep the same host process alive so its database handle and relay presence stay stable. -echo "[INFO] waiting 5s before download phase..." -sleep 5 -echo "[INFO] running P2P vitest — download phase" -env "${P2P_ENV[@]}" \ - npx dotenv-cli -e .env -e .test.env -- \ - vitest run --config vitest.config.p2p.ts test/suitep2p/syncp2p.p2p-down.test.ts -echo "[INFO] download phase completed" - -echo "[INFO] P2P vitest suite completed" diff --git a/test/suitep2p/sync_common_p2p.ts b/test/suitep2p/sync_common_p2p.ts deleted file mode 100644 index 53009894..00000000 --- a/test/suitep2p/sync_common_p2p.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * P2P-specific sync helpers. - * - * Derived from test/suite/sync_common.ts but with all acceptWebPeer() calls - * removed. When using a CLI p2p-host with P2P_AutoAcceptingPeers="~.*", peer - * acceptance is automatic and no Playwright dialog interaction is needed. - */ -import { expect } from "vitest"; -import { waitForIdle, type LiveSyncHarness } from "../harness/harness"; -import { RemoteTypes, type ObsidianLiveSyncSettings } from "@/lib/src/common/types"; -import { delay } from "@/lib/src/common/utils"; -import { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator"; -import { waitTaskWithFollowups } from "../lib/util"; - -const P2P_REPLICATION_TIMEOUT_MS = 180000; - -async function testWebSocketConnection(relayUrl: string): Promise { - return new Promise((resolve, reject) => { - console.log(`[P2P Debug] Testing WebSocket connection to ${relayUrl}`); - try { - const ws = new WebSocket(relayUrl); - const timer = setTimeout(() => { - ws.close(); - reject(new Error(`WebSocket connection to ${relayUrl} timed out`)); - }, 5000); - ws.onopen = () => { - clearTimeout(timer); - console.log(`[P2P Debug] WebSocket connected to ${relayUrl} successfully`); - ws.close(); - resolve(); - }; - ws.onerror = (e) => { - clearTimeout(timer); - console.error(`[P2P Debug] WebSocket error connecting to ${relayUrl}:`, e); - reject(new Error(`WebSocket connection to ${relayUrl} failed`)); - }; - } catch (e) { - console.error(`[P2P Debug] WebSocket constructor threw:`, e); - reject(e); - } - }); -} - -async function waitForP2PPeers(harness: LiveSyncHarness) { - if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) { - const maxRetries = 20; - let retries = maxRetries; - const replicator = await harness.plugin.core.services.replicator.getActiveReplicator(); - console.log("[P2P Debug] replicator type:", replicator?.constructor?.name); - if (!(replicator instanceof LiveSyncTrysteroReplicator)) { - throw new Error("Replicator is not an instance of LiveSyncTrysteroReplicator"); - } - - // Ensure P2P is open (getActiveReplicator returns a fresh instance that may not be open yet) - if (!replicator.server?.isServing) { - console.log("[P2P Debug] P2P not yet serving, calling open()"); - // Test WebSocket connectivity first - const relay = harness.plugin.core.settings.P2P_relays?.split(",")[0]?.trim(); - if (relay) { - try { - await testWebSocketConnection(relay); - } catch (e) { - console.error("[P2P Debug] WebSocket connectivity test failed:", e); - } - } - try { - await replicator.open(); - console.log("[P2P Debug] open() completed, isServing:", replicator.server?.isServing); - } catch (e) { - console.error("[P2P Debug] open() threw:", e); - } - } - - // Wait for P2P server to actually start (room joined) - for (let i = 0; i < 30; i++) { - const serving = replicator.server?.isServing; - console.log(`[P2P Debug] isServing: ${serving} (${i}/30)`); - if (serving) break; - await delay(500); - if (i === 29) throw new Error("P2P server did not start in time."); - } - - while (retries-- > 0) { - await delay(1000); - const peers = replicator.knownAdvertisements; - if (peers && peers.length > 0) { - console.log("P2P peers connected:", peers); - return; - } - console.log(`Waiting for any P2P peers to be connected... ${maxRetries - retries}/${maxRetries}`); - console.dir(peers); - await delay(1000); - } - console.log("Failed to connect P2P peers after retries"); - throw new Error("P2P peers did not connect in time."); - } -} - -export async function closeP2PReplicatorConnections(harness: LiveSyncHarness) { - if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) { - const replicator = await harness.plugin.core.services.replicator.getActiveReplicator(); - if (!(replicator instanceof LiveSyncTrysteroReplicator)) { - throw new Error("Replicator is not an instance of LiveSyncTrysteroReplicator"); - } - replicator.closeReplication(); - await delay(30); - replicator.closeReplication(); - await delay(1000); - console.log("P2P replicator connections closed"); - } -} - -export async function performReplication(harness: LiveSyncHarness) { - await waitForP2PPeers(harness); - await delay(500); - if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) { - const replicator = await harness.plugin.core.services.replicator.getActiveReplicator(); - if (!(replicator instanceof LiveSyncTrysteroReplicator)) { - throw new Error("Replicator is not an instance of LiveSyncTrysteroReplicator"); - } - const knownPeers = replicator.knownAdvertisements; - - const targetPeer = knownPeers.find((peer) => peer.name.startsWith("vault-host")) ?? knownPeers[0] ?? undefined; - if (!targetPeer) { - throw new Error("No connected P2P peer to synchronise with"); - } - - const p = replicator.sync(targetPeer.peerId, true); - const result = await waitTaskWithFollowups(p, () => Promise.resolve(), P2P_REPLICATION_TIMEOUT_MS, 500); - if (result && typeof result === "object" && "error" in result && result.error) { - throw result.error; - } - return result; - } - - return await harness.plugin.core.services.replication.replicate(true); -} - -export async function closeReplication(harness: LiveSyncHarness) { - if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) { - return await closeP2PReplicatorConnections(harness); - } - const replicator = await harness.plugin.core.services.replicator.getActiveReplicator(); - if (!replicator) { - console.log("No active replicator to close"); - return; - } - await replicator.closeReplication(); - await waitForIdle(harness); - console.log("Replication closed"); -} - -export async function prepareRemote(harness: LiveSyncHarness, setting: ObsidianLiveSyncSettings, shouldReset = false) { - // P2P has no remote database to initialise — skip - if (setting.remoteType === RemoteTypes.REMOTE_P2P) return; - - if (shouldReset) { - await delay(1000); - await harness.plugin.core.services.replicator - .getActiveReplicator() - ?.tryResetRemoteDatabase(harness.plugin.core.settings); - } else { - await harness.plugin.core.services.replicator - .getActiveReplicator() - ?.tryCreateRemoteDatabase(harness.plugin.core.settings); - } - await harness.plugin.core.services.replicator - .getActiveReplicator() - ?.markRemoteResolved(harness.plugin.core.settings); - const status = await harness.plugin.core.services.replicator - .getActiveReplicator() - ?.getRemoteStatus(harness.plugin.core.settings); - console.log("Remote status:", status); - expect(status).not.toBeFalsy(); -} diff --git a/test/suitep2p/syncp2p.p2p-down.test.ts b/test/suitep2p/syncp2p.p2p-down.test.ts deleted file mode 100644 index 7f3b77f7..00000000 --- a/test/suitep2p/syncp2p.p2p-down.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * P2P Replication Tests — Download phase (process 2 of 2) - * - * Executed by run-p2p-tests.sh as the second vitest process, after the - * upload phase has completed and the CLI host holds all the data. - * - * Reads the handoff JSON written by the upload phase to know which files - * to verify, then replicates from the CLI host and checks every file. - */ -import { afterAll, beforeAll, beforeEach, describe, expect, it, test } from "vitest"; -import { generateHarness, waitForIdle, waitForReady, type LiveSyncHarness } from "../harness/harness"; -import { - PREFERRED_SETTING_SELF_HOSTED, - RemoteTypes, - type FilePath, - type ObsidianLiveSyncSettings, - AutoAccepting, -} from "@/lib/src/common/types"; -import { DummyFileSourceInisialised, generateBinaryFile, generateFile } from "../utils/dummyfile"; -import { defaultFileOption, testFileRead } from "../suite/db_common"; -import { delay } from "@/lib/src/common/utils"; -import { closeReplication, performReplication } from "./sync_common_p2p"; -import { settingBase } from "../suite/variables"; - -const env = (import.meta as any).env; - -const ROOM_ID: string = env.P2P_TEST_ROOM_ID ?? "p2p-test-room"; -const PASSPHRASE: string = env.P2P_TEST_PASSPHRASE ?? "p2p-test-pass"; -const HOST_PEER_NAME: string = env.P2P_TEST_HOST_PEER_NAME ?? "p2p-cli-host"; -const RELAY: string = env.P2P_TEST_RELAY ?? "ws://localhost:4000/"; -const APP_ID: string = env.P2P_TEST_APP_ID ?? "self-hosted-livesync-vitest-p2p"; -const DOWNLOAD_PEER_NAME: string = env.P2P_TEST_DOWNLOAD_PEER_NAME ?? `p2p-download-${Date.now()}`; -const DOWNLOAD_VAULT_NAME: string = env.P2P_TEST_DOWNLOAD_VAULT_NAME ?? `TestVaultDownload-${Date.now()}`; -const HANDOFF_FILE: string = env.P2P_TEST_HANDOFF_FILE ?? "/tmp/p2p-test-handoff.json"; - -console.log("[P2P Down] ROOM_ID:", ROOM_ID, "HOST:", HOST_PEER_NAME, "RELAY:", RELAY, "APP_ID:", APP_ID); -console.log("[P2P Down] HANDOFF_FILE:", HANDOFF_FILE); - -const p2pSetting: ObsidianLiveSyncSettings = { - ...settingBase, - ...PREFERRED_SETTING_SELF_HOSTED, - showVerboseLog: true, - remoteType: RemoteTypes.REMOTE_P2P, - encrypt: true, - passphrase: PASSPHRASE, - usePathObfuscation: true, - P2P_Enabled: true, - P2P_AppID: APP_ID, - handleFilenameCaseSensitive: false, - P2P_AutoAccepting: AutoAccepting.ALL, - P2P_AutoBroadcast: true, - P2P_AutoStart: true, - P2P_passphrase: PASSPHRASE, - P2P_roomID: ROOM_ID, - P2P_relays: RELAY, - P2P_AutoAcceptingPeers: "~.*", - P2P_SyncOnReplication: HOST_PEER_NAME, -}; - -const fileOptions = defaultFileOption; -const nameFile = (type: string, ext: string, size: number) => `p2p-cli-test-${type}-file-${size}.${ext}`; - -/** Read the handoff JSON produced by the upload phase. */ -async function readHandoff(): Promise<{ fileSizeMd: number[]; fileSizeBins: number[] }> { - const { commands } = await import("@vitest/browser/context"); - const raw = await commands.readHandoffFile(HANDOFF_FILE); - return JSON.parse(raw); -} - -describe("P2P Replication — Download", () => { - let harnessDownload: LiveSyncHarness; - let fileSizeMd: number[] = []; - let fileSizeBins: number[] = []; - - const downloadSetting: ObsidianLiveSyncSettings = { - ...p2pSetting, - P2P_DevicePeerName: DOWNLOAD_PEER_NAME, - }; - - beforeAll(async () => { - await DummyFileSourceInisialised; - - const handoff = await readHandoff(); - fileSizeMd = handoff.fileSizeMd; - fileSizeBins = handoff.fileSizeBins; - console.log("[P2P Down] handoff loaded — md sizes:", fileSizeMd, "bin sizes:", fileSizeBins); - - const vaultName = DOWNLOAD_VAULT_NAME; - console.log(`[P2P Down] BeforeAll - Vault: ${vaultName}`); - console.log(`[P2P Down] Peer name: ${DOWNLOAD_PEER_NAME}`); - harnessDownload = await generateHarness(vaultName, downloadSetting); - await waitForReady(harnessDownload); - - await performReplication(harnessDownload); - await waitForIdle(harnessDownload); - await delay(1000); - await performReplication(harnessDownload); - await waitForIdle(harnessDownload); - await delay(3000); - }); - beforeEach(async () => { - await performReplication(harnessDownload); - await waitForIdle(harnessDownload); - }); - - afterAll(async () => { - await closeReplication(harnessDownload); - await harnessDownload.dispose(); - await delay(1000); - }); - - it("should be instantiated and defined", () => { - expect(harnessDownload.plugin).toBeDefined(); - expect(harnessDownload.plugin.app).toBe(harnessDownload.app); - }); - - it("should have services initialized", () => { - expect(harnessDownload.plugin.core.services).toBeDefined(); - }); - - it("should have local database initialized", () => { - expect(harnessDownload.plugin.core.localDatabase).toBeDefined(); - expect(harnessDownload.plugin.core.localDatabase.isReady).toBe(true); - }); - - it("should have synchronised the stored file", async () => { - await testFileRead(harnessDownload, nameFile("store", "md", 0), "Hello, World!", fileOptions); - }); - - it("should have synchronised files with different content", async () => { - await testFileRead(harnessDownload, nameFile("test-diff-1", "md", 0), "Content A", fileOptions); - await testFileRead(harnessDownload, nameFile("test-diff-2", "md", 0), "Content B", fileOptions); - await testFileRead(harnessDownload, nameFile("test-diff-3", "md", 0), "Content C", fileOptions); - }); - - // NOTE: test.each cannot use variables populated in beforeAll, so we use - // a single it() that iterates over the sizes loaded from the handoff file. - it("should have synchronised all large md files", async () => { - for (const size of fileSizeMd) { - const content = Array.from(generateFile(size)).join(""); - const path = nameFile("large", "md", size); - const isTooLarge = harnessDownload.plugin.core.services.vault.isFileSizeTooLarge(size); - if (isTooLarge) { - const entry = await harnessDownload.plugin.core.localDatabase.getDBEntry(path as FilePath); - expect(entry).toBe(false); - } else { - await testFileRead(harnessDownload, path, content, fileOptions); - } - } - }); - - it("should have synchronised all binary files", async () => { - for (const size of fileSizeBins) { - const path = nameFile("binary", "bin", size); - const isTooLarge = harnessDownload.plugin.core.services.vault.isFileSizeTooLarge(size); - if (isTooLarge) { - const entry = await harnessDownload.plugin.core.localDatabase.getDBEntry(path as FilePath); - expect(entry).toBe(false); - } else { - const content = new Blob([...generateBinaryFile(size)], { type: "application/octet-stream" }); - await testFileRead(harnessDownload, path, content, fileOptions); - } - } - }); -}); diff --git a/test/suitep2p/syncp2p.p2p-up.test.ts b/test/suitep2p/syncp2p.p2p-up.test.ts deleted file mode 100644 index 7c463eb3..00000000 --- a/test/suitep2p/syncp2p.p2p-up.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * P2P Replication Tests — Upload phase (process 1 of 2) - * - * Executed by run-p2p-tests.sh as the first vitest process. - * Writes files into the local DB, replicates them to the CLI host, - * then writes a handoff JSON so the download process knows what to verify. - * - * Trystero has module-level global state (occupiedRooms, didInit, etc.) - * that cannot be safely reused across upload→download within the same - * browser process. Running upload and download as separate vitest - * invocations gives each phase a fresh browser context. - */ -import { afterAll, beforeAll, describe, expect, it, test } from "vitest"; -import { generateHarness, waitForIdle, waitForReady, type LiveSyncHarness } from "../harness/harness"; -import { - PREFERRED_SETTING_SELF_HOSTED, - RemoteTypes, - type ObsidianLiveSyncSettings, - AutoAccepting, -} from "@/lib/src/common/types"; -import { - DummyFileSourceInisialised, - FILE_SIZE_BINS, - FILE_SIZE_MD, - generateBinaryFile, - generateFile, -} from "../utils/dummyfile"; -import { checkStoredFileInDB, defaultFileOption, testFileWrite } from "../suite/db_common"; -import { delay } from "@/lib/src/common/utils"; -import { closeReplication, performReplication } from "./sync_common_p2p"; -import { settingBase } from "../suite/variables"; - -const env = (import.meta as any).env; - -const ROOM_ID: string = env.P2P_TEST_ROOM_ID ?? "p2p-test-room"; -const PASSPHRASE: string = env.P2P_TEST_PASSPHRASE ?? "p2p-test-pass"; -const HOST_PEER_NAME: string = env.P2P_TEST_HOST_PEER_NAME ?? "p2p-cli-host"; -const RELAY: string = env.P2P_TEST_RELAY ?? "ws://localhost:4000/"; -const APP_ID: string = env.P2P_TEST_APP_ID ?? "self-hosted-livesync-vitest-p2p"; -const UPLOAD_PEER_NAME: string = env.P2P_TEST_UPLOAD_PEER_NAME ?? `p2p-upload-${Date.now()}`; -const UPLOAD_VAULT_NAME: string = env.P2P_TEST_UPLOAD_VAULT_NAME ?? `TestVaultUpload-${Date.now()}`; -// Path written by run-p2p-tests.sh; the download phase reads it back. -const HANDOFF_FILE: string = env.P2P_TEST_HANDOFF_FILE ?? "/tmp/p2p-test-handoff.json"; - -console.log("[P2P Up] ROOM_ID:", ROOM_ID, "HOST:", HOST_PEER_NAME, "RELAY:", RELAY, "APP_ID:", APP_ID); -console.log("[P2P Up] HANDOFF_FILE:", HANDOFF_FILE); - -const p2pSetting: ObsidianLiveSyncSettings = { - ...settingBase, - ...PREFERRED_SETTING_SELF_HOSTED, - showVerboseLog: true, - remoteType: RemoteTypes.REMOTE_P2P, - encrypt: true, - passphrase: PASSPHRASE, - usePathObfuscation: true, - P2P_Enabled: true, - P2P_AppID: APP_ID, - handleFilenameCaseSensitive: false, - P2P_AutoAccepting: AutoAccepting.ALL, - P2P_AutoBroadcast: true, - P2P_AutoStart: true, - P2P_passphrase: PASSPHRASE, - P2P_roomID: ROOM_ID, - P2P_relays: RELAY, - P2P_AutoAcceptingPeers: "~.*", - P2P_SyncOnReplication: HOST_PEER_NAME, -}; - -const fileOptions = defaultFileOption; -const nameFile = (type: string, ext: string, size: number) => `p2p-cli-test-${type}-file-${size}.${ext}`; - -/** Write the handoff JSON so the download phase knows which files to verify. */ -async function writeHandoff() { - const handoff = { - fileSizeMd: FILE_SIZE_MD, - fileSizeBins: FILE_SIZE_BINS, - }; - const { commands } = await import("@vitest/browser/context"); - await commands.writeHandoffFile(HANDOFF_FILE, JSON.stringify(handoff)); - console.log("[P2P Up] handoff written to", HANDOFF_FILE); -} - -describe("P2P Replication — Upload", () => { - let harnessUpload: LiveSyncHarness; - - const uploadSetting: ObsidianLiveSyncSettings = { - ...p2pSetting, - P2P_DevicePeerName: UPLOAD_PEER_NAME, - }; - - beforeAll(async () => { - await DummyFileSourceInisialised; - const vaultName = UPLOAD_VAULT_NAME; - console.log(`[P2P Up] BeforeAll - Vault: ${vaultName}`); - console.log(`[P2P Up] Peer name: ${UPLOAD_PEER_NAME}`); - harnessUpload = await generateHarness(vaultName, uploadSetting); - await waitForReady(harnessUpload); - expect(harnessUpload.plugin).toBeDefined(); - await waitForIdle(harnessUpload); - }); - - afterAll(async () => { - await closeReplication(harnessUpload); - await harnessUpload.dispose(); - await delay(1000); - }); - - it("should be instantiated and defined", () => { - expect(harnessUpload.plugin).toBeDefined(); - expect(harnessUpload.plugin.app).toBe(harnessUpload.app); - }); - - it("should have services initialized", () => { - expect(harnessUpload.plugin.core.services).toBeDefined(); - }); - - it("should have local database initialized", () => { - expect(harnessUpload.plugin.core.localDatabase).toBeDefined(); - expect(harnessUpload.plugin.core.localDatabase.isReady).toBe(true); - }); - - it("should create a file", async () => { - await testFileWrite(harnessUpload, nameFile("store", "md", 0), "Hello, World!", false, fileOptions); - }); - - it("should create several files with different content", async () => { - await testFileWrite(harnessUpload, nameFile("test-diff-1", "md", 0), "Content A", false, fileOptions); - await testFileWrite(harnessUpload, nameFile("test-diff-2", "md", 0), "Content B", false, fileOptions); - await testFileWrite(harnessUpload, nameFile("test-diff-3", "md", 0), "Content C", false, fileOptions); - }); - - test.each(FILE_SIZE_MD)("should create large md file of size %i bytes", async (size) => { - const content = Array.from(generateFile(size)).join(""); - const path = nameFile("large", "md", size); - const isTooLarge = harnessUpload.plugin.core.services.vault.isFileSizeTooLarge(size); - if (isTooLarge) { - expect(true).toBe(true); - } else { - await testFileWrite(harnessUpload, path, content, false, fileOptions); - } - }); - - test.each(FILE_SIZE_BINS)("should create binary file of size %i bytes", async (size) => { - const content = new Blob([...generateBinaryFile(size)], { type: "application/octet-stream" }); - const path = nameFile("binary", "bin", size); - await testFileWrite(harnessUpload, path, content, true, fileOptions); - const isTooLarge = harnessUpload.plugin.core.services.vault.isFileSizeTooLarge(size); - if (!isTooLarge) { - await checkStoredFileInDB(harnessUpload, path, content, fileOptions); - } - }); - - it("should replicate uploads to CLI host", async () => { - await performReplication(harnessUpload); - await performReplication(harnessUpload); - }); - - it("should write handoff file for download phase", async () => { - await writeHandoff(); - }); -}); diff --git a/test/suitep2p/syncp2p.test.ts b/test/suitep2p/syncp2p.test.ts deleted file mode 100644 index 08c2c101..00000000 --- a/test/suitep2p/syncp2p.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Functional Test on Main Cases -// This test suite only covers main functional cases of synchronisation. Event handling, error cases, -// and edge, resolving conflicts, etc. will be covered in separate test suites. -import { describe } from "vitest"; -import { - PREFERRED_JOURNAL_SYNC, - PREFERRED_SETTING_SELF_HOSTED, - RemoteTypes, - type ObsidianLiveSyncSettings, -} from "@/lib/src/common/types"; - -import { settingBase } from "../suite/variables.ts"; -import { defaultFileOption } from "../suite/db_common"; -import { syncBasicCase } from "../suite/sync.senario.basic.ts"; - -export const env = (import.meta as any).env; -function* generateCase() { - const sync_test_setting_base = settingBase; - const passpharse = "thetest-Passphrase3+9-for-e2ee!"; - const REMOTE_RECOMMENDED = { - [RemoteTypes.REMOTE_COUCHDB]: PREFERRED_SETTING_SELF_HOSTED, - [RemoteTypes.REMOTE_MINIO]: PREFERRED_JOURNAL_SYNC, - [RemoteTypes.REMOTE_P2P]: PREFERRED_SETTING_SELF_HOSTED, - }; - // const remoteTypes = [RemoteTypes.REMOTE_COUCHDB, RemoteTypes.REMOTE_MINIO, RemoteTypes.REMOTE_P2P]; - const remoteTypes = [RemoteTypes.REMOTE_P2P]; - // const e2eeOptions = [false, true]; - const e2eeOptions = [true]; - for (const remoteType of remoteTypes) { - for (const useE2EE of e2eeOptions) { - yield { - setting: { - ...sync_test_setting_base, - ...REMOTE_RECOMMENDED[remoteType], - remoteType, - encrypt: useE2EE, - passphrase: useE2EE ? passpharse : "", - usePathObfuscation: useE2EE, - } as ObsidianLiveSyncSettings, - }; - } - } -} - -describe("Replication Suite Tests (P2P)", async () => { - const cases = Array.from(generateCase()); - const fileOptions = defaultFileOption; - describe.each(cases)("Replication Tests - Remote: $setting.remoteType, E2EE: $setting.encrypt", ({ setting }) => { - syncBasicCase(`Remote: ${setting.remoteType}, E2EE: ${setting.encrypt}`, { setting, fileOptions }); - }); -}); diff --git a/test/testtest/dummyfile.test.ts b/test/testtest/dummyfile.test.ts deleted file mode 100644 index 32349963..00000000 --- a/test/testtest/dummyfile.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { writeFile } from "../utils/fileapi.vite"; -import { DummyFileSourceInisialised, generateBinaryFile, generateFile } from "../utils/dummyfile"; -import { describe, expect, it } from "vitest"; - -describe("Test File Teet", async () => { - await DummyFileSourceInisialised; - - it("should generate binary file correctly", async () => { - const size = 5000; - let generatedSize = 0; - const chunks: Uint8Array[] = []; - const generator = generateBinaryFile(size); - const blob = new Blob([...generator], { type: "application/octet-stream" }); - const buf = await blob.arrayBuffer(); - const hexDump = new Uint8Array(buf) - //@ts-ignore - .toHex() - .match(/.{1,32}/g) - ?.join("\n"); - const secondDummy = generateBinaryFile(size); - const secondBlob = new Blob([...secondDummy], { type: "application/octet-stream" }); - const secondBuf = await secondBlob.arrayBuffer(); - const secondHexDump = new Uint8Array(secondBuf) - //@ts-ignore - .toHex() - .match(/.{1,32}/g) - ?.join("\n"); - if (hexDump !== secondHexDump) { - throw new Error("Generated binary files do not match"); - } - expect(hexDump).toBe(secondHexDump); - // await writeFile("test/testtest/dummyfile.test.bin", buf); - // await writeFile("test/testtest/dummyfile.test.bin.hexdump.txt", hexDump || ""); - }); - it("should generate text file correctly", async () => { - const size = 25000; - let generatedSize = 0; - let content = ""; - const generator = generateFile(size); - const out = [...generator]; - // const blob = new Blob(out, { type: "text/plain" }); - content = out.join(""); - - const secondDummy = generateFile(size); - const secondOut = [...secondDummy]; - const secondContent = secondOut.join(""); - if (content !== secondContent) { - throw new Error("Generated text files do not match"); - } - expect(content).toBe(secondContent); - // await writeFile("test/testtest/dummyfile.test.txt", await blob.text()); - }); -}); diff --git a/test/unit/dialog.test.ts b/test/unit/dialog.test.ts deleted file mode 100644 index 86c424cc..00000000 --- a/test/unit/dialog.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -// Dialog Unit Tests -import { beforeAll, describe, expect, it } from "vitest"; -import { commands } from "vitest/browser"; - -import { generateHarness, waitForIdle, waitForReady, type LiveSyncHarness } from "../harness/harness"; -import { ChunkAlgorithms, DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@/lib/src/common/types"; - -import { DummyFileSourceInisialised } from "../utils/dummyfile"; - -import { page } from "vitest/browser"; -import { DoctorRegulation } from "@/lib/src/common/configForDoc"; -import { waitForDialogHidden, waitForDialogShown } from "../lib/ui"; -const env = (import.meta as any).env; -const dialog_setting_base = { - ...DEFAULT_SETTINGS, - isConfigured: true, - handleFilenameCaseSensitive: false, - couchDB_URI: `${env.hostname}`, - couchDB_DBNAME: `${env.dbname}`, - couchDB_USER: `${env.username}`, - couchDB_PASSWORD: `${env.password}`, - bucket: `${env.bucketName}`, - region: "us-east-1", - endpoint: `${env.minioEndpoint}`, - accessKey: `${env.accessKey}`, - secretKey: `${env.secretKey}`, - useCustomRequestHandler: true, - forcePathStyle: true, - bucketPrefix: "", - usePluginSyncV2: true, - chunkSplitterVersion: ChunkAlgorithms.RabinKarp, - doctorProcessedVersion: DoctorRegulation.version, - notifyThresholdOfRemoteStorageSize: 800, -} as ObsidianLiveSyncSettings; - -function checkDialogVisibility(dialogText: string, shouldBeVisible: boolean): void { - const dialog = page.getByText(dialogText); - expect(dialog).toHaveClass(/modal-title/); - if (!shouldBeVisible) { - expect(dialog).not.toBeVisible(); - } else { - expect(dialog).toBeVisible(); - } - return; -} -function checkDialogShown(dialogText: string) { - checkDialogVisibility(dialogText, true); -} -function checkDialogHidden(dialogText: string) { - checkDialogVisibility(dialogText, false); -} - -describe("Dialog Tests", async () => { - // describe.each(cases)("Replication Tests - Remote: $setting.remoteType, E2EE: $setting.encrypt", ({ setting }) => { - const setting = dialog_setting_base; - beforeAll(async () => { - await DummyFileSourceInisialised; - await commands.grantClipboardPermissions(); - }); - let harness: LiveSyncHarness; - const vaultName = "TestVault" + Date.now(); - beforeAll(async () => { - harness = await generateHarness(vaultName, setting); - await waitForReady(harness); - expect(harness.plugin).toBeDefined(); - expect(harness.plugin.app).toBe(harness.app); - await waitForIdle(harness); - }); - it("should show copy to clipboard dialog and confirm", async () => { - const testString = "This is a test string to copy to clipboard."; - const title = "Copy Test"; - const result = harness.plugin.core.services.UI.promptCopyToClipboard(title, testString); - const isDialogShown = await waitForDialogShown(title, 500); - expect(isDialogShown).toBe(true); - const copyButton = page.getByText("📋"); - expect(copyButton).toBeDefined(); - expect(copyButton).toBeVisible(); - await copyButton.click(); - const copyResultButton = page.getByText("âœ”ī¸"); - expect(copyResultButton).toBeDefined(); - expect(copyResultButton).toBeVisible(); - const clipboardText = await navigator.clipboard.readText(); - expect(clipboardText).toBe(testString); - const okButton = page.getByText("OK"); - expect(okButton).toBeDefined(); - expect(okButton).toBeVisible(); - await okButton.click(); - const resultValue = await result; - expect(resultValue).toBe(true); - // Check that the dialog is closed - const isDialogHidden = await waitForDialogHidden(title, 500); - expect(isDialogHidden).toBe(true); - }); -}); diff --git a/test/utils/dummyfile.ts b/test/utils/dummyfile.ts deleted file mode 100644 index ab4b8b3f..00000000 --- a/test/utils/dummyfile.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { DEFAULT_SETTINGS } from "@/lib/src/common/types.ts"; -import { readFile } from "../utils/fileapi.vite.ts"; -let charset = ""; -export async function init() { - console.log("Initializing dummyfile utils..."); - - charset = (await readFile("test/utils/testcharvariants.txt")).toString(); - console.log(`Loaded charset of length ${charset.length}`); - console.log(charset); -} -export const DummyFileSourceInisialised = init(); -function* indexer(range: number = 1000, seed: number = 0): Generator { - let t = seed | 0; - while (true) { - t = (t + 0x6d2b79f5) | 0; - let z = t; - z = Math.imul(z ^ (z >>> 15), z | 1); - z ^= z + Math.imul(z ^ (z >>> 7), z | 61); - const float = ((z ^ (z >>> 14)) >>> 0) / 4294967296; - yield Math.floor(float * range); - } -} - -export function* generateFile(size: number): Generator { - const chunkSourceStr = charset; - const chunkStore = [...chunkSourceStr]; // To support indexing avoiding multi-byte issues - const bufSize = 1024; - let buf = ""; - let generated = 0; - const indexGen = indexer(chunkStore.length); - while (generated < size) { - const f = indexGen.next().value; - buf += chunkStore[f]; - generated += 1; - if (buf.length >= bufSize) { - yield buf; - buf = ""; - } - } - if (buf.length > 0) { - yield buf; - } -} -export function* generateBinaryFile(size: number): Generator> { - let generated = 0; - const pattern = Array.from({ length: 256 }, (_, i) => i); - const indexGen = indexer(pattern.length); - const bufSize = 1024; - const buf = new Uint8Array(bufSize); - let bufIdx = 0; - while (generated < size) { - const f = indexGen.next().value; - buf[bufIdx] = pattern[f]; - bufIdx += 1; - generated += 1; - if (bufIdx >= bufSize) { - yield buf; - bufIdx = 0; - } - } - if (bufIdx > 0) { - yield buf.subarray(0, bufIdx); - } -} - -// File size for markdown test files (10B to 1MB, roughly logarithmic scale) -export const FILE_SIZE_MD = [10, 100, 1000, 10000, 100000, 1000000]; -// File size for test files (10B to 40MB, roughly logarithmic scale) -export const FILE_SIZE_BINS = [ - 10, - 100, - 1000, - 50000, - 100000, - 5000000, - DEFAULT_SETTINGS.syncMaxSizeInMB * 1024 * 1024 + 1, -]; diff --git a/test/utils/fileapi.vite.ts b/test/utils/fileapi.vite.ts deleted file mode 100644 index e84f5bde..00000000 --- a/test/utils/fileapi.vite.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { server } from "vitest/browser"; -const { readFile, writeFile } = server.commands; -export { readFile, writeFile }; diff --git a/test/utils/testcharvariants.txt b/test/utils/testcharvariants.txt deleted file mode 100644 index b517a531..00000000 --- a/test/utils/testcharvariants.txt +++ /dev/null @@ -1,17 +0,0 @@ -åœ‹į ´åąąæ˛ŗåœ¨īŧŒåŸŽæ˜Ĩč‰æœ¨æˇąã€‚ -æ„Ÿæ™‚čŠąæŋ翎šīŧŒæ¨åˆĨéŗĨ驚åŋƒã€‚ -įƒŊįĢé€Ŗä¸‰æœˆīŧŒåŽļ書æŠĩčŦ金。 -į™Ŋé ­æ”æ›´įŸ­īŧŒæ¸žæŦ˛ä¸å‹į°Ē。 -ÂĢNel mezzo del cammin di nostra vita -mi ritrovai per una selva oscura, -chÊ la diritta via era smarrita.Âģ -Đ”ŅƒŅ…ĐžĐ˛ĐŊОК ĐļаĐļĐ´ĐžŅŽ Ņ‚ĐžĐŧиĐŧ, -В ĐŋŅƒŅŅ‚Ņ‹ĐŊĐĩ ĐŧŅ€Đ°Ņ‡ĐŊОК Ņ вĐģĐ°Ņ‡Đ¸ĐģŅŅ, — -И ҈ĐĩŅŅ‚Đ¸ĐēҀҋĐģŅ‹Đš ҁĐĩŅ€Đ°Ņ„Đ¸Đŧ -На ĐŋĐĩŅ€ĐĩĐŋŅƒŅ‚ŅŒĐĩ ĐŧĐŊĐĩ ŅĐ˛Đ¸ĐģŅŅ. -Shall I compare thee to a summer’s day? -Thou art more lovely and more temperate: -Rough winds do shake the darling buds of May, -And summer’s lease hath all too short a date: - -đŸ“œđŸ–‹ī¸ đŸē đŸ›ī¸ æ˜Ĩæœ›đ Žˇâ€ŒcheĖđŸ‡ˇđŸ‡ēАa‮RTLOđŸŗī¸â€đŸŒˆđŸ‘¨â€đŸ‘Šâ€đŸ‘§â€đŸ‘ĻlĘŧanatraīŊąīŊ˛īŊŗīŊ´īŊĩ \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 80944670..4e159699 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,10 +19,18 @@ "strictBindCallApply": true, "strictFunctionTypes": true, "paths": { - "@/*": ["./src/*"], - "@lib/*": ["./src/lib/src/*", "./_types/src/lib/src/*"] + "@/*": ["./src/*"] } }, "include": ["**/*.ts", "test/**/*.test.ts", "**/*.unit.spec.ts", "**/*.svelte"], - "exclude": ["pouchdb-browser-webpack", "utils", "src/apps", "src/**/*.test.ts", "**/_test/**", "utilsdeno"] + "exclude": [ + "pouchdb-browser-webpack", + "utils", + "src/apps", + "src/**/*.test.ts", + "**/_test/**", + "test/browser-apps", + "test/styles/*.deno.ts", + "utilsdeno" + ] } diff --git a/tsconfig.types.json b/tsconfig.types.json deleted file mode 100644 index 089dbfa5..00000000 --- a/tsconfig.types.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": false, - "declaration": true, - "emitDeclarationOnly": true, - "outDir": "./_types", - "rootDir": "." - }, - "include": ["src/lib/**/*.ts"], - "exclude": [ - "_types", - "pouchdb-browser-webpack", - "utils", - "src/apps", - "src/**/*.test.ts", - "src/lib/_tools", - "src/lib/apps", - "src/lib/src/cli", - "**/_test/**", - "utilsdeno", - "node_modules", - "test/**/*.test.ts", - "**/*.unit.spec.ts" - ] -} diff --git a/updates.md b/updates.md index 231ca742..c25953a3 100644 --- a/updates.md +++ b/updates.md @@ -1,170 +1,267 @@ -# 0.25 -Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025) +# 1.0 -The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope. +Well then, everyone: it has been roughly a year since I declared the 0.25 beta. During that time, we have concentrated mainly on fixing defects and completing the features that the project needed. + +Version 1.0 has been in mind for some time. We have now brought together the work intended to make it possible: stronger CI, more detailed tests, an E2E runner suited to synchronisation, and testing tools for physical devices. These now form a coherent Kit rather than a collection of isolated pieces. With those foundations in place, it seems that the time has finally come to reshape the structure of this repository. + +None of this would have been possible without your issue reports, pull requests, sponsorship, and the support provided through OpenAI's Codex for Open Source. I would like to express my gratitude once again. As with every pull request contributed to the project, code produced with Codex and similar tools is reviewed and audited by me, vrtmrz. Anyone interested in how I manage that process can refer to my dotfiles. + +This will call for your help once again. I would be very grateful for your co-operation as we build a sounder foundation for the project and its future development. + +Earlier releases remain available in the 0.25 release history and the legacy release history. ## Unreleased -## 0.25.82 +## 1.0.10 -15th July, 2026 +9th August, 2026 -Recently, I created a repository called Fancy Kit and have been trying to build some proper infrastructure around it. Does any of this look like cumbersome bureaucracy? No need to worry: you can carry on as usual. Codex is simply tidying up my usual rambling prose, terrible pull requests, and disjointed remarks. +### Setup and compatibility -### Fixed +#### Fixed -- Refreshed the remote Security Seed before each replication, preventing a client that remained open during a remote database rebuild from uploading data encrypted with the previous seed (#1018). -- The P2P **Start Sync & Close** action now waits for synchronisation to settle before closing the dialogue, avoiding premature release of screen-awake protection while work remains in flight. +- Fast Setup now sends configured CouchDB custom headers with every changes-feed request, allowing reverse proxies such as Cloudflare Access to authenticate initial setup in the same way as ordinary synchronisation ([Commonlib PR #82](https://github.com/vrtmrz/livesync-commonlib/pull/82)). Thank you to @nimula for the contribution! -### Improved +## 1.0.9 -- On supported mobile and desktop devices, one-shot replication, P2P peer discovery and selection, database rebuild and fetch operations, and remote chunk fetching now keep the screen awake for the duration of these finite remote operations. LiveSync also postpones its normal visibility suspension until the operation finishes, although platform restrictions may still pause or terminate background work. -- The 📲 status-bar indicator now covers finite remote work as well as HTTP requests. It reports broad remote activity, such as P2P peer selection and remote chunk fetching, rather than an exact physical connection count. +8th August, 2026 -### Improved (CLI) +For the first time in a while, I published a release that could not be promoted to a stable release. Sorry about that! I am glad that we caught it while it was still a pre-release. -- CLI container images are now published for both AMD64 and ARM64 platforms. +### Setup and compatibility -### Testing +#### Fixed -- Added a local real-Obsidian compatibility test that writes an encrypted, path-obfuscated note through the LiveSync CLI and verifies that the plug-in materialises the same content. +- Multi-part settings QR codes now preserve special characters in passwords, passphrases, and other settings (PR #1083). Thank you to @calvinbui for the improvement! +- Fast Setup now sizes each finite CouchDB changes page from a one-row status probe, counts the returned result together with `pending`, and resumes from the page's opaque `last_seq` without comparing token representations. Each page uses a one-second idle timeout instead of a heartbeat, allowing CouchDB 3.2 to return its terminator after the currently available rows have been persisted. -## 0.25.81 +## 1.0.8 -14th July, 2026 +8th August, 2026 -I am releasing this update to make the chunk-boundary fix available before the next broader release. Thank you to everyone who helped reproduce, trace, and verify the issue. +This version was published for pre-release validation only and was not promoted to a stable release. -### Fixed +### Setup and compatibility -- Fixed an issue where a U+FEFF character at a Rabin–Karp chunk boundary could be lost, changing the reconstructed file and causing repeated size-mismatch conflicts (#1000). +#### Fixed -### Improved +- Fast Setup now sizes each finite CouchDB changes page from a one-row status probe, counts the returned result together with `pending`, and resumes from the page's opaque `last_seq` without comparing token representations. Heartbeat-enabled feeds no longer wait for future writes after the currently available rows have been persisted (#1065). +- Cancelling remote selection during a scheduled Fetch now removes the Fetch flag before restarting with file and database reflection paused, preventing the same selection dialogue from reopening on every start-up. -- Improved vault scanning and CLI file filtering by reusing compiled ignore patterns, reducing processing overhead for vaults with many files or ignore rules (#1006, #1007, and #1008). +## 1.0.7 -### Improved (CLI and Webapp) +8th August, 2026 -- Rooted storage adapters now reject absolute, drive-qualified, backslash-separated, and traversal paths. They also prevent file writes, appends, and removal from targeting the configured root itself. -- File System Access API storage can now create files below previously missing parent directories, matching the existing Node behaviour. +### Setup and compatibility -### Testing +#### Fixed -- Added shared Node and File System Access API storage contract coverage for metadata, text and binary operations, append, listing, removal, path containment, and empty-root handling. -- Added a Compose-based P2P end-to-end smoke test and repeatable network benchmark cases for local performance investigations. +- Fast Setup now completes only after the captured CouchDB changes target has been persisted. Decryption, protocol, and local write failures stop the operation without finalising an incomplete database, while transient interruptions resume from the last durable checkpoint (#1065). -### Miscellaneous +## 1.0.6 -- Split the internal storage adapter contract into focused capability views without changing existing runtime behaviour. +6th August, 2026 -## 0.25.80 +I know that onboarding, and other parts which feel unclear or confusing, still need improvement. Please do report any such cases. -7th July, 2026 +### Setup and compatibility -### Fixed +#### Fixed -- Improved Markdown conflict auto-merge so that non-overlapping edits are merged while overlapping delete-and-edit cases remain visible for manual resolution (#993). - - Behaviour change: - - When one side deletes an unchanged line and the other side edits a different region, the deleted line is no longer reintroduced into the merged result. - - When one side deletes a line and the other side modifies that same line, the conflict is preserved instead of silently choosing one side. -- Fixed an issue where applying a newer database entry to storage could incorrectly preserve an older local file as a conflict (#994). - - Behaviour change: - - Local storage is preserved as a conflict when it may contain unsynchronised changes that are not represented in the revision history. A newer incoming text entry is applied without creating a conflict only when it clearly extends the existing local text. -- Fixed an issue where choosing Disable and then Overwrite in Hidden File Sync could silently skip hidden files, because the overwrite setup ran while hidden file synchronisation was still disabled (#989, PR #992). - - Hidden File Sync is now re-enabled before the Fetch, Overwrite, or Merge initialisation runs, instead of after it completes. If that initialisation fails, the setting may remain enabled. +- Initial setup now distinguishes an empty remote with no saved synchronisation settings from a failed remote read. New remotes can use this device's settings without an unnecessary retry; Fetch pauses on unreadable settings, while Rebuild can explicitly continue with this device's settings. Cancelling preserves the selected automatic synchronisation mode and restarts with Vault and database reflection paused (#1064). Thank you to @mateus2k2 for the follow-up report! -## 0.25.79 +## 1.0.5 -29th June, 2026 +5th August, 2026 -### Fixed +### Synchronisation and storage -- Fast Fetch now retries transient stream interruptions and resumes from the latest persisted checkpoint, instead of starting over after ordinary network or platform interruptions (#977, PR #978; commonlib PR #59). Thank you so much for @apple-ouyang for the fix! -- Simple Fetch now remembers the selected setup choices while an interrupted Fetch All operation is still pending, so users are not asked the same questions again on retry (#977, PR #978). Thank you so much for @apple-ouyang for the fix! -- No longer hidden storage events, such as `.git` paths, reach the normal target-file filter when internal file synchronisation is disabled. This avoids noisy non-target logs before those files are skipped (commonlib PR #60). Thank you so much for @apple-ouyang for the fix! -- Fixed an issue where a file deleted from storage could be resurrected by the offline scanner because the database tombstone was not written when the storage file was already gone (commonlib PR #56). Thank you so much for @cosmic-fire-eng for the fix! +#### Improved -### Improved +- Added settings to control whether finite synchronisation operations keep the screen awake. Desktop devices now allow automatic sleep by default, while mobile devices retain screen-awake protection unless the general option is enabled (#1073). -- Local database maintenance commands now ask before applying the required chunk settings, and can apply those prerequisites before continuing (#980, PR #981). Thank you so much for @apple-ouyang for the improvement! -- Improved CouchDB replication event handling by using the new `StreamInbox` helper from `octagonal-wheels` (commonlib PR #62). +### Interface and translation -### Documentation +#### Improved -- Added `nginx` to the setup documentation table of contents (PR #976). Thank you so much for @kiraventom for the improvement! +- Korean translations now cover the complete current catalogue across Setup, P2P, remote configuration, diagnostics, and maintenance (PR #1075). Thank you to @motolies for the improvement! -### Miscellaneous +#### Fixed -- Updated `octagonal-wheels` to `0.1.47` across the plug-in and workspace packages to use the newly published helper modules. +- The in-editor LiveSync status on iOS now remains below the view-header controls instead of overlapping them (PR #1067). Thank you to @Hsiii for the improvement! -## 0.25.78 +## 1.0.4 -23rd June, 2026 +5th August, 2026 -### Fixed -- No longer fast synchronisation (a.k.a. Fast Fetch) causes a rewind and re-fetch of the entire database when some errors occur during the process (#972, PR #973). Thank you so much for @apple-ouyang for the fix! +### Synchronisation and storage -### Improved +#### Fixed -- Overhauled the Object Storage (e.g., MinIO and S3) replication engine ('Journal Replicator 2nd Edition'). - - It now leverages the standard Web Streams API for a resilient, backpressure-aware architecture, reducing memory footprints/temporary storage usage on large vaults. - - Decoupled the physical storage logic to make it easier to add new storage backends in the future. - - Stricter compliance with CouchDB's replication protocol (proper `_revisions` transfers with `new_edits: false`) when using Object Storage. +- Testing or saving a fresh remote configuration no longer tries to access the local database while constructing a replicator, avoiding 'Local database is not ready yet' failures before local database initialisation (#1064). -### Testing - - Added comprehensive unit tests for the new `JournalSyncCore` engine, covering streams, backpressure, and `new_edits: false` validation. - - Improved integration test workflows in the CI pipeline to run MinIO tests automatically using standard environment variables. +### Command-line tool -## 0.25.77 +#### Fixed -19th June, 2026 +- Successful setup and remote-configuration commands now retain their settings changes. Other commands leave the settings file unchanged unless `--write-settings` is supplied, and temporary CLI suspension values are never written (#1070). -This update is mostly meaningless for users. But for maintainers, not, I hope. I wonder if I were done well in the start, there would be no hassles. It really was a great opportunity. +## 1.0.3 -Also, this update is a very large one, even if we had a lot of time, and we had CI tests, and mostly only fixing the types. Please let me know if you find any issues! +3rd August, 2026 -### Improved +### Synchronisation and storage -- File deletion now respects the user's deletion preferences (by utilising the `FileManager.trashFile` API) on Obsidian v1.7.2 or newer, regardless of the plug-in's internal trashbin setting. +#### Fixed -### Miscellaneous -- Typings of the library are now included -- Many typing errors have been improved. -- Import paths have been normalised to be relative to the root and to the `lib/src` directory, to avoid breaking the boundary between the library and the plug-in. -- Subprojects, such as the CLI and the webapp, are now in the workspace. +- File consistency checks no longer read older revisions after the current Vault content matches known synchronised history, avoiding unnecessary 'Missing document content' warnings from obsolete unreadable revisions. +- Remote chunk fetching now retains chunks which were returned successfully when another chunk in the same request is unavailable, so the available content can still be processed (#771). -## 0.25.76 +### Interface -15th June, 2026 +#### Fixed -### Fixed +- The Remediation setting now displays its configured modification-time limit without raising a `HierarchyRequestError`. -- Now the S3 connection with custom headers works properly (#875). - - Previously, custom headers injected for proxy authentication were incorrectly included in the AWS Signature v4 calculation. This led to a '400 Bad Request' error (such as 'signed header is not present') on strict S3 backends (for example, Garage), or when reverse proxies modified, renamed, or stripped these headers before they reached the storage service. -- No longer connection information of the P2P synchronisation is broken on the specific platform (#956). +## 1.0.2 -## 0.25.75 +31st July, 2026 -13th June, 2026 +I am aware that some of the Community Directory review checks have become a little more sensitive again. I will watch them for a little longer, then consider the most appropriate way to adapt. -### Fixed +### Synchronisation and storage -- Fixed an issue where using fast synchronisation caused a TypeError in some environments (#953). +#### Improved -### New features -- Now we can configure to keep replication active in the background on desktop platforms (#939, PR #949). Thank you so much for @migsferro! +- Downloaded document batches retain best-effort screen-awake and lifecycle protection until every queued file has been applied to local storage, without extending the remote-activity indicator (#1031, PR #1032). Thank you to @apple-ouyang for the improvement! -### Fixed (CLI, automated) +#### Fixed -- Fixed an issue where the mirror command could fail to apply updates when conflict preservation checks prevented overwriting unsynchronised local changes, even when the `force` parameter or `writeDocumentsIfConflicted` setting was enabled. +- Leading UTF-8 byte order marks are preserved during Vault ingestion, keeping stored content sizes consistent with file metadata and preventing persistent three-byte integrity mismatches (#1056, PR #1058). -### Improved +### Interface and translation -- (CLI) Ported the remaining bash regression tests (`test-daemon-linux.sh`, `test-decoupled-vault-linux.sh`, and `test-remote-commands-linux.sh`) to Deno for cross-platform validation. +#### Improved -### Miscellaneous -- Some dependencies have been updated. -- Now we check the compatibility with iOS 15 in the CI tests to ensure the plugin continues to work on older iOS versions even after we upgrade some dependencies. +- Document History now provides previous and next revision controls, reports the current revision position, and disables navigation at the oldest and newest boundaries without changing search-result navigation (#990, PR #1009). Thank you to @SeleiXi for the improvement! +- Remaining user-visible text in Setup, P2P, Customisation Sync, Global History, JSON conflict handling, and remote configuration now uses the translation catalogue (PR #1015). Thank you to @zeedif for the improvement! +- Korean translations have broader coverage and corrections for placeholders, punctuation, and established terminology (PR #1055). Thank you to @motolies for the improvement! +- Spanish translation coverage has been expanded across settings, Setup, P2P, maintenance, and newly catalogued interface text (PR #1059). Thank you to @zeedif for the improvement! -Full notes are in -[updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). +### Command-line tool + +#### Fixed + +- Large-buffer base64 encoding under Node.js now uses the published `octagonal-wheels` fallback when `FileReader` is unavailable, including correctly handling sliced binary views (#1036, PR #1060; [Fancy Kit PR #44](https://github.com/vrtmrz/fancy-kit/pull/44)). + +## 1.0.1 + +29th July, 2026 + +I am taking this opportunity to update the experimental features as well. + +This maintenance release mainly improves the robustness and maintainability of the experimental WebApp, WebPeer, and shared dialogue composition. Most plug-in users can skip it. I have reviewed the changes through CI and a real Obsidian instance, and I will validate the exact published build before merging the release commit. + +### Interface + +#### Improved + +- Removed a custom positioning workaround from the onboarding Notice so that it follows Obsidian's standard placement and dismissal behaviour. +- WebApp now points users to **Scan local files** when automatic file observation is unavailable, instead of relying on a fixed browser-version recommendation. + +## 1.0.0 + +27th July, 2026 + +The work towards 1.0 has become so substantial that I have written [an article about it](https://fancy-syncing.vrtmrz.net/blog/0036-livesync-1_0_0-en.html) (linked again here). + +### Setup and compatibility + +#### Improved + +- An unconfigured Vault now waits for the user to start setup. Onboarding is offered through a persistent Notice and remains available from **Self-hosted LiveSync settings** → **Setup**. +- Setup now creates named CouchDB, Object Storage, and P2P connections. Setup URIs preserve their connection names and selections, and reserve Fetch or Rebuild before the ordinary start-up scan begins. +- Manual CouchDB setup distinguishes creating the first database from connecting another device. Onboarding requires a successful connection, while Settings can explicitly save an unverified connection and offers each server-setting correction separately. +- Compatible differences limited to the chunk hash algorithm, chunk size, or splitter version are aligned automatically by default. Existing chunks remain readable, an explicit opt-out remains available, and differences involving incompatible settings still require review. + +#### Fixed + +- Existing Vaults retain their effective legacy settings, including the case-insensitive file-name fallback used when an older release had no explicit case setting. + +#### Security + +- Fly.io setup generates CouchDB and Vault encryption secrets with cryptographically secure randomness. +- Dependency updates address excessive CPU use from crafted path patterns and `mailto:` links. + +### Conflict handling and recovery + +#### Improved + +- **Not now** postpones repeated automatic merge dialogues while retaining the unresolved-conflict warning. Three or more live revisions are reviewed one reproducible pair at a time, completed pairs remain resolved across restart, and explicit commands can reopen a postponed conflict. +- **Inspect conflicts and file/database differences** compares the Vault with the database winner and every live conflict revision. Compact indicators show missing chunks, `Δsize`, `Δtime`, whether the Vault matches the winner, and whether conflicts remain. +- Each reported file and live revision has a compact wrench menu for comparison, applying an exact readable revision, recording an exact byte match, storing the Vault content as a child of a selected branch, retrying missing chunks without changing the tree, or explicitly discarding one selected live branch. + +#### Fixed + +- Automatic text and structured-data merge now uses the nearest revision actually shared by both branches. A resolution received from another device no longer recreates the same conflict merely because the Vault still contains the exact content of the removed branch. +- Edits, logical deletions, and renames made while a file remains conflicted extend the revision displayed on that device. When the relationship cannot be proved, LiveSync preserves the branches for review. +- Unreadable live revisions are preserved during automatic handling. An absent Vault file and a winning logical deletion are treated as agreement unless another live branch still requires attention. +- Garbage Collection V3 is limited to CouchDB and now protects every live conflict branch, required shared ancestry, and shared chunks. It stops when device progress cannot be verified and reports compaction failure without a contradictory success message. + +### P2P and optional synchronisation features + +#### Improved + +- P2P and Hidden File Sync remain supported opt-in features. Customisation Sync remains a supported Advanced workflow, while Data Compression remains available but disabled by default. +- P2P controls remain outside the ordinary CouchDB experience until P2P is configured. The current status pane distinguishes announcing changes, following a peer, and persistent per-device actions. +- P2P setup and guidance now distinguish the required signalling relay from optional TURN and describe the replaceable public relay's privacy and availability limits. +- Enabling Hidden File Sync opens one progress Notice before saving the setting and reuses it until the initial scan has finished instead of stacking phase, reload, and restart messages. + +#### Fixed + +- First-device P2P setup can complete its signalling test without another peer online. Fetch on an additional device still requires an available source peer and a completed P2P Rebuild. +- P2P relay connections now close and are recreated reliably after settings changes and database resets. + +### Interface, translation, and operations + +#### Improved + +- Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands retain their identifiers so that existing hotkeys continue to work. +- Setup and review dialogue text can be selected for copying or translation. +- Remote-size warnings use persistent clickable Notices. Initial uploads and Rebuild no longer ask to send every chunk in advance; ordinary replication completes the transfer. +- Obsolete controls for the plug-in trash setting and fixed chunk revisions were removed. The Change Log remains available but no longer opens automatically or tracks an unread count. +- Self-hosted LiveSync now owns its translation catalogue. Commonlib supplies canonical English to other consumers, while translation contributions can be made in the main Self-hosted LiveSync repository. + +#### Fixed + +- Applying an available interface translation no longer holds start-up behind an unsolicited dialogue; a persistent Notice opens the existing details on demand. +- Action buttons are arranged for narrow mobile screens, long dialogues keep their controls reachable, and persistent Notices no longer cover close controls. + +### Storage and file selection + +#### Fixed + +- The optional Custom HTTP Handler used by Object Storage sends the correct byte range from binary request bodies and reports unsupported body types instead of silently sending an empty request. +- Broadening selectors, ignore rules, size or modification-time limits, or file-name case handling now rechecks previously received files without requiring another remote update. +- Start-up and full-inspection scans omit built-in legacy LiveSync log files and recovery flag files before comparing Vault and local-database state. Existing ignored database records remain untouched, and user-configured ignore behaviour is unchanged. + +### Command-line tool + +#### Fixed + +- CLI Setup URI validation now uses the supported Commonlib ESM package interface. +- The non-root Docker image no longer depends on permissions inherited from the source checkout. + +#### Security + +- The CLI rejects detected path traversal and symbolic-link components before Vault operations. + +### Validation + +#### Testing + +- Expanded automated Real Obsidian coverage for upgrades, two-device synchronisation, CouchDB, Object Storage, P2P, Hidden File Sync, mobile dialogues, conflict and revision recovery, failure diagnostics, and strict clean-up. +- Real CouchDB integration coverage verifies logical deletion, shared and conflict chunk retention, compaction, downstream replication, and recreation of content-addressed chunks. +- An encrypted Real Obsidian reconnect scenario replaces the remote Security Seed while one client retains the previous value, verifies that synchronisation adopts the replacement without restoring the old value, and proves a bidirectional encrypted round-trip. +- The plug-in code in this release was installed through BRAT and validated on macOS, iOS, and Android, including upgrade from 0.25.83, bidirectional synchronisation, P2P setup, conflict handling, recovery controls, mobile layouts, and start-up with existing configurations. +- Native and non-root Docker CLI scenarios cover setup, write, read, list, information, deletion, conflict resolution, and revision retrieval with the packaged Commonlib dependency. diff --git a/updates_old.md b/updates_old.md index ad706bf8..6d6e4ee6 100644 --- a/updates_old.md +++ b/updates_old.md @@ -1,3553 +1,10 @@ -# 0.25 -Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025) +# Release history has moved -The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope. +The release history is now kept as one chronological sequence across smaller files: +- [Current 1.x releases](updates.md) +- [1.0 beta and release-candidate history](docs/releases/1.0-previews.md) +- [0.25 releases](docs/releases/0.25.md) +- [Releases before 0.25](docs/releases/legacy.md) -## 0.25.80 - -7th July, 2026 - -### Fixed - -- Improved Markdown conflict auto-merge so that non-overlapping edits are merged while overlapping delete-and-edit cases remain visible for manual resolution (#993). - - Behaviour change: - - When one side deletes an unchanged line and the other side edits a different region, the deleted line is no longer reintroduced into the merged result. - - When one side deletes a line and the other side modifies that same line, the conflict is preserved instead of silently choosing one side. -- Fixed an issue where applying a newer database entry to storage could incorrectly preserve an older local file as a conflict (#994). - - Behaviour change: - - Local storage is preserved as a conflict when it may contain unsynchronised changes that are not represented in the revision history. A newer incoming text entry is applied without creating a conflict only when it clearly extends the existing local text. -- Fixed an issue where choosing Disable and then Overwrite in Hidden File Sync could silently skip hidden files, because the overwrite setup ran while hidden file synchronisation was still disabled (#989, PR #992). - - Hidden File Sync is now re-enabled before the Fetch, Overwrite, or Merge initialisation runs, instead of after it completes. If that initialisation fails, the setting may remain enabled. - -## 0.25.79 - -29th June, 2026 - -### Fixed - -- Fast Fetch now retries transient stream interruptions and resumes from the latest persisted checkpoint, instead of starting over after ordinary network or platform interruptions (#977, PR #978; commonlib PR #59). Thank you so much for @apple-ouyang for the fix! -- Simple Fetch now remembers the selected setup choices while an interrupted Fetch All operation is still pending, so users are not asked the same questions again on retry (#977, PR #978). Thank you so much for @apple-ouyang for the fix! -- No longer hidden storage events, such as `.git` paths, reach the normal target-file filter when internal file synchronisation is disabled. This avoids noisy non-target logs before those files are skipped (commonlib PR #60). Thank you so much for @apple-ouyang for the fix! -- Fixed an issue where a file deleted from storage could be resurrected by the offline scanner because the database tombstone was not written when the storage file was already gone (commonlib PR #56). Thank you so much for @cosmic-fire-eng for the fix! - -### Improved - -- Local database maintenance commands now ask before applying the required chunk settings, and can apply those prerequisites before continuing (#980, PR #981). Thank you so much for @apple-ouyang for the improvement! -- Improved CouchDB replication event handling by using the new `StreamInbox` helper from `octagonal-wheels` (commonlib PR #62). - -### Documentation - -- Added `nginx` to the setup documentation table of contents (PR #976). Thank you so much for @kiraventom for the improvement! - -### Miscellaneous - -- Updated `octagonal-wheels` to `0.1.47` across the plug-in and workspace packages to use the newly published helper modules. - -## 0.25.78 - -23rd June, 2026 - -### Fixed -- No longer fast synchronisation (a.k.a. Fast Fetch) causes a rewind and re-fetch of the entire database when some errors occur during the process (#972, PR #973). Thank you so much for @apple-ouyang for the fix! - -### Improved - -- Overhauled the Object Storage (e.g., MinIO and S3) replication engine ('Journal Replicator 2nd Edition'). - - It now leverages the standard Web Streams API for a resilient, backpressure-aware architecture, reducing memory footprints/temporary storage usage on large vaults. - - Decoupled the physical storage logic to make it easier to add new storage backends in the future. - - Stricter compliance with CouchDB's replication protocol (proper `_revisions` transfers with `new_edits: false`) when using Object Storage. - -### Testing - - Added comprehensive unit tests for the new `JournalSyncCore` engine, covering streams, backpressure, and `new_edits: false` validation. - - Improved integration test workflows in the CI pipeline to run MinIO tests automatically using standard environment variables. - -## 0.25.77 - -19th June, 2026 - -This update is mostly meaningless for users. But for maintainers, not, I hope. I wonder if I were done well in the start, there would be no hassles. It really was a great opportunity. - -Also, this update is a very large one, even if we had a lot of time, and we had CI tests, and mostly only fixing the types. Please let me know if you find any issues! - -### Improved - -- File deletion now respects the user's deletion preferences (by utilising the `FileManager.trashFile` API) on Obsidian v1.7.2 or newer, regardless of the plug-in's internal trashbin setting. - -### Miscellaneous -- Typings of the library are now included -- Many typing errors have been improved. -- Import paths have been normalised to be relative to the root and to the `lib/src` directory, to avoid breaking the boundary between the library and the plug-in. -- Subprojects, such as the CLI and the webapp, are now in the workspace. - -## 0.25.76 - -15th June, 2026 - -### Fixed - -- Now the S3 connection with custom headers works properly (#875). - - Previously, custom headers injected for proxy authentication were incorrectly included in the AWS Signature v4 calculation. This led to a '400 Bad Request' error (such as 'signed header is not present') on strict S3 backends (for example, Garage), or when reverse proxies modified, renamed, or stripped these headers before they reached the storage service. -- No longer connection information of the P2P synchronisation is broken on the specific platform (#956). - -## 0.25.75 - -13th June, 2026 - -### Fixed - -- Fixed an issue where using fast synchronisation caused a TypeError in some environments (#953). - -### New features -- Now we can configure to keep replication active in the background on desktop platforms (#939, PR #949). Thank you so much for @migsferro! - -### Fixed (CLI, automated) - -- Fixed an issue where the mirror command could fail to apply updates when conflict preservation checks prevented overwriting unsynchronised local changes, even when the `force` parameter or `writeDocumentsIfConflicted` setting was enabled. - -### Improved - -- (CLI) Ported the remaining bash regression tests (`test-daemon-linux.sh`, `test-decoupled-vault-linux.sh`, and `test-remote-commands-linux.sh`) to Deno for cross-platform validation. - -### Miscellaneous -- Some dependencies have been updated. -- Now we check the compatibility with iOS 15 in the CI tests to ensure the plugin continues to work on older iOS versions even after we upgrade some dependencies. - -## 0.25.74 - -8th June, 2026 - -### Fixed - -- Fixed an issue where disabling hidden file synchronisation did not take effect, allowing non-target hidden files to continue to be processed and synchronised by replication or boot-sequence scan (#941). -- Prevented the automatic merging of conflicted revisions when one of the revisions has been deleted, which was causing deleted files to reappear (#911). -- The startup sequence now saves the state more effectively (Thank you so much for @bmcyver)! - -## Only CLI - -8th June, 2026 - -I should also consider the version numbering for the CLI... - -### Improved - -- Added new remote database management commands: `remote-status`, `unlock-remote`, `lock-remote`, and `mark-resolved`. -- --vault option is now available for daemon and mirror commands! (Thank you so much for @starskyzheng)! -- Decoupled the database directory path from the actual vault directory path using the `--vault` (or `-V`) option. - -### Fixed (preventive) - -- Validated that the specified vault path exists and is indeed a directory before starting the CLI. -- Integrated path resolution and validations for one-off commands (such as `'push'`, `'pull'`, `'cat'`, `'rm'`, `'info'`, and `'resolve'`) against the decoupled vault path instead of the database path. - -## 0.25.73 - -4th June, 2026 - -### Fixed - -- Adjust CouchDB's database name checking to its specification (#926). -- `Reset Syncronisation on This Device` for minio and P2P is now working properly. - -## ~~0.25.71~~ 0.25.72 - -0.25.71 was cancelled due to the fixes needed (Object Storage related) - -3rd June, 2026 - -### Improved - -- Database fetching (a.k.a. Reset Synchronisation on This Device) on the initialisation now supports streaming and is faster (CouchDB only) -- The database fetching process has been streamlined, and database operations are now suspended until it has been completed -- The initial synchronisation process has been simplified, making it easier to synchronise files with the remote server -- We can select the remote database to fetch from during the initialisation, when there are multiple remote databases configured (e.g. multiple CouchDBs or S3 remotes) -- Hebrew (he) Translation has been added (Thank you so much, @MusiCode1)! -- Translation loading time has been reduced (Thank you so much, @bmcyver)! - -### Fixed - -- No longer does the status element break other plugins' interaction (#930). -- No longer does file events occured during initial database fetching using Object Storage. - -### Refactored - -To support the new Community automated tests, we fixed numerous lint warnings. This may have also resolved potential issues. - -## 0.25.70 - -25th May, 2026 - -### New features -- Diff dialogue now has great tools to navigate and understand the differences, including: - - A checkbox to toggle the visibility of collapsed identical sections, making it easier to focus on the actual differences (PR #889). - - A search feature to find specific text in past revisions, and navigate revisions with search results highlighted in the dialogue (PR #890). - -- Conflict resolution dialogue now has a navigation feature to jump between conflicts (PR #891). - -Thank you so much to @SeleiXi for implementing these features! - -### Improved - -- More diagnostic information for P2P connections is now shown, including why a connection failure occurred and the current connection status. - -## 0.25.69 - -22nd May, 2026 - -### Fixed -- No longer does the P2P passphrase mismatch cause a server shutdown. -- Settings related to P2P synchronisation are now correctly applied on start-up and no longer reverted. - -### New features -- Diagnostic P2P connection stats are now available. - - These stats indicate the number of connection trials, successes, and failures. - -## 0.25.68 - -22nd May, 2026 - -### Improved - -- P2P connections have improved slightly - - Upgrade to `trystero` v0.24.0, and fixes event handler assignment. This should fix some edge cases where P2P connections fail to establish or messages are not properly handled. - - Weaken terser options to avoid potential issues with minification that could cause runtime errors in some environments. - -## ~~0.25.66~~ 0.25.67 - -20th May, 2026 - -0.25.66 had a bug that the auto-accept logic for compatible but lossy mismatches was not working as intended. - -### New features -- Implement an auto-accept compatible tweak setting and enhance the mismatch resolution logic. - -### Improved -- Many messages related to tweak mismatch resolution have been updated for clarity. - -## 0.25.65 - -19th May, 2026 - -### Fixed -- Fix an issue about resuming from background on iOS (#888). -- Now Chunk Splitter: `V3: Fine Deduplication` is working fine again (#866). - - It has some drawbacks, such as fewer chunks are generated. However, it makes less transfer and storage when the files are modified but not completely changed. -- Unsynchronised local changes (which means changes that have not been sent) are now correctly preserved as a conflict (Thank you so much for @SeleiXi!). -- Avoid creating a new revision when the current and conflicted revisions have identical content (Thank you so much for @daichi-629). - -### Improved -- Improved the error verbosity on concurrent processing during the start-up process. -- Now the `report` includes recent logs (of verbosity `verbose` even settings is not set to `verbose`). -- Updating logs is now debounced to avoid excessive updates during rapid log generation. -- Added a `Generate full report for opening the issue with debug info` command to the command palette, which generates a report without opening the settings dialogue. - -## 0.25.64 - -17th May, 2026 - -### P2P Status Pane - -- Added active P2P remote selector (combo box) and `+` action to create/select a P2P remote from the P2P setup dialogue. -- Added per-peer immediate replication action on accepted peers. -- Updated status control icons for clarity: - - Replicate now: `🔄` (`âŗ` while running) - - Watch: `🔔` / `🔕` - - Sync target: `🔗` / `â›“ī¸â€đŸ’Ĩ` -- Added warning state when no active P2P remote is selected. - -### P2P Status Card - -- Added stable Room ID suffix display and placed it above Peer ID for better identification. - -### Non behavioural internal changes - -#### P2P - -- Added `P2P_ActiveRemoteConfigurationId` as a dedicated active remote selection for P2P features, separate from the normal active remote. -- Added activation logic for P2P dedicated remote configuration that reflects P2P settings while keeping `remoteType` unchanged. -- Added migration support to carry over P2P active remote selection when appropriate. -- Added shared Room ID utility functions and applied them across P2P setup and P2P panes. - -#### Tests - -- Added/updated unit test coverage around settings load behaviour for P2P active remote application. - -## 0.25.63 - -17th May, 2026 - -### Fixed -- The issue which cannot synchronise in Only-P2P mode has been fixed. -- Fixed an issue where "Failed to connect to the remote server" was shown during the redFlag rebuild flow when P2P was the primary remote type. Remote configuration fetch is now skipped for P2P. - -### P2P Replication UI Improvements -- Brand-new P2P Server Status pane has been added to provide real-time visibility into your connection status and peer network. - - For detailed instructions on using the new P2P features, please refer to the updated [User Guide: Peer-to-Peer Synchronisation (2026 Edition)](./docs/p2p_sync_updates_2026.md). -- Now `Replicate` button or ribbon icon opens a redesigned interactive replication dialogue that performs smart bidirectional sync with a single click. -- The vault rebuild flow (`replicateAllFromServer`) now opens the redesigned P2P Replication modal instead of a plain text selection dialogue, providing a consistent UI experience. - -## 0.25.62 - -14th May, 2026 - -### Fixed - -- Fixed an issue where a connection could not be established when attempting to connect to a brand-new remote database without going through the set-up wizard or configuration checking (#660). - -## 0.25.61 - -13th May, 2026 - -Reviews have started on the Obsidian Community, haven't they? It was quite a struggle, what with having to fix the outdated ESLint. -I am a bit nervous, but it is far better than just plodding along aimlessly, so let us get on with it. If you spot any issues, please let me know straight away. - -From now on, I am avoiding committing directly to the main branch. This is because you lots have all been sending so much PRs. I wanted to keep things harmonious. -That said, I am still not used to rebasing, so there are some parts where the commit history is a right mess. I will work on improving that. - -### Improved - -- P2P synchronisation has been made more robust - Now the foundation for P2P synchronisation has been rewritten, and the unit tests have been added. The foundation has been separated into the transport layer, signalling-and-connection layer, and, an RPC layers. And each layer has been unit-tested. As the result, the P2P synchronisation now uses the robust shim that uses RPC-ed PouchDB synchronisation in contrast to previous implementation. -This P2P synchronisation is not compatible with previous versions in terms of connectivity. All devices must be updated. - -### Fixed - -- No longer baffling errors occur when setting-update is triggered during the early stage of initialisation. -- Network error notice pop-ups are now suppressed when 'NetworkWarningStyle' is set to 'Hidden'. (Thank you so much @SeleiXi!) - -### New features - -- Diff navigation buttons have been added to the diff view, making it easier to move between differences. (Thank you so much @SeleiXi! #871) - -### Translations - -- Chinese (Simplified) translations for settings and the Setup Wizard have been added. (Thank you so much @zombiek731!) -- Common UI controls and signal words are now localised into Chinese (Simplified). (Thank you so much @zombiek731!) -- i18n runtime behaviour and locale coverage have been improved. (Thank you so much @52sanmao!) - -### CLI - -#### New features - -- Daemon synchronisation is now supported. (Thank you so much @andrewleech! #843) -- `HeadlessConfirm` has been implemented with sensible defaults, enabling unattended operation in headless environments. (Thank you so much @andrewleech!) -- The CLI onboarding experience has been improved. (Thank you so much @OriBoharon! #872) - -#### Fixed - -- Sub-millisecond CLI mtimes are now truncated to prevent mobile crash. (Thank you so much @brian-spackman! #893) - -## 0.25.60 - -29th April, 2026 - -### Fixed - -- Now larger settings can be exported and imported via QR code without issues. (#595) - - When the settings data exceeds the QR code capacity, it is now split into multiple QR codes. - - These QR codes are reassembled by the aggregator page, which collects the split data and reconstructs the original settings. - - Aggregator page is available at `https://vrtmrz.github.io/obsidian-livesync/aggregator.html`, and this file is also included in the repository. - - We will not send the settings data to any server. The QR code data is generated and processed entirely on the client side, ensuring that your settings remain private and secure. HOWEVER, please be careful your network environment. -- Fixed some errors during serialisation and deserialisation of the settings, which caused issues in some cases when importing/exporting settings via QR code. - -### Fixed (CLI) - -- `ls` and `mirror` commands now provide informative feedback when no documents are found or filters skip all files, resolving the issue where they would exit silently (#860). - - Improved the clarity of CLI command logs by including the total count of processed items. -- The command-line argument `vault` has been renamed to a more appropriate name, `databaseDir`. -- The `mirror` command now accepts a `vault` directory, which specifies the location where the actual files are stored. For compatibility reasons, the previous behaviour is still supported. - -## 0.25.59 - -### Fixed - -- No longer Setup-wizard drops username and password silently. (#865) - - Thank you so much for @koteitan ! -- Setup URI is now correctly imported (#859). - - Also thank you so much for @koteitan ! - -### Improved - -- now French translation is added by @foXaCe ! Thank you so much! - -## 0.25.58 - -### Fixed - -- No longer credentials are broken during object storage configuration (related: #852). -- Fixed a worker-side recursion issue that could raise `Maximum call stack size exceeded` during chunk splitting (related: #855). -- Improved background worker crash cleanup so pending split/encryption tasks are released cleanly instead of being left in a waiting state (related: #855). -- On start-up, the selected remote configuration is now applied to runtime connection fields as well, reducing intermittent authentication failures caused by stale runtime settings (related: #855). -- Issue report generation now redacts `remoteConfigurations` connection strings and keeps only the scheme (e.g. `sls+https://`), so credentials are not exposed in reports. -- Hidden file JSON conflicts no longer keep re-opening and dismissing the merge dialogue before we can act, which fixes persistent unresolvable `data.json` conflicts in plug-in settings sync (related: #850). - -## 0.25.57 - -9th April, 2026 - -- Packing a batch during the journal sync now continues even if the batch contains no items to upload. -- No unexpected error (about a replicator) during the early stage of initialisation. -- Now error messages are kept hidden if the show status inside the editor is disabled (related: #829). -- Fixed an issue where devices could no longer upload after another device performed 'Fresh Start Wipe' and 'Overwrite remote' in Object Storage mode (#848). - - Each device's local deduplication caches (`knownIDs`, `sentIDs`, `receivedFiles`, `sentFiles`) now track the remote journal epoch (derived from the encryption parameters stored on the remote). - - When the epoch changes, the plugin verifies whether the device's last uploaded file still exists on the remote. If the file is gone, it confirms a remote wipe and automatically clears the stale caches. If the file is still present (e.g. a protocol upgrade without a wipe), the caches are preserved, and only the epoch is updated. This means normal upgrades never cause unnecessary re-processing. - -### Translations - -- Russian translation has been added! Thank you so much for the contribution, @vipka1n! (#845) - -### New features - -- Now we can configure multiple Remote Databases of the same type, e.g, multiple CouchDBs or S3 remotes. - - A user interface for managing multiple remote databases has been added to the settings dialogue. I think no explanation is needed for the UI, but please let me know if you have any questions. -- We can switch between multiple Remote Databases in the settings dialogue. - -### CLI - -#### Fixed - -- Replication progress is now correctly saved and restored in the CLI (related: #846). - -## ~~0.25.55~~ 0.25.56 - -30th March, 2026 - -### Fixed - -- No longer `Peer-to-Peer Sync is not enabled. We cannot open a new connection.` error occurs when we have not enabled P2P sync and are not expected to use it (#830). - -### CLI - -- Fixed incomplete localStorage support in the CLI (#831). Thank you so much @rewse ! -- Fixed the issue where the CLI could not be connected to the remote which had been locked once (#833), also thanks to @rewse ! - -## 0.25.54 - -18th March, 2026 - -### Fixed - -- Remote storage size check now works correctly again (#818). -- Some buttons on the settings dialogue now respond correctly again (#827). - -### Refactored - -- P2P replicator has been refactored to be a little more robust and easier to understand. -- Delete items which are no longer used that might cause potential problems - -### CLI - -- Fixed the corrupted display of the help message. -- Remove some unnecessary code. - -### WebApp - -- Fixed the issue where the detail level was not being applied in the log pane. -- Pop-ups are now shown. -- Add coverage for the test. -- Pop-ups are now shown in the web app as well. - -## 0.25.53 - -17th March, 2026 - -I did wonder whether I should have released a minor version update, but when I actually tested it, compatibility seemed to be intact, so I didn’t. Hmm. - -### Fixed - -#### P2P Synchronisation - -- Fixed flaky timing issues in P2P synchronisation. -- No longer unexpected `Unhandled Rejections` during P2P operations (waiting for acceptance). - -#### Journal Sync - -- Fixed an issue where some conflicts cannot be resolved in Journal Sync. -- Many minor fixes have been made for better stability and reliability. - -### Tests - -- Rewrite P2P end-to-end tests to use the CLI as a host. - -### CLI - -We have previously developed FileSystem LiveSync and various other components in a separate repository, but updates have been significantly delayed, and we have been plagued by compatibility issues. Now, a CLI tool using the same core logic is emerging. This does not directly manipulate the file system, but it offers a more convenient way of working and can also communicate with Object Storage. We can also resolve conflicts. Please refer to the code in `src/apps/cli` for the [self-hosted-livesync-cli](./src/apps/cli/README.md) for more details. -- Add `self-hosted-livesync-cli` to `src/apps/cli` as a headless and dedicated version. -- P2P sync and Object Storage are also supported in the CLI. - - Yes, we have finally managed to 'get one file'. - - Also, no more need for a [LiveSync PeerServer](https://github.com/vrtmrz/livesync-serverpeer) for virtual environments! The CLI can do it. - -- Now binary files are also supported in the CLI. - -### Refactored or internal changes - -- ServiceFileAccessBase now correctly handles the reading of binary files. -- HeadlessAPIService now correctly provides the online status (always online) to the plug-in. -- Non-worker version of bgWorker now correctly handles some functions. -- Separated `ObsidianLiveSyncPlugin` into `ObsidianLiveSyncPlugin` and `LiveSyncBaseCore`. -- Now `LiveSyncCore` indicates the type specified version of `LiveSyncBaseCore`. -- Referencing `plugin.xxx` has been rewritten to referencing the corresponding service or `core.xxx`. -- Offline change scanner and the local database preparation have been separated. -- Set default priority for processFileEvent and processSynchroniseResult for the place to add hooks. -- ControlService now provides the readiness for processing operations. -- DatabaseService is now able to modify database opening options on derived classes. -- Now `useOfflineScanner`, `useCheckRemoteSize`, and `useRedFlagFeatures` are set from `main.ts`, instead of `LiveSyncBaseCore`. -- Storage Access APIs are now yielding Promises. This is to allow more limited storage platforms to be supported. -- Journal Replicator now yields true after the replication is done. - -### R&D - -- Browser-version of Self-hosted LiveSync is now in development. This is not intended for public use now, but I will eventually make it available for testing. -- We can see the code in `src/apps/webapp` for the browser version. - - -## 0.25.52-patched-3 - -16th March, 2026 - -### Fixed - -- Fixed flaky timing issues in P2P synchronisation. -- Fixed more binary file handling issues in CLI. - -### Tests - -- Rewrite P2P end-to-end tests to use the CLI as host. - - -## 0.25.52-patched-2 - -14th March, 2026 - -### Fixed - -- No longer unexpected `Unhandled Rejections` during P2P operations (waiting acceptance). -- Fixed an issue where conflicts cannot be resolved in Journal Sync - -### CLI new features - -- `mirror` command has been added to the CLI. This command is intended to mirror the storage to the local database. -- `p2p-sync`, `p2p-peers`, and `p2p-host` commands have been added to the CLI. These commands are intended for P2P synchronisation. - - Yes, no more need for a [LiveSync PeerServer](https://github.com/vrtmrz/livesync-serverpeer) for virtual environments! The CLI can handle it by itself. - -## 0.25.52-patched-1 - -12th March, 2026 - -### Fixed - -- Fixed Journal Sync had not been working on some timing, due to a compatibility issue (for a long time). -- ServiceFileAccessBase now correctly handles the reading of binary files. -- HeadlessAPIService now correctly provides the online status (always online) to the plug-in. -- Non-worker version of bgWorker now correctly handles some functions. - -### Refactored - -- Separated `ObsidianLiveSyncPlugin` into `ObsidianLiveSyncPlugin` and `LiveSyncBaseCore`. -- Now `LiveSyncCore` indicates the type specified version of `LiveSyncBaseCore`. -- Referencing `plugin.xxx` has been rewritten to referencing the corresponding service or `core.xxx`. -- Offline change scanner and the local database preparation have been separated. -- Set default priority for processFileEvent and processSynchroniseResult for the place to add hooks. -- ControlService now provides the readiness for processing operations. -- DatabaseService is now able to modify database opening options on derived classes. -- Now `useOfflineScanner`, `useCheckRemoteSize`, and `useRedFlagFeatures` are set from `main.ts`, instead of `LiveSyncBaseCore`. - -### Internal API changes - -- Storage Access APIs are now yielding Promises. This is to allow more limited storage platforms to be supported. -- Journal Replicator now yields true after the replication is done. - -### CLI - -We have previously developed FileSystem LiveSync and various other components in a separate repository, but updates have been significantly delayed, and we have been plagued by compatibility issues. Now, a CLI tool using the same core logic is emerging. This does not directly manipulate the file system, but it offers a more convenient way of working and can also communicate with Object Storage. We can also resolve conflicts. Please refer to the code in `src/apps/cli` for the [self-hosted-livesync-cli](./src/apps/cli/README.md) for more details. - -- Add `self-hosted-livesync-cli` to `src/apps/cli` as a headless and dedicated version. -- Add more tests. -- Object Storage support has also been confirmed (and fixed) in CLI. - - Yes, we have finally managed to 'get one file'. -- Now binary files are also supported in the CLI. - -### R&D - -- Browser-version of Self-hosted LiveSync is now in development. This is not intended for public use now, but I will eventually make it available for testing. -- We can see the code in `src/apps/webapp` for the browser version. - - -## 0.25.52 - -9th March, 2026 - -Excuses: Too much `I`. -Whilst I had a fever, I could not figure it out at all, but once I felt better, I spotted the problem in about thirty seconds. I apologise for causing you concern. I am grateful for your patience. -I would like to devise a mechanism for running simple test scenarios. Now that we have got the Obsidian CLI up and running, it seems the perfect opportunity. - -To improve the bus factor, we really need to organise the source code more thoroughly. Your cooperation and contributions would be greatly appreciated. - -### Fixed - -- No longer unexpected deletion-propagation occurs when the parent directory is not empty (#813). - -### Revert reversions - -- Reverted the reversion of ModuleCheckRemoteSize. Now it is back to the service feature. - -## 0.25.51 - -7th March, 2026 - -### Reverted - -- Reverted to ModuleRedFlag and ModuleInitializerFile to the previous version because of some unexpected issues. (#813) - - I will re-implement them in the future with better design and tests. - -## 0.25.50 - -3rd March, 2026 - -Note: 0.25.49 has been skipped because of too verbose logging (credentials are logged in verbose level, but I realised that could lead to unexpected exposure on issue reporting). Please bump to 0.25.50 to get the fix if you are on 0.25.49. (No expected behaviour changes except the logging). - -### Fixed - -- No longer deleted files are not clickable in the Global History pane. -- Diff view now uses more specific classes (#803). -- A message of configuration mismatching slightly added for better understanding. - - Now it says `When replication is initiated manually via the command palette or ribbon, a dialogue box will open to address this.` to make it clear that the user can fix the issue by themselves. - -### Refactored - -- `ModuleRedFlag` has been refactored to `serviceFeatures/redFlag` and also tested. -- `ModuleInitializerFile` has been refactored to `lib/serviceFeatures/offlineScanner` and also tested. - -## 0.25.48 - -2nd March, 2026 - -No behavioural changes except unidentified faults. Please report if you find any unexpected behaviour after this update. - -### Refactored - -- Many storage-related functions have been refactored for better maintainability and testability. - - Now all platform-specific logics are supplied as adapters, and the core logic has become platform-agnostic. - - Quite a number of tests have been added for the core logic, and the platform-specific logics are also tested with mocked adapters. - -## 0.25.47 - -27th February, 2026 - -Phew, the financial year is still not over yet, but I have got some time to work on the plug-in again! - -### Fixed and refactored - -- Fixed the inexplicable behaviour when retrieving chunks from the network. - - The chunk manager has been layered to be responsible for its own areas and duties. e.g., `DatabaseWriteLayer`, `DatabaseReadLayer`, `NetworkLayer`, `CacheLayer`, and `ArrivalWaitLayer`. - - All layers have been tested now! - - `LayeredChunkManager` has been implemented to manage these layers. Also tested. - - `EntryManager` has been mostly rewritten and also tested. - -- Now we can configure `Never warn` for remote storage size notification again. - -### Tests - -- The following test has been added: - - `ConflictManager`. - -## 0.25.46 - -26th February, 2026 - -### Fixed - -- Unexpected errors no longer occurred when the plug-in was unloaded. -- Hidden File Sync now respects selectors. -- Registering protocol-handlers now works safely without causing unexpected errors. - -### Refactored - -- `ModuleCheckRemoteSize` has been ported to a serviceFeature, and tests have also been added. -- Some unnecessary things have been removed. -- LiveSyncManagers has now explicit dependencies. -- LiveSyncLocalDB is now responsible for LiveSyncManagers, not accepting the managers as dependencies. - - This is to avoid circular dependencies and clarify the ownership of the managers. -- ChangeManager has been refactored. This had a potential issue, so something had been fixed, possibly. -- Some tests have been ported from Deno's test runner to Vitest to accumulate coverage. - -## 0.25.45 - -25th February, 2026 - -As a result of recent refactoring, we are able to write tests more easily now! - -### Refactored - -- `ModuleTargetFilter`, which was responsible for checking if a file is a target file, has been ported to a serviceFeature. - - And also tests have been added. The middleware-style-power. -- `ModuleObsidianAPI` has been removed and implemented in `APIService` and `RemoteService`. -- Now `APIService` is responsible for the network-online-status, not `databaseService.managers.networkManager`. - -## 0.25.44 - -24th February, 2026 - -This release represents a significant architectural overhaul of the plug-in, focusing on modularity, testability, and stability. While many changes are internal, they pave the way for more robust features and easier maintenance. -However, as this update is very substantial, please do feel free to let me know if you encounter any issues. - -### Fixed - -- Ignore files (e.g., `.ignore`) are now handled efficiently. -- Replication & Database: - - Replication statistics are now correctly reset after switching replicators. -- Fixed `File already exists` for .md files has been merged (PR #802) So thanks @waspeer for the contribution! - -### Improved - -- Now we can configure network-error banners as icons, or hide them completely with the new `Network Warning Style` setting in the `General` pane of the settings dialogue. (#770, PR #804) - - Thanks so much to @A-wry! - -### Refactored - -#### Architectural Overhaul: - -- A major transition from Class-based Modules to a Service/Middleware architecture has begun. - - Many modules (for example, `ModulePouchDB`, `ModuleLocalDatabaseObsidian`, `ModuleKeyValueDB`) have been removed or integrated into specific Services (`database`, `keyValueDB`, etc.). - - Reduced reliance on dynamic binding and inverted dependencies; dependencies are now explicit. - - `ObsidianLiveSyncPlugin` properties (`replicator`, `localDatabase`, `storageAccess`, etc.) have been moved to their respective services for better separation of concerns. - - In this refactoring, the Service will henceforth, as a rule, cease to use setHandler, that is to say, simple lazy binding. - - They will be implemented directly in the service. - - However, not everything will be middlewarised. Modules that maintain state or make decisions based on the results of multiple handlers are permitted. -- Lifecycle: - - Application LifeCycle now starts in `Main` rather than `ServiceHub` or `ObsidianMenuModule`, ensuring smoother startup coordination. - -#### New Services & Utilities: - -- Added a `control` service to orchestrate other services (for example, handling stop/start logic during settings realisation). -- Added `UnresolvedErrorManager` to handle and display unresolved errors in a unified way. -- Added `logUtils` to unify logging injection and formatting. -- `VaultService.isTargetFile` now uses multiple, distinct checkers for better extensibility. - -#### Code Separation: - -- Separated Obsidian-specific logic from base logic for `StorageEventManager` and `FileAccess` modules. -- Moved reactive state values and statistics from the main plug-in instance to the services responsible for them. - -#### Internal Cleanups: - -- Many functions have been renamed for clarity (for example, `_isTargetFileByLocalDB` is now `_isTargetAcceptedByLocalDB`). -- Added `override` keywords to overridden items and removed dynamic binding for clearer code inheritance. -- Moved common functions to the common library. - -#### Dependencies: - -- Bumped dependencies simply to a point where they can be considered problem-free (by human-powered-artefacts-diff). - - Svelte, terser, and more something will be bumped later. They have a significant impact on the diff and paint it totally. - - You may be surprised, but when I bump the library, I am actually checking for any unintended code. - -## 0.25.43-patched-9 a.k.a. 0.25.44-rc1 - -We are finally ready for release. I think I will go ahead and release it after using it for a few days. - -### Fixed - -- Hidden file synchronisation now works! -- Now Hidden file synchronisation respects `.ignore` files. -- Replicator initialisation during rebuilding now works correctly. - -### Refactored - -- Some methods naming have been changed for better clarity, i.e., `_isTargetFileByLocalDB` is now `_isTargetAcceptedByLocalDB`. - -### Follow-up tasks memo (After 0.25.44) - -Going forward, functionality that does not span multiple events is expected to be implemented as middleware-style functions rather than modules based on classes. - -Consequently, the existing modules will likely be gradually dismantled. -For reference, `ModuleReplicator.ts` has extracted several functionalities as functions. - -However, this does not negate object-oriented design. Where lifecycles and state are present, and the Liskov Substitution Principle can be upheld, we design using classes. After all, a visible state is preferable to a hidden state. In other words, the handler still accepts both functions and member methods, so formally there is no change. - -As undertaking this for everything would be a bit longer task, I intend to release it at this stage. - -Note: I left using `setHandler`s that as a mark of `need to be refactored`. Basically, they should be implemented in the service itself. That is because it is just a mis-designed, separated implementation. - -## 0.25.43-patched-8 - -I really must thank you all. You know that it seems we have just a little more to do. -Note: This version is not fully tested yet. Be careful to use this. Very dogfood-y one. - -### Fixed - -- Now the device name is saved correctly. - -### Refactored - -- Add `override` keyword to all overridden items. -- More dynamic binding has been removed. -- The number of inverted dependencies has decreased much more. -- Some check-logic; i.e., like pre-replication check is now separated into check functions and added to the service as handlers, layered. - - This may help with better testing and better maintainability. - - -## 0.25.43-patched-7 - -19th February, 2026 - -Right then, let us make a decision already. - -Last time, since I found a bug, I ended up doing a few other things as well, but next time I intend to release it with just the bug fix. It is quite substantial, after all. - -Customisation Sync has mostly been verified. Hidden file synchronisation has not been done yet. - -Vite's build system is not in the production. However, I possibly migrate to it in the future. - -And, the `daily-progress` will be tidied on releasing 0.25.44. Do not worry! - -### Fixed - -- Fixed an issue where the StorageEventManager was not correctly loading the settings. -- Replication statistics are now correctly reset after switching replicators. - -### Refactored - -- Now, many reactive values which keep the state or statistics of the plugin are moved to the services which have the responsibility for these states. -- `serviceFeatures` are now able to be added to the services; this is not a class module, but a function which accepts dependencies and returns an addHandler-able function. This is for better separation of concerns, better maintainability, and testability. -- `control` service; is a meta-service which is responsible for orchestrating services has been added. - - Don't you think stopping replication or something occurs during `settingService.realiseSetting` is quite weird? It may be done by the control service, which can orchestrate the setting service and the replicator service. - - -- Some functions on services have been moved. e.g., `getSystemVaultName` is now on the API service. -- Setting Service is now responsible for the setting, no longer using dynamic binding for the modules. - -## 0.25.43-patched-6 - -18th February, 2026 - -Let me confess that I have lied about `now all ambiguous properties`... I have found some more implicit calling. - -Note: I have not checked hidden file sync and customisation sync yet. Please report if you find any unexpected behaviour in these features. - -### Fixed - -- Now ReplicatorService responds to database reset and database initialisation events to dispose of the active replicator. - - Fixes some unlocking issues during rebuilding. - -### Refactored - -- Now `StorageEventManagerBase` is separated from `StorageEventManagerObsidian` following their concerns. - - No longer using `ObsidianFileAccess` indirectly during checking duplicated-file events. - - Last event memorisation is now moved into the StorageAccessManager, just like the file processing interlocking. - - These methods, i.e., `ObsidianFileAccess.touch`. `StorageEventManager.recentlyTouched`, and `StorageEventManager.touch` are still available, but simply call the StorageAccessManager's methods. -- Now `FileAccessBase` is separated from `FileAccessObsidian` following their concerns. - -## 0.25.43-patched-5 - -17th February, 2026 - -Yes, we mostly have got refactored! - -### Refactored - -- Following properties of `ObsidianLiveSyncPlugin` are now initialised more explicitly: - - - property : what is responsible - - `storageAccess` : `ServiceFileAccessObsidian` - - `databaseFileAccess` : `ServiceDatabaseFileAccess` - - `fileHandler` : `ServiceFileHandler` - - `rebuilder` : `ServiceRebuilder` - - Not so long from now, ServiceFileAccessObsidian might be abstracted to a more general FileAccessService, and make more testable and maintainable. - - These properties are initialised in `initialiseServiceModules` on `ObsidianLiveSyncPlugin`. - - They are `ServiceModule`s. - - Which means they do not use dynamic binding themselves, but they use bound services. - - ServiceModules are in src/lib/src/serviceModules for common implementations, and src/serviceModules for Obsidian-specific implementations. - - Hence, now all ambiguous properties of `ObsidianLiveSyncPlugin` are initialised explicitly. We can proceed to testing. - - Well, I will release v0.25.44 after testing this. - -- Conflict service is now responsible for `resolveAllConflictedFilesByNewerOnes` function, which has been in the rebuilder. -- New functions `updateSettings`, and `applyPartial` have been added to the setting service. We should use these functions instead of directly writing the settings on `ObsidianLiveSyncPlugin.setting`. -- Some interfaces for services have been moved to src/lib/src/interfaces. -- `RemoteService.tryResetDatabase` and `tryCreateDatabase` are now moved to the replicator service. - - You know that these functions are surely performed by the replicator. - - Probably, most of the functions in `RemoteService` should be moved to the replicator service, but for now, these two functions are moved as they are the most related ones, to rewrite the rebuilder service. -- Common functions are gradually moved to the common library. -- Now, binding functions on modules have been delayed until the services and service modules are initialised, to avoid fragile behaviour. - -## 0.25.43-patched-4 - -16th February, 2026 - -I have been working on it little by little in my spare time. Sorry for the delayed response for issues! ! However, thanks for your patience, we seems the `revert to 0.25.43` is not necessary, and I will keep going with this version. - -### Refactored - -- No longer `DatabaseService` is an injectable service. It is now actually a service which has its own handlers. No dynamic binding for necessary functions. -- Now the following properties of `ObsidianLiveSyncPlugin` belong to each service: - - `replicator` : `services.replicator` (still we can access `ObsidianLiveSyncPlugin.replicator` for the active replicator) -- A Handy class `UnresolvedErrorManager` has been added, which is responsible for managing unresolved errors and their handlers (we will see `unresolved errors` on a red-background-banner in the editor when they occur). - - This manager can be used to handle unresolved errors in a unified way, and it can also be used to display notifications or something when unresolved errors occur. - -## 0.25.43-patched-3 - -16th February, 2026 - -### Refactored - -- Now following properties of `ObsidianLiveSyncPlugin` belong to each service: - - property : service (still we can access these properties from `ObsidianLiveSyncPlugin` for better usability, but probably we should access these from services to clarify the dependencies) - - `localDatabase` : `services.database` - - `managers` : `services.database` - - `simpleStore` : `services.keyValueDB` - - `kvDB`: `services.keyValueDB` -- Initialising modules, addOns, and services are now explicitly separated in the `_startUp` function of the main plug-in class. -- LiveSyncLocalDB now depends more explicitly on specified services, not the whole `ServiceHub`. -- New service `keyValueDB` has been added. This had been separated from the `database` service. -- Non-trivial modules, such as `ModuleExtraSyncObsidian` (which only holds deviceAndVaultName), are simply implemented in the service. -- Add `logUtils` for unifying logging method injection and formatting. This utility is able to accept the API service for log writing. -- `ModuleKeyValueDB` has been removed, and its functionality is now implemented in the `keyValueDB` service. -- `ModulePouchDB` and `ModuleLocalDatabaseObsidian` have been removed, and their functionality is now implemented in the `database` service. - - Please be aware that you have overridden createPouchDBInstance or something by dynamic binding; you should now override the createPouchDBInstance in the database service instead of using the module. - - You can refer to the `DirectFileManipulatorV2` for an example of how to override the createPouchDBInstance function in the database service. - -## 0.25.43-patched-2 - -14th February, 2026 - -### Fixed - -- Application LifeCycle has now started in Main, not ServiceHub. - - Indeed, ServiceHub cannot be known other things in main have got ready, so it is quite natural to start the lifecycle in main. - -## 0.25.43-patched-1 - -13th February, 2026 - -**NOTE: Hidden File Sync and Customisation Sync may not work in this version.** - -Just a heads-up: this is a patch version, which is essentially a beta release. Do not worry about the following memos, as they are indeed freaking us out. I trust that you have thought this was too large; you're right. - -If this cannot be stable, I will revert to 0.24.43 and try again. - -### Refactored - -- Now resolving unexpected and inexplicable dependency order issues... -- The function which is able to implement to the service is now moved to each service. - - AppLifecycleService.performRestart -- VaultService.isTargetFile is now using multiple checkers instead of a single function. - - This change allows better separation of concerns and easier extension in the future. -- Application LifeCycle has now started in ServiceHub, not ObsidianMenuModule. - - - It was in a QUITE unexpected place..., isn't it? - - Instead of, we should call `await this.services.appLifecycle.onReady()` in other platforms. - - As in the browser platform, it will be called at `DOMContentLoaded` event. - -- ModuleTargetFilter, which is responsible for parsing ignore files, has been refined. - - This should be separated to a TargetFilter and an IgnoreFileFilter for better maintainability. -- Using `API.addCommand` or some Obsidian API and shimmer APIs, Many modules have been refactored to be derived to AbstractModule from AbstractObsidianModule, to clarify the dependencies. (we should make `app` usage clearer...) -- Fixed initialising `storageAccess` too late in `FileAccessObsidian` module (I am still wondering why it worked before...). -- Remove some redundant overrides in modules. - -### Planned - -- Some services have an ambiguous name, such as `Injectable`. These will be renamed in the future for better clarity. -- Following properties of `ObsidianLiveSyncPlugin` should be initialised more explicitly: - - property : where it is initialised currently - - `localDatabase` : `ModuleLocalDatabaseObsidian` - - `managers` : `ModuleLocalDatabaseObsidian` - - `replicator` : `ModuleReplicator` - - `simpleStore` : `ModuleKeyValueDB` - - `storageAccess` : `ModuleFileAccessObsidian` - - `databaseFileAccess` : `ModuleDatabaseFileAccess` - - `fileHandler` : `ModuleFileHandler` - - `rebuilder` : `ModuleRebuilder` - - `kvDB`: `ModuleKeyValueDB` - - And I think that having a feature in modules directly is not good for maintainability, these should be separated to some module (loader) and implementation (not only service, but also independent something). -- Plug-in statuses such as requestCount, responseCount... should be moved to a status service or somewhere for better separation of concerns. - -## 0.25.43 - -5th, February, 2026 - -### Fixed - -- Encryption/decryption issues when using Object Storage as remote have been fixed. - - Now the plug-in falls back to V1 encryption/decryption when V2 fails (if not configured as ForceV1). - - This may fix the issue reported in #772. - -### Notice - -Quite a few packages have been updated in this release. Please report if you find any unexpected behaviour after this update. - -## 0.25.42 - -2nd, February, 2026 - -This release is identical to 0.25.41-patched-3, except for the version number. - -### Refactored - -- Now the service context is `protected` instead of `private` in `ServiceBase`. - - This change allows derived classes to access the context directly. -- Some dynamically bound services have been moved to services for better dependency management. -- `WebPeer` has been moved to the main repository from the sub repository `livesync-commonlib` for correct dependency management. -- Migrated from the outdated, unstable platform abstraction layer to services. - - A bit more services will be added in the future for better maintainability. - -## 0.25.41 - -24th January, 2026 - -### Fixed - -- No longer `No available splitter for settings!!` errors occur after fetching old remote settings while rebuilding local database. (#748) - -### Improved - -- Boot sequence warning is now kept in the in-editor notification area. - -### New feature - -- We can now set the maximum modified time for reflect events in the settings. (for #754) - - This setting can be configured from `Patches` -> `Remediation` in the settings dialogue. - - Enabling this setting will restrict the propagation from the database to storage to only those changes made before the specified date and time. - - This feature is primarily intended for recovery purposes. After placing `redflag.md` in an empty vault and importing the Self-hosted LiveSync configuration, please perform this configuration, and then fetch the local database from the remote. - - This feature is useful when we want to prevent recent unwanted changes from being reflected in the local storage. - -### Refactored - -- Module to service refactoring has been started for better maintainability: - - UI module has been moved to UI service. - -### Behaviour change - -- Default chunk splitter version has been changed to `Rabin-Karp` for new installations. - -## 0.25.40 - -23rd January, 2026 - -### Fixed - -- Fixed an issue where some events were not triggered correctly after the refactoring in 0.25.39. - -## 0.25.39 - -23rd January, 2026 - -Also no behaviour changes or fixes in this release. Just refactoring for better maintainability. Thank you for your patience! I will address some of the reported issues soon. -However, this is not a minor refactoring, so please be careful. Let me know if you find any unexpected behaviour after this update. - -### Refactored - -- Rewrite the service's binding/handler assignment systems -- Removed loopholes that allowed traversal between services to clarify dependencies. -- Consolidated the hidden state-related state, the handler, and the addition of bindings to the handler into a single object. - - Currently, functions that can have handlers added implement either addHandler or setHandler directly on the function itself. - I understand there are differing opinions on this, but for now, this is how it stands. -- Services now possess a Context. Please ensure each platform has a class that inherits from ServiceContext. -- To permit services to be dynamically bound, the services themselves are now defined by interfaces. - -## 0.25.38 - -17th January, 2026 - -### Fixed - -- Fixed an issue where indexedDB would not close correctly on some environments, causing unexpected errors during database operations. - -## 0.25.37 - -15th January, 2026 - -Thank you for your patience until my return! - -This release contains minor changes discovered and fixed during test implementation. -There are no changes affecting usage. - -### Refactored - -- Logging system has been slightly refactored to improve maintainability. -- Some import statements have been unified. - -## 0.25.36 - -25th December, 2025 - -### Improved - -- Now the garbage collector (V3) has been implemented. (Beta) - - This garbage collector ensures that all devices are synchronised to the latest progress to prevent inconsistencies. - - In other words, it makes sure that no new conflicts would have arisen. - - This feature requires additional information (via node information), but it should be more reliable. - - This feature requires all devices have v0.25.36 or later. - - After the garbage collector runs, the database size may be reduced (Compaction will be run automatically after GC). - - We should have an administrative privilege on the remote database to run this garbage collector. -- Now the plug-in and device information is stored in the remote database. - - This information is used for the garbage collector (V3). - - Some additional features may be added in the future using this information. - -## 0.25.35 - -24th December, 2025 - -Sorry for a small release! I would like to keep things moving along like this if possible. After all, the holidays seem to be starting soon. I will be doubled by my business until the 27th though, indeed. - -### Fixed - -- Now the conflict resolution dialogue shows correctly which device only has older APIs (#764). - -## 0.25.34 - -10th December, 2025 - -### Behaviour change - -- The plug-in automatically fetches the missing chunks even if `Fetch chunks on demand` is disabled. - - This change is to avoid loss of data when receiving a bulk of revisions. - - This can be prevented by enabling `Use Only Local Chunks` in the settings. -- Storage application now saved during each event and restored on startup. -- Synchronisation result application is also now saved during each event and restored on startup. - - These may avoid some unexpected loss of data when the editor crashes. - -### Fixed - -- Now the plug-in waits for the application of pended batch changes before the synchronisation starts. - - This may avoid some unexpected loss or unexpected conflicts. - Plug-in sends custom headers correctly when RequestAPI is used. -- No longer causing unexpected chunk creation during `Reset synchronisation on This Device` with bucket sync. - -### Refactored - -- Synchronisation result application process has been refactored. -- Storage application process has been refactored. - - Please report if you find any unexpected behaviour after this update. A bit of large refactoring. - -## 0.25.33 - -05th December, 2025 - -### New feature - -- We can analyse the local database with the `Analyse database usage` command. - - This command makes a TSV-style report of the database usage, which can be pasted into spreadsheet applications. - - The report contains the number of unique chunks and shared chunks for each document revision. - - Unique chunks indicate the actual consumption. - - Shared chunks indicate the reference counts from other chunks with no consumption. - - We can find which notes or files are using large amounts of storage in the database. Or which notes cannot share chunks effectively. - - This command is useful when optimising the database size or investigating an unexpectedly large database size. -- We can reset the notification threshold and check the remote usage at once with the `Reset notification threshold and check the remote database usage` command. -- Commands are available from the Command Palette, or `Hatch` pane in the settings dialogue. - -### Fixed - -- Now the plug-in resets the remote size notification threshold after rebuild. - -## 0.25.32 - -02nd December, 2025 - -Now I am back from a short (?) break! Thank you all for your patience. (It is nothing major, but the first half of the year has finally come to an end). -Anyway, I will release the things a bit by bit. I think that we need a rehabilitation or getting gears in again. - -### Improved - -- Now the plugin warns when we are in several file-related situations that may cause unexpected behaviour (#300). - - These errors are displayed alongside issues such as file size exceeding limits. - - Such situations include: - - When the document has a name which is not supported by some file systems. - - When the vault has the same file names with different letter cases. - -## 0.25.31 - -18th November, 2025 - -### Fixed - -- Now fetching configuration from the server can handle the empty remote correctly (reported on #756). -- No longer asking to switch adapters during rebuilding. - -# 0.25 - -(0.25.0 through 0.25.30) - -Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025) - -After reading Issue #668, I conducted another self-review of the E2EE-related code. In retrospect, it was clearly written by someone inexperienced, which is understandable, but it is still rather embarrassing. Three years is certainly enough time for growth. - -I have now rewritten the E2EE code to be more robust and easier to understand. It is significantly more readable and should be easier to maintain in the future. The performance issue, previously considered a concern, has been addressed by introducing a master key and deriving keys using HKDF. This approach is both fast and robust, and it provides protection against rainbow table attacks. (In addition, this implementation has been [a dedicated package on the npm registry](https://github.com/vrtmrz/octagonal-wheels), and tested in 100% branch-coverage). - -As a result, this is the first time in a while that forward compatibility has been broken. We have also taken the opportunity to change all metadata to use encryption rather than obfuscation. Furthermore, the `Dynamic Iteration Count` setting is now redundant and has been moved to the `Patches` pane in the settings. Thanks to Rabin-Karp, the eden setting is also no longer necessary and has been relocated accordingly. Therefore, v0.25.0 represents a legitimate and correct evolution. - ---- - -## 0.25.30 - -17th November, 2025 - -So sorry for the quick follow-up release, due to a humble mistake in a quick causing a matter. - -### Fixed - -- Now we can save settings correctly again (#756). - -## ~~0.25.28~~ 0.25.29 - -(0.25.28 was skipped due to a packaging issue.) - -17th November, 2025 - -### New feature - -- We can now configure hidden file synchronisation to always overwrite with the latest version (#579). - -### Fixed - -- Timing dependency issues during initialisation have been mitigated (#714) - -### Improved - -- Error logs now contain stack-traces for better inspection. - -## 0.25.27 - -12th November, 2025 - -### Improved - -- Now we can switch the database adapter between IndexedDB and IDB without rebuilding (#747). - - Just a local migration will be required, but faster than a full rebuild. -- No longer checking for the adapter by `Doctor`. - -### Changes - -- The default adapter is reverted to IDB to avoid memory leaks (#747). - -### Fixed (?) - -- Reverted QR code library to v1.4.4 (To make sure #752). - -## 0.25.26 - -07th November, 2025 - -### Improved - -- Some JWT notes have been added to the setting dialogue (#742). - -### Fixed - -- No longer wrong values encoded into the QR code. -- We can acknowledge why the QR codes have not been generated. - - Probably too large a dataset to encode. When this happens, please consider using Setup-URI via text instead of QR code, or reduce the settings temporarily. - -### Refactored - -- Some dependencies have been updated. -- Internal functions have been modularised into `octagonal-wheels` packages and are well tested. - - `dataobject/Computed` for caching computed values. - - `encodeAnyArray/decodeAnyArray` for encoding and decoding any array-like data into compact strings (#729). -- Fixed importing from the parent project in library codes. (#729). - -## 0.25.25 - -06th November, 2025 - -### Fixed - -#### JWT Authentication - -- Now we can use JWT Authentication ES512 correctly (#742). -- Several misdirections in the Setting dialogues have been fixed (i.e., seconds and minutes confusion...). -- The key area in the Setting dialogue has been enlarged and accepts newlines correctly. -- Caching of JWT tokens now works correctly - - Tokens are now cached and reused until they expire. - - They will be kept until 10% of the expiration duration is remaining or 10 seconds, whichever is longer (but at a maximum of 1 minute). -- JWT settings are now correctly displayed on the Setting dialogue. - -And, tips about JWT Authentication on CouchDB have been added to the documentation (docs/tips/jwt-on-couchdb.md). - -#### Other fixes - -- Receiving non-latest revisions no longer causes unexpected overwrites. - - On receiving revisions that made conflicting changes, we are still able to handle them. - -### Improved - -- No longer duplicated message notifications are shown when a connection to the remote server fails. - - Instead, a single notification is shown, and it will be kept on the notification area inside the editor until the situation is resolved. -- The notification area is no longer imposing, distracting, and overwhelming. - - With a pale background, but bordered and with icons. - -## 0.25.24 - -04th November, 2025 - -(Beta release notes have been consolidated to this note). - -### Guidance and UI improvements! - -Since several issues were pointed out, our setup procedure had been quite `system-oriented`. This is not good for users. Therefore, I have changed the procedure to be more `goal-oriented`. I have made extensive use of Svelte, resulting in a very straightforward setup. -While I would like to accelerate documentation and i18n adoption, I do not want to confuse everyone who's already working on it. Therefore, I have decided to release a Beta version at this stage. Significant changes are not expected from this point onward, so I will proceed to stabilise the codebase. (However, this is significant). - -### TURN server support and important notice - -TURN server settings are only necessary if you are behind a strict NAT or firewall that prevents direct P2P -connections. In most cases, you do not need to set up a TURN server. - -Using public TURN servers may have privacy implications, as your data will be relayed through third-party -servers. Even if your data are encrypted, your existence may be known to them. Please ensure you trust the TURN -server provider before using their services. Also your `network administrator` too. You should consider setting -up your own TURN server for your FQDN, if possible. - -### New features - -- We can use the TURN server for P2P connections now. - -### Fixed - -- P2P Replication got more robust and stable. - - Update [Trystero](https://github.com/dmotz/trystero) to the official v0.22.0! - - Fixed a bug that caused P2P connections to drop or (unwanted reconnection to the relay server) unexpectedly in some environments. - - Now, the connection status is more accurately reported. - - While in the background, the connection to the signalling server is now disconnected to save resources. - - When returning to the foreground, it will not reconnect automatically for safety. Please reconnect manually. -- All connection configurations should be edited in each dedicated dialogue now. -- No longer will larger files create chunks during preparing `Reset Synchronisation on This Device`. -- Now hidden file synchronisation respects the filters correctly (#631, #735) - - And `ignore-files` settings are also respected and surely read during the start-up. - -### Behaviour changes - -- The setup wizard is now more `goal-oriented`. Brand-new screens are introduced. -- `Fetch everything` and `Rebuild everything` are now `Reset Synchronisation on This Device` and `Overwrite Server Data with This Device's Files`. -- Remote configuration and E2EE settings are now separated into each modal dialogue. - - Remote configuration is now more straightforward. And if we need the rebuild (No... `Overwrite Server Data with This Device's Files`), it is now clearly indicated. -- Peer-to-Peer settings are also separated into their own modal dialogue (still in progress, and we need to open a P2P pane, still). -- Setup-URI, and Report for the Issue are now not copied to the clipboard automatically. Instead, there are copy-dialogue and buttons to copy them explicitly. - - This is to avoid confusion for users who do not want to use these features. -- No longer optional features are introduced during the setup, or `Reset Synchronisation on This Device`, `Overwrite Server Data with This Device's Files`. - - This is to avoid confusion for users who do not want to use these features. Instead, we will be informed that optional features are available after the setup is completed. -- We cannot perform `Fetch everything` and `Rebuild everything` (Removed, so the old name) without restarting Obsidian now. - -### Miscellaneous - -- Setup QR Code generation is separated into a src/lib/src/API/processSetting.ts file. Please use it as a subrepository if you want to generate QR codes in your own application. -- Setup-URI is also separated into a src/lib/src/API/processSetting.ts -- Some direct access to web APIs is now wrapped into the services layer. - -### Dependency updates - -- Many dependencies are updated. Please see `package.json`. - - This is the hardest part of this update. I read most of the changes in the dependencies. If you find any extra information, please let me know. -- As upgrading TypeScript, Fixed many UInt8Array and Uint8Array type mismatches. -- - -### Breaking changes - -- Sending configuration via Peer-to-Peer connection is not compatible with older versions. - - Please upgrade all devices to v0.25.24.beta1 or later to use this feature again. - - This is due to security improvements in the encryption scheme. - -## 0.25.23 - -26th October, 2025 - -The next version we are preparing (you know that as 0.25.23.beta1) is now still on beta, resulting in this rather unfortunate versioning situation. Apologies for the confusion. The next v0.25.23.beta2 will be v0.25.24.beta1. In other words, this is a v0.25.22.patch-1 actually, but possibly not allowed by Obsidian's rule. -(Perhaps we ought to declare 1.0.0 with a little more confidence. The current minor part has been effectively a major one for a long time. If it were 1.22.1 and 1.23.0.beta1, no confusion ). - -### Fixed - -- We are now able to enable optional features correctly again (#732). -- No longer oversized files have been processed, furthermore. - - - Before creating a chunk, the file is verified as the target. - - The behaviour upon receiving replication has been changed as follows: - - If the remote file is oversized, it is ignored. - - If not, but while the local file is oversized, it is also ignored. - -- We are now able to enable optional features correctly again (#732). -- No longer oversized files have been processed, furthermore. - - Before creating a chunk, the file is verified as the target. - - The behaviour upon receiving replication has been changed as follows: - - If the remote file is oversized, it is ignored. - - If not, but while the local file is oversized, it is also ignored. - -## 0.25.22 - -15th October, 2025 - -### Fixed - -- Fixed a bug that caused wrong event bindings and flag inversion (#727) - - This caused following issues: - - In some cases, settings changes were not applied or saved correctly. - - Automatic synchronisation did not begin correctly. - -### Improved - -- Too large diffs are not shown in the file comparison view, due to performance reasons. - -### Notes - -- The checking algorithm implemented in 0.25.20 is also raised as PR (#237). And completely I merged it manually. - - Sorry for lacking merging this PR, and let me say thanks to the great contribution, @bioluks ! -- Known issues: - - Sync on Editor save seems not to work correctly in some cases. - - I am investigating this issue. If you have any information, please let me know. - -## 0.25.21 - -13th October, 2025 - -This release including 0.25.21.beta1 and 0.25.21.beta2. - -Apologies for taking a little time. I was seriously tackling this. -(Of course, being caught up in an unfamiliar structure due to personnel changes on my workplace played a part, but fortunately I have returned to a place where I can do research and development rather than production. Completely beside the point, though). -Now then, this time, moving away from 'convention over configuration', I have changed to a mechanism for manually binding events. This makes it much easier to leverage IDE assistance. -And, also, we are ready to separate `Features` and `APIs` from `Module`. Features are still in the module, but APIs will be moved to a Service layer. This will make it easier to maintain and extend the codebase in the future. - -If you have found any issues, please let me know. I am now on the following: - -- GitHub [Issues](https://github.com/vrtmrz/obsidian-livesync/issues) Excellent! May the other contributors will help you too. -- Twitter [@vorotamoroz](https://twitter.com/vorotamoroz) Quickest! -- Matrix [@vrtmrz:matrix.org](https://matrix.to/#/@vrtmrz:matrix.org) Also quick, and if you need to keep it private! - I am creating rooms too, but I'm struggling to figure out how to use them effectively because I cannot tell the difference of use-case between them and discussions. However, if you want to use Discord, this is a answer; We should on E2E encrypted platform. - -## 0.25.21.beta2 - -8th October, 2025 - -### Fixed - -- Fixed wrong event type bindings (which caused some events not to be handled correctly). -- Fixed detected a timing issue in StorageEventManager - - When multiple events for the same file are fired in quick succession, metadata has been kept older information. This induces unexpected wrong notifications and write prevention. - -## 0.25.21.beta1 - -6th October, 2025 - -### Refactored - -- Event handling now does not rely on 'convention over configuration'. - - Services.ts now have a proper event handler registration system. - -## 0.25.20 - -26th September, 2025 - -### Fixed - -- Chunk fetching no longer reports errors when the fetched chunk could not be saved (#710). - - Just using the fetched chunk temporarily. -- Chunk fetching reports errors when the fetched chunk is surely corrupted (#710, #712). -- It no longer detects files that the plug-in has modified. - - It may reduce unnecessary file comparisons and unexpected file states. - -### Improved - -- Now checking the remote database configuration respecting the CouchDB version (#714). - -## 0.25.19 - -18th September, 2025 - -### Improved - -- Now encoding/decoding for chunk data and encryption/decryption are performed in native functions (if they were available). - - This uses Uint8Array.fromBase64 and Uint8Array.toBase64, which are natively available in iOS 18.2+ and Android with Chrome 140+. - - In Android, WebView is by default updated with Chrome, so it should be available in most cases. - - Note that this is not available in Desktop yet (due to being based on Electron). We are staying tuned for future updates. - - This realised by an external(?) package [octagonal-wheels](https://github.com/vrtmrz/octagonal-wheels). Therefore, this update only updates the dependency. - -## 0.25.18 - -17th September, 2025 - -### Fixed - -- Property encryption detection now works correctly (On Self-hosted LiveSync, it was not broken, but as a library, it was not working correctly). -- Initialising the chunk splitter is now surely performed. -- DirectFileManipulator now works fine (as a library) - - Old `DirectFileManipulatorV1` is now removed. - -### Refactored - -- Removed some unnecessary intermediate files. - -## 0.25.17 - -16th September, 2025 - -### Fixed - -- No longer information-level logs have produced during toggling `Show only notifications` in the settings (#708). -- Ignoring filters for Hidden file sync now works correctly (#709). - -### Refactored - -- Removed some unnecessary intermediate files. - -## 0.25.16 - -4th September, 2025 - -### Improved - -- Improved connectivity for P2P connections -- The connection to the signalling server can now be disconnected while in the background or when explicitly disconnected. - - These features use a patch that has not been incorporated upstream. - - This patch is available at [vrtmrz/trystero](https://github.com/vrtmrz/trystero). - -## 0.25.15 - -3rd September, 2025 - -### Improved - -- Now we can configure `forcePathStyle` for bucket synchronisation (#707). - -## 0.25.14 - -2nd September, 2025 - -### Fixed - -- Opening IndexedDB handling has been ensured. -- Migration check of corrupted files detection has been fixed. - - Now informs us about conflicted files as non-recoverable, but noted so. - - No longer errors on not-found files. - -## 0.25.13 - -1st September, 2025 - -### Fixed - -- Conflict resolving dialogue now properly displays the changeset name instead of A or B (#691). - -## 0.25.12 - -29th August, 2025 - -### Fixed - -- Fixed an issue with automatic synchronisation starting (#702). - -## 0.25.11 - -28th August, 2025 - -### Fixed - -- Automatic translation detection on the first launch now works correctly (#630). -- No errors are shown during synchronisations in offline (if not explicitly requested) (#699). -- Missing some checking during automatic-synchronisation now works correctly. - -## 0.25.10 - -26th August, 2025 - -### New experimental feature - -- We can perform Garbage Collection (Beta2) without rebuilding the entire database, and also fetch the database. - - Note that this feature is very experimental and should be used with caution. - - This feature requires disabling `Fetch chunks on demand`. - -### Fixed - -- Resetting the bucket now properly clears all uploaded files. - -### Refactored - -- Some files have been moved to better reflect their purpose and improve maintainability. -- The extensive LiveSyncLocalDB has been split into separate files for each role. - -### Fixed - -- Unexpected `Failed to obtain PBKDF2 salt` or similar errors during bucket-synchronisation no longer occur. -- Unexpected long delays for chunk-missing documents when using bucket-synchronisation have been resolved. -- Fetched remote chunks are now properly stored in the local database if `Fetch chunks on demand` is enabled. -- The 'fetch' dialogue's message has been refined. -- No longer overwriting any corrupted documents to the storage on boot-sequence. - -### Refactored - -- Type errors have been corrected. - -## 0.25.9 - -20th August, 2025 - -### Fixed - -- CORS Checking messages now use replacements. -- Configuring CORS setting via the UI now respects the existing rules. -- Now startup-checking works correctly again, performs migration check serially and then it will also fix starting LiveSync or start-up sync. (#696) -- Statusline in editor now supported 'Bases'. - -## 0.25.8 - -18th August, 2025 - -### New feature - -- Insecure chunk detection has been implemented. - - A notification dialogue will be shown if any insecure chunks are detected; these may have been created by v0.25.6 due to its issue. If this dialogue appears, please ensure you rebuild the database after backing it up. - -## 0.25.7 - -15th August, 2025 - -**Since the release of 0.25.6, there are two large problem. Please update immediately.** - -- We may have corrupted some documents during the migration process. **Please check your documents on the wizard.** -- Due to a chunk ID assignment issue, some data has not been encrypted. **Please rebuild the database using Rebuild Everything** if you have enabled E2EE. - -**_So, If you have enabled E2EE, please perform `Rebuild everything`. If not, please check your documents on the wizard._** - -In next version, insecure chunk detection will be implemented. - -### Fixed - -- Off-loaded chunking have been fixed to ensure proper functionality (#693). -- Chunk document ID assignment has been fixed. -- Replication prevention message during version up detection has been improved (#686). -- `Keep A` and `Keep B` on Conflict resolving dialogue has been renamed to `Use Base` and `Use Conflicted` (#691). - -### Improved - -- Metadata and content-size unmatched documents are now detected and reported, prevented to be applied to the storage. - - This behaviour can be configured in `Patch` -> `Edge case addressing (Behaviour)` -> `Process files even if seems to be corrupted` - - Note: this toggle is for the direct-database-manipulation users. - -### New Features - -- `Scan for Broken files` has been implemented on `Hatch` -> `TroubleShooting`. - -### Refactored - -- Off-loaded processes have been refactored for the better maintainability. - - Files prefixed `bg.worker` are now work on the worker threads. - - Files prefixed `bgWorker.` are now also controls these worker threads. (I know what you want to say... I will rename them). -- Removed unused code. - -## ~~0.25.5~~ 0.25.6 - -(0.25.5 has been withdrawn due to a bug in the `Fetch chunks on demand` feature). - -9th August, 2025 - -### Fixed - -- Storage scanning no longer occurs when `Suspend file watching` is enabled (including boot-sequence). - - This change improves safety when troubleshooting or fetching the remote database. -- `Fetch chunks on demand` is now working again (if you installed 0.25.5, other versions are not affected). - -### Improved - -- Saving notes and files now consumes less memory. - - Data is no longer fully buffered in memory and written at once; instead, it is now written in each over-2MB increments. -- Chunk caching is now more efficient. - - Chunks are now managed solely by their count (still maintained as LRU). If memory usage becomes excessive, they will be automatically released by the system-runtime. - - Reverse-indexing is also no longer used. It is performed as scanning caches and act also as a WeakRef thinning. -- Both of them (may) are effective for #692, #680, and some more. - -### Changed - -- `Incubate Chunks in Document` (also known as `Eden`) is now fully sunset. - - Existing chunks can still be read, but new ones will no longer be created. -- The `Compute revisions for chunks` setting has also been removed. - - This feature is now always enabled and is no longer configurable (restoring the original behaviour). -- As mentioned, `Memory cache size (by total characters)` has been removed. - - The `Memory cache size (by total items)` setting is now the only option available (but it has 10x ratio compared to the previous version). - -### Refactored - -- A significant refactoring of the core codebase is underway. - - This is part of our ongoing efforts to improve code maintainability, readability, and to unify interfaces. - - Previously, complex files posed a risk due to a low bus factor. Fortunately, as our devices have become faster and more capable, we can now write code that is clearer and more maintainable (And not so much costs on performance). - - Hashing functions have been refactored into the `HashManager` class and its derived classes. - - Chunk splitting functions have been refactored into the `ContentSplitterCore` class and its derived classes. - - Change tracking functions have been refactored into the `ChangeManager` class. - - Chunk read/write functions have been refactored into the `ChunkManager` class. - - Fetching chunks on demand is now handled separately from the `ChunkManager` and chunk reading functions. Chunks are queued by the `ChunkManager` and then processed by the `ChunkFetcher`, simplifying the process and reducing unnecessary complexity. - - Then, local database access via `LiveSyncLocalDB` has been refactored to use the new classes. -- References to external sources from `commonlib` have been corrected. -- Type definitions in `types.ts` have been refined. -- Unit tests are being added incrementally. - - I am using `Deno` for testing, to simplify testing and coverage reporting. - - While this is not identical to the Obsidian environment, `jest` may also have limitations. It is certainly better than having no tests. - - In other words, recent manual scenario testing has highlighted some shortcomings. - - `pouchdb-test`, used for testing PouchDB with Deno, has been added, utilising the `memory` adapter. - -Side note: Although class-oriented programming is sometimes considered an outdated style, However, I have come to re-evaluate it as valuable from the perspectives of maintainability and readability. - -## 0.25.4 - -29th July, 2025 - -### Fixed - -- The PBKDF2Salt is no longer corrupted when attempting replication while the device is offline. (#686) - - If this issue has already occurred, please use `Maintenance` -> `Rebuilding Operations (Remote Only)` -> `Overwrite Remote` and `Send` to resolve it. - - Please perform this operation on the device that is most reliable. - - I am so sorry for the inconvenience; there are no patching workarounds. The rebuilding operation is the only solution. - - This issue only affects the encryption of the remote database and does not impact the local databases on any devices. - - (Preventing synchronisation is by design and expected behaviour, even if it is sometimes inconvenient. This is also why we should avoid using workarounds; it is, admittedly, an excuse). - - In any case, we can unlock the remote from the warning dialogue on receiving devices. We are performing replication, instead of simple synchronisation at the expense of a little complexity (I would love to express thank you again for your every effort to manage and maintain the settings! Your all understanding saves our notes). - - This process may require considerable time and bandwidth (as usual), so please wait patiently and ensure a stable network connection. - -### Side note - -The PBKDF2Salt will be referred to as the `Security Seed`, and it is used to derive the encryption key for replication. Therefore, it should be stored on the server prior to synchronisation. We apologise for the lack of explanation in previous updates! - -## 0.25.3 - -22nd July, 2025 - -### Fixed - -- Now the `Doctor` at migration will save the configuration. - -## 0.25.2 ~~0.25.1~~ - -(0.25.1 was missed due to a mistake in the versioning process). -19th July, 2025 - -### Refined and New Features - -- Fetching the remote database on `RedFlag` now also retrieves remote configurations optionally. - - This is beneficial if we have already set up another device and wish to use the same configuration. We will see a much less frequent `Unmatched` dialogue. -- The setup wizard using Set-up URI and QR code has been improved. - - The message is now more user-friendly. - - The obsolete method (manual setting application) has been removed. - - The `Cancel` button has been added to the setup wizard. - - We can now fetch the remote configuration from the server if it exists, which is useful for adding new devices. - - Mostly same as a `RedFlag` fetching remote configuration. - - We can also use the `Doctor` to check and fix the imported (and fetched) configuration before applying it. - -### Changes - -- The Set-up URI is now encrypted with a new encryption algorithm (mostly the same as `V2`). - - The new Set-up URI is not compatible with version 0.24.x or earlier. - -## 0.25.0 - -### Fixed - -- The encryption algorithm now uses HKDF with a master key. - - This is more robust and faster than the previous implementation. - - It is now more secure against rainbow table attacks. - - The previous implementation can still be used via `Patches` -> `End-to-end encryption algorithm` -> `Force V1`. - - Note that `V1: Legacy` can decrypt V2, but produces V1 output. -- `Fetch everything from the remote` now works correctly. - - It no longer creates local database entries before synchronisation. -- Extra log messages during QR code decoding have been removed. - -### Changed - -- The following settings have been moved to the `Patches` pane: - - `Remote Database Tweak` - - `Incubate Chunks in Document` - - `Data Compression` - -### Behavioural and API Changes - -- `DirectFileManipulatorV2` now requires new settings (as you may already know, E2EEAlgorithm). -- The database version has been increased to `12` from `10`. - - If an older version is detected, we will be notified and synchronisation will be paused until the update is acknowledged. (It has been a long time since this behaviour was last encountered; we always err on the side of caution, even if it is less convenient.) - -### Refactored - -- `couchdb_utils.ts` has been separated into several explicitly named files. -- Some missing functions in `bgWorker.mock.ts` have been added. - -## 0.24.0 - -I know that we have been waiting for a long time. It is finally released! - -Over the past three years since the inception of the plugin, various features have been implemented to address diverse user needs. This is truly honourable, and I am grateful for your years of support. However, this process has led to an increasingly disorganised codebase, with features becoming entangled. Consequently, this has led to a situation where bugs can go unnoticed and resolving one issue may inadvertently introduce another. - -In 0.24.0, I reorganised the previously jumbled main codebase into clearly defined modules. Although I had assumed that the total size of the code would not increase, I discovered that it has in fact increased. While the complexity is still considerable, the refactoring has improved the clarity of the code's structure. Additionally, while testing the release candidates, we still found many bugs to fix, which helped to make this plug-in robust and stable. Therefore, we are now ready to use the updated plug-in, and in addition to that, proceed to the next step. - -This is also the first step towards a fully-fledged-fancy LiveSync, not just a plug-in from Obsidian. Of course, it will still be a plug-in primarily and foremost, but this development marks a significant step towards the self-hosting concept. - -Finally, I would like to once again express my respect and gratitude to all of you. My gratitude extends to all of the dev testers! Your contributions have certainly made the plug-in robust and stable! - -Thank you, and I hope your troubles will be resolved! - ---- - -## 0.24.31 - -10th July, 2025 - -### Fixed - -- The description of `Enable Developers' Debug Tools.` has been refined. - - Now performance impact is more clearly stated. -- Automatic conflict checking and resolution has been improved. - - It now works parallelly for each other file, instead of sequentially. It makes significantly faster on first synchronisation when with local files information. -- Resolving conflicts dialogue will not be shown for the multiple files at once. - - It will be shown for each file, one by one. - -## 0.24.30 - -9th July, 2025 - -### New Feature - -- New chunking algorithm `V3: Fine deduplication` has been added, and will be recommended after updates. - - The Rabin-Karp algorithm is used for efficient chunking. - - This will be the default in the new installations. - - It is more robust and faster than the previous one. - - We can change it in the `Advanced` pane of the settings. -- New language `ko` (Korean) has been added. - - Thank you for your contribution, [@ellixspace](https://x.com/ellixspace)! - - Any contributions are welcome, from any route. Please let me know if I seem to be unaware of this. It is often the case that I am not really aware of it. -- Chinese (Simplified) translation has been updated. - - Thank you for your contribution, [@52sanmao](https://github.com/52sanmao)! - -### Fixed - -- Numeric settings are now never lost the focus during value changing. -- Doctor now redacts more sensitive information on error reports. - -### Improved - -- All translations have been rewritten into YAML format, to easier to manage and contribute. - - We can write them with comments, newlines, and other YAML features. -- Doctor recommendations are now shown in a user-friendly notation. - - We can now see the recommended as `V3: Fine deduplication` instead of `v3-rabin-karp`. - -### Refactored - -- Never-ending `ObsidianLiveSyncSettingTab.ts` has finally been separated into each pane's file. -- Some commented-out code has been removed. - -### Acknowledgement - -- Jun Murakami, Shun Ishiguro, and Yoshihiro Oyama. 2012. Implementation and Evaluation of a Cache Deduplication Mechanism with Content-Defined Chunking. In _IPSJ SIG Technical Report_, Vol.2012-ARC-202, No.4. Information Processing Society of Japan, 1-7. - -## 0.24.29 - -20th June, 2025 - -### Fixed - -- Synchronisation with buckets now works correctly, regardless of whether a prefix is set or the bucket has been (re-) initialised (#664). -- An information message is now displayed again, during any automatic synchronisation is enabled (#662). - -### Tidied up - -- Importing paths have been tidied up. - -## 0.24.28 - -15th June, 2025 - -### Fixed - -- Batch Update is no longer available in LiveSync mode to avoid unexpected behaviour. (#653) -- Now compatible with Cloudflare R2 again for bucket synchronisation. - - @edo-bari-ikutsu, thank you for [your contribution](https://github.com/vrtmrz/livesync-commonlib/pull/12)! -- Prevention of broken behaviour due to database connection failures added (#649). - -## 0.24.27 - -10th June, 2025 - -### Improved - -- We can use prefix for path for the Bucket synchronisation. - - For example, if you set the `vaultName/` as a prefix for the bucket in the root directory, all data will be transferred to the bucket under the `vaultName/` directory. -- The "Use Request API to avoid `inevitable` CORS problem" option is now promoted to the normal setting, not a niche patch. - -### Fixed - -- Now switching replicators applied immediately, without the need to restart Obsidian. - -### Tidied up - -- Some dependencies have been updated to the latest version. - -## 0.24.26 - -14th May, 2025 - -This update introduces an option to circumvent Cross-Origin Resource Sharing -(CORS) constraints for CouchDB requests, by leveraging Obsidian's native request -API. The implementation of such a feature had previously been deferred due to -significant security considerations. - -CORS is a vital security mechanism, enabling servers like CouchDB -- which -functions as a sophisticated REST API -- to control access from different -origins, thereby ensuring secure communication across trust boundaries. I had -long hesitated to offer a CORS circumvention method, as it deviates from -security best practices; My preference was for users to configure CORS correctly -on the server-side. - -However, this policy has shifted due to specific reports of intractable -CORS-related configuration issues, particularly within enterprise proxy -environments where proxy servers can unpredictably alter or block -communications. Given that a primary objective of the "Self-hosted LiveSync" -plugin is to facilitate secure Obsidian usage within stringent corporate -settings, addressing these 'unavoidable' user-reported problems became -essential. Mostly raison d'ÃĒtre of this plugin. - -Consequently, the option "Use Request API to avoid `inevitable` CORS problem" -has been implemented. Users are strongly advised to enable this _only_ when -operating within a trusted environment. We can enable this option in the `Patch` pane. - -However, just to whisper, this is tremendously fast. - -### New Features - -- Automatic display-language changing according to the Obsidian language - setting. - - We will be asked on the migration or first startup. - - **Note: Please revert to the default language if you report any issues.** - - Not all messages are translated yet. We welcome your contribution! -- Now we can limit files to be synchronised even in the hidden files. -- "Use Request API to avoid `inevitable` CORS problem" has been implemented. - - Less secure, please use it only if you are sure that you are in the trusted - environment and be able to ignore the CORS. No `Web viewer` or similar tools - are recommended. (To avoid the origin forged attack). If you are able to - configure the server setting, always that is recommended. -- `Show status icon instead of file warnings banner` has been implemented. - - If enabled, the ⛔ icon will be shown inside the status instead of the file - warnings banner. No details will be shown. - -### Improved - -- All regular expressions can be inverted by prefixing `!!` now. - -### Fixed - -- No longer unexpected files will be gathered during hidden file sync. -- No longer broken `\n` and new-line characters during the bucket - synchronisation. -- We can purge the remote bucket again if we using MinIO instead of AWS S3 or - Cloudflare R2. -- Purging the remote bucket is now more reliable. - - 100 files are purged at a time. -- Some wrong messages have been fixed. - -### Behaviour changed - -- Entering into the deeper directories to gather the hidden files is now limited - by `/` or `\/` prefixed ignore filters. (It means that directories are scanned - deeper than before). - - However, inside the these directories, the files are still limited by the - ignore filters. - -### Etcetera - -- Some code has been tidied up. -- Trying less warning-suppressing and be more safer-coding. -- Dependent libraries have been updated to the latest version. -- Some build processes have been separated to `pre` and `post` processes. - -## 0.24.25 - -22nd April, 2025 - -### Improved - -- Peer-to-peer synchronisation has been got more robust. - -### Fixed - -- No longer broken falsy values in settings during set-up by the QR code - generation. - -### Refactored - -- Some `window` references now have pointed to `globalThis`. -- Some sloppy-import has been fixed. -- A server side implementation `Synchromesh` has been suffixed with `deno` - instead of `server` now. - -## 0.24.24 - -15th April, 2025 - -### Fixed - -- No longer broken JSON files including `\n`, during the bucket synchronisation. - (#623) -- Custom headers and JWT tokens are now correctly sent to the server during - configuration checking. (#624) - -### Improved - -- Bucket synchronisation has been enhanced for better performance and - reliability. - - Now less duplicated chunks are sent to the server. Note: If you have - encountered about too less chunks, please let me know. However, you can send - it to the server by `Overwrite remote`. - - Fetching conflicted files from the server is now more reliable. - - Dependent libraries have been updated to the latest version. - - Also, let me know if you have encountered any issues with this update. - Especially you are using a device that has been in use for a little - longer. - -## 0.24.23 - -10th April, 2025 - -### New Feature - -- Now, we can send custom headers to the server. - - They can be sent to either CouchDB or Object Storage. -- Authentication with JWT in CouchDB is now supported. - - I will describe steps later, but please refer to the - [CouchDB document](https://docs.couchdb.org/en/stable/config/auth.html#authentication-configuration). - - A JWT keypair for testing can be generated in the setting dialogue. - -### Improved - -- The QR Code for set-up can be shown also from the setting dialogue now. -- Conflict checking for preventing unexpected overwriting on the boot-up process - has been quite faster. - -### Fixed - -- Some bugs on Dev and Testing modules have been fixed. - -## 0.24.22 ~~0.24.21~~ - -1st April, 2025 - -(Really sorry for the confusion. I have got a miss at releasing...). - -### Fixed - -- No longer conflicted files are handled in the boot-up process. No more - unexpected overwriting. - - It ignores `Always overwrite with a newer file`, and always be prevented for - the safety. Please pick it manually or open the file. -- Some log messages on conflict resolution has been corrected. -- Automatic merge notifications, displayed on the grounds of `same`, have been - degraded to logs. - -### Improved - -- Now we can fetch the remote database with keeping local files completely - intact. - - In new option, all files are stored into the local database before the - fetching, and will be merged automatically or detected as conflicts. -- The dialogue presenting options when performing `Fetch` are now more - informative. - -### Refactored - -- Some class methods have been fixed its arguments to be more consistent. -- Types have been defined for some conditional results. - -## 0.24.20 - -24th March, 2025 - -### Improved - -- Now we can see the detail of `TypeError` using Obsidian API during remote - database access. - -### Behaviour and default changed - -- **NOW INDEED AND ACTUALLY** `Compute revisions for chunks` are backed into - enabled again. it is necessary for garbage collection of chunks. - - As far as existing users are concerned, this will not automatically change, - but the Doctor will inform us. - -## 0.24.19 - -5th March, 2025 - -### New Feature - -- Now we can generate a QR Code for transferring the configuration to another device. - - This QR Code can be scanned by the camera app or something QR Code Reader of another device, and via Obsidian URL, the configuration will be transferred. - - Note: This QR Code is not encrypted. So, please be careful when transferring the configuration. - -## 0.24.18 - -28th February, 2025 - -### Fixed - -- Now no chunk creation errors will be raised after switching `Compute revisions for chunks`. -- Some invisible file can be handled correctly (e.g., `writing-goals-history.csv`). -- Fetching configuration from the server is now saves the configuration immediately (if we are not in the wizard). - -### Improved - -- Mismatched configuration dialogue is now more informative, and rewritten to more user-friendly. -- Applying configuration mismatch is now without rebuilding (at our own risks). -- Now, rebuilding is decided more fine grained. - -### Improved internally - -- Translations can be nested. i.e., task:`Some procedure`, check: `%{task} checking`, checkfailed: `%{check} failed` produces `Some procedure checking failed`. - - Max to 10 levels of nesting - -## 0.24.17 - -27th February, 2025 - -Confession. I got the default values wrong. So scary and sorry. - -## 0.24.16 - -### Improved - -#### Peer-to-Peer - -- Now peer-to-peer synchronisation checks the settings are compatible with each other. - - No longer unexpected database broken, phew. -- Peer-to-peer synchronisation now handles the platform and detects pseudo-clients. - - Pseudo clients will not decrypt/encrypt anything, just relay the data. Hence, always settings are not compatible. Therefore, we have to accept the incompatibility for pseudo clients. - -#### General - -- New migration method has been implemented, that called `Doctor`. - - - `Doctor` checks the difference between the ideal and actual values and encourages corrective action. To facilitate our decision, the reasons for this and the recommendations are also presented. - - This can be used not only during migration. We can invoke the doctor from the settings for trouble-shooting. - -- The minimum interval for replication to be caused when an event occurs can now be configurable. -- Some detail note has been added and change nuance about the `Report` in the setting dialogue, which had less informative. - -### Behaviour and default changed - -- `Compute revisions for chunks` are backed into enabled again. it is necessary for garbage collection of chunks. - - As far as existing users are concerned, this will not automatically change, but the Doctor will inform us. - -### Refactored - -- Platform specific codes are more separated. No longer `node` modules were used in the browser and Obsidian. - -## 0.24.15 - -### Fixed - -- Now, even without WeakRef, Polyfill is used and the whole thing works without error. However, if you can switch WebView Engine, it is recommended to switch to a WebView Engine that supports WeakRef. - -## 0.24.14 - -### Fixed - -- Resolving conflicts of JSON files (and sensibly merging them) is now working fine, again! - - And, failure logs are more informative. -- More robust to release the event listeners on unwatching the local database. - -### Refactored - -- JSON file conflict resolution dialogue has been rewritten into svelte v5. -- Upgrade eslint. -- Remove unnecessary pragma comments for eslint. - -## 0.24.13 - -Sorry for the lack of replies. The ones that were not good are popping up, so I am just going to go ahead and get this one... However, they realised that refactoring and restructuring is about clarifying the problem. Your patience and understanding is much appreciated. - -### Fixed - -#### General Replication - -- No longer unexpected errors occur when the replication is stopped during for some reason (e.g., network disconnection). - -#### Peer-to-Peer Synchronisation - -- Set-up process will not receive data from unexpected sources. -- No longer resource leaks while enabling the `broadcasting changes` -- Logs are less verbose. -- Received data is now correctly dispatched to other devices. -- `Timeout` error now more informative. -- No longer timeout error occurs for reporting the progress to other devices. -- Decision dialogues for the same thing are not shown multiply at the same time anymore. -- Disconnection of the peer-to-peer synchronisation is now more robust and less error-prone. - -#### Webpeer - -- Now we can toggle Peers' configuration. - -### Refactored - -- Cross-platform compatibility layer has been improved. -- Common events are moved to the common library. -- Displaying replication status of the peer-to-peer synchronisation is separated from the main-log-logic. -- Some file names have been changed to be more consistent. - -## 0.24.12 - -I created a SPA called [webpeer](https://github.com/vrtmrz/livesync-commonlib/tree/main/apps/webpeer) (well, right... I will think of a name again), which replaces the server when using Peer-to-Peer synchronisation. This is a pseudo-client that appears to other devices as if it were one of the clients. . As with the client, it receives and sends data without storing it as a file. -And, this is just a single web page, without any server-side code. It is a static web page that can be hosted on any static web server, such as GitHub Pages, Netlify, or Vercel. All you have to do is to open the page and enter several items, and leave it open. - -### Fixed - -- No longer unnecessary acknowledgements are sent when starting peer-to-peer synchronisation. - -### Refactored - -- Platform impedance-matching-layer has been improved. - - And you can see the actual usage of this on [webpeer](https://github.com/vrtmrz/livesync-commonlib/tree/main/apps/webpeer) that a pseudo client for peer-to-peer synchronisation. -- Some UIs have been got isomorphic among Obsidian and web applications (for `webpeer`). - -## 0.24.11 - -Peer-to-peer synchronisation has been implemented! - -Until now, I have not provided a synchronisation server. More people may not -even know that I have shut down the test server. I confess that this is a bit -repetitive, but I confess it is a cautionary tale. This is out of a sense of -self-discipline that someone has occurred who could see your data. Even if the -'someone' is me. I should not be unaware of its superiority, even though -well-meaning and am a servant of all. (Half joking, but also serious). However, -now I can provide you with a signalling server. Because, to the best of my -knowledge, it is only the network that is connected to your device. Also, this -signalling server is just a Nostr relay, not my implementation. You can run your -implementation, which you consider trustworthy, on a trustworthy server. You do -not even have to trust me. Mate, it is great, isn't it? For your information, -strfry is running on my signalling server. - -Nevertheless, that being said, to be more honest, I still have not decided what -to do with this signalling server if too much traffic comes in. - -Note: Already you have noticed this, but let me mention it again, this is a -significantly large update. If you have noticed anything, please let me know. I -will try to fix it as soon as possible (Some address is on my -[profile](https://github.com/vrtmrz)). - -### Improved - -- New Translation: `es` (Spanish) by @zeedif (Thank you so much)! -- Now all of messages can be selectable and copyable, also on the iPhone, iPad, and Android devices. Now we can copy or share the messages easily. - -### New Feature - -- Peer-to-Peer Synchronisation has been implemented! - - This feature is still in early beta, and it is recommended to use it with caution. - - However, it is a significant step towards the self-hosting concept. It is now possible to synchronise your data without using any remote database or storage. It is a direct connection between your devices. - - Note: We should keep the device online to synchronise the data. It is not a background synchronisation. Also it needs a signalling server to establish the connection. But, the signalling server is used only for establishing the connection, and it does not store any data. - -### Fixed - -- No longer memory or resource leaks when the plug-in is disabled. -- Now deleted chunks are correctly detected on conflict resolution, and we are guided to resurrect them. -- Hanging issue during the initial synchronisation has been fixed. -- Some unnecessary logs have been removed. -- Now all modal dialogues are correctly closed when the plug-in is disabled. - -### Refactor - -- Several interfaces have been moved to the separated library. -- Translations have been moved to each language file, and during the build, they are merged into one file. -- Non-mobile friendly code has been removed and replaced with the safer code. - - (Now a days, mostly server-side engine can use webcrypto, so it will be rewritten in the future more). -- Started writing Platform impedance-matching-layer. -- Svelte has been updated to v5. -- Some function have got more robust type definitions. -- Terser optimisation has slightly improved. -- During the build, analysis meta-file of the bundled codes will be generated. - -## 0.24.10 - -### Fixed - -- Fixed the issue which the filename is shown as `undefined`. -- Fixed the issue where files transferred at short intervals were not reflected. - -### Improved - -- Add more translations: `ja-JP` (Japanese) by @kohki-shikata (Thank you so much)! - -### Internal - -- Some files have been prettified. - -## 0.24.9 - -Skipped. - -## 0.24.8 - -### Fixed - -- Some parallel-processing tasks are now performed more safely. -- Some error messages has been fixed. - -### Improved - -- Synchronisation is now more efficient and faster. -- Saving chunks is a bit more robust. - -### New Feature - -- We can remove orphaned chunks again, now! - - Without rebuilding the database! - - Note: Please synchronise devices completely before removing orphaned chunks. - - Note2: Deleted files are using chunks, if you want to remove them, please commit the deletion first. (`Commit File Deletion`) - - Note3: If you lost some chunks, do not worry. They will be resurrected if not so much time has passed. Try `Resurrect deleted chunks`. - - Note4: This feature is still beta. Please report any issues you encounter. - - Note5: Please disable `On demand chunk fetching`, and enable `Compute revisions for each chunk` before using this feature. - - These settings is going to be default in the future. - -## 0.24.7 - -### Fixed (Security) - -- Assigning IDs to chunks has been corrected for more safety. - - Before version 0.24.6, there were possibilities in End-to-End encryption where a brute-force attack could be carried out against an E2EE passphrase via a chunk ID if a zero-byte file was present. Now the chunk ID should be assigned more safely, and not all of passphrases are used for generating the chunk ID. - - This is a security fix, and it is recommended to update and rebuild database to this version as soon as possible. - - Note: It keeps the compatibility with the previous versions, but the chunk ID will be changed for the new files and modified files. Hence, deduplication will not work for the files which are modified after the update. It is recommended to rebuild the database to avoid the potential issues, and reduce the database size. - - Note2: This fix is only for with E2EE. Plain synchronisation is not affected by this issue. - -### Fixed - -- Now the conflict resolving dialogue is automatically closed after the conflict has been resolved (and transferred from other devices; or written by some other resolution). -- Resolving conflicts by timestamp is now working correctly. - - It also fixes customisation sync. - -### Improved - -- Notifications can be suppressed for the hidden files update now. -- No longer uses the old-xxhash and sha1 for generating the chunk ID. Chunk ID is now generated with the new algorithm (Pure JavaScript hash implementation; which is using Murmur3Hash and FNV-1a now used). - -## 0.24.6 - -### Fixed (Quick Fix) - -- Fixed the issue of log is not displayed on the log pane if the pane has not been shown on startup. - - This release is only for it. However, fixing this had been necessary to report any other issues. - -## 0.24.5 - -### Fixed - -- Fixed incorrect behaviour when comparing objects with undefined as a property value. - -### Improved - -- The status line and the log summary are now displayed more smoothly and efficiently. - - This improvement has also been applied to the logs displayed in the log pane. - -## 0.24.4 - -### Fixed - -- Fixed so many inefficient and buggy modules inherited from the past. - -### Improved - -- Tasks are now executed in an efficient asynchronous library. -- On-demand chunk fetching is now more efficient and keeps the interval between requests. - - This will reduce the load on the server and the network. - - And, safe for the Cloudant. - -## 0.24.3 - -### Improved - -- Many messages have been improved for better understanding as thanks to the fine works of @Volkor3-16! Thank you so much! -- Documentations also have been updated to reflect the changes in the messages. -- Now the style of In-Editor Status has been solid for some Android devices. - -## 0.24.2 - -### Rewritten - -- Hidden File Sync is now respects the file changes on the storage. Not simply comparing modified times. - - This makes hidden file sync more robust and reliable. - -### Fixed - -- `Scan hidden files before replication` is now configurable again. -- Some unexpected errors are now handled more gracefully. -- Meaningless event passing during boot sequence is now prevented. -- Error handling for non-existing files has been fixed. -- Hidden files will not be batched to avoid the potential error. - - This behaviour had been causing the error in the previous versions in specific situations. -- The log which checking automatic conflict resolution is now in verbose level. -- Replication log (skipping non-targetting files) shows the correct information. -- The dialogue that asking enabling optional feature during `Rebuild Everything` now prevents to show the `overwrite` option. - - The rebuilding device is the first, meaningless. -- Files with different modified time but identical content are no longer processed repeatedly. -- Some unexpected errors which caused after terminating plug-in are now avoided. -- - -### Improved - -- JSON files are now more transferred efficiently. - - Now the JSON files are transferred in more fine chunks, which makes the transfer more efficient. - -## 0.24.1 - -### Fixed - -- Vault History can show the correct information of match-or-not for each file and database even if it is a binary file. -- `Sync settings via markdown` is now hidden during the setup wizard. -- Verify and Fix will ignore the hidden files if the hidden file sync is disabled. - -#### New feature - -- Now we can fetch the tweaks from the remote database while the setting dialogue and wizard are processing. - -### Improved - -- More things are moved to the modules. - - Includes the Main codebase. Now `main.ts` is almost stub. -- EventHub is now more robust and typesafe. - -## 0.24.0 - -### Improved - -- The welcome message is now more simple to encourage the use of the Setup-URI. - - The secondary message is also simpler to guide users to Minimal Setup. - - But Setup-URI will be recommended again, due to its importance. - - These dialogues contain a link to the documentation which can be clicked. -- The minimal setup is more minimal now. And, the setup is more user-friendly. - - Now the Configuration of the remote database is checked more robustly, but we can ignore the warning and proceed with the setup. -- Before we are asked about each feature, we are asked if we want to use optional features in the first place. - - This is to prevent the user from being overwhelmed by the features. - - And made it clear that it is not recommended for new users. -- Many messages have been improved for better understanding. - - Ridiculous messages have been (carefully) refined. - - Dialogues are more informative and friendly. - - A lot of messages have been mostly rewritten, leveraging Markdown. - - Especially auto-closing dialogues are now explicitly labelled: `To stop the countdown, tap anywhere on the dialogue`. -- Now if the is plugin configured to ignore some events, we will get a chance to fix it, in addition to the warning. - - And why that has happened is also explained in the dialogue. -- A note relating to device names has been added to Customisation Sync on the setting dialogue. -- We can verify and resolve also the hidden files now. - -### Fixed - -- We can resolve the conflict of the JSON file correctly now. -- Verifying files between the local database and storage is now working correctly. -- While restarting the plug-in, the shown dialogues will be automatically closed to avoid unexpected behaviour. -- Replicated documents that the local device has configured to ignore are now correctly ignored. -- The chunks of the document on the local device during the first transfer will be created correctly. - - And why we should create them is now explained in the dialogue. -- If optional features have been enabled in the wizard, `Enable advanced features` will be toggled correctly. - The hidden file sync is now working correctly. - Now the deletion of hidden files is correctly synchronised. -- Customisation Sync is now working correctly together with hidden file sync. -- No longer database suffix is stored in the setting sharing markdown. -- A fair number of bugs have been fixed. - -### Changed - -- Some default settings have been changed for an easier new user experience. - - Preventing the meaningless migration of the settings. - -### Tiding - -- The codebase has been reorganised into clearly defined modules. -- Commented-out codes have been gradually removed. - -### 0.23.0 - -Incredibly new features! - -Now, we can use object storage (MinIO, S3, R2 or anything you like) for synchronising! Moreover, despite that, we can use all the features as if we were using CouchDB. -Note: As this is a pretty experimental feature, hence we have some limitations. - -- This is built on the append-only architecture. It will not shrink used storage if we do not perform a rebuild. -- A bit fragile. However, our version x.yy.0 is always so. -- When the first synchronisation, the entire history to date is transferred. For this reason, it is preferable to do this under the WiFi network. -- Do not worry, from the second synchronisation, we always transfer only differences. - -I hope this feature empowers users to maintain independence and self-host their data, offering an alternative for those who prefer to manage their own storage solutions and avoid being stuck on the right side of a sudden change in business model. - -Of course, I use Self-hosted MinIO for testing and recommend this. It is for the same reason as using CouchDB. -- open, controllable, auditable and indeed already audited by numerous eyes. - -Let me write one more acknowledgement. - -I have a lot of respect for that plugin, even though it is sometimes treated as if it is a competitor, remotely-save. I think it is a great architecture that embodies a different approach to my approach of recreating history. This time, with all due respect, I have used some of its code as a reference. -Hooray for open source, and generous licences, and the sharing of knowledge by experts. - -#### Version history - -- 0.23.23: - - Refined: - - Setting dialogue very slightly refined. - - The hodgepodge inside the `Hatch` pane has been sorted into more explicit categorised panes. - - Now we have new panes for: - - `Selector` - - `Advanced` - - `Power users` - - `Patches (Edge case)` - - Applying the settings will now be more informative. - - The header bar will be shown for applying the settings which needs a database rebuild. - - Applying methods are now more clearly navigated. - - Definitely, drastic change. I hope this will be more user-friendly. However, if you notice any issues, please let me know. I hope that nothing missed. - - New features: - - Word-segmented chunk building on users language - - Chunks can now be built with word-segmented data, enhancing efficiency for markdown files which contains the multiple sentences in a single line. - - This feature is enabled by default through `Use Segmented-splitter`. - - (Default: Disabled, Please be relived, I have learnt). - - Fixed: - - Sending chunks on `Send chunk in bulk` are now buffered to avoid the out-of-memory error. - - `Send chunk in bulk` is back to default disabled. (Sorry, not applied to the migrated users; I did not think we should deepen the wound any further "automatically"). - - Merging conflicts of JSON files are now works fine even if it contains `null`. - - Development: - - Implemented the logic for automatically generating the stub of document for the setting dialogue. -- 0.23.22: - - Fixed: - - Case-insensitive file handling - - Full-lower-case files are no longer created during database checking. - - Bulk chunk transfer - - The default value will automatically adjust to an acceptable size when using IBM Cloudant. -- 0.23.21: - - New Features: - - Case-insensitive file handling - - Files can now be handled case-insensitively. - - This behaviour can be modified in the settings under `Handle files as Case-Sensitive` (Default: Prompt, Enabled for previous behaviour). - - Improved chunk revision fixing - - Revisions for chunks can now be fixed for faster chunk creation. - - This can be adjusted in the settings under `Compute revisions for chunks` (Default: Prompt, Enabled for previous behaviour). - - Bulk chunk transfer - - Chunks can now be transferred in bulk during uploads. - - This feature is enabled by default through `Send chunks in bulk`. - - Creation of missing chunks without - - Missing chunks can be created without storing notes, enhancing efficiency for first synchronisation or after prolonged periods without synchronisation. - - Improvements: - - File status scanning on the startup - - Quite significant performance improvements. - - No more missing scans of some files. - - Status in editor enhancements - - Significant performance improvements in the status display within the editor. - - Notifications for files that will not be synchronised will now be properly communicated. - - Encryption and Decryption - - These processes are now performed in background threads to ensure fast and stable transfers. - - Verify and repair all files - - Got faster through parallel checking. - - Migration on update - - Migration messages and wizards have become more helpful. - - Behavioural changes: - - Chunk size adjustments - - Large chunks will no longer be created for older, stable files, addressing storage consumption issues. - - Flag file automation - - Confirmation will be shown and we can cancel it. - - Fixed: - - Database File Scanning - - All files in the database will now be enumerated correctly. - - Miscellaneous - - Dependency updated. - - Now, tree shaking is left to terser, from esbuild. -- 0.23.20: - - Fixed: - - Customisation Sync now checks the difference while storing or applying the configuration. - - No longer storing the same configuration multiple times. - - Time difference in the dialogue has been fixed. - - Remote Storage Limit Notification dialogue has been fixed, now the chosen value is saved. - - Improved: - - The Enlarging button on the enlarging threshold dialogue now displays the new value. -- 0.23.19: - - Not released. -- 0.23.18: - - New feature: - - Per-file-saved customization sync has been shipped. - - We can synchronise plug-igs etc., more smoothly. - - Default: disabled. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost compatibility with old versions. - - Customisation sync has got beta3. - - We can set `Flag` to each item to select the newest, automatically. - - This configuration is per device. - - Improved: - - Start-up speed has been improved. - - Fixed: - - On the customisation sync dialogue, buttons are kept within the screen. - - No more unnecessary entries on `data.json` for customisation sync. - - Selections are no longer lost while updating customisation items. - - Tidied on source codes: - - Many typos have been fixed. - - Some unnecessary type casting removed. -- 0.23.17: - - Improved: - - Overall performance has been improved by using PouchDB 9.0.0. - - Configuration mismatch detection is refined. We can resolve mismatches more smoothly and naturally. - More detail is on `troubleshooting.md` on the repository. - - Fixed: - - Customisation Sync will be disabled when a corrupted configuration is detected. - Therefore, the Device Name can be changed even in the event of a configuration mismatch. - - New feature: - - We can get a notification about the storage usage of the remote database. - - Default: We will be asked. - - If the remote storage usage approaches the configured value, we will be asked whether we want to Rebuild or increase the limit. -- 0.23.16: - - Maintenance Update: - - Library refining (Phase 1 - step 2). There are no significant changes on the user side. - - Including the following fixes of potentially problems: - - the problem which the path had been obfuscating twice has been resolved. - - Note: Potential problems of the library; which has not happened in Self-hosted LiveSync for some reasons. -- 0.23.15: - - Maintenance Update: - - Library refining (Phase 1). There are no significant changes on the user side. -- 0.23.14: - - Fixed: - - No longer batch-saving ignores editor inputs. - - The file-watching and serialisation processes have been changed to the one which is similar to previous implementations. - - We can configure the settings (Especially about text-boxes) even if we have configured the device name. - - Improved: - - We can configure the delay of batch-saving. - - Default: 5 seconds, the same as the previous hard-coded value. (Note: also, the previous behaviour was not correct). - - Also, we can configure the limit of delaying batch-saving. - - The performance of showing status indicators has been improved. -- 0.23.13: - - Fixed: - - No longer files have been trimmed even delimiters have been continuous. - - Fixed the toggle title to `Do not split chunks in the background` from `Do not split chunks in the foreground`. - - Non-configured item mismatches are no longer detected. -- 0.23.12: - - Improved: - - Now notes will be split into chunks in the background thread to improve smoothness. - - Default enabled, to disable, toggle `Do not split chunks in the foreground` on `Hatch` -> `Compatibility`. - - If you want to process very small notes in the foreground, please enable `Process small files in the foreground` on `Hatch` -> `Compatibility`. - - We can use a `splitting-limit-capped chunk splitter`; which performs more simple and make less amount of chunks. - - Default disabled, to enable, toggle `Use splitting-limit-capped chunk splitter` on `Sync settings` -> `Performance tweaks` - - Tidied - - Some files have been separated into multiple files to make them more explicit in what they are responsible for. -- 0.23.11: - - Fixed: - - Now we _surely_ can set the device name and enable customised synchronisation. - - Unnecessary dialogue update processes have been eliminated. - - Customisation sync no longer stores half-collected files. - - No longer hangs up when removing or renaming files with the `Sync on Save` toggle enabled. - - Improved: - - Customisation sync now performs data deserialization more smoothly. - - New translations have been merged. -- 0.23.10 - - Fixed: - - No longer configurations have been locked in the minimal setup. -- 0.23.9 - - Fixed: - - No longer unexpected parallel replication is performed. - - Now we can set the device name and enable customised synchronisation again. -- 0.23.8 - - New feature: - - Now we are ready for i18n. - - Patch or PR of `rosetta.ts` are welcome! - - The setting dialogue has been refined. Very controllable, clearly displayed disabled items, and ready to i18n. - - Fixed: - - Many memory leaks have been rescued. - - Chunk caches now work well. - - Many trivial but potential bugs are fixed. - - No longer error messages will be shown on retrieving checkpoint or server information. - - Now we can check and correct tweak mismatch during the setup - - Improved: - - Customisation synchronisation has got more smoother. - - Tidied - - Practically unused functions have been removed or are being prepared for removal. - - Many of the type-errors and lint errors have been corrected. - - Unused files have been removed. - - Note: - - From this version, some test files have been included. However, they are not enabled and released in the release build. - - To try them, please run Self-hosted LiveSync in the dev build. -- 0.23.7 - - Fixed: - - No longer missing tasks which have queued as the same key (e.g., for the same operation to the same file). - - This occurs, for example, with hidden files that have been changed multiple times in a very short period of time, such as `appearance.json`. Thanks for the report! - - Some trivial issues have been fixed. - - New feature: - - Reloading Obsidian can be scheduled until that file and database operations are stable. -- 0.23.6: - - Fixed: - - Now the remote chunks could be decrypted even if we are using `Incubate chunks in Document`. (The note of 0.23.6 has been fixed). - - Chunk retrieving with `Incubate chunks in document` got more efficiently. - - No longer task processor misses the completed tasks. - - Replication is no longer started automatically during changes in window visibility (e.g., task switching on the desktop) when off-focused. -- 0.23.5: - - New feature: - - Now we can check configuration mismatching between clients before synchronisation. - - Default: enabled / Preferred: enabled / We can disable this by the `Do not check configuration mismatch before replication` toggle in the `Hatch` pane. - - It detects configuration mismatches and prevents synchronisation failures and wasted storage. - - Now we can perform remote database compaction from the `Maintenance` pane. - - Fixed: - - We can detect the bucket could not be reachable. - - Note: - - Known inexplicable behaviour: Recently, (Maybe while enabling `Incubate chunks in Document` and `Fetch chunks on demand` or some more toggles), our customisation sync data is sometimes corrupted. It will be addressed by the next release. -- 0.23.4 - - Fixed: - - No longer experimental configuration is shown on the Minimal Setup. - - New feature: - - We can now use `Incubate Chunks in Document` to reduce non-well-formed chunks. - - Default: disabled / Preferred: enabled in all devices. - - When we enabled this toggle, newly created chunks are temporarily kept within the document, and graduated to become independent chunks once stabilised. - - The [design document](https://github.com/vrtmrz/obsidian-livesync/blob/3925052f9290b3579e45a4b716b3679c833d8ca0/docs/design_docs_of_keep_newborn_chunks.md) has been also available.. -- 0.23.3 - - Fixed: No longer unwanted `\f` in journal sync. -- 0.23.2 - - Sorry for all the fixes to experimental features. (These things were also critical for dogfooding). The next release would be the main fixes! Thank you for your patience and understanding! - - Fixed: - - Journal Sync will not hang up during big replication, especially the initial one. - - All changes which have been replicated while rebuilding will not be postponed (Previous behaviour). - - Improved: - - Now Journal Sync works efficiently in download and parse, or pack and upload. - - Less server storage and faster packing/unpacking usage by the new chunk format. -- 0.23.1 - - - Fixed: - - Now journal synchronisation considers untransferred each from sent and received. - - Journal sync now handles retrying. - - Journal synchronisation no longer considers the synchronisation of chunks as revision updates (Simply ignored). - - Journal sync now splits the journal pack to prevent mobile device rebooting. - - Maintenance menus which had been on the command palette are now back in the maintain pane on the setting dialogue. - - Improved: - - Now all changes which have been replicated while rebuilding will be postponed. - -- 0.23.0 - - New feature: - - Now we can use Object Storage. - -### 0.22.0 - -A few years passed since Self-hosted LiveSync was born, and our codebase had been very complicated. This could be patient now, but it should be a tremendous hurt. -Therefore at v0.22.0, for future maintainability, I refined task scheduling logic totally. - -Of course, I think this would be our suffering in some cases. However, I would love to ask you for your cooperation and contribution. - -Sorry for being absent so much long. And thank you for your patience! - -Note: we got a very performance improvement. -Note at 0.22.2: **Now, to rescue mobile devices, Maximum file size is set to 50 by default**. Please configure the limit as you need. If you do not want to limit the sizes, set zero manually, please. - -#### Version history - -- 0.22.19 - - Fixed: - - No longer data corrupting due to false BASE64 detections. - - Improved: - - A bit more efficient in Automatic data compression. -- 0.22.18 - - New feature (Very Experimental): - - Now we can use `Automatic data compression` to reduce amount of traffic and the usage of remote database. - - Please make sure all devices are updated to v0.22.18 before trying this feature. - - If you are using some other utilities which connected to your vault, please make sure that they have compatibilities. - - Note: Setting `File Compression` on the remote database works for shrink the size of remote database. Please refer the [Doc](https://docs.couchdb.org/en/stable/config/couchdb.html#couchdb/file_compression). -- 0.22.17: - - Fixed: - - Error handling on booting now works fine. - - Replication is now started automatically in LiveSync mode. - - Batch database update is now disabled in LiveSync mode. - - No longer automatically reconnection while off-focused. - - Status saves are thinned out. - - Now Self-hosted LiveSync waits for all files between the local database and storage to be surely checked. - - Improved: - - The job scheduler is now more robust and stable. - - The status indicator no longer flickers and keeps zero for a while. - - No longer meaningless frequent updates of status indicators. - - Now we can configure regular expression filters in handy UI. Thank you so much, @eth-p! - - `Fetch` or `Rebuild everything` is now more safely performed. - - Minor things - - Some utility function has been added. - - Customisation sync now less wrong messages. - - Digging the weeds for eradication of type errors. -- 0.22.16: - - Fixed: - - Fixed the issue that binary files were sometimes corrupted. - - Fixed customisation sync data could be corrupted. - - Improved: - - Now the remote database costs lower memory. - - This release requires a brief wait on the first synchronisation, to track the latest changeset again. - - Description added for the `Device name`. - - Refactored: - - Many type-errors have been resolved. - - Obsolete file has been deleted. -- 0.22.15: - - Improved: - Faster start-up by removing too many logs which indicates normality - By streamlined scanning of customised synchronisation extra phases have been deleted. - ... To continue on to `updates_old.md`. -- 0.22.14: - - New feature: - - We can disable the status bar in the setting dialogue. - - Improved: - - Now some files are handled as correct data type. - - Customisation sync now uses the digest of each file for better performance. - - The status in the Editor now works performant. - - Refactored: - - Common functions have been ready and the codebase has been organised. - - Stricter type checking following TypeScript updates. - - Remove old iOS workaround for simplicity and performance. -- 0.22.13: - - Improved: - - Now using HTTP for the remote database URI warns of an error (on mobile) or notice (on desktop). - - Refactored: - - Dependencies have been polished. -- 0.22.12: - - Changed: - - The default settings has been changed. - - Improved: - - Default and preferred settings are applied on completion of the wizard. - - Fixed: - - Now Initialisation `Fetch` will be performed smoothly and there will be fewer conflicts. - - No longer stuck while Handling transferred or initialised documents. -- 0.22.11: - - Fixed: - - `Verify and repair all files` is no longer broken. - - New feature: - - Now `Verify and repair all files` is able to... - - Restore if the file only in the local database. - - Show the history. - - Improved: - - Performance improved. -- 0.22.10 - - Fixed: - - No longer unchanged hidden files and customisations are saved and transferred now. - - File integrity of vault history indicates the integrity correctly. - - Improved: - - In the report, the schema of the remote database URI is now printed. -- 0.22.9 - - Fixed: - - Fixed a bug on `fetch chunks on demand` that could not fetch the chunks on demand. - - Improved: - - `fetch chunks on demand` works more smoothly. - - Initialisation `Fetch` is now more efficient. - - Tidied: - - Removed some meaningless codes. -- 0.22.8 - - Fixed: - - Now fetch and unlock the locked remote database works well again. - - No longer crash on symbolic links inside hidden folders. - - Improved: - - Chunks are now created more efficiently. - - Splitting old notes into a larger chunk. - - Better performance in saving notes. - - Network activities are indicated as an icon. - - Less memory used for binary processing. - - Tidied: - - Cleaned unused functions up. - - Sorting out the codes that have become nonsense. - - Changed: - - Now no longer `fetch chunks on demand` needs `Pacing replication` - - The setting `Do not pace synchronization` has been deleted. -- 0.22.7 - - Fixed: - - No longer deleted hidden files were ignored. - - The document history dialogue is now able to process the deleted revisions. - - Deletion of a hidden file is now surely performed even if the file is already conflicted. -- 0.22.6 - - Fixed: - - Fixed a problem with synchronisation taking a long time to start in some cases. - - The first synchronisation after update might take a bit longer. - - Now we can disable E2EE encryption. - - Improved: - - `Setup Wizard` is now more clear. - - `Minimal Setup` is now more simple. - - Self-hosted LiveSync now be able to use even if there are vaults with the same name. - - Database suffix will automatically added. - - Now Self-hosted LiveSync waits until set-up is complete. - - Show reload prompts when possibly recommended while settings. - - New feature: - - A guidance dialogue prompting for settings will be shown after the installation. - - Changed - - `Open setup URI` is now `Use the copied setup URI` - - `Copy setup URI` is now `Copy current settings as a new setup URI` - - `Setup Wizard` is now `Minimal Setup` - - `Check database configuration` is now `Check and Fix database configuration` -- 0.22.5 - - Fixed: - - Some description of settings have been refined - - New feature: - - TroubleShooting is now shown in the setting dialogue. -- 0.22.4 - - Fixed: - - Now the result of conflict resolution could be surely written into the storage. - - Deleted files can be handled correctly again in the history dialogue and conflict dialogue. - - Some wrong log messages were fixed. - - Change handling now has become more stable. - - Some event handling became to be safer. - - Improved: - - Dumping document information shows conflicts and revisions. - - The timestamp-only differences can be surely cached. - - Timestamp difference detection can be rounded by two seconds. - - Refactored: - - A bit of organisation to write the test. -- 0.22.3 - - Fixed: - - No longer detects storage changes which have been caused by Self-hosted LiveSync itself. - - Setting sync file will be detected only if it has been configured now. - - And its log will be shown only while the verbose log is enabled. - - Customisation file enumeration has got less blingy. - - Deletion of files is now reliably synchronised. - - Fixed and improved: - - In-editor-status is now shown in the following areas: - - Note editing pane (Source mode and live-preview mode). - - New tab pane. - - Canvas pane. -- 0.22.2 - - Fixed: - - Now the results of resolving conflicts are surely synchronised. - - Modified: - - Some setting items got new clear names. (`Sync Settings` -> `Targets`). - - New feature: - - We can limit the synchronising files by their size. (`Sync Settings` -> `Targets` -> `Maximum file size`). - - It depends on the size of the newer one. - - At Obsidian 1.5.3 on mobile, we should set this to around 50MB to avoid restarting Obsidian. - - Now the settings could be stored in a specific markdown file to synchronise or switch it (`General Setting` -> `Share settings via markdown`). - - [Screwdriver](https://github.com/vrtmrz/obsidian-screwdriver) is quite good, but mostly we only need this. - - Customisation of the obsoleted device is now able to be deleted at once. - - We have to put the maintenance mode in at the Customisation sync dialogue. -- 0.22.1 - - New feature: - - We can perform automatic conflict resolution for inactive files, and postpone only manual ones by `Postpone manual resolution of inactive files`. - - Now we can see the image in the document history dialogue. - - We can see the difference of the image, in the document history dialogue. - - And also we can highlight differences. - - Improved: - - Hidden file sync has been stabilised. - - Now automatically reloads the conflict-resolution dialogue when new conflicted revisions have arrived. - - Fixed: - - No longer periodic process runs after unloading the plug-in. - - Now the modification of binary files is surely stored in the storage. -- 0.22.0 - - Refined: - - Task scheduling logics has been rewritten. - - Screen updates are also now efficient. - - Possibly many bugs and fragile behaviour has been fixed. - - Status updates and logging have been thinned out to display. - - Fixed: - - Remote-chunk-fetching now works with keeping request intervals - - New feature: - - We can show only the icons in the editor. - - Progress indicators have been more meaningful: - - đŸ“Ĩ Unprocessed transferred items - - 📄 Working database operation - - 💾 Working write storage processes - - âŗ Working read storage processes - - đŸ›Ģ Pending read storage processes - - âš™ī¸ Working or pending storage processes of hidden files - - 🧩 Waiting chunks - - 🔌 Working Customisation items (Configuration, snippets and plug-ins) - -... To continue on to `updates_old.md`. - -### 0.21.0 - -The E2EE encryption V2 format has been reverted. That was probably the cause of the glitch. -Instead, to maintain efficiency, files are treated with Blob until just before saving. Along with this, the old-fashioned encryption format has also been discontinued. -There are both forward and backwards compatibilities, with recent versions. However, unfortunately, we lost compatibility with filesystem-livesync or some. -It will be addressed soon. Please be patient if you are using filesystem-livesync with E2EE. - -- 0.21.5 - - Improved: - - Now all revisions will be shown only its first a few letters. - - Now ID of the documents is shown in the log with the first 8 letters. - - Fixed: - - Check before modifying files has been implemented. - - Content change detection has been improved. -- 0.21.4 - - This release had been skipped. -- 0.21.3 - - Implemented: - - Now we can use SHA1 for hash function as fallback. -- 0.21.2 - - IMPORTANT NOTICE: **0.21.1 CONTAINS A BUG WHILE REBUILDING THE DATABASE. IF YOU HAVE BEEN REBUILT, PLEASE MAKE SURE THAT ALL FILES ARE SANE.** - - This has been fixed in this version. - - Fixed: - - No longer files are broken while rebuilding. - - Now, Large binary files can be written correctly on a mobile platform. - - Any decoding errors now make zero-byte files. - - Modified: - - All files are processed sequentially for each. -- 0.21.1 - - Fixed: - - No more infinity loops on larger files. - - Show message on decode error. - - Refactored: - - Fixed to avoid obsolete global variables. -- 0.21.0 - - Changes and performance improvements: - - Now the saving files are processed by Blob. - - The V2-Format has been reverted. - - New encoding format has been enabled in default. - - WARNING: Since this version, the compatibilities with older Filesystem LiveSync have been lost. - -## 0.20.0 - -At 0.20.0, Self-hosted LiveSync has changed the binary file format and encrypting format, for efficient synchronisation. -The dialogue will be shown and asks us to decide whether to keep v1 or use v2. Once we have enabled v2, all subsequent edits will be saved in v2. Therefore, devices running 0.19 or below cannot understand this and they might say that decryption error. Please update all devices. -Then we will have an impressive performance. - -Of course, these are very impactful changes. If you have any questions or troubled things, please feel free to open an issue and mention me. - -Note: if you want to roll it back to v1, please enable `Use binary and encryption version 1` on the `Hatch` pane and perform the `rebuild everything` once. - -Extra but notable information: - -This format change gives us the ability to detect some `marks` in the binary files as same as text files. Therefore, we can split binary files and some specific sort of them (i.e., PDF files) at the specific character. It means that editing the middle of files could be detected with marks. - -Now only a few chunks are transferred, even if we add a comment to the PDF or put new files into the ZIP archives. - -- 0.20.7 - - Fixed - - To better replication, path obfuscation is now deterministic even if with E2EE. - Note: Compatible with previous database without any conversion. Only new files will be obfuscated in deterministic. -- 0.20.6 - - Fixed - - Now empty file could be decoded. - - Local files are no longer pre-saved before fetching from a remote database. - - No longer deadlock while applying customisation sync. - - Configuration with multiple files is now able to be applied correctly. - - Deleting folder propagation now works without enabling the use of a trash bin. -- 0.20.5 - - Fixed - - Now the files which having digit or character prefixes in the path will not be ignored. -- 0.20.4 - - Fixed - - The text-input-dialogue is no longer broken. - - Finally, we can use the Setup URI again on mobile. -- 0.20.3 - - New feature: - - We can launch Customization sync from the Ribbon if we enabled it. - - Fixed: - - Setup URI is now back to the previous spec; be encrypted by V1. - - It may avoid the trouble with iOS 17. - - The Settings dialogue is now registered at the beginning of the start-up process. - - We can change the configuration even though LiveSync could not be launched in normal. - - Improved: - - Enumerating documents has been faster. -- 0.20.2 - - New feature: - - We can delete all data of customization sync from the `Delete all customization sync data` on the `Hatch` pane. - - Fixed: - - Prevent keep restarting on iOS by yielding microtasks. -- 0.20.1 - - Fixed: - - No more UI freezing and keep restarting on iOS. - - Diff of Non-markdown documents are now shown correctly. - - Improved: - - Performance has been a bit improved. - - Customization sync has gotten faster. - - However, We lost forward compatibility again (only for this feature). Please update all devices. - - Misc - - Terser configuration has been more aggressive. -- 0.20.0 - - Improved: - - A New binary file handling implemented - - A new encrypted format has been implemented - - Now the chunk sizes will be adjusted for efficient sync - - Fixed: - - levels of exception in some logs have been fixed - - Tidied: - - Some Lint warnings have been suppressed. - -### 0.19.0 - -#### Customization sync - -Since `Plugin and their settings` have been broken, so I tried to fix it, not just fix it, but fix it the way it should be. - -Now, we have `Customization sync`. - -It is a real shame that the compatibility between these features has been broken. However, this new feature is surely useful and I believe that worth getting over the pain. -We can use the new feature with the same configuration. Only the menu on the command palette has been changed. The dialog can be opened by `Show customization sync dialog`. - -I hope you will give it a try. - -#### Minors - -- 0.19.1 - - Fixed: Fixed hidden file handling on Linux - - Improved: Now customization sync works more smoothly. -- 0.19.2 - - Fixed: - - Fixed garbage collection error while unreferenced chunks exist many. - - Fixed filename validation on Linux. - - Improved: - - Showing status is now thinned for performance. - - Enhance caching while collecting chunks. -- 0.19.3 - - Improved: - - Now replication will be paced by collecting chunks. If synchronisation has been deadlocked, please enable `Do not pace synchronization` once. -- 0.19.4 - - Improved: - - Reduced remote database checking to improve speed and reduce bandwidth. - - Fixed: - - Chunks which previously misinterpreted are now interpreted correctly. - - No more missing chunks which not be found forever, except if it has been actually missing. - - Deleted file detection on hidden file synchronising now works fine. - - Now the Customisation sync is surely quiet while it has been disabled. -- 0.19.5 - - Fixed: - - Now hidden file synchronisation would not be hanged, even if so many files exist. - - Improved: - - Customisation sync works more smoothly. - - Note: Concurrent processing has been rollbacked into the original implementation. As a result, the total number of processes is no longer shown next to the hourglass icon. However, only the processes that are running concurrently are shown. -- 0.19.6 - - Fixed: - - Logging has been tweaked. - - No more too many planes and rockets. - - The batch database update now surely only works in non-live mode. - - Internal things: - - Some frameworks has been upgraded. - - Import declaration has been fixed. - - Improved: - - The plug-in now asks to enable a new adaptor, when rebuilding, if it is not enabled yet. - - The setting dialogue refined. - - Configurations for compatibilities have been moved under the hatch. - - Made it clear that disabled is the default. - - Ambiguous names configuration have been renamed. - - Items that have no meaning in the settings are no longer displayed. - - Some items have been reordered for clarity. - - Each configuration has been grouped. -- 0.19.7 - - Fixed: - - The initial pane of Setting dialogue is now changed to General Settings. - - The Setup Wizard is now able to flush existing settings and get into the mode again. -- 0.19.8 - - New feature: - - Vault history: A tab has been implemented to give a birds-eye view of the changes that have occurred in the vault. - - Improved: - - Now the passphrases on the dialogue masked out. Thank you @antoKeinanen! - - Log dialogue is now shown as one of tabs. - - Fixed: - - Some minor issues has been fixed. -- 0.19.9 - - New feature (For fixing a problem): - - We can fix the database obfuscated and plain paths that have been mixed up. - - Improvements - - Customisation Sync performance has been improved. -- 0.19.10 - - Fixed - - Fixed the issue about fixing the database. -- 0.19.11 - - Improvements: - - Hashing ChunkID has been improved. - - Logging keeps 400 lines now. - - Refactored: - - Import statement has been fixed about types. -- 0.19.12 - - Improved: - - Boot-up performance has been improved. - - Customisation sync performance has been improved. - - Synchronising performance has been improved. -- 0.19.13 - - Implemented: - - Database clean-up is now in beta 2! - We can shrink the remote database by deleting unused chunks, with keeping history. - Note: Local database is not cleaned up totally. We have to `Fetch` again to let it done. - **Note2**: Still in beta. Please back your vault up anything before. - - Fixed: - - The log updates are not thinned out now. -- 0.19.14 - - Fixed: - - Internal documents are now ignored. - - Merge dialogue now respond immediately to button pressing. - - Periodic processing now works fine. - - The checking interval of detecting conflicted has got shorter. - - Replication is now cancelled while cleaning up. - - The database locking by the cleaning up is now carefully unlocked. - - Missing chunks message is correctly reported. - - New feature: - - Suspend database reflecting has been implemented. - - This can be disabled by `Fetch database with previous behaviour`. - - Now fetch suspends the reflecting database and storage changes temporarily to improve the performance. - - We can choose the action when the remote database has been cleaned - - Merge dialogue now show `↲` before the new line. - - Improved: - - Now progress is reported while the cleaning up and fetch process. - - Cancelled replication is now detected. -- 0.19.15 - - Fixed: - - Now storing files after cleaning up is correct works. - - Improved: - - Cleaning the local database up got incredibly fastened. - Now we can clean instead of fetching again when synchronising with the remote which has been cleaned up. -- 0.19.16 - - Many upgrades on this release. I have tried not to let that happen, if something got corrupted, please feel free to notify me. - - New feature: - - (Beta) ignore files handling - We can use `.gitignore`, `.dockerignore`, and anything you like to filter the synchronising files. - - Fixed: - - Buttons on lock-detected-dialogue now can be shown in narrow-width devices. - - Improved: - - Some constant has been flattened to be evaluated. - - The usage of the deprecated API of obsidian has been reduced. - - Now the indexedDB adapter will be enabled while the importing configuration. - - Misc: - - Compiler, framework, and dependencies have been upgraded. - - Due to standing for these impacts (especially in esbuild and svelte,) terser has been introduced. - Feel free to notify your opinion to me! I do not like to obfuscate the code too. -- 0.19.17 - - Fixed: - - Now nested ignore files could be parsed correctly. - - The unexpected deletion of hidden files in some cases has been corrected. - - Hidden file change is no longer reflected on the device which has made the change itself. - - Behaviour changed: - - From this version, the file which has `:` in its name should be ignored even if on Linux devices. -- 0.19.18 - - Fixed: - - Now the empty (or deleted) file could be conflict-resolved. -- 0.19.19 - - Fixed: - - Resolving conflicted revision has become more robust. - - LiveSync now try to keep local changes when fetching from the rebuilt remote database. - Local changes now have been kept as a revision and fetched things will be new revisions. - - Now, all files will be restored after performing `fetch` immediately. -- 0.19.20 - - New feature: - - `Sync on Editor save` has been implemented - - We can start synchronisation when we save from the Obsidian explicitly. - - Now we can use the `Hidden file sync` and the `Customization sync` cooperatively. - - We can exclude files from `Hidden file sync` which is already handled in Customization sync. - - We can ignore specific plugins in Customization sync. - - Now the message of leftover conflicted files accepts our click. - - We can open `Resolve all conflicted files` in an instant. - - Refactored: - - Parallelism functions made more explicit. - - Type errors have been reduced. - - Fixed: - - Now documents would not be overwritten if they are conflicted. - It will be saved as a new conflicted revision. - - Some error messages have been fixed. - - Missing dialogue titles have been shown now. - - We can click close buttons on mobile now. - - Conflicted Customisation sync files will be resolved automatically by their modified time. -- 0.19.21 - - Fixed: - - Hidden files are no longer handled in the initial replication. - - Report from `Making report` fixed - - No longer contains customisation sync information. - - Version of LiveSync has been added. -- 0.19.22 - - Fixed: - - Now the synchronisation will begin without our interaction. - - No longer puts the configuration of the remote database into the log while checking configuration. - - Some outdated description notes have been removed. - - Options that are meaningless depending on other settings configured are now hidden. - - Scan for hidden files before replication - - Scan customization periodically -- 0.19.23 - -Improved: - - We can open the log pane also from the command palette now. - - Now, the hidden file scanning interval could be configured to 0. - - `Check database configuration` now points out that we do not have administrator permission. - -### 0.18.0 - -#### Now, paths of files in the database can now be obfuscated. (Experimental Feature) - -At before v0.18.0, Self-hosted LiveSync used the path of files, to detect and resolve conflicts. In naive. The ID of the document stored in the CouchDB was naturally the filename. -However, it means a sort of lacking confidentiality. If the credentials of the database have been leaked, the attacker (or an innocent bystander) can read the path of files. So we could not use confidential things in the filename in some environments. -Since v0.18.0, they can be obfuscated. so it is no longer possible to decipher the path from the ID. Instead of that, it costs a bit CPU load than before, and the data structure has been changed a bit. - -We can configure the `Path Obfuscation` in the `Remote database configuration` pane. -Note: **When changing this configuration, we need to rebuild both of the local and the remote databases**. - -#### Minors - -- 0.18.1 - - Fixed: - - Some messages are fixed (Typo) - - File type detection now works fine! -- 0.18.2 - - Improved: - - The setting pane has been refined. - - We can enable `hidden files sync` with several initial behaviours; `Merge`, `Fetch` remote, and `Overwrite` remote. - - No longer `Touch hidden files`. -- 0.18.3 - - Fixed Pop-up is now correctly shown after hidden file synchronisation. -- 0.18.4 - - Fixed: - - `Fetch` and `Rebuild database` will work more safely. - - Case-sensitive renaming now works fine. - Revoked the logic which was made at #130, however, looks fine now. -- 0.18.5 - - - Improved: - - Actions for maintaining databases moved to the `đŸŽ›ī¸Maintain databases`. - - Clean-up of unreferenced chunks has been implemented on an **experimental**. - - This feature requires enabling `Use new adapter`. - - Be sure to fully all devices synchronised before perform it. - - After cleaning up the remote, all devices will be locked out. If we are sure had it be synchronised, we can perform only cleaning-up locally. If not, we have to perform `Fetch`. - -- 0.18.6 - - New features: - - Now remote database cleaning-up will be detected automatically. - - A solution selection dialogue will be shown if synchronisation is rejected after cleaning or rebuilding the remote database. - - During fetching or rebuilding, we can configure `Hidden file synchronisation` on the spot. - - It let us free from conflict resolution on initial synchronising. - -### 0.17.0 - -- 0.17.0 has no surfaced changes but the design of saving chunks has been changed. They have compatibility but changing files after upgrading makes different chunks than before 0.16.x. - Please rebuild databases once if you have been worried about storage usage. - - - Improved: - - - Splitting markdown - - Saving chunks - - - Changed: - - Chunk ID numbering rules - -#### Minors - -- 0.17.1 - - - Fixed: Now we can verify and repair the database. - - Refactored inside. - -- 0.17.2 - - - New feature - - We can merge conflicted documents automatically if sensible. - - Fixed - - Writing to the storage will be pended while they have conflicts after replication. - -- 0.17.3 - - - Now we supported canvas! And conflicted JSON files are also synchronised with merging its content if they are obvious. - -- 0.17.4 - - - Canvases are now treated as a sort of plain text file. now we transfer only the metadata and chunks that have differences. - -- 0.17.5 Now `read chunks online` had been fixed, and a new feature: `Use dynamic iteration count` to reduce the load on encryption/decryption. - Note: `Use dynamic iteration count` is not compatible with earlier versions. -- 0.17.6 Now our renamed/deleted files have been surely deleted again. -- 0.17.7 - - Fixed: - - Fixed merging issues. - - Fixed button styling. - - Changed: - - Conflict checking on synchronising has been enabled for every note in default. -- 0.17.8 - - Improved: Performance improved. Prebuilt PouchDB is no longer used. - - Fixed: Merging hidden files is also fixed. - - New Feature: Now we can synchronise automatically after merging conflicts. -- 0.17.9 - - Fixed: Conflict merge of internal files is no longer broken. - - Improved: Smoother status display inside the editor. -- 0.17.10 - - Fixed: Large file synchronising has been now addressed! - Note: When synchronising large files, we have to set `Chunk size` to lower than 50, disable `Read chunks online`, `Batch size` should be set 50-100, and `Batch limit` could be around 20. -- 0.17.11 - - Fixed: - - Performance improvement - - Now `Chunk size` can be set to under one hundred. - - New feature: - - The number of transfers required before replication stabilises is now displayed. -- 0.17.12: Skipped. -- 0.17.13 - - Fixed: Document history is now displayed again. - - Reorganised: Many files have been refactored. -- 0.17.14: Skipped. -- 0.17.15 - - Improved: - - Confidential information has no longer stored in data.json as is. - - Synchronising progress has been shown in the notification. - - We can commit passphrases with a keyboard. - - Configuration which had not been saved yet is marked now. - - Now the filename is shown on the Conflict resolving dialog - - Fixed: - - Hidden files have been synchronised again. - - Rename of files has been fixed again. - And, minor changes have been included. -- 0.17.16: - - Improved: - - Plugins and their settings no longer need scanning if changes are monitored. - - Now synchronising plugins and their settings are performed parallelly and faster. - - We can place `redflag2.md` to rebuild the database automatically while the boot sequence. - - Experimental: - - We can use a new adapter on PouchDB. This will make us smoother. - - Note: Not compatible with the older version. - - Fixed: - - The default batch size is smaller again. - - Plugins and their setting can be synchronised again. - - Hidden files and plugins are correctly scanned while rebuilding. - - Files with the name started `_` are also being performed conflict-checking. -- 0.17.17 - - Fixed: Now we can merge JSON files even if we failed to compare items like null. -- 0.17.18 - - Fixed: Fixed lack of error handling. -- 0.17.19 - - Fixed: Error reporting has been ensured. -- 0.17.20 - - Improved: Changes of hidden files will be notified to Obsidian. -- 0.17.21 - - Fixed: Skip patterns now handle capital letters. - - Improved - - New configuration to avoid exceeding throttle capacity. - - We have been grateful to @karasevm! - - The conflicted `data.json` is no longer merged automatically. - - This behaviour is not configurable, unlike the `Use newer file if conflicted` of normal files. -- 0.17.22 - - Fixed: - - Now hidden files will not be synchronised while we are not configured. - - Some processes could start without waiting for synchronisation to complete, but now they will wait for. - - Improved - - Now, by placing `redflag3.md`, we can discard the local database and fetch again. - - The document has been updated! Thanks to @hilsonp! -- 0.17.23 - - Improved: - - Now we can preserve the logs into the file. - - Note: This option will be enabled automatically also when we flagging a red flag. - - File names can now be made platform-appropriate. - - Refactored: - - Some redundant implementations have been sorted out. -- 0.17.24 - - New feature: - - If any conflicted files have been left, they will be reported. - - Fixed: - - Now the name of the conflicting file is shown on the conflict-resolving dialogue. - - Hidden files are now able to be merged again. - - No longer error caused at plug-in being loaded. - - Improved: - - Caching chunks are now limited in total size of cached chunks. -- 0.17.25 - - Fixed: - - Now reading error will be reported. -- 0.17.26 - - Fixed(Urgent): - - The modified document will be reflected in the storage now. -- 0.17.27 - - Improved: - - Now, the filename of the conflicted settings will be shown on the merging dialogue - - The plugin data can be resolved when conflicted. - - The semaphore status display has been changed to count only. - - Applying to the storage will be concurrent with a few files. -- 0.17.28 - -Fixed: - - Some messages have been refined. - - Boot sequence has been speeded up. - - Opening the local database multiple times in a short duration has been suppressed. - - Older migration logic. - - Note: If you have used 0.10.0 or lower and have not upgraded, you will need to run 0.17.27 or earlier once or reinstall Obsidian. -- 0.17.29 - - Fixed: - - Requests of reading chunks online are now split into a reasonable(and configurable) size. - - No longer error message will be shown on Linux devices with hidden file synchronisation. - - Improved: - - The interval of reading chunks online is now configurable. - - Boot sequence has been speeded up, more. - - Misc: - - Messages on the boot sequence will now be more detailed. If you want to see them, please enable the verbose log. - - Logs became be kept for 1000 lines while the verbose log is enabled. -- 0.17.30 - - Implemented: - - `Resolve all conflicted files` has been implemented. - - Fixed: - - Fixed a problem about reading chunks online when a file has more chunks than the concurrency limit. - - Rollbacked: - - Logs are kept only for 100 lines, again. -- 0.17.31 - - Fixed: - - Now `redflag3` can be run surely. - - Synchronisation can now be aborted. - - Note: The synchronisation flow has been rewritten drastically. Please do not haste to inform me if you have noticed anything. -- 0.17.32 - - Fixed: - - Now periodic internal file scanning works well. - - The handler of Window-visibility-changed has been fixed. - - And minor fixes possibly included. - - Refactored: - - Unused logic has been removed. - - Some utility functions have been moved into suitable files. - - Function names have been renamed. -- 0.17.33 - - Maintenance update: Refactored; the responsibilities that `LocalDatabase` had were shared. (Hoping) No changes in behaviour. -- 0.17.34 - - Fixed: The `Fetch` that was broken at 0.17.33 has been fixed. - - Refactored again: Internal file sync, plug-in sync and Set up URI have been moved into each file. - -### 0.16.0 - -- Now hidden files need not be scanned. Changes will be detected automatically. - - If you want it to back to its previous behaviour, please disable `Monitor changes to internal files`. - - Due to using an internal API, this feature may become unusable with a major update. If this happens, please disable this once. - -#### Minors - -- 0.16.1 Added missing log updates. -- 0.16.2 Fixed many problems caused by combinations of `Sync On Save` and the tracking logic that changed at 0.15.6. -- 0.16.3 - - Fixed detection of IBM Cloudant (And if there are some issues, be fixed automatically). - - A configuration information reporting tool has been implemented. -- 0.16.4 Fixed detection failure. Please set the `Chunk size` again when using a self-hosted database. -- 0.16.5 - - Fixed - - Conflict detection and merging now be able to treat deleted files. - - Logs while the boot-up sequence has been tidied up. - - Fixed incorrect log entries. - - New Feature - - The feature of automatically deleting old expired metadata has been implemented. - We can configure it in `Delete old metadata of deleted files on start-up` in the `General Settings` pane. -- 0.16.6 - - Fixed - - Automatic (temporary) batch size adjustment has been restored to work correctly. - - Chunk splitting has been backed to the previous behaviour for saving them correctly. - - Improved - - Corrupted chunks will be detected automatically. - - Now on the case-insensitive system, `aaa.md` and `AAA.md` will be treated as the same file or path at applying changesets. -- 0.16.7 Nothing has been changed except toolsets, framework library, and as like them. Please inform me if something had been getting strange! -- 0.16.8 Now we can synchronise without `bad_request:invalid UTF-8 JSON` even while end-to-end encryption has been disabled. - -Note: -Before 0.16.5, LiveSync had some issues making chunks. In this case, synchronisation had became been always failing after a corrupted one should be made. After 0.16.6, the corrupted chunk is automatically detected. Sorry for troubling you but please do `rebuild everything` when this plug-in notified so. - -### 0.15.0 - -- Outdated configuration items have been removed. -- Setup wizard has been implemented! - -I appreciate for reviewing and giving me advice @Pouhon158! - -#### Minors - -- 0.15.1 Missed the stylesheet. -- 0.15.2 The wizard has been improved and documented! -- 0.15.3 Fixed the issue about locking/unlocking remote database while rebuilding in the wizard. -- 0.15.4 Fixed issues about asynchronous processing (e.g., Conflict check or hidden file detection) -- 0.15.5 Add new features for setting Self-hosted LiveSync up more easier. -- 0.15.6 File tracking logic has been refined. -- 0.15.7 Fixed bug about renaming file. -- 0.15.8 Fixed bug about deleting empty directory, weird behaviour on boot-sequence on mobile devices. -- 0.15.9 Improved chunk retrieving, now chunks are retrieved in batch on continuous requests. -- 0.15.10 Fixed: - - The boot sequence has been corrected and now boots smoothly. - - Auto applying of batch save will be processed earlier than before. - -### 0.14.1 - -- The target selecting filter was implemented. - Now we can set what files are synchronised by regular expression. -- We can configure the size of chunks. - We can use larger chunks to improve performance. - (This feature can not be used with IBM Cloudant) -- Read chunks online. - Now we can synchronise only metadata and retrieve chunks on demand. It reduces local database size and time for replication. -- Added this note. -- Use local chunks in preference to remote them if present, - -#### Recommended configuration for Self-hosted CouchDB - -- Set chunk size to around 100 to 250 (10MB - 25MB per chunk) -- _Set batch size to 100 and batch limit to 20 (0.14.2)_ -- Be sure to `Read chunks online` checked. - -#### Minors - -- 0.14.2 Fixed issue about retrieving files if synchronisation has been interrupted or failed -- 0.14.3 New test items have been added to `Check database configuration`. -- 0.14.4 Fixed issue of importing configurations. -- 0.14.5 Auto chunk size adjusting implemented. -- 0.14.6 Change Target to ES2018 -- 0.14.7 Refactor and fix typos. -- 0.14.8 Refactored again. There should be no change in behaviour, but please let me know if there is any. - -### 0.13.0 - -- The metadata of the deleted files will be kept on the database by default. If you want to delete this as the previous version, please turn on `Delete metadata of deleted files.`. And, if you have upgraded from the older version, please ensure every device has been upgraded. -- Please turn on `Delete metadata of deleted files.` if you are using livesync-classroom or filesystem-livesync. -- We can see the history of deleted files. -- `Pick file to show` was renamed to `Pick a file to show. -- Files in the `Pick a file to show` are now ordered by their modified date descent. -- Update information became to be shown on the major upgrade. - -#### Minors - -- 0.13.1 Fixed on conflict resolution. -- 0.13.2 Fixed file deletion failures. -- 0.13.4 - - Now, we can synchronise hidden files that conflicted on each devices. - - We can search for conflicting docs. - - Pending processes can now be run at any time. - - Performance improved on synchronising large numbers of files at once. +This compatibility page remains at its previous path so existing links continue to work. diff --git a/utils/bench/splitPiecesRabinKarp.ts b/utils/bench/splitPiecesRabinKarp.ts index 1c4642c7..d5ee4e5c 100644 --- a/utils/bench/splitPiecesRabinKarp.ts +++ b/utils/bench/splitPiecesRabinKarp.ts @@ -2,15 +2,19 @@ import { glob } from "glob"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { promises as fs } from "node:fs"; -import { isPlainText, shouldSplitAsPlainText } from "../../src/lib/src/string_and_binary/path"; -import { splitPiecesRabinKarp } from "../../src/lib/src/string_and_binary/chunks"; +import { isPlainText, shouldSplitAsPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; +import { splitPiecesRabinKarp } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/chunks"; import { PREFERRED_BASE, PREFERRED_JOURNAL_SYNC, PREFERRED_SETTING_CLOUDANT, PREFERRED_SETTING_SELF_HOSTED, -} from "../../src/lib/src/common/models/setting.const.preferred"; -import { type ObsidianLiveSyncSettings, DEFAULT_SETTINGS, MAX_DOC_SIZE_BIN } from "../../src/lib/src/common/types"; +} from "@vrtmrz/livesync-commonlib/settings"; +import { + type ObsidianLiveSyncSettings, + DEFAULT_SETTINGS, + MAX_DOC_SIZE_BIN, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; async function blobFromString(content: string): Promise { return new Blob([content], { type: "text/plain" }); diff --git a/utils/commonlib-package-boundary.unit.spec.ts b/utils/commonlib-package-boundary.unit.spec.ts new file mode 100644 index 00000000..dc254759 --- /dev/null +++ b/utils/commonlib-package-boundary.unit.spec.ts @@ -0,0 +1,20 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +type PackageJson = Partial< + Record<"dependencies" | "devDependencies" | "optionalDependencies" | "peerDependencies", Record> +>; + +const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as PackageJson; + +describe("Commonlib package ownership", () => { + it("does not retain a host-owned Trystero dependency", () => { + const directDependencies = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + ...packageJson.optionalDependencies, + ...packageJson.peerDependencies, + }; + expect(directDependencies).not.toHaveProperty("@trystero-p2p/nostr"); + }); +}); diff --git a/utils/couchdb/couchdb-init.sh b/utils/couchdb/couchdb-init.sh index 323c13f3..3e41c3f8 100755 --- a/utils/couchdb/couchdb-init.sh +++ b/utils/couchdb/couchdb-init.sh @@ -1,33 +1,26 @@ #!/bin/bash -if [[ -z "$hostname" ]]; then - echo "ERROR: Hostname missing" - exit 1 -fi -if [[ -z "$username" ]]; then - echo "ERROR: Username missing" +set -euo pipefail + +if ! command -v deno >/dev/null 2>&1; then + echo "ERROR: Deno is required to run the Commonlib-backed CouchDB provisioning tool." >&2 exit 1 fi -if [[ -z "$password" ]]; then - echo "ERROR: Password missing" - exit 1 -fi -if [[ -z "$node" ]]; then - echo "INFO: defaulting to _local" - node=_local +script_url="${provision_script_url:-https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/provision.ts}" +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || true)" +deno_dependency_options=() +if [[ -n "$script_dir" && -f "$script_dir/provision.ts" ]]; then + script_url="$script_dir/provision.ts" + lockfile="$script_dir/../flyio/deno.lock" + deno_config="$script_dir/../flyio/deno.jsonc" + if [[ -f "$lockfile" && -f "$deno_config" ]]; then + deno_dependency_options+=("--config=$deno_config" --frozen "--lock=$lockfile") + fi fi -echo "-- Configuring CouchDB by REST APIs... -->" - -until (curl -X POST "${hostname}/_cluster_setup" -H "Content-Type: application/json" -d "{\"action\":\"enable_single_node\",\"username\":\"${username}\",\"password\":\"${password}\",\"bind_address\":\"0.0.0.0\",\"port\":5984,\"singlenode\":true}" --user "${username}:${password}"); do sleep 5; done -until (curl -X PUT "${hostname}/_node/${node}/_config/chttpd/require_valid_user" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done -until (curl -X PUT "${hostname}/_node/${node}/_config/chttpd_auth/require_valid_user" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done -until (curl -X PUT "${hostname}/_node/${node}/_config/httpd/WWW-Authenticate" -H "Content-Type: application/json" -d '"Basic realm=\"couchdb\""' --user "${username}:${password}"); do sleep 5; done -until (curl -X PUT "${hostname}/_node/${node}/_config/httpd/enable_cors" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done -until (curl -X PUT "${hostname}/_node/${node}/_config/chttpd/enable_cors" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done -until (curl -X PUT "${hostname}/_node/${node}/_config/chttpd/max_http_request_size" -H "Content-Type: application/json" -d '"4294967296"' --user "${username}:${password}"); do sleep 5; done -until (curl -X PUT "${hostname}/_node/${node}/_config/couchdb/max_document_size" -H "Content-Type: application/json" -d '"50000000"' --user "${username}:${password}"); do sleep 5; done -until (curl -X PUT "${hostname}/_node/${node}/_config/cors/credentials" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done -until (curl -X PUT "${hostname}/_node/${node}/_config/cors/origins" -H "Content-Type: application/json" -d '"app://obsidian.md,capacitor://localhost,http://localhost"' --user "${username}:${password}"); do sleep 5; done - -echo "<-- Configuring CouchDB by REST APIs Done!" +exec deno run \ + --minimum-dependency-age=0 \ + "${deno_dependency_options[@]}" \ + --allow-env \ + --allow-net \ + "$script_url" diff --git a/utils/couchdb/livesync-commonlib.ts b/utils/couchdb/livesync-commonlib.ts new file mode 100644 index 00000000..b1a3a30a --- /dev/null +++ b/utils/couchdb/livesync-commonlib.ts @@ -0,0 +1,5 @@ +// Keep CouchDB database-version negotiation isolated from Setup URI generation. +// The exact release must match utils/livesync-commonlib-version.ts; the setup +// tool suite checks every static specifier before release. +export { checkRemoteVersion } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/pouchdb/negotiation"; +export { PouchDB } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/pouchdb/pouchdb-browser"; diff --git a/utils/couchdb/provision.test.ts b/utils/couchdb/provision.test.ts new file mode 100644 index 00000000..c4942d16 --- /dev/null +++ b/utils/couchdb/provision.test.ts @@ -0,0 +1,83 @@ +import { provisionCouchDB } from "./provision.ts"; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +Deno.test("configures CouchDB and delegates database-version initialisation", async () => { + const requests: Array<{ url: string; method: string; body: string }> = []; + const initialisations: Array<[string, string, string]> = []; + await provisionCouchDB( + { + hostname: "https://couch.example.test/", + username: "alice", + password: "secret", + database: "notes", + retryCount: 1, + retryDelayMs: 0, + }, + { + fetch: async (input, init) => { + requests.push({ + url: String(input), + method: init?.method ?? "GET", + body: String(init?.body ?? ""), + }); + return new Response("{}", { status: 201 }); + }, + sleep: async () => {}, + initialiseDatabaseVersion: async (...args) => { + initialisations.push(args); + }, + }, + ); + + assert( + requests[0].url.endsWith("/_cluster_setup"), + "cluster setup was not first", + ); + assert( + requests.some((request) => + request.url.endsWith("/_config/cors/origins") && + request.body.includes("app://obsidian.md") + ), + "the Obsidian CORS origins were not configured", + ); + assert( + requests.at(-1)?.url === "https://couch.example.test/notes", + "the requested database was not created last", + ); + assert( + initialisations.length === 1, + "database-version initialisation was not delegated once", + ); + assert( + initialisations[0][0] === "https://couch.example.test/notes", + "database-version initialisation used the wrong URL", + ); +}); + +Deno.test("leaves database creation to the client when no database is supplied", async () => { + let initialised = false; + await provisionCouchDB( + { + hostname: "http://127.0.0.1:5984", + username: "admin", + password: "secret", + retryCount: 1, + retryDelayMs: 0, + }, + { + fetch: async () => new Response("{}", { status: 200 }), + sleep: async () => {}, + initialiseDatabaseVersion: async () => { + initialised = true; + }, + }, + ); + + assert( + !initialised, + "database-version initialisation ran without a database", + ); +}); diff --git a/utils/couchdb/provision.ts b/utils/couchdb/provision.ts new file mode 100644 index 00000000..21b6283b --- /dev/null +++ b/utils/couchdb/provision.ts @@ -0,0 +1,249 @@ +import { checkRemoteVersion, PouchDB } from "./livesync-commonlib.ts"; + +export interface CouchDBProvisioningOptions { + hostname: string; + username: string; + password: string; + node?: string; + database?: string; + origins?: string; + retryCount?: number; + retryDelayMs?: number; +} + +interface ProvisioningDependencies { + fetch: typeof fetch; + sleep: (milliseconds: number) => Promise; + initialiseDatabaseVersion: ( + databaseURL: string, + username: string, + password: string, + ) => Promise; +} + +const DEFAULT_ORIGINS = + "app://obsidian.md,capacitor://localhost,http://localhost"; + +function requireValue(value: string, name: string): string { + const trimmed = value.trim(); + if (!trimmed) throw new Error(`${name} is required`); + return trimmed; +} + +function normaliseHostname(hostname: string): string { + const parsed = new URL(requireValue(hostname, "hostname")); + parsed.pathname = parsed.pathname.replace(/\/+$/, ""); + parsed.search = ""; + parsed.hash = ""; + return parsed.href.replace(/\/$/, ""); +} + +function validateDatabaseName(database: string): string { + const trimmed = database.trim(); + if (!/^[a-z][a-z0-9_$()+-]*$/.test(trimmed)) { + throw new Error( + "database must begin with a lower-case letter and contain only lower-case letters, digits, _, $, (, ), +, or -", + ); + } + return trimmed; +} + +function basicAuthorisation(username: string, password: string): string { + return `Basic ${btoa(`${username}:${password}`)}`; +} + +async function requestWithRetry( + dependencies: ProvisioningDependencies, + label: string, + url: string, + init: RequestInit, + accept: (response: Response, body: string) => boolean, + retryCount: number, + retryDelayMs: number, +): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= retryCount; attempt++) { + try { + const response = await dependencies.fetch(url, init); + const body = await response.text(); + if (accept(response, body)) return; + const error = new Error( + `${label} failed with HTTP ${response.status}: ${body}`, + ); + if (response.status < 500) throw error; + lastError = error; + } catch (error) { + lastError = error; + if ( + error instanceof Error && + error.message.startsWith(`${label} failed with HTTP 4`) + ) { + throw error; + } + } + if (attempt < retryCount) await dependencies.sleep(retryDelayMs); + } + throw lastError instanceof Error + ? lastError + : new Error(`${label} failed after ${retryCount} attempts`); +} + +export async function initialiseLiveSyncDatabaseVersion( + databaseURL: string, + username: string, + password: string, +): Promise { + const database = new PouchDB(databaseURL, { + adapter: "http", + auth: { username, password }, + skip_setup: true, + }); + try { + const compatible = await checkRemoteVersion( + database, + async () => false, + ); + if (!compatible) { + throw new Error( + "the remote database uses an incompatible LiveSync database version", + ); + } + } finally { + await database.close(); + } +} + +export async function provisionCouchDB( + options: CouchDBProvisioningOptions, + overrides: Partial = {}, +): Promise { + const hostname = normaliseHostname(options.hostname); + const username = requireValue(options.username, "username"); + const password = requireValue(options.password, "password"); + const node = encodeURIComponent(options.node?.trim() || "_local"); + const origins = options.origins?.trim() || DEFAULT_ORIGINS; + const retryCount = options.retryCount ?? 12; + const retryDelayMs = options.retryDelayMs ?? 5_000; + if (!Number.isInteger(retryCount) || retryCount < 1) { + throw new Error("retryCount must be a positive integer"); + } + if (!Number.isFinite(retryDelayMs) || retryDelayMs < 0) { + throw new Error("retryDelayMs must be zero or greater"); + } + + const dependencies: ProvisioningDependencies = { + fetch, + sleep: (milliseconds) => + new Promise((resolve) => setTimeout(resolve, milliseconds)), + initialiseDatabaseVersion: initialiseLiveSyncDatabaseVersion, + ...overrides, + }; + const headers = { + "Content-Type": "application/json", + Authorization: basicAuthorisation(username, password), + }; + const configure = async ( + label: string, + path: string, + body: string, + method = "PUT", + accept: (response: Response, body: string) => boolean = (response) => + response.ok, + ) => + await requestWithRetry( + dependencies, + label, + `${hostname}${path}`, + { method, headers, body }, + accept, + retryCount, + retryDelayMs, + ); + + await configure( + "single-node cluster setup", + "/_cluster_setup", + JSON.stringify({ + action: "enable_single_node", + username, + password, + bind_address: "0.0.0.0", + port: 5984, + singlenode: true, + }), + "POST", + (response, body) => + response.ok || + ((response.status === 400 || response.status === 409) && + /already|finished/i.test(body)), + ); + + const settings: Array<[string, string, string]> = [ + ["require authenticated HTTP users", "chttpd/require_valid_user", '"true"'], + [ + "require authenticated HTTP users for authentication", + "chttpd_auth/require_valid_user", + '"true"', + ], + [ + "set the HTTP authentication challenge", + "httpd/WWW-Authenticate", + '"Basic realm=\\"couchdb\\""', + ], + ["enable HTTP CORS", "httpd/enable_cors", '"true"'], + ["enable clustered HTTP CORS", "chttpd/enable_cors", '"true"'], + [ + "set the maximum HTTP request size", + "chttpd/max_http_request_size", + '"4294967296"', + ], + [ + "set the maximum document size", + "couchdb/max_document_size", + '"50000000"', + ], + ["enable CORS credentials", "cors/credentials", '"true"'], + ["set allowed CORS origins", "cors/origins", JSON.stringify(origins)], + ]; + for (const [label, key, body] of settings) { + await configure(label, `/_node/${node}/_config/${key}`, body); + } + + if (options.database?.trim()) { + const database = validateDatabaseName(options.database); + const databaseURL = `${hostname}/${encodeURIComponent(database)}`; + await requestWithRetry( + dependencies, + "create database", + databaseURL, + { method: "PUT", headers }, + (response) => response.ok || response.status === 412, + retryCount, + retryDelayMs, + ); + await dependencies.initialiseDatabaseVersion( + databaseURL, + username, + password, + ); + } +} + +function optionalNumber(name: string): number | undefined { + const value = Deno.env.get(name)?.trim(); + return value ? Number(value) : undefined; +} + +if (import.meta.main) { + await provisionCouchDB({ + hostname: Deno.env.get("hostname") ?? "", + username: Deno.env.get("username") ?? "", + password: Deno.env.get("password") ?? "", + node: Deno.env.get("node"), + database: Deno.env.get("database"), + origins: Deno.env.get("origins"), + retryCount: optionalNumber("retry_count"), + retryDelayMs: optionalNumber("retry_delay_ms"), + }); + console.log("CouchDB provisioning completed."); +} diff --git a/utils/flyio/deno.lock b/utils/flyio/deno.lock index 30d3c5eb..7ca3f1f5 100644 --- a/utils/flyio/deno.lock +++ b/utils/flyio/deno.lock @@ -1,25 +1,855 @@ { - "version": "4", + "version": "5", "specifiers": { - "npm:octagonal-wheels@0.1.11": "0.1.11" + "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4": "0.1.0-rc.4" }, "npm": { - "idb@8.0.0": { - "integrity": "sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw==" - }, - "octagonal-wheels@0.1.11": { - "integrity": "sha512-KsXfpziFHmlLEBe5VAXFz9OyyjJEEdSg7xxASqdzmbe5oo9dhcOeWGrQfyipRJwHAhlFkI4vEf8JCSgkcyRxYg==", + "@aws-sdk/checksums@3.1000.18": { + "integrity": "sha512-IImkbEyXdV6/uaF5r6Wkk+8718mQw1ll83j0a4a30R3JM/rHVFdWAiT4jtJpFjJiIwM/oJ6SxIxr0z2TaQUGqw==", "dependencies": [ - "idb", - "xxhash-wasm@0.4.2", - "xxhash-wasm-102@npm:xxhash-wasm@1.0.2" + "@aws-sdk/core", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib" ] }, - "xxhash-wasm@0.4.2": { - "integrity": "sha512-/eyHVRJQCirEkSZ1agRSCwriMhwlyUcFkXD5TPVSLP+IPzjsqMVzZwdoczLp1SoQU0R3dxz1RpIK+4YNQbCVOA==" + "@aws-sdk/client-s3@3.1090.0": { + "integrity": "sha512-R6GX9cd1jljwzZ8xFmgAI/hHCuX1MobIKBdsymv7WL9SENvO9Vgz9KOR6avTnu0Ao+w1LmxnTe+jqmZXEn7Q/Q==", + "dependencies": [ + "@aws-sdk/checksums", + "@aws-sdk/core", + "@aws-sdk/credential-provider-node", + "@aws-sdk/middleware-sdk-s3", + "@aws-sdk/signature-v4-multi-region", + "@aws-sdk/types", + "@smithy/core", + "@smithy/fetch-http-handler", + "@smithy/node-http-handler", + "@smithy/types", + "tslib" + ] }, - "xxhash-wasm@1.0.2": { - "integrity": "sha512-ibF0Or+FivM9lNrg+HGJfVX8WJqgo+kCLDc4vx6xMeTce7Aj+DLttKbxxRR/gNLSAelRc1omAPlJ77N/Jem07A==" + "@aws-sdk/core@3.975.3": { + "integrity": "sha512-7ur3kCKuvPLqlsZ2XlvnNBVQ7KkpSu6Y6dOTwSPHLrFpTEfZM8isLBJc4cgv96WB7GifeVM436mpycwxBd2vEA==", + "dependencies": [ + "@aws-sdk/types", + "@aws-sdk/xml-builder", + "@aws/lambda-invoke-store", + "@smithy/core", + "@smithy/signature-v4", + "@smithy/types", + "bowser", + "tslib" + ] + }, + "@aws-sdk/credential-provider-env@3.972.59": { + "integrity": "sha512-Ny5e4Mfh3QPmiAc0AiUe+cbTXDlxkU3Rc+EpWOfyWeWEy6yp7Fa1KmfNeCc+1a8by9zQ9gtohmiQUkMPScF3ng==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/credential-provider-http@3.972.61": { + "integrity": "sha512-8jAjgStl5Ytq4+HF3X/9f+EmRinaRbGRRtQGktlPfBRVx73H+R1y48vIeXerQtYGFaUqkEp3fT6jP854rVO2yQ==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/types", + "@smithy/core", + "@smithy/fetch-http-handler", + "@smithy/node-http-handler", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/credential-provider-ini@3.973.4": { + "integrity": "sha512-e6ZvVsj90aRALf1kHP+J4iqC1496ZpVgqI/+u0LJ5HL7q7ATauGy4gdDvRCP13L1pN/fMiZLah162PGIYkbUVQ==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/credential-provider-env", + "@aws-sdk/credential-provider-http", + "@aws-sdk/credential-provider-login", + "@aws-sdk/credential-provider-process", + "@aws-sdk/credential-provider-sso", + "@aws-sdk/credential-provider-web-identity", + "@aws-sdk/nested-clients", + "@aws-sdk/types", + "@smithy/core", + "@smithy/credential-provider-imds", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/credential-provider-login@3.972.66": { + "integrity": "sha512-g2fsqm87r/nKthLZ0VkkDBElkGg0PvSa8d97HQ6EilMbJTZ6hxa8FxkSZyJfgPfFdZn0TTmkOffQmTSUcAHIng==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/nested-clients", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/credential-provider-node@3.972.70": { + "integrity": "sha512-3xzvkGdykBunxqh8WudmUpSyLWvIhfI6aBQo1b5rb3mDO5mNLadK+0hiI0qBQBMVynJbfLO+Ajy9dztMwy9O8w==", + "dependencies": [ + "@aws-sdk/credential-provider-env", + "@aws-sdk/credential-provider-http", + "@aws-sdk/credential-provider-ini", + "@aws-sdk/credential-provider-process", + "@aws-sdk/credential-provider-sso", + "@aws-sdk/credential-provider-web-identity", + "@aws-sdk/types", + "@smithy/core", + "@smithy/credential-provider-imds", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/credential-provider-process@3.972.59": { + "integrity": "sha512-DlZF2/MhLlatDdlrIy3CUCpfdbLrKx+3SMjVo+WyHnPpwzkc/M3vwAHw4OVJf7DMvO+4vfRqSCMc/E9I1auN0g==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/credential-provider-sso@3.973.3": { + "integrity": "sha512-hmdDHoy2G5Es2e8IgelNMYUuSQI6uCIAKZMJ2u2PdKDhxvbk1uWD/g4+R7R5c/tJfKEB1+KjjWiaoCr/S+ZTiQ==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/nested-clients", + "@aws-sdk/token-providers", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/credential-provider-web-identity@3.972.65": { + "integrity": "sha512-gHQb/Kt0chjk/JQDa/GJDqmAvEuVn8n7z10wK2h0LFM9TUDRkohgOO4aEF+s2sBLM0br7Cl5W6P7phgjrrJvLQ==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/nested-clients", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/middleware-sdk-s3@3.972.64": { + "integrity": "sha512-RBi43anhDBUv+HCfxCOXwGOE7GmT4n7ChV04Mwr22RhXTNcamW/iWnJlOotDPCZSrJ4dEvhZSiWWQMwLX+ZhFA==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/signature-v4-multi-region", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/nested-clients@3.997.33": { + "integrity": "sha512-dVZOroI/r3/ENvqNGgjMPul+jjlz9GddfVusgTXlVjfZj5isibOxecLkGQbRPp8XOuX+RAfjXLFgPkD1JS5xrw==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/signature-v4-multi-region", + "@aws-sdk/types", + "@smithy/core", + "@smithy/fetch-http-handler", + "@smithy/node-http-handler", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/signature-v4-multi-region@3.996.41": { + "integrity": "sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==", + "dependencies": [ + "@aws-sdk/types", + "@smithy/signature-v4", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/token-providers@3.1088.0": { + "integrity": "sha512-4ObatWt2qpJg5FBk4LOOKrTQYzaqeewAtdO3r9ZO8lH9YqLtpTzLyIdy0mJ+nVdfYOnqISkKNfmzP22bNDhwyw==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/nested-clients", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/types@3.974.2": { + "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", + "dependencies": [ + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/xml-builder@3.972.36": { + "integrity": "sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==", + "dependencies": [ + "@smithy/types", + "tslib" + ] + }, + "@aws/lambda-invoke-store@0.3.0": { + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==" + }, + "@noble/secp256k1@3.1.0": { + "integrity": "sha512-+F7iS7tUMaNGXcc9X3PjmjvuQnXEuSjCRNzVVA2xAcKXgCaP0dHYz4SFyt4FKNHef7sOP//xihowcySSS7PK9g==" + }, + "@smithy/core@3.29.5": { + "integrity": "sha512-i0dk2t5B+CwV/dcJdUHILYkOQF5lof8f44dFCfDWToGCxjT9YQ+CgHqTAvJxzc3+zqQwm2QtVoJ5IqiNar/CnQ==", + "dependencies": [ + "@smithy/types", + "tslib" + ] + }, + "@smithy/credential-provider-imds@4.4.10": { + "integrity": "sha512-MJenAe4OKRZUo1LdYYFDCsSHxaHvInIU/z52GsheO9vl1/VSySVCr0zkyKD6TFiGkSUaWGxvKZ/70OvgUZR5HQ==", + "dependencies": [ + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@smithy/fetch-http-handler@5.6.7": { + "integrity": "sha512-3zpg8yqqyXzoK2TsRDdkqVOj2RDBFfLXwCczOZ5c7TWB4eiaebfSCsbMjDPYB3PJ9ihV62QaeadZ+wLadZtNGA==", + "dependencies": [ + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@smithy/md5-js@4.4.10": { + "integrity": "sha512-XI5xhWxRkWuiLNj0/Z30vLTPpZe9UQcg57Ox/n4vzGmFiHSrD+xAmx6ubEcWt6u69IOH+4LAucpaZk5AMxB8oQ==", + "dependencies": [ + "@smithy/core", + "tslib" + ] + }, + "@smithy/middleware-apply-body-checksum@4.5.10": { + "integrity": "sha512-1qFwlILFq+QnI1oqZIBpLda1W6oCkI667GPTyAn0eRan72ddQ/zA1CoKiIezTHLQKxINpPvDKVwsw/znLmKEbg==", + "dependencies": [ + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@smithy/node-http-handler@4.9.7": { + "integrity": "sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==", + "dependencies": [ + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@smithy/signature-v4@5.6.6": { + "integrity": "sha512-efP6DN3UTFrzIsGO42/xcabv8jU7+9nwEdphFUH7yL0k010ERyAWaO41KFQIDLcFZLZ8xzIQr4wplFxNzslSGQ==", + "dependencies": [ + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@smithy/types@4.16.1": { + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", + "dependencies": [ + "tslib" + ] + }, + "@smithy/util-retry@4.5.10": { + "integrity": "sha512-hYu5ieq8myuO29xCQV0IIhicRm1aO0lcNBeMcF1mVVAVQC0oylPGKFliMq5NbxtdOvRxJWCMuir2gwKI91f1lw==", + "dependencies": [ + "@smithy/core", + "tslib" + ] + }, + "@trystero-p2p/core@0.25.3": { + "integrity": "sha512-lQKNq/ha+vF6kQZrpaXJGzlzxLF/Fhizoy5dwJUYQlkqRrbPup/Jov2EpPX/CPr2X+f79HxVzonkeni3MxmCuQ==" + }, + "@trystero-p2p/nostr@0.25.3": { + "integrity": "sha512-nZV9Fl/GXuhIkJSQ+wIkdMoRLO1oSTUL/+vZorukaojeAdOpT/61e8b9Vzbt8L1ktMTaGPEJHw2BG/B5BCwBuw==", + "dependencies": [ + "@noble/secp256k1", + "@trystero-p2p/core" + ] + }, + "@vrtmrz/livesync-commonlib@0.1.0-rc.4": { + "integrity": "sha512-u4FdbjnYg7lAf38z7eUv4eq4vxEdrl4rFMxiDZiJ7T701awKiflkXGIJQnaHdLaMa3zkRBU15qqEo7GtextmlA==", + "dependencies": [ + "@aws-sdk/client-s3", + "@smithy/fetch-http-handler", + "@smithy/md5-js", + "@smithy/middleware-apply-body-checksum", + "@smithy/types", + "@smithy/util-retry", + "@trystero-p2p/nostr", + "diff-match-patch", + "events", + "fflate", + "idb", + "markdown-it", + "minimatch", + "octagonal-wheels", + "pouchdb-adapter-http", + "pouchdb-adapter-idb", + "pouchdb-adapter-indexeddb", + "pouchdb-adapter-memory", + "pouchdb-core", + "pouchdb-errors", + "pouchdb-find", + "pouchdb-mapreduce", + "pouchdb-merge", + "pouchdb-replication", + "pouchdb-utils", + "qrcode-generator", + "transform-pouch", + "xxhash-wasm-102@npm:xxhash-wasm@1.1.0" + ] + }, + "abstract-leveldown@2.7.2": { + "integrity": "sha512-+OVvxH2rHVEhWLdbudP6p0+dNMXu8JA1CbhP19T8paTYAcX7oJ4OVjT+ZUVpv7mITxXHqDMej+GdqXBmXkw09w==", + "dependencies": [ + "xtend" + ], + "deprecated": true + }, + "abstract-leveldown@6.2.3": { + "integrity": "sha512-BsLm5vFMRUrrLeCcRc+G0t2qOaTzpoJQLOubq2XM72eNpjF5UdU5o/5NvlNhx95XHcAvcl8OMXr4mlg/fRgUXQ==", + "dependencies": [ + "buffer", + "immediate", + "level-concat-iterator", + "level-supports", + "xtend" + ], + "deprecated": true + }, + "argparse@2.0.1": { + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "balanced-match@4.0.4": { + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==" + }, + "base64-js@1.5.1": { + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "bowser@2.14.1": { + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==" + }, + "brace-expansion@5.0.7": { + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dependencies": [ + "balanced-match" + ] + }, + "buffer@5.7.1": { + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dependencies": [ + "base64-js", + "ieee754" + ] + }, + "core-util-is@1.0.3": { + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "deferred-leveldown@5.3.0": { + "integrity": "sha512-a59VOT+oDy7vtAbLRCZwWgxu2BaCfd5Hk7wxJd48ei7I+nsg8Orlb9CLG0PMZienk9BSUKgeAqkO2+Lw+1+Ukw==", + "dependencies": [ + "abstract-leveldown@6.2.3", + "inherits" + ], + "deprecated": true + }, + "diff-match-patch@1.0.5": { + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==" + }, + "double-ended-queue@2.1.0-0": { + "integrity": "sha512-+BNfZ+deCo8hMNpDqDnvT+c0XpJ5cUa6mqYq89bho2Ifze4URTqRkcwR399hWoTrTkbZ/XJYDgP6rc7pRgffEQ==" + }, + "entities@4.5.0": { + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + }, + "errno@0.1.8": { + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", + "dependencies": [ + "prr" + ], + "bin": true + }, + "events@3.3.0": { + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" + }, + "fetch-cookie@2.2.0": { + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "dependencies": [ + "set-cookie-parser", + "tough-cookie" + ] + }, + "fflate@0.8.3": { + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==" + }, + "functional-red-black-tree@1.0.1": { + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==" + }, + "idb@8.0.3": { + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==" + }, + "ieee754@1.2.1": { + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, + "immediate@3.3.0": { + "integrity": "sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q==" + }, + "inherits@2.0.4": { + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "isarray@0.0.1": { + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" + }, + "level-codec@9.0.2": { + "integrity": "sha512-UyIwNb1lJBChJnGfjmO0OR+ezh2iVu1Kas3nvBS/BzGnx79dv6g7unpKIDNPMhfdTEGoc7mC8uAu51XEtX+FHQ==", + "dependencies": [ + "buffer" + ], + "deprecated": true + }, + "level-concat-iterator@2.0.1": { + "integrity": "sha512-OTKKOqeav2QWcERMJR7IS9CUo1sHnke2C0gkSmcR7QuEtFNLLzHQAvnMw8ykvEcv0Qtkg0p7FOwP1v9e5Smdcw==", + "deprecated": true + }, + "level-errors@2.0.1": { + "integrity": "sha512-UVprBJXite4gPS+3VznfgDSU8PTRuVX0NXwoWW50KLxd2yw4Y1t2JUR5In1itQnudZqRMT9DlAM3Q//9NCjCFw==", + "dependencies": [ + "errno" + ], + "deprecated": true + }, + "level-iterator-stream@4.0.2": { + "integrity": "sha512-ZSthfEqzGSOMWoUGhTXdX9jv26d32XJuHz/5YnuHZzH6wldfWMOVwI9TBtKcya4BKTyTt3XVA0A3cF3q5CY30Q==", + "dependencies": [ + "inherits", + "readable-stream@3.6.2", + "xtend" + ] + }, + "level-supports@1.0.1": { + "integrity": "sha512-rXM7GYnW8gsl1vedTJIbzOrRv85c/2uCMpiiCzO2fndd06U/kUXEEU9evYn4zFggBOg36IsBW8LzqIpETwwQzg==", + "dependencies": [ + "xtend" + ] + }, + "levelup@4.4.0": { + "integrity": "sha512-94++VFO3qN95cM/d6eBXvd894oJE0w3cInq9USsyQzzoJxmiYzPAocNcuGCPGGjoXqDVJcr3C1jzt1TSjyaiLQ==", + "dependencies": [ + "deferred-leveldown", + "level-errors", + "level-iterator-stream", + "level-supports", + "xtend" + ], + "deprecated": true + }, + "linkify-it@5.0.2": { + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "dependencies": [ + "uc.micro" + ] + }, + "ltgt@2.2.1": { + "integrity": "sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA==" + }, + "markdown-it@14.3.0": { + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", + "dependencies": [ + "argparse", + "entities", + "linkify-it", + "mdurl", + "punycode.js", + "uc.micro" + ], + "bin": true + }, + "mdurl@2.0.0": { + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==" + }, + "memdown@1.4.1": { + "integrity": "sha512-iVrGHZB8i4OQfM155xx8akvG9FIj+ht14DX5CQkCTG4EHzZ3d3sgckIf/Lm9ivZalEsFuEVnWv2B2WZvbrro2w==", + "dependencies": [ + "abstract-leveldown@2.7.2", + "functional-red-black-tree", + "immediate", + "inherits", + "ltgt", + "safe-buffer@5.1.2" + ], + "deprecated": true + }, + "minimatch@10.2.5": { + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dependencies": [ + "brace-expansion" + ] + }, + "node-fetch@2.6.9": { + "integrity": "sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==", + "dependencies": [ + "whatwg-url" + ] + }, + "octagonal-wheels@0.1.51": { + "integrity": "sha512-KTlfqKPjobHJg/t3A539srnFf+VHr1aXkHSmsNDDpiI5UFC7FamZ95dWpJfGE2EI/HULR5hveQDgkazmz8SAcg==", + "dependencies": [ + "idb" + ] + }, + "pouchdb-abstract-mapreduce@9.0.0": { + "integrity": "sha512-SnTtqwAEiAa3uxKbc1J7LfiBViwEkKe2xkK92zxyTXPqWBvMnh4UU3GXxx7GrXTM4L9llsQ3lSjpbH4CNqG1Mw==", + "dependencies": [ + "pouchdb-binary-utils", + "pouchdb-collate", + "pouchdb-errors", + "pouchdb-fetch", + "pouchdb-mapreduce-utils", + "pouchdb-md5", + "pouchdb-utils" + ] + }, + "pouchdb-adapter-http@9.0.0": { + "integrity": "sha512-2eL008XeRZkdyp3hMHHOhdIPqK9H6Mn4SLlQvit4zCbqnOFfAswzPjUmHULGMbDUCrQBTu6y82FnV6NHXv9kgw==", + "dependencies": [ + "pouchdb-binary-utils", + "pouchdb-errors", + "pouchdb-fetch", + "pouchdb-utils" + ] + }, + "pouchdb-adapter-idb@9.0.0": { + "integrity": "sha512-2oLlgwMyOQwdKuzrEmOv8T7jFVgX7JgT4Cr81zX3eiiRClp7xXGgjv41ZRdVCAbM530sIN8BudafaQRVFKRVmA==", + "dependencies": [ + "pouchdb-adapter-utils", + "pouchdb-binary-utils", + "pouchdb-errors", + "pouchdb-json", + "pouchdb-merge", + "pouchdb-utils" + ] + }, + "pouchdb-adapter-indexeddb@9.0.0": { + "integrity": "sha512-/mcCbnVR0VKwtVZWKf8lVSdADLD0yApjFudu4d+0jeLWAeBSGZBRKYlogz2PGs4uTA7GVc2TXjVCNGUdkCM9ZQ==", + "dependencies": [ + "pouchdb-adapter-utils", + "pouchdb-binary-utils", + "pouchdb-errors", + "pouchdb-md5", + "pouchdb-merge", + "pouchdb-utils" + ] + }, + "pouchdb-adapter-leveldb-core@9.0.0": { + "integrity": "sha512-b3ZGPtVXyivGL5SK3AIDG7PrNsZdoDpGFkmTytDTtctkVhxOg71gnXXP+CrupENPqSNG/eGbKW4w+bbMpxy6aA==", + "dependencies": [ + "double-ended-queue", + "levelup", + "pouchdb-adapter-utils", + "pouchdb-binary-utils", + "pouchdb-core", + "pouchdb-errors", + "pouchdb-json", + "pouchdb-md5", + "pouchdb-merge", + "pouchdb-utils", + "sublevel-pouchdb", + "through2" + ] + }, + "pouchdb-adapter-memory@9.0.0": { + "integrity": "sha512-XbCwJ5f5U9dGdkiDikzYjTebdPHuA6Ghylx1Pq0lDe4y6l8R9xhjDSUy56pJ8G2F4Z+8QdB5FBY9EQoFlFSXWQ==", + "dependencies": [ + "memdown", + "pouchdb-adapter-leveldb-core" + ] + }, + "pouchdb-adapter-utils@9.0.0": { + "integrity": "sha512-hmbm4ey0HL0vtoY1tRTPIt2FfYjvMh3DWoGGSxXDTS73qTFQ+Fhhi5I0AnN9PcD2omfKQAVXiYks4kkMvlAHqA==", + "dependencies": [ + "pouchdb-binary-utils", + "pouchdb-errors", + "pouchdb-md5", + "pouchdb-merge", + "pouchdb-utils" + ] + }, + "pouchdb-binary-utils@9.0.0": { + "integrity": "sha512-2OMtgDZi82vqs+zNDE0YiYjOaWkYCUcZJZKK3WkRr+XYRu+2B7umJrnygJFhUwoGedBbHSrlQBLhdNV3F1AX1A==" + }, + "pouchdb-changes-filter@9.0.0": { + "integrity": "sha512-ig0fo0WLgIjAniFJ19Uw1Y+oxiypqC+Skhd8BCETRVXOhLBzueRwEQR4thffyo0UayYVqldJfSR5wHSDvEVk/A==", + "dependencies": [ + "pouchdb-errors", + "pouchdb-selector-core", + "pouchdb-utils" + ] + }, + "pouchdb-checkpointer@9.0.0": { + "integrity": "sha512-yu1OlWw78oTHKOkg1GoxxF2qB7YUsjK3rUDJOChMs/sVlZwOTZ4mGdWFPBr3udxSGvR77E+g89kpdmAWhPpHvA==", + "dependencies": [ + "pouchdb-collate", + "pouchdb-utils" + ] + }, + "pouchdb-collate@9.0.0": { + "integrity": "sha512-TrnEDNZEmIIl+W3xKUO8h+geqVLQ90oZe5ujPkl8myUzpREULWXWQBnV5EzPXVEKDBpJlb8T3I6oy/zdWGQpdA==" + }, + "pouchdb-core@9.0.0": { + "integrity": "sha512-98SJgs8bqXhr4gMGuOTR8yVeLlMYy797zlOtdlvlXIxIicvocyA8ColhVVhdBXPNOGxT2HwReIMywdIVAgibpg==", + "dependencies": [ + "pouchdb-changes-filter", + "pouchdb-errors", + "pouchdb-fetch", + "pouchdb-merge", + "pouchdb-utils", + "uuid" + ] + }, + "pouchdb-errors@9.0.0": { + "integrity": "sha512-961PSMLhW0UqqdJ566g+CdLZ5pkBJRd6l4WWpCDdD0USvE4xYfYGzv43w7nZZBw1k3Xdy092yqPge7yX/tfnyw==" + }, + "pouchdb-fetch@9.0.0": { + "integrity": "sha512-TbE3cUcAJQrwb9kr44tDP0X+NAbcqgjsTvcL30L4xzBNJeCPTIRjukYX80s154SHJUXBxcWRiPsMmNqpXsjfCA==", + "dependencies": [ + "fetch-cookie", + "node-fetch" + ] + }, + "pouchdb-find@9.0.0": { + "integrity": "sha512-vvVhq4eEOmSkwSRwf2NBYtdhURB7ryJ7sUI4WDN00GuLUj2g8jAXBJuZIryVgdYt/5S5cfn70iRL6Eow+LFhpA==", + "dependencies": [ + "pouchdb-abstract-mapreduce", + "pouchdb-collate", + "pouchdb-errors", + "pouchdb-fetch", + "pouchdb-md5", + "pouchdb-selector-core", + "pouchdb-utils" + ] + }, + "pouchdb-generate-replication-id@9.0.0": { + "integrity": "sha512-wetxjU0W/qNYtfHIoKwBO73ddUr0/eqzYOkoKHSFXCgOzYmTglDeqXiVY9LPysRXTgaHUJPKC5LoknZZw7e+Dw==", + "dependencies": [ + "pouchdb-collate", + "pouchdb-md5" + ] + }, + "pouchdb-json@9.0.0": { + "integrity": "sha512-aI41mYVyI195GXuT1Ys7mLIB/Mvrz11ihoTP6km6hYqVgSuaUxuZcFUozlyTJiZXr7H5kdhNgclhlVnjir4JAA==", + "dependencies": [ + "vuvuzela" + ] + }, + "pouchdb-mapreduce-utils@9.0.0": { + "integrity": "sha512-Bjh8W6QXqp1j7MKmHhYYp5cYlcQsm5drD8Jd/F+ZlfNt18uiD2SQXWzGM5797+tiW/LszFGb8ttw0uHWjxufCQ==", + "dependencies": [ + "pouchdb-utils" + ] + }, + "pouchdb-mapreduce@9.0.0": { + "integrity": "sha512-ZD8PleQ9atzQAzT2LZWsvooUVEfsen5QGv/SDfci20IleCaFW2A2q7OERrqY0YWKDCCNRsWhPWPmsFvZC9K8DQ==", + "dependencies": [ + "pouchdb-abstract-mapreduce", + "pouchdb-mapreduce-utils", + "pouchdb-utils" + ] + }, + "pouchdb-md5@9.0.0": { + "integrity": "sha512-58xUYBvW3/s+aH0j4uOhhN8yCk0LQ254cxBzI/gbKA9PrfwHpe4zrr0L/ia5ml3A30oH1f8aTnuVMwWDkFcuww==", + "dependencies": [ + "pouchdb-binary-utils", + "spark-md5" + ] + }, + "pouchdb-merge@9.0.0": { + "integrity": "sha512-Xh+TgOZCkGoZpI589btKf/cTiuQ5CsnPl9YpdW4h0cAPusniN6XNsR62F+/HbL9wirI6XTEPHUrk7MsQbk3S3A==", + "dependencies": [ + "pouchdb-utils" + ] + }, + "pouchdb-replication@9.0.0": { + "integrity": "sha512-EZ68KJ3ZUWuPe35NxP6WnRw8J6Zudf0j/tZ/6mOSrCcp3EbtBNt8Ke2FaAThUgiFahVnHD5Y8nd53EGs2DLygg==", + "dependencies": [ + "pouchdb-checkpointer", + "pouchdb-errors", + "pouchdb-generate-replication-id", + "pouchdb-utils" + ] + }, + "pouchdb-selector-core@9.0.0": { + "integrity": "sha512-ZYHYsdoedwm8j5tYofz+3+uUSK8i+7tRCBb01T0OuqDQb17+w5mzjHF8Ppi160xdPUPaWCo1Un+nLWGJzkmA3g==", + "dependencies": [ + "pouchdb-collate", + "pouchdb-utils" + ] + }, + "pouchdb-utils@9.0.0": { + "integrity": "sha512-xWZE5c+nAslgmLC8JBZbky8AYgdz7pKtv7KTSi6CD2tuQD0WyNKib0YnhZndeE84dksTeZlqlg56RQHsHoB2LQ==", + "dependencies": [ + "pouchdb-errors", + "pouchdb-md5", + "uuid" + ] + }, + "pouchdb-wrappers@5.0.0": { + "integrity": "sha512-fXqsVn+rmlPtxaAIGaQP5TkiaT39OMwvMk+ScLLtHrmfXD2KBO6fe/qBl38N/rpTn0h/A058dPN4fLAHt550zA==" + }, + "prr@1.0.1": { + "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==" + }, + "psl@1.15.0": { + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dependencies": [ + "punycode" + ] + }, + "punycode.js@2.3.1": { + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==" + }, + "punycode@2.3.1": { + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" + }, + "qrcode-generator@1.5.2": { + "integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==" + }, + "querystringify@2.2.0": { + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + }, + "readable-stream@1.1.14": { + "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", + "dependencies": [ + "core-util-is", + "inherits", + "isarray", + "string_decoder@0.10.31" + ] + }, + "readable-stream@3.6.2": { + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": [ + "inherits", + "string_decoder@1.3.0", + "util-deprecate" + ] + }, + "requires-port@1.0.0": { + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, + "safe-buffer@5.1.2": { + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "safe-buffer@5.2.1": { + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "set-cookie-parser@2.7.2": { + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, + "spark-md5@3.0.2": { + "integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==" + }, + "string_decoder@0.10.31": { + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==" + }, + "string_decoder@1.3.0": { + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": [ + "safe-buffer@5.2.1" + ] + }, + "sublevel-pouchdb@9.0.0": { + "integrity": "sha512-pX4r8+F7wuts0C81kUJ341h4bl2aRe7qV572FE8X1FMz9VkKlmi2nPD1vfeiOJXz5Y09I4MHjGULAbqvTfQZEQ==", + "dependencies": [ + "level-codec", + "ltgt", + "readable-stream@1.1.14" + ] + }, + "through2@3.0.2": { + "integrity": "sha512-enaDQ4MUyP2W6ZyT6EsMzqBPZaM/avg8iuo+l2d3QCs0J+6RaqkHV/2/lOwDTueBHeJ/2LG9lrLW3d5rWPucuQ==", + "dependencies": [ + "inherits", + "readable-stream@3.6.2" + ] + }, + "tough-cookie@4.1.4": { + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dependencies": [ + "psl", + "punycode", + "universalify", + "url-parse" + ] + }, + "tr46@0.0.3": { + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "transform-pouch@2.0.0": { + "integrity": "sha512-nDZovo0U5o0UdMNL93fMQgGjrwH9h4F/a7qqRTnF6cVA+FfgyXiJPTrSuD+LmWSO7r2deZt0P0oeCD8hkgxl5g==", + "dependencies": [ + "pouchdb-wrappers" + ] + }, + "tslib@2.8.1": { + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "uc.micro@2.1.0": { + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==" + }, + "universalify@0.2.0": { + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==" + }, + "url-parse@1.5.10": { + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dependencies": [ + "querystringify", + "requires-port" + ] + }, + "util-deprecate@1.0.2": { + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "uuid@8.3.2": { + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": true, + "bin": true + }, + "vuvuzela@1.0.3": { + "integrity": "sha512-Tm7jR1xTzBbPW+6y1tknKiEhz04Wf/1iZkcTJjSFcpNko43+dFW6+OOeQe9taJIug3NdfUAjFKgUSyQrIKaDvQ==" + }, + "webidl-conversions@3.0.1": { + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "whatwg-url@5.0.0": { + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": [ + "tr46", + "webidl-conversions" + ] + }, + "xtend@4.0.2": { + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" + }, + "xxhash-wasm@1.1.0": { + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==" } } } diff --git a/utils/flyio/deploy-server.sh b/utils/flyio/deploy-server.sh index 967c1760..9c59329c 100755 --- a/utils/flyio/deploy-server.sh +++ b/utils/flyio/deploy-server.sh @@ -1,8 +1,15 @@ #!/bin/bash ## Script for deploy and automatic setup CouchDB onto fly.io. -## We need Deno for generating the Setup-URI. +## Deno is used for Commonlib-backed provisioning and Setup URI generation. -source setenv.sh $@ +set -euo pipefail + +if ! command -v deno >/dev/null 2>&1; then + echo "ERROR: Deno 2 is required for CouchDB provisioning and Setup URI generation." >&2 + exit 1 +fi + +source setenv.sh "$@" export hostname="https://$appname.fly.dev" @@ -14,30 +21,16 @@ echo "region : $region" echo "" echo "-- START DEPLOYING --> " -set -e fly launch --name=$appname --env="COUCHDB_USER=$username" --copy-config=true --detach --no-deploy --region ${region} --yes fly secrets set COUCHDB_PASSWORD=$password fly deploy -set +e ../couchdb/couchdb-init.sh -# flyctl deploy echo "OK!" -if command -v deno >/dev/null 2>&1; then - echo "Setup finished! Also, we can set up Self-hosted LiveSync instantly, by the following setup uri." - echo "Passphrase of setup-uri will be printed only one time. Keep it safe!" - echo "--- configured ---" - echo "database : ${database}" - echo "E2EE passphrase: ${passphrase}" - echo "--- setup uri ---" - deno run -A generate_setupuri.ts -else - echo "Setup finished! Here is the configured values (reprise)!" - echo "-- YOUR CONFIGURATION --" - echo "URL : $hostname" - echo "username: $username" - echo "password: $password" - echo "-- YOUR CONFIGURATION --" - echo "If we had Deno, we would got the setup uri directly!" -fi +echo "Setup finished. The Commonlib-generated Setup URI follows." +echo "Its passphrase is printed only once, so store it safely." +echo "--- configured ---" +echo "database: ${database}" +echo "--- setup URI ---" +deno run --minimum-dependency-age=0 --allow-env generate_setupuri.ts diff --git a/utils/flyio/generate_setupuri.test.ts b/utils/flyio/generate_setupuri.test.ts new file mode 100644 index 00000000..ec36f369 --- /dev/null +++ b/utils/flyio/generate_setupuri.test.ts @@ -0,0 +1,72 @@ +import { + decodeSettingsFromSetupURI, + DEFAULT_SETTINGS, +} from "../setup/livesync-commonlib.ts"; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +Deno.test("generates a current self-hosted Setup URI through the published Commonlib contract", async () => { + const scriptPath = new URL("./generate_setupuri.ts", import.meta.url); + const command = new Deno.Command(Deno.execPath(), { + args: ["run", "-A", scriptPath.pathname], + env: { + hostname: "https://couch.example.test", + username: "alice", + password: "couch-secret", + database: "notes", + passphrase: "vault-secret", + uri_passphrase: "setup-secret", + }, + stdout: "piped", + stderr: "piped", + }); + + const result = await command.output(); + const stdout = new TextDecoder().decode(result.stdout); + const stderr = new TextDecoder().decode(result.stderr); + assert(result.success, `generator failed:\n${stdout}\n${stderr}`); + + const setupURI = stdout.match(/obsidian:\/\/setuplivesync\?settings=\S+/) + ?.[0]; + assert(setupURI, `generator did not print a Setup URI:\n${stdout}`); + + const decoded = await decodeSettingsFromSetupURI(setupURI, "setup-secret"); + assert(decoded, "Commonlib could not decode the generated Setup URI"); + const effectiveSettings = { ...DEFAULT_SETTINGS, ...decoded }; + assert( + effectiveSettings.isConfigured, + "the CouchDB Setup URI left the imported device unconfigured", + ); + assert( + effectiveSettings.customChunkSize === 60, + "the Setup URI did not use the current self-hosted chunk-size recommendation", + ); + assert( + effectiveSettings.chunkSplitterVersion === "v3-rabin-karp", + "the Setup URI did not use the current chunk splitter", + ); + assert( + effectiveSettings.E2EEAlgorithm === "v2", + "the Setup URI did not use the current E2EE algorithm", + ); + assert( + !Object.hasOwn(decoded, "doNotUseFixedRevisionForChunks"), + "the Setup URI serialised the obsolete fixed-revision compatibility setting", + ); + + const profiles = Object.values(decoded.remoteConfigurations ?? {}); + assert( + profiles.length === 1, + "the Setup URI did not contain exactly one CouchDB remote profile", + ); + assert( + decoded.activeConfigurationId === profiles[0].id, + "the CouchDB remote profile was not selected", + ); + assert( + profiles[0].uri.startsWith("sls+https://"), + "the selected remote profile was not a CouchDB connection URI", + ); +}); diff --git a/utils/flyio/generate_setupuri.ts b/utils/flyio/generate_setupuri.ts index 390c2e1c..5df0e7d6 100644 --- a/utils/flyio/generate_setupuri.ts +++ b/utils/flyio/generate_setupuri.ts @@ -1,175 +1,6 @@ -import { encrypt } from "npm:octagonal-wheels@0.1.30/encryption/encryption"; +import { runSetupURIGenerator } from "../setup/generate_setup_uri.ts"; -const noun = [ - "waterfall", - "river", - "breeze", - "moon", - "rain", - "wind", - "sea", - "morning", - "snow", - "lake", - "sunset", - "pine", - "shadow", - "leaf", - "dawn", - "glitter", - "forest", - "hill", - "cloud", - "meadow", - "sun", - "glade", - "bird", - "brook", - "butterfly", - "bush", - "dew", - "dust", - "field", - "fire", - "flower", - "firefly", - "feather", - "grass", - "haze", - "mountain", - "night", - "pond", - "darkness", - "snowflake", - "silence", - "sound", - "sky", - "shape", - "surf", - "thunder", - "violet", - "water", - "wildflower", - "wave", - "water", - "resonance", - "sun", - "log", - "dream", - "cherry", - "tree", - "fog", - "frost", - "voice", - "paper", - "frog", - "smoke", - "star", -]; -const adjectives = [ - "autumn", - "hidden", - "bitter", - "misty", - "silent", - "empty", - "dry", - "dark", - "summer", - "icy", - "delicate", - "quiet", - "white", - "cool", - "spring", - "winter", - "patient", - "twilight", - "dawn", - "crimson", - "wispy", - "weathered", - "blue", - "billowing", - "broken", - "cold", - "damp", - "falling", - "frosty", - "green", - "long", - "late", - "lingering", - "bold", - "little", - "morning", - "muddy", - "old", - "red", - "rough", - "still", - "small", - "sparkling", - "thrumming", - "shy", - "wandering", - "withered", - "wild", - "black", - "young", - "holy", - "solitary", - "fragrant", - "aged", - "snowy", - "proud", - "floral", - "restless", - "divine", - "polished", - "ancient", - "purple", - "lively", - "nameless", -]; -function friendlyString() { - return `${adjectives[Math.floor(Math.random() * adjectives.length)]}-${noun[Math.floor(Math.random() * noun.length)]}`; -} - -const uri_passphrase = `${Deno.env.get("uri_passphrase") ?? friendlyString()}`; - -const URIBASE = "obsidian://setuplivesync?settings="; -async function main() { - const conf = { - couchDB_URI: `${Deno.env.get("hostname")}`, - couchDB_USER: `${Deno.env.get("username")}`, - couchDB_PASSWORD: `${Deno.env.get("password")}`, - couchDB_DBNAME: `${Deno.env.get("database")}`, - syncOnStart: true, - gcDelay: 0, - periodicReplication: true, - syncOnFileOpen: true, - encrypt: true, - passphrase: `${Deno.env.get("passphrase")}`, - usePathObfuscation: true, - batchSave: true, - batch_size: 50, - batches_limit: 50, - useHistory: true, - disableRequestURI: true, - customChunkSize: 50, - syncAfterMerge: false, - concurrencyOfReadChunksOnline: 100, - minimumIntervalOfReadChunksOnline: 100, - handleFilenameCaseSensitive: false, - doNotUseFixedRevisionForChunks: false, - settingVersion: 10, - notifyThresholdOfRemoteStorageSize: 800, - }; - const encryptedConf = encodeURIComponent(await encrypt(JSON.stringify(conf), uri_passphrase, false)); - const theURI = `${URIBASE}${encryptedConf}`; - console.log("\nYour passphrase of Setup-URI is: ", uri_passphrase); - console.log("This passphrase is never shown again, so please note it in a safe place."); - console.log(theURI); -} -await main(); +await runSetupURIGenerator({ + ...Deno.env.toObject(), + remote_type: "couchdb", +}); diff --git a/utils/flyio/setenv.sh b/utils/flyio/setenv.sh index 1c1f4c1f..50e2dff3 100755 --- a/utils/flyio/setenv.sh +++ b/utils/flyio/setenv.sh @@ -1,19 +1,23 @@ random_num() { - echo $RANDOM + echo "$RANDOM" } random_noun() { nouns=("waterfall" "river" "breeze" "moon" "rain" "wind" "sea" "morning" "snow" "lake" "sunset" "pine" "shadow" "leaf" "dawn" "glitter" "forest" "hill" "cloud" "meadow" "sun" "glade" "bird" "brook" "butterfly" "bush" "dew" "dust" "field" "fire" "flower" "firefly" "feather" "grass" "haze" "mountain" "night" "pond" "darkness" "snowflake" "silence" "sound" "sky" "shape" "surf" "thunder" "violet" "water" "wildflower" "wave" "water" "resonance" "sun" "log" "dream" "cherry" "tree" "fog" "frost" "voice" "paper" "frog" "smoke" "star") - echo ${nouns[$(($RANDOM % ${#nouns[*]}))]} + echo "${nouns[$((RANDOM % ${#nouns[*]}))]}" } random_adjective() { adjectives=("autumn" "hidden" "bitter" "misty" "silent" "empty" "dry" "dark" "summer" "icy" "delicate" "quiet" "white" "cool" "spring" "winter" "patient" "twilight" "dawn" "crimson" "wispy" "weathered" "blue" "billowing" "broken" "cold" "damp" "falling" "frosty" "green" "long" "late" "lingering" "bold" "little" "morning" "muddy" "old" "red" "rough" "still" "small" "sparkling" "thrumming" "shy" "wandering" "withered" "wild" "black" "young" "holy" "solitary" "fragrant" "aged" "snowy" "proud" "floral" "restless" "divine" "polished" "ancient" "purple" "lively" "nameless") - echo ${adjectives[$(($RANDOM % ${#adjectives[*]}))]} + echo "${adjectives[$((RANDOM % ${#adjectives[*]}))]}" +} + +random_secret() { + deno eval 'const bytes=crypto.getRandomValues(new Uint8Array(24));console.log(btoa(String.fromCharCode(...bytes)).replaceAll("+","-").replaceAll("/","_").replace(/=+$/, ""));' } cp ./fly.template.toml ./fly.toml -if [ "$1" = "renew" ]; then +if [ "${1:-}" = "renew" ]; then unset appname unset username unset password @@ -22,9 +26,9 @@ if [ "$1" = "renew" ]; then unset region fi -[ -z $appname ] && export appname=$(random_adjective)-$(random_noun)-$(random_num) -[ -z $username ] && export username=$(random_adjective)-$(random_noun)-$(random_num) -[ -z $password ] && export password=$(random_adjective)-$(random_noun)-$(random_num) -[ -z $database ] && export database="obsidiannotes" -[ -z $passphrase ] && export passphrase=$(random_adjective)-$(random_noun)-$(random_num) -[ -z $region ] && export region="nrt" +[ -z "${appname:-}" ] && export appname="$(random_adjective)-$(random_noun)-$(random_num)" +[ -z "${username:-}" ] && export username="$(random_adjective)-$(random_noun)-$(random_num)" +[ -z "${password:-}" ] && export password="$(random_secret)" +[ -z "${database:-}" ] && export database="obsidiannotes" +[ -z "${passphrase:-}" ] && export passphrase="$(random_secret)" +[ -z "${region:-}" ] && export region="nrt" diff --git a/utils/flyio/setenv.test.sh b/utils/flyio/setenv.test.sh new file mode 100644 index 00000000..82886754 --- /dev/null +++ b/utils/flyio/setenv.test.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -euo pipefail + +fixture_dir="$(mktemp -d)" +trap 'rm -rf "$fixture_dir"' EXIT +cp "$(dirname "$0")/setenv.sh" "$(dirname "$0")/fly.template.toml" "$fixture_dir/" + +( + cd "$fixture_dir" + appname="" + username="" + password="" + database="" + passphrase="" + region="" + source ./setenv.sh keep + + [[ "$password" =~ ^[A-Za-z0-9_-]{32}$ ]] || { + echo "generated CouchDB password is not a 32-character base64url secret" >&2 + exit 1 + } + [[ "$passphrase" =~ ^[A-Za-z0-9_-]{32}$ ]] || { + echo "generated Vault encryption passphrase is not a 32-character base64url secret" >&2 + exit 1 + } +) diff --git a/utils/livesync-commonlib-version.test.ts b/utils/livesync-commonlib-version.test.ts new file mode 100644 index 00000000..8ffacdc3 --- /dev/null +++ b/utils/livesync-commonlib-version.test.ts @@ -0,0 +1,27 @@ +import { LIVESYNC_COMMONLIB_VERSION } from "./livesync-commonlib-version.ts"; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +Deno.test("standalone utilities select one exact Commonlib registry version", async () => { + const facadeURLs = [ + new URL("./setup/livesync-commonlib.ts", import.meta.url), + new URL("./couchdb/livesync-commonlib.ts", import.meta.url), + ]; + const selectedVersions = ( + await Promise.all(facadeURLs.map((url) => Deno.readTextFile(url))) + ).flatMap((source) => + [...source.matchAll( + /npm:@vrtmrz\/livesync-commonlib@([^/"]+)\//gu, + )].map((match) => match[1]) + ); + + assert(selectedVersions.length > 0, "no Commonlib npm specifier was found"); + assert( + selectedVersions.every((version) => version === LIVESYNC_COMMONLIB_VERSION), + `Commonlib specifiers do not all select ${LIVESYNC_COMMONLIB_VERSION}: ${ + selectedVersions.join(", ") + }`, + ); +}); diff --git a/utils/livesync-commonlib-version.ts b/utils/livesync-commonlib-version.ts new file mode 100644 index 00000000..945f0039 --- /dev/null +++ b/utils/livesync-commonlib-version.ts @@ -0,0 +1,5 @@ +// The standalone Deno utilities deliberately remain pinned to one immutable +// Commonlib registry release. Static npm specifiers cannot interpolate this +// value, so livesync-commonlib-version.test.ts verifies the domain-specific +// facades against it. +export const LIVESYNC_COMMONLIB_VERSION = "0.1.0-rc.4"; diff --git a/utils/readme.md b/utils/readme.md index 6fe52ac9..df4c3c64 100644 --- a/utils/readme.md +++ b/utils/readme.md @@ -1,167 +1,84 @@ - # Utilities -Here are some useful things. -## couchdb +These utilities support self-hosted CouchDB provisioning and Setup URI generation. They consume an exact immutable `@vrtmrz/livesync-commonlib` registry version declared in `livesync-commonlib-version.ts`; the utility lockfile records its resolved package integrity. This selection is independent of the plug-in's runtime dependency and advances only when the utility behaviour is revalidated against a newer package. The setup-tool test keeps the domain-specific static npm specifiers aligned with that declaration. Update the declaration, the two facades, and the lockfile together when selecting a newer release. -### couchdb-init.sh -This script can configure CouchDB with the necessary settings by REST APIs. +## CouchDB provisioning -#### Materials -- Mandatory: curl +`couchdb/couchdb-init.sh` is a Bash wrapper for the Deno provisioning tool. Deno 2 is required. The tool configures single-node CouchDB, authenticated access, CORS for Obsidian, and the request and document size limits used by Self-hosted LiveSync. -#### Usage +Set `database` to create a database as part of provisioning. The tool then uses Commonlib's database negotiation contract to initialise and verify the LiveSync database version. If `database` is omitted, only the CouchDB server is configured and the first LiveSync client remains responsible for creating its database. ```sh -export hostname=http://localhost:5984/ +export hostname=http://localhost:5984 export username=couchdb-admin-username export password=couchdb-admin-password -./couchdb-init.sh +export database=obsidiannotes +./couchdb/couchdb-init.sh ``` -curl result will be shown, however, all of them can be ignored if the script has been run completely. +Optional variables are: -## fly.io +- `node`, which defaults to `_local`; +- `origins`, which defaults to the supported Obsidian desktop, mobile, and local origins; +- `retry_count`, which defaults to `12`; and +- `retry_delay_ms`, which defaults to `5000`. -### deploy-server.sh +Authentication and other non-retryable HTTP failures stop immediately. Network and server failures are retried within the configured bound. -A fully automated CouchDB deployment script. We can deploy CouchDB onto fly.io. The only we need is an account of it. +## Setup URI generation -All omitted configurations will be determined at random. (And, it is preferred). The region is configured to `nrt`. -If Japan is not close to you, please choose a region closer to you. However, the deployed database will work if you leave it at all. +`setup/generate_setup_uri.ts` creates current CouchDB, Object Storage, and P2P configurations from Commonlib's new-Vault and remote-specific presets. It stores the connection as the selected remote profile and encodes the result with Commonlib's Setup URI contract. Set `remote_type` to `couchdb`, `s3`, or `p2p`; CouchDB is the default. -#### Materials -- Mandatory: curl, flyctl -- Recommended: deno +The existing `flyio/generate_setupuri.ts` path remains a CouchDB-only compatibility wrapper for the Fly.io deployment script. + +### CouchDB -#### Usage ```sh -#export appname= -#export username= -#export password= -#export database= -#export passphrase= -export region=nrt #pick your nearest location +export hostname=https://couch.example.com +export username=couchdb-admin-username +export password=couchdb-admin-password +export database=obsidiannotes +export passphrase=a-strong-vault-encryption-passphrase +export uri_passphrase=a-separate-setup-uri-passphrase # Optional +deno run --minimum-dependency-age=0 --config=./flyio/deno.jsonc --frozen --lock=./flyio/deno.lock --allow-env ./setup/generate_setup_uri.ts +``` + +If `uri_passphrase` is omitted, the tool generates and prints a cryptographically random one. Store the Setup URI and its passphrase separately. The `passphrase` value protects synchronised Vault data and must also be stored safely. + +### Object Storage + +```sh +export remote_type=s3 +export endpoint=https://objects.example.com +export access_key= +export secret_key= +export bucket=vault-data +export region=auto +export bucket_prefix=team-a # Optional +export passphrase= +deno run --minimum-dependency-age=0 --config=./flyio/deno.jsonc --frozen --lock=./flyio/deno.lock --allow-env ./setup/generate_setup_uri.ts +``` + +Optional Object Storage variables are `use_custom_request_handler`, `force_path_style`, and `bucket_custom_headers`. + +### P2P + +```sh +export remote_type=p2p +export passphrase= +deno run --minimum-dependency-age=0 --config=./flyio/deno.jsonc --frozen --lock=./flyio/deno.lock --allow-env ./setup/generate_setup_uri.ts +``` + +If `p2p_room_id` or `p2p_passphrase` is omitted, the tool generates it with Commonlib's room-ID contract or cryptographically secure randomness. Optional variables are `p2p_relays`, `p2p_app_id`, `p2p_auto_start`, and `p2p_auto_broadcast`. Auto-start and auto-broadcast retain Commonlib's disabled defaults unless explicitly enabled. A peer name is deliberately absent because it identifies one device and must not be copied to another device through a Setup URI. + +## Fly.io deployment + +`flyio/deploy-server.sh` deploys CouchDB through `flyctl`, provisions the selected database through the tool above, and prints a Commonlib-generated Setup URI. Both `flyctl` and Deno 2 are required. + +```sh +export region=nrt # Choose a nearby Fly.io region. +cd flyio ./deploy-server.sh ``` -The result of this command is as follows. - -``` --- YOUR CONFIGURATION -- -URL : https://young-darkness-25342.fly.dev -username: billowing-cherry-22580 -password: misty-dew-13571 -region : nrt - --- START DEPLOYING --> -An existing fly.toml file was found -Using build strategies '[the "couchdb:latest" docker image]'. Remove [build] from fly.toml to force a rescan -Creating app in /home/vorotamoroz/dev/obsidian-livesync/utils/flyio -We're about to launch your app on Fly.io. Here's what you're getting: - -Organization: vorotamoroz (fly launch defaults to the personal org) -Name: young-darkness-25342 (specified on the command line) -Region: Tokyo, Japan (specified on the command line) -App Machines: shared-cpu-1x, 256MB RAM (specified on the command line) -Postgres: (not requested) -Redis: (not requested) - -Created app 'young-darkness-25342' in organization 'personal' -Admin URL: https://fly.io/apps/young-darkness-25342 -Hostname: young-darkness-25342.fly.dev -Wrote config file fly.toml -Validating /home/vorotamoroz/dev/obsidian-livesync/utils/flyio/fly.toml -Platform: machines -✓ Configuration is valid -Your app is ready! Deploy with `flyctl deploy` -Secrets are staged for the first deployment -==> Verifying app config -Validating /home/vorotamoroz/dev/obsidian-livesync/utils/flyio/fly.toml -Platform: machines -✓ Configuration is valid ---> Verified app config -==> Building image -Searching for image 'couchdb:latest' remotely... -image found: img_ox20prk63084j1zq - -Watch your deployment at https://fly.io/apps/young-darkness-25342/monitoring - -Provisioning ips for young-darkness-25342 - Dedicated ipv6: 2a09:8280:1::37:fde9 - Shared ipv4: 66.241.124.163 - Add a dedicated ipv4 with: fly ips allocate-v4 - -Creating a 1 GB volume named 'couchdata' for process group 'app'. Use 'fly vol extend' to increase its size -This deployment will: - * create 1 "app" machine - -No machines in group app, launching a new machine - -WARNING The app is not listening on the expected address and will not be reachable by fly-proxy. -You can fix this by configuring your app to listen on the following addresses: - - 0.0.0.0:5984 -Found these processes inside the machine with open listening sockets: - PROCESS | ADDRESSES ------------------*--------------------------------------- - /.fly/hallpass | [fdaa:0:73b9:a7b:22e:3851:7f28:2]:22 - -Finished launching new machines - -NOTE: The machines for [app] have services with 'auto_stop_machines = true' that will be stopped when idling - -------- -Checking DNS configuration for young-darkness-25342.fly.dev - -Visit your newly deployed app at https://young-darkness-25342.fly.dev/ --- Configuring CouchDB by REST APIs... --> -curl: (35) OpenSSL SSL_connect: Connection reset by peer in connection to young-darkness-25342.fly.dev:443 -{"ok":true} -"" -"" -"" -"" -"" -"" -"" -"" -"" -<-- Configuring CouchDB by REST APIs Done! -OK! -Setup finished! Also, we can set up Self-hosted LiveSync instantly, by the following setup uri. -Passphrase of setup-uri will be printed only one time. Keep it safe! ---- configured --- -database : obsidiannotes -E2EE passphrase: dark-wildflower-26467 ---- setup uri --- -obsidian://setuplivesync?settings=%5B%22gZkBwjFbLqxbdSIbJymU%2FmTPBPAKUiHVGDRKYiNnKhW0auQeBgJOfvnxexZtMCn8sNiIUTAlxNaMGF2t%2BCEhpJoeCP%2FO%2BrwfN5LaNDQyky1Uf7E%2B64A5UWyjOYvZDOgq4iCKSdBAXp9oO%2BwKh4MQjUZ78vIVvJp8Mo6NWHfm5fkiWoAoddki1xBMvi%2BmmN%2FhZatQGcslVb9oyYWpZocduTl0a5Dv%2FQviGwlYQ%2F4NY0dVDIoOdvaYS%2FX4GhNAnLzyJKMXhPEJHo9FvR%2FEOBuwyfMdftV1SQUZ8YDCuiR3T7fh7Kn1c6OFgaFMpFm%2BWgIJ%2FZpmAyhZFpEcjpd7ty%2BN9kfd9gQsZM4%2BYyU9OwDd2DahVMBWkqoV12QIJ8OlJScHHdcUfMW5ex%2F4UZTWKNEHJsigITXBrtq11qGk3rBfHys8O0vY6sz%2FaYNM3iAOsR1aoZGyvwZm4O6VwtzK8edg0T15TL4O%2B7UajQgtCGxgKNYxb8EMOGeskv7NifYhjCWcveeTYOJzBhnIDyRbYaWbkAXQgHPBxzJRkkG%2FpBPfBBoJarj7wgjMvhLJ9xtL4FbP6sBNlr8jtAUCoq4L7LJcRNF4hlgvjJpL2BpFZMzkRNtUBcsRYR5J%2BM1X2buWi2BHncbSiRRDKEwNOQkc%2FmhMJjbAn%2F8eNKRuIICOLD5OvxD7FZNCJ0R%2BWzgrzcNV%22%2C%22ec7edc900516b4fcedb4c7cc01000000%22%2C%22fceb5fe54f6619ee266ed9a887634e07%22%5D - -Your passphrase of Setup-URI is: patient-haze -This passphrase is never shown again, so please note it in a safe place. -``` - -All we have to do is copy the setup-URI (`obsidian`://...`) and open it from Self-hosted LiveSync on Obsidian. - -If you did not install Deno, configurations will be printed again, instead of the setup-URI. In this case, we should configure it manually. - -### delete-server.sh - -The pair script of `deploy-server.sh`. We can delete the deployed server by this with fly.toml. - -#### Materials - -- Mandatory: flyctl, jq -- Recommended: none - -#### Usage -```sh -./delete-server.sh -``` - -``` -App 'young-darkness-25342 is going to be scaled according to this plan: - -1 machines for group 'app' on region 'nrt' of size 'shared-cpu-1x' -Executing scale plan - Destroyed e28667eec57158 group:app region:nrt size:shared-cpu-1x -Destroyed app young-darkness-25342 -``` \ No newline at end of file +Set `appname`, `username`, `password`, `database`, `passphrase`, or `region` before running the script to override its generated values. Use `delete-server.sh` to remove the Fly.io application described by the generated `fly.toml`. diff --git a/utils/release-notes.mjs b/utils/release-notes.mjs index 3e4d9857..ed2ef2f8 100644 --- a/utils/release-notes.mjs +++ b/utils/release-notes.mjs @@ -1,4 +1,4 @@ -import { readFileSync, writeFileSync } from "fs"; +import { readFileSync, writeFileSync, writeSync } from "fs"; const updatesPath = "updates.md"; @@ -8,7 +8,7 @@ const updatesPath = "updates.md"; // allowed. function fail(message) { - console.error(message); + writeSync(process.stderr.fd, `${message}\n`); process.exit(1); } @@ -86,7 +86,8 @@ function prepare(version) { } // Keep a fresh empty Unreleased section above the newly dated release notes. - const releasedBody = `${unreleased.body.trim()}\n\n`; + const releaseSuffix = unreleased.end < markdown.length ? "\n\n" : "\n"; + const releasedBody = `${unreleased.body.trim()}${releaseSuffix}`; const releaseDate = process.env.RELEASE_DATE || formatReleaseDate(); const replacement = `## Unreleased\n\n## ${version}\n\n${releaseDate}\n\n${releasedBody}`; const nextMarkdown = markdown.slice(0, unreleased.headingStart) + replacement + markdown.slice(unreleased.end); diff --git a/utils/release-pr-body.mjs b/utils/release-pr-body.mjs new file mode 100644 index 00000000..89d6f8b1 --- /dev/null +++ b/utils/release-pr-body.mjs @@ -0,0 +1,98 @@ +import { pathToFileURL } from "node:url"; + +/** + * Escape a value for use as inline Markdown code. + * + * Use a fence longer than any backtick run in the value so that branch names + * supplied by a caller cannot break the surrounding Markdown. + * + * @param {string} value + * @returns {string} + */ +function inlineCode(value) { + const backtickRuns = value.match(/`+/g) ?? []; + const fenceLength = Math.max(1, ...backtickRuns.map((run) => run.length + 1)); + const fence = "`".repeat(fenceLength); + const padding = value.startsWith("`") || value.endsWith("`") ? " " : ""; + return `${fence}${padding}${value}${padding}${fence}`; +} + +/** + * Render the reader-facing checklist for a draft release pull request. + * + * The version decides whether the release commit is an immutable SemVer + * pre-release or a stable version staged through a GitHub pre-release. The + * base branch is included explicitly because integration previews can target + * a reviewed integration branch rather than `main`. + * + * @param {string} version + * @param {string} baseBranch + * @returns {string} + */ +export function renderReleasePrBody(version, baseBranch) { + const selectedVersion = version.trim(); + const selectedBaseBranch = baseBranch.trim(); + if (selectedVersion.length === 0) throw new Error("A release version is required."); + if (selectedBaseBranch.length === 0) throw new Error("A base branch is required."); + + const versionCode = inlineCode(selectedVersion); + const baseBranchCode = inlineCode(selectedBaseBranch); + const isPrerelease = selectedVersion.includes("-"); + const purpose = isPrerelease + ? `an immutable pre-release for BRAT validation without replacing the latest stable release` + : `a stable version which will first be staged as a GitHub pre-release for BRAT validation`; + const finaliseInstruction = isPrerelease + ? "Run the finalise release workflow with this PR's fixed head SHA and `prerelease=true`" + : "Run the finalise release workflow with this PR's fixed head SHA, `prerelease=true`, and `publish_cli=false`"; + const publicationInstruction = isPrerelease + ? "Publish the GitHub Release as a pre-release without replacing the latest stable release, while keeping this pull request in draft" + : "Publish the GitHub Release initially as a pre-release without replacing the latest stable release, while keeping this pull request in draft"; + const assetInstruction = isPrerelease + ? "Confirm the draft GitHub Release assets and the published CLI image, if selected" + : "Confirm the draft GitHub Release assets; keep stable CLI publication deferred until BRAT validation passes"; + const holdInstruction = isPrerelease + ? `Publishing and validating this pre-release does not unblock this pull request. Keep it in draft and unmerged, and leave ${baseBranchCode} unchanged.` + : `Publishing the GitHub pre-release does not unblock this pull request. Keep it in draft, and leave ${baseBranchCode} unchanged, until the exact published build has passed BRAT validation. Before the exact release commit reaches the repository's default branch, remove the pre-release designation and make this exact release the latest stable release so Community Directory review does not inspect a manifest version whose GitHub Release remains a pre-release.`; + const completionInstructions = isPrerelease + ? ["- [ ] Keep this pre-release pull request unmerged; close it only through a separate maintainer action"] + : [ + "- [ ] After BRAT validation passes, remove the pre-release designation and make this exact release the latest stable release", + `- [ ] Confirm this exact release is no longer a pre-release, then mark this pull request ready and merge it into ${baseBranchCode} with a merge commit`, + "- [ ] Integrate the exact release commit through the reviewed branch chain into the repository's default branch", + "- [ ] Confirm the default branch contains the exact release commit and metadata", + "- [ ] Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate", + ]; + + return [ + `This release pull request prepares Self-hosted LiveSync ${versionCode} from ${baseBranchCode} as ${purpose}.`, + "", + "> [!IMPORTANT]", + "> **Merge intentionally on hold**", + ">", + `> ${holdInstruction}`, + "", + "## Release checklist", + "", + "- [ ] Review and polish `updates.md`", + "- [ ] Confirm the release date", + "- [ ] Confirm `manifest.json`, `versions.json`, workspace package versions, and the locked Commonlib package version", + "- [ ] Confirm CI has passed", + `- [ ] ${finaliseInstruction}`, + `- [ ] ${assetInstruction}`, + `- [ ] ${publicationInstruction}`, + "- [ ] Validate the exact published release with BRAT", + ...completionInstructions, + "", + ].join("\n"); +} + +const invokedPath = process.argv[1]; +if (invokedPath !== undefined && pathToFileURL(invokedPath).href === import.meta.url) { + const [, , version, baseBranch] = process.argv; + try { + process.stdout.write(renderReleasePrBody(version ?? "", baseBranch ?? "")); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/utils/release-process.unit.spec.ts b/utils/release-process.unit.spec.ts index 859abd01..1e864d06 100644 --- a/utils/release-process.unit.spec.ts +++ b/utils/release-process.unit.spec.ts @@ -4,6 +4,8 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { renderReleasePrBody } from "./release-pr-body.mjs"; +import { ensureTags } from "./release-tags.mjs"; const releaseNotesScript = fileURLToPath(new URL("./release-notes.mjs", import.meta.url)); const versionBumpScript = @@ -11,6 +13,8 @@ const versionBumpScript = const workspaceUpdateScript = fileURLToPath(new URL("../update-workspaces.mjs", import.meta.url)); const prepareReleaseWorkflow = fileURLToPath(new URL("../.github/workflows/prepare-release.yml", import.meta.url)); const finaliseReleaseWorkflow = fileURLToPath(new URL("../.github/workflows/finalise-release.yml", import.meta.url)); +const releaseWorkflow = fileURLToPath(new URL("../.github/workflows/release.yml", import.meta.url)); +const cliDockerWorkflow = fileURLToPath(new URL("../.github/workflows/cli-docker.yml", import.meta.url)); const temporaryDirectories: string[] = []; afterEach(() => { @@ -39,6 +43,36 @@ function runNode(script: string, args: string[], cwd: string, env: Record = {}) { + const tags = new Map(Object.entries(initialTags)); + const git = (args: string[], allowMissing = false): string | undefined => { + if (args[0] === "rev-parse") { + const revision = args.at(-1); + if (revision === `${expectedRevision}^{commit}`) return expectedRevision; + const tagMatch = revision?.match(/^refs\/tags\/(.+)\^\{commit\}$/); + if (tagMatch) { + const commit = tags.get(tagMatch[1]); + if (commit !== undefined) return commit; + if (allowMissing) return undefined; + } + } + if (args[0] === "tag" && args.length === 3) { + tags.set(args[1], args[2]); + return ""; + } + throw new Error(`Unexpected git command: ${args.join(" ")}`); + }; + return { git, tags }; +} + function createReleaseFixture(version = "0.25.81"): string { const directory = makeTemporaryDirectory(); writeJson(directory, "package.json", { version }); @@ -72,6 +106,24 @@ describe("release notes", () => { expect(validated.status, validated.stderr).toBe(0); }); + it("ends rotated notes with one newline when Unreleased is the final release section", () => { + const directory = createReleaseFixture(); + writeFileSync( + join(directory, "updates.md"), + "# 1.0\n\n## Unreleased\n\n### Fixed\n\n- Preserved file content.\n", + "utf8" + ); + + const prepared = runNode(releaseNotesScript, ["prepare", "1.0.0-beta.0"], directory, { + RELEASE_DATE: "22nd July, 2026", + }); + + expect(prepared.status, prepared.stderr).toBe(0); + expect(readFileSync(join(directory, "updates.md"), "utf8")).toBe( + "# 1.0\n\n## Unreleased\n\n## 1.0.0-beta.0\n\n22nd July, 2026\n\n### Fixed\n\n- Preserved file content.\n" + ); + }); + it("rejects an empty Unreleased section unless explicitly allowed", () => { const directory = createReleaseFixture(); writeFileSync( @@ -106,40 +158,153 @@ describe("release notes", () => { }); describe("release workflow", () => { - it("regenerates and stages fallback type definitions", () => { + it("uses the locked Commonlib package instead of generated fallback declarations", () => { const workflow = readFileSync(prepareReleaseWorkflow, "utf8"); + const body = renderReleasePrBody("1.0.0-beta.0", "integration"); - expect(workflow).toContain("npm run build:lib:types"); - expect(workflow).toMatch(/git add[^\n]*_types/); + expect(workflow).not.toContain("npm run build:lib:types"); + expect(workflow).not.toMatch(/git add[^\n]*_types/); + expect(workflow).toMatch(/git add[^\n]*package-lock\.json/); + expect(body).toContain("locked Commonlib package version"); }); - it("installs Deno before post-processing fallback type definitions", () => { + it("reruns the version lifecycle when the integration branch already selects the release version", () => { const workflow = readFileSync(prepareReleaseWorkflow, "utf8"); - const setupDeno = workflow.indexOf("denoland/setup-deno@v2"); - const buildTypes = workflow.indexOf("npm run build:lib:types"); - expect(setupDeno).toBeGreaterThan(-1); - expect(setupDeno).toBeLessThan(buildTypes); + expect(workflow).toContain('npm version "${VERSION}" --no-git-tag-version --allow-same-version'); }); - it("keeps the release PR in draft until BRAT validation", () => { + it("generates the release PR body from the selected version and base branch", () => { const workflow = readFileSync(prepareReleaseWorkflow, "utf8"); - expect(workflow).toContain("Merge intentionally on hold"); - expect(workflow).toContain( - "Publish the GitHub Release as the latest stable release while keeping this pull request in draft" + expect(workflow).toContain('node utils/release-pr-body.mjs "${VERSION}" "${BASE_BRANCH}"'); + expect(workflow).not.toContain("leave \\`main\\`"); + expect(workflow).not.toContain("latest stable release"); + }); + + it("keeps an immutable pre-release out of its base branch after BRAT validation", () => { + const prerelease = renderReleasePrBody("1.0.0-rc.0", "common-library-package-boundary"); + + expect(prerelease).toContain("Merge intentionally on hold"); + expect(prerelease).toContain("Self-hosted LiveSync `1.0.0-rc.0`"); + expect(prerelease).toContain("leave `common-library-package-boundary` unchanged"); + expect(prerelease).toContain("prerelease=true"); + expect(prerelease).toContain( + "Publish the GitHub Release as a pre-release without replacing the latest stable release" ); - expect(workflow).toContain("Validate the published release with BRAT"); - expect(workflow).toContain("Mark this pull request ready and merge it with a merge commit"); + expect(prerelease).toContain("Validate the exact published release with BRAT"); + expect(prerelease).toContain("Keep this pre-release pull request unmerged"); + expect(prerelease).toContain("close it only through a separate maintainer action"); + expect(prerelease).not.toContain("Mark this pull request ready and merge it"); }); - it("explicitly dispatches publishing workflows after creating tags", () => { + it("publishes a stable version initially as a GitHub pre-release for BRAT validation", () => { + const stable = renderReleasePrBody("1.0.0", "main"); + + expect(stable).toContain("prerelease=true"); + expect(stable).toContain("publish_cli=false"); + expect(stable).toContain( + "Publish the GitHub Release initially as a pre-release without replacing the latest stable release" + ); + expect(stable).not.toContain("prerelease=false"); + }); + + it("requires stable pre-release staging to defer CLI publication", () => { + const workflow = readFileSync(finaliseReleaseWorkflow, "utf8"); + + expect(workflow).toContain( + 'if [[ "${VERSION}" != *-* && "${PRERELEASE}" == "true" && "${PUBLISH_CLI}" == "true" ]]; then' + ); + expect(workflow).toContain( + "A stable version staged as a pre-release must use publish_cli=false so that the CLI latest and major-minor image tags do not advance before BRAT validation." + ); + }); + + it("dispatches the selected plug-in and CLI workflows explicitly", () => { const workflow = readFileSync(finaliseReleaseWorkflow, "utf8"); expect(workflow).toContain("actions: write"); - expect(workflow).toContain("gh workflow run release.yml"); + expect(workflow).toContain('node utils/release-tags.mjs ensure "${VERSION}" "${EXPECTED_HEAD_SHA}"'); + expect(workflow).toContain('git push --atomic origin "refs/tags/${VERSION}" "refs/tags/${VERSION}-cli"'); + expect(workflow).not.toContain("Tag already exists"); + expect(workflow).toContain("PUBLISH_CLI: ${{ inputs.publish_cli }}"); + expect(workflow).toContain('if [[ "${PUBLISH_CLI}" == "true" ]]; then'); expect(workflow).toContain("gh workflow run cli-docker.yml"); - expect(workflow).toContain("dry_run=false"); + expect(workflow).toContain('--ref "${VERSION}-cli"'); + expect(workflow).toContain("--field dry_run=false"); + expect(workflow).toContain("--field force=false"); + expect(workflow).toContain("gh workflow run release.yml"); + expect(workflow).toContain("explicitly dispatched the CLI container workflow"); + }); + + it("publishes only by explicit dispatch and validates the selected release", () => { + const workflow = readFileSync(releaseWorkflow, "utf8"); + + expect(workflow).not.toMatch(/^\s+push:/m); + expect(workflow).toContain("ref: ${{ inputs.tag }}"); + expect(workflow).toContain('node utils/release-notes.mjs validate "${TAG}"'); + expect(workflow).toContain('TAG_SHA="$(git rev-parse "refs/tags/${TAG}^{commit}")"'); + expect(workflow).not.toContain("Get Version"); + }); + + it("supports a pre-release plug-in without creating or publishing a CLI release", () => { + const workflow = readFileSync(finaliseReleaseWorkflow, "utf8"); + + expect(workflow).toContain("prerelease:"); + expect(workflow).toContain("publish_cli:"); + expect(workflow).toContain('--field prerelease="${PRERELEASE}"'); + expect(workflow).toContain("--plugin-only"); + }); + + it("does not attach an unsupported release archive", () => { + const workflow = readFileSync(releaseWorkflow, "utf8"); + + expect(workflow).not.toContain("zip -r"); + expect(workflow).not.toContain("${{ github.event.repository.name }}.zip"); + }); + + it("does not promote a pre-release CLI image to stable moving tags", () => { + const workflow = readFileSync(cliDockerWorkflow, "utf8"); + + expect(workflow).toContain('if [[ "${VERSION}" == *-* ]]; then'); + expect(workflow).toContain('TAGS="${IMAGE}:${VERSION}-cli,${IMAGE}:${VERSION}-sha-${SHORT_SHA}-cli"'); + }); +}); + +describe("release tags", () => { + it("creates missing tags and accepts matching tags on retry", () => { + const head = "a".repeat(40); + const { git, tags } = createTagGit(head); + const messages: string[] = []; + + ensureTags("0.25.84", head, git, (message) => messages.push(message)); + expect(tags.get("0.25.84")).toBe(head); + expect(tags.get("0.25.84-cli")).toBe(head); + + ensureTags("0.25.84", head, git, (message) => messages.push(message)); + expect(messages).toContain(`Tag 0.25.84 already points to the expected commit ${head}.`); + expect(messages).toContain(`Tag 0.25.84-cli already points to the expected commit ${head}.`); + }); + + it("rejects an existing release tag that points to another commit without creating missing tags", () => { + const previousHead = "b".repeat(40); + const expectedHead = "a".repeat(40); + const { git, tags } = createTagGit(expectedHead, { "0.25.84-cli": previousHead }); + + expect(() => ensureTags("0.25.84", expectedHead, git)).toThrow( + `Tag 0.25.84-cli points to ${previousHead}; expected ${expectedHead}.` + ); + expect(tags.has("0.25.84")).toBe(false); + }); + + it("can create only the plug-in tag for a review release", () => { + const head = "a".repeat(40); + const { git, tags } = createTagGit(head); + + ensureTags("1.0.0-rc.0", head, git, () => undefined, { pluginOnly: true }); + + expect(tags.get("1.0.0-rc.0")).toBe(head); + expect(tags.has("1.0.0-rc.0-cli")).toBe(false); }); }); @@ -161,6 +326,59 @@ describe("version bump", () => { "0.25.81": "1.7.2", }); }); + + it("runs release metadata scripts when the selected version is already the package version", () => { + const directory = makeTemporaryDirectory(); + const workspaces = ["src/apps/cli", "src/apps/webpeer", "src/apps/webapp"]; + writeJson(directory, "package.json", { + name: "release-lifecycle-fixture", + version: "1.0.0-beta.0", + private: true, + workspaces, + scripts: { + version: `node ${JSON.stringify(versionBumpScript)} && node ${JSON.stringify(workspaceUpdateScript)}`, + }, + }); + writeJson(directory, "manifest.json", { version: "1.0.0-alpha.9", minAppVersion: "1.7.2" }); + writeJson(directory, "versions.json", { "0.25.83": "1.7.2" }); + const lockPackages: Record = { + "": { version: "1.0.0-beta.0", workspaces }, + }; + for (const workspace of ["cli", "webpeer", "webapp"]) { + writeJson(directory, `src/apps/${workspace}/package.json`, { + name: `release-lifecycle-${workspace}`, + version: `1.0.0-alpha.9-${workspace}`, + }); + lockPackages[`src/apps/${workspace}`] = { version: `1.0.0-alpha.9-${workspace}` }; + } + writeJson(directory, "package-lock.json", { + name: "release-lifecycle-fixture", + version: "1.0.0-beta.0", + lockfileVersion: 3, + requires: true, + packages: lockPackages, + }); + + const result = runNpm(["version", "1.0.0-beta.0", "--no-git-tag-version", "--allow-same-version"], directory); + + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(readFileSync(join(directory, "manifest.json"), "utf8"))).toMatchObject({ + version: "1.0.0-beta.0", + minAppVersion: "1.7.2", + }); + expect(JSON.parse(readFileSync(join(directory, "versions.json"), "utf8"))).toEqual({ + "0.25.83": "1.7.2", + "1.0.0-beta.0": "1.7.2", + }); + const packageLock = JSON.parse(readFileSync(join(directory, "package-lock.json"), "utf8")); + expect(packageLock.version).toBe("1.0.0-beta.0"); + expect(packageLock.packages[""].version).toBe("1.0.0-beta.0"); + for (const workspace of ["cli", "webpeer", "webapp"]) { + const packageJson = JSON.parse(readFileSync(join(directory, `src/apps/${workspace}/package.json`), "utf8")); + expect(packageJson.version).toBe(`1.0.0-beta.0-${workspace}`); + expect(packageLock.packages[`src/apps/${workspace}`].version).toBe(`1.0.0-beta.0-${workspace}`); + } + }); }); describe("workspace version update", () => { diff --git a/utils/release-tags.mjs b/utils/release-tags.mjs new file mode 100644 index 00000000..62e4fc5d --- /dev/null +++ b/utils/release-tags.mjs @@ -0,0 +1,73 @@ +import { spawnSync } from "node:child_process"; +import { writeSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +function fail(message) { + writeSync(process.stderr.fd, `${message}\n`); + process.exit(1); +} + +function assertVersion(version) { + if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error(`Invalid release version: ${version}`); + } +} + +function git(args, allowMissing = false) { + const result = spawnSync("git", args, { encoding: "utf8" }); + if (allowMissing && result.status === 1 && result.stdout === "" && result.stderr === "") { + return undefined; + } + if (result.error) { + throw new Error(`Could not run git ${args.join(" ")}: ${result.error.message}`); + } + if (result.status !== 0) { + throw new Error(result.stderr.trim() || `git ${args.join(" ")} exited with status ${result.status}.`); + } + return result.stdout.trim(); +} + +function resolveCommit(revision, runGit) { + return runGit(["rev-parse", "--verify", `${revision}^{commit}`]); +} + +function resolveTag(tag, runGit) { + return runGit(["rev-parse", "--verify", "--quiet", `refs/tags/${tag}^{commit}`], true); +} + +export function ensureTags(version, expectedRevision, runGit = git, log = console.log, options = {}) { + assertVersion(version); + const expectedCommit = resolveCommit(expectedRevision, runGit); + const tags = options.pluginOnly ? [version] : [version, `${version}-cli`]; + const existing = tags.map((tag) => ({ tag, commit: resolveTag(tag, runGit) })); + + for (const { tag, commit } of existing) { + if (commit !== undefined && commit !== expectedCommit) { + throw new Error(`Tag ${tag} points to ${commit}; expected ${expectedCommit}.`); + } + } + + for (const { tag, commit } of existing) { + if (commit === undefined) { + runGit(["tag", tag, expectedCommit]); + log(`Created tag ${tag} at ${expectedCommit}.`); + } else { + log(`Tag ${tag} already points to the expected commit ${expectedCommit}.`); + } + } +} + +const isMain = process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href; +if (isMain) { + const [command, version, expectedRevision, mode] = process.argv.slice(2); + if (command !== "ensure" || !version || !expectedRevision || (mode && mode !== "--plugin-only")) { + fail("Usage: node utils/release-tags.mjs ensure [--plugin-only]"); + } + + try { + ensureTags(version, expectedRevision, git, console.log, { pluginOnly: mode === "--plugin-only" }); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } +} diff --git a/utils/setup/generate_setup_uri.test.ts b/utils/setup/generate_setup_uri.test.ts new file mode 100644 index 00000000..725725bf --- /dev/null +++ b/utils/setup/generate_setup_uri.test.ts @@ -0,0 +1,123 @@ +import { + decodeSettingsFromSetupURI, + DEFAULT_SETTINGS, +} from "./livesync-commonlib.ts"; +import { generateSetupURI } from "./generate_setup_uri.ts"; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +Deno.test("generates an Object Storage Setup URI with a selected S3 profile", async () => { + const generated = await generateSetupURI({ + remote_type: "s3", + endpoint: "https://objects.example.test", + access_key: "access-key", + secret_key: "secret-key", + bucket: "vault-data", + region: "auto", + bucket_prefix: "team-a", + passphrase: "vault-secret", + uri_passphrase: "setup-secret", + }); + const decoded = await decodeSettingsFromSetupURI( + generated.setupURI, + generated.setupPassphrase, + ); + assert(decoded, "Commonlib could not decode the Object Storage Setup URI"); + const effective = { ...DEFAULT_SETTINGS, ...decoded }; + assert( + effective.isConfigured, + "the Setup URI left the imported device unconfigured", + ); + assert( + effective.customChunkSize === 10, + "the journal chunk-size preset was not applied", + ); + assert( + effective.liveSync, + "Object Storage was not configured for live journal synchronisation", + ); + assert( + effective.endpoint === "https://objects.example.test", + "the endpoint was not preserved", + ); + assert( + effective.bucketPrefix === "team-a", + "the bucket prefix was not preserved", + ); + + const profiles = Object.values(decoded.remoteConfigurations ?? {}); + assert( + profiles.length === 1, + "the Setup URI did not contain exactly one Object Storage profile", + ); + assert( + decoded.activeConfigurationId === profiles[0].id, + "the Object Storage profile was not selected", + ); + assert( + profiles[0].uri.startsWith("sls+s3://"), + "the selected profile was not an S3 connection URI", + ); +}); + +Deno.test("generates a random-room P2P Setup URI without copying a device identity", async () => { + const generated = await generateSetupURI({ + remote_type: "p2p", + passphrase: "vault-secret", + uri_passphrase: "setup-secret", + }); + const decoded = await decodeSettingsFromSetupURI( + generated.setupURI, + generated.setupPassphrase, + ); + assert(decoded, "Commonlib could not decode the P2P Setup URI"); + const effective = { ...DEFAULT_SETTINGS, ...decoded }; + assert( + /^\d{3}-\d{3}-\d{3}-[a-z0-9]{3}$/.test(effective.P2P_roomID), + "Commonlib did not generate the expected random room ID", + ); + assert( + /^[A-Za-z0-9_-]{32}$/.test(effective.P2P_passphrase), + "the generated P2P passphrase was not a 32-character base64url secret", + ); + assert( + !effective.P2P_AutoStart, + "P2P auto-start was enabled without an explicit request", + ); + assert( + !effective.P2P_AutoBroadcast, + "P2P auto-broadcast was enabled without an explicit request", + ); + assert( + effective.customChunkSize === 0, + "the P2P profile inherited the self-hosted CouchDB chunk-size recommendation", + ); + assert( + effective.sendChunksBulkMaxSize === 1, + "the P2P profile did not retain the conservative manual resend size", + ); + assert( + !Object.hasOwn(decoded, "P2P_DevicePeerName"), + "the Setup URI copied a device-specific P2P peer name", + ); + + const profiles = Object.values(decoded.remoteConfigurations ?? {}); + assert( + profiles.length === 1, + "the Setup URI did not contain exactly one P2P profile", + ); + assert( + decoded.activeConfigurationId === profiles[0].id, + "the P2P profile was not selected as the main remote", + ); + assert( + decoded.P2P_ActiveRemoteConfigurationId === profiles[0].id, + "the P2P profile was not selected for P2P features", + ); + assert( + profiles[0].uri.startsWith("sls+p2p://"), + "the selected profile was not a P2P connection URI", + ); +}); diff --git a/utils/setup/generate_setup_uri.ts b/utils/setup/generate_setup_uri.ts new file mode 100644 index 00000000..07c4f1cd --- /dev/null +++ b/utils/setup/generate_setup_uri.ts @@ -0,0 +1,189 @@ +import { + createNewVaultSettings, + encodeSettingsToSetupURI, + generateP2PRoomId, + type ObsidianLiveSyncSettings, + P2P_DEFAULT_SETTINGS, + PREFERRED_BASE, + PREFERRED_JOURNAL_SYNC, + PREFERRED_SETTING_SELF_HOSTED, + upsertRemoteConfigurationInPlace, +} from "./livesync-commonlib.ts"; + +export type SetupRemoteType = "couchdb" | "s3" | "p2p"; +export type SetupGeneratorEnvironment = Readonly< + Record +>; + +export interface GeneratedSetupURI { + remoteType: SetupRemoteType; + setupURI: string; + setupPassphrase: string; +} + +function requireValue( + environment: SetupGeneratorEnvironment, + name: string, +): string { + const value = environment[name]?.trim(); + if (!value) throw new Error(`${name} is required`); + return value; +} + +function optionalBoolean( + environment: SetupGeneratorEnvironment, + name: string, + fallback: boolean, +): boolean { + const value = environment[name]?.trim().toLowerCase(); + if (!value) return fallback; + if (value === "true" || value === "1") return true; + if (value === "false" || value === "0") return false; + throw new Error(`${name} must be true, false, 1, or 0`); +} + +export function generateSecret(): string { + const bytes = crypto.getRandomValues(new Uint8Array(24)); + return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll( + "/", + "_", + ).replace(/=+$/, ""); +} + +function applyEncryptedVaultSettings( + settings: ObsidianLiveSyncSettings, + environment: SetupGeneratorEnvironment, +): void { + Object.assign(settings, { + isConfigured: true, + encrypt: true, + passphrase: requireValue(environment, "passphrase"), + usePathObfuscation: true, + }); +} + +function createCouchDBSettings( + environment: SetupGeneratorEnvironment, +): ObsidianLiveSyncSettings { + const settings = createNewVaultSettings(); + Object.assign(settings, PREFERRED_SETTING_SELF_HOSTED, { + couchDB_URI: requireValue(environment, "hostname"), + couchDB_USER: requireValue(environment, "username"), + couchDB_PASSWORD: requireValue(environment, "password"), + couchDB_DBNAME: requireValue(environment, "database"), + batchSave: true, + periodicReplication: true, + syncOnStart: true, + syncOnFileOpen: true, + syncAfterMerge: true, + }); + applyEncryptedVaultSettings(settings, environment); + upsertRemoteConfigurationInPlace(settings, "couchdb", { activate: true }); + return settings; +} + +function createObjectStorageSettings( + environment: SetupGeneratorEnvironment, +): ObsidianLiveSyncSettings { + const settings = createNewVaultSettings(); + Object.assign(settings, PREFERRED_JOURNAL_SYNC, { + endpoint: requireValue(environment, "endpoint"), + accessKey: requireValue(environment, "access_key"), + secretKey: requireValue(environment, "secret_key"), + bucket: requireValue(environment, "bucket"), + region: environment.region?.trim() || "auto", + bucketPrefix: environment.bucket_prefix?.trim() || "", + bucketCustomHeaders: environment.bucket_custom_headers?.trim() || "", + useCustomRequestHandler: optionalBoolean( + environment, + "use_custom_request_handler", + false, + ), + forcePathStyle: optionalBoolean(environment, "force_path_style", true), + liveSync: true, + }); + applyEncryptedVaultSettings(settings, environment); + upsertRemoteConfigurationInPlace(settings, "s3", { activate: true }); + return settings; +} + +function createP2PSettings( + environment: SetupGeneratorEnvironment, +): ObsidianLiveSyncSettings { + const settings = createNewVaultSettings(); + Object.assign(settings, PREFERRED_BASE, P2P_DEFAULT_SETTINGS, { + P2P_Enabled: true, + P2P_roomID: environment.p2p_room_id?.trim() || generateP2PRoomId(), + P2P_passphrase: environment.p2p_passphrase?.trim() || generateSecret(), + P2P_relays: environment.p2p_relays?.trim() || + P2P_DEFAULT_SETTINGS.P2P_relays, + P2P_AppID: environment.p2p_app_id?.trim() || P2P_DEFAULT_SETTINGS.P2P_AppID, + P2P_AutoStart: optionalBoolean( + environment, + "p2p_auto_start", + P2P_DEFAULT_SETTINGS.P2P_AutoStart, + ), + P2P_AutoBroadcast: optionalBoolean( + environment, + "p2p_auto_broadcast", + P2P_DEFAULT_SETTINGS.P2P_AutoBroadcast, + ), + }); + applyEncryptedVaultSettings(settings, environment); + upsertRemoteConfigurationInPlace(settings, "p2p", { + activate: true, + activateForP2P: true, + }); + return settings; +} + +function parseRemoteType( + environment: SetupGeneratorEnvironment, +): SetupRemoteType { + const remoteType = environment.remote_type?.trim().toLowerCase() || "couchdb"; + if (remoteType === "couchdb" || remoteType === "s3" || remoteType === "p2p") { + return remoteType; + } + throw new Error("remote_type must be couchdb, s3, or p2p"); +} + +export function createSetupSettings( + environment: SetupGeneratorEnvironment, +): { remoteType: SetupRemoteType; settings: ObsidianLiveSyncSettings } { + const remoteType = parseRemoteType(environment); + if (remoteType === "couchdb") { + return { remoteType, settings: createCouchDBSettings(environment) }; + } + if (remoteType === "s3") { + return { remoteType, settings: createObjectStorageSettings(environment) }; + } + return { remoteType, settings: createP2PSettings(environment) }; +} + +export async function generateSetupURI( + environment: SetupGeneratorEnvironment, +): Promise { + const setupPassphrase = environment.uri_passphrase?.trim() || + generateSecret(); + const { remoteType, settings } = createSetupSettings(environment); + const setupURI = await encodeSettingsToSetupURI(settings, setupPassphrase, [ + "pluginSyncExtendedSetting", + "doNotUseFixedRevisionForChunks", + ], true); + return { remoteType, setupURI: setupURI.trim(), setupPassphrase }; +} + +export async function runSetupURIGenerator( + environment: SetupGeneratorEnvironment = Deno.env.toObject(), +): Promise { + const generated = await generateSetupURI(environment); + console.log(`\nGenerated ${generated.remoteType} Setup URI.`); + console.log( + "Your passphrase for the Setup URI is:", + generated.setupPassphrase, + ); + console.log("This passphrase is never shown again, so store it safely."); + console.log(generated.setupURI); +} + +if (import.meta.main) await runSetupURIGenerator(); diff --git a/utils/setup/livesync-commonlib.ts b/utils/setup/livesync-commonlib.ts new file mode 100644 index 00000000..d8091d4d --- /dev/null +++ b/utils/setup/livesync-commonlib.ts @@ -0,0 +1,18 @@ +// Keep Setup URI generation on its own static Commonlib module graph. This is +// intentionally separate from the CouchDB facade so that a raw-URL invocation +// does not load the PouchDB browser adapter. +export { + decodeSettingsFromSetupURI, + encodeSettingsToSetupURI, +} from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/API/processSetting"; +export { generateP2PRoomId } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/common/utils"; +export { upsertRemoteConfigurationInPlace } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/remote-configurations"; +export { + createNewVaultSettings, + DEFAULT_SETTINGS, + P2P_DEFAULT_SETTINGS, + PREFERRED_BASE, + PREFERRED_JOURNAL_SYNC, + PREFERRED_SETTING_SELF_HOSTED, +} from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/settings"; +export type { ObsidianLiveSyncSettings } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/settings"; diff --git a/utilsdeno/README.md b/utilsdeno/README.md index dc46fc49..d0b2a76e 100644 --- a/utilsdeno/README.md +++ b/utilsdeno/README.md @@ -32,7 +32,7 @@ Converts standard global variable usages to compatibility wrappers to ensure saf * **Targets**: `setTimeout`, `clearTimeout`, `setInterval`, `clearInterval`, `requestAnimationFrame`, `cancelAnimationFrame`, `localStorage`, `navigator`, `location`, `window`, `globalThis`, and `document`. * **Actions**: * Replaces global namespace references (like `window` and `globalThis`) with `compatGlobal`. - * Replaces `document` with `_activeDocument` (from `@lib/common/coreEnvFunctions.ts`). + * Replaces `document` with `_activeDocument` from the Commonlib compatibility entry. * Injects or updates the necessary imports in modified files. * **Command**: ```bash @@ -86,29 +86,15 @@ Scans the codebase and logs all occurrences of explicit `any` types. ``` ### 6. Import Normalisation (`normalise-imports.ts`) -Ensures that all import statements are standardised across the codebase, resolving paths to aliases such as `@lib/` and `@/` where applicable. +Ensures that internal plug-in import statements are standardised to the `@/` alias where applicable. Commonlib imports remain explicit package subpaths and are not rewritten. * **Command**: ```bash deno run --allow-read --allow-write --allow-env normalise-imports.ts ``` -### 7. CLI Node.js Import Redirection (`refactor-cli-node-imports.ts`) -Redirects direct Node.js built-in module imports (like `fs` and `path`) within the CLI codebase to use a single barrel file (`src/apps/cli/node-compat.ts`). - -* **Actions**: - * Finds imports of Node.js built-in APIs (`fs`, `fs/promises`, `path`, and `readline/promises`) in CLI source files. - * Replaces them with imports from the local `node-compat.ts` barrel file. - * This eliminates duplicate browser-targeted linter warnings on Node.js built-ins in the CLI workspace, keeping linter ignores consolidated. -* **Command**: - ```bash - deno run --allow-read --allow-write --allow-env refactor-cli-node-imports.ts - ``` - ---- - ## Safety and Exclusions * **Tests Excluded**: All scripts automatically skip files located in `_test/` or `testdeno/` folders, as well as files ending with `.spec.ts` or `.test.ts`. -* **Submodule Caution**: Some tools will run against the `src/lib/` submodule. Ensure you verify changes inside the submodule prior to committing. +* **Package Boundary**: These tools operate on this repository only. Changes to Commonlib belong in its own repository and must be validated with its package checks. * **Verification**: Always run `npm run check` and `npm run test:unit` after performing refactoring tasks to verify that type safety and tests remain intact. diff --git a/utilsdeno/normalise-imports.ts b/utilsdeno/normalise-imports.ts index 11c3d153..c23ee0eb 100644 --- a/utilsdeno/normalise-imports.ts +++ b/utilsdeno/normalise-imports.ts @@ -1,4 +1,4 @@ -// Normalise import and export paths in the codebase to use @lib/ and @/ aliases correctly. +// Normalise import and export paths in the codebase to use the @/ alias correctly. // Use this script by running `deno run --allow-read --allow-write normalise-imports.ts` from the utilsdeno directory. // Set the --run flag to apply changes: `deno run --allow-read --allow-write normalise-imports.ts --run` // Set the --all-alias flag to also normalise sibling/child imports (starting with ./): `deno run --allow-read --allow-write normalise-imports.ts --all-alias` @@ -39,12 +39,9 @@ function toPosixPath(filePath: string): string { const posixProjectRoot = toPosixPath(projectRoot); const posixSrc = `${posixProjectRoot}/src`; -const posixLibSrc = `${posixProjectRoot}/src/lib/src`; -const posixSubrepo = `${posixProjectRoot}/src/lib`; console.log(`Project Root: ${posixProjectRoot}`); console.log(`Source Directory: ${posixSrc}`); -console.log(`Library Source Directory: ${posixLibSrc}`); console.log(""); let modifiedFilesCount = 0; @@ -87,7 +84,7 @@ for (const sourceFile of project.getSourceFiles()) { // Determine if it is an internal import. const isRelative = moduleSpecifier.startsWith("."); - const isAlias = moduleSpecifier.startsWith("@/") || moduleSpecifier.startsWith("@lib/"); + const isAlias = moduleSpecifier.startsWith("@/"); if (!isRelative && !isAlias) { // Skip external packages/modules. @@ -96,9 +93,7 @@ for (const sourceFile of project.getSourceFiles()) { // Resolve path to an absolute POSIX path. let resolvedPath = ""; - if (moduleSpecifier.startsWith("@lib/")) { - resolvedPath = `${posixLibSrc}/${moduleSpecifier.slice(5)}`; - } else if (moduleSpecifier.startsWith("@/")) { + if (moduleSpecifier.startsWith("@/")) { resolvedPath = `${posixSrc}/${moduleSpecifier.slice(2)}`; } else { // Relative path. @@ -107,14 +102,9 @@ for (const sourceFile of project.getSourceFiles()) { resolvedPath = toPosixPath(path.normalize(resolvedPath)); - // Keep relative sibling/child imports unchanged (e.g. ./utils) unless: - // 1. --all-alias is set, OR - // 2. the import crosses the subrepository boundary (src/lib/) + // Keep relative sibling/child imports unchanged (e.g. ./utils) unless --all-alias is set. const isSibling = isRelative && !moduleSpecifier.startsWith(".."); - const importerInsideSubrepo = posixFilePath.startsWith(posixSubrepo + "/"); - const targetInsideSubrepo = resolvedPath.startsWith(posixSubrepo + "/"); - const crossesSubrepo = importerInsideSubrepo !== targetInsideSubrepo; - if (isSibling && !allAlias && !crossesSubrepo) { + if (isSibling && !allAlias) { continue; } @@ -126,18 +116,7 @@ for (const sourceFile of project.getSourceFiles()) { moduleSpecifier.endsWith(".svelte") || moduleSpecifier.endsWith(".d.ts"); - if (resolvedPath.startsWith(posixLibSrc + "/")) { - let rel = resolvedPath.slice(posixLibSrc.length + 1); - if (!hasExtension && (rel.endsWith(".ts") || rel.endsWith(".js"))) { - // Strip extension if the original import did not have one. - if (rel.endsWith(".ts") && !rel.endsWith(".d.ts")) { - rel = rel.slice(0, -3); - } else if (rel.endsWith(".js")) { - rel = rel.slice(0, -3); - } - } - newSpecifier = `@lib/${rel}`; - } else if (resolvedPath.startsWith(posixSrc + "/")) { + if (resolvedPath.startsWith(posixSrc + "/")) { let rel = resolvedPath.slice(posixSrc.length + 1); if (!hasExtension && (rel.endsWith(".ts") || rel.endsWith(".js"))) { // Strip extension if the original import did not have one. diff --git a/utilsdeno/refactor-cli-node-imports.ts b/utilsdeno/refactor-cli-node-imports.ts deleted file mode 100644 index 36f88536..00000000 --- a/utilsdeno/refactor-cli-node-imports.ts +++ /dev/null @@ -1,132 +0,0 @@ -// Refactor Node.js imports in the CLI application to use the barrel compatibility file. -// Use this script by running `deno run --allow-read --allow-write --allow-env refactor-cli-node-imports.ts` from the utilsdeno directory. -// Run with --run flag to apply changes. -import { Project, SyntaxKind, Node } from "npm:ts-morph"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const isDryRun = !Deno.args.includes("--run"); - -if (isDryRun) { - console.log("=== DRY RUN MODE ==="); - console.log( - "To apply changes, run with: deno run --allow-read --allow-write --allow-env refactor-cli-node-imports.ts --run\n" - ); -} else { - console.log("=== RUN MODE: WILL MODIFY FILES ==="); -} - -const project = new Project({ tsConfigFilePath: "../tsconfig.json" }); -project.addSourceFilesAtPaths("../src/apps/cli/**/*.ts"); - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const projectRoot = path.resolve(__dirname, ".."); -const nodeCompatPath = path.resolve(projectRoot, "src", "apps", "cli", "node-compat.ts"); - -function toPosixPath(filePath: string): string { - return filePath.replace(/\\/g, "/"); -} - -const posixProjectRoot = toPosixPath(projectRoot); -const posixSrc = `${posixProjectRoot}/src`; - -function getRelativeImportPath(fromFile: string, toFile: string): string { - let rel = path.relative(path.dirname(fromFile), toFile); - rel = rel.replace(/\\/g, "/"); - if (!rel.startsWith(".") && !rel.startsWith("/")) { - rel = "./" + rel; - } - if (rel.endsWith(".ts")) { - rel = rel.slice(0, -3); - } - return rel; -} - -let modifiedFilesCount = 0; - -for (const sourceFile of project.getSourceFiles()) { - const filePath = sourceFile.getFilePath(); - const posixFilePath = toPosixPath(filePath); - - // Only process CLI source files under src/apps/cli/ - if (!posixFilePath.includes("/src/apps/cli/")) continue; - if ( - posixFilePath.endsWith("node-compat.ts") || - posixFilePath.endsWith("vite.config.ts") || - posixFilePath.endsWith(".spec.ts") || - posixFilePath.endsWith(".test.ts") || - posixFilePath.includes("/_test/") || - posixFilePath.includes("/testdeno/") || - posixFilePath.includes("/test/") - ) { - continue; - } - - const importDeclarations = sourceFile.getImportDeclarations(); - const targetImports: any[] = []; - const namedImportsToAdd: string[] = []; - - for (const impDecl of importDeclarations) { - const specifier = impDecl.getModuleSpecifierValue(); - - // Check if it's a Node.js built-in module we want to redirect - let exportedName = ""; - if (specifier === "fs/promises" || specifier === "node:fs/promises") { - exportedName = "fsPromises"; - } else if (specifier === "fs" || specifier === "node:fs") { - exportedName = "fs"; - } else if (specifier === "path" || specifier === "node:path") { - exportedName = "path"; - } else if (specifier === "node:readline/promises") { - exportedName = "readline"; - } - - if (exportedName) { - const localName = impDecl.getNamespaceImport()?.getText() || impDecl.getDefaultImport()?.getText(); - if (localName) { - targetImports.push({ impDecl, exportedName, localName }); - } - } - } - - if (targetImports.length > 0) { - console.log(`File: ${posixFilePath.slice(posixProjectRoot.length + 1)}`); - - for (const { impDecl, exportedName, localName } of targetImports) { - const { line } = sourceFile.getLineAndColumnAtPos(impDecl.getStart()); - console.log(` Line ${line}: Redirecting "${impDecl.getText()}"`); - - if (exportedName === localName) { - namedImportsToAdd.push(exportedName); - } else { - namedImportsToAdd.push(`${exportedName} as ${localName}`); - } - - if (!isDryRun) { - impDecl.remove(); - } - } - - const relImportPath = getRelativeImportPath(filePath, nodeCompatPath); - console.log(` Adding: import { ${namedImportsToAdd.join(", ")} } from "${relImportPath}"`); - - if (!isDryRun) { - sourceFile.addImportDeclaration({ - namedImports: namedImportsToAdd, - moduleSpecifier: relImportPath, - }); - } - - modifiedFilesCount++; - } -} - -console.log(`\nTotal files to modify: ${modifiedFilesCount}`); - -if (!isDryRun) { - project.saveSync(); - console.log("All changes successfully saved."); -} else { - console.log("Dry run complete. No changes were written to files."); -} diff --git a/utilsdeno/refactor-globals.ts b/utilsdeno/refactor-globals.ts index bd368dfd..ea54a1f4 100644 --- a/utilsdeno/refactor-globals.ts +++ b/utilsdeno/refactor-globals.ts @@ -32,7 +32,6 @@ function toPosixPath(filePath: string): string { const posixProjectRoot = toPosixPath(projectRoot); const posixSrc = `${posixProjectRoot}/src`; -const posixLibSrc = `${posixProjectRoot}/src/lib`; const TARGET_GLOBALS = new Set([ "setTimeout", @@ -191,7 +190,7 @@ for (const sourceFile of project.getSourceFiles()) { if (requiredImports.length > 0) { const existingImport = sourceFile.getImportDeclarations().find((imp) => { const spec = imp.getModuleSpecifierValue(); - return spec === "@lib/common/coreEnvFunctions" || spec === "@lib/common/coreEnvFunctions.ts"; + return spec === "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions" || spec === "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; }); if (existingImport) { @@ -206,7 +205,7 @@ for (const sourceFile of project.getSourceFiles()) { } else { sourceFile.addImportDeclaration({ namedImports: requiredImports, - moduleSpecifier: "@lib/common/coreEnvFunctions.ts", + moduleSpecifier: "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", }); } } diff --git a/utilsdeno/refactor-import-utils.ts b/utilsdeno/refactor-import-utils.ts deleted file mode 100644 index 96e45dfd..00000000 --- a/utilsdeno/refactor-import-utils.ts +++ /dev/null @@ -1,187 +0,0 @@ -// Delete references to utils.ts and replace them with new imports based on the importMap. -// Use this script by running `deno run --allow-read --allow-write --allow-run refactor-import-utils.ts` from the utilsdeno directory. -import { Project } from "npm:ts-morph"; - -const isDryRun = !Deno.args.includes("--run"); - -if (isDryRun) { - console.log("=== DRY RUN MODE ==="); - console.log( - "To apply changes, run with: deno run --allow-read --allow-write --allow-run refactor-import-utils.ts --run\n" - ); -} - -// const project = new Project({ tsConfigFilePath: "../src/apps/cli/tsconfig.json" }); -const project = new Project({ tsConfigFilePath: "../tsconfig.json" }); - -const importMap = new Map(); - -const targetFiles = [ - "utils.concurrency.ts", - "utils.timer.ts", - "utils.notations.ts", - "utils.database.ts", - "utils.regexp.ts", - "utils.settings.ts", - "utils.patch.ts", - "utils.misc.ts", -]; - -// 1. Map exports from our newly created subfiles -for (const sourceFile of project.getSourceFiles()) { - const filePath = sourceFile.getFilePath(); - const fileName = sourceFile.getBaseName(); - if (filePath.includes("src/lib/src/common/") && targetFiles.includes(fileName)) { - const exports = sourceFile.getExportedDeclarations(); - for (const [name] of exports) { - const relativePath = filePath.split("src/lib/src/")[1].replace(/\.ts$/, ""); - importMap.set(name, `@lib/${relativePath}`); - } - } -} - -// 2. Map exports/imports of octagonal-wheels in utils.ts -const utilsFile = project.getSourceFile("src/lib/src/common/utils.ts"); -if (utilsFile) { - // Parse imports from octagonal-wheels - for (const imp of utilsFile.getImportDeclarations()) { - const moduleSpec = imp.getModuleSpecifierValue(); - if (moduleSpec.startsWith("octagonal-wheels")) { - for (const namedImport of imp.getNamedImports()) { - importMap.set(namedImport.getName(), moduleSpec); - } - } - } - // Parse export declarations from octagonal-wheels - for (const exp of utilsFile.getExportDeclarations()) { - const moduleSpec = exp.getModuleSpecifierValue(); - if (moduleSpec && moduleSpec.startsWith("octagonal-wheels")) { - for (const namedExport of exp.getNamedExports()) { - importMap.set(namedExport.getName(), moduleSpec); - } - } - } -} - -console.log(`Built importMap with ${importMap.size} mappings.\n`); - -let modifiedFilesCount = 0; - -// 3. Loop through all source files and replace imports -for (const sourceFile of project.getSourceFiles()) { - let fileModified = false; - const imports = sourceFile.getImportDeclarations(); - - for (const imp of imports) { - const moduleSpec = imp.getModuleSpecifierValue(); - const isUtilsImport = - moduleSpec === "@lib/common/utils" || - moduleSpec === "@lib/common/utils.ts" || - moduleSpec.endsWith("/common/utils") || - moduleSpec.endsWith("/common/utils.ts"); - - if (isUtilsImport) { - const namedImports = imp.getNamedImports(); - const defaultImport = imp.getDefaultImport(); - - const importsToReplace: Record = {}; - for (const namedImport of namedImports) { - const name = namedImport.getName(); - let newPath = importMap.get(name); - if (newPath) { - // If original ended with .ts and the new path starts with @lib, keep .ts - if (moduleSpec.endsWith(".ts") && newPath.startsWith("@lib/")) { - newPath = newPath + ".ts"; - } - if (!importsToReplace[newPath]) { - importsToReplace[newPath] = []; - } - importsToReplace[newPath].push({ - name, - newPath, - isTypeOnly: namedImport.isTypeOnly() || imp.isTypeOnly(), - }); - } - } - - if (Object.keys(importsToReplace).length > 0 || (defaultImport && importMap.has(defaultImport.getText()))) { - fileModified = true; - - console.log(`File: ${sourceFile.getFilePath().split("obsidian-livesync/")[1]}`); - console.log(` Old: ${imp.getText()}`); - } - - if (!isDryRun) { - // Apply replacements - for (const newPath in importsToReplace) { - const isTypeOnly = importsToReplace[newPath].filter((i) => i.isTypeOnly); - if (isTypeOnly.length > 0) { - sourceFile.insertImportDeclaration(imp.getChildIndex(), { - namedImports: isTypeOnly.map((i) => i.name), - moduleSpecifier: newPath, - isTypeOnly: true, - }); - } - const isValueImport = importsToReplace[newPath].filter((i) => !i.isTypeOnly); - if (isValueImport.length > 0) { - sourceFile.insertImportDeclaration(imp.getChildIndex(), { - namedImports: isValueImport.map((i) => i.name), - moduleSpecifier: newPath, - isTypeOnly: false, - }); - } - for (const { name } of importsToReplace[newPath]) { - const namedImport = imp.getNamedImports().find((ni) => ni.getName() === name); - if (namedImport) { - namedImport.remove(); - } - } - } - } else { - // In dry run, just print what it would do - for (const newPath in importsToReplace) { - const names = importsToReplace[newPath].map((i) => i.name).join(", "); - console.log(` -> Would import { ${names} } from "${newPath}"`); - } - } - - if (defaultImport) { - const name = defaultImport.getText(); - let newPath = importMap.get(name); - if (newPath) { - if (moduleSpec.endsWith(".ts") && newPath.startsWith("@lib/")) { - newPath = newPath + ".ts"; - } - if (!isDryRun) { - sourceFile.insertImportDeclaration(imp.getChildIndex(), { - defaultImport: name, - moduleSpecifier: newPath, - isTypeOnly: imp.isTypeOnly(), - }); - imp.removeDefaultImport(); - } else { - console.log(` -> Would import default ${name} from "${newPath}"`); - } - } - } - - if (!isDryRun) { - if (imp.getNamedImports().length === 0 && !imp.getDefaultImport()) { - imp.remove(); - } - } - } - } - if (fileModified) { - modifiedFilesCount++; - } -} - -console.log(`\nTotal files to modify: ${modifiedFilesCount}`); - -if (!isDryRun) { - project.saveSync(); - console.log("All changes successfully saved."); -} else { - console.log("Dry run complete. No changes were written to files."); -} diff --git a/utilsdeno/refactor-imports.ts b/utilsdeno/refactor-imports.ts deleted file mode 100644 index b7d7a6a2..00000000 --- a/utilsdeno/refactor-imports.ts +++ /dev/null @@ -1,155 +0,0 @@ -// Delete references to types.ts and replace them with new imports based on the importMap. It will also split imports if some are type-only and some are value imports. -// Use this script by running `deno run --allow-read --allow-write --allow-run refactor-imports.ts` from the utilsdeno directory. It will read all source files, find imports from types.ts, and replace them with the new paths based on the importMap. Make sure to review the changes before saving, as it will modify your source files. -import { Project } from "npm:ts-morph"; - -const isDryRun = !Deno.args.includes("--run"); - -if (isDryRun) { - console.log("=== DRY RUN MODE ==="); - console.log( - "To apply changes, run with: deno run --allow-read --allow-write --allow-run refactor-import-utils.ts --run\n" - ); -} - -// const project = new Project({ tsConfigFilePath: "../src/apps/cli/tsconfig.json" }); -const project = new Project({ tsConfigFilePath: "../tsconfig.json" }); - -const importMap = new Map(); -// Build a map of types moved out of Models. -// Under src/lib/src/common/models. -for (const sourceFile of project.getSourceFiles()) { - if (sourceFile.getFilePath().includes("src/lib/src/common/models")) { - const exports = sourceFile.getExportedDeclarations(); - for (const [name, declarations] of exports) { - for (const declaration of declarations) { - if ( - // declaration.getKindName() === "TypeAliasDeclaration" || - // declaration.getKindName() === "InterfaceDeclaration" || - // declaration.getKindName() === "EnumDeclaration" || - true - ) { - // console.log(`Found type export in ${sourceFile.getFilePath()}:`, name); - const relativePath = sourceFile.getFilePath().split("src/lib/src/")[1].replace(/\.ts$/, ""); - importMap.set(name, `@lib/${relativePath}`); - } - } - } - } -} -// Extras - -importMap.set("LOG_LEVEL_NOTICE", "@lib/common/logger"); -importMap.set("LOG_LEVEL_VERBOSE", "@lib/common/logger"); -importMap.set("LOG_LEVEL_INFO", "@lib/common/logger"); -importMap.set("LOG_LEVEL_DEBUG", "@lib/common/logger"); -importMap.set("LOG_LEVEL_URGENT", "@lib/common/logger"); -importMap.set("LOG_LEVEL", "@lib/common/logger"); -importMap.set("Logger", "@lib/common/logger"); - -// console.log("Import map:", importMap); - -// Loop through all files that import from types.ts. -for (const sourceFile of project.getSourceFiles()) { - const imports = sourceFile.getImportDeclarations(); - // if import from types.ts and the file is pointing `/lib/src/common/types.ts` (resolved), then we will check if the imported names exist in the importMap, if yes, we will replace the import path with the new path from importMap. - - for (const imp of imports) { - const moduleSpecifier = imp.getModuleSpecifierValue(); - if (moduleSpecifier.endsWith("types") || moduleSpecifier.endsWith("types.ts")) { - const filePath = sourceFile.getFilePath(); - const lineNumber = imp.getStartLineNumber(); - const resolvedModule = imp.getModuleSpecifierSourceFile(); - if (!resolvedModule || !resolvedModule.getFilePath().includes("/lib/src/common/types.ts")) { - continue; - } - - // Collect imports from types.ts. - const namedImports = imp.getNamedImports(); - const defaultImport = imp.getDefaultImport(); - console.log(`Found import in ${filePath} at line ${lineNumber}:`, { - namedImports: namedImports.map((ni) => ni.getText()), - defaultImport: defaultImport ? defaultImport.getText() : null, - }); - // Group imports by their names and generate new import paths based on the importMap - const importsToReplace: Record = {}; - for (const namedImport of namedImports) { - const name = namedImport.getName(); - const newPath = importMap.get(name); - if (newPath) { - console.log( - `Will replace import of ${name} in ${filePath} at line ${lineNumber} with new path:`, - newPath - ); - if (!importsToReplace[newPath]) { - importsToReplace[newPath] = []; - } - importsToReplace[newPath].push({ - name, - newPath, - isTypeOnly: namedImport.isTypeOnly() || imp.isTypeOnly(), - }); - } - } - - // For each import, generate a new path from importMap and replace it. - // Split the import when it needs to become multiple imports. - - for (const newPath in importsToReplace) { - // First, handle type-only imports. - const isTypeOnly = importsToReplace[newPath].filter((i) => i.isTypeOnly); - if (isTypeOnly.length > 0) { - sourceFile.insertImportDeclaration(imp.getChildIndex(), { - namedImports: isTypeOnly.map((i) => i.name), - moduleSpecifier: newPath, - isTypeOnly: true, - }); - } - // Then, handle non-type-only imports. - const isValueImport = importsToReplace[newPath].filter((i) => !i.isTypeOnly); - if (isValueImport.length > 0) { - sourceFile.insertImportDeclaration(imp.getChildIndex(), { - namedImports: isValueImport.map((i) => i.name), - moduleSpecifier: newPath, - isTypeOnly: false, - }); - } - // Remove the replaced named imports from the old import. - for (const { name } of importsToReplace[newPath]) { - const namedImport = imp.getNamedImports().find((ni) => ni.getName() === name); - if (namedImport) { - namedImport.remove(); - } - } - } - // If there is also a default import and it exists in importMap, replace it too. - if (defaultImport) { - const name = defaultImport.getText(); - const newPath = importMap.get(name); - - if (newPath) { - console.log( - `Replacing default import of ${name} in ${filePath} at line ${lineNumber} with new path:`, - newPath - ); - // Add the new import statement. - sourceFile.insertImportDeclaration(imp.getChildIndex(), { - defaultImport: name, - moduleSpecifier: newPath, - isTypeOnly: imp.isTypeOnly(), - }); - // Remove the default import from the old import. - imp.removeDefaultImport(); - } - } - if (imp.getNamedImports().length === 0 && !imp.getDefaultImport()) { - // Delete the entire import statement if nothing remains. - imp.remove(); - } - } - } -} - -// Save everything at the end. -if (!isDryRun) { - project.saveSync(); -} diff --git a/utilsdeno/refactor-styles.ts b/utilsdeno/refactor-styles.ts index c9546d78..95f46d82 100644 --- a/utilsdeno/refactor-styles.ts +++ b/utilsdeno/refactor-styles.ts @@ -32,7 +32,6 @@ function toPosixPath(filePath: string): string { const posixProjectRoot = toPosixPath(projectRoot); const posixSrc = `${posixProjectRoot}/src`; -const posixLibSrc = `${posixProjectRoot}/src/lib`; function matchStyleAccess(node: Node): { element: Node; propertyName: string; isComputed: boolean } | undefined { if (Node.isPropertyAccessExpression(node)) { diff --git a/utilsdeno/types-add-ignore.ts b/utilsdeno/types-add-ignore.ts deleted file mode 100644 index 3a546525..00000000 --- a/utilsdeno/types-add-ignore.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { Project, SyntaxKind } from "npm:ts-morph"; - -function processFile(filePath: string, origin: string, repoHash: string): string { - const project = new Project(); - const sourceFile = project.addSourceFileAtPath(filePath); - let updated = false; - - // 0. insert a commit hash comment at the top of the file - sourceFile.insertText(0, `// @ts-nocheck\n// REPO: ${origin} Commit hash: ${repoHash}\n`); - updated = true; - - // 1. Replacements for Uint8Array and DataView - let sourceText = sourceFile.getFullText(); - if (sourceText.includes("Uint8Array") || sourceText.includes("DataView")) { - sourceText = sourceText.replace(/Uint8Array/g, "Uint8Array"); - sourceText = sourceText.replace(/DataView/g, "DataView"); - sourceFile.replaceWithText(sourceText); - updated = true; - } - - // 2. Remove EventEmitter import from "events" and declare class EventEmitter inline - const imports = sourceFile.getImportDeclarations(); - imports.forEach((importDecl) => { - if (importDecl.getModuleSpecifierValue() === "events") { - const defaultImport = importDecl.getDefaultImport(); - if (defaultImport && defaultImport.getText() === "EventEmitter") { - importDecl.remove(); - sourceFile.addClass({ - name: "EventEmitter", - isExported: false, - methods: [ - { - name: "on", - parameters: [ - { name: "event", type: "string | symbol" }, - { name: "listener", type: "(...args: any[]) => void" }, - ], - returnType: "this", - }, - { - name: "once", - parameters: [ - { name: "event", type: "string | symbol" }, - { name: "listener", type: "(...args: any[]) => void" }, - ], - returnType: "this", - }, - { - name: "off", - parameters: [ - { name: "event", type: "string | symbol" }, - { name: "listener", type: "(...args: any[]) => void" }, - ], - returnType: "this", - }, - { - name: "emit", - parameters: [ - { name: "event", type: "string | symbol" }, - { name: "args", isRestParameter: true, type: "any[]" }, - ], - returnType: "boolean", - }, - { - name: "addListener", - parameters: [ - { name: "event", type: "string | symbol" }, - { name: "listener", type: "(...args: any[]) => void" }, - ], - returnType: "this", - }, - { - name: "removeListener", - parameters: [ - { name: "event", type: "string | symbol" }, - { name: "listener", type: "(...args: any[]) => void" }, - ], - returnType: "this", - }, - { - name: "removeAllListeners", - parameters: [{ name: "event", isOptional: true, type: "string | symbol" }], - returnType: "this", - }, - ], - }); - updated = true; - } - } - }); - - // 3. Collect targets for inline disable comments - const targetAnyLines = new Set(); - const targetEmptyObjectLines = new Set(); - const targetEmptyInterfaceLines = new Set(); - const targetDuplicateEnumLines = new Set(); - - // 3.1. 'any' type nodes - const anyTypeNodes = sourceFile.getDescendantsOfKind(SyntaxKind.AnyKeyword); - anyTypeNodes.forEach((anyNode: any) => { - const { line } = sourceFile.getLineAndColumnAtPos(anyNode.getStart()); - targetAnyLines.add(line - 1); - }); - - // 3.2. Empty object type literals {} - const typeLiterals = sourceFile.getDescendantsOfKind(SyntaxKind.TypeLiteral); - typeLiterals.forEach((node) => { - if (node.getMembers().length === 0) { - const { line } = sourceFile.getLineAndColumnAtPos(node.getStart()); - targetEmptyObjectLines.add(line - 1); - } - }); - - // 3.3. Empty interfaces - const interfaces = sourceFile.getInterfaces(); - interfaces.forEach((node) => { - if (node.getMembers().length === 0) { - const { line } = sourceFile.getLineAndColumnAtPos(node.getStart()); - targetEmptyInterfaceLines.add(line - 1); - } - }); - - // 3.4. Duplicate enum member values - const enums = sourceFile.getEnums(); - enums.forEach((enumDecl) => { - const values = new Set(); - enumDecl.getMembers().forEach((member) => { - const initValue = member.getInitializer()?.getText(); - if (initValue) { - if (values.has(initValue)) { - const { line } = sourceFile.getLineAndColumnAtPos(member.getStart()); - targetDuplicateEnumLines.add(line - 1); - } else { - values.add(initValue); - } - } - }); - }); - - // 4. Inject ignore comments line by line - const finalSourceText = sourceFile.getFullText(); - const lineBreak = finalSourceText.includes("\r\n") ? "\r\n" : "\n"; - const lines = finalSourceText.split(/\r?\n/); - - // 4.1. Add inline disable to lines that contain 'any' - for (const lineIndex of targetAnyLines) { - const line = lines[lineIndex]; - if (!line) continue; - if (line.includes("eslint-disable-line @typescript-eslint/no-explicit-any")) continue; - lines[lineIndex] = `${line} // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration`; - updated = true; - } - - // 4.2. Add inline disable to lines that contain empty object {} - for (const lineIndex of targetEmptyObjectLines) { - const line = lines[lineIndex]; - if (!line) continue; - if (line.includes("eslint-disable-line") || line.includes("eslint-disable-next-line")) continue; - lines[lineIndex] = - `${line} // eslint-disable-line @typescript-eslint/no-empty-object-type, @typescript-eslint/ban-types -- Empty object type`; - updated = true; - } - - // 4.3. Add inline disable to lines that contain empty interface - for (const lineIndex of targetEmptyInterfaceLines) { - const line = lines[lineIndex]; - if (!line) continue; - if (line.includes("eslint-disable-line") || line.includes("eslint-disable-next-line")) continue; - lines[lineIndex] = - `${line} // eslint-disable-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-empty-interface -- Empty interface`; - updated = true; - } - - // 4.4. Add inline disable to lines with duplicate enums - for (const lineIndex of targetDuplicateEnumLines) { - const line = lines[lineIndex]; - if (!line) continue; - if (line.includes("eslint-disable-line") || line.includes("eslint-disable-next-line")) continue; - lines[lineIndex] = - `${line} // eslint-disable-line @typescript-eslint/no-duplicate-enum-values -- Duplicate enum value`; - updated = true; - } - - const updatedSourceText = lines.join(lineBreak); - if (updated) { - console.log(`Processed file: ${filePath}`); - } - return updatedSourceText; -} - -const targetDir = `./_types`; - -async function processDir(dirPath: string) { - for await (const entry of Deno.readDir(dirPath)) { - if (entry.isDirectory) { - await processDir(`${dirPath}/${entry.name}`); - } - if (entry.isFile && entry.name.endsWith(".d.ts")) { - const filePath = `${dirPath}/${entry.name}`; - console.log(`Processing: ${filePath}`); - const updatedContent = processFile(filePath, repoRemoteOriginStr, gitCommitHashStr); - // Write the file. To revert, regenerate it with npm run lib:build:types. - await Deno.writeTextFile(filePath, updatedContent); - } - } -} - -const subDir = "./src/lib/"; -const repoRemoteOrigins = new Deno.Command("git", { - args: ["remote", "get-url", "origin"], - cwd: subDir, - stdout: "piped", -}).outputSync().stdout; -const repoRemoteOriginStr = new TextDecoder().decode(repoRemoteOrigins).trim(); -console.log(`STAMP: Git remote origin: ${repoRemoteOriginStr}`); -const gitCommitHashSub = new Deno.Command("git", { - args: ["rev-parse", "--short", "HEAD"], - cwd: subDir, - stdout: "piped", -}).outputSync().stdout; -const gitCommitHashStr = new TextDecoder().decode(gitCommitHashSub).trim(); -console.log(`STAMP: Git commit hash: ${gitCommitHashStr}`); -await processDir(targetDir); diff --git a/versions.json b/versions.json index fc900972..b3dfac9e 100644 --- a/versions.json +++ b/versions.json @@ -1,8 +1,26 @@ { "0.25.61": "1.7.2", "0.25.60": "1.7.2", - "1.0.1": "0.9.12", - "1.0.0": "0.9.7", "0.25.81": "1.7.2", - "0.25.82": "1.7.2" + "0.25.82": "1.7.2", + "0.25.83": "1.7.2", + "1.0.0-beta.0": "1.7.2", + "1.0.0-beta.1": "1.7.2", + "1.0.0-beta.2": "1.7.2", + "1.0.0-beta.3": "1.7.2", + "1.0.0-beta.4": "1.7.2", + "1.0.0-beta.5": "1.7.2", + "1.0.0-rc.0": "1.7.2", + "1.0.0-rc.1": "1.7.2", + "1.0.0": "1.7.2", + "1.0.1": "1.7.2", + "1.0.2": "1.7.2", + "1.0.3": "1.7.2", + "1.0.4": "1.7.2", + "1.0.5": "1.7.2", + "1.0.6": "1.7.2", + "1.0.7": "1.7.2", + "1.0.8": "1.7.2", + "1.0.9": "1.7.2", + "1.0.10": "1.7.2" } diff --git a/vite.config.ts b/vite.config.ts index a7c47aee..8b009f0a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -130,7 +130,6 @@ export default defineConfig(({ mode }) => { resolve: { alias: { "@": path.resolve(__dirname, "./src"), - "@lib": path.resolve(__dirname, "./src/lib/src"), src: path.resolve(__dirname, "./src"), }, }, diff --git a/vitest.config.common.ts b/vitest.config.common.ts index 73f023bf..0a7d7375 100644 --- a/vitest.config.common.ts +++ b/vitest.config.common.ts @@ -96,7 +96,6 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "./src"), - "@lib": path.resolve(__dirname, "./src/lib/src"), src: path.resolve(__dirname, "./src"), }, }, diff --git a/vitest.config.e2e-runner.ts b/vitest.config.e2e-runner.ts new file mode 100644 index 00000000..ce2c2743 --- /dev/null +++ b/vitest.config.e2e-runner.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["test/e2e-obsidian/runner/*.test.ts"], + }, +}); diff --git a/vitest.config.p2p.ts b/vitest.config.p2p.ts deleted file mode 100644 index 9478db52..00000000 --- a/vitest.config.p2p.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * @file vitest.config.p2p.ts - * @description Configuration for running browser-based Peer-to-Peer (P2P) replication tests - * in Playwright (Chromium) using Trystero and Nostr relays. - * This is executed via the `npm run test:p2p` command (which runs `test/suitep2p/run-p2p-tests.sh` internally). - */ -import { defineConfig, mergeConfig } from "vitest/config"; -import { playwright } from "@vitest/browser-playwright"; -import viteConfig from "./vitest.config.common"; -import path from "path"; -import { existsSync, readFileSync } from "node:fs"; -import { parseEnv } from "node:util"; -import { grantClipboardPermissions, writeHandoffFile, readHandoffFile } from "./test/lib/commands"; - -// P2P test environment variables -// Configure these in .env or .test.env, or inject via shell before running tests. -// Shell-injected values take precedence over dotenv files. -// -// Required: -// P2P_TEST_ROOM_ID - Shared room identifier for peers to discover each other -// P2P_TEST_PASSPHRASE - Encryption passphrase shared between test peers -// -// Optional: -// P2P_TEST_HOST_PEER_NAME - Name used to identify the host peer (default varies) -// P2P_TEST_RELAY - Nostr relay server URL used for peer signalling/discovery -// P2P_TEST_APP_ID - Application ID scoping the P2P session -// P2P_TEST_HANDOFF_FILE - File path used to pass state between up/down test phases -// -// General test options (also read from env): -// ENABLE_DEBUGGER - Set to "true" to attach a debugger and pause before tests -// ENABLE_UI - Set to "true" to open a visible browser window during tests -const loadEnvFile = (path: string) => (existsSync(path) ? parseEnv(readFileSync(path, "utf-8")) : undefined); -const defEnv = loadEnvFile(".env"); -const testEnv = loadEnvFile(".test.env"); -// Merge: dotenv files < process.env (so shell-injected vars like P2P_TEST_* take precedence) -const p2pEnv: Record = {}; -if (process.env.P2P_TEST_ROOM_ID) p2pEnv.P2P_TEST_ROOM_ID = process.env.P2P_TEST_ROOM_ID; -if (process.env.P2P_TEST_PASSPHRASE) p2pEnv.P2P_TEST_PASSPHRASE = process.env.P2P_TEST_PASSPHRASE; -if (process.env.P2P_TEST_HOST_PEER_NAME) p2pEnv.P2P_TEST_HOST_PEER_NAME = process.env.P2P_TEST_HOST_PEER_NAME; -if (process.env.P2P_TEST_RELAY) p2pEnv.P2P_TEST_RELAY = process.env.P2P_TEST_RELAY; -if (process.env.P2P_TEST_APP_ID) p2pEnv.P2P_TEST_APP_ID = process.env.P2P_TEST_APP_ID; -if (process.env.P2P_TEST_HANDOFF_FILE) p2pEnv.P2P_TEST_HANDOFF_FILE = process.env.P2P_TEST_HANDOFF_FILE; -const env = Object.assign({}, defEnv, testEnv, p2pEnv); -const debuggerEnabled = env?.ENABLE_DEBUGGER === "true"; -const enableUI = env?.ENABLE_UI === "true"; -const headless = !debuggerEnabled && !enableUI; - -export default mergeConfig( - viteConfig, - defineConfig({ - resolve: { - alias: { - obsidian: path.resolve(__dirname, "./test/harness/obsidian-mock.ts"), - }, - }, - test: { - env: env, - testTimeout: 240000, - hookTimeout: 240000, - fileParallelism: false, - isolate: true, - watch: false, - // Run all CLI-host P2P test files (*.p2p.test.ts, *.p2p-up.test.ts, *.p2p-down.test.ts) - include: ["test/suitep2p/**/*.p2p*.test.ts"], - browser: { - isolate: true, - // Only grantClipboardPermissions is needed; no openWebPeer/acceptWebPeer - commands: { - grantClipboardPermissions, - writeHandoffFile, - readHandoffFile, - }, - provider: playwright({ - launchOptions: { - args: [ - "--js-flags=--expose-gc", - "--allow-insecure-localhost", - "--disable-web-security", - "--ignore-certificate-errors", - ], - }, - }), - enabled: true, - screenshotFailures: false, - instances: [ - { - execArgv: ["--js-flags=--expose-gc"], - browser: "chromium", - headless, - isolate: true, - inspector: debuggerEnabled ? { waitForDebugger: true, enabled: true } : undefined, - printConsoleTrace: true, - onUnhandledError(error) { - const msg = error.message || ""; - if (msg.includes("Cannot create so many PeerConnections")) { - return false; - } - }, - }, - ], - headless, - fileParallelism: false, - ui: debuggerEnabled || enableUI ? true : false, - }, - }, - }) -); diff --git a/vitest.config.rpc-unit.ts b/vitest.config.rpc-unit.ts deleted file mode 100644 index d3c175e7..00000000 --- a/vitest.config.rpc-unit.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * @file vitest.config.rpc-unit.ts - * @description Configuration for running RPC-specific unit tests (such as RpcRoom and transport layers) in Node.js, - * enforcing coverage thresholds on the RPC sub-module. - * This can be run manually to verify RPC-specific coverage, or is matched by the glob patterns in `npm run test:unit`. - */ -import { defineConfig, mergeConfig } from "vitest/config"; -import viteConfig from "./vitest.config.common"; - -export default mergeConfig( - viteConfig, - defineConfig({ - resolve: { - alias: { - obsidian: "", - }, - }, - test: { - name: "rpc-unit-tests", - include: ["src/lib/src/rpc/**/*.unit.spec.ts"], - exclude: ["test/**"], - coverage: { - include: ["src/lib/src/rpc/**/*.ts"], - exclude: ["**/*.unit.spec.ts", "**/index.ts"], - provider: "v8", - reporter: ["text", "json", "html", ["text", { file: "coverage-rpc-text.txt" }]], - thresholds: { - lines: 90, - functions: 90, - branches: 75, - statements: 90, - }, - }, - }, - }) -); diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index a62992d3..00000000 --- a/vitest.config.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * @file vitest.config.ts - * @description Configuration for running browser-based end-to-end (E2E) integration tests - * using Playwright (Chromium) to test replication and synchronisation scenarios. - * This is executed when running the full test suite via `npm run test` or `npm run test:full`. - */ -import { defineConfig, mergeConfig } from "vitest/config"; -import { playwright } from "@vitest/browser-playwright"; -import viteConfig from "./vitest.config.common"; -import path from "path"; -import { existsSync, readFileSync } from "node:fs"; -import { parseEnv } from "node:util"; -import { grantClipboardPermissions, openWebPeer, closeWebPeer, acceptWebPeer } from "./test/lib/commands"; - -const loadEnvFile = (path: string) => (existsSync(path) ? parseEnv(readFileSync(path, "utf-8")) : undefined); -const defEnv = loadEnvFile(".env"); -const testEnv = loadEnvFile(".test.env"); -const env = Object.assign({}, defEnv, testEnv); -const debuggerEnabled = env?.ENABLE_DEBUGGER === "true"; -const enableUI = env?.ENABLE_UI === "true"; -const headless = !debuggerEnabled && !enableUI; -export default mergeConfig( - viteConfig, - defineConfig({ - resolve: { - alias: { - obsidian: path.resolve(__dirname, "./test/harness/obsidian-mock.ts"), - }, - }, - test: { - env: env, - testTimeout: 40000, - hookTimeout: 50000, - fileParallelism: false, - isolate: true, - watch: false, - - // environment: "browser", - include: ["test/**/*.test.ts"], - coverage: { - include: ["src/**/*.ts", "src/lib/src/**/*.ts", "src/**/*.svelte"], - exclude: ["**/*.test.ts", "src/lib/**"], - provider: "v8", - reporter: ["text", "json", "html"], - // ignoreEmptyLines: true, - }, - browser: { - isolate: true, - commands: { - grantClipboardPermissions, - openWebPeer, - closeWebPeer, - acceptWebPeer, - }, - provider: playwright({ - launchOptions: { - args: ["--js-flags=--expose-gc"], - // chromiumSandbox: true, - }, - }), - enabled: true, - screenshotFailures: false, - instances: [ - { - execArgv: ["--js-flags=--expose-gc"], - browser: "chromium", - headless, - isolate: true, - inspector: debuggerEnabled - ? { - waitForDebugger: true, - enabled: true, - } - : undefined, - printConsoleTrace: debuggerEnabled, - onUnhandledError(error) { - // Ignore certain errors - const msg = error.message || ""; - if (msg.includes("Cannot create so many PeerConnections")) { - return false; - } - }, - }, - ], - headless, - fileParallelism: false, - ui: debuggerEnabled || enableUI ? true : false, - }, - }, - }) -); diff --git a/vitest.config.unit.ts b/vitest.config.unit.ts index 3012a075..de36205b 100644 --- a/vitest.config.unit.ts +++ b/vitest.config.unit.ts @@ -20,7 +20,7 @@ export default mergeConfig( // maxConcurrency: 2, name: "unit-tests", include: ["**/*unit.test.ts", "**/*.unit.spec.ts"], - exclude: ["test/**", "src/apps/**/testdeno/**"], + exclude: ["node_modules/**", ".devmemo.local/**", "src/apps/**/testdeno/**"], coverage: { include: ["src/**/*.ts"], exclude: [ @@ -28,12 +28,8 @@ export default mergeConfig( "**/*unit.test.ts", "**/*.unit.spec.ts", "test/**", - "src/lib/**/*.test.ts", "**/_*", "src/apps/**/testdeno/**", - // "src/apps/**", - // "src/cli/**", - "src/lib/src/cli/**", "**/*_obsolete.ts", ...importOnlyFiles, ],