Compare commits
242 commits
asch/recon
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d99e249fa5 | |||
| 6647a4e632 | |||
| 201f9aeaee | |||
| 682dc74497 | |||
| 40fbd42b92 | |||
| 0e3132f96c | |||
| 4482e0155f | |||
| 9a75569e83 | |||
| 5efe30d9d6 | |||
| 0e0a85df82 | |||
| 42a77a5cd5 | |||
| 4fb3839b3e | |||
| 7daa363723 | |||
| 47f24e168b | |||
| b6ab01d56a | |||
| 580c993071 | |||
| 299c3baea9 | |||
| 1b71f3e780 | |||
| 8aba8ee44a | |||
| 079cd26faa | |||
| f6dccc4492 | |||
| 387e7afd58 | |||
| 056fb96ce8 | |||
| 9ac7fdbeb7 | |||
| 8e4ac3a26a | |||
| e2b24725ef | |||
| 2db49da654 | |||
| dbc63fcecd | |||
| ce6d44f26b | |||
| 6608804d34 | |||
| e47d8a8179 | |||
| e9252955b4 | |||
| 570c41299b | |||
| 78a706ab8d | |||
| 8439bd8b92 | |||
| 504ddb6ff6 | |||
| 0a5bbbf20e | |||
| b05e415acf | |||
| ad3191957a | |||
| 1ed22c72d7 | |||
| e6bfefd2d5 | |||
| 9e06d99512 | |||
| 3f2ecfb0b6 | |||
| 07cb8491e2 | |||
| aca1ca50a4 | |||
| 2885026d2f | |||
| e6f7543114 | |||
| d979963f86 | |||
| ea603f83fd | |||
| 66e2fb3768 | |||
| a1bda41646 | |||
| bfe3e9aeeb | |||
| 5238d85181 | |||
| 1646f74633 | |||
| 7a13cb57ce | |||
| 77e0bb4caf | |||
| e8d86c737b | |||
| a5e128efcd | |||
| 8adb8841ef | |||
| 564d4a6c37 | |||
| 2607bc5213 | |||
| 8ef2f8c132 | |||
|
|
d39a91b447 | ||
|
|
da2237fa68 | ||
|
|
e98f7acefa | ||
| 8e336cb0f3 | |||
| f8d62f4416 | |||
| 89e3b61766 | |||
| 215c024876 | |||
| bbf81d3111 | |||
| 9349afc00f | |||
| c7c96b787a | |||
| 7beda491e9 | |||
| 515a8f2bf4 | |||
| 39860f7f04 | |||
| 3517af1461 | |||
| 89565e23f3 | |||
| d07fa32ba3 | |||
| 10bde4bc3a | |||
| 952e89343a | |||
| 2ce5faea92 | |||
| b595a060a7 | |||
| 5905aa37b9 | |||
| 5417c1ddd0 | |||
| 84f077f36b | |||
| 4456767ec4 | |||
| e635e84aa4 | |||
| 10fdc938c5 | |||
| 91f49d6997 | |||
| 7a95d9f0a8 | |||
| e53482ced8 | |||
| 67c912ae4c | |||
| b0b5da7d37 | |||
| 13f5456b39 | |||
| c10b6435d4 | |||
| 476588a63b | |||
| 9d60ec14dd | |||
| d45d2c0be3 | |||
| 82f11d8c86 | |||
| c3cc678446 | |||
| 3ed2e4f666 | |||
| b1826907e7 | |||
| c3cbde052a | |||
| 7008c54e2e | |||
| 18be9f4dd8 | |||
| 4b195b070d | |||
| c4da1426b1 | |||
| 340c347841 | |||
| c94d732f24 | |||
| d8058d396c | |||
| ef4444afc2 | |||
| fb2d82a06e | |||
| 5a0c64d39c | |||
| 17fa584ea1 | |||
| 83c15a77c3 | |||
| 3cdd2a4387 | |||
| 213a9e18fb | |||
| cb2a1c0df1 | |||
| 9f1f4beae4 | |||
| 12d8d15572 | |||
| 56c77dc3f6 | |||
| 4186aa9e0c | |||
| 71274d466c | |||
| 9d645f43f8 | |||
| 72ad82ab83 | |||
| 4fcd134e55 | |||
| aa3c587002 | |||
| 10fd928459 | |||
| 91675ea99c | |||
| d4b68154df | |||
| c798d96009 | |||
| 51baa4d8e0 | |||
| a57ed5c4ae | |||
| c3c2cafde5 | |||
| f11c8db6d2 | |||
| 9c3dedad76 | |||
| d590a2c9c8 | |||
| 511ac78e6d | |||
| aaeca588fb | |||
| 1b1b72cb92 | |||
| fbf03c41e0 | |||
| a1a4610109 | |||
| 00d2061627 | |||
| 38810579ec | |||
| ea189f3d09 | |||
| fccc66aaea | |||
| 50a95b114d | |||
| 56c1f4d58b | |||
|
|
812eb7a644 | ||
| 72bae2d93e | |||
| e2189d4dbe | |||
| c08feba0ad | |||
| e75298c4f1 | |||
| be1635c26e | |||
|
|
1da17c462e | ||
|
|
29747d0829 | ||
|
|
f1c2c8f846 | ||
|
|
04034b85da | ||
|
|
c3773a2a7a | ||
|
|
97a4494085 | ||
|
|
33a24c3a77 | ||
|
|
0a7b8568e8 | ||
| cd57ea6682 | |||
|
|
2b9d77d165 | ||
|
|
b4ff4cbf25 | ||
| 4fce317dea | |||
| dd5334c538 | |||
| 4704d258ea | |||
| 90752e687a | |||
| a31c2d87b5 | |||
| 1ddba47b80 | |||
| aa73a5d718 | |||
| 90abf5ab14 | |||
| d97a177edf | |||
| 3b018819aa | |||
| 00fd7e2516 | |||
| 215a05d84a | |||
| 5e3544f601 | |||
| 1b5f236674 | |||
| 12aa457e3a | |||
| de143f9033 | |||
|
|
a3621b6d90 | ||
|
|
7c48e27dbd | ||
|
|
59e02bcb4d | ||
|
|
4556cc6cec | ||
|
|
0e6b2c4985 | ||
| 088f474a2e | |||
| c0171ad72f | |||
| b6c85f6370 | |||
| acdacf655d | |||
| 0f38d42212 | |||
| 1d19ceabd3 | |||
| a919b04cf0 | |||
| 4cdd0cbd40 | |||
| 9177984ff6 | |||
|
|
3f089bd37e | ||
| 0ff3bb5967 | |||
| d33f80cca6 | |||
| 27e2082747 | |||
| 376008de54 | |||
|
|
47f4ddfc63 | ||
|
|
524de60585 | ||
|
|
606b674a98 | ||
|
|
2f251f72fc | ||
| 36f2dc0d43 | |||
| 37ca507ae4 | |||
| eb6200dd73 | |||
| b2f4e0c038 | |||
|
|
2500378de0 | ||
|
|
6b12603915 | ||
|
|
79279df5b0 | ||
| d513ad9824 | |||
| d6e4305588 | |||
| 2ff1384fde | |||
| 6afb828bd9 | |||
| 16fc3a8234 | |||
| de4763c2cd | |||
| 43311ed30b | |||
|
|
a27b039646 | ||
|
|
8c6271cd0e | ||
| a36a24effc | |||
| 81b81e30ff | |||
| e73f147fbc | |||
| b7e80c39f1 | |||
| 2d016c44bd | |||
| a9ddd1032f | |||
| 0916f54045 | |||
| 278fa912df | |||
| cf8c9ebe00 | |||
| a2cbcf0519 | |||
| 6da107ff3a | |||
| bb07602c68 | |||
| d9ffcfeb5c | |||
| b56e8f6c15 | |||
| 396d07be66 | |||
| e8a719f844 | |||
| 49dcc22982 | |||
| 700903647e | |||
| 019858917e | |||
| bb0e44f06f | |||
|
|
75b020146a | ||
|
|
8602b1c9a3 |
467 changed files with 37474 additions and 50178 deletions
|
|
@ -9,6 +9,8 @@ trim_trailing_whitespace = true
|
|||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
tab_width = 4
|
||||
|
||||
[*.{yml,yaml}]
|
||||
[*.{yml,yaml,md}]
|
||||
indent_size = 2
|
||||
tab_width = 2
|
||||
|
|
|
|||
35
.forgejo/workflows/check.yml
Normal file
35
.forgejo/workflows/check.yml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
name: Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: "-Dwarnings"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "25.x"
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: "1.92.0"
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Lint & test
|
||||
run: scripts/check.sh
|
||||
38
.forgejo/workflows/deploy-docs.yml
Normal file
38
.forgejo/workflows/deploy-docs.yml
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
name: Deploy Documentation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "docs/**"
|
||||
- ".forgejo/workflows/deploy-docs.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "25.x"
|
||||
|
||||
- name: Build docs
|
||||
run: scripts/build-docs.sh
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: docs
|
||||
path: docs/.vitepress/dist
|
||||
71
.forgejo/workflows/e2e.yml
Normal file
71
.forgejo/workflows/e2e.yml
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
name: E2E tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
schedule:
|
||||
- cron: "0 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: e2e-tests
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
RUSTFLAGS: "-Dwarnings"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "25.x"
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: "1.92.0"
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Setup rust
|
||||
run: |
|
||||
which sqlx || cargo install sqlx-cli
|
||||
cd sync-server
|
||||
sqlx database create --database-url sqlite://db.sqlite3
|
||||
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
|
||||
|
||||
- name: E2E tests
|
||||
run: |
|
||||
cd sync-server
|
||||
cargo run config-e2e.yml --color never &
|
||||
SERVER_PID=$!
|
||||
cd ..
|
||||
|
||||
scripts/e2e.sh 8
|
||||
EXIT_CODE=$?
|
||||
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
wait $SERVER_PID 2>/dev/null || true
|
||||
|
||||
exit $EXIT_CODE
|
||||
|
||||
- name: Upload e2e logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-logs
|
||||
path: logs/
|
||||
retention-days: 30
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: scripts/clean-up.sh
|
||||
51
.forgejo/workflows/publish-cli-docker.yml
Normal file
51
.forgejo/workflows/publish-cli-docker.yml
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
name: Publish CLI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
tags: ["*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
publish-docker:
|
||||
runs-on: ubuntu-docker
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract registry hostname
|
||||
id: registry
|
||||
run: echo "host=$(echo '${{ github.server_url }}' | sed 's|https\?://||')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log into container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ steps.registry.outputs.host }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ steps.registry.outputs.host }}/${{ github.repository }}-cli
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: frontend
|
||||
file: frontend/local-client-cli/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}-cli:buildcache
|
||||
cache-to: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}-cli:buildcache,mode=max
|
||||
71
.forgejo/workflows/publish-plugin.yml
Normal file
71
.forgejo/workflows/publish-plugin.yml
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
name: Publish Obsidian plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["*"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
publish-plugin:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "25.x"
|
||||
|
||||
- name: Build plugin
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: "1.92.0"
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Install cross-compilation tools
|
||||
run: |
|
||||
apt update
|
||||
apt install -y gcc-aarch64-linux-gnu musl-tools gcc-mingw-w64-x86-64 jq
|
||||
|
||||
- name: Build Linux and Windows binaries
|
||||
run: ./scripts/build-sync-server-binaries.sh
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
mkdir -p release
|
||||
cp frontend/obsidian-plugin/dist/* release/
|
||||
cp sync-server/artifacts/sync-server-* release/
|
||||
|
||||
# Create draft release via Forgejo API
|
||||
RELEASE_ID=$(curl -s -X POST \
|
||||
"${SERVER_URL}/api/v1/repos/${REPO}/releases" \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${tag}\", \"name\": \"${tag}\", \"draft\": true}" \
|
||||
| jq -r '.id')
|
||||
|
||||
# Upload release assets
|
||||
for file in release/*; do
|
||||
filename=$(basename "$file")
|
||||
curl -s -X POST \
|
||||
"${SERVER_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}" \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-F "attachment=@${file}"
|
||||
done
|
||||
51
.forgejo/workflows/publish-server-docker.yml
Normal file
51
.forgejo/workflows/publish-server-docker.yml
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
name: Publish server Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
tags: ["*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
publish-docker:
|
||||
runs-on: ubuntu-docker
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract registry hostname
|
||||
id: registry
|
||||
run: echo "host=$(echo '${{ github.server_url }}' | sed 's|https\?://||')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log into container registry
|
||||
if: github.ref_type == 'tag'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ steps.registry.outputs.host }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ steps.registry.outputs.host }}/${{ github.repository }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: sync-server
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.ref_type == 'tag' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}:buildcache
|
||||
cache-to: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}:buildcache,mode=max
|
||||
27
.github/dependabot.yml
vendored
27
.github/dependabot.yml
vendored
|
|
@ -1,27 +0,0 @@
|
|||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directories: ["/frontend"]
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directories: ["**"]
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
- package-ecosystem: "cargo"
|
||||
directories: ["**"]
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
# Disable this for security reasons
|
||||
# - package-ecosystem: "github-actions"
|
||||
# directories: ["**"]
|
||||
# schedule:
|
||||
# interval: "daily"
|
||||
67
.github/workflows/check.yml
vendored
67
.github/workflows/check.yml
vendored
|
|
@ -1,67 +0,0 @@
|
|||
name: Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: "-Dwarnings"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version: "22.x"
|
||||
check-latest: true
|
||||
|
||||
- name: Setup rust
|
||||
run: |
|
||||
cargo install sqlx-cli wasm-pack cargo-machete
|
||||
cd backend
|
||||
sqlx database create --database-url sqlite://db.sqlite3
|
||||
sqlx migrate run --source sync_server/src/app_state/database/migrations --database-url sqlite://db.sqlite3
|
||||
|
||||
- name: Build wasm
|
||||
run: |
|
||||
cd backend
|
||||
wasm-pack build --target web sync_lib
|
||||
|
||||
- name: Lint backend
|
||||
run: |
|
||||
cd backend
|
||||
cargo clippy --all-targets --all-features
|
||||
cargo fmt --all -- --check
|
||||
cargo machete
|
||||
|
||||
- name: Test backend
|
||||
run: |
|
||||
cd backend
|
||||
cargo test --verbose -- --include-ignored
|
||||
cd sync_lib
|
||||
wasm-pack test --node
|
||||
|
||||
- name: Lint frontend
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
npm run lint
|
||||
if [[ $(git status --porcelain) ]]; then
|
||||
git status --porcelain
|
||||
echo "Failing CI because the working directory is not clean after linting"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Test frontend
|
||||
run: |
|
||||
cd frontend
|
||||
npm run test
|
||||
56
.github/workflows/e2e.yml
vendored
56
.github/workflows/e2e.yml
vendored
|
|
@ -1,56 +0,0 @@
|
|||
name: E2E tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: "-Dwarnings"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version: "22.x"
|
||||
check-latest: true
|
||||
|
||||
- name: Setup rust
|
||||
run: |
|
||||
cargo install sqlx-cli wasm-pack
|
||||
cd backend
|
||||
sqlx database create --database-url sqlite://db.sqlite3
|
||||
sqlx migrate run --source sync_server/src/app_state/database/migrations --database-url sqlite://db.sqlite3
|
||||
|
||||
- name: Build wasm
|
||||
run: |
|
||||
cd backend
|
||||
wasm-pack build --target web sync_lib
|
||||
|
||||
- name: E2E tests
|
||||
run: |
|
||||
cd backend
|
||||
cargo run -p sync_server config-e2e.yml --color never &
|
||||
cd ..
|
||||
|
||||
scripts/update-api-types.sh
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
npm run lint
|
||||
if [[ $(git status --porcelain) ]]; then
|
||||
git status --porcelain
|
||||
echo "Failing CI because the working directory is not clean after updating the API types"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd ..
|
||||
scripts/e2e.sh 32
|
||||
89
.github/workflows/publish-docker.yml
vendored
89
.github/workflows/publish-docker.yml
vendored
|
|
@ -1,89 +0,0 @@
|
|||
name: Publish server Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
tags: ["*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: ghcr.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
publish-docker:
|
||||
runs-on: self-hosted
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# This is used to complete the identity challenge
|
||||
# with sigstore/fulcio.
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Install the cosign tool
|
||||
# https://github.com/sigstore/cosign-installer
|
||||
- name: Install cosign
|
||||
if: github.ref_type == 'tag'
|
||||
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0
|
||||
with:
|
||||
cosign-release: "v2.2.4"
|
||||
|
||||
# Set up BuildKit Docker container builder to be able to build
|
||||
# multi-platform images and export cache
|
||||
# https://github.com/docker/setup-buildx-action
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
||||
|
||||
# Login against a Docker registry
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.ref_type == 'tag'
|
||||
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Extract metadata (tags, labels) for Docker
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
# Build and push Docker image with Buildx
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
||||
with:
|
||||
context: backend
|
||||
push: ${{ github.ref_type == 'tag' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Sign the resulting Docker image digest.
|
||||
# This will only write to the public Rekor transparency log when the Docker
|
||||
# repository is public to avoid leaking data. If you would like to publish
|
||||
# transparency data even for private images, pass --force to cosign below.
|
||||
# https://github.com/sigstore/cosign
|
||||
- name: Sign the published Docker image
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
env:
|
||||
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
# This step uses the identity token to provision an ephemeral certificate
|
||||
# against the sigstore community Fulcio instance.
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
46
.github/workflows/publish-plugin.yml
vendored
46
.github/workflows/publish-plugin.yml
vendored
|
|
@ -1,46 +0,0 @@
|
|||
name: Publish Obsidian plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["*"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
publish-plugin:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version: "22.x"
|
||||
check-latest: true
|
||||
|
||||
- name: Build wasm
|
||||
run: |
|
||||
cd backend
|
||||
cargo install wasm-pack
|
||||
wasm-pack build --target web sync_lib
|
||||
|
||||
- name: Build plugin
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
cd frontend/obsidian-plugin/dist
|
||||
|
||||
gh release create "$tag" \
|
||||
--title="$tag" \
|
||||
--draft \
|
||||
main.js manifest.json styles.css
|
||||
18
.gitignore
vendored
18
.gitignore
vendored
|
|
@ -4,15 +4,21 @@ node_modules
|
|||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
|
||||
# Rust build folder
|
||||
backend/target
|
||||
|
||||
# Frontend build folders
|
||||
frontend/*/dist
|
||||
|
||||
backend/db.sqlite3*
|
||||
backend/databases
|
||||
backend/sync_server/bindings/*.ts
|
||||
# Rust build folders
|
||||
sync-server/target
|
||||
sync-server/artifacts
|
||||
sync-server/bindings/*.ts
|
||||
|
||||
# build folders
|
||||
sync-server/db.sqlite3*
|
||||
**/databases
|
||||
|
||||
*.log
|
||||
*.sqlx
|
||||
|
||||
target
|
||||
|
||||
.task
|
||||
|
|
|
|||
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
|
@ -5,5 +5,6 @@
|
|||
"**/dist": true,
|
||||
"**/node_modules": true,
|
||||
"**/.sqlx": true,
|
||||
"**/target": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
155
CLAUDE.md
Normal file
155
CLAUDE.md
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project shape
|
||||
|
||||
VaultLink is a self-hosted Obsidian file-sync system. Two halves of one repo:
|
||||
|
||||
- `sync-server/` — Rust (axum + sqlx/SQLite). Source of truth for vault state, broadcasts changes via WebSocket.
|
||||
- `frontend/` — npm workspaces. The sync engine (`sync-client`) is consumed by an Obsidian plugin, a standalone CLI, a fuzz E2E harness, a scripted determinism harness, and a history UI.
|
||||
|
||||
The HTTP/WS API types are generated from Rust (`ts-rs`) and mirrored into the TS workspaces. **Never hand-edit files in `frontend/sync-client/src/services/types/` or `frontend/history-ui/src/lib/types/`** — run `scripts/update-api-types.sh` after changing anything Serde-derived in the server.
|
||||
|
||||
### Frontend workspaces
|
||||
|
||||
- `sync-client` — the sync engine; published to consumers via `dist/`. All other TS workspaces depend on it via `file:../sync-client`.
|
||||
- `obsidian-plugin` — Obsidian plugin built from `sync-client`.
|
||||
- `local-client-cli` — same engine wrapped as a standalone CLI.
|
||||
- `history-ui` — vault-history web UI.
|
||||
- `test-client` — fuzz E2E harness (random ops across N processes).
|
||||
- `deterministic-tests` — scripted multi-client tests with an in-memory FS, run against a real server.
|
||||
|
||||
## Common commands
|
||||
|
||||
Pre-push hygiene (formats, lints, runs tests, requires clean git state):
|
||||
|
||||
```sh
|
||||
scripts/check.sh --fix
|
||||
```
|
||||
|
||||
Run the fuzz E2E (N parallel processes):
|
||||
|
||||
```sh
|
||||
scripts/e2e.sh 12
|
||||
# Logs land in logs/log_<i>.log. Clean with scripts/clean-up.sh
|
||||
```
|
||||
|
||||
Run deterministic tests (require a release-built server in `sync-server/target/release/sync_server` — they spawn it themselves):
|
||||
|
||||
```sh
|
||||
cd sync-server && cargo build --release && cd ..
|
||||
cd frontend
|
||||
npm run build -w sync-client -w deterministic-tests
|
||||
node deterministic-tests/dist/cli.js # all
|
||||
node deterministic-tests/dist/cli.js --filter=rename # subset
|
||||
node deterministic-tests/dist/cli.js --filter=… -j 4 # cap parallelism
|
||||
```
|
||||
|
||||
Run a single sync-client unit test by file:
|
||||
|
||||
```sh
|
||||
cd frontend/sync-client && npx tsx --test 'src/**/sync-event-queue.test.ts'
|
||||
```
|
||||
|
||||
Server: dev runs from `sync-server/` against `config-e2e.yml`:
|
||||
|
||||
```sh
|
||||
cd sync-server
|
||||
cargo run config-e2e.yml # dev
|
||||
cargo build --release # used by both e2e harnesses
|
||||
cargo test # unit + ts-rs binding export tests
|
||||
```
|
||||
|
||||
Frontend dev (sync-client + obsidian-plugin watch in parallel):
|
||||
|
||||
```sh
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
Regenerate TS bindings from Rust types (touches `frontend/{sync-client,history-ui}/src/.../types/`):
|
||||
|
||||
```sh
|
||||
scripts/update-api-types.sh
|
||||
```
|
||||
|
||||
## SQLite / sqlx
|
||||
|
||||
The server uses `sqlx::query!` macros that need a prepared `.sqlx` cache to compile offline. Touching any SQL means regenerating it:
|
||||
|
||||
```sh
|
||||
cd sync-server
|
||||
sqlx database create --database-url sqlite://db.sqlite3
|
||||
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
|
||||
cargo sqlx prepare --workspace
|
||||
```
|
||||
|
||||
New migrations: `sqlx migrate add --source src/app_state/database/migrations <name>`.
|
||||
|
||||
## Sync engine architecture
|
||||
|
||||
Read `frontend/sync-client/src/sync-operations/` to follow the sync engine; the rest of `sync-client` is plumbing (filesystem ops, persistence, services, telemetry).
|
||||
|
||||
The engine is **two independent loops with separate invariants**:
|
||||
|
||||
- **Wire loop** (`syncer.ts`) — drains the single-consumer FIFO queue. HTTP and WS handlers update record fields (`remoteRelativePath`, `parentVersionId`, `remoteHash`) and write content to the file at `record.localPath`. They never move files for path placement.
|
||||
- **Path reconciler** (`reconciler.ts`) — runs after every drained event. Best-effort pass that moves files to make `localPath === remoteRelativePath`. The move graph is topologically sorted; cycles are resolved by reading every file in the cycle into memory and writing each back to its new slot (no tmp files). Records with pending local events are skipped on each pass — the reconciler operates only on settled records. Failures (slot occupied by an untracked file, etc.) are silent skips; the next pass retries.
|
||||
|
||||
**`SyncEventQueue`** (`sync-event-queue.ts`) holds:
|
||||
|
||||
- `byDocId: Map<DocumentId, DocumentRecord>` — primary record store.
|
||||
- `byLocalPath: Map<RelativePath, DocumentRecord>` — derived index for path lookups, maintained at every mutation point.
|
||||
- `events: SyncEvent[]` — pending wire ops in FIFO drain order.
|
||||
|
||||
```ts
|
||||
DocumentRecord = {
|
||||
documentId,
|
||||
parentVersionId,
|
||||
remoteHash?,
|
||||
remoteRelativePath,
|
||||
localPath: RelativePath | undefined
|
||||
}
|
||||
```
|
||||
|
||||
`localPath === undefined` means the doc has no local file yet — typically a remote create whose target slot was occupied at receive time; the reconciler will fetch and place when the slot frees (the bytes wait in `pendingPlacementContent`).
|
||||
|
||||
Local FS events from the watcher update `localPath` synchronously at enqueue time via `setLocalPath` / `upsertRecord`. The wire loop never updates it for path placement; only the reconciler does. A user rename onto a tracked slot enqueues a `LocalDelete` for the displaced doc (the OS rename clobbered its content) and clears that doc's `localPath`.
|
||||
|
||||
**Pending creates** use a `Promise<DocumentId>` chain to serialize dependent ops (`LocalUpdate`, `LocalDelete`) behind the still-in-flight `LocalCreate`. `resolveCreate` resolves the promise once the server returns a docId, and `replacePendingDocumentId` swaps the resolved id across already-queued events. `findLatestCreateForPath` is the lookup the watcher uses to attach dependents; `updatePendingCreatePath` rewrites a pending create's `event.path` in place when the user renames the file before its create has acked.
|
||||
|
||||
**Watermark.** `lastSeenUpdateId` uses a `MinCovered` (a contiguous-prefix tracker over a stream of integers): we only advance the published min when the next consecutive id has been processed, so out-of-order RemoteChange ids don't fool the WebSocket handshake into requesting a too-recent catch-up.
|
||||
|
||||
**Server catch-up.** The server's WS handshake replays events newer than the client's `last_seen_vault_update_id` from the `latest_document_versions` view (one row per doc, the latest). On those replayed rows `is_new_file` means _new to this client_ (`creation_vault_update_id > last_seen_vault_update_id`), not "this row is the doc's first version" — necessary because the catch-up only carries the latest version; if a doc was created and updated past the watermark, the client never sees its create otherwise.
|
||||
|
||||
## Edge-case patterns the sync engine has to survive
|
||||
|
||||
The two-loop split defuses most of the old race catalogue (slot-collision stashes, conflict-uuid divergence, `MoveOnConflict.NEW`/`EXISTING` policy choices) by separating wire transport from path placement. What's left:
|
||||
|
||||
**Pending-create docId is a `Promise`, not a string, until the create acks.** Any `LocalUpdate` / `LocalDelete` queued behind a still-in-flight `LocalCreate` carries the create's `resolvers.promise` as its `documentId`. `replacePendingDocumentId` swaps the resolved id across queued events when the create resolves; `===` comparisons against the resolved string elsewhere will silently fail until that swap runs. Anything that walks `events[]` looking for a docId match must either run after the swap or be tolerant of `Promise`-typed ids.
|
||||
|
||||
**`processCreate` reads `event.path` live, not `event.originalPath`.** The watcher rewrites `event.path` in place via `updatePendingCreatePath` when the user renames a pending-create file. `originalPath` was removed from `LocalCreate` events specifically because reading it would send the stale pre-rename path to the server.
|
||||
|
||||
**`record.localPath` mutates in place across awaits.** When the watcher renames a doc while a drain handler is awaiting an HTTP roundtrip, the queue mutates the in-flight event's record so subsequent reads see the new path. Snapshotting `record.localPath` into a local at function entry and using it after an `await` reads/writes a now-vacated slot. Read `record.localPath` live; only snapshot for the deliberate "did it change while I was awaiting" comparison.
|
||||
|
||||
**Reconciler-defer is the wire-loop's contract with the reconciler.** The reconciler skips records where `hasPendingLocalEventsForDocumentId` returns true. Wire-loop handlers can therefore freely write `remoteRelativePath` to whatever the server returned — even if it disagrees with `localPath` — knowing the reconciler won't move the file out from under a queued user rename.
|
||||
|
||||
**Watermark advancement is load-bearing both ways.** Branches that _skip_ a remote event without advancing `lastSeenUpdateId` create permanent gaps that re-deliver forever. Branches that _advance_ without applying the content lose data: the server has no further event to re-deliver, the catch-up only carries the latest version, and any state in between is gone. Don't advance unless the event was actually applied (or deliberately discarded after weighing both halves).
|
||||
|
||||
**`isNewFile` semantics differ between catch-up and real-time.** On WS handshake replay it means _new to this client_ (`creation_vault_update_id > last_seen_vault_update_id`); on real-time broadcasts it means _this version is the create_ (`creation_vault_update_id == vault_update_id`). A handler that decides based on one interpretation will be wrong on the other channel; reasoning about fetch-and-treat-as-new vs. ignore needs to know which channel delivered the event.
|
||||
|
||||
**Pause / disable-sync mid-flight** is the one race the new model doesn't structurally fix. An HTTP that committed server-side but whose response was discarded leaves the server holding a doc the client has no record of. Resume → offline scan → server-side dedupe handles it (the server merges the duplicate create into the existing doc), but if the merge produces a deconflict, the client picks up an extra file. Out of scope for the two-loop split.
|
||||
|
||||
**Cycle reconciliation uses in-memory content swap.** When the move graph contains a cycle, the reconciler reads every file in the cycle into memory and writes each back to its new slot, with no tmp files. A write-ahead marker at `.vaultlink/swap-<uuid>.json` lists each leg; on startup the reconciler reads the marker, hashes each `from` to determine which legs ran, and replays the rest. The `.vaultlink/**` glob is hard-coded as an internal ignore pattern so swap markers don't get sync'd.
|
||||
|
||||
## Two complementary E2E harnesses
|
||||
|
||||
- **`test-client` (fuzz):** random ops across N parallel processes for many minutes. Used by `scripts/e2e.sh`. Catches bugs nobody thought to write a test for, but reproductions are noisy.
|
||||
- **`deterministic-tests`:** scripted scenarios with an in-memory FS pinned to a real server. Used to _capture_ a fuzz-discovered bug as a minimal repro before fixing it. See `frontend/deterministic-tests/README.md` for the step grammar (`pause-server`, `pause-websocket`, `barrier`, `assert-consistent`, etc.).
|
||||
|
||||
When a fuzz failure surfaces, the workflow is: root-cause from logs → write a deterministic test that fails on the bug → fix → confirm both the deterministic test and `e2e.sh` pass.
|
||||
|
||||
## Style
|
||||
|
||||
- TS: 4-space indent, no tabs, LF, prettier (`trailingComma: "none"`). YAML/MD use 2-space indent.
|
||||
- Rust: `rustfmt.toml` enforces 4-space spaces, LF.
|
||||
- Lint: ESLint for TS, Clippy for Rust, `cargo machete` for unused deps. All wired into `scripts/check.sh`.
|
||||
42
README.md
42
README.md
|
|
@ -2,25 +2,24 @@
|
|||
|
||||
[](https://github.com/schmelczer/vault-link/actions/workflows/check.yml)
|
||||
[](https://github.com/schmelczer/vault-link/actions/workflows/e2e.yml)
|
||||
[](https://github.com/schmelczer/vault-link/actions/workflows/publish-docker.yml)
|
||||
[](https://github.com/schmelczer/vault-link/actions/workflows/publish-server-docker.yml)
|
||||
[](https://github.com/schmelczer/vault-link/actions/workflows/publish-cli-docker.yml)
|
||||
[](https://github.com/schmelczer/vault-link/actions/workflows/publish-plugin.yml)
|
||||
|
||||
|
||||
## Develop
|
||||
|
||||
### Install [nvm](https://github.com/nvm-sh/nvm)
|
||||
### Set up Node.JS 25 with [nvm](https://github.com/nvm-sh/nvm)
|
||||
|
||||
- `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash`
|
||||
- `nvm install 22`
|
||||
- `nvm use 22`
|
||||
- Optionally set the system-wide default: `nvm alias default 22`
|
||||
|
||||
- `nvm install 25`
|
||||
- `nvm use 25`
|
||||
- Optionally, set the system-wide default: `nvm alias default 25`
|
||||
|
||||
### Set up Rust
|
||||
|
||||
- Install [`rustup`](https://rustup.rs): `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`
|
||||
- Install [`wasm-pack`](https://rustwasm.github.io/wasm-pack/installer): `curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh`
|
||||
- `cargo install cargo-insta sqlx-cli cargo-edit`
|
||||
- `cargo install cargo-insta sqlx-cli`
|
||||
|
||||
### Install Obsidian on Linux
|
||||
|
||||
|
|
@ -31,8 +30,30 @@ flatpak install flathub md.obsidian.Obsidian
|
|||
flatpak run md.obsidian.Obsidian
|
||||
```
|
||||
|
||||
#### Run in development mode
|
||||
|
||||
Start the server:
|
||||
|
||||
```sh
|
||||
cargo install sqlx-cli
|
||||
cd sync-server
|
||||
cargo run config-e2e.yml
|
||||
```
|
||||
|
||||
```sh
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Scripts
|
||||
|
||||
#### Before pushing
|
||||
|
||||
```sh
|
||||
scripts/check.sh --fix
|
||||
```
|
||||
|
||||
#### Update HTTP API TS bindings
|
||||
|
||||
```sh
|
||||
|
|
@ -45,15 +66,14 @@ scripts/update-api-types.sh
|
|||
scripts/bump-version.sh patch
|
||||
```
|
||||
|
||||
|
||||
#### Run E2E tests
|
||||
|
||||
```sh
|
||||
scripts/e2e.sh
|
||||
scripts/e2e.sh 8
|
||||
```
|
||||
|
||||
And to clean up the logs & database files, run `scripts/clean-up.sh`
|
||||
|
||||
## Projects
|
||||
|
||||
- [Sync server](./backend/sync_server/README.md)
|
||||
- [Sync server](./sync-server/README.md)
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
FROM rust:1.87 AS builder
|
||||
|
||||
WORKDIR /usr/src/backend
|
||||
|
||||
RUN apt update && apt install -y musl-tools
|
||||
RUN cargo install sqlx-cli
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN sqlx database create --database-url sqlite://db.sqlite3
|
||||
RUN sqlx migrate run --source sync_server/src/app_state/database/migrations --database-url sqlite://db.sqlite3
|
||||
|
||||
RUN cargo build --package sync_server --release --target x86_64-unknown-linux-musl
|
||||
|
||||
# Runtime image
|
||||
FROM alpine:3.21.3
|
||||
|
||||
LABEL org.opencontainers.image.authors="andras@schmelczer.dev"
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
COPY --from=builder /usr/src/backend/target/x86_64-unknown-linux-musl/release/sync_server /app/sync_server
|
||||
|
||||
VOLUME /data
|
||||
EXPOSE 3000/tcp
|
||||
WORKDIR /data
|
||||
|
||||
HEALTHCHECK \
|
||||
--interval=30s \
|
||||
--timeout=5s \
|
||||
CMD curl -f http://localhost:3000/vaults/fake/ping || exit 1
|
||||
|
||||
ENTRYPOINT ["/app/sync_server"]
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
[package]
|
||||
name = "reconcile"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0.219", optional = true, features = ["derive"] }
|
||||
|
||||
[features]
|
||||
serde = [ "dep:serde" ]
|
||||
|
||||
[dev-dependencies]
|
||||
insta = "1.42.2"
|
||||
pretty_assertions = "1.4.1"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_yaml ="0.9.34"
|
||||
test-case = "3.3.1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
pub mod myers;
|
||||
pub mod raw_operation;
|
||||
|
|
@ -1,357 +0,0 @@
|
|||
//! Taken from <https://github.com/mitsuhiko/similar/blob/7e15c44de11a1cd61e1149189929e189ef977fd8/src/algorithms/myers.rs>
|
||||
//!
|
||||
//! Myers' diff algorithm.
|
||||
//!
|
||||
//! * time: `O((N+M)D)`
|
||||
//! * space `O(N+M)`
|
||||
//!
|
||||
//! See [the original article by Eugene W. Myers](http://www.xmailserver.org/diff2.pdf)
|
||||
//! describing it.
|
||||
//!
|
||||
//! The implementation of this algorithm is based on the implementation by
|
||||
//! Brandon Williams.
|
||||
//!
|
||||
//! # Heuristics
|
||||
//!
|
||||
//! At present this implementation of Myers' does not implement any more
|
||||
//! advanced heuristics that would solve some pathological cases. For instance
|
||||
//! passing two large and completely distinct sequences to the algorithm will
|
||||
//! make it spin without making reasonable progress.
|
||||
//! For potential improvements here see [similar#15](https://github.com/mitsuhiko/similar/issues/15).
|
||||
|
||||
use std::{
|
||||
ops::{Index, IndexMut, Range},
|
||||
vec,
|
||||
};
|
||||
|
||||
use super::raw_operation::RawOperation;
|
||||
use crate::{
|
||||
tokenizer::token::Token,
|
||||
utils::{common_prefix_len::common_prefix_len, common_suffix_len::common_suffix_len},
|
||||
};
|
||||
|
||||
/// Myers' diff algorithm with deadline.
|
||||
///
|
||||
/// Diff `old`, between indices `old_range` and `new` between indices
|
||||
/// `new_range`.
|
||||
///
|
||||
/// The returned `RawOperations` all have a token count of 1.
|
||||
pub fn diff<T>(old: &[Token<T>], new: &[Token<T>]) -> Vec<RawOperation<T>>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
{
|
||||
let max_d = (old.len() + new.len()).div_ceil(2) + 1;
|
||||
let mut vb = V::new(max_d);
|
||||
let mut vf = V::new(max_d);
|
||||
let mut result: Vec<RawOperation<T>> = vec![];
|
||||
|
||||
conquer(
|
||||
old,
|
||||
0..old.len(),
|
||||
new,
|
||||
0..new.len(),
|
||||
&mut vf,
|
||||
&mut vb,
|
||||
&mut result,
|
||||
);
|
||||
|
||||
debug_assert!(
|
||||
result.iter().all(|op| op.tokens().len() == 1),
|
||||
"All operations should be of length 1"
|
||||
);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// A D-path is a path which starts at (0,0) that has exactly D non-diagonal
|
||||
// edges. All D-paths consist of a (D - 1)-path followed by a non-diagonal edge
|
||||
// and then a possibly empty sequence of diagonal edges called a snake.
|
||||
|
||||
/// `V` contains the endpoints of the furthest reaching `D-paths`. For each
|
||||
/// recorded endpoint `(x,y)` in diagonal `k`, we only need to retain `x`
|
||||
/// because `y` can be computed from `x - k`. In other words, `V` is an array of
|
||||
/// integers where `V[k]` contains the row index of the endpoint of the furthest
|
||||
/// reaching path in diagonal `k`.
|
||||
///
|
||||
/// We can't use a traditional Vec to represent `V` since we use `k` as an index
|
||||
/// and it can take on negative values. So instead `V` is represented as a
|
||||
/// light-weight wrapper around a Vec plus an `offset` which is the maximum
|
||||
/// value `k` can take on in order to map negative `k`'s back to a value >= 0.
|
||||
#[derive(Debug)]
|
||||
struct V {
|
||||
offset: isize,
|
||||
v: Vec<usize>, // Look into initializing this to -1 and storing isize
|
||||
}
|
||||
|
||||
impl V {
|
||||
fn new(max_d: usize) -> Self {
|
||||
Self {
|
||||
offset: max_d as isize,
|
||||
v: vec![0; 2 * max_d],
|
||||
}
|
||||
}
|
||||
|
||||
fn len(&self) -> usize { self.v.len() }
|
||||
}
|
||||
|
||||
impl Index<isize> for V {
|
||||
type Output = usize;
|
||||
|
||||
fn index(&self, index: isize) -> &Self::Output { &self.v[(index + self.offset) as usize] }
|
||||
}
|
||||
|
||||
impl IndexMut<isize> for V {
|
||||
fn index_mut(&mut self, index: isize) -> &mut Self::Output {
|
||||
&mut self.v[(index + self.offset) as usize]
|
||||
}
|
||||
}
|
||||
|
||||
fn split_at(range: Range<usize>, at: usize) -> (Range<usize>, Range<usize>) {
|
||||
(range.start..at, at..range.end)
|
||||
}
|
||||
|
||||
/// A `Snake` is a sequence of diagonal edges in the edit graph. Normally
|
||||
/// a snake has a start end end point (and it is possible for a snake to have
|
||||
/// a length of zero, meaning the start and end points are the same) however
|
||||
/// we do not need the end point which is why it's not implemented here.
|
||||
///
|
||||
/// The divide part of a divide-and-conquer strategy. A D-path has D+1 snakes
|
||||
/// some of which may be empty. The divide step requires finding the ceil(D/2) +
|
||||
/// 1 or middle snake of an optimal D-path. The idea for doing so is to
|
||||
/// simultaneously run the basic algorithm in both the forward and reverse
|
||||
/// directions until furthest reaching forward and reverse paths starting at
|
||||
/// opposing corners 'overlap'.
|
||||
fn find_middle_snake<T>(
|
||||
old: &[Token<T>],
|
||||
old_range: Range<usize>,
|
||||
new: &[Token<T>],
|
||||
new_range: Range<usize>,
|
||||
vf: &mut V,
|
||||
vb: &mut V,
|
||||
) -> Option<(usize, usize)>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
{
|
||||
let n = old_range.len();
|
||||
let m = new_range.len();
|
||||
|
||||
// By Lemma 1 in the paper, the optimal edit script length is odd or even as
|
||||
// `delta` is odd or even.
|
||||
let delta = n as isize - m as isize;
|
||||
let odd = delta & 1 == 1;
|
||||
|
||||
// The initial point at (0, -1)
|
||||
vf[1] = 0;
|
||||
// The initial point at (N, M+1)
|
||||
vb[1] = 0;
|
||||
|
||||
let d_max = (n + m).div_ceil(2) + 1;
|
||||
assert!(vf.len() >= d_max);
|
||||
assert!(vb.len() >= d_max);
|
||||
|
||||
for d in 0..d_max as isize {
|
||||
// Forward path
|
||||
for k in (-d..=d).rev().step_by(2) {
|
||||
let mut x = if k == -d || (k != d && vf[k - 1] < vf[k + 1]) {
|
||||
vf[k + 1]
|
||||
} else {
|
||||
vf[k - 1] + 1
|
||||
};
|
||||
let y = (x as isize - k) as usize;
|
||||
|
||||
// The coordinate of the start of a snake
|
||||
let (x0, y0) = (x, y);
|
||||
// While these sequences are identical, keep moving through the
|
||||
// graph with no cost
|
||||
if x < old_range.len() && y < new_range.len() {
|
||||
let advance = common_prefix_len(
|
||||
old,
|
||||
old_range.start + x..old_range.end,
|
||||
new,
|
||||
new_range.start + y..new_range.end,
|
||||
);
|
||||
x += advance;
|
||||
}
|
||||
|
||||
// This is the new best x value
|
||||
vf[k] = x;
|
||||
|
||||
// Only check for connections from the forward search when N - M is
|
||||
// odd and when there is a reciprocal k line coming from the other
|
||||
// direction.
|
||||
if odd && (k - delta).abs() <= (d - 1) {
|
||||
// TODO optimize this so we don't have to compare against n
|
||||
if vf[k] + vb[-(k - delta)] >= n {
|
||||
// Return the snake
|
||||
return Some((x0 + old_range.start, y0 + new_range.start));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backward path
|
||||
for k in (-d..=d).rev().step_by(2) {
|
||||
let mut x = if k == -d || (k != d && vb[k - 1] < vb[k + 1]) {
|
||||
vb[k + 1]
|
||||
} else {
|
||||
vb[k - 1] + 1
|
||||
};
|
||||
let mut y = (x as isize - k) as usize;
|
||||
|
||||
// The coordinate of the start of a snake
|
||||
if x < n && y < m {
|
||||
let advance = common_suffix_len(
|
||||
old,
|
||||
old_range.start..old_range.start + n - x,
|
||||
new,
|
||||
new_range.start..new_range.start + m - y,
|
||||
);
|
||||
x += advance;
|
||||
y += advance;
|
||||
}
|
||||
|
||||
// This is the new best x value
|
||||
vb[k] = x;
|
||||
|
||||
if !odd && (k - delta).abs() <= d {
|
||||
// TODO optimize this so we don't have to compare against n
|
||||
if vb[k] + vf[-(k - delta)] >= n {
|
||||
// Return the snake
|
||||
return Some((n - x + old_range.start, m - y + new_range.start));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Maybe there's an opportunity to optimize and bail early?
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn conquer<T>(
|
||||
old: &[Token<T>],
|
||||
mut old_range: Range<usize>,
|
||||
new: &[Token<T>],
|
||||
mut new_range: Range<usize>,
|
||||
vf: &mut V,
|
||||
vb: &mut V,
|
||||
result: &mut Vec<RawOperation<T>>,
|
||||
) where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
{
|
||||
// Check for common prefix
|
||||
let common_prefix_len = common_prefix_len(old, old_range.clone(), new, new_range.clone());
|
||||
if common_prefix_len > 0 {
|
||||
result.extend(
|
||||
old[old_range.start..old_range.start + common_prefix_len]
|
||||
.iter()
|
||||
.map(|token| RawOperation::Equal(vec![token.clone()])),
|
||||
);
|
||||
}
|
||||
old_range.start += common_prefix_len;
|
||||
new_range.start += common_prefix_len;
|
||||
|
||||
// Check for common suffix
|
||||
let common_suffix_len = common_suffix_len(old, old_range.clone(), new, new_range.clone());
|
||||
let common_suffix = (
|
||||
old_range.end - common_suffix_len,
|
||||
new_range.end - common_suffix_len,
|
||||
);
|
||||
old_range.end -= common_suffix_len;
|
||||
new_range.end -= common_suffix_len;
|
||||
|
||||
if old_range.is_empty() && new_range.is_empty() {
|
||||
// do nothing
|
||||
} else if new_range.is_empty() {
|
||||
result.extend(
|
||||
old[old_range.start..old_range.start + old_range.len()]
|
||||
.iter()
|
||||
.map(|token| RawOperation::Delete(vec![token.clone()])),
|
||||
);
|
||||
} else if old_range.is_empty() {
|
||||
result.extend(
|
||||
new[new_range.start..new_range.start + new_range.len()]
|
||||
.iter()
|
||||
.map(|token| RawOperation::Insert(vec![token.clone()])),
|
||||
);
|
||||
} else if let Some((x_start, y_start)) =
|
||||
find_middle_snake(old, old_range.clone(), new, new_range.clone(), vf, vb)
|
||||
{
|
||||
let (old_a, old_b) = split_at(old_range, x_start);
|
||||
let (new_a, new_b) = split_at(new_range, y_start);
|
||||
conquer(old, old_a, new, new_a, vf, vb, result);
|
||||
conquer(old, old_b, new, new_b, vf, vb, result);
|
||||
} else {
|
||||
result.extend(
|
||||
old[old_range.start..old_range.end]
|
||||
.iter()
|
||||
.map(|token| RawOperation::Delete(vec![token.clone()])),
|
||||
);
|
||||
result.extend(
|
||||
new[new_range.start..new_range.end]
|
||||
.iter()
|
||||
.map(|token| RawOperation::Insert(vec![token.clone()])),
|
||||
);
|
||||
}
|
||||
|
||||
if common_suffix_len > 0 {
|
||||
result.extend(
|
||||
old[common_suffix.0..common_suffix.0 + common_suffix_len]
|
||||
.iter()
|
||||
.map(|token| RawOperation::Equal(vec![token.clone()])),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use insta::assert_debug_snapshot;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_empty_diff() {
|
||||
let old: Vec<Token<String>> = vec![];
|
||||
let new: Vec<Token<String>> = vec![];
|
||||
let result = diff(&old, &new);
|
||||
assert_eq!(result.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_identical_content() {
|
||||
let content = vec!["a".into(), "b".into(), "c".into()];
|
||||
let result = diff(&content, &content);
|
||||
assert_debug_snapshot!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_only() {
|
||||
let old: Vec<Token<String>> = vec![];
|
||||
let new: Vec<Token<String>> = vec!["a".into(), "b".into()];
|
||||
let result = diff(&old, &new);
|
||||
assert_debug_snapshot!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_only() {
|
||||
let old = vec!["a".into(), "b".into()];
|
||||
let new: Vec<Token<String>> = vec![];
|
||||
let result = diff(&old, &new);
|
||||
assert_debug_snapshot!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prefix_and_suffix() {
|
||||
let old = vec!["a".into(), "b".into(), "c".into(), "d".into()];
|
||||
let new = vec!["a".into(), "x".into(), "d".into()];
|
||||
let result = diff(&old, &new);
|
||||
assert_debug_snapshot!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complex_diff() {
|
||||
let old = vec!["a".into(), "b".into(), "c".into(), "d".into()];
|
||||
let new = vec!["a".into(), "x".into(), "c".into(), "y".into()];
|
||||
let result = diff(&old, &new);
|
||||
assert_debug_snapshot!(result);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
use crate::tokenizer::token::Token;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum RawOperation<T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
{
|
||||
Insert(Vec<Token<T>>),
|
||||
Delete(Vec<Token<T>>),
|
||||
Equal(Vec<Token<T>>),
|
||||
}
|
||||
|
||||
impl<T> RawOperation<T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
{
|
||||
pub fn tokens(&self) -> &Vec<Token<T>> {
|
||||
match self {
|
||||
RawOperation::Insert(tokens)
|
||||
| RawOperation::Delete(tokens)
|
||||
| RawOperation::Equal(tokens) => tokens,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn original_text_length(&self) -> usize {
|
||||
self.tokens().iter().map(Token::get_original_length).sum()
|
||||
}
|
||||
|
||||
pub fn get_original_text(self) -> String { self.tokens().iter().map(Token::original).collect() }
|
||||
|
||||
pub fn is_left_joinable(&self) -> bool {
|
||||
let first_token = self.tokens().first();
|
||||
first_token.is_none_or(super::super::tokenizer::token::Token::get_is_left_joinable)
|
||||
}
|
||||
|
||||
pub fn is_right_joinable(&self) -> bool {
|
||||
let last_token = self.tokens().last();
|
||||
last_token.is_none_or(super::super::tokenizer::token::Token::get_is_right_joinable)
|
||||
}
|
||||
|
||||
/// Extends the operation with another operation. Only operations of the
|
||||
/// same type as self can be used to extend self, otherwise the function
|
||||
/// will panic.
|
||||
pub fn extend(self, other: RawOperation<T>) -> RawOperation<T> {
|
||||
debug_assert!(
|
||||
std::mem::discriminant(&self) == std::mem::discriminant(&other),
|
||||
"Cannot extend operations of different types. This should have been handled before \
|
||||
calling this function."
|
||||
);
|
||||
|
||||
match (self, other) {
|
||||
(RawOperation::Insert(tokens1), RawOperation::Insert(tokens2)) => {
|
||||
RawOperation::Insert(tokens1.into_iter().chain(tokens2).collect())
|
||||
}
|
||||
(RawOperation::Delete(tokens1), RawOperation::Delete(tokens2)) => {
|
||||
RawOperation::Delete(tokens1.into_iter().chain(tokens2).collect())
|
||||
}
|
||||
(RawOperation::Equal(tokens1), RawOperation::Equal(tokens2)) => {
|
||||
RawOperation::Equal(tokens1.into_iter().chain(tokens2).collect())
|
||||
}
|
||||
_ => unreachable!("Only operations of the same type can be extended"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
---
|
||||
source: reconcile/src/diffs/myers.rs
|
||||
expression: result
|
||||
snapshot_kind: text
|
||||
---
|
||||
[
|
||||
Equal(
|
||||
[
|
||||
Token {
|
||||
normalised: "a",
|
||||
original: "a",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
],
|
||||
),
|
||||
Insert(
|
||||
[
|
||||
Token {
|
||||
normalised: "x",
|
||||
original: "x",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
],
|
||||
),
|
||||
Delete(
|
||||
[
|
||||
Token {
|
||||
normalised: "b",
|
||||
original: "b",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
],
|
||||
),
|
||||
Equal(
|
||||
[
|
||||
Token {
|
||||
normalised: "c",
|
||||
original: "c",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
],
|
||||
),
|
||||
Insert(
|
||||
[
|
||||
Token {
|
||||
normalised: "y",
|
||||
original: "y",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
],
|
||||
),
|
||||
Delete(
|
||||
[
|
||||
Token {
|
||||
normalised: "d",
|
||||
original: "d",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
---
|
||||
source: reconcile/src/diffs/myers.rs
|
||||
expression: result
|
||||
snapshot_kind: text
|
||||
---
|
||||
[
|
||||
Delete(
|
||||
[
|
||||
Token {
|
||||
normalised: "a",
|
||||
original: "a",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
],
|
||||
),
|
||||
Delete(
|
||||
[
|
||||
Token {
|
||||
normalised: "b",
|
||||
original: "b",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
---
|
||||
source: reconcile/src/diffs/myers.rs
|
||||
expression: result
|
||||
snapshot_kind: text
|
||||
---
|
||||
[
|
||||
Equal(
|
||||
[
|
||||
Token {
|
||||
normalised: "a",
|
||||
original: "a",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
],
|
||||
),
|
||||
Equal(
|
||||
[
|
||||
Token {
|
||||
normalised: "b",
|
||||
original: "b",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
],
|
||||
),
|
||||
Equal(
|
||||
[
|
||||
Token {
|
||||
normalised: "c",
|
||||
original: "c",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
---
|
||||
source: reconcile/src/diffs/myers.rs
|
||||
expression: result
|
||||
snapshot_kind: text
|
||||
---
|
||||
[
|
||||
Insert(
|
||||
[
|
||||
Token {
|
||||
normalised: "a",
|
||||
original: "a",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
],
|
||||
),
|
||||
Insert(
|
||||
[
|
||||
Token {
|
||||
normalised: "b",
|
||||
original: "b",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
---
|
||||
source: reconcile/src/diffs/myers.rs
|
||||
expression: result
|
||||
snapshot_kind: text
|
||||
---
|
||||
[
|
||||
Equal(
|
||||
[
|
||||
Token {
|
||||
normalised: "a",
|
||||
original: "a",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
],
|
||||
),
|
||||
Delete(
|
||||
[
|
||||
Token {
|
||||
normalised: "b",
|
||||
original: "b",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
],
|
||||
),
|
||||
Delete(
|
||||
[
|
||||
Token {
|
||||
normalised: "c",
|
||||
original: "c",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
],
|
||||
),
|
||||
Insert(
|
||||
[
|
||||
Token {
|
||||
normalised: "x",
|
||||
original: "x",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
],
|
||||
),
|
||||
Equal(
|
||||
[
|
||||
Token {
|
||||
normalised: "d",
|
||||
original: "d",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
mod diffs;
|
||||
mod operation_transformation;
|
||||
mod tokenizer;
|
||||
mod utils;
|
||||
|
||||
pub use operation_transformation::{
|
||||
CursorPosition, EditedText, TextWithCursors, reconcile, reconcile_with_cursors,
|
||||
reconcile_with_tokenizer,
|
||||
};
|
||||
pub use tokenizer::{Tokenizer, token::Token};
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
mod cursor;
|
||||
mod edited_text;
|
||||
mod merge_context;
|
||||
mod operation;
|
||||
mod ordered_operation;
|
||||
|
||||
pub use cursor::{CursorPosition, TextWithCursors};
|
||||
pub use edited_text::EditedText;
|
||||
pub use operation::Operation;
|
||||
|
||||
use crate::Tokenizer;
|
||||
|
||||
#[must_use]
|
||||
pub fn reconcile(original: &str, left: &str, right: &str) -> String {
|
||||
reconcile_with_cursors(original, left.into(), right.into())
|
||||
.text
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn reconcile_with_cursors<'a>(
|
||||
original: &'a str,
|
||||
left: TextWithCursors<'a>,
|
||||
right: TextWithCursors<'a>,
|
||||
) -> TextWithCursors<'static> {
|
||||
let left_operations = EditedText::from_strings(original, left);
|
||||
let right_operations = EditedText::from_strings(original, right);
|
||||
|
||||
let merged_operations = left_operations.merge(right_operations);
|
||||
|
||||
TextWithCursors::new_owned(merged_operations.apply(), merged_operations.cursors)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn reconcile_with_tokenizer<'a, F, T>(
|
||||
original: &str,
|
||||
left: TextWithCursors<'a>,
|
||||
right: TextWithCursors<'a>,
|
||||
tokenizer: &Tokenizer<T>,
|
||||
) -> TextWithCursors<'static>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
{
|
||||
let left_operations = EditedText::from_strings_with_tokenizer(original, left, tokenizer);
|
||||
let right_operations = EditedText::from_strings_with_tokenizer(original, right, tokenizer);
|
||||
|
||||
let merged_operations = left_operations.merge(right_operations);
|
||||
|
||||
TextWithCursors::new_owned(merged_operations.apply(), merged_operations.cursors)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use std::{fs, ops::Range, path::Path};
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use test_case::test_matrix;
|
||||
|
||||
use super::*;
|
||||
use crate::CursorPosition;
|
||||
|
||||
#[test]
|
||||
fn test_cursor_complex() {
|
||||
let original = "this is some complex text to test cursor positions";
|
||||
let left = TextWithCursors::new(
|
||||
"this is really complex text for testing cursor positions",
|
||||
vec![
|
||||
CursorPosition {
|
||||
id: 0,
|
||||
char_index: 8,
|
||||
}, // after "this is "
|
||||
CursorPosition {
|
||||
id: 1,
|
||||
char_index: 22,
|
||||
}, // after "this is really complex text"
|
||||
],
|
||||
);
|
||||
let right = TextWithCursors::new(
|
||||
"that was some complex sample to test cursor movements",
|
||||
vec![
|
||||
CursorPosition {
|
||||
id: 2,
|
||||
char_index: 5,
|
||||
}, // after "that "
|
||||
CursorPosition {
|
||||
id: 3,
|
||||
char_index: 29,
|
||||
}, // after "some complex sample "
|
||||
],
|
||||
);
|
||||
|
||||
let merged = reconcile_with_cursors(original, left, right);
|
||||
|
||||
assert_eq!(
|
||||
merged,
|
||||
TextWithCursors::new(
|
||||
"that was really complex sample for testing cursor movements",
|
||||
vec![
|
||||
CursorPosition {
|
||||
id: 2,
|
||||
char_index: 5
|
||||
}, // unchanged
|
||||
CursorPosition {
|
||||
id: 0,
|
||||
char_index: 9
|
||||
}, // before "really"
|
||||
CursorPosition {
|
||||
id: 1,
|
||||
char_index: 23
|
||||
}, // inside of "s|ample" because "text" got replaced by "sample"
|
||||
CursorPosition {
|
||||
id: 3,
|
||||
char_index: 43
|
||||
}, // before "cursor movements"
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "expensive to run, only run in CI"]
|
||||
#[test_matrix( [
|
||||
"pride_and_prejudice.txt",
|
||||
"room_with_a_view.txt",
|
||||
"kun_lu.txt",
|
||||
"blns.txt"
|
||||
], [
|
||||
"pride_and_prejudice.txt",
|
||||
"room_with_a_view.txt",
|
||||
"kun_lu.txt",
|
||||
"blns.txt"
|
||||
], [
|
||||
"pride_and_prejudice.txt",
|
||||
"room_with_a_view.txt",
|
||||
"kun_lu.txt",
|
||||
"blns.txt"
|
||||
], [0..10000, 10000..20000], [0..10000, 10000..20000], [0..10000, 10000..20000])]
|
||||
fn test_merge_files_without_panic(
|
||||
file_name_1: &str,
|
||||
file_name_2: &str,
|
||||
file_name_3: &str,
|
||||
range_1: Range<usize>,
|
||||
range_2: Range<usize>,
|
||||
range_3: Range<usize>,
|
||||
) {
|
||||
let files = [file_name_1, file_name_2, file_name_3];
|
||||
let permutations = [range_1, range_2, range_3];
|
||||
|
||||
let root = Path::new("tests/resources/");
|
||||
|
||||
let contents = files
|
||||
.iter()
|
||||
.zip(permutations.iter())
|
||||
.map(|(file, range)| {
|
||||
let path = root.join(file);
|
||||
fs::read_to_string(&path)
|
||||
.unwrap()
|
||||
.chars()
|
||||
.skip(range.start)
|
||||
.take(range.end)
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let _ = reconcile(&contents[0], &contents[1], &contents[2]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// CursorPosition represents the position of an identifiable cursor in a text
|
||||
// document based on its (UTF-8) character index.
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct CursorPosition {
|
||||
pub id: usize,
|
||||
pub char_index: usize,
|
||||
}
|
||||
|
||||
impl CursorPosition {
|
||||
#[must_use]
|
||||
pub fn with_index(&self, index: usize) -> Self {
|
||||
CursorPosition {
|
||||
id: self.id,
|
||||
char_index: index,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct TextWithCursors<'a> {
|
||||
pub text: Cow<'a, str>,
|
||||
pub cursors: Vec<CursorPosition>,
|
||||
}
|
||||
|
||||
impl<'a> TextWithCursors<'a> {
|
||||
#[must_use]
|
||||
pub fn new(text: &'a str, cursors: Vec<CursorPosition>) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
cursors,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new_owned(text: String, cursors: Vec<CursorPosition>) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
cursors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a str> for TextWithCursors<'a> {
|
||||
fn from(text: &'a str) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
cursors: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,381 +0,0 @@
|
|||
use core::iter;
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{CursorPosition, Operation, TextWithCursors, ordered_operation::OrderedOperation};
|
||||
use crate::{
|
||||
diffs::{myers::diff, raw_operation::RawOperation},
|
||||
operation_transformation::merge_context::MergeContext,
|
||||
tokenizer::{Tokenizer, word_tokenizer::word_tokenizer},
|
||||
utils::{merge_iters::MergeSorted as _, side::Side, string_builder::StringBuilder},
|
||||
};
|
||||
|
||||
/// A sequence of operations that can be applied to a text document.
|
||||
/// `EditedText` supports merging two sequences of operations using the
|
||||
/// principle of Operational Transformation.
|
||||
///
|
||||
/// It's mainly created through the `from_strings` method, then merged with
|
||||
/// another `EditedText` derived from the same original text and then applied to
|
||||
/// the original text to get the reconciled text of concurrent edits.
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct EditedText<'a, T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
{
|
||||
text: &'a str,
|
||||
operations: Vec<OrderedOperation<T>>,
|
||||
pub(crate) cursors: Vec<CursorPosition>,
|
||||
}
|
||||
|
||||
impl<'a> EditedText<'a, String> {
|
||||
/// Create an `EditedText` from the given original (old) and updated (new)
|
||||
/// strings. The returned `EditedText` represents the changes from the
|
||||
/// original to the updated text. When the return value is applied to
|
||||
/// the original text, it will result in the updated text. The default
|
||||
/// word tokenizer is used to tokenize the text which splits the text on
|
||||
/// whitespaces.
|
||||
#[must_use]
|
||||
pub fn from_strings(original: &'a str, updated: TextWithCursors<'a>) -> Self {
|
||||
Self::from_strings_with_tokenizer(original, updated, &word_tokenizer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T> EditedText<'a, T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
{
|
||||
/// Create an `EditedText` from the given original (old) and updated (new)
|
||||
/// strings. The returned `EditedText` represents the changes from the
|
||||
/// original to the updated text. When the return value is applied to
|
||||
/// the original text, it will result in the updated text. The tokenizer
|
||||
/// function is used to tokenize the text.
|
||||
pub fn from_strings_with_tokenizer(
|
||||
original: &'a str,
|
||||
updated: TextWithCursors<'a>,
|
||||
tokenizer: &Tokenizer<T>,
|
||||
) -> Self {
|
||||
let original_tokens = (tokenizer)(original);
|
||||
let updated_tokens = (tokenizer)(&updated.text);
|
||||
|
||||
let diff: Vec<RawOperation<T>> = diff(&original_tokens, &updated_tokens);
|
||||
|
||||
Self::new(
|
||||
original,
|
||||
Self::cook_operations(Self::elongate_operations(diff)).collect(),
|
||||
updated.cursors,
|
||||
)
|
||||
}
|
||||
|
||||
fn elongate_operations<I>(raw_operations: I) -> Vec<RawOperation<T>>
|
||||
where
|
||||
I: IntoIterator<Item = RawOperation<T>>,
|
||||
{
|
||||
// This might look bad, but this makes sense. The inserts and deltes can be
|
||||
// interleaved, such as: IDIDID and we need to turn this into IIIDDD.
|
||||
// So we need to keep track of both the last insert and delete operations, not
|
||||
// just the last one.
|
||||
let mut maybe_previous_insert: Option<RawOperation<T>> = None;
|
||||
let mut maybe_previous_delete: Option<RawOperation<T>> = None;
|
||||
|
||||
let mut result: Vec<RawOperation<T>> = raw_operations
|
||||
.into_iter()
|
||||
.flat_map(|next| match next {
|
||||
RawOperation::Insert(..) => match maybe_previous_insert.take() {
|
||||
Some(prev) if prev.is_right_joinable() && next.is_left_joinable() => {
|
||||
maybe_previous_insert = Some(prev.extend(next));
|
||||
Box::new(iter::empty()) as Box<dyn Iterator<Item = RawOperation<T>>>
|
||||
}
|
||||
prev => {
|
||||
maybe_previous_insert = Some(next);
|
||||
Box::new(prev.into_iter())
|
||||
}
|
||||
},
|
||||
RawOperation::Delete(..) => match maybe_previous_delete.take() {
|
||||
Some(prev) if prev.is_right_joinable() && next.is_left_joinable() => {
|
||||
maybe_previous_delete = Some(prev.extend(next));
|
||||
Box::new(iter::empty()) as Box<dyn Iterator<Item = RawOperation<T>>>
|
||||
}
|
||||
prev => {
|
||||
maybe_previous_delete = Some(next);
|
||||
Box::new(prev.into_iter())
|
||||
}
|
||||
},
|
||||
RawOperation::Equal(..) => Box::new(
|
||||
maybe_previous_insert
|
||||
.take()
|
||||
.into_iter()
|
||||
.chain(maybe_previous_delete.take())
|
||||
.chain(iter::once(next)),
|
||||
)
|
||||
as Box<dyn Iterator<Item = RawOperation<T>>>,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if let Some(prev) = maybe_previous_insert {
|
||||
result.push(prev);
|
||||
}
|
||||
|
||||
if let Some(prev) = maybe_previous_delete {
|
||||
result.push(prev);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// Turn raw operations into ordered operations while keeping track of old & new
|
||||
// indexes.
|
||||
fn cook_operations<I>(raw_operations: I) -> impl Iterator<Item = OrderedOperation<T>>
|
||||
where
|
||||
I: IntoIterator<Item = RawOperation<T>>,
|
||||
{
|
||||
let mut new_index = 0; // this is the start index of the operation on the new text
|
||||
let mut order = 0; // this is the start index of the operation on the original text
|
||||
|
||||
raw_operations.into_iter().filter_map(move |raw_operation| {
|
||||
let length = raw_operation.original_text_length();
|
||||
|
||||
match raw_operation {
|
||||
RawOperation::Equal(..) => {
|
||||
let op = if cfg!(debug_assertions) {
|
||||
Operation::create_equal_with_text(
|
||||
new_index,
|
||||
raw_operation.get_original_text(),
|
||||
)
|
||||
} else {
|
||||
Operation::create_equal(new_index, length)
|
||||
}
|
||||
.map(|operation| OrderedOperation { order, operation });
|
||||
|
||||
new_index += length;
|
||||
order += length;
|
||||
|
||||
op
|
||||
}
|
||||
RawOperation::Insert(tokens) => {
|
||||
let op = Operation::create_insert(new_index, tokens)
|
||||
.map(|operation| OrderedOperation { order, operation });
|
||||
|
||||
new_index += length;
|
||||
|
||||
op
|
||||
}
|
||||
RawOperation::Delete(..) => {
|
||||
let op = if cfg!(debug_assertions) {
|
||||
Operation::create_delete_with_text(
|
||||
new_index,
|
||||
raw_operation.get_original_text(),
|
||||
)
|
||||
} else {
|
||||
Operation::create_delete(new_index, length)
|
||||
}
|
||||
.map(|operation| OrderedOperation { order, operation });
|
||||
|
||||
order += length;
|
||||
|
||||
op
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new `EditedText` with the given operations.
|
||||
/// The operations must be in the order in which they are meant to be
|
||||
/// applied. The operations must not overlap.
|
||||
fn new(
|
||||
text: &'a str,
|
||||
operations: Vec<OrderedOperation<T>>,
|
||||
mut cursors: Vec<CursorPosition>,
|
||||
) -> Self {
|
||||
operations
|
||||
.iter()
|
||||
.zip(operations.iter().skip(1))
|
||||
.for_each(|(previous, next)| {
|
||||
debug_assert!(
|
||||
previous.operation.start_index() <= next.operation.start_index(),
|
||||
"{} must not come before {} yet it does",
|
||||
previous.operation,
|
||||
next.operation
|
||||
);
|
||||
});
|
||||
|
||||
cursors.sort_by_key(|cursor| cursor.char_index);
|
||||
|
||||
Self {
|
||||
text,
|
||||
operations,
|
||||
cursors,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn merge(self, other: Self) -> Self {
|
||||
debug_assert_eq!(
|
||||
self.text, other.text,
|
||||
"`EditedText`-s must be derived from the same text to be mergable"
|
||||
);
|
||||
|
||||
let mut left_merge_context = MergeContext::default();
|
||||
let mut right_merge_context = MergeContext::default();
|
||||
|
||||
let mut merged_cursors = Vec::with_capacity(self.cursors.len() + other.cursors.len());
|
||||
let mut left_cursors = self.cursors.into_iter().peekable();
|
||||
let mut right_cursors = other.cursors.into_iter().peekable();
|
||||
|
||||
let merged_operations: Vec<OrderedOperation<T>> = self
|
||||
.operations
|
||||
.into_iter()
|
||||
// The current text is always the left; the other operation is the right side.
|
||||
.map(|op| (op, Side::Left))
|
||||
.merge_sorted_by_key(
|
||||
other.operations.into_iter().map(|op| (op, Side::Right)),
|
||||
|(operation, _)| {
|
||||
(
|
||||
operation.order,
|
||||
operation.operation.start_index(),
|
||||
// Make sure that the ordering is deterministic regardless which text
|
||||
// is left or right.
|
||||
match &operation.operation {
|
||||
Operation::Equal { index, .. } => index.to_string(),
|
||||
Operation::Insert { text, .. } => text
|
||||
.iter()
|
||||
.map(crate::tokenizer::token::Token::original)
|
||||
.collect::<String>(),
|
||||
Operation::Delete {
|
||||
deleted_character_count,
|
||||
..
|
||||
} => deleted_character_count.to_string(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
.flat_map(|(OrderedOperation { order, operation }, side)| {
|
||||
let original_start = operation.start_index() as i64;
|
||||
let original_end = operation.end_index();
|
||||
let original_length = operation.len() as i64;
|
||||
|
||||
let result = match side {
|
||||
Side::Left => operation.merge_operations_with_context(
|
||||
&mut right_merge_context,
|
||||
&mut left_merge_context,
|
||||
),
|
||||
Side::Right => operation.merge_operations_with_context(
|
||||
&mut left_merge_context,
|
||||
&mut right_merge_context,
|
||||
),
|
||||
};
|
||||
|
||||
if let Some(ref op @ (Operation::Insert { .. } | Operation::Equal { .. })) = result
|
||||
{
|
||||
let shift = op.start_index() as i64 - original_start + op.len() as i64
|
||||
- original_length;
|
||||
match side {
|
||||
Side::Left => {
|
||||
while let Some(cursor) =
|
||||
left_cursors.next_if(|cursor| cursor.char_index <= original_end + 1)
|
||||
{
|
||||
merged_cursors.push(cursor.with_index(
|
||||
(op.start_index() as i64).max(cursor.char_index as i64 + shift)
|
||||
as usize,
|
||||
));
|
||||
}
|
||||
}
|
||||
Side::Right => {
|
||||
while let Some(cursor) = right_cursors
|
||||
.next_if(|cursor| cursor.char_index <= original_end + 1)
|
||||
{
|
||||
merged_cursors.push(cursor.with_index(
|
||||
(op.start_index() as i64).max(cursor.char_index as i64 + shift)
|
||||
as usize,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
.map(|operation| OrderedOperation { order, operation })
|
||||
.into_iter()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let last_index = merged_operations
|
||||
.iter()
|
||||
.filter(|operation| {
|
||||
matches!(
|
||||
operation.operation,
|
||||
Operation::Insert { .. } | Operation::Equal { .. }
|
||||
)
|
||||
})
|
||||
.next_back()
|
||||
.map_or(0, |op| op.operation.end_index());
|
||||
|
||||
for cursor in left_cursors.chain(right_cursors) {
|
||||
merged_cursors.push(cursor.with_index(last_index));
|
||||
}
|
||||
|
||||
Self::new(self.text, merged_operations, merged_cursors)
|
||||
}
|
||||
|
||||
/// Apply the operations to the text and return the resulting text.
|
||||
#[must_use]
|
||||
pub fn apply(&self) -> String {
|
||||
let mut builder: StringBuilder<'_> = StringBuilder::new(self.text);
|
||||
|
||||
for OrderedOperation { operation, .. } in &self.operations {
|
||||
builder = operation.apply(builder);
|
||||
}
|
||||
|
||||
builder.build()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::env;
|
||||
|
||||
use insta::assert_debug_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_calculate_operations() {
|
||||
let left = "hello world! How are you? Adam";
|
||||
let right = "Hello, my friend! How are you doing? Albert";
|
||||
|
||||
let operations = EditedText::from_strings(left, right.into());
|
||||
|
||||
insta::assert_debug_snapshot!(operations);
|
||||
|
||||
let new_right = operations.apply();
|
||||
assert_eq!(new_right.to_string(), right);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_operations_with_no_diff() {
|
||||
let text = "hello world!";
|
||||
|
||||
let operations = EditedText::from_strings(text, text.into());
|
||||
|
||||
assert_debug_snapshot!(operations);
|
||||
|
||||
let new_right = operations.apply();
|
||||
assert_eq!(new_right.to_string(), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_operations_with_insert() {
|
||||
let original = "hello world! ...";
|
||||
let left = "Hello world! I'm Andras.";
|
||||
let right = "Hello world! How are you?";
|
||||
let expected = "Hello world! How are you? I'm Andras.";
|
||||
|
||||
let operations_1 = EditedText::from_strings(original, left.into());
|
||||
let operations_2 = EditedText::from_strings(original, right.into());
|
||||
|
||||
let operations = operations_1.merge(operations_2);
|
||||
assert_eq!(operations.apply(), expected);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
use core::fmt::Debug;
|
||||
|
||||
use crate::operation_transformation::Operation;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MergeContext<T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
{
|
||||
last_operation: Option<Operation<T>>,
|
||||
pub shift: i64,
|
||||
}
|
||||
|
||||
impl<T> Default for MergeContext<T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
{
|
||||
fn default() -> Self {
|
||||
MergeContext {
|
||||
last_operation: None,
|
||||
shift: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> MergeContext<T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
{
|
||||
pub fn last_operation(&self) -> Option<&Operation<T>> { self.last_operation.as_ref() }
|
||||
|
||||
pub fn replace_last_operation(&mut self, operation: Option<Operation<T>>) {
|
||||
self.last_operation = operation;
|
||||
}
|
||||
|
||||
/// Replace the last delete operation (if there was one) with a new one
|
||||
/// while applying it to the `shift` in case the last operation
|
||||
/// was a delete.
|
||||
pub fn consume_and_replace_last_operation(&mut self, operation: Option<Operation<T>>) {
|
||||
if let Some(Operation::Delete {
|
||||
deleted_character_count,
|
||||
..
|
||||
}) = self.last_operation.take()
|
||||
{
|
||||
self.shift -= deleted_character_count as i64;
|
||||
}
|
||||
|
||||
self.last_operation = operation;
|
||||
}
|
||||
|
||||
/// Remove the last operation (if there was one) in case it is behind the
|
||||
/// threshold operation. This updates the `shift` in case the last operation
|
||||
/// was a delete.
|
||||
pub fn consume_last_operation_if_it_is_too_behind(&mut self, threshold_index: i64) {
|
||||
if let Some(last_operation) = self.last_operation.as_ref() {
|
||||
if let Operation::Delete {
|
||||
deleted_character_count,
|
||||
..
|
||||
} = last_operation
|
||||
{
|
||||
if threshold_index + self.shift > last_operation.end_index() as i64 {
|
||||
self.shift -= *deleted_character_count as i64;
|
||||
self.last_operation = None;
|
||||
}
|
||||
} else if let Operation::Insert { .. } = last_operation
|
||||
&& threshold_index + self.shift - last_operation.len() as i64
|
||||
> last_operation.end_index() as i64
|
||||
{
|
||||
self.last_operation = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,513 +0,0 @@
|
|||
use core::fmt::{Debug, Display};
|
||||
use std::ops::Range;
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::merge_context::MergeContext;
|
||||
use crate::{
|
||||
Token,
|
||||
utils::{
|
||||
find_longest_prefix_contained_within::find_longest_prefix_contained_within,
|
||||
string_builder::StringBuilder,
|
||||
},
|
||||
};
|
||||
|
||||
/// Represents a change that can be applied on a `StringBuilder`.
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum Operation<T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
{
|
||||
Equal {
|
||||
index: usize,
|
||||
length: usize,
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
text: Option<String>,
|
||||
},
|
||||
|
||||
Insert {
|
||||
index: usize,
|
||||
text: Vec<Token<T>>,
|
||||
},
|
||||
|
||||
Delete {
|
||||
index: usize,
|
||||
deleted_character_count: usize,
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
deleted_text: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl<T> Operation<T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
{
|
||||
/// Creates an equal operation with the given index.
|
||||
/// This operation is used to indicate that the text at the given index
|
||||
/// is unchanged.
|
||||
pub fn create_equal(index: usize, length: usize) -> Option<Self> {
|
||||
if length == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Operation::Equal {
|
||||
index,
|
||||
length,
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
text: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_equal_with_text(index: usize, text: String) -> Option<Self> {
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Operation::Equal {
|
||||
index,
|
||||
length: text.chars().count(),
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
text: Some(text),
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates an insert operation with the given index and text.
|
||||
/// If the text is empty (meaning that the operation would be a no-op),
|
||||
/// returns None.
|
||||
pub fn create_insert(index: usize, text: Vec<Token<T>>) -> Option<Self> {
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Operation::Insert { index, text })
|
||||
}
|
||||
|
||||
/// Creates a delete operation with the given index and number of
|
||||
/// to-be-deleted characters. If the operation would delete 0 (meaning
|
||||
/// that the operation would be a no-op), returns None.
|
||||
pub fn create_delete(index: usize, deleted_character_count: usize) -> Option<Self> {
|
||||
if deleted_character_count == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Operation::Delete {
|
||||
index,
|
||||
deleted_character_count,
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
deleted_text: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_delete_with_text(index: usize, text: String) -> Option<Self> {
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Operation::Delete {
|
||||
index,
|
||||
deleted_character_count: text.chars().count(),
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
deleted_text: Some(text),
|
||||
})
|
||||
}
|
||||
|
||||
/// Applies the operation to the given `StringBuilder`, returning the
|
||||
/// modified `StringBuilder`.
|
||||
///
|
||||
/// When compiled in debug mode, panics if a delete operation is attempted
|
||||
/// on a range of text that does not match the text to be deleted.
|
||||
pub fn apply<'a>(&self, mut builder: StringBuilder<'a>) -> StringBuilder<'a> {
|
||||
match self {
|
||||
Operation::Equal {
|
||||
#[cfg(debug_assertions)]
|
||||
text,
|
||||
..
|
||||
} => {
|
||||
#[cfg(debug_assertions)]
|
||||
debug_assert!(
|
||||
text.as_ref()
|
||||
.is_none_or(|text| builder.get_slice(self.range()) == *text),
|
||||
"Text which is supposed to be equal does not match the text in the range"
|
||||
);
|
||||
|
||||
return builder;
|
||||
}
|
||||
Operation::Insert { text, .. } => builder.insert(
|
||||
self.start_index(),
|
||||
&text.iter().map(Token::original).collect::<String>(),
|
||||
),
|
||||
Operation::Delete {
|
||||
#[cfg(debug_assertions)]
|
||||
deleted_text,
|
||||
..
|
||||
} => {
|
||||
#[cfg(debug_assertions)]
|
||||
debug_assert!(
|
||||
deleted_text
|
||||
.as_ref()
|
||||
.is_none_or(|text| builder.get_slice(self.range()) == *text),
|
||||
"Text to delete does not match the text in the range"
|
||||
);
|
||||
|
||||
builder.delete(self.range());
|
||||
}
|
||||
}
|
||||
|
||||
builder
|
||||
}
|
||||
|
||||
/// Returns the index of the first character that the operation affects.
|
||||
pub fn start_index(&self) -> usize {
|
||||
match self {
|
||||
Operation::Equal { index, .. }
|
||||
| Operation::Insert { index, .. }
|
||||
| Operation::Delete { index, .. } => *index,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the index of the last character that the operation affects.
|
||||
pub fn end_index(&self) -> usize {
|
||||
debug_assert!(
|
||||
self.len() > 0,
|
||||
" len() must be greater than 0 because operations must be non-empty"
|
||||
);
|
||||
self.start_index() + self.len() - 1
|
||||
}
|
||||
|
||||
/// Returns the range of indices of characters that the operation affects.
|
||||
#[allow(clippy::range_plus_one)]
|
||||
pub fn range(&self) -> Range<usize> { self.start_index()..self.end_index() + 1 }
|
||||
|
||||
/// Returns the number of affected characters. It is always greater than 0
|
||||
/// because empty operations cannot be created.
|
||||
pub fn len(&self) -> usize {
|
||||
match self {
|
||||
Operation::Equal { length, .. } => *length,
|
||||
Operation::Insert { text, .. } => text.iter().map(Token::get_original_length).sum(),
|
||||
Operation::Delete {
|
||||
deleted_character_count,
|
||||
..
|
||||
} => *deleted_character_count,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new operation with the same type and text but with the given
|
||||
/// index.
|
||||
pub fn with_index(self, index: usize) -> Self {
|
||||
match self {
|
||||
Operation::Equal {
|
||||
length,
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
text,
|
||||
..
|
||||
} => Operation::Equal {
|
||||
index,
|
||||
length,
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
text,
|
||||
},
|
||||
Operation::Insert { text, .. } => Operation::Insert { index, text },
|
||||
Operation::Delete {
|
||||
deleted_character_count,
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
deleted_text,
|
||||
..
|
||||
} => Operation::Delete {
|
||||
index,
|
||||
deleted_character_count,
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
deleted_text,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new operation with the same type and text but with the index
|
||||
/// shifted by the given offset. The offset can be negative but the
|
||||
/// resulting index must be non-negative.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// In debug mode, panics if the resulting index is negative.
|
||||
pub fn with_shifted_index(self, offset: i64) -> Self {
|
||||
let index = self.start_index() as i64 + offset;
|
||||
debug_assert!(index >= 0, "Shifted index must be non-negative");
|
||||
|
||||
self.with_index(index as usize)
|
||||
}
|
||||
|
||||
/// Merges the operation with the given context, producing a new operation
|
||||
/// and updating the context. This implements a comples FSM that handles
|
||||
/// the merging of operations in a way that is consistent with the text.
|
||||
/// The contexts are updated in-place.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn merge_operations_with_context(
|
||||
self,
|
||||
affecting_context: &mut MergeContext<T>,
|
||||
produced_context: &mut MergeContext<T>,
|
||||
) -> Option<Operation<T>> {
|
||||
affecting_context.consume_last_operation_if_it_is_too_behind(self.start_index() as i64);
|
||||
let operation = self.with_shifted_index(affecting_context.shift);
|
||||
|
||||
match (operation, affecting_context.last_operation()) {
|
||||
(operation @ Operation::Insert { .. }, None | Some(Operation::Equal { .. })) => {
|
||||
produced_context.shift += operation.len() as i64;
|
||||
produced_context.consume_and_replace_last_operation(Some(operation.clone()));
|
||||
Some(operation)
|
||||
}
|
||||
|
||||
(
|
||||
Operation::Insert { text, index },
|
||||
Some(Operation::Insert {
|
||||
text: previous_inserted_text,
|
||||
..
|
||||
}),
|
||||
) => {
|
||||
// In case the current insert's prefix appears in the previously inserted text,
|
||||
// we can trim the current insert to only include the non-overlapping part.
|
||||
// This way, we don't end up duplicating text.
|
||||
let offset_in_tokens =
|
||||
find_longest_prefix_contained_within(previous_inserted_text, &text);
|
||||
let offset_in_length = text
|
||||
.iter()
|
||||
.take(offset_in_tokens)
|
||||
.map(Token::get_original_length)
|
||||
.sum::<usize>();
|
||||
let trimmed_operation =
|
||||
Operation::create_insert(index, text[offset_in_tokens..].to_vec());
|
||||
|
||||
affecting_context.shift -= offset_in_length as i64;
|
||||
produced_context.shift += trimmed_operation
|
||||
.as_ref()
|
||||
.map(Operation::len)
|
||||
.unwrap_or_default() as i64;
|
||||
produced_context.consume_and_replace_last_operation(trimmed_operation.clone());
|
||||
|
||||
trimmed_operation
|
||||
}
|
||||
|
||||
(
|
||||
operation @ Operation::Delete { .. },
|
||||
None | Some(Operation::Insert { .. } | Operation::Equal { .. }),
|
||||
) => {
|
||||
produced_context.consume_and_replace_last_operation(Some(operation.clone()));
|
||||
Some(operation)
|
||||
}
|
||||
|
||||
(
|
||||
operation @ Operation::Insert { .. },
|
||||
Some(last_delete @ Operation::Delete { .. }),
|
||||
) => {
|
||||
produced_context.shift += operation.len() as i64;
|
||||
|
||||
debug_assert!(
|
||||
last_delete.range().contains(&operation.start_index()),
|
||||
"There is a last delete ({last_delete}) but the operation ({operation}) is \
|
||||
not contained in it"
|
||||
);
|
||||
|
||||
let difference = operation.start_index() as i64 - last_delete.start_index() as i64;
|
||||
|
||||
let moved_operation = operation.with_index(last_delete.start_index());
|
||||
|
||||
affecting_context.replace_last_operation(Operation::create_delete(
|
||||
moved_operation.end_index() + 1,
|
||||
(last_delete.len() as i64 - difference) as usize,
|
||||
));
|
||||
affecting_context.shift -= difference;
|
||||
|
||||
produced_context.consume_and_replace_last_operation(Some(moved_operation.clone()));
|
||||
|
||||
Some(moved_operation)
|
||||
}
|
||||
|
||||
(
|
||||
operation @ Operation::Delete { .. },
|
||||
Some(last_delete @ Operation::Delete { .. }),
|
||||
) => {
|
||||
debug_assert!(
|
||||
last_delete.range().contains(&operation.start_index()),
|
||||
"There is a last delete ({last_delete}) but the operation ({operation}) is \
|
||||
not contained in it"
|
||||
);
|
||||
|
||||
let difference = operation.start_index() as i64 - last_delete.start_index() as i64;
|
||||
|
||||
let updated_delete = Operation::create_delete(
|
||||
last_delete.start_index(),
|
||||
0.max(operation.end_index() as i64 - last_delete.end_index() as i64) as usize,
|
||||
);
|
||||
|
||||
affecting_context.replace_last_operation(Operation::create_delete(
|
||||
last_delete.start_index(),
|
||||
0.max(last_delete.end_index() as i64 - operation.end_index() as i64) as usize,
|
||||
));
|
||||
affecting_context.shift -= difference;
|
||||
|
||||
produced_context.consume_and_replace_last_operation(updated_delete.clone());
|
||||
|
||||
updated_delete
|
||||
}
|
||||
(
|
||||
ref operation @ Operation::Equal {
|
||||
length,
|
||||
#[cfg(debug_assertions)]
|
||||
ref text,
|
||||
..
|
||||
},
|
||||
Some(last_delete @ Operation::Delete { .. }),
|
||||
) => {
|
||||
debug_assert!(
|
||||
last_delete.range().contains(&operation.start_index()),
|
||||
"There is a last delete ({last_delete}) but the operation ({operation}) is \
|
||||
not contained in it"
|
||||
);
|
||||
|
||||
let overlap = (length as i64)
|
||||
.min(last_delete.end_index() as i64 - operation.start_index() as i64 + 1);
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
let result = text.as_ref().map_or_else(
|
||||
|| {
|
||||
Operation::create_equal(
|
||||
operation.end_index().min(last_delete.end_index()),
|
||||
(length as i64 - overlap) as usize,
|
||||
)
|
||||
},
|
||||
|text| {
|
||||
Operation::create_equal_with_text(
|
||||
operation.end_index().min(last_delete.end_index()),
|
||||
text.chars().skip(overlap as usize).collect::<String>(),
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
let result = Operation::create_equal(
|
||||
operation.end_index().min(last_delete.end_index()),
|
||||
(length as i64 - overlap) as usize,
|
||||
);
|
||||
|
||||
result
|
||||
}
|
||||
(operation @ Operation::Equal { .. }, _) => Some(operation),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Display for Operation<T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
{
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
match self {
|
||||
Operation::Equal {
|
||||
index,
|
||||
length,
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
text,
|
||||
} => {
|
||||
#[cfg(debug_assertions)]
|
||||
write!(
|
||||
f,
|
||||
"<equal {} from index {}>",
|
||||
text.as_ref()
|
||||
.map(|text| format!("'{text}'"))
|
||||
.unwrap_or(format!("{length} characters")),
|
||||
index
|
||||
)?;
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
write!(f, "<equal {length} from index {index}>")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Operation::Insert { index, text } => {
|
||||
write!(
|
||||
f,
|
||||
"<insert '{}' from index {}>",
|
||||
text.iter().map(Token::original).collect::<String>(),
|
||||
index
|
||||
)
|
||||
}
|
||||
Operation::Delete {
|
||||
index,
|
||||
deleted_character_count,
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
deleted_text,
|
||||
} => {
|
||||
#[cfg(debug_assertions)]
|
||||
write!(
|
||||
f,
|
||||
"<delete {} from index {}>",
|
||||
deleted_text
|
||||
.as_ref()
|
||||
.map(|text| format!("'{text}'"))
|
||||
.unwrap_or(format!("{deleted_character_count} characters")),
|
||||
index
|
||||
)?;
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
write!(
|
||||
f,
|
||||
"<delete {deleted_character_count} characters from index {index}>",
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Debug for Operation<T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
{
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!(f, "{self}") }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Shifted index must be non-negative")]
|
||||
fn test_shifting_error() {
|
||||
insta::assert_debug_snapshot!(
|
||||
Operation::create_insert(1, vec!["hi".into()])
|
||||
.unwrap()
|
||||
.with_shifted_index(-2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_delete_with_create() {
|
||||
let builder = StringBuilder::new("hello world");
|
||||
let operation = Operation::<()>::create_delete_with_text(5, " world".to_owned()).unwrap();
|
||||
|
||||
assert_eq!(operation.apply(builder).build(), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_insert() {
|
||||
let builder = StringBuilder::new("hello");
|
||||
let operation = Operation::create_insert(5, vec![" my friend".into()]).unwrap();
|
||||
|
||||
assert_eq!(operation.apply(builder).build(), "hello my friend");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::operation_transformation::Operation;
|
||||
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OrderedOperation<T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
{
|
||||
pub order: usize,
|
||||
pub operation: Operation<T>,
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
---
|
||||
source: reconcile/src/operation_transformation/edited_text.rs
|
||||
expression: operations
|
||||
snapshot_kind: text
|
||||
---
|
||||
EditedText {
|
||||
text: "hello world! How are you? Adam",
|
||||
operations: [
|
||||
OrderedOperation {
|
||||
order: 0,
|
||||
operation: <insert 'Hello, my friend!' from index 0>,
|
||||
},
|
||||
OrderedOperation {
|
||||
order: 0,
|
||||
operation: <delete 'hello world!' from index 17>,
|
||||
},
|
||||
OrderedOperation {
|
||||
order: 12,
|
||||
operation: <equal ' ' from index 17>,
|
||||
},
|
||||
OrderedOperation {
|
||||
order: 13,
|
||||
operation: <equal 'How' from index 18>,
|
||||
},
|
||||
OrderedOperation {
|
||||
order: 16,
|
||||
operation: <equal ' ' from index 21>,
|
||||
},
|
||||
OrderedOperation {
|
||||
order: 17,
|
||||
operation: <equal 'are' from index 22>,
|
||||
},
|
||||
OrderedOperation {
|
||||
order: 20,
|
||||
operation: <insert ' you doing? Albert' from index 25>,
|
||||
},
|
||||
OrderedOperation {
|
||||
order: 20,
|
||||
operation: <delete ' you? Adam' from index 43>,
|
||||
},
|
||||
],
|
||||
cursors: [],
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
---
|
||||
source: reconcile/src/operation_transformation/edited_text.rs
|
||||
expression: operations
|
||||
snapshot_kind: text
|
||||
---
|
||||
EditedText {
|
||||
text: "hello world!",
|
||||
operations: [
|
||||
OrderedOperation {
|
||||
order: 0,
|
||||
operation: <equal 'hello' from index 0>,
|
||||
},
|
||||
OrderedOperation {
|
||||
order: 5,
|
||||
operation: <equal ' ' from index 5>,
|
||||
},
|
||||
OrderedOperation {
|
||||
order: 6,
|
||||
operation: <equal 'world!' from index 6>,
|
||||
},
|
||||
],
|
||||
cursors: [],
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
---
|
||||
source: reconcile/src/operations/edited_text.rs
|
||||
expression: operations
|
||||
snapshot_kind: text
|
||||
---
|
||||
EditedText {
|
||||
text: "hello world! How are you? Adam",
|
||||
operations: [
|
||||
OrderedOperation {
|
||||
order: 0,
|
||||
operation: Insert {
|
||||
index: 0,
|
||||
text: "Hello, my friend! ",
|
||||
},
|
||||
},
|
||||
OrderedOperation {
|
||||
order: 0,
|
||||
operation: Delete {
|
||||
index: 18,
|
||||
deleted_character_count: 13,
|
||||
deleted_text: Some(
|
||||
"hello world! ",
|
||||
),
|
||||
},
|
||||
},
|
||||
OrderedOperation {
|
||||
order: 21,
|
||||
operation: Delete {
|
||||
index: 26,
|
||||
deleted_character_count: 5,
|
||||
deleted_text: Some(
|
||||
"you? ",
|
||||
),
|
||||
},
|
||||
},
|
||||
OrderedOperation {
|
||||
order: 26,
|
||||
operation: Delete {
|
||||
index: 26,
|
||||
deleted_character_count: 5,
|
||||
deleted_text: Some(
|
||||
" Adam",
|
||||
),
|
||||
},
|
||||
},
|
||||
OrderedOperation {
|
||||
order: 31,
|
||||
operation: Insert {
|
||||
index: 26,
|
||||
text: "you ",
|
||||
},
|
||||
},
|
||||
OrderedOperation {
|
||||
order: 31,
|
||||
operation: Insert {
|
||||
index: 30,
|
||||
text: "doing? Albert",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
---
|
||||
source: reconcile/src/operations/operation_sequence.rs
|
||||
expression: operations
|
||||
snapshot_kind: text
|
||||
---
|
||||
EditedText {
|
||||
operations: [
|
||||
OrderedOperation {
|
||||
order: 0,
|
||||
operation: Insert {
|
||||
index: 0,
|
||||
text: "Hello, my friend! ",
|
||||
},
|
||||
},
|
||||
OrderedOperation {
|
||||
order: 0,
|
||||
operation: Delete {
|
||||
index: 18,
|
||||
deleted_character_count: 13,
|
||||
deleted_text: Some(
|
||||
"hello world! ",
|
||||
),
|
||||
},
|
||||
},
|
||||
OrderedOperation {
|
||||
order: 21,
|
||||
operation: Delete {
|
||||
index: 26,
|
||||
deleted_character_count: 5,
|
||||
deleted_text: Some(
|
||||
"you? ",
|
||||
),
|
||||
},
|
||||
},
|
||||
OrderedOperation {
|
||||
order: 26,
|
||||
operation: Delete {
|
||||
index: 26,
|
||||
deleted_character_count: 5,
|
||||
deleted_text: Some(
|
||||
" Adam",
|
||||
),
|
||||
},
|
||||
},
|
||||
OrderedOperation {
|
||||
order: 31,
|
||||
operation: Insert {
|
||||
index: 26,
|
||||
text: "you ",
|
||||
},
|
||||
},
|
||||
OrderedOperation {
|
||||
order: 31,
|
||||
operation: Insert {
|
||||
index: 30,
|
||||
text: "doing? Albert",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
use token::Token;
|
||||
|
||||
pub mod token;
|
||||
pub mod word_tokenizer;
|
||||
|
||||
pub type Tokenizer<T> = dyn Fn(&str) -> Vec<Token<T>>;
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
---
|
||||
source: reconcile/src/tokenizer/word_tokenizer.rs
|
||||
expression: "word_tokenizer(\"\")"
|
||||
snapshot_kind: text
|
||||
---
|
||||
[]
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
---
|
||||
source: reconcile/src/tokenizer/word_tokenizer.rs
|
||||
expression: "word_tokenizer(\" what? \")"
|
||||
snapshot_kind: text
|
||||
---
|
||||
[
|
||||
Token {
|
||||
normalised: " what?",
|
||||
original: " ",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "what?",
|
||||
original: "what?",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: " ",
|
||||
original: " ",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
]
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
---
|
||||
source: reconcile/src/tokenizer/word_tokenizer.rs
|
||||
expression: "word_tokenizer(\" hello, \\nwhere are you?\")"
|
||||
snapshot_kind: text
|
||||
---
|
||||
[
|
||||
Token {
|
||||
normalised: " hello,",
|
||||
original: " ",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "hello,",
|
||||
original: "hello,",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: " \nwhere",
|
||||
original: " \n",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "where",
|
||||
original: "where",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: " are",
|
||||
original: " ",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "are",
|
||||
original: "are",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: " you?",
|
||||
original: " ",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "you?",
|
||||
original: "you?",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
]
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
---
|
||||
source: reconcile/src/tokenizer/word_tokenizer.rs
|
||||
expression: "word_tokenizer(\" hello, \\nwhere are you?\")"
|
||||
snapshot_kind: text
|
||||
---
|
||||
[
|
||||
Token {
|
||||
normalised: " ",
|
||||
original: " ",
|
||||
},
|
||||
Token {
|
||||
normalised: "hello,",
|
||||
original: "hello,",
|
||||
},
|
||||
Token {
|
||||
normalised: " \n",
|
||||
original: " \n",
|
||||
},
|
||||
Token {
|
||||
normalised: "where",
|
||||
original: "where",
|
||||
},
|
||||
Token {
|
||||
normalised: " ",
|
||||
original: " ",
|
||||
},
|
||||
Token {
|
||||
normalised: "are",
|
||||
original: "are",
|
||||
},
|
||||
Token {
|
||||
normalised: " ",
|
||||
original: " ",
|
||||
},
|
||||
Token {
|
||||
normalised: "you?",
|
||||
original: "you?",
|
||||
},
|
||||
]
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
---
|
||||
source: reconcile/src/tokenizer/word_tokenizer.rs
|
||||
expression: "word_tokenizer(\"Hi there!\")"
|
||||
snapshot_kind: text
|
||||
---
|
||||
[
|
||||
Token {
|
||||
normalised: "Hi",
|
||||
original: "Hi",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: " there!",
|
||||
original: " ",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
Token {
|
||||
normalised: "there!",
|
||||
original: "there!",
|
||||
is_left_joinable: true,
|
||||
is_right_joinable: true,
|
||||
},
|
||||
]
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A token is a string that has been normalised in some way.
|
||||
/// The normalised form is used for comparison, while the original form is used
|
||||
/// for applying `Operation`-s.
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Token<T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
{
|
||||
/// The normalised form of the token used deriving the diff.
|
||||
pub normalised: T,
|
||||
|
||||
/// The original string, that should be inserted or deleted in the document.
|
||||
original: String,
|
||||
|
||||
/// Whether the token is joinable with the previous token.
|
||||
is_left_joinable: bool,
|
||||
|
||||
/// Whether the token is joinable with the next token.
|
||||
is_right_joinable: bool,
|
||||
}
|
||||
|
||||
impl From<&str> for Token<String> {
|
||||
fn from(text: &str) -> Self { Token::new(text.to_owned(), text.to_owned(), true, true) }
|
||||
}
|
||||
|
||||
impl<T> Token<T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
{
|
||||
pub fn new(
|
||||
normalised: T,
|
||||
original: String,
|
||||
is_left_joinable: bool,
|
||||
is_right_joinable: bool,
|
||||
) -> Self {
|
||||
Token {
|
||||
normalised,
|
||||
original,
|
||||
is_left_joinable,
|
||||
is_right_joinable,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn original(&self) -> &str { &self.original }
|
||||
|
||||
pub fn normalised(&self) -> &T { &self.normalised }
|
||||
|
||||
pub fn get_original_length(&self) -> usize { self.original.chars().count() }
|
||||
|
||||
pub fn get_is_left_joinable(&self) -> bool { self.is_left_joinable }
|
||||
|
||||
pub fn get_is_right_joinable(&self) -> bool { self.is_right_joinable }
|
||||
}
|
||||
|
||||
impl<T> PartialEq for Token<T>
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
{
|
||||
fn eq(&self, other: &Self) -> bool { self.normalised == other.normalised }
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
use super::token::Token;
|
||||
|
||||
/// Splits on word boundaries creating alternating words and whitespaces with
|
||||
/// the whitesspaces getting unique IDs.
|
||||
///
|
||||
/// ## Example
|
||||
///
|
||||
/// ```not_rust
|
||||
/// "Hi there!" -> ["Hi", " ", "there!"]
|
||||
/// ```
|
||||
pub fn word_tokenizer(text: &str) -> Vec<Token<String>> {
|
||||
let mut result: Vec<Token<String>> = Vec::new();
|
||||
|
||||
let mut previous_boundary_index = 0;
|
||||
let mut previous_char_is_whitespace = text.chars().next().is_none_or(char::is_whitespace);
|
||||
|
||||
for (i, c) in text.char_indices() {
|
||||
let is_current_char_whitespace = c.is_whitespace();
|
||||
if previous_char_is_whitespace != is_current_char_whitespace {
|
||||
result.push(text[previous_boundary_index..i].into());
|
||||
previous_boundary_index = i;
|
||||
}
|
||||
|
||||
previous_char_is_whitespace = is_current_char_whitespace;
|
||||
}
|
||||
|
||||
if previous_boundary_index < text.len() {
|
||||
result.push(text[previous_boundary_index..].into());
|
||||
}
|
||||
|
||||
if result.is_empty() {
|
||||
return result;
|
||||
}
|
||||
|
||||
for i in 0..result.len() - 1 {
|
||||
if result[i].original().chars().all(char::is_whitespace) {
|
||||
result[i].normalised = result[i].normalised().to_owned() + result[i + 1].original();
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use insta::assert_debug_snapshot;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_with_snapshots() {
|
||||
assert_debug_snapshot!(word_tokenizer("Hi there!"));
|
||||
|
||||
assert_debug_snapshot!(word_tokenizer(""));
|
||||
|
||||
assert_debug_snapshot!(word_tokenizer(" what? "));
|
||||
|
||||
assert_debug_snapshot!(word_tokenizer(" hello, \nwhere are you?"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
pub mod common_prefix_len;
|
||||
pub mod common_suffix_len;
|
||||
pub mod find_longest_prefix_contained_within;
|
||||
pub mod merge_iters;
|
||||
pub mod side;
|
||||
pub mod string_builder;
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
use core::ops::{Index, Range};
|
||||
|
||||
/// Given two lookups and ranges calculates the length of the common prefix.
|
||||
/// Copied from <https://github.com/mitsuhiko/similar/blob/7e15c44de11a1cd61e1149189929e189ef977fd8/src/algorithms/utils.rs>
|
||||
pub fn common_prefix_len<Old, New>(
|
||||
old: &Old,
|
||||
old_range: Range<usize>,
|
||||
new: &New,
|
||||
new_range: Range<usize>,
|
||||
) -> usize
|
||||
where
|
||||
Old: Index<usize> + ?Sized,
|
||||
New: Index<usize> + ?Sized,
|
||||
New::Output: PartialEq<Old::Output>,
|
||||
{
|
||||
new_range
|
||||
.zip(old_range)
|
||||
.take_while(|x| new[x.0] == old[x.1])
|
||||
.count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_common_prefix_len() {
|
||||
assert_eq!(
|
||||
common_prefix_len("".as_bytes(), 0..0, "".as_bytes(), 0..0),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
common_prefix_len("foobarbaz".as_bytes(), 0..9, "foobarblah".as_bytes(), 0..10),
|
||||
7
|
||||
);
|
||||
assert_eq!(
|
||||
common_prefix_len("foobarbaz".as_bytes(), 0..9, "blablabla".as_bytes(), 0..9),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
common_prefix_len("foobarbaz".as_bytes(), 3..9, "foobarblah".as_bytes(), 3..10),
|
||||
4
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
use core::ops::{Index, Range};
|
||||
|
||||
/// Given two lookups and ranges calculates the length of common suffix.
|
||||
/// Copied from <https://github.com/mitsuhiko/similar/blob/7e15c44de11a1cd61e1149189929e189ef977fd8/src/algorithms/utils.rs>
|
||||
pub fn common_suffix_len<Old, New>(
|
||||
old: &Old,
|
||||
old_range: Range<usize>,
|
||||
new: &New,
|
||||
new_range: Range<usize>,
|
||||
) -> usize
|
||||
where
|
||||
Old: Index<usize> + ?Sized,
|
||||
New: Index<usize> + ?Sized,
|
||||
New::Output: PartialEq<Old::Output>,
|
||||
{
|
||||
new_range
|
||||
.rev()
|
||||
.zip(old_range.rev())
|
||||
.take_while(|x| new[x.0] == old[x.1])
|
||||
.count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_common_suffix_len() {
|
||||
assert_eq!(
|
||||
common_suffix_len("".as_bytes(), 0..0, "".as_bytes(), 0..0),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
common_suffix_len("1234".as_bytes(), 0..4, "X0001234".as_bytes(), 0..8),
|
||||
4
|
||||
);
|
||||
assert_eq!(
|
||||
common_suffix_len("1234".as_bytes(), 0..4, "Xxxx".as_bytes(), 0..4),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
common_suffix_len("1234".as_bytes(), 2..4, "01234".as_bytes(), 2..5),
|
||||
2
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
use crate::Token;
|
||||
|
||||
/// Given two lists of tokens, returns `length` where `old` list somewhere
|
||||
/// within contains the `length` prefix of the `new` list.
|
||||
///
|
||||
/// ## Example
|
||||
///
|
||||
/// ```not_rust
|
||||
/// old: [0, 1, 9, 0, 2, 5]
|
||||
/// new: [9, 0, 2, 5, 1]
|
||||
/// ```
|
||||
/// > results in an length of 4
|
||||
///
|
||||
///
|
||||
/// ```not_rust
|
||||
/// old: [0, 1, 9, 0, 2, 5]
|
||||
/// new: [0, 2]
|
||||
/// ```
|
||||
/// > results in an length of 2
|
||||
///
|
||||
/// ```not_rust
|
||||
/// old: [0, 1, 9, 0, 2, 5]
|
||||
/// new: [0, 4]
|
||||
/// ```
|
||||
/// > results in an length of 1
|
||||
pub fn find_longest_prefix_contained_within<T>(old: &[Token<T>], new: &[Token<T>]) -> usize
|
||||
where
|
||||
T: PartialEq + Clone + std::fmt::Debug,
|
||||
{
|
||||
let max_possible = new.len().min(old.len());
|
||||
|
||||
for len in (1..=max_possible).rev() {
|
||||
let prefix = &new[..len];
|
||||
if old.windows(len).any(|window| window == prefix) {
|
||||
return len;
|
||||
}
|
||||
}
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_common_overlap() {
|
||||
assert_eq!(
|
||||
find_longest_prefix_contained_within(&["".into()], &["".into()]),
|
||||
1
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
find_longest_prefix_contained_within(
|
||||
&["a".into(), "b".into(), "c".into()],
|
||||
&["b".into(), "c".into(), "a".into()]
|
||||
),
|
||||
2
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
find_longest_prefix_contained_within(
|
||||
&["a".into(), "b".into(), "c".into()],
|
||||
&["b".into(), "c".into()]
|
||||
),
|
||||
2
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
find_longest_prefix_contained_within(
|
||||
&["a".into(), "b".into(), "c".into()],
|
||||
&["b".into()]
|
||||
),
|
||||
1
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
find_longest_prefix_contained_within(
|
||||
&["a".into(), "b".into(), "c".into(), "b".into(), "a".into()],
|
||||
&["b".into(), "a".into()]
|
||||
),
|
||||
2
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
find_longest_prefix_contained_within(
|
||||
&["a".into(), "a".into(), "a".into()],
|
||||
&["a".into(), "b".into(), "c".into()]
|
||||
),
|
||||
1
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
find_longest_prefix_contained_within(
|
||||
&["a".into(), "b".into(), "c".into()],
|
||||
&["d".into(), "e".into(), "a".into()]
|
||||
),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
use core::{cmp::Ordering, iter::Peekable};
|
||||
|
||||
pub struct MergeAscending<L, R, F, O>
|
||||
where
|
||||
L: Iterator<Item = R::Item>,
|
||||
R: Iterator,
|
||||
F: Fn(&R::Item) -> O,
|
||||
O: PartialOrd,
|
||||
{
|
||||
left: Peekable<L>,
|
||||
right: Peekable<R>,
|
||||
get_key: F,
|
||||
}
|
||||
|
||||
impl<L, R, F, O> MergeAscending<L, R, F, O>
|
||||
where
|
||||
L: Iterator<Item = R::Item>,
|
||||
R: Iterator,
|
||||
F: Fn(&R::Item) -> O,
|
||||
O: PartialOrd,
|
||||
{
|
||||
fn new(left: L, right: R, get_key: F) -> Self {
|
||||
MergeAscending {
|
||||
left: left.peekable(),
|
||||
right: right.peekable(),
|
||||
get_key,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<L, R, F, O> Iterator for MergeAscending<L, R, F, O>
|
||||
where
|
||||
L: Iterator<Item = R::Item>,
|
||||
R: Iterator,
|
||||
F: Fn(&R::Item) -> O,
|
||||
O: PartialOrd,
|
||||
{
|
||||
type Item = L::Item;
|
||||
|
||||
fn next(&mut self) -> Option<L::Item> {
|
||||
let order = match (self.left.peek(), self.right.peek()) {
|
||||
(Some(l), Some(r)) => (self.get_key)(l).partial_cmp(&(self.get_key)(r)),
|
||||
(Some(_), None) => Some(Ordering::Less),
|
||||
(None, Some(_)) => Some(Ordering::Greater),
|
||||
(None, None) => return None,
|
||||
};
|
||||
|
||||
match order {
|
||||
Some(Ordering::Less | Ordering::Equal) | None => self.left.next(),
|
||||
Some(Ordering::Greater) => self.right.next(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait MergeSorted: Iterator {
|
||||
fn merge_sorted_by_key<R, F, O>(self, other: R, get_key: F) -> MergeAscending<Self, R, F, O>
|
||||
where
|
||||
Self: Sized,
|
||||
R: Iterator<Item = Self::Item>,
|
||||
F: Fn(&Self::Item) -> O,
|
||||
O: PartialOrd,
|
||||
{
|
||||
MergeAscending::new(self, other, get_key)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ?Sized> MergeSorted for T where T: Iterator {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_merge_sorted_by_key() {
|
||||
let left = [9, 7, 5, 3, 1];
|
||||
let right = [7, 6, 5, 4, 3];
|
||||
|
||||
let result: Vec<i32> = left
|
||||
.into_iter()
|
||||
.merge_sorted_by_key(right.into_iter(), |x| -1 * x)
|
||||
.collect();
|
||||
assert_eq!(result, vec![9, 7, 7, 6, 5, 5, 4, 3, 3, 1]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
use std::fmt::Display;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Side {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl Display for Side {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Side::Left => write!(f, "Left"),
|
||||
Side::Right => write!(f, "Right"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
use core::ops::Range;
|
||||
|
||||
/// A helper for building a string in order based on an original string and a
|
||||
/// series of insertions and deletions applied to it. It is safe to use with
|
||||
/// UTF-8 strings as all operations are based on character indices.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StringBuilder<'a> {
|
||||
original: &'a str,
|
||||
last_old_char_index: usize,
|
||||
buffer: String,
|
||||
}
|
||||
|
||||
impl StringBuilder<'_> {
|
||||
pub fn new(original: &str) -> StringBuilder<'_> {
|
||||
StringBuilder {
|
||||
original,
|
||||
last_old_char_index: 0,
|
||||
buffer: String::with_capacity(original.len()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a string at the given index after copying the original string up
|
||||
/// to that index from the last insertion or deletion.
|
||||
pub fn insert(&mut self, from: usize, text: &str) {
|
||||
self.copy_until(from);
|
||||
self.buffer.push_str(text);
|
||||
}
|
||||
|
||||
/// Delete a string at the given index after copying the original string up
|
||||
/// to that index from the last insertion or deletion.
|
||||
pub fn delete(&mut self, range: core::ops::Range<usize>) {
|
||||
self.copy_until(range.start);
|
||||
self.last_old_char_index += range.len();
|
||||
}
|
||||
|
||||
fn copy_until(&mut self, index: usize) {
|
||||
let current_char_count = self.buffer.chars().count();
|
||||
debug_assert!(
|
||||
index >= current_char_count,
|
||||
"String builder only support building in order"
|
||||
);
|
||||
|
||||
let jump = index - current_char_count;
|
||||
|
||||
self.buffer.push_str(
|
||||
&self
|
||||
.original
|
||||
.chars()
|
||||
.skip(self.last_old_char_index)
|
||||
.take(jump)
|
||||
.collect::<String>(),
|
||||
);
|
||||
self.last_old_char_index += jump;
|
||||
}
|
||||
|
||||
/// Finish building the string after copying the remaining original string
|
||||
/// since the last insertion or deletion.
|
||||
pub fn build(mut self) -> String {
|
||||
self.buffer.push_str(
|
||||
&self
|
||||
.original
|
||||
.chars()
|
||||
.skip(self.last_old_char_index)
|
||||
.collect::<String>(),
|
||||
);
|
||||
|
||||
self.buffer
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_slice(&self, range: Range<usize>) -> String {
|
||||
let result = self
|
||||
.buffer
|
||||
.chars()
|
||||
.chain(self.original.chars().skip(self.last_old_char_index))
|
||||
.skip(range.start)
|
||||
.take(range.end - range.start)
|
||||
.collect::<String>();
|
||||
|
||||
debug_assert_eq!(result.chars().count(), range.len(), "Range out of bounds",);
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_string_builder() {
|
||||
let original = "aaa bbb ccc";
|
||||
let mut builder = StringBuilder::new(original);
|
||||
|
||||
builder.insert(0, "ddd ");
|
||||
builder.delete(4..8);
|
||||
builder.insert(11, " eee");
|
||||
|
||||
assert_eq!(builder.build(), "ddd bbb ccc eee");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_string_builder2() {
|
||||
let original = "abcde";
|
||||
let mut builder = StringBuilder::new(original);
|
||||
|
||||
builder.delete(1..4);
|
||||
|
||||
assert_eq!(builder.build(), "ae");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
use pretty_assertions::assert_eq;
|
||||
use reconcile::{CursorPosition, TextWithCursors};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// `ExampleDocument` represents a test case for the reconciliation process.
|
||||
/// It contains a parent string, left and right strings with cursor positions,
|
||||
/// and the expected result after reconciliation.
|
||||
///
|
||||
/// '|' characters in the left, right, and expected strings are treated as
|
||||
/// cursor positions and are converted into `CursorPosition` objects.
|
||||
#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
|
||||
pub struct ExampleDocument {
|
||||
parent: String,
|
||||
left: String,
|
||||
right: String,
|
||||
expected: String,
|
||||
}
|
||||
|
||||
impl ExampleDocument {
|
||||
#[must_use]
|
||||
pub fn parent(&self) -> String { self.parent.clone() }
|
||||
|
||||
#[must_use]
|
||||
pub fn left(&self) -> TextWithCursors<'static> {
|
||||
ExampleDocument::string_to_text_with_cursors(&self.left)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn right(&self) -> TextWithCursors<'static> {
|
||||
ExampleDocument::string_to_text_with_cursors(&self.right)
|
||||
}
|
||||
|
||||
/// Asserts that the result string matches the expected string,
|
||||
/// including cursor positions.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// If the result string does not match the expected string, the program
|
||||
/// will panic.
|
||||
pub fn assert_eq(&self, result: &TextWithCursors<'static>) {
|
||||
let result_str = ExampleDocument::text_with_cursors_to_string(result);
|
||||
assert_eq!(
|
||||
self.expected, result_str,
|
||||
"Left (expected) isn't equal to right (actual). Actual: ```\n{result_str}```",
|
||||
);
|
||||
}
|
||||
|
||||
/// Asserts that the result string matches the expected string,
|
||||
/// ignoring cursor positions.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// If the result string does not match the expected string, the program
|
||||
/// will panic.
|
||||
pub fn assert_eq_without_cursors(&self, result: &str) {
|
||||
let expected = ExampleDocument::string_to_text_with_cursors(&self.expected).text;
|
||||
assert_eq!(
|
||||
expected, result,
|
||||
"Left (expected) isn't equal to right (actual), Actual: ```\n{result}```",
|
||||
);
|
||||
}
|
||||
|
||||
fn text_with_cursors_to_string(text: &TextWithCursors<'_>) -> String {
|
||||
let mut result = text.text.clone().into_owned();
|
||||
for (i, cursor) in text.cursors.iter().enumerate() {
|
||||
assert!(
|
||||
cursor.char_index <= result.len(), // equals in case of insert at the end
|
||||
"Cursor index out of bounds: {} > {} when testing for '{result}'",
|
||||
cursor.char_index,
|
||||
result.len()
|
||||
);
|
||||
|
||||
result.insert(
|
||||
result
|
||||
.char_indices()
|
||||
.nth(cursor.char_index + i)
|
||||
.map_or_else(|| result.len(), |(byte_index, _)| byte_index), /* find the utf8 char index of the insert
|
||||
* in byte index */
|
||||
'|',
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn string_to_text_with_cursors(text: &str) -> TextWithCursors<'static> {
|
||||
let cursors = Self::parse_cursors(text);
|
||||
let text = text.replace('|', "");
|
||||
TextWithCursors::new_owned(text, cursors)
|
||||
}
|
||||
|
||||
fn parse_cursors(text: &str) -> Vec<CursorPosition> {
|
||||
let mut cursors = Vec::new();
|
||||
for (i, c) in text.chars().enumerate() {
|
||||
if c == '|' {
|
||||
cursors.push(CursorPosition {
|
||||
id: 0,
|
||||
char_index: i - cursors.len(),
|
||||
});
|
||||
}
|
||||
}
|
||||
cursors
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
The `|` characters denote cursor positions which are stripped before the actual reconcile logic is run
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
# Both delete the same range
|
||||
parent: original_1 original_2 original_3 original_4 original_5
|
||||
left: original_1 original_5|
|
||||
right: "|original_1 original_5"
|
||||
expected: "|original_1 original_5|"
|
||||
|
||||
---
|
||||
# Both delete a range and one range contains the other
|
||||
parent: original_1 original_2 original_3 original_4 original_5
|
||||
left: original_1 original_5
|
||||
right: original_1 original_4 original_5
|
||||
expected: original_1 original_5
|
||||
|
||||
---
|
||||
# Deleting overlapping ranges
|
||||
parent: original_1 original_2 original_3 original_4 original_5
|
||||
left: original_1 original_4| original_5
|
||||
right: original_1 original_2| original_5
|
||||
expected: original_1|| original_5
|
||||
|
||||
---
|
||||
parent: long text with one big delete and many small
|
||||
left: long small
|
||||
right: long with big and small
|
||||
expected: long small
|
||||
|
||||
---
|
||||
parent: long text where the cursor has to be clamped after delete
|
||||
left: long text where the cursor has to be clamped after delete|
|
||||
right: long text where the cursor
|
||||
expected: long text where the cursor|
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
# One deleted a large range, the other deleted subranges and inserted as well
|
||||
parent: original_1 original_2 original_3 original_4 original_5
|
||||
left: original_1 original_5
|
||||
right: original_1 edit_1 original_3 edit_2 original_5
|
||||
expected: original_1 edit_1 edit_2 original_5
|
||||
|
||||
---
|
||||
# One deleted a large range, the other inserted and deleted a partially overlapping range
|
||||
parent: original_1 original_2 original_3 original_4 original_5
|
||||
left: original_1 original_5
|
||||
right: original_1 edit_1 original_3 edit_2
|
||||
expected: original_1 edit_1 edit_2
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
# Both inserted the same prefix; this should get deduplicateed
|
||||
parent: "hi "
|
||||
left: "hi there "
|
||||
right: "hi there my friend "
|
||||
expected: "hi there my friend "
|
||||
|
||||
---
|
||||
# The prefix of the 2nd appears on the 1st so it shouldn't get duplicatelicated
|
||||
parent: "hi "
|
||||
left: "hi there you "
|
||||
right: "hi there my friend "
|
||||
expected: "hi there my friend you "
|
||||
|
||||
---
|
||||
parent: a
|
||||
left: a b c
|
||||
right: a b c d
|
||||
expected: a b c d
|
||||
|
||||
---
|
||||
parent: a
|
||||
left: abc
|
||||
right: abcd
|
||||
expected: abcabcd
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
parent: Hello!
|
||||
left: |
|
||||
Hello there!
|
||||
|
||||
How are you?
|
||||
|
||||
right: |
|
||||
Hello there!
|
||||
|
||||
Best,
|
||||
Andras
|
||||
|
||||
expected: |
|
||||
Hello there!
|
||||
|
||||
Best,
|
||||
Andras
|
||||
|
||||
|
||||
How are you?
|
||||
|
||||
---
|
||||
parent: |
|
||||
- my list
|
||||
- 2nd item
|
||||
- 3rd item
|
||||
|
||||
left: |
|
||||
- my list
|
||||
- 2nd item
|
||||
- nested list
|
||||
- very nested list
|
||||
- 3rd item
|
||||
|
||||
right: |
|
||||
- my list
|
||||
- nested list
|
||||
- 2nd item
|
||||
- 3rd item
|
||||
- another nested list
|
||||
|
||||
expected: |
|
||||
- my list
|
||||
- nested list
|
||||
- 2nd item
|
||||
- nested list
|
||||
- very nested list
|
||||
- 3rd item
|
||||
- another nested list
|
||||
|
||||
---
|
||||
parent: |
|
||||
a
|
||||
a
|
||||
left: |
|
||||
a|
|
||||
a
|
||||
right: |
|
||||
a|
|
||||
a
|
||||
expected: |
|
||||
a||
|
||||
a
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
# Both replaced one token but the tokens are different
|
||||
parent: original_1 original_2 original_3
|
||||
left: original_1 edit_1| original_3
|
||||
right: original_1 original_2| edit_2
|
||||
expected: original_1 edit_1|| edit_2
|
||||
|
||||
---
|
||||
# Both replace the same token with the same value
|
||||
parent: original_1 original_2 original_3
|
||||
left: original_1 edit_1| original_3
|
||||
right: original_1 edit_1 original_3|
|
||||
expected: original_1 edit_1| original_3|
|
||||
|
||||
---
|
||||
# Both replace the same token with different value
|
||||
parent: original_1 original_2 original_3
|
||||
left: original_1 edit_1| original_3
|
||||
right: original_1 conflicting_edit_1| original_3
|
||||
expected: original_1 conflicting_edit_1| edit_1| original_3
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
parent: Meeting at 2pm in 会议室
|
||||
left: Meeting at |3pm in 会议室
|
||||
right: Team meeting at 2pm in conference room|
|
||||
expected: Team meeting at |3pm in conference room|
|
||||
|
||||
---
|
||||
parent: " "
|
||||
left: "it’|s utf-8!"
|
||||
right: " "
|
||||
expected: "it’|s utf-8!"
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
parent: You're Annual Savings Statement is available in our online portal
|
||||
left: Your| annual record is available in our online portal|
|
||||
right: You're Annual Savings information| is available online
|
||||
expected: Your| annual record information| is available online|
|
||||
|
||||
---
|
||||
parent: Party A shall pay Party B
|
||||
left: Party C shall pay Party B
|
||||
right: Party A shall receive from Party B
|
||||
expected: Party C shall receive from Party B
|
||||
|
||||
---
|
||||
parent:
|
||||
left: hi my friend|
|
||||
right: hi there|
|
||||
expected: hi my friend| there|
|
||||
|
||||
---
|
||||
parent: ""
|
||||
left: ""
|
||||
right: ""
|
||||
expected: ""
|
||||
|
||||
---
|
||||
parent: ""
|
||||
left: "|"
|
||||
right: "|"
|
||||
expected: "||"
|
||||
|
||||
---
|
||||
parent: Buy milk and eggs
|
||||
left: Buy organic milk| and eggs|
|
||||
right: Buy milk and eggs| and bread
|
||||
expected: Buy organic milk| and eggs|| and bread
|
||||
|
||||
---
|
||||
parent: Send the report to the team
|
||||
left: Send the |detailed report to the |entire |team
|
||||
right: Send the |quarterly |detailed report to the team
|
||||
expected: Send the |detailed |quarterly |detailed report to the |entire |team
|
||||
|
||||
---
|
||||
parent: Ready, Set go
|
||||
left: Ready! Set go|
|
||||
right: Ready, Set, go!|
|
||||
expected: Ready! Set, go!||
|
||||
|
||||
---
|
||||
parent: "Total: $100"
|
||||
left: "Total: |$150"
|
||||
right: "Total: |€100"
|
||||
expected: "Total: |$150 |€100"
|
||||
|
||||
---
|
||||
parent: Start middle end
|
||||
left: Start [important] middle end|
|
||||
right: Start middle [critical] end|
|
||||
expected: Start [important] middle [critical] end||
|
||||
|
||||
---
|
||||
parent: marketplace
|
||||
left: market| place
|
||||
right: market|space
|
||||
expected: market| placemarket|space
|
||||
|
||||
---
|
||||
parent: A B C D
|
||||
left: A X B D|
|
||||
right: A B Y|
|
||||
expected: A X B |Y|
|
||||
|
||||
---
|
||||
parent: Please submit your assignment by Friday
|
||||
left: Please submit your |completed |assignment by Friday
|
||||
right: Please submit your assignment |online |by Friday
|
||||
expected: Please submit your |completed |assignment |online |by Friday
|
||||
|
||||
---
|
||||
parent: "a b "
|
||||
left: "c d "
|
||||
right: "a b c d "
|
||||
expected: "c d c d "
|
||||
|
||||
---
|
||||
parent: a b c d e
|
||||
left: a e|
|
||||
right: a c e|
|
||||
expected: a e||
|
||||
|
||||
---
|
||||
parent: a 0 1 2 b
|
||||
left: a 0 1| 2 b
|
||||
right: a b|
|
||||
expected: a| b|
|
||||
|
||||
---
|
||||
parent: a 0 1 2 b
|
||||
left: "|a b"
|
||||
right: "|a E 1 F b"
|
||||
expected: "||a E F b"
|
||||
|
||||
---
|
||||
parent: a this one delete b
|
||||
left: a b|
|
||||
right: a my one change b|
|
||||
expected: a my change b||
|
||||
|
||||
---
|
||||
parent: this stays, this is one big delete, don't touch this
|
||||
left: this stays, don't touch this|
|
||||
right: this stays, my one change, don't touch this|
|
||||
expected: this stays, my change, don't touch this||
|
||||
|
||||
---
|
||||
parent: 1 2 3 4 5 6
|
||||
left: 1| 6
|
||||
right: 1 2 4|
|
||||
expected: 1||
|
||||
|
||||
---
|
||||
parent: hello world
|
||||
left: hi, world
|
||||
right: hello my friend!
|
||||
expected: hi, my friend!
|
||||
|
||||
---
|
||||
parent: a a
|
||||
left: a
|
||||
right: a
|
||||
expected: a
|
||||
|
|
@ -1,742 +0,0 @@
|
|||
# Reserved Strings
|
||||
#
|
||||
# Strings which may be used elsewhere in code
|
||||
|
||||
undefined
|
||||
undef
|
||||
null
|
||||
NULL
|
||||
(null)
|
||||
nil
|
||||
NIL
|
||||
true
|
||||
false
|
||||
True
|
||||
False
|
||||
TRUE
|
||||
FALSE
|
||||
None
|
||||
hasOwnProperty
|
||||
then
|
||||
constructor
|
||||
\
|
||||
\\
|
||||
|
||||
# Numeric Strings
|
||||
#
|
||||
# Strings which can be interpreted as numeric
|
||||
|
||||
0
|
||||
1
|
||||
1.00
|
||||
$1.00
|
||||
1/2
|
||||
1E2
|
||||
1E02
|
||||
1E+02
|
||||
-1
|
||||
-1.00
|
||||
-$1.00
|
||||
-1/2
|
||||
-1E2
|
||||
-1E02
|
||||
-1E+02
|
||||
1/0
|
||||
0/0
|
||||
-2147483648/-1
|
||||
-9223372036854775808/-1
|
||||
-0
|
||||
-0.0
|
||||
+0
|
||||
+0.0
|
||||
0.00
|
||||
0..0
|
||||
.
|
||||
0.0.0
|
||||
0,00
|
||||
0,,0
|
||||
,
|
||||
0,0,0
|
||||
0.0/0
|
||||
1.0/0.0
|
||||
0.0/0.0
|
||||
1,0/0,0
|
||||
0,0/0,0
|
||||
--1
|
||||
-
|
||||
-.
|
||||
-,
|
||||
999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999
|
||||
NaN
|
||||
Infinity
|
||||
-Infinity
|
||||
INF
|
||||
1#INF
|
||||
-1#IND
|
||||
1#QNAN
|
||||
1#SNAN
|
||||
1#IND
|
||||
0x0
|
||||
0xffffffff
|
||||
0xffffffffffffffff
|
||||
0xabad1dea
|
||||
123456789012345678901234567890123456789
|
||||
1,000.00
|
||||
1 000.00
|
||||
1'000.00
|
||||
1,000,000.00
|
||||
1 000 000.00
|
||||
1'000'000.00
|
||||
1.000,00
|
||||
1 000,00
|
||||
1'000,00
|
||||
1.000.000,00
|
||||
1 000 000,00
|
||||
1'000'000,00
|
||||
01000
|
||||
08
|
||||
09
|
||||
2.2250738585072011e-308
|
||||
|
||||
# Special Characters
|
||||
#
|
||||
# ASCII punctuation. All of these characters may need to be escaped in some
|
||||
# contexts. Divided into three groups based on (US-layout) keyboard position.
|
||||
|
||||
,./;'[]\-=
|
||||
<>?:"{}|_+
|
||||
!@#$%^&*()`~
|
||||
|
||||
# Non-whitespace C0 controls: U+0001 through U+0008, U+000E through U+001F,
|
||||
# and U+007F (DEL)
|
||||
# Often forbidden to appear in various text-based file formats (e.g. XML),
|
||||
# or reused for internal delimiters on the theory that they should never
|
||||
# appear in input.
|
||||
# The next line may appear to be blank or mojibake in some viewers.
|
||||
|
||||
|
||||
# Non-whitespace C1 controls: U+0080 through U+0084 and U+0086 through U+009F.
|
||||
# Commonly misinterpreted as additional graphic characters.
|
||||
# The next line may appear to be blank, mojibake, or dingbats in some viewers.
|
||||
|
||||
|
||||
# Whitespace: all of the characters with category Zs, Zl, or Zp (in Unicode
|
||||
# version 8.0.0), plus U+0009 (HT), U+000B (VT), U+000C (FF), U+0085 (NEL),
|
||||
# and U+200B (ZERO WIDTH SPACE), which are in the C categories but are often
|
||||
# treated as whitespace in some contexts.
|
||||
# This file unfortunately cannot express strings containing
|
||||
# U+0000, U+000A, or U+000D (NUL, LF, CR).
|
||||
# The next line may appear to be blank or mojibake in some viewers.
|
||||
# The next line may be flagged for "trailing whitespace" in some viewers.
|
||||
|
||||
|
||||
# Unicode additional control characters: all of the characters with
|
||||
# general category Cf (in Unicode 8.0.0).
|
||||
# The next line may appear to be blank or mojibake in some viewers.
|
||||
|
||||
|
||||
# "Byte order marks", U+FEFF and U+FFFE, each on its own line.
|
||||
# The next two lines may appear to be blank or mojibake in some viewers.
|
||||
|
||||
|
||||
|
||||
# Unicode Symbols
|
||||
#
|
||||
# Strings which contain common unicode symbols (e.g. smart quotes)
|
||||
|
||||
Ω≈ç√∫˜µ≤≥÷
|
||||
åß∂ƒ©˙∆˚¬…æ
|
||||
œ∑´®†¥¨ˆøπ“‘
|
||||
¡™£¢∞§¶•ªº–≠
|
||||
¸˛Ç◊ı˜Â¯˘¿
|
||||
ÅÍÎÏ˝ÓÔÒÚÆ☃
|
||||
Œ„´‰ˇÁ¨ˆØ∏”’
|
||||
`⁄€‹›fifl‡°·‚—±
|
||||
⅛⅜⅝⅞
|
||||
ЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыьэюя
|
||||
٠١٢٣٤٥٦٧٨٩
|
||||
|
||||
# Unicode Subscript/Superscript/Accents
|
||||
#
|
||||
# Strings which contain unicode subscripts/superscripts; can cause rendering issues
|
||||
|
||||
⁰⁴⁵
|
||||
₀₁₂
|
||||
⁰⁴⁵₀₁₂
|
||||
ด้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็ ด้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็ ด้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็
|
||||
|
||||
# Quotation Marks
|
||||
#
|
||||
# Strings which contain misplaced quotation marks; can cause encoding errors
|
||||
|
||||
'
|
||||
"
|
||||
''
|
||||
""
|
||||
'"'
|
||||
"''''"'"
|
||||
"'"'"''''"
|
||||
<foo val=“bar” />
|
||||
<foo val=“bar” />
|
||||
<foo val=”bar“ />
|
||||
<foo val=`bar' />
|
||||
|
||||
# Two-Byte Characters
|
||||
#
|
||||
# Strings which contain two-byte characters: can cause rendering issues or character-length issues
|
||||
|
||||
田中さんにあげて下さい
|
||||
パーティーへ行かないか
|
||||
和製漢語
|
||||
部落格
|
||||
사회과학원 어학연구소
|
||||
찦차를 타고 온 펲시맨과 쑛다리 똠방각하
|
||||
社會科學院語學研究所
|
||||
울란바토르
|
||||
𠜎𠜱𠝹𠱓𠱸𠲖𠳏
|
||||
|
||||
# Strings which contain two-byte letters: can cause issues with naïve UTF-16 capitalizers which think that 16 bits == 1 character
|
||||
|
||||
𐐜 𐐔𐐇𐐝𐐀𐐡𐐇𐐓 𐐙𐐊𐐡𐐝𐐓/𐐝𐐇𐐗𐐊𐐤𐐔 𐐒𐐋𐐗 𐐒𐐌 𐐜 𐐡𐐀𐐖𐐇𐐤𐐓𐐝 𐐱𐑂 𐑄 𐐔𐐇𐐝𐐀𐐡𐐇𐐓 𐐏𐐆𐐅𐐤𐐆𐐚𐐊𐐡𐐝𐐆𐐓𐐆
|
||||
|
||||
# Special Unicode Characters Union
|
||||
#
|
||||
# A super string recommended by VMware Inc. Globalization Team: can effectively cause rendering issues or character-length issues to validate product globalization readiness.
|
||||
#
|
||||
# 表 CJK_UNIFIED_IDEOGRAPHS (U+8868)
|
||||
# ポ KATAKANA LETTER PO (U+30DD)
|
||||
# あ HIRAGANA LETTER A (U+3042)
|
||||
# A LATIN CAPITAL LETTER A (U+0041)
|
||||
# 鷗 CJK_UNIFIED_IDEOGRAPHS (U+9DD7)
|
||||
# Œ LATIN SMALL LIGATURE OE (U+0153)
|
||||
# é LATIN SMALL LETTER E WITH ACUTE (U+00E9)
|
||||
# B FULLWIDTH LATIN CAPITAL LETTER B (U+FF22)
|
||||
# 逍 CJK_UNIFIED_IDEOGRAPHS (U+900D)
|
||||
# Ü LATIN SMALL LETTER U WITH DIAERESIS (U+00FC)
|
||||
# ß LATIN SMALL LETTER SHARP S (U+00DF)
|
||||
# ª FEMININE ORDINAL INDICATOR (U+00AA)
|
||||
# ą LATIN SMALL LETTER A WITH OGONEK (U+0105)
|
||||
# ñ LATIN SMALL LETTER N WITH TILDE (U+00F1)
|
||||
# 丂 CJK_UNIFIED_IDEOGRAPHS (U+4E02)
|
||||
# 㐀 CJK Ideograph Extension A, First (U+3400)
|
||||
# 𠀀 CJK Ideograph Extension B, First (U+20000)
|
||||
|
||||
表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀
|
||||
|
||||
# Changing length when lowercased
|
||||
#
|
||||
# Characters which increase in length (2 to 3 bytes) when lowercased
|
||||
# Credit: https://twitter.com/jifa/status/625776454479970304
|
||||
|
||||
Ⱥ
|
||||
Ⱦ
|
||||
|
||||
# Japanese Emoticons
|
||||
#
|
||||
# Strings which consists of Japanese-style emoticons which are popular on the web
|
||||
|
||||
ヽ༼ຈل͜ຈ༽ノ ヽ༼ຈل͜ຈ༽ノ
|
||||
(。◕ ∀ ◕。)
|
||||
`ィ(´∀`∩
|
||||
__ロ(,_,*)
|
||||
・( ̄∀ ̄)・:*:
|
||||
゚・✿ヾ╲(。◕‿◕。)╱✿・゚
|
||||
,。・:*:・゜’( ☻ ω ☻ )。・:*:・゜’
|
||||
(╯°□°)╯︵ ┻━┻)
|
||||
(ノಥ益ಥ)ノ ┻━┻
|
||||
┬─┬ノ( º _ ºノ)
|
||||
( ͡° ͜ʖ ͡°)
|
||||
¯\_(ツ)_/¯
|
||||
|
||||
# Emoji
|
||||
#
|
||||
# Strings which contain Emoji; should be the same behavior as two-byte characters, but not always
|
||||
|
||||
😍
|
||||
👩🏽
|
||||
👨🦰 👨🏿🦰 👨🦱 👨🏿🦱 🦹🏿♂️
|
||||
👾 🙇 💁 🙅 🙆 🙋 🙎 🙍
|
||||
🐵 🙈 🙉 🙊
|
||||
❤️ 💔 💌 💕 💞 💓 💗 💖 💘 💝 💟 💜 💛 💚 💙
|
||||
✋🏿 💪🏿 👐🏿 🙌🏿 👏🏿 🙏🏿
|
||||
👨👩👦 👨👩👧👦 👨👨👦 👩👩👧 👨👦 👨👧👦 👩👦 👩👧👦
|
||||
🚾 🆒 🆓 🆕 🆖 🆗 🆙 🏧
|
||||
0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️⃣ 8️⃣ 9️⃣ 🔟
|
||||
|
||||
# Regional Indicator Symbols
|
||||
#
|
||||
# Regional Indicator Symbols can be displayed differently across
|
||||
# fonts, and have a number of special behaviors
|
||||
|
||||
🇺🇸🇷🇺🇸 🇦🇫🇦🇲🇸
|
||||
🇺🇸🇷🇺🇸🇦🇫🇦🇲
|
||||
🇺🇸🇷🇺🇸🇦
|
||||
|
||||
# Unicode Numbers
|
||||
#
|
||||
# Strings which contain unicode numbers; if the code is localized, it should see the input as numeric
|
||||
|
||||
123
|
||||
١٢٣
|
||||
|
||||
# Right-To-Left Strings
|
||||
#
|
||||
# Strings which contain text that should be rendered RTL if possible (e.g. Arabic, Hebrew)
|
||||
|
||||
ثم نفس سقطت وبالتحديد،, جزيرتي باستخدام أن دنو. إذ هنا؟ الستار وتنصيب كان. أهّل ايطاليا، بريطانيا-فرنسا قد أخذ. سليمان، إتفاقية بين ما, يذكر الحدود أي بعد, معاملة بولندا، الإطلاق عل إيو.
|
||||
בְּרֵאשִׁית, בָּרָא אֱלֹהִים, אֵת הַשָּׁמַיִם, וְאֵת הָאָרֶץ
|
||||
הָיְתָהtestالصفحات التّحول
|
||||
﷽
|
||||
ﷺ
|
||||
مُنَاقَشَةُ سُبُلِ اِسْتِخْدَامِ اللُّغَةِ فِي النُّظُمِ الْقَائِمَةِ وَفِيم يَخُصَّ التَّطْبِيقَاتُ الْحاسُوبِيَّةُ،
|
||||
الكل في المجمو عة (5)
|
||||
|
||||
# Ogham Text
|
||||
#
|
||||
# The only unicode alphabet to use a space which isn't empty but should still act like a space.
|
||||
|
||||
᚛ᚄᚓᚐᚋᚒᚄ ᚑᚄᚂᚑᚏᚅ᚜
|
||||
᚛ ᚜
|
||||
|
||||
# Trick Unicode
|
||||
#
|
||||
# Strings which contain unicode with unusual properties (e.g. Right-to-left override) (c.f. http://www.unicode.org/charts/PDF/U2000.pdf)
|
||||
|
||||
test
|
||||
test
|
||||
test
|
||||
testtest
|
||||
test
|
||||
|
||||
# Zalgo Text
|
||||
#
|
||||
# Strings which contain "corrupted" text. The corruption will not appear in non-HTML text, however. (via http://www.eeemo.net)
|
||||
|
||||
Ṱ̺̺̕o͞ ̷i̲̬͇̪͙n̝̗͕v̟̜̘̦͟o̶̙̰̠kè͚̮̺̪̹̱̤ ̖t̝͕̳̣̻̪͞h̼͓̲̦̳̘̲e͇̣̰̦̬͎ ̢̼̻̱̘h͚͎͙̜̣̲ͅi̦̲̣̰̤v̻͍e̺̭̳̪̰-m̢iͅn̖̺̞̲̯̰d̵̼̟͙̩̼̘̳ ̞̥̱̳̭r̛̗̘e͙p͠r̼̞̻̭̗e̺̠̣͟s̘͇̳͍̝͉e͉̥̯̞̲͚̬͜ǹ̬͎͎̟̖͇̤t͍̬̤͓̼̭͘ͅi̪̱n͠g̴͉ ͏͉ͅc̬̟h͡a̫̻̯͘o̫̟̖͍̙̝͉s̗̦̲.̨̹͈̣
|
||||
̡͓̞ͅI̗̘̦͝n͇͇͙v̮̫ok̲̫̙͈i̖͙̭̹̠̞n̡̻̮̣̺g̲͈͙̭͙̬͎ ̰t͔̦h̞̲e̢̤ ͍̬̲͖f̴̘͕̣è͖ẹ̥̩l͖͔͚i͓͚̦͠n͖͍̗͓̳̮g͍ ̨o͚̪͡f̘̣̬ ̖̘͖̟͙̮c҉͔̫͖͓͇͖ͅh̵̤̣͚͔á̗̼͕ͅo̼̣̥s̱͈̺̖̦̻͢.̛̖̞̠̫̰
|
||||
̗̺͖̹̯͓Ṯ̤͍̥͇͈h̲́e͏͓̼̗̙̼̣͔ ͇̜̱̠͓͍ͅN͕͠e̗̱z̘̝̜̺͙p̤̺̹͍̯͚e̠̻̠͜r̨̤͍̺̖͔̖̖d̠̟̭̬̝͟i̦͖̩͓͔̤a̠̗̬͉̙n͚͜ ̻̞̰͚ͅh̵͉i̳̞v̢͇ḙ͎͟-҉̭̩̼͔m̤̭̫i͕͇̝̦n̗͙ḍ̟ ̯̲͕͞ǫ̟̯̰̲͙̻̝f ̪̰̰̗̖̭̘͘c̦͍̲̞͍̩̙ḥ͚a̮͎̟̙͜ơ̩̹͎s̤.̝̝ ҉Z̡̖̜͖̰̣͉̜a͖̰͙̬͡l̲̫̳͍̩g̡̟̼̱͚̞̬ͅo̗͜.̟
|
||||
̦H̬̤̗̤͝e͜ ̜̥̝̻͍̟́w̕h̖̯͓o̝͙̖͎̱̮ ҉̺̙̞̟͈W̷̼̭a̺̪͍į͈͕̭͙̯̜t̶̼̮s̘͙͖̕ ̠̫̠B̻͍͙͉̳ͅe̵h̵̬͇̫͙i̹͓̳̳̮͎̫̕n͟d̴̪̜̖ ̰͉̩͇͙̲͞ͅT͖̼͓̪͢h͏͓̮̻e̬̝̟ͅ ̤̹̝W͙̞̝͔͇͝ͅa͏͓͔̹̼̣l̴͔̰̤̟͔ḽ̫.͕
|
||||
Z̮̞̠͙͔ͅḀ̗̞͈̻̗Ḷ͙͎̯̹̞͓G̻O̭̗̮
|
||||
|
||||
# Unicode Upsidedown
|
||||
#
|
||||
# Strings which contain unicode with an "upsidedown" effect (via http://www.upsidedowntext.com)
|
||||
|
||||
˙ɐnbᴉlɐ ɐuƃɐɯ ǝɹolop ʇǝ ǝɹoqɐl ʇn ʇunpᴉpᴉɔuᴉ ɹodɯǝʇ poɯsnᴉǝ op pǝs 'ʇᴉlǝ ƃuᴉɔsᴉdᴉpɐ ɹnʇǝʇɔǝsuoɔ 'ʇǝɯɐ ʇᴉs ɹolop ɯnsdᴉ ɯǝɹo˥
|
||||
00˙Ɩ$-
|
||||
|
||||
# Unicode font
|
||||
#
|
||||
# Strings which contain bold/italic/etc. versions of normal characters
|
||||
|
||||
The quick brown fox jumps over the lazy dog
|
||||
𝐓𝐡𝐞 𝐪𝐮𝐢𝐜𝐤 𝐛𝐫𝐨𝐰𝐧 𝐟𝐨𝐱 𝐣𝐮𝐦𝐩𝐬 𝐨𝐯𝐞𝐫 𝐭𝐡𝐞 𝐥𝐚𝐳𝐲 𝐝𝐨𝐠
|
||||
𝕿𝖍𝖊 𝖖𝖚𝖎𝖈𝖐 𝖇𝖗𝖔𝖜𝖓 𝖋𝖔𝖝 𝖏𝖚𝖒𝖕𝖘 𝖔𝖛𝖊𝖗 𝖙𝖍𝖊 𝖑𝖆𝖟𝖞 𝖉𝖔𝖌
|
||||
𝑻𝒉𝒆 𝒒𝒖𝒊𝒄𝒌 𝒃𝒓𝒐𝒘𝒏 𝒇𝒐𝒙 𝒋𝒖𝒎𝒑𝒔 𝒐𝒗𝒆𝒓 𝒕𝒉𝒆 𝒍𝒂𝒛𝒚 𝒅𝒐𝒈
|
||||
𝓣𝓱𝓮 𝓺𝓾𝓲𝓬𝓴 𝓫𝓻𝓸𝔀𝓷 𝓯𝓸𝔁 𝓳𝓾𝓶𝓹𝓼 𝓸𝓿𝓮𝓻 𝓽𝓱𝓮 𝓵𝓪𝔃𝔂 𝓭𝓸𝓰
|
||||
𝕋𝕙𝕖 𝕢𝕦𝕚𝕔𝕜 𝕓𝕣𝕠𝕨𝕟 𝕗𝕠𝕩 𝕛𝕦𝕞𝕡𝕤 𝕠𝕧𝕖𝕣 𝕥𝕙𝕖 𝕝𝕒𝕫𝕪 𝕕𝕠𝕘
|
||||
𝚃𝚑𝚎 𝚚𝚞𝚒𝚌𝚔 𝚋𝚛𝚘𝚠𝚗 𝚏𝚘𝚡 𝚓𝚞𝚖𝚙𝚜 𝚘𝚟𝚎𝚛 𝚝𝚑𝚎 𝚕𝚊𝚣𝚢 𝚍𝚘𝚐
|
||||
⒯⒣⒠ ⒬⒰⒤⒞⒦ ⒝⒭⒪⒲⒩ ⒡⒪⒳ ⒥⒰⒨⒫⒮ ⒪⒱⒠⒭ ⒯⒣⒠ ⒧⒜⒵⒴ ⒟⒪⒢
|
||||
|
||||
# Script Injection
|
||||
#
|
||||
# Strings which attempt to invoke a benign script injection; shows vulnerability to XSS
|
||||
|
||||
<script>alert(0)</script>
|
||||
<script>alert('1');</script>
|
||||
<img src=x onerror=alert(2) />
|
||||
<svg><script>123<1>alert(3)</script>
|
||||
"><script>alert(4)</script>
|
||||
'><script>alert(5)</script>
|
||||
><script>alert(6)</script>
|
||||
</script><script>alert(7)</script>
|
||||
< / script >< script >alert(8)< / script >
|
||||
onfocus=JaVaSCript:alert(9) autofocus
|
||||
" onfocus=JaVaSCript:alert(10) autofocus
|
||||
' onfocus=JaVaSCript:alert(11) autofocus
|
||||
<script>alert(12)</script>
|
||||
<sc<script>ript>alert(13)</sc</script>ript>
|
||||
--><script>alert(14)</script>
|
||||
";alert(15);t="
|
||||
';alert(16);t='
|
||||
JavaSCript:alert(17)
|
||||
;alert(18);
|
||||
src=JaVaSCript:prompt(19)
|
||||
"><script>alert(20);</script x="
|
||||
'><script>alert(21);</script x='
|
||||
><script>alert(22);</script x=
|
||||
" autofocus onkeyup="javascript:alert(23)
|
||||
' autofocus onkeyup='javascript:alert(24)
|
||||
<script\x20type="text/javascript">javascript:alert(25);</script>
|
||||
<script\x3Etype="text/javascript">javascript:alert(26);</script>
|
||||
<script\x0Dtype="text/javascript">javascript:alert(27);</script>
|
||||
<script\x09type="text/javascript">javascript:alert(28);</script>
|
||||
<script\x0Ctype="text/javascript">javascript:alert(29);</script>
|
||||
<script\x2Ftype="text/javascript">javascript:alert(30);</script>
|
||||
<script\x0Atype="text/javascript">javascript:alert(31);</script>
|
||||
'`"><\x3Cscript>javascript:alert(32)</script>
|
||||
'`"><\x00script>javascript:alert(33)</script>
|
||||
ABC<div style="x\x3Aexpression(javascript:alert(34)">DEF
|
||||
ABC<div style="x:expression\x5C(javascript:alert(35)">DEF
|
||||
ABC<div style="x:expression\x00(javascript:alert(36)">DEF
|
||||
ABC<div style="x:exp\x00ression(javascript:alert(37)">DEF
|
||||
ABC<div style="x:exp\x5Cression(javascript:alert(38)">DEF
|
||||
ABC<div style="x:\x0Aexpression(javascript:alert(39)">DEF
|
||||
ABC<div style="x:\x09expression(javascript:alert(40)">DEF
|
||||
ABC<div style="x:\xE3\x80\x80expression(javascript:alert(41)">DEF
|
||||
ABC<div style="x:\xE2\x80\x84expression(javascript:alert(42)">DEF
|
||||
ABC<div style="x:\xC2\xA0expression(javascript:alert(43)">DEF
|
||||
ABC<div style="x:\xE2\x80\x80expression(javascript:alert(44)">DEF
|
||||
ABC<div style="x:\xE2\x80\x8Aexpression(javascript:alert(45)">DEF
|
||||
ABC<div style="x:\x0Dexpression(javascript:alert(46)">DEF
|
||||
ABC<div style="x:\x0Cexpression(javascript:alert(47)">DEF
|
||||
ABC<div style="x:\xE2\x80\x87expression(javascript:alert(48)">DEF
|
||||
ABC<div style="x:\xEF\xBB\xBFexpression(javascript:alert(49)">DEF
|
||||
ABC<div style="x:\x20expression(javascript:alert(50)">DEF
|
||||
ABC<div style="x:\xE2\x80\x88expression(javascript:alert(51)">DEF
|
||||
ABC<div style="x:\x00expression(javascript:alert(52)">DEF
|
||||
ABC<div style="x:\xE2\x80\x8Bexpression(javascript:alert(53)">DEF
|
||||
ABC<div style="x:\xE2\x80\x86expression(javascript:alert(54)">DEF
|
||||
ABC<div style="x:\xE2\x80\x85expression(javascript:alert(55)">DEF
|
||||
ABC<div style="x:\xE2\x80\x82expression(javascript:alert(56)">DEF
|
||||
ABC<div style="x:\x0Bexpression(javascript:alert(57)">DEF
|
||||
ABC<div style="x:\xE2\x80\x81expression(javascript:alert(58)">DEF
|
||||
ABC<div style="x:\xE2\x80\x83expression(javascript:alert(59)">DEF
|
||||
ABC<div style="x:\xE2\x80\x89expression(javascript:alert(60)">DEF
|
||||
<a href="\x0Bjavascript:javascript:alert(61)" id="fuzzelement1">test</a>
|
||||
<a href="\x0Fjavascript:javascript:alert(62)" id="fuzzelement1">test</a>
|
||||
<a href="\xC2\xA0javascript:javascript:alert(63)" id="fuzzelement1">test</a>
|
||||
<a href="\x05javascript:javascript:alert(64)" id="fuzzelement1">test</a>
|
||||
<a href="\xE1\xA0\x8Ejavascript:javascript:alert(65)" id="fuzzelement1">test</a>
|
||||
<a href="\x18javascript:javascript:alert(66)" id="fuzzelement1">test</a>
|
||||
<a href="\x11javascript:javascript:alert(67)" id="fuzzelement1">test</a>
|
||||
<a href="\xE2\x80\x88javascript:javascript:alert(68)" id="fuzzelement1">test</a>
|
||||
<a href="\xE2\x80\x89javascript:javascript:alert(69)" id="fuzzelement1">test</a>
|
||||
<a href="\xE2\x80\x80javascript:javascript:alert(70)" id="fuzzelement1">test</a>
|
||||
<a href="\x17javascript:javascript:alert(71)" id="fuzzelement1">test</a>
|
||||
<a href="\x03javascript:javascript:alert(72)" id="fuzzelement1">test</a>
|
||||
<a href="\x0Ejavascript:javascript:alert(73)" id="fuzzelement1">test</a>
|
||||
<a href="\x1Ajavascript:javascript:alert(74)" id="fuzzelement1">test</a>
|
||||
<a href="\x00javascript:javascript:alert(75)" id="fuzzelement1">test</a>
|
||||
<a href="\x10javascript:javascript:alert(76)" id="fuzzelement1">test</a>
|
||||
<a href="\xE2\x80\x82javascript:javascript:alert(77)" id="fuzzelement1">test</a>
|
||||
<a href="\x20javascript:javascript:alert(78)" id="fuzzelement1">test</a>
|
||||
<a href="\x13javascript:javascript:alert(79)" id="fuzzelement1">test</a>
|
||||
<a href="\x09javascript:javascript:alert(80)" id="fuzzelement1">test</a>
|
||||
<a href="\xE2\x80\x8Ajavascript:javascript:alert(81)" id="fuzzelement1">test</a>
|
||||
<a href="\x14javascript:javascript:alert(82)" id="fuzzelement1">test</a>
|
||||
<a href="\x19javascript:javascript:alert(83)" id="fuzzelement1">test</a>
|
||||
<a href="\xE2\x80\xAFjavascript:javascript:alert(84)" id="fuzzelement1">test</a>
|
||||
<a href="\x1Fjavascript:javascript:alert(85)" id="fuzzelement1">test</a>
|
||||
<a href="\xE2\x80\x81javascript:javascript:alert(86)" id="fuzzelement1">test</a>
|
||||
<a href="\x1Djavascript:javascript:alert(87)" id="fuzzelement1">test</a>
|
||||
<a href="\xE2\x80\x87javascript:javascript:alert(88)" id="fuzzelement1">test</a>
|
||||
<a href="\x07javascript:javascript:alert(89)" id="fuzzelement1">test</a>
|
||||
<a href="\xE1\x9A\x80javascript:javascript:alert(90)" id="fuzzelement1">test</a>
|
||||
<a href="\xE2\x80\x83javascript:javascript:alert(91)" id="fuzzelement1">test</a>
|
||||
<a href="\x04javascript:javascript:alert(92)" id="fuzzelement1">test</a>
|
||||
<a href="\x01javascript:javascript:alert(93)" id="fuzzelement1">test</a>
|
||||
<a href="\x08javascript:javascript:alert(94)" id="fuzzelement1">test</a>
|
||||
<a href="\xE2\x80\x84javascript:javascript:alert(95)" id="fuzzelement1">test</a>
|
||||
<a href="\xE2\x80\x86javascript:javascript:alert(96)" id="fuzzelement1">test</a>
|
||||
<a href="\xE3\x80\x80javascript:javascript:alert(97)" id="fuzzelement1">test</a>
|
||||
<a href="\x12javascript:javascript:alert(98)" id="fuzzelement1">test</a>
|
||||
<a href="\x0Djavascript:javascript:alert(99)" id="fuzzelement1">test</a>
|
||||
<a href="\x0Ajavascript:javascript:alert(100)" id="fuzzelement1">test</a>
|
||||
<a href="\x0Cjavascript:javascript:alert(101)" id="fuzzelement1">test</a>
|
||||
<a href="\x15javascript:javascript:alert(102)" id="fuzzelement1">test</a>
|
||||
<a href="\xE2\x80\xA8javascript:javascript:alert(103)" id="fuzzelement1">test</a>
|
||||
<a href="\x16javascript:javascript:alert(104)" id="fuzzelement1">test</a>
|
||||
<a href="\x02javascript:javascript:alert(105)" id="fuzzelement1">test</a>
|
||||
<a href="\x1Bjavascript:javascript:alert(106)" id="fuzzelement1">test</a>
|
||||
<a href="\x06javascript:javascript:alert(107)" id="fuzzelement1">test</a>
|
||||
<a href="\xE2\x80\xA9javascript:javascript:alert(108)" id="fuzzelement1">test</a>
|
||||
<a href="\xE2\x80\x85javascript:javascript:alert(109)" id="fuzzelement1">test</a>
|
||||
<a href="\x1Ejavascript:javascript:alert(110)" id="fuzzelement1">test</a>
|
||||
<a href="\xE2\x81\x9Fjavascript:javascript:alert(111)" id="fuzzelement1">test</a>
|
||||
<a href="\x1Cjavascript:javascript:alert(112)" id="fuzzelement1">test</a>
|
||||
<a href="javascript\x00:javascript:alert(113)" id="fuzzelement1">test</a>
|
||||
<a href="javascript\x3A:javascript:alert(114)" id="fuzzelement1">test</a>
|
||||
<a href="javascript\x09:javascript:alert(115)" id="fuzzelement1">test</a>
|
||||
<a href="javascript\x0D:javascript:alert(116)" id="fuzzelement1">test</a>
|
||||
<a href="javascript\x0A:javascript:alert(117)" id="fuzzelement1">test</a>
|
||||
`"'><img src=xxx:x \x0Aonerror=javascript:alert(118)>
|
||||
`"'><img src=xxx:x \x22onerror=javascript:alert(119)>
|
||||
`"'><img src=xxx:x \x0Bonerror=javascript:alert(120)>
|
||||
`"'><img src=xxx:x \x0Donerror=javascript:alert(121)>
|
||||
`"'><img src=xxx:x \x2Fonerror=javascript:alert(122)>
|
||||
`"'><img src=xxx:x \x09onerror=javascript:alert(123)>
|
||||
`"'><img src=xxx:x \x0Conerror=javascript:alert(124)>
|
||||
`"'><img src=xxx:x \x00onerror=javascript:alert(125)>
|
||||
`"'><img src=xxx:x \x27onerror=javascript:alert(126)>
|
||||
`"'><img src=xxx:x \x20onerror=javascript:alert(127)>
|
||||
"`'><script>\x3Bjavascript:alert(128)</script>
|
||||
"`'><script>\x0Djavascript:alert(129)</script>
|
||||
"`'><script>\xEF\xBB\xBFjavascript:alert(130)</script>
|
||||
"`'><script>\xE2\x80\x81javascript:alert(131)</script>
|
||||
"`'><script>\xE2\x80\x84javascript:alert(132)</script>
|
||||
"`'><script>\xE3\x80\x80javascript:alert(133)</script>
|
||||
"`'><script>\x09javascript:alert(134)</script>
|
||||
"`'><script>\xE2\x80\x89javascript:alert(135)</script>
|
||||
"`'><script>\xE2\x80\x85javascript:alert(136)</script>
|
||||
"`'><script>\xE2\x80\x88javascript:alert(137)</script>
|
||||
"`'><script>\x00javascript:alert(138)</script>
|
||||
"`'><script>\xE2\x80\xA8javascript:alert(139)</script>
|
||||
"`'><script>\xE2\x80\x8Ajavascript:alert(140)</script>
|
||||
"`'><script>\xE1\x9A\x80javascript:alert(141)</script>
|
||||
"`'><script>\x0Cjavascript:alert(142)</script>
|
||||
"`'><script>\x2Bjavascript:alert(143)</script>
|
||||
"`'><script>\xF0\x90\x96\x9Ajavascript:alert(144)</script>
|
||||
"`'><script>-javascript:alert(145)</script>
|
||||
"`'><script>\x0Ajavascript:alert(146)</script>
|
||||
"`'><script>\xE2\x80\xAFjavascript:alert(147)</script>
|
||||
"`'><script>\x7Ejavascript:alert(148)</script>
|
||||
"`'><script>\xE2\x80\x87javascript:alert(149)</script>
|
||||
"`'><script>\xE2\x81\x9Fjavascript:alert(150)</script>
|
||||
"`'><script>\xE2\x80\xA9javascript:alert(151)</script>
|
||||
"`'><script>\xC2\x85javascript:alert(152)</script>
|
||||
"`'><script>\xEF\xBF\xAEjavascript:alert(153)</script>
|
||||
"`'><script>\xE2\x80\x83javascript:alert(154)</script>
|
||||
"`'><script>\xE2\x80\x8Bjavascript:alert(155)</script>
|
||||
"`'><script>\xEF\xBF\xBEjavascript:alert(156)</script>
|
||||
"`'><script>\xE2\x80\x80javascript:alert(157)</script>
|
||||
"`'><script>\x21javascript:alert(158)</script>
|
||||
"`'><script>\xE2\x80\x82javascript:alert(159)</script>
|
||||
"`'><script>\xE2\x80\x86javascript:alert(160)</script>
|
||||
"`'><script>\xE1\xA0\x8Ejavascript:alert(161)</script>
|
||||
"`'><script>\x0Bjavascript:alert(162)</script>
|
||||
"`'><script>\x20javascript:alert(163)</script>
|
||||
"`'><script>\xC2\xA0javascript:alert(164)</script>
|
||||
<img \x00src=x onerror="alert(165)">
|
||||
<img \x47src=x onerror="javascript:alert(166)">
|
||||
<img \x11src=x onerror="javascript:alert(167)">
|
||||
<img \x12src=x onerror="javascript:alert(168)">
|
||||
<img\x47src=x onerror="javascript:alert(169)">
|
||||
<img\x10src=x onerror="javascript:alert(170)">
|
||||
<img\x13src=x onerror="javascript:alert(171)">
|
||||
<img\x32src=x onerror="javascript:alert(172)">
|
||||
<img\x47src=x onerror="javascript:alert(173)">
|
||||
<img\x11src=x onerror="javascript:alert(174)">
|
||||
<img \x47src=x onerror="javascript:alert(175)">
|
||||
<img \x34src=x onerror="javascript:alert(176)">
|
||||
<img \x39src=x onerror="javascript:alert(177)">
|
||||
<img \x00src=x onerror="javascript:alert(178)">
|
||||
<img src\x09=x onerror="javascript:alert(179)">
|
||||
<img src\x10=x onerror="javascript:alert(180)">
|
||||
<img src\x13=x onerror="javascript:alert(181)">
|
||||
<img src\x32=x onerror="javascript:alert(182)">
|
||||
<img src\x12=x onerror="javascript:alert(183)">
|
||||
<img src\x11=x onerror="javascript:alert(184)">
|
||||
<img src\x00=x onerror="javascript:alert(185)">
|
||||
<img src\x47=x onerror="javascript:alert(186)">
|
||||
<img src=x\x09onerror="javascript:alert(187)">
|
||||
<img src=x\x10onerror="javascript:alert(188)">
|
||||
<img src=x\x11onerror="javascript:alert(189)">
|
||||
<img src=x\x12onerror="javascript:alert(190)">
|
||||
<img src=x\x13onerror="javascript:alert(191)">
|
||||
<img[a][b][c]src[d]=x[e]onerror=[f]"alert(192)">
|
||||
<img src=x onerror=\x09"javascript:alert(193)">
|
||||
<img src=x onerror=\x10"javascript:alert(194)">
|
||||
<img src=x onerror=\x11"javascript:alert(195)">
|
||||
<img src=x onerror=\x12"javascript:alert(196)">
|
||||
<img src=x onerror=\x32"javascript:alert(197)">
|
||||
<img src=x onerror=\x00"javascript:alert(198)">
|
||||
<a href=javascript:javascript:alert(199)>XXX</a>
|
||||
<img src="x` `<script>javascript:alert(200)</script>"` `>
|
||||
<img src onerror /" '"= alt=javascript:alert(201)//">
|
||||
<title onpropertychange=javascript:alert(202)></title><title title=>
|
||||
<a href=http://foo.bar/#x=`y></a><img alt="`><img src=x:x onerror=javascript:alert(203)></a>">
|
||||
<!--[if]><script>javascript:alert(204)</script -->
|
||||
<!--[if<img src=x onerror=javascript:alert(205)//]> -->
|
||||
<script src="/\%(jscript)s"></script>
|
||||
<script src="\\%(jscript)s"></script>
|
||||
<IMG """><SCRIPT>alert("206")</SCRIPT>">
|
||||
<IMG SRC=javascript:alert(String.fromCharCode(50,48,55))>
|
||||
<IMG SRC=# onmouseover="alert('208')">
|
||||
<IMG SRC= onmouseover="alert('209')">
|
||||
<IMG onmouseover="alert('210')">
|
||||
<IMG SRC=javascript:alert('211')>
|
||||
<IMG SRC=javascript:alert('212')>
|
||||
<IMG SRC=javascript:alert('213')>
|
||||
<IMG SRC="jav ascript:alert('214');">
|
||||
<IMG SRC="jav	ascript:alert('215');">
|
||||
<IMG SRC="jav
ascript:alert('216');">
|
||||
<IMG SRC="jav
ascript:alert('217');">
|
||||
perl -e 'print "<IMG SRC=java\0script:alert(\"218\")>";' > out
|
||||
<IMG SRC="  javascript:alert('219');">
|
||||
<SCRIPT/XSS SRC="http://ha.ckers.org/xss.js"></SCRIPT>
|
||||
<BODY onload!#$%&()*~+-_.,:;?@[/|\]^`=alert("220")>
|
||||
<SCRIPT/SRC="http://ha.ckers.org/xss.js"></SCRIPT>
|
||||
<<SCRIPT>alert("221");//<</SCRIPT>
|
||||
<SCRIPT SRC=http://ha.ckers.org/xss.js?< B >
|
||||
<SCRIPT SRC=//ha.ckers.org/.j>
|
||||
<IMG SRC="javascript:alert('222')"
|
||||
<iframe src=http://ha.ckers.org/scriptlet.html <
|
||||
\";alert('223');//
|
||||
<u oncopy=alert()> Copy me</u>
|
||||
<i onwheel=alert(224)> Scroll over me </i>
|
||||
<plaintext>
|
||||
http://a/%%30%30
|
||||
</textarea><script>alert(225)</script>
|
||||
|
||||
# SQL Injection
|
||||
#
|
||||
# Strings which can cause a SQL injection if inputs are not sanitized
|
||||
|
||||
1;DROP TABLE users
|
||||
1'; DROP TABLE users-- 1
|
||||
' OR 1=1 -- 1
|
||||
' OR '1'='1
|
||||
'; EXEC sp_MSForEachTable 'DROP TABLE ?'; --
|
||||
|
||||
%
|
||||
_
|
||||
|
||||
# Server Code Injection
|
||||
#
|
||||
# Strings which can cause user to run code on server as a privileged user (c.f. https://news.ycombinator.com/item?id=7665153)
|
||||
|
||||
-
|
||||
--
|
||||
--version
|
||||
--help
|
||||
$USER
|
||||
/dev/null; touch /tmp/blns.fail ; echo
|
||||
`touch /tmp/blns.fail`
|
||||
$(touch /tmp/blns.fail)
|
||||
@{[system "touch /tmp/blns.fail"]}
|
||||
|
||||
# Command Injection (Ruby)
|
||||
#
|
||||
# Strings which can call system commands within Ruby/Rails applications
|
||||
|
||||
eval("puts 'hello world'")
|
||||
System("ls -al /")
|
||||
`ls -al /`
|
||||
Kernel.exec("ls -al /")
|
||||
Kernel.exit(1)
|
||||
%x('ls -al /')
|
||||
|
||||
# XXE Injection (XML)
|
||||
#
|
||||
# String which can reveal system files when parsed by a badly configured XML parser
|
||||
|
||||
<?xml version="1.0" encoding="ISO-8859-1"?><!DOCTYPE foo [ <!ELEMENT foo ANY ><!ENTITY xxe SYSTEM "file:///etc/passwd" >]><foo>&xxe;</foo>
|
||||
|
||||
# Unwanted Interpolation
|
||||
#
|
||||
# Strings which can be accidentally expanded into different strings if evaluated in the wrong context, e.g. used as a printf format string or via Perl or shell eval. Might expose sensitive data from the program doing the interpolation, or might just represent the wrong string.
|
||||
|
||||
$HOME
|
||||
$ENV{'HOME'}
|
||||
%d
|
||||
%s%s%s%s%s
|
||||
{0}
|
||||
%*.*s
|
||||
%@
|
||||
%n
|
||||
File:///
|
||||
|
||||
# File Inclusion
|
||||
#
|
||||
# Strings which can cause user to pull in files that should not be a part of a web server
|
||||
|
||||
../../../../../../../../../../../etc/passwd%00
|
||||
../../../../../../../../../../../etc/hosts
|
||||
|
||||
# Known CVEs and Vulnerabilities
|
||||
#
|
||||
# Strings that test for known vulnerabilities
|
||||
|
||||
() { 0; }; touch /tmp/blns.shellshock1.fail;
|
||||
() { _; } >_[$($())] { touch /tmp/blns.shellshock2.fail; }
|
||||
<<< %s(un='%s') = %u
|
||||
+++ATH0
|
||||
|
||||
# MSDOS/Windows Special Filenames
|
||||
#
|
||||
# Strings which are reserved characters in MSDOS/Windows
|
||||
|
||||
CON
|
||||
PRN
|
||||
AUX
|
||||
CLOCK$
|
||||
NUL
|
||||
A:
|
||||
ZZ:
|
||||
COM1
|
||||
LPT1
|
||||
LPT2
|
||||
LPT3
|
||||
COM2
|
||||
COM3
|
||||
COM4
|
||||
|
||||
# IRC specific strings
|
||||
#
|
||||
# Strings that may occur on IRC clients that make security products freak out
|
||||
|
||||
DCC SEND STARTKEYLOGGER 0 0 0
|
||||
|
||||
# Scunthorpe Problem
|
||||
#
|
||||
# Innocuous strings which may be blocked by profanity filters (https://en.wikipedia.org/wiki/Scunthorpe_problem)
|
||||
|
||||
Scunthorpe General Hospital
|
||||
Penistone Community Church
|
||||
Lightwater Country Park
|
||||
Jimmy Clitheroe
|
||||
Horniman Museum
|
||||
shitake mushrooms
|
||||
RomansInSussex.co.uk
|
||||
http://www.cum.qc.ca/
|
||||
Craig Cockburn, Software Specialist
|
||||
Linda Callahan
|
||||
Dr. Herman I. Libshitz
|
||||
magna cum laude
|
||||
Super Bowl XXX
|
||||
medieval erection of parapets
|
||||
evaluate
|
||||
mocha
|
||||
expression
|
||||
Arsenal canal
|
||||
classic
|
||||
Tyson Gay
|
||||
Dick Van Dyke
|
||||
basement
|
||||
|
||||
# Human injection
|
||||
#
|
||||
# Strings which may cause human to reinterpret worldview
|
||||
|
||||
If you're reading this, you've been in a coma for almost 20 years now. We're trying a new technique. We don't know where this message will end up in your dream, but we hope it works. Please wake up, we miss you.
|
||||
|
||||
# Terminal escape codes
|
||||
#
|
||||
# Strings which punish the fools who use cat/type on this file
|
||||
|
||||
Roses are [0;31mred[0m, violets are [0;34mblue. Hope you enjoy terminal hue
|
||||
But now...[20Cfor my greatest trick...[8m
|
||||
The quick brown fox... [Beeeep]
|
||||
|
||||
# iOS Vulnerabilities
|
||||
#
|
||||
# Strings which crashed iMessage in various versions of iOS
|
||||
|
||||
Powerلُلُصّبُلُلصّبُررً ॣ ॣh ॣ ॣ冗
|
||||
🏳0🌈️
|
||||
జ్ఞా
|
||||
|
||||
# Persian special characters
|
||||
#
|
||||
# This is a four characters string which includes Persian special characters (گچپژ)
|
||||
|
||||
گچپژ
|
||||
|
||||
# jinja2 injection
|
||||
#
|
||||
# first one is supposed to raise "MemoryError" exception
|
||||
# second, obviously, prints contents of /etc/passwd
|
||||
|
||||
{% print 'x' * 64 * 1024**3 %}
|
||||
{{ "".__class__.__mro__[2].__subclasses__()[40]("/etc/passwd").read() }}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,76 +0,0 @@
|
|||
mod example_document;
|
||||
|
||||
use std::{fs, path::Path};
|
||||
|
||||
use example_document::ExampleDocument;
|
||||
use reconcile::{reconcile, reconcile_with_cursors};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[test]
|
||||
fn test_document_one_way_without_cursors() {
|
||||
for doc in &get_all_documents() {
|
||||
doc.assert_eq_without_cursors(&reconcile(
|
||||
&doc.parent(),
|
||||
&doc.left().text,
|
||||
&doc.right().text,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_document_one_way_with_cursors() {
|
||||
for doc in &get_all_documents() {
|
||||
doc.assert_eq(&reconcile_with_cursors(
|
||||
&doc.parent(),
|
||||
doc.left(),
|
||||
doc.right(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_document_inverse_way_without_cursors() {
|
||||
for doc in &get_all_documents() {
|
||||
doc.assert_eq_without_cursors(&reconcile(
|
||||
&doc.parent(),
|
||||
&doc.right().text,
|
||||
&doc.left().text,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_document_inverse_way_with_cursors() {
|
||||
for doc in &get_all_documents() {
|
||||
doc.assert_eq(&reconcile_with_cursors(
|
||||
&doc.parent(),
|
||||
doc.right(),
|
||||
doc.left(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn get_all_documents() -> Vec<ExampleDocument> {
|
||||
let examples_dir = Path::new("tests/examples");
|
||||
let entries = fs::read_dir(examples_dir)
|
||||
.expect("Failed to read examples directory")
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut documents = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.expect("Failed to read directory entry");
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("yml") {
|
||||
let file = fs::File::open(&path).expect("Failed to open example file");
|
||||
for document in serde_yaml::Deserializer::from_reader(file) {
|
||||
let doc =
|
||||
ExampleDocument::deserialize(document).expect("Failed to deserialize document");
|
||||
documents.push(doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
documents
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
[toolchain]
|
||||
channel = "nightly-2025-06-06"
|
||||
targets = [ "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl" ]
|
||||
profile = "default"
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
imports_granularity = "crate"
|
||||
condense_wildcard_suffixes = true
|
||||
fn_single_line = true
|
||||
format_strings = true
|
||||
reorder_impl_items = true
|
||||
group_imports = "StdExternalCrate"
|
||||
use_field_init_shorthand = true
|
||||
wrap_comments=true
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
[package]
|
||||
name = "sync_lib"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22.1"
|
||||
reconcile = { path = "../reconcile" }
|
||||
wasm-bindgen = "0.2.99"
|
||||
thiserror = { workspace = true }
|
||||
|
||||
# The `console_error_panic_hook` crate provides better debugging of panics by
|
||||
# logging them with `console.error`. This is great for development, but requires
|
||||
# all the `std::fmt` and `std::panicking` infrastructure, so isn't great for
|
||||
# code size when deploying.
|
||||
console_error_panic_hook = { version = "0.1.7", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3.49"
|
||||
insta = "1.42.2"
|
||||
|
||||
[features]
|
||||
default = ["console_error_panic_hook"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
"name": "sync_lib",
|
||||
"type": "module",
|
||||
"collaborators": [
|
||||
"Andras Schmelczer <andras@schmelczer.dev>"
|
||||
],
|
||||
"version": "0.4.0",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/schmelczer/vault-link"
|
||||
},
|
||||
"files": [
|
||||
"sync_lib_bg.wasm",
|
||||
"sync_lib.js",
|
||||
"sync_lib.d.ts"
|
||||
],
|
||||
"main": "sync_lib.js",
|
||||
"types": "sync_lib.d.ts",
|
||||
"sideEffects": [
|
||||
"./snippets/*"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Wrapper type to expose `TextWithCursors` to JS.
|
||||
#[wasm_bindgen]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TextWithCursors {
|
||||
text: String,
|
||||
cursors: Vec<CursorPosition>,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl TextWithCursors {
|
||||
#[wasm_bindgen(constructor)]
|
||||
#[must_use]
|
||||
pub fn new(text: String, cursors: Vec<CursorPosition>) -> Self { Self { text, cursors } }
|
||||
|
||||
#[must_use]
|
||||
pub fn text(&self) -> String { self.text.clone() }
|
||||
|
||||
#[must_use]
|
||||
pub fn cursors(&self) -> Vec<CursorPosition> { self.cursors.clone() }
|
||||
}
|
||||
|
||||
impl From<TextWithCursors> for reconcile::TextWithCursors<'_> {
|
||||
fn from(owned: TextWithCursors) -> Self {
|
||||
reconcile::TextWithCursors::new_owned(
|
||||
owned.text.to_string(),
|
||||
owned
|
||||
.cursors
|
||||
.into_iter()
|
||||
.map(std::convert::Into::into)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reconcile::TextWithCursors<'_>> for TextWithCursors {
|
||||
fn from(text_with_cursors: reconcile::TextWithCursors<'_>) -> Self {
|
||||
TextWithCursors {
|
||||
text: text_with_cursors.text.into_owned(),
|
||||
cursors: text_with_cursors
|
||||
.cursors
|
||||
.into_iter()
|
||||
.map(std::convert::Into::into)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper type to expose `CursorPosition` to JS.
|
||||
#[wasm_bindgen]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CursorPosition {
|
||||
id: usize,
|
||||
char_index: usize,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl CursorPosition {
|
||||
#[wasm_bindgen(constructor)]
|
||||
#[must_use]
|
||||
pub fn new(id: usize, char_index: usize) -> Self { Self { id, char_index } }
|
||||
|
||||
#[must_use]
|
||||
pub fn id(&self) -> usize { self.id }
|
||||
|
||||
#[wasm_bindgen(js_name = characterPosition)]
|
||||
#[must_use]
|
||||
pub fn char_index(&self) -> usize { self.char_index }
|
||||
}
|
||||
|
||||
impl From<CursorPosition> for reconcile::CursorPosition {
|
||||
fn from(owned: CursorPosition) -> Self {
|
||||
reconcile::CursorPosition {
|
||||
id: owned.id,
|
||||
char_index: owned.char_index,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reconcile::CursorPosition> for CursorPosition {
|
||||
fn from(cursor: reconcile::CursorPosition) -> Self {
|
||||
CursorPosition {
|
||||
id: cursor.id,
|
||||
char_index: cursor.char_index,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
use base64::DecodeError;
|
||||
use thiserror::Error;
|
||||
use wasm_bindgen::JsValue;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum SyncLibError {
|
||||
#[error("Base64 decoding error because of {}", .reason)]
|
||||
Base64DecodingError { reason: String },
|
||||
}
|
||||
|
||||
impl From<DecodeError> for SyncLibError {
|
||||
fn from(e: DecodeError) -> Self {
|
||||
SyncLibError::Base64DecodingError {
|
||||
reason: e.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::string::FromUtf8Error> for SyncLibError {
|
||||
fn from(e: std::string::FromUtf8Error) -> Self {
|
||||
SyncLibError::Base64DecodingError {
|
||||
reason: e.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SyncLibError> for JsValue {
|
||||
fn from(val: SyncLibError) -> Self { JsValue::from_str(&val.to_string()) }
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
//! This crate provides utilities for easily communicating between backend &
|
||||
//! frontend and ensuring the same logic for encoding and decoding binary data,
|
||||
//! and 3-way-merging documents in Rust and JavaScript.
|
||||
//!
|
||||
//! The crate is designed to be used as a Rust library and as a
|
||||
//! TypeScript/JavaScript package through WebAssembly (WASM).
|
||||
//!
|
||||
//! # Modules
|
||||
//!
|
||||
//! - `errors`: Contains error types used in this crate.
|
||||
|
||||
use core::str;
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use cursor::TextWithCursors;
|
||||
use errors::SyncLibError;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
pub mod cursor;
|
||||
pub mod errors;
|
||||
|
||||
/// Encode binary data for easy transport over HTTP. Inverse of
|
||||
/// `base64_to_bytes`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// - `input`: The binary data to encode.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The base64-encoded string.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// If the input is not valid UTF-8.
|
||||
#[wasm_bindgen(js_name = bytesToBase64)]
|
||||
#[must_use]
|
||||
pub fn bytes_to_base64(input: &[u8]) -> String {
|
||||
set_panic_hook();
|
||||
|
||||
STANDARD.encode(input)
|
||||
}
|
||||
|
||||
/// Inverse of `bytes_to_base64`.
|
||||
/// Decode base64-encoded data into binary data.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// - `input`: The base64-encoded string.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The decoded binary data.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// If the input is not valid base64.
|
||||
#[wasm_bindgen(js_name = base64ToBytes)]
|
||||
pub fn base64_to_bytes(input: &str) -> Result<Vec<u8>, SyncLibError> {
|
||||
set_panic_hook();
|
||||
|
||||
STANDARD.decode(input).map_err(SyncLibError::from)
|
||||
}
|
||||
|
||||
/// Merge two documents with a common parent. Relies on `reconcile::reconcile`
|
||||
/// for texts and returns the right document as-is if either of the updated
|
||||
/// documents is binary.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// - `parent`: The common parent document.
|
||||
/// - `left`: The left document updated by one user.
|
||||
/// - `right`: The right document updated by another user.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The merged document.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// If any of the input documents are not valid UTF-8 strings.
|
||||
#[wasm_bindgen]
|
||||
#[must_use]
|
||||
pub fn merge(parent: &[u8], left: &[u8], right: &[u8]) -> Vec<u8> {
|
||||
set_panic_hook();
|
||||
|
||||
if is_binary(parent) || is_binary(left) || is_binary(right) {
|
||||
right.to_vec()
|
||||
} else {
|
||||
reconcile::reconcile(
|
||||
str::from_utf8(parent).expect("parent must be valid UTF-8 because it's not binary"),
|
||||
str::from_utf8(left).expect("left must be valid UTF-8 because it's not binary"),
|
||||
str::from_utf8(right).expect("right must be valid UTF-8 because it's not binary"),
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
/// WASM wrapper around `reconcile::reconcile` for merging text.
|
||||
#[wasm_bindgen(js_name = mergeText)]
|
||||
#[must_use]
|
||||
pub fn merge_text(parent: &str, left: &str, right: &str) -> String {
|
||||
set_panic_hook();
|
||||
|
||||
reconcile::reconcile(parent, left, right)
|
||||
}
|
||||
|
||||
/// WASM wrapper around `reconcile::reconcile_with_cursors` for merging text.
|
||||
#[wasm_bindgen(js_name = mergeTextWithCursors)]
|
||||
#[must_use]
|
||||
pub fn merge_text_with_cursors(
|
||||
parent: &str,
|
||||
left: TextWithCursors,
|
||||
right: TextWithCursors,
|
||||
) -> TextWithCursors {
|
||||
set_panic_hook();
|
||||
|
||||
reconcile::reconcile_with_cursors(parent, left.into(), right.into()).into()
|
||||
}
|
||||
|
||||
/// Heuristically determine if the given data is a binary or a text file's
|
||||
/// content.
|
||||
#[wasm_bindgen(js_name = isBinary)]
|
||||
#[must_use]
|
||||
pub fn is_binary(data: &[u8]) -> bool {
|
||||
set_panic_hook();
|
||||
|
||||
if data.contains(&0) {
|
||||
// Even though the NUL character is valid in UTF-8, it's highly suspicious in
|
||||
// human-readable text.
|
||||
return true;
|
||||
}
|
||||
|
||||
std::str::from_utf8(data).is_err()
|
||||
}
|
||||
|
||||
/// We don't want to support merging structured data like JSON, YAML, etc.
|
||||
#[wasm_bindgen(js_name = isFileTypeMergable)]
|
||||
#[must_use]
|
||||
pub fn is_file_type_mergable(path_or_file_name: &str) -> bool {
|
||||
set_panic_hook();
|
||||
|
||||
let file_extension = path_or_file_name.split('.').next_back().unwrap_or_default();
|
||||
|
||||
matches!(file_extension.to_lowercase().as_str(), "md" | "txt")
|
||||
}
|
||||
|
||||
fn set_panic_hook() {
|
||||
// https://github.com/rustwasm/console_error_panic_hook#readme
|
||||
#[cfg(feature = "console_error_panic_hook")]
|
||||
console_error_panic_hook::set_once();
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
---
|
||||
source: sync_lib/tests/web.rs
|
||||
expression: base64_to_bytes(input)
|
||||
snapshot_kind: text
|
||||
---
|
||||
Err(
|
||||
Base64DecodingError {
|
||||
reason: "Invalid symbol 61, offset 0.",
|
||||
},
|
||||
)
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
use insta::assert_debug_snapshot;
|
||||
use sync_lib::{
|
||||
cursor::{CursorPosition, TextWithCursors},
|
||||
*,
|
||||
};
|
||||
use wasm_bindgen_test::*;
|
||||
|
||||
#[wasm_bindgen_test(unsupported = test)]
|
||||
fn test_bytes_to_base64() {
|
||||
let input = b"hello";
|
||||
let expected = "aGVsbG8=";
|
||||
assert_eq!(bytes_to_base64(input), expected);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test(unsupported = test)]
|
||||
fn test_base64_to_bytes() {
|
||||
let input = "aGVsbG8=";
|
||||
let expected = b"hello".to_vec();
|
||||
assert_eq!(base64_to_bytes(input).unwrap(), expected);
|
||||
}
|
||||
|
||||
#[test] // insta doesn't support wasm-bindgen-test
|
||||
fn test_base64_to_bytes_error() {
|
||||
let input = "===";
|
||||
assert_debug_snapshot!(base64_to_bytes(input));
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test(unsupported = test)]
|
||||
fn test_merge() {
|
||||
let left = b"hello ";
|
||||
let right = b"world";
|
||||
let result = merge(b"", left, right);
|
||||
assert_eq!(result, b"hello world");
|
||||
|
||||
let left = b"\0binary";
|
||||
let right = b"other";
|
||||
let result = merge(b"", left, right);
|
||||
assert_eq!(result, right);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test(unsupported = test)]
|
||||
fn test_merge_text() {
|
||||
let left = "hello ";
|
||||
let right = "world";
|
||||
let result = merge_text("", left, right);
|
||||
assert_eq!(result, "hello world");
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test(unsupported = test)]
|
||||
fn test_merge_text_with_cursors() {
|
||||
let result = merge_text_with_cursors(
|
||||
"hi",
|
||||
TextWithCursors::new("hi world".to_owned(), vec![]),
|
||||
TextWithCursors::new(
|
||||
"hi".to_owned(),
|
||||
vec![CursorPosition::new(0, 1), CursorPosition::new(1, 2)],
|
||||
),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
TextWithCursors::new(
|
||||
"hi world".to_owned(),
|
||||
vec![CursorPosition::new(0, 1), CursorPosition::new(1, 2)]
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test(unsupported = test)]
|
||||
fn merge_binary() {
|
||||
let left = [0, 1, 2];
|
||||
let right = [3, 4, 5];
|
||||
assert_eq!(merge(b"", &left, &right), right);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test(unsupported = test)]
|
||||
fn test_is_binary() {
|
||||
assert!(is_binary(&[0, 159, 146, 150]));
|
||||
assert!(is_binary(&[0, 12]));
|
||||
assert!(!is_binary(b"hello"));
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test(unsupported = test)]
|
||||
fn test_is_binary_empty() {
|
||||
assert!(!is_binary(b""));
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test(unsupported = test)]
|
||||
fn test_is_file_type_mergable() {
|
||||
assert!(is_file_type_mergable(".md"));
|
||||
assert!(is_file_type_mergable("hi.md"));
|
||||
assert!(is_file_type_mergable("my/path/to/my/document.md"));
|
||||
assert!(is_file_type_mergable("hi.MD"));
|
||||
assert!(is_file_type_mergable("my/path/to/my/DOCUMENT.MD"));
|
||||
|
||||
assert!(!is_file_type_mergable(".json"));
|
||||
assert!(!is_file_type_mergable("HELLO.JSON"));
|
||||
assert!(!is_file_type_mergable("my/config.yml"));
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
[package]
|
||||
name = "sync_server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
sync_lib = { path = "../sync_lib" }
|
||||
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
tokio = { version = "1.44.2", features = ["full"]}
|
||||
uuid = { version = "1.16.0", features = ["v4", "serde"] }
|
||||
log = { version = "0.4.27" }
|
||||
anyhow = { version = "1.0.98", features = ["backtrace"] }
|
||||
axum = { version = "0.7.4", features = ["ws", "macros", "tracing", "multipart"]}
|
||||
axum-extra = { version = "0.9.6", features = ["typed-header"] }
|
||||
axum_typed_multipart = "0.11.0"
|
||||
tower-http = { version = "0.6.1", features = ["cors", "trace", "limit", "timeout"] }
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.19", features = ["fmt", "env-filter"]}
|
||||
sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio", "uuid", "chrono"] }
|
||||
chrono = { version = "0.4.41", features = ["serde"] }
|
||||
rand = "0.9.0"
|
||||
sanitize-filename = "0.6.0"
|
||||
regex = "1.11.1"
|
||||
clap = { version = "4.5.38", features = ["derive"] }
|
||||
futures = "0.3.31"
|
||||
serde_yaml = "0.9.34"
|
||||
serde_json = "1.0.140"
|
||||
clap-verbosity-flag = "3.0.3"
|
||||
bimap = "0.6.3"
|
||||
ts-rs = { version = "10.1", features = ["uuid-impl", "chrono-impl"] }
|
||||
serde_with = "3.12.0"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
# Sync server
|
||||
|
||||
## Creating/resetting the Database for development
|
||||
|
||||
```sh
|
||||
sqlx database create --database-url sqlite://db.sqlite3
|
||||
sqlx migrate run --source sync_server/src/app_state/database/migrations --database-url sqlite://db.sqlite3
|
||||
cargo sqlx prepare --workspace
|
||||
```
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
mod app_state;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod consts;
|
||||
mod errors;
|
||||
mod server;
|
||||
mod utils;
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use clap::Parser;
|
||||
use cli::args::Args;
|
||||
use errors::{SyncServerError, init_error};
|
||||
use log::info;
|
||||
use server::create_server;
|
||||
use tracing_subscriber::{EnvFilter, fmt::format, util::SubscriberInitExt};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
let args = Args::parse();
|
||||
|
||||
let mut result = set_up_logging(&args);
|
||||
|
||||
if result.is_ok() {
|
||||
result = start_server(args).await;
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("{}", e.serialize());
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_up_logging(args: &Args) -> Result<(), SyncServerError> {
|
||||
let level_filter = match args.verbose.log_level_filter() {
|
||||
// We don't want to allow disabling all logging
|
||||
log::LevelFilter::Off | log::LevelFilter::Error => tracing::Level::ERROR,
|
||||
log::LevelFilter::Warn => tracing::Level::WARN,
|
||||
log::LevelFilter::Info => tracing::Level::INFO,
|
||||
log::LevelFilter::Debug => tracing::Level::DEBUG,
|
||||
log::LevelFilter::Trace => tracing::Level::TRACE,
|
||||
};
|
||||
|
||||
let env_filter = EnvFilter::builder()
|
||||
.with_default_directive(level_filter.into())
|
||||
.from_env()
|
||||
.context("Failed to create logging env filter")
|
||||
.map_err(init_error)?;
|
||||
|
||||
let use_colors = args.color.use_colors();
|
||||
|
||||
let is_debug_mode = args.verbose.log_level_filter() >= log::LevelFilter::Debug;
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_ansi(use_colors)
|
||||
.with_env_filter(env_filter)
|
||||
.event_format(
|
||||
format()
|
||||
.without_time()
|
||||
.with_target(is_debug_mode)
|
||||
.with_line_number(is_debug_mode)
|
||||
.compact(),
|
||||
)
|
||||
.finish()
|
||||
.try_init()
|
||||
.context("Failed to initialise tracing")
|
||||
.map_err(init_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_server(args: Args) -> Result<(), SyncServerError> {
|
||||
info!(
|
||||
"Starting VaultLink server version {}",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
|
||||
create_server(args.config_path)
|
||||
.await
|
||||
.context("Failed to start server")
|
||||
.map_err(init_error)
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
use anyhow::{Context as _, anyhow};
|
||||
use axum::{
|
||||
Extension, Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use axum_extra::TypedHeader;
|
||||
use axum_typed_multipart::TypedMultipart;
|
||||
use log::info;
|
||||
use serde::Deserialize;
|
||||
use sync_lib::{is_file_type_mergable, merge};
|
||||
|
||||
use super::{
|
||||
device_id_header::DeviceIdHeader, requests::UpdateDocumentVersion,
|
||||
responses::DocumentUpdateResponse,
|
||||
};
|
||||
use crate::{
|
||||
app_state::{
|
||||
AppState,
|
||||
database::models::{DocumentId, StoredDocumentVersion, VaultId},
|
||||
},
|
||||
config::user_config::User,
|
||||
errors::{SyncServerError, not_found_error, server_error},
|
||||
utils::{dedup_paths::dedup_paths, normalize::normalize, sanitize_path::sanitize_path},
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateDocumentPathParams {
|
||||
#[serde(deserialize_with = "normalize")]
|
||||
vault_id: VaultId,
|
||||
|
||||
document_id: DocumentId,
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub async fn update_document(
|
||||
Path(UpdateDocumentPathParams {
|
||||
vault_id,
|
||||
document_id,
|
||||
}): Path<UpdateDocumentPathParams>,
|
||||
Extension(user): Extension<User>,
|
||||
TypedHeader(device_id): TypedHeader<DeviceIdHeader>,
|
||||
State(state): State<AppState>,
|
||||
TypedMultipart(request): TypedMultipart<UpdateDocumentVersion>,
|
||||
) -> Result<Json<DocumentUpdateResponse>, SyncServerError> {
|
||||
// No need for a transaction as document versions are immutable
|
||||
let parent_document = state
|
||||
.database
|
||||
.get_document_version(&vault_id, request.parent_version_id, None)
|
||||
.await
|
||||
.map_err(server_error)?
|
||||
.map_or_else(
|
||||
|| {
|
||||
Err(not_found_error(anyhow!(
|
||||
"Parent version with id `{}` not found",
|
||||
request.parent_version_id
|
||||
)))
|
||||
},
|
||||
Ok,
|
||||
)?;
|
||||
|
||||
let sanitized_relative_path = sanitize_path(&request.relative_path);
|
||||
|
||||
let mut transaction = state
|
||||
.database
|
||||
.create_write_transaction(&vault_id)
|
||||
.await
|
||||
.map_err(server_error)?;
|
||||
|
||||
let last_update_id = state
|
||||
.database
|
||||
.get_max_update_id_in_vault(&vault_id, Some(&mut transaction))
|
||||
.await
|
||||
.map_err(server_error)?;
|
||||
|
||||
let latest_version = state
|
||||
.database
|
||||
.get_latest_document(&vault_id, &document_id, Some(&mut transaction))
|
||||
.await
|
||||
.map_err(server_error)?
|
||||
.map_or_else(
|
||||
|| {
|
||||
Err(not_found_error(anyhow!(
|
||||
"Document with id `{document_id}` not found",
|
||||
)))
|
||||
},
|
||||
Ok,
|
||||
)?;
|
||||
|
||||
if latest_version.is_deleted {
|
||||
transaction
|
||||
.rollback()
|
||||
.await
|
||||
.context("Failed to roll back transaction")
|
||||
.map_err(server_error)?;
|
||||
|
||||
return Ok(Json(DocumentUpdateResponse::FastForwardUpdate(
|
||||
latest_version.into(),
|
||||
)));
|
||||
}
|
||||
|
||||
let content = request.content.contents.to_vec();
|
||||
|
||||
// Return the latest version if the content and path are the same as the latest
|
||||
// version
|
||||
if content == latest_version.content && sanitized_relative_path == latest_version.relative_path
|
||||
{
|
||||
info!("Document content is the same as the latest version, skipping update");
|
||||
transaction
|
||||
.rollback()
|
||||
.await
|
||||
.context("Failed to roll back transaction")
|
||||
.map_err(server_error)?;
|
||||
|
||||
return Ok(Json(DocumentUpdateResponse::FastForwardUpdate(
|
||||
latest_version.into(),
|
||||
)));
|
||||
}
|
||||
|
||||
let merged_content = if is_file_type_mergable(&sanitized_relative_path) {
|
||||
merge(&parent_document.content, &latest_version.content, &content)
|
||||
} else {
|
||||
content.clone()
|
||||
};
|
||||
|
||||
let is_different_from_request_content = merged_content != content;
|
||||
|
||||
// We can only update the relative path if we're the first one to do so
|
||||
let new_relative_path = if parent_document.relative_path == latest_version.relative_path
|
||||
&& latest_version.relative_path != sanitized_relative_path
|
||||
{
|
||||
let mut new_relative_path = String::default();
|
||||
for candidate in dedup_paths(&sanitized_relative_path) {
|
||||
if state
|
||||
.database
|
||||
.get_latest_document_by_path(&vault_id, &candidate, Some(&mut transaction))
|
||||
.await
|
||||
.map_err(server_error)?
|
||||
.is_none()
|
||||
{
|
||||
new_relative_path = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
new_relative_path
|
||||
} else {
|
||||
latest_version.relative_path.clone()
|
||||
};
|
||||
|
||||
let new_version = StoredDocumentVersion {
|
||||
document_id,
|
||||
vault_update_id: last_update_id + 1,
|
||||
relative_path: new_relative_path,
|
||||
content: merged_content,
|
||||
updated_date: chrono::Utc::now(),
|
||||
is_deleted: false,
|
||||
user_id: user.name,
|
||||
device_id: device_id.0,
|
||||
};
|
||||
|
||||
state
|
||||
.database
|
||||
.insert_document_version(&vault_id, &new_version, Some(&mut transaction))
|
||||
.await
|
||||
.map_err(server_error)?;
|
||||
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.context("Failed to commit successful transaction")
|
||||
.map_err(server_error)?;
|
||||
|
||||
Ok(Json(if is_different_from_request_content {
|
||||
DocumentUpdateResponse::MergingUpdate(new_version.into())
|
||||
} else {
|
||||
DocumentUpdateResponse::FastForwardUpdate(new_version.into())
|
||||
}))
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
pub mod dedup_paths;
|
||||
pub mod normalize;
|
||||
pub mod sanitize_path;
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
use regex::Regex;
|
||||
|
||||
pub fn dedup_paths(path: &str) -> impl Iterator<Item = String> {
|
||||
let mut path_parts = path.split('/').collect::<Vec<_>>();
|
||||
let file_name = path_parts.pop().unwrap().to_owned();
|
||||
|
||||
let mut directory = path_parts.join("/");
|
||||
if !directory.is_empty() {
|
||||
directory.push('/');
|
||||
}
|
||||
|
||||
let name_parts = file_name.rsplitn(2, '.').collect::<Vec<_>>();
|
||||
let mut reverse_parts = name_parts.into_iter().rev();
|
||||
let (stem, extension) = match (reverse_parts.next(), reverse_parts.next()) {
|
||||
(Some(stem), maybe_extension) => (
|
||||
stem.to_owned(),
|
||||
maybe_extension
|
||||
.map(|ext| format!(".{ext}"))
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
_ => unreachable!("Path must have at least one part"),
|
||||
};
|
||||
|
||||
let regex = Regex::new(r" \((\d+)\)$").unwrap();
|
||||
let start_number = regex
|
||||
.captures(&stem)
|
||||
.and_then(|caps| caps.get(1))
|
||||
.and_then(|m| m.as_str().parse::<u32>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let clean_stem = regex.replace(&stem, "").to_string();
|
||||
|
||||
(start_number..).map(move |dedup_number| {
|
||||
if dedup_number == 0 {
|
||||
format!("{directory}{clean_stem}{extension}")
|
||||
} else {
|
||||
format!("{directory}{clean_stem} ({dedup_number}){extension}")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_dedup_paths() {
|
||||
let mut deduped = dedup_paths("file.txt");
|
||||
assert_eq!(deduped.next(), Some("file.txt".to_owned()));
|
||||
assert_eq!(deduped.next(), Some("file (1).txt".to_owned()));
|
||||
assert_eq!(deduped.next(), Some("file (2).txt".to_owned()));
|
||||
|
||||
let mut deduped = dedup_paths("file");
|
||||
assert_eq!(deduped.next(), Some("file".to_owned()));
|
||||
assert_eq!(deduped.next(), Some("file (1)".to_owned()));
|
||||
assert_eq!(deduped.next(), Some("file (2)".to_owned()));
|
||||
|
||||
let mut deduped = dedup_paths("file (51).md");
|
||||
assert_eq!(deduped.next(), Some("file (51).md".to_owned()));
|
||||
assert_eq!(deduped.next(), Some("file (52).md".to_owned()));
|
||||
assert_eq!(deduped.next(), Some("file (53).md".to_owned()));
|
||||
|
||||
let mut deduped = dedup_paths("file (5)");
|
||||
assert_eq!(deduped.next(), Some("file (5)".to_owned()));
|
||||
assert_eq!(deduped.next(), Some("file (6)".to_owned()));
|
||||
assert_eq!(deduped.next(), Some("file (7)".to_owned()));
|
||||
|
||||
let mut deduped = dedup_paths("my/path.with.dots/file (5).md");
|
||||
assert_eq!(
|
||||
deduped.next(),
|
||||
Some("my/path.with.dots/file (5).md".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
deduped.next(),
|
||||
Some("my/path.with.dots/file (6).md".to_owned())
|
||||
);
|
||||
|
||||
let mut deduped = dedup_paths("my/path.with.dots/file (5)");
|
||||
assert_eq!(
|
||||
deduped.next(),
|
||||
Some("my/path.with.dots/file (5)".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
deduped.next(),
|
||||
Some("my/path.with.dots/file (6)".to_owned())
|
||||
);
|
||||
}
|
||||
}
|
||||
87
docs/.cspell.json
Normal file
87
docs/.cspell.json
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
{
|
||||
"version": "0.2",
|
||||
"language": "en-GB",
|
||||
"dictionaries": ["en-gb"],
|
||||
"ignorePaths": ["node_modules", ".vitepress/dist", ".vitepress/cache", "package-lock.json"],
|
||||
"words": [
|
||||
"VaultLink",
|
||||
"Obsidian",
|
||||
"WebSocket",
|
||||
"SQLite",
|
||||
"codebase",
|
||||
"CRDT",
|
||||
"CRDTs",
|
||||
"YAML",
|
||||
"nginx",
|
||||
"Caddy",
|
||||
"Traefik",
|
||||
"systemd",
|
||||
"localhost",
|
||||
"vaultlink",
|
||||
"Axum",
|
||||
"Tokio",
|
||||
"SQLx",
|
||||
"reconcile",
|
||||
"postgresql",
|
||||
"VitePress",
|
||||
"markdownlint",
|
||||
"filesystem",
|
||||
"backend",
|
||||
"frontend",
|
||||
"macOS",
|
||||
"CLI",
|
||||
"API",
|
||||
"JSON",
|
||||
"HTTP",
|
||||
"HTTPS",
|
||||
"SSL",
|
||||
"TLS",
|
||||
"WSS",
|
||||
"TCP",
|
||||
"VPS",
|
||||
"Docker",
|
||||
"Github",
|
||||
"Dockerfile",
|
||||
"dockerignore",
|
||||
"Rustup",
|
||||
"PostgreSQL",
|
||||
"UUID",
|
||||
"CORS",
|
||||
"HSTS",
|
||||
"CI",
|
||||
"CD",
|
||||
"OpenSSL",
|
||||
"README",
|
||||
"config",
|
||||
"submodule",
|
||||
"repo",
|
||||
"autocomplete",
|
||||
"autoformat",
|
||||
"dedupe",
|
||||
"diff",
|
||||
"grep",
|
||||
"stdout",
|
||||
"stderr",
|
||||
"chmod",
|
||||
"mkdir",
|
||||
"rclone",
|
||||
"uuidgen",
|
||||
"letsencrypt",
|
||||
"fullchain",
|
||||
"privkey",
|
||||
"schmelczer",
|
||||
"Schmelczer",
|
||||
"ghcr",
|
||||
"keepalive",
|
||||
"healthcheck",
|
||||
"writable",
|
||||
"Cloudant",
|
||||
"Syncthing",
|
||||
"cadvisor",
|
||||
"Caddyfile",
|
||||
"nodelay",
|
||||
"websecure",
|
||||
"certresolver",
|
||||
"rootfs"
|
||||
]
|
||||
}
|
||||
2
docs/.gitignore
vendored
Normal file
2
docs/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
.vitepress/dist/
|
||||
.vitepress/cache/
|
||||
4
docs/.prettierignore
Normal file
4
docs/.prettierignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
.vitepress/dist/
|
||||
.vitepress/cache/
|
||||
package-lock.json
|
||||
19
docs/.prettierrc
Normal file
19
docs/.prettierrc
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"semi": false,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "none",
|
||||
"endOfLine": "lf",
|
||||
"proseWrap": "preserve",
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.md",
|
||||
"options": {
|
||||
"proseWrap": "preserve",
|
||||
"printWidth": 120
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
60
docs/.vitepress/config.mts
Normal file
60
docs/.vitepress/config.mts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { defineConfig } from "vitepress"
|
||||
|
||||
export default defineConfig({
|
||||
title: "VaultLink",
|
||||
description: "Self-hosted real-time synchronisation for Obsidian",
|
||||
base: "/vault-link/",
|
||||
themeConfig: {
|
||||
logo: "/logo.svg",
|
||||
nav: [
|
||||
{ text: "Home", link: "/" },
|
||||
{ text: "Guide", link: "/guide/getting-started" },
|
||||
{ text: "Architecture", link: "/architecture/" },
|
||||
{ text: "GitHub", link: "https://github.com/schmelczer/vault-link" }
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
text: "Introduction",
|
||||
items: [
|
||||
{ text: "What is VaultLink?", link: "/guide/what-is-vaultlink" },
|
||||
{ text: "Getting Started", link: "/guide/getting-started" },
|
||||
{ text: "Limitations", link: "/guide/limitations" },
|
||||
{ text: "Comparison with Alternatives", link: "/guide/alternatives" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Setup",
|
||||
items: [
|
||||
{ text: "Server Setup", link: "/guide/server-setup" },
|
||||
{ text: "Obsidian Plugin", link: "/guide/obsidian-plugin" },
|
||||
{ text: "CLI Client", link: "/guide/cli-client" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Configuration",
|
||||
items: [
|
||||
{ text: "Server Configuration", link: "/config/server" },
|
||||
{ text: "Authentication", link: "/config/authentication" },
|
||||
{ text: "Advanced Options", link: "/config/advanced" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Architecture",
|
||||
items: [
|
||||
{ text: "Overview", link: "/architecture/" },
|
||||
{ text: "Sync Algorithm", link: "/architecture/sync-algorithm" },
|
||||
{ text: "Data Flow", link: "/architecture/data-flow" }
|
||||
]
|
||||
}
|
||||
],
|
||||
socialLinks: [{ icon: "github", link: "https://github.com/schmelczer/vault-link" }],
|
||||
footer: {
|
||||
message: "Released under the MIT License.",
|
||||
copyright: "Copyright © 2024-present Andras Schmelczer"
|
||||
},
|
||||
search: {
|
||||
provider: "local"
|
||||
}
|
||||
},
|
||||
head: [["link", { rel: "icon", type: "image/svg+xml", href: "/vault-link/logo.svg" }]]
|
||||
})
|
||||
159
docs/README.md
Normal file
159
docs/README.md
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
# VaultLink Documentation
|
||||
|
||||
This directory contains the VaultLink documentation site built with [VitePress](https://vitepress.dev/).
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- npm
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
cd docs
|
||||
npm install
|
||||
```
|
||||
|
||||
### Local Development
|
||||
|
||||
Start the development server with hot reload:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The site will be available at `http://localhost:5173/vault-link/`
|
||||
|
||||
### Build
|
||||
|
||||
Build the static site:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Output will be in `.vitepress/dist/`
|
||||
|
||||
### Preview
|
||||
|
||||
Preview the built site:
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
### Format
|
||||
|
||||
Format all markdown and TypeScript files:
|
||||
|
||||
```bash
|
||||
npm run format
|
||||
```
|
||||
|
||||
Check formatting without making changes:
|
||||
|
||||
```bash
|
||||
npm run format:check
|
||||
```
|
||||
|
||||
### Spell Check
|
||||
|
||||
Check spelling (British English):
|
||||
|
||||
```bash
|
||||
npm run spell
|
||||
```
|
||||
|
||||
The spell checker enforces British English spellings (e.g., "synchronisation", "optimise", "behaviour").
|
||||
|
||||
## Deployment
|
||||
|
||||
The documentation is automatically deployed to GitHub Pages when changes are pushed to the `main` branch.
|
||||
|
||||
The deployment workflow is configured in `.github/workflows/deploy-docs.yml`.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
docs/
|
||||
├── .vitepress/
|
||||
│ └── config.ts # VitePress configuration
|
||||
├── public/ # Static assets
|
||||
│ └── logo.svg # VaultLink logo
|
||||
├── guide/ # User guides
|
||||
│ ├── what-is-vaultlink.md
|
||||
│ ├── getting-started.md
|
||||
│ ├── server-setup.md
|
||||
│ ├── obsidian-plugin.md
|
||||
│ └── cli-client.md
|
||||
├── architecture/ # Architecture documentation
|
||||
│ ├── index.md
|
||||
│ ├── sync-algorithm.md
|
||||
│ └── data-flow.md
|
||||
├── config/ # Configuration reference
|
||||
│ ├── server.md
|
||||
│ ├── authentication.md
|
||||
│ └── advanced.md
|
||||
└── index.md # Home page
|
||||
|
||||
```
|
||||
|
||||
## Writing Documentation
|
||||
|
||||
### Language
|
||||
|
||||
All documentation uses **British English**. The spell checker enforces this in CI.
|
||||
|
||||
### Markdown Features
|
||||
|
||||
VitePress supports:
|
||||
|
||||
- GitHub Flavoured Markdown
|
||||
- Custom containers (tip, warning, danger)
|
||||
- Code syntax highlighting
|
||||
- Mermaid diagrams
|
||||
- Emoji :rocket:
|
||||
|
||||
### Custom Containers
|
||||
|
||||
```markdown
|
||||
::: tip
|
||||
This is a tip
|
||||
:::
|
||||
|
||||
::: warning
|
||||
This is a warning
|
||||
:::
|
||||
|
||||
::: danger
|
||||
This is a danger message
|
||||
:::
|
||||
```
|
||||
|
||||
### Code Blocks
|
||||
|
||||
````markdown
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
```yaml
|
||||
server:
|
||||
port: 3000
|
||||
```
|
||||
````
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new pages:
|
||||
|
||||
1. Create the markdown file in the appropriate directory
|
||||
2. Add it to the sidebar in `.vitepress/config.ts`
|
||||
3. Test locally with `npm run dev`
|
||||
4. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
MIT - Same as VaultLink
|
||||
553
docs/architecture/data-flow.md
Normal file
553
docs/architecture/data-flow.md
Normal file
|
|
@ -0,0 +1,553 @@
|
|||
# Data Flow
|
||||
|
||||
How data flows through VaultLink, from client to server and back.
|
||||
|
||||
## Connection Lifecycle
|
||||
|
||||
### 1. Initial Connection
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant S as Server
|
||||
participant DB as Database
|
||||
|
||||
C->>S: WebSocket connect
|
||||
S->>S: Accept connection
|
||||
C->>S: Auth message (token + vault)
|
||||
S->>S: Validate token
|
||||
S->>S: Check vault access
|
||||
S-->>C: Auth success
|
||||
Note over C,S: Connection established
|
||||
```
|
||||
|
||||
**Steps**:
|
||||
|
||||
1. Client initiates WebSocket connection to server
|
||||
2. Server accepts connection
|
||||
3. Client sends authentication message with token and vault name
|
||||
4. Server validates token against `config.yml`
|
||||
5. Server checks if user has access to requested vault
|
||||
6. Server responds with success or error
|
||||
7. Connection is ready for syncing
|
||||
|
||||
### 2. Initial Sync
|
||||
|
||||
After authentication, the client performs initial synchronisation:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant S as Server
|
||||
participant DB as SQLite
|
||||
|
||||
C->>C: Scan local filesystem
|
||||
C->>S: Request file list
|
||||
S->>DB: Query all files
|
||||
DB-->>S: File metadata
|
||||
S-->>C: File list with versions
|
||||
|
||||
loop For each local file
|
||||
C->>C: Check if file on server
|
||||
alt File not on server
|
||||
C->>S: Upload file
|
||||
S->>DB: Store file + metadata
|
||||
else File on server (different version)
|
||||
C->>C: Compare versions
|
||||
C->>S: Upload newer or merge
|
||||
end
|
||||
end
|
||||
|
||||
loop For each server file
|
||||
C->>C: Check if file local
|
||||
alt File not local
|
||||
C->>S: Download file
|
||||
S->>DB: Retrieve file
|
||||
DB-->>S: File content
|
||||
S-->>C: File content
|
||||
C->>C: Write to disk
|
||||
end
|
||||
end
|
||||
|
||||
S-->>C: Sync complete message
|
||||
```
|
||||
|
||||
**Process**:
|
||||
|
||||
1. Client scans local filesystem
|
||||
2. Client requests file list from server
|
||||
3. Server queries database and returns metadata
|
||||
4. Client uploads missing or changed local files
|
||||
5. Client downloads missing files from server
|
||||
6. Server sends sync complete notification
|
||||
|
||||
### 3. Real-Time Synchronization
|
||||
|
||||
After initial sync, changes are pushed in real-time:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant FS as Filesystem
|
||||
participant C1 as Client 1
|
||||
participant S as Server
|
||||
participant DB as Database
|
||||
participant C2 as Client 2
|
||||
|
||||
FS->>C1: File changed (fs.watch)
|
||||
C1->>C1: Read file content
|
||||
C1->>S: Upload file
|
||||
S->>DB: Store new version
|
||||
S->>S: Apply OT if needed
|
||||
S-->>C1: Upload ACK
|
||||
S->>C2: File update notification
|
||||
C2->>S: Download file
|
||||
S->>DB: Retrieve file
|
||||
DB-->>S: File content
|
||||
S-->>C2: File content
|
||||
C2->>FS: Write to disk
|
||||
```
|
||||
|
||||
**Flow**:
|
||||
|
||||
1. Filesystem watcher detects local change
|
||||
2. Client reads file content
|
||||
3. Client uploads file via WebSocket
|
||||
4. Server stores in database
|
||||
5. Server applies operational transformation if concurrent edits
|
||||
6. Server acknowledges upload to sender
|
||||
7. Server broadcasts update to other clients
|
||||
8. Other clients download and apply changes
|
||||
|
||||
## File Operations
|
||||
|
||||
### Upload
|
||||
|
||||
```
|
||||
┌─────────┐
|
||||
│ Client │
|
||||
└───┬─-───┘
|
||||
│ 1. Detect file change
|
||||
│
|
||||
├─► 2. Read file content
|
||||
│
|
||||
├─► 3. Create upload message
|
||||
│ {
|
||||
│ type: "upload_file",
|
||||
│ path: "notes/daily.md",
|
||||
│ content: "...",
|
||||
│ version: 42,
|
||||
│ timestamp: "2024-01-01T12:00:00Z"
|
||||
│ }
|
||||
│
|
||||
▼
|
||||
┌─────────┐
|
||||
│ Server │
|
||||
└───┬────-┘
|
||||
│ 4. Validate message
|
||||
│
|
||||
├─► 5. Check permissions
|
||||
│
|
||||
├─► 6. Apply OT (if conflicts)
|
||||
│
|
||||
├─► 7. Store in database
|
||||
│
|
||||
├─► 8. Update version
|
||||
│
|
||||
├─► 9. Broadcast to clients
|
||||
│
|
||||
└─► 10. Send ACK to uploader
|
||||
```
|
||||
|
||||
### Download
|
||||
|
||||
```
|
||||
┌─────────┐
|
||||
│ Server │
|
||||
└───┬─-───┘
|
||||
│ 1. File updated by another client
|
||||
│
|
||||
├─► 2. Broadcast notification
|
||||
│ {
|
||||
│ type: "file_updated",
|
||||
│ path: "notes/daily.md",
|
||||
│ version: 43
|
||||
│ }
|
||||
│
|
||||
▼
|
||||
┌─────────┐
|
||||
│ Client │
|
||||
└───┬─-───┘
|
||||
│ 3. Receive notification
|
||||
│
|
||||
├─► 4. Request file download
|
||||
│ {
|
||||
│ type: "download_file",
|
||||
│ path: "notes/daily.md",
|
||||
│ version: 43
|
||||
│ }
|
||||
│
|
||||
▼
|
||||
┌─────────┐
|
||||
│ Server │
|
||||
└───┬─=───┘
|
||||
│ 5. Retrieve from database
|
||||
│
|
||||
└─► 6. Send file content
|
||||
{
|
||||
type: "file_content",
|
||||
path: "notes/daily.md",
|
||||
content: "...",
|
||||
version: 43
|
||||
}
|
||||
│
|
||||
▼
|
||||
┌─────────┐
|
||||
│ Client │
|
||||
└───-─┬───┘
|
||||
│ 7. Write to filesystem
|
||||
│
|
||||
└─► 8. Update local metadata
|
||||
```
|
||||
|
||||
### Delete
|
||||
|
||||
```
|
||||
┌─────────┐
|
||||
│ Client │
|
||||
└────┬────┘
|
||||
│ 1. File deleted locally
|
||||
│
|
||||
├─► 2. Send delete message
|
||||
│ {
|
||||
│ type: "delete_file",
|
||||
│ path: "notes/old.md"
|
||||
│ }
|
||||
│
|
||||
▼
|
||||
┌─────────┐
|
||||
│ Server │
|
||||
└────┬────┘
|
||||
│ 3. Mark as deleted in DB
|
||||
│ (soft delete for history)
|
||||
│
|
||||
├─► 4. Broadcast deletion
|
||||
│
|
||||
└─► 5. ACK to sender
|
||||
│
|
||||
▼
|
||||
┌─────────┐
|
||||
│ Other │
|
||||
│ Clients │
|
||||
└────┬────┘
|
||||
│ 6. Delete local file
|
||||
│
|
||||
└─► 7. Update metadata
|
||||
```
|
||||
|
||||
## Conflict Resolution Flow
|
||||
|
||||
### Concurrent Edits Scenario
|
||||
|
||||
```
|
||||
Time →
|
||||
|
||||
Client A Server Client B
|
||||
│ │ │
|
||||
│ Edit file v10 │ │
|
||||
│ "Add line A" │ │ Edit file v10
|
||||
│ │ │ "Add line B"
|
||||
│ │ │
|
||||
├─── Upload @ t1 ─────────►│ │
|
||||
│ │◄────── Upload @ t2 ────────┤
|
||||
│ │ │
|
||||
│ │ 1. Receive both edits │
|
||||
│ │ (based on v10) │
|
||||
│ │ │
|
||||
│ │ 2. Apply first edit │
|
||||
│ │ → v11 (line A added) │
|
||||
│ │ │
|
||||
│ │ 3. Transform second edit │
|
||||
│ │ against first │
|
||||
│ │ │
|
||||
│ │ 4. Apply transformed edit │
|
||||
│ │ → v12 (both lines) │
|
||||
│ │ │
|
||||
│◄──── v12 content ────────┤ │
|
||||
│ ├───── v12 content ─────────►│
|
||||
│ │ │
|
||||
│ Apply v12 │ │ Apply v12
|
||||
│ (has both lines) │ │ (has both lines)
|
||||
│ │ │
|
||||
```
|
||||
|
||||
### Conflict Resolution Steps
|
||||
|
||||
1. **Detection**: Server receives two edits based on the same version
|
||||
2. **Ordering**: Determine which edit to apply first (by timestamp or client ID)
|
||||
3. **First edit**: Apply directly to database
|
||||
4. **Transformation**: Transform second edit against first using OT
|
||||
5. **Second edit**: Apply transformed edit to database
|
||||
6. **Broadcast**: Send merged result to all clients
|
||||
7. **Application**: Clients apply merged version locally
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Core Tables
|
||||
|
||||
```sql
|
||||
-- Document metadata
|
||||
CREATE TABLE documents (
|
||||
id INTEGER PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
content_hash TEXT,
|
||||
size INTEGER,
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP,
|
||||
deleted BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- Version history
|
||||
CREATE TABLE versions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
document_id INTEGER,
|
||||
version INTEGER,
|
||||
content BLOB,
|
||||
created_at TIMESTAMP,
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id)
|
||||
);
|
||||
|
||||
-- Client sync cursors
|
||||
CREATE TABLE cursors (
|
||||
client_id TEXT PRIMARY KEY,
|
||||
last_version INTEGER,
|
||||
last_updated TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### Queries
|
||||
|
||||
**Get files since version**:
|
||||
|
||||
```sql
|
||||
SELECT * FROM documents
|
||||
WHERE version > ? AND deleted = FALSE
|
||||
ORDER BY version ASC;
|
||||
```
|
||||
|
||||
**Store new version**:
|
||||
|
||||
```sql
|
||||
INSERT INTO versions (document_id, version, content, created_at)
|
||||
VALUES (?, ?, ?, ?);
|
||||
|
||||
UPDATE documents
|
||||
SET version = ?, updated_at = ?
|
||||
WHERE id = ?;
|
||||
```
|
||||
|
||||
**Update cursor**:
|
||||
|
||||
```sql
|
||||
INSERT OR REPLACE INTO cursors (client_id, last_version, last_updated)
|
||||
VALUES (?, ?, ?);
|
||||
```
|
||||
|
||||
## Message Protocol
|
||||
|
||||
### Client → Server Messages
|
||||
|
||||
**Upload File**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "upload_file",
|
||||
"path": "notes/example.md",
|
||||
"content": "File content here...",
|
||||
"base_version": 10,
|
||||
"timestamp": "2024-01-01T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Download File**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "download_file",
|
||||
"path": "notes/example.md"
|
||||
}
|
||||
```
|
||||
|
||||
**Delete File**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "delete_file",
|
||||
"path": "notes/old.md"
|
||||
}
|
||||
```
|
||||
|
||||
**List Files**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "list_files",
|
||||
"since_version": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Server → Client Messages
|
||||
|
||||
**File Updated**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "file_updated",
|
||||
"path": "notes/example.md",
|
||||
"version": 11,
|
||||
"size": 1024,
|
||||
"hash": "abc123..."
|
||||
}
|
||||
```
|
||||
|
||||
**File Content**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "file_content",
|
||||
"path": "notes/example.md",
|
||||
"content": "Updated content...",
|
||||
"version": 11
|
||||
}
|
||||
```
|
||||
|
||||
**File Deleted**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "file_deleted",
|
||||
"path": "notes/old.md",
|
||||
"version": 12
|
||||
}
|
||||
```
|
||||
|
||||
**Sync Complete**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "sync_complete",
|
||||
"total_files": 150,
|
||||
"current_version": 200
|
||||
}
|
||||
```
|
||||
|
||||
**Error**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "error",
|
||||
"message": "File too large",
|
||||
"code": "FILE_TOO_LARGE"
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Client-Side Errors
|
||||
|
||||
**Network failure**:
|
||||
|
||||
1. Detect WebSocket disconnect
|
||||
2. Queue pending operations
|
||||
3. Retry connection with exponential backoff
|
||||
4. Replay queued operations on reconnect
|
||||
|
||||
**File read error**:
|
||||
|
||||
1. Log error
|
||||
2. Skip file
|
||||
3. Continue with other files
|
||||
4. Report to user
|
||||
|
||||
**Write conflict**:
|
||||
|
||||
1. Receive updated version from server
|
||||
2. Apply OT merge locally
|
||||
3. Overwrite local file
|
||||
4. Continue syncing
|
||||
|
||||
### Server-Side Errors
|
||||
|
||||
**Database error**:
|
||||
|
||||
1. Log error
|
||||
2. Return error to client
|
||||
3. Client retries operation
|
||||
|
||||
**Invalid operation**:
|
||||
|
||||
1. Validate message format
|
||||
2. Return specific error code
|
||||
3. Client handles error appropriately
|
||||
|
||||
**Authentication failure**:
|
||||
|
||||
1. Reject connection
|
||||
2. Send auth error
|
||||
3. Client prompts for new credentials
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### Batching
|
||||
|
||||
- Small, rapid changes are batched together
|
||||
- Reduces message overhead
|
||||
- Applied as single atomic update
|
||||
|
||||
### Compression
|
||||
|
||||
- Large files compressed before transmission
|
||||
- Reduces bandwidth usage
|
||||
- Transparent to application layer
|
||||
|
||||
### Incremental Sync
|
||||
|
||||
- Only changed portions of files sent
|
||||
- Uses content-based diffing
|
||||
- Significantly reduces data transfer
|
||||
|
||||
### Caching
|
||||
|
||||
- Server caches recent file versions
|
||||
- Reduces database queries
|
||||
- Improves response time
|
||||
|
||||
## Monitoring Data Flow
|
||||
|
||||
### Server Logs
|
||||
|
||||
```
|
||||
2024-01-01 12:00:00 INFO WebSocket connection from 192.168.1.100
|
||||
2024-01-01 12:00:01 INFO User 'alice' authenticated for vault 'personal'
|
||||
2024-01-01 12:00:05 INFO Upload: notes/daily.md (v10 -> v11)
|
||||
2024-01-01 12:00:06 INFO Broadcast to 3 clients
|
||||
2024-01-01 12:00:10 INFO Conflict resolved: notes/shared.md (v12)
|
||||
```
|
||||
|
||||
### Client Logs
|
||||
|
||||
```
|
||||
2024-01-01 12:00:00 INFO Connecting to ws://sync.example.com
|
||||
2024-01-01 12:00:01 INFO Connected, authenticating...
|
||||
2024-01-01 12:00:01 INFO Authentication successful
|
||||
2024-01-01 12:00:02 INFO Starting initial sync
|
||||
2024-01-01 12:00:10 INFO Sync complete: 150 files, 200 MB
|
||||
2024-01-01 12:00:15 INFO Uploaded: notes/daily.md
|
||||
2024-01-01 12:00:20 INFO Downloaded: notes/shared.md (merged)
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Understand the sync algorithm →](/architecture/sync-algorithm)
|
||||
- [Configure the server →](/config/server)
|
||||
- [Deploy VaultLink →](/guide/getting-started)
|
||||
334
docs/architecture/index.md
Normal file
334
docs/architecture/index.md
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
# Architecture Overview
|
||||
|
||||
Central sync server with multiple clients. High-level architecture and design decisions.
|
||||
|
||||
## System Components
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Clients │
|
||||
├─────────────────────┬───────────────────┬───────────────────┤
|
||||
│ Obsidian Plugin │ Obsidian Plugin │ CLI Client │
|
||||
│ (User A - Device1) │ (User A - Device2│ (Server/Backup) │
|
||||
└──────────┬──────────┴─────────┬─────────┴──────────┬────────┘
|
||||
│ │ │
|
||||
│ WebSocket │ WebSocket │ WebSocket
|
||||
│ │ │
|
||||
└────────────────────┼────────────────────┘
|
||||
│
|
||||
┌───────────▼───────────┐
|
||||
│ Sync Server │
|
||||
│ (Rust + Axum) │
|
||||
│ │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ WebSocket Hub │ │
|
||||
│ └────────┬────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────▼────────┐ │
|
||||
│ │ Sync Engine │ │
|
||||
│ │ (OT Algorithm) │ │
|
||||
│ └────────┬────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────▼────────┐ │
|
||||
│ │ SQLite Database │ │
|
||||
│ │ (Per Vault) │ │
|
||||
│ └─────────────────┘ │
|
||||
└───────────────────────┘
|
||||
```
|
||||
|
||||
## Core Components
|
||||
|
||||
### Sync Server
|
||||
|
||||
Central authority for synchronisation. Rust + Axum framework.
|
||||
|
||||
**Responsibilities**:
|
||||
|
||||
- Accept WebSocket connections from clients
|
||||
- Authenticate users via token-based auth
|
||||
- Store document versions in SQLite
|
||||
- Coordinate real-time updates between clients
|
||||
- Apply operational transformation for conflict resolution
|
||||
- Manage vault access control
|
||||
|
||||
**Technology**:
|
||||
|
||||
- **Language**: Rust 1.92+
|
||||
- **Framework**: Axum (async web framework)
|
||||
- **Database**: SQLite with SQLx
|
||||
- **Protocol**: WebSockets for real-time communication
|
||||
- **Sync Algorithm**: reconcile-text (operational transformation)
|
||||
|
||||
### Sync Client Library
|
||||
|
||||
TypeScript library with core sync logic. Used by Obsidian plugin and CLI client.
|
||||
|
||||
**Responsibilities**:
|
||||
|
||||
- Manage WebSocket connection to server
|
||||
- Watch local filesystem for changes
|
||||
- Upload and download files
|
||||
- Apply remote changes locally
|
||||
- Handle conflict resolution
|
||||
- Maintain sync metadata
|
||||
|
||||
**Technology**:
|
||||
|
||||
- **Language**: TypeScript
|
||||
- **Build**: Webpack
|
||||
- **Protocol**: WebSocket client
|
||||
- **File System**: Node.js `fs` API / Obsidian API
|
||||
|
||||
### Obsidian Plugin
|
||||
|
||||
Integration layer between sync client and Obsidian.
|
||||
|
||||
**Responsibilities**:
|
||||
|
||||
- Provide UI for configuration
|
||||
- Bridge sync client with Obsidian's file system API
|
||||
- Handle Obsidian lifecycle events
|
||||
- Display sync status to users
|
||||
|
||||
**Technology**:
|
||||
|
||||
- **Platform**: Obsidian Plugin API
|
||||
- **Core**: sync-client library
|
||||
- **UI**: Obsidian settings UI
|
||||
|
||||
### CLI Client
|
||||
|
||||
Standalone executable for syncing vaults without Obsidian.
|
||||
|
||||
**Responsibilities**:
|
||||
|
||||
- Command-line interface
|
||||
- File system access via Node.js
|
||||
- Daemon mode for continuous sync
|
||||
- Health check endpoint for monitoring
|
||||
|
||||
**Technology**:
|
||||
|
||||
- **Language**: TypeScript
|
||||
- **Runtime**: Node.js
|
||||
- **CLI**: Commander.js
|
||||
- **Core**: sync-client library
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Initial Connection
|
||||
|
||||
1. Client connects via WebSocket to server
|
||||
2. Server authenticates using provided token
|
||||
3. Server verifies user has access to requested vault
|
||||
4. Connection established, sync begins
|
||||
|
||||
### File Upload Flow
|
||||
|
||||
```
|
||||
Client Server
|
||||
│ │
|
||||
│ 1. File changed locally │
|
||||
│ │
|
||||
│ 2. Read file content │
|
||||
│ │
|
||||
│ 3. WebSocket: Upload file │
|
||||
├──────────────────────────────►│
|
||||
│ │ 4. Store in SQLite
|
||||
│ │
|
||||
│ │ 5. Broadcast to other clients
|
||||
│ ├───────────────────────►
|
||||
│ 6. Ack upload │
|
||||
│◄──────────────────────────────┤
|
||||
```
|
||||
|
||||
### File Download Flow
|
||||
|
||||
```
|
||||
Client A Server Client B
|
||||
│ │ │
|
||||
│ │ 1. File uploaded │
|
||||
│ │◄────────────────────────┤
|
||||
│ │ │
|
||||
│ │ 2. Store in DB │
|
||||
│ │ │
|
||||
│ 3. Push notification │ │
|
||||
│◄────────────────────────┤ │
|
||||
│ │ │
|
||||
│ 4. Download file │ │
|
||||
├────────────────────────►│ │
|
||||
│ │ │
|
||||
│ 5. Write locally │ │
|
||||
│ │ │
|
||||
```
|
||||
|
||||
### Conflict Resolution
|
||||
|
||||
When two clients edit the same file simultaneously:
|
||||
|
||||
```
|
||||
Client A Server Client B
|
||||
│ │ │
|
||||
│ 1. Edit file │ │ 1. Edit same file
|
||||
│ │ │
|
||||
│ 2. Upload changes │ │ 2. Upload changes
|
||||
├────────────────────────►│◄────────────────────────┤
|
||||
│ │ │
|
||||
│ │ 3. Apply OT algorithm │
|
||||
│ │ - Merge both edits │
|
||||
│ │ - Preserve all changes│
|
||||
│ │ │
|
||||
│ 4. Receive merged ver. │ 5. Receive merged ver. │
|
||||
│◄────────────────────────┤────────────────────────►│
|
||||
│ │ │
|
||||
│ 6. Apply locally │ │ 6. Apply locally
|
||||
```
|
||||
|
||||
## Storage Architecture
|
||||
|
||||
### Server Storage
|
||||
|
||||
Each vault has its own SQLite database:
|
||||
|
||||
```
|
||||
databases/
|
||||
├── vault-1.db
|
||||
├── vault-2.db
|
||||
└── shared-team.db
|
||||
```
|
||||
|
||||
**Database Schema** (simplified):
|
||||
|
||||
- **documents**: File metadata (path, size, modified time)
|
||||
- **versions**: Document content with version history
|
||||
- **cursors**: Client sync state
|
||||
|
||||
### Client Storage
|
||||
|
||||
Clients maintain sync metadata:
|
||||
|
||||
```
|
||||
.vaultlink/
|
||||
├── metadata.json # Sync state
|
||||
└── cache/ # Optional local cache
|
||||
```
|
||||
|
||||
The `.vaultlink` directory tracks which files have been synced and their versions to enable efficient synchronisation.
|
||||
|
||||
## Communication Protocol
|
||||
|
||||
### WebSocket Messages
|
||||
|
||||
Client-server communication uses JSON messages over WebSocket.
|
||||
|
||||
**Message Types**:
|
||||
|
||||
- `upload_file`: Client → Server (file upload)
|
||||
- `download_file`: Client → Server (request file)
|
||||
- `file_updated`: Server → Client (file changed notification)
|
||||
- `file_deleted`: Server → Client (file deleted notification)
|
||||
- `sync_complete`: Server → Client (initial sync finished)
|
||||
|
||||
### Authentication
|
||||
|
||||
Token-based authentication on connection:
|
||||
|
||||
```typescript
|
||||
// Client sends token on connect
|
||||
{
|
||||
type: "auth",
|
||||
token: "user-auth-token",
|
||||
vault: "vault-name"
|
||||
}
|
||||
|
||||
// Server responds
|
||||
{
|
||||
type: "auth_success"
|
||||
}
|
||||
// or
|
||||
{
|
||||
type: "auth_error",
|
||||
message: "Invalid token"
|
||||
}
|
||||
```
|
||||
|
||||
## Scalability Considerations
|
||||
|
||||
### Current Architecture
|
||||
|
||||
- **SQLite per vault**: Simple, performant, limited to single server
|
||||
- **WebSocket connections**: Stateful, requires sticky sessions for load balancing
|
||||
- **Operational transformation**: Centralized on server
|
||||
|
||||
### Scaling Approaches
|
||||
|
||||
**Vertical Scaling**:
|
||||
|
||||
- Increase server resources (CPU, RAM, storage)
|
||||
- Optimize database queries and indexing
|
||||
- Tune connection limits
|
||||
|
||||
**Horizontal Scaling** (future):
|
||||
|
||||
- Separate vault servers (vault sharding)
|
||||
- Load balancer with sticky sessions
|
||||
- Shared storage layer for SQLite databases
|
||||
- Consider alternative databases (PostgreSQL) for multi-server setups
|
||||
|
||||
### Performance Characteristics
|
||||
|
||||
- **Small vaults** (< 1000 files): Excellent performance
|
||||
- **Medium vaults** (1000-10000 files): Good performance with tuning
|
||||
- **Large vaults** (> 10000 files): May require optimisation
|
||||
- **Concurrent users**: Tested with dozens of simultaneous clients per vault
|
||||
|
||||
## Security Model
|
||||
|
||||
### Authentication
|
||||
|
||||
- Token-based authentication
|
||||
- Tokens configured in server `config.yml`
|
||||
- No password hashing (tokens are secrets)
|
||||
|
||||
### Authorization
|
||||
|
||||
- Per-user vault access control
|
||||
- Allow-list or deny-list patterns
|
||||
- Global access or vault-specific access
|
||||
|
||||
### Network Security
|
||||
|
||||
- WebSocket over TLS (WSS) for encrypted transport
|
||||
- No built-in SSL (use reverse proxy)
|
||||
- CORS configured for web clients
|
||||
|
||||
### Data Security
|
||||
|
||||
- No encryption at rest (use encrypted filesystems if needed)
|
||||
- No end-to-end encryption (server sees all content)
|
||||
- Self-hosted model: you control the data
|
||||
|
||||
## Technology Choices
|
||||
|
||||
**Rust**: Low latency, memory safe, excellent async with Tokio, compile-time SQL verification
|
||||
|
||||
**SQLite**: No separate database server, fast for reads, single file per vault, backups are file copies
|
||||
|
||||
**WebSocket**: Bidirectional push, no polling overhead, built-in browser/Node.js support
|
||||
|
||||
**Operational Transformation**: Automatic conflict resolution, preserves all edits, real-time collaboration
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Self-hosted first**: Users control their data and infrastructure
|
||||
2. **Simplicity**: Easy to deploy and operate
|
||||
3. **Real-time**: Changes appear immediately
|
||||
4. **Reliability**: Handle network failures gracefully
|
||||
5. **Performance**: Fast sync for typical vault sizes
|
||||
6. **Privacy**: No third-party services or telemetry
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Learn about the sync algorithm →](/architecture/sync-algorithm)
|
||||
- [Understand data flow in detail →](/architecture/data-flow)
|
||||
- [Deploy the server →](/guide/server-setup)
|
||||
438
docs/architecture/sync-algorithm.md
Normal file
438
docs/architecture/sync-algorithm.md
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
# Sync Algorithm
|
||||
|
||||
VaultLink uses operational transformation (OT) to handle concurrent edits and maintain consistency across clients.
|
||||
|
||||
## Operational Transformation
|
||||
|
||||
Operational transformation is a technique for managing concurrent edits to the same document. It transforms operations (edits) so they can be applied in different orders while preserving user intent.
|
||||
|
||||
### Why OT?
|
||||
|
||||
Traditional conflict resolution approaches:
|
||||
|
||||
- **Last write wins**: Loses data, frustrating for users
|
||||
- **Manual merging**: Interrupts workflow, requires user intervention
|
||||
- **Version branching**: Complex, not suitable for real-time sync
|
||||
|
||||
Operational transformation:
|
||||
|
||||
- **Automatic**: No user intervention required
|
||||
- **Preserves all edits**: No data loss
|
||||
- **Real-time**: Changes appear immediately
|
||||
- **Intuitive**: Behaviour matches user expectations
|
||||
|
||||
## The reconcile-text Library
|
||||
|
||||
VaultLink uses the [`reconcile-text`](https://crates.io/crates/reconcile-text) Rust library for operational transformation on text documents.
|
||||
|
||||
### Why reconcile-text over CRDTs?
|
||||
|
||||
VaultLink faces a **differential synchronisation** challenge: users edit Obsidian vaults with various editors (Obsidian desktop, Obsidian mobile, Vim, VS Code, or any text editor), often while offline. This means we only observe the **final state** of each document after editing, not the individual keystrokes or operations that produced it.
|
||||
|
||||
**The fundamental problem**:
|
||||
|
||||
- **CRDTs and traditional OT** require capturing every individual operation (each character insertion, deletion, cursor movement)
|
||||
- **VaultLink's reality**: Users edit files with arbitrary tools, sync happens after the fact
|
||||
- **What we know**: Parent version and two modified versions
|
||||
- **What we don't know**: The sequence of operations that created those modifications
|
||||
|
||||
**Why reconcile-text wins for this use case**:
|
||||
|
||||
1. **Works with end states only**: reconcile-text performs conflict-free 3-way merging given just parent, left, and right versions—no operation history needed
|
||||
|
||||
2. **Editor-agnostic**: Users can edit with any tool without requiring VaultLink-specific plugins or operation tracking
|
||||
|
||||
3. **Offline-first**: Edits made while disconnected are merged cleanly when sync resumes, because we're diffing final states rather than replaying operations
|
||||
|
||||
4. **No conflict markers**: Unlike Git merge, produces clean merged output without `<<<<<<<` markers that interrupt note-taking flow
|
||||
|
||||
5. **Human text forgiveness**: For knowledge bases and documentation, a slightly imperfect merge (e.g., minor word order issues) is vastly preferable to manual conflict resolution
|
||||
|
||||
6. **Simpler infrastructure**: No need for complex operation capture, transformation logs, or tombstone management that CRDTs require
|
||||
|
||||
**The trade-off**:
|
||||
|
||||
CRDTs excel when you control the entire editing infrastructure and can capture every operation. reconcile-text excels when you're synchronising independently-edited files—exactly VaultLink's scenario. The merge quality depends on Myers' diff algorithm rather than operation history, which is the correct trade-off for differential sync.
|
||||
|
||||
For note-taking workflows where users value editor freedom and offline editing, this approach provides superior user experience compared to either CRDTs (which would require operation tracking) or Git-style merging (which requires manual conflict resolution).
|
||||
|
||||
[Learn more about reconcile-text →](https://schmelczer.dev/reconcile)
|
||||
|
||||
### How It Works
|
||||
|
||||
Given three versions (parent, left, right), reconcile-text produces a merged result.
|
||||
|
||||
**How reconcile-text works**:
|
||||
|
||||
1. **Tokenisation**: Split text into words (using `BuiltinTokenizer::Word`)
|
||||
2. **Three-way diff**: Compare parent→left and parent→right changes
|
||||
3. **Merge**: Combine non-conflicting changes, prefer content preservation for conflicts
|
||||
4. **Result**: Merged text with both edits applied
|
||||
|
||||
**Example**:
|
||||
|
||||
```
|
||||
Parent: "The quick brown fox"
|
||||
User A: "The quick red fox" (changes "brown" → "red")
|
||||
User B: "The very quick brown fox" (inserts "very ")
|
||||
|
||||
Merged: "The very quick red fox" (both changes applied)
|
||||
```
|
||||
|
||||
**Merge conditions**: Only `.md` and `.txt` files with valid UTF-8 get merged. Binary files or other extensions use last-write-wins.
|
||||
|
||||
### Operation Types
|
||||
|
||||
The algorithm handles these operations:
|
||||
|
||||
- **Insert**: Add text at position
|
||||
- **Delete**: Remove text from position
|
||||
- **Retain**: Keep existing text unchanged
|
||||
|
||||
### Transformation Process
|
||||
|
||||
1. **Client A** makes edit and sends to server
|
||||
2. **Client B** makes concurrent edit and sends to server
|
||||
3. **Server** receives both edits
|
||||
4. **Server** transforms operations to account for concurrent changes
|
||||
5. **Server** applies merged result to database
|
||||
6. **Server** sends transformed operations to both clients
|
||||
7. **Clients** apply transformed operations locally
|
||||
|
||||
## Sync State Management
|
||||
|
||||
VaultLink maintains sync state to track which changes have been applied.
|
||||
|
||||
### Version Vectors
|
||||
|
||||
Each document has a version tracked by:
|
||||
|
||||
- **Server version**: Incremented on each change
|
||||
- **Client cursors**: Track which version each client has seen
|
||||
|
||||
This enables:
|
||||
|
||||
- Efficient syncing (only send changes since last sync)
|
||||
- Conflict detection (concurrent edits to same version)
|
||||
- Ordering of operations
|
||||
|
||||
### Cursor Management
|
||||
|
||||
Clients maintain a cursor position:
|
||||
|
||||
```rust
|
||||
struct Cursor {
|
||||
vault_id: String,
|
||||
client_id: String,
|
||||
last_version: u64,
|
||||
last_updated: DateTime,
|
||||
}
|
||||
```
|
||||
|
||||
On sync:
|
||||
|
||||
1. Client sends cursor (last seen version)
|
||||
2. Server returns all changes since that version
|
||||
3. Client applies changes and updates cursor
|
||||
|
||||
## Conflict Resolution Flow
|
||||
|
||||
### Scenario: Concurrent Edits
|
||||
|
||||
Two users edit the same paragraph simultaneously.
|
||||
|
||||
**Initial state**:
|
||||
|
||||
```
|
||||
Version 10: "The quick brown fox jumps over the lazy dog."
|
||||
```
|
||||
|
||||
**User A's edit** (version 11):
|
||||
|
||||
```
|
||||
"The quick brown fox jumps over the very lazy dog."
|
||||
```
|
||||
|
||||
_Inserts "very " at position 40_
|
||||
|
||||
**User B's edit** (also from version 10):
|
||||
|
||||
```
|
||||
"The quick red fox jumps over the lazy dog."
|
||||
```
|
||||
|
||||
_Replaces "brown" with "red" at position 10_
|
||||
|
||||
### Server Processing
|
||||
|
||||
1. **Receive User A's operation**:
|
||||
- Base: version 10
|
||||
- Operation: Insert("very ", position=40)
|
||||
- Apply to database → version 11
|
||||
|
||||
2. **Receive User B's operation**:
|
||||
- Base: version 10
|
||||
- Operation: Replace("brown"→"red", position=10)
|
||||
- **Conflict detected**: Base is version 10, but current is version 11
|
||||
|
||||
3. **Transform User B's operation**:
|
||||
- Transform against User A's operation
|
||||
- Adjust positions/content as needed
|
||||
- Apply transformed operation → version 12
|
||||
|
||||
4. **Broadcast updates**:
|
||||
- Send User A's operation to User B
|
||||
- Send transformed User B's operation to User A
|
||||
|
||||
### Final Result
|
||||
|
||||
```
|
||||
Version 12: "The quick red fox jumps over the very lazy dog."
|
||||
```
|
||||
|
||||
Both edits are preserved in the final document.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### 1. Delete vs Insert Conflict
|
||||
|
||||
**Scenario**: User A deletes a paragraph while User B edits it.
|
||||
|
||||
**Resolution**:
|
||||
|
||||
- OT algorithm prioritizes preservation of content
|
||||
- Insert operation is transformed to account for deletion
|
||||
- Typically results in inserted content appearing nearby
|
||||
|
||||
**Example**:
|
||||
|
||||
```
|
||||
Base: "Line 1\nLine 2\nLine 3"
|
||||
|
||||
User A: Delete Line 2 → "Line 1\nLine 3"
|
||||
User B: Edit Line 2 → "Line 1\nLine 2 modified\nLine 3"
|
||||
|
||||
Result: "Line 1\nLine 2 modified\nLine 3"
|
||||
```
|
||||
|
||||
(Insert takes precedence, preserving user content)
|
||||
|
||||
### 2. Overlapping Edits
|
||||
|
||||
**Scenario**: Two users edit overlapping regions.
|
||||
|
||||
**Resolution**:
|
||||
|
||||
- OT splits operations into non-overlapping segments
|
||||
- Applies each segment independently
|
||||
- Merges results
|
||||
|
||||
### 3. Delete vs Delete
|
||||
|
||||
**Scenario**: Two users delete overlapping text.
|
||||
|
||||
**Resolution**:
|
||||
|
||||
- Deletes are merged
|
||||
- Final result has the union of deleted ranges removed
|
||||
|
||||
### 4. Network Partitions
|
||||
|
||||
**Scenario**: Client loses connection, makes edits offline, reconnects.
|
||||
|
||||
**Resolution**:
|
||||
|
||||
1. Client queues edits locally
|
||||
2. On reconnect, sends all queued operations
|
||||
3. Server applies OT against all operations that happened during partition
|
||||
4. Client receives transformed operations and applies
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
### Time Complexity
|
||||
|
||||
- **Single operation**: O(1) for most operations
|
||||
- **Transformation**: O(n) where n is operation size
|
||||
- **Conflict resolution**: O(m × n) where m is number of concurrent operations
|
||||
|
||||
### Space Complexity
|
||||
|
||||
- **Version history**: Grows with number of changes
|
||||
- **Cursors**: O(clients × vaults)
|
||||
- **Active operations**: Minimal (processed in real-time)
|
||||
|
||||
### Optimisation
|
||||
|
||||
VaultLink optimises for:
|
||||
|
||||
- Small, frequent edits (typical typing patterns)
|
||||
- Text documents (not binary files)
|
||||
- Real-time processing (no batching delay)
|
||||
|
||||
## Limitations
|
||||
|
||||
### Binary and Non-Mergeable Files
|
||||
|
||||
Only **`.md`** and **`.txt`** files get automatic merging. Everything else uses last-write-wins.
|
||||
|
||||
**Binary detection**:
|
||||
|
||||
- Files with NUL bytes (`0x00`)
|
||||
- Files failing UTF-8 validation
|
||||
|
||||
Even `.md` files are treated as binary if they fail UTF-8 checks.
|
||||
|
||||
**Last-write-wins behaviour**:
|
||||
|
||||
```
|
||||
User A uploads image.png → Server version 1
|
||||
User B uploads image.png → Server version 2 (A's upload lost)
|
||||
```
|
||||
|
||||
**Workaround**: Avoid concurrent edits to non-text files. [See all limitations →](/guide/limitations)
|
||||
|
||||
### Large Documents
|
||||
|
||||
Very large documents (> 1MB) may have:
|
||||
|
||||
- Higher transformation costs
|
||||
- Slower sync times
|
||||
- Increased memory usage
|
||||
|
||||
**Workaround**: Split large documents or increase timeout settings.
|
||||
|
||||
### Complex Formatting
|
||||
|
||||
Markdown with complex structures may occasionally produce unexpected results:
|
||||
|
||||
- Nested lists
|
||||
- Tables
|
||||
- Code blocks
|
||||
|
||||
**Workaround**: Manual cleanup if needed, or minimize concurrent edits to complex structures.
|
||||
|
||||
## Consistency Guarantees
|
||||
|
||||
### Strong Consistency
|
||||
|
||||
VaultLink provides **strong eventual consistency**:
|
||||
|
||||
- All clients eventually converge to the same state
|
||||
- Operations applied in causal order
|
||||
- No data loss under normal operation
|
||||
|
||||
### Ordering Guarantees
|
||||
|
||||
- Operations from the same client are applied in order
|
||||
- Concurrent operations may be applied in any order
|
||||
- Final result is independent of operation order (commutative)
|
||||
|
||||
### Durability
|
||||
|
||||
- Operations are written to SQLite before acknowledgment
|
||||
- SQLite ACID guarantees protect against data loss
|
||||
- Clients retry failed uploads
|
||||
|
||||
## Comparison with Other Approaches
|
||||
|
||||
### Git-style Merging
|
||||
|
||||
| Aspect | Git Merge | VaultLink OT |
|
||||
| -------------------------- | ------------ | ----------------------- |
|
||||
| Real-time | No | Yes |
|
||||
| Manual conflict resolution | Yes | No |
|
||||
| Branching | Yes | No |
|
||||
| Automatic merge | Limited | Always |
|
||||
| Use case | Code changes | Collaborative documents |
|
||||
|
||||
### CRDTs (Conflict-free Replicated Data Types)
|
||||
|
||||
| Aspect | CRDTs | VaultLink (reconcile-text) |
|
||||
| ----------------------------- | ------------------------------------ | ------------------------------------------------- |
|
||||
| **Operation tracking** | Required (every keystroke) | Not required (end states only) |
|
||||
| **Editor freedom** | Limited (must use CRDT-aware editor) | Unlimited (any text editor works) |
|
||||
| **Offline editing** | Requires operation log | Works with file comparison |
|
||||
| **Server required** | No | Yes |
|
||||
| **Memory overhead** | Higher (tombstones, metadata) | Lower (versions only) |
|
||||
| **Infrastructure complexity** | Higher | Lower |
|
||||
| **Best for** | Controlled editing environments | Independent file editing (Obsidian, Vim, VS Code) |
|
||||
|
||||
**Key insight**: CRDTs are superior when you can capture every operation. reconcile-text is superior when users edit files independently with arbitrary tools—exactly VaultLink's scenario.
|
||||
|
||||
### Last Write Wins
|
||||
|
||||
| Aspect | LWW | VaultLink OT |
|
||||
| --------------- | ---- | ------------ |
|
||||
| Data loss | Yes | No |
|
||||
| Simplicity | High | Medium |
|
||||
| User experience | Poor | Excellent |
|
||||
| Performance | Best | Good |
|
||||
|
||||
## Algorithm Details
|
||||
|
||||
### Transformation Rules
|
||||
|
||||
When transforming operation `A` against operation `B`:
|
||||
|
||||
1. **Insert vs Insert**:
|
||||
- If positions equal: Order by client ID
|
||||
- If different positions: Adjust positions
|
||||
|
||||
2. **Insert vs Delete**:
|
||||
- If insert in deleted range: Shift insert position
|
||||
- If insert after delete: Adjust position by deleted length
|
||||
|
||||
3. **Delete vs Delete**:
|
||||
- If ranges overlap: Merge delete ranges
|
||||
- If ranges disjoint: Adjust positions
|
||||
|
||||
4. **Retain vs Any**:
|
||||
- Retain operations don't conflict
|
||||
- Simply adjust positions
|
||||
|
||||
### Transformation Example
|
||||
|
||||
```rust
|
||||
// Pseudo-code for transformation
|
||||
fn transform(op_a: Operation, op_b: Operation) -> (Operation, Operation) {
|
||||
match (op_a, op_b) {
|
||||
(Insert(pos_a, text_a), Insert(pos_b, text_b)) => {
|
||||
if pos_a < pos_b {
|
||||
(op_a, Insert(pos_b + text_a.len(), text_b))
|
||||
} else if pos_a > pos_b {
|
||||
(Insert(pos_a + text_b.len(), text_a), op_b)
|
||||
} else {
|
||||
// Same position, use client ID to break tie
|
||||
if client_id_a < client_id_b {
|
||||
(op_a, Insert(pos_b + text_a.len(), text_b))
|
||||
} else {
|
||||
(Insert(pos_a + text_b.len(), text_a), op_b)
|
||||
}
|
||||
}
|
||||
}
|
||||
// ... other cases
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For Smooth Collaboration
|
||||
|
||||
1. **Small edits**: Make small, focused changes for easier merging
|
||||
2. **Coordinate major changes**: Discuss large refactors with team
|
||||
3. **Monitor sync status**: Ensure changes are uploaded before signing off
|
||||
4. **Test conflict resolution**: Verify behaviour matches expectations
|
||||
|
||||
### For Developers
|
||||
|
||||
1. **Text files preferred**: OT works best on text
|
||||
2. **Limit file sizes**: Keep documents reasonably sized
|
||||
3. **Binary files**: Use versioning or avoid concurrent edits
|
||||
4. **Testing**: Test concurrent edit scenarios thoroughly
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [reconcile-text library](https://crates.io/crates/reconcile-text)
|
||||
- [Operational Transformation FAQ](https://en.wikipedia.org/wiki/Operational_transformation)
|
||||
- [Data flow architecture →](/architecture/data-flow)
|
||||
603
docs/config/advanced.md
Normal file
603
docs/config/advanced.md
Normal file
|
|
@ -0,0 +1,603 @@
|
|||
# Advanced Configuration
|
||||
|
||||
Advanced topics for optimising and customising your VaultLink deployment.
|
||||
|
||||
## Database Optimisation
|
||||
|
||||
### SQLite Tuning
|
||||
|
||||
While VaultLink handles most SQLite configuration automatically, you can optimise for specific workloads.
|
||||
|
||||
#### WAL Mode
|
||||
|
||||
VaultLink uses Write-Ahead Logging (WAL) mode by default for better concurrency.
|
||||
|
||||
**Benefits**:
|
||||
|
||||
- Readers don't block writers
|
||||
- Writers don't block readers
|
||||
- Better performance for concurrent access
|
||||
|
||||
**Maintenance**:
|
||||
|
||||
```bash
|
||||
# Checkpoint WAL to main database (run periodically)
|
||||
sqlite3 databases/vault.db "PRAGMA wal_checkpoint(TRUNCATE);"
|
||||
```
|
||||
|
||||
#### Database Size Management
|
||||
|
||||
Over time, databases can grow with version history:
|
||||
|
||||
```bash
|
||||
# Check database size
|
||||
du -h databases/*.db
|
||||
|
||||
# Vacuum to reclaim space (offline only)
|
||||
sqlite3 databases/vault.db "VACUUM;"
|
||||
|
||||
# Analyse for query optimisation
|
||||
sqlite3 databases/vault.db "ANALYZE;"
|
||||
```
|
||||
|
||||
**Schedule maintenance**:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# monthly-maintenance.sh
|
||||
|
||||
for db in databases/*.db; do
|
||||
echo "Optimising $db"
|
||||
sqlite3 "$db" "PRAGMA optimize;"
|
||||
sqlite3 "$db" "PRAGMA wal_checkpoint(TRUNCATE);"
|
||||
done
|
||||
```
|
||||
|
||||
### Version History Cleanup
|
||||
|
||||
VaultLink stores **all versions indefinitely** by default. Database grows with every change.
|
||||
|
||||
**Database schema**: Each version stored in `documents` table with `vault_update_id` (sequential).
|
||||
|
||||
Manual cleanup (keep last 100 versions per document):
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# prune-old-versions.sh
|
||||
|
||||
for db in databases/*.db; do
|
||||
sqlite3 "$db" <<EOF
|
||||
DELETE FROM documents
|
||||
WHERE vault_update_id NOT IN (
|
||||
SELECT vault_update_id FROM documents d2
|
||||
WHERE d2.document_id = documents.document_id
|
||||
ORDER BY vault_update_id DESC
|
||||
LIMIT 100
|
||||
);
|
||||
EOF
|
||||
done
|
||||
```
|
||||
|
||||
**Warning**: This deletes old versions permanently. No undo.
|
||||
|
||||
Run monthly via cron:
|
||||
|
||||
```bash
|
||||
0 3 1 * * /opt/vaultlink/prune-old-versions.sh
|
||||
```
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
### Connection Pool Sizing
|
||||
|
||||
Calculate optimal `max_connections_per_vault`:
|
||||
|
||||
```
|
||||
max_connections = (concurrent_users × avg_operations_per_user) + buffer
|
||||
```
|
||||
|
||||
**Example**:
|
||||
|
||||
- 20 concurrent users
|
||||
- 2 operations per user on average
|
||||
- 25% buffer
|
||||
|
||||
```
|
||||
max_connections = (20 × 2) × 1.25 = 50
|
||||
```
|
||||
|
||||
### Timeout Configuration
|
||||
|
||||
Adjust timeouts based on network characteristics:
|
||||
|
||||
**Fast local network**:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
cursor_timeout_seconds: 30
|
||||
|
||||
server:
|
||||
response_timeout_seconds: 30
|
||||
```
|
||||
|
||||
**Slow or unreliable network**:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
cursor_timeout_seconds: 180
|
||||
|
||||
server:
|
||||
response_timeout_seconds: 120
|
||||
```
|
||||
|
||||
**Mobile clients**:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
cursor_timeout_seconds: 300 # Longer for intermittent connections
|
||||
|
||||
server:
|
||||
response_timeout_seconds: 180
|
||||
```
|
||||
|
||||
## Reverse Proxy Configuration
|
||||
|
||||
### Nginx with SSL
|
||||
|
||||
Complete Nginx configuration for production:
|
||||
|
||||
```nginx
|
||||
# Rate limiting
|
||||
limit_req_zone $binary_remote_addr zone=vaultlink:10m rate=10r/s;
|
||||
|
||||
upstream vaultlink {
|
||||
server localhost:3000;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name sync.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/sync.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/sync.example.com/privkey.pem;
|
||||
|
||||
# SSL security settings
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
# HSTS
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# Rate limiting
|
||||
limit_req zone=vaultlink burst=20 nodelay;
|
||||
|
||||
# Client body size (match server config)
|
||||
client_max_body_size 512M;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 90s;
|
||||
proxy_send_timeout 90s;
|
||||
proxy_read_timeout 3600s; # WebSocket long-lived connections
|
||||
|
||||
# WebSocket headers
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Disable buffering for WebSocket
|
||||
proxy_buffering off;
|
||||
|
||||
location / {
|
||||
proxy_pass http://vaultlink;
|
||||
}
|
||||
|
||||
# Health check endpoint (use any vault name)
|
||||
location /health {
|
||||
proxy_pass http://vaultlink/vaults/test/ping;
|
||||
access_log off;
|
||||
}
|
||||
}
|
||||
|
||||
# Redirect HTTP to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name sync.example.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
### Caddy with Auto SSL
|
||||
|
||||
Caddy handles SSL automatically:
|
||||
|
||||
```caddy
|
||||
sync.example.com {
|
||||
reverse_proxy localhost:3000 {
|
||||
# WebSocket support
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
|
||||
# Timeouts
|
||||
transport http {
|
||||
read_timeout 3600s
|
||||
write_timeout 90s
|
||||
}
|
||||
}
|
||||
|
||||
# Rate limiting (requires caddy-rate-limit plugin)
|
||||
rate_limit {
|
||||
zone dynamic {
|
||||
match {
|
||||
remote_ip
|
||||
}
|
||||
rate 10r/s
|
||||
burst 20
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Traefik Configuration
|
||||
|
||||
Using Docker labels:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
vaultlink-server:
|
||||
image: ghcr.io/schmelczer/vault-link-server:latest
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.vaultlink.rule=Host(`sync.example.com`)"
|
||||
- "traefik.http.routers.vaultlink.entrypoints=websecure"
|
||||
- "traefik.http.routers.vaultlink.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.vaultlink.loadbalancer.server.port=3000"
|
||||
# Middleware for timeouts
|
||||
- "traefik.http.middlewares.vaultlink-timeout.timeout.request=3600s"
|
||||
```
|
||||
|
||||
## Docker Optimizations
|
||||
|
||||
### Resource Limits
|
||||
|
||||
Limit container resources:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
vaultlink-server:
|
||||
image: ghcr.io/schmelczer/vault-link-server:latest
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "2.0"
|
||||
memory: 4G
|
||||
reservations:
|
||||
cpus: "1.0"
|
||||
memory: 2G
|
||||
```
|
||||
|
||||
### Logging Configuration
|
||||
|
||||
Optimize Docker logging:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
vaultlink-server:
|
||||
image: ghcr.io/schmelczer/vault-link-server:latest
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "5"
|
||||
```
|
||||
|
||||
### Volume Optimization
|
||||
|
||||
Use named volumes for better performance:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
vaultlink-server:
|
||||
image: ghcr.io/schmelczer/vault-link-server:latest
|
||||
volumes:
|
||||
- vaultlink-data:/data
|
||||
- vaultlink-logs:/data/logs
|
||||
|
||||
volumes:
|
||||
vaultlink-data:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: /mnt/fast-ssd/vaultlink
|
||||
vaultlink-logs:
|
||||
driver: local
|
||||
```
|
||||
|
||||
## High Availability
|
||||
|
||||
### Health Checks
|
||||
|
||||
Comprehensive health monitoring:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
vaultlink-server:
|
||||
image: ghcr.io/schmelczer/vault-link-server:latest
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:3000/vaults/test/ping || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
```
|
||||
|
||||
Monitor health in production:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# health-monitor.sh
|
||||
|
||||
while true; do
|
||||
if ! curl -sf http://localhost:3000/vaults/test/ping > /dev/null; then
|
||||
echo "Health check failed at $(date)" | mail -s "VaultLink Down" admin@example.com
|
||||
# Optionally restart
|
||||
# docker restart vaultlink-server
|
||||
fi
|
||||
sleep 30
|
||||
done
|
||||
```
|
||||
|
||||
### Backup Automation
|
||||
|
||||
Automated backup script:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# backup-vaultlink.sh
|
||||
|
||||
BACKUP_DIR="/backup/vaultlink"
|
||||
DATA_DIR="/data"
|
||||
DATE=$(date +%Y%m%d-%H%M%S)
|
||||
RETENTION_DAYS=30
|
||||
|
||||
# Create backup directory
|
||||
mkdir -p "$BACKUP_DIR/$DATE"
|
||||
|
||||
# Backup databases (with WAL checkpoint)
|
||||
for db in "$DATA_DIR"/databases/*.db; do
|
||||
sqlite3 "$db" "PRAGMA wal_checkpoint(TRUNCATE);"
|
||||
cp "$db" "$BACKUP_DIR/$DATE/"
|
||||
[ -f "${db}-wal" ] && cp "${db}-wal" "$BACKUP_DIR/$DATE/"
|
||||
[ -f "${db}-shm" ] && cp "${db}-shm" "$BACKUP_DIR/$DATE/"
|
||||
done
|
||||
|
||||
# Backup configuration
|
||||
cp "$DATA_DIR/config.yml" "$BACKUP_DIR/$DATE/"
|
||||
|
||||
# Compress backup
|
||||
tar -czf "$BACKUP_DIR/vaultlink-$DATE.tar.gz" -C "$BACKUP_DIR" "$DATE"
|
||||
rm -rf "$BACKUP_DIR/$DATE"
|
||||
|
||||
# Clean old backups
|
||||
find "$BACKUP_DIR" -name "vaultlink-*.tar.gz" -mtime +$RETENTION_DAYS -delete
|
||||
|
||||
# Upload to remote storage (optional)
|
||||
# rclone copy "$BACKUP_DIR/vaultlink-$DATE.tar.gz" remote:backups/
|
||||
```
|
||||
|
||||
Schedule with cron:
|
||||
|
||||
```cron
|
||||
0 2 * * * /opt/vaultlink/backup-vaultlink.sh
|
||||
```
|
||||
|
||||
### Restore from Backup
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# restore-vaultlink.sh
|
||||
|
||||
BACKUP_FILE="$1"
|
||||
DATA_DIR="/data"
|
||||
|
||||
if [ -z "$BACKUP_FILE" ]; then
|
||||
echo "Usage: $0 <backup-file.tar.gz>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stop server
|
||||
docker stop vaultlink-server
|
||||
|
||||
# Extract backup
|
||||
tar -xzf "$BACKUP_FILE" -C /tmp/
|
||||
BACKUP_DATE=$(basename "$BACKUP_FILE" .tar.gz | cut -d- -f2-)
|
||||
|
||||
# Restore databases
|
||||
cp /tmp/"$BACKUP_DATE"/databases/*.db "$DATA_DIR/databases/"
|
||||
|
||||
# Restore config (careful!)
|
||||
# cp /tmp/$BACKUP_DATE/config.yml "$DATA_DIR/"
|
||||
|
||||
# Cleanup
|
||||
rm -rf /tmp/"$BACKUP_DATE"
|
||||
|
||||
# Start server
|
||||
docker start vaultlink-server
|
||||
|
||||
echo "Restore complete. Check server logs."
|
||||
```
|
||||
|
||||
## Monitoring and Metrics
|
||||
|
||||
### Prometheus Metrics
|
||||
|
||||
While VaultLink doesn't expose metrics natively, monitor Docker:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
vaultlink-server:
|
||||
image: ghcr.io/schmelczer/vault-link-server:latest
|
||||
labels:
|
||||
- "prometheus.io/scrape=true"
|
||||
- "prometheus.io/port=3000"
|
||||
|
||||
cadvisor:
|
||||
image: gcr.io/cadvisor/cadvisor:latest
|
||||
volumes:
|
||||
- /:/rootfs:ro
|
||||
- /var/run:/var/run:ro
|
||||
- /sys:/sys:ro
|
||||
- /var/lib/docker/:/var/lib/docker:ro
|
||||
ports:
|
||||
- 8080:8080
|
||||
```
|
||||
|
||||
### Log Analysis
|
||||
|
||||
Analyze logs for insights:
|
||||
|
||||
```bash
|
||||
# Most active users
|
||||
grep "authenticated" logs/*.log | cut -d"'" -f2 | sort | uniq -c | sort -rn
|
||||
|
||||
# Failed authentications by IP
|
||||
grep "Authentication failed" logs/*.log | grep -oP '\d+\.\d+\.\d+\.\d+' | sort | uniq -c | sort -rn
|
||||
|
||||
# Upload activity
|
||||
grep "Upload:" logs/*.log | wc -l
|
||||
|
||||
# Average files per vault
|
||||
grep "Sync complete" logs/*.log | grep -oP '\d+ files' | cut -d' ' -f1 | awk '{sum+=$1; count++} END {print sum/count}'
|
||||
```
|
||||
|
||||
### Alerting
|
||||
|
||||
Simple alerting with cron:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# alert-errors.sh
|
||||
|
||||
ERROR_THRESHOLD=10
|
||||
ERROR_COUNT=$(grep -c "ERROR" logs/latest.log)
|
||||
|
||||
if [ "$ERROR_COUNT" -gt "$ERROR_THRESHOLD" ]; then
|
||||
echo "VaultLink has $ERROR_COUNT errors in the last hour" | \
|
||||
mail -s "VaultLink Alert" admin@example.com
|
||||
fi
|
||||
```
|
||||
|
||||
## Security Hardening
|
||||
|
||||
### Network Isolation
|
||||
|
||||
Run VaultLink in isolated network:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
vaultlink-server:
|
||||
image: ghcr.io/schmelczer/vault-link-server:latest
|
||||
networks:
|
||||
- vaultlink-internal
|
||||
- proxy-external
|
||||
|
||||
networks:
|
||||
vaultlink-internal:
|
||||
internal: true
|
||||
proxy-external:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
### Read-Only Root Filesystem
|
||||
|
||||
Run with read-only root (mount writable volumes for data):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
vaultlink-server:
|
||||
image: ghcr.io/schmelczer/vault-link-server:latest
|
||||
read_only: true
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- /tmp
|
||||
```
|
||||
|
||||
### Drop Capabilities
|
||||
|
||||
Run with minimal privileges:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
vaultlink-server:
|
||||
image: ghcr.io/schmelczer/vault-link-server:latest
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
```
|
||||
|
||||
## Migration
|
||||
|
||||
### Moving to New Server
|
||||
|
||||
1. **Backup on old server**:
|
||||
|
||||
```bash
|
||||
./backup-vaultlink.sh
|
||||
```
|
||||
|
||||
2. **Transfer backup**:
|
||||
|
||||
```bash
|
||||
scp vaultlink-backup.tar.gz new-server:/tmp/
|
||||
```
|
||||
|
||||
3. **Restore on new server**:
|
||||
|
||||
```bash
|
||||
./restore-vaultlink.sh /tmp/vaultlink-backup.tar.gz
|
||||
```
|
||||
|
||||
4. **Update DNS/clients** to point to new server
|
||||
|
||||
5. **Verify sync** on all clients
|
||||
|
||||
### Version Upgrades
|
||||
|
||||
```bash
|
||||
# Pull latest image
|
||||
docker pull ghcr.io/schmelczer/vault-link-server:latest
|
||||
|
||||
# Backup first
|
||||
./backup-vaultlink.sh
|
||||
|
||||
# Stop old container
|
||||
docker stop vaultlink-server
|
||||
docker rm vaultlink-server
|
||||
|
||||
# Start with new image
|
||||
docker run -d \
|
||||
--name vaultlink-server \
|
||||
--restart unless-stopped \
|
||||
-p 3000:3000 \
|
||||
-v ./data:/data \
|
||||
ghcr.io/schmelczer/vault-link-server:latest \
|
||||
/app/sync_server /data/config.yml
|
||||
|
||||
# Check logs
|
||||
docker logs -f vaultlink-server
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Understand the architecture →](/architecture/)
|
||||
- [Deploy the server →](/guide/server-setup)
|
||||
- [Configure clients →](/guide/obsidian-plugin)
|
||||
558
docs/config/authentication.md
Normal file
558
docs/config/authentication.md
Normal file
|
|
@ -0,0 +1,558 @@
|
|||
# Authentication Configuration
|
||||
|
||||
VaultLink uses token-based authentication with per-user vault access control. This guide covers all authentication and authorization options.
|
||||
|
||||
## Overview
|
||||
|
||||
Authentication in VaultLink:
|
||||
|
||||
- **Token-based**: Users authenticate with secure tokens
|
||||
- **Configured in YAML**: All users defined in `config.yml`
|
||||
- **Vault-level access**: Control which vaults each user can access
|
||||
- **No password hashing**: Tokens are treated as secrets
|
||||
|
||||
## Basic Configuration
|
||||
|
||||
```yaml
|
||||
users:
|
||||
user_configs:
|
||||
- name: alice
|
||||
token: alice-secure-token-here
|
||||
vault_access:
|
||||
type: allow_access_to_all
|
||||
```
|
||||
|
||||
## User Configuration Fields
|
||||
|
||||
### `name`
|
||||
|
||||
**Type**: String
|
||||
**Required**: Yes
|
||||
|
||||
Human-readable identifier for the user. Used in logs and auditing.
|
||||
|
||||
```yaml
|
||||
- name: alice
|
||||
```
|
||||
|
||||
**Notes**:
|
||||
|
||||
- Must be unique across all users
|
||||
- Used for identification only, not authentication
|
||||
- Appears in server logs
|
||||
- Can be any string (e.g., email, username)
|
||||
|
||||
### `token`
|
||||
|
||||
**Type**: String
|
||||
**Required**: Yes
|
||||
|
||||
Authentication token for the user. Must be kept secret.
|
||||
|
||||
```yaml
|
||||
- token: 1a2b3c4d5e6f7g8h9i0j...
|
||||
```
|
||||
|
||||
**Best practices**:
|
||||
|
||||
- Generate with: `openssl rand -hex 32`
|
||||
- Minimum length: 32 characters
|
||||
- Use different token per user
|
||||
- Never commit to version control
|
||||
- Rotate periodically
|
||||
|
||||
**Example token generation**:
|
||||
|
||||
```bash
|
||||
# Generate a secure token
|
||||
openssl rand -hex 32
|
||||
# Output: a7f3c9d1e8b2f4a6c3d9e1f7b8a4c2d6e9f1a3b7c5d8e2f4a6b9c3d1e8f7a4b2
|
||||
```
|
||||
|
||||
### `vault_access`
|
||||
|
||||
**Type**: Object
|
||||
**Required**: Yes
|
||||
|
||||
Defines which vaults the user can access.
|
||||
|
||||
**Three modes**:
|
||||
|
||||
1. `allow_access_to_all`: Access to all vaults
|
||||
2. `allow_list`: Access to specific vaults only
|
||||
3. `deny_list`: Access to all vaults except specific ones
|
||||
|
||||
## Access Control Modes
|
||||
|
||||
### Allow Access to All
|
||||
|
||||
Grant access to every vault:
|
||||
|
||||
```yaml
|
||||
users:
|
||||
user_configs:
|
||||
- name: admin
|
||||
token: admin-token
|
||||
vault_access:
|
||||
type: allow_access_to_all
|
||||
```
|
||||
|
||||
**Use cases**:
|
||||
|
||||
- Administrator accounts
|
||||
- Personal single-user deployments
|
||||
- Development/testing
|
||||
|
||||
### Allow List
|
||||
|
||||
Grant access only to specific vaults:
|
||||
|
||||
```yaml
|
||||
users:
|
||||
user_configs:
|
||||
- name: alice
|
||||
token: alice-token
|
||||
vault_access:
|
||||
type: allow_list
|
||||
allowed:
|
||||
- personal
|
||||
- shared-team
|
||||
- project-alpha
|
||||
```
|
||||
|
||||
**Use cases**:
|
||||
|
||||
- Multi-user deployments
|
||||
- Restricted access scenarios
|
||||
- Separation of concerns
|
||||
|
||||
**Notes**:
|
||||
|
||||
- User can only access listed vaults
|
||||
- Attempting to access other vaults returns authentication error
|
||||
- Empty list = no access to any vault
|
||||
|
||||
### Deny List
|
||||
|
||||
Grant access to all vaults except specific ones:
|
||||
|
||||
```yaml
|
||||
users:
|
||||
user_configs:
|
||||
- name: bob
|
||||
token: bob-token
|
||||
vault_access:
|
||||
type: deny_list
|
||||
denied:
|
||||
- restricted
|
||||
- admin-only
|
||||
```
|
||||
|
||||
**Use cases**:
|
||||
|
||||
- Users with broad access except sensitive vaults
|
||||
- Simplify configuration when most vaults are accessible
|
||||
|
||||
**Notes**:
|
||||
|
||||
- User can access any vault not in the deny list
|
||||
- Attempting to access denied vaults returns authentication error
|
||||
|
||||
## Multi-User Scenarios
|
||||
|
||||
### Personal Use (Single User)
|
||||
|
||||
```yaml
|
||||
users:
|
||||
user_configs:
|
||||
- name: me
|
||||
token: my-super-secret-token
|
||||
vault_access:
|
||||
type: allow_access_to_all
|
||||
```
|
||||
|
||||
### Small Team (Shared Vaults)
|
||||
|
||||
```yaml
|
||||
users:
|
||||
user_configs:
|
||||
- name: alice
|
||||
token: alice-token
|
||||
vault_access:
|
||||
type: allow_list
|
||||
allowed:
|
||||
- personal-alice
|
||||
- team-shared
|
||||
- name: bob
|
||||
token: bob-token
|
||||
vault_access:
|
||||
type: allow_list
|
||||
allowed:
|
||||
- personal-bob
|
||||
- team-shared
|
||||
- name: charlie
|
||||
token: charlie-token
|
||||
vault_access:
|
||||
type: allow_list
|
||||
allowed:
|
||||
- personal-charlie
|
||||
- team-shared
|
||||
```
|
||||
|
||||
### Organization (Mixed Access)
|
||||
|
||||
```yaml
|
||||
users:
|
||||
user_configs:
|
||||
- name: admin
|
||||
token: admin-token
|
||||
vault_access:
|
||||
type: allow_access_to_all
|
||||
|
||||
- name: developer
|
||||
token: dev-token
|
||||
vault_access:
|
||||
type: allow_list
|
||||
allowed:
|
||||
- engineering-docs
|
||||
- api-specs
|
||||
- shared
|
||||
|
||||
- name: designer
|
||||
token: design-token
|
||||
vault_access:
|
||||
type: allow_list
|
||||
allowed:
|
||||
- design-docs
|
||||
- brand-assets
|
||||
- shared
|
||||
|
||||
- name: readonly
|
||||
token: readonly-token
|
||||
vault_access:
|
||||
type: allow_list
|
||||
allowed:
|
||||
- public-wiki
|
||||
```
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
### Connection
|
||||
|
||||
1. Client connects via WebSocket
|
||||
2. Client sends authentication message:
|
||||
```json
|
||||
{
|
||||
"type": "auth",
|
||||
"token": "user-token",
|
||||
"vault": "vault-name"
|
||||
}
|
||||
```
|
||||
3. Server validates:
|
||||
- Token exists in config
|
||||
- User has access to requested vault
|
||||
4. Server responds:
|
||||
- Success: Connection established
|
||||
- Failure: Connection closed with error
|
||||
|
||||
### Validation
|
||||
|
||||
Server checks:
|
||||
|
||||
1. **Token match**: Token exists in `user_configs`
|
||||
2. **Vault access**: User has permission for vault
|
||||
3. **Connection limits**: Not exceeding `max_clients_per_vault`
|
||||
|
||||
### Errors
|
||||
|
||||
**Invalid token**:
|
||||
|
||||
```
|
||||
Authentication failed: Invalid token
|
||||
```
|
||||
|
||||
**No vault access**:
|
||||
|
||||
```
|
||||
Authentication failed: User does not have access to vault 'restricted'
|
||||
```
|
||||
|
||||
**Connection limit**:
|
||||
|
||||
```
|
||||
Connection rejected: Maximum clients reached for vault
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### Token Generation
|
||||
|
||||
Generate strong tokens:
|
||||
|
||||
```bash
|
||||
# 64 character hex token (256 bits)
|
||||
openssl rand -hex 32
|
||||
|
||||
# Base64 encoded (256 bits)
|
||||
openssl rand -base64 32
|
||||
|
||||
# UUID v4
|
||||
uuidgen
|
||||
```
|
||||
|
||||
### Token Storage
|
||||
|
||||
**In config file**:
|
||||
|
||||
```yaml
|
||||
users:
|
||||
user_configs:
|
||||
- name: alice
|
||||
token: !ENV ALICE_TOKEN # Read from environment variable
|
||||
```
|
||||
|
||||
**Load from environment**:
|
||||
|
||||
```bash
|
||||
export ALICE_TOKEN="$(openssl rand -hex 32)"
|
||||
./sync_server config.yml
|
||||
```
|
||||
|
||||
### Token Rotation
|
||||
|
||||
Periodically change tokens:
|
||||
|
||||
1. Generate new token
|
||||
2. Update `config.yml`
|
||||
3. Restart server
|
||||
4. Update clients with new token
|
||||
|
||||
### Token Revocation
|
||||
|
||||
To revoke access:
|
||||
|
||||
1. Remove user from `config.yml`
|
||||
2. Restart server
|
||||
3. User's connections will be rejected
|
||||
|
||||
For immediate revocation:
|
||||
|
||||
- Remove user from config
|
||||
- Restart server
|
||||
- Existing connections are terminated
|
||||
|
||||
## Access Patterns
|
||||
|
||||
### Read-Only Users
|
||||
|
||||
VaultLink doesn't distinguish read-only vs read-write. Implement via client:
|
||||
|
||||
```yaml
|
||||
# Server: Grant access
|
||||
users:
|
||||
user_configs:
|
||||
- name: readonly
|
||||
token: readonly-token
|
||||
vault_access:
|
||||
type: allow_list
|
||||
allowed:
|
||||
- public
|
||||
|
||||
# Client: Use CLI in read-only mode (mount vault read-only)
|
||||
docker run -v /vault:/vault:ro ...
|
||||
```
|
||||
|
||||
### Temporary Access
|
||||
|
||||
Grant temporary access:
|
||||
|
||||
1. Add user to config
|
||||
2. Set reminder to remove later
|
||||
3. Remove user when no longer needed
|
||||
4. Restart server
|
||||
|
||||
For automation:
|
||||
|
||||
```bash
|
||||
# Add user with expiry comment
|
||||
echo " - name: temp-user # EXPIRES: 2024-12-31" >> config.yml
|
||||
echo " token: temp-token" >> config.yml
|
||||
```
|
||||
|
||||
### Shared Tokens (Not Recommended)
|
||||
|
||||
Multiple users sharing a token:
|
||||
|
||||
- All appear as same user in logs
|
||||
- Can't revoke individual access
|
||||
- Security risk if one person leaves
|
||||
|
||||
**Instead**: Create separate users with same vault access.
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Server Logs
|
||||
|
||||
Authentication events are logged:
|
||||
|
||||
```
|
||||
2024-01-01 12:00:00 INFO User 'alice' authenticated for vault 'personal'
|
||||
2024-01-01 12:00:05 WARN Authentication failed: Invalid token from 192.168.1.100
|
||||
2024-01-01 12:00:10 WARN User 'bob' denied access to vault 'restricted'
|
||||
```
|
||||
|
||||
### Audit Trail
|
||||
|
||||
Monitor authentication:
|
||||
|
||||
```bash
|
||||
# View authentication logs
|
||||
grep "authenticated" logs/*.log
|
||||
|
||||
# View failed authentications
|
||||
grep "Authentication failed" logs/*.log
|
||||
|
||||
# View access denials
|
||||
grep "denied access" logs/*.log
|
||||
```
|
||||
|
||||
## Advanced Scenarios
|
||||
|
||||
### Multiple Servers
|
||||
|
||||
Same user across multiple server instances:
|
||||
|
||||
```yaml
|
||||
# Server 1 config.yml
|
||||
users:
|
||||
user_configs:
|
||||
- name: alice
|
||||
token: alice-global-token
|
||||
vault_access:
|
||||
type: allow_list
|
||||
allowed:
|
||||
- vault-1
|
||||
- vault-2
|
||||
|
||||
# Server 2 config.yml
|
||||
users:
|
||||
user_configs:
|
||||
- name: alice
|
||||
token: alice-global-token # Same token
|
||||
vault_access:
|
||||
type: allow_list
|
||||
allowed:
|
||||
- vault-3
|
||||
- vault-4
|
||||
```
|
||||
|
||||
### Service Accounts
|
||||
|
||||
Tokens for automated systems:
|
||||
|
||||
```yaml
|
||||
users:
|
||||
user_configs:
|
||||
- name: backup-service
|
||||
token: backup-service-token
|
||||
vault_access:
|
||||
type: allow_access_to_all
|
||||
|
||||
- name: ci-pipeline
|
||||
token: ci-token
|
||||
vault_access:
|
||||
type: allow_list
|
||||
allowed:
|
||||
- documentation
|
||||
|
||||
- name: monitoring
|
||||
token: monitoring-token
|
||||
vault_access:
|
||||
type: allow_list
|
||||
allowed:
|
||||
- metrics
|
||||
```
|
||||
|
||||
### Dynamic Vault Access
|
||||
|
||||
VaultLink doesn't support runtime user management. To change access:
|
||||
|
||||
1. Update `config.yml`
|
||||
2. Restart server
|
||||
3. Users reconnect automatically
|
||||
|
||||
For frequent changes, consider:
|
||||
|
||||
- Over-provision access (deny list)
|
||||
- Use external authentication proxy
|
||||
- Script config updates + reload
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Can't connect
|
||||
|
||||
**Check token**:
|
||||
|
||||
```bash
|
||||
# Verify token in config matches client
|
||||
grep "token:" config.yml
|
||||
```
|
||||
|
||||
**Check vault name**:
|
||||
|
||||
```bash
|
||||
# Ensure vault is in allowed list
|
||||
grep -A 5 "name: alice" config.yml
|
||||
```
|
||||
|
||||
**Check server logs**:
|
||||
|
||||
```bash
|
||||
tail -f logs/*.log | grep -i auth
|
||||
```
|
||||
|
||||
### Access denied
|
||||
|
||||
**Verify vault access**:
|
||||
|
||||
```yaml
|
||||
# Check user's vault_access configuration
|
||||
users:
|
||||
user_configs:
|
||||
- name: alice
|
||||
vault_access:
|
||||
type: allow_list
|
||||
allowed:
|
||||
- vault-name # Must match exactly
|
||||
```
|
||||
|
||||
**Case sensitivity**:
|
||||
|
||||
- Vault names are case-sensitive
|
||||
- `Vault` ≠ `vault`
|
||||
- Ensure exact match in config and client
|
||||
|
||||
### Token not working
|
||||
|
||||
**Check for typos**:
|
||||
|
||||
- Extra spaces
|
||||
- Hidden characters
|
||||
- Wrong quotes in YAML
|
||||
|
||||
**Regenerate token**:
|
||||
|
||||
```bash
|
||||
# Generate new token
|
||||
openssl rand -hex 32
|
||||
|
||||
# Update config
|
||||
# Restart server
|
||||
# Update client
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Server configuration reference →](/config/server)
|
||||
- [Advanced configuration →](/config/advanced)
|
||||
- [Deploy the server →](/guide/server-setup)
|
||||
489
docs/config/server.md
Normal file
489
docs/config/server.md
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
# Server Configuration
|
||||
|
||||
Complete reference for configuring the VaultLink sync server via `config.yml`.
|
||||
|
||||
## Configuration File Format
|
||||
|
||||
The server is configured using a YAML file passed as a command-line argument:
|
||||
|
||||
```bash
|
||||
/app/sync_server /path/to/config.yml
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```yaml
|
||||
database:
|
||||
databases_directory_path: databases
|
||||
max_connections_per_vault: 12
|
||||
cursor_timeout_seconds: 60
|
||||
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 3000
|
||||
max_body_size_mb: 512
|
||||
max_clients_per_vault: 256
|
||||
response_timeout_seconds: 60
|
||||
|
||||
users:
|
||||
user_configs:
|
||||
- name: admin
|
||||
token: your-secure-random-token
|
||||
vault_access:
|
||||
type: allow_access_to_all
|
||||
- name: alice
|
||||
token: alice-token
|
||||
vault_access:
|
||||
type: allow_list
|
||||
allowed:
|
||||
- personal
|
||||
- shared
|
||||
- name: bob
|
||||
token: bob-token
|
||||
vault_access:
|
||||
type: deny_list
|
||||
denied:
|
||||
- restricted
|
||||
|
||||
logging:
|
||||
log_directory: logs
|
||||
log_rotation: 7days
|
||||
```
|
||||
|
||||
## Database Section
|
||||
|
||||
### `databases_directory_path`
|
||||
|
||||
**Type**: String
|
||||
**Required**: Yes
|
||||
**Default**: None
|
||||
|
||||
Directory where SQLite database files are stored. One database file per vault.
|
||||
|
||||
```yaml
|
||||
database:
|
||||
databases_directory_path: /data/databases
|
||||
```
|
||||
|
||||
The directory structure:
|
||||
|
||||
```
|
||||
databases/
|
||||
├── vault-1.db
|
||||
├── vault-2.db
|
||||
└── personal.db
|
||||
```
|
||||
|
||||
**Notes**:
|
||||
|
||||
- Path is relative to working directory or absolute
|
||||
- Directory must be writable by the server process
|
||||
- Ensure adequate disk space for vault data
|
||||
- Back up this directory regularly
|
||||
|
||||
### `max_connections_per_vault`
|
||||
|
||||
**Type**: Integer
|
||||
**Required**: Yes
|
||||
**Default**: None
|
||||
**Recommended**: 12
|
||||
|
||||
Maximum concurrent database connections per vault.
|
||||
|
||||
```yaml
|
||||
database:
|
||||
max_connections_per_vault: 12
|
||||
```
|
||||
|
||||
**Tuning**:
|
||||
|
||||
- Higher values: Better performance under load
|
||||
- Lower values: Less memory usage
|
||||
- Typical range: 8-20
|
||||
- Consider: Number of concurrent users × average operations per user
|
||||
|
||||
### `cursor_timeout_seconds`
|
||||
|
||||
**Type**: Integer
|
||||
**Required**: Yes
|
||||
**Default**: None
|
||||
**Recommended**: 60
|
||||
|
||||
How long to keep database cursors alive for inactive clients.
|
||||
|
||||
```yaml
|
||||
database:
|
||||
cursor_timeout_seconds: 60
|
||||
```
|
||||
|
||||
**Notes**:
|
||||
|
||||
- Cursors track client sync state
|
||||
- Timeout too short: Clients may need to re-sync frequently
|
||||
- Timeout too long: More memory usage
|
||||
- Typical range: 30-300 seconds
|
||||
|
||||
## Server Section
|
||||
|
||||
### `host`
|
||||
|
||||
**Type**: String
|
||||
**Required**: Yes
|
||||
**Default**: None
|
||||
|
||||
Network interface to bind the server to.
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: 0.0.0.0 # All interfaces
|
||||
# OR
|
||||
host: 127.0.0.1 # Localhost only
|
||||
# OR
|
||||
host: 192.168.1.100 # Specific interface
|
||||
```
|
||||
|
||||
**Common values**:
|
||||
|
||||
- `0.0.0.0`: Listen on all network interfaces (production)
|
||||
- `127.0.0.1`: Listen on localhost only (development/testing)
|
||||
- Specific IP: Listen on specific interface
|
||||
|
||||
### `port`
|
||||
|
||||
**Type**: Integer
|
||||
**Required**: Yes
|
||||
**Default**: None
|
||||
**Recommended**: 3000
|
||||
|
||||
TCP port to listen on.
|
||||
|
||||
```yaml
|
||||
server:
|
||||
port: 3000
|
||||
```
|
||||
|
||||
**Notes**:
|
||||
|
||||
- Must be available (not in use)
|
||||
- Privileged ports (< 1024) require root
|
||||
- Common ports: 3000, 8080, 8888
|
||||
- Configure firewall to allow this port
|
||||
|
||||
### `max_body_size_mb`
|
||||
|
||||
**Type**: Integer
|
||||
**Required**: Yes
|
||||
**Default**: None
|
||||
**Recommended**: 512
|
||||
|
||||
Maximum size of HTTP request body in megabytes.
|
||||
|
||||
```yaml
|
||||
server:
|
||||
max_body_size_mb: 512
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
- Limits file upload size
|
||||
- Prevents memory exhaustion attacks
|
||||
- Must be larger than largest expected file
|
||||
- Consider client `max_file_size_mb` settings
|
||||
|
||||
**Tuning**:
|
||||
|
||||
- Small vaults (mostly text): 100 MB
|
||||
- Medium vaults (some images): 512 MB
|
||||
- Large vaults (many images/PDFs): 1024+ MB
|
||||
|
||||
### `max_clients_per_vault`
|
||||
|
||||
**Type**: Integer
|
||||
**Required**: Yes
|
||||
**Default**: None
|
||||
**Recommended**: 256
|
||||
|
||||
Maximum concurrent clients per vault.
|
||||
|
||||
```yaml
|
||||
server:
|
||||
max_clients_per_vault: 256
|
||||
```
|
||||
|
||||
**Notes**:
|
||||
|
||||
- Limits concurrent WebSocket connections
|
||||
- Prevents resource exhaustion
|
||||
- Consider expected number of users
|
||||
- Each client uses memory and file descriptors
|
||||
|
||||
**Scaling**:
|
||||
|
||||
- Personal use: 10-50
|
||||
- Small team: 50-100
|
||||
- Large team: 100-500
|
||||
|
||||
### `response_timeout_seconds`
|
||||
|
||||
**Type**: Integer
|
||||
**Required**: Yes
|
||||
**Default**: None
|
||||
**Recommended**: 60
|
||||
|
||||
Maximum time to wait for client responses.
|
||||
|
||||
```yaml
|
||||
server:
|
||||
response_timeout_seconds: 60
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
- Timeout for HTTP requests
|
||||
- Timeout for WebSocket operations
|
||||
- Clients disconnected if unresponsive
|
||||
|
||||
**Tuning**:
|
||||
|
||||
- Fast networks: 30 seconds
|
||||
- Slow networks: 90-120 seconds
|
||||
- Large file uploads: Increase proportionally
|
||||
|
||||
## Users Section
|
||||
|
||||
See [Authentication Configuration →](/config/authentication) for detailed user configuration.
|
||||
|
||||
## Logging Section
|
||||
|
||||
### `log_directory`
|
||||
|
||||
**Type**: String
|
||||
**Required**: Yes
|
||||
**Default**: None
|
||||
|
||||
Directory where log files are written.
|
||||
|
||||
```yaml
|
||||
logging:
|
||||
log_directory: /data/logs
|
||||
# OR
|
||||
log_directory: logs # Relative to working directory
|
||||
```
|
||||
|
||||
**Notes**:
|
||||
|
||||
- Path is relative to working directory or absolute
|
||||
- Directory must be writable
|
||||
- Logs are rotated based on `log_rotation`
|
||||
- Monitor disk usage
|
||||
|
||||
### `log_rotation`
|
||||
|
||||
**Type**: String
|
||||
**Required**: Yes
|
||||
**Default**: None
|
||||
|
||||
How often to rotate log files.
|
||||
|
||||
```yaml
|
||||
logging:
|
||||
log_rotation: 7days
|
||||
# OR
|
||||
log_rotation: 24hours
|
||||
# OR
|
||||
log_rotation: 30days
|
||||
```
|
||||
|
||||
**Format**: `<number><unit>`
|
||||
|
||||
**Units**:
|
||||
|
||||
- `hours`: Hours (e.g., `12hours`, `24hours`)
|
||||
- `days`: Days (e.g., `7days`, `30days`)
|
||||
|
||||
**Recommendations**:
|
||||
|
||||
- Development: `24hours` or `7days`
|
||||
- Production: `7days` or `30days`
|
||||
- High traffic: `24hours` (logs can be large)
|
||||
|
||||
## Environment-Specific Configurations
|
||||
|
||||
### Development
|
||||
|
||||
```yaml
|
||||
database:
|
||||
databases_directory_path: ./databases
|
||||
max_connections_per_vault: 8
|
||||
cursor_timeout_seconds: 30
|
||||
|
||||
server:
|
||||
host: 127.0.0.1
|
||||
port: 3000
|
||||
max_body_size_mb: 100
|
||||
max_clients_per_vault: 10
|
||||
response_timeout_seconds: 30
|
||||
|
||||
users:
|
||||
user_configs:
|
||||
- name: dev
|
||||
token: dev-token
|
||||
vault_access:
|
||||
type: allow_access_to_all
|
||||
|
||||
logging:
|
||||
log_directory: logs
|
||||
log_rotation: 24hours
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```yaml
|
||||
database:
|
||||
databases_directory_path: /data/databases
|
||||
max_connections_per_vault: 16
|
||||
cursor_timeout_seconds: 120
|
||||
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 3000
|
||||
max_body_size_mb: 512
|
||||
max_clients_per_vault: 256
|
||||
response_timeout_seconds: 90
|
||||
|
||||
users:
|
||||
user_configs:
|
||||
- name: admin
|
||||
token: <strong-random-token>
|
||||
vault_access:
|
||||
type: allow_access_to_all
|
||||
# Additional users...
|
||||
|
||||
logging:
|
||||
log_directory: /data/logs
|
||||
log_rotation: 7days
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
The server validates configuration on startup:
|
||||
|
||||
```bash
|
||||
# Start server
|
||||
./sync_server config.yml
|
||||
|
||||
# Check for errors in logs
|
||||
tail -f logs/latest.log
|
||||
```
|
||||
|
||||
**Common errors**:
|
||||
|
||||
- Missing required fields
|
||||
- Invalid YAML syntax
|
||||
- Invalid values (negative numbers, etc.)
|
||||
- Directory not writable
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
### High Concurrency
|
||||
|
||||
For many concurrent users:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
max_connections_per_vault: 20 # Increase
|
||||
|
||||
server:
|
||||
max_clients_per_vault: 500 # Increase
|
||||
response_timeout_seconds: 120 # Increase for slow clients
|
||||
```
|
||||
|
||||
### Large Files
|
||||
|
||||
For vaults with large files:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
max_body_size_mb: 1024 # Allow larger uploads
|
||||
response_timeout_seconds: 180 # More time for uploads
|
||||
```
|
||||
|
||||
### Resource-Constrained Systems
|
||||
|
||||
For limited CPU/memory:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
max_connections_per_vault: 6 # Reduce
|
||||
|
||||
server:
|
||||
max_clients_per_vault: 50 # Reduce
|
||||
max_body_size_mb: 256 # Reduce
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Token Security
|
||||
|
||||
- Use strong random tokens: `openssl rand -hex 32`
|
||||
- Never commit tokens to version control
|
||||
- Rotate tokens periodically
|
||||
- Use different tokens per user
|
||||
|
||||
### Network Security
|
||||
|
||||
- Bind to `127.0.0.1` if using reverse proxy on same host
|
||||
- Use firewall to restrict access
|
||||
- Enable SSL/TLS via reverse proxy
|
||||
|
||||
### Resource Limits
|
||||
|
||||
- Set `max_clients_per_vault` to prevent DoS
|
||||
- Set `max_body_size_mb` to prevent memory exhaustion
|
||||
- Configure `response_timeout_seconds` to prevent hanging connections
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server won't start
|
||||
|
||||
**Check YAML syntax**:
|
||||
|
||||
```bash
|
||||
# Use a YAML validator
|
||||
python -c 'import yaml, sys; yaml.safe_load(open("config.yml"))'
|
||||
```
|
||||
|
||||
**Check file paths**:
|
||||
|
||||
```bash
|
||||
# Ensure directories exist and are writable
|
||||
mkdir -p databases logs
|
||||
chmod 755 databases logs
|
||||
```
|
||||
|
||||
**Check port availability**:
|
||||
|
||||
```bash
|
||||
# Verify port is not in use
|
||||
lsof -i :3000
|
||||
```
|
||||
|
||||
### High memory usage
|
||||
|
||||
- Reduce `max_connections_per_vault`
|
||||
- Reduce `max_clients_per_vault`
|
||||
- Reduce `max_body_size_mb`
|
||||
- Check for large vaults or many concurrent users
|
||||
|
||||
### Slow performance
|
||||
|
||||
- Increase `max_connections_per_vault`
|
||||
- Increase database connection pool
|
||||
- Use SSD for database storage
|
||||
- Monitor database size (vacuum if needed)
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Configure authentication →](/config/authentication)
|
||||
- [Advanced configuration options →](/config/advanced)
|
||||
- [Deploy the server →](/guide/server-setup)
|
||||
324
docs/guide/alternatives.md
Normal file
324
docs/guide/alternatives.md
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
# Comparison with Alternatives
|
||||
|
||||
VaultLink is one of several solutions for synchronising Obsidian vaults. This page compares VaultLink with popular alternatives to help you choose the right tool.
|
||||
|
||||
## Key Differentiator: Editor Agnostic
|
||||
|
||||
**VaultLink is not tied to Obsidian.** While it includes an Obsidian plugin for convenience, VaultLink synchronises plain text files and works with any editor:
|
||||
|
||||
- Edit with **Obsidian desktop** on your laptop
|
||||
- Edit with **Vim** on your server
|
||||
- Edit with **VS Code** on your workstation
|
||||
- Edit with **Obsidian mobile** on your phone
|
||||
- Use the **CLI client** for automated workflows
|
||||
|
||||
All changes merge automatically without conflict markers, regardless of which editor you use. This is possible because VaultLink uses [reconcile-text](/architecture/sync-algorithm#why-reconcile-text-over-crdts) for differential synchronisation rather than requiring operation-level tracking.
|
||||
|
||||
## VaultLink's Core Strengths
|
||||
|
||||
Before diving into comparisons:
|
||||
|
||||
1. **Fully self-hosted**: Server and all components are open source
|
||||
2. **Collaborative editing**: Real-time sync with operational transformation
|
||||
3. **Automatic conflict resolution**: No manual intervention or paid features required
|
||||
4. **Cursor tracking**: See where other users are editing
|
||||
5. **Extensively tested**: Comprehensive test suite for server and client
|
||||
6. **Editor freedom**: Use any text editor, not just Obsidian
|
||||
7. **Production-ready**: Docker images, health checks, monitoring
|
||||
|
||||
## Obsidian Sync Alternatives
|
||||
|
||||
### Self-hosted LiveSync
|
||||
|
||||
**Downloads**: ~300,000
|
||||
**Repository**: https://github.com/vrtmrz/obsidian-livesync
|
||||
|
||||
**Overview**: CouchDB/IBM Cloudant-based sync with end-to-end encryption.
|
||||
|
||||
| Aspect | Self-hosted LiveSync | VaultLink |
|
||||
| ------------------------- | --------------------------- | -------------------------------------- |
|
||||
| **Self-hosted** | Yes (CouchDB required) | Yes (single binary or Docker) |
|
||||
| **Conflict resolution** | Manual or automatic (basic) | Automatic (operational transformation) |
|
||||
| **Collaborative editing** | No | Yes (real-time with cursors) |
|
||||
| **Editor support** | Obsidian only | Any text editor |
|
||||
| **Infrastructure** | CouchDB database | SQLite (bundled) |
|
||||
| **Deployment complexity** | Medium (external DB) | Low (single container) |
|
||||
| **End-to-end encryption** | Yes | No (transport encryption only) |
|
||||
| **Out-of-band edits** | Limited support | Full support (edit with any tool) |
|
||||
|
||||
**When to use LiveSync**:
|
||||
|
||||
- Need end-to-end encryption
|
||||
- Already running CouchDB
|
||||
- Only use Obsidian (no external editors)
|
||||
|
||||
**When to use VaultLink**:
|
||||
|
||||
- Want collaborative editing with multiple users
|
||||
- Edit files with various tools (Vim, VS Code, etc.)
|
||||
- Need simpler deployment (no external database)
|
||||
- Want operational transformation for better merges
|
||||
|
||||
---
|
||||
|
||||
### Remotely Save
|
||||
|
||||
**Downloads**: ~1.1M
|
||||
**Repository**: https://github.com/remotely-save/remotely-save
|
||||
|
||||
**Overview**: Sync to cloud storage providers (S3, Dropbox, OneDrive, WebDAV).
|
||||
|
||||
| Aspect | Remotely Save | VaultLink |
|
||||
| ------------------------- | ---------------------------- | ------------------------ |
|
||||
| **Self-hosted** | Partial (uses cloud storage) | Fully self-hosted |
|
||||
| **Conflict resolution** | Paid Pro feature | Free and automatic |
|
||||
| **Collaborative editing** | No | Yes |
|
||||
| **Editor support** | Obsidian only | Any text editor |
|
||||
| **Storage backend** | Cloud providers | Self-hosted SQLite |
|
||||
| **Cost** | Free (basic) / Paid (Pro) | Free (open source) |
|
||||
| **Code quality** | No tests, complex codebase | Comprehensive test suite |
|
||||
| **Real-time sync** | No (periodic polling) | Yes (WebSocket) |
|
||||
|
||||
**When to use Remotely Save**:
|
||||
|
||||
- Already use cloud storage (S3, Dropbox)
|
||||
- Don't need real-time sync
|
||||
- Single-user scenario
|
||||
|
||||
**When to use VaultLink**:
|
||||
|
||||
- Want full control over data
|
||||
- Need automatic conflict resolution without paying
|
||||
- Want real-time collaborative editing
|
||||
- Value code quality and testing
|
||||
|
||||
**Note**: Remotely Save's conflict resolution is a paid feature. VaultLink provides superior automatic merging for free.
|
||||
|
||||
---
|
||||
|
||||
### Relay
|
||||
|
||||
**Downloads**: ~24,000
|
||||
**Repository**: https://github.com/No-Instructions/Relay
|
||||
|
||||
**Overview**: CRDT-based sync with proprietary server component.
|
||||
|
||||
| Aspect | Relay | VaultLink |
|
||||
| -------------------------- | ---------------------------- | ----------------------- |
|
||||
| **Self-hosted** | No (proprietary server) | Yes (fully open source) |
|
||||
| **Conflict resolution** | CRDT (automatic) | OT (automatic) |
|
||||
| **Collaborative editing** | Yes | Yes |
|
||||
| **Editor support** | Obsidian only | Any text editor |
|
||||
| **Out-of-band edits** | No (breaks CRDT consistency) | Yes (differential sync) |
|
||||
| **Server open source** | No | Yes |
|
||||
| **Infrastructure control** | Limited | Full |
|
||||
| **Per-file overhead** | High (CRDT metadata) | Low (version history) |
|
||||
|
||||
**When to use Relay**:
|
||||
|
||||
- Want hosted solution (don't self-host)
|
||||
- Only edit within Obsidian
|
||||
- Don't need out-of-band editing
|
||||
|
||||
**When to use VaultLink**:
|
||||
|
||||
- Need fully open source solution
|
||||
- Want to self-host completely
|
||||
- Edit files outside Obsidian (Vim, VS Code)
|
||||
- Value infrastructure control
|
||||
|
||||
**Critical limitation**: Relay's CRDT approach requires tracking every operation within Obsidian. Editing files outside Obsidian breaks the CRDT state. VaultLink's differential sync works regardless of how files are edited.
|
||||
|
||||
---
|
||||
|
||||
### Obsidian Git
|
||||
|
||||
**Downloads**: ~1.4M
|
||||
**Repository**: https://github.com/denolehov/obsidian-git
|
||||
|
||||
**Overview**: Uses Git for version control and synchronisation.
|
||||
|
||||
| Aspect | Obsidian Git | VaultLink |
|
||||
| ------------------------- | ----------------------------- | ----------------------- |
|
||||
| **Self-hosted** | Yes (Git server) | Yes (sync server) |
|
||||
| **Conflict resolution** | Manual (conflict markers) | Automatic (no markers) |
|
||||
| **Collaborative editing** | No | Yes (real-time) |
|
||||
| **Editor support** | Any (it's Git) | Any (differential sync) |
|
||||
| **Version history** | Full Git history | Document versions |
|
||||
| **Real-time sync** | No (commit-based) | Yes (instant) |
|
||||
| **Merge conflicts** | Manual resolution | Automatic |
|
||||
| **Learning curve** | High (Git knowledge required) | Low |
|
||||
| **Workflow interruption** | Yes (resolve conflicts) | No |
|
||||
|
||||
**When to use Obsidian Git**:
|
||||
|
||||
- Need full version control (branches, tags, etc.)
|
||||
- Already familiar with Git workflows
|
||||
- Want integration with existing Git repos
|
||||
- Don't mind manual conflict resolution
|
||||
|
||||
**When to use VaultLink**:
|
||||
|
||||
- Want automatic conflict-free merging
|
||||
- Need real-time collaborative editing
|
||||
- Don't want workflow interruptions from merge conflicts
|
||||
- Prefer simpler mental model (sync, not commits)
|
||||
|
||||
**Key difference**: Git requires manual conflict resolution with `<<<<<<<` markers. VaultLink automatically merges all changes using operational transformation, never interrupting your workflow.
|
||||
|
||||
---
|
||||
|
||||
### Syncthing Integration
|
||||
|
||||
**Downloads**: ~22,600
|
||||
**Repository**: https://github.com/LBF38/obsidian-syncthing-integration
|
||||
|
||||
**Overview**: Wrapper around Syncthing for file synchronisation.
|
||||
|
||||
| Aspect | Syncthing Integration | VaultLink |
|
||||
| ------------------------- | ------------------------------ | ----------------- |
|
||||
| **Self-hosted** | Yes (Syncthing) | Yes (sync server) |
|
||||
| **Conflict resolution** | Manual | Automatic |
|
||||
| **Collaborative editing** | No | Yes |
|
||||
| **Editor support** | Any | Any |
|
||||
| **Status** | Unfinished | Production-ready |
|
||||
| **Conflict files** | Creates `.sync-conflict` files | No conflict files |
|
||||
| **Real-time sync** | Yes | Yes |
|
||||
| **Automatic merging** | No | Yes |
|
||||
|
||||
**When to use Syncthing Integration**:
|
||||
|
||||
- Already use Syncthing for other files
|
||||
- Don't need automatic conflict resolution
|
||||
- Single-user with multiple devices
|
||||
|
||||
**When to use VaultLink**:
|
||||
|
||||
- Want automatic conflict resolution
|
||||
- Need collaborative editing
|
||||
- Want production-ready solution
|
||||
- Don't want to manage conflict files
|
||||
|
||||
**Status note**: Syncthing Integration is marked as unfinished. VaultLink is production-ready with comprehensive testing.
|
||||
|
||||
---
|
||||
|
||||
### Remotely Sync
|
||||
|
||||
**Downloads**: ~38,000
|
||||
**Repository**: https://github.com/sboesen/remotely-sync
|
||||
|
||||
**Overview**: Similar to Remotely Save, syncs to cloud storage.
|
||||
|
||||
| Aspect | Remotely Sync | VaultLink |
|
||||
| ----------------------- | ----------------------- | ------------------- |
|
||||
| **Self-hosted** | Partial (cloud storage) | Fully self-hosted |
|
||||
| **Conflict resolution** | Limited/Paid | Free and automatic |
|
||||
| **Code quality** | No tests | Comprehensive tests |
|
||||
| **Maintenance** | Low activity | Active development |
|
||||
|
||||
**Same concerns as Remotely Save**: No test suite, conflict resolution limitations, cloud storage dependency.
|
||||
|
||||
**When to use VaultLink**: See Remotely Save comparison above.
|
||||
|
||||
---
|
||||
|
||||
### SyncFTP
|
||||
|
||||
**Downloads**: ~5,000
|
||||
**Repository**: https://github.com/alex-donnan/SyncFTP
|
||||
|
||||
**Overview**: Simple FTP-based file synchronisation.
|
||||
|
||||
| Aspect | SyncFTP | VaultLink |
|
||||
| ------------------------- | ---------------------- | ---------------- |
|
||||
| **Conflict resolution** | None (last write wins) | Automatic (OT) |
|
||||
| **Data loss risk** | High (overwrites) | None (merges) |
|
||||
| **Collaborative editing** | No | Yes |
|
||||
| **Sophistication** | Minimal | Production-grade |
|
||||
|
||||
**When to use SyncFTP**: Don't use SyncFTP for any scenario where data integrity matters.
|
||||
|
||||
**When to use VaultLink**: Any scenario requiring reliable synchronisation.
|
||||
|
||||
---
|
||||
|
||||
## Feature Comparison Matrix
|
||||
|
||||
| Feature | VaultLink | LiveSync | Relay | Git | Remotely Save | Syncthing |
|
||||
| --------------------------------- | --------- | -------- | ----- | --- | ------------- | --------- |
|
||||
| **Fully open source** | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ |
|
||||
| **Self-hosted** | ✅ | ✅ | ❌ | ✅ | Partial | ✅ |
|
||||
| **Automatic conflict resolution** | ✅ | Basic | ✅ | ❌ | Paid | ❌ |
|
||||
| **Real-time sync** | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
| **Collaborative editing** | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
| **Cursor tracking** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| **Editor agnostic** | ✅ | ❌ | ❌ | ✅ | ❌ | ✅ |
|
||||
| **Out-of-band edits** | ✅ | Limited | ❌ | ✅ | ❌ | ✅ |
|
||||
| **No conflict markers** | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| **Comprehensive tests** | ✅ | ❌ | ❌ | N/A | ❌ | N/A |
|
||||
| **Simple deployment** | ✅ | ❌ | N/A | ❌ | ✅ | ❌ |
|
||||
| **Low infrastructure** | ✅ | ❌ | N/A | ✅ | ✅ | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## VaultLink's Unique Position
|
||||
|
||||
VaultLink is the **only** solution that combines:
|
||||
|
||||
1. **Fully open source** self-hosted server
|
||||
2. **Editor agnostic** operation (not locked to Obsidian)
|
||||
3. **Automatic conflict-free merging** using operational transformation
|
||||
4. **Real-time collaborative editing** with cursor tracking
|
||||
5. **Differential synchronisation** supporting out-of-band edits
|
||||
6. **Comprehensive test coverage** ensuring reliability
|
||||
7. **Simple deployment** via Docker or single binary
|
||||
|
||||
## Use Case Recommendations
|
||||
|
||||
### Choose VaultLink when you:
|
||||
|
||||
- Edit vaults with multiple editors (Obsidian + Vim + VS Code)
|
||||
- Need real-time collaboration with teammates
|
||||
- Want automatic conflict resolution without manual intervention
|
||||
- Value full control over infrastructure
|
||||
- Need production-ready reliability with comprehensive testing
|
||||
- Want to edit files while offline and sync later seamlessly
|
||||
|
||||
### Consider alternatives when you:
|
||||
|
||||
- **LiveSync**: Need end-to-end encryption and only use Obsidian
|
||||
- **Git**: Need full version control with branches and advanced Git features
|
||||
- **Remotely Save**: Already committed to cloud storage providers
|
||||
- **Syncthing**: Already use Syncthing and don't need automatic merging
|
||||
|
||||
## Migration from Other Solutions
|
||||
|
||||
VaultLink works with plain Markdown files, making migration simple:
|
||||
|
||||
1. **From Git**: Clone your repo, point VaultLink to the directory
|
||||
2. **From cloud sync**: Download files, configure VaultLink client
|
||||
3. **From LiveSync**: Export vault, import to VaultLink
|
||||
4. **From Syncthing**: Point VaultLink to synced directory
|
||||
|
||||
All solutions work with the same Markdown files—VaultLink just syncs them better.
|
||||
|
||||
## Beyond Obsidian
|
||||
|
||||
Because VaultLink is editor-agnostic, you can use it for:
|
||||
|
||||
- **Documentation teams**: Sync technical docs edited in VS Code
|
||||
- **Academic writing**: Collaborate on papers with various Markdown editors
|
||||
- **Personal knowledge bases**: Use Obsidian on mobile, Vim on servers
|
||||
- **Automated workflows**: CLI client for backup systems and CI/CD
|
||||
- **Multi-tool workflows**: Different team members use different editors
|
||||
|
||||
VaultLink doesn't lock you into Obsidian—it's a general-purpose differential sync system that happens to work excellently with Obsidian vaults.
|
||||
|
||||
## Next Steps
|
||||
|
||||
Ready to try VaultLink?
|
||||
|
||||
- [Get started →](/guide/getting-started)
|
||||
- [Understand the architecture →](/architecture/)
|
||||
- [See how sync works →](/architecture/sync-algorithm)
|
||||
532
docs/guide/cli-client.md
Normal file
532
docs/guide/cli-client.md
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
# CLI Client
|
||||
|
||||
Sync vaults without Obsidian. Works on servers, automation, backups, headless systems.
|
||||
|
||||
## Installation
|
||||
|
||||
### Docker (Recommended)
|
||||
|
||||
Pull the latest image:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/schmelczer/vault-link-cli:latest
|
||||
```
|
||||
|
||||
### npm
|
||||
|
||||
Install globally:
|
||||
|
||||
```bash
|
||||
npm install -g @schmelczer/local-client-cli
|
||||
```
|
||||
|
||||
Verify installation:
|
||||
|
||||
```bash
|
||||
vaultlink --version
|
||||
```
|
||||
|
||||
### From Source
|
||||
|
||||
Build from the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/schmelczer/vault-link.git
|
||||
cd vault-link/frontend/local-client-cli
|
||||
npm install
|
||||
npm run build
|
||||
node dist/cli.js --help
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
vaultlink \
|
||||
--local-path /path/to/vault \
|
||||
--remote-uri wss://sync.example.com \
|
||||
--token your-auth-token \
|
||||
--vault-name default
|
||||
```
|
||||
|
||||
### Docker Usage
|
||||
|
||||
```bash
|
||||
docker run -v /path/to/vault:/vault \
|
||||
ghcr.io/schmelczer/vault-link-cli:latest \
|
||||
-l /vault \
|
||||
-r wss://sync.example.com \
|
||||
-t your-auth-token \
|
||||
-v default
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
Create `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
vaultlink-cli:
|
||||
image: ghcr.io/schmelczer/vault-link-cli:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./vault:/vault
|
||||
command:
|
||||
- "-l"
|
||||
- "/vault"
|
||||
- "-r"
|
||||
- "wss://sync.example.com"
|
||||
- "-t"
|
||||
- "your-token"
|
||||
- "-v"
|
||||
- "default"
|
||||
```
|
||||
|
||||
Start the client:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Required Arguments
|
||||
|
||||
| Argument | Short | Description | Example |
|
||||
| -------------- | ----- | ----------------------- | ------------------------ |
|
||||
| `--local-path` | `-l` | Local directory to sync | `/vault` |
|
||||
| `--remote-uri` | `-r` | Server WebSocket URI | `wss://sync.example.com` |
|
||||
| `--token` | `-t` | Authentication token | `abc123...` |
|
||||
| `--vault-name` | `-v` | Vault name on server | `default` |
|
||||
|
||||
### Optional Arguments
|
||||
|
||||
| Argument | Default | Description |
|
||||
| ------------------------------- | ------- | -------------------------------------- |
|
||||
| `--sync-concurrency` | `1` | Concurrent file operations |
|
||||
| `--max-file-size-mb` | `10` | Max file size in MB |
|
||||
| `--ignore-pattern` | - | Glob pattern to ignore (repeatable) |
|
||||
| `--websocket-retry-interval-ms` | `3500` | Reconnection interval |
|
||||
| `--log-level` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Alternative to command-line arguments:
|
||||
|
||||
```bash
|
||||
export VAULTLINK_LOCAL_PATH="/vault"
|
||||
export VAULTLINK_REMOTE_URI="wss://sync.example.com"
|
||||
export VAULTLINK_TOKEN="your-token"
|
||||
export VAULTLINK_VAULT_NAME="default"
|
||||
|
||||
vaultlink
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Sync
|
||||
|
||||
Sync a local directory to the server:
|
||||
|
||||
```bash
|
||||
vaultlink \
|
||||
-l ./my-notes \
|
||||
-r wss://sync.example.com \
|
||||
-t my-secure-token \
|
||||
-v personal
|
||||
```
|
||||
|
||||
### With Ignore Patterns
|
||||
|
||||
Exclude specific files or directories:
|
||||
|
||||
```bash
|
||||
vaultlink \
|
||||
-l ./vault \
|
||||
-r wss://sync.example.com \
|
||||
-t token123 \
|
||||
-v default \
|
||||
--ignore-pattern "*.tmp" \
|
||||
--ignore-pattern ".DS_Store" \
|
||||
--ignore-pattern "node_modules/**"
|
||||
```
|
||||
|
||||
### Debug Logging
|
||||
|
||||
Enable verbose logging:
|
||||
|
||||
```bash
|
||||
vaultlink \
|
||||
-l ./vault \
|
||||
-r wss://sync.example.com \
|
||||
-t token123 \
|
||||
-v default \
|
||||
--log-level DEBUG
|
||||
```
|
||||
|
||||
### High Concurrency
|
||||
|
||||
Faster initial sync:
|
||||
|
||||
```bash
|
||||
vaultlink \
|
||||
-l ./vault \
|
||||
-r wss://sync.example.com \
|
||||
-t token123 \
|
||||
-v default \
|
||||
--sync-concurrency 5
|
||||
```
|
||||
|
||||
### Large Files
|
||||
|
||||
Allow larger file uploads:
|
||||
|
||||
```bash
|
||||
vaultlink \
|
||||
-l ./vault \
|
||||
-r wss://sync.example.com \
|
||||
-t token123 \
|
||||
-v default \
|
||||
--max-file-size-mb 50
|
||||
```
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Long-Running Sync
|
||||
|
||||
Run as a daemon for continuous synchronisation:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name vaultlink-sync \
|
||||
--restart unless-stopped \
|
||||
-v $(pwd)/vault:/vault \
|
||||
ghcr.io/schmelczer/vault-link-cli:latest \
|
||||
-l /vault \
|
||||
-r wss://sync.example.com \
|
||||
-t your-token \
|
||||
-v default
|
||||
```
|
||||
|
||||
Monitor logs:
|
||||
|
||||
```bash
|
||||
docker logs -f vaultlink-sync
|
||||
```
|
||||
|
||||
### Health Monitoring
|
||||
|
||||
The Docker image includes built-in health checks:
|
||||
|
||||
```bash
|
||||
# Check health status
|
||||
docker ps
|
||||
|
||||
# View detailed health info
|
||||
docker inspect --format='{{json .State.Health}}' vaultlink-sync | jq
|
||||
```
|
||||
|
||||
Health check verifies:
|
||||
|
||||
- Health file exists
|
||||
- Status updated within last 30 seconds
|
||||
- WebSocket connection is active
|
||||
|
||||
Configure custom health check:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
vaultlink-cli:
|
||||
image: ghcr.io/schmelczer/vault-link-cli:latest
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "/app/healthcheck.js"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
```
|
||||
|
||||
### Read-Only Vault
|
||||
|
||||
Mount vault as read-only to prevent local changes:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-v $(pwd)/vault:/vault:ro \
|
||||
ghcr.io/schmelczer/vault-link-cli:latest \
|
||||
-l /vault \
|
||||
-r wss://sync.example.com \
|
||||
-t token \
|
||||
-v default
|
||||
```
|
||||
|
||||
::: warning
|
||||
The CLI needs write access to create `.vaultlink` metadata directory. Mount as read-write or provide a separate writeable directory.
|
||||
:::
|
||||
|
||||
## How It Works
|
||||
|
||||
### Initial Sync
|
||||
|
||||
On startup:
|
||||
|
||||
1. Creates `.vaultlink/` directory for metadata
|
||||
2. Scans local filesystem
|
||||
3. Uploads all local files to server
|
||||
4. Downloads files from server not present locally
|
||||
5. Resolves conflicts using operational transformation
|
||||
|
||||
### Real-Time Synchronization
|
||||
|
||||
After initial sync:
|
||||
|
||||
1. Watches filesystem for changes using `fs.watch`
|
||||
2. Uploads changed files immediately
|
||||
3. Receives real-time updates from server via WebSocket
|
||||
4. Handles bidirectional sync automatically
|
||||
|
||||
### Graceful Shutdown
|
||||
|
||||
On SIGINT (Ctrl+C) or SIGTERM:
|
||||
|
||||
1. Completes pending uploads
|
||||
2. Closes WebSocket connection cleanly
|
||||
3. Flushes metadata to disk
|
||||
4. Exits gracefully
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Automated Backups
|
||||
|
||||
Continuously backup vaults to a remote server:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name vault-backup \
|
||||
-v /important/notes:/vault:ro \
|
||||
ghcr.io/schmelczer/vault-link-cli:latest \
|
||||
-l /vault -r wss://backup.example.com -t backup-token -v backups
|
||||
```
|
||||
|
||||
### CI/CD Documentation
|
||||
|
||||
Sync documentation in automated pipelines:
|
||||
|
||||
```bash
|
||||
# In your CI pipeline
|
||||
docker run \
|
||||
-v $(pwd)/docs:/vault \
|
||||
ghcr.io/schmelczer/vault-link-cli:latest \
|
||||
-l /vault -r wss://docs.example.com -t ci-token -v prod-docs
|
||||
```
|
||||
|
||||
### Multi-Location Sync
|
||||
|
||||
Sync between different geographic locations:
|
||||
|
||||
```bash
|
||||
# Location A
|
||||
vaultlink -l /data/vault -r wss://hub.example.com -t token -v shared
|
||||
|
||||
# Location B
|
||||
vaultlink -l /backup/vault -r wss://hub.example.com -t token -v shared
|
||||
```
|
||||
|
||||
### Development Environment
|
||||
|
||||
Keep documentation in sync across dev environments:
|
||||
|
||||
```bash
|
||||
# In docker-compose.yml for your dev stack
|
||||
services:
|
||||
docs-sync:
|
||||
image: ghcr.io/schmelczer/vault-link-cli:latest
|
||||
volumes:
|
||||
- ./docs:/vault
|
||||
command: ["-l", "/vault", "-r", "wss://docs-server", "-t", "dev-token", "-v", "dev"]
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Client won't connect
|
||||
|
||||
**Check server accessibility**:
|
||||
|
||||
```bash
|
||||
curl https://sync.example.com/vaults/test/ping
|
||||
```
|
||||
|
||||
**Verify WebSocket protocol**:
|
||||
|
||||
- Use `ws://` for HTTP servers
|
||||
- Use `wss://` for HTTPS servers
|
||||
|
||||
**Check authentication**:
|
||||
|
||||
- Token must match server config
|
||||
- User must have access to the vault
|
||||
|
||||
### Permission errors
|
||||
|
||||
**Docker volume permissions**:
|
||||
|
||||
```bash
|
||||
# Ensure directory is writable
|
||||
chmod 755 /path/to/vault
|
||||
|
||||
# Check Docker user ID
|
||||
docker run --rm ghcr.io/schmelczer/vault-link-cli:latest id
|
||||
```
|
||||
|
||||
**SELinux issues**:
|
||||
|
||||
```bash
|
||||
# Add :z flag to volume mount
|
||||
docker run -v /path/to/vault:/vault:z ...
|
||||
```
|
||||
|
||||
### Files not syncing
|
||||
|
||||
**Check ignore patterns**:
|
||||
|
||||
- View logs to see which files are skipped
|
||||
- Ensure patterns don't match unintentionally
|
||||
|
||||
**File size limits**:
|
||||
|
||||
- Check `--max-file-size-mb` setting
|
||||
- Large files are skipped with a warning
|
||||
|
||||
**Check metadata**:
|
||||
|
||||
```bash
|
||||
# View sync metadata
|
||||
cat /path/to/vault/.vaultlink/metadata.json
|
||||
```
|
||||
|
||||
### High memory usage
|
||||
|
||||
**Reduce concurrency**:
|
||||
|
||||
```bash
|
||||
--sync-concurrency 1
|
||||
```
|
||||
|
||||
**Limit file sizes**:
|
||||
|
||||
```bash
|
||||
--max-file-size-mb 5
|
||||
```
|
||||
|
||||
**Check vault size**:
|
||||
|
||||
- Very large vaults may need more resources
|
||||
- Consider splitting into multiple vaults
|
||||
|
||||
### Connection keeps dropping
|
||||
|
||||
**Increase retry interval**:
|
||||
|
||||
```bash
|
||||
--websocket-retry-interval-ms 5000
|
||||
```
|
||||
|
||||
**Check network stability**:
|
||||
|
||||
```bash
|
||||
# Monitor connection
|
||||
docker logs -f vaultlink-sync | grep -i websocket
|
||||
```
|
||||
|
||||
**Server timeout settings**:
|
||||
|
||||
- Verify reverse proxy WebSocket timeout
|
||||
- Check server `response_timeout_seconds`
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Custom Healthcheck Script
|
||||
|
||||
Create your own health monitoring:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
HEALTH_FILE="/tmp/vaultlink-health.json"
|
||||
|
||||
if [ ! -f "$HEALTH_FILE" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check file is recent (within 60 seconds)
|
||||
if [ $(( $(date +%s) - $(stat -c %Y "$HEALTH_FILE") )) -gt 60 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check WebSocket is connected
|
||||
if ! jq -e '.connected == true' "$HEALTH_FILE" > /dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
```
|
||||
|
||||
### Automated Recovery
|
||||
|
||||
Restart on failure with exponential backoff:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
RETRY_DELAY=5
|
||||
|
||||
while true; do
|
||||
vaultlink -l /vault -r wss://server -t token -v default
|
||||
|
||||
echo "Client exited, restarting in ${RETRY_DELAY}s..."
|
||||
sleep $RETRY_DELAY
|
||||
|
||||
# Exponential backoff up to 5 minutes
|
||||
RETRY_DELAY=$((RETRY_DELAY * 2))
|
||||
if [ $RETRY_DELAY -gt 300 ]; then
|
||||
RETRY_DELAY=300
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
### Integration with systemd
|
||||
|
||||
Create `/etc/systemd/system/vaultlink-cli.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=VaultLink CLI Sync
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
Environment="VAULTLINK_LOCAL_PATH=/data/vault"
|
||||
Environment="VAULTLINK_REMOTE_URI=wss://sync.example.com"
|
||||
Environment="VAULTLINK_TOKEN=your-token"
|
||||
Environment="VAULTLINK_VAULT_NAME=default"
|
||||
ExecStart=/usr/local/bin/vaultlink
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable vaultlink-cli
|
||||
sudo systemctl start vaultlink-cli
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Configure server authentication →](/config/authentication)
|
||||
- [Learn about the sync algorithm →](/architecture/sync-algorithm)
|
||||
- [Set up Obsidian plugin →](/guide/obsidian-plugin)
|
||||
125
docs/guide/getting-started.md
Normal file
125
docs/guide/getting-started.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# Getting Started
|
||||
|
||||
Set up VaultLink in 5 minutes. Deploy server, connect clients, done.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker (or Rust toolchain if building from source)
|
||||
- A server (VPS, home server, or localhost for testing)
|
||||
|
||||
## Step 1: Deploy Server
|
||||
|
||||
Create `config.yml`:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
databases_directory_path: databases
|
||||
max_connections_per_vault: 12
|
||||
cursor_timeout_seconds: 60
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 3000
|
||||
max_body_size_mb: 512
|
||||
max_clients_per_vault: 256
|
||||
response_timeout_seconds: 60
|
||||
users:
|
||||
user_configs:
|
||||
- name: admin
|
||||
token: change-this-to-secure-random-token
|
||||
vault_access:
|
||||
type: allow_access_to_all
|
||||
logging:
|
||||
log_directory: logs
|
||||
log_rotation: 7days
|
||||
```
|
||||
|
||||
::: tip
|
||||
Generate secure token: `openssl rand -hex 32`
|
||||
:::
|
||||
|
||||
Run server:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name vaultlink-server \
|
||||
--restart unless-stopped \
|
||||
-p 3000:3000 \
|
||||
-v $(pwd):/data \
|
||||
ghcr.io/schmelczer/vault-link-server:latest \
|
||||
/app/sync_server /data/config.yml
|
||||
```
|
||||
|
||||
Verify: `curl http://localhost:3000/vaults/test/ping` should return server version and auth status
|
||||
|
||||
## Step 2: Connect Client
|
||||
|
||||
### Obsidian Plugin
|
||||
|
||||
1. Settings → Community Plugins → Browse
|
||||
2. Search "VaultLink", install, enable
|
||||
3. Configure:
|
||||
- Server URL: `ws://localhost:3000` (or `wss://your-server.com` for SSL)
|
||||
- Token: Your token from config.yml
|
||||
- Vault Name: `default`
|
||||
|
||||
[Full plugin guide →](/guide/obsidian-plugin)
|
||||
|
||||
### CLI Client
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name vaultlink-cli \
|
||||
--restart unless-stopped \
|
||||
-v /path/to/vault:/vault \
|
||||
ghcr.io/schmelczer/vault-link-cli:latest \
|
||||
-l /vault -r ws://localhost:3000 -t your-token -v default
|
||||
```
|
||||
|
||||
[Full CLI guide →](/guide/cli-client)
|
||||
|
||||
## Production Setup
|
||||
|
||||
For production:
|
||||
|
||||
1. **SSL/TLS**: Use Nginx/Caddy reverse proxy for `wss://` ([setup guide](/guide/server-setup#ssl-tls-with-reverse-proxy))
|
||||
2. **Secure tokens**: Generate with `openssl rand -hex 32`, don't reuse the example
|
||||
3. **Firewall**: Only expose port 3000 to reverse proxy
|
||||
4. **Backups**: SQLite databases are in `databases/` directory
|
||||
|
||||
## Multiple Users
|
||||
|
||||
```yaml
|
||||
users:
|
||||
user_configs:
|
||||
- name: alice
|
||||
token: alice-token
|
||||
vault_access:
|
||||
type: allow_list
|
||||
allowed:
|
||||
- personal
|
||||
- shared
|
||||
- name: bob
|
||||
token: bob-token
|
||||
vault_access:
|
||||
type: allow_list
|
||||
allowed:
|
||||
- shared
|
||||
```
|
||||
|
||||
[Auth docs →](/config/authentication)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Server won't start**: `docker logs vaultlink-server`
|
||||
|
||||
**Client can't connect**:
|
||||
|
||||
1. Verify server: `curl http://your-server:3000/vaults/test/ping`
|
||||
2. Check URL: `ws://` for HTTP, `wss://` for HTTPS
|
||||
3. Verify token matches config.yml
|
||||
|
||||
**Understanding limitations**: [See what VaultLink can and can't do →](/guide/limitations)
|
||||
|
||||
**Files not syncing**: Check client logs, verify vault name matches
|
||||
|
||||
[Server setup →](/guide/server-setup) | [Architecture →](/architecture/)
|
||||
192
docs/guide/limitations.md
Normal file
192
docs/guide/limitations.md
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
# Limitations
|
||||
|
||||
VaultLink works well for most Obsidian vaults, but has some constraints you should know about.
|
||||
|
||||
## File Type Limitations
|
||||
|
||||
### Mergeable Files
|
||||
|
||||
Only **`.md`** and **`.txt`** files get automatic conflict-free merging.
|
||||
|
||||
Other file types (images, PDFs, etc.) use last-write-wins:
|
||||
|
||||
```
|
||||
User A updates diagram.png → Server stores version 1
|
||||
User B updates diagram.png → Server stores version 2 (overwrites A's changes)
|
||||
```
|
||||
|
||||
**Workaround**: Avoid editing the same non-text file simultaneously.
|
||||
|
||||
### Binary Detection
|
||||
|
||||
Files are treated as binary if they:
|
||||
|
||||
- Contain NUL bytes (`0x00`)
|
||||
- Fail UTF-8 validation
|
||||
|
||||
Binary files within `.md` or `.txt` extensions still get last-write-wins (no merge).
|
||||
|
||||
## Performance Constraints
|
||||
|
||||
### Server Limits (Configurable)
|
||||
|
||||
| Resource | Default | Maximum Tested |
|
||||
| ------------------------ | ------- | -------------- |
|
||||
| Clients per vault | 256 | ~256 |
|
||||
| Database connections | 12 | 20 |
|
||||
| Max file size | 512 MB | 4096 MB |
|
||||
| Request timeout | 60s | 180s |
|
||||
| WebSocket cursor timeout | 60s | 300s |
|
||||
| Database busy timeout | 3600s | - |
|
||||
|
||||
### Vault Size
|
||||
|
||||
- **Small vaults** (< 1000 files): Excellent performance
|
||||
- **Medium vaults** (1000-10000 files): Good performance
|
||||
- **Large vaults** (> 10000 files): Works, but initial sync slower
|
||||
|
||||
No hard file count limit—constrained by disk space and sync time.
|
||||
|
||||
### Resource Usage
|
||||
|
||||
Rough estimates (varies by vault size and activity):
|
||||
|
||||
- **RAM**: ~50-200 MB base + ~1-5 MB per active client
|
||||
- **CPU**: Low (< 5%) for typical usage, spikes during merges
|
||||
- **Disk**: Vault size + version history (grows over time)
|
||||
|
||||
## Version History
|
||||
|
||||
### Storage
|
||||
|
||||
- All versions stored indefinitely (no automatic cleanup)
|
||||
- Each vault is a separate SQLite database
|
||||
- Deleted files marked as deleted (not purged)
|
||||
|
||||
**Growth**: Version history grows with every change. A 10 MB vault with frequent edits might grow to 100+ MB over months.
|
||||
|
||||
**Cleanup**: Manual only (see [Advanced Configuration](/config/advanced#version-history-cleanup)).
|
||||
|
||||
### Implications
|
||||
|
||||
- Disk usage grows over time
|
||||
- Database size affects backup time
|
||||
- No built-in retention policy
|
||||
|
||||
## Merge Quality
|
||||
|
||||
### Text Merging
|
||||
|
||||
VaultLink uses word-level tokenisation for merging:
|
||||
|
||||
```markdown
|
||||
Parent: "The quick brown fox"
|
||||
User A: "The quick red fox"
|
||||
User B: "The very quick brown fox"
|
||||
Result: "The very quick red fox" ← Both changes preserved
|
||||
```
|
||||
|
||||
**Imperfect scenarios**:
|
||||
|
||||
- Complex nested Markdown (tables, code blocks)
|
||||
- Simultaneous edits to the same sentence
|
||||
- Large structural changes (moving sections around)
|
||||
|
||||
**Result**: Merged file might need manual cleanup in ~1-5% of concurrent edits.
|
||||
|
||||
## Scalability
|
||||
|
||||
### SQLite Limitations
|
||||
|
||||
- One SQLite database per vault
|
||||
- Single-server architecture (no built-in clustering)
|
||||
- Write serialisation through database
|
||||
|
||||
**For high concurrency**: Consider multiple vaults instead of one massive shared vault.
|
||||
|
||||
### Horizontal Scaling
|
||||
|
||||
Not currently supported. Running multiple servers requires manual vault partitioning.
|
||||
|
||||
## Network Requirements
|
||||
|
||||
### Latency
|
||||
|
||||
- Real-time sync typically < 500ms on good connections
|
||||
- Mobile/slow networks: 1-5s latency possible
|
||||
- Timeout failures on very slow connections (> 60s)
|
||||
|
||||
### Offline Behaviour
|
||||
|
||||
- Clients queue changes locally
|
||||
- On reconnect, sync all changes since last connection
|
||||
- Conflicts resolved automatically (for mergeable files)
|
||||
|
||||
**Limitation**: No offline conflict preview—merged result appears after reconnect.
|
||||
|
||||
## Security
|
||||
|
||||
### No End-to-End Encryption
|
||||
|
||||
- Server sees all file contents
|
||||
- Transport encryption only (WSS/TLS)
|
||||
- Trust your server
|
||||
|
||||
**Workaround**: Self-host on infrastructure you control.
|
||||
|
||||
### Authentication
|
||||
|
||||
- Token-based only (no OAuth, SAML, etc.)
|
||||
- Tokens configured in server config file
|
||||
- No runtime user management
|
||||
|
||||
## Known Edge Cases
|
||||
|
||||
### Simultaneous Deletes and Edits
|
||||
|
||||
```
|
||||
User A deletes note.md
|
||||
User B edits note.md
|
||||
Result: Edit wins (file recreated with B's content)
|
||||
```
|
||||
|
||||
Operational transformation prioritises content preservation.
|
||||
|
||||
### Large File Uploads
|
||||
|
||||
Files > 100 MB may time out on slow connections. Increase `response_timeout_seconds` or split large files.
|
||||
|
||||
### Mobile Sync
|
||||
|
||||
- Mobile networks may drop WebSocket connections frequently
|
||||
- Client auto-reconnects, but causes sync delays
|
||||
- Battery impact from constant reconnections
|
||||
|
||||
## What VaultLink is NOT
|
||||
|
||||
- **Not a backup solution**: Version history helps but isn't a backup (make backups!)
|
||||
- **Not Git**: No branching, no commit messages, no diffs to review before merge
|
||||
- **Not encrypted storage**: Server sees everything
|
||||
- **Not multi-master**: One server, multiple clients (not peer-to-peer)
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Good Use Cases
|
||||
|
||||
- Personal multi-device sync (< 10 devices)
|
||||
- Small team collaboration (< 20 people)
|
||||
- Primarily text/Markdown content
|
||||
- Trusted server environment
|
||||
|
||||
### Poor Use Cases
|
||||
|
||||
- Large teams (> 50 concurrent users per vault)
|
||||
- Primarily binary files (images, videos, large PDFs)
|
||||
- Untrusted server (need E2E encryption)
|
||||
- Highly regulated environments (HIPAA, etc.)
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Server configuration limits →](/config/server)
|
||||
- [Advanced tuning →](/config/advanced)
|
||||
- [Architecture details →](/architecture/)
|
||||
276
docs/guide/obsidian-plugin.md
Normal file
276
docs/guide/obsidian-plugin.md
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
# Obsidian Plugin
|
||||
|
||||
Real-time sync for Obsidian vaults.
|
||||
|
||||
## Installation
|
||||
|
||||
### From Obsidian Community Plugins
|
||||
|
||||
1. Open Obsidian Settings
|
||||
2. Navigate to **Community Plugins**
|
||||
3. Click **Browse** and search for "VaultLink"
|
||||
4. Click **Install**
|
||||
5. Enable the plugin
|
||||
|
||||
### Manual Installation
|
||||
|
||||
1. Download the latest release from [GitHub Releases](https://github.com/schmelczer/vault-link/releases)
|
||||
2. Extract `main.js`, `manifest.json`, and `styles.css`
|
||||
3. Copy to `.obsidian/plugins/vault-link/` in your vault
|
||||
4. Reload Obsidian
|
||||
5. Enable VaultLink in Community Plugins settings
|
||||
|
||||
## Configuration
|
||||
|
||||
After installation, configure the plugin in **Settings → VaultLink**.
|
||||
|
||||
### Required Settings
|
||||
|
||||
#### Server URL
|
||||
|
||||
The WebSocket URL of your sync server.
|
||||
|
||||
- **Development/Local**: `ws://localhost:3000`
|
||||
- **Production (SSL)**: `wss://sync.example.com`
|
||||
|
||||
::: tip
|
||||
Use `ws://` for unencrypted connections and `wss://` for SSL connections (production).
|
||||
:::
|
||||
|
||||
#### Authentication Token
|
||||
|
||||
Your authentication token from the server's `config.yml`.
|
||||
|
||||
Generate a secure token:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
#### Vault Name
|
||||
|
||||
The name of the vault on the server. Can be any string.
|
||||
|
||||
Multiple Obsidian vaults can sync to the same server vault name (for shared vaults), or use unique names for separate vaults.
|
||||
|
||||
### Optional Settings
|
||||
|
||||
#### Sync Concurrency
|
||||
|
||||
Number of files to sync simultaneously.
|
||||
|
||||
- **Default**: 1
|
||||
- **Range**: 1-10
|
||||
- Higher values = faster initial sync, more resource usage
|
||||
|
||||
#### Max File Size
|
||||
|
||||
Maximum file size to sync (in MB).
|
||||
|
||||
- **Default**: 10
|
||||
- Files larger than this are skipped
|
||||
|
||||
#### Ignore Patterns
|
||||
|
||||
Glob patterns for files to exclude from sync.
|
||||
|
||||
Examples:
|
||||
|
||||
- `*.tmp` - Ignore temporary files
|
||||
- `.trash/**` - Ignore trash folder
|
||||
- `private/**` - Ignore private directory
|
||||
|
||||
#### WebSocket Retry Interval
|
||||
|
||||
Milliseconds between reconnection attempts when disconnected.
|
||||
|
||||
- **Default**: 3500ms
|
||||
- Increase for flaky networks to avoid connection spam
|
||||
|
||||
## Usage
|
||||
|
||||
### Initial Sync
|
||||
|
||||
When first connecting:
|
||||
|
||||
1. The plugin uploads all local files to the server
|
||||
2. Downloads any missing files from the server
|
||||
3. Resolves any conflicts using operational transformation
|
||||
4. Begins real-time synchronisation
|
||||
|
||||
Initial sync time depends on vault size and `sync_concurrency` setting.
|
||||
|
||||
### Real-Time Sync
|
||||
|
||||
Once connected:
|
||||
|
||||
- **File changes**: Automatically synced when saved
|
||||
- **File creation**: New files immediately uploaded
|
||||
- **File deletion**: Deletions propagated to other clients
|
||||
- **File renames**: Tracked and synchronised
|
||||
|
||||
The plugin watches your vault filesystem and syncs changes in real-time via WebSocket.
|
||||
|
||||
### Status Indicators
|
||||
|
||||
The plugin provides visual feedback:
|
||||
|
||||
- **Connected**: Green status in settings
|
||||
- **Syncing**: Progress indicator during uploads
|
||||
- **Disconnected**: Red status, automatic reconnection attempts
|
||||
- **Error**: Error message in settings and console
|
||||
|
||||
Check the Obsidian console (Ctrl+Shift+I / Cmd+Option+I) for detailed logs.
|
||||
|
||||
## Features
|
||||
|
||||
### Automatic Conflict Resolution
|
||||
|
||||
When multiple users edit the same file simultaneously, operational transformation merges changes automatically:
|
||||
|
||||
- All edits are preserved
|
||||
- No manual conflict resolution required
|
||||
- Changes appear in real-time as others type
|
||||
|
||||
### Mobile Support
|
||||
|
||||
VaultLink works on Obsidian mobile (iOS and Android):
|
||||
|
||||
- Same configuration as desktop
|
||||
- Real-time sync across all devices
|
||||
- Handle network changes gracefully
|
||||
|
||||
::: warning
|
||||
Ensure your sync server is accessible from mobile networks (use WSS with a public domain or VPN).
|
||||
:::
|
||||
|
||||
### Offline Support
|
||||
|
||||
The plugin handles offline scenarios:
|
||||
|
||||
- Continue working when disconnected
|
||||
- Changes queue locally
|
||||
- Automatic sync when connection restored
|
||||
- Conflict resolution if others edited the same files
|
||||
|
||||
## Collaboration Workflows
|
||||
|
||||
### Personal Multi-Device Sync
|
||||
|
||||
Sync the same vault across devices:
|
||||
|
||||
1. Configure each Obsidian instance with the same vault name
|
||||
2. Use the same authentication token
|
||||
3. All devices stay in sync automatically
|
||||
|
||||
### Team Shared Vault
|
||||
|
||||
Multiple users collaborating:
|
||||
|
||||
1. Each user has their own token (configured in server `config.yml`)
|
||||
2. All users connect to the same vault name
|
||||
3. Real-time collaborative editing with automatic conflict resolution
|
||||
|
||||
### Selective Sharing
|
||||
|
||||
Share specific folders while keeping others private:
|
||||
|
||||
1. Use different vault names for shared vs. private content
|
||||
2. Configure access control on the server per vault
|
||||
3. Use ignore patterns to exclude sensitive directories
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plugin won't connect
|
||||
|
||||
1. **Verify server is running**:
|
||||
|
||||
```bash
|
||||
curl http://your-server:3000/vaults/test/ping
|
||||
```
|
||||
|
||||
Should return `pong`
|
||||
|
||||
2. **Check URL format**:
|
||||
- Local: `ws://localhost:3000`
|
||||
- Remote (SSL): `wss://sync.example.com`
|
||||
- Don't include `/vault/name` in the URL
|
||||
|
||||
3. **Verify token**:
|
||||
- Must match server config exactly
|
||||
- No extra spaces or quotes
|
||||
- Check server logs for authentication errors
|
||||
|
||||
4. **Check firewall**:
|
||||
- Ensure port is accessible from your network
|
||||
- For mobile, server must be publicly accessible or use VPN
|
||||
|
||||
### Files not syncing
|
||||
|
||||
1. **Check ignore patterns**: File may match an exclusion pattern
|
||||
2. **File size**: Check if file exceeds `max_file_size_mb`
|
||||
3. **Permissions**: Ensure vault directory is readable/writable
|
||||
4. **Console errors**: Open dev tools (Ctrl+Shift+I) and check console
|
||||
|
||||
### Slow initial sync
|
||||
|
||||
1. **Increase concurrency**: Set `sync_concurrency` higher (e.g., 5)
|
||||
2. **Network speed**: Check internet connection
|
||||
3. **Server resources**: Ensure server isn't overloaded
|
||||
4. **Large files**: Consider increasing timeout settings
|
||||
|
||||
### Conflicts not resolving
|
||||
|
||||
Operational transformation should handle conflicts automatically. If issues persist:
|
||||
|
||||
1. Check console for sync errors
|
||||
2. Verify both clients are connected
|
||||
3. Check server logs for processing errors
|
||||
4. Ensure files are text-based (binary files may not merge well)
|
||||
|
||||
### High CPU/Memory usage
|
||||
|
||||
1. **Reduce concurrency**: Lower `sync_concurrency`
|
||||
2. **Add ignore patterns**: Exclude unnecessary files
|
||||
3. **File watchers**: Large vaults may trigger many filesystem events
|
||||
4. **Check for sync loops**: Ensure no circular dependencies
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Multiple Vaults
|
||||
|
||||
To sync multiple Obsidian vaults to different server vaults:
|
||||
|
||||
1. Each Obsidian vault has its own VaultLink plugin configuration
|
||||
2. Use different vault names for each
|
||||
3. Can use the same or different tokens (depending on access control)
|
||||
|
||||
### Custom Sync Patterns
|
||||
|
||||
Combine ignore patterns for fine-grained control:
|
||||
|
||||
```
|
||||
# Ignore patterns
|
||||
*.tmp
|
||||
*.bak
|
||||
.DS_Store
|
||||
.trash/**
|
||||
private/**
|
||||
drafts/**/*.draft.md
|
||||
```
|
||||
|
||||
### Development/Testing
|
||||
|
||||
For plugin development:
|
||||
|
||||
1. Clone the repository
|
||||
2. `cd frontend && npm install`
|
||||
3. `npm run dev` to build in watch mode
|
||||
4. Plugin rebuilds automatically on changes
|
||||
5. Reload Obsidian to test changes
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Learn about the sync algorithm →](/architecture/sync-algorithm)
|
||||
- [Configure the server →](/config/server)
|
||||
- [Set up the CLI client →](/guide/cli-client)
|
||||
379
docs/guide/server-setup.md
Normal file
379
docs/guide/server-setup.md
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
# Server Setup
|
||||
|
||||
Deploy VaultLink server via Docker, binary, or build from source.
|
||||
|
||||
## Deployment Options
|
||||
|
||||
### Docker (Recommended)
|
||||
|
||||
Easiest deployment path, includes health checks.
|
||||
|
||||
#### Basic Docker Deployment
|
||||
|
||||
```bash
|
||||
# Pull the latest image
|
||||
docker pull ghcr.io/schmelczer/vault-link-server:latest
|
||||
|
||||
# Create data directory
|
||||
mkdir -p ~/vaultlink-data
|
||||
|
||||
# Create config.yml (see Configuration section below)
|
||||
|
||||
# Run the container
|
||||
docker run -d \
|
||||
--name vaultlink-server \
|
||||
--restart unless-stopped \
|
||||
-p 3000:3000 \
|
||||
-v ~/vaultlink-data:/data \
|
||||
ghcr.io/schmelczer/vault-link-server:latest \
|
||||
/app/sync_server /data/config.yml
|
||||
```
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
Create `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
vaultlink-server:
|
||||
image: ghcr.io/schmelczer/vault-link-server:latest
|
||||
container_name: vaultlink-server
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
command: ["/app/sync_server", "/data/config.yml"]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/vaults/fake/ping"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
```
|
||||
|
||||
Start the server:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Binary Installation
|
||||
|
||||
Download pre-built binaries from [GitHub Releases](https://github.com/schmelczer/vault-link/releases):
|
||||
|
||||
```bash
|
||||
# Download the binary for your platform
|
||||
wget https://github.com/schmelczer/vault-link/releases/latest/download/sync_server-linux-x86_64
|
||||
|
||||
# Make executable
|
||||
chmod +x sync_server-linux-x86_64
|
||||
|
||||
# Run the server
|
||||
./sync_server-linux-x86_64 config.yml
|
||||
```
|
||||
|
||||
### Build from Source
|
||||
|
||||
Requirements: Rust 1.92.0+, SQLite development headers, SQLx CLI
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/schmelczer/vault-link.git
|
||||
cd vault-link/sync-server
|
||||
|
||||
# Install SQLx CLI
|
||||
cargo install sqlx-cli
|
||||
|
||||
# Set up the database
|
||||
sqlx database create --database-url sqlite://db.sqlite3
|
||||
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
|
||||
cargo sqlx prepare --workspace
|
||||
|
||||
# Build in release mode
|
||||
cargo build --release
|
||||
|
||||
# Run the server
|
||||
./target/release/sync_server config.yml
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Create a `config.yml` file with your server configuration:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
databases_directory_path: databases
|
||||
max_connections_per_vault: 12
|
||||
cursor_timeout_seconds: 60
|
||||
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 3000
|
||||
max_body_size_mb: 512
|
||||
max_clients_per_vault: 256
|
||||
response_timeout_seconds: 60
|
||||
|
||||
users:
|
||||
user_configs:
|
||||
- name: admin
|
||||
token: your-secure-random-token-here
|
||||
vault_access:
|
||||
type: allow_access_to_all
|
||||
|
||||
logging:
|
||||
log_directory: logs
|
||||
log_rotation: 7days
|
||||
```
|
||||
|
||||
### Configuration Fields
|
||||
|
||||
#### Database
|
||||
|
||||
- `databases_directory_path`: Directory for SQLite databases (one per vault)
|
||||
- `max_connections_per_vault`: Maximum concurrent database connections
|
||||
- `cursor_timeout_seconds`: How long to keep database cursors alive
|
||||
|
||||
#### Server
|
||||
|
||||
- `host`: Bind address (use `0.0.0.0` for all interfaces)
|
||||
- `port`: Port to listen on (default: 3000)
|
||||
- `max_body_size_mb`: Maximum upload size
|
||||
- `max_clients_per_vault`: Concurrent client limit per vault
|
||||
- `response_timeout_seconds`: Request timeout
|
||||
|
||||
#### Users
|
||||
|
||||
See [Authentication Configuration →](/config/authentication) for detailed user setup.
|
||||
|
||||
#### Logging
|
||||
|
||||
- `log_directory`: Where to store log files
|
||||
- `log_rotation`: How often to rotate logs (e.g., `7days`, `24hours`)
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### SSL/TLS with Reverse Proxy
|
||||
|
||||
VaultLink doesn't handle SSL directly. Use a reverse proxy like Nginx or Caddy.
|
||||
|
||||
#### Nginx Configuration
|
||||
|
||||
```nginx
|
||||
upstream vaultlink {
|
||||
server localhost:3000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name sync.example.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://vaultlink;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket specific
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reload Nginx:
|
||||
|
||||
```bash
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
#### Caddy Configuration
|
||||
|
||||
Caddy handles SSL automatically:
|
||||
|
||||
```caddy
|
||||
sync.example.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
```
|
||||
|
||||
Start Caddy:
|
||||
|
||||
```bash
|
||||
caddy run --config Caddyfile
|
||||
```
|
||||
|
||||
### Systemd Service
|
||||
|
||||
Create `/etc/systemd/system/vaultlink.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=VaultLink Sync Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=vaultlink
|
||||
WorkingDirectory=/opt/vaultlink
|
||||
ExecStart=/opt/vaultlink/sync_server /opt/vaultlink/config.yml
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable vaultlink
|
||||
sudo systemctl start vaultlink
|
||||
sudo systemctl status vaultlink
|
||||
```
|
||||
|
||||
### Security Best Practices
|
||||
|
||||
1. **Use strong tokens**: Generate with `openssl rand -hex 32`
|
||||
2. **Enable firewall**: Only expose port 3000 to reverse proxy
|
||||
3. **Regular updates**: Keep Docker images and binaries updated
|
||||
4. **Backup databases**: SQLite files in `databases_directory_path`
|
||||
5. **Monitor logs**: Check log directory for errors and anomalies
|
||||
6. **Limit access**: Use vault-specific access controls per user
|
||||
|
||||
### Backup Strategy
|
||||
|
||||
The SQLite databases contain all vault data and history:
|
||||
|
||||
```bash
|
||||
# Backup script
|
||||
#!/bin/bash
|
||||
BACKUP_DIR="/backup/vaultlink/$(date +%Y%m%d)"
|
||||
DATA_DIR="/data/databases"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
cp -r "$DATA_DIR" "$BACKUP_DIR/"
|
||||
|
||||
# Keep 30 days of backups
|
||||
find /backup/vaultlink -type d -mtime +30 -exec rm -rf {} +
|
||||
```
|
||||
|
||||
Run daily via cron:
|
||||
|
||||
```cron
|
||||
0 2 * * * /opt/vaultlink/backup.sh
|
||||
```
|
||||
|
||||
### Monitoring
|
||||
|
||||
#### Health Checks
|
||||
|
||||
The server exposes a ping endpoint:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/vaults/test/ping
|
||||
# Returns: {"server_version":"0.10.1","is_authenticated":false}
|
||||
```
|
||||
|
||||
Replace `test` with any vault name. The endpoint returns:
|
||||
|
||||
- `server_version`: Current server version
|
||||
- `is_authenticated`: Whether the request included a valid token
|
||||
|
||||
Docker health check is built-in and checks this endpoint every 30 seconds.
|
||||
|
||||
#### Prometheus Metrics
|
||||
|
||||
For advanced monitoring, collect Docker stats or implement custom metrics.
|
||||
|
||||
#### Log Monitoring
|
||||
|
||||
Logs are written to the configured `log_directory`. Monitor for:
|
||||
|
||||
- Connection failures
|
||||
- Authentication errors
|
||||
- Database errors
|
||||
- WebSocket disconnections
|
||||
|
||||
Example log watching:
|
||||
|
||||
```bash
|
||||
tail -f /data/logs/*.log | grep -i error
|
||||
```
|
||||
|
||||
## Scaling
|
||||
|
||||
### Horizontal Scaling
|
||||
|
||||
VaultLink currently uses SQLite, which limits horizontal scaling. For multiple servers:
|
||||
|
||||
1. Run separate instances for different vaults
|
||||
2. Use load balancer with sticky sessions (same vault → same server)
|
||||
3. Consider database architecture for your scale needs
|
||||
|
||||
### Vertical Scaling
|
||||
|
||||
Increase resources for the server:
|
||||
|
||||
- More CPU for handling concurrent connections
|
||||
- More RAM for database caching
|
||||
- Faster storage (SSD) for database operations
|
||||
|
||||
Tune configuration:
|
||||
|
||||
- Increase `max_clients_per_vault` for more concurrent users
|
||||
- Increase `max_connections_per_vault` for database performance
|
||||
- Adjust `max_body_size_mb` based on typical file sizes
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server won't start
|
||||
|
||||
```bash
|
||||
# Check Docker logs
|
||||
docker logs vaultlink-server
|
||||
|
||||
# Common issues:
|
||||
# - Port already in use: Change port mapping
|
||||
# - Config syntax error: Validate YAML
|
||||
# - Permission error: Check volume permissions
|
||||
```
|
||||
|
||||
### High memory usage
|
||||
|
||||
- Reduce `max_connections_per_vault`
|
||||
- Reduce `max_clients_per_vault`
|
||||
- Check for large vaults (may need database optimisation)
|
||||
|
||||
### Database corruption
|
||||
|
||||
```bash
|
||||
# Verify database integrity
|
||||
sqlite3 databases/your-vault.db "PRAGMA integrity_check;"
|
||||
|
||||
# If corrupted, restore from backup
|
||||
cp /backup/databases/your-vault.db /data/databases/
|
||||
```
|
||||
|
||||
### WebSocket connection drops
|
||||
|
||||
- Check reverse proxy timeout settings
|
||||
- Verify firewall isn't closing connections
|
||||
- Review client retry intervals
|
||||
- Check server logs for errors
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Configure authentication and access control →](/config/authentication)
|
||||
- [Set up Obsidian plugin →](/guide/obsidian-plugin)
|
||||
- [Deploy CLI client →](/guide/cli-client)
|
||||
- [Understand the architecture →](/architecture/)
|
||||
71
docs/guide/what-is-vaultlink.md
Normal file
71
docs/guide/what-is-vaultlink.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# What is VaultLink?
|
||||
|
||||
Self-hosted sync for Obsidian vaults with automatic conflict-free merging. Edit with any tool, collaborate in real-time, no conflict markers.
|
||||
|
||||
## The Problem
|
||||
|
||||
Syncing Obsidian vaults across devices or sharing with teammates sucks:
|
||||
|
||||
- **Commercial services**: Lock-in, subscriptions, third-party access to your data
|
||||
- **Git**: Manual conflict resolution with `<<<<<<<` markers interrupting your workflow
|
||||
- **Cloud storage**: Last-write-wins data loss or manual conflict resolution
|
||||
- **CRDT solutions**: Only work if you edit inside Obsidian (break if you use Vim, VS Code, etc.)
|
||||
|
||||
## VaultLink's Solution
|
||||
|
||||
Differential synchronisation with operational transformation for Markdown and text files.
|
||||
|
||||
Edit `.md` and `.txt` files with Obsidian, Vim, VS Code, or any editor. VaultLink compares versions and automatically merges all changes. No operation tracking required, no conflict markers.
|
||||
|
||||
**Note**: Binary files (images, PDFs, etc.) use last-write-wins. [See limitations →](/guide/limitations)
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Server**: Rust WebSocket server with SQLite stores document versions
|
||||
2. **Clients**: Obsidian plugin or CLI client watches filesystem changes
|
||||
3. **Sync**: Changes upload to server, server broadcasts to other clients
|
||||
4. **Merge**: [reconcile-text](https://schmelczer.dev/reconcile) automatically merges concurrent edits
|
||||
|
||||
No CRDT infrastructure. No operation logs. Just file comparison and smart merging.
|
||||
|
||||
## Key Advantages
|
||||
|
||||
**Editor agnostic**: Edit files with any tool. Other solutions break when you edit outside their ecosystem.
|
||||
|
||||
**Self-hosted**: Your data, your server. No third parties, no subscriptions, no surprises.
|
||||
|
||||
**Automatic merging**: Operational transformation handles conflicts without interrupting your workflow.
|
||||
|
||||
**Production-ready**: Comprehensive tests, E2E tests, battle-tested. Many alternatives have zero tests.
|
||||
|
||||
**Collaborative**: Real-time sync with cursor tracking. See where teammates are editing.
|
||||
|
||||
## Not Tied to Obsidian
|
||||
|
||||
VaultLink syncs Markdown files. Use it for:
|
||||
|
||||
- Obsidian vaults (Obsidian desktop + mobile + CLI)
|
||||
- Technical documentation (VS Code, your-editor, CLI)
|
||||
- Academic writing (multiple Markdown editors)
|
||||
- Automated workflows (CLI client for backups/CI/CD)
|
||||
|
||||
The Obsidian plugin is just a convenience wrapper around the sync client.
|
||||
|
||||
## Quick Comparison
|
||||
|
||||
| Feature | VaultLink | Git | Cloud Sync | CRDT Solutions |
|
||||
| ------------------- | --------- | --- | ---------- | -------------- |
|
||||
| Self-hosted | ✅ | ✅ | ❌ | Varies |
|
||||
| Any editor | ✅ | ✅ | ✅ | ❌ |
|
||||
| No conflict markers | ✅ | ❌ | ❌ | ✅ |
|
||||
| Real-time | ✅ | ❌ | ❌ | ✅ |
|
||||
| No subscriptions | ✅ | ✅ | ❌ | Varies |
|
||||
| Comprehensive tests | ✅ | N/A | N/A | ❌ |
|
||||
|
||||
[Detailed comparison with alternatives →](/guide/alternatives)
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Get started →](/guide/getting-started) (5 minute setup)
|
||||
- [See the architecture →](/architecture/) (understand how it works)
|
||||
- [Compare alternatives →](/guide/alternatives) (why VaultLink vs others)
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue