WIP: Quality of life features #180
174 changed files with 21319 additions and 17689 deletions
|
|
@ -11,5 +11,6 @@ indent_style = space
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
tab_width = 4
|
tab_width = 4
|
||||||
|
|
||||||
[*.{yml,yaml}]
|
[*.{yml,yaml,md}]
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
|
tab_width = 2
|
||||||
|
|
|
||||||
6
.github/workflows/deploy-docs.yml
vendored
6
.github/workflows/deploy-docs.yml
vendored
|
|
@ -42,14 +42,10 @@ jobs:
|
||||||
cd docs
|
cd docs
|
||||||
npm ci
|
npm ci
|
||||||
|
|
||||||
- name: Check formatting
|
- name: Check formatting & spelling
|
||||||
run: |
|
run: |
|
||||||
cd docs
|
cd docs
|
||||||
npm run format:check
|
npm run format:check
|
||||||
|
|
||||||
- name: Check spelling
|
|
||||||
run: |
|
|
||||||
cd docs
|
|
||||||
npm run spell:check
|
npm run spell:check
|
||||||
|
|
||||||
- name: Build documentation
|
- name: Build documentation
|
||||||
|
|
|
||||||
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
|
|
@ -6,7 +6,7 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ["main"]
|
branches: ["main"]
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '*/30 * * * *'
|
- cron: '0 * * * *'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: e2e-tests
|
group: e2e-tests
|
||||||
|
|
|
||||||
1
docs/.gitignore
vendored
1
docs/.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
||||||
.vitepress/dist/
|
.vitepress/dist/
|
||||||
.vitepress/cache/
|
.vitepress/cache/
|
||||||
|
.vitepress/.temp/
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"printWidth": 120,
|
"printWidth": 120,
|
||||||
"tabWidth": 4,
|
"tabWidth": 4,
|
||||||
"useTabs": true,
|
"useTabs": false,
|
||||||
"semi": false,
|
"semi": false,
|
||||||
"singleQuote": false,
|
"singleQuote": false,
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,60 @@
|
||||||
import { defineConfig } from "vitepress"
|
import { defineConfig } from "vitepress"
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
title: "VaultLink",
|
title: "VaultLink",
|
||||||
description: "Self-hosted real-time synchronisation for Obsidian",
|
description: "Self-hosted real-time synchronisation for Obsidian",
|
||||||
base: "/vault-link/",
|
base: "/",
|
||||||
themeConfig: {
|
themeConfig: {
|
||||||
logo: "/logo.svg",
|
logo: "/logo.svg",
|
||||||
nav: [
|
nav: [
|
||||||
{ text: "Home", link: "/" },
|
{ text: "Home", link: "/" },
|
||||||
{ text: "Guide", link: "/guide/getting-started" },
|
{ text: "Guide", link: "/guide/getting-started" },
|
||||||
{ text: "Architecture", link: "/architecture/" },
|
{ text: "Architecture", link: "/architecture/" },
|
||||||
{ text: "GitHub", link: "https://github.com/schmelczer/vault-link" }
|
{ text: "GitHub", link: "https://github.com/schmelczer/vault-link" }
|
||||||
],
|
],
|
||||||
sidebar: [
|
sidebar: [
|
||||||
{
|
{
|
||||||
text: "Introduction",
|
text: "Introduction",
|
||||||
items: [
|
items: [
|
||||||
{ text: "What is VaultLink?", link: "/guide/what-is-vaultlink" },
|
{ text: "What is VaultLink?", link: "/guide/what-is-vaultlink" },
|
||||||
{ text: "Getting Started", link: "/guide/getting-started" },
|
{ text: "Getting Started", link: "/guide/getting-started" },
|
||||||
{ text: "Limitations", link: "/guide/limitations" },
|
{ text: "Limitations", link: "/guide/limitations" },
|
||||||
{ text: "Comparison with Alternatives", link: "/guide/alternatives" }
|
{ text: "Comparison with Alternatives", link: "/guide/alternatives" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Setup",
|
text: "Setup",
|
||||||
items: [
|
items: [
|
||||||
{ text: "Server Setup", link: "/guide/server-setup" },
|
{ text: "Server Setup", link: "/guide/server-setup" },
|
||||||
{ text: "Obsidian Plugin", link: "/guide/obsidian-plugin" },
|
{ text: "Obsidian Plugin", link: "/guide/obsidian-plugin" },
|
||||||
{ text: "CLI Client", link: "/guide/cli-client" }
|
{ text: "CLI Client", link: "/guide/cli-client" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Configuration",
|
text: "Configuration",
|
||||||
items: [
|
items: [
|
||||||
{ text: "Server Configuration", link: "/config/server" },
|
{ text: "Server Configuration", link: "/config/server" },
|
||||||
{ text: "Authentication", link: "/config/authentication" },
|
{ text: "Authentication", link: "/config/authentication" },
|
||||||
{ text: "Advanced Options", link: "/config/advanced" }
|
{ text: "Advanced Options", link: "/config/advanced" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Architecture",
|
text: "Architecture",
|
||||||
items: [
|
items: [
|
||||||
{ text: "Overview", link: "/architecture/" },
|
{ text: "Overview", link: "/architecture/" },
|
||||||
{ text: "Sync Algorithm", link: "/architecture/sync-algorithm" },
|
{ text: "Sync Algorithm", link: "/architecture/sync-algorithm" },
|
||||||
{ text: "Data Flow", link: "/architecture/data-flow" }
|
{ text: "Data Flow", link: "/architecture/data-flow" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
socialLinks: [{ icon: "github", link: "https://github.com/schmelczer/vault-link" }],
|
socialLinks: [{ icon: "github", link: "https://github.com/schmelczer/vault-link" }],
|
||||||
footer: {
|
footer: {
|
||||||
message: "Released under the MIT License.",
|
message: "Released under the MIT License.",
|
||||||
copyright: "Copyright © 2024-present Andras Schmelczer"
|
copyright: "Copyright © 2024-present Andras Schmelczer"
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
provider: "local"
|
provider: "local"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
head: [["link", { rel: "icon", type: "image/svg+xml", href: "/vault-link/logo.svg" }]]
|
head: [["link", { rel: "icon", type: "image/svg+xml", href: "/logo.svg" }]]
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -125,37 +125,37 @@ sequenceDiagram
|
||||||
```
|
```
|
||||||
┌─────────┐
|
┌─────────┐
|
||||||
│ Client │
|
│ Client │
|
||||||
└────┬────┘
|
└───┬─-───┘
|
||||||
│ 1. Detect file change
|
│ 1. Detect file change
|
||||||
│
|
│
|
||||||
├─► 2. Read file content
|
├─► 2. Read file content
|
||||||
│
|
│
|
||||||
├─► 3. Create upload message
|
├─► 3. Create upload message
|
||||||
│ {
|
│ {
|
||||||
│ type: "upload_file",
|
│ type: "upload_file",
|
||||||
│ path: "notes/daily.md",
|
│ path: "notes/daily.md",
|
||||||
│ content: "...",
|
│ content: "...",
|
||||||
│ version: 42,
|
│ version: 42,
|
||||||
│ timestamp: "2024-01-01T12:00:00Z"
|
│ timestamp: "2024-01-01T12:00:00Z"
|
||||||
│ }
|
│ }
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────┐
|
┌─────────┐
|
||||||
│ Server │
|
│ Server │
|
||||||
└────┬────┘
|
└───┬────-┘
|
||||||
│ 4. Validate message
|
│ 4. Validate message
|
||||||
│
|
│
|
||||||
├─► 5. Check permissions
|
├─► 5. Check permissions
|
||||||
│
|
│
|
||||||
├─► 6. Apply OT (if conflicts)
|
├─► 6. Apply OT (if conflicts)
|
||||||
│
|
│
|
||||||
├─► 7. Store in database
|
├─► 7. Store in database
|
||||||
│
|
│
|
||||||
├─► 8. Update version
|
├─► 8. Update version
|
||||||
│
|
│
|
||||||
├─► 9. Broadcast to clients
|
├─► 9. Broadcast to clients
|
||||||
│
|
│
|
||||||
└─► 10. Send ACK to uploader
|
└─► 10. Send ACK to uploader
|
||||||
```
|
```
|
||||||
|
|
||||||
### Download
|
### Download
|
||||||
|
|
@ -163,36 +163,36 @@ sequenceDiagram
|
||||||
```
|
```
|
||||||
┌─────────┐
|
┌─────────┐
|
||||||
│ Server │
|
│ Server │
|
||||||
└────┬────┘
|
└───┬─-───┘
|
||||||
│ 1. File updated by another client
|
│ 1. File updated by another client
|
||||||
│
|
│
|
||||||
├─► 2. Broadcast notification
|
├─► 2. Broadcast notification
|
||||||
│ {
|
│ {
|
||||||
│ type: "file_updated",
|
│ type: "file_updated",
|
||||||
│ path: "notes/daily.md",
|
│ path: "notes/daily.md",
|
||||||
│ version: 43
|
│ version: 43
|
||||||
│ }
|
│ }
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────┐
|
┌─────────┐
|
||||||
│ Client │
|
│ Client │
|
||||||
└────┬────┘
|
└───┬─-───┘
|
||||||
│ 3. Receive notification
|
│ 3. Receive notification
|
||||||
│
|
│
|
||||||
├─► 4. Request file download
|
├─► 4. Request file download
|
||||||
│ {
|
│ {
|
||||||
│ type: "download_file",
|
│ type: "download_file",
|
||||||
│ path: "notes/daily.md",
|
│ path: "notes/daily.md",
|
||||||
│ version: 43
|
│ version: 43
|
||||||
│ }
|
│ }
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────┐
|
┌─────────┐
|
||||||
│ Server │
|
│ Server │
|
||||||
└────┬────┘
|
└───┬─=───┘
|
||||||
│ 5. Retrieve from database
|
│ 5. Retrieve from database
|
||||||
│
|
│
|
||||||
└─► 6. Send file content
|
└─► 6. Send file content
|
||||||
{
|
{
|
||||||
type: "file_content",
|
type: "file_content",
|
||||||
path: "notes/daily.md",
|
path: "notes/daily.md",
|
||||||
|
|
@ -201,9 +201,9 @@ sequenceDiagram
|
||||||
}
|
}
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────┐
|
┌─────────┐
|
||||||
│ Client │
|
│ Client │
|
||||||
└────┬────┘
|
└───-─┬───┘
|
||||||
│ 7. Write to filesystem
|
│ 7. Write to filesystem
|
||||||
│
|
│
|
||||||
└─► 8. Update local metadata
|
└─► 8. Update local metadata
|
||||||
|
|
@ -215,30 +215,30 @@ sequenceDiagram
|
||||||
┌─────────┐
|
┌─────────┐
|
||||||
│ Client │
|
│ Client │
|
||||||
└────┬────┘
|
└────┬────┘
|
||||||
│ 1. File deleted locally
|
│ 1. File deleted locally
|
||||||
│
|
│
|
||||||
├─► 2. Send delete message
|
├─► 2. Send delete message
|
||||||
│ {
|
│ {
|
||||||
│ type: "delete_file",
|
│ type: "delete_file",
|
||||||
│ path: "notes/old.md"
|
│ path: "notes/old.md"
|
||||||
│ }
|
│ }
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────┐
|
┌─────────┐
|
||||||
│ Server │
|
│ Server │
|
||||||
└────┬────┘
|
└────┬────┘
|
||||||
│ 3. Mark as deleted in DB
|
│ 3. Mark as deleted in DB
|
||||||
│ (soft delete for history)
|
│ (soft delete for history)
|
||||||
│
|
│
|
||||||
├─► 4. Broadcast deletion
|
├─► 4. Broadcast deletion
|
||||||
│
|
│
|
||||||
└─► 5. ACK to sender
|
└─► 5. ACK to sender
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────┐
|
┌─────────┐
|
||||||
│ Other │
|
│ Other │
|
||||||
│ Clients │
|
│ Clients │
|
||||||
└────┬────┘
|
└────┬────┘
|
||||||
│ 6. Delete local file
|
│ 6. Delete local file
|
||||||
│
|
│
|
||||||
└─► 7. Update metadata
|
└─► 7. Update metadata
|
||||||
|
|
@ -252,32 +252,32 @@ sequenceDiagram
|
||||||
Time →
|
Time →
|
||||||
|
|
||||||
Client A Server Client B
|
Client A Server Client B
|
||||||
│ │ │
|
│ │ │
|
||||||
│ Edit file v10 │ │
|
│ Edit file v10 │ │
|
||||||
│ "Add line A" │ │ Edit file v10
|
│ "Add line A" │ │ Edit file v10
|
||||||
│ │ │ "Add line B"
|
│ │ │ "Add line B"
|
||||||
│ │ │
|
│ │ │
|
||||||
├─── Upload @ t1 ─────────►│ │
|
├─── Upload @ t1 ─────────►│ │
|
||||||
│ │◄────── Upload @ t2 ────────┤
|
│ │◄────── Upload @ t2 ────────┤
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ 1. Receive both edits │
|
│ │ 1. Receive both edits │
|
||||||
│ │ (based on v10) │
|
│ │ (based on v10) │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ 2. Apply first edit │
|
│ │ 2. Apply first edit │
|
||||||
│ │ → v11 (line A added) │
|
│ │ → v11 (line A added) │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ 3. Transform second edit │
|
│ │ 3. Transform second edit │
|
||||||
│ │ against first │
|
│ │ against first │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ 4. Apply transformed edit │
|
│ │ 4. Apply transformed edit │
|
||||||
│ │ → v12 (both lines) │
|
│ │ → v12 (both lines) │
|
||||||
│ │ │
|
│ │ │
|
||||||
│◄──── v12 content ────────┤ │
|
│◄──── v12 content ────────┤ │
|
||||||
│ ├───── v12 content ─────────►│
|
│ ├───── v12 content ─────────►│
|
||||||
│ │ │
|
│ │ │
|
||||||
│ Apply v12 │ │ Apply v12
|
│ Apply v12 │ │ Apply v12
|
||||||
│ (has both lines) │ │ (has both lines)
|
│ (has both lines) │ │ (has both lines)
|
||||||
│ │ │
|
│ │ │
|
||||||
```
|
```
|
||||||
|
|
||||||
### Conflict Resolution Steps
|
### Conflict Resolution Steps
|
||||||
|
|
@ -361,11 +361,11 @@ VALUES (?, ?, ?);
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "upload_file",
|
"type": "upload_file",
|
||||||
"path": "notes/example.md",
|
"path": "notes/example.md",
|
||||||
"content": "File content here...",
|
"content": "File content here...",
|
||||||
"base_version": 10,
|
"base_version": 10,
|
||||||
"timestamp": "2024-01-01T12:00:00Z"
|
"timestamp": "2024-01-01T12:00:00Z"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -373,8 +373,8 @@ VALUES (?, ?, ?);
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "download_file",
|
"type": "download_file",
|
||||||
"path": "notes/example.md"
|
"path": "notes/example.md"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -382,8 +382,8 @@ VALUES (?, ?, ?);
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "delete_file",
|
"type": "delete_file",
|
||||||
"path": "notes/old.md"
|
"path": "notes/old.md"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -391,8 +391,8 @@ VALUES (?, ?, ?);
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "list_files",
|
"type": "list_files",
|
||||||
"since_version": 0
|
"since_version": 0
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -402,11 +402,11 @@ VALUES (?, ?, ?);
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "file_updated",
|
"type": "file_updated",
|
||||||
"path": "notes/example.md",
|
"path": "notes/example.md",
|
||||||
"version": 11,
|
"version": 11,
|
||||||
"size": 1024,
|
"size": 1024,
|
||||||
"hash": "abc123..."
|
"hash": "abc123..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -414,10 +414,10 @@ VALUES (?, ?, ?);
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "file_content",
|
"type": "file_content",
|
||||||
"path": "notes/example.md",
|
"path": "notes/example.md",
|
||||||
"content": "Updated content...",
|
"content": "Updated content...",
|
||||||
"version": 11
|
"version": 11
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -425,9 +425,9 @@ VALUES (?, ?, ?);
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "file_deleted",
|
"type": "file_deleted",
|
||||||
"path": "notes/old.md",
|
"path": "notes/old.md",
|
||||||
"version": 12
|
"version": 12
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -435,9 +435,9 @@ VALUES (?, ?, ?);
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "sync_complete",
|
"type": "sync_complete",
|
||||||
"total_files": 150,
|
"total_files": 150,
|
||||||
"current_version": 200
|
"current_version": 200
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -445,9 +445,9 @@ VALUES (?, ?, ?);
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "error",
|
"type": "error",
|
||||||
"message": "File too large",
|
"message": "File too large",
|
||||||
"code": "FILE_TOO_LARGE"
|
"code": "FILE_TOO_LARGE"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,10 @@ Central sync server with multiple clients. High-level architecture and design de
|
||||||
│ Obsidian Plugin │ Obsidian Plugin │ CLI Client │
|
│ Obsidian Plugin │ Obsidian Plugin │ CLI Client │
|
||||||
│ (User A - Device1) │ (User A - Device2│ (Server/Backup) │
|
│ (User A - Device1) │ (User A - Device2│ (Server/Backup) │
|
||||||
└──────────┬──────────┴─────────┬─────────┴──────────┬────────┘
|
└──────────┬──────────┴─────────┬─────────┴──────────┬────────┘
|
||||||
│ │ │
|
│ │ │
|
||||||
│ WebSocket │ WebSocket │ WebSocket
|
│ WebSocket │ WebSocket │ WebSocket
|
||||||
│ │ │
|
│ │ │
|
||||||
└────────────────────┼────────────────────┘
|
└────────────────────┼────────────────────┘
|
||||||
│
|
│
|
||||||
┌───────────▼───────────┐
|
┌───────────▼───────────┐
|
||||||
│ Sync Server │
|
│ Sync Server │
|
||||||
|
|
|
||||||
|
|
@ -243,9 +243,9 @@ users:
|
||||||
2. Client sends authentication message:
|
2. Client sends authentication message:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "auth",
|
"type": "auth",
|
||||||
"token": "user-token",
|
"token": "user-token",
|
||||||
"vault": "vault-name"
|
"vault": "vault-name"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
3. Server validates:
|
3. Server validates:
|
||||||
|
|
|
||||||
5964
docs/package-lock.json
generated
5964
docs/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,8 @@
|
||||||
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
<stop offset="0%" style="stop-color:#4A90E2;stop-opacity:1" />
|
<stop offset="0%" style="stop-color:#4A90E2;stop-opacity:1" />
|
||||||
<stop offset="100%" style="stop-color:#357ABD;stop-opacity:1" />
|
<stop offset="100%" style="stop-color:#357ABD;stop-opacity:1" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
|
|
@ -25,23 +25,23 @@
|
||||||
|
|
||||||
<!-- Link chain -->
|
<!-- Link chain -->
|
||||||
<g opacity="0.9">
|
<g opacity="0.9">
|
||||||
<!-- Left link -->
|
<!-- Left link -->
|
||||||
<ellipse cx="-30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
<ellipse cx="-30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
||||||
<!-- Right link -->
|
<!-- Right link -->
|
||||||
<ellipse cx="30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
<ellipse cx="30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
||||||
<!-- Center link connecting them -->
|
<!-- Center link connecting them -->
|
||||||
<ellipse cx="0" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
<ellipse cx="0" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- Sync arrows (subtle) -->
|
<!-- Sync arrows (subtle) -->
|
||||||
<g opacity="0.5">
|
<g opacity="0.5">
|
||||||
<!-- Clockwise arrow top-right -->
|
<!-- Clockwise arrow top-right -->
|
||||||
<path d="M 35 -35 Q 50 -35 50 -20 L 50 -15" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
|
<path d="M 35 -35 Q 50 -35 50 -20 L 50 -15" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
<polygon points="50,-15 47,-22 53,-22" fill="url(#grad1)"/>
|
<polygon points="50,-15 47,-22 53,-22" fill="url(#grad1)"/>
|
||||||
|
|
||||||
<!-- Counter-clockwise arrow bottom-left -->
|
<!-- Counter-clockwise arrow bottom-left -->
|
||||||
<path d="M -35 25 Q -50 25 -50 10 L -50 5" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
|
<path d="M -35 25 Q -50 25 -50 10 L -50 5" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
<polygon points="-50,5 -47,12 -53,12" fill="url(#grad1)"/>
|
<polygon points="-50,5 -47,12 -53,12" fill="url(#grad1)"/>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 2 KiB |
|
|
@ -3,73 +3,92 @@ import tseslint from "typescript-eslint";
|
||||||
import unusedImports from "eslint-plugin-unused-imports";
|
import unusedImports from "eslint-plugin-unused-imports";
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
"sync-client/src/services/types.ts",
|
"sync-client/src/services/types.ts",
|
||||||
"**/dist/",
|
"**/dist/",
|
||||||
"**/*.mjs",
|
"**/*.mjs",
|
||||||
"**/*.js"
|
"**/*.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
...tseslint.config({
|
...tseslint.config({
|
||||||
plugins: {
|
plugins: {
|
||||||
"unused-imports": unusedImports
|
"unused-imports": unusedImports
|
||||||
},
|
},
|
||||||
extends: [eslint.configs.recommended, tseslint.configs.all],
|
extends: [eslint.configs.recommended, tseslint.configs.all],
|
||||||
rules: {
|
rules: {
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "off",
|
||||||
"@typescript-eslint/restrict-template-expressions": "off",
|
"@typescript-eslint/restrict-template-expressions": "off",
|
||||||
"@typescript-eslint/no-unused-vars": "off",
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
"@typescript-eslint/no-floating-promises": [
|
"@typescript-eslint/no-floating-promises": [
|
||||||
"error",
|
"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": "off",
|
|
||||||
"@typescript-eslint/no-magic-numbers": "off",
|
|
||||||
"@typescript-eslint/prefer-readonly-parameter-types": "off",
|
|
||||||
"@typescript-eslint/naming-convention": "off",
|
|
||||||
"no-restricted-properties": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
object: "Promise",
|
|
||||||
property: "all",
|
|
||||||
message: "Use `awaitAll` instead of Promise.all to always await all promises."
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
object: "Promise",
|
allowForKnownSafeCalls: [
|
||||||
property: "allSettled",
|
{ from: "package", name: ["suite", "test"], package: "node:test" },
|
||||||
message: "Use `awaitAll` instead of Promise.allSettled to always await all promises and throw on errors."
|
],
|
||||||
},
|
},
|
||||||
{
|
],
|
||||||
object: "String",
|
"@typescript-eslint/parameter-properties": "off",
|
||||||
property: "replace",
|
"@typescript-eslint/require-await": "off",
|
||||||
message: "Use replaceAll instead of replace to replace all occurrences of a substring."
|
"@typescript-eslint/class-methods-use-this": "off",
|
||||||
}
|
"@typescript-eslint/consistent-return": "off",
|
||||||
],
|
"@typescript-eslint/no-unsafe-argument": "off",
|
||||||
"unused-imports/no-unused-vars": [
|
"@typescript-eslint/max-params": "off",
|
||||||
"warn",
|
"@typescript-eslint/no-magic-numbers": "off",
|
||||||
{
|
"@typescript-eslint/prefer-readonly-parameter-types": "off",
|
||||||
vars: "all",
|
"@typescript-eslint/naming-convention": "off",
|
||||||
varsIgnorePattern: "^_",
|
"no-restricted-properties": [
|
||||||
args: "after-used",
|
"error",
|
||||||
argsIgnorePattern: "^_"
|
{
|
||||||
}
|
object: "Promise",
|
||||||
]
|
property: "all",
|
||||||
},
|
message: "Use `awaitAll` instead of Promise.all to always await all promises."
|
||||||
languageOptions: {
|
},
|
||||||
parserOptions: {
|
{
|
||||||
projectService: true,
|
object: "Promise",
|
||||||
tsconfigRootDir: import.meta.dirname
|
property: "allSettled",
|
||||||
}
|
message: "Use `awaitAll` instead of Promise.allSettled to always await all promises and throw on errors."
|
||||||
}
|
},
|
||||||
})
|
{
|
||||||
|
object: "String",
|
||||||
|
property: "replace",
|
||||||
|
message: "Use replaceAll instead of replace to replace all occurrences of a substring."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-restricted-syntax": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
selector: "CallExpression[callee.property.name='splice'][arguments.length=2][arguments.1.type='Literal'][arguments.1.value=1]",
|
||||||
|
message: "Use `removeFromArray(array, item)` instead of manually using indexOf + splice(index, 1). Import from 'sync-client/src/utils/remove-from-array'."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: "CallExpression[callee.property.name='filter'] > ArrowFunctionExpression[body.type='BinaryExpression'][body.operator='!==']",
|
||||||
|
message: "Use `removeFromArray(array, item)` instead of filter(x => x !== item) for better performance. Import from 'sync-client/src/utils/remove-from-array'."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: "CallExpression[callee.property.name='filter'] > ArrowFunctionExpression > BlockStatement > ReturnStatement > BinaryExpression[operator='!==']",
|
||||||
|
message: "Use `removeFromArray(array, item)` instead of filter(x => { return x !== item }) for better performance. Import from 'sync-client/src/utils/remove-from-array'."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: "CallExpression[callee.property.name='filter'] > FunctionExpression[body.type='BlockStatement'] > BlockStatement > ReturnStatement > BinaryExpression[operator='!==']",
|
||||||
|
message: "Use `removeFromArray(array, item)` instead of filter(function(x) { return x !== item }) for better performance. Import from 'sync-client/src/utils/remove-from-array'."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"unused-imports/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
vars: "all",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
args: "after-used",
|
||||||
|
argsIgnorePattern: "^_"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,28 @@
|
||||||
{
|
{
|
||||||
"name": "local-client-cli",
|
"name": "local-client-cli",
|
||||||
"version": "0.12.0",
|
"version": "0.12.0",
|
||||||
"description": "Standalone CLI for VaultLink sync client",
|
"description": "Standalone CLI for VaultLink sync client",
|
||||||
"private": false,
|
"private": false,
|
||||||
"bin": {
|
"bin": {
|
||||||
"vaultlink": "./dist/cli.js"
|
"vaultlink": "./dist/cli.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "webpack watch --mode development",
|
"dev": "webpack watch --mode development",
|
||||||
"build": "webpack --mode production",
|
"build": "webpack --mode production",
|
||||||
"test": "tsx --test src/args.test.ts src/node-filesystem.test.ts"
|
"test": "tsx --test 'src/**/*.test.ts'"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"commander": "^14.0.2"
|
"commander": "^14.0.2",
|
||||||
},
|
"watcher": "^2.3.1"
|
||||||
"devDependencies": {
|
},
|
||||||
"@types/node": "^24.8.1",
|
"devDependencies": {
|
||||||
"sync-client": "file:../sync-client",
|
"@types/node": "^24.8.1",
|
||||||
"ts-loader": "^9.5.2",
|
"sync-client": "file:../sync-client",
|
||||||
"tslib": "2.8.1",
|
"ts-loader": "^9.5.2",
|
||||||
"tsx": "^4.20.6",
|
"tslib": "2.8.1",
|
||||||
"typescript": "5.8.3",
|
"tsx": "^4.20.6",
|
||||||
"webpack": "^5.99.9",
|
"typescript": "5.8.3",
|
||||||
"webpack-cli": "^6.0.1"
|
"webpack": "^5.99.9",
|
||||||
}
|
"webpack-cli": "^6.0.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,227 +4,227 @@ import { parseArgs } from "./args";
|
||||||
import { LogLevel } from "sync-client";
|
import { LogLevel } from "sync-client";
|
||||||
|
|
||||||
test("parseArgs - parse basic arguments", () => {
|
test("parseArgs - parse basic arguments", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default"
|
"default"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.equal(args.localPath, "/path/to/vault");
|
assert.equal(args.localPath, "/path/to/vault");
|
||||||
assert.equal(args.remoteUri, "https://sync.example.com");
|
assert.equal(args.remoteUri, "https://sync.example.com");
|
||||||
assert.equal(args.token, "mytoken");
|
assert.equal(args.token, "mytoken");
|
||||||
assert.equal(args.vaultName, "default");
|
assert.equal(args.vaultName, "default");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - parse long form arguments", () => {
|
test("parseArgs - parse long form arguments", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"--local-path",
|
"--local-path",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"--remote-uri",
|
"--remote-uri",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"--token",
|
"--token",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"--vault-name",
|
"--vault-name",
|
||||||
"default"
|
"default"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.equal(args.localPath, "/path/to/vault");
|
assert.equal(args.localPath, "/path/to/vault");
|
||||||
assert.equal(args.remoteUri, "https://sync.example.com");
|
assert.equal(args.remoteUri, "https://sync.example.com");
|
||||||
assert.equal(args.token, "mytoken");
|
assert.equal(args.token, "mytoken");
|
||||||
assert.equal(args.vaultName, "default");
|
assert.equal(args.vaultName, "default");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - parse with optional arguments", () => {
|
test("parseArgs - parse with optional arguments", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default",
|
"default",
|
||||||
"--sync-concurrency",
|
"--sync-concurrency",
|
||||||
"5",
|
"5",
|
||||||
"--max-file-size-mb",
|
"--max-file-size-mb",
|
||||||
"20"
|
"20"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.equal(args.syncConcurrency, 5);
|
assert.equal(args.syncConcurrency, 5);
|
||||||
assert.equal(args.maxFileSizeMB, 20);
|
assert.equal(args.maxFileSizeMB, 20);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - parse with multiple ignore patterns", () => {
|
test("parseArgs - parse with multiple ignore patterns", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default",
|
"default",
|
||||||
"--ignore-pattern",
|
"--ignore-pattern",
|
||||||
".git/**",
|
".git/**",
|
||||||
"*.tmp"
|
"*.tmp"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.deepEqual(args.ignorePatterns, [".git/**", "*.tmp"]);
|
assert.deepEqual(args.ignorePatterns, [".git/**", "*.tmp"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - throws on missing required arguments", () => {
|
test("parseArgs - throws on missing required arguments", () => {
|
||||||
assert.throws(() => {
|
assert.throws(() => {
|
||||||
parseArgs(["node", "cli.js", "-r", "https://sync.example.com"]);
|
parseArgs(["node", "cli.js", "-r", "https://sync.example.com"]);
|
||||||
}, /required option/);
|
}, /required option/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - throws on missing remote uri", () => {
|
test("parseArgs - throws on missing remote uri", () => {
|
||||||
assert.throws(() => {
|
assert.throws(() => {
|
||||||
parseArgs([
|
parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default"
|
"default"
|
||||||
]);
|
]);
|
||||||
}, /--remote-uri/);
|
}, /--remote-uri/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - throws on missing token", () => {
|
test("parseArgs - throws on missing token", () => {
|
||||||
assert.throws(() => {
|
assert.throws(() => {
|
||||||
parseArgs([
|
parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-v",
|
"-v",
|
||||||
"default"
|
"default"
|
||||||
]);
|
]);
|
||||||
}, /--token/);
|
}, /--token/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - throws on missing vault name", () => {
|
test("parseArgs - throws on missing vault name", () => {
|
||||||
assert.throws(() => {
|
assert.throws(() => {
|
||||||
parseArgs([
|
parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken"
|
"mytoken"
|
||||||
]);
|
]);
|
||||||
}, /--vault-name/);
|
}, /--vault-name/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - default log level is INFO", () => {
|
test("parseArgs - default log level is INFO", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default"
|
"default"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.equal(args.logLevel, LogLevel.INFO);
|
assert.equal(args.logLevel, LogLevel.INFO);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - parse DEBUG log level", () => {
|
test("parseArgs - parse DEBUG log level", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default",
|
"default",
|
||||||
"--log-level",
|
"--log-level",
|
||||||
"DEBUG"
|
"DEBUG"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.equal(args.logLevel, LogLevel.DEBUG);
|
assert.equal(args.logLevel, LogLevel.DEBUG);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - parse ERROR log level", () => {
|
test("parseArgs - parse ERROR log level", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default",
|
"default",
|
||||||
"--log-level",
|
"--log-level",
|
||||||
"ERROR"
|
"ERROR"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.equal(args.logLevel, LogLevel.ERROR);
|
assert.equal(args.logLevel, LogLevel.ERROR);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - log level is case insensitive", () => {
|
test("parseArgs - log level is case insensitive", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default",
|
"default",
|
||||||
"--log-level",
|
"--log-level",
|
||||||
"debug"
|
"debug"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.equal(args.logLevel, LogLevel.DEBUG);
|
assert.equal(args.logLevel, LogLevel.DEBUG);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - throws on invalid log level", () => {
|
test("parseArgs - throws on invalid log level", () => {
|
||||||
assert.throws(() => {
|
assert.throws(() => {
|
||||||
parseArgs([
|
parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default",
|
"default",
|
||||||
"--log-level",
|
"--log-level",
|
||||||
"INVALID"
|
"INVALID"
|
||||||
]);
|
]);
|
||||||
}, /Invalid log level/);
|
}, /Invalid log level/);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,134 +3,134 @@ import packageJson from "../package.json";
|
||||||
import { LogLevel } from "sync-client";
|
import { LogLevel } from "sync-client";
|
||||||
|
|
||||||
export interface CliArgs {
|
export interface CliArgs {
|
||||||
remoteUri: string;
|
remoteUri: string;
|
||||||
token: string;
|
token: string;
|
||||||
vaultName: string;
|
vaultName: string;
|
||||||
localPath: string;
|
localPath: string;
|
||||||
syncConcurrency?: number;
|
syncConcurrency?: number;
|
||||||
maxFileSizeMB?: number;
|
maxFileSizeMB?: number;
|
||||||
ignorePatterns?: string[];
|
ignorePatterns?: string[];
|
||||||
webSocketRetryIntervalMs?: number;
|
webSocketRetryIntervalMs?: number;
|
||||||
logLevel: LogLevel;
|
logLevel: LogLevel;
|
||||||
health?: string;
|
health?: string;
|
||||||
enableTelemetry?: boolean;
|
enableTelemetry?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseArgs(argv: string[]): CliArgs {
|
export function parseArgs(argv: string[]): CliArgs {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
program
|
program
|
||||||
.name("vaultlink")
|
.name("vaultlink")
|
||||||
.description(
|
.description(
|
||||||
"VaultLink Local CLI - Sync your vault to the local filesystem"
|
"VaultLink Local CLI - Sync your vault to the local filesystem"
|
||||||
)
|
)
|
||||||
.version(packageJson.version)
|
.version(packageJson.version)
|
||||||
.option("-l, --local-path <path>", "Local directory path to sync")
|
.option("-l, --local-path <path>", "Local directory path to sync")
|
||||||
.option("-r, --remote-uri <uri>", "Remote server URI")
|
.option("-r, --remote-uri <uri>", "Remote server URI")
|
||||||
.option("-t, --token <token>", "Authentication token")
|
.option("-t, --token <token>", "Authentication token")
|
||||||
.option("-v, --vault-name <name>", "Vault name")
|
.option("-v, --vault-name <name>", "Vault name")
|
||||||
.option(
|
.option(
|
||||||
"--sync-concurrency <number>",
|
"--sync-concurrency <number>",
|
||||||
"[OPTIONAL] Number of concurrent sync operations",
|
"[OPTIONAL] Number of concurrent sync operations",
|
||||||
parseInt
|
parseInt
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--max-file-size-mb <number>",
|
"--max-file-size-mb <number>",
|
||||||
"[OPTIONAL] Maximum file size in MB",
|
"[OPTIONAL] Maximum file size in MB",
|
||||||
parseInt
|
parseInt
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--ignore-pattern <pattern...>",
|
"--ignore-pattern <pattern...>",
|
||||||
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
|
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--websocket-retry-interval-ms <number>",
|
"--websocket-retry-interval-ms <number>",
|
||||||
"[OPTIONAL] WebSocket retry interval in milliseconds",
|
"[OPTIONAL] WebSocket retry interval in milliseconds",
|
||||||
parseInt
|
parseInt
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--log-level <level>",
|
"--log-level <level>",
|
||||||
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)",
|
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)",
|
||||||
"INFO"
|
"INFO"
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--health <path>",
|
"--health <path>",
|
||||||
"[OPTIONAL] Path to health status file for Docker healthcheck"
|
"[OPTIONAL] Path to health status file for Docker healthcheck"
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--enable-telemetry",
|
"--enable-telemetry",
|
||||||
"[OPTIONAL] Enable telemetry (disabled by default)"
|
"[OPTIONAL] Enable telemetry (disabled by default)"
|
||||||
)
|
)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
"after",
|
"after",
|
||||||
`
|
`
|
||||||
Examples:
|
Examples:
|
||||||
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default
|
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default
|
||||||
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
|
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
|
||||||
--ignore-pattern ".git/**" --ignore-pattern "*.tmp"
|
--ignore-pattern ".git/**" --ignore-pattern "*.tmp"
|
||||||
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
|
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
|
||||||
--log-level DEBUG
|
--log-level DEBUG
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
|
|
||||||
program.parse(argv);
|
program.parse(argv);
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
const localPath = opts.localPath as string | undefined;
|
const localPath = opts.localPath as string | undefined;
|
||||||
const remoteUri = opts.remoteUri as string | undefined;
|
const remoteUri = opts.remoteUri as string | undefined;
|
||||||
const token = opts.token as string | undefined;
|
const token = opts.token as string | undefined;
|
||||||
const vaultName = opts.vaultName as string | undefined;
|
const vaultName = opts.vaultName as string | undefined;
|
||||||
const syncConcurrency = opts.syncConcurrency as number | undefined;
|
const syncConcurrency = opts.syncConcurrency as number | undefined;
|
||||||
const maxFileSizeMb = opts.maxFileSizeMb as number | undefined;
|
const maxFileSizeMb = opts.maxFileSizeMb as number | undefined;
|
||||||
const ignorePattern = opts.ignorePattern as string[] | undefined;
|
const ignorePattern = opts.ignorePattern as string[] | undefined;
|
||||||
const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as
|
const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as
|
||||||
| number
|
| number
|
||||||
| undefined;
|
| undefined;
|
||||||
const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO";
|
const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO";
|
||||||
const health = opts.health as string | undefined;
|
const health = opts.health as string | undefined;
|
||||||
const enableTelemetry = opts.enableTelemetry as boolean | undefined;
|
const enableTelemetry = opts.enableTelemetry as boolean | undefined;
|
||||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
||||||
|
|
||||||
if (localPath === undefined) {
|
if (localPath === undefined) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"required option '-l, --local-path <path>' not specified"
|
"required option '-l, --local-path <path>' not specified"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (remoteUri === undefined) {
|
if (remoteUri === undefined) {
|
||||||
throw new Error("required option '--remote-uri <uri>' not specified");
|
throw new Error("required option '--remote-uri <uri>' not specified");
|
||||||
}
|
}
|
||||||
if (token === undefined) {
|
if (token === undefined) {
|
||||||
throw new Error("required option '--token <token>' not specified");
|
throw new Error("required option '--token <token>' not specified");
|
||||||
}
|
}
|
||||||
if (vaultName === undefined) {
|
if (vaultName === undefined) {
|
||||||
throw new Error("required option '--vault-name <name>' not specified");
|
throw new Error("required option '--vault-name <name>' not specified");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and parse log level
|
// Validate and parse log level
|
||||||
const logLevelUpper = logLevelStr.toUpperCase();
|
const logLevelUpper = logLevelStr.toUpperCase();
|
||||||
const validLogLevels = Object.values(LogLevel);
|
const validLogLevels = Object.values(LogLevel);
|
||||||
const isLogLevel = (value: string): value is LogLevel => {
|
const isLogLevel = (value: string): value is LogLevel => {
|
||||||
return (validLogLevels as readonly string[]).includes(value);
|
return (validLogLevels as readonly string[]).includes(value);
|
||||||
};
|
};
|
||||||
if (!isLogLevel(logLevelUpper)) {
|
if (!isLogLevel(logLevelUpper)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid log level '${logLevelStr}'. Valid values are: ${validLogLevels.join(", ")}`
|
`Invalid log level '${logLevelStr}'. Valid values are: ${validLogLevels.join(", ")}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const logLevel = logLevelUpper;
|
const logLevel = logLevelUpper;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
localPath,
|
localPath,
|
||||||
remoteUri,
|
remoteUri,
|
||||||
token,
|
token,
|
||||||
vaultName,
|
vaultName,
|
||||||
syncConcurrency,
|
syncConcurrency,
|
||||||
maxFileSizeMB: maxFileSizeMb,
|
maxFileSizeMB: maxFileSizeMb,
|
||||||
ignorePatterns: ignorePattern,
|
ignorePatterns: ignorePattern,
|
||||||
webSocketRetryIntervalMs: websocketRetryIntervalMs,
|
webSocketRetryIntervalMs: websocketRetryIntervalMs,
|
||||||
logLevel,
|
logLevel,
|
||||||
health,
|
health,
|
||||||
enableTelemetry
|
enableTelemetry
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@ import * as fs from "fs/promises";
|
||||||
import * as fsSync from "fs";
|
import * as fsSync from "fs";
|
||||||
import type { NetworkConnectionStatus } from "sync-client";
|
import type { NetworkConnectionStatus } from "sync-client";
|
||||||
import {
|
import {
|
||||||
SyncClient,
|
SyncClient,
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
type SyncSettings,
|
type SyncSettings,
|
||||||
type StoredDatabase
|
type StoredDatabase
|
||||||
} from "sync-client";
|
} from "sync-client";
|
||||||
import { parseArgs } from "./args";
|
import { parseArgs } from "./args";
|
||||||
import { NodeFileSystemOperations } from "./node-filesystem";
|
import { NodeFileSystemOperations } from "./node-filesystem";
|
||||||
|
|
@ -16,229 +16,233 @@ import { formatLogLine, colorize, styleText } from "./logger-formatter";
|
||||||
import packageJson from "../package.json";
|
import packageJson from "../package.json";
|
||||||
|
|
||||||
function writeHealthStatus(
|
function writeHealthStatus(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
connectionStatus: NetworkConnectionStatus
|
connectionStatus: NetworkConnectionStatus
|
||||||
): void {
|
): void {
|
||||||
try {
|
try {
|
||||||
fsSync.writeFileSync(filePath, JSON.stringify(connectionStatus));
|
fsSync.writeFileSync(filePath, JSON.stringify(connectionStatus));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`Failed to write health status to ${filePath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to write health status to ${filePath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOG_LEVEL_ORDER = {
|
const LOG_LEVEL_ORDER = {
|
||||||
[LogLevel.DEBUG]: 0,
|
[LogLevel.DEBUG]: 0,
|
||||||
[LogLevel.INFO]: 1,
|
[LogLevel.INFO]: 1,
|
||||||
[LogLevel.WARNING]: 2,
|
[LogLevel.WARNING]: 2,
|
||||||
[LogLevel.ERROR]: 3
|
[LogLevel.ERROR]: 3
|
||||||
};
|
};
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const args = parseArgs(process.argv);
|
const args = parseArgs(process.argv);
|
||||||
const absolutePath = path.resolve(args.localPath);
|
const absolutePath = path.resolve(args.localPath);
|
||||||
|
|
||||||
try {
|
if (!fsSync.existsSync(absolutePath)) {
|
||||||
const stats = await fs.stat(absolutePath);
|
fsSync.mkdirSync(absolutePath, { recursive: true });
|
||||||
if (!stats.isDirectory()) {
|
}
|
||||||
console.error(
|
|
||||||
colorize(`Error: ${absolutePath} is not a directory`, "red")
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
colorize(
|
|
||||||
`Error: Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
"red"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
try {
|
||||||
styleText("VaultLink Local CLI", "bold", "cyan") +
|
const stats = await fs.stat(absolutePath);
|
||||||
colorize(` v${packageJson.version}`, "dim")
|
if (!stats.isDirectory()) {
|
||||||
);
|
console.error(
|
||||||
console.log(colorize("=".repeat(50), "dim"));
|
colorize(`Error: ${absolutePath} is not a directory`, "red")
|
||||||
console.log(
|
);
|
||||||
`${colorize("Local path:", "dim")} ${colorize(absolutePath, "green")}`
|
process.exit(1);
|
||||||
);
|
}
|
||||||
console.log(
|
} catch (error) {
|
||||||
`${colorize("Remote URI:", "dim")} ${colorize(args.remoteUri, "cyan")}`
|
console.error(
|
||||||
);
|
colorize(
|
||||||
console.log(
|
`Error: Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
`${colorize("Vault name:", "dim")} ${colorize(args.vaultName, "green")}`
|
"red"
|
||||||
);
|
)
|
||||||
console.log("");
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
const dataDir = path.join(absolutePath, ".vaultlink");
|
console.log(
|
||||||
const dataFile = path.join(dataDir, "sync-data.json");
|
styleText("VaultLink Local CLI", "bold", "cyan") +
|
||||||
|
colorize(` v${packageJson.version}`, "dim")
|
||||||
|
);
|
||||||
|
console.log(colorize("=".repeat(50), "dim"));
|
||||||
|
console.log(
|
||||||
|
`${colorize("Local path:", "dim")} ${colorize(absolutePath, "green")}`
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`${colorize("Remote URI:", "dim")} ${colorize(args.remoteUri, "cyan")}`
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`${colorize("Vault name:", "dim")} ${colorize(args.vaultName, "green")}`
|
||||||
|
);
|
||||||
|
console.log("");
|
||||||
|
|
||||||
await fs.mkdir(dataDir, { recursive: true });
|
const dataDir = path.join(absolutePath, ".vaultlink");
|
||||||
|
const dataFile = path.join(dataDir, "sync-data.json");
|
||||||
|
|
||||||
const fileSystem = new NodeFileSystemOperations(absolutePath);
|
await fs.mkdir(dataDir, { recursive: true });
|
||||||
|
|
||||||
const ignorePatterns = [
|
const fileSystem = new NodeFileSystemOperations(absolutePath);
|
||||||
...(args.ignorePatterns ?? []),
|
|
||||||
".vaultlink/**",
|
|
||||||
".git/**"
|
|
||||||
];
|
|
||||||
|
|
||||||
const settings: SyncSettings = {
|
const ignorePatterns = [
|
||||||
...DEFAULT_SETTINGS,
|
...(args.ignorePatterns ?? []),
|
||||||
remoteUri: args.remoteUri,
|
".vaultlink/**",
|
||||||
token: args.token,
|
".git/**"
|
||||||
vaultName: args.vaultName,
|
];
|
||||||
syncConcurrency:
|
|
||||||
args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency,
|
|
||||||
maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB,
|
|
||||||
ignorePatterns,
|
|
||||||
webSocketRetryIntervalMs:
|
|
||||||
args.webSocketRetryIntervalMs ??
|
|
||||||
DEFAULT_SETTINGS.webSocketRetryIntervalMs,
|
|
||||||
isSyncEnabled: true,
|
|
||||||
enableTelemetry:
|
|
||||||
args.enableTelemetry ?? DEFAULT_SETTINGS.enableTelemetry
|
|
||||||
};
|
|
||||||
|
|
||||||
const client = await SyncClient.create({
|
const settings: SyncSettings = {
|
||||||
fs: fileSystem,
|
...DEFAULT_SETTINGS,
|
||||||
persistence: {
|
remoteUri: args.remoteUri,
|
||||||
load: async () => {
|
token: args.token,
|
||||||
let database: Partial<StoredDatabase> | undefined = undefined;
|
vaultName: args.vaultName,
|
||||||
try {
|
syncConcurrency:
|
||||||
const content = await fs.readFile(dataFile, "utf-8");
|
args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB,
|
||||||
database = JSON.parse(content) as Partial<StoredDatabase>;
|
ignorePatterns,
|
||||||
} catch {
|
webSocketRetryIntervalMs:
|
||||||
console.error(
|
args.webSocketRetryIntervalMs ??
|
||||||
colorize(
|
DEFAULT_SETTINGS.webSocketRetryIntervalMs,
|
||||||
`Cannot read data file at ${dataFile}`,
|
isSyncEnabled: true,
|
||||||
"yellow"
|
enableTelemetry:
|
||||||
)
|
args.enableTelemetry ?? DEFAULT_SETTINGS.enableTelemetry
|
||||||
);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
const client = await SyncClient.create({
|
||||||
settings,
|
fs: fileSystem,
|
||||||
database
|
persistence: {
|
||||||
};
|
load: async () => {
|
||||||
},
|
let database: Partial<StoredDatabase> | undefined = undefined;
|
||||||
save: async ({ database: persistedDatabase }) => {
|
try {
|
||||||
// settings can't be updated when running with this CLI
|
const content = await fs.readFile(dataFile, "utf-8");
|
||||||
await fs.writeFile(
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
dataFile,
|
database = JSON.parse(content) as Partial<StoredDatabase>;
|
||||||
JSON.stringify(persistedDatabase, null, 2)
|
} catch {
|
||||||
);
|
console.error(
|
||||||
}
|
colorize(
|
||||||
},
|
`Cannot read data file at ${dataFile}`,
|
||||||
nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n"
|
"yellow"
|
||||||
});
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (args.health !== undefined) {
|
return {
|
||||||
const healthFile = args.health;
|
settings,
|
||||||
const healthInterval = setInterval(() => {
|
database
|
||||||
void client.checkConnection().then((status) => {
|
};
|
||||||
writeHealthStatus(healthFile, status);
|
},
|
||||||
});
|
save: async ({ database: persistedDatabase }) => {
|
||||||
}, 30 * 1000); // every 30 seconds
|
// settings can't be updated when running with this CLI
|
||||||
const clearHealthInterval = (): void => {
|
await fs.writeFile(
|
||||||
clearInterval(healthInterval);
|
dataFile,
|
||||||
};
|
JSON.stringify(persistedDatabase, null, 2)
|
||||||
process.on("SIGINT", clearHealthInterval);
|
);
|
||||||
process.on("SIGTERM", clearHealthInterval);
|
}
|
||||||
process.on("exit", clearHealthInterval);
|
},
|
||||||
}
|
nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n"
|
||||||
|
});
|
||||||
|
|
||||||
// Add colored log formatter with level filtering
|
if (args.health !== undefined) {
|
||||||
client.logger.addOnMessageListener((logLine) => {
|
const healthFile = args.health;
|
||||||
// Only show messages at or above the configured log level
|
const healthInterval = setInterval(() => {
|
||||||
if (LOG_LEVEL_ORDER[logLine.level] >= LOG_LEVEL_ORDER[args.logLevel]) {
|
void client.checkConnection().then((status) => {
|
||||||
console.log(formatLogLine(logLine));
|
writeHealthStatus(healthFile, status);
|
||||||
}
|
});
|
||||||
});
|
}, 30 * 1000); // every 30 seconds
|
||||||
|
const clearHealthInterval = (): void => {
|
||||||
|
clearInterval(healthInterval);
|
||||||
|
};
|
||||||
|
process.on("SIGINT", clearHealthInterval);
|
||||||
|
process.on("SIGTERM", clearHealthInterval);
|
||||||
|
process.on("exit", clearHealthInterval);
|
||||||
|
}
|
||||||
|
|
||||||
client.logger.info("Starting sync client");
|
// Add colored log formatter with level filtering
|
||||||
|
client.logger.onLogEmitted.add((logLine) => {
|
||||||
|
// Only show messages at or above the configured log level
|
||||||
|
if (LOG_LEVEL_ORDER[logLine.level] >= LOG_LEVEL_ORDER[args.logLevel]) {
|
||||||
|
console.log(formatLogLine(logLine));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const fileWatcher = new FileWatcher(absolutePath, client);
|
client.logger.info("Starting sync client");
|
||||||
|
|
||||||
client.addWebSocketStatusChangeListener(() => {
|
const fileWatcher = new FileWatcher(absolutePath, client);
|
||||||
const isConnected = client.isWebSocketConnected;
|
|
||||||
client.logger.info(
|
|
||||||
`WebSocket status changed: ${isConnected ? "connected" : "disconnected"}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.addRemainingSyncOperationsListener((remaining) => {
|
client.onWebSocketStatusChanged.add(() => {
|
||||||
if (remaining === 0) {
|
const isConnected = client.isWebSocketConnected;
|
||||||
client.logger.info("All sync operations completed");
|
client.logger.info(
|
||||||
} else {
|
`WebSocket status changed: ${isConnected ? "connected" : "disconnected"}`
|
||||||
client.logger.info(`${remaining} sync operations remaining`);
|
);
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const gracefulShutdown = async (signal: string): Promise<void> => {
|
client.onRemainingOperationsCountChanged.add((remaining) => {
|
||||||
console.log(
|
if (remaining === 0) {
|
||||||
colorize(
|
client.logger.info("All sync operations completed");
|
||||||
`\n${signal} received. Shutting down gracefully...`,
|
} else {
|
||||||
"yellow"
|
client.logger.info(`${remaining} sync operations remaining`);
|
||||||
)
|
}
|
||||||
);
|
});
|
||||||
|
|
||||||
fileWatcher.stop();
|
const gracefulShutdown = async (signal: string): Promise<void> => {
|
||||||
await client.waitUntilFinished();
|
console.log(
|
||||||
await client.destroy();
|
colorize(
|
||||||
console.log(colorize("Shutdown complete", "green"));
|
`\n${signal} received. Shutting down gracefully...`,
|
||||||
process.exit(0);
|
"yellow"
|
||||||
};
|
)
|
||||||
|
);
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
fileWatcher.stop();
|
||||||
void gracefulShutdown("SIGINT");
|
await client.waitUntilFinished();
|
||||||
});
|
await client.destroy();
|
||||||
process.on("SIGTERM", () => {
|
console.log(colorize("Shutdown complete", "green"));
|
||||||
void gracefulShutdown("SIGTERM");
|
process.exit(0);
|
||||||
});
|
};
|
||||||
|
|
||||||
try {
|
process.on("SIGINT", () => {
|
||||||
const connectionStatus = await client.checkConnection();
|
void gracefulShutdown("SIGINT");
|
||||||
if (!connectionStatus.isSuccessful) {
|
});
|
||||||
console.error(
|
process.on("SIGTERM", () => {
|
||||||
colorize(
|
void gracefulShutdown("SIGTERM");
|
||||||
`Error: Cannot connect to server: ${connectionStatus.serverMessage}`,
|
});
|
||||||
"red"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`${colorize("✓", "green")} Server connection successful`);
|
try {
|
||||||
console.log(colorize("Press Ctrl+C to stop", "dim"));
|
const connectionStatus = await client.checkConnection();
|
||||||
console.log("");
|
if (!connectionStatus.isSuccessful) {
|
||||||
|
console.error(
|
||||||
|
colorize(
|
||||||
|
`Error: Cannot connect to server: ${connectionStatus.serverMessage}`,
|
||||||
|
"red"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
await client.start();
|
console.log(`${colorize("✓", "green")} Server connection successful`);
|
||||||
fileWatcher.start();
|
console.log(colorize("Press Ctrl+C to stop", "dim"));
|
||||||
} catch (error) {
|
console.log("");
|
||||||
console.error(
|
|
||||||
colorize(
|
|
||||||
`Fatal error: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
"red"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
fileWatcher.stop();
|
await client.start();
|
||||||
await client.destroy();
|
fileWatcher.start();
|
||||||
process.exit(1);
|
} catch (error) {
|
||||||
}
|
console.error(
|
||||||
|
colorize(
|
||||||
|
`Fatal error: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
"red"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
fileWatcher.stop();
|
||||||
|
await client.destroy();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error: unknown) => {
|
main().catch((error: unknown) => {
|
||||||
console.error(
|
console.error(
|
||||||
colorize(
|
colorize(
|
||||||
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
|
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
"red"
|
"red"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,102 +1,121 @@
|
||||||
import * as fs from "fs";
|
import Watcher from "watcher";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import type { SyncClient, RelativePath } from "sync-client";
|
import type { SyncClient, RelativePath } from "sync-client";
|
||||||
|
|
||||||
export class FileWatcher {
|
export class FileWatcher {
|
||||||
private watcher: fs.FSWatcher | undefined;
|
private watcher: Watcher | undefined;
|
||||||
private isRunning = false;
|
private isRunning = false;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly basePath: string,
|
private readonly basePath: string,
|
||||||
private readonly client: SyncClient
|
private readonly client: SyncClient
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public start(): void {
|
public start(): void {
|
||||||
if (this.isRunning) {
|
if (this.isRunning) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
|
|
||||||
this.watcher = fs.watch(
|
this.watcher = new Watcher(this.basePath, {
|
||||||
this.basePath,
|
recursive: true,
|
||||||
{ recursive: true },
|
renameDetection: true,
|
||||||
(eventType, filename) => {
|
renameTimeout: 125,
|
||||||
if (filename === null || filename.length === 0) {
|
ignoreInitial: true
|
||||||
return;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to forward slashes for consistency
|
this.watcher.on("add", (filePath: string) => {
|
||||||
const relativePath = this.toUnixPath(filename);
|
this.handleCreate(this.toRelativePath(filePath));
|
||||||
|
});
|
||||||
|
|
||||||
if (eventType === "rename") {
|
this.watcher.on("change", (filePath: string) => {
|
||||||
this.handleRenameOrDelete(relativePath);
|
this.handleChange(this.toRelativePath(filePath));
|
||||||
} else {
|
});
|
||||||
// Must be "change" event
|
|
||||||
this.handleChange(relativePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
this.client.logger.info("File watcher started");
|
this.watcher.on("unlink", (filePath: string) => {
|
||||||
}
|
this.handleDelete(this.toRelativePath(filePath));
|
||||||
|
});
|
||||||
|
|
||||||
public stop(): void {
|
this.watcher.on("rename", (oldPath: string, newPath: string) => {
|
||||||
if (this.watcher !== undefined) {
|
this.handleRename(
|
||||||
this.watcher.close();
|
this.toRelativePath(oldPath),
|
||||||
this.watcher = undefined;
|
this.toRelativePath(newPath)
|
||||||
}
|
);
|
||||||
this.isRunning = false;
|
});
|
||||||
this.client.logger.info("File watcher stopped");
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleChange(relativePath: RelativePath): void {
|
this.client.logger.info("File watcher started");
|
||||||
this.client
|
}
|
||||||
.syncLocallyUpdatedFile({ relativePath })
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
this.client.logger.error(
|
|
||||||
`Failed to sync updated file ${relativePath}: ${err instanceof Error ? err.message : String(err)}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleRenameOrDelete(relativePath: RelativePath): void {
|
public stop(): void {
|
||||||
const fullPath = path.join(this.basePath, relativePath);
|
if (this.watcher !== undefined) {
|
||||||
|
this.watcher.close();
|
||||||
|
this.watcher = undefined;
|
||||||
|
}
|
||||||
|
this.isRunning = false;
|
||||||
|
this.client.logger.info("File watcher stopped");
|
||||||
|
}
|
||||||
|
|
||||||
fs.access(fullPath, fs.constants.F_OK, (accessError) => {
|
private handleCreate(relativePath: RelativePath): void {
|
||||||
if (accessError) {
|
this.client
|
||||||
this.client
|
.syncLocallyCreatedFile(relativePath)
|
||||||
.syncLocallyDeletedFile(relativePath)
|
.catch((err: unknown) => {
|
||||||
.catch((deleteErr: unknown) => {
|
this.client.logger.error(
|
||||||
this.client.logger.error(
|
`Failed to sync created file ${relativePath}: ${this.formatError(err)}`
|
||||||
`Failed to sync deleted file ${relativePath}: ${deleteErr instanceof Error ? deleteErr.message : String(deleteErr)}`
|
);
|
||||||
);
|
});
|
||||||
});
|
}
|
||||||
} else {
|
|
||||||
fs.stat(fullPath, (statErr, stats) => {
|
|
||||||
if (statErr !== null || !stats.isFile()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.client
|
private handleChange(relativePath: RelativePath): void {
|
||||||
.syncLocallyCreatedFile(relativePath)
|
this.client
|
||||||
.catch((createErr: unknown) => {
|
.syncLocallyUpdatedFile({ relativePath })
|
||||||
this.client.logger.error(
|
.catch((err: unknown) => {
|
||||||
`Failed to sync created file ${relativePath}: ${createErr instanceof Error ? createErr.message : String(createErr)}`
|
this.client.logger.error(
|
||||||
);
|
`Failed to sync updated file ${relativePath}: ${this.formatError(err)}`
|
||||||
});
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
private handleDelete(relativePath: RelativePath): void {
|
||||||
* Convert a native platform path to forward slashes
|
this.client
|
||||||
*/
|
.syncLocallyDeletedFile(relativePath)
|
||||||
private toUnixPath(nativePath: string): string {
|
.catch((err: unknown) => {
|
||||||
if (path.sep === "\\") {
|
this.client.logger.error(
|
||||||
return nativePath.replace(/\\/g, "/");
|
`Failed to sync deleted file ${relativePath}: ${this.formatError(err)}`
|
||||||
}
|
);
|
||||||
return nativePath;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleRename(oldPath: RelativePath, newPath: RelativePath): void {
|
||||||
|
this.client.logger.info(`File renamed: ${oldPath} -> ${newPath}`);
|
||||||
|
this.client
|
||||||
|
.syncLocallyUpdatedFile({
|
||||||
|
oldPath,
|
||||||
|
relativePath: newPath
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
this.client.logger.error(
|
||||||
|
`Failed to sync renamed file ${oldPath} -> ${newPath}: ${this.formatError(err)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toRelativePath(absolutePath: string): RelativePath {
|
||||||
|
const relative = path.relative(this.basePath, absolutePath);
|
||||||
|
return this.toUnixPath(relative);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a native platform path to forward slashes
|
||||||
|
*/
|
||||||
|
private toUnixPath(nativePath: string): string {
|
||||||
|
if (path.sep === "\\") {
|
||||||
|
return nativePath.replace(/\\/g, "/");
|
||||||
|
}
|
||||||
|
return nativePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatError(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,58 +9,58 @@ import * as fs from "fs";
|
||||||
import type { NetworkConnectionStatus } from "sync-client";
|
import type { NetworkConnectionStatus } from "sync-client";
|
||||||
|
|
||||||
function isHealthStatus(value: unknown): value is NetworkConnectionStatus {
|
function isHealthStatus(value: unknown): value is NetworkConnectionStatus {
|
||||||
if (typeof value !== "object" || value === null) {
|
if (typeof value !== "object" || value === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
"isSuccessful" in value &&
|
"isSuccessful" in value &&
|
||||||
typeof value.isSuccessful === "boolean" &&
|
typeof value.isSuccessful === "boolean" &&
|
||||||
"isWebSocketConnected" in value &&
|
"isWebSocketConnected" in value &&
|
||||||
typeof value.isWebSocketConnected === "boolean" &&
|
typeof value.isWebSocketConnected === "boolean" &&
|
||||||
"serverMessage" in value &&
|
"serverMessage" in value &&
|
||||||
typeof value.serverMessage === "string"
|
typeof value.serverMessage === "string"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function main(): void {
|
function main(): void {
|
||||||
if (process.argv.length < 3) {
|
if (process.argv.length < 3) {
|
||||||
console.error("Usage: healthcheck <path-to-health-file>");
|
console.error("Usage: healthcheck <path-to-health-file>");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const [, , healthFile] = process.argv;
|
const [, , healthFile] = process.argv;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if health file exists
|
// Check if health file exists
|
||||||
if (!fs.existsSync(healthFile)) {
|
if (!fs.existsSync(healthFile)) {
|
||||||
console.error(`Health file does not exist: ${healthFile}`);
|
console.error(`Health file does not exist: ${healthFile}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read and parse health status
|
// Read and parse health status
|
||||||
const content = fs.readFileSync(healthFile, "utf-8");
|
const content = fs.readFileSync(healthFile, "utf-8");
|
||||||
const parsed: unknown = JSON.parse(content);
|
const parsed: unknown = JSON.parse(content);
|
||||||
|
|
||||||
// Validate the parsed object using type guard
|
// Validate the parsed object using type guard
|
||||||
if (!isHealthStatus(parsed)) {
|
if (!isHealthStatus(parsed)) {
|
||||||
throw new Error("Invalid health status format");
|
throw new Error("Invalid health status format");
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = parsed;
|
const status = parsed;
|
||||||
|
|
||||||
if (!status.isSuccessful || !status.isWebSocketConnected) {
|
if (!status.isSuccessful || !status.isWebSocketConnected) {
|
||||||
console.error("Not connected to server: " + status.serverMessage);
|
console.error("Not connected to server: " + status.serverMessage);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Healthy: Connected to server");
|
console.log("Healthy: Connected to server");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`Health check failed: ${error instanceof Error ? error.message : String(error)}`
|
`Health check failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
|
|
|
||||||
|
|
@ -2,85 +2,85 @@ import { LogLevel, type LogLine } from "sync-client";
|
||||||
|
|
||||||
// ANSI color codes
|
// ANSI color codes
|
||||||
export const colors = {
|
export const colors = {
|
||||||
reset: "\x1b[0m",
|
reset: "\x1b[0m",
|
||||||
bold: "\x1b[1m",
|
bold: "\x1b[1m",
|
||||||
dim: "\x1b[2m",
|
dim: "\x1b[2m",
|
||||||
|
|
||||||
// Foreground colors
|
// Foreground colors
|
||||||
red: "\x1b[31m",
|
red: "\x1b[31m",
|
||||||
green: "\x1b[32m",
|
green: "\x1b[32m",
|
||||||
yellow: "\x1b[33m",
|
yellow: "\x1b[33m",
|
||||||
blue: "\x1b[34m",
|
blue: "\x1b[34m",
|
||||||
magenta: "\x1b[35m",
|
magenta: "\x1b[35m",
|
||||||
cyan: "\x1b[36m",
|
cyan: "\x1b[36m",
|
||||||
gray: "\x1b[90m"
|
gray: "\x1b[90m"
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function colorize(text: string, color: keyof typeof colors): string {
|
export function colorize(text: string, color: keyof typeof colors): string {
|
||||||
return `${colors[color]}${text}${colors.reset}`;
|
return `${colors[color]}${text}${colors.reset}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function to apply multiple color modifiers to text
|
* Helper function to apply multiple color modifiers to text
|
||||||
*/
|
*/
|
||||||
export function styleText(
|
export function styleText(
|
||||||
text: string,
|
text: string,
|
||||||
...modifiers: (keyof typeof colors)[]
|
...modifiers: (keyof typeof colors)[]
|
||||||
): string {
|
): string {
|
||||||
const prefix = modifiers.map((m) => colors[m]).join("");
|
const prefix = modifiers.map((m) => colors[m]).join("");
|
||||||
return `${prefix}${text}${colors.reset}`;
|
return `${prefix}${text}${colors.reset}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(date: Date): string {
|
function formatTimestamp(date: Date): string {
|
||||||
const [time] = date.toTimeString().split(" ");
|
const [time] = date.toTimeString().split(" ");
|
||||||
const ms = date.getMilliseconds().toString().padStart(3, "0");
|
const ms = date.getMilliseconds().toString().padStart(3, "0");
|
||||||
return colorize(`${time}.${ms}`, "gray");
|
return colorize(`${time}.${ms}`, "gray");
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLevel(level: LogLevel): string {
|
function formatLevel(level: LogLevel): string {
|
||||||
const levelStr = level.padEnd(7);
|
const levelStr = level.padEnd(7);
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case LogLevel.DEBUG:
|
case LogLevel.DEBUG:
|
||||||
return colorize(levelStr, "cyan");
|
return colorize(levelStr, "cyan");
|
||||||
case LogLevel.INFO:
|
case LogLevel.INFO:
|
||||||
return colorize(levelStr, "green");
|
return colorize(levelStr, "green");
|
||||||
case LogLevel.WARNING:
|
case LogLevel.WARNING:
|
||||||
return colorize(levelStr, "yellow");
|
return colorize(levelStr, "yellow");
|
||||||
case LogLevel.ERROR:
|
case LogLevel.ERROR:
|
||||||
return colorize(levelStr, "red");
|
return colorize(levelStr, "red");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMessage(message: string, level: LogLevel): string {
|
function formatMessage(message: string, level: LogLevel): string {
|
||||||
// Highlight important parts of the message
|
// Highlight important parts of the message
|
||||||
let formatted = message;
|
let formatted = message;
|
||||||
|
|
||||||
// Highlight file paths
|
// Highlight file paths
|
||||||
formatted = formatted.replace(
|
formatted = formatted.replace(
|
||||||
/(['"])([^'"]*?\.(json|txt|md|js|ts))(['"])/g,
|
/(['"])([^'"]*?\.(json|txt|md|js|ts))(['"])/g,
|
||||||
(_, q1, path, _ext, q2) => q1 + colorize(path, "magenta") + q2
|
(_, q1, path, _ext, q2) => q1 + colorize(path, "magenta") + q2
|
||||||
);
|
);
|
||||||
|
|
||||||
// Highlight numbers
|
// Highlight numbers
|
||||||
formatted = formatted.replace(/\b(\d+)\b/g, (num) => colorize(num, "cyan"));
|
formatted = formatted.replace(/\b(\d+)\b/g, (num) => colorize(num, "cyan"));
|
||||||
|
|
||||||
// Highlight patterns like /regex/
|
// Highlight patterns like /regex/
|
||||||
formatted = formatted.replace(/(\/\^[^$]*\$\/)/g, (pattern) =>
|
formatted = formatted.replace(/(\/\^[^$]*\$\/)/g, (pattern) =>
|
||||||
colorize(pattern, "yellow")
|
colorize(pattern, "yellow")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Make error messages bold
|
// Make error messages bold
|
||||||
if (level === LogLevel.ERROR) {
|
if (level === LogLevel.ERROR) {
|
||||||
formatted = colorize(formatted, "bold");
|
formatted = colorize(formatted, "bold");
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatted;
|
return formatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatLogLine(logLine: LogLine): string {
|
export function formatLogLine(logLine: LogLine): string {
|
||||||
const timestamp = formatTimestamp(logLine.timestamp);
|
const timestamp = formatTimestamp(logLine.timestamp);
|
||||||
const level = formatLevel(logLine.level);
|
const level = formatLevel(logLine.level);
|
||||||
const message = formatMessage(logLine.message, logLine.level);
|
const message = formatMessage(logLine.message, logLine.level);
|
||||||
|
|
||||||
return `${timestamp} ${level} ${message}`;
|
return `${timestamp} ${level} ${message}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,157 +6,157 @@ import * as os from "os";
|
||||||
import { NodeFileSystemOperations } from "./node-filesystem";
|
import { NodeFileSystemOperations } from "./node-filesystem";
|
||||||
|
|
||||||
test("NodeFileSystemOperations - read and write files", async () => {
|
test("NodeFileSystemOperations - read and write files", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = new TextEncoder().encode("Hello, world!");
|
const content = new TextEncoder().encode("Hello, world!");
|
||||||
await fsOps.write("test.txt", content);
|
await fsOps.write("test.txt", content);
|
||||||
|
|
||||||
const readContent = await fsOps.read("test.txt");
|
const readContent = await fsOps.read("test.txt");
|
||||||
assert.equal(new TextDecoder().decode(readContent), "Hello, world!");
|
assert.equal(new TextDecoder().decode(readContent), "Hello, world!");
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NodeFileSystemOperations - create nested directories with forward slashes", async () => {
|
test("NodeFileSystemOperations - create nested directories with forward slashes", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = new TextEncoder().encode("Nested file");
|
const content = new TextEncoder().encode("Nested file");
|
||||||
// Always use forward slashes in API
|
// Always use forward slashes in API
|
||||||
await fsOps.write("dir1/dir2/test.txt", content);
|
await fsOps.write("dir1/dir2/test.txt", content);
|
||||||
|
|
||||||
const readContent = await fsOps.read("dir1/dir2/test.txt");
|
const readContent = await fsOps.read("dir1/dir2/test.txt");
|
||||||
assert.equal(new TextDecoder().decode(readContent), "Nested file");
|
assert.equal(new TextDecoder().decode(readContent), "Nested file");
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NodeFileSystemOperations - exists with forward slashes", async () => {
|
test("NodeFileSystemOperations - exists with forward slashes", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
assert.equal(await fsOps.exists("test.txt"), false);
|
assert.equal(await fsOps.exists("test.txt"), false);
|
||||||
|
|
||||||
await fsOps.write("test.txt", new TextEncoder().encode("test"));
|
await fsOps.write("test.txt", new TextEncoder().encode("test"));
|
||||||
|
|
||||||
assert.equal(await fsOps.exists("test.txt"), true);
|
assert.equal(await fsOps.exists("test.txt"), true);
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NodeFileSystemOperations - delete with forward slashes", async () => {
|
test("NodeFileSystemOperations - delete with forward slashes", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fsOps.write("test.txt", new TextEncoder().encode("test"));
|
await fsOps.write("test.txt", new TextEncoder().encode("test"));
|
||||||
assert.equal(await fsOps.exists("test.txt"), true);
|
assert.equal(await fsOps.exists("test.txt"), true);
|
||||||
|
|
||||||
await fsOps.delete("test.txt");
|
await fsOps.delete("test.txt");
|
||||||
assert.equal(await fsOps.exists("test.txt"), false);
|
assert.equal(await fsOps.exists("test.txt"), false);
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NodeFileSystemOperations - rename with forward slashes", async () => {
|
test("NodeFileSystemOperations - rename with forward slashes", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = new TextEncoder().encode("test content");
|
const content = new TextEncoder().encode("test content");
|
||||||
await fsOps.write("old.txt", content);
|
await fsOps.write("old.txt", content);
|
||||||
|
|
||||||
await fsOps.rename("old.txt", "new.txt");
|
await fsOps.rename("old.txt", "new.txt");
|
||||||
|
|
||||||
assert.equal(await fsOps.exists("old.txt"), false);
|
assert.equal(await fsOps.exists("old.txt"), false);
|
||||||
assert.equal(await fsOps.exists("new.txt"), true);
|
assert.equal(await fsOps.exists("new.txt"), true);
|
||||||
|
|
||||||
const readContent = await fsOps.read("new.txt");
|
const readContent = await fsOps.read("new.txt");
|
||||||
assert.equal(new TextDecoder().decode(readContent), "test content");
|
assert.equal(new TextDecoder().decode(readContent), "test content");
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NodeFileSystemOperations - rename to nested path with forward slashes", async () => {
|
test("NodeFileSystemOperations - rename to nested path with forward slashes", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = new TextEncoder().encode("test content");
|
const content = new TextEncoder().encode("test content");
|
||||||
await fsOps.write("old.txt", content);
|
await fsOps.write("old.txt", content);
|
||||||
|
|
||||||
await fsOps.rename("old.txt", "dir1/dir2/new.txt");
|
await fsOps.rename("old.txt", "dir1/dir2/new.txt");
|
||||||
|
|
||||||
assert.equal(await fsOps.exists("old.txt"), false);
|
assert.equal(await fsOps.exists("old.txt"), false);
|
||||||
assert.equal(await fsOps.exists("dir1/dir2/new.txt"), true);
|
assert.equal(await fsOps.exists("dir1/dir2/new.txt"), true);
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NodeFileSystemOperations - getFileSize", async () => {
|
test("NodeFileSystemOperations - getFileSize", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = new TextEncoder().encode("Hello!");
|
const content = new TextEncoder().encode("Hello!");
|
||||||
await fsOps.write("test.txt", content);
|
await fsOps.write("test.txt", content);
|
||||||
|
|
||||||
const size = await fsOps.getFileSize("test.txt");
|
const size = await fsOps.getFileSize("test.txt");
|
||||||
assert.equal(size, content.length);
|
assert.equal(size, content.length);
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NodeFileSystemOperations - atomicUpdateText", async () => {
|
test("NodeFileSystemOperations - atomicUpdateText", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fsOps.write("test.txt", new TextEncoder().encode("Hello"));
|
await fsOps.write("test.txt", new TextEncoder().encode("Hello"));
|
||||||
|
|
||||||
const result = await fsOps.atomicUpdateText("test.txt", (current) => ({
|
const result = await fsOps.atomicUpdateText("test.txt", (current) => ({
|
||||||
text: current.text + " World",
|
text: current.text + " World",
|
||||||
cursors: []
|
cursors: []
|
||||||
}));
|
}));
|
||||||
|
|
||||||
assert.equal(result, "Hello World");
|
assert.equal(result, "Hello World");
|
||||||
|
|
||||||
const content = await fsOps.read("test.txt");
|
const content = await fsOps.read("test.txt");
|
||||||
assert.equal(new TextDecoder().decode(content), "Hello World");
|
assert.equal(new TextDecoder().decode(content), "Hello World");
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NodeFileSystemOperations - handles paths with forward slashes on all platforms", async () => {
|
test("NodeFileSystemOperations - handles paths with forward slashes on all platforms", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// API should always accept forward slashes
|
// API should always accept forward slashes
|
||||||
const testPath = "deep/nested/directory/file.txt";
|
const testPath = "deep/nested/directory/file.txt";
|
||||||
const content = new TextEncoder().encode("test");
|
const content = new TextEncoder().encode("test");
|
||||||
|
|
||||||
await fsOps.write(testPath, content);
|
await fsOps.write(testPath, content);
|
||||||
assert.equal(await fsOps.exists(testPath), true);
|
assert.equal(await fsOps.exists(testPath), true);
|
||||||
|
|
||||||
const readContent = await fsOps.read(testPath);
|
const readContent = await fsOps.read(testPath);
|
||||||
assert.equal(new TextDecoder().decode(readContent), "test");
|
assert.equal(new TextDecoder().decode(readContent), "test");
|
||||||
|
|
||||||
await fsOps.delete(testPath);
|
await fsOps.delete(testPath);
|
||||||
assert.equal(await fsOps.exists(testPath), false);
|
assert.equal(await fsOps.exists(testPath), false);
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,205 +2,205 @@ import * as fs from "fs/promises";
|
||||||
import type { Dirent } from "fs";
|
import type { Dirent } from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import type {
|
import type {
|
||||||
FileSystemOperations,
|
FileSystemOperations,
|
||||||
RelativePath,
|
RelativePath,
|
||||||
TextWithCursors
|
TextWithCursors
|
||||||
} from "sync-client";
|
} from "sync-client";
|
||||||
|
|
||||||
export class NodeFileSystemOperations implements FileSystemOperations {
|
export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
public constructor(private readonly basePath: string) {}
|
public constructor(private readonly basePath: string) {}
|
||||||
|
|
||||||
public async listFilesRecursively(
|
public async listFilesRecursively(
|
||||||
directory: RelativePath | undefined
|
directory: RelativePath | undefined
|
||||||
): Promise<RelativePath[]> {
|
): Promise<RelativePath[]> {
|
||||||
const files: RelativePath[] = [];
|
const files: RelativePath[] = [];
|
||||||
await this.walkDirectory(
|
await this.walkDirectory(
|
||||||
directory !== undefined ? this.toNativePath(directory) : "",
|
directory !== undefined ? this.toNativePath(directory) : "",
|
||||||
files
|
files
|
||||||
);
|
);
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async read(relativePath: RelativePath): Promise<Uint8Array> {
|
public async read(relativePath: RelativePath): Promise<Uint8Array> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
this.toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
return await fs.readFile(fullPath);
|
return await fs.readFile(fullPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to read file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to read file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async write(
|
public async write(
|
||||||
relativePath: RelativePath,
|
relativePath: RelativePath,
|
||||||
content: Uint8Array
|
content: Uint8Array
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
this.toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
const dir = path.dirname(fullPath);
|
const dir = path.dirname(fullPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(dir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
await fs.writeFile(fullPath, content);
|
await fs.writeFile(fullPath, content);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async atomicUpdateText(
|
public async atomicUpdateText(
|
||||||
relativePath: RelativePath,
|
relativePath: RelativePath,
|
||||||
updater: (current: TextWithCursors) => TextWithCursors
|
updater: (current: TextWithCursors) => TextWithCursors
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
this.toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentContent = await fs.readFile(fullPath, "utf-8");
|
const currentContent = await fs.readFile(fullPath, "utf-8");
|
||||||
const result = updater({ text: currentContent, cursors: [] });
|
const result = updater({ text: currentContent, cursors: [] });
|
||||||
await fs.writeFile(fullPath, result.text, "utf-8");
|
await fs.writeFile(fullPath, result.text, "utf-8");
|
||||||
return result.text;
|
return result.text;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to atomically update file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to atomically update file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getFileSize(relativePath: RelativePath): Promise<number> {
|
public async getFileSize(relativePath: RelativePath): Promise<number> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
this.toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const stats = await fs.stat(fullPath);
|
const stats = await fs.stat(fullPath);
|
||||||
return stats.size;
|
return stats.size;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to get file size for ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to get file size for ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async exists(relativePath: RelativePath): Promise<boolean> {
|
public async exists(relativePath: RelativePath): Promise<boolean> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
this.toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await fs.access(fullPath);
|
await fs.access(fullPath);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createDirectory(relativePath: RelativePath): Promise<void> {
|
public async createDirectory(relativePath: RelativePath): Promise<void> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
this.toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(fullPath, { recursive: false });
|
await fs.mkdir(fullPath, { recursive: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to create directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to create directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(relativePath: RelativePath): Promise<void> {
|
public async delete(relativePath: RelativePath): Promise<void> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
this.toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await fs.unlink(fullPath);
|
await fs.unlink(fullPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to delete file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to delete file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async rename(
|
public async rename(
|
||||||
oldPath: RelativePath,
|
oldPath: RelativePath,
|
||||||
newPath: RelativePath
|
newPath: RelativePath
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const oldFullPath = path.join(
|
const oldFullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(oldPath)
|
this.toNativePath(oldPath)
|
||||||
);
|
);
|
||||||
const newFullPath = path.join(
|
const newFullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(newPath)
|
this.toNativePath(newPath)
|
||||||
);
|
);
|
||||||
const newDir = path.dirname(newFullPath);
|
const newDir = path.dirname(newFullPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(newDir, { recursive: true });
|
await fs.mkdir(newDir, { recursive: true });
|
||||||
await fs.rename(oldFullPath, newFullPath);
|
await fs.rename(oldFullPath, newFullPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to rename file from ${oldFullPath} to ${newFullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to rename file from ${oldFullPath} to ${newFullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async walkDirectory(
|
private async walkDirectory(
|
||||||
relativePath: string,
|
relativePath: string,
|
||||||
files: RelativePath[]
|
files: RelativePath[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const fullPath = path.join(this.basePath, relativePath);
|
const fullPath = path.join(this.basePath, relativePath);
|
||||||
let entries: Dirent[] = [];
|
let entries: Dirent[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
entries = await fs.readdir(fullPath, { withFileTypes: true });
|
entries = await fs.readdir(fullPath, { withFileTypes: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to read directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to read directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const entryName = entry.name;
|
const entryName = entry.name;
|
||||||
const entryRelativePath = path.join(relativePath, entryName);
|
const entryRelativePath = path.join(relativePath, entryName);
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
await this.walkDirectory(entryRelativePath, files);
|
await this.walkDirectory(entryRelativePath, files);
|
||||||
} else if (entry.isFile()) {
|
} else if (entry.isFile()) {
|
||||||
// Always return forward slashes
|
// Always return forward slashes
|
||||||
files.push(this.toUnixPath(entryRelativePath));
|
files.push(this.toUnixPath(entryRelativePath));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a forward-slash path to native platform path separators
|
* Convert a forward-slash path to native platform path separators
|
||||||
*/
|
*/
|
||||||
private toNativePath(relativePath: string): string {
|
private toNativePath(relativePath: string): string {
|
||||||
if (path.sep === "\\") {
|
if (path.sep === "\\") {
|
||||||
return relativePath.replace(/\//g, "\\");
|
return relativePath.replace(/\//g, "\\");
|
||||||
}
|
}
|
||||||
return relativePath;
|
return relativePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a native platform path to forward slashes
|
* Convert a native platform path to forward slashes
|
||||||
*/
|
*/
|
||||||
private toUnixPath(nativePath: string): string {
|
private toUnixPath(nativePath: string): string {
|
||||||
if (path.sep === "\\") {
|
if (path.sep === "\\") {
|
||||||
return nativePath.replace(/\\/g, "/");
|
return nativePath.replace(/\\/g, "/");
|
||||||
}
|
}
|
||||||
return nativePath;
|
return nativePath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"lib": [
|
"lib": [
|
||||||
"DOM", // to get `fetch` & `WebSocket`
|
"DOM", // to get `fetch` & `WebSocket`
|
||||||
"ES2024"
|
"ES2024"
|
||||||
],
|
],
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
|
|
@ -18,5 +18,7 @@
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
},
|
},
|
||||||
"exclude": ["dist"]
|
"exclude": [
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,32 +2,32 @@ const path = require("path");
|
||||||
const webpack = require("webpack");
|
const webpack = require("webpack");
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
entry: {
|
entry: {
|
||||||
cli: "./src/cli.ts",
|
cli: "./src/cli.ts",
|
||||||
healthcheck: "./src/healthcheck.ts"
|
healthcheck: "./src/healthcheck.ts"
|
||||||
},
|
},
|
||||||
target: "node",
|
target: "node",
|
||||||
mode: "production",
|
mode: "production",
|
||||||
optimization: {
|
optimization: {
|
||||||
minimize: false
|
minimize: false
|
||||||
},
|
},
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
test: /\.ts$/,
|
test: /\.ts$/,
|
||||||
use: "ts-loader"
|
use: "ts-loader"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
extensions: [".ts", ".js"]
|
extensions: [".ts", ".js"]
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
globalObject: "this",
|
globalObject: "this",
|
||||||
filename: "[name].js",
|
filename: "[name].js",
|
||||||
path: path.resolve(__dirname, "dist")
|
path: path.resolve(__dirname, "dist")
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true })
|
new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true })
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -85,8 +85,3 @@ If you have multiple URLs, you can also do:
|
||||||
## API Documentation
|
## API Documentation
|
||||||
|
|
||||||
See https://github.com/obsidianmd/obsidian-api
|
See https://github.com/obsidianmd/obsidian-api
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
{
|
{
|
||||||
"id": "vault-link",
|
"id": "vault-link",
|
||||||
"name": "VaultLink",
|
"name": "VaultLink",
|
||||||
"version": "0.12.0",
|
"version": "0.12.0",
|
||||||
"minAppVersion": "0.0.0",
|
"minAppVersion": "0.0.0",
|
||||||
"description": "Self-hosted synchronization and collaboration for your Vault.",
|
"description": "Self-hosted synchronization and collaboration for your Vault.",
|
||||||
"author": "Andras Schmelczer",
|
"author": "Andras Schmelczer",
|
||||||
"authorUrl": "https://schmelczer.dev",
|
"authorUrl": "https://schmelczer.dev",
|
||||||
"isDesktopOnly": false
|
"isDesktopOnly": false
|
||||||
}
|
}
|
||||||
|
|
@ -2,175 +2,175 @@ import type { Stat, Vault, Workspace } from "obsidian";
|
||||||
import { MarkdownView, normalizePath } from "obsidian";
|
import { MarkdownView, normalizePath } from "obsidian";
|
||||||
import type { CursorPosition, TextWithCursors } from "sync-client";
|
import type { CursorPosition, TextWithCursors } from "sync-client";
|
||||||
import {
|
import {
|
||||||
utils,
|
utils,
|
||||||
type FileSystemOperations,
|
type FileSystemOperations,
|
||||||
type RelativePath
|
type RelativePath
|
||||||
} from "sync-client";
|
} from "sync-client";
|
||||||
import { getSelectionsFromEditor } from "./views/cursors/get-selections-from-editor";
|
import { getSelectionsFromEditor } from "./views/cursors/get-selections-from-editor";
|
||||||
|
|
||||||
export class ObsidianFileSystemOperations implements FileSystemOperations {
|
export class ObsidianFileSystemOperations implements FileSystemOperations {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly vault: Vault,
|
private readonly vault: Vault,
|
||||||
private readonly workspace: Workspace
|
private readonly workspace: Workspace
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async listFilesRecursively(
|
public async listFilesRecursively(
|
||||||
root: RelativePath | undefined
|
root: RelativePath | undefined
|
||||||
): Promise<RelativePath[]> {
|
): Promise<RelativePath[]> {
|
||||||
// Let's implement this by hand because vault.adapter.listAllFiles doesn't always return all files.
|
// Let's implement this by hand because vault.adapter.listAllFiles doesn't always return all files.
|
||||||
const allFiles = [];
|
const allFiles = [];
|
||||||
const remainingFolders = [root ?? this.vault.getRoot().path];
|
const remainingFolders = [root ?? this.vault.getRoot().path];
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
while (true) {
|
while (true) {
|
||||||
const folder = remainingFolders.pop();
|
const folder = remainingFolders.pop();
|
||||||
if (folder == undefined) {
|
if (folder == undefined) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This would be a very bad idea to sync as it would mess with
|
// This would be a very bad idea to sync as it would mess with
|
||||||
// the integrity of the sync database.
|
// the integrity of the sync database.
|
||||||
if (folder.endsWith(".obsidian/plugins/vault-link/data.json")) {
|
if (folder.endsWith(".obsidian/plugins/vault-link/data.json")) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = await this.vault.adapter.list(normalizePath(folder));
|
const files = await this.vault.adapter.list(normalizePath(folder));
|
||||||
allFiles.push(...files.files);
|
allFiles.push(...files.files);
|
||||||
remainingFolders.push(...files.folders);
|
remainingFolders.push(...files.folders);
|
||||||
}
|
}
|
||||||
|
|
||||||
return allFiles;
|
return allFiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async read(path: RelativePath): Promise<Uint8Array> {
|
public async read(path: RelativePath): Promise<Uint8Array> {
|
||||||
path = normalizePath(path);
|
path = normalizePath(path);
|
||||||
const view = this.workspace.getActiveViewOfType(MarkdownView);
|
const view = this.workspace.getActiveViewOfType(MarkdownView);
|
||||||
if (view?.file?.path === path) {
|
if (view?.file?.path === path) {
|
||||||
return new TextEncoder().encode(view.editor.getValue());
|
return new TextEncoder().encode(view.editor.getValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Uint8Array(await this.vault.adapter.readBinary(path));
|
return new Uint8Array(await this.vault.adapter.readBinary(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
|
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
|
||||||
path = normalizePath(path);
|
path = normalizePath(path);
|
||||||
|
|
||||||
const view = this.workspace.getActiveViewOfType(MarkdownView);
|
const view = this.workspace.getActiveViewOfType(MarkdownView);
|
||||||
if (view?.file?.path === path) {
|
if (view?.file?.path === path) {
|
||||||
const position = view.editor.getCursor();
|
const position = view.editor.getCursor();
|
||||||
view.editor.setValue(new TextDecoder().decode(content));
|
view.editor.setValue(new TextDecoder().decode(content));
|
||||||
view.editor.setCursor(position);
|
view.editor.setCursor(position);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.vault.adapter.writeBinary(
|
return this.vault.adapter.writeBinary(
|
||||||
path,
|
path,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
content.buffer as ArrayBuffer
|
content.buffer as ArrayBuffer
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async atomicUpdateText(
|
public async atomicUpdateText(
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
updater: (current: TextWithCursors) => TextWithCursors
|
updater: (current: TextWithCursors) => TextWithCursors
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
path = normalizePath(path);
|
path = normalizePath(path);
|
||||||
|
|
||||||
const view = this.workspace.getActiveViewOfType(MarkdownView);
|
const view = this.workspace.getActiveViewOfType(MarkdownView);
|
||||||
|
|
||||||
if (view?.file?.path === path) {
|
if (view?.file?.path === path) {
|
||||||
const text = view.editor.getValue();
|
const text = view.editor.getValue();
|
||||||
|
|
||||||
const cursors: CursorPosition[] = getSelectionsFromEditor(
|
const cursors: CursorPosition[] = getSelectionsFromEditor(
|
||||||
view.editor
|
view.editor
|
||||||
).flatMap(({ id, start: anchor, end: head }) => [
|
).flatMap(({ id, start: anchor, end: head }) => [
|
||||||
{
|
{
|
||||||
id: 2 * id,
|
id: 2 * id,
|
||||||
position: anchor
|
position: anchor
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2 * id + 1,
|
id: 2 * id + 1,
|
||||||
position: head
|
position: head
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = updater({
|
const result = updater({
|
||||||
text,
|
text,
|
||||||
cursors
|
cursors
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.text === text) {
|
if (result.text === text) {
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
view.editor.setValue(result.text);
|
view.editor.setValue(result.text);
|
||||||
|
|
||||||
const selections = [];
|
const selections = [];
|
||||||
for (let i = 0; i < result.cursors.length / 2; i++) {
|
for (let i = 0; i < result.cursors.length / 2; i++) {
|
||||||
const from = result.cursors[2 * i];
|
const from = result.cursors[2 * i];
|
||||||
const to = result.cursors[2 * i + 1];
|
const to = result.cursors[2 * i + 1];
|
||||||
const { line: fromLine, column: fromColumn } =
|
const { line: fromLine, column: fromColumn } =
|
||||||
utils.positionToLineAndColumn(result.text, from.position);
|
utils.positionToLineAndColumn(result.text, from.position);
|
||||||
|
|
||||||
const { line: toLine, column: toColumn } =
|
const { line: toLine, column: toColumn } =
|
||||||
utils.positionToLineAndColumn(result.text, to.position);
|
utils.positionToLineAndColumn(result.text, to.position);
|
||||||
|
|
||||||
selections.push({
|
selections.push({
|
||||||
anchor: { line: fromLine, ch: fromColumn },
|
anchor: { line: fromLine, ch: fromColumn },
|
||||||
head: { line: toLine, ch: toColumn }
|
head: { line: toLine, ch: toColumn }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
view.editor.setSelections(selections);
|
view.editor.setSelections(selections);
|
||||||
|
|
||||||
return result.text;
|
return result.text;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.vault.adapter.process(
|
return this.vault.adapter.process(
|
||||||
path,
|
path,
|
||||||
(text) =>
|
(text) =>
|
||||||
updater({
|
updater({
|
||||||
text,
|
text,
|
||||||
cursors: []
|
cursors: []
|
||||||
}).text
|
}).text
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getFileSize(path: RelativePath): Promise<number> {
|
public async getFileSize(path: RelativePath): Promise<number> {
|
||||||
return (await this.statFile(path)).size;
|
return (await this.statFile(path)).size;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getModificationTime(path: RelativePath): Promise<Date> {
|
public async getModificationTime(path: RelativePath): Promise<Date> {
|
||||||
return new Date((await this.statFile(path)).mtime);
|
return new Date((await this.statFile(path)).mtime);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async exists(path: RelativePath): Promise<boolean> {
|
public async exists(path: RelativePath): Promise<boolean> {
|
||||||
return this.vault.adapter.exists(normalizePath(path));
|
return this.vault.adapter.exists(normalizePath(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createDirectory(path: RelativePath): Promise<void> {
|
public async createDirectory(path: RelativePath): Promise<void> {
|
||||||
return this.vault.adapter.mkdir(normalizePath(path));
|
return this.vault.adapter.mkdir(normalizePath(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(path: RelativePath): Promise<void> {
|
public async delete(path: RelativePath): Promise<void> {
|
||||||
if (!(await this.vault.adapter.trashSystem(normalizePath(path)))) {
|
if (!(await this.vault.adapter.trashSystem(normalizePath(path)))) {
|
||||||
return this.vault.adapter.remove(normalizePath(path));
|
return this.vault.adapter.remove(normalizePath(path));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async rename(
|
public async rename(
|
||||||
oldPath: RelativePath,
|
oldPath: RelativePath,
|
||||||
newPath: RelativePath
|
newPath: RelativePath
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return this.vault.adapter.rename(oldPath, newPath);
|
return this.vault.adapter.rename(oldPath, newPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async statFile(path: string): Promise<Stat> {
|
private async statFile(path: string): Promise<Stat> {
|
||||||
const file = await this.vault.adapter.stat(normalizePath(path));
|
const file = await this.vault.adapter.stat(normalizePath(path));
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
throw new Error(`File not found: ${path}`);
|
throw new Error(`File not found: ${path}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import type {
|
import type {
|
||||||
MarkdownView,
|
MarkdownView,
|
||||||
Editor,
|
Editor,
|
||||||
MarkdownFileInfo,
|
MarkdownFileInfo,
|
||||||
TAbstractFile,
|
TAbstractFile,
|
||||||
WorkspaceLeaf
|
WorkspaceLeaf
|
||||||
} from "obsidian";
|
} from "obsidian";
|
||||||
import { Notice, Platform, Plugin, TFile } from "obsidian";
|
import { Notice, Platform, Plugin, TFile } from "obsidian";
|
||||||
import "../manifest.json";
|
import "../manifest.json";
|
||||||
|
|
@ -12,19 +12,19 @@ import { StatusBar } from "./views/status-bar/status-bar";
|
||||||
import { LogsView } from "./views/logs/logs-view";
|
import { LogsView } from "./views/logs/logs-view";
|
||||||
import { StatusDescription } from "./views/status-description/status-description";
|
import { StatusDescription } from "./views/status-description/status-description";
|
||||||
import {
|
import {
|
||||||
SyncClient,
|
SyncClient,
|
||||||
rateLimit,
|
rateLimit,
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
Logger,
|
Logger,
|
||||||
debugging
|
debugging
|
||||||
} from "sync-client";
|
} from "sync-client";
|
||||||
import { ObsidianFileSystemOperations } from "./obsidian-file-system";
|
import { ObsidianFileSystemOperations } from "./obsidian-file-system";
|
||||||
import { SyncSettingsTab } from "./views/settings/settings-tab";
|
import { SyncSettingsTab } from "./views/settings/settings-tab";
|
||||||
import { EditorStatusDisplayManager } from "./views/editor-status-display-manager/editor-status-display-manager";
|
import { EditorStatusDisplayManager } from "./views/editor-status-display-manager/editor-status-display-manager";
|
||||||
import { remoteCursorsTheme } from "./views/cursors/remote-cursor-theme";
|
import { remoteCursorsTheme } from "./views/cursors/remote-cursor-theme";
|
||||||
import {
|
import {
|
||||||
remoteCursorsPlugin,
|
remoteCursorsPlugin,
|
||||||
RemoteCursorsPluginValue
|
RemoteCursorsPluginValue
|
||||||
} from "./views/cursors/remote-cursors-plugin";
|
} from "./views/cursors/remote-cursors-plugin";
|
||||||
import { LocalCursorUpdateListener } from "./views/cursors/local-cursor-update-listener";
|
import { LocalCursorUpdateListener } from "./views/cursors/local-cursor-update-listener";
|
||||||
import { renderCursorsInFileExplorer } from "./views/cursors/file-explorer";
|
import { renderCursorsInFileExplorer } from "./views/cursors/file-explorer";
|
||||||
|
|
@ -33,252 +33,249 @@ const MIN_WAIT_BETWEEN_UPDATES_IN_MS = 250;
|
||||||
const IS_DEBUG_BUILD = process.env.NODE_ENV === "development";
|
const IS_DEBUG_BUILD = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
export default class VaultLinkPlugin extends Plugin {
|
export default class VaultLinkPlugin extends Plugin {
|
||||||
private readonly rateLimitedUpdatesPerFile = new Map<
|
private readonly rateLimitedUpdatesPerFile = new Map<
|
||||||
string,
|
string,
|
||||||
() => Promise<unknown>
|
() => Promise<unknown>
|
||||||
>();
|
>();
|
||||||
|
|
||||||
private readonly syncClient: SyncClient | undefined;
|
private readonly syncClient: SyncClient | undefined;
|
||||||
private settingsTab: SyncSettingsTab | undefined;
|
private settingsTab: SyncSettingsTab | undefined;
|
||||||
|
|
||||||
public async onload(): Promise<void> {
|
public async onload(): Promise<void> {
|
||||||
this.app.workspace.onLayoutReady(async () => {
|
this.app.workspace.onLayoutReady(async () => {
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
if ((globalThis as any).VAULT_LINK_RUNNING_INSTANCE) {
|
if ((globalThis as any).VAULT_LINK_RUNNING_INSTANCE) {
|
||||||
new Notice(
|
new Notice(
|
||||||
"Another instance of VaultLink is already running. Please disable the duplicate instance."
|
"Another instance of VaultLink is already running. Please disable the duplicate instance."
|
||||||
);
|
);
|
||||||
throw new Error("VaultLink instance already running");
|
throw new Error("VaultLink instance already running");
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
(globalThis as any).VAULT_LINK_RUNNING_INSTANCE = this;
|
(globalThis as any).VAULT_LINK_RUNNING_INSTANCE = this;
|
||||||
|
|
||||||
const client = await this.createSyncClient();
|
const client = await this.createSyncClient();
|
||||||
|
|
||||||
this.registerObsidianExtensions(client);
|
this.registerObsidianExtensions(client);
|
||||||
|
|
||||||
this.registerEditorEvents(client);
|
this.registerEditorEvents(client);
|
||||||
|
|
||||||
this.register(async () => {
|
this.register(async () => {
|
||||||
await client.waitUntilFinished();
|
await client.waitUntilFinished();
|
||||||
await client.destroy();
|
await client.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
await client.start();
|
await client.start();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onUserEnable(): void {
|
public onUserEnable(): void {
|
||||||
new Notice(
|
new Notice(
|
||||||
"VaultLink has been enabled, check out the docs for tips on getting started!"
|
"VaultLink has been enabled, check out the docs for tips on getting started!"
|
||||||
);
|
);
|
||||||
void this.activateView(HistoryView.TYPE).catch((e: unknown) => {
|
void this.activateView(HistoryView.TYPE).catch((e: unknown) => {
|
||||||
this.syncClient?.logger.error(
|
this.syncClient?.logger.error(
|
||||||
`Failed to open history view on enable: ${e}`
|
`Failed to open history view on enable: ${e}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
void this.activateView(LogsView.TYPE).catch((e: unknown) => {
|
void this.activateView(LogsView.TYPE).catch((e: unknown) => {
|
||||||
this.syncClient?.logger.error(
|
this.syncClient?.logger.error(
|
||||||
`Failed to open logs view on enable: ${e}`
|
`Failed to open logs view on enable: ${e}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
this.openSettings();
|
this.openSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
public openSettings(): void {
|
public openSettings(): void {
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
(this.app as any).setting.open(); // this is undocumented
|
(this.app as any).setting.open(); // this is undocumented
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
(this.app as any).setting.openTab(this.settingsTab); // this is undocumented
|
(this.app as any).setting.openTab(this.settingsTab); // this is undocumented
|
||||||
}
|
}
|
||||||
|
|
||||||
public closeSettings(): void {
|
public closeSettings(): void {
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
(this.app as any).setting.close(); // this is undocumented
|
(this.app as any).setting.close(); // this is undocumented
|
||||||
}
|
}
|
||||||
|
|
||||||
public async activateView(type: string): Promise<void> {
|
public async activateView(type: string): Promise<void> {
|
||||||
const { workspace } = this.app;
|
const { workspace } = this.app;
|
||||||
|
|
||||||
let leaf: WorkspaceLeaf | null = null;
|
let leaf: WorkspaceLeaf | null = null;
|
||||||
const leaves = workspace.getLeavesOfType(type);
|
const leaves = workspace.getLeavesOfType(type);
|
||||||
|
|
||||||
if (leaves.length > 0) {
|
if (leaves.length > 0) {
|
||||||
[leaf] = leaves;
|
[leaf] = leaves;
|
||||||
} else {
|
} else {
|
||||||
leaf = workspace.getRightLeaf(false);
|
leaf = workspace.getRightLeaf(false);
|
||||||
await leaf?.setViewState({ type: type, active: true });
|
await leaf?.setViewState({ type: type, active: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (leaf) {
|
if (leaf) {
|
||||||
await workspace.revealLeaf(leaf);
|
await workspace.revealLeaf(leaf);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createSyncClient(): Promise<SyncClient> {
|
private async createSyncClient(): Promise<SyncClient> {
|
||||||
DEFAULT_SETTINGS.ignorePatterns.push(
|
DEFAULT_SETTINGS.ignorePatterns.push(
|
||||||
".obsidian/**",
|
".obsidian/**",
|
||||||
".git/**",
|
".git/**",
|
||||||
".trash/**",
|
".trash/**",
|
||||||
"**/.DS_Store"
|
"**/.DS_Store"
|
||||||
);
|
);
|
||||||
|
|
||||||
const client = await SyncClient.create({
|
const client = await SyncClient.create({
|
||||||
fs: new ObsidianFileSystemOperations(
|
fs: new ObsidianFileSystemOperations(
|
||||||
this.app.vault,
|
this.app.vault,
|
||||||
this.app.workspace
|
this.app.workspace
|
||||||
),
|
),
|
||||||
persistence: {
|
persistence: {
|
||||||
load: this.loadData.bind(this),
|
load: this.loadData.bind(this),
|
||||||
save: this.saveData.bind(this)
|
save: this.saveData.bind(this)
|
||||||
},
|
},
|
||||||
nativeLineEndings: Platform.isWin ? "\r\n" : "\n",
|
nativeLineEndings: Platform.isWin ? "\r\n" : "\n",
|
||||||
...(IS_DEBUG_BUILD
|
...(IS_DEBUG_BUILD
|
||||||
? {
|
? {
|
||||||
fetch: debugging.slowFetchFactory(1),
|
fetch: debugging.slowFetchFactory(1),
|
||||||
webSocket: debugging.slowWebSocketFactory(
|
webSocket: debugging.slowWebSocketFactory(1, new Logger())
|
||||||
1,
|
}
|
||||||
new Logger()
|
: {})
|
||||||
)
|
});
|
||||||
}
|
|
||||||
: {})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (IS_DEBUG_BUILD) {
|
if (IS_DEBUG_BUILD) {
|
||||||
debugging.logToConsole(client);
|
debugging.logToConsole(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerObsidianExtensions(client: SyncClient): void {
|
private registerObsidianExtensions(client: SyncClient): void {
|
||||||
const statusDescription = new StatusDescription(client);
|
const statusDescription = new StatusDescription(client);
|
||||||
|
|
||||||
this.settingsTab = new SyncSettingsTab({
|
this.settingsTab = new SyncSettingsTab({
|
||||||
app: this.app,
|
app: this.app,
|
||||||
plugin: this,
|
plugin: this,
|
||||||
syncClient: client,
|
syncClient: client,
|
||||||
statusDescription
|
statusDescription
|
||||||
});
|
});
|
||||||
this.addSettingTab(this.settingsTab);
|
this.addSettingTab(this.settingsTab);
|
||||||
|
|
||||||
new StatusBar(this, client);
|
new StatusBar(this, client);
|
||||||
|
|
||||||
this.registerView(HistoryView.TYPE, (leaf) => {
|
this.registerView(HistoryView.TYPE, (leaf) => {
|
||||||
const view = new HistoryView(client, leaf);
|
const view = new HistoryView(client, leaf);
|
||||||
this.register(async () => view.onClose());
|
this.register(async () => view.onClose());
|
||||||
return view;
|
return view;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.registerView(LogsView.TYPE, (leaf) => new LogsView(client, leaf));
|
this.registerView(LogsView.TYPE, (leaf) => new LogsView(client, leaf));
|
||||||
|
|
||||||
this.registerEditorExtension([remoteCursorsTheme, remoteCursorsPlugin]);
|
this.registerEditorExtension([remoteCursorsTheme, remoteCursorsPlugin]);
|
||||||
|
|
||||||
client.addRemoteCursorsUpdateListener((cursors) => {
|
client.onRemoteCursorsUpdated.add((cursors) => {
|
||||||
RemoteCursorsPluginValue.setCursors(cursors, this.app);
|
RemoteCursorsPluginValue.setCursors(cursors, this.app);
|
||||||
renderCursorsInFileExplorer(cursors, this.app);
|
renderCursorsInFileExplorer(cursors, this.app);
|
||||||
});
|
});
|
||||||
|
|
||||||
const cursorListener = new LocalCursorUpdateListener(
|
const cursorListener = new LocalCursorUpdateListener(
|
||||||
client,
|
client,
|
||||||
this.app.workspace
|
this.app.workspace
|
||||||
);
|
);
|
||||||
this.register(() => {
|
this.register(() => {
|
||||||
cursorListener.dispose();
|
cursorListener.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.app.workspace.updateOptions();
|
this.app.workspace.updateOptions();
|
||||||
|
|
||||||
this.addRibbonIcons();
|
this.addRibbonIcons();
|
||||||
|
|
||||||
const editorStatusDisplayManager = new EditorStatusDisplayManager(
|
const editorStatusDisplayManager = new EditorStatusDisplayManager(
|
||||||
this,
|
this,
|
||||||
this.app.workspace,
|
this.app.workspace,
|
||||||
client
|
client
|
||||||
);
|
);
|
||||||
this.register(() => {
|
this.register(() => {
|
||||||
editorStatusDisplayManager.dispose();
|
editorStatusDisplayManager.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.register(() => {
|
this.register(() => {
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
(globalThis as any).VAULT_LINK_RUNNING_INSTANCE = null;
|
(globalThis as any).VAULT_LINK_RUNNING_INSTANCE = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private addRibbonIcons(): void {
|
private addRibbonIcons(): void {
|
||||||
this.addRibbonIcon(
|
this.addRibbonIcon(
|
||||||
HistoryView.ICON,
|
HistoryView.ICON,
|
||||||
"Open VaultLink events",
|
"Open VaultLink events",
|
||||||
async (_: MouseEvent) => this.activateView(HistoryView.TYPE)
|
async (_: MouseEvent) => this.activateView(HistoryView.TYPE)
|
||||||
);
|
);
|
||||||
|
|
||||||
this.addRibbonIcon(
|
this.addRibbonIcon(
|
||||||
LogsView.ICON,
|
LogsView.ICON,
|
||||||
"Open VaultLink logs",
|
"Open VaultLink logs",
|
||||||
async (_: MouseEvent) => this.activateView(LogsView.TYPE)
|
async (_: MouseEvent) => this.activateView(LogsView.TYPE)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerEditorEvents(client: SyncClient): void {
|
private registerEditorEvents(client: SyncClient): void {
|
||||||
[
|
[
|
||||||
this.app.workspace.on(
|
this.app.workspace.on(
|
||||||
"editor-change",
|
"editor-change",
|
||||||
async (
|
async (
|
||||||
_editor: Editor,
|
_editor: Editor,
|
||||||
info: MarkdownView | MarkdownFileInfo
|
info: MarkdownView | MarkdownFileInfo
|
||||||
) => {
|
) => {
|
||||||
const { file } = info;
|
const { file } = info;
|
||||||
if (file) {
|
if (file) {
|
||||||
await this.rateLimitedUpdate(file.path, client);
|
await this.rateLimitedUpdate(file.path, client);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
this.app.vault.on("create", async (file: TAbstractFile) => {
|
this.app.vault.on("create", async (file: TAbstractFile) => {
|
||||||
if (file instanceof TFile) {
|
if (file instanceof TFile) {
|
||||||
await client.syncLocallyCreatedFile(file.path);
|
await client.syncLocallyCreatedFile(file.path);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
this.app.vault.on("modify", async (file: TAbstractFile) => {
|
this.app.vault.on("modify", async (file: TAbstractFile) => {
|
||||||
if (file instanceof TFile) {
|
if (file instanceof TFile) {
|
||||||
await this.rateLimitedUpdate(file.path, client);
|
await this.rateLimitedUpdate(file.path, client);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
this.app.vault.on("delete", async (file: TAbstractFile) => {
|
this.app.vault.on("delete", async (file: TAbstractFile) => {
|
||||||
await client.syncLocallyDeletedFile(file.path);
|
await client.syncLocallyDeletedFile(file.path);
|
||||||
}),
|
}),
|
||||||
this.app.vault.on(
|
this.app.vault.on(
|
||||||
"rename",
|
"rename",
|
||||||
async (file: TAbstractFile, oldPath: string) => {
|
async (file: TAbstractFile, oldPath: string) => {
|
||||||
if (file instanceof TFile) {
|
if (file instanceof TFile) {
|
||||||
await client.syncLocallyUpdatedFile({
|
await client.syncLocallyUpdatedFile({
|
||||||
oldPath,
|
oldPath,
|
||||||
relativePath: file.path
|
relativePath: file.path
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
].forEach((event) => {
|
].forEach((event) => {
|
||||||
this.registerEvent(event);
|
this.registerEvent(event);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async rateLimitedUpdate(
|
private async rateLimitedUpdate(
|
||||||
path: string,
|
path: string,
|
||||||
client: SyncClient
|
client: SyncClient
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!this.rateLimitedUpdatesPerFile.has(path)) {
|
if (!this.rateLimitedUpdatesPerFile.has(path)) {
|
||||||
this.rateLimitedUpdatesPerFile.set(
|
this.rateLimitedUpdatesPerFile.set(
|
||||||
path,
|
path,
|
||||||
rateLimit(
|
rateLimit(
|
||||||
async () =>
|
async () =>
|
||||||
client.syncLocallyUpdatedFile({
|
client.syncLocallyUpdatedFile({
|
||||||
relativePath: path
|
relativePath: path
|
||||||
}),
|
}),
|
||||||
MIN_WAIT_BETWEEN_UPDATES_IN_MS
|
MIN_WAIT_BETWEEN_UPDATES_IN_MS
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.rateLimitedUpdatesPerFile.get(path)?.();
|
await this.rateLimitedUpdatesPerFile.get(path)?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,54 +2,54 @@ import "./file-explorer.scss";
|
||||||
|
|
||||||
import type { App, View } from "obsidian";
|
import type { App, View } from "obsidian";
|
||||||
import {
|
import {
|
||||||
utils,
|
utils,
|
||||||
type MaybeOutdatedClientCursors,
|
type MaybeOutdatedClientCursors,
|
||||||
type RelativePath
|
type RelativePath
|
||||||
} from "sync-client";
|
} from "sync-client";
|
||||||
|
|
||||||
const REMOTE_USER_CONTAINER_CLASS = "remote-users";
|
const REMOTE_USER_CONTAINER_CLASS = "remote-users";
|
||||||
|
|
||||||
export function renderCursorsInFileExplorer(
|
export function renderCursorsInFileExplorer(
|
||||||
cursors: MaybeOutdatedClientCursors[],
|
cursors: MaybeOutdatedClientCursors[],
|
||||||
app: App
|
app: App
|
||||||
): void {
|
): void {
|
||||||
const fileExplorers = app.workspace.getLeavesOfType("file-explorer");
|
const fileExplorers = app.workspace.getLeavesOfType("file-explorer");
|
||||||
if (fileExplorers.length == 0) return;
|
if (fileExplorers.length == 0) return;
|
||||||
|
|
||||||
const [fileExplorer] = fileExplorers;
|
const [fileExplorer] = fileExplorers;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
const fileExplorerView: View & {
|
const fileExplorerView: View & {
|
||||||
fileItems: Record<RelativePath, { el: Element }>; // it's an internal API
|
fileItems: Record<RelativePath, { el: Element }>; // it's an internal API
|
||||||
} = fileExplorer.view as any; // eslint-disable-line
|
} = fileExplorer.view as any; // eslint-disable-line
|
||||||
|
|
||||||
for (const key in fileExplorerView.fileItems) {
|
for (const key in fileExplorerView.fileItems) {
|
||||||
const element =
|
const element =
|
||||||
fileExplorerView.fileItems[key].el.querySelector(".tree-item-self");
|
fileExplorerView.fileItems[key].el.querySelector(".tree-item-self");
|
||||||
|
|
||||||
const customElement = createDiv(
|
const customElement = createDiv(
|
||||||
{
|
{
|
||||||
cls: REMOTE_USER_CONTAINER_CLASS
|
cls: REMOTE_USER_CONTAINER_CLASS
|
||||||
},
|
},
|
||||||
(parent) => {
|
(parent) => {
|
||||||
cursors.forEach((cursor) => {
|
cursors.forEach((cursor) => {
|
||||||
cursor.documentsWithCursors.forEach((document) => {
|
cursor.documentsWithCursors.forEach((document) => {
|
||||||
if (document.relative_path.startsWith(key)) {
|
if (document.relative_path.startsWith(key)) {
|
||||||
parent.appendChild(
|
parent.appendChild(
|
||||||
createSpan({
|
createSpan({
|
||||||
text: cursor.userName,
|
text: cursor.userName,
|
||||||
attr: {
|
attr: {
|
||||||
style: `border-color: ${utils.getRandomColor(cursor.userName)}`
|
style: `border-color: ${utils.getRandomColor(cursor.userName)}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
element?.querySelector("." + REMOTE_USER_CONTAINER_CLASS)?.remove();
|
element?.querySelector("." + REMOTE_USER_CONTAINER_CLASS)?.remove();
|
||||||
element?.appendChild(customElement);
|
element?.appendChild(customElement);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,16 @@ import type { Editor } from "obsidian";
|
||||||
import { utils } from "sync-client";
|
import { utils } from "sync-client";
|
||||||
|
|
||||||
export interface Selection {
|
export interface Selection {
|
||||||
id: number;
|
id: number;
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSelectionsFromEditor(editor: Editor): Selection[] {
|
export function getSelectionsFromEditor(editor: Editor): Selection[] {
|
||||||
const text = editor.getValue();
|
const text = editor.getValue();
|
||||||
return editor.listSelections().map(({ anchor, head }, i) => ({
|
return editor.listSelections().map(({ anchor, head }, i) => ({
|
||||||
id: i,
|
id: i,
|
||||||
start: utils.lineAndColumnToPosition(text, anchor.line, anchor.ch),
|
start: utils.lineAndColumnToPosition(text, anchor.line, anchor.ch),
|
||||||
end: utils.lineAndColumnToPosition(text, head.line, head.ch)
|
end: utils.lineAndColumnToPosition(text, head.line, head.ch)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,46 +5,46 @@ import type { Selection } from "./get-selections-from-editor";
|
||||||
import { getSelectionsFromEditor } from "./get-selections-from-editor";
|
import { getSelectionsFromEditor } from "./get-selections-from-editor";
|
||||||
|
|
||||||
export class LocalCursorUpdateListener {
|
export class LocalCursorUpdateListener {
|
||||||
private static readonly UPDATE_INTERVAL_MS = 50;
|
private static readonly UPDATE_INTERVAL_MS = 50;
|
||||||
private readonly eventHandle: NodeJS.Timeout;
|
private readonly eventHandle: NodeJS.Timeout;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly client: SyncClient,
|
private readonly client: SyncClient,
|
||||||
private readonly workspace: Workspace
|
private readonly workspace: Workspace
|
||||||
) {
|
) {
|
||||||
this.eventHandle = setInterval(() => {
|
this.eventHandle = setInterval(() => {
|
||||||
this.updateAllSelections();
|
this.updateAllSelections();
|
||||||
}, LocalCursorUpdateListener.UPDATE_INTERVAL_MS);
|
}, LocalCursorUpdateListener.UPDATE_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
clearInterval(this.eventHandle);
|
clearInterval(this.eventHandle);
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateAllSelections(): void {
|
private updateAllSelections(): void {
|
||||||
const currentCursors = this.getAllSelections();
|
const currentCursors = this.getAllSelections();
|
||||||
this.client
|
this.client
|
||||||
.updateLocalCursors(currentCursors)
|
.updateLocalCursors(currentCursors)
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
this.client.logger.error(
|
this.client.logger.error(
|
||||||
`Failed to update local cursors: ${error}`
|
`Failed to update local cursors: ${error}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getAllSelections(): Record<string, Selection[]> {
|
private getAllSelections(): Record<string, Selection[]> {
|
||||||
const cursors: Record<string, Selection[]> = {};
|
const cursors: Record<string, Selection[]> = {};
|
||||||
this.workspace
|
this.workspace
|
||||||
.getLeavesOfType("markdown")
|
.getLeavesOfType("markdown")
|
||||||
.map((leaf) => leaf.view)
|
.map((leaf) => leaf.view)
|
||||||
.filter((view) => view instanceof MarkdownView)
|
.filter((view) => view instanceof MarkdownView)
|
||||||
.forEach((view) => {
|
.forEach((view) => {
|
||||||
const { file } = view;
|
const { file } = view;
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cursors[file.path] = getSelectionsFromEditor(view.editor);
|
cursors[file.path] = getSelectionsFromEditor(view.editor);
|
||||||
});
|
});
|
||||||
return cursors;
|
return cursors;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,60 +4,60 @@ const CARET_WIDTH = 2;
|
||||||
const DOT_RADIUS = 4;
|
const DOT_RADIUS = 4;
|
||||||
|
|
||||||
export const remoteCursorsTheme = EditorView.baseTheme({
|
export const remoteCursorsTheme = EditorView.baseTheme({
|
||||||
".selection-caret": {
|
".selection-caret": {
|
||||||
position: "relative"
|
position: "relative"
|
||||||
},
|
},
|
||||||
|
|
||||||
".selection-caret > *": {
|
".selection-caret > *": {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
backgroundColor: "inherit"
|
backgroundColor: "inherit"
|
||||||
},
|
},
|
||||||
|
|
||||||
".selection-caret > .stick": {
|
".selection-caret > .stick": {
|
||||||
left: 0,
|
left: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
transform: "translateX(-50%)",
|
transform: "translateX(-50%)",
|
||||||
width: `${CARET_WIDTH}px`,
|
width: `${CARET_WIDTH}px`,
|
||||||
height: "100%",
|
height: "100%",
|
||||||
display: "block",
|
display: "block",
|
||||||
borderRadius: `${CARET_WIDTH / 2}px`,
|
borderRadius: `${CARET_WIDTH / 2}px`,
|
||||||
animation: "blink-stick 1s steps(1) infinite"
|
animation: "blink-stick 1s steps(1) infinite"
|
||||||
},
|
},
|
||||||
|
|
||||||
"@keyframes blink-stick": {
|
"@keyframes blink-stick": {
|
||||||
"0%, 100%": { opacity: 1 },
|
"0%, 100%": { opacity: 1 },
|
||||||
"50%": { opacity: 0 }
|
"50%": { opacity: 0 }
|
||||||
},
|
},
|
||||||
|
|
||||||
".selection-caret > .dot": {
|
".selection-caret > .dot": {
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
width: `${DOT_RADIUS * 2}px`,
|
width: `${DOT_RADIUS * 2}px`,
|
||||||
height: `${DOT_RADIUS * 2}px`,
|
height: `${DOT_RADIUS * 2}px`,
|
||||||
top: `-${DOT_RADIUS}px`,
|
top: `-${DOT_RADIUS}px`,
|
||||||
left: `-${DOT_RADIUS}px`,
|
left: `-${DOT_RADIUS}px`,
|
||||||
transition: "transform .3s ease-in-out",
|
transition: "transform .3s ease-in-out",
|
||||||
transformOrigin: "bottom center",
|
transformOrigin: "bottom center",
|
||||||
boxSizing: "border-box"
|
boxSizing: "border-box"
|
||||||
},
|
},
|
||||||
|
|
||||||
".selection-caret:hover > .dot": {
|
".selection-caret:hover > .dot": {
|
||||||
transform: "scale(0)"
|
transform: "scale(0)"
|
||||||
},
|
},
|
||||||
|
|
||||||
".selection-caret > .info": {
|
".selection-caret > .info": {
|
||||||
top: "-1.3em",
|
top: "-1.3em",
|
||||||
left: `-${CARET_WIDTH / 2}px`,
|
left: `-${CARET_WIDTH / 2}px`,
|
||||||
fontSize: "0.9em",
|
fontSize: "0.9em",
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
color: "white",
|
color: "white",
|
||||||
padding: "0 2px",
|
padding: "0 2px",
|
||||||
transition: "opacity .3s ease-in-out",
|
transition: "opacity .3s ease-in-out",
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
borderRadius: "3px 3px 3px 0"
|
borderRadius: "3px 3px 3px 0"
|
||||||
},
|
},
|
||||||
|
|
||||||
".selection-caret:hover > .info": {
|
".selection-caret:hover > .info": {
|
||||||
opacity: 1
|
opacity: 1
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,46 @@
|
||||||
import { AnnotationType, Annotation, RangeSet, Range } from "@codemirror/state";
|
import { AnnotationType, Annotation, RangeSet, Range } from "@codemirror/state";
|
||||||
import {
|
import {
|
||||||
ViewUpdate,
|
ViewUpdate,
|
||||||
ViewPlugin,
|
ViewPlugin,
|
||||||
Decoration,
|
Decoration,
|
||||||
WidgetType
|
WidgetType
|
||||||
} from "@codemirror/view";
|
} from "@codemirror/view";
|
||||||
|
|
||||||
import type { PluginValue, DecorationSet, EditorView } from "@codemirror/view";
|
import type { PluginValue, DecorationSet, EditorView } from "@codemirror/view";
|
||||||
|
|
||||||
export class RemoteCursorWidget extends WidgetType {
|
export class RemoteCursorWidget extends WidgetType {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly color: string,
|
private readonly color: string,
|
||||||
private readonly name: string
|
private readonly name: string
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
public toDOM(editor: EditorView): HTMLElement {
|
public toDOM(editor: EditorView): HTMLElement {
|
||||||
return editor.contentDOM.createEl(
|
return editor.contentDOM.createEl(
|
||||||
"span",
|
"span",
|
||||||
{
|
{
|
||||||
cls: "selection-caret",
|
cls: "selection-caret",
|
||||||
attr: {
|
attr: {
|
||||||
style: `background-color: ${this.color}; border-color: ${this.color}`
|
style: `background-color: ${this.color}; border-color: ${this.color}`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
(span) => {
|
(span) => {
|
||||||
span.createEl("div", {
|
span.createEl("div", {
|
||||||
cls: "stick"
|
cls: "stick"
|
||||||
});
|
});
|
||||||
span.createEl("div", {
|
span.createEl("div", {
|
||||||
cls: "dot"
|
cls: "dot"
|
||||||
});
|
});
|
||||||
span.createEl("div", {
|
span.createEl("div", {
|
||||||
cls: "info",
|
cls: "info",
|
||||||
text: this.name
|
text: this.name
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public eq(other: RemoteCursorWidget): boolean {
|
public eq(other: RemoteCursorWidget): boolean {
|
||||||
return other.color === this.color && other.name === this.name;
|
return other.color === this.color && other.name === this.name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,17 @@ import { RangeSet } from "@codemirror/state";
|
||||||
import { ViewPlugin, Decoration } from "@codemirror/view";
|
import { ViewPlugin, Decoration } from "@codemirror/view";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
PluginValue,
|
PluginValue,
|
||||||
DecorationSet,
|
DecorationSet,
|
||||||
EditorView,
|
EditorView,
|
||||||
ViewUpdate
|
ViewUpdate
|
||||||
} from "@codemirror/view";
|
} from "@codemirror/view";
|
||||||
import { RemoteCursorWidget } from "./remote-cursor-widget";
|
import { RemoteCursorWidget } from "./remote-cursor-widget";
|
||||||
import type { RelativePath } from "sync-client";
|
import type { RelativePath } from "sync-client";
|
||||||
import {
|
import {
|
||||||
utils,
|
utils,
|
||||||
type CursorSpan,
|
type CursorSpan,
|
||||||
type MaybeOutdatedClientCursors
|
type MaybeOutdatedClientCursors
|
||||||
} from "sync-client";
|
} from "sync-client";
|
||||||
import type { App } from "obsidian";
|
import type { App } from "obsidian";
|
||||||
import { MarkdownView } from "obsidian";
|
import { MarkdownView } from "obsidian";
|
||||||
|
|
@ -25,241 +25,241 @@ import { reconcileWithHistory } from "reconcile-text";
|
||||||
const forceUpdate = StateEffect.define();
|
const forceUpdate = StateEffect.define();
|
||||||
|
|
||||||
export class RemoteCursorsPluginValue implements PluginValue {
|
export class RemoteCursorsPluginValue implements PluginValue {
|
||||||
private static cursors: {
|
private static cursors: {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
span: CursorSpan;
|
span: CursorSpan;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
isOutdated: boolean;
|
isOutdated: boolean;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
private static app?: App;
|
private static app?: App;
|
||||||
public decorations: DecorationSet = RangeSet.of([]);
|
public decorations: DecorationSet = RangeSet.of([]);
|
||||||
|
|
||||||
public static setCursors(
|
public static setCursors(
|
||||||
clients: MaybeOutdatedClientCursors[],
|
clients: MaybeOutdatedClientCursors[],
|
||||||
app: App
|
app: App
|
||||||
): void {
|
): void {
|
||||||
RemoteCursorsPluginValue.app = app;
|
RemoteCursorsPluginValue.app = app;
|
||||||
RemoteCursorsPluginValue.cursors = [
|
RemoteCursorsPluginValue.cursors = [
|
||||||
...RemoteCursorsPluginValue.cursors.filter(({ deviceId }) =>
|
...RemoteCursorsPluginValue.cursors.filter(({ deviceId }) =>
|
||||||
clients.some(
|
clients.some(
|
||||||
(client) =>
|
(client) =>
|
||||||
client.deviceId === deviceId && client.isOutdated
|
client.deviceId === deviceId && client.isOutdated
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
...clients
|
...clients
|
||||||
.filter(
|
.filter(
|
||||||
({ isOutdated, deviceId }) =>
|
({ isOutdated, deviceId }) =>
|
||||||
!isOutdated ||
|
!isOutdated ||
|
||||||
RemoteCursorsPluginValue.cursors.every(
|
RemoteCursorsPluginValue.cursors.every(
|
||||||
(c) => deviceId !== c.deviceId
|
(c) => deviceId !== c.deviceId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.flatMap((client) => {
|
.flatMap((client) => {
|
||||||
const clientCursors = client.documentsWithCursors;
|
const clientCursors = client.documentsWithCursors;
|
||||||
return clientCursors.flatMap((cursor) =>
|
return clientCursors.flatMap((cursor) =>
|
||||||
cursor.cursors.map((span) => ({
|
cursor.cursors.map((span) => ({
|
||||||
name: client.userName,
|
name: client.userName,
|
||||||
path: cursor.relative_path,
|
path: cursor.relative_path,
|
||||||
deviceId: client.deviceId,
|
deviceId: client.deviceId,
|
||||||
isOutdated: client.isOutdated,
|
isOutdated: client.isOutdated,
|
||||||
span: { ...span }
|
span: { ...span }
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
app.workspace
|
app.workspace
|
||||||
.getLeavesOfType("markdown")
|
.getLeavesOfType("markdown")
|
||||||
.map((leaf) => leaf.view)
|
.map((leaf) => leaf.view)
|
||||||
.filter((view) => view instanceof MarkdownView)
|
.filter((view) => view instanceof MarkdownView)
|
||||||
.forEach((view) => {
|
.forEach((view) => {
|
||||||
// @ts-expect-error, not typed
|
// @ts-expect-error, not typed
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
const editor = view.editor.cm as EditorView;
|
const editor = view.editor.cm as EditorView;
|
||||||
|
|
||||||
editor.dispatch({
|
editor.dispatch({
|
||||||
effects: [forceUpdate.of(null)]
|
effects: [forceUpdate.of(null)]
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static findFileForEditor(
|
private static findFileForEditor(
|
||||||
editor: EditorView
|
editor: EditorView
|
||||||
): RelativePath | undefined {
|
): RelativePath | undefined {
|
||||||
return RemoteCursorsPluginValue.app?.workspace
|
return RemoteCursorsPluginValue.app?.workspace
|
||||||
.getLeavesOfType("markdown")
|
.getLeavesOfType("markdown")
|
||||||
.map((leaf) => leaf.view)
|
.map((leaf) => leaf.view)
|
||||||
.filter((view) => view instanceof MarkdownView)
|
.filter((view) => view instanceof MarkdownView)
|
||||||
.flatMap((view) => {
|
.flatMap((view) => {
|
||||||
// @ts-expect-error, not typed
|
// @ts-expect-error, not typed
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
if ((view.editor.cm as EditorView) !== editor) {
|
if ((view.editor.cm as EditorView) !== editor) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { file } = view;
|
const { file } = view;
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [file.path];
|
return [file.path];
|
||||||
})
|
})
|
||||||
.first();
|
.first();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static interpolateRemoteCursorPositions(
|
private static interpolateRemoteCursorPositions(
|
||||||
original: string,
|
original: string,
|
||||||
edited: string
|
edited: string
|
||||||
): void {
|
): void {
|
||||||
if (
|
if (
|
||||||
original === edited ||
|
original === edited ||
|
||||||
RemoteCursorsPluginValue.cursors.length === 0
|
RemoteCursorsPluginValue.cursors.length === 0
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedPositions: number[] = [];
|
const updatedPositions: number[] = [];
|
||||||
const reconciled = reconcileWithHistory(
|
const reconciled = reconcileWithHistory(
|
||||||
original,
|
original,
|
||||||
{
|
{
|
||||||
text: original,
|
text: original,
|
||||||
cursors: RemoteCursorsPluginValue.cursors.flatMap(
|
cursors: RemoteCursorsPluginValue.cursors.flatMap(
|
||||||
({ span }, i) => [
|
({ span }, i) => [
|
||||||
{ id: i * 2, position: span.start },
|
{ id: i * 2, position: span.start },
|
||||||
{ id: i * 2 + 1, position: span.end }
|
{ id: i * 2 + 1, position: span.end }
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
edited
|
edited
|
||||||
);
|
);
|
||||||
|
|
||||||
reconciled.cursors.forEach(({ id, position }) => {
|
reconciled.cursors.forEach(({ id, position }) => {
|
||||||
const whereToJump = RemoteCursorsPluginValue.findWhereToMoveCursor(
|
const whereToJump = RemoteCursorsPluginValue.findWhereToMoveCursor(
|
||||||
position,
|
position,
|
||||||
reconciled.history
|
reconciled.history
|
||||||
);
|
);
|
||||||
if (whereToJump !== null) {
|
if (whereToJump !== null) {
|
||||||
updatedPositions[id] = whereToJump;
|
updatedPositions[id] = whereToJump;
|
||||||
} else {
|
} else {
|
||||||
updatedPositions[id] = position;
|
updatedPositions[id] = position;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
RemoteCursorsPluginValue.cursors.forEach(({ span }, i) => {
|
RemoteCursorsPluginValue.cursors.forEach(({ span }, i) => {
|
||||||
span.start = updatedPositions[i * 2];
|
span.start = updatedPositions[i * 2];
|
||||||
span.end = updatedPositions[i * 2 + 1];
|
span.end = updatedPositions[i * 2 + 1];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static findWhereToMoveCursor(
|
private static findWhereToMoveCursor(
|
||||||
cursor: number,
|
cursor: number,
|
||||||
spans: SpanWithHistory[]
|
spans: SpanWithHistory[]
|
||||||
): number | null {
|
): number | null {
|
||||||
let position = 0;
|
let position = 0;
|
||||||
for (const span of spans) {
|
for (const span of spans) {
|
||||||
// left and origin are the same
|
// left and origin are the same
|
||||||
if (position === cursor && span.history === "AddedFromRight") {
|
if (position === cursor && span.history === "AddedFromRight") {
|
||||||
return position + span.text.length;
|
return position + span.text.length;
|
||||||
}
|
}
|
||||||
position += span.text.length;
|
position += span.text.length;
|
||||||
if (position === cursor && span.history === "RemovedFromRight") {
|
if (position === cursor && span.history === "RemovedFromRight") {
|
||||||
return position - span.text.length;
|
return position - span.text.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public update(update: ViewUpdate): void {
|
public update(update: ViewUpdate): void {
|
||||||
const original = update.startState.doc.toString();
|
const original = update.startState.doc.toString();
|
||||||
const edited = update.state.doc.toString();
|
const edited = update.state.doc.toString();
|
||||||
|
|
||||||
RemoteCursorsPluginValue.interpolateRemoteCursorPositions(
|
RemoteCursorsPluginValue.interpolateRemoteCursorPositions(
|
||||||
original,
|
original,
|
||||||
edited
|
edited
|
||||||
);
|
);
|
||||||
|
|
||||||
const decorations: Range<Decoration>[] = [];
|
const decorations: Range<Decoration>[] = [];
|
||||||
const relative_path = RemoteCursorsPluginValue.findFileForEditor(
|
const relative_path = RemoteCursorsPluginValue.findFileForEditor(
|
||||||
update.view
|
update.view
|
||||||
);
|
);
|
||||||
RemoteCursorsPluginValue.cursors
|
RemoteCursorsPluginValue.cursors
|
||||||
.filter(({ path }) => path == relative_path)
|
.filter(({ path }) => path == relative_path)
|
||||||
.forEach(({ name, span: { start, end } }) => {
|
.forEach(({ name, span: { start, end } }) => {
|
||||||
const color = utils.getRandomColor(name);
|
const color = utils.getRandomColor(name);
|
||||||
const startLine = update.view.state.doc.lineAt(start);
|
const startLine = update.view.state.doc.lineAt(start);
|
||||||
const endLine = update.view.state.doc.lineAt(end);
|
const endLine = update.view.state.doc.lineAt(end);
|
||||||
|
|
||||||
const attributes = {
|
const attributes = {
|
||||||
style: `background-color: ${color};`
|
style: `background-color: ${color};`
|
||||||
};
|
};
|
||||||
|
|
||||||
if (startLine.number === endLine.number) {
|
if (startLine.number === endLine.number) {
|
||||||
// selected content in a single line.
|
// selected content in a single line.
|
||||||
decorations.push({
|
decorations.push({
|
||||||
from: start,
|
from: start,
|
||||||
to: end,
|
to: end,
|
||||||
value: Decoration.mark({
|
value: Decoration.mark({
|
||||||
attributes
|
attributes
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// selected content in multiple lines
|
// selected content in multiple lines
|
||||||
// first, render text-selection in the first line
|
// first, render text-selection in the first line
|
||||||
decorations.push({
|
decorations.push({
|
||||||
from: start,
|
from: start,
|
||||||
to: startLine.from + startLine.length,
|
to: startLine.from + startLine.length,
|
||||||
value: Decoration.mark({
|
value: Decoration.mark({
|
||||||
attributes
|
attributes
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
// render text-selection in the lines between the first and last line
|
// render text-selection in the lines between the first and last line
|
||||||
for (
|
for (
|
||||||
let i = startLine.number + 1;
|
let i = startLine.number + 1;
|
||||||
i < endLine.number;
|
i < endLine.number;
|
||||||
i++
|
i++
|
||||||
) {
|
) {
|
||||||
const currentLine = update.view.state.doc.line(i);
|
const currentLine = update.view.state.doc.line(i);
|
||||||
decorations.push({
|
decorations.push({
|
||||||
from: currentLine.from,
|
from: currentLine.from,
|
||||||
to: currentLine.to,
|
to: currentLine.to,
|
||||||
value: Decoration.mark({
|
value: Decoration.mark({
|
||||||
attributes
|
attributes
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// render text-selection in the last line
|
// render text-selection in the last line
|
||||||
decorations.push({
|
decorations.push({
|
||||||
from: endLine.from,
|
from: endLine.from,
|
||||||
to: end,
|
to: end,
|
||||||
value: Decoration.mark({
|
value: Decoration.mark({
|
||||||
attributes
|
attributes
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
decorations.push({
|
decorations.push({
|
||||||
from: end,
|
from: end,
|
||||||
to: end,
|
to: end,
|
||||||
value: Decoration.widget({
|
value: Decoration.widget({
|
||||||
side: end - start > 0 ? -1 : 1, // the local cursor should be rendered outside the remote selection
|
side: end - start > 0 ? -1 : 1, // the local cursor should be rendered outside the remote selection
|
||||||
block: false,
|
block: false,
|
||||||
widget: new RemoteCursorWidget(color, name)
|
widget: new RemoteCursorWidget(color, name)
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.decorations = Decoration.set(decorations, true);
|
this.decorations = Decoration.set(decorations, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const remoteCursorsPlugin = ViewPlugin.fromClass(
|
export const remoteCursorsPlugin = ViewPlugin.fromClass(
|
||||||
RemoteCursorsPluginValue,
|
RemoteCursorsPluginValue,
|
||||||
{
|
{
|
||||||
decorations: (v) => v.decorations
|
decorations: (v) => v.decorations
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,43 @@
|
||||||
.vault-link-sync-status {
|
.vault-link-sync-status {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: var(--size-4-4);
|
right: var(--size-4-4);
|
||||||
top: var(--size-4-2);
|
top: var(--size-4-2);
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
> span {
|
> span {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
padding-right: var(--size-2-2);
|
padding-right: var(--size-2-2);
|
||||||
|
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 0;
|
left: 0;
|
||||||
transform: translateY(-50%) translateX(-100%) translateY(-2px);
|
transform: translateY(-50%) translateX(-100%) translateY(-2px);
|
||||||
transition: opacity 200ms;
|
transition: opacity 200ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
> span {
|
> span {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
> .icon {
|
> .icon {
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.loading > .icon {
|
&.loading > .icon {
|
||||||
animation: spin 2s linear infinite;
|
animation: spin 2s linear infinite;
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
0% {
|
0% {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,91 +7,91 @@ import type VaultLinkPlugin from "src/vault-link-plugin";
|
||||||
import { HistoryView } from "../history/history-view";
|
import { HistoryView } from "../history/history-view";
|
||||||
|
|
||||||
export class EditorStatusDisplayManager {
|
export class EditorStatusDisplayManager {
|
||||||
private static readonly UPDATE_INTERVAL_IN_MS = 100;
|
private static readonly UPDATE_INTERVAL_IN_MS = 100;
|
||||||
|
|
||||||
private readonly intervalId: NodeJS.Timeout;
|
private readonly intervalId: NodeJS.Timeout;
|
||||||
private readonly lastStatuses = new Map<string, DocumentSyncStatus>();
|
private readonly lastStatuses = new Map<string, DocumentSyncStatus>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly plugin: VaultLinkPlugin,
|
private readonly plugin: VaultLinkPlugin,
|
||||||
private readonly workspace: Workspace,
|
private readonly workspace: Workspace,
|
||||||
private readonly client: SyncClient
|
private readonly client: SyncClient
|
||||||
) {
|
) {
|
||||||
this.intervalId = setInterval(() => {
|
this.intervalId = setInterval(() => {
|
||||||
this.updateEditorStatusDisplay();
|
this.updateEditorStatusDisplay();
|
||||||
}, EditorStatusDisplayManager.UPDATE_INTERVAL_IN_MS);
|
}, EditorStatusDisplayManager.UPDATE_INTERVAL_IN_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
clearInterval(this.intervalId);
|
clearInterval(this.intervalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateEditorStatusDisplay(): void {
|
private updateEditorStatusDisplay(): void {
|
||||||
this.workspace.iterateAllLeaves((leaf) => {
|
this.workspace.iterateAllLeaves((leaf) => {
|
||||||
if (leaf.view instanceof FileView) {
|
if (leaf.view instanceof FileView) {
|
||||||
const filePath = leaf.view.file?.path;
|
const filePath = leaf.view.file?.path;
|
||||||
if (filePath == null) {
|
if (filePath == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const element = this.getElementFromLeaf(leaf.view);
|
const element = this.getElementFromLeaf(leaf.view);
|
||||||
if (element == null) {
|
if (element == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousStatus = this.lastStatuses.get(filePath);
|
const previousStatus = this.lastStatuses.get(filePath);
|
||||||
const currentStatus =
|
const currentStatus =
|
||||||
this.client.getDocumentSyncingStatus(filePath);
|
this.client.getDocumentSyncingStatus(filePath);
|
||||||
if (previousStatus === currentStatus) {
|
if (previousStatus === currentStatus) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.lastStatuses.set(filePath, currentStatus);
|
this.lastStatuses.set(filePath, currentStatus);
|
||||||
|
|
||||||
if (currentStatus == DocumentSyncStatus.SYNCING_IS_DISABLED) {
|
if (currentStatus == DocumentSyncStatus.SYNCING_IS_DISABLED) {
|
||||||
element.remove();
|
element.remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentStatus == DocumentSyncStatus.SYNCING) {
|
if (currentStatus == DocumentSyncStatus.SYNCING) {
|
||||||
element.classList.add("loading");
|
element.classList.add("loading");
|
||||||
} else {
|
} else {
|
||||||
element.classList.remove("loading");
|
element.classList.remove("loading");
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconContainer = element.querySelector(".icon");
|
const iconContainer = element.querySelector(".icon");
|
||||||
if (iconContainer != null) {
|
if (iconContainer != null) {
|
||||||
setIcon(
|
setIcon(
|
||||||
iconContainer as HTMLElement, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
iconContainer as HTMLElement, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
currentStatus == DocumentSyncStatus.SYNCING
|
currentStatus == DocumentSyncStatus.SYNCING
|
||||||
? "loader"
|
? "loader"
|
||||||
: "circle-check"
|
: "circle-check"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getElementFromLeaf(fileView: FileView): Element | undefined {
|
private getElementFromLeaf(fileView: FileView): Element | undefined {
|
||||||
const parent = fileView.contentEl.querySelector(".cm-editor");
|
const parent = fileView.contentEl.querySelector(".cm-editor");
|
||||||
if (parent == null) {
|
if (parent == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
parent.querySelector(".vault-link-sync-status") ??
|
parent.querySelector(".vault-link-sync-status") ??
|
||||||
parent.createDiv(
|
parent.createDiv(
|
||||||
{
|
{
|
||||||
cls: "vault-link-sync-status"
|
cls: "vault-link-sync-status"
|
||||||
},
|
},
|
||||||
(el) => {
|
(el) => {
|
||||||
el.createSpan({ text: "VaultLink sync state" });
|
el.createSpan({ text: "VaultLink sync state" });
|
||||||
el.createDiv({
|
el.createDiv({
|
||||||
cls: "icon"
|
cls: "icon"
|
||||||
});
|
});
|
||||||
el.onclick = async (): Promise<void> =>
|
el.onclick = async (): Promise<void> =>
|
||||||
this.plugin.activateView(HistoryView.TYPE);
|
this.plugin.activateView(HistoryView.TYPE);
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,61 @@
|
||||||
.history-card {
|
.history-card {
|
||||||
padding: var(--size-4-4);
|
padding: var(--size-4-4);
|
||||||
margin: var(--size-4-2);
|
margin: var(--size-4-2);
|
||||||
background-color: var(--color-base-00);
|
background-color: var(--color-base-00);
|
||||||
border-radius: var(--radius-l);
|
border-radius: var(--radius-l);
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
|
||||||
&.clickable {
|
&.clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.success {
|
&.success {
|
||||||
background-color: rgba(var(--color-green-rgb), 0.2);
|
background-color: rgba(var(--color-green-rgb), 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.error {
|
&.error {
|
||||||
background-color: rgba(var(--color-red-rgb), 0.2);
|
background-color: rgba(var(--color-red-rgb), 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.skipped {
|
&.skipped {
|
||||||
background-color: rgba(var(--color-green-rgb), 0.08);
|
background-color: rgba(var(--color-green-rgb), 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-card-header {
|
.history-card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: var(--size-4-2);
|
margin-bottom: var(--size-4-2);
|
||||||
gap: var(--size-4-2);
|
gap: var(--size-4-2);
|
||||||
|
|
||||||
@container (max-width: 300px) {
|
@container (max-width: 300px) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-card-title {
|
.history-card-title {
|
||||||
font: var(--font-monospace);
|
font: var(--font-monospace);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--size-4-2);
|
gap: var(--size-4-2);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
> span {
|
> span {
|
||||||
margin-bottom: var(--size-4-1);
|
margin-bottom: var(--size-4-1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-card-timestamp {
|
.history-card-timestamp {
|
||||||
font-size: var(--font-ui-small);
|
font-size: var(--font-ui-small);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--italic-color);
|
color: var(--italic-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-card-message {
|
.history-card-message {
|
||||||
font-size: var(--font-ui-medium);
|
font-size: var(--font-ui-medium);
|
||||||
color: var(--color-base-70);
|
color: var(--color-base-70);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,234 +7,234 @@ import type { HistoryEntry, SyncClient } from "sync-client";
|
||||||
import { SyncType } from "sync-client";
|
import { SyncType } from "sync-client";
|
||||||
|
|
||||||
export class HistoryView extends ItemView {
|
export class HistoryView extends ItemView {
|
||||||
public static readonly TYPE = "history-view";
|
public static readonly TYPE = "history-view";
|
||||||
public static readonly ICON = "square-stack";
|
public static readonly ICON = "square-stack";
|
||||||
private timer: NodeJS.Timeout | null = null;
|
private timer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
private historyContainer: HTMLElement | undefined;
|
private historyContainer: HTMLElement | undefined;
|
||||||
private readonly historyEntryToElement = new Map<
|
private readonly historyEntryToElement = new Map<
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
HTMLElement
|
HTMLElement
|
||||||
>();
|
>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly client: SyncClient,
|
private readonly client: SyncClient,
|
||||||
leaf: WorkspaceLeaf
|
leaf: WorkspaceLeaf
|
||||||
) {
|
) {
|
||||||
super(leaf);
|
super(leaf);
|
||||||
this.icon = HistoryView.ICON;
|
this.icon = HistoryView.ICON;
|
||||||
|
|
||||||
this.client.addSyncHistoryUpdateListener(async () =>
|
this.client.onSyncHistoryUpdated.add(async () =>
|
||||||
this.updateView().catch((error: unknown) => {
|
this.updateView().catch((error: unknown) => {
|
||||||
this.client.logger.error(
|
this.client.logger.error(
|
||||||
`Failed to update history view: ${error}`
|
`Failed to update history view: ${error}`
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getSyncTypeIcon(type: SyncType | undefined): IconName {
|
private static getSyncTypeIcon(type: SyncType | undefined): IconName {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case SyncType.CREATE:
|
case SyncType.CREATE:
|
||||||
return "file-plus";
|
return "file-plus";
|
||||||
case SyncType.DELETE:
|
case SyncType.DELETE:
|
||||||
return "trash-2";
|
return "trash-2";
|
||||||
case SyncType.UPDATE:
|
case SyncType.UPDATE:
|
||||||
return "file-pen-line";
|
return "file-pen-line";
|
||||||
case SyncType.MOVE:
|
case SyncType.MOVE:
|
||||||
return "move-right";
|
return "move-right";
|
||||||
case SyncType.SKIPPED:
|
case SyncType.SKIPPED:
|
||||||
return "circle-slash";
|
return "circle-slash";
|
||||||
case undefined:
|
case undefined:
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static renderSyncItemTitle(
|
private static renderSyncItemTitle(
|
||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
entry: HistoryEntry
|
entry: HistoryEntry
|
||||||
): void {
|
): void {
|
||||||
const syncTypeIcon = HistoryView.getSyncTypeIcon(entry.details.type);
|
const syncTypeIcon = HistoryView.getSyncTypeIcon(entry.details.type);
|
||||||
if (syncTypeIcon) {
|
if (syncTypeIcon) {
|
||||||
setIcon(element.createDiv(), syncTypeIcon);
|
setIcon(element.createDiv(), syncTypeIcon);
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileName = entry.details.relativePath.split("/").pop() ?? "";
|
let fileName = entry.details.relativePath.split("/").pop() ?? "";
|
||||||
fileName = fileName.replace(/\.md$/, "");
|
fileName = fileName.replace(/\.md$/, "");
|
||||||
|
|
||||||
element.createEl("span", {
|
element.createEl("span", {
|
||||||
text:
|
text:
|
||||||
entry.details.type === SyncType.SKIPPED
|
entry.details.type === SyncType.SKIPPED
|
||||||
? `Skipped: ${fileName}`
|
? `Skipped: ${fileName}`
|
||||||
: fileName
|
: fileName
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static updateTimeSince(
|
private static updateTimeSince(
|
||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
entry: HistoryEntry
|
entry: HistoryEntry
|
||||||
): void {
|
): void {
|
||||||
const timestampElement = element.querySelector(
|
const timestampElement = element.querySelector(
|
||||||
".history-card-timestamp"
|
".history-card-timestamp"
|
||||||
);
|
);
|
||||||
|
|
||||||
if (timestampElement != null) {
|
if (timestampElement != null) {
|
||||||
timestampElement.textContent =
|
timestampElement.textContent =
|
||||||
HistoryView.getTimestampAndAuthor(entry);
|
HistoryView.getTimestampAndAuthor(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getTimestampAndAuthor(entry: HistoryEntry): string {
|
private static getTimestampAndAuthor(entry: HistoryEntry): string {
|
||||||
let content = intlFormatDistance(entry.timestamp, new Date());
|
let content = intlFormatDistance(entry.timestamp, new Date());
|
||||||
if ("author" in entry && entry.author !== undefined) {
|
if ("author" in entry && entry.author !== undefined) {
|
||||||
content += ` by ${entry.author}`;
|
content += ` by ${entry.author}`;
|
||||||
}
|
}
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getViewType(): string {
|
public getViewType(): string {
|
||||||
return HistoryView.TYPE;
|
return HistoryView.TYPE;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getDisplayText(): string {
|
public getDisplayText(): string {
|
||||||
return "VaultLink history";
|
return "VaultLink history";
|
||||||
}
|
}
|
||||||
|
|
||||||
public async onOpen(): Promise<void> {
|
public async onOpen(): Promise<void> {
|
||||||
const container = this.containerEl.children[1];
|
const container = this.containerEl.children[1];
|
||||||
container.createEl("h4", { text: "VaultLink history" });
|
container.createEl("h4", { text: "VaultLink history" });
|
||||||
|
|
||||||
this.historyContainer = container.createDiv({ cls: "logs-container" });
|
this.historyContainer = container.createDiv({ cls: "logs-container" });
|
||||||
|
|
||||||
await this.updateView();
|
await this.updateView();
|
||||||
this.clearTimer();
|
this.clearTimer();
|
||||||
this.timer = setInterval(
|
this.timer = setInterval(
|
||||||
() =>
|
() =>
|
||||||
void this.updateView().catch((error: unknown) => {
|
void this.updateView().catch((error: unknown) => {
|
||||||
this.client.logger.error(
|
this.client.logger.error(
|
||||||
`Failed to update history view: ${error}`
|
`Failed to update history view: ${error}`
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
1000
|
1000
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async onClose(): Promise<void> {
|
public async onClose(): Promise<void> {
|
||||||
this.clearTimer();
|
this.clearTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearTimer(): void {
|
private clearTimer(): void {
|
||||||
if (this.timer) {
|
if (this.timer) {
|
||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateView(): Promise<void> {
|
private async updateView(): Promise<void> {
|
||||||
const container = this.historyContainer;
|
const container = this.historyContainer;
|
||||||
if (container === undefined) {
|
if (container === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// entries are newest first, but we prepend new ones
|
// entries are newest first, but we prepend new ones
|
||||||
const entries = this.client.getHistoryEntries().toReversed();
|
const entries = this.client.getHistoryEntries().toReversed();
|
||||||
|
|
||||||
if (this.historyEntryToElement.size === 0 && entries.length > 0) {
|
if (this.historyEntryToElement.size === 0 && entries.length > 0) {
|
||||||
// Clear the "No update has happened yet" message
|
// Clear the "No update has happened yet" message
|
||||||
container.empty();
|
container.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
const element = this.historyEntryToElement.get(entry);
|
const element = this.historyEntryToElement.get(entry);
|
||||||
if (element !== undefined) {
|
if (element !== undefined) {
|
||||||
HistoryView.updateTimeSince(element, entry);
|
HistoryView.updateTimeSince(element, entry);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newElement = this.createHistoryCard(container, entry);
|
const newElement = this.createHistoryCard(container, entry);
|
||||||
container.prepend(newElement);
|
container.prepend(newElement);
|
||||||
this.historyEntryToElement.set(entry, newElement);
|
this.historyEntryToElement.set(entry, newElement);
|
||||||
});
|
});
|
||||||
|
|
||||||
const newEntries = new Set(entries);
|
const newEntries = new Set(entries);
|
||||||
for (const [entry, element] of this.historyEntryToElement) {
|
for (const [entry, element] of this.historyEntryToElement) {
|
||||||
if (!newEntries.has(entry)) {
|
if (!newEntries.has(entry)) {
|
||||||
element.remove();
|
element.remove();
|
||||||
this.historyEntryToElement.delete(entry);
|
this.historyEntryToElement.delete(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
container.empty();
|
container.empty();
|
||||||
container.createEl("p", {
|
container.createEl("p", {
|
||||||
text: "No update has happened yet."
|
text: "No update has happened yet."
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private createHistoryCard(
|
private createHistoryCard(
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
entry: HistoryEntry
|
entry: HistoryEntry
|
||||||
): HTMLElement {
|
): HTMLElement {
|
||||||
return container.createDiv(
|
return container.createDiv(
|
||||||
{
|
{
|
||||||
cls: ["history-card", entry.status.toLocaleLowerCase()]
|
cls: ["history-card", entry.status.toLocaleLowerCase()]
|
||||||
},
|
},
|
||||||
(card) => {
|
(card) => {
|
||||||
if (
|
if (
|
||||||
this.app.vault.getFileByPath(entry.details.relativePath) !=
|
this.app.vault.getFileByPath(entry.details.relativePath) !=
|
||||||
null
|
null
|
||||||
) {
|
) {
|
||||||
card.addEventListener("click", () => {
|
card.addEventListener("click", () => {
|
||||||
this.app.workspace
|
this.app.workspace
|
||||||
.openLinkText(
|
.openLinkText(
|
||||||
entry.details.relativePath,
|
entry.details.relativePath,
|
||||||
entry.details.relativePath,
|
entry.details.relativePath,
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
this.client.logger.error(
|
this.client.logger.error(
|
||||||
`Failed to open link for ${entry.details.relativePath}: ${error}`
|
`Failed to open link for ${entry.details.relativePath}: ${error}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
card.addClass("clickable");
|
card.addClass("clickable");
|
||||||
}
|
}
|
||||||
|
|
||||||
card.createDiv(
|
card.createDiv(
|
||||||
{
|
{
|
||||||
cls: "history-card-header"
|
cls: "history-card-header"
|
||||||
},
|
},
|
||||||
(header) => {
|
(header) => {
|
||||||
header.createEl(
|
header.createEl(
|
||||||
"h5",
|
"h5",
|
||||||
{
|
{
|
||||||
cls: "history-card-title"
|
cls: "history-card-title"
|
||||||
},
|
},
|
||||||
(title) => {
|
(title) => {
|
||||||
HistoryView.renderSyncItemTitle(title, entry);
|
HistoryView.renderSyncItemTitle(title, entry);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
header.createSpan({
|
header.createSpan({
|
||||||
text: HistoryView.getTimestampAndAuthor(entry),
|
text: HistoryView.getTimestampAndAuthor(entry),
|
||||||
cls: "history-card-timestamp"
|
cls: "history-card-timestamp"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const body =
|
const body =
|
||||||
entry.details.type === SyncType.MOVE
|
entry.details.type === SyncType.MOVE
|
||||||
? `${entry.message}. Moved from '${entry.details.movedFrom}' to '${entry.details.relativePath}'`
|
? `${entry.message}. Moved from '${entry.details.movedFrom}' to '${entry.details.relativePath}'`
|
||||||
: `${entry.message}.`;
|
: `${entry.message}.`;
|
||||||
|
|
||||||
card.createEl("p", {
|
card.createEl("p", {
|
||||||
text: body,
|
text: body,
|
||||||
cls: "history-card-message"
|
cls: "history-card-message"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,74 +1,74 @@
|
||||||
.logs-view {
|
.logs-view {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
.verbosity-selector {
|
.verbosity-selector {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
gap: var(--size-4-2);
|
gap: var(--size-4-2);
|
||||||
margin: var(--size-4-4) var(--size-4-2);
|
margin: var(--size-4-4) var(--size-4-2);
|
||||||
|
|
||||||
h4 {
|
h4 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logs-controls {
|
.logs-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--size-4-2);
|
gap: var(--size-4-2);
|
||||||
|
|
||||||
button {
|
button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--size-2-1);
|
gap: var(--size-2-1);
|
||||||
padding: var(--size-2-2) var(--size-4-2);
|
padding: var(--size-2-2) var(--size-4-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.logs-container {
|
.logs-container {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
.log-message {
|
.log-message {
|
||||||
font: var(--font-monospace);
|
font: var(--font-monospace);
|
||||||
margin-bottom: var(--size-2-1);
|
margin-bottom: var(--size-2-1);
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
user-select: all;
|
user-select: all;
|
||||||
|
|
||||||
.timestamp {
|
.timestamp {
|
||||||
padding: var(--size-2-1) var(--size-4-1);
|
padding: var(--size-2-1) var(--size-4-1);
|
||||||
border-radius: var(--radius-s);
|
border-radius: var(--radius-s);
|
||||||
background-color: var(--color-base-30);
|
background-color: var(--color-base-30);
|
||||||
font-size: var(--font-ui-small);
|
font-size: var(--font-ui-small);
|
||||||
font-family: var(--font-monospace);
|
font-family: var(--font-monospace);
|
||||||
font-weight: var(--bold-weight);
|
font-weight: var(--bold-weight);
|
||||||
margin-right: var(--size-4-1);
|
margin-right: var(--size-4-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.DEBUG {
|
&.DEBUG {
|
||||||
color: var(--color-base-50);
|
color: var(--color-base-50);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.INFO {
|
&.INFO {
|
||||||
color: var(--color-base-100);
|
color: var(--color-base-100);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.WARNING {
|
&.WARNING {
|
||||||
color: rgb(var(--color-yellow-rgb));
|
color: rgb(var(--color-yellow-rgb));
|
||||||
}
|
}
|
||||||
|
|
||||||
&.ERROR {
|
&.ERROR {
|
||||||
color: rgb(var(--color-red-rgb));
|
color: rgb(var(--color-red-rgb));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,189 +6,189 @@ import type { LogLine } from "sync-client";
|
||||||
import { LogLevel, type SyncClient } from "sync-client";
|
import { LogLevel, type SyncClient } from "sync-client";
|
||||||
|
|
||||||
export class LogsView extends ItemView {
|
export class LogsView extends ItemView {
|
||||||
public static readonly TYPE = "logs-view";
|
public static readonly TYPE = "logs-view";
|
||||||
public static readonly ICON = "logs";
|
public static readonly ICON = "logs";
|
||||||
|
|
||||||
private static readonly MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX = 300;
|
private static readonly MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX = 300;
|
||||||
|
|
||||||
private logsContainer: HTMLElement | undefined;
|
private logsContainer: HTMLElement | undefined;
|
||||||
private readonly logLineToElement = new Map<LogLine, HTMLElement>();
|
private readonly logLineToElement = new Map<LogLine, HTMLElement>();
|
||||||
private minLogLevel: LogLevel = LogLevel.INFO;
|
private minLogLevel: LogLevel = LogLevel.INFO;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly client: SyncClient,
|
private readonly client: SyncClient,
|
||||||
leaf: WorkspaceLeaf
|
leaf: WorkspaceLeaf
|
||||||
) {
|
) {
|
||||||
super(leaf);
|
super(leaf);
|
||||||
this.icon = LogsView.ICON;
|
this.icon = LogsView.ICON;
|
||||||
this.client.logger.addOnMessageListener(() => {
|
this.client.logger.onLogEmitted.add(() => {
|
||||||
this.updateView();
|
this.updateView();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static createLogLineElement(
|
private static createLogLineElement(
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
logLine: LogLine
|
logLine: LogLine
|
||||||
): HTMLElement {
|
): HTMLElement {
|
||||||
return container.createDiv(
|
return container.createDiv(
|
||||||
{
|
{
|
||||||
cls: ["log-message", logLine.level]
|
cls: ["log-message", logLine.level]
|
||||||
},
|
},
|
||||||
(messageContainer) => {
|
(messageContainer) => {
|
||||||
messageContainer.createEl("span", {
|
messageContainer.createEl("span", {
|
||||||
text: LogsView.formatTimestamp(logLine.timestamp),
|
text: LogsView.formatTimestamp(logLine.timestamp),
|
||||||
cls: "timestamp"
|
cls: "timestamp"
|
||||||
});
|
});
|
||||||
messageContainer.createEl("span", {
|
messageContainer.createEl("span", {
|
||||||
text: logLine.message
|
text: logLine.message
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static formatTimestamp(timestamp: Date): string {
|
private static formatTimestamp(timestamp: Date): string {
|
||||||
return timestamp.toTimeString().split(" ")[0];
|
return timestamp.toTimeString().split(" ")[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
public getViewType(): string {
|
public getViewType(): string {
|
||||||
return LogsView.TYPE;
|
return LogsView.TYPE;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getDisplayText(): string {
|
public getDisplayText(): string {
|
||||||
return "VaultLink logs";
|
return "VaultLink logs";
|
||||||
}
|
}
|
||||||
|
|
||||||
public async onOpen(): Promise<void> {
|
public async onOpen(): Promise<void> {
|
||||||
const container = this.containerEl.children[1];
|
const container = this.containerEl.children[1];
|
||||||
container.addClass("logs-view");
|
container.addClass("logs-view");
|
||||||
|
|
||||||
const logLevels = [
|
const logLevels = [
|
||||||
{ label: "Debug", value: LogLevel.DEBUG },
|
{ label: "Debug", value: LogLevel.DEBUG },
|
||||||
{ label: "Info", value: LogLevel.INFO },
|
{ label: "Info", value: LogLevel.INFO },
|
||||||
{ label: "Warn", value: LogLevel.WARNING },
|
{ label: "Warn", value: LogLevel.WARNING },
|
||||||
{ label: "Error", value: LogLevel.ERROR }
|
{ label: "Error", value: LogLevel.ERROR }
|
||||||
];
|
];
|
||||||
|
|
||||||
container.createDiv(
|
container.createDiv(
|
||||||
{
|
{
|
||||||
cls: "verbosity-selector"
|
cls: "verbosity-selector"
|
||||||
},
|
},
|
||||||
(verbositySection) => {
|
(verbositySection) => {
|
||||||
verbositySection.createEl("h4", {
|
verbositySection.createEl("h4", {
|
||||||
text: "VaultLink logs"
|
text: "VaultLink logs"
|
||||||
});
|
});
|
||||||
|
|
||||||
const controls = verbositySection.createDiv({
|
const controls = verbositySection.createDiv({
|
||||||
cls: "logs-controls"
|
cls: "logs-controls"
|
||||||
});
|
});
|
||||||
|
|
||||||
const copyButton = controls.createEl("button", {
|
const copyButton = controls.createEl("button", {
|
||||||
text: "Copy logs",
|
text: "Copy logs",
|
||||||
cls: "clickable-icon"
|
cls: "clickable-icon"
|
||||||
});
|
});
|
||||||
setIcon(copyButton, "clipboard-copy");
|
setIcon(copyButton, "clipboard-copy");
|
||||||
copyButton.addEventListener("click", () => {
|
copyButton.addEventListener("click", () => {
|
||||||
this.copyLogsToClipboard();
|
this.copyLogsToClipboard();
|
||||||
});
|
});
|
||||||
|
|
||||||
controls.createEl("select", {}, (dropdown) => {
|
controls.createEl("select", {}, (dropdown) => {
|
||||||
logLevels.forEach(({ label, value }) =>
|
logLevels.forEach(({ label, value }) =>
|
||||||
dropdown.createEl("option", { text: label, value })
|
dropdown.createEl("option", { text: label, value })
|
||||||
);
|
);
|
||||||
|
|
||||||
dropdown.value = this.minLogLevel;
|
dropdown.value = this.minLogLevel;
|
||||||
|
|
||||||
dropdown.addEventListener("change", () => {
|
dropdown.addEventListener("change", () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
this.minLogLevel = dropdown.value as LogLevel;
|
this.minLogLevel = dropdown.value as LogLevel;
|
||||||
|
|
||||||
this.logsContainer?.empty();
|
this.logsContainer?.empty();
|
||||||
this.logLineToElement.clear();
|
this.logLineToElement.clear();
|
||||||
this.updateView();
|
this.updateView();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logsContainer = container.createDiv({ cls: "logs-container" });
|
this.logsContainer = container.createDiv({ cls: "logs-container" });
|
||||||
|
|
||||||
this.updateView();
|
this.updateView();
|
||||||
}
|
}
|
||||||
|
|
||||||
private copyLogsToClipboard(): void {
|
private copyLogsToClipboard(): void {
|
||||||
const logs = this.client.logger.getMessages(this.minLogLevel);
|
const logs = this.client.logger.getMessages(this.minLogLevel);
|
||||||
|
|
||||||
if (logs.length === 0) {
|
if (logs.length === 0) {
|
||||||
new Notice("No logs to copy");
|
new Notice("No logs to copy");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formattedLogs = logs
|
const formattedLogs = logs
|
||||||
.map((logLine) => {
|
.map((logLine) => {
|
||||||
const timestamp = logLine.timestamp.toLocaleString();
|
const timestamp = logLine.timestamp.toLocaleString();
|
||||||
const level = logLine.level.toUpperCase();
|
const level = logLine.level.toUpperCase();
|
||||||
return `[${timestamp}] ${level}: ${logLine.message}`;
|
return `[${timestamp}] ${level}: ${logLine.message}`;
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
navigator.clipboard
|
navigator.clipboard
|
||||||
.writeText(formattedLogs)
|
.writeText(formattedLogs)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
new Notice(`Copied ${logs.length} log entries to clipboard`);
|
new Notice(`Copied ${logs.length} log entries to clipboard`);
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
this.client.logger.error(
|
this.client.logger.error(
|
||||||
`Failed to copy logs to clipboard: ${error}`
|
`Failed to copy logs to clipboard: ${error}`
|
||||||
);
|
);
|
||||||
new Notice("Failed to copy logs to clipboard");
|
new Notice("Failed to copy logs to clipboard");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateView(): void {
|
private updateView(): void {
|
||||||
const container = this.logsContainer;
|
const container = this.logsContainer;
|
||||||
if (container === undefined) {
|
if (container === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const logs = this.client.logger.getMessages(this.minLogLevel);
|
const logs = this.client.logger.getMessages(this.minLogLevel);
|
||||||
|
|
||||||
if (this.logLineToElement.size === 0 && logs.length > 0) {
|
if (this.logLineToElement.size === 0 && logs.length > 0) {
|
||||||
// Clear the "No logs available yet" message
|
// Clear the "No logs available yet" message
|
||||||
container.empty();
|
container.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldScroll =
|
const shouldScroll =
|
||||||
container.scrollTop == 0 ||
|
container.scrollTop == 0 ||
|
||||||
container.scrollHeight -
|
container.scrollHeight -
|
||||||
container.clientHeight -
|
container.clientHeight -
|
||||||
container.scrollTop <
|
container.scrollTop <
|
||||||
LogsView.MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX;
|
LogsView.MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX;
|
||||||
|
|
||||||
logs.forEach((message) => {
|
logs.forEach((message) => {
|
||||||
if (this.logLineToElement.has(message)) {
|
if (this.logLineToElement.has(message)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const element = LogsView.createLogLineElement(container, message);
|
const element = LogsView.createLogLineElement(container, message);
|
||||||
|
|
||||||
this.logLineToElement.set(message, element);
|
this.logLineToElement.set(message, element);
|
||||||
});
|
});
|
||||||
|
|
||||||
const newLines = new Set(logs);
|
const newLines = new Set(logs);
|
||||||
for (const [logLine, element] of this.logLineToElement) {
|
for (const [logLine, element] of this.logLineToElement) {
|
||||||
if (!newLines.has(logLine)) {
|
if (!newLines.has(logLine)) {
|
||||||
element.remove();
|
element.remove();
|
||||||
this.logLineToElement.delete(logLine);
|
this.logLineToElement.delete(logLine);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (logs.length === 0) {
|
if (logs.length === 0) {
|
||||||
container.empty();
|
container.empty();
|
||||||
container.createEl("p", {
|
container.createEl("p", {
|
||||||
text: "No logs available yet."
|
text: "No logs available yet."
|
||||||
});
|
});
|
||||||
} else if (shouldScroll) {
|
} else if (shouldScroll) {
|
||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,134 +1,168 @@
|
||||||
@mixin number-card {
|
@mixin number-card {
|
||||||
padding: var(--size-2-1) var(--size-4-1);
|
padding: var(--size-2-1) var(--size-4-1);
|
||||||
border-radius: var(--radius-s);
|
border-radius: var(--radius-s);
|
||||||
background-color: var(--color-base-30);
|
background-color: var(--color-base-30);
|
||||||
font-size: var(--font-ui-small);
|
font-size: var(--font-ui-small);
|
||||||
|
|
||||||
&.good {
|
&.good {
|
||||||
background-color: rgba(var(--color-green-rgb), 0.35);
|
background-color: rgba(var(--color-green-rgb), 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.bad {
|
&.bad {
|
||||||
background-color: rgba(var(--color-red-rgb), 0.35);
|
background-color: rgba(var(--color-red-rgb), 0.35);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.vault-link-settings-container {
|
.vault-link-settings-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
.vault-link-settings {
|
.vault-link-settings {
|
||||||
h2 {
|
h2 {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: var(--h2-size);
|
font-size: var(--h2-size);
|
||||||
|
|
||||||
.version {
|
.version {
|
||||||
@include number-card;
|
@include number-card;
|
||||||
margin: var(--size-2-2) 0 0 var(--size-4-2);
|
margin: var(--size-2-2) 0 0 var(--size-4-2);
|
||||||
background-color: var(--color-base-30);
|
background-color: var(--color-base-30);
|
||||||
color: var(--color-base-70);
|
color: var(--color-base-70);
|
||||||
font-size: var(--font-ui-smaller);
|
font-size: var(--font-ui-smaller);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-container {
|
.button-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--size-4-2);
|
gap: var(--size-4-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
font-size: var(--font-ui-large);
|
font-size: var(--font-ui-large);
|
||||||
margin-top: var(--heading-spacing);
|
margin-top: var(--heading-spacing);
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
input[type="range"],
|
input[type="range"],
|
||||||
.checkbox-container,
|
.checkbox-container,
|
||||||
.slider::-webkit-slider-thumb {
|
.slider::-webkit-slider-thumb {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
textarea {
|
textarea {
|
||||||
width: 250px;
|
width: 250px;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
resize: none;
|
resize: none;
|
||||||
height: 75px;
|
height: 75px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.applying-changes-overlay {
|
.ignored-files-container {
|
||||||
position: absolute;
|
margin-top: var(--size-4-3);
|
||||||
top: 50%;
|
padding: var(--size-4-3);
|
||||||
left: 50%;
|
border: 1px solid var(--background-modifier-border);
|
||||||
transform: translateY(-50%) translateX(-50%);
|
border-radius: var(--radius-s);
|
||||||
z-index: 10;
|
background-color: var(--background-secondary);
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
|
|
||||||
.spinner-container {
|
h4 {
|
||||||
background-color: rgba(var(--background-primary), 0.5);
|
margin-top: 0;
|
||||||
border: 1px solid var(--background-modifier-border);
|
margin-bottom: var(--size-4-2);
|
||||||
border-radius: var(--radius-m);
|
color: var(--text-normal);
|
||||||
padding: var(--size-4-8);
|
}
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--size-4-3);
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
.ignored-files-list {
|
||||||
width: 48px;
|
max-height: 300px;
|
||||||
height: 48px;
|
overflow-y: auto;
|
||||||
border: 4px solid var(--background-modifier-border);
|
margin: 0;
|
||||||
border-top-color: var(--interactive-accent);
|
padding-left: var(--size-4-4);
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner-text {
|
li {
|
||||||
color: var(--text-normal);
|
font-family: var(--font-monospace);
|
||||||
font-size: var(--font-ui-medium);
|
font-size: var(--font-ui-small);
|
||||||
font-weight: 500;
|
color: var(--text-muted);
|
||||||
}
|
padding: var(--size-2-1) 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.spinner-warning {
|
p {
|
||||||
color: var(--text-muted);
|
margin: 0;
|
||||||
font-size: var(--font-ui-small);
|
color: var(--text-muted);
|
||||||
text-align: center;
|
font-style: italic;
|
||||||
margin-top: var(--size-2-2);
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
.applying-changes-overlay {
|
||||||
from {
|
position: absolute;
|
||||||
transform: rotate(0deg);
|
top: 50%;
|
||||||
}
|
left: 50%;
|
||||||
|
transform: translateY(-50%) translateX(-50%);
|
||||||
|
z-index: 10;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
|
||||||
to {
|
.spinner-container {
|
||||||
transform: rotate(360deg);
|
background-color: rgba(var(--background-primary), 0.5);
|
||||||
}
|
border: 1px solid var(--background-modifier-border);
|
||||||
}
|
border-radius: var(--radius-m);
|
||||||
|
padding: var(--size-4-8);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--size-4-3);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
&.applying-changes {
|
.spinner {
|
||||||
.setting-item-control {
|
width: 48px;
|
||||||
pointer-events: none;
|
height: 48px;
|
||||||
opacity: 0.5;
|
border: 4px solid var(--background-modifier-border);
|
||||||
}
|
border-top-color: var(--interactive-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
button:not(.applying-changes-overlay button) {
|
.spinner-text {
|
||||||
pointer-events: none;
|
color: var(--text-normal);
|
||||||
opacity: 0.5;
|
font-size: var(--font-ui-medium);
|
||||||
}
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
input,
|
.spinner-warning {
|
||||||
textarea,
|
color: var(--text-muted);
|
||||||
select {
|
font-size: var(--font-ui-small);
|
||||||
pointer-events: none;
|
text-align: center;
|
||||||
opacity: 0.5;
|
margin-top: var(--size-2-2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.applying-changes {
|
||||||
|
.setting-item-control {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:not(.applying-changes-overlay button) {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,14 +1,14 @@
|
||||||
.sync-status {
|
.sync-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--size-4-2);
|
gap: var(--size-4-2);
|
||||||
|
|
||||||
* {
|
* {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.initialize-button {
|
.initialize-button {
|
||||||
padding: 0 var(--size-4-2);
|
padding: 0 var(--size-4-2);
|
||||||
background: rgba(var(--color-red-rgb), 0.4);
|
background: rgba(var(--color-red-rgb), 0.4);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,72 +4,72 @@ import type { HistoryStats, SyncClient } from "sync-client";
|
||||||
import type VaultLinkPlugin from "../../vault-link-plugin";
|
import type VaultLinkPlugin from "../../vault-link-plugin";
|
||||||
|
|
||||||
export class StatusBar {
|
export class StatusBar {
|
||||||
private readonly statusBarItem: HTMLElement;
|
private readonly statusBarItem: HTMLElement;
|
||||||
|
|
||||||
private lastHistoryStats: HistoryStats | undefined;
|
private lastHistoryStats: HistoryStats | undefined;
|
||||||
private lastRemaining: number | undefined;
|
private lastRemaining: number | undefined;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly plugin: VaultLinkPlugin,
|
private readonly plugin: VaultLinkPlugin,
|
||||||
private readonly syncClient: SyncClient
|
private readonly syncClient: SyncClient
|
||||||
) {
|
) {
|
||||||
this.statusBarItem = plugin.addStatusBarItem();
|
this.statusBarItem = plugin.addStatusBarItem();
|
||||||
this.syncClient.addSyncHistoryUpdateListener((status) => {
|
this.syncClient.onSyncHistoryUpdated.add((status) => {
|
||||||
this.lastHistoryStats = status;
|
this.lastHistoryStats = status;
|
||||||
this.updateStatus();
|
this.updateStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.syncClient.addRemainingSyncOperationsListener(
|
this.syncClient.onRemainingOperationsCountChanged.add(
|
||||||
(remainingOperations) => {
|
(remainingOperations) => {
|
||||||
this.lastRemaining = remainingOperations;
|
this.lastRemaining = remainingOperations;
|
||||||
this.updateStatus();
|
this.updateStatus();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
this.syncClient.addOnSettingsChangeListener(() => {
|
this.syncClient.onSettingsChanged.add(() => {
|
||||||
this.updateStatus();
|
this.updateStatus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateStatus(): void {
|
private updateStatus(): void {
|
||||||
this.statusBarItem.empty();
|
this.statusBarItem.empty();
|
||||||
const container = this.statusBarItem.createDiv({
|
const container = this.statusBarItem.createDiv({
|
||||||
cls: ["sync-status"]
|
cls: ["sync-status"]
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!this.syncClient.getSettings().isSyncEnabled) {
|
if (!this.syncClient.getSettings().isSyncEnabled) {
|
||||||
const button = container.createEl("button", {
|
const button = container.createEl("button", {
|
||||||
text: "VaultLink is disabled, click to configure",
|
text: "VaultLink is disabled, click to configure",
|
||||||
cls: "initialize-button"
|
cls: "initialize-button"
|
||||||
});
|
});
|
||||||
button.onclick = this.plugin.openSettings.bind(this.plugin);
|
button.onclick = this.plugin.openSettings.bind(this.plugin);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasShownMessage = false;
|
let hasShownMessage = false;
|
||||||
|
|
||||||
if ((this.lastRemaining ?? 0) > 0) {
|
if ((this.lastRemaining ?? 0) > 0) {
|
||||||
hasShownMessage = true;
|
hasShownMessage = true;
|
||||||
container.createSpan({ text: `${this.lastRemaining} ⏳` });
|
container.createSpan({ text: `${this.lastRemaining} ⏳` });
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((this.lastHistoryStats?.success ?? 0) > 0) {
|
if ((this.lastHistoryStats?.success ?? 0) > 0) {
|
||||||
hasShownMessage = true;
|
hasShownMessage = true;
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: `${this.lastHistoryStats?.success ?? 0} ✅`
|
text: `${this.lastHistoryStats?.success ?? 0} ✅`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((this.lastHistoryStats?.error ?? 0) > 0) {
|
if ((this.lastHistoryStats?.error ?? 0) > 0) {
|
||||||
hasShownMessage = true;
|
hasShownMessage = true;
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: `${this.lastHistoryStats?.error ?? 0} ❌`
|
text: `${this.lastHistoryStats?.error ?? 0} ❌`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasShownMessage) {
|
if (!hasShownMessage) {
|
||||||
container.createSpan({ text: "VaultLink is idle" });
|
container.createSpan({ text: "VaultLink is idle" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,32 @@
|
||||||
@mixin number-card {
|
@mixin number-card {
|
||||||
padding: var(--size-2-1) var(--size-4-1);
|
padding: var(--size-2-1) var(--size-4-1);
|
||||||
border-radius: var(--radius-s);
|
border-radius: var(--radius-s);
|
||||||
background-color: var(--color-base-30);
|
background-color: var(--color-base-30);
|
||||||
font-size: var(--font-ui-small);
|
font-size: var(--font-ui-small);
|
||||||
|
|
||||||
&.good {
|
&.good {
|
||||||
background-color: rgba(var(--color-green-rgb), 0.35);
|
background-color: rgba(var(--color-green-rgb), 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.bad {
|
&.bad {
|
||||||
background-color: rgba(var(--color-red-rgb), 0.35);
|
background-color: rgba(var(--color-red-rgb), 0.35);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-description {
|
.status-description {
|
||||||
margin: var(--p-spacing) 0;
|
margin: var(--p-spacing) 0;
|
||||||
|
|
||||||
.number {
|
.number {
|
||||||
@include number-card;
|
@include number-card;
|
||||||
font-family: var(--font-monospace);
|
font-family: var(--font-monospace);
|
||||||
font-weight: var(--bold-weight);
|
font-weight: var(--bold-weight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: rgb(var(--color-red-rgb));
|
color: rgb(var(--color-red-rgb));
|
||||||
}
|
}
|
||||||
|
|
||||||
.warning {
|
.warning {
|
||||||
color: rgb(var(--color-yellow-rgb));
|
color: rgb(var(--color-yellow-rgb));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,148 +1,147 @@
|
||||||
import "./status-description.scss";
|
import "./status-description.scss";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
HistoryStats,
|
HistoryStats,
|
||||||
NetworkConnectionStatus,
|
NetworkConnectionStatus,
|
||||||
SyncClient
|
SyncClient
|
||||||
} from "sync-client";
|
} from "sync-client";
|
||||||
|
import { utils } from "sync-client";
|
||||||
|
|
||||||
export class StatusDescription {
|
export class StatusDescription {
|
||||||
private lastHistoryStats: HistoryStats | undefined;
|
private lastHistoryStats: HistoryStats | undefined;
|
||||||
private lastRemaining: number | undefined;
|
private lastRemaining: number | undefined;
|
||||||
private lastConnectionState: NetworkConnectionStatus | undefined;
|
private lastConnectionState: NetworkConnectionStatus | undefined;
|
||||||
|
|
||||||
private statusChangeListeners: (() => unknown)[] = [];
|
private readonly statusChangeListeners: (() => unknown)[] = [];
|
||||||
|
|
||||||
public constructor(private readonly syncClient: SyncClient) {
|
public constructor(private readonly syncClient: SyncClient) {
|
||||||
void this.updateConnectionState();
|
void this.updateConnectionState();
|
||||||
|
|
||||||
syncClient.addSyncHistoryUpdateListener((status) => {
|
syncClient.onSyncHistoryUpdated.add((status) => {
|
||||||
this.lastHistoryStats = status;
|
this.lastHistoryStats = status;
|
||||||
this.updateDescription();
|
this.updateDescription();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.syncClient.addRemainingSyncOperationsListener(
|
this.syncClient.onRemainingOperationsCountChanged.add(
|
||||||
(remainingOperations) => {
|
(remainingOperations) => {
|
||||||
this.lastRemaining = remainingOperations;
|
this.lastRemaining = remainingOperations;
|
||||||
this.updateDescription();
|
this.updateDescription();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
this.syncClient.addWebSocketStatusChangeListener(async () =>
|
this.syncClient.onWebSocketStatusChanged.add(async () =>
|
||||||
this.updateConnectionState()
|
this.updateConnectionState()
|
||||||
);
|
);
|
||||||
|
|
||||||
this.syncClient.addOnSettingsChangeListener(async () =>
|
this.syncClient.onSettingsChanged.add(async () =>
|
||||||
this.updateConnectionState()
|
this.updateConnectionState()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateConnectionState(): Promise<void> {
|
public async updateConnectionState(): Promise<void> {
|
||||||
this.lastConnectionState = await this.syncClient.checkConnection();
|
this.lastConnectionState = await this.syncClient.checkConnection();
|
||||||
this.updateDescription();
|
this.updateDescription();
|
||||||
}
|
}
|
||||||
|
|
||||||
public addStatusChangeListener(listener: () => unknown): void {
|
public addStatusChangeListener(listener: () => unknown): void {
|
||||||
this.statusChangeListeners.push(listener);
|
this.statusChangeListeners.push(listener);
|
||||||
}
|
}
|
||||||
public removeStatusChangeListener(listener: () => unknown): void {
|
public removeStatusChangeListener(listener: () => unknown): void {
|
||||||
this.statusChangeListeners = this.statusChangeListeners.filter(
|
utils.removeFromArray(this.statusChangeListeners, listener);
|
||||||
(l) => l !== listener
|
}
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public renderStatusDescription(container: HTMLElement): void {
|
public renderStatusDescription(container: HTMLElement): void {
|
||||||
container.empty();
|
container.empty();
|
||||||
container.addClass("status-description");
|
container.addClass("status-description");
|
||||||
|
|
||||||
if (this.lastConnectionState == undefined) {
|
if (this.lastConnectionState == undefined) {
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: "VaultLink is starting up…",
|
text: "VaultLink is starting up…",
|
||||||
cls: "warning"
|
cls: "warning"
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.lastConnectionState.isSuccessful) {
|
if (!this.lastConnectionState.isSuccessful) {
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: `VaultLink failed to connect to the remote server with error '${this.lastConnectionState.serverMessage}'`,
|
text: `VaultLink failed to connect to the remote server with error '${this.lastConnectionState.serverMessage}'`,
|
||||||
cls: "error"
|
cls: "error"
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.lastConnectionState.isWebSocketConnected) {
|
if (!this.lastConnectionState.isWebSocketConnected) {
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: `${this.lastConnectionState.serverMessage} but the WebSocket connection could not be established.`,
|
text: `${this.lastConnectionState.serverMessage} but the WebSocket connection could not be established.`,
|
||||||
cls: "error"
|
cls: "error"
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.createSpan({ text: "VaultLink is connected to the server " });
|
container.createSpan({ text: "VaultLink is connected to the server " });
|
||||||
container.createEl("a", {
|
container.createEl("a", {
|
||||||
text: this.syncClient.getSettings().remoteUri,
|
text: this.syncClient.getSettings().remoteUri,
|
||||||
href: this.syncClient.getSettings().remoteUri
|
href: this.syncClient.getSettings().remoteUri
|
||||||
});
|
});
|
||||||
|
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: ` and has indexed approximately `
|
text: ` and has indexed approximately `
|
||||||
});
|
});
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: `${this.syncClient.documentCount}`,
|
text: `${this.syncClient.documentCount}`,
|
||||||
cls: "number"
|
cls: "number"
|
||||||
});
|
});
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: ` documents. `
|
text: ` documents. `
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(this.lastRemaining ?? 0) === 0 &&
|
(this.lastRemaining ?? 0) === 0 &&
|
||||||
(this.lastHistoryStats?.success ?? 0) === 0 &&
|
(this.lastHistoryStats?.success ?? 0) === 0 &&
|
||||||
(this.lastHistoryStats?.error ?? 0) === 0
|
(this.lastHistoryStats?.error ?? 0) === 0
|
||||||
) {
|
) {
|
||||||
if (this.syncClient.getSettings().isSyncEnabled) {
|
if (this.syncClient.getSettings().isSyncEnabled) {
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: "Syncing is enabled but VaultLink hasn't found anything to sync yet."
|
text: "Syncing is enabled but VaultLink hasn't found anything to sync yet."
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: "However, syncing is disabled right now.",
|
text: "However, syncing is disabled right now.",
|
||||||
cls: "warning"
|
cls: "warning"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: "The plugin has "
|
text: "The plugin has "
|
||||||
});
|
});
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: `${this.lastRemaining ?? 0}`,
|
text: `${this.lastRemaining ?? 0}`,
|
||||||
cls: "number"
|
cls: "number"
|
||||||
});
|
});
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: " outstanding operations while having succeeded "
|
text: " outstanding operations while having succeeded "
|
||||||
});
|
});
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: `${this.lastHistoryStats?.success ?? 0}`,
|
text: `${this.lastHistoryStats?.success ?? 0}`,
|
||||||
cls: ["number", "good"]
|
cls: ["number", "good"]
|
||||||
});
|
});
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: " times and failed "
|
text: " times and failed "
|
||||||
});
|
});
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: `${this.lastHistoryStats?.error ?? 0}`,
|
text: `${this.lastHistoryStats?.error ?? 0}`,
|
||||||
cls: ["number", "bad"]
|
cls: ["number", "bad"]
|
||||||
});
|
});
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: " times."
|
text: " times."
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateDescription(): void {
|
private updateDescription(): void {
|
||||||
this.statusChangeListeners.forEach((listener) => {
|
this.statusChangeListeners.forEach((listener) => {
|
||||||
listener();
|
listener();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"target": "ES2023",
|
"target": "ES2023",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"lib": [
|
"lib": [
|
||||||
"DOM",
|
"DOM",
|
||||||
"ES2024"
|
"ES2024"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"./dist"
|
"./dist"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -4,114 +4,114 @@ const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||||
const fs = require("fs-extra");
|
const fs = require("fs-extra");
|
||||||
|
|
||||||
module.exports = (env, argv) => ({
|
module.exports = (env, argv) => ({
|
||||||
devtool: argv.mode === "development" ? "inline-source-map" : false,
|
devtool: argv.mode === "development" ? "inline-source-map" : false,
|
||||||
entry: {
|
entry: {
|
||||||
index: "./src/vault-link-plugin.ts"
|
index: "./src/vault-link-plugin.ts"
|
||||||
},
|
},
|
||||||
watchOptions: {
|
watchOptions: {
|
||||||
ignored: "**/node_modules"
|
ignored: "**/node_modules"
|
||||||
},
|
},
|
||||||
externals: {
|
externals: {
|
||||||
obsidian: "commonjs obsidian",
|
obsidian: "commonjs obsidian",
|
||||||
electron: "commonjs electron",
|
electron: "commonjs electron",
|
||||||
"@codemirror/autocomplete": "commonjs @codemirror/autocomplete",
|
"@codemirror/autocomplete": "commonjs @codemirror/autocomplete",
|
||||||
"@codemirror/collab": "commonjs @codemirror/collab",
|
"@codemirror/collab": "commonjs @codemirror/collab",
|
||||||
"@codemirror/commands": "commonjs @codemirror/commands",
|
"@codemirror/commands": "commonjs @codemirror/commands",
|
||||||
"@codemirror/language": "commonjs @codemirror/language",
|
"@codemirror/language": "commonjs @codemirror/language",
|
||||||
"@codemirror/lint": "commonjs @codemirror/lint",
|
"@codemirror/lint": "commonjs @codemirror/lint",
|
||||||
"@codemirror/search": "commonjs @codemirror/search",
|
"@codemirror/search": "commonjs @codemirror/search",
|
||||||
"@codemirror/state": "commonjs @codemirror/state",
|
"@codemirror/state": "commonjs @codemirror/state",
|
||||||
"@codemirror/view": "commonjs @codemirror/view"
|
"@codemirror/view": "commonjs @codemirror/view"
|
||||||
},
|
},
|
||||||
optimization: {
|
optimization: {
|
||||||
minimizer: [
|
minimizer: [
|
||||||
new TerserPlugin({
|
new TerserPlugin({
|
||||||
terserOptions: {
|
terserOptions: {
|
||||||
module: true
|
module: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
new MiniCssExtractPlugin({
|
new MiniCssExtractPlugin({
|
||||||
filename: "styles.css"
|
filename: "styles.css"
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
apply: (compiler) => {
|
apply: (compiler) => {
|
||||||
if (argv.mode !== "development") {
|
if (argv.mode !== "development") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
compiler.hooks.done.tap("Copy Files Plugin", (stats) => {
|
compiler.hooks.done.tap("Copy Files Plugin", (stats) => {
|
||||||
const source = path.resolve(__dirname, "dist");
|
const source = path.resolve(__dirname, "dist");
|
||||||
const destinations = [
|
const destinations = [
|
||||||
"/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link",
|
"/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link",
|
||||||
"/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link",
|
"/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link",
|
||||||
// "/home/andras/obsidian-test/.obsidian/plugins/vault-link"
|
// "/home/andras/obsidian-test/.obsidian/plugins/vault-link"
|
||||||
];
|
];
|
||||||
destinations.forEach((destination) => {
|
destinations.forEach((destination) => {
|
||||||
fs.copy(source, destination)
|
fs.copy(source, destination)
|
||||||
.then(() =>
|
.then(() =>
|
||||||
console.log(
|
console.log(
|
||||||
"Files copied successfully after build!"
|
"Files copied successfully after build!"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.catch((err) =>
|
.catch((err) =>
|
||||||
console.error("Error copying files:", err)
|
console.error("Error copying files:", err)
|
||||||
);
|
);
|
||||||
|
|
||||||
fs.createFile(path.join(destination, ".hotreload"));
|
fs.createFile(path.join(destination, ".hotreload"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
test: /\.json$/i,
|
test: /\.json$/i,
|
||||||
type: "asset/resource",
|
type: "asset/resource",
|
||||||
generator: {
|
generator: {
|
||||||
filename: "[name][ext]"
|
filename: "[name][ext]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
test: /\.scss$/i,
|
test: /\.scss$/i,
|
||||||
use: [
|
use: [
|
||||||
MiniCssExtractPlugin.loader,
|
MiniCssExtractPlugin.loader,
|
||||||
"css-loader",
|
"css-loader",
|
||||||
"resolve-url-loader",
|
"resolve-url-loader",
|
||||||
{
|
{
|
||||||
loader: "sass-loader",
|
loader: "sass-loader",
|
||||||
options: {
|
options: {
|
||||||
sourceMap: true // required by resolve-url-loader
|
sourceMap: true // required by resolve-url-loader
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
test: /\.ts$/,
|
test: /\.ts$/,
|
||||||
use: ["ts-loader"]
|
use: ["ts-loader"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
extensions: [
|
extensions: [
|
||||||
".ts",
|
".ts",
|
||||||
".js" // required for development
|
".js" // required for development
|
||||||
],
|
],
|
||||||
alias: {
|
alias: {
|
||||||
root: __dirname,
|
root: __dirname,
|
||||||
src: path.resolve(__dirname, "src")
|
src: path.resolve(__dirname, "src")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
clean: true,
|
clean: true,
|
||||||
filename: "main.js",
|
filename: "main.js",
|
||||||
library: {
|
library: {
|
||||||
type: "commonjs" // required for Obsidian
|
type: "commonjs" // required for Obsidian
|
||||||
},
|
},
|
||||||
path: path.resolve(__dirname, "dist"),
|
path: path.resolve(__dirname, "dist"),
|
||||||
publicPath: ""
|
publicPath: ""
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
12305
frontend/package-lock.json
generated
12305
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,31 +1,32 @@
|
||||||
{
|
{
|
||||||
"name": "my-workspace",
|
"name": "my-workspace",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"sync-client",
|
"sync-client",
|
||||||
"obsidian-plugin",
|
"obsidian-plugin",
|
||||||
"test-client",
|
"test-client",
|
||||||
"local-client-cli"
|
"local-client-cli"
|
||||||
],
|
],
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"tabWidth": 4,
|
"tabWidth": 4,
|
||||||
"useTabs": true,
|
"useTabs": false,
|
||||||
"endOfLine": "lf"
|
"endOfLine": "lf"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build --workspaces",
|
"build": "npm run build --workspaces",
|
||||||
"dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"",
|
"dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"",
|
||||||
"test": "npm run test --workspaces",
|
"test": "npm run test --workspaces",
|
||||||
"lint": "eslint --fix sync-client obsidian-plugin test-client local-client-cli && prettier --write \"**/*.ts\"",
|
"lint": "eslint --fix sync-client obsidian-plugin test-client local-client-cli && prettier --write \"**/*.ts\"",
|
||||||
"update": "ncu -u -ws"
|
"update": "ncu -u -ws"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"eslint": "9.38.0",
|
"eclint": "^2.8.1",
|
||||||
"eslint-plugin-unused-imports": "^4.1.4",
|
"eslint": "9.38.0",
|
||||||
"npm-check-updates": "^19.1.1",
|
"eslint-plugin-unused-imports": "^4.1.4",
|
||||||
"prettier": "^3.6.2",
|
"npm-check-updates": "^19.1.1",
|
||||||
"typescript-eslint": "8.41.0"
|
"prettier": "^3.6.2",
|
||||||
}
|
"typescript-eslint": "8.41.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "webpack watch --mode development",
|
"dev": "webpack watch --mode development",
|
||||||
"build": "webpack --mode production",
|
"build": "webpack --mode production",
|
||||||
"test": "tsx --test src/**/*.test.ts"
|
"test": "tsx --test 'src/**/*.test.ts'"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"byte-base64": "^1.1.0",
|
"byte-base64": "^1.1.0",
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
export class FileNotFoundError extends Error {
|
export class FileNotFoundError extends Error {
|
||||||
public constructor(
|
public constructor(
|
||||||
message: string,
|
message: string,
|
||||||
public readonly filePath: string
|
public readonly filePath: string
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "FileNotFoundError";
|
this.name = "FileNotFoundError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import type {
|
import type {
|
||||||
Database,
|
Database,
|
||||||
DocumentRecord,
|
DocumentRecord,
|
||||||
RelativePath
|
RelativePath
|
||||||
} from "../persistence/database";
|
} from "../persistence/database";
|
||||||
import { FileOperations } from "./file-operations";
|
import { FileOperations } from "./file-operations";
|
||||||
import { Logger } from "../tracing/logger";
|
import { Logger } from "../tracing/logger";
|
||||||
|
|
@ -12,224 +12,224 @@ import type { TextWithCursors } from "reconcile-text";
|
||||||
import type { ServerConfig, ServerConfigData } from "../services/server-config";
|
import type { ServerConfig, ServerConfigData } from "../services/server-config";
|
||||||
|
|
||||||
class MockServerConfig implements Pick<ServerConfig, "getConfig"> {
|
class MockServerConfig implements Pick<ServerConfig, "getConfig"> {
|
||||||
public getConfig(): ServerConfigData {
|
public getConfig(): ServerConfigData {
|
||||||
return {
|
return {
|
||||||
mergeableFileExtensions: ["md", "txt"],
|
mergeableFileExtensions: ["md", "txt"],
|
||||||
supportedApiVersion: 1,
|
supportedApiVersion: 1,
|
||||||
isAuthenticated: true
|
isAuthenticated: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MockDatabase implements Partial<Database> {
|
class MockDatabase implements Partial<Database> {
|
||||||
public getLatestDocumentByRelativePath(
|
public getLatestDocumentByRelativePath(
|
||||||
_find: RelativePath
|
_find: RelativePath
|
||||||
): DocumentRecord | undefined {
|
): DocumentRecord | undefined {
|
||||||
// no-op
|
// no-op
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
public move(
|
public move(
|
||||||
_oldRelativePath: RelativePath,
|
_oldRelativePath: RelativePath,
|
||||||
_newRelativePath: RelativePath
|
_newRelativePath: RelativePath
|
||||||
): void {
|
): void {
|
||||||
// no-op
|
// no-op
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeFileSystemOperations implements FileSystemOperations {
|
class FakeFileSystemOperations implements FileSystemOperations {
|
||||||
public readonly names = new Set<string>();
|
public readonly names = new Set<string>();
|
||||||
|
|
||||||
public async listFilesRecursively(
|
public async listFilesRecursively(
|
||||||
_root: RelativePath | undefined
|
_root: RelativePath | undefined
|
||||||
): Promise<RelativePath[]> {
|
): Promise<RelativePath[]> {
|
||||||
return ["file.md"];
|
return ["file.md"];
|
||||||
}
|
}
|
||||||
public async read(_path: RelativePath): Promise<Uint8Array> {
|
public async read(_path: RelativePath): Promise<Uint8Array> {
|
||||||
throw new Error("Method not implemented.");
|
throw new Error("Method not implemented.");
|
||||||
}
|
}
|
||||||
public async write(
|
public async write(
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
_content: Uint8Array
|
_content: Uint8Array
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.names.add(path);
|
this.names.add(path);
|
||||||
}
|
}
|
||||||
public async atomicUpdateText(
|
public async atomicUpdateText(
|
||||||
_path: RelativePath,
|
_path: RelativePath,
|
||||||
_updater: (current: TextWithCursors) => TextWithCursors
|
_updater: (current: TextWithCursors) => TextWithCursors
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
throw new Error("Method not implemented.");
|
throw new Error("Method not implemented.");
|
||||||
}
|
}
|
||||||
public async getFileSize(_path: RelativePath): Promise<number> {
|
public async getFileSize(_path: RelativePath): Promise<number> {
|
||||||
throw new Error("Method not implemented.");
|
throw new Error("Method not implemented.");
|
||||||
}
|
}
|
||||||
public async getModificationTime(_path: RelativePath): Promise<Date> {
|
public async getModificationTime(_path: RelativePath): Promise<Date> {
|
||||||
throw new Error("Method not implemented.");
|
throw new Error("Method not implemented.");
|
||||||
}
|
}
|
||||||
public async exists(path: RelativePath): Promise<boolean> {
|
public async exists(path: RelativePath): Promise<boolean> {
|
||||||
return this.names.has(path);
|
return this.names.has(path);
|
||||||
}
|
}
|
||||||
public async createDirectory(_path: RelativePath): Promise<void> {
|
public async createDirectory(_path: RelativePath): Promise<void> {
|
||||||
// this is called but irrelevant for this mock
|
// this is called but irrelevant for this mock
|
||||||
}
|
}
|
||||||
public async delete(_path: RelativePath): Promise<void> {
|
public async delete(_path: RelativePath): Promise<void> {
|
||||||
throw new Error("Method not implemented.");
|
throw new Error("Method not implemented.");
|
||||||
}
|
}
|
||||||
public async rename(
|
public async rename(
|
||||||
oldPath: RelativePath,
|
oldPath: RelativePath,
|
||||||
newPath: RelativePath
|
newPath: RelativePath
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.names.delete(oldPath);
|
this.names.delete(oldPath);
|
||||||
this.names.add(newPath);
|
this.names.add(newPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("File operations", () => {
|
describe("File operations", () => {
|
||||||
it("should deconflict renames", async () => {
|
it("should deconflict renames", async () => {
|
||||||
const fileSystemOperations = new FakeFileSystemOperations();
|
const fileSystemOperations = new FakeFileSystemOperations();
|
||||||
const fileOperations = new FileOperations(
|
const fileOperations = new FileOperations(
|
||||||
new Logger(),
|
new Logger(),
|
||||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
fileSystemOperations,
|
fileSystemOperations,
|
||||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
);
|
);
|
||||||
|
|
||||||
await fileOperations.create("a", new Uint8Array());
|
await fileOperations.create("a", new Uint8Array());
|
||||||
assertSetContainsExactly(fileSystemOperations.names, "a");
|
assertSetContainsExactly(fileSystemOperations.names, "a");
|
||||||
await fileOperations.move("a", "b");
|
await fileOperations.move("a", "b");
|
||||||
assertSetContainsExactly(fileSystemOperations.names, "b");
|
assertSetContainsExactly(fileSystemOperations.names, "b");
|
||||||
|
|
||||||
await fileOperations.create("c", new Uint8Array());
|
await fileOperations.create("c", new Uint8Array());
|
||||||
assertSetContainsExactly(fileSystemOperations.names, "b", "c");
|
assertSetContainsExactly(fileSystemOperations.names, "b", "c");
|
||||||
|
|
||||||
await fileOperations.move("c", "b");
|
await fileOperations.move("c", "b");
|
||||||
assertSetContainsExactly(fileSystemOperations.names, "b", "b (1)");
|
assertSetContainsExactly(fileSystemOperations.names, "b", "b (1)");
|
||||||
|
|
||||||
await fileOperations.create("c", new Uint8Array());
|
await fileOperations.create("c", new Uint8Array());
|
||||||
await fileOperations.move("c", "b");
|
await fileOperations.move("c", "b");
|
||||||
assertSetContainsExactly(
|
assertSetContainsExactly(
|
||||||
fileSystemOperations.names,
|
fileSystemOperations.names,
|
||||||
"b",
|
"b",
|
||||||
"b (1)",
|
"b (1)",
|
||||||
"b (2)"
|
"b (2)"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should deconflict renames with file extension", async () => {
|
it("should deconflict renames with file extension", async () => {
|
||||||
const fileSystemOperations = new FakeFileSystemOperations();
|
const fileSystemOperations = new FakeFileSystemOperations();
|
||||||
const fileOperations = new FileOperations(
|
const fileOperations = new FileOperations(
|
||||||
new Logger(),
|
new Logger(),
|
||||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
fileSystemOperations,
|
fileSystemOperations,
|
||||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
);
|
);
|
||||||
|
|
||||||
await fileOperations.create("b.md", new Uint8Array());
|
await fileOperations.create("b.md", new Uint8Array());
|
||||||
await fileOperations.create("c.md", new Uint8Array());
|
await fileOperations.create("c.md", new Uint8Array());
|
||||||
await fileOperations.move("c.md", "b.md");
|
await fileOperations.move("c.md", "b.md");
|
||||||
assertSetContainsExactly(
|
assertSetContainsExactly(
|
||||||
fileSystemOperations.names,
|
fileSystemOperations.names,
|
||||||
"b.md",
|
"b.md",
|
||||||
"b (1).md"
|
"b (1).md"
|
||||||
);
|
);
|
||||||
|
|
||||||
await fileOperations.create("d.md", new Uint8Array());
|
await fileOperations.create("d.md", new Uint8Array());
|
||||||
await fileOperations.move("d.md", "b.md");
|
await fileOperations.move("d.md", "b.md");
|
||||||
assertSetContainsExactly(
|
assertSetContainsExactly(
|
||||||
fileSystemOperations.names,
|
fileSystemOperations.names,
|
||||||
"b.md",
|
"b.md",
|
||||||
"b (1).md",
|
"b (1).md",
|
||||||
"b (2).md"
|
"b (2).md"
|
||||||
);
|
);
|
||||||
|
|
||||||
await fileOperations.create("file-23.md", new Uint8Array());
|
await fileOperations.create("file-23.md", new Uint8Array());
|
||||||
await fileOperations.create("file-23 (1).md", new Uint8Array());
|
await fileOperations.create("file-23 (1).md", new Uint8Array());
|
||||||
await fileOperations.move("file-23.md", "file-23 (1).md");
|
await fileOperations.move("file-23.md", "file-23 (1).md");
|
||||||
assertSetContainsExactly(
|
assertSetContainsExactly(
|
||||||
fileSystemOperations.names,
|
fileSystemOperations.names,
|
||||||
"b.md",
|
"b.md",
|
||||||
"b (1).md",
|
"b (1).md",
|
||||||
"b (2).md",
|
"b (2).md",
|
||||||
"file-23 (1).md",
|
"file-23 (1).md",
|
||||||
"file-23 (2).md"
|
"file-23 (2).md"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should deconflict renames with paths", async () => {
|
it("should deconflict renames with paths", async () => {
|
||||||
const fileSystemOperations = new FakeFileSystemOperations();
|
const fileSystemOperations = new FakeFileSystemOperations();
|
||||||
const fileOperations = new FileOperations(
|
const fileOperations = new FileOperations(
|
||||||
new Logger(),
|
new Logger(),
|
||||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
fileSystemOperations,
|
fileSystemOperations,
|
||||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
);
|
);
|
||||||
|
|
||||||
await fileOperations.create("a/b.c/d", new Uint8Array());
|
await fileOperations.create("a/b.c/d", new Uint8Array());
|
||||||
await fileOperations.create("a/b.c/e", new Uint8Array());
|
await fileOperations.create("a/b.c/e", new Uint8Array());
|
||||||
await fileOperations.move("a/b.c/d", "a/b.c/e");
|
await fileOperations.move("a/b.c/d", "a/b.c/e");
|
||||||
assertSetContainsExactly(
|
assertSetContainsExactly(
|
||||||
fileSystemOperations.names,
|
fileSystemOperations.names,
|
||||||
"a/b.c/e",
|
"a/b.c/e",
|
||||||
"a/b.c/e (1)"
|
"a/b.c/e (1)"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should continue deconfliction from existing number in filename", async () => {
|
it("should continue deconfliction from existing number in filename", async () => {
|
||||||
const fileSystemOperations = new FakeFileSystemOperations();
|
const fileSystemOperations = new FakeFileSystemOperations();
|
||||||
const fileOperations = new FileOperations(
|
const fileOperations = new FileOperations(
|
||||||
new Logger(),
|
new Logger(),
|
||||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
fileSystemOperations,
|
fileSystemOperations,
|
||||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
);
|
);
|
||||||
|
|
||||||
await fileOperations.create("document (5).md", new Uint8Array());
|
await fileOperations.create("document (5).md", new Uint8Array());
|
||||||
await fileOperations.create("other.md", new Uint8Array());
|
await fileOperations.create("other.md", new Uint8Array());
|
||||||
|
|
||||||
await fileOperations.move("other.md", "document (5).md");
|
await fileOperations.move("other.md", "document (5).md");
|
||||||
assertSetContainsExactly(
|
assertSetContainsExactly(
|
||||||
fileSystemOperations.names,
|
fileSystemOperations.names,
|
||||||
"document (5).md",
|
"document (5).md",
|
||||||
"document (6).md"
|
"document (6).md"
|
||||||
);
|
);
|
||||||
|
|
||||||
await fileOperations.create("another.md", new Uint8Array());
|
await fileOperations.create("another.md", new Uint8Array());
|
||||||
await fileOperations.move("another.md", "document (5).md");
|
await fileOperations.move("another.md", "document (5).md");
|
||||||
assertSetContainsExactly(
|
assertSetContainsExactly(
|
||||||
fileSystemOperations.names,
|
fileSystemOperations.names,
|
||||||
"document (5).md",
|
"document (5).md",
|
||||||
"document (6).md",
|
"document (6).md",
|
||||||
"document (7).md"
|
"document (7).md"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle dotfiles correctly", async () => {
|
it("should handle dotfiles correctly", async () => {
|
||||||
const fileSystemOperations = new FakeFileSystemOperations();
|
const fileSystemOperations = new FakeFileSystemOperations();
|
||||||
const fileOperations = new FileOperations(
|
const fileOperations = new FileOperations(
|
||||||
new Logger(),
|
new Logger(),
|
||||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
fileSystemOperations,
|
fileSystemOperations,
|
||||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
);
|
);
|
||||||
|
|
||||||
await fileOperations.create(".gitignore", new Uint8Array());
|
await fileOperations.create(".gitignore", new Uint8Array());
|
||||||
await fileOperations.create("temp", new Uint8Array());
|
await fileOperations.create("temp", new Uint8Array());
|
||||||
await fileOperations.move("temp", ".gitignore");
|
await fileOperations.move("temp", ".gitignore");
|
||||||
assertSetContainsExactly(
|
assertSetContainsExactly(
|
||||||
fileSystemOperations.names,
|
fileSystemOperations.names,
|
||||||
".gitignore",
|
".gitignore",
|
||||||
".gitignore (1)"
|
".gitignore (1)"
|
||||||
);
|
);
|
||||||
|
|
||||||
await fileOperations.create(".config.json", new Uint8Array());
|
await fileOperations.create(".config.json", new Uint8Array());
|
||||||
await fileOperations.create("temp2", new Uint8Array());
|
await fileOperations.create("temp2", new Uint8Array());
|
||||||
await fileOperations.move("temp2", ".config.json");
|
await fileOperations.move("temp2", ".config.json");
|
||||||
assertSetContainsExactly(
|
assertSetContainsExactly(
|
||||||
fileSystemOperations.names,
|
fileSystemOperations.names,
|
||||||
".gitignore",
|
".gitignore",
|
||||||
".gitignore (1)",
|
".gitignore (1)",
|
||||||
".config.json",
|
".config.json",
|
||||||
".config (1).json"
|
".config (1).json"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,283 +9,283 @@ import { isBinary } from "../utils/is-binary";
|
||||||
import type { ServerConfig } from "../services/server-config";
|
import type { ServerConfig } from "../services/server-config";
|
||||||
|
|
||||||
export class FileOperations {
|
export class FileOperations {
|
||||||
private static readonly PARENTHESES_REGEX = / \((?<count>\d+)\)$/;
|
private static readonly PARENTHESES_REGEX = / \((?<count>\d+)\)$/;
|
||||||
private readonly fs: SafeFileSystemOperations;
|
private readonly fs: SafeFileSystemOperations;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly database: Database,
|
private readonly database: Database,
|
||||||
fs: FileSystemOperations,
|
fs: FileSystemOperations,
|
||||||
private readonly serverConfig: ServerConfig,
|
private readonly serverConfig: ServerConfig,
|
||||||
private readonly nativeLineEndings = "\n"
|
private readonly nativeLineEndings = "\n"
|
||||||
) {
|
) {
|
||||||
this.fs = new SafeFileSystemOperations(fs, logger);
|
this.fs = new SafeFileSystemOperations(fs, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getParentDirAndFile(
|
private static getParentDirAndFile(
|
||||||
path: RelativePath
|
path: RelativePath
|
||||||
): [RelativePath, RelativePath] {
|
): [RelativePath, RelativePath] {
|
||||||
const pathParts = path.split("/");
|
const pathParts = path.split("/");
|
||||||
const fileName = pathParts.pop();
|
const fileName = pathParts.pop();
|
||||||
if (fileName == null || fileName === "") {
|
if (fileName == null || fileName === "") {
|
||||||
throw new Error(`Path '${path}' cannot be empty`);
|
throw new Error(`Path '${path}' cannot be empty`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [pathParts.join("/"), fileName];
|
return [pathParts.join("/"), fileName];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async listFilesRecursively(
|
public async listFilesRecursively(
|
||||||
root: RelativePath | undefined = undefined
|
root: RelativePath | undefined = undefined
|
||||||
): Promise<RelativePath[]> {
|
): Promise<RelativePath[]> {
|
||||||
return this.fs.listFilesRecursively(root);
|
return this.fs.listFilesRecursively(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async read(path: RelativePath): Promise<Uint8Array> {
|
public async read(path: RelativePath): Promise<Uint8Array> {
|
||||||
return this.fromNativeLineEndings(await this.fs.read(path));
|
return this.fromNativeLineEndings(await this.fs.read(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a file at the specified 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.
|
* If a file with the same name already exists, it is moved before creating the new one.
|
||||||
* Parent directories are created if necessary.
|
* Parent directories are created if necessary.
|
||||||
*/
|
*/
|
||||||
public async create(
|
public async create(
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
newContent: Uint8Array
|
newContent: Uint8Array
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.ensureClearPath(path);
|
await this.ensureClearPath(path);
|
||||||
return this.fs.write(path, this.toNativeLineEndings(newContent));
|
return this.fs.write(path, this.toNativeLineEndings(newContent));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ensureClearPath(path: RelativePath): Promise<void> {
|
public async ensureClearPath(path: RelativePath): Promise<void> {
|
||||||
if (await this.fs.exists(path)) {
|
if (await this.fs.exists(path)) {
|
||||||
const deconflictedPath = await this.deconflictPath(path);
|
const deconflictedPath = await this.deconflictPath(path);
|
||||||
try {
|
try {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'`
|
`Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'`
|
||||||
);
|
);
|
||||||
|
|
||||||
this.database.move(path, deconflictedPath);
|
this.database.move(path, deconflictedPath);
|
||||||
await this.fs.rename(path, deconflictedPath, true);
|
await this.fs.rename(path, deconflictedPath, true);
|
||||||
} finally {
|
} finally {
|
||||||
this.fs.unlock(deconflictedPath);
|
this.fs.unlock(deconflictedPath);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await this.createParentDirectories(path);
|
await this.createParentDirectories(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the file at the given path.
|
* Update the file at the given path.
|
||||||
*
|
*
|
||||||
* Performs a 3-way merge before writing if the file's content differs from `expectedContent`.
|
* 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.
|
* Does not recreate the file if it no longer exists, returning an empty array instead.
|
||||||
*/
|
*/
|
||||||
public async write(
|
public async write(
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
expectedContent: Uint8Array,
|
expectedContent: Uint8Array,
|
||||||
newContent: Uint8Array
|
newContent: Uint8Array
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!(await this.fs.exists(path))) {
|
if (!(await this.fs.exists(path))) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`The caller assumed ${path} exists, but it no longer, so we wont recreate it`
|
`The caller assumed ${path} exists, but it no longer, so we wont recreate it`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!isFileTypeMergable(
|
!isFileTypeMergable(
|
||||||
path,
|
path,
|
||||||
this.serverConfig.getConfig().mergeableFileExtensions
|
this.serverConfig.getConfig().mergeableFileExtensions
|
||||||
) ||
|
) ||
|
||||||
isBinary(expectedContent) ||
|
isBinary(expectedContent) ||
|
||||||
isBinary(newContent)
|
isBinary(newContent)
|
||||||
) {
|
) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it`
|
`The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it`
|
||||||
);
|
);
|
||||||
await this.fs.write(
|
await this.fs.write(
|
||||||
path,
|
path,
|
||||||
// `newContent` might not be binary so we still have to ensure the line endings are correct
|
// `newContent` might not be binary so we still have to ensure the line endings are correct
|
||||||
this.toNativeLineEndings(newContent)
|
this.toNativeLineEndings(newContent)
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectedText = new TextDecoder().decode(expectedContent); // this comes from a previous read which must only have \n line endings
|
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
|
const newText = new TextDecoder().decode(newContent); // this comes from the server which stores text with \n line endings
|
||||||
|
|
||||||
await this.fs.atomicUpdateText(
|
await this.fs.atomicUpdateText(
|
||||||
path,
|
path,
|
||||||
({ text, cursors }: TextWithCursors): TextWithCursors => {
|
({ text, cursors }: TextWithCursors): TextWithCursors => {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Performing a 3-way merge for ${path} with the expected content`
|
`Performing a 3-way merge for ${path} with the expected content`
|
||||||
);
|
);
|
||||||
|
|
||||||
text = text.replaceAll(this.nativeLineEndings, "\n");
|
text = text.replaceAll(this.nativeLineEndings, "\n");
|
||||||
const merged = reconcile(
|
const merged = reconcile(
|
||||||
expectedText,
|
expectedText,
|
||||||
{ text, cursors },
|
{ text, cursors },
|
||||||
newText
|
newText
|
||||||
);
|
);
|
||||||
|
|
||||||
const resultText = merged.text.replaceAll(
|
const resultText = merged.text.replaceAll(
|
||||||
"\n",
|
"\n",
|
||||||
this.nativeLineEndings
|
this.nativeLineEndings
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: resultText,
|
text: resultText,
|
||||||
cursors: merged.cursors
|
cursors: merged.cursors
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(path: RelativePath): Promise<void> {
|
public async delete(path: RelativePath): Promise<void> {
|
||||||
if (await this.exists(path)) {
|
if (await this.exists(path)) {
|
||||||
await this.fs.delete(path);
|
await this.fs.delete(path);
|
||||||
await this.deletingEmptyParentDirectoriesOfDeletedFile(path);
|
await this.deletingEmptyParentDirectoriesOfDeletedFile(path);
|
||||||
} else {
|
} else {
|
||||||
this.logger.debug(`No need to delete '${path}', it doesn't exist`);
|
this.logger.debug(`No need to delete '${path}', it doesn't exist`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getFileSize(path: RelativePath): Promise<number> {
|
public async getFileSize(path: RelativePath): Promise<number> {
|
||||||
return this.fs.getFileSize(path);
|
return this.fs.getFileSize(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async exists(path: RelativePath): Promise<boolean> {
|
public async exists(path: RelativePath): Promise<boolean> {
|
||||||
return this.fs.exists(path);
|
return this.fs.exists(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async move(
|
public async move(
|
||||||
oldPath: RelativePath,
|
oldPath: RelativePath,
|
||||||
newPath: RelativePath
|
newPath: RelativePath
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (oldPath === newPath) {
|
if (oldPath === newPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.ensureClearPath(newPath);
|
await this.ensureClearPath(newPath);
|
||||||
|
|
||||||
this.database.move(oldPath, newPath);
|
this.database.move(oldPath, newPath);
|
||||||
await this.fs.rename(oldPath, newPath);
|
await this.fs.rename(oldPath, newPath);
|
||||||
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
|
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this.fs.reset();
|
this.fs.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deletingEmptyParentDirectoriesOfDeletedFile(
|
private async deletingEmptyParentDirectoriesOfDeletedFile(
|
||||||
path: RelativePath
|
path: RelativePath
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let directory = path;
|
let directory = path;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
while (true) {
|
while (true) {
|
||||||
[directory] = FileOperations.getParentDirAndFile(directory);
|
[directory] = FileOperations.getParentDirAndFile(directory);
|
||||||
if (directory.length === 0) {
|
if (directory.length === 0) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const remainingContent =
|
const remainingContent =
|
||||||
await this.fs.listFilesRecursively(directory);
|
await this.fs.listFilesRecursively(directory);
|
||||||
if (remainingContent.length === 0) {
|
if (remainingContent.length === 0) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Folder (${directory}) is now empty, deleting`
|
`Folder (${directory}) is now empty, deleting`
|
||||||
);
|
);
|
||||||
await this.fs.delete(directory);
|
await this.fs.delete(directory);
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fromNativeLineEndings(content: Uint8Array): Uint8Array {
|
private fromNativeLineEndings(content: Uint8Array): Uint8Array {
|
||||||
if (isBinary(content)) {
|
if (isBinary(content)) {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoder = new TextDecoder("utf-8");
|
const decoder = new TextDecoder("utf-8");
|
||||||
let text = decoder.decode(content);
|
let text = decoder.decode(content);
|
||||||
text = text.replaceAll(this.nativeLineEndings, "\n");
|
text = text.replaceAll(this.nativeLineEndings, "\n");
|
||||||
return new TextEncoder().encode(text);
|
return new TextEncoder().encode(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
private toNativeLineEndings(content: Uint8Array): Uint8Array {
|
private toNativeLineEndings(content: Uint8Array): Uint8Array {
|
||||||
if (isBinary(content)) {
|
if (isBinary(content)) {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoder = new TextDecoder("utf-8");
|
const decoder = new TextDecoder("utf-8");
|
||||||
let text = decoder.decode(content);
|
let text = decoder.decode(content);
|
||||||
text = text.replaceAll("\n", this.nativeLineEndings);
|
text = text.replaceAll("\n", this.nativeLineEndings);
|
||||||
return new TextEncoder().encode(text);
|
return new TextEncoder().encode(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createParentDirectories(path: string): Promise<void> {
|
private async createParentDirectories(path: string): Promise<void> {
|
||||||
const components = path.split("/");
|
const components = path.split("/");
|
||||||
if (components.length === 1) {
|
if (components.length === 1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (let i = 1; i < components.length; i++) {
|
for (let i = 1; i < components.length; i++) {
|
||||||
const parentDir = components.slice(0, i).join("/");
|
const parentDir = components.slice(0, i).join("/");
|
||||||
if (!(await this.fs.exists(parentDir))) {
|
if (!(await this.fs.exists(parentDir))) {
|
||||||
await this.fs.createDirectory(parentDir);
|
await this.fs.createDirectory(parentDir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deconflicts the given path by appending (1), (2), etc. before the file extension until a non-existent path is found.
|
* Deconflicts the given path by appending (1), (2), etc. before the file extension until a non-existent path is found.
|
||||||
* The returned path has a lock acquired on it; it must be released by the caller when no longer needed.
|
* The returned path has a lock acquired on it; it must be released by the caller when no longer needed.
|
||||||
*
|
*
|
||||||
* @param path The starting path to deconflict
|
* @param path The starting path to deconflict
|
||||||
* @returns a non-existent path with a lock acquired on it
|
* @returns a non-existent path with a lock acquired on it
|
||||||
*/
|
*/
|
||||||
private async deconflictPath(path: RelativePath): Promise<RelativePath> {
|
private async deconflictPath(path: RelativePath): Promise<RelativePath> {
|
||||||
// eslint-disable-next-line prefer-const
|
// eslint-disable-next-line prefer-const
|
||||||
let [directory, fileName] = FileOperations.getParentDirAndFile(path);
|
let [directory, fileName] = FileOperations.getParentDirAndFile(path);
|
||||||
|
|
||||||
if (directory) {
|
if (directory) {
|
||||||
directory += "/";
|
directory += "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
const nameParts = fileName.split(".");
|
const nameParts = fileName.split(".");
|
||||||
// Handle dotfiles: ".gitignore" should have no extension, ".config.json" should have ".json"
|
// Handle dotfiles: ".gitignore" should have no extension, ".config.json" should have ".json"
|
||||||
const isDotfile = fileName.startsWith(".") && nameParts[0] === "";
|
const isDotfile = fileName.startsWith(".") && nameParts[0] === "";
|
||||||
const extension =
|
const extension =
|
||||||
nameParts.length > 1 && !(isDotfile && nameParts.length === 2)
|
nameParts.length > 1 && !(isDotfile && nameParts.length === 2)
|
||||||
? "." + nameParts[nameParts.length - 1]
|
? "." + nameParts[nameParts.length - 1]
|
||||||
: "";
|
: "";
|
||||||
let stem = extension ? nameParts.slice(0, -1).join(".") : fileName;
|
let stem = extension ? nameParts.slice(0, -1).join(".") : fileName;
|
||||||
let currentCount = Number.parseInt(
|
let currentCount = Number.parseInt(
|
||||||
FileOperations.PARENTHESES_REGEX.exec(stem)?.groups?.count ?? "0"
|
FileOperations.PARENTHESES_REGEX.exec(stem)?.groups?.count ?? "0"
|
||||||
);
|
);
|
||||||
stem = stem.replace(FileOperations.PARENTHESES_REGEX, "");
|
stem = stem.replace(FileOperations.PARENTHESES_REGEX, "");
|
||||||
|
|
||||||
let newName = path;
|
let newName = path;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
while (true) {
|
while (true) {
|
||||||
currentCount++;
|
currentCount++;
|
||||||
newName = `${directory}${stem} (${currentCount})${extension}`;
|
newName = `${directory}${stem} (${currentCount})${extension}`;
|
||||||
|
|
||||||
// Avoid multiple deconflictPath calls returning the same path
|
// Avoid multiple deconflictPath calls returning the same path
|
||||||
if (this.fs.tryLock(newName)) {
|
if (this.fs.tryLock(newName)) {
|
||||||
const newDocument =
|
const newDocument =
|
||||||
this.database.getLatestDocumentByRelativePath(newName);
|
this.database.getLatestDocumentByRelativePath(newName);
|
||||||
if (
|
if (
|
||||||
newDocument?.isDeleted === false || // the document might have been confirmed by the server at a new path but haven't yet moved there locally
|
newDocument?.isDeleted === false || // the document might have been confirmed by the server at a new path but haven't yet moved there locally
|
||||||
(await this.fs.exists(newName, true))
|
(await this.fs.exists(newName, true))
|
||||||
) {
|
) {
|
||||||
this.fs.unlock(newName);
|
this.fs.unlock(newName);
|
||||||
} else {
|
} else {
|
||||||
return newName;
|
return newName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,35 +3,35 @@ import type { RelativePath } from "../persistence/database";
|
||||||
import type { TextWithCursors } from "reconcile-text";
|
import type { TextWithCursors } from "reconcile-text";
|
||||||
|
|
||||||
export interface FileSystemOperations {
|
export interface FileSystemOperations {
|
||||||
// List all files under root that should be synced. If root is undefined, return every file.
|
// List all files under root that should be synced. If root is undefined, return every file.
|
||||||
listFilesRecursively: (
|
listFilesRecursively: (
|
||||||
root: RelativePath | undefined
|
root: RelativePath | undefined
|
||||||
) => Promise<RelativePath[]>;
|
) => Promise<RelativePath[]>;
|
||||||
|
|
||||||
// Read the content of a file.
|
// Read the content of a file.
|
||||||
read: (path: RelativePath) => Promise<Uint8Array>;
|
read: (path: RelativePath) => Promise<Uint8Array>;
|
||||||
|
|
||||||
// Create or overwrite a file with the given content.
|
// Create or overwrite a file with the given content.
|
||||||
write: (path: RelativePath, content: Uint8Array) => Promise<void>;
|
write: (path: RelativePath, content: Uint8Array) => Promise<void>;
|
||||||
|
|
||||||
// Atomically update the content of a text file.
|
// Atomically update the content of a text file.
|
||||||
atomicUpdateText: (
|
atomicUpdateText: (
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
updater: (current: TextWithCursors) => TextWithCursors
|
updater: (current: TextWithCursors) => TextWithCursors
|
||||||
) => Promise<string>;
|
) => Promise<string>;
|
||||||
|
|
||||||
// Get the size of a file in bytes.
|
// Get the size of a file in bytes.
|
||||||
getFileSize: (path: RelativePath) => Promise<number>;
|
getFileSize: (path: RelativePath) => Promise<number>;
|
||||||
|
|
||||||
// Check if a file exists.
|
// Check if a file exists.
|
||||||
exists: (path: RelativePath) => Promise<boolean>;
|
exists: (path: RelativePath) => Promise<boolean>;
|
||||||
|
|
||||||
// Create a directory at the specified path. All parent directories must already exist.
|
// Create a directory at the specified path. All parent directories must already exist.
|
||||||
createDirectory: (path: RelativePath) => Promise<void>;
|
createDirectory: (path: RelativePath) => Promise<void>;
|
||||||
|
|
||||||
// Delete a file. It is expected that the path points to an existing file.
|
// Delete a file. It is expected that the path points to an existing file.
|
||||||
delete: (path: RelativePath) => Promise<void>;
|
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 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>;
|
rename: (oldPath: RelativePath, newPath: RelativePath) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,160 +11,160 @@ import type { TextWithCursors } from "reconcile-text";
|
||||||
* single request in-flight for any one file through the use of locks.
|
* single request in-flight for any one file through the use of locks.
|
||||||
*/
|
*/
|
||||||
export class SafeFileSystemOperations implements FileSystemOperations {
|
export class SafeFileSystemOperations implements FileSystemOperations {
|
||||||
private readonly locks: Locks<RelativePath>;
|
private readonly locks: Locks<RelativePath>;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly fs: FileSystemOperations,
|
private readonly fs: FileSystemOperations,
|
||||||
private readonly logger: Logger
|
private readonly logger: Logger
|
||||||
) {
|
) {
|
||||||
this.locks = new Locks(logger);
|
this.locks = new Locks(logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async listFilesRecursively(
|
public async listFilesRecursively(
|
||||||
root: RelativePath | undefined
|
root: RelativePath | undefined
|
||||||
): Promise<RelativePath[]> {
|
): Promise<RelativePath[]> {
|
||||||
this.logger.debug("Listing all files");
|
this.logger.debug("Listing all files");
|
||||||
const result = await this.fs.listFilesRecursively(root);
|
const result = await this.fs.listFilesRecursively(root);
|
||||||
this.logger.debug(`Listed ${result.length} files`);
|
this.logger.debug(`Listed ${result.length} files`);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async read(path: RelativePath): Promise<Uint8Array> {
|
public async read(path: RelativePath): Promise<Uint8Array> {
|
||||||
this.logger.debug(`Reading file '${path}'`);
|
this.logger.debug(`Reading file '${path}'`);
|
||||||
return this.safeOperation(
|
return this.safeOperation(
|
||||||
path,
|
path,
|
||||||
async () =>
|
async () =>
|
||||||
this.locks.withLock(path, async () => this.fs.read(path)),
|
this.locks.withLock(path, async () => this.fs.read(path)),
|
||||||
"read"
|
"read"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
|
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
|
||||||
this.logger.debug(`Writing to file '${path}'`);
|
this.logger.debug(`Writing to file '${path}'`);
|
||||||
return this.locks.withLock(path, async () =>
|
return this.locks.withLock(path, async () =>
|
||||||
this.fs.write(path, content)
|
this.fs.write(path, content)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async atomicUpdateText(
|
public async atomicUpdateText(
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
updater: (current: TextWithCursors) => TextWithCursors
|
updater: (current: TextWithCursors) => TextWithCursors
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
this.logger.debug(`Atomically updating file '${path}'`);
|
this.logger.debug(`Atomically updating file '${path}'`);
|
||||||
return this.safeOperation(
|
return this.safeOperation(
|
||||||
path,
|
path,
|
||||||
async () =>
|
async () =>
|
||||||
this.locks.withLock(path, async () =>
|
this.locks.withLock(path, async () =>
|
||||||
this.fs.atomicUpdateText(path, updater)
|
this.fs.atomicUpdateText(path, updater)
|
||||||
),
|
),
|
||||||
"atomicUpdateText"
|
"atomicUpdateText"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getFileSize(path: RelativePath): Promise<number> {
|
public async getFileSize(path: RelativePath): Promise<number> {
|
||||||
// Logging this would be too noisy
|
// Logging this would be too noisy
|
||||||
return this.safeOperation(
|
return this.safeOperation(
|
||||||
path,
|
path,
|
||||||
async () =>
|
async () =>
|
||||||
this.locks.withLock(path, async () =>
|
this.locks.withLock(path, async () =>
|
||||||
this.fs.getFileSize(path)
|
this.fs.getFileSize(path)
|
||||||
),
|
),
|
||||||
"getFileSize"
|
"getFileSize"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async exists(
|
public async exists(
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
skipLock = false
|
skipLock = false
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
this.logger.debug(`Checking if file '${path}' exists`);
|
this.logger.debug(`Checking if file '${path}' exists`);
|
||||||
if (skipLock) {
|
if (skipLock) {
|
||||||
return this.fs.exists(path);
|
return this.fs.exists(path);
|
||||||
} else {
|
} else {
|
||||||
return this.locks.withLock(path, async () => this.fs.exists(path));
|
return this.locks.withLock(path, async () => this.fs.exists(path));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createDirectory(path: RelativePath): Promise<void> {
|
public async createDirectory(path: RelativePath): Promise<void> {
|
||||||
this.logger.debug(`Creating directory '${path}'`);
|
this.logger.debug(`Creating directory '${path}'`);
|
||||||
return this.locks.withLock(path, async () =>
|
return this.locks.withLock(path, async () =>
|
||||||
this.fs.createDirectory(path)
|
this.fs.createDirectory(path)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(path: RelativePath): Promise<void> {
|
public async delete(path: RelativePath): Promise<void> {
|
||||||
this.logger.debug(`Deleting file '${path}'`);
|
this.logger.debug(`Deleting file '${path}'`);
|
||||||
return this.locks.withLock(path, async () => this.fs.delete(path));
|
return this.locks.withLock(path, async () => this.fs.delete(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async rename(
|
public async rename(
|
||||||
oldPath: RelativePath,
|
oldPath: RelativePath,
|
||||||
newPath: RelativePath,
|
newPath: RelativePath,
|
||||||
skipLock = false
|
skipLock = false
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.debug(`Renaming file '${oldPath}' to '${newPath}'`);
|
this.logger.debug(`Renaming file '${oldPath}' to '${newPath}'`);
|
||||||
return this.safeOperation(
|
return this.safeOperation(
|
||||||
oldPath,
|
oldPath,
|
||||||
async () => {
|
async () => {
|
||||||
if (skipLock) {
|
if (skipLock) {
|
||||||
return this.fs.rename(oldPath, newPath);
|
return this.fs.rename(oldPath, newPath);
|
||||||
} else {
|
} else {
|
||||||
return this.locks.withLock([oldPath, newPath], async () =>
|
return this.locks.withLock([oldPath, newPath], async () =>
|
||||||
this.fs.rename(oldPath, newPath)
|
this.fs.rename(oldPath, newPath)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rename"
|
"rename"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public tryLock(path: RelativePath): boolean {
|
public tryLock(path: RelativePath): boolean {
|
||||||
return this.locks.tryLock(path);
|
return this.locks.tryLock(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async waitForLock(path: RelativePath): Promise<void> {
|
public async waitForLock(path: RelativePath): Promise<void> {
|
||||||
return this.locks.waitForLock(path);
|
return this.locks.waitForLock(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
public unlock(path: RelativePath): void {
|
public unlock(path: RelativePath): void {
|
||||||
this.locks.unlock(path);
|
this.locks.unlock(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this.locks.reset();
|
this.locks.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decorate an operation to ensure that the file exists before running it.
|
* 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
|
* If the operation fails, it will check if the file still exists and throw
|
||||||
* a FileNotFoundError if it doesn't.
|
* a FileNotFoundError if it doesn't.
|
||||||
*/
|
*/
|
||||||
private async safeOperation<T>(
|
private async safeOperation<T>(
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
operation: () => Promise<T>,
|
operation: () => Promise<T>,
|
||||||
operationName: string
|
operationName: string
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
if (!(await this.fs.exists(path))) {
|
if (!(await this.fs.exists(path))) {
|
||||||
throw new FileNotFoundError(
|
throw new FileNotFoundError(
|
||||||
`File not found before trying to ${operationName}`,
|
`File not found before trying to ${operationName}`,
|
||||||
path
|
path
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await operation();
|
return await operation();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Without locking the file, this isn't atomic, however, it's good enough in practice.
|
// 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
|
// This will only break if the file exists, gets deleted and then immediately
|
||||||
// recreated while `operation` is running.
|
// recreated while `operation` is running.
|
||||||
if (await this.fs.exists(path)) {
|
if (await this.fs.exists(path)) {
|
||||||
throw error;
|
throw error;
|
||||||
} else {
|
} else {
|
||||||
throw new FileNotFoundError(
|
throw new FileNotFoundError(
|
||||||
`File not found when trying to ${operationName}`,
|
`File not found when trying to ${operationName}`,
|
||||||
path
|
path
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,18 @@ import { slowWebSocketFactory } from "./utils/debugging/slow-web-socket-factory"
|
||||||
import { getRandomColor } from "./utils/get-random-color";
|
import { getRandomColor } from "./utils/get-random-color";
|
||||||
import { lineAndColumnToPosition } from "./utils/line-and-column-to-position";
|
import { lineAndColumnToPosition } from "./utils/line-and-column-to-position";
|
||||||
import { positionToLineAndColumn } from "./utils/position-to-line-and-column";
|
import { positionToLineAndColumn } from "./utils/position-to-line-and-column";
|
||||||
|
import { removeFromArray } from "./utils/remove-from-array";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
SyncType,
|
SyncType,
|
||||||
SyncStatus,
|
SyncStatus,
|
||||||
type HistoryStats,
|
type HistoryStats,
|
||||||
type HistoryEntry,
|
type HistoryEntry,
|
||||||
type SyncDetails,
|
type SyncDetails,
|
||||||
type SyncCreateDetails,
|
type SyncCreateDetails,
|
||||||
type SyncUpdateDetails,
|
type SyncUpdateDetails,
|
||||||
type SyncMovedDetails,
|
type SyncMovedDetails,
|
||||||
type SyncDeleteDetails
|
type SyncDeleteDetails
|
||||||
} from "./tracing/sync-history";
|
} from "./tracing/sync-history";
|
||||||
export { Logger, LogLevel, LogLine } from "./tracing/logger";
|
export { Logger, LogLevel, LogLine } from "./tracing/logger";
|
||||||
export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings";
|
export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings";
|
||||||
|
|
@ -34,14 +35,17 @@ export { SyncClient } from "./sync-client";
|
||||||
export type { TextWithCursors, CursorPosition } from "reconcile-text";
|
export type { TextWithCursors, CursorPosition } from "reconcile-text";
|
||||||
|
|
||||||
export const debugging = {
|
export const debugging = {
|
||||||
slowFetchFactory,
|
slowFetchFactory,
|
||||||
slowWebSocketFactory,
|
slowWebSocketFactory,
|
||||||
logToConsole
|
logToConsole
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { globsToRegexes } from "./utils/globs-to-regexes";
|
||||||
|
|
||||||
export const utils = {
|
export const utils = {
|
||||||
getRandomColor,
|
getRandomColor,
|
||||||
positionToLineAndColumn,
|
positionToLineAndColumn,
|
||||||
lineAndColumnToPosition,
|
lineAndColumnToPosition,
|
||||||
awaitAll
|
awaitAll,
|
||||||
|
removeFromArray
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,29 +2,30 @@ import type { Logger } from "../tracing/logger";
|
||||||
import { EMPTY_HASH } from "../utils/hash";
|
import { EMPTY_HASH } from "../utils/hash";
|
||||||
import { CoveredValues } from "../utils/data-structures/min-covered";
|
import { CoveredValues } from "../utils/data-structures/min-covered";
|
||||||
import { awaitAll } from "../utils/await-all";
|
import { awaitAll } from "../utils/await-all";
|
||||||
|
import { removeFromArray } from "../utils/remove-from-array";
|
||||||
|
|
||||||
export type VaultUpdateId = number;
|
export type VaultUpdateId = number;
|
||||||
export type DocumentId = string;
|
export type DocumentId = string;
|
||||||
export type RelativePath = string;
|
export type RelativePath = string;
|
||||||
|
|
||||||
export interface DocumentMetadata {
|
export interface DocumentMetadata {
|
||||||
parentVersionId: VaultUpdateId;
|
parentVersionId: VaultUpdateId;
|
||||||
hash: string;
|
hash: string;
|
||||||
remoteRelativePath?: RelativePath;
|
remoteRelativePath?: RelativePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoredDocumentMetadata {
|
export interface StoredDocumentMetadata {
|
||||||
relativePath: RelativePath;
|
relativePath: RelativePath;
|
||||||
documentId: DocumentId;
|
documentId: DocumentId;
|
||||||
parentVersionId: VaultUpdateId;
|
parentVersionId: VaultUpdateId;
|
||||||
remoteRelativePath?: RelativePath;
|
remoteRelativePath?: RelativePath;
|
||||||
hash: string;
|
hash: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoredDatabase {
|
export interface StoredDatabase {
|
||||||
documents: StoredDocumentMetadata[];
|
documents: StoredDocumentMetadata[];
|
||||||
lastSeenUpdateId: VaultUpdateId | undefined;
|
lastSeenUpdateId: VaultUpdateId | undefined;
|
||||||
hasInitialSyncCompleted: boolean;
|
hasInitialSyncCompleted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -34,339 +35,340 @@ export interface StoredDatabase {
|
||||||
* state of the document on disk based on the update events we have seen.
|
* state of the document on disk based on the update events we have seen.
|
||||||
*/
|
*/
|
||||||
export interface DocumentRecord {
|
export interface DocumentRecord {
|
||||||
relativePath: RelativePath;
|
relativePath: RelativePath;
|
||||||
documentId: DocumentId;
|
documentId: DocumentId;
|
||||||
metadata: DocumentMetadata | undefined;
|
metadata: DocumentMetadata | undefined;
|
||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
updates: Promise<unknown>[];
|
updates: Promise<unknown>[];
|
||||||
parallelVersion: number;
|
parallelVersion: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Database {
|
export class Database {
|
||||||
private documents: DocumentRecord[];
|
private documents: DocumentRecord[];
|
||||||
private lastSeenUpdateIds: CoveredValues;
|
private lastSeenUpdateIds: CoveredValues;
|
||||||
private hasInitialSyncCompleted: boolean;
|
private hasInitialSyncCompleted: boolean;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
initialState: Partial<StoredDatabase> | undefined,
|
initialState: Partial<StoredDatabase> | undefined,
|
||||||
private readonly saveData: (data: StoredDatabase) => Promise<void>
|
private readonly saveData: (data: StoredDatabase) => Promise<void>
|
||||||
) {
|
) {
|
||||||
initialState ??= {};
|
initialState ??= {};
|
||||||
|
|
||||||
this.documents =
|
this.documents =
|
||||||
initialState.documents?.map(
|
initialState.documents?.map(
|
||||||
({ relativePath, documentId, ...metadata }) => ({
|
({ relativePath, documentId, ...metadata }) => ({
|
||||||
relativePath,
|
relativePath,
|
||||||
documentId,
|
documentId,
|
||||||
metadata,
|
metadata,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
updates: [],
|
updates: [],
|
||||||
parallelVersion: 0
|
parallelVersion: 0
|
||||||
})
|
})
|
||||||
) ?? [];
|
) ?? [];
|
||||||
|
|
||||||
this.ensureConsistency();
|
this.ensureConsistency();
|
||||||
this.logger.debug(`Loaded ${this.documents.length} documents`);
|
this.logger.debug(`Loaded ${this.documents.length} documents`);
|
||||||
|
|
||||||
const { lastSeenUpdateId } = initialState;
|
const { lastSeenUpdateId } = initialState;
|
||||||
this.logger.debug(`Loaded last seen update id: ${lastSeenUpdateId}`);
|
this.logger.debug(`Loaded last seen update id: ${lastSeenUpdateId}`);
|
||||||
this.lastSeenUpdateIds = new CoveredValues(
|
this.lastSeenUpdateIds = new CoveredValues(
|
||||||
Math.max(0, lastSeenUpdateId ?? 0) // the first updateId will be 1 which is the first integer after -1
|
Math.max(0, lastSeenUpdateId ?? 0) // the first updateId will be 1 which is the first integer after -1
|
||||||
);
|
);
|
||||||
|
|
||||||
this.documents.forEach((doc) => {
|
this.documents.forEach((doc) => {
|
||||||
this.lastSeenUpdateIds.add(doc.metadata?.parentVersionId);
|
this.lastSeenUpdateIds.add(doc.metadata?.parentVersionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.hasInitialSyncCompleted =
|
this.hasInitialSyncCompleted =
|
||||||
initialState.hasInitialSyncCompleted ?? false;
|
initialState.hasInitialSyncCompleted ?? false;
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Loaded hasInitialSyncCompleted: ${this.hasInitialSyncCompleted}`
|
`Loaded hasInitialSyncCompleted: ${this.hasInitialSyncCompleted}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get length(): number {
|
public get length(): number {
|
||||||
return this.documents.length;
|
return this.documents.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get resolvedDocuments(): DocumentRecord[] {
|
public get resolvedDocuments(): DocumentRecord[] {
|
||||||
const paths = new Map<string, DocumentRecord[]>();
|
const paths = new Map<string, DocumentRecord[]>();
|
||||||
this.documents
|
this.documents
|
||||||
.filter(({ metadata }) => metadata !== undefined)
|
// eslint-disable-next-line no-restricted-syntax -- Type narrowing, not removing a specific item
|
||||||
.forEach((record) =>
|
.filter(({ metadata }) => metadata !== undefined)
|
||||||
paths.set(record.relativePath, [
|
.forEach((record) =>
|
||||||
record,
|
paths.set(record.relativePath, [
|
||||||
...(paths.get(record.relativePath) ?? [])
|
record,
|
||||||
])
|
...(paths.get(record.relativePath) ?? [])
|
||||||
);
|
])
|
||||||
|
);
|
||||||
|
|
||||||
return Array.from(paths.values()).map((records) => {
|
return Array.from(paths.values()).map((records) => {
|
||||||
records.sort(
|
records.sort(
|
||||||
(a, b) => b.parallelVersion - a.parallelVersion // descending
|
(a, b) => b.parallelVersion - a.parallelVersion // descending
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
records.length > 1 &&
|
records.length > 1 &&
|
||||||
records.some((current, i) =>
|
records.some((current, i) =>
|
||||||
i === 0
|
i === 0
|
||||||
? false
|
? false
|
||||||
: records[i - 1].parallelVersion ===
|
: records[i - 1].parallelVersion ===
|
||||||
current.parallelVersion
|
current.parallelVersion
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Multiple documents with the same parallel version and path at ${records[0].relativePath}`
|
`Multiple documents with the same parallel version and path at ${records[0].relativePath}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return records[0];
|
return records[0];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public updateDocumentMetadata(
|
public updateDocumentMetadata(
|
||||||
metadata: {
|
metadata: {
|
||||||
parentVersionId: VaultUpdateId;
|
parentVersionId: VaultUpdateId;
|
||||||
hash: string;
|
hash: string;
|
||||||
remoteRelativePath: RelativePath;
|
remoteRelativePath: RelativePath;
|
||||||
},
|
},
|
||||||
toUpdate: DocumentRecord
|
toUpdate: DocumentRecord
|
||||||
): void {
|
): void {
|
||||||
if (!this.documents.includes(toUpdate)) {
|
if (!this.documents.includes(toUpdate)) {
|
||||||
throw new Error("Document not found in database");
|
throw new Error("Document not found in database");
|
||||||
}
|
}
|
||||||
|
|
||||||
toUpdate.metadata = metadata;
|
toUpdate.metadata = metadata;
|
||||||
|
|
||||||
this.saveInTheBackground();
|
this.saveInTheBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeDocumentPromise(promise: Promise<unknown>): void {
|
public removeDocumentPromise(promise: Promise<unknown>): void {
|
||||||
const entry = this.documents.find(({ updates }) =>
|
const entry = this.documents.find(({ updates }) =>
|
||||||
updates.includes(promise)
|
updates.includes(promise)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (entry === undefined) {
|
if (entry === undefined) {
|
||||||
// This method should be idempotent and tolerant of
|
// This method should be idempotent and tolerant of
|
||||||
// stragglers calling it after the databse has been reset.
|
// stragglers calling it after the databse has been reset.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.updates = entry.updates.filter((update) => update !== promise);
|
removeFromArray(entry.updates, promise);
|
||||||
// No need to save as Promises don't get serialized
|
// No need to save as Promises don't get serialized
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeDocument(find: DocumentRecord): void {
|
public removeDocument(find: DocumentRecord): void {
|
||||||
this.documents = this.documents.filter((document) => document !== find);
|
removeFromArray(this.documents, find);
|
||||||
this.saveInTheBackground();
|
this.saveInTheBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
public getLatestDocumentByRelativePath(
|
public getLatestDocumentByRelativePath(
|
||||||
find: RelativePath
|
find: RelativePath
|
||||||
): DocumentRecord | undefined {
|
): DocumentRecord | undefined {
|
||||||
const candidates = this.documents.filter(
|
const candidates = this.documents.filter(
|
||||||
({ relativePath }) => relativePath === find
|
({ relativePath }) => relativePath === find
|
||||||
);
|
);
|
||||||
candidates.sort((a, b) => b.parallelVersion - a.parallelVersion); // descending
|
candidates.sort((a, b) => b.parallelVersion - a.parallelVersion); // descending
|
||||||
return candidates[0];
|
return candidates[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getResolvedDocumentByRelativePath(
|
public async getResolvedDocumentByRelativePath(
|
||||||
relativePath: RelativePath,
|
relativePath: RelativePath,
|
||||||
promise: Promise<unknown>
|
promise: Promise<unknown>
|
||||||
): Promise<DocumentRecord> {
|
): Promise<DocumentRecord> {
|
||||||
const entry = this.getLatestDocumentByRelativePath(relativePath);
|
const entry = this.getLatestDocumentByRelativePath(relativePath);
|
||||||
|
|
||||||
if (entry === undefined) {
|
if (entry === undefined) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Document not found by relative path: ${relativePath}, ${JSON.stringify(
|
`Document not found by relative path: ${relativePath}, ${JSON.stringify(
|
||||||
this.documents,
|
this.documents,
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentPromises = entry.updates;
|
const currentPromises = entry.updates;
|
||||||
entry.updates = [...currentPromises, promise];
|
entry.updates = [...currentPromises, promise];
|
||||||
await awaitAll(currentPromises);
|
await awaitAll(currentPromises);
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
public createNewPendingDocument(
|
public createNewPendingDocument(
|
||||||
documentId: DocumentId,
|
documentId: DocumentId,
|
||||||
relativePath: RelativePath,
|
relativePath: RelativePath,
|
||||||
promise: Promise<unknown>
|
promise: Promise<unknown>
|
||||||
): DocumentRecord {
|
): DocumentRecord {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Creating new pending document: ${relativePath} (${documentId})`
|
`Creating new pending document: ${relativePath} (${documentId})`
|
||||||
);
|
);
|
||||||
const previousEntry =
|
const previousEntry =
|
||||||
this.getLatestDocumentByRelativePath(relativePath);
|
this.getLatestDocumentByRelativePath(relativePath);
|
||||||
|
|
||||||
const entry = {
|
const entry = {
|
||||||
relativePath,
|
relativePath,
|
||||||
documentId,
|
documentId,
|
||||||
metadata: undefined,
|
metadata: undefined,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
updates: [promise],
|
updates: [promise],
|
||||||
parallelVersion:
|
parallelVersion:
|
||||||
previousEntry?.parallelVersion === undefined
|
previousEntry?.parallelVersion === undefined
|
||||||
? 0
|
? 0
|
||||||
: previousEntry.parallelVersion + 1
|
: previousEntry.parallelVersion + 1
|
||||||
};
|
};
|
||||||
|
|
||||||
this.documents.push(entry);
|
this.documents.push(entry);
|
||||||
this.saveInTheBackground();
|
this.saveInTheBackground();
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
public createNewEmptyDocument(
|
public createNewEmptyDocument(
|
||||||
documentId: DocumentId,
|
documentId: DocumentId,
|
||||||
parentVersionId: VaultUpdateId,
|
parentVersionId: VaultUpdateId,
|
||||||
relativePath: RelativePath
|
relativePath: RelativePath
|
||||||
): DocumentRecord {
|
): DocumentRecord {
|
||||||
const entry = {
|
const entry = {
|
||||||
relativePath,
|
relativePath,
|
||||||
documentId,
|
documentId,
|
||||||
metadata: {
|
metadata: {
|
||||||
parentVersionId,
|
parentVersionId,
|
||||||
hash: EMPTY_HASH,
|
hash: EMPTY_HASH,
|
||||||
remoteRelativePath: relativePath
|
remoteRelativePath: relativePath
|
||||||
},
|
},
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
updates: [],
|
updates: [],
|
||||||
parallelVersion: 0
|
parallelVersion: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
this.documents.push(entry);
|
this.documents.push(entry);
|
||||||
this.saveInTheBackground();
|
this.saveInTheBackground();
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getDocumentByDocumentId(
|
public getDocumentByDocumentId(
|
||||||
find: DocumentId
|
find: DocumentId
|
||||||
): DocumentRecord | undefined {
|
): DocumentRecord | undefined {
|
||||||
return this.documents.find(({ documentId }) => documentId === find);
|
return this.documents.find(({ documentId }) => documentId === find);
|
||||||
}
|
}
|
||||||
|
|
||||||
public move(
|
public move(
|
||||||
oldRelativePath: RelativePath,
|
oldRelativePath: RelativePath,
|
||||||
newRelativePath: RelativePath
|
newRelativePath: RelativePath
|
||||||
): void {
|
): void {
|
||||||
const oldDocument =
|
const oldDocument =
|
||||||
this.getLatestDocumentByRelativePath(oldRelativePath);
|
this.getLatestDocumentByRelativePath(oldRelativePath);
|
||||||
|
|
||||||
if (oldDocument === undefined) {
|
if (oldDocument === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newDocument =
|
const newDocument =
|
||||||
this.getLatestDocumentByRelativePath(newRelativePath);
|
this.getLatestDocumentByRelativePath(newRelativePath);
|
||||||
if (newDocument?.isDeleted === false) {
|
if (newDocument?.isDeleted === false) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Document already exists at new location: ${newRelativePath}`
|
`Document already exists at new location: ${newRelativePath}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
oldDocument.relativePath = newRelativePath;
|
oldDocument.relativePath = newRelativePath;
|
||||||
// We're in a strange state where the target of the move has just got deleted,
|
// 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
|
// 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.
|
// the document at the new location. We need to keep these updates.
|
||||||
oldDocument.parallelVersion =
|
oldDocument.parallelVersion =
|
||||||
newDocument !== undefined ? newDocument.parallelVersion + 1 : 0;
|
newDocument !== undefined ? newDocument.parallelVersion + 1 : 0;
|
||||||
|
|
||||||
this.saveInTheBackground();
|
this.saveInTheBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
public delete(relativePath: RelativePath): void {
|
public delete(relativePath: RelativePath): void {
|
||||||
const candidate = this.getLatestDocumentByRelativePath(relativePath);
|
const candidate = this.getLatestDocumentByRelativePath(relativePath);
|
||||||
if (candidate === undefined) {
|
if (candidate === undefined) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Document not found by relative path: ${relativePath}`
|
`Document not found by relative path: ${relativePath}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
candidate.isDeleted = true;
|
candidate.isDeleted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getHasInitialSyncCompleted(): boolean {
|
public getHasInitialSyncCompleted(): boolean {
|
||||||
return this.hasInitialSyncCompleted;
|
return this.hasInitialSyncCompleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
public setHasInitialSyncCompleted(value: boolean): void {
|
public setHasInitialSyncCompleted(value: boolean): void {
|
||||||
this.hasInitialSyncCompleted = value;
|
this.hasInitialSyncCompleted = value;
|
||||||
this.saveInTheBackground();
|
this.saveInTheBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
public getLastSeenUpdateId(): VaultUpdateId {
|
public getLastSeenUpdateId(): VaultUpdateId {
|
||||||
return this.lastSeenUpdateIds.min;
|
return this.lastSeenUpdateIds.min;
|
||||||
}
|
}
|
||||||
|
|
||||||
public addSeenUpdateId(value: number): void {
|
public addSeenUpdateId(value: number): void {
|
||||||
const previousMin = this.lastSeenUpdateIds.min;
|
const previousMin = this.lastSeenUpdateIds.min;
|
||||||
this.lastSeenUpdateIds.add(value);
|
this.lastSeenUpdateIds.add(value);
|
||||||
if (previousMin !== this.lastSeenUpdateIds.min) {
|
if (previousMin !== this.lastSeenUpdateIds.min) {
|
||||||
this.saveInTheBackground();
|
this.saveInTheBackground();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public setLastSeenUpdateId(value: number): void {
|
public setLastSeenUpdateId(value: number): void {
|
||||||
this.lastSeenUpdateIds.min = value;
|
this.lastSeenUpdateIds.min = value;
|
||||||
this.saveInTheBackground();
|
this.saveInTheBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this.documents = [];
|
this.documents = [];
|
||||||
this.lastSeenUpdateIds = new CoveredValues(
|
this.lastSeenUpdateIds = new CoveredValues(
|
||||||
0 // the first updateId will be 1 which is the first integer after -1
|
0 // the first updateId will be 1 which is the first integer after -1
|
||||||
);
|
);
|
||||||
this.hasInitialSyncCompleted = false;
|
this.hasInitialSyncCompleted = false;
|
||||||
this.saveInTheBackground();
|
this.saveInTheBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async save(): Promise<void> {
|
public async save(): Promise<void> {
|
||||||
return this.saveData({
|
return this.saveData({
|
||||||
documents: this.resolvedDocuments.map(
|
documents: this.resolvedDocuments.map(
|
||||||
({ relativePath, documentId, metadata }) => ({
|
({ relativePath, documentId, metadata }) => ({
|
||||||
documentId,
|
documentId,
|
||||||
relativePath,
|
relativePath,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
...metadata! // `resolvedDocuments` only returns docs with metadata set
|
...metadata! // `resolvedDocuments` only returns docs with metadata set
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
lastSeenUpdateId: this.lastSeenUpdateIds.min,
|
lastSeenUpdateId: this.lastSeenUpdateIds.min,
|
||||||
hasInitialSyncCompleted: this.hasInitialSyncCompleted
|
hasInitialSyncCompleted: this.hasInitialSyncCompleted
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureConsistency(): void {
|
private ensureConsistency(): void {
|
||||||
const idToPath = new Map<string, string[]>();
|
const idToPath = new Map<string, string[]>();
|
||||||
|
|
||||||
this.resolvedDocuments.forEach(({ relativePath, documentId }) => {
|
this.resolvedDocuments.forEach(({ relativePath, documentId }) => {
|
||||||
idToPath.set(documentId, [
|
idToPath.set(documentId, [
|
||||||
...(idToPath.get(documentId) ?? []),
|
...(idToPath.get(documentId) ?? []),
|
||||||
relativePath
|
relativePath
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const duplicates = Array.from(idToPath.entries())
|
const duplicates = Array.from(idToPath.entries())
|
||||||
.filter(([_, paths]) => paths.length > 1)
|
.filter(([_, paths]) => paths.length > 1)
|
||||||
.map(([id, paths]) => `${id} (${paths.join(", ")})`);
|
.map(([id, paths]) => `${id} (${paths.join(", ")})`);
|
||||||
|
|
||||||
if (duplicates.length > 0) {
|
if (duplicates.length > 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Document IDs are not unique, found duplicates: " +
|
"Document IDs are not unique, found duplicates: " +
|
||||||
duplicates.join("; ")
|
duplicates.join("; ")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private saveInTheBackground(): void {
|
private saveInTheBackground(): void {
|
||||||
this.ensureConsistency();
|
this.ensureConsistency();
|
||||||
void this.save().catch((error: unknown) => {
|
void this.save().catch((error: unknown) => {
|
||||||
this.logger.error(`Error saving data: ${error}`);
|
this.logger.error(`Error saving data: ${error}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export interface PersistenceProvider<T> {
|
export interface PersistenceProvider<T> {
|
||||||
load: () => Promise<T | undefined>;
|
load: () => Promise<T | undefined>;
|
||||||
save: (data: T) => Promise<void>;
|
save: (data: T) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,115 +1,94 @@
|
||||||
import type { Logger } from "../tracing/logger";
|
import type { Logger } from "../tracing/logger";
|
||||||
import { awaitAll } from "../utils/await-all";
|
|
||||||
import { Lock } from "../utils/data-structures/locks";
|
import { Lock } from "../utils/data-structures/locks";
|
||||||
|
import { EventListeners } from "../utils/data-structures/event-listeners";
|
||||||
|
|
||||||
export interface SyncSettings {
|
export interface SyncSettings {
|
||||||
remoteUri: string;
|
remoteUri: string;
|
||||||
token: string;
|
token: string;
|
||||||
vaultName: string;
|
vaultName: string;
|
||||||
syncConcurrency: number;
|
syncConcurrency: number;
|
||||||
isSyncEnabled: boolean;
|
isSyncEnabled: boolean;
|
||||||
maxFileSizeMB: number;
|
maxFileSizeMB: number;
|
||||||
ignorePatterns: string[];
|
ignorePatterns: string[];
|
||||||
webSocketRetryIntervalMs: number;
|
webSocketRetryIntervalMs: number;
|
||||||
diffCacheSizeMB: number;
|
diffCacheSizeMB: number;
|
||||||
enableTelemetry: boolean;
|
enableTelemetry: boolean;
|
||||||
networkRetryIntervalMs: number;
|
networkRetryIntervalMs: number;
|
||||||
minimumSaveIntervalMs: number;
|
minimumSaveIntervalMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: SyncSettings = {
|
export const DEFAULT_SETTINGS: SyncSettings = {
|
||||||
remoteUri: "",
|
remoteUri: "",
|
||||||
token: "",
|
token: "",
|
||||||
vaultName: "default",
|
vaultName: "default",
|
||||||
syncConcurrency: 1,
|
syncConcurrency: 1,
|
||||||
isSyncEnabled: false,
|
isSyncEnabled: false,
|
||||||
maxFileSizeMB: 10,
|
maxFileSizeMB: 10,
|
||||||
ignorePatterns: [],
|
ignorePatterns: [],
|
||||||
webSocketRetryIntervalMs: 3500,
|
webSocketRetryIntervalMs: 3500,
|
||||||
diffCacheSizeMB: 4,
|
diffCacheSizeMB: 4,
|
||||||
enableTelemetry: false,
|
enableTelemetry: false,
|
||||||
networkRetryIntervalMs: 1000,
|
networkRetryIntervalMs: 1000,
|
||||||
minimumSaveIntervalMs: 1000
|
minimumSaveIntervalMs: 1000
|
||||||
};
|
};
|
||||||
|
|
||||||
export class Settings {
|
export class Settings {
|
||||||
private settings: SyncSettings;
|
public readonly onSettingsChanged = new EventListeners<
|
||||||
private readonly lock: Lock = new Lock();
|
(newSettings: SyncSettings, oldSettings: SyncSettings) => unknown
|
||||||
|
>();
|
||||||
|
|
||||||
private readonly onSettingsChangeHandlers: ((
|
private settings: SyncSettings;
|
||||||
newSettings: SyncSettings,
|
private readonly lock: Lock = new Lock();
|
||||||
oldSettings: SyncSettings
|
|
||||||
) => unknown)[] = [];
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
initialState: Partial<SyncSettings> | undefined,
|
initialState: Partial<SyncSettings> | undefined,
|
||||||
private readonly saveData: (data: SyncSettings) => Promise<void>
|
private readonly saveData: (data: SyncSettings) => Promise<void>
|
||||||
) {
|
) {
|
||||||
this.settings = {
|
this.settings = {
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
...(initialState ?? {})
|
...(initialState ?? {})
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Loaded settings: ${JSON.stringify(this.settings, null, 2)}`
|
`Loaded settings: ${JSON.stringify(this.settings, null, 2)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getSettings(): SyncSettings {
|
public getSettings(): SyncSettings {
|
||||||
return this.settings;
|
return this.settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
public addOnSettingsChangeListener(
|
public async setSetting<T extends keyof SyncSettings>(
|
||||||
listener: (settings: SyncSettings, oldSettings: SyncSettings) => unknown
|
key: T,
|
||||||
): void {
|
value: SyncSettings[T]
|
||||||
this.onSettingsChangeHandlers.push(listener);
|
): Promise<void> {
|
||||||
}
|
await this.setSettings({
|
||||||
|
[key]: value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public removeOnSettingsChangeListener(
|
public async setSettings(value: Partial<SyncSettings>): Promise<void> {
|
||||||
listener: (settings: SyncSettings, oldSettings: SyncSettings) => unknown
|
await this.lock.withLock(async () => {
|
||||||
): void {
|
this.logger.debug(
|
||||||
const index = this.onSettingsChangeHandlers.indexOf(listener);
|
`Updating settings with: ${JSON.stringify(value)}`
|
||||||
if (index !== -1) {
|
);
|
||||||
this.onSettingsChangeHandlers.splice(index, 1);
|
const oldSettings = this.settings;
|
||||||
}
|
this.settings = {
|
||||||
}
|
...this.settings,
|
||||||
|
...value
|
||||||
|
};
|
||||||
|
|
||||||
public async setSetting<T extends keyof SyncSettings>(
|
await this.onSettingsChanged.triggerAsync(
|
||||||
key: T,
|
this.settings,
|
||||||
value: SyncSettings[T]
|
oldSettings
|
||||||
): Promise<void> {
|
);
|
||||||
await this.setSettings({
|
|
||||||
[key]: value
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async setSettings(value: Partial<SyncSettings>): Promise<void> {
|
await this.save();
|
||||||
await this.lock.withLock(async () => {
|
});
|
||||||
this.logger.debug(
|
}
|
||||||
`Updating settings with: ${JSON.stringify(value)}`
|
|
||||||
);
|
|
||||||
const oldSettings = this.settings;
|
|
||||||
this.settings = {
|
|
||||||
...this.settings,
|
|
||||||
...value
|
|
||||||
};
|
|
||||||
|
|
||||||
await awaitAll(
|
private async save(): Promise<void> {
|
||||||
this.onSettingsChangeHandlers
|
await this.saveData(this.settings);
|
||||||
.map((handler) => {
|
}
|
||||||
return handler(this.settings, oldSettings);
|
|
||||||
})
|
|
||||||
.filter((result): result is Promise<unknown> => {
|
|
||||||
return result instanceof Promise;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.save();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async save(): Promise<void> {
|
|
||||||
await this.saveData(this.settings);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export class AuthenticationError extends Error {
|
export class AuthenticationError extends Error {
|
||||||
public constructor(message: string) {
|
public constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "AuthenticationError";
|
this.name = "AuthenticationError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,171 +7,171 @@ import { SyncResetError } from "./sync-reset-error";
|
||||||
import { sleep } from "../utils/sleep";
|
import { sleep } from "../utils/sleep";
|
||||||
|
|
||||||
describe("FetchController", () => {
|
describe("FetchController", () => {
|
||||||
const createMockFetch = (
|
const createMockFetch = (
|
||||||
shouldSleep: boolean
|
shouldSleep: boolean
|
||||||
): Mock<() => Promise<Response>> =>
|
): Mock<() => Promise<Response>> =>
|
||||||
mock.fn(async () => {
|
mock.fn(async () => {
|
||||||
if (shouldSleep) {
|
if (shouldSleep) {
|
||||||
await sleep(30);
|
await sleep(30);
|
||||||
}
|
}
|
||||||
return Promise.resolve(new Response("OK", { status: 200 }));
|
return Promise.resolve(new Response("OK", { status: 200 }));
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mock.timers.enable({ apis: ["setTimeout"] });
|
mock.timers.enable({ apis: ["setTimeout"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mock.timers.reset();
|
mock.timers.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should allow fetch when canFetch is true", async () => {
|
it("should allow fetch when canFetch is true", async () => {
|
||||||
const logger = new Logger();
|
const logger = new Logger();
|
||||||
const controller = new FetchController(true, logger);
|
const controller = new FetchController(true, logger);
|
||||||
const mockFetch = createMockFetch(false);
|
const mockFetch = createMockFetch(false);
|
||||||
const controlledFetch = controller.getControlledFetchImplementation(
|
const controlledFetch = controller.getControlledFetchImplementation(
|
||||||
logger,
|
logger,
|
||||||
mockFetch
|
mockFetch
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await controlledFetch("http://example.com");
|
const response = await controlledFetch("http://example.com");
|
||||||
|
|
||||||
assert.strictEqual(await response.text(), "OK");
|
assert.strictEqual(await response.text(), "OK");
|
||||||
assert.strictEqual(mockFetch.mock.calls.length, 1);
|
assert.strictEqual(mockFetch.mock.calls.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should block fetch until canFetch becomes true", async () => {
|
it("should block fetch until canFetch becomes true", async () => {
|
||||||
const logger = new Logger();
|
const logger = new Logger();
|
||||||
const controller = new FetchController(false, logger);
|
const controller = new FetchController(false, logger);
|
||||||
const mockFetch = createMockFetch(true);
|
const mockFetch = createMockFetch(true);
|
||||||
const controlledFetch = controller.getControlledFetchImplementation(
|
const controlledFetch = controller.getControlledFetchImplementation(
|
||||||
logger,
|
logger,
|
||||||
mockFetch
|
mockFetch
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchPromise = controlledFetch("http://example.com");
|
const fetchPromise = controlledFetch("http://example.com");
|
||||||
assert.strictEqual(mockFetch.mock.calls.length, 0);
|
assert.strictEqual(mockFetch.mock.calls.length, 0);
|
||||||
|
|
||||||
controller.canFetch = true;
|
controller.canFetch = true;
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
mock.timers.tick(30);
|
mock.timers.tick(30);
|
||||||
|
|
||||||
const response = await fetchPromise;
|
const response = await fetchPromise;
|
||||||
assert.strictEqual(await response.text(), "OK");
|
assert.strictEqual(await response.text(), "OK");
|
||||||
assert.strictEqual(mockFetch.mock.calls.length, 1);
|
assert.strictEqual(mockFetch.mock.calls.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should reject during reset", async () => {
|
it("should reject during reset", async () => {
|
||||||
const logger = new Logger();
|
const logger = new Logger();
|
||||||
const controller = new FetchController(true, logger);
|
const controller = new FetchController(true, logger);
|
||||||
const mockFetch = createMockFetch(true);
|
const mockFetch = createMockFetch(true);
|
||||||
const controlledFetch = controller.getControlledFetchImplementation(
|
const controlledFetch = controller.getControlledFetchImplementation(
|
||||||
logger,
|
logger,
|
||||||
mockFetch
|
mockFetch
|
||||||
);
|
);
|
||||||
|
|
||||||
const firstRequest = controlledFetch("http://example.com");
|
const firstRequest = controlledFetch("http://example.com");
|
||||||
assert.strictEqual(mockFetch.mock.calls.length, 1);
|
assert.strictEqual(mockFetch.mock.calls.length, 1);
|
||||||
|
|
||||||
controller.startReset();
|
controller.startReset();
|
||||||
|
|
||||||
const secondRequest = controlledFetch("http://example.com");
|
const secondRequest = controlledFetch("http://example.com");
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
firstRequest,
|
firstRequest,
|
||||||
(error: unknown) => error instanceof SyncResetError
|
(error: unknown) => error instanceof SyncResetError
|
||||||
);
|
);
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
secondRequest,
|
secondRequest,
|
||||||
(error: unknown) => error instanceof SyncResetError
|
(error: unknown) => error instanceof SyncResetError
|
||||||
);
|
);
|
||||||
assert.strictEqual(mockFetch.mock.calls.length, 1);
|
assert.strictEqual(mockFetch.mock.calls.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should allow fetch after reset finishes", async () => {
|
it("should allow fetch after reset finishes", async () => {
|
||||||
const logger = new Logger();
|
const logger = new Logger();
|
||||||
const controller = new FetchController(true, logger);
|
const controller = new FetchController(true, logger);
|
||||||
const mockFetch = createMockFetch(false);
|
const mockFetch = createMockFetch(false);
|
||||||
const controlledFetch = controller.getControlledFetchImplementation(
|
const controlledFetch = controller.getControlledFetchImplementation(
|
||||||
logger,
|
logger,
|
||||||
mockFetch
|
mockFetch
|
||||||
);
|
);
|
||||||
|
|
||||||
controller.startReset();
|
controller.startReset();
|
||||||
controller.finishReset();
|
controller.finishReset();
|
||||||
|
|
||||||
const response = await controlledFetch("http://example.com");
|
const response = await controlledFetch("http://example.com");
|
||||||
assert.strictEqual(await response.text(), "OK");
|
assert.strictEqual(await response.text(), "OK");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should defer canFetch changes during reset", async () => {
|
it("should defer canFetch changes during reset", async () => {
|
||||||
const logger = new Logger();
|
const logger = new Logger();
|
||||||
const controller = new FetchController(false, logger);
|
const controller = new FetchController(false, logger);
|
||||||
const mockFetch = createMockFetch(true);
|
const mockFetch = createMockFetch(true);
|
||||||
const controlledFetch = controller.getControlledFetchImplementation(
|
const controlledFetch = controller.getControlledFetchImplementation(
|
||||||
logger,
|
logger,
|
||||||
mockFetch
|
mockFetch
|
||||||
);
|
);
|
||||||
|
|
||||||
controller.startReset();
|
controller.startReset();
|
||||||
controller.canFetch = true;
|
controller.canFetch = true;
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
async () => controlledFetch("http://example.com"),
|
async () => controlledFetch("http://example.com"),
|
||||||
(error: unknown) => error instanceof SyncResetError
|
(error: unknown) => error instanceof SyncResetError
|
||||||
);
|
);
|
||||||
|
|
||||||
controller.finishReset();
|
controller.finishReset();
|
||||||
|
|
||||||
const fetchPromise = controlledFetch("http://example.com");
|
const fetchPromise = controlledFetch("http://example.com");
|
||||||
mock.timers.tick(30);
|
mock.timers.tick(30);
|
||||||
|
|
||||||
const response = await fetchPromise;
|
const response = await fetchPromise;
|
||||||
assert.strictEqual(await response.text(), "OK");
|
assert.strictEqual(await response.text(), "OK");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle different input types", async () => {
|
it("should handle different input types", async () => {
|
||||||
const logger = new Logger();
|
const logger = new Logger();
|
||||||
const controller = new FetchController(true, logger);
|
const controller = new FetchController(true, logger);
|
||||||
const mockFetch = createMockFetch(false);
|
const mockFetch = createMockFetch(false);
|
||||||
const controlledFetch = controller.getControlledFetchImplementation(
|
const controlledFetch = controller.getControlledFetchImplementation(
|
||||||
logger,
|
logger,
|
||||||
mockFetch
|
mockFetch
|
||||||
);
|
);
|
||||||
|
|
||||||
await controlledFetch("http://example.com");
|
await controlledFetch("http://example.com");
|
||||||
await controlledFetch(new URL("http://example.com"));
|
await controlledFetch(new URL("http://example.com"));
|
||||||
await controlledFetch(
|
await controlledFetch(
|
||||||
new Request("http://example.com", { method: "POST" })
|
new Request("http://example.com", { method: "POST" })
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.strictEqual(mockFetch.mock.calls.length, 3);
|
assert.strictEqual(mockFetch.mock.calls.length, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle fetch errors", async () => {
|
it("should handle fetch errors", async () => {
|
||||||
const logger = new Logger();
|
const logger = new Logger();
|
||||||
const controller = new FetchController(true, logger);
|
const controller = new FetchController(true, logger);
|
||||||
const mockFetch = mock.fn(async () => {
|
const mockFetch = mock.fn(async () => {
|
||||||
throw new Error("Network error");
|
throw new Error("Network error");
|
||||||
});
|
});
|
||||||
const controlledFetch = controller.getControlledFetchImplementation(
|
const controlledFetch = controller.getControlledFetchImplementation(
|
||||||
logger,
|
logger,
|
||||||
mockFetch
|
mockFetch
|
||||||
);
|
);
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
async () => controlledFetch("http://example.com"),
|
async () => controlledFetch("http://example.com"),
|
||||||
(error: unknown) =>
|
(error: unknown) =>
|
||||||
error instanceof Error && error.message === "Network error"
|
error instanceof Error && error.message === "Network error"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not create unhandled rejection on reset with no waiting fetches", async () => {
|
it("should not create unhandled rejection on reset with no waiting fetches", async () => {
|
||||||
const logger = new Logger();
|
const logger = new Logger();
|
||||||
const controller = new FetchController(true, logger);
|
const controller = new FetchController(true, logger);
|
||||||
|
|
||||||
controller.startReset();
|
controller.startReset();
|
||||||
mock.timers.tick(10);
|
mock.timers.tick(10);
|
||||||
controller.finishReset();
|
controller.finishReset();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,143 +7,143 @@ import { SyncResetError } from "./sync-reset-error";
|
||||||
* and aborts outstanding requests when a reset is started.
|
* and aborts outstanding requests when a reset is started.
|
||||||
*/
|
*/
|
||||||
export class FetchController {
|
export class FetchController {
|
||||||
private static readonly UNTIL_RESOLUTION = Symbol();
|
private static readonly UNTIL_RESOLUTION = Symbol();
|
||||||
|
|
||||||
private isResetting = false;
|
private isResetting = false;
|
||||||
|
|
||||||
// Promise resolves on the next state change: sync enabled/disabled or reset started/ended
|
// Promise resolves on the next state change: sync enabled/disabled or reset started/ended
|
||||||
private until: Promise<symbol>;
|
private until: Promise<symbol>;
|
||||||
private resolveUntil: (result: symbol) => unknown;
|
private resolveUntil: (result: symbol) => unknown;
|
||||||
private rejectUntil: (reason: unknown) => unknown;
|
private rejectUntil: (reason: unknown) => unknown;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private _canFetch: boolean,
|
private _canFetch: boolean,
|
||||||
private readonly logger: Logger
|
private readonly logger: Logger
|
||||||
) {
|
) {
|
||||||
[this.until, this.resolveUntil, this.rejectUntil] =
|
[this.until, this.resolveUntil, this.rejectUntil] =
|
||||||
createPromise<symbol>();
|
createPromise<symbol>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the fetch implementation can immediately send requests once outside of a reset.
|
* Whether the fetch implementation can immediately send requests once outside of a reset.
|
||||||
*/
|
*/
|
||||||
public get canFetch(): boolean {
|
public get canFetch(): boolean {
|
||||||
return this._canFetch;
|
return this._canFetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allow or disallow fetching. The changes only take effect if not resetting.
|
* Allow or disallow fetching. The changes only take effect if not resetting.
|
||||||
* When called during a reset, its effect is deferred until the reset is finished.
|
* When called during a reset, its effect is deferred until the reset is finished.
|
||||||
*
|
*
|
||||||
* @param canFetch Whether fetching is enabled
|
* @param canFetch Whether fetching is enabled
|
||||||
*/
|
*/
|
||||||
public set canFetch(canFetch: boolean) {
|
public set canFetch(canFetch: boolean) {
|
||||||
this._canFetch = canFetch;
|
this._canFetch = canFetch;
|
||||||
|
|
||||||
if (!this.isResetting) {
|
if (!this.isResetting) {
|
||||||
const previousResolve = this.resolveUntil;
|
const previousResolve = this.resolveUntil;
|
||||||
[this.until, this.resolveUntil, this.rejectUntil] =
|
[this.until, this.resolveUntil, this.rejectUntil] =
|
||||||
createPromise<symbol>();
|
createPromise<symbol>();
|
||||||
previousResolve(FetchController.UNTIL_RESOLUTION);
|
previousResolve(FetchController.UNTIL_RESOLUTION);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getUrlFromInput(input: RequestInfo | URL): string {
|
private static getUrlFromInput(input: RequestInfo | URL): string {
|
||||||
if (input instanceof URL) {
|
if (input instanceof URL) {
|
||||||
return input.href;
|
return input.href;
|
||||||
}
|
}
|
||||||
if (typeof input === "string") {
|
if (typeof input === "string") {
|
||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
return input.url;
|
return input.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts a reset, causing all ongoing and future fetches to be rejected
|
* Starts a reset, causing all ongoing and future fetches to be rejected
|
||||||
* with a SyncResetError until finishReset is called.
|
* with a SyncResetError until finishReset is called.
|
||||||
*/
|
*/
|
||||||
public startReset(): void {
|
public startReset(): void {
|
||||||
this.isResetting = true;
|
this.isResetting = true;
|
||||||
this.rejectUntil(new SyncResetError());
|
this.rejectUntil(new SyncResetError());
|
||||||
// Catch unhandled rejection if no fetches are waiting
|
// Catch unhandled rejection if no fetches are waiting
|
||||||
this.until.catch(() => {
|
this.until.catch(() => {
|
||||||
// Intentionally ignore - this rejection is handled by waiting fetches
|
// Intentionally ignore - this rejection is handled by waiting fetches
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finishes a reset, allowing fetches to proceed or wait again depending on
|
* Finishes a reset, allowing fetches to proceed or wait again depending on
|
||||||
* the current sync settings.
|
* the current sync settings.
|
||||||
*/
|
*/
|
||||||
public finishReset(): void {
|
public finishReset(): void {
|
||||||
if (!this.isResetting) {
|
if (!this.isResetting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isResetting = false;
|
this.isResetting = false;
|
||||||
[this.until, this.resolveUntil, this.rejectUntil] = createPromise();
|
[this.until, this.resolveUntil, this.rejectUntil] = createPromise();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* |------------------|---------------|-----------------------------------------------------|
|
* |------------------|---------------|-----------------------------------------------------|
|
||||||
* | | Sync enabled | Sync disabled |
|
* | | Sync enabled | Sync disabled |
|
||||||
* |------------------|-------------- |-----------------------------------------------------|
|
* |------------------|-------------- |-----------------------------------------------------|
|
||||||
* | During reset | Rejects with SyncResetError without sending request |
|
* | During reset | Rejects with SyncResetError without sending request |
|
||||||
* |------------------|-------------- |-----------------------------------------------------|
|
* |------------------|-------------- |-----------------------------------------------------|
|
||||||
* | Outside of reset | Same as fetch | Blocks until sync is enabled and then same as fetch |
|
* | Outside of reset | Same as fetch | Blocks until sync is enabled and then same as fetch |
|
||||||
* |------------------|---------------|-----------------------------------------------------|
|
* |------------------|---------------|-----------------------------------------------------|
|
||||||
*
|
*
|
||||||
* @param logger for errors
|
* @param logger for errors
|
||||||
* @param fetch to wrap
|
* @param fetch to wrap
|
||||||
* @returns a wrapped fetch implementation affected by the FetchController state
|
* @returns a wrapped fetch implementation affected by the FetchController state
|
||||||
*/
|
*/
|
||||||
public getControlledFetchImplementation(
|
public getControlledFetchImplementation(
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
fetch: typeof globalThis.fetch = globalThis.fetch
|
fetch: typeof globalThis.fetch = globalThis.fetch
|
||||||
): typeof globalThis.fetch {
|
): typeof globalThis.fetch {
|
||||||
return async (
|
return async (
|
||||||
input: RequestInfo | URL,
|
input: RequestInfo | URL,
|
||||||
init?: RequestInit
|
init?: RequestInit
|
||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
while (!this.canFetch || this.isResetting) {
|
while (!this.canFetch || this.isResetting) {
|
||||||
await this.until;
|
await this.until;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// https://github.com/jonbern/fetch-retry/blob/8684ef4e688375f623bd76f13add76dbc1d67cfb/index.js#L67C1-L70C21
|
// https://github.com/jonbern/fetch-retry/blob/8684ef4e688375f623bd76f13add76dbc1d67cfb/index.js#L67C1-L70C21
|
||||||
const _input =
|
const _input =
|
||||||
typeof Request !== "undefined" && input instanceof Request
|
typeof Request !== "undefined" && input instanceof Request
|
||||||
? input.clone()
|
? input.clone()
|
||||||
: input;
|
: input;
|
||||||
|
|
||||||
const fetchPromise = fetch(_input, init);
|
const fetchPromise = fetch(_input, init);
|
||||||
|
|
||||||
// We only want to catch rejections from `this.until`
|
// We only want to catch rejections from `this.until`
|
||||||
let result: symbol | Response | undefined = undefined;
|
let result: symbol | Response | undefined = undefined;
|
||||||
do {
|
do {
|
||||||
result = await Promise.race([this.until, fetchPromise]);
|
result = await Promise.race([this.until, fetchPromise]);
|
||||||
} while (result === FetchController.UNTIL_RESOLUTION);
|
} while (result === FetchController.UNTIL_RESOLUTION);
|
||||||
|
|
||||||
const fetchResult: Response = result as Response; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
const fetchResult: Response = result as Response; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
|
||||||
if (!fetchResult.ok) {
|
if (!fetchResult.ok) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Fetch for ${FetchController.getUrlFromInput(
|
`Fetch for ${FetchController.getUrlFromInput(
|
||||||
input
|
input
|
||||||
)}, got status ${fetchResult.status}`
|
)}, got status ${fetchResult.status}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetchResult;
|
return fetchResult;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Fetch for ${FetchController.getUrlFromInput(
|
`Fetch for ${FetchController.getUrlFromInput(
|
||||||
input
|
input
|
||||||
)}, got error: ${error}`
|
)}, got error: ${error}`
|
||||||
);
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,83 +5,83 @@ import type { SyncService } from "./sync-service";
|
||||||
import type { PingResponse } from "./types/PingResponse";
|
import type { PingResponse } from "./types/PingResponse";
|
||||||
|
|
||||||
export interface ServerConfigData {
|
export interface ServerConfigData {
|
||||||
mergeableFileExtensions: string[];
|
mergeableFileExtensions: string[];
|
||||||
supportedApiVersion: number;
|
supportedApiVersion: number;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ServerConfig {
|
export class ServerConfig {
|
||||||
private response: Promise<PingResponse> | undefined;
|
private response: Promise<PingResponse> | undefined;
|
||||||
private config: ServerConfigData | undefined;
|
private config: ServerConfigData | undefined;
|
||||||
|
|
||||||
public constructor(private readonly syncService: SyncService) {}
|
public constructor(private readonly syncService: SyncService) {}
|
||||||
|
|
||||||
public async initialize(): Promise<void> {
|
public async initialize(): Promise<void> {
|
||||||
this.response = this.syncService.ping();
|
this.response = this.syncService.ping();
|
||||||
this.config = await this.response;
|
this.config = await this.response;
|
||||||
|
|
||||||
if (this.config.supportedApiVersion !== SUPPORTED_API_VERSION) {
|
if (this.config.supportedApiVersion !== SUPPORTED_API_VERSION) {
|
||||||
const shouldUpgradeClient =
|
const shouldUpgradeClient =
|
||||||
this.config.supportedApiVersion > SUPPORTED_API_VERSION;
|
this.config.supportedApiVersion > SUPPORTED_API_VERSION;
|
||||||
throw new ServerVersionMismatchError(
|
throw new ServerVersionMismatchError(
|
||||||
`Unsupported API version: ${this.config.supportedApiVersion}. Consider upgrading the ${
|
`Unsupported API version: ${this.config.supportedApiVersion}. Consider upgrading the ${
|
||||||
shouldUpgradeClient ? "client" : "sync-server"
|
shouldUpgradeClient ? "client" : "sync-server"
|
||||||
} to ensure compatibility.`
|
} to ensure compatibility.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.config.isAuthenticated) {
|
if (!this.config.isAuthenticated) {
|
||||||
throw new AuthenticationError(
|
throw new AuthenticationError(
|
||||||
"Failed to authenticate with the sync-server."
|
"Failed to authenticate with the sync-server."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async checkConnection(forceUpdate = false): Promise<{
|
public async checkConnection(forceUpdate = false): Promise<{
|
||||||
isSuccessful: boolean;
|
isSuccessful: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
let { response } = this;
|
let { response } = this;
|
||||||
if (!response && !forceUpdate) {
|
if (!response && !forceUpdate) {
|
||||||
throw new Error("ServerConfig not initialized");
|
throw new Error("ServerConfig not initialized");
|
||||||
} else if (forceUpdate) {
|
} else if (forceUpdate) {
|
||||||
response = this.response = this.syncService.ping();
|
response = this.response = this.syncService.ping();
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const result: PingResponse = (await response)!; // it must be defined, otherwise we would have thrown above
|
const result: PingResponse = (await response)!; // it must be defined, otherwise we would have thrown above
|
||||||
this.config = result;
|
this.config = result;
|
||||||
|
|
||||||
if (result.isAuthenticated) {
|
if (result.isAuthenticated) {
|
||||||
return {
|
return {
|
||||||
isSuccessful: true,
|
isSuccessful: true,
|
||||||
message: `Successfully connected to server (version: ${result.serverVersion}) and authenticated`
|
message: `Successfully connected to server (version: ${result.serverVersion}) and authenticated`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isSuccessful: false,
|
isSuccessful: false,
|
||||||
message: `Successfully connected to server (version: ${result.serverVersion}) but failed to authenticate`
|
message: `Successfully connected to server (version: ${result.serverVersion}) but failed to authenticate`
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
isSuccessful: false,
|
isSuccessful: false,
|
||||||
message: `Failed to connect to server: ${e}`
|
message: `Failed to connect to server: ${e}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public getConfig(): ServerConfigData {
|
public getConfig(): ServerConfigData {
|
||||||
if (!this.config) {
|
if (!this.config) {
|
||||||
throw new Error("ServerConfig not initialized");
|
throw new Error("ServerConfig not initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.config;
|
return this.config;
|
||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this.response = undefined;
|
this.response = undefined;
|
||||||
this.config = undefined;
|
this.config = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export class ServerVersionMismatchError extends Error {
|
export class ServerVersionMismatchError extends Error {
|
||||||
public constructor(message: string) {
|
public constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "ServerVersionMismatchError";
|
this.name = "ServerVersionMismatchError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export class SyncResetError extends Error {
|
export class SyncResetError extends Error {
|
||||||
public constructor() {
|
public constructor() {
|
||||||
super("SyncClient has been reset, cleaning up");
|
super("SyncClient has been reset, cleaning up");
|
||||||
this.name = "SyncResetError";
|
this.name = "SyncResetError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type {
|
import type {
|
||||||
DocumentId,
|
DocumentId,
|
||||||
RelativePath,
|
RelativePath,
|
||||||
VaultUpdateId
|
VaultUpdateId
|
||||||
} from "../persistence/database";
|
} from "../persistence/database";
|
||||||
|
|
||||||
import type { Logger } from "../tracing/logger";
|
import type { Logger } from "../tracing/logger";
|
||||||
|
|
@ -19,416 +19,416 @@ import type { DeleteDocumentVersion } from "./types/DeleteDocumentVersion";
|
||||||
import type { UpdateTextDocumentVersion } from "./types/UpdateTextDocumentVersion";
|
import type { UpdateTextDocumentVersion } from "./types/UpdateTextDocumentVersion";
|
||||||
|
|
||||||
export class SyncService {
|
export class SyncService {
|
||||||
private readonly client: typeof globalThis.fetch;
|
private readonly client: typeof globalThis.fetch;
|
||||||
private readonly pingClient: typeof globalThis.fetch;
|
private readonly pingClient: typeof globalThis.fetch;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly deviceId: string,
|
private readonly deviceId: string,
|
||||||
private readonly fetchController: FetchController,
|
private readonly fetchController: FetchController,
|
||||||
private readonly settings: Settings,
|
private readonly settings: Settings,
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
fetchImplementation: typeof globalThis.fetch = globalThis.fetch
|
fetchImplementation: typeof globalThis.fetch = globalThis.fetch
|
||||||
) {
|
) {
|
||||||
// ensure that if it's called a method, `this` won't be bound to the instance
|
// ensure that if it's called a method, `this` won't be bound to the instance
|
||||||
const unboundFetch: typeof globalThis.fetch = async (...args) =>
|
const unboundFetch: typeof globalThis.fetch = async (...args) =>
|
||||||
fetchImplementation(...args);
|
fetchImplementation(...args);
|
||||||
|
|
||||||
this.client = this.fetchController.getControlledFetchImplementation(
|
this.client = this.fetchController.getControlledFetchImplementation(
|
||||||
this.logger,
|
this.logger,
|
||||||
unboundFetch
|
unboundFetch
|
||||||
);
|
);
|
||||||
this.pingClient = unboundFetch;
|
this.pingClient = unboundFetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async errorFromResponse(
|
private static async errorFromResponse(
|
||||||
response: Response
|
response: Response
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (
|
if (
|
||||||
response.headers
|
response.headers
|
||||||
.get("Content-Type")
|
.get("Content-Type")
|
||||||
?.includes("application/json") == true
|
?.includes("application/json") == true
|
||||||
) {
|
) {
|
||||||
const result: SerializedError =
|
const result: SerializedError =
|
||||||
(await response.json()) as SerializedError; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
(await response.json()) as SerializedError; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
return SyncService.formatError(result);
|
return SyncService.formatError(result);
|
||||||
}
|
}
|
||||||
return `HTTP ${response.status}: ${response.statusText}`;
|
return `HTTP ${response.status}: ${response.statusText}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static formatError(error: SerializedError): string {
|
private static formatError(error: SerializedError): string {
|
||||||
let result = error.message;
|
let result = error.message;
|
||||||
if (error.causes.length > 0) {
|
if (error.causes.length > 0) {
|
||||||
const causes = error.causes.join(", ");
|
const causes = error.causes.join(", ");
|
||||||
result += ` caused by: ${causes}`;
|
result += ` caused by: ${causes}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async create({
|
public async create({
|
||||||
documentId,
|
documentId,
|
||||||
relativePath,
|
relativePath,
|
||||||
contentBytes
|
contentBytes
|
||||||
}: {
|
}: {
|
||||||
documentId?: DocumentId;
|
documentId?: DocumentId;
|
||||||
relativePath: RelativePath;
|
relativePath: RelativePath;
|
||||||
contentBytes: Uint8Array;
|
contentBytes: Uint8Array;
|
||||||
}): Promise<DocumentVersionWithoutContent> {
|
}): Promise<DocumentVersionWithoutContent> {
|
||||||
return this.retryForever(async () => {
|
return this.retryForever(async () => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
if (documentId !== undefined) {
|
if (documentId !== undefined) {
|
||||||
formData.append("document_id", documentId);
|
formData.append("document_id", documentId);
|
||||||
}
|
}
|
||||||
formData.append("relative_path", relativePath);
|
formData.append("relative_path", relativePath);
|
||||||
formData.append(
|
formData.append(
|
||||||
"content",
|
"content",
|
||||||
new Blob([new Uint8Array(contentBytes)])
|
new Blob([new Uint8Array(contentBytes)])
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Creating document with id ${documentId} and relative path ${relativePath}`
|
`Creating document with id ${documentId} and relative path ${relativePath}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await this.client(this.getUrl("/documents"), {
|
const response = await this.client(this.getUrl("/documents"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
headers: this.getDefaultHeaders()
|
headers: this.getDefaultHeaders()
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to create document: ${await SyncService.errorFromResponse(
|
`Failed to create document: ${await SyncService.errorFromResponse(
|
||||||
response
|
response
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: DocumentVersionWithoutContent =
|
const result: DocumentVersionWithoutContent =
|
||||||
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
|
||||||
this.logger.debug(`Created document ${JSON.stringify(result)}`);
|
this.logger.debug(`Created document ${JSON.stringify(result)}`);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async putText({
|
public async putText({
|
||||||
parentVersionId,
|
parentVersionId,
|
||||||
documentId,
|
documentId,
|
||||||
relativePath,
|
relativePath,
|
||||||
content
|
content
|
||||||
}: {
|
}: {
|
||||||
parentVersionId: VaultUpdateId;
|
parentVersionId: VaultUpdateId;
|
||||||
documentId: DocumentId;
|
documentId: DocumentId;
|
||||||
relativePath: RelativePath;
|
relativePath: RelativePath;
|
||||||
content: (number | string)[];
|
content: (number | string)[];
|
||||||
}): Promise<DocumentUpdateResponse> {
|
}): Promise<DocumentUpdateResponse> {
|
||||||
return this.retryForever(async () => {
|
return this.retryForever(async () => {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Updating text document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}, content [${content.join(", ")}]`
|
`Updating text document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}, content [${content.join(", ")}]`
|
||||||
);
|
);
|
||||||
|
|
||||||
const request: UpdateTextDocumentVersion = {
|
const request: UpdateTextDocumentVersion = {
|
||||||
parentVersionId,
|
parentVersionId,
|
||||||
relativePath,
|
relativePath,
|
||||||
content
|
content
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await this.client(
|
const response = await this.client(
|
||||||
this.getUrl(`/documents/${documentId}/text`),
|
this.getUrl(`/documents/${documentId}/text`),
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(request),
|
body: JSON.stringify(request),
|
||||||
headers: this.getDefaultHeaders({ type: "json" })
|
headers: this.getDefaultHeaders({ type: "json" })
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to update document: ${await SyncService.errorFromResponse(
|
`Failed to update document: ${await SyncService.errorFromResponse(
|
||||||
response
|
response
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: DocumentUpdateResponse =
|
const result: DocumentUpdateResponse =
|
||||||
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Updated document ${JSON.stringify(result)} with id ${
|
`Updated document ${JSON.stringify(result)} with id ${
|
||||||
result.documentId
|
result.documentId
|
||||||
}}`
|
}}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async putBinary({
|
public async putBinary({
|
||||||
parentVersionId,
|
parentVersionId,
|
||||||
documentId,
|
documentId,
|
||||||
relativePath,
|
relativePath,
|
||||||
contentBytes
|
contentBytes
|
||||||
}: {
|
}: {
|
||||||
parentVersionId: VaultUpdateId;
|
parentVersionId: VaultUpdateId;
|
||||||
documentId: DocumentId;
|
documentId: DocumentId;
|
||||||
relativePath: RelativePath;
|
relativePath: RelativePath;
|
||||||
contentBytes: Uint8Array;
|
contentBytes: Uint8Array;
|
||||||
}): Promise<DocumentUpdateResponse> {
|
}): Promise<DocumentUpdateResponse> {
|
||||||
return this.retryForever(async () => {
|
return this.retryForever(async () => {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Updating binary document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}`
|
`Updating binary document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}`
|
||||||
);
|
);
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("parent_version_id", parentVersionId.toString());
|
formData.append("parent_version_id", parentVersionId.toString());
|
||||||
formData.append("relative_path", relativePath);
|
formData.append("relative_path", relativePath);
|
||||||
formData.append(
|
formData.append(
|
||||||
"content",
|
"content",
|
||||||
new Blob([new Uint8Array(contentBytes)])
|
new Blob([new Uint8Array(contentBytes)])
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await this.client(
|
const response = await this.client(
|
||||||
this.getUrl(`/documents/${documentId}/binary`),
|
this.getUrl(`/documents/${documentId}/binary`),
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: formData,
|
body: formData,
|
||||||
headers: this.getDefaultHeaders()
|
headers: this.getDefaultHeaders()
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to update document: ${await SyncService.errorFromResponse(
|
`Failed to update document: ${await SyncService.errorFromResponse(
|
||||||
response
|
response
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: DocumentUpdateResponse =
|
const result: DocumentUpdateResponse =
|
||||||
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Updated document ${JSON.stringify(result)} with id ${
|
`Updated document ${JSON.stringify(result)} with id ${
|
||||||
result.documentId
|
result.documentId
|
||||||
}}`
|
}}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete({
|
public async delete({
|
||||||
documentId,
|
documentId,
|
||||||
relativePath
|
relativePath
|
||||||
}: {
|
}: {
|
||||||
documentId: DocumentId;
|
documentId: DocumentId;
|
||||||
relativePath: RelativePath;
|
relativePath: RelativePath;
|
||||||
}): Promise<DocumentVersionWithoutContent> {
|
}): Promise<DocumentVersionWithoutContent> {
|
||||||
return this.retryForever(async () => {
|
return this.retryForever(async () => {
|
||||||
const request: DeleteDocumentVersion = {
|
const request: DeleteDocumentVersion = {
|
||||||
relativePath
|
relativePath
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Delete document with id ${documentId} and relative path ${relativePath}`
|
`Delete document with id ${documentId} and relative path ${relativePath}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await this.client(
|
const response = await this.client(
|
||||||
this.getUrl(`/documents/${documentId}`),
|
this.getUrl(`/documents/${documentId}`),
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
body: JSON.stringify(request),
|
body: JSON.stringify(request),
|
||||||
headers: this.getDefaultHeaders({ type: "json" })
|
headers: this.getDefaultHeaders({ type: "json" })
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to delete document: ${await SyncService.errorFromResponse(
|
`Failed to delete document: ${await SyncService.errorFromResponse(
|
||||||
response
|
response
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: DocumentVersionWithoutContent =
|
const result: DocumentVersionWithoutContent =
|
||||||
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Deleted document ${relativePath} with id ${documentId}`
|
`Deleted document ${relativePath} with id ${documentId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get({
|
public async get({
|
||||||
documentId
|
documentId
|
||||||
}: {
|
}: {
|
||||||
documentId: DocumentId;
|
documentId: DocumentId;
|
||||||
}): Promise<DocumentVersion> {
|
}): Promise<DocumentVersion> {
|
||||||
return this.retryForever(async () => {
|
return this.retryForever(async () => {
|
||||||
this.logger.debug(`Getting document with id ${documentId}`);
|
this.logger.debug(`Getting document with id ${documentId}`);
|
||||||
|
|
||||||
const response = await this.client(
|
const response = await this.client(
|
||||||
this.getUrl(`/documents/${documentId}`),
|
this.getUrl(`/documents/${documentId}`),
|
||||||
{
|
{
|
||||||
headers: this.getDefaultHeaders()
|
headers: this.getDefaultHeaders()
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to get document: ${await SyncService.errorFromResponse(
|
`Failed to get document: ${await SyncService.errorFromResponse(
|
||||||
response
|
response
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: DocumentVersion =
|
const result: DocumentVersion =
|
||||||
(await response.json()) as DocumentVersion; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
(await response.json()) as DocumentVersion; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
|
||||||
this.logger.debug(`Got document ${JSON.stringify(result)}`);
|
this.logger.debug(`Got document ${JSON.stringify(result)}`);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getDocumentVersionContent({
|
public async getDocumentVersionContent({
|
||||||
documentId,
|
documentId,
|
||||||
vaultUpdateId
|
vaultUpdateId
|
||||||
}: {
|
}: {
|
||||||
documentId: DocumentId;
|
documentId: DocumentId;
|
||||||
vaultUpdateId: VaultUpdateId;
|
vaultUpdateId: VaultUpdateId;
|
||||||
}): Promise<Uint8Array> {
|
}): Promise<Uint8Array> {
|
||||||
return this.retryForever(async () => {
|
return this.retryForever(async () => {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Getting document with id ${documentId} and version ${vaultUpdateId}`
|
`Getting document with id ${documentId} and version ${vaultUpdateId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await this.client(
|
const response = await this.client(
|
||||||
this.getUrl(
|
this.getUrl(
|
||||||
`/documents/${documentId}/versions/${vaultUpdateId}/content`
|
`/documents/${documentId}/versions/${vaultUpdateId}/content`
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
headers: this.getDefaultHeaders()
|
headers: this.getDefaultHeaders()
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to get document: ${await SyncService.errorFromResponse(
|
`Failed to get document: ${await SyncService.errorFromResponse(
|
||||||
response
|
response
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.bytes();
|
const result = await response.bytes();
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Got document version content for document ${documentId} version ${vaultUpdateId}`
|
`Got document version content for document ${documentId} version ${vaultUpdateId}`
|
||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAll(
|
public async getAll(
|
||||||
since?: VaultUpdateId
|
since?: VaultUpdateId
|
||||||
): Promise<FetchLatestDocumentsResponse> {
|
): Promise<FetchLatestDocumentsResponse> {
|
||||||
return this.retryForever(async () => {
|
return this.retryForever(async () => {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
"Getting all documents" +
|
"Getting all documents" +
|
||||||
(since != null ? ` since ${since}` : "")
|
(since != null ? ` since ${since}` : "")
|
||||||
);
|
);
|
||||||
|
|
||||||
const url = new URL(this.getUrl("/documents"));
|
const url = new URL(this.getUrl("/documents"));
|
||||||
if (since !== undefined) {
|
if (since !== undefined) {
|
||||||
url.searchParams.append("since", since.toString());
|
url.searchParams.append("since", since.toString());
|
||||||
}
|
}
|
||||||
const response = await this.client(url.toString(), {
|
const response = await this.client(url.toString(), {
|
||||||
headers: this.getDefaultHeaders()
|
headers: this.getDefaultHeaders()
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to get documents: ${await SyncService.errorFromResponse(
|
`Failed to get documents: ${await SyncService.errorFromResponse(
|
||||||
response
|
response
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: FetchLatestDocumentsResponse =
|
const result: FetchLatestDocumentsResponse =
|
||||||
(await response.json()) as FetchLatestDocumentsResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
(await response.json()) as FetchLatestDocumentsResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Got ${result.latestDocuments.length} document metadata`
|
`Got ${result.latestDocuments.length} document metadata`
|
||||||
);
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ping(): Promise<PingResponse> {
|
public async ping(): Promise<PingResponse> {
|
||||||
this.logger.debug("Pinging server");
|
this.logger.debug("Pinging server");
|
||||||
const response = await this.pingClient(this.getUrl("/ping"), {
|
const response = await this.pingClient(this.getUrl("/ping"), {
|
||||||
headers: this.getDefaultHeaders()
|
headers: this.getDefaultHeaders()
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to ping server: ${await SyncService.errorFromResponse(
|
`Failed to ping server: ${await SyncService.errorFromResponse(
|
||||||
response
|
response
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: PingResponse = (await response.json()) as PingResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
const result: PingResponse = (await response.json()) as PingResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Pinged server, got response: ${JSON.stringify(result)}`
|
`Pinged server, got response: ${JSON.stringify(result)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getUrl(path: string): string {
|
private getUrl(path: string): string {
|
||||||
const { vaultName, remoteUri } = this.settings.getSettings();
|
const { vaultName, remoteUri } = this.settings.getSettings();
|
||||||
const remoteUriWithoutTrailingSlash = remoteUri.replace(/\/+$/, "");
|
const remoteUriWithoutTrailingSlash = remoteUri.replace(/\/+$/, "");
|
||||||
const encodedVaultName = encodeURIComponent(vaultName.trim());
|
const encodedVaultName = encodeURIComponent(vaultName.trim());
|
||||||
return `${remoteUriWithoutTrailingSlash}/vaults/${encodedVaultName}${path}`;
|
return `${remoteUriWithoutTrailingSlash}/vaults/${encodedVaultName}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDefaultHeaders(
|
private getDefaultHeaders(
|
||||||
{ type }: { type?: "json" } = { type: undefined }
|
{ type }: { type?: "json" } = { type: undefined }
|
||||||
): Record<string, string> {
|
): Record<string, string> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"device-id": this.deviceId,
|
"device-id": this.deviceId,
|
||||||
authorization: `Bearer ${this.settings.getSettings().token}`
|
authorization: `Bearer ${this.settings.getSettings().token}`
|
||||||
};
|
};
|
||||||
|
|
||||||
if (type === "json") {
|
if (type === "json") {
|
||||||
headers["Content-Type"] = "application/json";
|
headers["Content-Type"] = "application/json";
|
||||||
}
|
}
|
||||||
|
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async retryForever<T>(fn: () => Promise<T>): Promise<T> {
|
private async retryForever<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// We must not retry errors coming from reset
|
// We must not retry errors coming from reset
|
||||||
if (e instanceof SyncResetError) {
|
if (e instanceof SyncResetError) {
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
const retryInterval =
|
const retryInterval =
|
||||||
this.settings.getSettings().networkRetryIntervalMs;
|
this.settings.getSettings().networkRetryIntervalMs;
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed network call (${e}), retrying in ${retryInterval}ms`
|
`Failed network call (${e}), retrying in ${retryInterval}ms`
|
||||||
);
|
);
|
||||||
await sleep(retryInterval);
|
await sleep(retryInterval);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
||||||
|
|
||||||
export interface ClientCursors {
|
export interface ClientCursors {
|
||||||
userName: string;
|
userName: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
documentsWithCursors: DocumentWithCursors[];
|
documentsWithCursors: DocumentWithCursors[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface CreateDocumentVersion {
|
export interface CreateDocumentVersion {
|
||||||
/**
|
/**
|
||||||
* The client can decide the document id (if it wishes to) in order
|
* 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,
|
* 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
|
* the server will generate one. If the client provides a document id
|
||||||
* it must not already exist in the database.
|
* it must not already exist in the database.
|
||||||
*/
|
*/
|
||||||
document_id: string | null;
|
document_id: string | null;
|
||||||
relative_path: string;
|
relative_path: string;
|
||||||
content: number[];
|
content: number[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,5 @@
|
||||||
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
||||||
|
|
||||||
export interface CursorPositionFromClient {
|
export interface CursorPositionFromClient {
|
||||||
documentsWithCursors: DocumentWithCursors[];
|
documentsWithCursors: DocumentWithCursors[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,5 @@
|
||||||
import type { ClientCursors } from "./ClientCursors";
|
import type { ClientCursors } from "./ClientCursors";
|
||||||
|
|
||||||
export interface CursorPositionFromServer {
|
export interface CursorPositionFromServer {
|
||||||
clients: ClientCursors[];
|
clients: ClientCursors[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface CursorSpan {
|
export interface CursorSpan {
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface DeleteDocumentVersion {
|
export interface DeleteDocumentVersion {
|
||||||
relativePath: string;
|
relativePath: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,5 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
|
||||||
* Response to an update document request.
|
* Response to an update document request.
|
||||||
*/
|
*/
|
||||||
export type DocumentUpdateResponse =
|
export type DocumentUpdateResponse =
|
||||||
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
|
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
|
||||||
| ({ type: "MergingUpdate" } & DocumentVersion);
|
| ({ type: "MergingUpdate" } & DocumentVersion);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface DocumentVersion {
|
export interface DocumentVersion {
|
||||||
vaultUpdateId: number;
|
vaultUpdateId: number;
|
||||||
documentId: string;
|
documentId: string;
|
||||||
relativePath: string;
|
relativePath: string;
|
||||||
updatedDate: string;
|
updatedDate: string;
|
||||||
contentBase64: string;
|
contentBase64: string;
|
||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
userId: string;
|
userId: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface DocumentVersionWithoutContent {
|
export interface DocumentVersionWithoutContent {
|
||||||
vaultUpdateId: number;
|
vaultUpdateId: number;
|
||||||
documentId: string;
|
documentId: string;
|
||||||
relativePath: string;
|
relativePath: string;
|
||||||
updatedDate: string;
|
updatedDate: string;
|
||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
userId: string;
|
userId: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
contentSize: number;
|
contentSize: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
import type { CursorSpan } from "./CursorSpan";
|
import type { CursorSpan } from "./CursorSpan";
|
||||||
|
|
||||||
export interface DocumentWithCursors {
|
export interface DocumentWithCursors {
|
||||||
vault_update_id: number | null;
|
vault_update_id: number | null;
|
||||||
document_id: string;
|
document_id: string;
|
||||||
relative_path: string;
|
relative_path: string;
|
||||||
cursors: CursorSpan[];
|
cursors: CursorSpan[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
|
||||||
* Response to a fetch latest documents request.
|
* Response to a fetch latest documents request.
|
||||||
*/
|
*/
|
||||||
export interface FetchLatestDocumentsResponse {
|
export interface FetchLatestDocumentsResponse {
|
||||||
latestDocuments: DocumentVersionWithoutContent[];
|
latestDocuments: DocumentVersionWithoutContent[];
|
||||||
/**
|
/**
|
||||||
* The update ID of the latest document in the response.
|
* The update ID of the latest document in the response.
|
||||||
*/
|
*/
|
||||||
lastUpdateId: bigint;
|
lastUpdateId: bigint;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,22 +4,22 @@
|
||||||
* Response to a ping request.
|
* Response to a ping request.
|
||||||
*/
|
*/
|
||||||
export interface PingResponse {
|
export interface PingResponse {
|
||||||
/**
|
/**
|
||||||
* Semantic version of the server.
|
* Semantic version of the server.
|
||||||
*/
|
*/
|
||||||
serverVersion: string;
|
serverVersion: string;
|
||||||
/**
|
/**
|
||||||
* Whether the client is authenticated based on the sent Authorization
|
* Whether the client is authenticated based on the sent Authorization
|
||||||
* header.
|
* header.
|
||||||
*/
|
*/
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
/**
|
/**
|
||||||
* List of file extensions that are allowed to be merged.
|
* List of file extensions that are allowed to be merged.
|
||||||
*/
|
*/
|
||||||
mergeableFileExtensions: string[];
|
mergeableFileExtensions: string[];
|
||||||
/**
|
/**
|
||||||
* API version ensuring backwards & forwards compatibility between the client
|
* API version ensuring backwards & forwards compatibility between the client
|
||||||
* and server.
|
* and server.
|
||||||
*/
|
*/
|
||||||
supportedApiVersion: number;
|
supportedApiVersion: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface SerializedError {
|
export interface SerializedError {
|
||||||
errorType: string;
|
errorType: string;
|
||||||
message: string;
|
message: string;
|
||||||
causes: string[];
|
causes: string[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface UpdateDocumentVersion {
|
export interface UpdateDocumentVersion {
|
||||||
parent_version_id: bigint;
|
parent_version_id: bigint;
|
||||||
relative_path: string;
|
relative_path: string;
|
||||||
content: number[];
|
content: number[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface UpdateTextDocumentVersion {
|
export interface UpdateTextDocumentVersion {
|
||||||
parentVersionId: number;
|
parentVersionId: number;
|
||||||
relativePath: string;
|
relativePath: string;
|
||||||
content: (number | string)[];
|
content: (number | string)[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,5 @@ import type { CursorPositionFromClient } from "./CursorPositionFromClient";
|
||||||
import type { WebSocketHandshake } from "./WebSocketHandshake";
|
import type { WebSocketHandshake } from "./WebSocketHandshake";
|
||||||
|
|
||||||
export type WebSocketClientMessage =
|
export type WebSocketClientMessage =
|
||||||
| ({ type: "handshake" } & WebSocketHandshake)
|
| ({ type: "handshake" } & WebSocketHandshake)
|
||||||
| ({ type: "cursorPositions" } & CursorPositionFromClient);
|
| ({ type: "cursorPositions" } & CursorPositionFromClient);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface WebSocketHandshake {
|
export interface WebSocketHandshake {
|
||||||
token: string;
|
token: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
lastSeenVaultUpdateId: number | null;
|
lastSeenVaultUpdateId: number | null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,5 @@ import type { CursorPositionFromServer } from "./CursorPositionFromServer";
|
||||||
import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate";
|
import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate";
|
||||||
|
|
||||||
export type WebSocketServerMessage =
|
export type WebSocketServerMessage =
|
||||||
| ({ type: "vaultUpdate" } & WebSocketVaultUpdate)
|
| ({ type: "vaultUpdate" } & WebSocketVaultUpdate)
|
||||||
| ({ type: "cursorPositions" } & CursorPositionFromServer);
|
| ({ type: "cursorPositions" } & CursorPositionFromServer);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,6 @@
|
||||||
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
|
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
|
||||||
|
|
||||||
export interface WebSocketVaultUpdate {
|
export interface WebSocketVaultUpdate {
|
||||||
documents: DocumentVersionWithoutContent[];
|
documents: DocumentVersionWithoutContent[];
|
||||||
isInitialSync: boolean;
|
isInitialSync: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,291 +8,291 @@ import type { Settings } from "../persistence/settings";
|
||||||
const WebSocket = require("ws") as typeof globalThis.WebSocket;
|
const WebSocket = require("ws") as typeof globalThis.WebSocket;
|
||||||
|
|
||||||
class MockCloseEvent extends Event {
|
class MockCloseEvent extends Event {
|
||||||
public code: number;
|
public code: number;
|
||||||
public reason: string;
|
public reason: string;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
type: string,
|
type: string,
|
||||||
options: { code: number; reason: string }
|
options: { code: number; reason: string }
|
||||||
) {
|
) {
|
||||||
super(type);
|
super(type);
|
||||||
this.code = options.code;
|
this.code = options.code;
|
||||||
this.reason = options.reason;
|
this.reason = options.reason;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MockMessageEvent extends Event {
|
class MockMessageEvent extends Event {
|
||||||
public data: string;
|
public data: string;
|
||||||
|
|
||||||
public constructor(type: string, options: { data: string }) {
|
public constructor(type: string, options: { data: string }) {
|
||||||
super(type);
|
super(type);
|
||||||
this.data = options.data;
|
this.data = options.data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MockWebSocket {
|
class MockWebSocket {
|
||||||
public readyState: number = WebSocket.CONNECTING;
|
public readyState: number = WebSocket.CONNECTING;
|
||||||
public onopen: ((event: Event) => void) | null = null;
|
public onopen: ((event: Event) => void) | null = null;
|
||||||
public onclose: ((event: MockCloseEvent) => void) | null = null;
|
public onclose: ((event: MockCloseEvent) => void) | null = null;
|
||||||
public onmessage: ((event: MockMessageEvent) => void) | null = null;
|
public onmessage: ((event: MockMessageEvent) => void) | null = null;
|
||||||
public onerror: ((event: Event) => void) | null = null;
|
public onerror: ((event: Event) => void) | null = null;
|
||||||
|
|
||||||
public sentMessages: string[] = [];
|
public sentMessages: string[] = [];
|
||||||
|
|
||||||
public constructor(public url: string) {
|
public constructor(public url: string) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.readyState === WebSocket.CONNECTING) {
|
if (this.readyState === WebSocket.CONNECTING) {
|
||||||
this.readyState = WebSocket.OPEN;
|
this.readyState = WebSocket.OPEN;
|
||||||
this.onopen?.(new Event("open"));
|
this.onopen?.(new Event("open"));
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public send(data: string): void {
|
public send(data: string): void {
|
||||||
if (this.readyState !== WebSocket.OPEN) {
|
if (this.readyState !== WebSocket.OPEN) {
|
||||||
throw new Error("WebSocket is not open");
|
throw new Error("WebSocket is not open");
|
||||||
}
|
}
|
||||||
this.sentMessages.push(data);
|
this.sentMessages.push(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
public close(code?: number, reason?: string): void {
|
public close(code?: number, reason?: string): void {
|
||||||
this.readyState = WebSocket.CLOSED;
|
this.readyState = WebSocket.CLOSED;
|
||||||
this.onclose?.(
|
this.onclose?.(
|
||||||
new MockCloseEvent("close", {
|
new MockCloseEvent("close", {
|
||||||
code: code ?? 1000,
|
code: code ?? 1000,
|
||||||
reason: reason ?? ""
|
reason: reason ?? ""
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public simulateMessage(data: unknown): void {
|
public simulateMessage(data: unknown): void {
|
||||||
this.onmessage?.(
|
this.onmessage?.(
|
||||||
new MockMessageEvent("message", { data: JSON.stringify(data) })
|
new MockMessageEvent("message", { data: JSON.stringify(data) })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type MockFn<T extends (...args: unknown[]) => unknown> = T & {
|
type MockFn<T extends (...args: unknown[]) => unknown> = T & {
|
||||||
calls: Parameters<T>[];
|
calls: Parameters<T>[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function createMockFn<T extends (...args: unknown[]) => unknown>(
|
function createMockFn<T extends (...args: unknown[]) => unknown>(
|
||||||
implementation?: T
|
implementation?: T
|
||||||
): MockFn<T> {
|
): MockFn<T> {
|
||||||
const calls: Parameters<T>[] = [];
|
const calls: Parameters<T>[] = [];
|
||||||
const mockFn = ((...args: Parameters<T>) => {
|
const mockFn = ((...args: Parameters<T>) => {
|
||||||
calls.push(args);
|
calls.push(args);
|
||||||
return implementation?.(...args);
|
return implementation?.(...args);
|
||||||
}) as unknown as MockFn<T>;
|
}) as unknown as MockFn<T>;
|
||||||
mockFn.calls = calls;
|
mockFn.calls = calls;
|
||||||
return mockFn;
|
return mockFn;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("WebSocketManager", () => {
|
describe("WebSocketManager", () => {
|
||||||
let mockLogger: Logger = undefined as unknown as Logger;
|
let mockLogger: Logger = undefined as unknown as Logger;
|
||||||
let mockSettings: Settings = undefined as unknown as Settings;
|
let mockSettings: Settings = undefined as unknown as Settings;
|
||||||
let deviceId = "test-device-123";
|
let deviceId = "test-device-123";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
deviceId = "test-device-123";
|
deviceId = "test-device-123";
|
||||||
const noop = (): void => {
|
const noop = (): void => {
|
||||||
// Intentionally empty for mock
|
// Intentionally empty for mock
|
||||||
};
|
};
|
||||||
mockLogger = {
|
mockLogger = {
|
||||||
info: createMockFn(noop),
|
info: createMockFn(noop),
|
||||||
warn: createMockFn(noop),
|
warn: createMockFn(noop),
|
||||||
error: createMockFn(noop),
|
error: createMockFn(noop),
|
||||||
debug: createMockFn(noop)
|
debug: createMockFn(noop)
|
||||||
} as unknown as Logger;
|
} as unknown as Logger;
|
||||||
|
|
||||||
mockSettings = {
|
mockSettings = {
|
||||||
getSettings: () => ({
|
getSettings: () => ({
|
||||||
remoteUri: "https://example.com",
|
remoteUri: "https://example.com",
|
||||||
vaultName: "test-vault",
|
vaultName: "test-vault",
|
||||||
webSocketRetryIntervalMs: 1000
|
webSocketRetryIntervalMs: 1000
|
||||||
})
|
})
|
||||||
} as unknown as Settings;
|
} as unknown as Settings;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cleans up promises after message handling", async () => {
|
it("cleans up promises after message handling", async () => {
|
||||||
const manager = new WebSocketManager(
|
const manager = new WebSocketManager(
|
||||||
deviceId,
|
deviceId,
|
||||||
mockLogger,
|
mockLogger,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
MockWebSocket as unknown as typeof WebSocket
|
MockWebSocket as unknown as typeof WebSocket
|
||||||
);
|
);
|
||||||
|
|
||||||
manager.addRemoteVaultUpdateListener(async () => {
|
manager.onRemoteVaultUpdateReceived.add(async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
});
|
});
|
||||||
manager.start();
|
manager.start();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
const { outstandingPromises } = manager as unknown as {
|
const { outstandingPromises } = manager as unknown as {
|
||||||
outstandingPromises: Promise<unknown>[];
|
outstandingPromises: Promise<unknown>[];
|
||||||
};
|
};
|
||||||
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||||
.webSocket;
|
.webSocket;
|
||||||
|
|
||||||
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
||||||
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
||||||
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
assert.strictEqual(outstandingPromises.length, 0);
|
assert.strictEqual(outstandingPromises.length, 0);
|
||||||
await manager.stop();
|
await manager.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cleans up cursor position promises", async () => {
|
it("cleans up cursor position promises", async () => {
|
||||||
const manager = new WebSocketManager(
|
const manager = new WebSocketManager(
|
||||||
deviceId,
|
deviceId,
|
||||||
mockLogger,
|
mockLogger,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
MockWebSocket as unknown as typeof WebSocket
|
MockWebSocket as unknown as typeof WebSocket
|
||||||
);
|
);
|
||||||
|
|
||||||
manager.addRemoteCursorsUpdateListener(async () => {
|
manager.onRemoteCursorsUpdateReceived.add(async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
});
|
});
|
||||||
manager.start();
|
manager.start();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
const { outstandingPromises } = manager as unknown as {
|
const { outstandingPromises } = manager as unknown as {
|
||||||
outstandingPromises: Promise<unknown>[];
|
outstandingPromises: Promise<unknown>[];
|
||||||
};
|
};
|
||||||
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||||
.webSocket;
|
.webSocket;
|
||||||
|
|
||||||
mockWs.simulateMessage({
|
mockWs.simulateMessage({
|
||||||
type: "cursorPositions",
|
type: "cursorPositions",
|
||||||
clients: [{ deviceId: "other-device", cursors: [] }]
|
clients: [{ deviceId: "other-device", cursors: [] }]
|
||||||
});
|
});
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
assert.strictEqual(outstandingPromises.length, 0);
|
assert.strictEqual(outstandingPromises.length, 0);
|
||||||
await manager.stop();
|
await manager.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs handshake send errors", async () => {
|
it("logs handshake send errors", async () => {
|
||||||
const manager = new WebSocketManager(
|
const manager = new WebSocketManager(
|
||||||
deviceId,
|
deviceId,
|
||||||
mockLogger,
|
mockLogger,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
MockWebSocket as unknown as typeof WebSocket
|
MockWebSocket as unknown as typeof WebSocket
|
||||||
);
|
);
|
||||||
|
|
||||||
manager.start();
|
manager.start();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||||
.webSocket;
|
.webSocket;
|
||||||
mockWs.send = (): void => {
|
mockWs.send = (): void => {
|
||||||
throw new Error("Buffer full");
|
throw new Error("Buffer full");
|
||||||
};
|
};
|
||||||
|
|
||||||
assert.throws(() => {
|
assert.throws(() => {
|
||||||
manager.sendHandshakeMessage({
|
manager.sendHandshakeMessage({
|
||||||
type: "handshake",
|
type: "handshake",
|
||||||
token: "test",
|
token: "test",
|
||||||
deviceId: "test",
|
deviceId: "test",
|
||||||
lastSeenVaultUpdateId: null
|
lastSeenVaultUpdateId: null
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await manager.stop();
|
await manager.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("completes stop with timeout protection", async () => {
|
it("completes stop with timeout protection", async () => {
|
||||||
const manager = new WebSocketManager(
|
const manager = new WebSocketManager(
|
||||||
deviceId,
|
deviceId,
|
||||||
mockLogger,
|
mockLogger,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
MockWebSocket as unknown as typeof WebSocket
|
MockWebSocket as unknown as typeof WebSocket
|
||||||
);
|
);
|
||||||
|
|
||||||
manager.start();
|
manager.start();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
await manager.stop();
|
await manager.stop();
|
||||||
assert.ok(true);
|
assert.ok(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears old handlers on reconnection", async () => {
|
it("clears old handlers on reconnection", async () => {
|
||||||
const manager = new WebSocketManager(
|
const manager = new WebSocketManager(
|
||||||
deviceId,
|
deviceId,
|
||||||
mockLogger,
|
mockLogger,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
MockWebSocket as unknown as typeof WebSocket
|
MockWebSocket as unknown as typeof WebSocket
|
||||||
);
|
);
|
||||||
|
|
||||||
let statusChangeCount = 0;
|
let statusChangeCount = 0;
|
||||||
manager.addWebSocketStatusChangeListener(() => {
|
manager.onWebSocketStatusChanged.add(() => {
|
||||||
statusChangeCount++;
|
statusChangeCount++;
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.start();
|
manager.start();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
const firstWs = (manager as unknown as { webSocket: MockWebSocket })
|
const firstWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||||
.webSocket;
|
.webSocket;
|
||||||
|
|
||||||
statusChangeCount = 0;
|
statusChangeCount = 0;
|
||||||
|
|
||||||
(
|
(
|
||||||
manager as unknown as { initializeWebSocket: () => void }
|
manager as unknown as { initializeWebSocket: () => void }
|
||||||
).initializeWebSocket();
|
).initializeWebSocket();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
statusChangeCount = 0;
|
statusChangeCount = 0;
|
||||||
|
|
||||||
// Old handler should be cleared
|
// Old handler should be cleared
|
||||||
firstWs.onclose?.(
|
firstWs.onclose?.(
|
||||||
new MockCloseEvent("close", { code: 1000, reason: "test" })
|
new MockCloseEvent("close", { code: 1000, reason: "test" })
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.strictEqual(statusChangeCount, 0);
|
assert.strictEqual(statusChangeCount, 0);
|
||||||
await manager.stop();
|
await manager.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tracks message handling promises", async () => {
|
it("tracks message handling promises", async () => {
|
||||||
const manager = new WebSocketManager(
|
const manager = new WebSocketManager(
|
||||||
deviceId,
|
deviceId,
|
||||||
mockLogger,
|
mockLogger,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
MockWebSocket as unknown as typeof WebSocket
|
MockWebSocket as unknown as typeof WebSocket
|
||||||
);
|
);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/init-declarations
|
// eslint-disable-next-line @typescript-eslint/init-declarations
|
||||||
let resolveListener: () => void;
|
let resolveListener: () => void;
|
||||||
const listenerPromise = new Promise<void>((resolve) => {
|
const listenerPromise = new Promise<void>((resolve) => {
|
||||||
resolveListener = resolve;
|
resolveListener = resolve;
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.addRemoteVaultUpdateListener(async () => {
|
manager.onRemoteVaultUpdateReceived.add(async () => {
|
||||||
await listenerPromise;
|
await listenerPromise;
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.start();
|
manager.start();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||||
.webSocket;
|
.webSocket;
|
||||||
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
const { outstandingPromises } = manager as unknown as {
|
const { outstandingPromises } = manager as unknown as {
|
||||||
outstandingPromises: Promise<unknown>[];
|
outstandingPromises: Promise<unknown>[];
|
||||||
};
|
};
|
||||||
|
|
||||||
assert.ok(outstandingPromises.length > 0);
|
assert.ok(outstandingPromises.length > 0);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
resolveListener!();
|
resolveListener!();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
assert.strictEqual(outstandingPromises.length, 0);
|
assert.strictEqual(outstandingPromises.length, 0);
|
||||||
await manager.stop();
|
await manager.stop();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,296 +6,260 @@ import type { CursorPositionFromClient } from "./types/CursorPositionFromClient"
|
||||||
import type { ClientCursors } from "./types/ClientCursors";
|
import type { ClientCursors } from "./types/ClientCursors";
|
||||||
import { createPromise } from "../utils/create-promise";
|
import { createPromise } from "../utils/create-promise";
|
||||||
import type { WebSocketVaultUpdate } from "./types/WebSocketVaultUpdate";
|
import type { WebSocketVaultUpdate } from "./types/WebSocketVaultUpdate";
|
||||||
import { awaitAll } from "../utils/await-all";
|
|
||||||
import { WEBSOCKET_DISCONNECT_TIMEOUT_IN_S } from "../consts";
|
import { WEBSOCKET_DISCONNECT_TIMEOUT_IN_S } from "../consts";
|
||||||
|
import { removeFromArray } from "../utils/remove-from-array";
|
||||||
|
import { EventListeners } from "../utils/data-structures/event-listeners";
|
||||||
|
import { awaitAll } from "../utils/await-all";
|
||||||
|
|
||||||
export class WebSocketManager {
|
export class WebSocketManager {
|
||||||
private readonly webSocketStatusChangeListeners: ((
|
public readonly onWebSocketStatusChanged = new EventListeners<
|
||||||
isConnected: boolean
|
(isConnected: boolean) => unknown
|
||||||
) => unknown)[] = [];
|
>();
|
||||||
|
|
||||||
private readonly remoteVaultUpdateListeners: ((
|
public readonly onRemoteVaultUpdateReceived = new EventListeners<
|
||||||
update: WebSocketVaultUpdate
|
(update: WebSocketVaultUpdate) => Promise<void>
|
||||||
) => Promise<void>)[] = [];
|
>();
|
||||||
|
|
||||||
private readonly remoteCursorsUpdateListeners: ((
|
public readonly onRemoteCursorsUpdateReceived = new EventListeners<
|
||||||
cursors: ClientCursors[]
|
(cursors: ClientCursors[]) => Promise<void>
|
||||||
) => Promise<void>)[] = [];
|
>();
|
||||||
|
|
||||||
private isStopped = true;
|
private isStopped = true;
|
||||||
private resolveDisconnectingPromise: null | (() => unknown) = null;
|
private resolveDisconnectingPromise: null | (() => unknown) = null;
|
||||||
private reconnectTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
private reconnectTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
private readonly outstandingPromises: Promise<unknown>[] = [];
|
private readonly outstandingPromises: Promise<unknown>[] = [];
|
||||||
|
|
||||||
private webSocket: WebSocket | undefined;
|
private webSocket: WebSocket | undefined;
|
||||||
private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket;
|
private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly deviceId: string,
|
private readonly deviceId: string,
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly settings: Settings,
|
private readonly settings: Settings,
|
||||||
webSocketImplementation?: typeof globalThis.WebSocket
|
webSocketImplementation?: typeof globalThis.WebSocket
|
||||||
) {
|
) {
|
||||||
if (webSocketImplementation) {
|
if (webSocketImplementation) {
|
||||||
this.webSocketFactoryImplementation = webSocketImplementation;
|
this.webSocketFactoryImplementation = webSocketImplementation;
|
||||||
} else {
|
} else {
|
||||||
if (
|
if (
|
||||||
typeof globalThis !== "undefined" &&
|
typeof globalThis !== "undefined" &&
|
||||||
typeof globalThis.WebSocket === "undefined"
|
typeof globalThis.WebSocket === "undefined"
|
||||||
) {
|
) {
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
this.webSocketFactoryImplementation = require("ws"); // polyfill for WebSocket in Node.js
|
this.webSocketFactoryImplementation = require("ws"); // polyfill for WebSocket in Node.js
|
||||||
} else {
|
} else {
|
||||||
this.webSocketFactoryImplementation = WebSocket;
|
this.webSocketFactoryImplementation = WebSocket;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public get isWebSocketConnected(): boolean {
|
public get isWebSocketConnected(): boolean {
|
||||||
return (
|
return (
|
||||||
this.webSocket?.readyState ===
|
this.webSocket?.readyState ===
|
||||||
this.webSocketFactoryImplementation.OPEN
|
this.webSocketFactoryImplementation.OPEN
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public addWebSocketStatusChangeListener(
|
public start(): void {
|
||||||
listener: (isConnected: boolean) => unknown
|
this.isStopped = false;
|
||||||
): void {
|
this.initializeWebSocket();
|
||||||
this.webSocketStatusChangeListeners.push(listener);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public addRemoteCursorsUpdateListener(
|
public async stop(): Promise<void> {
|
||||||
listener: (cursors: ClientCursors[]) => Promise<void>
|
const [promise, resolve] = createPromise();
|
||||||
): void {
|
this.resolveDisconnectingPromise = resolve;
|
||||||
this.remoteCursorsUpdateListeners.push(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
public addRemoteVaultUpdateListener(
|
this.isStopped = true;
|
||||||
listener: (update: WebSocketVaultUpdate) => Promise<void>
|
|
||||||
): void {
|
|
||||||
this.remoteVaultUpdateListeners.push(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
public start(): void {
|
if (this.reconnectTimeoutId !== undefined) {
|
||||||
this.isStopped = false;
|
clearTimeout(this.reconnectTimeoutId);
|
||||||
this.initializeWebSocket();
|
this.reconnectTimeoutId = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async stop(): Promise<void> {
|
this.webSocket?.close(1000, "WebSocketManager has been stopped");
|
||||||
const [promise, resolve] = createPromise();
|
|
||||||
this.resolveDisconnectingPromise = resolve;
|
|
||||||
|
|
||||||
this.isStopped = true;
|
// eslint-disable-next-line @typescript-eslint/init-declarations
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const timeoutPromise = new Promise<void>((_, reject) => {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Timeout waiting for WebSocket to close after ${WEBSOCKET_DISCONNECT_TIMEOUT_IN_S} seconds`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, WEBSOCKET_DISCONNECT_TIMEOUT_IN_S * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
if (this.reconnectTimeoutId !== undefined) {
|
try {
|
||||||
clearTimeout(this.reconnectTimeoutId);
|
while (this.isWebSocketConnected) {
|
||||||
this.reconnectTimeoutId = undefined;
|
await Promise.race([promise, timeoutPromise]);
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Error while waiting for WebSocket to close: ${String(error)}`
|
||||||
|
);
|
||||||
|
// Force cleanup even if close didn't work
|
||||||
|
this.resolveDisconnectingPromise();
|
||||||
|
this.resolveDisconnectingPromise = null;
|
||||||
|
} finally {
|
||||||
|
// Clear timeout to prevent unhandled rejection
|
||||||
|
if (timeoutId !== undefined) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.webSocket?.close(1000, "WebSocketManager has been stopped");
|
await this.waitUntilFinished();
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/init-declarations
|
public async waitUntilFinished(): Promise<void> {
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
await awaitAll(this.outstandingPromises);
|
||||||
const timeoutPromise = new Promise<void>((_, reject) => {
|
}
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
reject(
|
|
||||||
new Error(
|
|
||||||
`Timeout waiting for WebSocket to close after ${WEBSOCKET_DISCONNECT_TIMEOUT_IN_S} seconds`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, WEBSOCKET_DISCONNECT_TIMEOUT_IN_S * 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
public sendHandshakeMessage(
|
||||||
while (this.isWebSocketConnected) {
|
message: WebSocketClientMessage & { type: "handshake" }
|
||||||
await Promise.race([promise, timeoutPromise]);
|
): void {
|
||||||
}
|
const { webSocket } = this;
|
||||||
} catch (error) {
|
if (!webSocket) {
|
||||||
this.logger.error(
|
throw new Error(
|
||||||
`Error while waiting for WebSocket to close: ${String(error)}`
|
"WebSocket is not connected, cannot send handshake message"
|
||||||
);
|
);
|
||||||
// Force cleanup even if close didn't work
|
}
|
||||||
this.resolveDisconnectingPromise();
|
|
||||||
this.resolveDisconnectingPromise = null;
|
|
||||||
} finally {
|
|
||||||
// Clear timeout to prevent unhandled rejection
|
|
||||||
if (timeoutId !== undefined) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.waitUntilFinished();
|
try {
|
||||||
}
|
webSocket.send(JSON.stringify(message));
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to send handshake message: ${String(error)}`
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async waitUntilFinished(): Promise<void> {
|
public updateLocalCursors(cursorPositions: CursorPositionFromClient): void {
|
||||||
await awaitAll(this.outstandingPromises);
|
if (!this.isWebSocketConnected || !this.webSocket) {
|
||||||
}
|
// A missing cursor update is fine, we can just skip it if needed
|
||||||
|
this.logger.warn(
|
||||||
|
"WebSocket is not connected, cannot send cursor positions"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
public sendHandshakeMessage(
|
const message: WebSocketClientMessage = {
|
||||||
message: WebSocketClientMessage & { type: "handshake" }
|
type: "cursorPositions",
|
||||||
): void {
|
...cursorPositions
|
||||||
const { webSocket } = this;
|
};
|
||||||
if (!webSocket) {
|
|
||||||
throw new Error(
|
|
||||||
"WebSocket is not connected, cannot send handshake message"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
webSocket.send(JSON.stringify(message));
|
this.webSocket.send(JSON.stringify(message));
|
||||||
} catch (error) {
|
this.logger.debug(
|
||||||
this.logger.error(
|
`Sent cursor positions: ${JSON.stringify(cursorPositions)}`
|
||||||
`Failed to send handshake message: ${String(error)}`
|
);
|
||||||
);
|
} catch (error) {
|
||||||
throw error;
|
this.logger.warn(
|
||||||
}
|
`Failed to send cursor positions: ${String(error)}`
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public updateLocalCursors(cursorPositions: CursorPositionFromClient): void {
|
private initializeWebSocket(): void {
|
||||||
if (!this.isWebSocketConnected || !this.webSocket) {
|
// Clean up old WebSocket handlers to prevent race conditions
|
||||||
// A missing cursor update is fine, we can just skip it if needed
|
if (this.webSocket) {
|
||||||
this.logger.warn(
|
try {
|
||||||
"WebSocket is not connected, cannot send cursor positions"
|
// Remove handlers to prevent them from firing after new connection
|
||||||
);
|
this.webSocket.onopen = null;
|
||||||
return;
|
this.webSocket.onclose = null;
|
||||||
}
|
this.webSocket.onmessage = null;
|
||||||
|
this.webSocket.onerror = null;
|
||||||
|
this.webSocket.close();
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to close previous WebSocket connection: ${e}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const message: WebSocketClientMessage = {
|
const wsUri = new URL(this.settings.getSettings().remoteUri);
|
||||||
type: "cursorPositions",
|
wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws";
|
||||||
...cursorPositions
|
wsUri.pathname = `/vaults/${this.settings.getSettings().vaultName}/ws`;
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
this.logger.info(`Connecting to WebSocket at ${wsUri.toString()}`);
|
||||||
this.webSocket.send(JSON.stringify(message));
|
|
||||||
this.logger.debug(
|
|
||||||
`Sent cursor positions: ${JSON.stringify(cursorPositions)}`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Failed to send cursor positions: ${String(error)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private initializeWebSocket(): void {
|
this.webSocket = new this.webSocketFactoryImplementation(wsUri);
|
||||||
// Clean up old WebSocket handlers to prevent race conditions
|
|
||||||
if (this.webSocket) {
|
|
||||||
try {
|
|
||||||
// Remove handlers to prevent them from firing after new connection
|
|
||||||
this.webSocket.onopen = null;
|
|
||||||
this.webSocket.onclose = null;
|
|
||||||
this.webSocket.onmessage = null;
|
|
||||||
this.webSocket.onerror = null;
|
|
||||||
this.webSocket.close();
|
|
||||||
} catch (e) {
|
|
||||||
this.logger.error(
|
|
||||||
`Failed to close previous WebSocket connection: ${e}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const wsUri = new URL(this.settings.getSettings().remoteUri);
|
this.webSocket.onopen = (): void => {
|
||||||
wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws";
|
this.logger.info("WebSocket connection opened");
|
||||||
wsUri.pathname = `/vaults/${this.settings.getSettings().vaultName}/ws`;
|
this.onWebSocketStatusChanged.trigger(true);
|
||||||
|
};
|
||||||
|
|
||||||
this.logger.info(`Connecting to WebSocket at ${wsUri.toString()}`);
|
this.webSocket.onmessage = (event): void => {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
const message = JSON.parse(
|
||||||
|
event.data
|
||||||
|
) as WebSocketServerMessage;
|
||||||
|
|
||||||
this.webSocket = new this.webSocketFactoryImplementation(wsUri);
|
// Track the message handling promise
|
||||||
|
const messageHandlingPromise = this.handleWebSocketMessage(
|
||||||
|
message
|
||||||
|
)
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
this.logger.error(
|
||||||
|
`Error handling WebSocket message: ${String(error)}`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
removeFromArray(
|
||||||
|
this.outstandingPromises,
|
||||||
|
messageHandlingPromise
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
this.webSocket.onopen = (): void => {
|
void this.outstandingPromises.push(messageHandlingPromise); // ignore the returned promise
|
||||||
this.logger.info("WebSocket connection opened");
|
} catch (error) {
|
||||||
this.webSocketStatusChangeListeners.forEach((listener) =>
|
this.logger.error(
|
||||||
listener(true)
|
`Error parsing WebSocket message: ${String(error)}`
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
this.webSocket.onmessage = (event): void => {
|
this.webSocket.onclose = (event): void => {
|
||||||
try {
|
this.logger.warn(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
`WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})`
|
||||||
const message = JSON.parse(
|
);
|
||||||
event.data
|
this.onWebSocketStatusChanged.trigger(false);
|
||||||
) as WebSocketServerMessage;
|
|
||||||
|
|
||||||
// Track the message handling promise
|
if (this.isStopped) {
|
||||||
const messageHandlingPromise = this.handleWebSocketMessage(
|
this.resolveDisconnectingPromise?.();
|
||||||
message
|
this.resolveDisconnectingPromise = null;
|
||||||
)
|
} else {
|
||||||
.catch((error: unknown) => {
|
this.reconnectTimeoutId = setTimeout(() => {
|
||||||
this.logger.error(
|
this.reconnectTimeoutId = undefined;
|
||||||
`Error handling WebSocket message: ${String(error)}`
|
this.initializeWebSocket();
|
||||||
);
|
}, this.settings.getSettings().webSocketRetryIntervalMs);
|
||||||
})
|
}
|
||||||
.finally(() => {
|
};
|
||||||
const index = this.outstandingPromises.indexOf(
|
}
|
||||||
messageHandlingPromise
|
|
||||||
);
|
|
||||||
if (index !== -1) {
|
|
||||||
void this.outstandingPromises.splice(index, 1); // ignore the returned promise
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
void this.outstandingPromises.push(messageHandlingPromise); // ignore the returned promise
|
private async handleWebSocketMessage(
|
||||||
} catch (error) {
|
message: WebSocketServerMessage
|
||||||
this.logger.error(
|
): Promise<void> {
|
||||||
`Error parsing WebSocket message: ${String(error)}`
|
if (message.type === "vaultUpdate") {
|
||||||
);
|
await this.onRemoteVaultUpdateReceived.triggerAsync(message);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.webSocket.onclose = (event): void => {
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
this.logger.warn(
|
} else if (message.type === "cursorPositions") {
|
||||||
`WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})`
|
this.logger.debug(
|
||||||
);
|
`Received cursor positions for ${JSON.stringify(message.clients)}`
|
||||||
this.webSocketStatusChangeListeners.forEach((listener) =>
|
);
|
||||||
listener(false)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (this.isStopped) {
|
await this.onRemoteCursorsUpdateReceived.triggerAsync(
|
||||||
this.resolveDisconnectingPromise?.();
|
message.clients
|
||||||
this.resolveDisconnectingPromise = null;
|
);
|
||||||
} else {
|
} else {
|
||||||
this.reconnectTimeoutId = setTimeout(() => {
|
this.logger.warn(
|
||||||
this.reconnectTimeoutId = undefined;
|
`Received unknown message type: ${JSON.stringify(message)}`
|
||||||
this.initializeWebSocket();
|
);
|
||||||
}, this.settings.getSettings().webSocketRetryIntervalMs);
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleWebSocketMessage(
|
|
||||||
message: WebSocketServerMessage
|
|
||||||
): Promise<void> {
|
|
||||||
if (message.type === "vaultUpdate") {
|
|
||||||
await awaitAll(
|
|
||||||
this.remoteVaultUpdateListeners.map(async (listener) => {
|
|
||||||
await listener(message).catch((error: unknown) => {
|
|
||||||
this.logger.error(
|
|
||||||
`Error in vault update listener: ${String(error)}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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)}`
|
|
||||||
);
|
|
||||||
|
|
||||||
await awaitAll(
|
|
||||||
this.remoteCursorsUpdateListeners.map(async (listener) => {
|
|
||||||
await listener(message.clients).catch((error: unknown) => {
|
|
||||||
this.logger.error(
|
|
||||||
`Error in cursor positions listener: ${String(error)}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.logger.warn(
|
|
||||||
`Received unknown message type: ${JSON.stringify(message)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,495 +26,506 @@ import { FixedSizeDocumentCache } from "./utils/data-structures/fix-sized-cache"
|
||||||
import { setUpTelemetry } from "./utils/set-up-telemetry";
|
import { setUpTelemetry } from "./utils/set-up-telemetry";
|
||||||
import { DIFF_CACHE_SIZE_MB } from "./consts";
|
import { DIFF_CACHE_SIZE_MB } from "./consts";
|
||||||
import { ServerConfig } from "./services/server-config";
|
import { ServerConfig } from "./services/server-config";
|
||||||
|
import type { EventListeners } from "./utils/data-structures/event-listeners";
|
||||||
|
|
||||||
export class SyncClient {
|
export class SyncClient {
|
||||||
private hasStartedOfflineSync = false;
|
private hasStartedOfflineSync = false;
|
||||||
private hasFinishedOfflineSync = false;
|
private hasFinishedOfflineSync = false;
|
||||||
private hasStarted = false;
|
private hasStarted = false;
|
||||||
private hasBeenDestroyed = false;
|
private hasBeenDestroyed = false;
|
||||||
private unloadTelemetry?: () => void;
|
private unloadTelemetry?: () => void;
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
private readonly history: SyncHistory,
|
private readonly history: SyncHistory,
|
||||||
private readonly settings: Settings,
|
private readonly settings: Settings,
|
||||||
private readonly database: Database,
|
private readonly database: Database,
|
||||||
private readonly syncer: Syncer,
|
private readonly syncer: Syncer,
|
||||||
private readonly webSocketManager: WebSocketManager,
|
private readonly webSocketManager: WebSocketManager,
|
||||||
public readonly logger: Logger,
|
public readonly logger: Logger,
|
||||||
private readonly fetchController: FetchController,
|
private readonly fetchController: FetchController,
|
||||||
private readonly cursorTracker: CursorTracker,
|
private readonly cursorTracker: CursorTracker,
|
||||||
private readonly fileChangeNotifier: FileChangeNotifier,
|
private readonly fileChangeNotifier: FileChangeNotifier,
|
||||||
private readonly contentCache: FixedSizeDocumentCache,
|
private readonly contentCache: FixedSizeDocumentCache,
|
||||||
private readonly fileOperations: FileOperations,
|
private readonly fileOperations: FileOperations,
|
||||||
private readonly serverConfig: ServerConfig,
|
private readonly serverConfig: ServerConfig,
|
||||||
private readonly persistence: PersistenceProvider<
|
private readonly persistence: PersistenceProvider<
|
||||||
Partial<{
|
Partial<{
|
||||||
settings: Partial<SyncSettings>;
|
settings: Partial<SyncSettings>;
|
||||||
database: Partial<StoredDatabase>;
|
database: Partial<StoredDatabase>;
|
||||||
}>
|
}>
|
||||||
>
|
>
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public get documentCount(): number {
|
public get documentCount(): number {
|
||||||
return this.database.length;
|
return this.database.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get isWebSocketConnected(): boolean {
|
public get isWebSocketConnected(): boolean {
|
||||||
return this.webSocketManager.isWebSocketConnected;
|
return this.webSocketManager.isWebSocketConnected;
|
||||||
}
|
}
|
||||||
public static async create({
|
|
||||||
fs,
|
public get onSyncHistoryUpdated(): EventListeners<
|
||||||
persistence,
|
(stats: HistoryStats) => unknown
|
||||||
fetch,
|
> {
|
||||||
webSocket,
|
this.checkIfDestroyed("onSyncHistoryUpdated getter");
|
||||||
nativeLineEndings = "\n"
|
return this.history.onHistoryUpdated;
|
||||||
}: {
|
}
|
||||||
fs: FileSystemOperations;
|
|
||||||
persistence: PersistenceProvider<
|
public get onSettingsChanged(): EventListeners<
|
||||||
Partial<{
|
(newSettings: SyncSettings, oldSettings: SyncSettings) => unknown
|
||||||
settings: Partial<SyncSettings>;
|
> {
|
||||||
database: Partial<StoredDatabase>;
|
this.checkIfDestroyed("onSettingsChanged getter");
|
||||||
}>
|
return this.settings.onSettingsChanged;
|
||||||
>;
|
}
|
||||||
fetch?: typeof globalThis.fetch;
|
|
||||||
webSocket?: typeof globalThis.WebSocket;
|
public get onRemainingOperationsCountChanged(): EventListeners<
|
||||||
nativeLineEndings?: string;
|
(remainingOperationsCount: number) => unknown
|
||||||
}): Promise<SyncClient> {
|
> {
|
||||||
const logger = new Logger();
|
this.checkIfDestroyed("onRemainingOperationsCountChanged getter");
|
||||||
|
return this.syncer.onRemainingOperationsCountChanged;
|
||||||
const deviceId = createClientId();
|
}
|
||||||
|
|
||||||
logger.info(`Creating SyncClient with client id ${deviceId}`);
|
public get onWebSocketStatusChanged(): EventListeners<
|
||||||
|
(isConnected: boolean) => unknown
|
||||||
const history = new SyncHistory(logger);
|
> {
|
||||||
|
this.checkIfDestroyed("onWebSocketStatusChanged getter");
|
||||||
let state = (await persistence.load()) ?? {
|
return this.webSocketManager.onWebSocketStatusChanged;
|
||||||
settings: undefined,
|
}
|
||||||
database: undefined
|
|
||||||
};
|
public get onRemoteCursorsUpdated(): EventListeners<
|
||||||
|
(cursors: MaybeOutdatedClientCursors[]) => unknown
|
||||||
const settings = new Settings(
|
> {
|
||||||
logger,
|
this.checkIfDestroyed("onRemoteCursorsUpdated getter");
|
||||||
state.settings,
|
return this.cursorTracker.onRemoteCursorsUpdated;
|
||||||
async (data): Promise<void> => {
|
}
|
||||||
state = { ...state, settings: data };
|
|
||||||
// we're not rate-limiting settings saves as (1) we need to initialise the settings to know the rate limit
|
public static async create({
|
||||||
// and (2) settings changes are infrequent enough that rate-limiting is not necessary
|
fs,
|
||||||
await persistence.save(state);
|
persistence,
|
||||||
}
|
fetch,
|
||||||
);
|
webSocket,
|
||||||
|
nativeLineEndings = "\n"
|
||||||
const rateLimitedSave = rateLimit(
|
}: {
|
||||||
persistence.save,
|
fs: FileSystemOperations;
|
||||||
() => settings.getSettings().minimumSaveIntervalMs
|
persistence: PersistenceProvider<
|
||||||
);
|
Partial<{
|
||||||
|
settings: Partial<SyncSettings>;
|
||||||
const database = new Database(
|
database: Partial<StoredDatabase>;
|
||||||
logger,
|
}>
|
||||||
state.database,
|
>;
|
||||||
async (data): Promise<void> => {
|
fetch?: typeof globalThis.fetch;
|
||||||
state = { ...state, database: data };
|
webSocket?: typeof globalThis.WebSocket;
|
||||||
await rateLimitedSave(state);
|
nativeLineEndings?: string;
|
||||||
}
|
}): Promise<SyncClient> {
|
||||||
);
|
const logger = new Logger();
|
||||||
|
|
||||||
const fetchController = new FetchController(
|
const deviceId = createClientId();
|
||||||
settings.getSettings().isSyncEnabled,
|
|
||||||
logger
|
logger.info(`Creating SyncClient with client id ${deviceId}`);
|
||||||
);
|
|
||||||
settings.addOnSettingsChangeListener((newSettings, oldSettings) => {
|
const history = new SyncHistory(logger);
|
||||||
if (oldSettings.isSyncEnabled != newSettings.isSyncEnabled) {
|
|
||||||
fetchController.canFetch = newSettings.isSyncEnabled;
|
let state = (await persistence.load()) ?? {
|
||||||
}
|
settings: undefined,
|
||||||
});
|
database: undefined
|
||||||
|
};
|
||||||
const syncService = new SyncService(
|
|
||||||
deviceId,
|
const settings = new Settings(
|
||||||
fetchController,
|
logger,
|
||||||
settings,
|
state.settings,
|
||||||
logger,
|
async (data): Promise<void> => {
|
||||||
fetch
|
state = { ...state, settings: data };
|
||||||
);
|
// we're not rate-limiting settings saves as (1) we need to initialise the settings to know the rate limit
|
||||||
|
// and (2) settings changes are infrequent enough that rate-limiting is not necessary
|
||||||
const serverConfig = new ServerConfig(syncService);
|
await persistence.save(state);
|
||||||
|
}
|
||||||
const fileOperations = new FileOperations(
|
);
|
||||||
logger,
|
|
||||||
database,
|
const rateLimitedSave = rateLimit(
|
||||||
fs,
|
persistence.save,
|
||||||
serverConfig,
|
() => settings.getSettings().minimumSaveIntervalMs
|
||||||
nativeLineEndings
|
);
|
||||||
);
|
|
||||||
|
const database = new Database(
|
||||||
const contentCache = new FixedSizeDocumentCache(
|
logger,
|
||||||
1024 * 1024 * DIFF_CACHE_SIZE_MB
|
state.database,
|
||||||
);
|
async (data): Promise<void> => {
|
||||||
const unrestrictedSyncer = new UnrestrictedSyncer(
|
state = { ...state, database: data };
|
||||||
logger,
|
await rateLimitedSave(state);
|
||||||
database,
|
}
|
||||||
settings,
|
);
|
||||||
syncService,
|
|
||||||
fileOperations,
|
const fetchController = new FetchController(
|
||||||
history,
|
settings.getSettings().isSyncEnabled,
|
||||||
contentCache,
|
logger
|
||||||
serverConfig
|
);
|
||||||
);
|
settings.onSettingsChanged.add((newSettings, oldSettings) => {
|
||||||
|
if (oldSettings.isSyncEnabled != newSettings.isSyncEnabled) {
|
||||||
const webSocketManager = new WebSocketManager(
|
fetchController.canFetch = newSettings.isSyncEnabled;
|
||||||
deviceId,
|
}
|
||||||
logger,
|
});
|
||||||
settings,
|
|
||||||
webSocket
|
const syncService = new SyncService(
|
||||||
);
|
deviceId,
|
||||||
|
fetchController,
|
||||||
const syncer = new Syncer(
|
settings,
|
||||||
deviceId,
|
logger,
|
||||||
logger,
|
fetch
|
||||||
database,
|
);
|
||||||
settings,
|
|
||||||
syncService,
|
const serverConfig = new ServerConfig(syncService);
|
||||||
webSocketManager,
|
|
||||||
fileOperations,
|
const fileOperations = new FileOperations(
|
||||||
unrestrictedSyncer
|
logger,
|
||||||
);
|
database,
|
||||||
|
fs,
|
||||||
const fileChangeNotifier = new FileChangeNotifier();
|
serverConfig,
|
||||||
const cursorTracker = new CursorTracker(
|
nativeLineEndings
|
||||||
database,
|
);
|
||||||
webSocketManager,
|
|
||||||
fileOperations,
|
const contentCache = new FixedSizeDocumentCache(
|
||||||
fileChangeNotifier
|
1024 * 1024 * DIFF_CACHE_SIZE_MB
|
||||||
);
|
);
|
||||||
const client = new SyncClient(
|
const unrestrictedSyncer = new UnrestrictedSyncer(
|
||||||
history,
|
logger,
|
||||||
settings,
|
database,
|
||||||
database,
|
settings,
|
||||||
syncer,
|
syncService,
|
||||||
webSocketManager,
|
fileOperations,
|
||||||
logger,
|
history,
|
||||||
fetchController,
|
contentCache,
|
||||||
cursorTracker,
|
serverConfig
|
||||||
fileChangeNotifier,
|
);
|
||||||
contentCache,
|
|
||||||
fileOperations,
|
const webSocketManager = new WebSocketManager(
|
||||||
serverConfig,
|
deviceId,
|
||||||
persistence
|
logger,
|
||||||
);
|
settings,
|
||||||
|
webSocket
|
||||||
logger.info("SyncClient created successfully");
|
);
|
||||||
|
|
||||||
return client;
|
const syncer = new Syncer(
|
||||||
}
|
deviceId,
|
||||||
|
logger,
|
||||||
public async start(): Promise<void> {
|
database,
|
||||||
this.checkIfDestroyed("start");
|
settings,
|
||||||
|
syncService,
|
||||||
if (this.hasStarted) {
|
webSocketManager,
|
||||||
throw new Error("SyncClient has already been started");
|
fileOperations,
|
||||||
}
|
unrestrictedSyncer
|
||||||
this.hasStarted = true;
|
);
|
||||||
|
|
||||||
if (
|
const fileChangeNotifier = new FileChangeNotifier();
|
||||||
!this.unloadTelemetry &&
|
const cursorTracker = new CursorTracker(
|
||||||
this.settings.getSettings().enableTelemetry
|
database,
|
||||||
) {
|
webSocketManager,
|
||||||
this.unloadTelemetry = setUpTelemetry();
|
fileOperations,
|
||||||
}
|
fileChangeNotifier
|
||||||
|
);
|
||||||
this.logger.addOnMessageListener((log): void => {
|
const client = new SyncClient(
|
||||||
if (log.level === LogLevel.ERROR && Sentry.isInitialized()) {
|
history,
|
||||||
Sentry.captureMessage(log.message);
|
settings,
|
||||||
}
|
database,
|
||||||
});
|
syncer,
|
||||||
|
webSocketManager,
|
||||||
this.settings.addOnSettingsChangeListener(
|
logger,
|
||||||
this.onSettingsChange.bind(this)
|
fetchController,
|
||||||
);
|
cursorTracker,
|
||||||
|
fileChangeNotifier,
|
||||||
if (this.settings.getSettings().isSyncEnabled) {
|
contentCache,
|
||||||
this.logger.info("Starting SyncClient");
|
fileOperations,
|
||||||
await this.startSyncing();
|
serverConfig,
|
||||||
this.logger.info("SyncClient has successfully started");
|
persistence
|
||||||
}
|
);
|
||||||
}
|
|
||||||
|
logger.info("SyncClient created successfully");
|
||||||
/**
|
|
||||||
* Reload settings from disk overriding current in-memory settings.
|
return client;
|
||||||
* Missing values will be filled in from DEFAULT_SETTINGS rather than
|
}
|
||||||
* retaining current in-memory settings.
|
|
||||||
*/
|
public async start(): Promise<void> {
|
||||||
public async reloadSettings(): Promise<void> {
|
this.checkIfDestroyed("start");
|
||||||
this.checkIfDestroyed("reloadSettings");
|
|
||||||
|
if (this.hasStarted) {
|
||||||
const state = (await this.persistence.load()) ?? {
|
throw new Error("SyncClient has already been started");
|
||||||
settings: undefined
|
}
|
||||||
};
|
this.hasStarted = true;
|
||||||
|
|
||||||
const settings = {
|
if (
|
||||||
...DEFAULT_SETTINGS,
|
!this.unloadTelemetry &&
|
||||||
...(state.settings ?? {})
|
this.settings.getSettings().enableTelemetry
|
||||||
};
|
) {
|
||||||
|
this.unloadTelemetry = setUpTelemetry();
|
||||||
await this.setSettings(settings);
|
}
|
||||||
}
|
|
||||||
|
this.logger.onLogEmitted.add((log): void => {
|
||||||
public async checkConnection(): Promise<NetworkConnectionStatus> {
|
if (log.level === LogLevel.ERROR && Sentry.isInitialized()) {
|
||||||
this.checkIfDestroyed("checkConnection");
|
Sentry.captureMessage(log.message);
|
||||||
|
}
|
||||||
const server = await this.serverConfig.checkConnection(true);
|
});
|
||||||
return {
|
|
||||||
isSuccessful: server.isSuccessful,
|
this.settings.onSettingsChanged.add(this.onSettingsChange.bind(this));
|
||||||
serverMessage: server.message,
|
|
||||||
isWebSocketConnected: this.webSocketManager.isWebSocketConnected
|
if (this.settings.getSettings().isSyncEnabled) {
|
||||||
};
|
this.logger.info("Starting SyncClient");
|
||||||
}
|
await this.startSyncing();
|
||||||
|
this.logger.info("SyncClient has successfully started");
|
||||||
public getHistoryEntries(): readonly HistoryEntry[] {
|
}
|
||||||
return this.history.entries;
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
public addSyncHistoryUpdateListener(
|
* Reload settings from disk overriding current in-memory settings.
|
||||||
listener: (stats: HistoryStats) => unknown
|
* Missing values will be filled in from DEFAULT_SETTINGS rather than
|
||||||
): void {
|
* retaining current in-memory settings.
|
||||||
this.checkIfDestroyed("addSyncHistoryUpdateListener");
|
*/
|
||||||
|
public async reloadSettings(): Promise<void> {
|
||||||
this.history.addSyncHistoryUpdateListener(listener);
|
this.checkIfDestroyed("reloadSettings");
|
||||||
}
|
|
||||||
|
const state = (await this.persistence.load()) ?? {
|
||||||
/**
|
settings: undefined
|
||||||
* 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.
|
const settings = {
|
||||||
*/
|
...DEFAULT_SETTINGS,
|
||||||
public async reset(): Promise<void> {
|
...(state.settings ?? {})
|
||||||
this.checkIfDestroyed("reset");
|
};
|
||||||
|
|
||||||
this.logger.info(
|
await this.setSettings(settings);
|
||||||
"Stopping SyncClient to apply changed connection settings"
|
}
|
||||||
);
|
|
||||||
await this.pause();
|
public async checkConnection(): Promise<NetworkConnectionStatus> {
|
||||||
|
this.checkIfDestroyed("checkConnection");
|
||||||
// clear all local state
|
|
||||||
this.logger.info("Resetting SyncClient's local state");
|
const server = await this.serverConfig.checkConnection(true);
|
||||||
this.database.reset();
|
return {
|
||||||
await this.database.save(); // ensure the new database reads as empty
|
isSuccessful: server.isSuccessful,
|
||||||
this.resetInMemoryState();
|
serverMessage: server.message,
|
||||||
this.hasStartedOfflineSync = false;
|
isWebSocketConnected: this.webSocketManager.isWebSocketConnected
|
||||||
this.hasFinishedOfflineSync = false;
|
};
|
||||||
this.serverConfig.reset();
|
}
|
||||||
|
|
||||||
await this.startSyncing();
|
public getHistoryEntries(): readonly HistoryEntry[] {
|
||||||
}
|
return this.history.entries;
|
||||||
|
}
|
||||||
public getSettings(): SyncSettings {
|
|
||||||
return this.settings.getSettings();
|
/**
|
||||||
}
|
* Wait for the in-flight operations to finish, reset all tracking,
|
||||||
|
* and the local database but retain the settings.
|
||||||
public async setSetting<T extends keyof SyncSettings>(
|
* The SyncClient can be used again after calling this method.
|
||||||
key: T,
|
*/
|
||||||
value: SyncSettings[T]
|
public async reset(): Promise<void> {
|
||||||
): Promise<void> {
|
this.checkIfDestroyed("reset");
|
||||||
this.checkIfDestroyed("setSetting");
|
|
||||||
|
this.logger.info(
|
||||||
await this.settings.setSetting(key, value);
|
"Stopping SyncClient to apply changed connection settings"
|
||||||
}
|
);
|
||||||
|
await this.pause();
|
||||||
public async setSettings(value: Partial<SyncSettings>): Promise<void> {
|
|
||||||
this.checkIfDestroyed("setSettings");
|
// clear all local state
|
||||||
|
this.logger.info("Resetting SyncClient's local state");
|
||||||
await this.settings.setSettings(value);
|
this.database.reset();
|
||||||
}
|
await this.database.save(); // ensure the new database reads as empty
|
||||||
|
this.resetInMemoryState();
|
||||||
public addOnSettingsChangeListener(
|
this.hasStartedOfflineSync = false;
|
||||||
listener: (settings: SyncSettings, oldSettings: SyncSettings) => unknown
|
this.hasFinishedOfflineSync = false;
|
||||||
): void {
|
this.serverConfig.reset();
|
||||||
this.checkIfDestroyed("addOnSettingsChangeListener");
|
|
||||||
|
await this.startSyncing();
|
||||||
this.settings.addOnSettingsChangeListener(listener);
|
}
|
||||||
}
|
|
||||||
|
public getSettings(): SyncSettings {
|
||||||
public addRemainingSyncOperationsListener(
|
return this.settings.getSettings();
|
||||||
listener: (remainingOperations: number) => unknown
|
}
|
||||||
): void {
|
|
||||||
this.checkIfDestroyed("addRemainingSyncOperationsListener");
|
public async setSetting<T extends keyof SyncSettings>(
|
||||||
|
key: T,
|
||||||
this.syncer.addRemainingOperationsListener(listener);
|
value: SyncSettings[T]
|
||||||
}
|
): Promise<void> {
|
||||||
|
this.checkIfDestroyed("setSetting");
|
||||||
public addWebSocketStatusChangeListener(listener: () => unknown): void {
|
|
||||||
this.checkIfDestroyed("addWebSocketStatusChangeListener");
|
await this.settings.setSetting(key, value);
|
||||||
|
}
|
||||||
this.webSocketManager.addWebSocketStatusChangeListener(listener);
|
|
||||||
}
|
public async setSettings(value: Partial<SyncSettings>): Promise<void> {
|
||||||
|
this.checkIfDestroyed("setSettings");
|
||||||
public async syncLocallyCreatedFile(
|
|
||||||
relativePath: RelativePath
|
await this.settings.setSettings(value);
|
||||||
): Promise<void> {
|
}
|
||||||
this.checkIfDestroyed("syncLocallyCreatedFile");
|
|
||||||
|
public async syncLocallyCreatedFile(
|
||||||
this.fileChangeNotifier.notifyOfFileChange(relativePath);
|
relativePath: RelativePath
|
||||||
return this.syncer.syncLocallyCreatedFile(relativePath);
|
): Promise<void> {
|
||||||
}
|
this.checkIfDestroyed("syncLocallyCreatedFile");
|
||||||
|
|
||||||
public async syncLocallyDeletedFile(
|
this.fileChangeNotifier.notifyOfFileChange(relativePath);
|
||||||
relativePath: RelativePath
|
return this.syncer.syncLocallyCreatedFile(relativePath);
|
||||||
): Promise<void> {
|
}
|
||||||
this.checkIfDestroyed("syncLocallyDeletedFile");
|
|
||||||
|
public async syncLocallyDeletedFile(
|
||||||
this.fileChangeNotifier.notifyOfFileChange(relativePath);
|
relativePath: RelativePath
|
||||||
return this.syncer.syncLocallyDeletedFile(relativePath);
|
): Promise<void> {
|
||||||
}
|
this.checkIfDestroyed("syncLocallyDeletedFile");
|
||||||
|
|
||||||
public async syncLocallyUpdatedFile({
|
this.fileChangeNotifier.notifyOfFileChange(relativePath);
|
||||||
oldPath,
|
return this.syncer.syncLocallyDeletedFile(relativePath);
|
||||||
relativePath
|
}
|
||||||
}: {
|
|
||||||
oldPath?: RelativePath;
|
public async syncLocallyUpdatedFile({
|
||||||
relativePath: RelativePath;
|
oldPath,
|
||||||
}): Promise<void> {
|
relativePath
|
||||||
this.checkIfDestroyed("syncLocallyUpdatedFile");
|
}: {
|
||||||
|
oldPath?: RelativePath;
|
||||||
this.fileChangeNotifier.notifyOfFileChange(relativePath);
|
relativePath: RelativePath;
|
||||||
return this.syncer.syncLocallyUpdatedFile({
|
}): Promise<void> {
|
||||||
oldPath,
|
this.checkIfDestroyed("syncLocallyUpdatedFile");
|
||||||
relativePath
|
|
||||||
});
|
this.fileChangeNotifier.notifyOfFileChange(relativePath);
|
||||||
}
|
return this.syncer.syncLocallyUpdatedFile({
|
||||||
|
oldPath,
|
||||||
public getDocumentSyncingStatus(
|
relativePath
|
||||||
relativePath: RelativePath
|
});
|
||||||
): DocumentSyncStatus {
|
}
|
||||||
this.checkIfDestroyed("getDocumentSyncingStatus");
|
|
||||||
|
public getDocumentSyncingStatus(
|
||||||
if (!this.settings.getSettings().isSyncEnabled) {
|
relativePath: RelativePath
|
||||||
return DocumentSyncStatus.SYNCING_IS_DISABLED;
|
): DocumentSyncStatus {
|
||||||
}
|
this.checkIfDestroyed("getDocumentSyncingStatus");
|
||||||
|
|
||||||
if (!this.syncer.isFirstSyncComplete || !this.hasFinishedOfflineSync) {
|
if (!this.settings.getSettings().isSyncEnabled) {
|
||||||
return DocumentSyncStatus.SYNCING;
|
return DocumentSyncStatus.SYNCING_IS_DISABLED;
|
||||||
}
|
}
|
||||||
|
|
||||||
const document =
|
if (!this.syncer.isFirstSyncComplete || !this.hasFinishedOfflineSync) {
|
||||||
this.database.getLatestDocumentByRelativePath(relativePath);
|
return DocumentSyncStatus.SYNCING;
|
||||||
if (document === undefined) {
|
}
|
||||||
return DocumentSyncStatus.SYNCING;
|
|
||||||
}
|
const document =
|
||||||
return document.updates.length > 0
|
this.database.getLatestDocumentByRelativePath(relativePath);
|
||||||
? DocumentSyncStatus.SYNCING
|
if (document === undefined) {
|
||||||
: DocumentSyncStatus.UP_TO_DATE;
|
return DocumentSyncStatus.SYNCING;
|
||||||
}
|
}
|
||||||
|
return document.updates.length > 0
|
||||||
public async updateLocalCursors(
|
? DocumentSyncStatus.SYNCING
|
||||||
documentToCursors: Record<RelativePath, CursorSpan[]>
|
: DocumentSyncStatus.UP_TO_DATE;
|
||||||
): Promise<void> {
|
}
|
||||||
this.checkIfDestroyed("updateLocalCursors");
|
|
||||||
|
public async updateLocalCursors(
|
||||||
await this.cursorTracker.sendLocalCursorsToServer(documentToCursors);
|
documentToCursors: Record<RelativePath, CursorSpan[]>
|
||||||
}
|
): Promise<void> {
|
||||||
|
this.checkIfDestroyed("updateLocalCursors");
|
||||||
public addRemoteCursorsUpdateListener(
|
|
||||||
listener: (cursors: MaybeOutdatedClientCursors[]) => unknown
|
await this.cursorTracker.sendLocalCursorsToServer(documentToCursors);
|
||||||
): void {
|
}
|
||||||
this.checkIfDestroyed("addRemoteCursorsUpdateListener");
|
|
||||||
|
public getTrackedFilePaths(): RelativePath[] {
|
||||||
this.cursorTracker.addRemoteCursorsUpdateListener(listener);
|
this.checkIfDestroyed("getTrackedFilePaths");
|
||||||
}
|
|
||||||
|
return this.database.resolvedDocuments
|
||||||
public async waitUntilFinished(): Promise<void> {
|
.filter((doc) => !doc.isDeleted && doc.metadata !== undefined)
|
||||||
this.checkIfDestroyed("waitUntilIdle");
|
.map((doc) => doc.relativePath);
|
||||||
await this.syncer.waitUntilFinished();
|
}
|
||||||
await this.webSocketManager.waitUntilFinished();
|
|
||||||
await this.database.save(); // flush all changes to disk
|
public async getAllVaultFiles(): Promise<RelativePath[]> {
|
||||||
}
|
this.checkIfDestroyed("getAllVaultFiles");
|
||||||
|
|
||||||
/**
|
return this.fileOperations.listFilesRecursively(undefined);
|
||||||
* Completely destroy the SyncClient, cancelling all in-progress operations.
|
}
|
||||||
* After calling this method, the SyncClient cannot be used again.
|
|
||||||
*/
|
public async waitUntilFinished(): Promise<void> {
|
||||||
public async destroy(): Promise<void> {
|
this.checkIfDestroyed("waitUntilIdle");
|
||||||
this.checkIfDestroyed("destroy");
|
await this.syncer.waitUntilFinished();
|
||||||
|
await this.webSocketManager.waitUntilFinished();
|
||||||
// cancel everything that's in progress
|
await this.database.save(); // flush all changes to disk
|
||||||
await this.pause();
|
}
|
||||||
|
|
||||||
this.hasBeenDestroyed = true;
|
/**
|
||||||
|
* Completely destroy the SyncClient, cancelling all in-progress operations.
|
||||||
this.resetInMemoryState();
|
* After calling this method, the SyncClient cannot be used again.
|
||||||
|
*/
|
||||||
this.logger.info("SyncClient has been successfully disposed");
|
public async destroy(): Promise<void> {
|
||||||
|
this.checkIfDestroyed("destroy");
|
||||||
this.unloadTelemetry?.();
|
|
||||||
}
|
// cancel everything that's in progress
|
||||||
|
await this.pause();
|
||||||
private async startSyncing(): Promise<void> {
|
|
||||||
this.checkIfDestroyed("startSyncing");
|
this.hasBeenDestroyed = true;
|
||||||
this.fetchController.finishReset();
|
|
||||||
|
this.resetInMemoryState();
|
||||||
await this.serverConfig.initialize();
|
|
||||||
this.webSocketManager.start();
|
this.logger.info("SyncClient has been successfully disposed");
|
||||||
|
|
||||||
if (!this.hasStartedOfflineSync) {
|
this.unloadTelemetry?.();
|
||||||
this.hasStartedOfflineSync = true;
|
}
|
||||||
await this.syncer.scheduleSyncForOfflineChanges();
|
|
||||||
}
|
private async startSyncing(): Promise<void> {
|
||||||
|
this.checkIfDestroyed("startSyncing");
|
||||||
this.hasFinishedOfflineSync = true;
|
this.fetchController.finishReset();
|
||||||
}
|
|
||||||
|
await this.serverConfig.initialize();
|
||||||
private async pause(): Promise<void> {
|
this.webSocketManager.start();
|
||||||
this.fetchController.startReset();
|
|
||||||
await this.webSocketManager.stop();
|
if (!this.hasStartedOfflineSync) {
|
||||||
await this.waitUntilFinished();
|
this.hasStartedOfflineSync = true;
|
||||||
}
|
await this.syncer.scheduleSyncForOfflineChanges();
|
||||||
|
}
|
||||||
private resetInMemoryState(): void {
|
|
||||||
this.history.reset();
|
this.hasFinishedOfflineSync = true;
|
||||||
this.contentCache.reset();
|
}
|
||||||
// don't reset the logger
|
|
||||||
this.cursorTracker.reset();
|
private async pause(): Promise<void> {
|
||||||
this.syncer.reset();
|
this.fetchController.startReset();
|
||||||
this.fileOperations.reset();
|
await this.webSocketManager.stop();
|
||||||
}
|
await this.waitUntilFinished();
|
||||||
|
}
|
||||||
private async onSettingsChange(
|
|
||||||
newSettings: SyncSettings,
|
private resetInMemoryState(): void {
|
||||||
oldSettings: SyncSettings
|
this.history.reset();
|
||||||
): Promise<void> {
|
this.contentCache.reset();
|
||||||
this.checkIfDestroyed("onSettingsChange");
|
// don't reset the logger
|
||||||
|
this.cursorTracker.reset();
|
||||||
if (
|
this.syncer.reset();
|
||||||
newSettings.vaultName !== oldSettings.vaultName ||
|
this.fileOperations.reset();
|
||||||
newSettings.remoteUri !== oldSettings.remoteUri
|
}
|
||||||
) {
|
|
||||||
await this.reset();
|
private async onSettingsChange(
|
||||||
}
|
newSettings: SyncSettings,
|
||||||
|
oldSettings: SyncSettings
|
||||||
if (newSettings.isSyncEnabled !== oldSettings.isSyncEnabled) {
|
): Promise<void> {
|
||||||
if (newSettings.isSyncEnabled) {
|
this.checkIfDestroyed("onSettingsChange");
|
||||||
await this.startSyncing();
|
|
||||||
} else {
|
if (
|
||||||
await this.pause();
|
newSettings.vaultName !== oldSettings.vaultName ||
|
||||||
}
|
newSettings.remoteUri !== oldSettings.remoteUri
|
||||||
}
|
) {
|
||||||
|
await this.reset();
|
||||||
if (newSettings.diffCacheSizeMB !== oldSettings.diffCacheSizeMB) {
|
}
|
||||||
this.contentCache.resize(newSettings.diffCacheSizeMB * 1024 * 1024);
|
|
||||||
}
|
if (newSettings.isSyncEnabled !== oldSettings.isSyncEnabled) {
|
||||||
|
if (newSettings.isSyncEnabled) {
|
||||||
if (newSettings.enableTelemetry !== oldSettings.enableTelemetry) {
|
await this.startSyncing();
|
||||||
if (newSettings.enableTelemetry) {
|
} else {
|
||||||
this.unloadTelemetry = setUpTelemetry();
|
await this.pause();
|
||||||
} else {
|
}
|
||||||
this.unloadTelemetry?.();
|
}
|
||||||
}
|
|
||||||
}
|
if (newSettings.diffCacheSizeMB !== oldSettings.diffCacheSizeMB) {
|
||||||
}
|
this.contentCache.resize(newSettings.diffCacheSizeMB * 1024 * 1024);
|
||||||
|
}
|
||||||
private checkIfDestroyed(origin: string): void {
|
|
||||||
if (this.hasBeenDestroyed) {
|
if (newSettings.enableTelemetry !== oldSettings.enableTelemetry) {
|
||||||
throw new Error(
|
if (newSettings.enableTelemetry) {
|
||||||
`SyncClient has been destroyed and can no longer be used; called from ${origin}`
|
this.unloadTelemetry = setUpTelemetry();
|
||||||
);
|
} else {
|
||||||
}
|
this.unloadTelemetry?.();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkIfDestroyed(origin: string): void {
|
||||||
|
if (this.hasBeenDestroyed) {
|
||||||
|
throw new Error(
|
||||||
|
`SyncClient has been destroyed and can no longer be used; called from ${origin}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,252 +9,250 @@ import { DocumentUpToDateness } from "../types/document-up-to-dateness";
|
||||||
import { hash } from "../utils/hash";
|
import { hash } from "../utils/hash";
|
||||||
import type { FileChangeNotifier } from "./file-change-notifier";
|
import type { FileChangeNotifier } from "./file-change-notifier";
|
||||||
import { Lock } from "../utils/data-structures/locks";
|
import { Lock } from "../utils/data-structures/locks";
|
||||||
|
import { EventListeners } from "../utils/data-structures/event-listeners";
|
||||||
|
|
||||||
// Cursor positions are updated separately from documents. However, a given cursor position is only
|
// 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
|
// 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
|
// known remote cursor positions, and for each document, tries to return the latest cursor positions that are
|
||||||
// not from the future.
|
// not from the future.
|
||||||
export class CursorTracker {
|
export class CursorTracker {
|
||||||
private readonly updateLock = new Lock();
|
// 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 readonly onRemoteCursorsUpdated = new EventListeners<
|
||||||
|
(cursors: MaybeOutdatedClientCursors[]) => unknown
|
||||||
|
>();
|
||||||
|
|
||||||
private knownRemoteCursors: (ClientCursors & {
|
private readonly updateLock = new Lock();
|
||||||
upToDateness: DocumentUpToDateness;
|
|
||||||
})[] = [];
|
|
||||||
|
|
||||||
private lastLocalCursorState: DocumentWithCursors[] = [];
|
private knownRemoteCursors: (ClientCursors & {
|
||||||
private lastLocalCursorStateWithoutDirtyDocuments: DocumentWithCursors[] =
|
upToDateness: DocumentUpToDateness;
|
||||||
[];
|
})[] = [];
|
||||||
|
|
||||||
public constructor(
|
private lastLocalCursorState: DocumentWithCursors[] = [];
|
||||||
private readonly database: Database,
|
private lastLocalCursorStateWithoutDirtyDocuments: DocumentWithCursors[] =
|
||||||
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) =>
|
public constructor(
|
||||||
client.documentsWithCursors.every(
|
private readonly database: Database,
|
||||||
(doc) => doc.vault_update_id != null
|
private readonly webSocketManager: WebSocketManager,
|
||||||
)
|
private readonly fileOperations: FileOperations,
|
||||||
)) {
|
private readonly fileChangeNotifier: FileChangeNotifier
|
||||||
updatedKnownRemoteCursors.push({
|
) {
|
||||||
...cursor,
|
this.webSocketManager.onRemoteCursorsUpdateReceived.add(
|
||||||
upToDateness:
|
async (clientCursors) => {
|
||||||
await this.getDocumentsUpToDateness(cursor)
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
this.knownRemoteCursors = updatedKnownRemoteCursors;
|
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.fileChangeNotifier.addFileChangeListener(async (relativePath) =>
|
this.knownRemoteCursors = updatedKnownRemoteCursors;
|
||||||
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.
|
this.onRemoteCursorsUpdated.trigger(
|
||||||
/// Can be called frequently as it only emits an event
|
this.getRelevantAndPruneKnownClientCursors()
|
||||||
/// 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(
|
this.fileChangeNotifier.onFileChanged.add(async (relativePath) =>
|
||||||
documentToCursors
|
this.updateLock.withLock(async () => {
|
||||||
)) {
|
for (const clientCursor of this.knownRemoteCursors) {
|
||||||
const record =
|
if (
|
||||||
this.database.getLatestDocumentByRelativePath(relativePath);
|
clientCursor.documentsWithCursors.some(
|
||||||
|
(document) =>
|
||||||
|
document.relative_path === relativePath
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
clientCursor.upToDateness =
|
||||||
|
await this.getDocumentsUpToDateness(clientCursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!record) {
|
/// Update the local cursors for the given documents.
|
||||||
continue; // Let's wait for the file to be created before sending cursors
|
/// 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[] = [];
|
||||||
|
|
||||||
if (!record.metadata) {
|
for (const [relativePath, cursors] of Object.entries(
|
||||||
continue; // this is a new document, no need to sync the cursors
|
documentToCursors
|
||||||
}
|
)) {
|
||||||
|
const record =
|
||||||
|
this.database.getLatestDocumentByRelativePath(relativePath);
|
||||||
|
|
||||||
documentsWithCursors.push({
|
if (!record) {
|
||||||
relative_path: relativePath,
|
continue; // Let's wait for the file to be created before sending cursors
|
||||||
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 (
|
if (!record.metadata) {
|
||||||
JSON.stringify(this.lastLocalCursorState) ===
|
continue; // this is a new document, no need to sync the cursors
|
||||||
JSON.stringify(documentsWithCursors)
|
}
|
||||||
) {
|
|
||||||
// Caching step to avoid reading the edited files all the time
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.lastLocalCursorState = documentsWithCursors;
|
|
||||||
|
|
||||||
for (const doc of documentsWithCursors) {
|
documentsWithCursors.push({
|
||||||
const readContent = await this.fileOperations.read(
|
relative_path: relativePath,
|
||||||
doc.relative_path
|
document_id: record.documentId,
|
||||||
);
|
vault_update_id: record.metadata.parentVersionId,
|
||||||
const record = this.database.getLatestDocumentByRelativePath(
|
cursors: cursors.map(({ start, end }) => ({
|
||||||
doc.relative_path
|
start: Math.min(start, end),
|
||||||
);
|
end: Math.max(start, end)
|
||||||
if (record?.metadata?.hash !== hash(readContent)) {
|
})) // the client might send directional selections
|
||||||
doc.vault_update_id = null;
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
JSON.stringify(this.lastLocalCursorStateWithoutDirtyDocuments) ===
|
JSON.stringify(this.lastLocalCursorState) ===
|
||||||
JSON.stringify(documentsWithCursors)
|
JSON.stringify(documentsWithCursors)
|
||||||
) {
|
) {
|
||||||
return;
|
// Caching step to avoid reading the edited files all the time
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
this.lastLocalCursorState = documentsWithCursors;
|
||||||
|
|
||||||
this.lastLocalCursorStateWithoutDirtyDocuments = 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.webSocketManager.updateLocalCursors({ documentsWithCursors });
|
if (
|
||||||
}
|
JSON.stringify(this.lastLocalCursorStateWithoutDirtyDocuments) ===
|
||||||
|
JSON.stringify(documentsWithCursors)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// The returned position may be accurate, if it matches the document version, or outdated, in which case
|
this.lastLocalCursorStateWithoutDirtyDocuments = documentsWithCursors;
|
||||||
// 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())
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public reset(): void {
|
this.webSocketManager.updateLocalCursors({ documentsWithCursors });
|
||||||
this.knownRemoteCursors = [];
|
}
|
||||||
this.lastLocalCursorState = [];
|
|
||||||
this.lastLocalCursorStateWithoutDirtyDocuments = [];
|
|
||||||
this.updateLock.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
private getRelevantAndPruneKnownClientCursors(): MaybeOutdatedClientCursors[] {
|
public reset(): void {
|
||||||
const result: MaybeOutdatedClientCursors[] = [];
|
this.knownRemoteCursors = [];
|
||||||
const included = new Set<string>();
|
this.lastLocalCursorState = [];
|
||||||
|
this.lastLocalCursorStateWithoutDirtyDocuments = [];
|
||||||
|
this.updateLock.reset();
|
||||||
|
}
|
||||||
|
|
||||||
const relevantCursors = [];
|
private getRelevantAndPruneKnownClientCursors(): MaybeOutdatedClientCursors[] {
|
||||||
for (const clientCursors of [...this.knownRemoteCursors].reverse()) {
|
const result: MaybeOutdatedClientCursors[] = [];
|
||||||
if (included.has(clientCursors.deviceId)) {
|
const included = new Set<string>();
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clientCursors.upToDateness === DocumentUpToDateness.Later) {
|
const relevantCursors = [];
|
||||||
continue;
|
for (const clientCursors of [...this.knownRemoteCursors].reverse()) {
|
||||||
}
|
if (included.has(clientCursors.deviceId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
result.push({
|
if (clientCursors.upToDateness === DocumentUpToDateness.Later) {
|
||||||
...clientCursors,
|
continue;
|
||||||
isOutdated:
|
}
|
||||||
clientCursors.upToDateness === DocumentUpToDateness.Prior
|
|
||||||
});
|
|
||||||
|
|
||||||
included.add(clientCursors.deviceId);
|
result.push({
|
||||||
relevantCursors.unshift(clientCursors); // to reverse order back to normal
|
...clientCursors,
|
||||||
}
|
isOutdated:
|
||||||
|
clientCursors.upToDateness === DocumentUpToDateness.Prior
|
||||||
|
});
|
||||||
|
|
||||||
this.knownRemoteCursors = relevantCursors;
|
included.add(clientCursors.deviceId);
|
||||||
|
relevantCursors.unshift(clientCursors); // to reverse order back to normal
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
this.knownRemoteCursors = relevantCursors;
|
||||||
}
|
|
||||||
|
|
||||||
// We store up-to-dateness on a per-client basis to simplify the implementation.
|
return result;
|
||||||
// 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 (
|
// We store up-to-dateness on a per-client basis to simplify the implementation.
|
||||||
results.every((result) => result === DocumentUpToDateness.UpToDate)
|
// An individual client won't have too many documents open at once, so this is a reasonable trade-off.
|
||||||
) {
|
private async getDocumentsUpToDateness(
|
||||||
return DocumentUpToDateness.UpToDate;
|
clientCursor: ClientCursors
|
||||||
}
|
): Promise<DocumentUpToDateness> {
|
||||||
|
const results = [];
|
||||||
|
for (const document of clientCursor.documentsWithCursors) {
|
||||||
|
results.push(await this.getDocumentUpToDateness(document));
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
results.every(
|
results.every((result) => result === DocumentUpToDateness.UpToDate)
|
||||||
(result) =>
|
) {
|
||||||
result === DocumentUpToDateness.UpToDate ||
|
return DocumentUpToDateness.UpToDate;
|
||||||
result === DocumentUpToDateness.Prior
|
}
|
||||||
)
|
|
||||||
) {
|
|
||||||
return DocumentUpToDateness.Prior;
|
|
||||||
}
|
|
||||||
|
|
||||||
return DocumentUpToDateness.Later;
|
if (
|
||||||
}
|
results.every(
|
||||||
|
(result) =>
|
||||||
|
result === DocumentUpToDateness.UpToDate ||
|
||||||
|
result === DocumentUpToDateness.Prior
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return DocumentUpToDateness.Prior;
|
||||||
|
}
|
||||||
|
|
||||||
private async getDocumentUpToDateness(
|
return DocumentUpToDateness.Later;
|
||||||
document: DocumentWithCursors
|
}
|
||||||
): Promise<DocumentUpToDateness> {
|
|
||||||
const record = this.database.getLatestDocumentByRelativePath(
|
|
||||||
document.relative_path
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!record) {
|
private async getDocumentUpToDateness(
|
||||||
// the document of the cursor must be from the future
|
document: DocumentWithCursors
|
||||||
return DocumentUpToDateness.Later;
|
): Promise<DocumentUpToDateness> {
|
||||||
}
|
const record = this.database.getLatestDocumentByRelativePath(
|
||||||
|
document.relative_path
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (!record) {
|
||||||
(record.metadata?.parentVersionId ?? 0) <
|
// the document of the cursor must be from the future
|
||||||
(document.vault_update_id ?? 0)
|
return DocumentUpToDateness.Later;
|
||||||
) {
|
}
|
||||||
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(
|
if (
|
||||||
document.relative_path
|
(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;
|
||||||
|
}
|
||||||
|
|
||||||
return this.database.getLatestDocumentByRelativePath(
|
const currentContent = await this.fileOperations.read(
|
||||||
document.relative_path
|
document.relative_path
|
||||||
)?.metadata?.hash === hash(currentContent)
|
);
|
||||||
? DocumentUpToDateness.UpToDate
|
|
||||||
: DocumentUpToDateness.Prior;
|
return this.database.getLatestDocumentByRelativePath(
|
||||||
}
|
document.relative_path
|
||||||
|
)?.metadata?.hash === hash(currentContent)
|
||||||
|
? DocumentUpToDateness.UpToDate
|
||||||
|
: DocumentUpToDateness.Prior;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,12 @@
|
||||||
import type { RelativePath } from "../persistence/database";
|
import type { RelativePath } from "../persistence/database";
|
||||||
|
import { EventListeners } from "../utils/data-structures/event-listeners";
|
||||||
|
|
||||||
export class FileChangeNotifier {
|
export class FileChangeNotifier {
|
||||||
private readonly listeners: ((filePath: RelativePath) => unknown)[] = [];
|
public readonly onFileChanged = new EventListeners<
|
||||||
|
(filePath: RelativePath) => unknown
|
||||||
|
>();
|
||||||
|
|
||||||
public addFileChangeListener(
|
public notifyOfFileChange(filePath: RelativePath): void {
|
||||||
listener: (filePath: RelativePath) => unknown
|
this.onFileChanged.trigger(filePath);
|
||||||
): void {
|
}
|
||||||
this.listeners.push(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
public removeFileChangeListener(
|
|
||||||
listener: (filePath: RelativePath) => unknown
|
|
||||||
): void {
|
|
||||||
const index = this.listeners.indexOf(listener);
|
|
||||||
if (index !== -1) {
|
|
||||||
this.listeners.splice(index, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public notifyOfFileChange(filePath: RelativePath): void {
|
|
||||||
this.listeners.forEach((listener) => listener(filePath));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,89 +1,72 @@
|
||||||
import { MAX_LOG_MESSAGE_COUNT } from "../consts";
|
import { MAX_LOG_MESSAGE_COUNT } from "../consts";
|
||||||
|
import { EventListeners } from "../utils/data-structures/event-listeners";
|
||||||
|
|
||||||
export enum LogLevel {
|
export enum LogLevel {
|
||||||
DEBUG = "DEBUG",
|
DEBUG = "DEBUG",
|
||||||
INFO = "INFO",
|
INFO = "INFO",
|
||||||
WARNING = "WARNING",
|
WARNING = "WARNING",
|
||||||
ERROR = "ERROR"
|
ERROR = "ERROR"
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOG_LEVEL_ORDER = {
|
const LOG_LEVEL_ORDER = {
|
||||||
[LogLevel.DEBUG]: 0,
|
[LogLevel.DEBUG]: 0,
|
||||||
[LogLevel.INFO]: 1,
|
[LogLevel.INFO]: 1,
|
||||||
[LogLevel.WARNING]: 2,
|
[LogLevel.WARNING]: 2,
|
||||||
[LogLevel.ERROR]: 3
|
[LogLevel.ERROR]: 3
|
||||||
};
|
};
|
||||||
|
|
||||||
export class LogLine {
|
export class LogLine {
|
||||||
public timestamp = new Date();
|
public timestamp = new Date();
|
||||||
public constructor(
|
public constructor(
|
||||||
public level: LogLevel,
|
public level: LogLevel,
|
||||||
public message: string
|
public message: string
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Logger {
|
export class Logger {
|
||||||
private readonly messages: LogLine[] = [];
|
public readonly onLogEmitted = new EventListeners<
|
||||||
private readonly onMessageListeners: ((message: LogLine) => unknown)[] = [];
|
(message: LogLine) => unknown
|
||||||
|
>();
|
||||||
|
|
||||||
public constructor(
|
private readonly messages: LogLine[] = [];
|
||||||
...onMessageListeners: ((message: LogLine) => unknown)[]
|
|
||||||
) {
|
|
||||||
this.onMessageListeners = onMessageListeners;
|
|
||||||
}
|
|
||||||
|
|
||||||
public debug(message: string): void {
|
public debug(message: string): void {
|
||||||
this.pushMessage(message, LogLevel.DEBUG);
|
this.pushMessage(message, LogLevel.DEBUG);
|
||||||
}
|
}
|
||||||
|
|
||||||
public info(message: string): void {
|
public info(message: string): void {
|
||||||
this.pushMessage(message, LogLevel.INFO);
|
this.pushMessage(message, LogLevel.INFO);
|
||||||
}
|
}
|
||||||
|
|
||||||
public warn(message: string): void {
|
public warn(message: string): void {
|
||||||
this.pushMessage(message, LogLevel.WARNING);
|
this.pushMessage(message, LogLevel.WARNING);
|
||||||
}
|
}
|
||||||
|
|
||||||
public error(message: string): void {
|
public error(message: string): void {
|
||||||
this.pushMessage(message, LogLevel.ERROR);
|
this.pushMessage(message, LogLevel.ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getMessages(mininumSeverity: LogLevel): LogLine[] {
|
public getMessages(mininumSeverity: LogLevel): LogLine[] {
|
||||||
return this.messages.filter(
|
return this.messages.filter(
|
||||||
(message) =>
|
(message) =>
|
||||||
LOG_LEVEL_ORDER[message.level] >=
|
LOG_LEVEL_ORDER[message.level] >=
|
||||||
LOG_LEVEL_ORDER[mininumSeverity]
|
LOG_LEVEL_ORDER[mininumSeverity]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public addOnMessageListener(listener: (message: LogLine) => unknown): void {
|
public reset(): void {
|
||||||
this.onMessageListeners.push(listener);
|
this.messages.length = 0;
|
||||||
}
|
this.debug("Logger has been reset");
|
||||||
|
}
|
||||||
|
|
||||||
public removeOnMessageListener(
|
private pushMessage(message: string, level: LogLevel): void {
|
||||||
listener: (message: LogLine) => unknown
|
const logLine = new LogLine(level, message);
|
||||||
): void {
|
this.messages.push(logLine);
|
||||||
const index = this.onMessageListeners.indexOf(listener);
|
|
||||||
if (index !== -1) {
|
|
||||||
this.onMessageListeners.splice(index, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public reset(): void {
|
while (this.messages.length > MAX_LOG_MESSAGE_COUNT) {
|
||||||
this.messages.length = 0;
|
this.messages.shift();
|
||||||
this.debug("Logger has been reset");
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private pushMessage(message: string, level: LogLevel): void {
|
this.onLogEmitted.trigger(logLine);
|
||||||
const logLine = new LogLine(level, message);
|
}
|
||||||
this.messages.push(logLine);
|
|
||||||
|
|
||||||
while (this.messages.length > MAX_LOG_MESSAGE_COUNT) {
|
|
||||||
this.messages.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.onMessageListeners.forEach((listener) => {
|
|
||||||
listener(logLine);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,185 +1,167 @@
|
||||||
import {
|
import {
|
||||||
MAX_HISTORY_ENTRY_COUNT,
|
MAX_HISTORY_ENTRY_COUNT,
|
||||||
TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS
|
TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS
|
||||||
} from "../consts";
|
} from "../consts";
|
||||||
import type { RelativePath } from "../persistence/database";
|
import type { RelativePath } from "../persistence/database";
|
||||||
import type { Logger } from "./logger";
|
import type { Logger } from "./logger";
|
||||||
|
import { removeFromArray } from "../utils/remove-from-array";
|
||||||
|
import { EventListeners } from "../utils/data-structures/event-listeners";
|
||||||
|
|
||||||
export interface SyncCreateDetails {
|
export interface SyncCreateDetails {
|
||||||
type: SyncType.CREATE;
|
type: SyncType.CREATE;
|
||||||
relativePath: RelativePath;
|
relativePath: RelativePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncUpdateDetails {
|
export interface SyncUpdateDetails {
|
||||||
type: SyncType.UPDATE;
|
type: SyncType.UPDATE;
|
||||||
relativePath: RelativePath;
|
relativePath: RelativePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncMovedDetails {
|
export interface SyncMovedDetails {
|
||||||
type: SyncType.MOVE;
|
type: SyncType.MOVE;
|
||||||
relativePath: RelativePath;
|
relativePath: RelativePath;
|
||||||
movedFrom: RelativePath;
|
movedFrom: RelativePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncDeleteDetails {
|
export interface SyncDeleteDetails {
|
||||||
type: SyncType.DELETE;
|
type: SyncType.DELETE;
|
||||||
relativePath: RelativePath;
|
relativePath: RelativePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncSkippedDetails {
|
export interface SyncSkippedDetails {
|
||||||
type: SyncType.SKIPPED;
|
type: SyncType.SKIPPED;
|
||||||
relativePath: RelativePath;
|
relativePath: RelativePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SyncDetails =
|
export type SyncDetails =
|
||||||
| SyncCreateDetails
|
| SyncCreateDetails
|
||||||
| SyncUpdateDetails
|
| SyncUpdateDetails
|
||||||
| SyncDeleteDetails
|
| SyncDeleteDetails
|
||||||
| SyncMovedDetails
|
| SyncMovedDetails
|
||||||
| SyncSkippedDetails;
|
| SyncSkippedDetails;
|
||||||
|
|
||||||
export interface CommonHistoryEntry {
|
export interface CommonHistoryEntry {
|
||||||
status: SyncStatus;
|
status: SyncStatus;
|
||||||
message: string;
|
message: string;
|
||||||
details: SyncDetails;
|
details: SyncDetails;
|
||||||
author?: string;
|
author?: string;
|
||||||
timestamp?: Date;
|
timestamp?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SyncType {
|
export enum SyncType {
|
||||||
CREATE = "CREATE",
|
CREATE = "CREATE",
|
||||||
UPDATE = "UPDATE",
|
UPDATE = "UPDATE",
|
||||||
DELETE = "DELETE",
|
DELETE = "DELETE",
|
||||||
MOVE = "MOVE",
|
MOVE = "MOVE",
|
||||||
SKIPPED = "SKIPPED"
|
SKIPPED = "SKIPPED"
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SyncStatus {
|
export enum SyncStatus {
|
||||||
SUCCESS = "SUCCESS",
|
SUCCESS = "SUCCESS",
|
||||||
ERROR = "ERROR",
|
ERROR = "ERROR",
|
||||||
SKIPPED = "SKIPPED"
|
SKIPPED = "SKIPPED"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HistoryEntry = CommonHistoryEntry & { timestamp: Date };
|
export type HistoryEntry = CommonHistoryEntry & { timestamp: Date };
|
||||||
|
|
||||||
export interface HistoryStats {
|
export interface HistoryStats {
|
||||||
success: number;
|
success: number;
|
||||||
error: number;
|
error: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SyncHistory {
|
export class SyncHistory {
|
||||||
private _entries: HistoryEntry[] = [];
|
public readonly onHistoryUpdated = new EventListeners<
|
||||||
|
(status: HistoryStats) => unknown
|
||||||
|
>();
|
||||||
|
|
||||||
private readonly syncHistoryUpdateListeners: ((
|
private readonly _entries: HistoryEntry[] = [];
|
||||||
status: HistoryStats
|
|
||||||
) => unknown)[] = [];
|
|
||||||
|
|
||||||
private status: HistoryStats = {
|
private status: HistoryStats = {
|
||||||
success: 0,
|
success: 0,
|
||||||
error: 0
|
error: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
public constructor(private readonly logger: Logger) {}
|
public constructor(private readonly logger: Logger) {}
|
||||||
|
|
||||||
public get entries(): readonly HistoryEntry[] {
|
public get entries(): readonly HistoryEntry[] {
|
||||||
return this._entries;
|
return this._entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert the entry at the beginning of the history list. If the entry
|
* 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.
|
* 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.
|
* If the entry list is too long, the oldest entry will be removed.
|
||||||
*/
|
*/
|
||||||
public addHistoryEntry(entry: CommonHistoryEntry): void {
|
public addHistoryEntry(entry: CommonHistoryEntry): void {
|
||||||
const historyEntry = {
|
const historyEntry = {
|
||||||
...entry,
|
...entry,
|
||||||
timestamp: entry.timestamp ?? new Date()
|
timestamp: entry.timestamp ?? new Date()
|
||||||
};
|
};
|
||||||
|
|
||||||
const candidate = this.findSimilarRecentUpdateEntry(historyEntry);
|
const candidate = this.findSimilarRecentUpdateEntry(historyEntry);
|
||||||
if (candidate !== undefined) {
|
if (candidate !== undefined) {
|
||||||
this._entries = this._entries.filter((e) => e !== candidate);
|
removeFromArray(this._entries, candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert the entry at the beginning
|
// Insert the entry at the beginning
|
||||||
this._entries.unshift(historyEntry);
|
this._entries.unshift(historyEntry);
|
||||||
|
|
||||||
if (this._entries.length > MAX_HISTORY_ENTRY_COUNT) {
|
if (this._entries.length > MAX_HISTORY_ENTRY_COUNT) {
|
||||||
this._entries.pop();
|
this._entries.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateSuccessCount(historyEntry);
|
this.updateSuccessCount(historyEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
public addSyncHistoryUpdateListener(
|
public reset(): void {
|
||||||
listener: (stats: HistoryStats) => unknown
|
this._entries.length = 0;
|
||||||
): void {
|
this.status = {
|
||||||
this.syncHistoryUpdateListeners.push(listener);
|
success: 0,
|
||||||
listener({ ...this.status });
|
error: 0
|
||||||
}
|
};
|
||||||
|
this.onHistoryUpdated.trigger(this.status);
|
||||||
|
}
|
||||||
|
|
||||||
public removeSyncHistoryUpdateListener(
|
private findSimilarRecentUpdateEntry(
|
||||||
listener: (stats: HistoryStats) => unknown
|
entry: HistoryEntry
|
||||||
): void {
|
): HistoryEntry | undefined {
|
||||||
const index = this.syncHistoryUpdateListeners.indexOf(listener);
|
if (entry.details.type !== SyncType.UPDATE) {
|
||||||
if (index !== -1) {
|
return;
|
||||||
this.syncHistoryUpdateListeners.splice(index, 1);
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public reset(): void {
|
const candidate = this._entries.find(
|
||||||
this._entries.length = 0;
|
(e) =>
|
||||||
this.status = {
|
e.details.type === SyncType.UPDATE &&
|
||||||
success: 0,
|
e.details.relativePath === entry.details.relativePath
|
||||||
error: 0
|
);
|
||||||
};
|
if (
|
||||||
this.syncHistoryUpdateListeners.forEach((listener) => {
|
candidate !== undefined &&
|
||||||
listener(this.status);
|
(this._entries[0] === candidate ||
|
||||||
});
|
candidate.timestamp.getTime() +
|
||||||
}
|
TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS * 1000 >
|
||||||
|
entry.timestamp.getTime())
|
||||||
|
) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private findSimilarRecentUpdateEntry(
|
private updateSuccessCount(entry: HistoryEntry): void {
|
||||||
entry: HistoryEntry
|
const message = `${entry.details.relativePath} - ${entry.message} (${entry.details.type.toLocaleLowerCase()})`;
|
||||||
): HistoryEntry | undefined {
|
switch (entry.status) {
|
||||||
if (entry.details.type !== SyncType.UPDATE) {
|
case SyncStatus.SUCCESS:
|
||||||
return;
|
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.warn(`Skipping file: ${message}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const candidate = this._entries.find(
|
this.onHistoryUpdated.trigger(this.status);
|
||||||
(e) =>
|
}
|
||||||
e.details.type === SyncType.UPDATE &&
|
|
||||||
e.details.relativePath === entry.details.relativePath
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
candidate !== undefined &&
|
|
||||||
(this._entries[0] === candidate ||
|
|
||||||
candidate.timestamp.getTime() +
|
|
||||||
TIMEOUT_FOR_MERGING_HISTORY_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.warn(`Skipping file: ${message}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.syncHistoryUpdateListeners.forEach((listener) => {
|
|
||||||
listener(this.status);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export enum DocumentSyncStatus {
|
export enum DocumentSyncStatus {
|
||||||
UP_TO_DATE = "UP_TO_DATE",
|
UP_TO_DATE = "UP_TO_DATE",
|
||||||
SYNCING = "SYNCING",
|
SYNCING = "SYNCING",
|
||||||
SYNCING_IS_DISABLED = "SYNCING_IS_DISABLED"
|
SYNCING_IS_DISABLED = "SYNCING_IS_DISABLED"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export enum DocumentUpToDateness {
|
export enum DocumentUpToDateness {
|
||||||
UpToDate = "UpToDate", // easiest case, the client can just show the cursors as-is
|
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.
|
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.
|
Later = "Later" // The cursors are from a future version of a document, there's no way we can accuratly show them locally.
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { ClientCursors } from "../services/types/ClientCursors";
|
import type { ClientCursors } from "../services/types/ClientCursors";
|
||||||
|
|
||||||
export interface MaybeOutdatedClientCursors extends ClientCursors {
|
export interface MaybeOutdatedClientCursors extends ClientCursors {
|
||||||
isOutdated: boolean;
|
isOutdated: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export interface NetworkConnectionStatus {
|
export interface NetworkConnectionStatus {
|
||||||
isSuccessful: boolean;
|
isSuccessful: boolean;
|
||||||
serverMessage: string;
|
serverMessage: string;
|
||||||
isWebSocketConnected: boolean;
|
isWebSocketConnected: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import assert from "node:assert";
|
import assert from "node:assert";
|
||||||
|
|
||||||
export function assertSetContainsExactly<T>(set: Set<T>, ...values: T[]): void {
|
export function assertSetContainsExactly<T>(set: Set<T>, ...values: T[]): void {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
set.size === values.length &&
|
set.size === values.length &&
|
||||||
Array.from(set).every((value) => values.includes(value)),
|
Array.from(set).every((value) => values.includes(value)),
|
||||||
`Expected set to contain only ${values.map((v) => '"' + v + '"').join(", ")}, but it contained ${Array.from(
|
`Expected set to contain only ${values.map((v) => '"' + v + '"').join(", ")}, but it contained ${Array.from(
|
||||||
set
|
set
|
||||||
)
|
)
|
||||||
.map((v) => '"' + v + '"')
|
.map((v) => '"' + v + '"')
|
||||||
.join(", ")}`
|
.join(", ")}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue