This commit is contained in:
Andras Schmelczer 2025-10-18 15:36:49 +01:00
parent 0c42c23669
commit 352c71af65
185 changed files with 20165 additions and 0 deletions

View file

@ -0,0 +1,20 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(npm run test:*)",
"Bash(npm test)",
"Bash(cargo:*)",
"Bash(grep:*)",
"Bash(npm test:*)",
"Bash(npm run lint)",
"Bash(docker build:*)",
"Bash(docker image:*)",
"Bash(timeout 120 docker buildx build --platform linux/arm64 -t vault-link-arm64-test . --load)",
"Bash(docker run:*)",
"Bash(./scripts/build-sync-server-binaries.sh:*)",
"Bash(npm install:*)"
],
"deny": []
}
}

14
.editorconfig Normal file
View file

@ -0,0 +1,14 @@
# https://editorconfig.org
root = true
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
indent_style = space
indent_size = 4
[*.{yml,yaml}]
indent_size = 2

27
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,27 @@
# 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"

40
.github/workflows/check.yml vendored Normal file
View file

@ -0,0 +1,40 @@
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 toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.89.0"
components: clippy, rustfmt
- name: Setup rust
run: |
cargo install sqlx-cli cargo-machete
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: Lint & test
run: scripts/check.sh

45
.github/workflows/e2e.yml vendored Normal file
View file

@ -0,0 +1,45 @@
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 toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.89.0"
components: clippy, rustfmt
- name: Setup rust
run: |
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 &
cd ..
scripts/e2e.sh 32

90
.github/workflows/publish-docker.yml vendored Normal file
View file

@ -0,0 +1,90 @@
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: 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=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}

57
.github/workflows/publish-plugin.yml vendored Normal file
View file

@ -0,0 +1,57 @@
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 plugin
run: |
cd frontend
npm ci
npm run build
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.89.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
- name: Build Linux and Windows binaries
run: ./scripts/build-sync-server-binaries.sh
- name: Create release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${GITHUB_REF#refs/tags/}"
mkdir -p release
cp frontend/obsidian-plugin/dist/* release/
cp sync-server/artifacts/sync-server-* release/
cd release
gh release create "$tag" \
--title="$tag" \
--draft \
*

21
.gitignore vendored Normal file
View file

@ -0,0 +1,21 @@
# npm
node_modules
# Exclude macOS Finder (System Explorer) View States
.DS_Store
# Frontend build folders
frontend/*/dist
sync-server/db.sqlite3*
sync-server/databases
# Rust build folders
sync-server/target
sync-server/artifacts
sync-server/bindings/*.ts
*.log
*.sqlx

10
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,10 @@
{
"jest.jestCommandLine": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" npx jest",
"jest.rootPath": "plugin",
"files.exclude": {
"**/dist": true,
"**/node_modules": true,
"**/.sqlx": true,
"**/target": true,
},
}

99
CLAUDE.md Normal file
View file

@ -0,0 +1,99 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
VaultLink is a self-hosted Obsidian plugin for real-time collaborative file syncing. The project consists of a Rust-based sync server and a TypeScript frontend with three main components: an Obsidian plugin, a sync client library, and a test client.
## Architecture
### Core Components
- **sync-server/**: Rust-based WebSocket server with SQLite database for document versioning and real-time synchronization
- **frontend/sync-client/**: TypeScript library providing core sync functionality, WebSocket management, and file operations
- **frontend/obsidian-plugin/**: Obsidian plugin that integrates the sync client with Obsidian's API
- **frontend/test-client/**: CLI testing tool for the sync functionality
### Key Technologies
- **Backend**: Rust with Axum framework, SQLite with SQLx, WebSockets for real-time sync
- **Frontend**: TypeScript, Webpack for bundling, Jest for testing
- **Sync Algorithm**: Uses reconcile-text library for operational transformation
## Development Commands
### Server Development
```bash
cd sync-server
cargo run config-e2e.yml # Start development server
cargo test --verbose # Run Rust tests
cargo clippy --all-targets --all-features # Lint Rust code
cargo fmt --all -- --check # Check Rust formatting
```
### Frontend Development
```bash
cd frontend
npm run dev # Start development mode (watches sync-client and obsidian-plugin)
npm run build # Build all workspaces
npm run test # Run all tests
npm run lint # Lint and format TypeScript code
```
### Database Setup (Development)
```bash
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
```
### Scripts
- `scripts/check.sh`: Full CI check (builds, lints, tests both server and frontend)
- `scripts/e2e.sh`: End-to-end testing
- `scripts/clean-up.sh`: Clean logs and database files
- `scripts/bump-version.sh patch`: Publish new version
- `scripts/update-api-types.sh`: Update TypeScript bindings from Rust types
## Code Structure
### Workspace Configuration
The frontend uses npm workspaces with three packages:
- `sync-client`: Core synchronization logic
- `obsidian-plugin`: Obsidian-specific integration
- `test-client`: Testing utilities
### Type Generation
Rust structs generate TypeScript types via ts-rs crate, stored in `sync-server/bindings/` and used by frontend packages.
### Key Files
- `sync-server/src/`: Rust server implementation with WebSocket handlers
- `frontend/sync-client/src/sync-client.ts`: Main sync client entry point
- `frontend/obsidian-plugin/src/vault-link-plugin.ts`: Main Obsidian plugin class
- `frontend/sync-client/src/services/sync-service.ts`: Core synchronization logic
## Testing
### Running Tests
- Server: `cargo test --verbose`
- Frontend: `npm run test` (runs Jest across all workspaces)
- E2E: `scripts/e2e.sh`
### Test Structure
- Rust: Unit tests alongside source files
- TypeScript: `.test.ts` files using Jest
- E2E: Uses test-client to simulate multiple concurrent users
## Code Style
### Rust
- Uses extensive Clippy lints (see Cargo.toml)
- Follows pedantic linting rules
- Forbids unsafe code
- Uses cargo fmt with default settings
### TypeScript
- Prettier configuration: 4-space tabs, trailing commas removed, LF line endings
- ESLint with unused imports plugin
- Consistent across all three frontend packages

68
README.md Normal file
View file

@ -0,0 +1,68 @@
# VaultLink self-hosted Obsidian plugin for file syncing
[![Check](https://github.com/schmelczer/vault-link/actions/workflows/check.yml/badge.svg)](https://github.com/schmelczer/vault-link/actions/workflows/check.yml)
[![E2E tests](https://github.com/schmelczer/vault-link/actions/workflows/e2e.yml/badge.svg)](https://github.com/schmelczer/vault-link/actions/workflows/e2e.yml)
[![Publish server Docker image](https://github.com/schmelczer/vault-link/actions/workflows/publish-docker.yml/badge.svg)](https://github.com/schmelczer/vault-link/actions/workflows/publish-docker.yml)
[![Publish Obsidian plugin](https://github.com/schmelczer/vault-link/actions/workflows/publish-plugin.yml/badge.svg)](https://github.com/schmelczer/vault-link/actions/workflows/publish-plugin.yml)
## Develop
### Install [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`
### 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`
### Install Obsidian on Linux
```sh
apt install flatpak
flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak install flathub md.obsidian.Obsidian
flatpak run md.obsidian.Obsidian
```
#### Run in development mode
Start the server:
```sh
cd sync-server && cargo run config-e2e.yml
```
```sh
cd frontend && npm run dev
```
### Scripts
#### Update HTTP API TS bindings
```sh
scripts/update-api-types.sh
```
#### Publish new version
```sh
scripts/bump-version.sh patch
```
#### Run E2E tests
```sh
scripts/e2e.sh
```
And to clean up the logs & database files, run `scripts/clean-up.sh`
## Projects
- [Sync server](./sync-server/README.md)

View file

@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash(npm test:*)",
"Bash(npm ls:*)",
"Bash(find:*)",
"Bash(grep -n \"isBinary\" ../node_modules/reconcile-text/dist/reconcile.node.js)",
"Read(/home/andras/projects/obsidian-shared-sync/sync-server/**)",
"Read(/mnt/wsl/PHYSICALDRIVE1/projects/obsidian-shared-sync/sync-server/**)"
],
"deny": []
}
}

View file

@ -0,0 +1,62 @@
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import unusedImports from "eslint-plugin-unused-imports";
export default [
{
ignores: [
"sync-client/src/services/types.ts",
"**/dist/",
"**/*.mjs",
"**/*.js"
]
},
...tseslint.config({
plugins: {
"unused-imports": unusedImports
},
extends: [eslint.configs.recommended, tseslint.configs.all],
rules: {
"no-unused-vars": "off",
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-floating-promises": [
"error",
{
allowForKnownSafeCalls: [
{ from: "package", name: ["suite", "test"], package: "node:test" },
],
},
],
"@typescript-eslint/parameter-properties": "off",
"@typescript-eslint/require-await": "off",
"@typescript-eslint/class-methods-use-this": "off",
"@typescript-eslint/consistent-return": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/max-params": [
"error",
{
max: 6
}
],
"@typescript-eslint/no-magic-numbers": "off",
"@typescript-eslint/prefer-readonly-parameter-types": "off",
"@typescript-eslint/naming-convention": "off",
"unused-imports/no-unused-vars": [
"warn",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_"
}
]
},
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname
}
}
})
];

View file

@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(npm run test:*)"
],
"deny": []
}
}

View file

View file

@ -0,0 +1,92 @@
# Obsidian Sample Plugin
This is a sample plugin for Obsidian (https://obsidian.md).
This project uses TypeScript to provide type checking and documentation.
The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definition format, which contains TSDoc comments describing what it does.
**Note:** The Obsidian API is still in early alpha and is subject to change at any time!
This sample plugin demonstrates some of the basic functionality the plugin API can do.
- Adds a ribbon icon, which shows a Notice when clicked.
- Adds a command "Open Sample Modal" which opens a Modal.
- Adds a plugin setting tab to the settings page.
- Registers a global click event and output 'click' to the console.
- Registers a global interval which logs 'setInterval' to the console.
## First time developing plugins?
Quick starting guide for new plugin devs:
- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with.
- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it).
- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder.
- Install NodeJS, then run `npm i` in the command line under your repo folder.
- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`.
- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`.
- Reload Obsidian to load the new version of your plugin.
- Enable plugin in settings window.
- For updates to the Obsidian API run `npm update` in the command line under your repo folder.
## Releasing new releases
- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release.
- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible.
- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases
- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release.
- Publish the release.
> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`.
> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json`
## Adding your plugin to the community plugin list
- Check the [plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines).
- Publish an initial version.
- Make sure you have a `README.md` file in the root of your repo.
- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin.
## How to use
- Clone this repo.
- Make sure your NodeJS is at least v16 (`node --version`).
- `npm i` or `yarn` to install dependencies.
- `npm run dev` to start compilation in watch mode.
## Manually installing the plugin
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
## Funding URL
You can include funding URLs where people who use your plugin can financially support it.
The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file:
```json
{
"fundingUrl": "https://buymeacoffee.com"
}
```
If you have multiple URLs, you can also do:
```json
{
"fundingUrl": {
"Buy Me a Coffee": "https://buymeacoffee.com",
"GitHub Sponsor": "https://github.com/sponsors",
"Patreon": "https://www.patreon.com/"
}
}
```
## API Documentation
See https://github.com/obsidianmd/obsidian-api

View file

@ -0,0 +1,10 @@
{
"id": "vault-link",
"name": "VaultLink",
"version": "0.8.0",
"minAppVersion": "0.0.0",
"description": "Self-hosted synchronization and collaboration for your Vault.",
"author": "Andras Schmelczer",
"authorUrl": "https://schmelczer.dev",
"isDesktopOnly": false
}

View file

@ -0,0 +1,40 @@
{
"name": "vault-link-obsidian-plugin",
"version": "0.8.0",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"main": "main.js",
"scripts": {
"dev": "webpack watch --mode development",
"build": "webpack --mode production",
"test": "echo \"no tests defined\" && exit 0",
"version": "node version-bump.mjs"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"@plausible-analytics/tracker": "^0.4.0",
"@sentry/browser": "^10.8.0",
"@types/node": "^22.15.30",
"css-loader": "^7.1.2",
"date-fns": "^4.1.0",
"file-loader": "^6.2.0",
"fs-extra": "^11.3.0",
"mini-css-extract-plugin": "^2.9.2",
"obsidian": "1.8.7",
"reconcile-text": "^0.5.0",
"resolve-url-loader": "^5.0.0",
"sass": "^1.91.0",
"sass-loader": "^16.0.5",
"sync-client": "file:../sync-client",
"terser-webpack-plugin": "^5.3.14",
"ts-loader": "^9.5.2",
"tslib": "2.8.1",
"tsx": "^4.20.5",
"typescript": "5.8.3",
"url": "^0.11.4",
"virtual-scroller": "^1.13.1",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1"
}
}

View file

@ -0,0 +1,174 @@
import type { Stat, Vault, Workspace } from "obsidian";
import { MarkdownView, normalizePath } from "obsidian";
import {
utils,
type FileSystemOperations,
type RelativePath
} from "sync-client";
import { getSelectionsFromEditor } from "./views/cursors/get-selections-from-editor";
import type { TextWithCursors, CursorPosition } from "reconcile-text";
export class ObsidianFileSystemOperations implements FileSystemOperations {
public constructor(
private readonly vault: Vault,
private readonly workspace: Workspace
) {}
public async listAllFiles(): Promise<RelativePath[]> {
// Let's implement this by hand because vault.adapter.listAllFiles doesn't always return all files.
const allFiles = [];
const remainingFolders = [this.vault.getRoot().path];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
const folder = remainingFolders.pop();
if (folder == undefined) {
break;
}
// This would be a very bad idea to sync as it would mess with
// the integrity of the sync database.
if (folder.endsWith(".obsidian/plugins/vault-link/data.json")) {
continue;
}
const files = await this.vault.adapter.list(normalizePath(folder));
allFiles.push(...files.files);
remainingFolders.push(...files.folders);
}
return allFiles;
}
public async read(path: RelativePath): Promise<Uint8Array> {
path = normalizePath(path);
const view = this.workspace.getActiveViewOfType(MarkdownView);
if (view?.file?.path === path) {
return new TextEncoder().encode(view.editor.getValue());
}
return new Uint8Array(await this.vault.adapter.readBinary(path));
}
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
path = normalizePath(path);
const view = this.workspace.getActiveViewOfType(MarkdownView);
if (view?.file?.path === path) {
const position = view.editor.getCursor();
view.editor.setValue(new TextDecoder().decode(content));
view.editor.setCursor(position);
return;
}
return this.vault.adapter.writeBinary(
path,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
content.buffer as ArrayBuffer
);
}
public async atomicUpdateText(
path: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> {
path = normalizePath(path);
const view = this.workspace.getActiveViewOfType(MarkdownView);
if (view?.file?.path === path) {
const text = view.editor.getValue();
const cursors: CursorPosition[] = getSelectionsFromEditor(
view.editor
).flatMap(({ id, start: anchor, end: head }) => [
{
id: 2 * id,
position: anchor
},
{
id: 2 * id + 1,
position: head
}
]);
const result = updater({
text,
cursors
});
if (result.text === text) {
return text;
}
view.editor.setValue(result.text);
const selections = [];
for (let i = 0; i < result.cursors.length / 2; i++) {
const from = result.cursors[2 * i];
const to = result.cursors[2 * i + 1];
const { line: fromLine, column: fromColumn } =
utils.positionToLineAndColumn(result.text, from.position);
const { line: toLine, column: toColumn } =
utils.positionToLineAndColumn(result.text, to.position);
selections.push({
anchor: { line: fromLine, ch: fromColumn },
head: { line: toLine, ch: toColumn }
});
}
view.editor.setSelections(selections);
return result.text;
}
return this.vault.adapter.process(
path,
(text) =>
updater({
text,
cursors: []
}).text
);
}
public async getFileSize(path: RelativePath): Promise<number> {
return (await this.statFile(path)).size;
}
public async getModificationTime(path: RelativePath): Promise<Date> {
return new Date((await this.statFile(path)).mtime);
}
public async exists(path: RelativePath): Promise<boolean> {
return this.vault.adapter.exists(normalizePath(path));
}
public async createDirectory(path: RelativePath): Promise<void> {
return this.vault.adapter.mkdir(normalizePath(path));
}
public async delete(path: RelativePath): Promise<void> {
if (!(await this.vault.adapter.trashSystem(normalizePath(path)))) {
return this.vault.adapter.remove(normalizePath(path));
}
}
public async rename(
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
return this.vault.adapter.rename(oldPath, newPath);
}
private async statFile(path: string): Promise<Stat> {
const file = await this.vault.adapter.stat(normalizePath(path));
if (!file) {
throw new Error(`File not found: ${path}`);
}
return file;
}
}

View file

@ -0,0 +1,287 @@
import type {
MarkdownView,
Editor,
MarkdownFileInfo,
TAbstractFile,
WorkspaceLeaf
} from "obsidian";
import { Platform, Plugin, TFile } from "obsidian";
import "../manifest.json";
import { HistoryView } from "./views/history/history-view";
import { StatusBar } from "./views/status-bar/status-bar";
import { LogsView } from "./views/logs/logs-view";
import { StatusDescription } from "./views/status-description/status-description";
import * as Sentry from "@sentry/browser";
import { init as plausibleInit } from "@plausible-analytics/tracker";
import {
SyncClient,
rateLimit,
DEFAULT_SETTINGS,
Logger,
debugging
} from "sync-client";
import { ObsidianFileSystemOperations } from "./obsidian-file-system";
import { SyncSettingsTab } from "./views/settings/settings-tab";
import { EditorStatusDisplayManager } from "./views/editor-status-display-manager/editor-status-display-manager";
import { remoteCursorsTheme } from "./views/cursors/remote-cursor-theme";
import {
remoteCursorsPlugin,
RemoteCursorsPluginValue
} from "./views/cursors/remote-cursors-plugin";
import { LocalCursorUpdateListener } from "./views/cursors/local-cursor-update-listener";
import { renderCursorsInFileExplorer } from "./views/cursors/file-explorer";
const MIN_WAIT_BETWEEN_UPDATES_IN_MS = 250;
export default class VaultLinkPlugin extends Plugin {
private readonly disposables: (() => unknown)[] = [];
private settingsTab: SyncSettingsTab | undefined;
private client!: SyncClient;
private readonly rateLimitedUpdatesPerFile = new Map<
string,
() => Promise<unknown>
>();
public async onload(): Promise<void> {
DEFAULT_SETTINGS.ignorePatterns.push(
".obsidian/**",
".git/**",
".trash/**"
);
const isDebugBuild = process.env.NODE_ENV === "development";
if (!isDebugBuild) {
plausibleInit({
domain: "vault-link",
endpoint: "https://stats.schmelczer.dev/status",
autoCapturePageviews: true,
captureOnLocalhost: true,
logging: true
});
Sentry.init({
dsn: "https://56accd39d92442e788a457a04623cf57@bugs.schmelczer.dev/1",
skipBrowserExtensionCheck: false
});
const onError = (event: ErrorEvent): void => {
Sentry.captureException(event.error, {
extra: {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno
}
});
};
window.addEventListener("error", onError);
this.disposables.push(() => {
window.removeEventListener("error", onError);
});
const onUnhandledRejection = (
event: PromiseRejectionEvent
): void => {
Sentry.captureException(event.reason);
};
window.addEventListener("unhandledrejection", onUnhandledRejection);
this.disposables.push(() => {
window.removeEventListener(
"unhandledrejection",
onUnhandledRejection
);
});
}
const debugOptions = isDebugBuild
? {
fetch: debugging.slowFetchFactory(1),
webSocket: debugging.slowWebSocketFactory(1, new Logger())
}
: {};
this.client = await SyncClient.create({
fs: new ObsidianFileSystemOperations(
this.app.vault,
this.app.workspace
),
persistence: {
load: this.loadData.bind(this),
save: this.saveData.bind(this)
},
nativeLineEndings: Platform.isWin ? "\r\n" : "\n",
...debugOptions
});
if (isDebugBuild) {
debugging.logToConsole(this.client);
}
const statusDescription = new StatusDescription(this.client);
this.settingsTab = new SyncSettingsTab({
app: this.app,
plugin: this,
syncClient: this.client,
statusDescription
});
this.addSettingTab(this.settingsTab);
new StatusBar(this, this.client);
this.registerView(
HistoryView.TYPE,
(leaf) => new HistoryView(this.client, leaf)
);
this.registerView(
LogsView.TYPE,
(leaf) => new LogsView(this.client, leaf)
);
this.registerEditorExtension([remoteCursorsTheme, remoteCursorsPlugin]);
this.client.addRemoteCursorsUpdateListener((cursors) => {
RemoteCursorsPluginValue.setCursors(cursors, this.app);
renderCursorsInFileExplorer(cursors, this.app);
});
const cursorListener = new LocalCursorUpdateListener(
this.client,
this.app.workspace
);
this.disposables.push(() => {
cursorListener.dispose();
});
this.app.workspace.updateOptions();
this.addRibbonIcon(
HistoryView.ICON,
"Open VaultLink events",
async (_: MouseEvent) => this.activateView(HistoryView.TYPE)
);
this.addRibbonIcon(
LogsView.ICON,
"Open VaultLink logs",
async (_: MouseEvent) => this.activateView(LogsView.TYPE)
);
this.app.workspace.onLayoutReady(async () => {
this.registerEditorEvents();
await this.client.start();
const editorStatusDisplayManager = new EditorStatusDisplayManager(
this,
this.app.workspace,
this.client
);
this.disposables.push(() => {
editorStatusDisplayManager.stop();
});
});
}
public onunload(): void {
this.client.waitAndStop().catch((err: unknown) => {
this.client.logger.error(
`Error while stopping the sync client: ${err}`
);
});
this.disposables.forEach((disposable) => {
disposable();
});
}
public openSettings(): void {
// eslint-disable-next-line
(this.app as any).setting.open(); // this is undocumented
// eslint-disable-next-line
(this.app as any).setting.openTab(this.settingsTab); // this is undocumented
}
public closeSettings(): void {
// eslint-disable-next-line
(this.app as any).setting.close(); // this is undocumented
}
public async activateView(type: string): Promise<void> {
const { workspace } = this.app;
let leaf: WorkspaceLeaf | null = null;
const leaves = workspace.getLeavesOfType(type);
if (leaves.length > 0) {
[leaf] = leaves;
} else {
leaf = workspace.getRightLeaf(false);
await leaf?.setViewState({ type: type, active: true });
}
if (leaf) {
await workspace.revealLeaf(leaf);
}
}
private registerEditorEvents(): void {
[
this.app.workspace.on(
"editor-change",
async (
_editor: Editor,
info: MarkdownView | MarkdownFileInfo
) => {
const { file } = info;
if (file) {
await this.rateLimitedUpdate(file.path);
}
}
),
this.app.vault.on("create", async (file: TAbstractFile) => {
if (file instanceof TFile) {
await this.client.syncLocallyCreatedFile(file.path);
}
}),
this.app.vault.on("modify", async (file: TAbstractFile) => {
if (file instanceof TFile) {
await this.rateLimitedUpdate(file.path);
}
}),
this.app.vault.on("delete", async (file: TAbstractFile) => {
await this.client.syncLocallyDeletedFile(file.path);
}),
this.app.vault.on(
"rename",
async (file: TAbstractFile, oldPath: string) => {
if (file instanceof TFile) {
await this.client.syncLocallyUpdatedFile({
oldPath,
relativePath: file.path
});
}
}
)
].forEach((event) => {
this.registerEvent(event);
});
}
private async rateLimitedUpdate(path: string): Promise<void> {
if (!this.rateLimitedUpdatesPerFile.has(path)) {
this.rateLimitedUpdatesPerFile.set(
path,
rateLimit(
async () =>
this.client.syncLocallyUpdatedFile({
relativePath: path
}),
MIN_WAIT_BETWEEN_UPDATES_IN_MS
)
);
}
await this.rateLimitedUpdatesPerFile.get(path)?.();
}
}

View file

@ -0,0 +1,15 @@
.remote-users {
display: flex;
flex-wrap: wrap;
gap: var(--size-4-2);
margin-left: var(--size-4-2);
span {
border-radius: var(--radius-l);
padding: 0 var(--size-4-1);
border-width: 1.4px;
border-style: solid;
font-size: var(--font-smallest);
font-style: italic;
}
}

View file

@ -0,0 +1,55 @@
import "./file-explorer.scss";
import type { App, View } from "obsidian";
import {
utils,
type MaybeOutdatedClientCursors,
type RelativePath
} from "sync-client";
const REMOTE_USER_CONTAINER_CLASS = "remote-users";
export function renderCursorsInFileExplorer(
cursors: MaybeOutdatedClientCursors[],
app: App
): void {
const fileExplorers = app.workspace.getLeavesOfType("file-explorer");
if (fileExplorers.length == 0) return;
const [fileExplorer] = fileExplorers;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const fileExplorerView: View & {
fileItems: Record<RelativePath, { el: Element }>; // it's an internal API
} = fileExplorer.view as any; // eslint-disable-line
for (const key in fileExplorerView.fileItems) {
const element =
fileExplorerView.fileItems[key].el.querySelector(".tree-item-self");
const customElement = createDiv(
{
cls: REMOTE_USER_CONTAINER_CLASS
},
(parent) => {
cursors.forEach((cursor) => {
cursor.documentsWithCursors.forEach((document) => {
if (document.relative_path === key) {
parent.appendChild(
createSpan({
text: cursor.userName,
attr: {
style: `border-color: ${utils.getRandomColor(cursor.userName)}`
}
})
);
}
});
});
}
);
element?.querySelector("." + REMOTE_USER_CONTAINER_CLASS)?.remove();
element?.appendChild(customElement);
}
}

View file

@ -0,0 +1,17 @@
import type { Editor } from "obsidian";
import { utils } from "sync-client";
export interface Selection {
id: number;
start: number;
end: number;
}
export function getSelectionsFromEditor(editor: Editor): Selection[] {
const text = editor.getValue();
return editor.listSelections().map(({ anchor, head }, i) => ({
id: i,
start: utils.lineAndColumnToPosition(text, anchor.line, anchor.ch),
end: utils.lineAndColumnToPosition(text, head.line, head.ch)
}));
}

View file

@ -0,0 +1,50 @@
import type { Workspace } from "obsidian";
import { MarkdownView } from "obsidian";
import type { SyncClient } from "sync-client";
import type { Selection } from "./get-selections-from-editor";
import { getSelectionsFromEditor } from "./get-selections-from-editor";
export class LocalCursorUpdateListener {
private static readonly UPDATE_INTERVAL_MS = 50;
private readonly eventHandle: NodeJS.Timeout;
public constructor(
private readonly client: SyncClient,
private readonly workspace: Workspace
) {
this.eventHandle = setInterval(() => {
this.updateAllSelections();
}, LocalCursorUpdateListener.UPDATE_INTERVAL_MS);
}
public dispose(): void {
clearInterval(this.eventHandle);
}
private updateAllSelections(): void {
const currentCursors = this.getAllSelections();
this.client
.updateLocalCursors(currentCursors)
.catch((error: unknown) => {
this.client.logger.error(
`Failed to update local cursors: ${error}`
);
});
}
private getAllSelections(): Record<string, Selection[]> {
const cursors: Record<string, Selection[]> = {};
this.workspace
.getLeavesOfType("markdown")
.map((leaf) => leaf.view)
.filter((view) => view instanceof MarkdownView)
.forEach((view) => {
const { file } = view;
if (!file) {
return;
}
cursors[file.path] = getSelectionsFromEditor(view.editor);
});
return cursors;
}
}

View file

@ -0,0 +1,63 @@
import { EditorView } from "@codemirror/view";
const CARET_WIDTH = 2;
const DOT_RADIUS = 4;
export const remoteCursorsTheme = EditorView.baseTheme({
".selection-caret": {
position: "relative"
},
".selection-caret > *": {
position: "absolute",
backgroundColor: "inherit"
},
".selection-caret > .stick": {
left: 0,
top: 0,
transform: "translateX(-50%)",
width: `${CARET_WIDTH}px`,
height: "100%",
display: "block",
borderRadius: `${CARET_WIDTH / 2}px`,
animation: "blink-stick 1s steps(1) infinite"
},
"@keyframes blink-stick": {
"0%, 100%": { opacity: 1 },
"50%": { opacity: 0 }
},
".selection-caret > .dot": {
borderRadius: "50%",
width: `${DOT_RADIUS * 2}px`,
height: `${DOT_RADIUS * 2}px`,
top: `-${DOT_RADIUS}px`,
left: `-${DOT_RADIUS}px`,
transition: "transform .3s ease-in-out",
transformOrigin: "bottom center",
boxSizing: "border-box"
},
".selection-caret:hover > .dot": {
transform: "scale(0)"
},
".selection-caret > .info": {
top: "-1.3em",
left: `-${CARET_WIDTH / 2}px`,
fontSize: "0.9em",
userSelect: "none",
color: "white",
padding: "0 2px",
transition: "opacity .3s ease-in-out",
opacity: 0,
whiteSpace: "nowrap",
borderRadius: "3px 3px 3px 0"
},
".selection-caret:hover > .info": {
opacity: 1
}
});

View file

@ -0,0 +1,46 @@
import { AnnotationType, Annotation, RangeSet, Range } from "@codemirror/state";
import {
ViewUpdate,
ViewPlugin,
Decoration,
WidgetType
} from "@codemirror/view";
import type { PluginValue, DecorationSet, EditorView } from "@codemirror/view";
export class RemoteCursorWidget extends WidgetType {
public constructor(
private readonly color: string,
private readonly name: string
) {
super();
}
public toDOM(editor: EditorView): HTMLElement {
return editor.contentDOM.createEl(
"span",
{
cls: "selection-caret",
attr: {
style: `background-color: ${this.color}; border-color: ${this.color}`
}
},
(span) => {
span.createEl("div", {
cls: "stick"
});
span.createEl("div", {
cls: "dot"
});
span.createEl("div", {
cls: "info",
text: this.name
});
}
);
}
public eq(other: RemoteCursorWidget): boolean {
return other.color === this.color && other.name === this.name;
}
}

View file

@ -0,0 +1,236 @@
import type { Range } from "@codemirror/state";
import { RangeSet } from "@codemirror/state";
import { ViewPlugin, Decoration } from "@codemirror/view";
import type {
PluginValue,
DecorationSet,
EditorView,
ViewUpdate
} from "@codemirror/view";
import { RemoteCursorWidget } from "./remote-cursor-widget";
import {
utils,
type CursorSpan,
type MaybeOutdatedClientCursors
} from "sync-client";
import type { App } from "obsidian";
import { MarkdownView } from "obsidian";
import { StateEffect } from "@codemirror/state";
import type { SpanWithHistory } from "reconcile-text";
import { reconcileWithHistory } from "reconcile-text";
const forceUpdate = StateEffect.define();
export class RemoteCursorsPluginValue implements PluginValue {
private static cursors: {
name: string;
path: string;
span: CursorSpan;
deviceId: string;
isOutdated: boolean;
}[] = [];
public decorations: DecorationSet = RangeSet.of([]);
public static setCursors(
clients: MaybeOutdatedClientCursors[],
app: App
): void {
RemoteCursorsPluginValue.cursors = [
...RemoteCursorsPluginValue.cursors.filter(({ deviceId }) =>
clients.some(
(client) =>
client.deviceId === deviceId && client.isOutdated
)
),
...clients
.filter(
({ isOutdated, deviceId }) =>
!isOutdated ||
RemoteCursorsPluginValue.cursors.every(
(c) => deviceId !== c.deviceId
)
)
.flatMap((client) => {
const clientCursors = client.documentsWithCursors;
return clientCursors.flatMap((cursor) =>
cursor.cursors.map((span) => ({
name: client.userName,
path: cursor.relative_path,
deviceId: client.deviceId,
isOutdated: client.isOutdated,
span: { ...span }
}))
);
})
];
app.workspace
.getLeavesOfType("markdown")
.map((leaf) => leaf.view)
.filter((view) => view instanceof MarkdownView)
.forEach((view) => {
// @ts-expect-error, not typed
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const editor = view.editor.cm as EditorView;
editor.dispatch({
effects: [forceUpdate.of(null)]
});
});
}
private static interpolateRemoteCursorPositions(
original: string,
edited: string
): void {
if (
original === edited ||
RemoteCursorsPluginValue.cursors.length === 0
) {
return;
}
const updatedPositions: number[] = [];
const reconciled = reconcileWithHistory(
original,
{
text: original,
cursors: RemoteCursorsPluginValue.cursors.flatMap(
({ span }, i) => [
{ id: i * 2, position: span.start },
{ id: i * 2 + 1, position: span.end }
]
)
},
edited
);
reconciled.cursors.forEach(({ id, position }) => {
const whereToJump = RemoteCursorsPluginValue.findWhereToMoveCursor(
position,
reconciled.history
);
if (whereToJump !== null) {
updatedPositions[id] = whereToJump;
} else {
updatedPositions[id] = position;
}
});
RemoteCursorsPluginValue.cursors.forEach(({ span }, i) => {
span.start = updatedPositions[i * 2];
span.end = updatedPositions[i * 2 + 1];
});
}
private static findWhereToMoveCursor(
cursor: number,
spans: SpanWithHistory[]
): number | null {
let position = 0;
for (const span of spans) {
// left and origin are the same
if (position === cursor && span.history === "AddedFromRight") {
return position + span.text.length;
}
position += span.text.length;
if (position === cursor && span.history === "RemovedFromRight") {
return position - span.text.length;
}
}
return null;
}
public update(update: ViewUpdate): void {
const original = update.startState.doc.toString();
const edited = update.state.doc.toString();
RemoteCursorsPluginValue.interpolateRemoteCursorPositions(
original,
edited
);
const decorations: Range<Decoration>[] = [];
RemoteCursorsPluginValue.cursors.forEach(
({ name, span: { start, end } }) => {
const color = utils.getRandomColor(name);
const startLine = update.view.state.doc.lineAt(start);
const endLine = update.view.state.doc.lineAt(end);
const attributes = {
style: `background-color: ${color};`
};
if (startLine.number === endLine.number) {
// selected content in a single line.
decorations.push({
from: start,
to: end,
value: Decoration.mark({
attributes
})
});
} else {
// selected content in multiple lines
// first, render text-selection in the first line
decorations.push({
from: start,
to: startLine.from + startLine.length,
value: Decoration.mark({
attributes
})
});
// render text-selection in the lines between the first and last line
for (
let i = startLine.number + 1;
i < endLine.number;
i++
) {
const currentLine = update.view.state.doc.line(i);
decorations.push({
from: currentLine.from,
to: currentLine.to,
value: Decoration.mark({
attributes
})
});
}
// render text-selection in the last line
decorations.push({
from: endLine.from,
to: end,
value: Decoration.mark({
attributes
})
});
}
decorations.push({
from: end,
to: end,
value: Decoration.widget({
side: end - start > 0 ? -1 : 1, // the local cursor should be rendered outside the remote selection
block: false,
widget: new RemoteCursorWidget(color, name)
})
});
}
);
this.decorations = Decoration.set(decorations, true);
}
}
export const remoteCursorsPlugin = ViewPlugin.fromClass(
RemoteCursorsPluginValue,
{
decorations: (v) => v.decorations
}
);

View file

@ -0,0 +1,43 @@
.vault-link-sync-status {
position: absolute;
right: var(--size-4-4);
top: var(--size-4-2);
opacity: 0.7;
cursor: pointer;
> span {
opacity: 0;
position: absolute;
min-width: 200px;
text-align: right;
padding-right: var(--size-2-2);
top: 50%;
left: 0;
transform: translateY(-50%) translateX(-100%) translateY(-2px);
transition: opacity 200ms;
}
&:hover {
> span {
opacity: 1;
}
}
> .icon {
line-height: 0;
}
&.loading > .icon {
animation: spin 2s linear infinite;
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
}
}

View file

@ -0,0 +1,97 @@
import type { Workspace } from "obsidian";
import { FileView, setIcon } from "obsidian";
import type { SyncClient } from "sync-client";
import { DocumentSyncStatus } from "sync-client";
import "./editor-status-display-manager.scss";
import type VaultLinkPlugin from "src/vault-link-plugin";
import { HistoryView } from "../history/history-view";
export class EditorStatusDisplayManager {
private static readonly UPDATE_INTERVAL_IN_MS = 100;
private readonly intervalId: NodeJS.Timeout;
private readonly lastStatuses = new Map<string, DocumentSyncStatus>();
public constructor(
private readonly plugin: VaultLinkPlugin,
private readonly workspace: Workspace,
private readonly client: SyncClient
) {
this.intervalId = setInterval(() => {
this.updateEditorStatusDisplay();
}, EditorStatusDisplayManager.UPDATE_INTERVAL_IN_MS);
}
public stop(): void {
clearInterval(this.intervalId);
}
private updateEditorStatusDisplay(): void {
this.workspace.iterateAllLeaves((leaf) => {
if (leaf.view instanceof FileView) {
const filePath = leaf.view.file?.path;
if (filePath == null) {
return;
}
const element = this.getElementFromLeaf(leaf.view);
if (element == null) {
return;
}
const previousStatus = this.lastStatuses.get(filePath);
const currentStatus =
this.client.getDocumentSyncingStatus(filePath);
if (previousStatus === currentStatus) {
return;
}
this.lastStatuses.set(filePath, currentStatus);
if (currentStatus == DocumentSyncStatus.SYNCING_IS_DISABLED) {
element.remove();
return;
}
if (currentStatus == DocumentSyncStatus.SYNCING) {
element.classList.add("loading");
} else {
element.classList.remove("loading");
}
const iconContainer = element.querySelector(".icon");
if (iconContainer != null) {
setIcon(
iconContainer as HTMLElement, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
currentStatus == DocumentSyncStatus.SYNCING
? "loader"
: "circle-check"
);
}
}
});
}
private getElementFromLeaf(fileView: FileView): Element | undefined {
const parent = fileView.contentEl.querySelector(".cm-editor");
if (parent == null) {
return;
}
return (
parent.querySelector(".vault-link-sync-status") ??
parent.createDiv(
{
cls: "vault-link-sync-status"
},
(el) => {
el.createSpan({ text: "VaultLink sync state" });
el.createDiv({
cls: "icon"
});
el.onclick = async (): Promise<void> =>
this.plugin.activateView(HistoryView.TYPE);
}
)
);
}
}

View file

@ -0,0 +1,61 @@
.history-card {
padding: var(--size-4-4);
margin: var(--size-4-2);
background-color: var(--color-base-00);
border-radius: var(--radius-l);
container-type: inline-size;
&.clickable {
cursor: pointer;
}
&.success {
background-color: rgba(var(--color-green-rgb), 0.2);
}
&.error {
background-color: rgba(var(--color-red-rgb), 0.2);
}
&.skipped {
background-color: rgba(var(--color-green-rgb), 0.08);
}
.history-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--size-4-2);
gap: var(--size-4-2);
@container (max-width: 300px) {
flex-direction: column;
align-items: flex-start;
}
.history-card-title {
font: var(--font-monospace);
display: flex;
align-items: center;
gap: var(--size-4-2);
word-break: break-all;
margin: 0;
> span {
margin-bottom: var(--size-4-1);
}
}
.history-card-timestamp {
font-size: var(--font-ui-small);
font-style: italic;
color: var(--italic-color);
}
}
.history-card-message {
font-size: var(--font-ui-medium);
color: var(--color-base-70);
margin: 0;
}
}

View file

@ -0,0 +1,234 @@
import "./history-view.scss";
import type { IconName, WorkspaceLeaf } from "obsidian";
import { ItemView, setIcon } from "obsidian";
import { intlFormatDistance } from "date-fns";
import type { HistoryEntry, SyncClient } from "sync-client";
import { SyncType } from "sync-client";
export class HistoryView extends ItemView {
public static readonly TYPE = "history-view";
public static readonly ICON = "square-stack";
private timer: NodeJS.Timeout | null = null;
private historyContainer: HTMLElement | undefined;
private readonly historyEntryToElement = new Map<
HistoryEntry,
HTMLElement
>();
public constructor(
private readonly client: SyncClient,
leaf: WorkspaceLeaf
) {
super(leaf);
this.icon = HistoryView.ICON;
this.client.addSyncHistoryUpdateListener(async () =>
this.updateView().catch((error: unknown) => {
this.client.logger.error(
`Failed to update history view: ${error}`
);
})
);
}
private static getSyncTypeIcon(type: SyncType | undefined): IconName {
switch (type) {
case SyncType.CREATE:
return "file-plus";
case SyncType.DELETE:
return "trash-2";
case SyncType.UPDATE:
return "file-pen-line";
case SyncType.MOVE:
return "move-right";
case SyncType.SKIPPED:
return "circle-slash";
case undefined:
default:
return "";
}
}
private static renderSyncItemTitle(
element: HTMLElement,
entry: HistoryEntry
): void {
const syncTypeIcon = HistoryView.getSyncTypeIcon(entry.details.type);
if (syncTypeIcon) {
setIcon(element.createDiv(), syncTypeIcon);
}
let fileName = entry.details.relativePath.split("/").pop() ?? "";
fileName = fileName.replace(/\.md$/, "");
element.createEl("span", {
text:
entry.details.type === SyncType.SKIPPED
? `Skipped: ${fileName}`
: fileName
});
}
private static updateTimeSince(
element: HTMLElement,
entry: HistoryEntry
): void {
const timestampElement = element.querySelector(
".history-card-timestamp"
);
if (timestampElement != null) {
timestampElement.textContent =
HistoryView.getTimestampAndAuthor(entry);
}
}
private static getTimestampAndAuthor(entry: HistoryEntry): string {
let content = intlFormatDistance(entry.timestamp, new Date());
if ("author" in entry && entry.author !== undefined) {
content += ` by ${entry.author}`;
}
return content;
}
public getViewType(): string {
return HistoryView.TYPE;
}
public getDisplayText(): string {
return "VaultLink history";
}
public async onOpen(): Promise<void> {
const container = this.containerEl.children[1];
container.createEl("h4", { text: "VaultLink history" });
this.historyContainer = container.createDiv({ cls: "logs-container" });
await this.updateView();
this.timer = setInterval(
() =>
void this.updateView().catch((error: unknown) => {
this.client.logger.error(
`Failed to update history view: ${error}`
);
}),
1000
);
}
public async onClose(): Promise<void> {
if (this.timer) {
clearInterval(this.timer);
}
}
private async updateView(): Promise<void> {
const container = this.historyContainer;
if (container === undefined) {
return;
}
// entries are newest first, but we prepend new ones
const entries = this.client.getHistoryEntries().toReversed();
if (this.historyEntryToElement.size === 0 && entries.length > 0) {
// Clear the "No update has happened yet" message
container.empty();
}
entries.forEach((entry) => {
const element = this.historyEntryToElement.get(entry);
if (element !== undefined) {
HistoryView.updateTimeSince(element, entry);
return;
}
const newElement = this.createHistoryCard(container, entry);
container.prepend(newElement);
this.historyEntryToElement.set(entry, newElement);
});
const newEntries = new Set(entries);
for (const [entry, element] of this.historyEntryToElement) {
if (!newEntries.has(entry)) {
element.remove();
this.historyEntryToElement.delete(entry);
}
}
if (entries.length === 0) {
container.empty();
container.createEl("p", {
text: "No update has happened yet."
});
}
}
private createHistoryCard(
container: HTMLElement,
entry: HistoryEntry
): HTMLElement {
return container.createDiv(
{
cls: ["history-card", entry.status.toLocaleLowerCase()]
},
(card) => {
if (
this.app.vault.getFileByPath(entry.details.relativePath) !=
null
) {
card.addEventListener("click", () => {
this.app.workspace
.openLinkText(
entry.details.relativePath,
entry.details.relativePath,
false
)
.catch((error: unknown) => {
this.client.logger.error(
`Failed to open link for ${entry.details.relativePath}: ${error}`
);
});
});
card.addClass("clickable");
}
card.createDiv(
{
cls: "history-card-header"
},
(header) => {
header.createEl(
"h5",
{
cls: "history-card-title"
},
(title) => {
HistoryView.renderSyncItemTitle(title, entry);
}
);
header.createSpan({
text: HistoryView.getTimestampAndAuthor(entry),
cls: "history-card-timestamp"
});
}
);
const body =
entry.details.type === SyncType.MOVE
? `${entry.message}. Moved from '${entry.details.movedFrom}' to '${entry.details.relativePath}'`
: `${entry.message}.`;
card.createEl("p", {
text: body,
cls: "history-card-message"
});
}
);
}
}

View file

@ -0,0 +1,60 @@
.logs-view {
display: flex;
flex-direction: column;
.verbosity-selector {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: normal;
gap: var(--size-4-2);
margin: var(--size-4-4) var(--size-4-2);
h4 {
margin: 0;
}
select {
cursor: pointer;
}
}
.logs-container {
max-width: 100%;
overflow-y: auto;
.log-message {
font: var(--font-monospace);
margin-bottom: var(--size-2-1);
overflow-wrap: break-word;
white-space: pre-wrap;
user-select: all;
.timestamp {
padding: var(--size-2-1) var(--size-4-1);
border-radius: var(--radius-s);
background-color: var(--color-base-30);
font-size: var(--font-ui-small);
font-family: var(--font-monospace);
font-weight: var(--bold-weight);
margin-right: var(--size-4-1);
}
&.DEBUG {
color: var(--color-base-50);
}
&.INFO {
color: var(--color-base-100);
}
&.WARNING {
color: rgb(var(--color-yellow-rgb));
}
&.ERROR {
color: rgb(var(--color-red-rgb));
}
}
}
}

View file

@ -0,0 +1,152 @@
import "./logs-view.scss";
import type { WorkspaceLeaf } from "obsidian";
import { ItemView } from "obsidian";
import type { LogLine } from "sync-client";
import { LogLevel, type SyncClient } from "sync-client";
export class LogsView extends ItemView {
public static readonly TYPE = "logs-view";
public static readonly ICON = "logs";
private static readonly MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX = 300;
private logsContainer: HTMLElement | undefined;
private readonly logLineToElement = new Map<LogLine, HTMLElement>();
private minLogLevel: LogLevel = LogLevel.INFO;
public constructor(
private readonly client: SyncClient,
leaf: WorkspaceLeaf
) {
super(leaf);
this.icon = LogsView.ICON;
this.client.logger.addOnMessageListener(() => {
this.updateView();
});
}
private static createLogLineElement(
container: HTMLElement,
logLine: LogLine
): HTMLElement {
return container.createDiv(
{
cls: ["log-message", logLine.level]
},
(messageContainer) => {
messageContainer.createEl("span", {
text: LogsView.formatTimestamp(logLine.timestamp),
cls: "timestamp"
});
messageContainer.createEl("span", {
text: logLine.message
});
}
);
}
private static formatTimestamp(timestamp: Date): string {
return timestamp.toTimeString().split(" ")[0];
}
public getViewType(): string {
return LogsView.TYPE;
}
public getDisplayText(): string {
return "VaultLink logs";
}
public async onOpen(): Promise<void> {
const container = this.containerEl.children[1];
container.addClass("logs-view");
const logLevels = [
{ label: "Debug", value: LogLevel.DEBUG },
{ label: "Info", value: LogLevel.INFO },
{ label: "Warn", value: LogLevel.WARNING },
{ label: "Error", value: LogLevel.ERROR }
];
container.createDiv(
{
cls: "verbosity-selector"
},
(verbositySection) => {
verbositySection.createEl("h4", {
text: "VaultLink logs"
});
verbositySection.createEl("select", {}, (dropdown) => {
logLevels.forEach(({ label, value }) =>
dropdown.createEl("option", { text: label, value })
);
dropdown.value = this.minLogLevel;
dropdown.addEventListener("change", () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
this.minLogLevel = dropdown.value as LogLevel;
this.logsContainer?.empty();
this.logLineToElement.clear();
this.updateView();
});
});
}
);
this.logsContainer = container.createDiv({ cls: "logs-container" });
this.updateView();
}
private updateView(): void {
const container = this.logsContainer;
if (container === undefined) {
return;
}
const logs = this.client.logger.getMessages(this.minLogLevel);
if (this.logLineToElement.size === 0 && logs.length > 0) {
// Clear the "No logs available yet" message
container.empty();
}
const shouldScroll =
container.scrollTop == 0 ||
container.scrollHeight -
container.clientHeight -
container.scrollTop <
LogsView.MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX;
logs.forEach((message) => {
if (this.logLineToElement.has(message)) {
return;
}
const element = LogsView.createLogLineElement(container, message);
this.logLineToElement.set(message, element);
});
const newLines = new Set(logs);
for (const [logLine, element] of this.logLineToElement) {
if (!newLines.has(logLine)) {
element.remove();
this.logLineToElement.delete(logLine);
}
}
if (logs.length === 0) {
container.empty();
container.createEl("p", {
text: "No logs available yet."
});
} else if (shouldScroll) {
container.scrollTop = container.scrollHeight;
}
}
}

View file

@ -0,0 +1,57 @@
@mixin number-card {
padding: var(--size-2-1) var(--size-4-1);
border-radius: var(--radius-s);
background-color: var(--color-base-30);
font-size: var(--font-ui-small);
&.good {
background-color: rgba(var(--color-green-rgb), 0.35);
}
&.bad {
background-color: rgba(var(--color-red-rgb), 0.35);
}
}
.vault-link-settings {
h2 {
display: flex;
align-items: center;
font-size: var(--h2-size);
.version {
@include number-card;
margin: var(--size-2-2) 0 0 var(--size-4-2);
background-color: var(--color-base-30);
color: var(--color-base-70);
font-size: var(--font-ui-smaller);
}
}
.button-container {
display: flex;
gap: var(--size-4-2);
}
h3 {
font-size: var(--font-ui-large);
margin-top: var(--heading-spacing);
}
button,
input[type="range"],
.checkbox-container,
.slider::-webkit-slider-thumb {
cursor: pointer;
}
input[type="text"],
textarea {
width: 250px;
}
textarea {
resize: none;
height: 75px;
}
}

View file

@ -0,0 +1,384 @@
import "./settings-tab.scss";
import type { App } from "obsidian";
import { Notice, PluginSettingTab, Setting } from "obsidian";
import type VaultLinkPlugin from "src/vault-link-plugin";
import type { SyncClient, SyncSettings } from "sync-client";
import { HistoryView } from "../history/history-view";
import { LogsView } from "../logs/logs-view";
import type { StatusDescription } from "../status-description/status-description";
export class SyncSettingsTab extends PluginSettingTab {
private editedServerUri: string;
private editedToken: string;
private editedVaultName: string;
private readonly plugin: VaultLinkPlugin;
private readonly syncClient: SyncClient;
private readonly statusDescription: StatusDescription;
private statusDescriptionSubscription: (() => unknown) | undefined;
public constructor({
app,
plugin,
syncClient,
statusDescription
}: {
app: App;
plugin: VaultLinkPlugin;
syncClient: SyncClient;
statusDescription: StatusDescription;
}) {
super(app, plugin);
this.plugin = plugin;
this.syncClient = syncClient;
this.statusDescription = statusDescription;
this.editedServerUri = this.syncClient.getSettings().remoteUri;
this.editedToken = this.syncClient.getSettings().token;
this.editedVaultName = this.syncClient.getSettings().vaultName;
this.syncClient.addOnSettingsChangeListener(
(newSettings, oldSettings) => {
let hasChanged = false;
if (newSettings.remoteUri !== oldSettings.remoteUri) {
this.editedServerUri = newSettings.remoteUri;
hasChanged = true;
}
if (newSettings.token !== oldSettings.token) {
this.editedToken = newSettings.token;
hasChanged = true;
}
if (newSettings.vaultName !== oldSettings.vaultName) {
this.editedVaultName = newSettings.vaultName;
hasChanged = true;
}
if (hasChanged) {
this.display();
}
}
);
}
public display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.addClass("vault-link-settings");
this.renderSettingsHeader(containerEl);
this.renderConnectionSettings(containerEl);
this.renderSyncSettings(containerEl);
}
public hide(): void {
super.hide();
this.setStatusDescriptionSubscription();
}
private renderSettingsHeader(containerEl: HTMLElement): void {
containerEl.createEl("h2", { text: "VaultLink" }).createSpan({
text: this.plugin.manifest.version,
cls: "version"
});
containerEl.createDiv(
{
cls: "description"
},
(descriptionContainer) => {
this.setStatusDescriptionSubscription(
this.statusDescription.renderStatusDescription.bind(
this.statusDescription,
descriptionContainer
)
);
}
);
containerEl.createDiv(
{
cls: "button-container"
},
(buttonContainer) => {
buttonContainer.createEl(
"button",
{
text: "Show history"
},
(button) =>
(button.onclick = async (): Promise<void> => {
this.plugin.closeSettings();
await this.plugin.activateView(HistoryView.TYPE);
})
);
buttonContainer.createEl(
"button",
{
text: "Show logs"
},
(button) =>
(button.onclick = async (): Promise<void> => {
this.plugin.closeSettings();
await this.plugin.activateView(LogsView.TYPE);
})
);
}
);
}
private renderConnectionSettings(containerEl: HTMLElement): void {
containerEl.createEl("h3", { text: "Connection" });
const [title, updateTitle] = this.unsavedAwareSettingName(
"Server address",
"remoteUri"
);
new Setting(containerEl)
.setName(title)
.setDesc(
"Your VaultLink server's URL including the protocol and full path."
)
.setTooltip("This is the URL of the server you want to sync with.")
.addText((text) =>
text
.setPlaceholder("https://example.com:3000")
.setValue(this.editedServerUri.toLowerCase().trim())
.onChange((value) => {
this.editedServerUri = value.toLowerCase().trim();
updateTitle(value.toLowerCase().trim());
})
);
const [tokenTitle, updateTokenTitle] = this.unsavedAwareSettingName(
"Access token",
"token"
);
new Setting(containerEl)
.setName(tokenTitle)
.setClass("sync-settings-access-token")
.setDesc(
"Set the access token for the server that you can get from the server"
)
.setTooltip("todo, links to dcocs")
.addTextArea((text) =>
text
.setPlaceholder("ey...")
.setValue(this.editedToken.trim())
.onChange((value) => {
this.editedToken = value.trim();
updateTokenTitle(value.trim());
})
);
const [vaultNameTitle, updateVaultNameTitle] =
this.unsavedAwareSettingName("Vault name", "vaultName");
new Setting(containerEl)
.setName(vaultNameTitle)
.setDesc(
"Set the name of the remote vault that you want to sync with"
)
.setTooltip("todo, links to dcocs")
.addText((text) =>
text
.setPlaceholder("My Obsidian Vault")
.setValue(this.editedVaultName.toLowerCase().trim())
.onChange((value) => {
this.editedVaultName = value.toLowerCase().trim();
updateVaultNameTitle(value.toLowerCase().trim());
})
);
new Setting(containerEl)
.addButton((button) =>
button.setButtonText("Apply").onClick(async () => {
if (this.areThereUnsavedChanges()) {
await this.syncClient.setSettings({
vaultName: this.editedVaultName,
remoteUri: this.editedServerUri,
token: this.editedToken
});
new Notice(
"The changes have been applied successfully!"
);
await this.statusDescription.updateConnectionState();
} else {
new Notice("No changes to apply");
}
})
)
.addButton((button) =>
button.setButtonText("Test connection").onClick(async () => {
if (this.areThereUnsavedChanges()) {
new Notice(
"There are unsaved changes, testing with the currently saved settings"
);
}
new Notice(
(await this.syncClient.checkConnection()).serverMessage
);
await this.statusDescription.updateConnectionState();
})
);
}
private areThereUnsavedChanges(): boolean {
return (
this.editedServerUri !== this.syncClient.getSettings().remoteUri ||
this.editedToken !== this.syncClient.getSettings().token ||
this.editedVaultName !== this.syncClient.getSettings().vaultName
);
}
private renderSyncSettings(containerEl: HTMLElement): void {
containerEl.createEl("h3", { text: "Sync" });
new Setting(containerEl)
.setName("Enable sync")
.setDesc(
"Enable pulling and pushing changes to the remote server. The first time it's enabled, or after the sync state has been reset, all local files will be pushed to the server."
)
.setTooltip(
"Enable pulling and pushing changes to the remote server."
)
.addToggle((toggle) =>
toggle
.setValue(this.syncClient.getSettings().isSyncEnabled)
.onChange(async (value) =>
this.syncClient.setSetting("isSyncEnabled", value)
)
);
new Setting(containerEl)
.setName("Ignore patterns")
.setDesc(
"Patterns to ignore when syncing. Each line is a separate glob pattern. Patterns are matched against the relative path of the file. For example, to ignore all files in a folder named 'ignore', enter 'ignore/*'. To ignore all files with the extension '.log', enter '*.log'."
)
.addTextArea((text) =>
text
.setValue(
this.syncClient.getSettings().ignorePatterns.join("\n")
)
.setPlaceholder("Enter patterns to ignore, one per line")
.onChange(async (value) => {
const patterns = value
.split("\n")
.map((pattern) => pattern.trim())
.filter((pattern) => pattern.length > 0);
return this.syncClient.setSetting(
"ignorePatterns",
patterns
);
})
);
new Setting(containerEl)
.setName("Sync concurrency")
.setDesc(
"How many concurrent sync operations to run. Setting this value higher may increase the overall performance, however, it will require more memory as well. If you notice frequent crashes, especially on mobile, set this to 1."
)
.addSlider((text) =>
text
.setLimits(1, 16, 1)
.setDynamicTooltip()
.setInstant(false)
.setValue(this.syncClient.getSettings().syncConcurrency)
.onChange(async (value) =>
this.syncClient.setSetting("syncConcurrency", value)
)
);
new Setting(containerEl)
.setName("Maximum file size to be uploaded (MB)")
.setDesc(
"Set the maximum file size that can be uploaded to the server. Files larger than this size will be ignored."
)
.addText((input) =>
input
.setValue(
this.syncClient.getSettings().maxFileSizeMB.toString()
)
.onChange(async (value) => {
if (value === "") {
return;
}
let parsedValue = Number.parseFloat(value);
if (Number.isNaN(parsedValue) || parsedValue < 0) {
parsedValue =
this.syncClient.getSettings().maxFileSizeMB;
}
if (value !== parsedValue.toString()) {
input.setValue(parsedValue.toString());
}
return this.syncClient.setSetting(
"maxFileSizeMB",
parsedValue
);
})
);
new Setting(containerEl)
.setName("Danger zone")
.setDesc(
"Delete the local metadata database while leaving the local and remote files intact."
)
.addButton((button) =>
button.setButtonText("Reset sync state").onClick(async () => {
await this.syncClient.reset();
new Notice(
"Sync state has been reset, you will need to resync"
);
})
);
}
private setStatusDescriptionSubscription(
newSubscription?: () => unknown
): void {
if (this.statusDescriptionSubscription) {
this.statusDescription.removeStatusChangeListener(
this.statusDescriptionSubscription
);
}
this.statusDescriptionSubscription = newSubscription;
if (this.statusDescriptionSubscription) {
this.statusDescriptionSubscription();
this.statusDescription.addStatusChangeListener(
this.statusDescriptionSubscription
);
}
}
private unsavedAwareSettingName(
name: string,
settingName: keyof SyncSettings
): [
DocumentFragment,
(newValue: SyncSettings[keyof SyncSettings]) => unknown
] {
const titleContainer = document.createDocumentFragment();
const title = titleContainer.createEl("div", {
text: name,
cls: "setting-item-name"
});
const updateTitle = (
currentValue: SyncSettings[keyof SyncSettings]
): void => {
title.innerText = `${name}${
currentValue !== this.syncClient.getSettings()[settingName]
? " (unsaved)"
: ""
}`;
};
return [titleContainer, updateTitle];
}
}

View file

@ -0,0 +1,14 @@
.sync-status {
display: flex;
gap: var(--size-4-2);
* {
display: block;
}
.initialize-button {
padding: 0 var(--size-4-2);
background: rgba(var(--color-red-rgb), 0.4);
cursor: pointer;
}
}

View file

@ -0,0 +1,75 @@
import "./status-bar.scss";
import type { HistoryStats, SyncClient } from "sync-client";
import type VaultLinkPlugin from "../../vault-link-plugin";
export class StatusBar {
private readonly statusBarItem: HTMLElement;
private lastHistoryStats: HistoryStats | undefined;
private lastRemaining: number | undefined;
public constructor(
private readonly plugin: VaultLinkPlugin,
private readonly syncClient: SyncClient
) {
this.statusBarItem = plugin.addStatusBarItem();
this.syncClient.addSyncHistoryUpdateListener((status) => {
this.lastHistoryStats = status;
this.updateStatus();
});
this.syncClient.addRemainingSyncOperationsListener(
(remainingOperations) => {
this.lastRemaining = remainingOperations;
this.updateStatus();
}
);
this.syncClient.addOnSettingsChangeListener(() => {
this.updateStatus();
});
}
private updateStatus(): void {
this.statusBarItem.empty();
const container = this.statusBarItem.createDiv({
cls: ["sync-status"]
});
if (!this.syncClient.getSettings().isSyncEnabled) {
const button = container.createEl("button", {
text: "VaultLink is disabled, click to configure",
cls: "initialize-button"
});
button.onclick = this.plugin.openSettings.bind(this.plugin);
return;
}
let hasShownMessage = false;
if ((this.lastRemaining ?? 0) > 0) {
hasShownMessage = true;
container.createSpan({ text: `${this.lastRemaining}` });
}
if ((this.lastHistoryStats?.success ?? 0) > 0) {
hasShownMessage = true;
container.createSpan({
text: `${this.lastHistoryStats?.success ?? 0}`
});
}
if ((this.lastHistoryStats?.error ?? 0) > 0) {
hasShownMessage = true;
container.createSpan({
text: `${this.lastHistoryStats?.error ?? 0}`
});
}
if (!hasShownMessage) {
container.createSpan({ text: "VaultLink is idle" });
}
}
}

View file

@ -0,0 +1,32 @@
@mixin number-card {
padding: var(--size-2-1) var(--size-4-1);
border-radius: var(--radius-s);
background-color: var(--color-base-30);
font-size: var(--font-ui-small);
&.good {
background-color: rgba(var(--color-green-rgb), 0.35);
}
&.bad {
background-color: rgba(var(--color-red-rgb), 0.35);
}
}
.status-description {
margin: var(--p-spacing) 0;
.number {
@include number-card;
font-family: var(--font-monospace);
font-weight: var(--bold-weight);
}
.error {
color: rgb(var(--color-red-rgb));
}
.warning {
color: rgb(var(--color-yellow-rgb));
}
}

View file

@ -0,0 +1,148 @@
import "./status-description.scss";
import type {
HistoryStats,
NetworkConnectionStatus,
SyncClient
} from "sync-client";
export class StatusDescription {
private lastHistoryStats: HistoryStats | undefined;
private lastRemaining: number | undefined;
private lastConnectionState: NetworkConnectionStatus | undefined;
private statusChangeListeners: (() => unknown)[] = [];
public constructor(private readonly syncClient: SyncClient) {
void this.updateConnectionState();
syncClient.addSyncHistoryUpdateListener((status) => {
this.lastHistoryStats = status;
this.updateDescription();
});
this.syncClient.addRemainingSyncOperationsListener(
(remainingOperations) => {
this.lastRemaining = remainingOperations;
this.updateDescription();
}
);
this.syncClient.addWebSocketStatusChangeListener(async () =>
this.updateConnectionState()
);
this.syncClient.addOnSettingsChangeListener(async () =>
this.updateConnectionState()
);
}
public async updateConnectionState(): Promise<void> {
this.lastConnectionState = await this.syncClient.checkConnection();
this.updateDescription();
}
public addStatusChangeListener(listener: () => unknown): void {
this.statusChangeListeners.push(listener);
}
public removeStatusChangeListener(listener: () => unknown): void {
this.statusChangeListeners = this.statusChangeListeners.filter(
(l) => l !== listener
);
}
public renderStatusDescription(container: HTMLElement): void {
container.empty();
container.addClass("status-description");
if (this.lastConnectionState == undefined) {
container.createSpan({
text: "VaultLink is starting up…",
cls: "warning"
});
return;
}
if (!this.lastConnectionState.isSuccessful) {
container.createSpan({
text: `VaultLink failed to connect to the remote server with error '${this.lastConnectionState.serverMessage}'`,
cls: "error"
});
return;
}
if (!this.lastConnectionState.isWebSocketConnected) {
container.createSpan({
text: `${this.lastConnectionState.serverMessage} but the WebSocket connection could not be established.`,
cls: "error"
});
return;
}
container.createSpan({ text: "VaultLink is connected to the server " });
container.createEl("a", {
text: this.syncClient.getSettings().remoteUri,
href: this.syncClient.getSettings().remoteUri
});
container.createSpan({
text: ` and has indexed approximately `
});
container.createSpan({
text: `${this.syncClient.documentCount}`,
cls: "number"
});
container.createSpan({
text: ` documents. `
});
if (
(this.lastRemaining ?? 0) === 0 &&
(this.lastHistoryStats?.success ?? 0) === 0 &&
(this.lastHistoryStats?.error ?? 0) === 0
) {
if (this.syncClient.getSettings().isSyncEnabled) {
container.createSpan({
text: "Syncing is enabled but VaultLink hasn't found anything to sync yet."
});
} else {
container.createSpan({
text: "However, syncing is disabled right now.",
cls: "warning"
});
}
return;
}
container.createSpan({
text: "The plugin has "
});
container.createSpan({
text: `${this.lastRemaining ?? 0}`,
cls: "number"
});
container.createSpan({
text: " outstanding operations while having succeeded "
});
container.createSpan({
text: `${this.lastHistoryStats?.success ?? 0}`,
cls: ["number", "good"]
});
container.createSpan({
text: " times and failed "
});
container.createSpan({
text: `${this.lastHistoryStats?.error ?? 0}`,
cls: ["number", "bad"]
});
container.createSpan({
text: " times."
});
}
private updateDescription(): void {
this.statusChangeListeners.forEach((listener) => {
listener();
});
}
}

View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"baseUrl": ".",
"module": "ESNext",
"target": "ES2023",
"strict": true,
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"lib": [
"DOM",
"ES2024"
]
},
"exclude": [
"./dist"
]
}

View file

@ -0,0 +1,7 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));

View file

@ -0,0 +1,117 @@
const path = require("path");
const TerserPlugin = require("terser-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const fs = require("fs-extra");
module.exports = (env, argv) => ({
devtool: argv.mode === "development" ? "inline-source-map" : false,
entry: {
index: "./src/vault-link-plugin.ts"
},
watchOptions: {
ignored: "**/node_modules"
},
externals: {
obsidian: "commonjs obsidian",
electron: "commonjs electron",
"@codemirror/autocomplete": "commonjs @codemirror/autocomplete",
"@codemirror/collab": "commonjs @codemirror/collab",
"@codemirror/commands": "commonjs @codemirror/commands",
"@codemirror/language": "commonjs @codemirror/language",
"@codemirror/lint": "commonjs @codemirror/lint",
"@codemirror/search": "commonjs @codemirror/search",
"@codemirror/state": "commonjs @codemirror/state",
"@codemirror/view": "commonjs @codemirror/view"
},
optimization: {
minimizer: [
new TerserPlugin({
terserOptions: {
module: true
}
})
]
},
plugins: [
new MiniCssExtractPlugin({
filename: "styles.css"
}),
{
apply: (compiler) => {
if (argv.mode !== "development") {
return;
}
compiler.hooks.done.tap("Copy Files Plugin", (stats) => {
const source = path.resolve(__dirname, "dist");
const destinations = [
"/mnt/c/Users/Andras/Desktop/test/test/.obsidian/plugins/vault-link",
"/mnt/c/Users/Andras/Desktop/test/test2/.obsidian/plugins/vault-link",
"/home/andras/obsidian-test/.obsidian/plugins/vault-link"
];
destinations.forEach((destination) => {
fs.copy(source, destination)
.then(() =>
console.log(
"Files copied successfully after build!"
)
)
.catch((err) =>
console.error("Error copying files:", err)
);
fs.createFile(path.join(destination, ".hotreload"));
});
});
}
}
],
module: {
rules: [
{
test: /\.json$/i,
type: "asset/resource",
generator: {
filename: "[name][ext]"
}
},
{
test: /\.scss$/i,
use: [
MiniCssExtractPlugin.loader,
"css-loader",
"resolve-url-loader",
{
loader: "sass-loader",
options: {
sourceMap: true // required by resolve-url-loader
}
}
]
},
{
test: /\.ts$/,
use: ["ts-loader"]
}
]
},
resolve: {
extensions: [
".ts",
".js" // required for development
],
alias: {
root: __dirname,
src: path.resolve(__dirname, "src")
}
},
output: {
clean: true,
filename: "main.js",
library: {
type: "commonjs" // required for Obsidian
},
path: path.resolve(__dirname, "dist"),
publicPath: ""
}
});

4724
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

30
frontend/package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "my-workspace",
"private": true,
"workspaces": [
"sync-client",
"obsidian-plugin",
"test-client"
],
"prettier": {
"trailingComma": "none",
"tabWidth": 4,
"useTabs": true,
"endOfLine": "lf"
},
"scripts": {
"build": "npm run build --workspaces",
"dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"",
"test": "npm run test --workspaces",
"lint": "eslint --fix sync-client obsidian-plugin test-client && prettier --write \"**/*.ts\"",
"update": "ncu -u -ws"
},
"devDependencies": {
"concurrently": "^9.1.2",
"eslint": "9.28.0",
"eslint-plugin-unused-imports": "^4.1.4",
"npm-check-updates": "^18.0.1",
"prettier": "^3.6.2",
"typescript-eslint": "8.41.0"
}
}

View file

@ -0,0 +1,33 @@
{
"name": "sync-client",
"version": "0.8.0",
"main": "dist/sync-client.node.js",
"browser": "dist/sync-client.web.js",
"types": "dist/types/index.d.ts",
"files": [
"dist/**/*"
],
"scripts": {
"dev": "webpack watch --mode development",
"build": "webpack --mode production",
"test": "tsx --test src/**/*.test.ts"
},
"dependencies": {
"byte-base64": "^1.1.0",
"minimatch": "^10.0.1",
"p-queue": "^8.1.0",
"reconcile-text": "^0.5.0",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/node": "^22.15.30",
"ts-loader": "^9.5.2",
"tslib": "2.8.1",
"tsx": "^4.20.5",
"typescript": "5.8.3",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1",
"webpack-merge": "^6.0.1",
"ws": "^8.18.3"
}
}

View file

@ -0,0 +1,24 @@
import type { SyncClient } from "../sync-client";
import type { LogLine } from "../tracing/logger";
import { LogLevel } from "../tracing/logger";
export function logToConsole(client: SyncClient): void {
client.logger.addOnMessageListener((logLine: LogLine) => {
const formatted = `${logLine.timestamp.toISOString()} ${logLine.level} ${logLine.message}`;
switch (logLine.level) {
case LogLevel.ERROR:
console.error(formatted);
break;
case LogLevel.WARNING:
console.warn(formatted);
break;
case LogLevel.INFO:
console.info(formatted);
break;
case LogLevel.DEBUG:
console.debug(formatted);
break;
}
});
}

View file

@ -0,0 +1,16 @@
import { sleep } from "../utils/sleep";
export const slowFetchFactory =
(jitterScaleInSeconds: number) =>
async (
input: string | URL | globalThis.Request,
init?: RequestInit
): Promise<Response> => {
if (jitterScaleInSeconds > 0) {
await sleep(Math.random() * jitterScaleInSeconds * 1000);
}
const response = await fetch(input, init);
return response;
};

View file

@ -0,0 +1,81 @@
import { sleep } from "../utils/sleep";
import { Locks } from "../utils/locks";
import type { Logger } from "../tracing/logger";
export function slowWebSocketFactory(
jitterScaleInSeconds: number,
logger: Logger
): typeof WebSocket {
// eslint-disable-next-line
return class FlakyWebSocket extends WebSocket {
private static readonly RECEIVE_KEY = "websocket-receive";
private static readonly SEND_KEY = "websocket-send";
private readonly locks = new Locks(logger);
public set onopen(callback: (event: Event) => void) {
super.onopen = async (event: Event): Promise<void> => {
if (jitterScaleInSeconds > 0) {
await sleep(Math.random() * jitterScaleInSeconds * 1000);
}
callback(event);
};
}
public set onmessage(callback: (event: MessageEvent) => void) {
super.onmessage = async (event: MessageEvent): Promise<void> => {
await this.locks.withLock(
FlakyWebSocket.RECEIVE_KEY,
async () => {
if (jitterScaleInSeconds > 0) {
await sleep(
Math.random() * jitterScaleInSeconds * 1000
);
}
callback(event);
}
);
};
}
public set onclose(callback: (event: CloseEvent) => void) {
super.onclose = async (event: CloseEvent): Promise<void> => {
if (jitterScaleInSeconds > 0) {
await sleep(Math.random() * jitterScaleInSeconds * 1000);
}
callback(event);
};
}
public set onerror(callback: (event: Event) => void) {
super.onerror = async (event: Event): Promise<void> => {
if (jitterScaleInSeconds > 0) {
await sleep(Math.random() * jitterScaleInSeconds * 1000);
}
callback(event);
};
}
public send(
data: string | ArrayBufferLike | Blob | ArrayBufferView
): void {
this.waitingSend(data).catch((error: unknown) => {
logger.error(`Error sending WebSocket message: ${error}`);
});
}
private async waitingSend(
data: string | ArrayBufferLike | Blob | ArrayBufferView
): Promise<void> {
// maintain message order
await this.locks.withLock(FlakyWebSocket.SEND_KEY, async () => {
if (jitterScaleInSeconds > 0) {
await sleep(Math.random() * jitterScaleInSeconds * 1000);
}
super.send(data);
});
}
} as unknown as typeof WebSocket;
}

View file

@ -0,0 +1,6 @@
export class FileNotFoundError extends Error {
public constructor(message: string) {
super(message);
this.name = "FileNotFoundError";
}
}

View file

@ -0,0 +1,160 @@
import { describe, it } from "node:test";
import type {
Database,
DocumentRecord,
RelativePath
} from "../persistence/database";
import { FileOperations } from "./file-operations";
import { Logger } from "../tracing/logger";
import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly";
import type { FileSystemOperations } from "./filesystem-operations";
import type { TextWithCursors } from "reconcile-text";
class MockDatabase implements Partial<Database> {
public getLatestDocumentByRelativePath(
_find: RelativePath
): DocumentRecord | undefined {
// no-op
return undefined;
}
public move(
_oldRelativePath: RelativePath,
_newRelativePath: RelativePath
): void {
// no-op
}
}
class FakeFileSystemOperations implements FileSystemOperations {
public readonly names = new Set<string>();
public async listAllFiles(): Promise<RelativePath[]> {
throw new Error("Method not implemented.");
}
public async read(_path: RelativePath): Promise<Uint8Array> {
throw new Error("Method not implemented.");
}
public async write(
path: RelativePath,
_content: Uint8Array
): Promise<void> {
this.names.add(path);
}
public async atomicUpdateText(
_path: RelativePath,
_updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> {
throw new Error("Method not implemented.");
}
public async getFileSize(_path: RelativePath): Promise<number> {
throw new Error("Method not implemented.");
}
public async getModificationTime(_path: RelativePath): Promise<Date> {
throw new Error("Method not implemented.");
}
public async exists(path: RelativePath): Promise<boolean> {
return this.names.has(path);
}
public async createDirectory(_path: RelativePath): Promise<void> {
// this is called but irrelevant for this mock
}
public async delete(_path: RelativePath): Promise<void> {
throw new Error("Method not implemented.");
}
public async rename(
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
this.names.delete(oldPath);
this.names.add(newPath);
}
}
describe("File operations", () => {
it("should deconflict renames", async () => {
const fileSystemOperations = new FakeFileSystemOperations();
const fileOperations = new FileOperations(
new Logger(),
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
fileSystemOperations
);
await fileOperations.create("a", new Uint8Array());
assertSetContainsExactly(fileSystemOperations.names, "a");
await fileOperations.move("a", "b");
assertSetContainsExactly(fileSystemOperations.names, "b");
await fileOperations.create("c", new Uint8Array());
assertSetContainsExactly(fileSystemOperations.names, "b", "c");
await fileOperations.move("c", "b");
assertSetContainsExactly(fileSystemOperations.names, "b", "b (1)");
await fileOperations.create("c", new Uint8Array());
await fileOperations.move("c", "b");
assertSetContainsExactly(
fileSystemOperations.names,
"b",
"b (1)",
"b (2)"
);
});
it("should deconflict renames with file extension", async () => {
const fileSystemOperations = new FakeFileSystemOperations();
const fileOperations = new FileOperations(
new Logger(),
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
fileSystemOperations
);
await fileOperations.create("b.md", new Uint8Array());
await fileOperations.create("c.md", new Uint8Array());
await fileOperations.move("c.md", "b.md");
assertSetContainsExactly(
fileSystemOperations.names,
"b.md",
"b (1).md"
);
await fileOperations.create("d.md", new Uint8Array());
await fileOperations.move("d.md", "b.md");
assertSetContainsExactly(
fileSystemOperations.names,
"b.md",
"b (1).md",
"b (2).md"
);
await fileOperations.create("file-23.md", new Uint8Array());
await fileOperations.create("file-23 (1).md", new Uint8Array());
await fileOperations.move("file-23.md", "file-23 (1).md");
assertSetContainsExactly(
fileSystemOperations.names,
"b.md",
"b (1).md",
"b (2).md",
"file-23 (1).md",
"file-23 (2).md"
);
});
it("should deconflict renames with paths", async () => {
const fileSystemOperations = new FakeFileSystemOperations();
const fileOperations = new FileOperations(
new Logger(),
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
fileSystemOperations
);
await fileOperations.create("a/b.c/d", new Uint8Array());
await fileOperations.create("a/b.c/e", new Uint8Array());
await fileOperations.move("a/b.c/d", "a/b.c/e");
assertSetContainsExactly(
fileSystemOperations.names,
"a/b.c/e",
"a/b.c/e (1)"
);
});
});

View file

@ -0,0 +1,215 @@
import type { Logger } from "../tracing/logger";
import type { FileSystemOperations } from "./filesystem-operations";
import type { Database, RelativePath } from "../persistence/database";
import { SafeFileSystemOperations } from "./safe-filesystem-operations";
import type { TextWithCursors } from "reconcile-text";
import { isBinary, reconcile } from "reconcile-text";
import { isFileTypeMergable } from "../utils/is-file-type-mergable";
export class FileOperations {
private static readonly PARENTHESES_REGEX = / \((\d+)\)$/;
private readonly fs: SafeFileSystemOperations;
public constructor(
private readonly logger: Logger,
private readonly database: Database,
fs: FileSystemOperations,
private readonly nativeLineEndings = "\n"
) {
this.fs = new SafeFileSystemOperations(fs, logger);
}
public async listAllFiles(): Promise<RelativePath[]> {
return this.fs.listAllFiles();
}
public async read(path: RelativePath): Promise<Uint8Array> {
return this.fromNativeLineEndings(await this.fs.read(path));
}
/**
* Create a file at the specified path.
*
* If a file with the same name already exists, it is moved before creating the new one.
* Parent directories are created if necessary.
*/
public async create(
path: RelativePath,
newContent: Uint8Array
): Promise<void> {
await this.ensureClearPath(path);
return this.fs.write(path, this.toNativeLineEndings(newContent));
}
public async ensureClearPath(path: RelativePath): Promise<void> {
if (await this.fs.exists(path)) {
const deconflictedPath = await this.deconflictPath(path);
this.logger.debug(
`Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'`
);
this.database.move(path, deconflictedPath);
await this.fs.rename(path, deconflictedPath);
} else {
await this.createParentDirectories(path);
}
}
/**
* Update the file at the given path.
*
* Performs a 3-way merge before writing if the file's content differs from `expectedContent`.
* Does not recreate the file if it no longer exists, returning an empty array instead.
*/
public async write(
path: RelativePath,
expectedContent: Uint8Array,
newContent: Uint8Array
): Promise<void> {
if (!(await this.fs.exists(path))) {
this.logger.debug(
`The caller assumed ${path} exists, but it no longer, so we wont recreate it`
);
return;
}
if (
!isFileTypeMergable(path) ||
isBinary(expectedContent) ||
isBinary(newContent)
) {
this.logger.debug(
`The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it`
);
await this.fs.write(
path,
// `newContent` might not be binary so we still have to ensure the line endings are correct
this.toNativeLineEndings(newContent)
);
return;
}
const expectedText = new TextDecoder().decode(expectedContent); // this comes from a previous read which must only have \n line endings
const newText = new TextDecoder().decode(newContent); // this comes from the server which stores text with \n line endings
await this.fs.atomicUpdateText(
path,
({ text, cursors }: TextWithCursors): TextWithCursors => {
this.logger.debug(
`Performing a 3-way merge for ${path} with the expected content`
);
text = text.replace(this.nativeLineEndings, "\n");
const merged = reconcile(
expectedText,
{ text, cursors },
newText
);
const resultText = merged.text.replace(
"\n",
this.nativeLineEndings
);
return {
text: resultText,
cursors: merged.cursors
};
}
);
}
public async delete(path: RelativePath): Promise<void> {
if (await this.exists(path)) {
return this.fs.delete(path);
} else {
this.logger.debug(`No need to delete '${path}', it doesn't exist`);
}
}
public async getFileSize(path: RelativePath): Promise<number> {
return this.fs.getFileSize(path);
}
public async exists(path: RelativePath): Promise<boolean> {
return this.fs.exists(path);
}
public async move(
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
if (oldPath === newPath) {
return;
}
await this.ensureClearPath(newPath);
this.database.move(oldPath, newPath);
await this.fs.rename(oldPath, newPath);
}
private fromNativeLineEndings(content: Uint8Array): Uint8Array {
if (isBinary(content)) {
return content;
}
const decoder = new TextDecoder("utf-8");
let text = decoder.decode(content);
text = text.replace(this.nativeLineEndings, "\n");
return new TextEncoder().encode(text);
}
private toNativeLineEndings(content: Uint8Array): Uint8Array {
if (isBinary(content)) {
return content;
}
const decoder = new TextDecoder("utf-8");
let text = decoder.decode(content);
text = text.replace("\n", this.nativeLineEndings);
return new TextEncoder().encode(text);
}
private async createParentDirectories(path: string): Promise<void> {
const components = path.split("/");
if (components.length === 1) {
return;
}
for (let i = 1; i < components.length; i++) {
const parentDir = components.slice(0, i).join("/");
if (!(await this.fs.exists(parentDir))) {
await this.fs.createDirectory(parentDir);
}
}
}
private async deconflictPath(path: RelativePath): Promise<RelativePath> {
const pathParts = path.split("/");
const fileName = pathParts.pop();
if (fileName == "" || fileName == null) {
throw new Error(`Path '${path}' cannot be empty`);
}
let directory = pathParts.join("/");
if (directory) {
directory += "/";
}
const nameParts = fileName.split(".");
const extension =
nameParts.length > 1 ? "." + nameParts[nameParts.length - 1] : "";
let stem = extension ? nameParts.slice(0, -1).join(".") : fileName;
let currentCount = Number.parseInt(
FileOperations.PARENTHESES_REGEX.exec(stem)?.groups?.[0] ?? "0"
);
stem = stem.replace(FileOperations.PARENTHESES_REGEX, "");
let newName = path;
do {
currentCount++;
newName = `${directory}${stem} (${currentCount})${extension}`;
} while (await this.fs.exists(newName));
return newName;
}
}

View file

@ -0,0 +1,35 @@
import type { RelativePath } from "../persistence/database";
import type { TextWithCursors } from "reconcile-text";
export interface FileSystemOperations {
// List all files that should be synced.
listAllFiles: () => Promise<RelativePath[]>;
// Read the content of a file.
read: (path: RelativePath) => Promise<Uint8Array>;
// Create or overwrite a file with the given content.
write: (path: RelativePath, content: Uint8Array) => Promise<void>;
// Atomically update the content of a text file.
atomicUpdateText: (
path: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors
) => Promise<string>;
// Get the size of a file in bytes.
getFileSize: (path: RelativePath) => Promise<number>;
// Check if a file exists.
exists: (path: RelativePath) => Promise<boolean>;
// Create a directory at the specified path. All parent directories must already exist.
createDirectory: (path: RelativePath) => Promise<void>;
// Delete a file. It is expected that the path points to an existing file.
delete: (path: RelativePath) => Promise<void>;
// Rename a file. It is expected that the oldPath points to an existing file and the newPath does not exist.
rename: (oldPath: RelativePath, newPath: RelativePath) => Promise<void>;
}

View file

@ -0,0 +1,137 @@
import type { RelativePath } from "../persistence/database";
import type { FileSystemOperations } from "./filesystem-operations";
import type { Logger } from "../tracing/logger";
import { Locks } from "../utils/locks";
import { FileNotFoundError } from "./file-not-found-error";
import type { TextWithCursors } from "reconcile-text";
/**
* Decorates `FileSystemOperations` to replace errors with `FileNotFoundError`
* if the accessed file doesn't exist. It also ensures that there's at most a
* single request in-flight for any one file through the use of locks.
*/
export class SafeFileSystemOperations implements FileSystemOperations {
private readonly locks: Locks<RelativePath>;
public constructor(
private readonly fs: FileSystemOperations,
private readonly logger: Logger
) {
this.locks = new Locks(logger);
}
public async listAllFiles(): Promise<RelativePath[]> {
this.logger.debug("Listing all files");
const result = await this.fs.listAllFiles();
this.logger.debug(`Listed ${result.length} files`);
return result;
}
public async read(path: RelativePath): Promise<Uint8Array> {
this.logger.debug(`Reading file '${path}'`);
return this.safeOperation(
path,
async () =>
this.locks.withLock(path, async () => this.fs.read(path)),
"read"
);
}
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
this.logger.debug(`Writing to file '${path}'`);
return this.locks.withLock(path, async () =>
this.fs.write(path, content)
);
}
public async atomicUpdateText(
path: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> {
this.logger.debug(`Atomically updating file '${path}'`);
return this.safeOperation(
path,
async () =>
this.locks.withLock(path, async () =>
this.fs.atomicUpdateText(path, updater)
),
"atomicUpdateText"
);
}
public async getFileSize(path: RelativePath): Promise<number> {
// Logging this would be too noisy
return this.safeOperation(
path,
async () =>
this.locks.withLock(path, async () =>
this.fs.getFileSize(path)
),
"getFileSize"
);
}
public async exists(path: RelativePath): Promise<boolean> {
this.logger.debug(`Checking if file '${path}' exists`);
return this.locks.withLock(path, async () => this.fs.exists(path));
}
public async createDirectory(path: RelativePath): Promise<void> {
this.logger.debug(`Creating directory '${path}'`);
return this.locks.withLock(path, async () =>
this.fs.createDirectory(path)
);
}
public async delete(path: RelativePath): Promise<void> {
this.logger.debug(`Deleting file '${path}'`);
return this.locks.withLock(path, async () => this.fs.delete(path));
}
public async rename(
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
this.logger.debug(`Renaming file '${oldPath}' to '${newPath}'`);
return this.safeOperation(
oldPath,
async () =>
this.locks.withLock([oldPath, newPath], async () =>
this.fs.rename(oldPath, newPath)
),
"rename"
);
}
/**
* Decorate an operation to ensure that the file exists before running it.
* If the operation fails, it will check if the file still exists and throw
* a FileNotFoundError if it doesn't.
*/
private async safeOperation<T>(
path: RelativePath,
operation: () => Promise<T>,
operationName: string
): Promise<T> {
if (!(await this.fs.exists(path))) {
throw new FileNotFoundError(
`File '${path}' not found before trying to ${operationName}`
);
}
try {
return await operation();
} catch (error) {
// Without locking the file, this isn't atomic, however, it's good enough in practice.
// This will only break if the file exists, gets deleted and then immediately
// recreated while `operation` is running.
if (await this.fs.exists(path)) {
throw error;
} else {
throw new FileNotFoundError(
`File '${path}' not found when trying to ${operationName}`
);
}
}
}
}

View file

@ -0,0 +1,42 @@
import { logToConsole } from "./debugging/log-to-console";
import { slowFetchFactory } from "./debugging/slow-fetch-factory";
import { slowWebSocketFactory } from "./debugging/slow-web-socket-factory";
import { getRandomColor } from "./utils/get-random-color";
import { lineAndColumnToPosition } from "./utils/line-and-column-to-position";
import { positionToLineAndColumn } from "./utils/position-to-line-and-column";
export {
SyncType,
SyncStatus,
type HistoryStats,
type HistoryEntry,
type SyncDetails,
type SyncCreateDetails,
type SyncUpdateDetails,
type SyncMovedDetails,
type SyncDeleteDetails
} from "./tracing/sync-history";
export { Logger, LogLevel, LogLine } from "./tracing/logger";
export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings";
export { rateLimit } from "./utils/rate-limit";
export type { RelativePath, StoredDatabase } from "./persistence/database";
export type { FileSystemOperations } from "./file-operations/filesystem-operations";
export type { PersistenceProvider } from "./persistence/persistence";
export type { CursorSpan } from "./services/types/CursorSpan";
export type { ClientCursors } from "./services/types/ClientCursors";
export type { NetworkConnectionStatus } from "./types/network-connection-status";
export type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors";
export { DocumentSyncStatus } from "./types/document-sync-status";
export { SyncClient } from "./sync-client";
export const debugging = {
slowFetchFactory,
slowWebSocketFactory,
logToConsole
};
export const utils = {
getRandomColor,
positionToLineAndColumn,
lineAndColumnToPosition
};

View file

@ -0,0 +1,360 @@
import type { Logger } from "../tracing/logger";
import { EMPTY_HASH } from "../utils/hash";
import { CoveredValues } from "../utils/min-covered";
export type VaultUpdateId = number;
export type DocumentId = string;
export type RelativePath = string;
export interface DocumentMetadata {
parentVersionId: VaultUpdateId;
hash: string;
remoteRelativePath?: RelativePath;
}
export interface StoredDocumentMetadata {
relativePath: RelativePath;
documentId: DocumentId;
parentVersionId: VaultUpdateId;
remoteRelativePath?: RelativePath;
hash: string;
}
export interface StoredDatabase {
documents: StoredDocumentMetadata[];
lastSeenUpdateId: VaultUpdateId | undefined;
hasInitialSyncCompleted: boolean;
}
/**
* Represents a document in the database.
*
* It is mutable and its content should always represent the latest
* state of the document on disk based on the update events we have seen.
*/
export interface DocumentRecord {
relativePath: RelativePath;
documentId: DocumentId;
metadata: DocumentMetadata | undefined;
isDeleted: boolean;
updates: Promise<unknown>[];
parallelVersion: number;
}
export class Database {
private documents: DocumentRecord[];
private lastSeenUpdateIds: CoveredValues;
private hasInitialSyncCompleted: boolean;
public constructor(
private readonly logger: Logger,
initialState: Partial<StoredDatabase> | undefined,
private readonly saveData: (data: StoredDatabase) => Promise<void>
) {
initialState ??= {};
this.documents =
initialState.documents?.map(
({ relativePath, documentId, ...metadata }) => ({
relativePath,
documentId,
metadata,
isDeleted: false,
updates: [],
parallelVersion: 0
})
) ?? [];
this.ensureConsistency();
this.logger.debug(`Loaded ${this.documents.length} documents`);
const { lastSeenUpdateId } = initialState;
this.logger.debug(`Loaded last seen update id: ${lastSeenUpdateId}`);
this.lastSeenUpdateIds = new CoveredValues(
Math.max(0, lastSeenUpdateId ?? 0) // the first updateId will be 1 which is the first integer after -1
);
this.hasInitialSyncCompleted =
initialState.hasInitialSyncCompleted ?? false;
this.logger.debug(
`Loaded hasInitialSyncCompleted: ${this.hasInitialSyncCompleted}`
);
}
public get length(): number {
return this.documents.length;
}
public get resolvedDocuments(): DocumentRecord[] {
const paths = new Map<string, DocumentRecord[]>();
this.documents
.filter(({ metadata }) => metadata !== undefined)
.forEach((record) =>
paths.set(record.relativePath, [
record,
...(paths.get(record.relativePath) ?? [])
])
);
return Array.from(paths.values()).map((records) => {
records.sort(
(a, b) => b.parallelVersion - a.parallelVersion // descending
);
if (
records.length > 1 &&
records.some((current, i) =>
i === 0
? false
: records[i - 1].parallelVersion ===
current.parallelVersion
)
) {
throw new Error(
`Multiple documents with the same parallel version and path at ${records[0].relativePath}`
);
}
return records[0];
});
}
public updateDocumentMetadata(
metadata: {
parentVersionId: VaultUpdateId;
hash: string;
remoteRelativePath: RelativePath;
},
toUpdate: DocumentRecord
): void {
if (!this.documents.includes(toUpdate)) {
throw new Error("Document not found in database");
}
toUpdate.metadata = metadata;
this.save();
}
public removeDocumentPromise(promise: Promise<unknown>): void {
const entry = this.documents.find(({ updates }) =>
updates.includes(promise)
);
if (entry === undefined) {
// This method should be idempotent and tolerant of
// stragglers calling it after the databse has been reset.
return;
}
entry.updates = entry.updates.filter((update) => update !== promise);
// No need to save as Promises don't get serialized
}
public removeDocument(find: DocumentRecord): void {
this.documents = this.documents.filter((document) => document !== find);
this.save();
}
public getLatestDocumentByRelativePath(
find: RelativePath
): DocumentRecord | undefined {
const candidates = this.documents.filter(
({ relativePath }) => relativePath === find
);
candidates.sort((a, b) => b.parallelVersion - a.parallelVersion); // descending
return candidates[0];
}
public async getResolvedDocumentByRelativePath(
relativePath: RelativePath,
promise: Promise<unknown>
): Promise<DocumentRecord> {
const entry = this.getLatestDocumentByRelativePath(relativePath);
if (entry === undefined) {
throw new Error(
`Document not found by relative path: ${relativePath}, ${JSON.stringify(
this.documents,
null,
2
)}`
);
}
const currentPromises = entry.updates;
entry.updates = [...currentPromises, promise];
await Promise.all(currentPromises);
return entry;
}
public createNewPendingDocument(
documentId: DocumentId,
relativePath: RelativePath,
promise: Promise<unknown>
): DocumentRecord {
const previousEntry =
this.getLatestDocumentByRelativePath(relativePath);
const entry = {
relativePath,
documentId,
metadata: undefined,
isDeleted: false,
updates: [promise],
parallelVersion:
previousEntry?.parallelVersion === undefined
? 0
: previousEntry.parallelVersion + 1
};
this.documents.push(entry);
this.save();
return entry;
}
public createNewEmptyDocument(
documentId: DocumentId,
parentVersionId: VaultUpdateId,
relativePath: RelativePath
): DocumentRecord {
const entry = {
relativePath,
documentId,
metadata: {
parentVersionId,
hash: EMPTY_HASH,
remoteRelativePath: relativePath
},
isDeleted: false,
updates: [],
parallelVersion: 0
};
this.documents.push(entry);
this.save();
return entry;
}
public getDocumentByDocumentId(
find: DocumentId
): DocumentRecord | undefined {
return this.documents.find(({ documentId }) => documentId === find);
}
public move(
oldRelativePath: RelativePath,
newRelativePath: RelativePath
): void {
const oldDocument =
this.getLatestDocumentByRelativePath(oldRelativePath);
if (oldDocument === undefined) {
return;
}
const newDocument =
this.getLatestDocumentByRelativePath(newRelativePath);
if (newDocument?.isDeleted === false) {
throw new Error(
`Document already exists at new location: ${newRelativePath}`
);
}
oldDocument.relativePath = newRelativePath;
// We're in a strange state where the target of the move has just got deleted,
// however, its metadata might already have a bunch of updates queued up for
// the document at the new location. We need to keep these updates.
oldDocument.parallelVersion =
newDocument !== undefined ? newDocument.parallelVersion + 1 : 0;
this.save();
}
public delete(relativePath: RelativePath): void {
const candidate = this.getLatestDocumentByRelativePath(relativePath);
if (candidate === undefined) {
throw new Error(
`Document not found by relative path: ${relativePath}`
);
}
candidate.isDeleted = true;
}
public getHasInitialSyncCompleted(): boolean {
return this.hasInitialSyncCompleted;
}
public setHasInitialSyncCompleted(value: boolean): void {
this.hasInitialSyncCompleted = value;
this.save();
}
public getLastSeenUpdateId(): VaultUpdateId {
return this.lastSeenUpdateIds.min;
}
public addSeenUpdateId(value: number): void {
const previousMin = this.lastSeenUpdateIds.min;
this.lastSeenUpdateIds.add(value);
if (previousMin !== this.lastSeenUpdateIds.min) {
this.save();
}
}
public setLastSeenUpdateId(value: number): void {
this.lastSeenUpdateIds.min = value;
this.save();
}
public reset(): void {
this.documents = [];
this.lastSeenUpdateIds = new CoveredValues(
0 // the first updateId will be 1 which is the first integer after -1
);
this.hasInitialSyncCompleted = false;
this.save();
}
private save(): void {
this.ensureConsistency();
void this.saveData({
documents: this.resolvedDocuments.map(
({ relativePath, documentId, metadata }) => ({
documentId,
relativePath,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
...metadata! // `resolvedDocuments` only returns docs with metadata set
})
),
lastSeenUpdateId: this.lastSeenUpdateIds.min,
hasInitialSyncCompleted: this.hasInitialSyncCompleted
}).catch((error: unknown) => {
this.logger.error(`Error saving data: ${error}`);
});
}
private ensureConsistency(): void {
const idToPath = new Map<string, string[]>();
this.resolvedDocuments.forEach(({ relativePath, documentId }) => {
idToPath.set(documentId, [
...(idToPath.get(documentId) ?? []),
relativePath
]);
});
const duplicates = Array.from(idToPath.entries())
.filter(([_, paths]) => paths.length > 1)
.map(([id, paths]) => `${id} (${paths.join(", ")})`);
if (duplicates.length > 0) {
throw new Error(
"Document IDs are not unique, found duplicates: " +
duplicates.join("; ")
);
}
}
}

View file

@ -0,0 +1,4 @@
export interface PersistenceProvider<T> {
load: () => Promise<T | undefined>;
save: (data: T) => Promise<void>;
}

View file

@ -0,0 +1,84 @@
import type { Logger } from "../tracing/logger";
export interface SyncSettings {
remoteUri: string;
token: string;
vaultName: string;
syncConcurrency: number;
isSyncEnabled: boolean;
maxFileSizeMB: number;
ignorePatterns: string[];
webSocketRetryIntervalMs: number;
}
export const DEFAULT_SETTINGS: SyncSettings = {
remoteUri: "",
token: "",
vaultName: "default",
syncConcurrency: 1,
isSyncEnabled: false,
maxFileSizeMB: 10,
ignorePatterns: [],
webSocketRetryIntervalMs: 3500
};
export class Settings {
private settings: SyncSettings;
private readonly onSettingsChangeHandlers: ((
newSettings: SyncSettings,
oldSettings: SyncSettings
) => unknown)[] = [];
public constructor(
private readonly logger: Logger,
initialState: Partial<SyncSettings> | undefined,
private readonly saveData: (data: SyncSettings) => Promise<void>
) {
this.settings = {
...DEFAULT_SETTINGS,
...(initialState ?? {})
};
this.logger.debug(
`Loaded settings: ${JSON.stringify(this.settings, null, 2)}`
);
}
public getSettings(): SyncSettings {
return this.settings;
}
public addOnSettingsChangeListener(
handler: (settings: SyncSettings, oldSettings: SyncSettings) => unknown
): void {
this.onSettingsChangeHandlers.push(handler);
}
public async setSetting<T extends keyof SyncSettings>(
key: T,
value: SyncSettings[T]
): Promise<void> {
this.logger.debug(`Setting '${key}' to '${value}'`);
await this.setSettings({
[key]: value
});
}
public async setSettings(value: Partial<SyncSettings>): Promise<void> {
const oldSettings = this.settings;
this.settings = {
...this.settings,
...value
};
this.onSettingsChangeHandlers.forEach((handler) => {
handler(this.settings, oldSettings);
});
await this.save();
}
private async save(): Promise<void> {
await this.saveData(this.settings);
}
}

View file

@ -0,0 +1,98 @@
import type { Settings } from "../persistence/settings";
import type { Logger } from "../tracing/logger";
import { createPromise } from "../utils/create-promise";
import { SyncResetError } from "./sync-reset-error";
export class ConnectionStatus {
private static readonly UNTIL_RESOLUTION = Symbol();
private canFetch: boolean;
private until: Promise<symbol>;
private resolveUntil: (result: symbol) => unknown;
private rejectUntil: (reason: unknown) => unknown;
public constructor(
settings: Settings,
private readonly logger: Logger
) {
this.canFetch = settings.getSettings().isSyncEnabled;
[this.until, this.resolveUntil, this.rejectUntil] =
createPromise<symbol>();
settings.addOnSettingsChangeListener((newSettings, oldSettings) => {
if (oldSettings.isSyncEnabled != newSettings.isSyncEnabled) {
this.canFetch = newSettings.isSyncEnabled;
this.resolveUntil(ConnectionStatus.UNTIL_RESOLUTION);
[this.until, this.resolveUntil, this.rejectUntil] =
createPromise<symbol>();
}
});
}
private static getUrlFromInput(input: RequestInfo | URL): string {
if (input instanceof URL) {
return input.href;
}
if (typeof input === "string") {
return input;
}
return input.url;
}
public startReset(): void {
this.rejectUntil(new SyncResetError());
}
public finishReset(): void {
[this.until, this.resolveUntil, this.rejectUntil] = createPromise();
}
public getFetchImplementation(
logger: Logger,
fetch: typeof globalThis.fetch = globalThis.fetch
): typeof globalThis.fetch {
return async (
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> => {
while (!this.canFetch) {
await this.until;
}
try {
// https://github.com/jonbern/fetch-retry/blob/8684ef4e688375f623bd76f13add76dbc1d67cfb/index.js#L67C1-L70C21
const _input =
typeof Request !== "undefined" && input instanceof Request
? input.clone()
: input;
const fetchPromise = fetch(_input, init);
// We only want to catch rejections from `this.until`
let result: symbol | Response | undefined = undefined;
do {
result = await Promise.race([this.until, fetchPromise]);
} while (result === ConnectionStatus.UNTIL_RESOLUTION);
const fetchResult: Response = result as Response; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
if (!fetchResult.ok) {
this.logger.warn(
`Fetch for ${ConnectionStatus.getUrlFromInput(
input
)}, got status ${fetchResult.status}`
);
}
return fetchResult;
} catch (error) {
logger.warn(
`Fetch for ${ConnectionStatus.getUrlFromInput(
input
)}, got error: ${error}`
);
throw error;
}
};
}
}

View file

@ -0,0 +1,6 @@
export class SyncResetError extends Error {
public constructor() {
super("Sync was reset");
this.name = "SyncResetError";
}
}

View file

@ -0,0 +1,325 @@
import type {
DocumentId,
RelativePath,
VaultUpdateId
} from "../persistence/database";
import type { Logger } from "../tracing/logger";
import type { Settings } from "../persistence/settings";
import type { ConnectionStatus } from "./connection-status";
import { sleep } from "../utils/sleep";
import { SyncResetError } from "./sync-reset-error";
import type { SerializedError } from "./types/SerializedError";
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse";
import type { DocumentVersion } from "./types/DocumentVersion";
import type { FetchLatestDocumentsResponse } from "./types/FetchLatestDocumentsResponse";
import type { PingResponse } from "./types/PingResponse";
import type { DeleteDocumentVersion } from "./types/DeleteDocumentVersion";
export interface CheckConnectionResult {
isSuccessful: boolean;
message: string;
}
export class SyncService {
private static readonly NETWORK_RETRY_INTERVAL_MS = 1000;
private readonly client: typeof globalThis.fetch;
private readonly pingClient: typeof globalThis.fetch;
public constructor(
private readonly deviceId: string,
private readonly connectionStatus: ConnectionStatus,
private readonly settings: Settings,
private readonly logger: Logger,
fetchImplementation: typeof globalThis.fetch = globalThis.fetch
) {
// ensure that if it's called a method, `this` won't be bound to the instance
const unboundFetch: typeof globalThis.fetch = async (...args) =>
fetchImplementation(...args);
this.client = this.connectionStatus.getFetchImplementation(
this.logger,
unboundFetch
);
this.pingClient = unboundFetch;
}
private static formatError(error: SerializedError): string {
let result = error.message;
if (error.causes.length > 0) {
const causes = error.causes.join(", ");
result += ` caused by: ${causes}`;
}
return result;
}
public async create({
documentId,
relativePath,
contentBytes
}: {
documentId?: DocumentId;
relativePath: RelativePath;
contentBytes: Uint8Array;
}): Promise<DocumentVersionWithoutContent> {
return this.withRetries(async () => {
const formData = new FormData();
if (documentId !== undefined) {
formData.append("document_id", documentId);
}
formData.append("relative_path", relativePath);
formData.append(
"content",
new Blob([new Uint8Array(contentBytes)])
);
const response = await this.client(this.getUrl("/documents"), {
method: "POST",
body: formData,
headers: this.getDefaultHeaders()
});
const result: SerializedError | DocumentVersionWithoutContent =
(await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
| SerializedError
| DocumentVersionWithoutContent;
if ("errorType" in result) {
throw new Error(
`Failed to create document: ${SyncService.formatError(result)}`
);
}
this.logger.debug(
`Created document ${JSON.stringify(result)} with id ${
result.documentId
}`
);
return result;
});
}
public async put({
parentVersionId,
documentId,
relativePath,
contentBytes
}: {
parentVersionId: VaultUpdateId;
documentId: DocumentId;
relativePath: RelativePath;
contentBytes: Uint8Array;
}): Promise<DocumentUpdateResponse> {
return this.withRetries(async () => {
this.logger.debug(
`Updating document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}`
);
const formData = new FormData();
formData.append("parent_version_id", parentVersionId.toString());
formData.append("relative_path", relativePath);
formData.append(
"content",
new Blob([new Uint8Array(contentBytes)])
);
const response = await this.client(
this.getUrl(`/documents/${documentId}`),
{
method: "PUT",
body: formData,
headers: this.getDefaultHeaders()
}
);
const result: SerializedError | DocumentUpdateResponse =
(await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
| SerializedError
| DocumentUpdateResponse;
if ("errorType" in result) {
throw new Error(
`Failed to update document: ${SyncService.formatError(result)}`
);
}
this.logger.debug(
`Updated document ${JSON.stringify(result)} with id ${
result.documentId
}}`
);
return result;
});
}
public async delete({
documentId,
relativePath
}: {
documentId: DocumentId;
relativePath: RelativePath;
}): Promise<DocumentVersionWithoutContent> {
return this.withRetries(async () => {
const request: DeleteDocumentVersion = {
relativePath
};
const response = await this.client(
this.getUrl(`/documents/${documentId}`),
{
method: "DELETE",
body: JSON.stringify(request),
headers: {
"Content-Type": "application/json",
...this.getDefaultHeaders()
}
}
);
const result: SerializedError | DocumentVersionWithoutContent =
(await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
| SerializedError
| DocumentVersionWithoutContent;
if ("errorType" in result) {
throw new Error(
`Failed to delete document: ${SyncService.formatError(result)}`
);
}
this.logger.debug(
`Deleted document ${relativePath} with id ${documentId}`
);
return result;
});
}
public async get({
documentId
}: {
documentId: DocumentId;
}): Promise<DocumentVersion> {
return this.withRetries(async () => {
const response = await this.client(
this.getUrl(`/documents/${documentId}`),
{
headers: this.getDefaultHeaders()
}
);
const result: SerializedError | DocumentVersion =
(await response.json()) as SerializedError | DocumentVersion; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
if ("errorType" in result) {
throw new Error(
`Failed to get document: ${SyncService.formatError(result)}`
);
}
this.logger.debug(
`Get document ${result.relativePath} with id ${result.documentId}`
);
return result;
});
}
public async getAll(
since?: VaultUpdateId
): Promise<FetchLatestDocumentsResponse> {
return this.withRetries(async () => {
const url = new URL(this.getUrl("/documents"));
if (since !== undefined) {
url.searchParams.append("since", since.toString());
}
const response = await this.client(url.toString(), {
headers: this.getDefaultHeaders()
});
const result: SerializedError | FetchLatestDocumentsResponse =
(await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
| SerializedError
| FetchLatestDocumentsResponse;
if ("errorType" in result) {
throw new Error(
`Failed to get documents: ${SyncService.formatError(result)}`
);
}
this.logger.debug(
`Got ${result.latestDocuments.length} document metadata`
);
return result;
});
}
public async checkConnection(): Promise<CheckConnectionResult> {
try {
const response = await this.pingClient(this.getUrl("/ping"), {
headers: this.getDefaultHeaders()
});
const result: PingResponse | SerializedError =
(await response.json()) as PingResponse | SerializedError; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
if ("errorType" in result) {
throw new Error(
`Failed to ping server: ${SyncService.formatError(result)}`
);
}
if (result.isAuthenticated) {
return {
isSuccessful: true,
message: `Successfully connected to server (version: ${result.serverVersion}) and authenticated`
};
}
return {
isSuccessful: false,
message: `Successfully connected to server (version: ${result.serverVersion}) but failed to authenticate`
};
} catch (e) {
return {
isSuccessful: false,
message: `Failed to connect to server: ${e}`
};
}
}
private getUrl(path: string): string {
const { vaultName, remoteUri } = this.settings.getSettings();
const safeRemoteUri = remoteUri.replace(/\/+$/, "");
return `${safeRemoteUri}/vaults/${vaultName}${path}`;
}
private getDefaultHeaders(): Record<string, string> {
return {
"device-id": this.deviceId,
authorization: `Bearer ${this.settings.getSettings().token}`
};
}
private async withRetries<T>(fn: () => Promise<T>): Promise<T> {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
try {
return await fn();
} catch (e) {
// We must not retry errors coming from reset
if (e instanceof SyncResetError) {
throw e;
}
this.logger.error(
`Failed network call (${e}), retrying in ${SyncService.NETWORK_RETRY_INTERVAL_MS}ms`
);
await sleep(SyncService.NETWORK_RETRY_INTERVAL_MS);
}
}
}
}

View file

@ -0,0 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DocumentWithCursors } from "./DocumentWithCursors";
export interface ClientCursors {
userName: string;
deviceId: string;
documentsWithCursors: DocumentWithCursors[];
}

View file

@ -0,0 +1,13 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface CreateDocumentVersion {
/**
* The client can decide the document id (if it wishes to) in order
* to help with syncing. If the client does not provide a document id,
* the server will generate one. If the client provides a document id
* it must not already exist in the database.
*/
document_id: string | null;
relative_path: string;
content: number[];
}

View file

@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DocumentWithCursors } from "./DocumentWithCursors";
export interface CursorPositionFromClient {
documentsWithCursors: DocumentWithCursors[];
}

View file

@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ClientCursors } from "./ClientCursors";
export interface CursorPositionFromServer {
clients: ClientCursors[];
}

View file

@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface CursorSpan {
start: number;
end: number;
}

View file

@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface DeleteDocumentVersion {
relativePath: string;
}

View file

@ -0,0 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DocumentVersion } from "./DocumentVersion";
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
/**
* Response to an update document request.
*/
export type DocumentUpdateResponse =
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
| ({ type: "MergingUpdate" } & DocumentVersion);

View file

@ -0,0 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface DocumentVersion {
vaultUpdateId: number;
documentId: string;
relativePath: string;
updatedDate: string;
contentBase64: string;
isDeleted: boolean;
userId: string;
deviceId: string;
}

View file

@ -0,0 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface DocumentVersionWithoutContent {
vaultUpdateId: number;
documentId: string;
relativePath: string;
updatedDate: string;
isDeleted: boolean;
userId: string;
deviceId: string;
contentSize: number;
}

View file

@ -0,0 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CursorSpan } from "./CursorSpan";
export interface DocumentWithCursors {
vault_update_id: number | null;
document_id: string;
relative_path: string;
cursors: CursorSpan[];
}

View file

@ -0,0 +1,13 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
/**
* Response to a fetch latest documents request.
*/
export interface FetchLatestDocumentsResponse {
latestDocuments: DocumentVersionWithoutContent[];
/**
* The update ID of the latest document in the response.
*/
lastUpdateId: bigint;
}

View file

@ -0,0 +1,16 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Response to a ping request.
*/
export interface PingResponse {
/**
* Semantic version of the server.
*/
serverVersion: string;
/**
* Whether the client is authenticated based on the sent Authorization
* header.
*/
isAuthenticated: boolean;
}

View file

@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface SerializedError {
errorType: string;
message: string;
causes: string[];
}

View file

@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface UpdateDocumentVersion {
parent_version_id: bigint;
relative_path: string;
content: number[];
}

View file

@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CursorPositionFromClient } from "./CursorPositionFromClient";
import type { WebSocketHandshake } from "./WebSocketHandshake";
export type WebSocketClientMessage =
| ({ type: "handshake" } & WebSocketHandshake)
| ({ type: "cursorPositions" } & CursorPositionFromClient);

View file

@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface WebSocketHandshake {
token: string;
deviceId: string;
lastSeenVaultUpdateId: number | null;
}

View file

@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CursorPositionFromServer } from "./CursorPositionFromServer";
import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate";
export type WebSocketServerMessage =
| ({ type: "vaultUpdate" } & WebSocketVaultUpdate)
| ({ type: "cursorPositions" } & CursorPositionFromServer);

View file

@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
export interface WebSocketVaultUpdate {
documents: DocumentVersionWithoutContent[];
isInitialSync: boolean;
}

View file

@ -0,0 +1,199 @@
import type { Database } from "../persistence/database";
import type { Logger } from "../tracing/logger";
import type { Settings, SyncSettings } from "../persistence/settings";
import type { WebSocketServerMessage } from "./types/WebSocketServerMessage";
import type { Syncer } from "../sync-operations/syncer";
import type { WebSocketClientMessage } from "./types/WebSocketClientMessage";
import type { CursorPositionFromClient } from "./types/CursorPositionFromClient";
import type { ClientCursors } from "./types/ClientCursors";
export class WebSocketManager {
private readonly webSocketStatusChangeListeners: (() => unknown)[] = [];
private readonly remoteCursorsUpdateListeners: ((
cursors: ClientCursors[]
) => unknown)[] = [];
private webSocket: WebSocket | undefined;
private isStopped = true;
private _isFirstSyncCompleted = false;
private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket;
public constructor(
private readonly deviceId: string,
private readonly logger: Logger,
private readonly database: Database,
private readonly settings: Settings,
private readonly syncer: Syncer,
webSocketImplementation?: typeof globalThis.WebSocket
) {
if (webSocketImplementation) {
this.webSocketFactoryImplementation = webSocketImplementation;
} else {
if (
typeof globalThis !== "undefined" &&
typeof globalThis.WebSocket === "undefined"
) {
// eslint-disable-next-line
this.webSocketFactoryImplementation = require("ws"); // polyfill for WebSocket in Node.js
} else {
this.webSocketFactoryImplementation = WebSocket;
}
}
settings.addOnSettingsChangeListener((newSettings, oldSettings) => {
if (
newSettings.remoteUri !== oldSettings.remoteUri ||
newSettings.vaultName !== oldSettings.vaultName ||
newSettings.token !== oldSettings.token
) {
this.initializeWebSocket(newSettings);
}
});
}
public get isWebSocketConnected(): boolean {
return (
this.webSocket?.readyState ===
this.webSocketFactoryImplementation.OPEN
);
}
public get isFirstSyncCompleted(): boolean {
return this._isFirstSyncCompleted;
}
public addWebSocketStatusChangeListener(listener: () => unknown): void {
this.webSocketStatusChangeListeners.push(listener);
}
public addRemoteCursorsUpdateListener(
listener: (cursors: ClientCursors[]) => unknown
): void {
this.remoteCursorsUpdateListeners.push(listener);
}
public start(): void {
this.isStopped = false;
this._isFirstSyncCompleted = false;
this.initializeWebSocket(this.settings.getSettings());
}
public stop(): void {
this.isStopped = true;
this.webSocket?.close(1000, "WebSocketManager has been stopped");
}
public updateLocalCursors(cursorPositions: CursorPositionFromClient): void {
if (!this.isWebSocketConnected) {
this.logger.warn(
"WebSocket is not connected, cannot send cursor positions"
);
return;
}
const message: WebSocketClientMessage = {
type: "cursorPositions",
...cursorPositions
};
this.webSocket?.send(JSON.stringify(message));
this.logger.debug(
`Sent cursor positions: ${JSON.stringify(cursorPositions)}`
);
}
private initializeWebSocket(settings: SyncSettings): void {
if (this.isStopped) {
return;
}
try {
this.webSocket?.close();
} catch (e) {
this.logger.warn(`Failed to close WebSocket: ${e}`);
}
const wsUri = new URL(settings.remoteUri);
wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws";
wsUri.pathname = `/vaults/${settings.vaultName}/ws`;
this.logger.info(`Connecting to WebSocket at ${wsUri.toString()}`);
this.webSocket = new this.webSocketFactoryImplementation(wsUri);
// The JS WebSocket API doesn't support setting headers, so we have to send the token as a message
this.webSocket.onopen = (): void => {
this.logger.info("WebSocket connection opened");
this.webSocketStatusChangeListeners.forEach((l) => l());
const message: WebSocketClientMessage = {
type: "handshake",
deviceId: this.deviceId,
token: settings.token,
lastSeenVaultUpdateId: this.database.getLastSeenUpdateId()
};
this.webSocket?.send(JSON.stringify(message));
};
this.webSocket.onmessage = async (event): Promise<void> => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const message = JSON.parse(event.data) as WebSocketServerMessage;
return this.handleWebSocketMessage(message);
};
this.webSocket.onclose = (event): void => {
this.logger.warn(
`WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})`
);
this.webSocketStatusChangeListeners.forEach((l) => l());
if (!this.isStopped) {
setTimeout(() => {
this.initializeWebSocket(this.settings.getSettings());
}, this.settings.getSettings().webSocketRetryIntervalMs);
}
};
}
private async handleWebSocketMessage(
message: WebSocketServerMessage
): Promise<void> {
if (message.type === "vaultUpdate") {
try {
await Promise.all(
message.documents.map(async (document) =>
this.syncer.syncRemotelyUpdatedFile(document)
)
);
if (message.isInitialSync && message.documents.length > 0) {
this.database.setLastSeenUpdateId(
message.documents
.map((document) => document.vaultUpdateId)
.reduce((a, b) => Math.max(a, b))
);
}
this._isFirstSyncCompleted = true;
} catch (e) {
this.logger.error(`Failed to sync remotely updated file: ${e}`);
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} else if (message.type === "cursorPositions") {
this.logger.debug(
`Received cursor positions for ${JSON.stringify(message.clients)}`
);
this.remoteCursorsUpdateListeners.forEach((listener) => {
listener(
message.clients.filter(
(client) => client.deviceId !== this.deviceId
)
);
});
} else {
this.logger.warn(
`Received unknown message type: ${JSON.stringify(message)}`
);
}
}
}

View file

@ -0,0 +1,337 @@
import type { PersistenceProvider } from "./persistence/persistence";
import type { HistoryEntry, HistoryStats } from "./tracing/sync-history";
import { SyncHistory } from "./tracing/sync-history";
import { Logger } from "./tracing/logger";
import type { RelativePath, StoredDatabase } from "./persistence/database";
import { Database } from "./persistence/database";
import type { SyncSettings } from "./persistence/settings";
import { Settings } from "./persistence/settings";
import { SyncService } from "./services/sync-service";
import { Syncer } from "./sync-operations/syncer";
import type { FileSystemOperations } from "./file-operations/filesystem-operations";
import { FileOperations } from "./file-operations/file-operations";
import { ConnectionStatus } from "./services/connection-status";
import { UnrestrictedSyncer } from "./sync-operations/unrestricted-syncer";
import { rateLimit } from "./utils/rate-limit";
import type { NetworkConnectionStatus } from "./types/network-connection-status";
import { DocumentSyncStatus } from "./types/document-sync-status";
import { WebSocketManager } from "./services/websocket-manager";
import { createClientId } from "./utils/create-client-id";
import { CursorTracker } from "./sync-operations/cursor-tracker";
import type { CursorSpan } from "./services/types/CursorSpan";
import type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors";
import { FileChangeNotifier } from "./sync-operations/file-change-notifier";
export class SyncClient {
private static readonly MINIMUM_SAVE_INTERVAL_MS = 1000;
private hasStartedOfflineSync = false;
private hasFinishedOfflineSync = false;
// eslint-disable-next-line @typescript-eslint/max-params
private constructor(
private readonly history: SyncHistory,
private readonly settings: Settings,
private readonly database: Database,
private readonly syncer: Syncer,
private readonly syncService: SyncService,
private readonly webSocketManager: WebSocketManager,
private readonly _logger: Logger,
private readonly connectionStatus: ConnectionStatus,
private readonly cursorTracker: CursorTracker,
private readonly fileChangeNotifier: FileChangeNotifier
) {
this.settings.addOnSettingsChangeListener(
async (newSettings, oldSettings) => {
if (newSettings.vaultName !== oldSettings.vaultName) {
await this.reset();
}
if (newSettings.isSyncEnabled !== oldSettings.isSyncEnabled) {
if (newSettings.isSyncEnabled) {
await this.start();
} else {
this.stop();
}
}
}
);
}
public get logger(): Logger {
return this._logger;
}
public get documentCount(): number {
return this.database.length;
}
public static async create({
fs,
persistence,
fetch,
webSocket,
nativeLineEndings = "\n"
}: {
fs: FileSystemOperations;
persistence: PersistenceProvider<
Partial<{
settings: Partial<SyncSettings>;
database: Partial<StoredDatabase>;
}>
>;
fetch?: typeof globalThis.fetch;
webSocket?: typeof globalThis.WebSocket;
nativeLineEndings?: string;
}): Promise<SyncClient> {
const logger = new Logger();
const deviceId = createClientId();
logger.info(`Initialising SyncClient with client id ${deviceId}`);
const history = new SyncHistory(logger);
let state = (await persistence.load()) ?? {
settings: undefined,
database: undefined
};
const rateLimitedSave = rateLimit(
persistence.save,
SyncClient.MINIMUM_SAVE_INTERVAL_MS
);
const database = new Database(
logger,
state.database,
async (data): Promise<void> => {
state = { ...state, database: data };
await rateLimitedSave(state);
}
);
const settings = new Settings(
logger,
state.settings,
async (data): Promise<void> => {
state = { ...state, settings: data };
await rateLimitedSave(state);
}
);
const connectionStatus = new ConnectionStatus(settings, logger);
const syncService = new SyncService(
deviceId,
connectionStatus,
settings,
logger,
fetch
);
const fileOperations = new FileOperations(
logger,
database,
fs,
nativeLineEndings
);
const unrestrictedSyncer = new UnrestrictedSyncer(
logger,
database,
settings,
syncService,
fileOperations,
history
);
const syncer = new Syncer(
logger,
database,
settings,
syncService,
fileOperations,
unrestrictedSyncer
);
const webSocketManager = new WebSocketManager(
deviceId,
logger,
database,
settings,
syncer,
webSocket
);
const fileChangeNotifier = new FileChangeNotifier();
const cursorTracker = new CursorTracker(
database,
webSocketManager,
fileOperations,
fileChangeNotifier
);
const client = new SyncClient(
history,
settings,
database,
syncer,
syncService,
webSocketManager,
logger,
connectionStatus,
cursorTracker,
fileChangeNotifier
);
logger.info("SyncClient initialised");
return client;
}
public async checkConnection(): Promise<NetworkConnectionStatus> {
const server = await this.syncService.checkConnection();
return {
isSuccessful: server.isSuccessful,
serverMessage: server.message,
isWebSocketConnected: this.webSocketManager.isWebSocketConnected
};
}
public getHistoryEntries(): readonly HistoryEntry[] {
return this.history.entries;
}
public addSyncHistoryUpdateListener(
listener: (stats: HistoryStats) => unknown
): void {
this.history.addSyncHistoryUpdateListener(listener);
}
public async start(): Promise<void> {
if (!this.hasStartedOfflineSync) {
await this.syncer.scheduleSyncForOfflineChanges();
this.hasStartedOfflineSync = true;
}
this.hasFinishedOfflineSync = true;
this.webSocketManager.start();
}
public stop(): void {
this.hasFinishedOfflineSync = false;
this.webSocketManager.stop();
}
public async waitAndStop(): Promise<void> {
this.stop();
await this.syncer.waitUntilFinished();
}
/// Wait for the in-flight operations to finish, reset all tracking,
/// and the local database but retain the settings.
/// The SyncClient can be used again after calling this method.
public async reset(): Promise<void> {
this.stop();
this.connectionStatus.startReset();
await this.syncer.reset();
this.history.reset();
this.database.reset();
this._logger.reset();
this.connectionStatus.finishReset();
await this.start();
}
public getSettings(): SyncSettings {
return this.settings.getSettings();
}
public async setSetting<T extends keyof SyncSettings>(
key: T,
value: SyncSettings[T]
): Promise<void> {
await this.settings.setSetting(key, value);
}
public async setSettings(value: Partial<SyncSettings>): Promise<void> {
await this.settings.setSettings(value);
}
public addOnSettingsChangeListener(
handler: (settings: SyncSettings, oldSettings: SyncSettings) => unknown
): void {
this.settings.addOnSettingsChangeListener(handler);
}
public addRemainingSyncOperationsListener(
listener: (remainingOperations: number) => unknown
): void {
this.syncer.addRemainingOperationsListener(listener);
}
public addWebSocketStatusChangeListener(listener: () => unknown): void {
this.webSocketManager.addWebSocketStatusChangeListener(listener);
}
public async syncLocallyCreatedFile(
relativePath: RelativePath
): Promise<void> {
this.fileChangeNotifier.notifyOfFileChange(relativePath);
return this.syncer.syncLocallyCreatedFile(relativePath);
}
public async syncLocallyDeletedFile(
relativePath: RelativePath
): Promise<void> {
this.fileChangeNotifier.notifyOfFileChange(relativePath);
return this.syncer.syncLocallyDeletedFile(relativePath);
}
public async syncLocallyUpdatedFile({
oldPath,
relativePath
}: {
oldPath?: RelativePath;
relativePath: RelativePath;
}): Promise<void> {
this.fileChangeNotifier.notifyOfFileChange(relativePath);
return this.syncer.syncLocallyUpdatedFile({
oldPath,
relativePath
});
}
public getDocumentSyncingStatus(
relativePath: RelativePath
): DocumentSyncStatus {
if (!this.settings.getSettings().isSyncEnabled) {
return DocumentSyncStatus.SYNCING_IS_DISABLED;
}
if (
!this.webSocketManager.isFirstSyncCompleted ||
!this.hasFinishedOfflineSync
) {
return DocumentSyncStatus.SYNCING;
}
const document =
this.database.getLatestDocumentByRelativePath(relativePath);
if (document === undefined) {
return DocumentSyncStatus.SYNCING;
}
return document.updates.length > 0
? DocumentSyncStatus.SYNCING
: DocumentSyncStatus.UP_TO_DATE;
}
public async updateLocalCursors(
documentToCursors: Record<RelativePath, CursorSpan[]>
): Promise<void> {
await this.cursorTracker.sendLocalCursorsToServer(documentToCursors);
}
public addRemoteCursorsUpdateListener(
listener: (cursors: MaybeOutdatedClientCursors[]) => unknown
): void {
this.cursorTracker.addRemoteCursorsUpdateListener(listener);
}
}

View file

@ -0,0 +1,253 @@
import type { FileOperations } from "../file-operations/file-operations";
import type { Database, RelativePath } from "../persistence/database";
import type { ClientCursors } from "../services/types/ClientCursors";
import type { CursorSpan } from "../services/types/CursorSpan";
import type { DocumentWithCursors } from "../services/types/DocumentWithCursors";
import type { WebSocketManager } from "../services/websocket-manager";
import type { MaybeOutdatedClientCursors } from "../types/maybe-outdated-client-cursors";
import { DocumentUpToDateness } from "../types/document-up-to-dateness";
import { hash } from "../utils/hash";
import type { FileChangeNotifier } from "./file-change-notifier";
import { Lock } from "../utils/locks";
// Cursor positions are updated separately from documents. However, a given cursor position is only
// valid within a certain version of the document it belongs to. This class tracks previous and the latest
// known remote cursor positions, and for each document, tries to return the latest cursor positions that are
// not from the future.
export class CursorTracker {
private readonly updateLock = new Lock();
private knownRemoteCursors: (ClientCursors & {
upToDateness: DocumentUpToDateness;
})[] = [];
private lastLocalCursorState: DocumentWithCursors[] = [];
private lastLocalCursorStateWithoutDirtyDocuments: DocumentWithCursors[] =
[];
public constructor(
private readonly database: Database,
private readonly webSocketManager: WebSocketManager,
private readonly fileOperations: FileOperations,
private readonly fileChangeNotifier: FileChangeNotifier
) {
this.webSocketManager.addRemoteCursorsUpdateListener(
async (clientCursors) => {
await this.updateLock.withLock(async () => {
// The latest message will contain all active clients, so we can delete the ones
// from the local list which are no longer active.
const allIds = new Set(
clientCursors.map((c) => c.deviceId)
);
const updatedKnownRemoteCursors =
this.knownRemoteCursors.filter((c) =>
allIds.has(c.deviceId)
);
for (const cursor of clientCursors.filter((client) =>
client.documentsWithCursors.every(
(doc) => doc.vault_update_id != null
)
)) {
updatedKnownRemoteCursors.push({
...cursor,
upToDateness:
await this.getDocumentsUpToDateness(cursor)
});
}
this.knownRemoteCursors = updatedKnownRemoteCursors;
});
}
);
this.fileChangeNotifier.addFileChangeListener(async (relativePath) =>
this.updateLock.withLock(async () => {
for (const clientCursor of this.knownRemoteCursors) {
if (
clientCursor.documentsWithCursors.some(
(document) =>
document.relative_path === relativePath
)
) {
clientCursor.upToDateness =
await this.getDocumentsUpToDateness(clientCursor);
}
}
})
);
}
/// Update the local cursors for the given documents.
/// Can be called frequently as it only emits an event
/// if the state has actually changed.
public async sendLocalCursorsToServer(
documentToCursors: Record<RelativePath, CursorSpan[]>
): Promise<void> {
const documentsWithCursors: DocumentWithCursors[] = [];
for (const [relativePath, cursors] of Object.entries(
documentToCursors
)) {
const record =
this.database.getLatestDocumentByRelativePath(relativePath);
if (!record) {
continue; // Let's wait for the file to be created before sending cursors
}
if (!record.metadata) {
continue; // this is a new document, no need to sync the cursors
}
documentsWithCursors.push({
relative_path: relativePath,
document_id: record.documentId,
vault_update_id: record.metadata.parentVersionId,
cursors: cursors.map(({ start, end }) => ({
start: Math.min(start, end),
end: Math.max(start, end)
})) // the client might send directional selections
});
}
if (
JSON.stringify(this.lastLocalCursorState) ===
JSON.stringify(documentsWithCursors)
) {
// Caching step to avoid reading the edited files all the time
return;
}
this.lastLocalCursorState = documentsWithCursors;
for (const doc of documentsWithCursors) {
const readContent = await this.fileOperations.read(
doc.relative_path
);
const record = this.database.getLatestDocumentByRelativePath(
doc.relative_path
);
if (record?.metadata?.hash !== hash(readContent)) {
doc.vault_update_id = null;
}
}
if (
JSON.stringify(this.lastLocalCursorStateWithoutDirtyDocuments) ===
JSON.stringify(documentsWithCursors)
) {
return;
}
this.lastLocalCursorStateWithoutDirtyDocuments = documentsWithCursors;
this.webSocketManager.updateLocalCursors({ documentsWithCursors });
}
// The returned position may be accurate, if it matches the document version, or outdated, in which case
// the client has to heuristically guess it's current position based on the local edits.
public addRemoteCursorsUpdateListener(
listener: (cursors: MaybeOutdatedClientCursors[]) => unknown
): void {
// CursorTracker registers its own event listener in the constructor so it must have been called before this
this.webSocketManager.addRemoteCursorsUpdateListener(async () => {
await this.updateLock.withLock(() =>
listener(this.getRelevantAndPruneKnownClientCursors())
);
});
}
private getRelevantAndPruneKnownClientCursors(): MaybeOutdatedClientCursors[] {
const result: MaybeOutdatedClientCursors[] = [];
const included = new Set<string>();
const relevantCursors = [];
for (const clientCursors of [...this.knownRemoteCursors].reverse()) {
if (included.has(clientCursors.deviceId)) {
continue;
}
if (clientCursors.upToDateness == DocumentUpToDateness.Later) {
continue;
}
result.push({
...clientCursors,
isOutdated:
clientCursors.upToDateness == DocumentUpToDateness.Prior
});
included.add(clientCursors.deviceId);
relevantCursors.unshift(clientCursors); // to reverse order back to normal
}
this.knownRemoteCursors = relevantCursors;
return result;
}
// We store up-to-dateness on a per-client basis to simplify the implementation.
// An individual client won't have too many documents open at once, so this is a reasonable trade-off.
private async getDocumentsUpToDateness(
clientCursor: ClientCursors
): Promise<DocumentUpToDateness> {
const results = [];
for (const document of clientCursor.documentsWithCursors) {
results.push(await this.getDocumentUpToDateness(document));
}
if (
results.every((result) => result === DocumentUpToDateness.UpToDate)
) {
return DocumentUpToDateness.UpToDate;
}
if (
results.every(
(result) =>
result === DocumentUpToDateness.UpToDate ||
result === DocumentUpToDateness.Prior
)
) {
return DocumentUpToDateness.Prior;
}
return DocumentUpToDateness.Later;
}
private async getDocumentUpToDateness(
document: DocumentWithCursors
): Promise<DocumentUpToDateness> {
const record = this.database.getLatestDocumentByRelativePath(
document.relative_path
);
if (!record) {
// the document of the cursor must be from the future
return DocumentUpToDateness.Later;
}
if (
(record.metadata?.parentVersionId ?? 0) <
(document.vault_update_id ?? 0)
) {
return DocumentUpToDateness.Later;
} else if (
(document.vault_update_id ?? 0) <
(record.metadata?.parentVersionId ?? 0)
) {
// the document of the cursor must be from the past
return DocumentUpToDateness.Prior;
}
const currentContent = await this.fileOperations.read(
document.relative_path
);
return this.database.getLatestDocumentByRelativePath(
document.relative_path
)?.metadata?.hash === hash(currentContent)
? DocumentUpToDateness.UpToDate
: DocumentUpToDateness.Prior;
}
}

View file

@ -0,0 +1,15 @@
import type { RelativePath } from "../persistence/database";
export class FileChangeNotifier {
private readonly listeners: ((filePath: RelativePath) => unknown)[] = [];
public addFileChangeListener(
listener: (filePath: RelativePath) => unknown
): void {
this.listeners.push(listener);
}
public notifyOfFileChange(filePath: RelativePath): void {
this.listeners.forEach((listener) => listener(filePath));
}
}

View file

@ -0,0 +1,459 @@
import type {
Database,
DocumentId,
DocumentRecord,
RelativePath
} from "../persistence/database";
import type { SyncService } from "../services/sync-service";
import type { Logger } from "../tracing/logger";
import PQueue from "p-queue";
import { hash } from "../utils/hash";
import { v4 as uuidv4 } from "uuid";
import type { Settings } from "../persistence/settings";
import type { FileOperations } from "../file-operations/file-operations";
import { findMatchingFile } from "../utils/find-matching-file";
import type { UnrestrictedSyncer } from "./unrestricted-syncer";
import { createPromise } from "../utils/create-promise";
import { SyncResetError } from "../services/sync-reset-error";
import { Locks } from "../utils/locks";
import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent";
export class Syncer {
private readonly remoteDocumentsLock: Locks<DocumentId>;
private readonly remainingOperationsListeners: ((
remainingOperations: number
) => unknown)[] = [];
private readonly syncQueue: PQueue;
private runningScheduleSyncForOfflineChanges: Promise<void> | undefined;
public constructor(
private readonly logger: Logger,
private readonly database: Database,
settings: Settings,
private readonly syncService: SyncService,
private readonly operations: FileOperations,
private readonly internalSyncer: UnrestrictedSyncer
) {
this.syncQueue = new PQueue({
concurrency: settings.getSettings().syncConcurrency
});
this.remoteDocumentsLock = new Locks<DocumentId>(this.logger);
settings.addOnSettingsChangeListener((newSettings, oldSettings) => {
if (newSettings.syncConcurrency !== oldSettings.syncConcurrency) {
this.syncQueue.concurrency = newSettings.syncConcurrency;
}
});
this.syncQueue.on("active", () => {
this.remainingOperationsListeners.forEach((listener) => {
listener(this.syncQueue.size);
});
});
}
public addRemainingOperationsListener(
listener: (remainingOperations: number) => unknown
): void {
this.remainingOperationsListeners.push(listener);
}
public async syncLocallyCreatedFile(
relativePath: RelativePath
): Promise<void> {
if (
this.database.getLatestDocumentByRelativePath(relativePath)
?.isDeleted === false
) {
this.logger.debug(
`Document ${relativePath} already exists in the database, skipping`
);
return;
}
const [promise, resolve, reject] = createPromise();
const id = uuidv4();
const document = this.database.createNewPendingDocument(
id,
relativePath,
promise
);
this.logger.debug(
`Creating new pending document ${relativePath} with id ${id}`
);
try {
await this.syncQueue.add(async () =>
this.internalSyncer.unrestrictedSyncLocallyCreatedFile(document)
);
resolve();
} catch (e) {
reject(e);
} finally {
this.database.removeDocumentPromise(promise);
}
}
public async syncLocallyDeletedFile(
relativePath: RelativePath
): Promise<void> {
if (
this.database.getLatestDocumentByRelativePath(relativePath)
?.isDeleted === true
) {
// This is must be a consequence of us deleting a file because of a remote update
// which triggered a local delete, so we don't need to do anything here.
this.logger.debug(
`Document ${relativePath} has already been markes as deleted, skipping`
);
return;
}
// We have to have a record of the delete in case there's an in-flight update for the same
// document which finishes after the delete has succeeded and would introduce a phantom metadata record.
this.database.delete(relativePath);
const [promise, resolve, reject] = createPromise();
const document = await this.database.getResolvedDocumentByRelativePath(
relativePath,
promise
);
try {
await this.syncQueue.add(async () =>
this.internalSyncer.unrestrictedSyncLocallyDeletedFile(document)
);
resolve();
this.database.removeDocument(document);
} catch (e) {
reject(e);
} finally {
this.database.removeDocumentPromise(promise);
}
}
public async syncLocallyUpdatedFile({
oldPath,
relativePath
}: {
oldPath?: RelativePath;
relativePath: RelativePath;
}): Promise<void> {
if (oldPath !== undefined) {
// We might have moved the document in the database before calling this method,
// in that case, we mustn't move it again.
if (
this.database.getLatestDocumentByRelativePath(relativePath) ===
undefined ||
this.database.getLatestDocumentByRelativePath(relativePath)
?.isDeleted === true
) {
if (oldPath === relativePath) {
throw new Error(
`Old path and new path are the same: ${oldPath}`
);
}
this.database.move(oldPath, relativePath);
}
}
let document =
this.database.getLatestDocumentByRelativePath(relativePath);
if (
oldPath !== undefined &&
document?.metadata?.remoteRelativePath === relativePath
) {
this.logger.debug(
`Document ${relativePath} has been moved as a result of a remote update, skipping sync`
);
return;
}
if (document === undefined) {
this.logger.debug(
`Cannot find document ${relativePath} in the database, skipping`
);
return;
}
if (document.isDeleted) {
this.logger.debug(
`Document ${relativePath} has been deleted locally, skipping`
);
return;
}
const [promise, resolve, reject] = createPromise();
document = await this.database.getResolvedDocumentByRelativePath(
relativePath,
promise
);
try {
await this.syncQueue.add(async () =>
this.internalSyncer.unrestrictedSyncLocallyUpdatedFile({
oldPath,
document
})
);
resolve();
} catch (e) {
reject(e);
} finally {
this.database.removeDocumentPromise(promise);
}
}
public async scheduleSyncForOfflineChanges(): Promise<void> {
if (this.runningScheduleSyncForOfflineChanges !== undefined) {
this.logger.debug("Uploading local changes is already in progress");
return this.runningScheduleSyncForOfflineChanges;
}
try {
this.runningScheduleSyncForOfflineChanges =
this.internalScheduleSyncForOfflineChanges();
await this.runningScheduleSyncForOfflineChanges;
this.logger.info(`All local changes have been applied remotely`);
} catch (e) {
if (e instanceof SyncResetError) {
this.logger.info(
"Failed to apply local changes remotely due to a reset"
);
return;
}
this.logger.error(
`Not all local changes have been applied remotely: ${e}`
);
throw e;
} finally {
this.runningScheduleSyncForOfflineChanges = undefined;
}
}
public async waitUntilFinished(): Promise<void> {
await this.runningScheduleSyncForOfflineChanges;
return this.syncQueue.onEmpty();
}
public async reset(): Promise<void> {
await this.waitUntilFinished();
}
public async syncRemotelyUpdatedFile(
remoteVersion: DocumentVersionWithoutContent
): Promise<void> {
let document = this.database.getDocumentByDocumentId(
remoteVersion.documentId
);
if (document === undefined) {
// Let's avoid the same documents getting created in parallel multiple times.
// There might be multiple tasks waiting for the lock
return this.remoteDocumentsLock.withLock(
remoteVersion.documentId,
async () => {
document = this.database.getDocumentByDocumentId(
remoteVersion.documentId
);
// We're either the first one to get the lock, so we have to create the document in `unrestrictedSyncRemotelyUpdatedFile`
if (document === undefined) {
await this.syncQueue.add(async () =>
this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile(
remoteVersion
)
);
} else {
const [promise, resolve, reject] = createPromise();
document =
await this.database.getResolvedDocumentByRelativePath(
document.relativePath,
promise
);
try {
await this.syncQueue.add(async () =>
this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile(
remoteVersion,
document
)
);
resolve();
} catch (e) {
reject(e);
} finally {
this.database.removeDocumentPromise(promise);
}
}
this.database.addSeenUpdateId(remoteVersion.vaultUpdateId);
}
);
}
// We're either the first one to get the lock, so we have to create the document in `unrestrictedSyncRemotelyUpdatedFile`
const [promise, resolve, reject] = createPromise();
document = await this.database.getResolvedDocumentByRelativePath(
document.relativePath,
promise
);
try {
await this.syncQueue.add(async () =>
this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile(
remoteVersion,
document
)
);
resolve();
} catch (e) {
reject(e);
} finally {
this.database.removeDocumentPromise(promise);
}
this.database.addSeenUpdateId(remoteVersion.vaultUpdateId);
}
private async internalScheduleSyncForOfflineChanges(): Promise<void> {
await this.createFakeDocumentsFromRemoteState();
const allLocalFiles = await this.operations.listAllFiles();
let locallyPossiblyDeletedFiles: DocumentRecord[] = [];
for (const document of this.database.resolvedDocuments) {
if (
!document.isDeleted &&
!(await this.operations.exists(document.relativePath))
) {
locallyPossiblyDeletedFiles.push(document);
}
}
const updates = Promise.all(
allLocalFiles.map(async (relativePath) => {
if (
this.database.getLatestDocumentByRelativePath(relativePath)
?.metadata !== undefined
) {
this.logger.debug(
`Document ${relativePath} might have been updated locally, scheduling sync to validate and update it`
);
return this.syncLocallyUpdatedFile({
relativePath
});
}
// Perhaps the file has been moved; let's check by looking at the deleted files
const contentHash = await this.syncQueue.add(async () => {
const contentBytes =
await this.operations.read(relativePath); // this can throw FileNotFoundError
return hash(contentBytes);
});
if (contentHash == undefined) {
// The file was deleted before we had a chance to read it, no need to sync it here
return;
}
const originalFile = findMatchingFile(
contentHash,
locallyPossiblyDeletedFiles
);
if (originalFile !== undefined) {
// `originalFile` hasn't been deleted but it got moved instead
locallyPossiblyDeletedFiles =
locallyPossiblyDeletedFiles.filter(
(item) =>
item.relativePath !== originalFile.relativePath
);
this.logger.debug(
`Document '${originalFile.relativePath}' was not found under its current path in the database but was found under a different path (${relativePath}), scheduling sync to move it`
);
// We're outside of the pqueue, so we need to call the public wrapper
return this.syncLocallyUpdatedFile({
oldPath: originalFile.relativePath,
relativePath
});
}
this.logger.debug(
`Document ${relativePath} not found in database, scheduling sync to create it`
);
// We're outside of the pqueue, so we need to call the public wrapper
return this.syncLocallyCreatedFile(relativePath);
})
);
const deletes = Promise.all(
locallyPossiblyDeletedFiles.map(async ({ relativePath }) => {
this.logger.debug(
`Document ${relativePath} has been deleted locally, scheduling sync to delete it`
);
// We're outside of the pqueue, so we need to call the public wrapper
return this.syncLocallyDeletedFile(relativePath);
})
);
await Promise.all([updates, deletes]);
}
/**
* Create fake documents in the database for all files that are present locally
* and also exist remotely. This will stop the subequent syncs from duplicating
* the documents by creating the same documents from multiple clients.
*/
private async createFakeDocumentsFromRemoteState(): Promise<void> {
if (this.database.getHasInitialSyncCompleted()) {
return;
}
const [allLocalFiles, remote] = await Promise.all([
this.operations.listAllFiles(),
this.syncQueue.add(async () => this.syncService.getAll())
]);
if (remote !== undefined) {
remote.latestDocuments
.filter(
(remoteDocument) =>
allLocalFiles.includes(remoteDocument.relativePath) &&
!remoteDocument.isDeleted &&
this.database.getDocumentByDocumentId(
remoteDocument.documentId
) === undefined
)
.forEach((remoteDocument) => {
this.database.createNewEmptyDocument(
remoteDocument.documentId,
remoteDocument.vaultUpdateId,
remoteDocument.relativePath
);
});
}
this.database.setHasInitialSyncCompleted(true);
}
}

View file

@ -0,0 +1,513 @@
import type {
Database,
DocumentRecord,
RelativePath
} from "../persistence/database";
import type { SyncService } from "../services/sync-service";
import type { Logger } from "../tracing/logger";
import type {
CommonHistoryEntry,
SyncCreateDetails,
SyncDeleteDetails,
SyncDetails,
SyncHistory,
SyncMovedDetails,
SyncUpdateDetails
} from "../tracing/sync-history";
import { SyncStatus, SyncType } from "../tracing/sync-history";
import { EMPTY_HASH, hash } from "../utils/hash";
import { deserialize } from "../utils/deserialize";
import type { Settings } from "../persistence/settings";
import type { FileOperations } from "../file-operations/file-operations";
import { createPromise } from "../utils/create-promise";
import { FileNotFoundError } from "../file-operations/file-not-found-error";
import { SyncResetError } from "../services/sync-reset-error";
import { globsToRegexes } from "../utils/globs-to-regexes";
import type { DocumentVersion } from "../services/types/DocumentVersion";
import type { DocumentUpdateResponse } from "../services/types/DocumentUpdateResponse";
import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent";
export class UnrestrictedSyncer {
private ignorePatterns: RegExp[];
public constructor(
private readonly logger: Logger,
private readonly database: Database,
private readonly settings: Settings,
private readonly syncService: SyncService,
private readonly operations: FileOperations,
private readonly history: SyncHistory
) {
this.ignorePatterns = globsToRegexes(
this.settings.getSettings().ignorePatterns,
this.logger
);
this.settings.addOnSettingsChangeListener((newSettings) => {
this.ignorePatterns = globsToRegexes(
newSettings.ignorePatterns,
this.logger
);
});
}
public async unrestrictedSyncLocallyCreatedFile(
document: DocumentRecord
): Promise<void> {
const updateDetails: SyncCreateDetails = {
type: SyncType.CREATE,
relativePath: document.relativePath
};
return this.executeSync(updateDetails, async () => {
if (document.isDeleted) {
this.logger.debug(
`Document ${document.relativePath} has been already deleted, no need to create it`
);
return;
}
const contentBytes = await this.operations.read(
document.relativePath
); // this can throw FileNotFoundError
const contentHash = hash(contentBytes);
const response = await this.syncService.create({
documentId: document.documentId,
relativePath: document.relativePath,
contentBytes
});
this.database.updateDocumentMetadata(
{
parentVersionId: response.vaultUpdateId,
hash: contentHash,
remoteRelativePath: response.relativePath
},
document
);
this.database.addSeenUpdateId(response.vaultUpdateId);
this.history.addHistoryEntry({
status: SyncStatus.SUCCESS,
details: updateDetails,
message: `Successfully uploaded locally created file`
});
});
}
public async unrestrictedSyncLocallyDeletedFile(
document: DocumentRecord
): Promise<void> {
const updateDetails: SyncDeleteDetails = {
type: SyncType.DELETE,
relativePath: document.relativePath
};
await this.executeSync(updateDetails, async () => {
const response = await this.syncService.delete({
documentId: document.documentId,
relativePath: document.relativePath
});
this.database.updateDocumentMetadata(
{
parentVersionId: response.vaultUpdateId,
hash: EMPTY_HASH,
remoteRelativePath: document.relativePath
},
document
);
this.database.addSeenUpdateId(response.vaultUpdateId);
this.history.addHistoryEntry({
status: SyncStatus.SUCCESS,
details: updateDetails,
message: `Successfully deleted locally deleted file on the server`,
author: response.userId
});
});
}
public async unrestrictedSyncLocallyUpdatedFile({
oldPath,
document,
// We use the same code path for both local and remote updates. We need to force the update
// if there are no local changes but we know that the remote version is newer.
force = false
}: {
oldPath?: RelativePath;
force?: boolean;
document: DocumentRecord;
}): Promise<void> {
const updateDetails: SyncUpdateDetails | SyncMovedDetails =
oldPath !== undefined
? {
type: SyncType.MOVE,
relativePath: document.relativePath,
movedFrom: oldPath
}
: {
type: SyncType.UPDATE,
relativePath: document.relativePath
};
await this.executeSync(updateDetails, async () => {
const originalRelativePath = document.relativePath;
if (document.isDeleted || document.metadata === undefined) {
this.logger.debug(
`Document ${document.relativePath} has been already deleted, no need to update it`
);
return;
}
const contentBytes = await this.operations.read(
document.relativePath
); // this can throw FileNotFoundError
let contentHash = hash(contentBytes);
const areThereLocalChanges = !(
document.metadata.hash === contentHash && oldPath === undefined
);
let response: DocumentVersion | DocumentUpdateResponse | undefined =
undefined;
if (areThereLocalChanges) {
response = await this.syncService.put({
documentId: document.documentId,
parentVersionId: document.metadata.parentVersionId,
relativePath: document.relativePath,
contentBytes
});
} else {
if (!force) {
this.logger.debug(
`File hash of ${document.relativePath} matches with last synced version and the path hasn't changed; no need to sync`
);
return;
}
response = await this.syncService.get({
documentId: document.documentId
});
}
// `document` is mutable and reflects the latest state in the local database
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (document.isDeleted) {
this.logger.info(
`Document ${document.relativePath} has been deleted before we could finish updating it`
);
this.database.addSeenUpdateId(response.vaultUpdateId);
return;
}
if (
// `Syncer` creates fake local document metadata for all remote docs with invalid hashes. The parent IDs will likely match
// the latest versions so we still need to update the local versions to turn the fakes into real metadata.
document.metadata.parentVersionId > response.vaultUpdateId
) {
this.logger.debug(
`Document ${document.relativePath} is already more up to date than the fetched version`
);
this.database.addSeenUpdateId(response.vaultUpdateId); // in case the previous `vaultUpdateId` update hasn't made it through
return;
}
if (response.isDeleted) {
this.history.addHistoryEntry({
status: SyncStatus.SUCCESS,
details: {
type: SyncType.DELETE,
relativePath: document.relativePath
},
message:
"File has been deleted remotely, so we deleted it locally",
author: response.userId
});
this.database.delete(document.relativePath);
this.database.updateDocumentMetadata(
{
parentVersionId: response.vaultUpdateId,
hash: EMPTY_HASH,
remoteRelativePath: response.relativePath
},
document
);
await this.operations.delete(document.relativePath);
this.database.addSeenUpdateId(response.vaultUpdateId);
return;
}
let actualPath = document.relativePath;
if (response.relativePath != originalRelativePath) {
actualPath = response.relativePath;
// Make sure to update the remote relative path to avoid uploading
// the file as a result of this filesystem event.
document.metadata.remoteRelativePath = response.relativePath;
await this.operations.move(
document.relativePath,
response.relativePath
); // this can throw FileNotFoundError
}
if (!("type" in response) || response.type === "MergingUpdate") {
const responseBytes = deserialize(response.contentBase64);
contentHash = hash(responseBytes);
this.database.updateDocumentMetadata(
{
parentVersionId: response.vaultUpdateId,
hash: contentHash,
remoteRelativePath: response.relativePath
},
document
);
await this.operations.write(
actualPath,
contentBytes,
responseBytes
);
if (!force) {
this.history.addHistoryEntry({
status: SyncStatus.SUCCESS,
details: updateDetails,
message: `The file we updated had been updated remotely, so we downloaded the merged version`
});
}
} else {
this.database.updateDocumentMetadata(
{
parentVersionId: response.vaultUpdateId,
hash: contentHash,
remoteRelativePath: response.relativePath
},
document
);
}
this.database.addSeenUpdateId(response.vaultUpdateId);
const actualUpdateDetails: SyncUpdateDetails | SyncMovedDetails =
oldPath !== undefined ||
response.relativePath != originalRelativePath
? {
type: SyncType.MOVE,
relativePath: response.relativePath,
movedFrom: originalRelativePath
}
: {
type: SyncType.UPDATE,
relativePath: response.relativePath
};
if (areThereLocalChanges) {
this.history.addHistoryEntry({
status: SyncStatus.SUCCESS,
details: actualUpdateDetails,
message: `Successfully uploaded locally updated file to the server`,
author: response.userId
});
} else {
this.history.addHistoryEntry({
status: SyncStatus.SUCCESS,
details: actualUpdateDetails,
message: `Successfully downloaded remotely updated file from the server`,
author: response.userId
});
}
});
}
public async unrestrictedSyncRemotelyUpdatedFile(
remoteVersion: DocumentVersionWithoutContent,
document?: DocumentRecord
): Promise<void> {
const updateDetails: SyncCreateDetails = {
type: SyncType.CREATE,
relativePath: remoteVersion.relativePath
};
await this.executeSync(updateDetails, async () => {
if (document?.metadata !== undefined) {
// If the file exists locally, let's pretend the user has updated it
// and deal with remote update/deletion within `unrestrictedSyncLocallyUpdatedFile`
if (
document.metadata.parentVersionId >=
remoteVersion.vaultUpdateId
) {
this.logger.debug(
`Document ${remoteVersion.relativePath} is already at least as up to date as the fetched version`
);
return;
}
return this.unrestrictedSyncLocallyUpdatedFile({
document,
force: true
});
} else if (remoteVersion.isDeleted) {
// Either the document hasn't made it to us before and therefore we don't need to delete it,
// or we already have it, in which case the preceeding if would've dealt with it
this.logger.debug(
`Document ${remoteVersion.relativePath} has been deleted remotely, no need to sync`
);
return;
}
// Don't download oversized files
const historyEntryForSkippedOversizedFile =
this.getHistoryEntryForSkippedOversizedFile(
remoteVersion.contentSize,
remoteVersion.relativePath
);
if (historyEntryForSkippedOversizedFile !== undefined) {
this.history.addHistoryEntry(
historyEntryForSkippedOversizedFile
);
return;
}
const content = (
await this.syncService.get({
documentId: remoteVersion.documentId
})
).contentBase64;
// We're trying to create an entirely new document that didn't exist locally
document = this.database.getDocumentByDocumentId(
remoteVersion.documentId
);
// It can happen that a concurrent sync operation has already created the document, so we can bail here
if (document !== undefined) {
this.logger.debug(
`Document ${remoteVersion.relativePath} has already been created locally, no need to create it again`
);
return;
}
const contentBytes = deserialize(content);
await this.operations.ensureClearPath(remoteVersion.relativePath);
const [promise, resolve] = createPromise();
this.database.updateDocumentMetadata(
{
parentVersionId: remoteVersion.vaultUpdateId,
hash: hash(contentBytes),
remoteRelativePath: remoteVersion.relativePath
},
this.database.createNewPendingDocument(
remoteVersion.documentId,
remoteVersion.relativePath,
promise
)
);
await this.operations.create(
remoteVersion.relativePath,
contentBytes
);
resolve();
this.database.removeDocumentPromise(promise);
this.history.addHistoryEntry({
status: SyncStatus.SUCCESS,
details: updateDetails,
message: `Successfully downloaded remote file which hadn't existed locally`,
author: remoteVersion.userId
});
});
}
public async executeSync<T>(
details: SyncDetails,
fn: () => Promise<T>
): Promise<T | undefined> {
for (const pattern of this.ignorePatterns) {
if (pattern.test(details.relativePath)) {
this.logger.debug(
`File '${details.relativePath}' is ignored by the ignore pattern: ${pattern}`
);
return; // bail without SKIPPED status because we were told to ignore this file and we shouldn't clutter up the history
}
}
try {
// Only check the size of files which already exist locally.
if (await this.operations.exists(details.relativePath)) {
const sizeInBytes = await this.operations.getFileSize(
details.relativePath
);
const historyEntryForSkippedOversizedFile =
this.getHistoryEntryForSkippedOversizedFile(
sizeInBytes,
details.relativePath
);
if (historyEntryForSkippedOversizedFile !== undefined) {
this.history.addHistoryEntry(
historyEntryForSkippedOversizedFile
);
return;
}
}
return await fn();
} catch (e) {
if (e instanceof FileNotFoundError) {
// A subsequent sync operation must have been creating to deal with this
this.logger.info(
`Skiping file '${details.relativePath}' because it no longer exists when trying to ${details.type.toLocaleLowerCase()} it`
);
return;
}
if (e instanceof SyncResetError) {
this.logger.info(
`Interrupting sync operation because of a reset`
);
return;
} else {
this.history.addHistoryEntry({
status: SyncStatus.ERROR,
details,
message: `Failed to sync file '${details.relativePath}' because of ${e} when trying to ${details.type.toLocaleLowerCase()} it`
});
throw e;
}
}
}
private getHistoryEntryForSkippedOversizedFile(
sizeInBytes: number,
relativePath: RelativePath
): CommonHistoryEntry | undefined {
const sizeInMB = Math.round(sizeInBytes / 1024 / 1024);
const { maxFileSizeMB } = this.settings.getSettings();
if (sizeInMB > maxFileSizeMB) {
return {
status: SyncStatus.SKIPPED,
details: {
type: SyncType.SKIPPED,
relativePath
},
message: `File size of ${sizeInMB} MB exceeds the maximum file size limit of ${
maxFileSizeMB
} MB`
};
}
}
}

View file

@ -0,0 +1,79 @@
export enum LogLevel {
DEBUG = "DEBUG",
INFO = "INFO",
WARNING = "WARNING",
ERROR = "ERROR"
}
const LOG_LEVEL_ORDER = {
[LogLevel.DEBUG]: 0,
[LogLevel.INFO]: 1,
[LogLevel.WARNING]: 2,
[LogLevel.ERROR]: 3
};
export class LogLine {
public timestamp = new Date();
public constructor(
public level: LogLevel,
public message: string
) {}
}
export class Logger {
private static readonly MAX_MESSAGES = 100000;
private readonly messages: LogLine[] = [];
private readonly onMessageListeners: ((message: LogLine) => unknown)[] = [];
public constructor(
...onMessageListeners: ((message: LogLine) => unknown)[]
) {
this.onMessageListeners = onMessageListeners;
}
public debug(message: string): void {
this.pushMessage(message, LogLevel.DEBUG);
}
public info(message: string): void {
this.pushMessage(message, LogLevel.INFO);
}
public warn(message: string): void {
this.pushMessage(message, LogLevel.WARNING);
}
public error(message: string): void {
this.pushMessage(message, LogLevel.ERROR);
}
public getMessages(mininumSeverity: LogLevel): LogLine[] {
return this.messages.filter(
(message) =>
LOG_LEVEL_ORDER[message.level] >=
LOG_LEVEL_ORDER[mininumSeverity]
);
}
public addOnMessageListener(listener: (message: LogLine) => unknown): void {
this.onMessageListeners.push(listener);
}
public reset(): void {
this.messages.length = 0;
this.debug("Logger has been reset");
}
private pushMessage(message: string, level: LogLevel): void {
const logLine = new LogLine(level, message);
this.messages.push(logLine);
while (this.messages.length > Logger.MAX_MESSAGES) {
this.messages.shift();
}
this.onMessageListeners.forEach((listener) => {
listener(logLine);
});
}
}

View file

@ -0,0 +1,174 @@
import type { RelativePath } from "../persistence/database";
import type { Logger } from "./logger";
export interface SyncCreateDetails {
type: SyncType.CREATE;
relativePath: RelativePath;
}
export interface SyncUpdateDetails {
type: SyncType.UPDATE;
relativePath: RelativePath;
}
export interface SyncMovedDetails {
type: SyncType.MOVE;
relativePath: RelativePath;
movedFrom: RelativePath;
}
export interface SyncDeleteDetails {
type: SyncType.DELETE;
relativePath: RelativePath;
}
export interface SyncSkippedDetails {
type: SyncType.SKIPPED;
relativePath: RelativePath;
}
export type SyncDetails =
| SyncCreateDetails
| SyncUpdateDetails
| SyncDeleteDetails
| SyncMovedDetails
| SyncSkippedDetails;
export interface CommonHistoryEntry {
status: SyncStatus;
message: string;
details: SyncDetails;
author?: string;
}
export enum SyncType {
CREATE = "CREATE",
UPDATE = "UPDATE",
DELETE = "DELETE",
MOVE = "MOVE",
SKIPPED = "SKIPPED"
}
export enum SyncStatus {
SUCCESS = "SUCCESS",
ERROR = "ERROR",
SKIPPED = "SKIPPED"
}
export type HistoryEntry = CommonHistoryEntry & { timestamp: Date };
export interface HistoryStats {
success: number;
error: number;
}
export class SyncHistory {
private static readonly MAX_ENTRIES = 5000;
private static readonly TIMEOUT_FOR_MERGING_ENTRIES_IN_SECONDS = 60;
private _entries: HistoryEntry[] = [];
private readonly syncHistoryUpdateListeners: ((
status: HistoryStats
) => unknown)[] = [];
private status: HistoryStats = {
success: 0,
error: 0
};
public constructor(private readonly logger: Logger) {}
public get entries(): readonly HistoryEntry[] {
return this._entries;
}
/**
* Insert the entry at the beginning of the history list. If the entry
* already in the list, it will get moved to the beginning and updated.
*
* If the entry list is too long, the oldest entry will be removed.
*/
public addHistoryEntry(entry: CommonHistoryEntry): void {
const historyEntry = {
...entry,
timestamp: new Date()
};
const candidate = this.findSimilarRecentUpdateEntry(historyEntry);
if (candidate !== undefined) {
this._entries = this._entries.filter((e) => e !== candidate);
}
// Insert the entry at the beginning
this._entries.unshift(historyEntry);
if (this._entries.length > SyncHistory.MAX_ENTRIES) {
this._entries.pop();
}
this.updateSuccessCount(historyEntry);
}
public addSyncHistoryUpdateListener(
listener: (stats: HistoryStats) => unknown
): void {
this.syncHistoryUpdateListeners.push(listener);
listener({ ...this.status });
}
public reset(): void {
this._entries.length = 0;
this.status = {
success: 0,
error: 0
};
this.syncHistoryUpdateListeners.forEach((listener) => {
listener(this.status);
});
}
private findSimilarRecentUpdateEntry(
entry: HistoryEntry
): HistoryEntry | undefined {
if (entry.details.type !== SyncType.UPDATE) {
return;
}
const candidate = this._entries.find(
(e) =>
e.details.type === SyncType.UPDATE &&
e.details.relativePath === entry.details.relativePath
);
if (
candidate !== undefined &&
(this._entries[0] === candidate ||
candidate.timestamp.getTime() +
SyncHistory.TIMEOUT_FOR_MERGING_ENTRIES_IN_SECONDS * 1000 >
entry.timestamp.getTime())
) {
return candidate;
}
}
private updateSuccessCount(entry: HistoryEntry): void {
const message = `${entry.details.relativePath} - ${entry.message} (${entry.details.type.toLocaleLowerCase()})`;
switch (entry.status) {
case SyncStatus.SUCCESS:
this.status.success++;
this.logger.info(`History entry: ${message}`);
break;
case SyncStatus.ERROR:
this.status.error++;
this.logger.error(`Cannot sync file: ${message}`);
break;
case SyncStatus.SKIPPED:
this.logger.error(`Skipping file: ${message}`);
break;
}
this.syncHistoryUpdateListeners.forEach((listener) => {
listener(this.status);
});
}
}

View file

@ -0,0 +1,5 @@
export enum DocumentSyncStatus {
UP_TO_DATE = "UP_TO_DATE",
SYNCING = "SYNCING",
SYNCING_IS_DISABLED = "SYNCING_IS_DISABLED"
}

View file

@ -0,0 +1,5 @@
export enum DocumentUpToDateness {
UpToDate = "UpToDate", // easiest case, the client can just show the cursors as-is
Prior = "Prior", // The cursors are outdated, so the client has to guess the cursor positions based on local updates. This is only possible if this client's cursor has once been up-to-date in a given document.
Later = "Later" // The cursors are from a future version of a document, there's no way we can accuratly show them locally.
}

View file

@ -0,0 +1,5 @@
import type { ClientCursors } from "../services/types/ClientCursors";
export interface MaybeOutdatedClientCursors extends ClientCursors {
isOutdated: boolean;
}

View file

@ -0,0 +1,5 @@
export interface NetworkConnectionStatus {
isSuccessful: boolean;
serverMessage: string;
isWebSocketConnected: boolean;
}

View file

@ -0,0 +1,13 @@
import assert from "node:assert";
export function assertSetContainsExactly<T>(set: Set<T>, ...values: T[]): void {
assert.ok(
set.size === values.length &&
Array.from(set).every((value) => values.includes(value)),
`Expected set to contain only ${values.map((v) => '"' + v + '"').join(", ")}, but it contained ${Array.from(
set
)
.map((v) => '"' + v + '"')
.join(", ")}`
);
}

View file

@ -0,0 +1,15 @@
import { v4 as uuidv4 } from "uuid";
export function createClientId(): string {
// @ts-expect-error, injected by webpack
const packageVersion = __CURRENT_VERSION__; // eslint-disable-line
const platform =
typeof navigator !== "undefined"
? navigator.platform // eslint-disable-line @typescript-eslint/no-deprecated
: typeof process !== "undefined"
? process.platform
: "unknown";
return `vault-link/${packageVersion} (${uuidv4()}; ${platform})`;
}

View file

@ -0,0 +1,25 @@
type ResolveFunction<T> = undefined extends T
? (value?: T) => unknown
: (value: T) => unknown;
/**
* A type-safe utility function to create a Promise with resolve and reject functions.
* @returns A tuple containing a Promise, a resolve function, and a reject function.
*/
export function createPromise<T = unknown>(): [
Promise<T>,
ResolveFunction<T>,
(error: unknown) => unknown
] {
let resolve: undefined | ResolveFunction<T> = undefined;
let reject: undefined | ((error: unknown) => unknown) = undefined;
const creationPromise = new Promise<T>(
(resolve_, reject_) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
((resolve = resolve_ as ResolveFunction<T>), (reject = reject_))
);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return [creationPromise, resolve!, reject!];
}

View file

@ -0,0 +1,5 @@
import { base64ToBytes } from "byte-base64";
export function deserialize(data: string): Uint8Array {
return base64ToBytes(data);
}

View file

@ -0,0 +1,14 @@
import type { DocumentRecord } from "../persistence/database";
import { EMPTY_HASH } from "./hash";
// TODO: make this smarter so that offline files can be renamed & edited at the same time
export function findMatchingFile(
contentHash: string,
candidates: DocumentRecord[]
): DocumentRecord | undefined {
if (contentHash === EMPTY_HASH) {
return undefined;
}
return candidates.find(({ metadata }) => metadata?.hash === contentHash);
}

View file

@ -0,0 +1,9 @@
export function getRandomColor(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash << 5) - hash + name.charCodeAt(i);
hash |= 0; // Convert to 32bit integer
}
const normalised = hash / 0x7fffffff;
return `oklch(0.58 0.15 ${Math.round(Math.abs(normalised * 360))})`;
}

View file

@ -0,0 +1,13 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { Logger } from "../tracing/logger";
import { globsToRegexes } from "./globs-to-regexes";
describe("globsToRegexes", () => {
it("basicExample", async () => {
const [regex] = globsToRegexes([".git/**"], new Logger());
assert.ok(regex.test(".git/objects/object"));
assert.ok(regex.test(".git/objects/.object"));
});
});

View file

@ -0,0 +1,18 @@
import { makeRe } from "minimatch";
import type { Logger } from "../tracing/logger";
export function globsToRegexes(globs: string[], logger: Logger): RegExp[] {
return globs
.map((pattern) => {
const result = makeRe(pattern, {
dot: true
});
if (result === false) {
logger.warn(
`Failed to parse ${pattern}' as a glob pattern, skipping it`
);
}
return result;
})
.filter((pattern) => pattern !== false);
}

View file

@ -0,0 +1,12 @@
// https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript
export function hash(content: Uint8Array): string {
let result = 0;
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < content.length; i++) {
result = (result << 5) - result + content[i];
result |= 0; // Convert to 32bit integer
}
return Math.abs(result).toString(16).padStart(8, "0");
}
export const EMPTY_HASH = hash(new Uint8Array(0));

View file

@ -0,0 +1,29 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { isEqualBytes } from "./is-equal-bytes";
describe("isEqualBytes", () => {
it("should return true for equal byte arrays", () => {
const bytes1 = new Uint8Array([1, 2, 3, 4]);
const bytes2 = new Uint8Array([1, 2, 3, 4]);
assert.strictEqual(isEqualBytes(bytes1, bytes2), true);
});
it("should return false for byte arrays of different lengths", () => {
const bytes1 = new Uint8Array([1, 2, 3, 4]);
const bytes2 = new Uint8Array([1, 2, 3]);
assert.strictEqual(isEqualBytes(bytes1, bytes2), false);
});
it("should return true for empty byte arrays", () => {
const bytes1 = new Uint8Array([]);
const bytes2 = new Uint8Array([]);
assert.strictEqual(isEqualBytes(bytes1, bytes2), true);
});
it("should return false for byte arrays with same length but different content", () => {
const bytes1 = new Uint8Array([1, 2, 3, 4]);
const bytes2 = new Uint8Array([4, 3, 2, 1]);
assert.strictEqual(isEqualBytes(bytes1, bytes2), false);
});
});

Some files were not shown because too many files have changed in this diff Show more