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
|
||||
tab_width = 4
|
||||
|
||||
[*.{yml,yaml}]
|
||||
[*.{yml,yaml,md}]
|
||||
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
|
||||
npm ci
|
||||
|
||||
- name: Check formatting
|
||||
- name: Check formatting & spelling
|
||||
run: |
|
||||
cd docs
|
||||
npm run format:check
|
||||
|
||||
- name: Check spelling
|
||||
run: |
|
||||
cd docs
|
||||
npm run spell:check
|
||||
|
||||
- name: Build documentation
|
||||
|
|
|
|||
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
|
|
@ -6,7 +6,7 @@ on:
|
|||
pull_request:
|
||||
branches: ["main"]
|
||||
schedule:
|
||||
- cron: '*/30 * * * *'
|
||||
- cron: '0 * * * *'
|
||||
|
||||
concurrency:
|
||||
group: e2e-tests
|
||||
|
|
|
|||
1
docs/.gitignore
vendored
1
docs/.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
.vitepress/dist/
|
||||
.vitepress/cache/
|
||||
.vitepress/.temp/
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 4,
|
||||
"useTabs": true,
|
||||
"useTabs": false,
|
||||
"semi": false,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "none",
|
||||
|
|
|
|||
|
|
@ -1,60 +1,60 @@
|
|||
import { defineConfig } from "vitepress"
|
||||
|
||||
export default defineConfig({
|
||||
title: "VaultLink",
|
||||
description: "Self-hosted real-time synchronisation for Obsidian",
|
||||
base: "/vault-link/",
|
||||
themeConfig: {
|
||||
logo: "/logo.svg",
|
||||
nav: [
|
||||
{ text: "Home", link: "/" },
|
||||
{ text: "Guide", link: "/guide/getting-started" },
|
||||
{ text: "Architecture", link: "/architecture/" },
|
||||
{ text: "GitHub", link: "https://github.com/schmelczer/vault-link" }
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
text: "Introduction",
|
||||
items: [
|
||||
{ text: "What is VaultLink?", link: "/guide/what-is-vaultlink" },
|
||||
{ text: "Getting Started", link: "/guide/getting-started" },
|
||||
{ text: "Limitations", link: "/guide/limitations" },
|
||||
{ text: "Comparison with Alternatives", link: "/guide/alternatives" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Setup",
|
||||
items: [
|
||||
{ text: "Server Setup", link: "/guide/server-setup" },
|
||||
{ text: "Obsidian Plugin", link: "/guide/obsidian-plugin" },
|
||||
{ text: "CLI Client", link: "/guide/cli-client" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Configuration",
|
||||
items: [
|
||||
{ text: "Server Configuration", link: "/config/server" },
|
||||
{ text: "Authentication", link: "/config/authentication" },
|
||||
{ text: "Advanced Options", link: "/config/advanced" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Architecture",
|
||||
items: [
|
||||
{ text: "Overview", link: "/architecture/" },
|
||||
{ text: "Sync Algorithm", link: "/architecture/sync-algorithm" },
|
||||
{ text: "Data Flow", link: "/architecture/data-flow" }
|
||||
]
|
||||
}
|
||||
],
|
||||
socialLinks: [{ icon: "github", link: "https://github.com/schmelczer/vault-link" }],
|
||||
footer: {
|
||||
message: "Released under the MIT License.",
|
||||
copyright: "Copyright © 2024-present Andras Schmelczer"
|
||||
},
|
||||
search: {
|
||||
provider: "local"
|
||||
}
|
||||
},
|
||||
head: [["link", { rel: "icon", type: "image/svg+xml", href: "/vault-link/logo.svg" }]]
|
||||
title: "VaultLink",
|
||||
description: "Self-hosted real-time synchronisation for Obsidian",
|
||||
base: "/",
|
||||
themeConfig: {
|
||||
logo: "/logo.svg",
|
||||
nav: [
|
||||
{ text: "Home", link: "/" },
|
||||
{ text: "Guide", link: "/guide/getting-started" },
|
||||
{ text: "Architecture", link: "/architecture/" },
|
||||
{ text: "GitHub", link: "https://github.com/schmelczer/vault-link" }
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
text: "Introduction",
|
||||
items: [
|
||||
{ text: "What is VaultLink?", link: "/guide/what-is-vaultlink" },
|
||||
{ text: "Getting Started", link: "/guide/getting-started" },
|
||||
{ text: "Limitations", link: "/guide/limitations" },
|
||||
{ text: "Comparison with Alternatives", link: "/guide/alternatives" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Setup",
|
||||
items: [
|
||||
{ text: "Server Setup", link: "/guide/server-setup" },
|
||||
{ text: "Obsidian Plugin", link: "/guide/obsidian-plugin" },
|
||||
{ text: "CLI Client", link: "/guide/cli-client" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Configuration",
|
||||
items: [
|
||||
{ text: "Server Configuration", link: "/config/server" },
|
||||
{ text: "Authentication", link: "/config/authentication" },
|
||||
{ text: "Advanced Options", link: "/config/advanced" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Architecture",
|
||||
items: [
|
||||
{ text: "Overview", link: "/architecture/" },
|
||||
{ text: "Sync Algorithm", link: "/architecture/sync-algorithm" },
|
||||
{ text: "Data Flow", link: "/architecture/data-flow" }
|
||||
]
|
||||
}
|
||||
],
|
||||
socialLinks: [{ icon: "github", link: "https://github.com/schmelczer/vault-link" }],
|
||||
footer: {
|
||||
message: "Released under the MIT License.",
|
||||
copyright: "Copyright © 2024-present Andras Schmelczer"
|
||||
},
|
||||
search: {
|
||||
provider: "local"
|
||||
}
|
||||
},
|
||||
head: [["link", { rel: "icon", type: "image/svg+xml", href: "/logo.svg" }]]
|
||||
})
|
||||
|
|
|
|||
|
|
@ -125,37 +125,37 @@ sequenceDiagram
|
|||
```
|
||||
┌─────────┐
|
||||
│ Client │
|
||||
└────┬────┘
|
||||
│ 1. Detect file change
|
||||
│
|
||||
├─► 2. Read file content
|
||||
│
|
||||
├─► 3. Create upload message
|
||||
│ {
|
||||
│ type: "upload_file",
|
||||
│ path: "notes/daily.md",
|
||||
│ content: "...",
|
||||
│ version: 42,
|
||||
│ timestamp: "2024-01-01T12:00:00Z"
|
||||
│ }
|
||||
│
|
||||
▼
|
||||
└───┬─-───┘
|
||||
│ 1. Detect file change
|
||||
│
|
||||
├─► 2. Read file content
|
||||
│
|
||||
├─► 3. Create upload message
|
||||
│ {
|
||||
│ type: "upload_file",
|
||||
│ path: "notes/daily.md",
|
||||
│ content: "...",
|
||||
│ version: 42,
|
||||
│ timestamp: "2024-01-01T12:00:00Z"
|
||||
│ }
|
||||
│
|
||||
▼
|
||||
┌─────────┐
|
||||
│ Server │
|
||||
└────┬────┘
|
||||
│ 4. Validate message
|
||||
│
|
||||
├─► 5. Check permissions
|
||||
│
|
||||
├─► 6. Apply OT (if conflicts)
|
||||
│
|
||||
├─► 7. Store in database
|
||||
│
|
||||
├─► 8. Update version
|
||||
│
|
||||
├─► 9. Broadcast to clients
|
||||
│
|
||||
└─► 10. Send ACK to uploader
|
||||
└───┬────-┘
|
||||
│ 4. Validate message
|
||||
│
|
||||
├─► 5. Check permissions
|
||||
│
|
||||
├─► 6. Apply OT (if conflicts)
|
||||
│
|
||||
├─► 7. Store in database
|
||||
│
|
||||
├─► 8. Update version
|
||||
│
|
||||
├─► 9. Broadcast to clients
|
||||
│
|
||||
└─► 10. Send ACK to uploader
|
||||
```
|
||||
|
||||
### Download
|
||||
|
|
@ -163,36 +163,36 @@ sequenceDiagram
|
|||
```
|
||||
┌─────────┐
|
||||
│ Server │
|
||||
└────┬────┘
|
||||
│ 1. File updated by another client
|
||||
│
|
||||
├─► 2. Broadcast notification
|
||||
│ {
|
||||
│ type: "file_updated",
|
||||
│ path: "notes/daily.md",
|
||||
│ version: 43
|
||||
│ }
|
||||
│
|
||||
▼
|
||||
└───┬─-───┘
|
||||
│ 1. File updated by another client
|
||||
│
|
||||
├─► 2. Broadcast notification
|
||||
│ {
|
||||
│ type: "file_updated",
|
||||
│ path: "notes/daily.md",
|
||||
│ version: 43
|
||||
│ }
|
||||
│
|
||||
▼
|
||||
┌─────────┐
|
||||
│ Client │
|
||||
└────┬────┘
|
||||
│ 3. Receive notification
|
||||
│
|
||||
├─► 4. Request file download
|
||||
│ {
|
||||
│ type: "download_file",
|
||||
│ path: "notes/daily.md",
|
||||
│ version: 43
|
||||
│ }
|
||||
│
|
||||
▼
|
||||
└───┬─-───┘
|
||||
│ 3. Receive notification
|
||||
│
|
||||
├─► 4. Request file download
|
||||
│ {
|
||||
│ type: "download_file",
|
||||
│ path: "notes/daily.md",
|
||||
│ version: 43
|
||||
│ }
|
||||
│
|
||||
▼
|
||||
┌─────────┐
|
||||
│ Server │
|
||||
└────┬────┘
|
||||
│ 5. Retrieve from database
|
||||
│
|
||||
└─► 6. Send file content
|
||||
└───┬─=───┘
|
||||
│ 5. Retrieve from database
|
||||
│
|
||||
└─► 6. Send file content
|
||||
{
|
||||
type: "file_content",
|
||||
path: "notes/daily.md",
|
||||
|
|
@ -201,9 +201,9 @@ sequenceDiagram
|
|||
}
|
||||
│
|
||||
▼
|
||||
┌─────────┐
|
||||
│ Client │
|
||||
└────┬────┘
|
||||
┌─────────┐
|
||||
│ Client │
|
||||
└───-─┬───┘
|
||||
│ 7. Write to filesystem
|
||||
│
|
||||
└─► 8. Update local metadata
|
||||
|
|
@ -215,30 +215,30 @@ sequenceDiagram
|
|||
┌─────────┐
|
||||
│ Client │
|
||||
└────┬────┘
|
||||
│ 1. File deleted locally
|
||||
│
|
||||
├─► 2. Send delete message
|
||||
│ {
|
||||
│ type: "delete_file",
|
||||
│ path: "notes/old.md"
|
||||
│ }
|
||||
│
|
||||
▼
|
||||
│ 1. File deleted locally
|
||||
│
|
||||
├─► 2. Send delete message
|
||||
│ {
|
||||
│ type: "delete_file",
|
||||
│ path: "notes/old.md"
|
||||
│ }
|
||||
│
|
||||
▼
|
||||
┌─────────┐
|
||||
│ Server │
|
||||
└────┬────┘
|
||||
│ 3. Mark as deleted in DB
|
||||
│ (soft delete for history)
|
||||
│
|
||||
├─► 4. Broadcast deletion
|
||||
│
|
||||
└─► 5. ACK to sender
|
||||
│ 3. Mark as deleted in DB
|
||||
│ (soft delete for history)
|
||||
│
|
||||
├─► 4. Broadcast deletion
|
||||
│
|
||||
└─► 5. ACK to sender
|
||||
│
|
||||
▼
|
||||
┌─────────┐
|
||||
│ Other │
|
||||
│ Clients │
|
||||
└────┬────┘
|
||||
┌─────────┐
|
||||
│ Other │
|
||||
│ Clients │
|
||||
└────┬────┘
|
||||
│ 6. Delete local file
|
||||
│
|
||||
└─► 7. Update metadata
|
||||
|
|
@ -252,32 +252,32 @@ sequenceDiagram
|
|||
Time →
|
||||
|
||||
Client A Server Client B
|
||||
│ │ │
|
||||
│ Edit file v10 │ │
|
||||
│ "Add line A" │ │ Edit file v10
|
||||
│ │ │ "Add line B"
|
||||
│ │ │
|
||||
├─── Upload @ t1 ─────────►│ │
|
||||
│ │◄────── Upload @ t2 ────────┤
|
||||
│ │ │
|
||||
│ │ 1. Receive both edits │
|
||||
│ │ (based on v10) │
|
||||
│ │ │
|
||||
│ │ 2. Apply first edit │
|
||||
│ │ → v11 (line A added) │
|
||||
│ │ │
|
||||
│ │ 3. Transform second edit │
|
||||
│ │ against first │
|
||||
│ │ │
|
||||
│ │ 4. Apply transformed edit │
|
||||
│ │ → v12 (both lines) │
|
||||
│ │ │
|
||||
│◄──── v12 content ────────┤ │
|
||||
│ ├───── v12 content ─────────►│
|
||||
│ │ │
|
||||
│ Apply v12 │ │ Apply v12
|
||||
│ (has both lines) │ │ (has both lines)
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ Edit file v10 │ │
|
||||
│ "Add line A" │ │ Edit file v10
|
||||
│ │ │ "Add line B"
|
||||
│ │ │
|
||||
├─── Upload @ t1 ─────────►│ │
|
||||
│ │◄────── Upload @ t2 ────────┤
|
||||
│ │ │
|
||||
│ │ 1. Receive both edits │
|
||||
│ │ (based on v10) │
|
||||
│ │ │
|
||||
│ │ 2. Apply first edit │
|
||||
│ │ → v11 (line A added) │
|
||||
│ │ │
|
||||
│ │ 3. Transform second edit │
|
||||
│ │ against first │
|
||||
│ │ │
|
||||
│ │ 4. Apply transformed edit │
|
||||
│ │ → v12 (both lines) │
|
||||
│ │ │
|
||||
│◄──── v12 content ────────┤ │
|
||||
│ ├───── v12 content ─────────►│
|
||||
│ │ │
|
||||
│ Apply v12 │ │ Apply v12
|
||||
│ (has both lines) │ │ (has both lines)
|
||||
│ │ │
|
||||
```
|
||||
|
||||
### Conflict Resolution Steps
|
||||
|
|
@ -361,11 +361,11 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "upload_file",
|
||||
"path": "notes/example.md",
|
||||
"content": "File content here...",
|
||||
"base_version": 10,
|
||||
"timestamp": "2024-01-01T12:00:00Z"
|
||||
"type": "upload_file",
|
||||
"path": "notes/example.md",
|
||||
"content": "File content here...",
|
||||
"base_version": 10,
|
||||
"timestamp": "2024-01-01T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -373,8 +373,8 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "download_file",
|
||||
"path": "notes/example.md"
|
||||
"type": "download_file",
|
||||
"path": "notes/example.md"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -382,8 +382,8 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "delete_file",
|
||||
"path": "notes/old.md"
|
||||
"type": "delete_file",
|
||||
"path": "notes/old.md"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -391,8 +391,8 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "list_files",
|
||||
"since_version": 0
|
||||
"type": "list_files",
|
||||
"since_version": 0
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -402,11 +402,11 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "file_updated",
|
||||
"path": "notes/example.md",
|
||||
"version": 11,
|
||||
"size": 1024,
|
||||
"hash": "abc123..."
|
||||
"type": "file_updated",
|
||||
"path": "notes/example.md",
|
||||
"version": 11,
|
||||
"size": 1024,
|
||||
"hash": "abc123..."
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -414,10 +414,10 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "file_content",
|
||||
"path": "notes/example.md",
|
||||
"content": "Updated content...",
|
||||
"version": 11
|
||||
"type": "file_content",
|
||||
"path": "notes/example.md",
|
||||
"content": "Updated content...",
|
||||
"version": 11
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -425,9 +425,9 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "file_deleted",
|
||||
"path": "notes/old.md",
|
||||
"version": 12
|
||||
"type": "file_deleted",
|
||||
"path": "notes/old.md",
|
||||
"version": 12
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -435,9 +435,9 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "sync_complete",
|
||||
"total_files": 150,
|
||||
"current_version": 200
|
||||
"type": "sync_complete",
|
||||
"total_files": 150,
|
||||
"current_version": 200
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -445,9 +445,9 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "error",
|
||||
"message": "File too large",
|
||||
"code": "FILE_TOO_LARGE"
|
||||
"type": "error",
|
||||
"message": "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 │
|
||||
│ (User A - Device1) │ (User A - Device2│ (Server/Backup) │
|
||||
└──────────┬──────────┴─────────┬─────────┴──────────┬────────┘
|
||||
│ │ │
|
||||
│ WebSocket │ WebSocket │ WebSocket
|
||||
│ │ │
|
||||
└────────────────────┼────────────────────┘
|
||||
│ │ │
|
||||
│ WebSocket │ WebSocket │ WebSocket
|
||||
│ │ │
|
||||
└────────────────────┼────────────────────┘
|
||||
│
|
||||
┌───────────▼───────────┐
|
||||
│ Sync Server │
|
||||
|
|
|
|||
|
|
@ -243,9 +243,9 @@ users:
|
|||
2. Client sends authentication message:
|
||||
```json
|
||||
{
|
||||
"type": "auth",
|
||||
"token": "user-token",
|
||||
"vault": "vault-name"
|
||||
"type": "auth",
|
||||
"token": "user-token",
|
||||
"vault": "vault-name"
|
||||
}
|
||||
```
|
||||
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">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#4A90E2;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#357ABD;stop-opacity:1" />
|
||||
<stop offset="0%" style="stop-color:#4A90E2;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#357ABD;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
|
|
@ -25,23 +25,23 @@
|
|||
|
||||
<!-- Link chain -->
|
||||
<g opacity="0.9">
|
||||
<!-- Left link -->
|
||||
<ellipse cx="-30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
||||
<!-- Right link -->
|
||||
<ellipse cx="30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
||||
<!-- Center link connecting them -->
|
||||
<ellipse cx="0" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
||||
<!-- Left link -->
|
||||
<ellipse cx="-30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
||||
<!-- Right link -->
|
||||
<ellipse cx="30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
||||
<!-- Center link connecting them -->
|
||||
<ellipse cx="0" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
||||
</g>
|
||||
|
||||
<!-- Sync arrows (subtle) -->
|
||||
<g opacity="0.5">
|
||||
<!-- 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"/>
|
||||
<polygon points="50,-15 47,-22 53,-22" fill="url(#grad1)"/>
|
||||
<!-- 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"/>
|
||||
<polygon points="50,-15 47,-22 53,-22" fill="url(#grad1)"/>
|
||||
|
||||
<!-- 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"/>
|
||||
<polygon points="-50,5 -47,12 -53,12" fill="url(#grad1)"/>
|
||||
<!-- 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"/>
|
||||
<polygon points="-50,5 -47,12 -53,12" fill="url(#grad1)"/>
|
||||
</g>
|
||||
</g>
|
||||
</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";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
"sync-client/src/services/types.ts",
|
||||
"**/dist/",
|
||||
"**/*.mjs",
|
||||
"**/*.js"
|
||||
]
|
||||
},
|
||||
...tseslint.config({
|
||||
plugins: {
|
||||
"unused-imports": unusedImports
|
||||
},
|
||||
extends: [eslint.configs.recommended, tseslint.configs.all],
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/restrict-template-expressions": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-floating-promises": [
|
||||
"error",
|
||||
{
|
||||
allowForKnownSafeCalls: [
|
||||
{ from: "package", name: ["suite", "test"], package: "node:test" },
|
||||
],
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/parameter-properties": "off",
|
||||
"@typescript-eslint/require-await": "off",
|
||||
"@typescript-eslint/class-methods-use-this": "off",
|
||||
"@typescript-eslint/consistent-return": "off",
|
||||
"@typescript-eslint/no-unsafe-argument": "off",
|
||||
"@typescript-eslint/max-params": "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."
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
"sync-client/src/services/types.ts",
|
||||
"**/dist/",
|
||||
"**/*.mjs",
|
||||
"**/*.js"
|
||||
]
|
||||
},
|
||||
...tseslint.config({
|
||||
plugins: {
|
||||
"unused-imports": unusedImports
|
||||
},
|
||||
extends: [eslint.configs.recommended, tseslint.configs.all],
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/restrict-template-expressions": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-floating-promises": [
|
||||
"error",
|
||||
{
|
||||
object: "Promise",
|
||||
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."
|
||||
}
|
||||
],
|
||||
"unused-imports/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
vars: "all",
|
||||
varsIgnorePattern: "^_",
|
||||
args: "after-used",
|
||||
argsIgnorePattern: "^_"
|
||||
}
|
||||
]
|
||||
},
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname
|
||||
}
|
||||
}
|
||||
})
|
||||
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",
|
||||
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",
|
||||
"version": "0.12.0",
|
||||
"description": "Standalone CLI for VaultLink sync client",
|
||||
"private": false,
|
||||
"bin": {
|
||||
"vaultlink": "./dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "webpack watch --mode development",
|
||||
"build": "webpack --mode production",
|
||||
"test": "tsx --test src/args.test.ts src/node-filesystem.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^14.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.8.1",
|
||||
"sync-client": "file:../sync-client",
|
||||
"ts-loader": "^9.5.2",
|
||||
"tslib": "2.8.1",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "5.8.3",
|
||||
"webpack": "^5.99.9",
|
||||
"webpack-cli": "^6.0.1"
|
||||
}
|
||||
"name": "local-client-cli",
|
||||
"version": "0.12.0",
|
||||
"description": "Standalone CLI for VaultLink sync client",
|
||||
"private": false,
|
||||
"bin": {
|
||||
"vaultlink": "./dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "webpack watch --mode development",
|
||||
"build": "webpack --mode production",
|
||||
"test": "tsx --test 'src/**/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^14.0.2",
|
||||
"watcher": "^2.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.8.1",
|
||||
"sync-client": "file:../sync-client",
|
||||
"ts-loader": "^9.5.2",
|
||||
"tslib": "2.8.1",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "5.8.3",
|
||||
"webpack": "^5.99.9",
|
||||
"webpack-cli": "^6.0.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,227 +4,227 @@ import { parseArgs } from "./args";
|
|||
import { LogLevel } from "sync-client";
|
||||
|
||||
test("parseArgs - parse basic arguments", () => {
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default"
|
||||
]);
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default"
|
||||
]);
|
||||
|
||||
assert.equal(args.localPath, "/path/to/vault");
|
||||
assert.equal(args.remoteUri, "https://sync.example.com");
|
||||
assert.equal(args.token, "mytoken");
|
||||
assert.equal(args.vaultName, "default");
|
||||
assert.equal(args.localPath, "/path/to/vault");
|
||||
assert.equal(args.remoteUri, "https://sync.example.com");
|
||||
assert.equal(args.token, "mytoken");
|
||||
assert.equal(args.vaultName, "default");
|
||||
});
|
||||
|
||||
test("parseArgs - parse long form arguments", () => {
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"--local-path",
|
||||
"/path/to/vault",
|
||||
"--remote-uri",
|
||||
"https://sync.example.com",
|
||||
"--token",
|
||||
"mytoken",
|
||||
"--vault-name",
|
||||
"default"
|
||||
]);
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"--local-path",
|
||||
"/path/to/vault",
|
||||
"--remote-uri",
|
||||
"https://sync.example.com",
|
||||
"--token",
|
||||
"mytoken",
|
||||
"--vault-name",
|
||||
"default"
|
||||
]);
|
||||
|
||||
assert.equal(args.localPath, "/path/to/vault");
|
||||
assert.equal(args.remoteUri, "https://sync.example.com");
|
||||
assert.equal(args.token, "mytoken");
|
||||
assert.equal(args.vaultName, "default");
|
||||
assert.equal(args.localPath, "/path/to/vault");
|
||||
assert.equal(args.remoteUri, "https://sync.example.com");
|
||||
assert.equal(args.token, "mytoken");
|
||||
assert.equal(args.vaultName, "default");
|
||||
});
|
||||
|
||||
test("parseArgs - parse with optional arguments", () => {
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default",
|
||||
"--sync-concurrency",
|
||||
"5",
|
||||
"--max-file-size-mb",
|
||||
"20"
|
||||
]);
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default",
|
||||
"--sync-concurrency",
|
||||
"5",
|
||||
"--max-file-size-mb",
|
||||
"20"
|
||||
]);
|
||||
|
||||
assert.equal(args.syncConcurrency, 5);
|
||||
assert.equal(args.maxFileSizeMB, 20);
|
||||
assert.equal(args.syncConcurrency, 5);
|
||||
assert.equal(args.maxFileSizeMB, 20);
|
||||
});
|
||||
|
||||
test("parseArgs - parse with multiple ignore patterns", () => {
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default",
|
||||
"--ignore-pattern",
|
||||
".git/**",
|
||||
"*.tmp"
|
||||
]);
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default",
|
||||
"--ignore-pattern",
|
||||
".git/**",
|
||||
"*.tmp"
|
||||
]);
|
||||
|
||||
assert.deepEqual(args.ignorePatterns, [".git/**", "*.tmp"]);
|
||||
assert.deepEqual(args.ignorePatterns, [".git/**", "*.tmp"]);
|
||||
});
|
||||
|
||||
test("parseArgs - throws on missing required arguments", () => {
|
||||
assert.throws(() => {
|
||||
parseArgs(["node", "cli.js", "-r", "https://sync.example.com"]);
|
||||
}, /required option/);
|
||||
assert.throws(() => {
|
||||
parseArgs(["node", "cli.js", "-r", "https://sync.example.com"]);
|
||||
}, /required option/);
|
||||
});
|
||||
|
||||
test("parseArgs - throws on missing remote uri", () => {
|
||||
assert.throws(() => {
|
||||
parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default"
|
||||
]);
|
||||
}, /--remote-uri/);
|
||||
assert.throws(() => {
|
||||
parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default"
|
||||
]);
|
||||
}, /--remote-uri/);
|
||||
});
|
||||
|
||||
test("parseArgs - throws on missing token", () => {
|
||||
assert.throws(() => {
|
||||
parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-v",
|
||||
"default"
|
||||
]);
|
||||
}, /--token/);
|
||||
assert.throws(() => {
|
||||
parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-v",
|
||||
"default"
|
||||
]);
|
||||
}, /--token/);
|
||||
});
|
||||
|
||||
test("parseArgs - throws on missing vault name", () => {
|
||||
assert.throws(() => {
|
||||
parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken"
|
||||
]);
|
||||
}, /--vault-name/);
|
||||
assert.throws(() => {
|
||||
parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken"
|
||||
]);
|
||||
}, /--vault-name/);
|
||||
});
|
||||
|
||||
test("parseArgs - default log level is INFO", () => {
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default"
|
||||
]);
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default"
|
||||
]);
|
||||
|
||||
assert.equal(args.logLevel, LogLevel.INFO);
|
||||
assert.equal(args.logLevel, LogLevel.INFO);
|
||||
});
|
||||
|
||||
test("parseArgs - parse DEBUG log level", () => {
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default",
|
||||
"--log-level",
|
||||
"DEBUG"
|
||||
]);
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default",
|
||||
"--log-level",
|
||||
"DEBUG"
|
||||
]);
|
||||
|
||||
assert.equal(args.logLevel, LogLevel.DEBUG);
|
||||
assert.equal(args.logLevel, LogLevel.DEBUG);
|
||||
});
|
||||
|
||||
test("parseArgs - parse ERROR log level", () => {
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default",
|
||||
"--log-level",
|
||||
"ERROR"
|
||||
]);
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default",
|
||||
"--log-level",
|
||||
"ERROR"
|
||||
]);
|
||||
|
||||
assert.equal(args.logLevel, LogLevel.ERROR);
|
||||
assert.equal(args.logLevel, LogLevel.ERROR);
|
||||
});
|
||||
|
||||
test("parseArgs - log level is case insensitive", () => {
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default",
|
||||
"--log-level",
|
||||
"debug"
|
||||
]);
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default",
|
||||
"--log-level",
|
||||
"debug"
|
||||
]);
|
||||
|
||||
assert.equal(args.logLevel, LogLevel.DEBUG);
|
||||
assert.equal(args.logLevel, LogLevel.DEBUG);
|
||||
});
|
||||
|
||||
test("parseArgs - throws on invalid log level", () => {
|
||||
assert.throws(() => {
|
||||
parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default",
|
||||
"--log-level",
|
||||
"INVALID"
|
||||
]);
|
||||
}, /Invalid log level/);
|
||||
assert.throws(() => {
|
||||
parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default",
|
||||
"--log-level",
|
||||
"INVALID"
|
||||
]);
|
||||
}, /Invalid log level/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,134 +3,134 @@ import packageJson from "../package.json";
|
|||
import { LogLevel } from "sync-client";
|
||||
|
||||
export interface CliArgs {
|
||||
remoteUri: string;
|
||||
token: string;
|
||||
vaultName: string;
|
||||
localPath: string;
|
||||
syncConcurrency?: number;
|
||||
maxFileSizeMB?: number;
|
||||
ignorePatterns?: string[];
|
||||
webSocketRetryIntervalMs?: number;
|
||||
logLevel: LogLevel;
|
||||
health?: string;
|
||||
enableTelemetry?: boolean;
|
||||
remoteUri: string;
|
||||
token: string;
|
||||
vaultName: string;
|
||||
localPath: string;
|
||||
syncConcurrency?: number;
|
||||
maxFileSizeMB?: number;
|
||||
ignorePatterns?: string[];
|
||||
webSocketRetryIntervalMs?: number;
|
||||
logLevel: LogLevel;
|
||||
health?: string;
|
||||
enableTelemetry?: boolean;
|
||||
}
|
||||
|
||||
export function parseArgs(argv: string[]): CliArgs {
|
||||
const program = new Command();
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name("vaultlink")
|
||||
.description(
|
||||
"VaultLink Local CLI - Sync your vault to the local filesystem"
|
||||
)
|
||||
.version(packageJson.version)
|
||||
.option("-l, --local-path <path>", "Local directory path to sync")
|
||||
.option("-r, --remote-uri <uri>", "Remote server URI")
|
||||
.option("-t, --token <token>", "Authentication token")
|
||||
.option("-v, --vault-name <name>", "Vault name")
|
||||
.option(
|
||||
"--sync-concurrency <number>",
|
||||
"[OPTIONAL] Number of concurrent sync operations",
|
||||
parseInt
|
||||
)
|
||||
.option(
|
||||
"--max-file-size-mb <number>",
|
||||
"[OPTIONAL] Maximum file size in MB",
|
||||
parseInt
|
||||
)
|
||||
.option(
|
||||
"--ignore-pattern <pattern...>",
|
||||
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
|
||||
)
|
||||
.option(
|
||||
"--websocket-retry-interval-ms <number>",
|
||||
"[OPTIONAL] WebSocket retry interval in milliseconds",
|
||||
parseInt
|
||||
)
|
||||
.option(
|
||||
"--log-level <level>",
|
||||
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)",
|
||||
"INFO"
|
||||
)
|
||||
.option(
|
||||
"--health <path>",
|
||||
"[OPTIONAL] Path to health status file for Docker healthcheck"
|
||||
)
|
||||
.option(
|
||||
"--enable-telemetry",
|
||||
"[OPTIONAL] Enable telemetry (disabled by default)"
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
program
|
||||
.name("vaultlink")
|
||||
.description(
|
||||
"VaultLink Local CLI - Sync your vault to the local filesystem"
|
||||
)
|
||||
.version(packageJson.version)
|
||||
.option("-l, --local-path <path>", "Local directory path to sync")
|
||||
.option("-r, --remote-uri <uri>", "Remote server URI")
|
||||
.option("-t, --token <token>", "Authentication token")
|
||||
.option("-v, --vault-name <name>", "Vault name")
|
||||
.option(
|
||||
"--sync-concurrency <number>",
|
||||
"[OPTIONAL] Number of concurrent sync operations",
|
||||
parseInt
|
||||
)
|
||||
.option(
|
||||
"--max-file-size-mb <number>",
|
||||
"[OPTIONAL] Maximum file size in MB",
|
||||
parseInt
|
||||
)
|
||||
.option(
|
||||
"--ignore-pattern <pattern...>",
|
||||
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
|
||||
)
|
||||
.option(
|
||||
"--websocket-retry-interval-ms <number>",
|
||||
"[OPTIONAL] WebSocket retry interval in milliseconds",
|
||||
parseInt
|
||||
)
|
||||
.option(
|
||||
"--log-level <level>",
|
||||
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)",
|
||||
"INFO"
|
||||
)
|
||||
.option(
|
||||
"--health <path>",
|
||||
"[OPTIONAL] Path to health status file for Docker healthcheck"
|
||||
)
|
||||
.option(
|
||||
"--enable-telemetry",
|
||||
"[OPTIONAL] Enable telemetry (disabled by default)"
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
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 \\
|
||||
--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 \\
|
||||
--log-level DEBUG
|
||||
--log-level DEBUG
|
||||
`
|
||||
);
|
||||
);
|
||||
|
||||
program.parse(argv);
|
||||
program.parse(argv);
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
||||
const opts = program.opts();
|
||||
const localPath = opts.localPath as string | undefined;
|
||||
const remoteUri = opts.remoteUri as string | undefined;
|
||||
const token = opts.token as string | undefined;
|
||||
const vaultName = opts.vaultName as string | undefined;
|
||||
const syncConcurrency = opts.syncConcurrency as number | undefined;
|
||||
const maxFileSizeMb = opts.maxFileSizeMb as number | undefined;
|
||||
const ignorePattern = opts.ignorePattern as string[] | undefined;
|
||||
const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as
|
||||
| number
|
||||
| undefined;
|
||||
const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO";
|
||||
const health = opts.health as string | undefined;
|
||||
const enableTelemetry = opts.enableTelemetry as boolean | undefined;
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
||||
const opts = program.opts();
|
||||
const localPath = opts.localPath as string | undefined;
|
||||
const remoteUri = opts.remoteUri as string | undefined;
|
||||
const token = opts.token as string | undefined;
|
||||
const vaultName = opts.vaultName as string | undefined;
|
||||
const syncConcurrency = opts.syncConcurrency as number | undefined;
|
||||
const maxFileSizeMb = opts.maxFileSizeMb as number | undefined;
|
||||
const ignorePattern = opts.ignorePattern as string[] | undefined;
|
||||
const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as
|
||||
| number
|
||||
| undefined;
|
||||
const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO";
|
||||
const health = opts.health as string | undefined;
|
||||
const enableTelemetry = opts.enableTelemetry as boolean | undefined;
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
||||
|
||||
if (localPath === undefined) {
|
||||
throw new Error(
|
||||
"required option '-l, --local-path <path>' not specified"
|
||||
);
|
||||
}
|
||||
if (remoteUri === undefined) {
|
||||
throw new Error("required option '--remote-uri <uri>' not specified");
|
||||
}
|
||||
if (token === undefined) {
|
||||
throw new Error("required option '--token <token>' not specified");
|
||||
}
|
||||
if (vaultName === undefined) {
|
||||
throw new Error("required option '--vault-name <name>' not specified");
|
||||
}
|
||||
if (localPath === undefined) {
|
||||
throw new Error(
|
||||
"required option '-l, --local-path <path>' not specified"
|
||||
);
|
||||
}
|
||||
if (remoteUri === undefined) {
|
||||
throw new Error("required option '--remote-uri <uri>' not specified");
|
||||
}
|
||||
if (token === undefined) {
|
||||
throw new Error("required option '--token <token>' not specified");
|
||||
}
|
||||
if (vaultName === undefined) {
|
||||
throw new Error("required option '--vault-name <name>' not specified");
|
||||
}
|
||||
|
||||
// Validate and parse log level
|
||||
const logLevelUpper = logLevelStr.toUpperCase();
|
||||
const validLogLevels = Object.values(LogLevel);
|
||||
const isLogLevel = (value: string): value is LogLevel => {
|
||||
return (validLogLevels as readonly string[]).includes(value);
|
||||
};
|
||||
if (!isLogLevel(logLevelUpper)) {
|
||||
throw new Error(
|
||||
`Invalid log level '${logLevelStr}'. Valid values are: ${validLogLevels.join(", ")}`
|
||||
);
|
||||
}
|
||||
const logLevel = logLevelUpper;
|
||||
// Validate and parse log level
|
||||
const logLevelUpper = logLevelStr.toUpperCase();
|
||||
const validLogLevels = Object.values(LogLevel);
|
||||
const isLogLevel = (value: string): value is LogLevel => {
|
||||
return (validLogLevels as readonly string[]).includes(value);
|
||||
};
|
||||
if (!isLogLevel(logLevelUpper)) {
|
||||
throw new Error(
|
||||
`Invalid log level '${logLevelStr}'. Valid values are: ${validLogLevels.join(", ")}`
|
||||
);
|
||||
}
|
||||
const logLevel = logLevelUpper;
|
||||
|
||||
return {
|
||||
localPath,
|
||||
remoteUri,
|
||||
token,
|
||||
vaultName,
|
||||
syncConcurrency,
|
||||
maxFileSizeMB: maxFileSizeMb,
|
||||
ignorePatterns: ignorePattern,
|
||||
webSocketRetryIntervalMs: websocketRetryIntervalMs,
|
||||
logLevel,
|
||||
health,
|
||||
enableTelemetry
|
||||
};
|
||||
return {
|
||||
localPath,
|
||||
remoteUri,
|
||||
token,
|
||||
vaultName,
|
||||
syncConcurrency,
|
||||
maxFileSizeMB: maxFileSizeMb,
|
||||
ignorePatterns: ignorePattern,
|
||||
webSocketRetryIntervalMs: websocketRetryIntervalMs,
|
||||
logLevel,
|
||||
health,
|
||||
enableTelemetry
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ import * as fs from "fs/promises";
|
|||
import * as fsSync from "fs";
|
||||
import type { NetworkConnectionStatus } from "sync-client";
|
||||
import {
|
||||
SyncClient,
|
||||
DEFAULT_SETTINGS,
|
||||
LogLevel,
|
||||
type SyncSettings,
|
||||
type StoredDatabase
|
||||
SyncClient,
|
||||
DEFAULT_SETTINGS,
|
||||
LogLevel,
|
||||
type SyncSettings,
|
||||
type StoredDatabase
|
||||
} from "sync-client";
|
||||
import { parseArgs } from "./args";
|
||||
import { NodeFileSystemOperations } from "./node-filesystem";
|
||||
|
|
@ -16,229 +16,233 @@ import { formatLogLine, colorize, styleText } from "./logger-formatter";
|
|||
import packageJson from "../package.json";
|
||||
|
||||
function writeHealthStatus(
|
||||
filePath: string,
|
||||
connectionStatus: NetworkConnectionStatus
|
||||
filePath: string,
|
||||
connectionStatus: NetworkConnectionStatus
|
||||
): void {
|
||||
try {
|
||||
fsSync.writeFileSync(filePath, JSON.stringify(connectionStatus));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to write health status to ${filePath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
fsSync.writeFileSync(filePath, JSON.stringify(connectionStatus));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to write health status to ${filePath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const LOG_LEVEL_ORDER = {
|
||||
[LogLevel.DEBUG]: 0,
|
||||
[LogLevel.INFO]: 1,
|
||||
[LogLevel.WARNING]: 2,
|
||||
[LogLevel.ERROR]: 3
|
||||
[LogLevel.DEBUG]: 0,
|
||||
[LogLevel.INFO]: 1,
|
||||
[LogLevel.WARNING]: 2,
|
||||
[LogLevel.ERROR]: 3
|
||||
};
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv);
|
||||
const absolutePath = path.resolve(args.localPath);
|
||||
const args = parseArgs(process.argv);
|
||||
const absolutePath = path.resolve(args.localPath);
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(absolutePath);
|
||||
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);
|
||||
}
|
||||
if (!fsSync.existsSync(absolutePath)) {
|
||||
fsSync.mkdirSync(absolutePath, { recursive: true });
|
||||
}
|
||||
|
||||
console.log(
|
||||
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("");
|
||||
try {
|
||||
const stats = await fs.stat(absolutePath);
|
||||
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);
|
||||
}
|
||||
|
||||
const dataDir = path.join(absolutePath, ".vaultlink");
|
||||
const dataFile = path.join(dataDir, "sync-data.json");
|
||||
console.log(
|
||||
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 = [
|
||||
...(args.ignorePatterns ?? []),
|
||||
".vaultlink/**",
|
||||
".git/**"
|
||||
];
|
||||
const fileSystem = new NodeFileSystemOperations(absolutePath);
|
||||
|
||||
const settings: SyncSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteUri: args.remoteUri,
|
||||
token: args.token,
|
||||
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 ignorePatterns = [
|
||||
...(args.ignorePatterns ?? []),
|
||||
".vaultlink/**",
|
||||
".git/**"
|
||||
];
|
||||
|
||||
const client = await SyncClient.create({
|
||||
fs: fileSystem,
|
||||
persistence: {
|
||||
load: async () => {
|
||||
let database: Partial<StoredDatabase> | undefined = undefined;
|
||||
try {
|
||||
const content = await fs.readFile(dataFile, "utf-8");
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
database = JSON.parse(content) as Partial<StoredDatabase>;
|
||||
} catch {
|
||||
console.error(
|
||||
colorize(
|
||||
`Cannot read data file at ${dataFile}`,
|
||||
"yellow"
|
||||
)
|
||||
);
|
||||
}
|
||||
const settings: SyncSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteUri: args.remoteUri,
|
||||
token: args.token,
|
||||
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
|
||||
};
|
||||
|
||||
return {
|
||||
settings,
|
||||
database
|
||||
};
|
||||
},
|
||||
save: async ({ database: persistedDatabase }) => {
|
||||
// settings can't be updated when running with this CLI
|
||||
await fs.writeFile(
|
||||
dataFile,
|
||||
JSON.stringify(persistedDatabase, null, 2)
|
||||
);
|
||||
}
|
||||
},
|
||||
nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n"
|
||||
});
|
||||
const client = await SyncClient.create({
|
||||
fs: fileSystem,
|
||||
persistence: {
|
||||
load: async () => {
|
||||
let database: Partial<StoredDatabase> | undefined = undefined;
|
||||
try {
|
||||
const content = await fs.readFile(dataFile, "utf-8");
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
database = JSON.parse(content) as Partial<StoredDatabase>;
|
||||
} catch {
|
||||
console.error(
|
||||
colorize(
|
||||
`Cannot read data file at ${dataFile}`,
|
||||
"yellow"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (args.health !== undefined) {
|
||||
const healthFile = args.health;
|
||||
const healthInterval = setInterval(() => {
|
||||
void client.checkConnection().then((status) => {
|
||||
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);
|
||||
}
|
||||
return {
|
||||
settings,
|
||||
database
|
||||
};
|
||||
},
|
||||
save: async ({ database: persistedDatabase }) => {
|
||||
// settings can't be updated when running with this CLI
|
||||
await fs.writeFile(
|
||||
dataFile,
|
||||
JSON.stringify(persistedDatabase, null, 2)
|
||||
);
|
||||
}
|
||||
},
|
||||
nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n"
|
||||
});
|
||||
|
||||
// Add colored log formatter with level filtering
|
||||
client.logger.addOnMessageListener((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));
|
||||
}
|
||||
});
|
||||
if (args.health !== undefined) {
|
||||
const healthFile = args.health;
|
||||
const healthInterval = setInterval(() => {
|
||||
void client.checkConnection().then((status) => {
|
||||
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 isConnected = client.isWebSocketConnected;
|
||||
client.logger.info(
|
||||
`WebSocket status changed: ${isConnected ? "connected" : "disconnected"}`
|
||||
);
|
||||
});
|
||||
const fileWatcher = new FileWatcher(absolutePath, client);
|
||||
|
||||
client.addRemainingSyncOperationsListener((remaining) => {
|
||||
if (remaining === 0) {
|
||||
client.logger.info("All sync operations completed");
|
||||
} else {
|
||||
client.logger.info(`${remaining} sync operations remaining`);
|
||||
}
|
||||
});
|
||||
client.onWebSocketStatusChanged.add(() => {
|
||||
const isConnected = client.isWebSocketConnected;
|
||||
client.logger.info(
|
||||
`WebSocket status changed: ${isConnected ? "connected" : "disconnected"}`
|
||||
);
|
||||
});
|
||||
|
||||
const gracefulShutdown = async (signal: string): Promise<void> => {
|
||||
console.log(
|
||||
colorize(
|
||||
`\n${signal} received. Shutting down gracefully...`,
|
||||
"yellow"
|
||||
)
|
||||
);
|
||||
client.onRemainingOperationsCountChanged.add((remaining) => {
|
||||
if (remaining === 0) {
|
||||
client.logger.info("All sync operations completed");
|
||||
} else {
|
||||
client.logger.info(`${remaining} sync operations remaining`);
|
||||
}
|
||||
});
|
||||
|
||||
fileWatcher.stop();
|
||||
await client.waitUntilFinished();
|
||||
await client.destroy();
|
||||
console.log(colorize("Shutdown complete", "green"));
|
||||
process.exit(0);
|
||||
};
|
||||
const gracefulShutdown = async (signal: string): Promise<void> => {
|
||||
console.log(
|
||||
colorize(
|
||||
`\n${signal} received. Shutting down gracefully...`,
|
||||
"yellow"
|
||||
)
|
||||
);
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
void gracefulShutdown("SIGINT");
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
void gracefulShutdown("SIGTERM");
|
||||
});
|
||||
fileWatcher.stop();
|
||||
await client.waitUntilFinished();
|
||||
await client.destroy();
|
||||
console.log(colorize("Shutdown complete", "green"));
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
try {
|
||||
const connectionStatus = await client.checkConnection();
|
||||
if (!connectionStatus.isSuccessful) {
|
||||
console.error(
|
||||
colorize(
|
||||
`Error: Cannot connect to server: ${connectionStatus.serverMessage}`,
|
||||
"red"
|
||||
)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
process.on("SIGINT", () => {
|
||||
void gracefulShutdown("SIGINT");
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
void gracefulShutdown("SIGTERM");
|
||||
});
|
||||
|
||||
console.log(`${colorize("✓", "green")} Server connection successful`);
|
||||
console.log(colorize("Press Ctrl+C to stop", "dim"));
|
||||
console.log("");
|
||||
try {
|
||||
const connectionStatus = await client.checkConnection();
|
||||
if (!connectionStatus.isSuccessful) {
|
||||
console.error(
|
||||
colorize(
|
||||
`Error: Cannot connect to server: ${connectionStatus.serverMessage}`,
|
||||
"red"
|
||||
)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await client.start();
|
||||
fileWatcher.start();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
colorize(
|
||||
`Fatal error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
"red"
|
||||
)
|
||||
);
|
||||
console.log(`${colorize("✓", "green")} Server connection successful`);
|
||||
console.log(colorize("Press Ctrl+C to stop", "dim"));
|
||||
console.log("");
|
||||
|
||||
fileWatcher.stop();
|
||||
await client.destroy();
|
||||
process.exit(1);
|
||||
}
|
||||
await client.start();
|
||||
fileWatcher.start();
|
||||
} 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) => {
|
||||
console.error(
|
||||
colorize(
|
||||
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
"red"
|
||||
)
|
||||
);
|
||||
process.exit(1);
|
||||
console.error(
|
||||
colorize(
|
||||
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
"red"
|
||||
)
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,102 +1,121 @@
|
|||
import * as fs from "fs";
|
||||
import Watcher from "watcher";
|
||||
import * as path from "path";
|
||||
import type { SyncClient, RelativePath } from "sync-client";
|
||||
|
||||
export class FileWatcher {
|
||||
private watcher: fs.FSWatcher | undefined;
|
||||
private isRunning = false;
|
||||
private watcher: Watcher | undefined;
|
||||
private isRunning = false;
|
||||
|
||||
public constructor(
|
||||
private readonly basePath: string,
|
||||
private readonly client: SyncClient
|
||||
) {}
|
||||
public constructor(
|
||||
private readonly basePath: string,
|
||||
private readonly client: SyncClient
|
||||
) {}
|
||||
|
||||
public start(): void {
|
||||
if (this.isRunning) {
|
||||
return;
|
||||
}
|
||||
public start(): void {
|
||||
if (this.isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
this.isRunning = true;
|
||||
|
||||
this.watcher = fs.watch(
|
||||
this.basePath,
|
||||
{ recursive: true },
|
||||
(eventType, filename) => {
|
||||
if (filename === null || filename.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.watcher = new Watcher(this.basePath, {
|
||||
recursive: true,
|
||||
renameDetection: true,
|
||||
renameTimeout: 125,
|
||||
ignoreInitial: true
|
||||
});
|
||||
|
||||
// Convert to forward slashes for consistency
|
||||
const relativePath = this.toUnixPath(filename);
|
||||
this.watcher.on("add", (filePath: string) => {
|
||||
this.handleCreate(this.toRelativePath(filePath));
|
||||
});
|
||||
|
||||
if (eventType === "rename") {
|
||||
this.handleRenameOrDelete(relativePath);
|
||||
} else {
|
||||
// Must be "change" event
|
||||
this.handleChange(relativePath);
|
||||
}
|
||||
}
|
||||
);
|
||||
this.watcher.on("change", (filePath: string) => {
|
||||
this.handleChange(this.toRelativePath(filePath));
|
||||
});
|
||||
|
||||
this.client.logger.info("File watcher started");
|
||||
}
|
||||
this.watcher.on("unlink", (filePath: string) => {
|
||||
this.handleDelete(this.toRelativePath(filePath));
|
||||
});
|
||||
|
||||
public stop(): void {
|
||||
if (this.watcher !== undefined) {
|
||||
this.watcher.close();
|
||||
this.watcher = undefined;
|
||||
}
|
||||
this.isRunning = false;
|
||||
this.client.logger.info("File watcher stopped");
|
||||
}
|
||||
this.watcher.on("rename", (oldPath: string, newPath: string) => {
|
||||
this.handleRename(
|
||||
this.toRelativePath(oldPath),
|
||||
this.toRelativePath(newPath)
|
||||
);
|
||||
});
|
||||
|
||||
private handleChange(relativePath: RelativePath): void {
|
||||
this.client
|
||||
.syncLocallyUpdatedFile({ relativePath })
|
||||
.catch((err: unknown) => {
|
||||
this.client.logger.error(
|
||||
`Failed to sync updated file ${relativePath}: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
this.client.logger.info("File watcher started");
|
||||
}
|
||||
|
||||
private handleRenameOrDelete(relativePath: RelativePath): void {
|
||||
const fullPath = path.join(this.basePath, relativePath);
|
||||
public stop(): void {
|
||||
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) => {
|
||||
if (accessError) {
|
||||
this.client
|
||||
.syncLocallyDeletedFile(relativePath)
|
||||
.catch((deleteErr: unknown) => {
|
||||
this.client.logger.error(
|
||||
`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;
|
||||
}
|
||||
private handleCreate(relativePath: RelativePath): void {
|
||||
this.client
|
||||
.syncLocallyCreatedFile(relativePath)
|
||||
.catch((err: unknown) => {
|
||||
this.client.logger.error(
|
||||
`Failed to sync created file ${relativePath}: ${this.formatError(err)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
this.client
|
||||
.syncLocallyCreatedFile(relativePath)
|
||||
.catch((createErr: unknown) => {
|
||||
this.client.logger.error(
|
||||
`Failed to sync created file ${relativePath}: ${createErr instanceof Error ? createErr.message : String(createErr)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
private handleChange(relativePath: RelativePath): void {
|
||||
this.client
|
||||
.syncLocallyUpdatedFile({ relativePath })
|
||||
.catch((err: unknown) => {
|
||||
this.client.logger.error(
|
||||
`Failed to sync updated file ${relativePath}: ${this.formatError(err)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a native platform path to forward slashes
|
||||
*/
|
||||
private toUnixPath(nativePath: string): string {
|
||||
if (path.sep === "\\") {
|
||||
return nativePath.replace(/\\/g, "/");
|
||||
}
|
||||
return nativePath;
|
||||
}
|
||||
private handleDelete(relativePath: RelativePath): void {
|
||||
this.client
|
||||
.syncLocallyDeletedFile(relativePath)
|
||||
.catch((err: unknown) => {
|
||||
this.client.logger.error(
|
||||
`Failed to sync deleted file ${relativePath}: ${this.formatError(err)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
function isHealthStatus(value: unknown): value is NetworkConnectionStatus {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
"isSuccessful" in value &&
|
||||
typeof value.isSuccessful === "boolean" &&
|
||||
"isWebSocketConnected" in value &&
|
||||
typeof value.isWebSocketConnected === "boolean" &&
|
||||
"serverMessage" in value &&
|
||||
typeof value.serverMessage === "string"
|
||||
);
|
||||
return (
|
||||
"isSuccessful" in value &&
|
||||
typeof value.isSuccessful === "boolean" &&
|
||||
"isWebSocketConnected" in value &&
|
||||
typeof value.isWebSocketConnected === "boolean" &&
|
||||
"serverMessage" in value &&
|
||||
typeof value.serverMessage === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
if (process.argv.length < 3) {
|
||||
console.error("Usage: healthcheck <path-to-health-file>");
|
||||
process.exit(1);
|
||||
}
|
||||
const [, , healthFile] = process.argv;
|
||||
if (process.argv.length < 3) {
|
||||
console.error("Usage: healthcheck <path-to-health-file>");
|
||||
process.exit(1);
|
||||
}
|
||||
const [, , healthFile] = process.argv;
|
||||
|
||||
try {
|
||||
// Check if health file exists
|
||||
if (!fs.existsSync(healthFile)) {
|
||||
console.error(`Health file does not exist: ${healthFile}`);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
// Check if health file exists
|
||||
if (!fs.existsSync(healthFile)) {
|
||||
console.error(`Health file does not exist: ${healthFile}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read and parse health status
|
||||
const content = fs.readFileSync(healthFile, "utf-8");
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
// Read and parse health status
|
||||
const content = fs.readFileSync(healthFile, "utf-8");
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
|
||||
// Validate the parsed object using type guard
|
||||
if (!isHealthStatus(parsed)) {
|
||||
throw new Error("Invalid health status format");
|
||||
}
|
||||
// Validate the parsed object using type guard
|
||||
if (!isHealthStatus(parsed)) {
|
||||
throw new Error("Invalid health status format");
|
||||
}
|
||||
|
||||
const status = parsed;
|
||||
const status = parsed;
|
||||
|
||||
if (!status.isSuccessful || !status.isWebSocketConnected) {
|
||||
console.error("Not connected to server: " + status.serverMessage);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!status.isSuccessful || !status.isWebSocketConnected) {
|
||||
console.error("Not connected to server: " + status.serverMessage);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Healthy: Connected to server");
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Health check failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("Healthy: Connected to server");
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Health check failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
|
|||
|
|
@ -2,85 +2,85 @@ import { LogLevel, type LogLine } from "sync-client";
|
|||
|
||||
// ANSI color codes
|
||||
export const colors = {
|
||||
reset: "\x1b[0m",
|
||||
bold: "\x1b[1m",
|
||||
dim: "\x1b[2m",
|
||||
reset: "\x1b[0m",
|
||||
bold: "\x1b[1m",
|
||||
dim: "\x1b[2m",
|
||||
|
||||
// Foreground colors
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
magenta: "\x1b[35m",
|
||||
cyan: "\x1b[36m",
|
||||
gray: "\x1b[90m"
|
||||
// Foreground colors
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
magenta: "\x1b[35m",
|
||||
cyan: "\x1b[36m",
|
||||
gray: "\x1b[90m"
|
||||
} as const;
|
||||
|
||||
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
|
||||
*/
|
||||
export function styleText(
|
||||
text: string,
|
||||
...modifiers: (keyof typeof colors)[]
|
||||
text: string,
|
||||
...modifiers: (keyof typeof colors)[]
|
||||
): string {
|
||||
const prefix = modifiers.map((m) => colors[m]).join("");
|
||||
return `${prefix}${text}${colors.reset}`;
|
||||
const prefix = modifiers.map((m) => colors[m]).join("");
|
||||
return `${prefix}${text}${colors.reset}`;
|
||||
}
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
const [time] = date.toTimeString().split(" ");
|
||||
const ms = date.getMilliseconds().toString().padStart(3, "0");
|
||||
return colorize(`${time}.${ms}`, "gray");
|
||||
const [time] = date.toTimeString().split(" ");
|
||||
const ms = date.getMilliseconds().toString().padStart(3, "0");
|
||||
return colorize(`${time}.${ms}`, "gray");
|
||||
}
|
||||
|
||||
function formatLevel(level: LogLevel): string {
|
||||
const levelStr = level.padEnd(7);
|
||||
switch (level) {
|
||||
case LogLevel.DEBUG:
|
||||
return colorize(levelStr, "cyan");
|
||||
case LogLevel.INFO:
|
||||
return colorize(levelStr, "green");
|
||||
case LogLevel.WARNING:
|
||||
return colorize(levelStr, "yellow");
|
||||
case LogLevel.ERROR:
|
||||
return colorize(levelStr, "red");
|
||||
}
|
||||
const levelStr = level.padEnd(7);
|
||||
switch (level) {
|
||||
case LogLevel.DEBUG:
|
||||
return colorize(levelStr, "cyan");
|
||||
case LogLevel.INFO:
|
||||
return colorize(levelStr, "green");
|
||||
case LogLevel.WARNING:
|
||||
return colorize(levelStr, "yellow");
|
||||
case LogLevel.ERROR:
|
||||
return colorize(levelStr, "red");
|
||||
}
|
||||
}
|
||||
|
||||
function formatMessage(message: string, level: LogLevel): string {
|
||||
// Highlight important parts of the message
|
||||
let formatted = message;
|
||||
// Highlight important parts of the message
|
||||
let formatted = message;
|
||||
|
||||
// Highlight file paths
|
||||
formatted = formatted.replace(
|
||||
/(['"])([^'"]*?\.(json|txt|md|js|ts))(['"])/g,
|
||||
(_, q1, path, _ext, q2) => q1 + colorize(path, "magenta") + q2
|
||||
);
|
||||
// Highlight file paths
|
||||
formatted = formatted.replace(
|
||||
/(['"])([^'"]*?\.(json|txt|md|js|ts))(['"])/g,
|
||||
(_, q1, path, _ext, q2) => q1 + colorize(path, "magenta") + q2
|
||||
);
|
||||
|
||||
// Highlight numbers
|
||||
formatted = formatted.replace(/\b(\d+)\b/g, (num) => colorize(num, "cyan"));
|
||||
// Highlight numbers
|
||||
formatted = formatted.replace(/\b(\d+)\b/g, (num) => colorize(num, "cyan"));
|
||||
|
||||
// Highlight patterns like /regex/
|
||||
formatted = formatted.replace(/(\/\^[^$]*\$\/)/g, (pattern) =>
|
||||
colorize(pattern, "yellow")
|
||||
);
|
||||
// Highlight patterns like /regex/
|
||||
formatted = formatted.replace(/(\/\^[^$]*\$\/)/g, (pattern) =>
|
||||
colorize(pattern, "yellow")
|
||||
);
|
||||
|
||||
// Make error messages bold
|
||||
if (level === LogLevel.ERROR) {
|
||||
formatted = colorize(formatted, "bold");
|
||||
}
|
||||
// Make error messages bold
|
||||
if (level === LogLevel.ERROR) {
|
||||
formatted = colorize(formatted, "bold");
|
||||
}
|
||||
|
||||
return formatted;
|
||||
return formatted;
|
||||
}
|
||||
|
||||
export function formatLogLine(logLine: LogLine): string {
|
||||
const timestamp = formatTimestamp(logLine.timestamp);
|
||||
const level = formatLevel(logLine.level);
|
||||
const message = formatMessage(logLine.message, logLine.level);
|
||||
const timestamp = formatTimestamp(logLine.timestamp);
|
||||
const level = formatLevel(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";
|
||||
|
||||
test("NodeFileSystemOperations - read and write files", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||
|
||||
try {
|
||||
const content = new TextEncoder().encode("Hello, world!");
|
||||
await fsOps.write("test.txt", content);
|
||||
try {
|
||||
const content = new TextEncoder().encode("Hello, world!");
|
||||
await fsOps.write("test.txt", content);
|
||||
|
||||
const readContent = await fsOps.read("test.txt");
|
||||
assert.equal(new TextDecoder().decode(readContent), "Hello, world!");
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
const readContent = await fsOps.read("test.txt");
|
||||
assert.equal(new TextDecoder().decode(readContent), "Hello, world!");
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("NodeFileSystemOperations - create nested directories with forward slashes", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||
|
||||
try {
|
||||
const content = new TextEncoder().encode("Nested file");
|
||||
// Always use forward slashes in API
|
||||
await fsOps.write("dir1/dir2/test.txt", content);
|
||||
try {
|
||||
const content = new TextEncoder().encode("Nested file");
|
||||
// Always use forward slashes in API
|
||||
await fsOps.write("dir1/dir2/test.txt", content);
|
||||
|
||||
const readContent = await fsOps.read("dir1/dir2/test.txt");
|
||||
assert.equal(new TextDecoder().decode(readContent), "Nested file");
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
const readContent = await fsOps.read("dir1/dir2/test.txt");
|
||||
assert.equal(new TextDecoder().decode(readContent), "Nested file");
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("NodeFileSystemOperations - exists with forward slashes", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||
|
||||
try {
|
||||
assert.equal(await fsOps.exists("test.txt"), false);
|
||||
try {
|
||||
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);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
assert.equal(await fsOps.exists("test.txt"), true);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("NodeFileSystemOperations - delete with forward slashes", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||
|
||||
try {
|
||||
await fsOps.write("test.txt", new TextEncoder().encode("test"));
|
||||
assert.equal(await fsOps.exists("test.txt"), true);
|
||||
try {
|
||||
await fsOps.write("test.txt", new TextEncoder().encode("test"));
|
||||
assert.equal(await fsOps.exists("test.txt"), true);
|
||||
|
||||
await fsOps.delete("test.txt");
|
||||
assert.equal(await fsOps.exists("test.txt"), false);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
await fsOps.delete("test.txt");
|
||||
assert.equal(await fsOps.exists("test.txt"), false);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("NodeFileSystemOperations - rename with forward slashes", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||
|
||||
try {
|
||||
const content = new TextEncoder().encode("test content");
|
||||
await fsOps.write("old.txt", content);
|
||||
try {
|
||||
const content = new TextEncoder().encode("test 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("new.txt"), true);
|
||||
assert.equal(await fsOps.exists("old.txt"), false);
|
||||
assert.equal(await fsOps.exists("new.txt"), true);
|
||||
|
||||
const readContent = await fsOps.read("new.txt");
|
||||
assert.equal(new TextDecoder().decode(readContent), "test content");
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
const readContent = await fsOps.read("new.txt");
|
||||
assert.equal(new TextDecoder().decode(readContent), "test content");
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("NodeFileSystemOperations - rename to nested path with forward slashes", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||
|
||||
try {
|
||||
const content = new TextEncoder().encode("test content");
|
||||
await fsOps.write("old.txt", content);
|
||||
try {
|
||||
const content = new TextEncoder().encode("test 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("dir1/dir2/new.txt"), true);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
assert.equal(await fsOps.exists("old.txt"), false);
|
||||
assert.equal(await fsOps.exists("dir1/dir2/new.txt"), true);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("NodeFileSystemOperations - getFileSize", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||
|
||||
try {
|
||||
const content = new TextEncoder().encode("Hello!");
|
||||
await fsOps.write("test.txt", content);
|
||||
try {
|
||||
const content = new TextEncoder().encode("Hello!");
|
||||
await fsOps.write("test.txt", content);
|
||||
|
||||
const size = await fsOps.getFileSize("test.txt");
|
||||
assert.equal(size, content.length);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
const size = await fsOps.getFileSize("test.txt");
|
||||
assert.equal(size, content.length);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("NodeFileSystemOperations - atomicUpdateText", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||
|
||||
try {
|
||||
await fsOps.write("test.txt", new TextEncoder().encode("Hello"));
|
||||
try {
|
||||
await fsOps.write("test.txt", new TextEncoder().encode("Hello"));
|
||||
|
||||
const result = await fsOps.atomicUpdateText("test.txt", (current) => ({
|
||||
text: current.text + " World",
|
||||
cursors: []
|
||||
}));
|
||||
const result = await fsOps.atomicUpdateText("test.txt", (current) => ({
|
||||
text: current.text + " World",
|
||||
cursors: []
|
||||
}));
|
||||
|
||||
assert.equal(result, "Hello World");
|
||||
assert.equal(result, "Hello World");
|
||||
|
||||
const content = await fsOps.read("test.txt");
|
||||
assert.equal(new TextDecoder().decode(content), "Hello World");
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
const content = await fsOps.read("test.txt");
|
||||
assert.equal(new TextDecoder().decode(content), "Hello World");
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("NodeFileSystemOperations - handles paths with forward slashes on all platforms", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||
|
||||
try {
|
||||
// API should always accept forward slashes
|
||||
const testPath = "deep/nested/directory/file.txt";
|
||||
const content = new TextEncoder().encode("test");
|
||||
try {
|
||||
// API should always accept forward slashes
|
||||
const testPath = "deep/nested/directory/file.txt";
|
||||
const content = new TextEncoder().encode("test");
|
||||
|
||||
await fsOps.write(testPath, content);
|
||||
assert.equal(await fsOps.exists(testPath), true);
|
||||
await fsOps.write(testPath, content);
|
||||
assert.equal(await fsOps.exists(testPath), true);
|
||||
|
||||
const readContent = await fsOps.read(testPath);
|
||||
assert.equal(new TextDecoder().decode(readContent), "test");
|
||||
const readContent = await fsOps.read(testPath);
|
||||
assert.equal(new TextDecoder().decode(readContent), "test");
|
||||
|
||||
await fsOps.delete(testPath);
|
||||
assert.equal(await fsOps.exists(testPath), false);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
await fsOps.delete(testPath);
|
||||
assert.equal(await fsOps.exists(testPath), false);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,205 +2,205 @@ import * as fs from "fs/promises";
|
|||
import type { Dirent } from "fs";
|
||||
import * as path from "path";
|
||||
import type {
|
||||
FileSystemOperations,
|
||||
RelativePath,
|
||||
TextWithCursors
|
||||
FileSystemOperations,
|
||||
RelativePath,
|
||||
TextWithCursors
|
||||
} from "sync-client";
|
||||
|
||||
export class NodeFileSystemOperations implements FileSystemOperations {
|
||||
public constructor(private readonly basePath: string) {}
|
||||
public constructor(private readonly basePath: string) {}
|
||||
|
||||
public async listFilesRecursively(
|
||||
directory: RelativePath | undefined
|
||||
): Promise<RelativePath[]> {
|
||||
const files: RelativePath[] = [];
|
||||
await this.walkDirectory(
|
||||
directory !== undefined ? this.toNativePath(directory) : "",
|
||||
files
|
||||
);
|
||||
return files;
|
||||
}
|
||||
public async listFilesRecursively(
|
||||
directory: RelativePath | undefined
|
||||
): Promise<RelativePath[]> {
|
||||
const files: RelativePath[] = [];
|
||||
await this.walkDirectory(
|
||||
directory !== undefined ? this.toNativePath(directory) : "",
|
||||
files
|
||||
);
|
||||
return files;
|
||||
}
|
||||
|
||||
public async read(relativePath: RelativePath): Promise<Uint8Array> {
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
);
|
||||
try {
|
||||
return await fs.readFile(fullPath);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to read file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
public async read(relativePath: RelativePath): Promise<Uint8Array> {
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
);
|
||||
try {
|
||||
return await fs.readFile(fullPath);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to read file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async write(
|
||||
relativePath: RelativePath,
|
||||
content: Uint8Array
|
||||
): Promise<void> {
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
);
|
||||
const dir = path.dirname(fullPath);
|
||||
public async write(
|
||||
relativePath: RelativePath,
|
||||
content: Uint8Array
|
||||
): Promise<void> {
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
);
|
||||
const dir = path.dirname(fullPath);
|
||||
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(fullPath, content);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(fullPath, content);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async atomicUpdateText(
|
||||
relativePath: RelativePath,
|
||||
updater: (current: TextWithCursors) => TextWithCursors
|
||||
): Promise<string> {
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
);
|
||||
public async atomicUpdateText(
|
||||
relativePath: RelativePath,
|
||||
updater: (current: TextWithCursors) => TextWithCursors
|
||||
): Promise<string> {
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
);
|
||||
|
||||
try {
|
||||
const currentContent = await fs.readFile(fullPath, "utf-8");
|
||||
const result = updater({ text: currentContent, cursors: [] });
|
||||
await fs.writeFile(fullPath, result.text, "utf-8");
|
||||
return result.text;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to atomically update file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const currentContent = await fs.readFile(fullPath, "utf-8");
|
||||
const result = updater({ text: currentContent, cursors: [] });
|
||||
await fs.writeFile(fullPath, result.text, "utf-8");
|
||||
return result.text;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to atomically update file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getFileSize(relativePath: RelativePath): Promise<number> {
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
);
|
||||
try {
|
||||
const stats = await fs.stat(fullPath);
|
||||
return stats.size;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to get file size for ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
public async getFileSize(relativePath: RelativePath): Promise<number> {
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
);
|
||||
try {
|
||||
const stats = await fs.stat(fullPath);
|
||||
return stats.size;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to get file size for ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async exists(relativePath: RelativePath): Promise<boolean> {
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
);
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
public async exists(relativePath: RelativePath): Promise<boolean> {
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
);
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async createDirectory(relativePath: RelativePath): Promise<void> {
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
);
|
||||
try {
|
||||
await fs.mkdir(fullPath, { recursive: false });
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to create directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
public async createDirectory(relativePath: RelativePath): Promise<void> {
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
);
|
||||
try {
|
||||
await fs.mkdir(fullPath, { recursive: false });
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to create directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async delete(relativePath: RelativePath): Promise<void> {
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
);
|
||||
try {
|
||||
await fs.unlink(fullPath);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to delete file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
public async delete(relativePath: RelativePath): Promise<void> {
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
);
|
||||
try {
|
||||
await fs.unlink(fullPath);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to delete file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async rename(
|
||||
oldPath: RelativePath,
|
||||
newPath: RelativePath
|
||||
): Promise<void> {
|
||||
const oldFullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(oldPath)
|
||||
);
|
||||
const newFullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(newPath)
|
||||
);
|
||||
const newDir = path.dirname(newFullPath);
|
||||
public async rename(
|
||||
oldPath: RelativePath,
|
||||
newPath: RelativePath
|
||||
): Promise<void> {
|
||||
const oldFullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(oldPath)
|
||||
);
|
||||
const newFullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(newPath)
|
||||
);
|
||||
const newDir = path.dirname(newFullPath);
|
||||
|
||||
try {
|
||||
await fs.mkdir(newDir, { recursive: true });
|
||||
await fs.rename(oldFullPath, newFullPath);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to rename file from ${oldFullPath} to ${newFullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await fs.mkdir(newDir, { recursive: true });
|
||||
await fs.rename(oldFullPath, newFullPath);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to rename file from ${oldFullPath} to ${newFullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async walkDirectory(
|
||||
relativePath: string,
|
||||
files: RelativePath[]
|
||||
): Promise<void> {
|
||||
const fullPath = path.join(this.basePath, relativePath);
|
||||
let entries: Dirent[] = [];
|
||||
private async walkDirectory(
|
||||
relativePath: string,
|
||||
files: RelativePath[]
|
||||
): Promise<void> {
|
||||
const fullPath = path.join(this.basePath, relativePath);
|
||||
let entries: Dirent[] = [];
|
||||
|
||||
try {
|
||||
entries = await fs.readdir(fullPath, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to read directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
entries = await fs.readdir(fullPath, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to read directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryName = entry.name;
|
||||
const entryRelativePath = path.join(relativePath, entryName);
|
||||
for (const entry of entries) {
|
||||
const entryName = entry.name;
|
||||
const entryRelativePath = path.join(relativePath, entryName);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await this.walkDirectory(entryRelativePath, files);
|
||||
} else if (entry.isFile()) {
|
||||
// Always return forward slashes
|
||||
files.push(this.toUnixPath(entryRelativePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
await this.walkDirectory(entryRelativePath, files);
|
||||
} else if (entry.isFile()) {
|
||||
// Always return forward slashes
|
||||
files.push(this.toUnixPath(entryRelativePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a forward-slash path to native platform path separators
|
||||
*/
|
||||
private toNativePath(relativePath: string): string {
|
||||
if (path.sep === "\\") {
|
||||
return relativePath.replace(/\//g, "\\");
|
||||
}
|
||||
return relativePath;
|
||||
}
|
||||
/**
|
||||
* Convert a forward-slash path to native platform path separators
|
||||
*/
|
||||
private toNativePath(relativePath: string): string {
|
||||
if (path.sep === "\\") {
|
||||
return relativePath.replace(/\//g, "\\");
|
||||
}
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a native platform path to forward slashes
|
||||
*/
|
||||
private toUnixPath(nativePath: string): string {
|
||||
if (path.sep === "\\") {
|
||||
return nativePath.replace(/\\/g, "/");
|
||||
}
|
||||
return nativePath;
|
||||
}
|
||||
/**
|
||||
* Convert a native platform path to forward slashes
|
||||
*/
|
||||
private toUnixPath(nativePath: string): string {
|
||||
if (path.sep === "\\") {
|
||||
return nativePath.replace(/\\/g, "/");
|
||||
}
|
||||
return nativePath;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"module": "ESNext",
|
||||
"lib": [
|
||||
"DOM", // to get `fetch` & `WebSocket`
|
||||
"ES2024"
|
||||
"ES2024"
|
||||
],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
|
|
@ -18,5 +18,7 @@
|
|||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"exclude": ["dist"]
|
||||
"exclude": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,32 +2,32 @@ const path = require("path");
|
|||
const webpack = require("webpack");
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
cli: "./src/cli.ts",
|
||||
healthcheck: "./src/healthcheck.ts"
|
||||
},
|
||||
target: "node",
|
||||
mode: "production",
|
||||
optimization: {
|
||||
minimize: false
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.ts$/,
|
||||
use: "ts-loader"
|
||||
}
|
||||
]
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".ts", ".js"]
|
||||
},
|
||||
output: {
|
||||
globalObject: "this",
|
||||
filename: "[name].js",
|
||||
path: path.resolve(__dirname, "dist")
|
||||
},
|
||||
plugins: [
|
||||
new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true })
|
||||
]
|
||||
entry: {
|
||||
cli: "./src/cli.ts",
|
||||
healthcheck: "./src/healthcheck.ts"
|
||||
},
|
||||
target: "node",
|
||||
mode: "production",
|
||||
optimization: {
|
||||
minimize: false
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.ts$/,
|
||||
use: "ts-loader"
|
||||
}
|
||||
]
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".ts", ".js"]
|
||||
},
|
||||
output: {
|
||||
globalObject: "this",
|
||||
filename: "[name].js",
|
||||
path: path.resolve(__dirname, "dist")
|
||||
},
|
||||
plugins: [
|
||||
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
|
||||
|
||||
See https://github.com/obsidianmd/obsidian-api
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"id": "vault-link",
|
||||
"name": "VaultLink",
|
||||
"version": "0.12.0",
|
||||
"minAppVersion": "0.0.0",
|
||||
"description": "Self-hosted synchronization and collaboration for your Vault.",
|
||||
"author": "Andras Schmelczer",
|
||||
"authorUrl": "https://schmelczer.dev",
|
||||
"isDesktopOnly": false
|
||||
"id": "vault-link",
|
||||
"name": "VaultLink",
|
||||
"version": "0.12.0",
|
||||
"minAppVersion": "0.0.0",
|
||||
"description": "Self-hosted synchronization and collaboration for your Vault.",
|
||||
"author": "Andras Schmelczer",
|
||||
"authorUrl": "https://schmelczer.dev",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
|
|
@ -2,175 +2,175 @@ import type { Stat, Vault, Workspace } from "obsidian";
|
|||
import { MarkdownView, normalizePath } from "obsidian";
|
||||
import type { CursorPosition, TextWithCursors } from "sync-client";
|
||||
import {
|
||||
utils,
|
||||
type FileSystemOperations,
|
||||
type RelativePath
|
||||
utils,
|
||||
type FileSystemOperations,
|
||||
type RelativePath
|
||||
} from "sync-client";
|
||||
import { getSelectionsFromEditor } from "./views/cursors/get-selections-from-editor";
|
||||
|
||||
export class ObsidianFileSystemOperations implements FileSystemOperations {
|
||||
public constructor(
|
||||
private readonly vault: Vault,
|
||||
private readonly workspace: Workspace
|
||||
) {}
|
||||
public constructor(
|
||||
private readonly vault: Vault,
|
||||
private readonly workspace: Workspace
|
||||
) {}
|
||||
|
||||
public async listFilesRecursively(
|
||||
root: RelativePath | undefined
|
||||
): Promise<RelativePath[]> {
|
||||
// Let's implement this by hand because vault.adapter.listAllFiles doesn't always return all files.
|
||||
const allFiles = [];
|
||||
const remainingFolders = [root ?? this.vault.getRoot().path];
|
||||
public async listFilesRecursively(
|
||||
root: RelativePath | undefined
|
||||
): Promise<RelativePath[]> {
|
||||
// Let's implement this by hand because vault.adapter.listAllFiles doesn't always return all files.
|
||||
const allFiles = [];
|
||||
const remainingFolders = [root ?? this.vault.getRoot().path];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
while (true) {
|
||||
const folder = remainingFolders.pop();
|
||||
if (folder == undefined) {
|
||||
break;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
while (true) {
|
||||
const folder = remainingFolders.pop();
|
||||
if (folder == undefined) {
|
||||
break;
|
||||
}
|
||||
|
||||
// This would be a very bad idea to sync as it would mess with
|
||||
// the integrity of the sync database.
|
||||
if (folder.endsWith(".obsidian/plugins/vault-link/data.json")) {
|
||||
continue;
|
||||
}
|
||||
// This would be a very bad idea to sync as it would mess with
|
||||
// the integrity of the sync database.
|
||||
if (folder.endsWith(".obsidian/plugins/vault-link/data.json")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const files = await this.vault.adapter.list(normalizePath(folder));
|
||||
allFiles.push(...files.files);
|
||||
remainingFolders.push(...files.folders);
|
||||
}
|
||||
const files = await this.vault.adapter.list(normalizePath(folder));
|
||||
allFiles.push(...files.files);
|
||||
remainingFolders.push(...files.folders);
|
||||
}
|
||||
|
||||
return allFiles;
|
||||
}
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
public async read(path: RelativePath): Promise<Uint8Array> {
|
||||
path = normalizePath(path);
|
||||
const view = this.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (view?.file?.path === path) {
|
||||
return new TextEncoder().encode(view.editor.getValue());
|
||||
}
|
||||
public async read(path: RelativePath): Promise<Uint8Array> {
|
||||
path = normalizePath(path);
|
||||
const view = this.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (view?.file?.path === path) {
|
||||
return new TextEncoder().encode(view.editor.getValue());
|
||||
}
|
||||
|
||||
return new Uint8Array(await this.vault.adapter.readBinary(path));
|
||||
}
|
||||
return new Uint8Array(await this.vault.adapter.readBinary(path));
|
||||
}
|
||||
|
||||
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
|
||||
path = normalizePath(path);
|
||||
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
|
||||
path = normalizePath(path);
|
||||
|
||||
const view = this.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (view?.file?.path === path) {
|
||||
const position = view.editor.getCursor();
|
||||
view.editor.setValue(new TextDecoder().decode(content));
|
||||
view.editor.setCursor(position);
|
||||
return;
|
||||
}
|
||||
const view = this.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (view?.file?.path === path) {
|
||||
const position = view.editor.getCursor();
|
||||
view.editor.setValue(new TextDecoder().decode(content));
|
||||
view.editor.setCursor(position);
|
||||
return;
|
||||
}
|
||||
|
||||
return this.vault.adapter.writeBinary(
|
||||
path,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
content.buffer as ArrayBuffer
|
||||
);
|
||||
}
|
||||
return this.vault.adapter.writeBinary(
|
||||
path,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
content.buffer as ArrayBuffer
|
||||
);
|
||||
}
|
||||
|
||||
public async atomicUpdateText(
|
||||
path: RelativePath,
|
||||
updater: (current: TextWithCursors) => TextWithCursors
|
||||
): Promise<string> {
|
||||
path = normalizePath(path);
|
||||
public async atomicUpdateText(
|
||||
path: RelativePath,
|
||||
updater: (current: TextWithCursors) => TextWithCursors
|
||||
): Promise<string> {
|
||||
path = normalizePath(path);
|
||||
|
||||
const view = this.workspace.getActiveViewOfType(MarkdownView);
|
||||
const view = this.workspace.getActiveViewOfType(MarkdownView);
|
||||
|
||||
if (view?.file?.path === path) {
|
||||
const text = view.editor.getValue();
|
||||
if (view?.file?.path === path) {
|
||||
const text = view.editor.getValue();
|
||||
|
||||
const cursors: CursorPosition[] = getSelectionsFromEditor(
|
||||
view.editor
|
||||
).flatMap(({ id, start: anchor, end: head }) => [
|
||||
{
|
||||
id: 2 * id,
|
||||
position: anchor
|
||||
},
|
||||
{
|
||||
id: 2 * id + 1,
|
||||
position: head
|
||||
}
|
||||
]);
|
||||
const cursors: CursorPosition[] = getSelectionsFromEditor(
|
||||
view.editor
|
||||
).flatMap(({ id, start: anchor, end: head }) => [
|
||||
{
|
||||
id: 2 * id,
|
||||
position: anchor
|
||||
},
|
||||
{
|
||||
id: 2 * id + 1,
|
||||
position: head
|
||||
}
|
||||
]);
|
||||
|
||||
const result = updater({
|
||||
text,
|
||||
cursors
|
||||
});
|
||||
const result = updater({
|
||||
text,
|
||||
cursors
|
||||
});
|
||||
|
||||
if (result.text === text) {
|
||||
return text;
|
||||
}
|
||||
if (result.text === text) {
|
||||
return text;
|
||||
}
|
||||
|
||||
view.editor.setValue(result.text);
|
||||
view.editor.setValue(result.text);
|
||||
|
||||
const selections = [];
|
||||
for (let i = 0; i < result.cursors.length / 2; i++) {
|
||||
const from = result.cursors[2 * i];
|
||||
const to = result.cursors[2 * i + 1];
|
||||
const { line: fromLine, column: fromColumn } =
|
||||
utils.positionToLineAndColumn(result.text, from.position);
|
||||
const selections = [];
|
||||
for (let i = 0; i < result.cursors.length / 2; i++) {
|
||||
const from = result.cursors[2 * i];
|
||||
const to = result.cursors[2 * i + 1];
|
||||
const { line: fromLine, column: fromColumn } =
|
||||
utils.positionToLineAndColumn(result.text, from.position);
|
||||
|
||||
const { line: toLine, column: toColumn } =
|
||||
utils.positionToLineAndColumn(result.text, to.position);
|
||||
const { line: toLine, column: toColumn } =
|
||||
utils.positionToLineAndColumn(result.text, to.position);
|
||||
|
||||
selections.push({
|
||||
anchor: { line: fromLine, ch: fromColumn },
|
||||
head: { line: toLine, ch: toColumn }
|
||||
});
|
||||
}
|
||||
view.editor.setSelections(selections);
|
||||
selections.push({
|
||||
anchor: { line: fromLine, ch: fromColumn },
|
||||
head: { line: toLine, ch: toColumn }
|
||||
});
|
||||
}
|
||||
view.editor.setSelections(selections);
|
||||
|
||||
return result.text;
|
||||
}
|
||||
return result.text;
|
||||
}
|
||||
|
||||
return this.vault.adapter.process(
|
||||
path,
|
||||
(text) =>
|
||||
updater({
|
||||
text,
|
||||
cursors: []
|
||||
}).text
|
||||
);
|
||||
}
|
||||
return this.vault.adapter.process(
|
||||
path,
|
||||
(text) =>
|
||||
updater({
|
||||
text,
|
||||
cursors: []
|
||||
}).text
|
||||
);
|
||||
}
|
||||
|
||||
public async getFileSize(path: RelativePath): Promise<number> {
|
||||
return (await this.statFile(path)).size;
|
||||
}
|
||||
public async getFileSize(path: RelativePath): Promise<number> {
|
||||
return (await this.statFile(path)).size;
|
||||
}
|
||||
|
||||
public async getModificationTime(path: RelativePath): Promise<Date> {
|
||||
return new Date((await this.statFile(path)).mtime);
|
||||
}
|
||||
public async getModificationTime(path: RelativePath): Promise<Date> {
|
||||
return new Date((await this.statFile(path)).mtime);
|
||||
}
|
||||
|
||||
public async exists(path: RelativePath): Promise<boolean> {
|
||||
return this.vault.adapter.exists(normalizePath(path));
|
||||
}
|
||||
public async exists(path: RelativePath): Promise<boolean> {
|
||||
return this.vault.adapter.exists(normalizePath(path));
|
||||
}
|
||||
|
||||
public async createDirectory(path: RelativePath): Promise<void> {
|
||||
return this.vault.adapter.mkdir(normalizePath(path));
|
||||
}
|
||||
public async createDirectory(path: RelativePath): Promise<void> {
|
||||
return this.vault.adapter.mkdir(normalizePath(path));
|
||||
}
|
||||
|
||||
public async delete(path: RelativePath): Promise<void> {
|
||||
if (!(await this.vault.adapter.trashSystem(normalizePath(path)))) {
|
||||
return this.vault.adapter.remove(normalizePath(path));
|
||||
}
|
||||
}
|
||||
public async delete(path: RelativePath): Promise<void> {
|
||||
if (!(await this.vault.adapter.trashSystem(normalizePath(path)))) {
|
||||
return this.vault.adapter.remove(normalizePath(path));
|
||||
}
|
||||
}
|
||||
|
||||
public async rename(
|
||||
oldPath: RelativePath,
|
||||
newPath: RelativePath
|
||||
): Promise<void> {
|
||||
return this.vault.adapter.rename(oldPath, newPath);
|
||||
}
|
||||
public async rename(
|
||||
oldPath: RelativePath,
|
||||
newPath: RelativePath
|
||||
): Promise<void> {
|
||||
return this.vault.adapter.rename(oldPath, newPath);
|
||||
}
|
||||
|
||||
private async statFile(path: string): Promise<Stat> {
|
||||
const file = await this.vault.adapter.stat(normalizePath(path));
|
||||
private async statFile(path: string): Promise<Stat> {
|
||||
const file = await this.vault.adapter.stat(normalizePath(path));
|
||||
|
||||
if (!file) {
|
||||
throw new Error(`File not found: ${path}`);
|
||||
}
|
||||
if (!file) {
|
||||
throw new Error(`File not found: ${path}`);
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import type {
|
||||
MarkdownView,
|
||||
Editor,
|
||||
MarkdownFileInfo,
|
||||
TAbstractFile,
|
||||
WorkspaceLeaf
|
||||
MarkdownView,
|
||||
Editor,
|
||||
MarkdownFileInfo,
|
||||
TAbstractFile,
|
||||
WorkspaceLeaf
|
||||
} from "obsidian";
|
||||
import { Notice, Platform, Plugin, TFile } from "obsidian";
|
||||
import "../manifest.json";
|
||||
|
|
@ -12,19 +12,19 @@ import { StatusBar } from "./views/status-bar/status-bar";
|
|||
import { LogsView } from "./views/logs/logs-view";
|
||||
import { StatusDescription } from "./views/status-description/status-description";
|
||||
import {
|
||||
SyncClient,
|
||||
rateLimit,
|
||||
DEFAULT_SETTINGS,
|
||||
Logger,
|
||||
debugging
|
||||
SyncClient,
|
||||
rateLimit,
|
||||
DEFAULT_SETTINGS,
|
||||
Logger,
|
||||
debugging
|
||||
} from "sync-client";
|
||||
import { ObsidianFileSystemOperations } from "./obsidian-file-system";
|
||||
import { SyncSettingsTab } from "./views/settings/settings-tab";
|
||||
import { EditorStatusDisplayManager } from "./views/editor-status-display-manager/editor-status-display-manager";
|
||||
import { remoteCursorsTheme } from "./views/cursors/remote-cursor-theme";
|
||||
import {
|
||||
remoteCursorsPlugin,
|
||||
RemoteCursorsPluginValue
|
||||
remoteCursorsPlugin,
|
||||
RemoteCursorsPluginValue
|
||||
} from "./views/cursors/remote-cursors-plugin";
|
||||
import { LocalCursorUpdateListener } from "./views/cursors/local-cursor-update-listener";
|
||||
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";
|
||||
|
||||
export default class VaultLinkPlugin extends Plugin {
|
||||
private readonly rateLimitedUpdatesPerFile = new Map<
|
||||
string,
|
||||
() => Promise<unknown>
|
||||
>();
|
||||
private readonly rateLimitedUpdatesPerFile = new Map<
|
||||
string,
|
||||
() => Promise<unknown>
|
||||
>();
|
||||
|
||||
private readonly syncClient: SyncClient | undefined;
|
||||
private settingsTab: SyncSettingsTab | undefined;
|
||||
private readonly syncClient: SyncClient | undefined;
|
||||
private settingsTab: SyncSettingsTab | undefined;
|
||||
|
||||
public async onload(): Promise<void> {
|
||||
this.app.workspace.onLayoutReady(async () => {
|
||||
// eslint-disable-next-line
|
||||
if ((globalThis as any).VAULT_LINK_RUNNING_INSTANCE) {
|
||||
new Notice(
|
||||
"Another instance of VaultLink is already running. Please disable the duplicate instance."
|
||||
);
|
||||
throw new Error("VaultLink instance already running");
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
(globalThis as any).VAULT_LINK_RUNNING_INSTANCE = this;
|
||||
public async onload(): Promise<void> {
|
||||
this.app.workspace.onLayoutReady(async () => {
|
||||
// eslint-disable-next-line
|
||||
if ((globalThis as any).VAULT_LINK_RUNNING_INSTANCE) {
|
||||
new Notice(
|
||||
"Another instance of VaultLink is already running. Please disable the duplicate instance."
|
||||
);
|
||||
throw new Error("VaultLink instance already running");
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
(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 () => {
|
||||
await client.waitUntilFinished();
|
||||
await client.destroy();
|
||||
});
|
||||
this.register(async () => {
|
||||
await client.waitUntilFinished();
|
||||
await client.destroy();
|
||||
});
|
||||
|
||||
await client.start();
|
||||
});
|
||||
}
|
||||
await client.start();
|
||||
});
|
||||
}
|
||||
|
||||
public onUserEnable(): void {
|
||||
new Notice(
|
||||
"VaultLink has been enabled, check out the docs for tips on getting started!"
|
||||
);
|
||||
void this.activateView(HistoryView.TYPE).catch((e: unknown) => {
|
||||
this.syncClient?.logger.error(
|
||||
`Failed to open history view on enable: ${e}`
|
||||
);
|
||||
});
|
||||
void this.activateView(LogsView.TYPE).catch((e: unknown) => {
|
||||
this.syncClient?.logger.error(
|
||||
`Failed to open logs view on enable: ${e}`
|
||||
);
|
||||
});
|
||||
this.openSettings();
|
||||
}
|
||||
public onUserEnable(): void {
|
||||
new Notice(
|
||||
"VaultLink has been enabled, check out the docs for tips on getting started!"
|
||||
);
|
||||
void this.activateView(HistoryView.TYPE).catch((e: unknown) => {
|
||||
this.syncClient?.logger.error(
|
||||
`Failed to open history view on enable: ${e}`
|
||||
);
|
||||
});
|
||||
void this.activateView(LogsView.TYPE).catch((e: unknown) => {
|
||||
this.syncClient?.logger.error(
|
||||
`Failed to open logs view on enable: ${e}`
|
||||
);
|
||||
});
|
||||
this.openSettings();
|
||||
}
|
||||
|
||||
public openSettings(): void {
|
||||
// eslint-disable-next-line
|
||||
(this.app as any).setting.open(); // this is undocumented
|
||||
// eslint-disable-next-line
|
||||
(this.app as any).setting.openTab(this.settingsTab); // this is undocumented
|
||||
}
|
||||
public openSettings(): void {
|
||||
// eslint-disable-next-line
|
||||
(this.app as any).setting.open(); // this is undocumented
|
||||
// eslint-disable-next-line
|
||||
(this.app as any).setting.openTab(this.settingsTab); // this is undocumented
|
||||
}
|
||||
|
||||
public closeSettings(): void {
|
||||
// eslint-disable-next-line
|
||||
(this.app as any).setting.close(); // this is undocumented
|
||||
}
|
||||
public closeSettings(): void {
|
||||
// eslint-disable-next-line
|
||||
(this.app as any).setting.close(); // this is undocumented
|
||||
}
|
||||
|
||||
public async activateView(type: string): Promise<void> {
|
||||
const { workspace } = this.app;
|
||||
public async activateView(type: string): Promise<void> {
|
||||
const { workspace } = this.app;
|
||||
|
||||
let leaf: WorkspaceLeaf | null = null;
|
||||
const leaves = workspace.getLeavesOfType(type);
|
||||
let leaf: WorkspaceLeaf | null = null;
|
||||
const leaves = workspace.getLeavesOfType(type);
|
||||
|
||||
if (leaves.length > 0) {
|
||||
[leaf] = leaves;
|
||||
} else {
|
||||
leaf = workspace.getRightLeaf(false);
|
||||
await leaf?.setViewState({ type: type, active: true });
|
||||
}
|
||||
if (leaves.length > 0) {
|
||||
[leaf] = leaves;
|
||||
} else {
|
||||
leaf = workspace.getRightLeaf(false);
|
||||
await leaf?.setViewState({ type: type, active: true });
|
||||
}
|
||||
|
||||
if (leaf) {
|
||||
await workspace.revealLeaf(leaf);
|
||||
}
|
||||
}
|
||||
if (leaf) {
|
||||
await workspace.revealLeaf(leaf);
|
||||
}
|
||||
}
|
||||
|
||||
private async createSyncClient(): Promise<SyncClient> {
|
||||
DEFAULT_SETTINGS.ignorePatterns.push(
|
||||
".obsidian/**",
|
||||
".git/**",
|
||||
".trash/**",
|
||||
"**/.DS_Store"
|
||||
);
|
||||
private async createSyncClient(): Promise<SyncClient> {
|
||||
DEFAULT_SETTINGS.ignorePatterns.push(
|
||||
".obsidian/**",
|
||||
".git/**",
|
||||
".trash/**",
|
||||
"**/.DS_Store"
|
||||
);
|
||||
|
||||
const client = await SyncClient.create({
|
||||
fs: new ObsidianFileSystemOperations(
|
||||
this.app.vault,
|
||||
this.app.workspace
|
||||
),
|
||||
persistence: {
|
||||
load: this.loadData.bind(this),
|
||||
save: this.saveData.bind(this)
|
||||
},
|
||||
nativeLineEndings: Platform.isWin ? "\r\n" : "\n",
|
||||
...(IS_DEBUG_BUILD
|
||||
? {
|
||||
fetch: debugging.slowFetchFactory(1),
|
||||
webSocket: debugging.slowWebSocketFactory(
|
||||
1,
|
||||
new Logger()
|
||||
)
|
||||
}
|
||||
: {})
|
||||
});
|
||||
const client = await SyncClient.create({
|
||||
fs: new ObsidianFileSystemOperations(
|
||||
this.app.vault,
|
||||
this.app.workspace
|
||||
),
|
||||
persistence: {
|
||||
load: this.loadData.bind(this),
|
||||
save: this.saveData.bind(this)
|
||||
},
|
||||
nativeLineEndings: Platform.isWin ? "\r\n" : "\n",
|
||||
...(IS_DEBUG_BUILD
|
||||
? {
|
||||
fetch: debugging.slowFetchFactory(1),
|
||||
webSocket: debugging.slowWebSocketFactory(1, new Logger())
|
||||
}
|
||||
: {})
|
||||
});
|
||||
|
||||
if (IS_DEBUG_BUILD) {
|
||||
debugging.logToConsole(client);
|
||||
}
|
||||
if (IS_DEBUG_BUILD) {
|
||||
debugging.logToConsole(client);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
private registerObsidianExtensions(client: SyncClient): void {
|
||||
const statusDescription = new StatusDescription(client);
|
||||
private registerObsidianExtensions(client: SyncClient): void {
|
||||
const statusDescription = new StatusDescription(client);
|
||||
|
||||
this.settingsTab = new SyncSettingsTab({
|
||||
app: this.app,
|
||||
plugin: this,
|
||||
syncClient: client,
|
||||
statusDescription
|
||||
});
|
||||
this.addSettingTab(this.settingsTab);
|
||||
this.settingsTab = new SyncSettingsTab({
|
||||
app: this.app,
|
||||
plugin: this,
|
||||
syncClient: client,
|
||||
statusDescription
|
||||
});
|
||||
this.addSettingTab(this.settingsTab);
|
||||
|
||||
new StatusBar(this, client);
|
||||
new StatusBar(this, client);
|
||||
|
||||
this.registerView(HistoryView.TYPE, (leaf) => {
|
||||
const view = new HistoryView(client, leaf);
|
||||
this.register(async () => view.onClose());
|
||||
return view;
|
||||
});
|
||||
this.registerView(HistoryView.TYPE, (leaf) => {
|
||||
const view = new HistoryView(client, leaf);
|
||||
this.register(async () => view.onClose());
|
||||
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) => {
|
||||
RemoteCursorsPluginValue.setCursors(cursors, this.app);
|
||||
renderCursorsInFileExplorer(cursors, this.app);
|
||||
});
|
||||
client.onRemoteCursorsUpdated.add((cursors) => {
|
||||
RemoteCursorsPluginValue.setCursors(cursors, this.app);
|
||||
renderCursorsInFileExplorer(cursors, this.app);
|
||||
});
|
||||
|
||||
const cursorListener = new LocalCursorUpdateListener(
|
||||
client,
|
||||
this.app.workspace
|
||||
);
|
||||
this.register(() => {
|
||||
cursorListener.dispose();
|
||||
});
|
||||
const cursorListener = new LocalCursorUpdateListener(
|
||||
client,
|
||||
this.app.workspace
|
||||
);
|
||||
this.register(() => {
|
||||
cursorListener.dispose();
|
||||
});
|
||||
|
||||
this.app.workspace.updateOptions();
|
||||
this.app.workspace.updateOptions();
|
||||
|
||||
this.addRibbonIcons();
|
||||
this.addRibbonIcons();
|
||||
|
||||
const editorStatusDisplayManager = new EditorStatusDisplayManager(
|
||||
this,
|
||||
this.app.workspace,
|
||||
client
|
||||
);
|
||||
this.register(() => {
|
||||
editorStatusDisplayManager.dispose();
|
||||
});
|
||||
const editorStatusDisplayManager = new EditorStatusDisplayManager(
|
||||
this,
|
||||
this.app.workspace,
|
||||
client
|
||||
);
|
||||
this.register(() => {
|
||||
editorStatusDisplayManager.dispose();
|
||||
});
|
||||
|
||||
this.register(() => {
|
||||
// eslint-disable-next-line
|
||||
(globalThis as any).VAULT_LINK_RUNNING_INSTANCE = null;
|
||||
});
|
||||
}
|
||||
this.register(() => {
|
||||
// eslint-disable-next-line
|
||||
(globalThis as any).VAULT_LINK_RUNNING_INSTANCE = null;
|
||||
});
|
||||
}
|
||||
|
||||
private addRibbonIcons(): void {
|
||||
this.addRibbonIcon(
|
||||
HistoryView.ICON,
|
||||
"Open VaultLink events",
|
||||
async (_: MouseEvent) => this.activateView(HistoryView.TYPE)
|
||||
);
|
||||
private addRibbonIcons(): void {
|
||||
this.addRibbonIcon(
|
||||
HistoryView.ICON,
|
||||
"Open VaultLink events",
|
||||
async (_: MouseEvent) => this.activateView(HistoryView.TYPE)
|
||||
);
|
||||
|
||||
this.addRibbonIcon(
|
||||
LogsView.ICON,
|
||||
"Open VaultLink logs",
|
||||
async (_: MouseEvent) => this.activateView(LogsView.TYPE)
|
||||
);
|
||||
}
|
||||
this.addRibbonIcon(
|
||||
LogsView.ICON,
|
||||
"Open VaultLink logs",
|
||||
async (_: MouseEvent) => this.activateView(LogsView.TYPE)
|
||||
);
|
||||
}
|
||||
|
||||
private registerEditorEvents(client: SyncClient): void {
|
||||
[
|
||||
this.app.workspace.on(
|
||||
"editor-change",
|
||||
async (
|
||||
_editor: Editor,
|
||||
info: MarkdownView | MarkdownFileInfo
|
||||
) => {
|
||||
const { file } = info;
|
||||
if (file) {
|
||||
await this.rateLimitedUpdate(file.path, client);
|
||||
}
|
||||
}
|
||||
),
|
||||
this.app.vault.on("create", async (file: TAbstractFile) => {
|
||||
if (file instanceof TFile) {
|
||||
await client.syncLocallyCreatedFile(file.path);
|
||||
}
|
||||
}),
|
||||
this.app.vault.on("modify", async (file: TAbstractFile) => {
|
||||
if (file instanceof TFile) {
|
||||
await this.rateLimitedUpdate(file.path, client);
|
||||
}
|
||||
}),
|
||||
this.app.vault.on("delete", async (file: TAbstractFile) => {
|
||||
await client.syncLocallyDeletedFile(file.path);
|
||||
}),
|
||||
this.app.vault.on(
|
||||
"rename",
|
||||
async (file: TAbstractFile, oldPath: string) => {
|
||||
if (file instanceof TFile) {
|
||||
await client.syncLocallyUpdatedFile({
|
||||
oldPath,
|
||||
relativePath: file.path
|
||||
});
|
||||
}
|
||||
}
|
||||
)
|
||||
].forEach((event) => {
|
||||
this.registerEvent(event);
|
||||
});
|
||||
}
|
||||
private registerEditorEvents(client: SyncClient): void {
|
||||
[
|
||||
this.app.workspace.on(
|
||||
"editor-change",
|
||||
async (
|
||||
_editor: Editor,
|
||||
info: MarkdownView | MarkdownFileInfo
|
||||
) => {
|
||||
const { file } = info;
|
||||
if (file) {
|
||||
await this.rateLimitedUpdate(file.path, client);
|
||||
}
|
||||
}
|
||||
),
|
||||
this.app.vault.on("create", async (file: TAbstractFile) => {
|
||||
if (file instanceof TFile) {
|
||||
await client.syncLocallyCreatedFile(file.path);
|
||||
}
|
||||
}),
|
||||
this.app.vault.on("modify", async (file: TAbstractFile) => {
|
||||
if (file instanceof TFile) {
|
||||
await this.rateLimitedUpdate(file.path, client);
|
||||
}
|
||||
}),
|
||||
this.app.vault.on("delete", async (file: TAbstractFile) => {
|
||||
await client.syncLocallyDeletedFile(file.path);
|
||||
}),
|
||||
this.app.vault.on(
|
||||
"rename",
|
||||
async (file: TAbstractFile, oldPath: string) => {
|
||||
if (file instanceof TFile) {
|
||||
await client.syncLocallyUpdatedFile({
|
||||
oldPath,
|
||||
relativePath: file.path
|
||||
});
|
||||
}
|
||||
}
|
||||
)
|
||||
].forEach((event) => {
|
||||
this.registerEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
private async rateLimitedUpdate(
|
||||
path: string,
|
||||
client: SyncClient
|
||||
): Promise<void> {
|
||||
if (!this.rateLimitedUpdatesPerFile.has(path)) {
|
||||
this.rateLimitedUpdatesPerFile.set(
|
||||
path,
|
||||
rateLimit(
|
||||
async () =>
|
||||
client.syncLocallyUpdatedFile({
|
||||
relativePath: path
|
||||
}),
|
||||
MIN_WAIT_BETWEEN_UPDATES_IN_MS
|
||||
)
|
||||
);
|
||||
}
|
||||
await this.rateLimitedUpdatesPerFile.get(path)?.();
|
||||
}
|
||||
private async rateLimitedUpdate(
|
||||
path: string,
|
||||
client: SyncClient
|
||||
): Promise<void> {
|
||||
if (!this.rateLimitedUpdatesPerFile.has(path)) {
|
||||
this.rateLimitedUpdatesPerFile.set(
|
||||
path,
|
||||
rateLimit(
|
||||
async () =>
|
||||
client.syncLocallyUpdatedFile({
|
||||
relativePath: path
|
||||
}),
|
||||
MIN_WAIT_BETWEEN_UPDATES_IN_MS
|
||||
)
|
||||
);
|
||||
}
|
||||
await this.rateLimitedUpdatesPerFile.get(path)?.();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,54 +2,54 @@ import "./file-explorer.scss";
|
|||
|
||||
import type { App, View } from "obsidian";
|
||||
import {
|
||||
utils,
|
||||
type MaybeOutdatedClientCursors,
|
||||
type RelativePath
|
||||
utils,
|
||||
type MaybeOutdatedClientCursors,
|
||||
type RelativePath
|
||||
} from "sync-client";
|
||||
|
||||
const REMOTE_USER_CONTAINER_CLASS = "remote-users";
|
||||
|
||||
export function renderCursorsInFileExplorer(
|
||||
cursors: MaybeOutdatedClientCursors[],
|
||||
app: App
|
||||
cursors: MaybeOutdatedClientCursors[],
|
||||
app: App
|
||||
): void {
|
||||
const fileExplorers = app.workspace.getLeavesOfType("file-explorer");
|
||||
if (fileExplorers.length == 0) return;
|
||||
const fileExplorers = app.workspace.getLeavesOfType("file-explorer");
|
||||
if (fileExplorers.length == 0) return;
|
||||
|
||||
const [fileExplorer] = fileExplorers;
|
||||
const [fileExplorer] = fileExplorers;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const fileExplorerView: View & {
|
||||
fileItems: Record<RelativePath, { el: Element }>; // it's an internal API
|
||||
} = fileExplorer.view as any; // eslint-disable-line
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const fileExplorerView: View & {
|
||||
fileItems: Record<RelativePath, { el: Element }>; // it's an internal API
|
||||
} = fileExplorer.view as any; // eslint-disable-line
|
||||
|
||||
for (const key in fileExplorerView.fileItems) {
|
||||
const element =
|
||||
fileExplorerView.fileItems[key].el.querySelector(".tree-item-self");
|
||||
for (const key in fileExplorerView.fileItems) {
|
||||
const element =
|
||||
fileExplorerView.fileItems[key].el.querySelector(".tree-item-self");
|
||||
|
||||
const customElement = createDiv(
|
||||
{
|
||||
cls: REMOTE_USER_CONTAINER_CLASS
|
||||
},
|
||||
(parent) => {
|
||||
cursors.forEach((cursor) => {
|
||||
cursor.documentsWithCursors.forEach((document) => {
|
||||
if (document.relative_path.startsWith(key)) {
|
||||
parent.appendChild(
|
||||
createSpan({
|
||||
text: cursor.userName,
|
||||
attr: {
|
||||
style: `border-color: ${utils.getRandomColor(cursor.userName)}`
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
const customElement = createDiv(
|
||||
{
|
||||
cls: REMOTE_USER_CONTAINER_CLASS
|
||||
},
|
||||
(parent) => {
|
||||
cursors.forEach((cursor) => {
|
||||
cursor.documentsWithCursors.forEach((document) => {
|
||||
if (document.relative_path.startsWith(key)) {
|
||||
parent.appendChild(
|
||||
createSpan({
|
||||
text: cursor.userName,
|
||||
attr: {
|
||||
style: `border-color: ${utils.getRandomColor(cursor.userName)}`
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
element?.querySelector("." + REMOTE_USER_CONTAINER_CLASS)?.remove();
|
||||
element?.appendChild(customElement);
|
||||
}
|
||||
element?.querySelector("." + REMOTE_USER_CONTAINER_CLASS)?.remove();
|
||||
element?.appendChild(customElement);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,16 @@ import type { Editor } from "obsidian";
|
|||
import { utils } from "sync-client";
|
||||
|
||||
export interface Selection {
|
||||
id: number;
|
||||
start: number;
|
||||
end: number;
|
||||
id: number;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export function getSelectionsFromEditor(editor: Editor): Selection[] {
|
||||
const text = editor.getValue();
|
||||
return editor.listSelections().map(({ anchor, head }, i) => ({
|
||||
id: i,
|
||||
start: utils.lineAndColumnToPosition(text, anchor.line, anchor.ch),
|
||||
end: utils.lineAndColumnToPosition(text, head.line, head.ch)
|
||||
}));
|
||||
const text = editor.getValue();
|
||||
return editor.listSelections().map(({ anchor, head }, i) => ({
|
||||
id: i,
|
||||
start: utils.lineAndColumnToPosition(text, anchor.line, anchor.ch),
|
||||
end: utils.lineAndColumnToPosition(text, head.line, head.ch)
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,46 +5,46 @@ import type { Selection } from "./get-selections-from-editor";
|
|||
import { getSelectionsFromEditor } from "./get-selections-from-editor";
|
||||
|
||||
export class LocalCursorUpdateListener {
|
||||
private static readonly UPDATE_INTERVAL_MS = 50;
|
||||
private readonly eventHandle: NodeJS.Timeout;
|
||||
private static readonly UPDATE_INTERVAL_MS = 50;
|
||||
private readonly eventHandle: NodeJS.Timeout;
|
||||
|
||||
public constructor(
|
||||
private readonly client: SyncClient,
|
||||
private readonly workspace: Workspace
|
||||
) {
|
||||
this.eventHandle = setInterval(() => {
|
||||
this.updateAllSelections();
|
||||
}, LocalCursorUpdateListener.UPDATE_INTERVAL_MS);
|
||||
}
|
||||
public constructor(
|
||||
private readonly client: SyncClient,
|
||||
private readonly workspace: Workspace
|
||||
) {
|
||||
this.eventHandle = setInterval(() => {
|
||||
this.updateAllSelections();
|
||||
}, LocalCursorUpdateListener.UPDATE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
clearInterval(this.eventHandle);
|
||||
}
|
||||
public dispose(): void {
|
||||
clearInterval(this.eventHandle);
|
||||
}
|
||||
|
||||
private updateAllSelections(): void {
|
||||
const currentCursors = this.getAllSelections();
|
||||
this.client
|
||||
.updateLocalCursors(currentCursors)
|
||||
.catch((error: unknown) => {
|
||||
this.client.logger.error(
|
||||
`Failed to update local cursors: ${error}`
|
||||
);
|
||||
});
|
||||
}
|
||||
private updateAllSelections(): void {
|
||||
const currentCursors = this.getAllSelections();
|
||||
this.client
|
||||
.updateLocalCursors(currentCursors)
|
||||
.catch((error: unknown) => {
|
||||
this.client.logger.error(
|
||||
`Failed to update local cursors: ${error}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private getAllSelections(): Record<string, Selection[]> {
|
||||
const cursors: Record<string, Selection[]> = {};
|
||||
this.workspace
|
||||
.getLeavesOfType("markdown")
|
||||
.map((leaf) => leaf.view)
|
||||
.filter((view) => view instanceof MarkdownView)
|
||||
.forEach((view) => {
|
||||
const { file } = view;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
cursors[file.path] = getSelectionsFromEditor(view.editor);
|
||||
});
|
||||
return cursors;
|
||||
}
|
||||
private getAllSelections(): Record<string, Selection[]> {
|
||||
const cursors: Record<string, Selection[]> = {};
|
||||
this.workspace
|
||||
.getLeavesOfType("markdown")
|
||||
.map((leaf) => leaf.view)
|
||||
.filter((view) => view instanceof MarkdownView)
|
||||
.forEach((view) => {
|
||||
const { file } = view;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
cursors[file.path] = getSelectionsFromEditor(view.editor);
|
||||
});
|
||||
return cursors;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,60 +4,60 @@ const CARET_WIDTH = 2;
|
|||
const DOT_RADIUS = 4;
|
||||
|
||||
export const remoteCursorsTheme = EditorView.baseTheme({
|
||||
".selection-caret": {
|
||||
position: "relative"
|
||||
},
|
||||
".selection-caret": {
|
||||
position: "relative"
|
||||
},
|
||||
|
||||
".selection-caret > *": {
|
||||
position: "absolute",
|
||||
backgroundColor: "inherit"
|
||||
},
|
||||
".selection-caret > *": {
|
||||
position: "absolute",
|
||||
backgroundColor: "inherit"
|
||||
},
|
||||
|
||||
".selection-caret > .stick": {
|
||||
left: 0,
|
||||
top: 0,
|
||||
transform: "translateX(-50%)",
|
||||
width: `${CARET_WIDTH}px`,
|
||||
height: "100%",
|
||||
display: "block",
|
||||
borderRadius: `${CARET_WIDTH / 2}px`,
|
||||
animation: "blink-stick 1s steps(1) infinite"
|
||||
},
|
||||
".selection-caret > .stick": {
|
||||
left: 0,
|
||||
top: 0,
|
||||
transform: "translateX(-50%)",
|
||||
width: `${CARET_WIDTH}px`,
|
||||
height: "100%",
|
||||
display: "block",
|
||||
borderRadius: `${CARET_WIDTH / 2}px`,
|
||||
animation: "blink-stick 1s steps(1) infinite"
|
||||
},
|
||||
|
||||
"@keyframes blink-stick": {
|
||||
"0%, 100%": { opacity: 1 },
|
||||
"50%": { opacity: 0 }
|
||||
},
|
||||
"@keyframes blink-stick": {
|
||||
"0%, 100%": { opacity: 1 },
|
||||
"50%": { opacity: 0 }
|
||||
},
|
||||
|
||||
".selection-caret > .dot": {
|
||||
borderRadius: "50%",
|
||||
width: `${DOT_RADIUS * 2}px`,
|
||||
height: `${DOT_RADIUS * 2}px`,
|
||||
top: `-${DOT_RADIUS}px`,
|
||||
left: `-${DOT_RADIUS}px`,
|
||||
transition: "transform .3s ease-in-out",
|
||||
transformOrigin: "bottom center",
|
||||
boxSizing: "border-box"
|
||||
},
|
||||
".selection-caret > .dot": {
|
||||
borderRadius: "50%",
|
||||
width: `${DOT_RADIUS * 2}px`,
|
||||
height: `${DOT_RADIUS * 2}px`,
|
||||
top: `-${DOT_RADIUS}px`,
|
||||
left: `-${DOT_RADIUS}px`,
|
||||
transition: "transform .3s ease-in-out",
|
||||
transformOrigin: "bottom center",
|
||||
boxSizing: "border-box"
|
||||
},
|
||||
|
||||
".selection-caret:hover > .dot": {
|
||||
transform: "scale(0)"
|
||||
},
|
||||
".selection-caret:hover > .dot": {
|
||||
transform: "scale(0)"
|
||||
},
|
||||
|
||||
".selection-caret > .info": {
|
||||
top: "-1.3em",
|
||||
left: `-${CARET_WIDTH / 2}px`,
|
||||
fontSize: "0.9em",
|
||||
userSelect: "none",
|
||||
color: "white",
|
||||
padding: "0 2px",
|
||||
transition: "opacity .3s ease-in-out",
|
||||
opacity: 0,
|
||||
whiteSpace: "nowrap",
|
||||
borderRadius: "3px 3px 3px 0"
|
||||
},
|
||||
".selection-caret > .info": {
|
||||
top: "-1.3em",
|
||||
left: `-${CARET_WIDTH / 2}px`,
|
||||
fontSize: "0.9em",
|
||||
userSelect: "none",
|
||||
color: "white",
|
||||
padding: "0 2px",
|
||||
transition: "opacity .3s ease-in-out",
|
||||
opacity: 0,
|
||||
whiteSpace: "nowrap",
|
||||
borderRadius: "3px 3px 3px 0"
|
||||
},
|
||||
|
||||
".selection-caret:hover > .info": {
|
||||
opacity: 1
|
||||
}
|
||||
".selection-caret:hover > .info": {
|
||||
opacity: 1
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,46 +1,46 @@
|
|||
import { AnnotationType, Annotation, RangeSet, Range } from "@codemirror/state";
|
||||
import {
|
||||
ViewUpdate,
|
||||
ViewPlugin,
|
||||
Decoration,
|
||||
WidgetType
|
||||
ViewUpdate,
|
||||
ViewPlugin,
|
||||
Decoration,
|
||||
WidgetType
|
||||
} from "@codemirror/view";
|
||||
|
||||
import type { PluginValue, DecorationSet, EditorView } from "@codemirror/view";
|
||||
|
||||
export class RemoteCursorWidget extends WidgetType {
|
||||
public constructor(
|
||||
private readonly color: string,
|
||||
private readonly name: string
|
||||
) {
|
||||
super();
|
||||
}
|
||||
public constructor(
|
||||
private readonly color: string,
|
||||
private readonly name: string
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
public toDOM(editor: EditorView): HTMLElement {
|
||||
return editor.contentDOM.createEl(
|
||||
"span",
|
||||
{
|
||||
cls: "selection-caret",
|
||||
attr: {
|
||||
style: `background-color: ${this.color}; border-color: ${this.color}`
|
||||
}
|
||||
},
|
||||
(span) => {
|
||||
span.createEl("div", {
|
||||
cls: "stick"
|
||||
});
|
||||
span.createEl("div", {
|
||||
cls: "dot"
|
||||
});
|
||||
span.createEl("div", {
|
||||
cls: "info",
|
||||
text: this.name
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
public toDOM(editor: EditorView): HTMLElement {
|
||||
return editor.contentDOM.createEl(
|
||||
"span",
|
||||
{
|
||||
cls: "selection-caret",
|
||||
attr: {
|
||||
style: `background-color: ${this.color}; border-color: ${this.color}`
|
||||
}
|
||||
},
|
||||
(span) => {
|
||||
span.createEl("div", {
|
||||
cls: "stick"
|
||||
});
|
||||
span.createEl("div", {
|
||||
cls: "dot"
|
||||
});
|
||||
span.createEl("div", {
|
||||
cls: "info",
|
||||
text: this.name
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public eq(other: RemoteCursorWidget): boolean {
|
||||
return other.color === this.color && other.name === this.name;
|
||||
}
|
||||
public eq(other: RemoteCursorWidget): boolean {
|
||||
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 type {
|
||||
PluginValue,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
ViewUpdate
|
||||
PluginValue,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
ViewUpdate
|
||||
} from "@codemirror/view";
|
||||
import { RemoteCursorWidget } from "./remote-cursor-widget";
|
||||
import type { RelativePath } from "sync-client";
|
||||
import {
|
||||
utils,
|
||||
type CursorSpan,
|
||||
type MaybeOutdatedClientCursors
|
||||
utils,
|
||||
type CursorSpan,
|
||||
type MaybeOutdatedClientCursors
|
||||
} from "sync-client";
|
||||
import type { App } from "obsidian";
|
||||
import { MarkdownView } from "obsidian";
|
||||
|
|
@ -25,241 +25,241 @@ import { reconcileWithHistory } from "reconcile-text";
|
|||
const forceUpdate = StateEffect.define();
|
||||
|
||||
export class RemoteCursorsPluginValue implements PluginValue {
|
||||
private static cursors: {
|
||||
name: string;
|
||||
path: string;
|
||||
span: CursorSpan;
|
||||
deviceId: string;
|
||||
isOutdated: boolean;
|
||||
}[] = [];
|
||||
private static cursors: {
|
||||
name: string;
|
||||
path: string;
|
||||
span: CursorSpan;
|
||||
deviceId: string;
|
||||
isOutdated: boolean;
|
||||
}[] = [];
|
||||
|
||||
private static app?: App;
|
||||
public decorations: DecorationSet = RangeSet.of([]);
|
||||
private static app?: App;
|
||||
public decorations: DecorationSet = RangeSet.of([]);
|
||||
|
||||
public static setCursors(
|
||||
clients: MaybeOutdatedClientCursors[],
|
||||
app: App
|
||||
): void {
|
||||
RemoteCursorsPluginValue.app = app;
|
||||
RemoteCursorsPluginValue.cursors = [
|
||||
...RemoteCursorsPluginValue.cursors.filter(({ deviceId }) =>
|
||||
clients.some(
|
||||
(client) =>
|
||||
client.deviceId === deviceId && client.isOutdated
|
||||
)
|
||||
),
|
||||
...clients
|
||||
.filter(
|
||||
({ isOutdated, deviceId }) =>
|
||||
!isOutdated ||
|
||||
RemoteCursorsPluginValue.cursors.every(
|
||||
(c) => deviceId !== c.deviceId
|
||||
)
|
||||
)
|
||||
.flatMap((client) => {
|
||||
const clientCursors = client.documentsWithCursors;
|
||||
return clientCursors.flatMap((cursor) =>
|
||||
cursor.cursors.map((span) => ({
|
||||
name: client.userName,
|
||||
path: cursor.relative_path,
|
||||
deviceId: client.deviceId,
|
||||
isOutdated: client.isOutdated,
|
||||
span: { ...span }
|
||||
}))
|
||||
);
|
||||
})
|
||||
];
|
||||
public static setCursors(
|
||||
clients: MaybeOutdatedClientCursors[],
|
||||
app: App
|
||||
): void {
|
||||
RemoteCursorsPluginValue.app = app;
|
||||
RemoteCursorsPluginValue.cursors = [
|
||||
...RemoteCursorsPluginValue.cursors.filter(({ deviceId }) =>
|
||||
clients.some(
|
||||
(client) =>
|
||||
client.deviceId === deviceId && client.isOutdated
|
||||
)
|
||||
),
|
||||
...clients
|
||||
.filter(
|
||||
({ isOutdated, deviceId }) =>
|
||||
!isOutdated ||
|
||||
RemoteCursorsPluginValue.cursors.every(
|
||||
(c) => deviceId !== c.deviceId
|
||||
)
|
||||
)
|
||||
.flatMap((client) => {
|
||||
const clientCursors = client.documentsWithCursors;
|
||||
return clientCursors.flatMap((cursor) =>
|
||||
cursor.cursors.map((span) => ({
|
||||
name: client.userName,
|
||||
path: cursor.relative_path,
|
||||
deviceId: client.deviceId,
|
||||
isOutdated: client.isOutdated,
|
||||
span: { ...span }
|
||||
}))
|
||||
);
|
||||
})
|
||||
];
|
||||
|
||||
app.workspace
|
||||
.getLeavesOfType("markdown")
|
||||
.map((leaf) => leaf.view)
|
||||
.filter((view) => view instanceof MarkdownView)
|
||||
.forEach((view) => {
|
||||
// @ts-expect-error, not typed
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const editor = view.editor.cm as EditorView;
|
||||
app.workspace
|
||||
.getLeavesOfType("markdown")
|
||||
.map((leaf) => leaf.view)
|
||||
.filter((view) => view instanceof MarkdownView)
|
||||
.forEach((view) => {
|
||||
// @ts-expect-error, not typed
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const editor = view.editor.cm as EditorView;
|
||||
|
||||
editor.dispatch({
|
||||
effects: [forceUpdate.of(null)]
|
||||
});
|
||||
});
|
||||
}
|
||||
editor.dispatch({
|
||||
effects: [forceUpdate.of(null)]
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static findFileForEditor(
|
||||
editor: EditorView
|
||||
): RelativePath | undefined {
|
||||
return RemoteCursorsPluginValue.app?.workspace
|
||||
.getLeavesOfType("markdown")
|
||||
.map((leaf) => leaf.view)
|
||||
.filter((view) => view instanceof MarkdownView)
|
||||
.flatMap((view) => {
|
||||
// @ts-expect-error, not typed
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
if ((view.editor.cm as EditorView) !== editor) {
|
||||
return [];
|
||||
}
|
||||
private static findFileForEditor(
|
||||
editor: EditorView
|
||||
): RelativePath | undefined {
|
||||
return RemoteCursorsPluginValue.app?.workspace
|
||||
.getLeavesOfType("markdown")
|
||||
.map((leaf) => leaf.view)
|
||||
.filter((view) => view instanceof MarkdownView)
|
||||
.flatMap((view) => {
|
||||
// @ts-expect-error, not typed
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
if ((view.editor.cm as EditorView) !== editor) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { file } = view;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const { file } = view;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
return [file.path];
|
||||
})
|
||||
.first();
|
||||
}
|
||||
return [file.path];
|
||||
})
|
||||
.first();
|
||||
}
|
||||
|
||||
private static interpolateRemoteCursorPositions(
|
||||
original: string,
|
||||
edited: string
|
||||
): void {
|
||||
if (
|
||||
original === edited ||
|
||||
RemoteCursorsPluginValue.cursors.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
private static interpolateRemoteCursorPositions(
|
||||
original: string,
|
||||
edited: string
|
||||
): void {
|
||||
if (
|
||||
original === edited ||
|
||||
RemoteCursorsPluginValue.cursors.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedPositions: number[] = [];
|
||||
const reconciled = reconcileWithHistory(
|
||||
original,
|
||||
{
|
||||
text: original,
|
||||
cursors: RemoteCursorsPluginValue.cursors.flatMap(
|
||||
({ span }, i) => [
|
||||
{ id: i * 2, position: span.start },
|
||||
{ id: i * 2 + 1, position: span.end }
|
||||
]
|
||||
)
|
||||
},
|
||||
edited
|
||||
);
|
||||
const updatedPositions: number[] = [];
|
||||
const reconciled = reconcileWithHistory(
|
||||
original,
|
||||
{
|
||||
text: original,
|
||||
cursors: RemoteCursorsPluginValue.cursors.flatMap(
|
||||
({ span }, i) => [
|
||||
{ id: i * 2, position: span.start },
|
||||
{ id: i * 2 + 1, position: span.end }
|
||||
]
|
||||
)
|
||||
},
|
||||
edited
|
||||
);
|
||||
|
||||
reconciled.cursors.forEach(({ id, position }) => {
|
||||
const whereToJump = RemoteCursorsPluginValue.findWhereToMoveCursor(
|
||||
position,
|
||||
reconciled.history
|
||||
);
|
||||
if (whereToJump !== null) {
|
||||
updatedPositions[id] = whereToJump;
|
||||
} else {
|
||||
updatedPositions[id] = position;
|
||||
}
|
||||
});
|
||||
reconciled.cursors.forEach(({ id, position }) => {
|
||||
const whereToJump = RemoteCursorsPluginValue.findWhereToMoveCursor(
|
||||
position,
|
||||
reconciled.history
|
||||
);
|
||||
if (whereToJump !== null) {
|
||||
updatedPositions[id] = whereToJump;
|
||||
} else {
|
||||
updatedPositions[id] = position;
|
||||
}
|
||||
});
|
||||
|
||||
RemoteCursorsPluginValue.cursors.forEach(({ span }, i) => {
|
||||
span.start = updatedPositions[i * 2];
|
||||
span.end = updatedPositions[i * 2 + 1];
|
||||
});
|
||||
}
|
||||
RemoteCursorsPluginValue.cursors.forEach(({ span }, i) => {
|
||||
span.start = updatedPositions[i * 2];
|
||||
span.end = updatedPositions[i * 2 + 1];
|
||||
});
|
||||
}
|
||||
|
||||
private static findWhereToMoveCursor(
|
||||
cursor: number,
|
||||
spans: SpanWithHistory[]
|
||||
): number | null {
|
||||
let position = 0;
|
||||
for (const span of spans) {
|
||||
// left and origin are the same
|
||||
if (position === cursor && span.history === "AddedFromRight") {
|
||||
return position + span.text.length;
|
||||
}
|
||||
position += span.text.length;
|
||||
if (position === cursor && span.history === "RemovedFromRight") {
|
||||
return position - span.text.length;
|
||||
}
|
||||
}
|
||||
private static findWhereToMoveCursor(
|
||||
cursor: number,
|
||||
spans: SpanWithHistory[]
|
||||
): number | null {
|
||||
let position = 0;
|
||||
for (const span of spans) {
|
||||
// left and origin are the same
|
||||
if (position === cursor && span.history === "AddedFromRight") {
|
||||
return position + span.text.length;
|
||||
}
|
||||
position += span.text.length;
|
||||
if (position === cursor && span.history === "RemovedFromRight") {
|
||||
return position - span.text.length;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public update(update: ViewUpdate): void {
|
||||
const original = update.startState.doc.toString();
|
||||
const edited = update.state.doc.toString();
|
||||
public update(update: ViewUpdate): void {
|
||||
const original = update.startState.doc.toString();
|
||||
const edited = update.state.doc.toString();
|
||||
|
||||
RemoteCursorsPluginValue.interpolateRemoteCursorPositions(
|
||||
original,
|
||||
edited
|
||||
);
|
||||
RemoteCursorsPluginValue.interpolateRemoteCursorPositions(
|
||||
original,
|
||||
edited
|
||||
);
|
||||
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
const relative_path = RemoteCursorsPluginValue.findFileForEditor(
|
||||
update.view
|
||||
);
|
||||
RemoteCursorsPluginValue.cursors
|
||||
.filter(({ path }) => path == relative_path)
|
||||
.forEach(({ name, span: { start, end } }) => {
|
||||
const color = utils.getRandomColor(name);
|
||||
const startLine = update.view.state.doc.lineAt(start);
|
||||
const endLine = update.view.state.doc.lineAt(end);
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
const relative_path = RemoteCursorsPluginValue.findFileForEditor(
|
||||
update.view
|
||||
);
|
||||
RemoteCursorsPluginValue.cursors
|
||||
.filter(({ path }) => path == relative_path)
|
||||
.forEach(({ name, span: { start, end } }) => {
|
||||
const color = utils.getRandomColor(name);
|
||||
const startLine = update.view.state.doc.lineAt(start);
|
||||
const endLine = update.view.state.doc.lineAt(end);
|
||||
|
||||
const attributes = {
|
||||
style: `background-color: ${color};`
|
||||
};
|
||||
const attributes = {
|
||||
style: `background-color: ${color};`
|
||||
};
|
||||
|
||||
if (startLine.number === endLine.number) {
|
||||
// selected content in a single line.
|
||||
decorations.push({
|
||||
from: start,
|
||||
to: end,
|
||||
value: Decoration.mark({
|
||||
attributes
|
||||
})
|
||||
});
|
||||
} else {
|
||||
// selected content in multiple lines
|
||||
// first, render text-selection in the first line
|
||||
decorations.push({
|
||||
from: start,
|
||||
to: startLine.from + startLine.length,
|
||||
value: Decoration.mark({
|
||||
attributes
|
||||
})
|
||||
});
|
||||
if (startLine.number === endLine.number) {
|
||||
// selected content in a single line.
|
||||
decorations.push({
|
||||
from: start,
|
||||
to: end,
|
||||
value: Decoration.mark({
|
||||
attributes
|
||||
})
|
||||
});
|
||||
} else {
|
||||
// selected content in multiple lines
|
||||
// first, render text-selection in the first line
|
||||
decorations.push({
|
||||
from: start,
|
||||
to: startLine.from + startLine.length,
|
||||
value: Decoration.mark({
|
||||
attributes
|
||||
})
|
||||
});
|
||||
|
||||
// render text-selection in the lines between the first and last line
|
||||
for (
|
||||
let i = startLine.number + 1;
|
||||
i < endLine.number;
|
||||
i++
|
||||
) {
|
||||
const currentLine = update.view.state.doc.line(i);
|
||||
decorations.push({
|
||||
from: currentLine.from,
|
||||
to: currentLine.to,
|
||||
value: Decoration.mark({
|
||||
attributes
|
||||
})
|
||||
});
|
||||
}
|
||||
// render text-selection in the lines between the first and last line
|
||||
for (
|
||||
let i = startLine.number + 1;
|
||||
i < endLine.number;
|
||||
i++
|
||||
) {
|
||||
const currentLine = update.view.state.doc.line(i);
|
||||
decorations.push({
|
||||
from: currentLine.from,
|
||||
to: currentLine.to,
|
||||
value: Decoration.mark({
|
||||
attributes
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// render text-selection in the last line
|
||||
decorations.push({
|
||||
from: endLine.from,
|
||||
to: end,
|
||||
value: Decoration.mark({
|
||||
attributes
|
||||
})
|
||||
});
|
||||
}
|
||||
// render text-selection in the last line
|
||||
decorations.push({
|
||||
from: endLine.from,
|
||||
to: end,
|
||||
value: Decoration.mark({
|
||||
attributes
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
decorations.push({
|
||||
from: end,
|
||||
to: end,
|
||||
value: Decoration.widget({
|
||||
side: end - start > 0 ? -1 : 1, // the local cursor should be rendered outside the remote selection
|
||||
block: false,
|
||||
widget: new RemoteCursorWidget(color, name)
|
||||
})
|
||||
});
|
||||
});
|
||||
decorations.push({
|
||||
from: end,
|
||||
to: end,
|
||||
value: Decoration.widget({
|
||||
side: end - start > 0 ? -1 : 1, // the local cursor should be rendered outside the remote selection
|
||||
block: false,
|
||||
widget: new RemoteCursorWidget(color, name)
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
this.decorations = Decoration.set(decorations, true);
|
||||
}
|
||||
this.decorations = Decoration.set(decorations, true);
|
||||
}
|
||||
}
|
||||
|
||||
export const remoteCursorsPlugin = ViewPlugin.fromClass(
|
||||
RemoteCursorsPluginValue,
|
||||
{
|
||||
decorations: (v) => v.decorations
|
||||
}
|
||||
RemoteCursorsPluginValue,
|
||||
{
|
||||
decorations: (v) => v.decorations
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,43 +1,43 @@
|
|||
.vault-link-sync-status {
|
||||
position: absolute;
|
||||
right: var(--size-4-4);
|
||||
top: var(--size-4-2);
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: var(--size-4-4);
|
||||
top: var(--size-4-2);
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
|
||||
> span {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
min-width: 200px;
|
||||
text-align: right;
|
||||
padding-right: var(--size-2-2);
|
||||
> span {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
min-width: 200px;
|
||||
text-align: right;
|
||||
padding-right: var(--size-2-2);
|
||||
|
||||
top: 50%;
|
||||
left: 0;
|
||||
transform: translateY(-50%) translateX(-100%) translateY(-2px);
|
||||
transition: opacity 200ms;
|
||||
}
|
||||
top: 50%;
|
||||
left: 0;
|
||||
transform: translateY(-50%) translateX(-100%) translateY(-2px);
|
||||
transition: opacity 200ms;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
> span {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
> span {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
> .icon {
|
||||
line-height: 0;
|
||||
}
|
||||
> .icon {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
&.loading > .icon {
|
||||
animation: spin 2s linear infinite;
|
||||
&.loading > .icon {
|
||||
animation: spin 2s linear infinite;
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,91 +7,91 @@ import type VaultLinkPlugin from "src/vault-link-plugin";
|
|||
import { HistoryView } from "../history/history-view";
|
||||
|
||||
export class EditorStatusDisplayManager {
|
||||
private static readonly UPDATE_INTERVAL_IN_MS = 100;
|
||||
private static readonly UPDATE_INTERVAL_IN_MS = 100;
|
||||
|
||||
private readonly intervalId: NodeJS.Timeout;
|
||||
private readonly lastStatuses = new Map<string, DocumentSyncStatus>();
|
||||
private readonly intervalId: NodeJS.Timeout;
|
||||
private readonly lastStatuses = new Map<string, DocumentSyncStatus>();
|
||||
|
||||
public constructor(
|
||||
private readonly plugin: VaultLinkPlugin,
|
||||
private readonly workspace: Workspace,
|
||||
private readonly client: SyncClient
|
||||
) {
|
||||
this.intervalId = setInterval(() => {
|
||||
this.updateEditorStatusDisplay();
|
||||
}, EditorStatusDisplayManager.UPDATE_INTERVAL_IN_MS);
|
||||
}
|
||||
public constructor(
|
||||
private readonly plugin: VaultLinkPlugin,
|
||||
private readonly workspace: Workspace,
|
||||
private readonly client: SyncClient
|
||||
) {
|
||||
this.intervalId = setInterval(() => {
|
||||
this.updateEditorStatusDisplay();
|
||||
}, EditorStatusDisplayManager.UPDATE_INTERVAL_IN_MS);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
public dispose(): void {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
|
||||
private updateEditorStatusDisplay(): void {
|
||||
this.workspace.iterateAllLeaves((leaf) => {
|
||||
if (leaf.view instanceof FileView) {
|
||||
const filePath = leaf.view.file?.path;
|
||||
if (filePath == null) {
|
||||
return;
|
||||
}
|
||||
private updateEditorStatusDisplay(): void {
|
||||
this.workspace.iterateAllLeaves((leaf) => {
|
||||
if (leaf.view instanceof FileView) {
|
||||
const filePath = leaf.view.file?.path;
|
||||
if (filePath == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = this.getElementFromLeaf(leaf.view);
|
||||
if (element == null) {
|
||||
return;
|
||||
}
|
||||
const element = this.getElementFromLeaf(leaf.view);
|
||||
if (element == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousStatus = this.lastStatuses.get(filePath);
|
||||
const currentStatus =
|
||||
this.client.getDocumentSyncingStatus(filePath);
|
||||
if (previousStatus === currentStatus) {
|
||||
return;
|
||||
}
|
||||
this.lastStatuses.set(filePath, currentStatus);
|
||||
const previousStatus = this.lastStatuses.get(filePath);
|
||||
const currentStatus =
|
||||
this.client.getDocumentSyncingStatus(filePath);
|
||||
if (previousStatus === currentStatus) {
|
||||
return;
|
||||
}
|
||||
this.lastStatuses.set(filePath, currentStatus);
|
||||
|
||||
if (currentStatus == DocumentSyncStatus.SYNCING_IS_DISABLED) {
|
||||
element.remove();
|
||||
return;
|
||||
}
|
||||
if (currentStatus == DocumentSyncStatus.SYNCING_IS_DISABLED) {
|
||||
element.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStatus == DocumentSyncStatus.SYNCING) {
|
||||
element.classList.add("loading");
|
||||
} else {
|
||||
element.classList.remove("loading");
|
||||
}
|
||||
if (currentStatus == DocumentSyncStatus.SYNCING) {
|
||||
element.classList.add("loading");
|
||||
} else {
|
||||
element.classList.remove("loading");
|
||||
}
|
||||
|
||||
const iconContainer = element.querySelector(".icon");
|
||||
if (iconContainer != null) {
|
||||
setIcon(
|
||||
iconContainer as HTMLElement, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
currentStatus == DocumentSyncStatus.SYNCING
|
||||
? "loader"
|
||||
: "circle-check"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
const iconContainer = element.querySelector(".icon");
|
||||
if (iconContainer != null) {
|
||||
setIcon(
|
||||
iconContainer as HTMLElement, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
currentStatus == DocumentSyncStatus.SYNCING
|
||||
? "loader"
|
||||
: "circle-check"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getElementFromLeaf(fileView: FileView): Element | undefined {
|
||||
const parent = fileView.contentEl.querySelector(".cm-editor");
|
||||
if (parent == null) {
|
||||
return;
|
||||
}
|
||||
private getElementFromLeaf(fileView: FileView): Element | undefined {
|
||||
const parent = fileView.contentEl.querySelector(".cm-editor");
|
||||
if (parent == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
parent.querySelector(".vault-link-sync-status") ??
|
||||
parent.createDiv(
|
||||
{
|
||||
cls: "vault-link-sync-status"
|
||||
},
|
||||
(el) => {
|
||||
el.createSpan({ text: "VaultLink sync state" });
|
||||
el.createDiv({
|
||||
cls: "icon"
|
||||
});
|
||||
el.onclick = async (): Promise<void> =>
|
||||
this.plugin.activateView(HistoryView.TYPE);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
return (
|
||||
parent.querySelector(".vault-link-sync-status") ??
|
||||
parent.createDiv(
|
||||
{
|
||||
cls: "vault-link-sync-status"
|
||||
},
|
||||
(el) => {
|
||||
el.createSpan({ text: "VaultLink sync state" });
|
||||
el.createDiv({
|
||||
cls: "icon"
|
||||
});
|
||||
el.onclick = async (): Promise<void> =>
|
||||
this.plugin.activateView(HistoryView.TYPE);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,61 +1,61 @@
|
|||
.history-card {
|
||||
padding: var(--size-4-4);
|
||||
margin: var(--size-4-2);
|
||||
background-color: var(--color-base-00);
|
||||
border-radius: var(--radius-l);
|
||||
container-type: inline-size;
|
||||
word-break: break-word;
|
||||
padding: var(--size-4-4);
|
||||
margin: var(--size-4-2);
|
||||
background-color: var(--color-base-00);
|
||||
border-radius: var(--radius-l);
|
||||
container-type: inline-size;
|
||||
word-break: break-word;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.success {
|
||||
background-color: rgba(var(--color-green-rgb), 0.2);
|
||||
}
|
||||
&.success {
|
||||
background-color: rgba(var(--color-green-rgb), 0.2);
|
||||
}
|
||||
|
||||
&.error {
|
||||
background-color: rgba(var(--color-red-rgb), 0.2);
|
||||
}
|
||||
&.error {
|
||||
background-color: rgba(var(--color-red-rgb), 0.2);
|
||||
}
|
||||
|
||||
&.skipped {
|
||||
background-color: rgba(var(--color-green-rgb), 0.08);
|
||||
}
|
||||
&.skipped {
|
||||
background-color: rgba(var(--color-green-rgb), 0.08);
|
||||
}
|
||||
|
||||
.history-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--size-4-2);
|
||||
gap: var(--size-4-2);
|
||||
.history-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--size-4-2);
|
||||
gap: var(--size-4-2);
|
||||
|
||||
@container (max-width: 300px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
@container (max-width: 300px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.history-card-title {
|
||||
font: var(--font-monospace);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-4-2);
|
||||
margin: 0;
|
||||
.history-card-title {
|
||||
font: var(--font-monospace);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-4-2);
|
||||
margin: 0;
|
||||
|
||||
> span {
|
||||
margin-bottom: var(--size-4-1);
|
||||
}
|
||||
}
|
||||
> span {
|
||||
margin-bottom: var(--size-4-1);
|
||||
}
|
||||
}
|
||||
|
||||
.history-card-timestamp {
|
||||
font-size: var(--font-ui-small);
|
||||
font-style: italic;
|
||||
color: var(--italic-color);
|
||||
}
|
||||
}
|
||||
.history-card-timestamp {
|
||||
font-size: var(--font-ui-small);
|
||||
font-style: italic;
|
||||
color: var(--italic-color);
|
||||
}
|
||||
}
|
||||
|
||||
.history-card-message {
|
||||
font-size: var(--font-ui-medium);
|
||||
color: var(--color-base-70);
|
||||
margin: 0;
|
||||
}
|
||||
.history-card-message {
|
||||
font-size: var(--font-ui-medium);
|
||||
color: var(--color-base-70);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,234 +7,234 @@ import type { HistoryEntry, SyncClient } from "sync-client";
|
|||
import { SyncType } from "sync-client";
|
||||
|
||||
export class HistoryView extends ItemView {
|
||||
public static readonly TYPE = "history-view";
|
||||
public static readonly ICON = "square-stack";
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
public static readonly TYPE = "history-view";
|
||||
public static readonly ICON = "square-stack";
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
|
||||
private historyContainer: HTMLElement | undefined;
|
||||
private readonly historyEntryToElement = new Map<
|
||||
HistoryEntry,
|
||||
HTMLElement
|
||||
>();
|
||||
private historyContainer: HTMLElement | undefined;
|
||||
private readonly historyEntryToElement = new Map<
|
||||
HistoryEntry,
|
||||
HTMLElement
|
||||
>();
|
||||
|
||||
public constructor(
|
||||
private readonly client: SyncClient,
|
||||
leaf: WorkspaceLeaf
|
||||
) {
|
||||
super(leaf);
|
||||
this.icon = HistoryView.ICON;
|
||||
public constructor(
|
||||
private readonly client: SyncClient,
|
||||
leaf: WorkspaceLeaf
|
||||
) {
|
||||
super(leaf);
|
||||
this.icon = HistoryView.ICON;
|
||||
|
||||
this.client.addSyncHistoryUpdateListener(async () =>
|
||||
this.updateView().catch((error: unknown) => {
|
||||
this.client.logger.error(
|
||||
`Failed to update history view: ${error}`
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
this.client.onSyncHistoryUpdated.add(async () =>
|
||||
this.updateView().catch((error: unknown) => {
|
||||
this.client.logger.error(
|
||||
`Failed to update history view: ${error}`
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private static getSyncTypeIcon(type: SyncType | undefined): IconName {
|
||||
switch (type) {
|
||||
case SyncType.CREATE:
|
||||
return "file-plus";
|
||||
case SyncType.DELETE:
|
||||
return "trash-2";
|
||||
case SyncType.UPDATE:
|
||||
return "file-pen-line";
|
||||
case SyncType.MOVE:
|
||||
return "move-right";
|
||||
case SyncType.SKIPPED:
|
||||
return "circle-slash";
|
||||
case undefined:
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
private static getSyncTypeIcon(type: SyncType | undefined): IconName {
|
||||
switch (type) {
|
||||
case SyncType.CREATE:
|
||||
return "file-plus";
|
||||
case SyncType.DELETE:
|
||||
return "trash-2";
|
||||
case SyncType.UPDATE:
|
||||
return "file-pen-line";
|
||||
case SyncType.MOVE:
|
||||
return "move-right";
|
||||
case SyncType.SKIPPED:
|
||||
return "circle-slash";
|
||||
case undefined:
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static renderSyncItemTitle(
|
||||
element: HTMLElement,
|
||||
entry: HistoryEntry
|
||||
): void {
|
||||
const syncTypeIcon = HistoryView.getSyncTypeIcon(entry.details.type);
|
||||
if (syncTypeIcon) {
|
||||
setIcon(element.createDiv(), syncTypeIcon);
|
||||
}
|
||||
private static renderSyncItemTitle(
|
||||
element: HTMLElement,
|
||||
entry: HistoryEntry
|
||||
): void {
|
||||
const syncTypeIcon = HistoryView.getSyncTypeIcon(entry.details.type);
|
||||
if (syncTypeIcon) {
|
||||
setIcon(element.createDiv(), syncTypeIcon);
|
||||
}
|
||||
|
||||
let fileName = entry.details.relativePath.split("/").pop() ?? "";
|
||||
fileName = fileName.replace(/\.md$/, "");
|
||||
let fileName = entry.details.relativePath.split("/").pop() ?? "";
|
||||
fileName = fileName.replace(/\.md$/, "");
|
||||
|
||||
element.createEl("span", {
|
||||
text:
|
||||
entry.details.type === SyncType.SKIPPED
|
||||
? `Skipped: ${fileName}`
|
||||
: fileName
|
||||
});
|
||||
}
|
||||
element.createEl("span", {
|
||||
text:
|
||||
entry.details.type === SyncType.SKIPPED
|
||||
? `Skipped: ${fileName}`
|
||||
: fileName
|
||||
});
|
||||
}
|
||||
|
||||
private static updateTimeSince(
|
||||
element: HTMLElement,
|
||||
entry: HistoryEntry
|
||||
): void {
|
||||
const timestampElement = element.querySelector(
|
||||
".history-card-timestamp"
|
||||
);
|
||||
private static updateTimeSince(
|
||||
element: HTMLElement,
|
||||
entry: HistoryEntry
|
||||
): void {
|
||||
const timestampElement = element.querySelector(
|
||||
".history-card-timestamp"
|
||||
);
|
||||
|
||||
if (timestampElement != null) {
|
||||
timestampElement.textContent =
|
||||
HistoryView.getTimestampAndAuthor(entry);
|
||||
}
|
||||
}
|
||||
if (timestampElement != null) {
|
||||
timestampElement.textContent =
|
||||
HistoryView.getTimestampAndAuthor(entry);
|
||||
}
|
||||
}
|
||||
|
||||
private static getTimestampAndAuthor(entry: HistoryEntry): string {
|
||||
let content = intlFormatDistance(entry.timestamp, new Date());
|
||||
if ("author" in entry && entry.author !== undefined) {
|
||||
content += ` by ${entry.author}`;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
private static getTimestampAndAuthor(entry: HistoryEntry): string {
|
||||
let content = intlFormatDistance(entry.timestamp, new Date());
|
||||
if ("author" in entry && entry.author !== undefined) {
|
||||
content += ` by ${entry.author}`;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
public getViewType(): string {
|
||||
return HistoryView.TYPE;
|
||||
}
|
||||
public getViewType(): string {
|
||||
return HistoryView.TYPE;
|
||||
}
|
||||
|
||||
public getDisplayText(): string {
|
||||
return "VaultLink history";
|
||||
}
|
||||
public getDisplayText(): string {
|
||||
return "VaultLink history";
|
||||
}
|
||||
|
||||
public async onOpen(): Promise<void> {
|
||||
const container = this.containerEl.children[1];
|
||||
container.createEl("h4", { text: "VaultLink history" });
|
||||
public async onOpen(): Promise<void> {
|
||||
const container = this.containerEl.children[1];
|
||||
container.createEl("h4", { text: "VaultLink history" });
|
||||
|
||||
this.historyContainer = container.createDiv({ cls: "logs-container" });
|
||||
this.historyContainer = container.createDiv({ cls: "logs-container" });
|
||||
|
||||
await this.updateView();
|
||||
this.clearTimer();
|
||||
this.timer = setInterval(
|
||||
() =>
|
||||
void this.updateView().catch((error: unknown) => {
|
||||
this.client.logger.error(
|
||||
`Failed to update history view: ${error}`
|
||||
);
|
||||
}),
|
||||
1000
|
||||
);
|
||||
}
|
||||
await this.updateView();
|
||||
this.clearTimer();
|
||||
this.timer = setInterval(
|
||||
() =>
|
||||
void this.updateView().catch((error: unknown) => {
|
||||
this.client.logger.error(
|
||||
`Failed to update history view: ${error}`
|
||||
);
|
||||
}),
|
||||
1000
|
||||
);
|
||||
}
|
||||
|
||||
public async onClose(): Promise<void> {
|
||||
this.clearTimer();
|
||||
}
|
||||
public async onClose(): Promise<void> {
|
||||
this.clearTimer();
|
||||
}
|
||||
|
||||
private clearTimer(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
private clearTimer(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async updateView(): Promise<void> {
|
||||
const container = this.historyContainer;
|
||||
if (container === undefined) {
|
||||
return;
|
||||
}
|
||||
private async updateView(): Promise<void> {
|
||||
const container = this.historyContainer;
|
||||
if (container === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// entries are newest first, but we prepend new ones
|
||||
const entries = this.client.getHistoryEntries().toReversed();
|
||||
// entries are newest first, but we prepend new ones
|
||||
const entries = this.client.getHistoryEntries().toReversed();
|
||||
|
||||
if (this.historyEntryToElement.size === 0 && entries.length > 0) {
|
||||
// Clear the "No update has happened yet" message
|
||||
container.empty();
|
||||
}
|
||||
if (this.historyEntryToElement.size === 0 && entries.length > 0) {
|
||||
// Clear the "No update has happened yet" message
|
||||
container.empty();
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const element = this.historyEntryToElement.get(entry);
|
||||
if (element !== undefined) {
|
||||
HistoryView.updateTimeSince(element, entry);
|
||||
return;
|
||||
}
|
||||
entries.forEach((entry) => {
|
||||
const element = this.historyEntryToElement.get(entry);
|
||||
if (element !== undefined) {
|
||||
HistoryView.updateTimeSince(element, entry);
|
||||
return;
|
||||
}
|
||||
|
||||
const newElement = this.createHistoryCard(container, entry);
|
||||
container.prepend(newElement);
|
||||
this.historyEntryToElement.set(entry, newElement);
|
||||
});
|
||||
const newElement = this.createHistoryCard(container, entry);
|
||||
container.prepend(newElement);
|
||||
this.historyEntryToElement.set(entry, newElement);
|
||||
});
|
||||
|
||||
const newEntries = new Set(entries);
|
||||
for (const [entry, element] of this.historyEntryToElement) {
|
||||
if (!newEntries.has(entry)) {
|
||||
element.remove();
|
||||
this.historyEntryToElement.delete(entry);
|
||||
}
|
||||
}
|
||||
const newEntries = new Set(entries);
|
||||
for (const [entry, element] of this.historyEntryToElement) {
|
||||
if (!newEntries.has(entry)) {
|
||||
element.remove();
|
||||
this.historyEntryToElement.delete(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
container.empty();
|
||||
container.createEl("p", {
|
||||
text: "No update has happened yet."
|
||||
});
|
||||
}
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
container.empty();
|
||||
container.createEl("p", {
|
||||
text: "No update has happened yet."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private createHistoryCard(
|
||||
container: HTMLElement,
|
||||
entry: HistoryEntry
|
||||
): HTMLElement {
|
||||
return container.createDiv(
|
||||
{
|
||||
cls: ["history-card", entry.status.toLocaleLowerCase()]
|
||||
},
|
||||
(card) => {
|
||||
if (
|
||||
this.app.vault.getFileByPath(entry.details.relativePath) !=
|
||||
null
|
||||
) {
|
||||
card.addEventListener("click", () => {
|
||||
this.app.workspace
|
||||
.openLinkText(
|
||||
entry.details.relativePath,
|
||||
entry.details.relativePath,
|
||||
false
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
this.client.logger.error(
|
||||
`Failed to open link for ${entry.details.relativePath}: ${error}`
|
||||
);
|
||||
});
|
||||
});
|
||||
private createHistoryCard(
|
||||
container: HTMLElement,
|
||||
entry: HistoryEntry
|
||||
): HTMLElement {
|
||||
return container.createDiv(
|
||||
{
|
||||
cls: ["history-card", entry.status.toLocaleLowerCase()]
|
||||
},
|
||||
(card) => {
|
||||
if (
|
||||
this.app.vault.getFileByPath(entry.details.relativePath) !=
|
||||
null
|
||||
) {
|
||||
card.addEventListener("click", () => {
|
||||
this.app.workspace
|
||||
.openLinkText(
|
||||
entry.details.relativePath,
|
||||
entry.details.relativePath,
|
||||
false
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
this.client.logger.error(
|
||||
`Failed to open link for ${entry.details.relativePath}: ${error}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
card.addClass("clickable");
|
||||
}
|
||||
card.addClass("clickable");
|
||||
}
|
||||
|
||||
card.createDiv(
|
||||
{
|
||||
cls: "history-card-header"
|
||||
},
|
||||
(header) => {
|
||||
header.createEl(
|
||||
"h5",
|
||||
{
|
||||
cls: "history-card-title"
|
||||
},
|
||||
(title) => {
|
||||
HistoryView.renderSyncItemTitle(title, entry);
|
||||
}
|
||||
);
|
||||
card.createDiv(
|
||||
{
|
||||
cls: "history-card-header"
|
||||
},
|
||||
(header) => {
|
||||
header.createEl(
|
||||
"h5",
|
||||
{
|
||||
cls: "history-card-title"
|
||||
},
|
||||
(title) => {
|
||||
HistoryView.renderSyncItemTitle(title, entry);
|
||||
}
|
||||
);
|
||||
|
||||
header.createSpan({
|
||||
text: HistoryView.getTimestampAndAuthor(entry),
|
||||
cls: "history-card-timestamp"
|
||||
});
|
||||
}
|
||||
);
|
||||
header.createSpan({
|
||||
text: HistoryView.getTimestampAndAuthor(entry),
|
||||
cls: "history-card-timestamp"
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const body =
|
||||
entry.details.type === SyncType.MOVE
|
||||
? `${entry.message}. Moved from '${entry.details.movedFrom}' to '${entry.details.relativePath}'`
|
||||
: `${entry.message}.`;
|
||||
const body =
|
||||
entry.details.type === SyncType.MOVE
|
||||
? `${entry.message}. Moved from '${entry.details.movedFrom}' to '${entry.details.relativePath}'`
|
||||
: `${entry.message}.`;
|
||||
|
||||
card.createEl("p", {
|
||||
text: body,
|
||||
cls: "history-card-message"
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
card.createEl("p", {
|
||||
text: body,
|
||||
cls: "history-card-message"
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,74 +1,74 @@
|
|||
.logs-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.verbosity-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: normal;
|
||||
gap: var(--size-4-2);
|
||||
margin: var(--size-4-4) var(--size-4-2);
|
||||
.verbosity-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: normal;
|
||||
gap: var(--size-4-2);
|
||||
margin: var(--size-4-4) var(--size-4-2);
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.logs-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-4-2);
|
||||
.logs-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-4-2);
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-2-1);
|
||||
padding: var(--size-2-2) var(--size-4-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-2-1);
|
||||
padding: var(--size-2-2) var(--size-4-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
select {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-container {
|
||||
max-width: 100%;
|
||||
overflow-y: auto;
|
||||
.logs-container {
|
||||
max-width: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
.log-message {
|
||||
font: var(--font-monospace);
|
||||
margin-bottom: var(--size-2-1);
|
||||
overflow-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
user-select: all;
|
||||
.log-message {
|
||||
font: var(--font-monospace);
|
||||
margin-bottom: var(--size-2-1);
|
||||
overflow-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
user-select: all;
|
||||
|
||||
.timestamp {
|
||||
padding: var(--size-2-1) var(--size-4-1);
|
||||
border-radius: var(--radius-s);
|
||||
background-color: var(--color-base-30);
|
||||
font-size: var(--font-ui-small);
|
||||
font-family: var(--font-monospace);
|
||||
font-weight: var(--bold-weight);
|
||||
margin-right: var(--size-4-1);
|
||||
}
|
||||
.timestamp {
|
||||
padding: var(--size-2-1) var(--size-4-1);
|
||||
border-radius: var(--radius-s);
|
||||
background-color: var(--color-base-30);
|
||||
font-size: var(--font-ui-small);
|
||||
font-family: var(--font-monospace);
|
||||
font-weight: var(--bold-weight);
|
||||
margin-right: var(--size-4-1);
|
||||
}
|
||||
|
||||
&.DEBUG {
|
||||
color: var(--color-base-50);
|
||||
}
|
||||
&.DEBUG {
|
||||
color: var(--color-base-50);
|
||||
}
|
||||
|
||||
&.INFO {
|
||||
color: var(--color-base-100);
|
||||
}
|
||||
&.INFO {
|
||||
color: var(--color-base-100);
|
||||
}
|
||||
|
||||
&.WARNING {
|
||||
color: rgb(var(--color-yellow-rgb));
|
||||
}
|
||||
&.WARNING {
|
||||
color: rgb(var(--color-yellow-rgb));
|
||||
}
|
||||
|
||||
&.ERROR {
|
||||
color: rgb(var(--color-red-rgb));
|
||||
}
|
||||
}
|
||||
}
|
||||
&.ERROR {
|
||||
color: rgb(var(--color-red-rgb));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,189 +6,189 @@ import type { LogLine } from "sync-client";
|
|||
import { LogLevel, type SyncClient } from "sync-client";
|
||||
|
||||
export class LogsView extends ItemView {
|
||||
public static readonly TYPE = "logs-view";
|
||||
public static readonly ICON = "logs";
|
||||
public static readonly TYPE = "logs-view";
|
||||
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 readonly logLineToElement = new Map<LogLine, HTMLElement>();
|
||||
private minLogLevel: LogLevel = LogLevel.INFO;
|
||||
private logsContainer: HTMLElement | undefined;
|
||||
private readonly logLineToElement = new Map<LogLine, HTMLElement>();
|
||||
private minLogLevel: LogLevel = LogLevel.INFO;
|
||||
|
||||
public constructor(
|
||||
private readonly client: SyncClient,
|
||||
leaf: WorkspaceLeaf
|
||||
) {
|
||||
super(leaf);
|
||||
this.icon = LogsView.ICON;
|
||||
this.client.logger.addOnMessageListener(() => {
|
||||
this.updateView();
|
||||
});
|
||||
}
|
||||
public constructor(
|
||||
private readonly client: SyncClient,
|
||||
leaf: WorkspaceLeaf
|
||||
) {
|
||||
super(leaf);
|
||||
this.icon = LogsView.ICON;
|
||||
this.client.logger.onLogEmitted.add(() => {
|
||||
this.updateView();
|
||||
});
|
||||
}
|
||||
|
||||
private static createLogLineElement(
|
||||
container: HTMLElement,
|
||||
logLine: LogLine
|
||||
): HTMLElement {
|
||||
return container.createDiv(
|
||||
{
|
||||
cls: ["log-message", logLine.level]
|
||||
},
|
||||
(messageContainer) => {
|
||||
messageContainer.createEl("span", {
|
||||
text: LogsView.formatTimestamp(logLine.timestamp),
|
||||
cls: "timestamp"
|
||||
});
|
||||
messageContainer.createEl("span", {
|
||||
text: logLine.message
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
private static createLogLineElement(
|
||||
container: HTMLElement,
|
||||
logLine: LogLine
|
||||
): HTMLElement {
|
||||
return container.createDiv(
|
||||
{
|
||||
cls: ["log-message", logLine.level]
|
||||
},
|
||||
(messageContainer) => {
|
||||
messageContainer.createEl("span", {
|
||||
text: LogsView.formatTimestamp(logLine.timestamp),
|
||||
cls: "timestamp"
|
||||
});
|
||||
messageContainer.createEl("span", {
|
||||
text: logLine.message
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private static formatTimestamp(timestamp: Date): string {
|
||||
return timestamp.toTimeString().split(" ")[0];
|
||||
}
|
||||
private static formatTimestamp(timestamp: Date): string {
|
||||
return timestamp.toTimeString().split(" ")[0];
|
||||
}
|
||||
|
||||
public getViewType(): string {
|
||||
return LogsView.TYPE;
|
||||
}
|
||||
public getViewType(): string {
|
||||
return LogsView.TYPE;
|
||||
}
|
||||
|
||||
public getDisplayText(): string {
|
||||
return "VaultLink logs";
|
||||
}
|
||||
public getDisplayText(): string {
|
||||
return "VaultLink logs";
|
||||
}
|
||||
|
||||
public async onOpen(): Promise<void> {
|
||||
const container = this.containerEl.children[1];
|
||||
container.addClass("logs-view");
|
||||
public async onOpen(): Promise<void> {
|
||||
const container = this.containerEl.children[1];
|
||||
container.addClass("logs-view");
|
||||
|
||||
const logLevels = [
|
||||
{ label: "Debug", value: LogLevel.DEBUG },
|
||||
{ label: "Info", value: LogLevel.INFO },
|
||||
{ label: "Warn", value: LogLevel.WARNING },
|
||||
{ label: "Error", value: LogLevel.ERROR }
|
||||
];
|
||||
const logLevels = [
|
||||
{ label: "Debug", value: LogLevel.DEBUG },
|
||||
{ label: "Info", value: LogLevel.INFO },
|
||||
{ label: "Warn", value: LogLevel.WARNING },
|
||||
{ label: "Error", value: LogLevel.ERROR }
|
||||
];
|
||||
|
||||
container.createDiv(
|
||||
{
|
||||
cls: "verbosity-selector"
|
||||
},
|
||||
(verbositySection) => {
|
||||
verbositySection.createEl("h4", {
|
||||
text: "VaultLink logs"
|
||||
});
|
||||
container.createDiv(
|
||||
{
|
||||
cls: "verbosity-selector"
|
||||
},
|
||||
(verbositySection) => {
|
||||
verbositySection.createEl("h4", {
|
||||
text: "VaultLink logs"
|
||||
});
|
||||
|
||||
const controls = verbositySection.createDiv({
|
||||
cls: "logs-controls"
|
||||
});
|
||||
const controls = verbositySection.createDiv({
|
||||
cls: "logs-controls"
|
||||
});
|
||||
|
||||
const copyButton = controls.createEl("button", {
|
||||
text: "Copy logs",
|
||||
cls: "clickable-icon"
|
||||
});
|
||||
setIcon(copyButton, "clipboard-copy");
|
||||
copyButton.addEventListener("click", () => {
|
||||
this.copyLogsToClipboard();
|
||||
});
|
||||
const copyButton = controls.createEl("button", {
|
||||
text: "Copy logs",
|
||||
cls: "clickable-icon"
|
||||
});
|
||||
setIcon(copyButton, "clipboard-copy");
|
||||
copyButton.addEventListener("click", () => {
|
||||
this.copyLogsToClipboard();
|
||||
});
|
||||
|
||||
controls.createEl("select", {}, (dropdown) => {
|
||||
logLevels.forEach(({ label, value }) =>
|
||||
dropdown.createEl("option", { text: label, value })
|
||||
);
|
||||
controls.createEl("select", {}, (dropdown) => {
|
||||
logLevels.forEach(({ label, value }) =>
|
||||
dropdown.createEl("option", { text: label, value })
|
||||
);
|
||||
|
||||
dropdown.value = this.minLogLevel;
|
||||
dropdown.value = this.minLogLevel;
|
||||
|
||||
dropdown.addEventListener("change", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
this.minLogLevel = dropdown.value as LogLevel;
|
||||
dropdown.addEventListener("change", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
this.minLogLevel = dropdown.value as LogLevel;
|
||||
|
||||
this.logsContainer?.empty();
|
||||
this.logLineToElement.clear();
|
||||
this.updateView();
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
this.logsContainer?.empty();
|
||||
this.logLineToElement.clear();
|
||||
this.updateView();
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
this.logsContainer = container.createDiv({ cls: "logs-container" });
|
||||
this.logsContainer = container.createDiv({ cls: "logs-container" });
|
||||
|
||||
this.updateView();
|
||||
}
|
||||
this.updateView();
|
||||
}
|
||||
|
||||
private copyLogsToClipboard(): void {
|
||||
const logs = this.client.logger.getMessages(this.minLogLevel);
|
||||
private copyLogsToClipboard(): void {
|
||||
const logs = this.client.logger.getMessages(this.minLogLevel);
|
||||
|
||||
if (logs.length === 0) {
|
||||
new Notice("No logs to copy");
|
||||
return;
|
||||
}
|
||||
if (logs.length === 0) {
|
||||
new Notice("No logs to copy");
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedLogs = logs
|
||||
.map((logLine) => {
|
||||
const timestamp = logLine.timestamp.toLocaleString();
|
||||
const level = logLine.level.toUpperCase();
|
||||
return `[${timestamp}] ${level}: ${logLine.message}`;
|
||||
})
|
||||
.join("\n");
|
||||
const formattedLogs = logs
|
||||
.map((logLine) => {
|
||||
const timestamp = logLine.timestamp.toLocaleString();
|
||||
const level = logLine.level.toUpperCase();
|
||||
return `[${timestamp}] ${level}: ${logLine.message}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
navigator.clipboard
|
||||
.writeText(formattedLogs)
|
||||
.then(() => {
|
||||
new Notice(`Copied ${logs.length} log entries to clipboard`);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
this.client.logger.error(
|
||||
`Failed to copy logs to clipboard: ${error}`
|
||||
);
|
||||
new Notice("Failed to copy logs to clipboard");
|
||||
});
|
||||
}
|
||||
navigator.clipboard
|
||||
.writeText(formattedLogs)
|
||||
.then(() => {
|
||||
new Notice(`Copied ${logs.length} log entries to clipboard`);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
this.client.logger.error(
|
||||
`Failed to copy logs to clipboard: ${error}`
|
||||
);
|
||||
new Notice("Failed to copy logs to clipboard");
|
||||
});
|
||||
}
|
||||
|
||||
private updateView(): void {
|
||||
const container = this.logsContainer;
|
||||
if (container === undefined) {
|
||||
return;
|
||||
}
|
||||
private updateView(): void {
|
||||
const container = this.logsContainer;
|
||||
if (container === undefined) {
|
||||
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) {
|
||||
// Clear the "No logs available yet" message
|
||||
container.empty();
|
||||
}
|
||||
if (this.logLineToElement.size === 0 && logs.length > 0) {
|
||||
// Clear the "No logs available yet" message
|
||||
container.empty();
|
||||
}
|
||||
|
||||
const shouldScroll =
|
||||
container.scrollTop == 0 ||
|
||||
container.scrollHeight -
|
||||
container.clientHeight -
|
||||
container.scrollTop <
|
||||
LogsView.MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX;
|
||||
const shouldScroll =
|
||||
container.scrollTop == 0 ||
|
||||
container.scrollHeight -
|
||||
container.clientHeight -
|
||||
container.scrollTop <
|
||||
LogsView.MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX;
|
||||
|
||||
logs.forEach((message) => {
|
||||
if (this.logLineToElement.has(message)) {
|
||||
return;
|
||||
}
|
||||
logs.forEach((message) => {
|
||||
if (this.logLineToElement.has(message)) {
|
||||
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);
|
||||
for (const [logLine, element] of this.logLineToElement) {
|
||||
if (!newLines.has(logLine)) {
|
||||
element.remove();
|
||||
this.logLineToElement.delete(logLine);
|
||||
}
|
||||
}
|
||||
const newLines = new Set(logs);
|
||||
for (const [logLine, element] of this.logLineToElement) {
|
||||
if (!newLines.has(logLine)) {
|
||||
element.remove();
|
||||
this.logLineToElement.delete(logLine);
|
||||
}
|
||||
}
|
||||
|
||||
if (logs.length === 0) {
|
||||
container.empty();
|
||||
container.createEl("p", {
|
||||
text: "No logs available yet."
|
||||
});
|
||||
} else if (shouldScroll) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
if (logs.length === 0) {
|
||||
container.empty();
|
||||
container.createEl("p", {
|
||||
text: "No logs available yet."
|
||||
});
|
||||
} else if (shouldScroll) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,134 +1,168 @@
|
|||
@mixin number-card {
|
||||
padding: var(--size-2-1) var(--size-4-1);
|
||||
border-radius: var(--radius-s);
|
||||
background-color: var(--color-base-30);
|
||||
font-size: var(--font-ui-small);
|
||||
padding: var(--size-2-1) var(--size-4-1);
|
||||
border-radius: var(--radius-s);
|
||||
background-color: var(--color-base-30);
|
||||
font-size: var(--font-ui-small);
|
||||
|
||||
&.good {
|
||||
background-color: rgba(var(--color-green-rgb), 0.35);
|
||||
}
|
||||
&.good {
|
||||
background-color: rgba(var(--color-green-rgb), 0.35);
|
||||
}
|
||||
|
||||
&.bad {
|
||||
background-color: rgba(var(--color-red-rgb), 0.35);
|
||||
}
|
||||
&.bad {
|
||||
background-color: rgba(var(--color-red-rgb), 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.vault-link-settings-container {
|
||||
position: relative;
|
||||
position: relative;
|
||||
|
||||
.vault-link-settings {
|
||||
h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--h2-size);
|
||||
.vault-link-settings {
|
||||
h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--h2-size);
|
||||
|
||||
.version {
|
||||
@include number-card;
|
||||
margin: var(--size-2-2) 0 0 var(--size-4-2);
|
||||
background-color: var(--color-base-30);
|
||||
color: var(--color-base-70);
|
||||
font-size: var(--font-ui-smaller);
|
||||
}
|
||||
}
|
||||
.version {
|
||||
@include number-card;
|
||||
margin: var(--size-2-2) 0 0 var(--size-4-2);
|
||||
background-color: var(--color-base-30);
|
||||
color: var(--color-base-70);
|
||||
font-size: var(--font-ui-smaller);
|
||||
}
|
||||
}
|
||||
|
||||
.button-container {
|
||||
display: flex;
|
||||
gap: var(--size-4-2);
|
||||
}
|
||||
.button-container {
|
||||
display: flex;
|
||||
gap: var(--size-4-2);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--font-ui-large);
|
||||
margin-top: var(--heading-spacing);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--font-ui-large);
|
||||
margin-top: var(--heading-spacing);
|
||||
}
|
||||
|
||||
button,
|
||||
input[type="range"],
|
||||
.checkbox-container,
|
||||
.slider::-webkit-slider-thumb {
|
||||
cursor: pointer;
|
||||
}
|
||||
button,
|
||||
input[type="range"],
|
||||
.checkbox-container,
|
||||
.slider::-webkit-slider-thumb {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
textarea {
|
||||
width: 250px;
|
||||
}
|
||||
input[type="text"],
|
||||
textarea {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: none;
|
||||
height: 75px;
|
||||
}
|
||||
textarea {
|
||||
resize: none;
|
||||
height: 75px;
|
||||
}
|
||||
|
||||
.applying-changes-overlay {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translateY(-50%) translateX(-50%);
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(10px);
|
||||
.ignored-files-container {
|
||||
margin-top: var(--size-4-3);
|
||||
padding: var(--size-4-3);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-s);
|
||||
background-color: var(--background-secondary);
|
||||
|
||||
.spinner-container {
|
||||
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;
|
||||
}
|
||||
h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--size-4-2);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid var(--background-modifier-border);
|
||||
border-top-color: var(--interactive-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
.ignored-files-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
padding-left: var(--size-4-4);
|
||||
|
||||
.spinner-text {
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-ui-medium);
|
||||
font-weight: 500;
|
||||
}
|
||||
li {
|
||||
font-family: var(--font-monospace);
|
||||
font-size: var(--font-ui-small);
|
||||
color: var(--text-muted);
|
||||
padding: var(--size-2-1) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.spinner-warning {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-small);
|
||||
text-align: center;
|
||||
margin-top: var(--size-2-2);
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
.applying-changes-overlay {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translateY(-50%) translateX(-50%);
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.spinner-container {
|
||||
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 {
|
||||
.setting-item-control {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
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) {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.spinner-text {
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-ui-medium);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
.spinner-warning {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-small);
|
||||
text-align: center;
|
||||
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 {
|
||||
display: flex;
|
||||
gap: var(--size-4-2);
|
||||
display: flex;
|
||||
gap: var(--size-4-2);
|
||||
|
||||
* {
|
||||
display: block;
|
||||
}
|
||||
* {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.initialize-button {
|
||||
padding: 0 var(--size-4-2);
|
||||
background: rgba(var(--color-red-rgb), 0.4);
|
||||
cursor: pointer;
|
||||
}
|
||||
.initialize-button {
|
||||
padding: 0 var(--size-4-2);
|
||||
background: rgba(var(--color-red-rgb), 0.4);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,72 +4,72 @@ import type { HistoryStats, SyncClient } from "sync-client";
|
|||
import type VaultLinkPlugin from "../../vault-link-plugin";
|
||||
|
||||
export class StatusBar {
|
||||
private readonly statusBarItem: HTMLElement;
|
||||
private readonly statusBarItem: HTMLElement;
|
||||
|
||||
private lastHistoryStats: HistoryStats | undefined;
|
||||
private lastRemaining: number | undefined;
|
||||
private lastHistoryStats: HistoryStats | undefined;
|
||||
private lastRemaining: number | undefined;
|
||||
|
||||
public constructor(
|
||||
private readonly plugin: VaultLinkPlugin,
|
||||
private readonly syncClient: SyncClient
|
||||
) {
|
||||
this.statusBarItem = plugin.addStatusBarItem();
|
||||
this.syncClient.addSyncHistoryUpdateListener((status) => {
|
||||
this.lastHistoryStats = status;
|
||||
this.updateStatus();
|
||||
});
|
||||
public constructor(
|
||||
private readonly plugin: VaultLinkPlugin,
|
||||
private readonly syncClient: SyncClient
|
||||
) {
|
||||
this.statusBarItem = plugin.addStatusBarItem();
|
||||
this.syncClient.onSyncHistoryUpdated.add((status) => {
|
||||
this.lastHistoryStats = status;
|
||||
this.updateStatus();
|
||||
});
|
||||
|
||||
this.syncClient.addRemainingSyncOperationsListener(
|
||||
(remainingOperations) => {
|
||||
this.lastRemaining = remainingOperations;
|
||||
this.updateStatus();
|
||||
}
|
||||
);
|
||||
this.syncClient.onRemainingOperationsCountChanged.add(
|
||||
(remainingOperations) => {
|
||||
this.lastRemaining = remainingOperations;
|
||||
this.updateStatus();
|
||||
}
|
||||
);
|
||||
|
||||
this.syncClient.addOnSettingsChangeListener(() => {
|
||||
this.updateStatus();
|
||||
});
|
||||
}
|
||||
this.syncClient.onSettingsChanged.add(() => {
|
||||
this.updateStatus();
|
||||
});
|
||||
}
|
||||
|
||||
private updateStatus(): void {
|
||||
this.statusBarItem.empty();
|
||||
const container = this.statusBarItem.createDiv({
|
||||
cls: ["sync-status"]
|
||||
});
|
||||
private updateStatus(): void {
|
||||
this.statusBarItem.empty();
|
||||
const container = this.statusBarItem.createDiv({
|
||||
cls: ["sync-status"]
|
||||
});
|
||||
|
||||
if (!this.syncClient.getSettings().isSyncEnabled) {
|
||||
const button = container.createEl("button", {
|
||||
text: "VaultLink is disabled, click to configure",
|
||||
cls: "initialize-button"
|
||||
});
|
||||
button.onclick = this.plugin.openSettings.bind(this.plugin);
|
||||
if (!this.syncClient.getSettings().isSyncEnabled) {
|
||||
const button = container.createEl("button", {
|
||||
text: "VaultLink is disabled, click to configure",
|
||||
cls: "initialize-button"
|
||||
});
|
||||
button.onclick = this.plugin.openSettings.bind(this.plugin);
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let hasShownMessage = false;
|
||||
let hasShownMessage = false;
|
||||
|
||||
if ((this.lastRemaining ?? 0) > 0) {
|
||||
hasShownMessage = true;
|
||||
container.createSpan({ text: `${this.lastRemaining} ⏳` });
|
||||
}
|
||||
if ((this.lastRemaining ?? 0) > 0) {
|
||||
hasShownMessage = true;
|
||||
container.createSpan({ text: `${this.lastRemaining} ⏳` });
|
||||
}
|
||||
|
||||
if ((this.lastHistoryStats?.success ?? 0) > 0) {
|
||||
hasShownMessage = true;
|
||||
container.createSpan({
|
||||
text: `${this.lastHistoryStats?.success ?? 0} ✅`
|
||||
});
|
||||
}
|
||||
if ((this.lastHistoryStats?.success ?? 0) > 0) {
|
||||
hasShownMessage = true;
|
||||
container.createSpan({
|
||||
text: `${this.lastHistoryStats?.success ?? 0} ✅`
|
||||
});
|
||||
}
|
||||
|
||||
if ((this.lastHistoryStats?.error ?? 0) > 0) {
|
||||
hasShownMessage = true;
|
||||
container.createSpan({
|
||||
text: `${this.lastHistoryStats?.error ?? 0} ❌`
|
||||
});
|
||||
}
|
||||
if ((this.lastHistoryStats?.error ?? 0) > 0) {
|
||||
hasShownMessage = true;
|
||||
container.createSpan({
|
||||
text: `${this.lastHistoryStats?.error ?? 0} ❌`
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasShownMessage) {
|
||||
container.createSpan({ text: "VaultLink is idle" });
|
||||
}
|
||||
}
|
||||
if (!hasShownMessage) {
|
||||
container.createSpan({ text: "VaultLink is idle" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,32 @@
|
|||
@mixin number-card {
|
||||
padding: var(--size-2-1) var(--size-4-1);
|
||||
border-radius: var(--radius-s);
|
||||
background-color: var(--color-base-30);
|
||||
font-size: var(--font-ui-small);
|
||||
padding: var(--size-2-1) var(--size-4-1);
|
||||
border-radius: var(--radius-s);
|
||||
background-color: var(--color-base-30);
|
||||
font-size: var(--font-ui-small);
|
||||
|
||||
&.good {
|
||||
background-color: rgba(var(--color-green-rgb), 0.35);
|
||||
}
|
||||
&.good {
|
||||
background-color: rgba(var(--color-green-rgb), 0.35);
|
||||
}
|
||||
|
||||
&.bad {
|
||||
background-color: rgba(var(--color-red-rgb), 0.35);
|
||||
}
|
||||
&.bad {
|
||||
background-color: rgba(var(--color-red-rgb), 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.status-description {
|
||||
margin: var(--p-spacing) 0;
|
||||
margin: var(--p-spacing) 0;
|
||||
|
||||
.number {
|
||||
@include number-card;
|
||||
font-family: var(--font-monospace);
|
||||
font-weight: var(--bold-weight);
|
||||
}
|
||||
.number {
|
||||
@include number-card;
|
||||
font-family: var(--font-monospace);
|
||||
font-weight: var(--bold-weight);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: rgb(var(--color-red-rgb));
|
||||
}
|
||||
.error {
|
||||
color: rgb(var(--color-red-rgb));
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: rgb(var(--color-yellow-rgb));
|
||||
}
|
||||
.warning {
|
||||
color: rgb(var(--color-yellow-rgb));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,148 +1,147 @@
|
|||
import "./status-description.scss";
|
||||
|
||||
import type {
|
||||
HistoryStats,
|
||||
NetworkConnectionStatus,
|
||||
SyncClient
|
||||
HistoryStats,
|
||||
NetworkConnectionStatus,
|
||||
SyncClient
|
||||
} from "sync-client";
|
||||
import { utils } from "sync-client";
|
||||
|
||||
export class StatusDescription {
|
||||
private lastHistoryStats: HistoryStats | undefined;
|
||||
private lastRemaining: number | undefined;
|
||||
private lastConnectionState: NetworkConnectionStatus | undefined;
|
||||
private lastHistoryStats: HistoryStats | undefined;
|
||||
private lastRemaining: number | undefined;
|
||||
private lastConnectionState: NetworkConnectionStatus | undefined;
|
||||
|
||||
private statusChangeListeners: (() => unknown)[] = [];
|
||||
private readonly statusChangeListeners: (() => unknown)[] = [];
|
||||
|
||||
public constructor(private readonly syncClient: SyncClient) {
|
||||
void this.updateConnectionState();
|
||||
public constructor(private readonly syncClient: SyncClient) {
|
||||
void this.updateConnectionState();
|
||||
|
||||
syncClient.addSyncHistoryUpdateListener((status) => {
|
||||
this.lastHistoryStats = status;
|
||||
this.updateDescription();
|
||||
});
|
||||
syncClient.onSyncHistoryUpdated.add((status) => {
|
||||
this.lastHistoryStats = status;
|
||||
this.updateDescription();
|
||||
});
|
||||
|
||||
this.syncClient.addRemainingSyncOperationsListener(
|
||||
(remainingOperations) => {
|
||||
this.lastRemaining = remainingOperations;
|
||||
this.updateDescription();
|
||||
}
|
||||
);
|
||||
this.syncClient.onRemainingOperationsCountChanged.add(
|
||||
(remainingOperations) => {
|
||||
this.lastRemaining = remainingOperations;
|
||||
this.updateDescription();
|
||||
}
|
||||
);
|
||||
|
||||
this.syncClient.addWebSocketStatusChangeListener(async () =>
|
||||
this.updateConnectionState()
|
||||
);
|
||||
this.syncClient.onWebSocketStatusChanged.add(async () =>
|
||||
this.updateConnectionState()
|
||||
);
|
||||
|
||||
this.syncClient.addOnSettingsChangeListener(async () =>
|
||||
this.updateConnectionState()
|
||||
);
|
||||
}
|
||||
this.syncClient.onSettingsChanged.add(async () =>
|
||||
this.updateConnectionState()
|
||||
);
|
||||
}
|
||||
|
||||
public async updateConnectionState(): Promise<void> {
|
||||
this.lastConnectionState = await this.syncClient.checkConnection();
|
||||
this.updateDescription();
|
||||
}
|
||||
public async updateConnectionState(): Promise<void> {
|
||||
this.lastConnectionState = await this.syncClient.checkConnection();
|
||||
this.updateDescription();
|
||||
}
|
||||
|
||||
public addStatusChangeListener(listener: () => unknown): void {
|
||||
this.statusChangeListeners.push(listener);
|
||||
}
|
||||
public removeStatusChangeListener(listener: () => unknown): void {
|
||||
this.statusChangeListeners = this.statusChangeListeners.filter(
|
||||
(l) => l !== listener
|
||||
);
|
||||
}
|
||||
public addStatusChangeListener(listener: () => unknown): void {
|
||||
this.statusChangeListeners.push(listener);
|
||||
}
|
||||
public removeStatusChangeListener(listener: () => unknown): void {
|
||||
utils.removeFromArray(this.statusChangeListeners, listener);
|
||||
}
|
||||
|
||||
public renderStatusDescription(container: HTMLElement): void {
|
||||
container.empty();
|
||||
container.addClass("status-description");
|
||||
public renderStatusDescription(container: HTMLElement): void {
|
||||
container.empty();
|
||||
container.addClass("status-description");
|
||||
|
||||
if (this.lastConnectionState == undefined) {
|
||||
container.createSpan({
|
||||
text: "VaultLink is starting up…",
|
||||
cls: "warning"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.lastConnectionState == undefined) {
|
||||
container.createSpan({
|
||||
text: "VaultLink is starting up…",
|
||||
cls: "warning"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.lastConnectionState.isSuccessful) {
|
||||
container.createSpan({
|
||||
text: `VaultLink failed to connect to the remote server with error '${this.lastConnectionState.serverMessage}'`,
|
||||
cls: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!this.lastConnectionState.isSuccessful) {
|
||||
container.createSpan({
|
||||
text: `VaultLink failed to connect to the remote server with error '${this.lastConnectionState.serverMessage}'`,
|
||||
cls: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.lastConnectionState.isWebSocketConnected) {
|
||||
container.createSpan({
|
||||
text: `${this.lastConnectionState.serverMessage} but the WebSocket connection could not be established.`,
|
||||
cls: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!this.lastConnectionState.isWebSocketConnected) {
|
||||
container.createSpan({
|
||||
text: `${this.lastConnectionState.serverMessage} but the WebSocket connection could not be established.`,
|
||||
cls: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
container.createSpan({ text: "VaultLink is connected to the server " });
|
||||
container.createEl("a", {
|
||||
text: this.syncClient.getSettings().remoteUri,
|
||||
href: this.syncClient.getSettings().remoteUri
|
||||
});
|
||||
container.createSpan({ text: "VaultLink is connected to the server " });
|
||||
container.createEl("a", {
|
||||
text: this.syncClient.getSettings().remoteUri,
|
||||
href: this.syncClient.getSettings().remoteUri
|
||||
});
|
||||
|
||||
container.createSpan({
|
||||
text: ` and has indexed approximately `
|
||||
});
|
||||
container.createSpan({
|
||||
text: `${this.syncClient.documentCount}`,
|
||||
cls: "number"
|
||||
});
|
||||
container.createSpan({
|
||||
text: ` documents. `
|
||||
});
|
||||
container.createSpan({
|
||||
text: ` and has indexed approximately `
|
||||
});
|
||||
container.createSpan({
|
||||
text: `${this.syncClient.documentCount}`,
|
||||
cls: "number"
|
||||
});
|
||||
container.createSpan({
|
||||
text: ` documents. `
|
||||
});
|
||||
|
||||
if (
|
||||
(this.lastRemaining ?? 0) === 0 &&
|
||||
(this.lastHistoryStats?.success ?? 0) === 0 &&
|
||||
(this.lastHistoryStats?.error ?? 0) === 0
|
||||
) {
|
||||
if (this.syncClient.getSettings().isSyncEnabled) {
|
||||
container.createSpan({
|
||||
text: "Syncing is enabled but VaultLink hasn't found anything to sync yet."
|
||||
});
|
||||
} else {
|
||||
container.createSpan({
|
||||
text: "However, syncing is disabled right now.",
|
||||
cls: "warning"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(this.lastRemaining ?? 0) === 0 &&
|
||||
(this.lastHistoryStats?.success ?? 0) === 0 &&
|
||||
(this.lastHistoryStats?.error ?? 0) === 0
|
||||
) {
|
||||
if (this.syncClient.getSettings().isSyncEnabled) {
|
||||
container.createSpan({
|
||||
text: "Syncing is enabled but VaultLink hasn't found anything to sync yet."
|
||||
});
|
||||
} else {
|
||||
container.createSpan({
|
||||
text: "However, syncing is disabled right now.",
|
||||
cls: "warning"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
container.createSpan({
|
||||
text: "The plugin has "
|
||||
});
|
||||
container.createSpan({
|
||||
text: `${this.lastRemaining ?? 0}`,
|
||||
cls: "number"
|
||||
});
|
||||
container.createSpan({
|
||||
text: " outstanding operations while having succeeded "
|
||||
});
|
||||
container.createSpan({
|
||||
text: `${this.lastHistoryStats?.success ?? 0}`,
|
||||
cls: ["number", "good"]
|
||||
});
|
||||
container.createSpan({
|
||||
text: " times and failed "
|
||||
});
|
||||
container.createSpan({
|
||||
text: `${this.lastHistoryStats?.error ?? 0}`,
|
||||
cls: ["number", "bad"]
|
||||
});
|
||||
container.createSpan({
|
||||
text: " times."
|
||||
});
|
||||
}
|
||||
container.createSpan({
|
||||
text: "The plugin has "
|
||||
});
|
||||
container.createSpan({
|
||||
text: `${this.lastRemaining ?? 0}`,
|
||||
cls: "number"
|
||||
});
|
||||
container.createSpan({
|
||||
text: " outstanding operations while having succeeded "
|
||||
});
|
||||
container.createSpan({
|
||||
text: `${this.lastHistoryStats?.success ?? 0}`,
|
||||
cls: ["number", "good"]
|
||||
});
|
||||
container.createSpan({
|
||||
text: " times and failed "
|
||||
});
|
||||
container.createSpan({
|
||||
text: `${this.lastHistoryStats?.error ?? 0}`,
|
||||
cls: ["number", "bad"]
|
||||
});
|
||||
container.createSpan({
|
||||
text: " times."
|
||||
});
|
||||
}
|
||||
|
||||
private updateDescription(): void {
|
||||
this.statusChangeListeners.forEach((listener) => {
|
||||
listener();
|
||||
});
|
||||
}
|
||||
private updateDescription(): void {
|
||||
this.statusChangeListeners.forEach((listener) => {
|
||||
listener();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"module": "ESNext",
|
||||
"target": "ES2023",
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES2024"
|
||||
]
|
||||
},
|
||||
"exclude": [
|
||||
"./dist"
|
||||
]
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"module": "ESNext",
|
||||
"target": "ES2023",
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES2024"
|
||||
]
|
||||
},
|
||||
"exclude": [
|
||||
"./dist"
|
||||
]
|
||||
}
|
||||
|
|
@ -4,114 +4,114 @@ const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
|||
const fs = require("fs-extra");
|
||||
|
||||
module.exports = (env, argv) => ({
|
||||
devtool: argv.mode === "development" ? "inline-source-map" : false,
|
||||
entry: {
|
||||
index: "./src/vault-link-plugin.ts"
|
||||
},
|
||||
watchOptions: {
|
||||
ignored: "**/node_modules"
|
||||
},
|
||||
externals: {
|
||||
obsidian: "commonjs obsidian",
|
||||
electron: "commonjs electron",
|
||||
"@codemirror/autocomplete": "commonjs @codemirror/autocomplete",
|
||||
"@codemirror/collab": "commonjs @codemirror/collab",
|
||||
"@codemirror/commands": "commonjs @codemirror/commands",
|
||||
"@codemirror/language": "commonjs @codemirror/language",
|
||||
"@codemirror/lint": "commonjs @codemirror/lint",
|
||||
"@codemirror/search": "commonjs @codemirror/search",
|
||||
"@codemirror/state": "commonjs @codemirror/state",
|
||||
"@codemirror/view": "commonjs @codemirror/view"
|
||||
},
|
||||
optimization: {
|
||||
minimizer: [
|
||||
new TerserPlugin({
|
||||
terserOptions: {
|
||||
module: true
|
||||
}
|
||||
})
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
new MiniCssExtractPlugin({
|
||||
filename: "styles.css"
|
||||
}),
|
||||
{
|
||||
apply: (compiler) => {
|
||||
if (argv.mode !== "development") {
|
||||
return;
|
||||
}
|
||||
devtool: argv.mode === "development" ? "inline-source-map" : false,
|
||||
entry: {
|
||||
index: "./src/vault-link-plugin.ts"
|
||||
},
|
||||
watchOptions: {
|
||||
ignored: "**/node_modules"
|
||||
},
|
||||
externals: {
|
||||
obsidian: "commonjs obsidian",
|
||||
electron: "commonjs electron",
|
||||
"@codemirror/autocomplete": "commonjs @codemirror/autocomplete",
|
||||
"@codemirror/collab": "commonjs @codemirror/collab",
|
||||
"@codemirror/commands": "commonjs @codemirror/commands",
|
||||
"@codemirror/language": "commonjs @codemirror/language",
|
||||
"@codemirror/lint": "commonjs @codemirror/lint",
|
||||
"@codemirror/search": "commonjs @codemirror/search",
|
||||
"@codemirror/state": "commonjs @codemirror/state",
|
||||
"@codemirror/view": "commonjs @codemirror/view"
|
||||
},
|
||||
optimization: {
|
||||
minimizer: [
|
||||
new TerserPlugin({
|
||||
terserOptions: {
|
||||
module: true
|
||||
}
|
||||
})
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
new MiniCssExtractPlugin({
|
||||
filename: "styles.css"
|
||||
}),
|
||||
{
|
||||
apply: (compiler) => {
|
||||
if (argv.mode !== "development") {
|
||||
return;
|
||||
}
|
||||
|
||||
compiler.hooks.done.tap("Copy Files Plugin", (stats) => {
|
||||
const source = path.resolve(__dirname, "dist");
|
||||
const destinations = [
|
||||
"/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link",
|
||||
"/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link",
|
||||
// "/home/andras/obsidian-test/.obsidian/plugins/vault-link"
|
||||
];
|
||||
destinations.forEach((destination) => {
|
||||
fs.copy(source, destination)
|
||||
.then(() =>
|
||||
console.log(
|
||||
"Files copied successfully after build!"
|
||||
)
|
||||
)
|
||||
.catch((err) =>
|
||||
console.error("Error copying files:", err)
|
||||
);
|
||||
compiler.hooks.done.tap("Copy Files Plugin", (stats) => {
|
||||
const source = path.resolve(__dirname, "dist");
|
||||
const destinations = [
|
||||
"/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link",
|
||||
"/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link",
|
||||
// "/home/andras/obsidian-test/.obsidian/plugins/vault-link"
|
||||
];
|
||||
destinations.forEach((destination) => {
|
||||
fs.copy(source, destination)
|
||||
.then(() =>
|
||||
console.log(
|
||||
"Files copied successfully after build!"
|
||||
)
|
||||
)
|
||||
.catch((err) =>
|
||||
console.error("Error copying files:", err)
|
||||
);
|
||||
|
||||
fs.createFile(path.join(destination, ".hotreload"));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
],
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.json$/i,
|
||||
type: "asset/resource",
|
||||
generator: {
|
||||
filename: "[name][ext]"
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.scss$/i,
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
"css-loader",
|
||||
"resolve-url-loader",
|
||||
{
|
||||
loader: "sass-loader",
|
||||
options: {
|
||||
sourceMap: true // required by resolve-url-loader
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
test: /\.ts$/,
|
||||
use: ["ts-loader"]
|
||||
}
|
||||
]
|
||||
},
|
||||
resolve: {
|
||||
extensions: [
|
||||
".ts",
|
||||
".js" // required for development
|
||||
],
|
||||
alias: {
|
||||
root: __dirname,
|
||||
src: path.resolve(__dirname, "src")
|
||||
}
|
||||
},
|
||||
output: {
|
||||
clean: true,
|
||||
filename: "main.js",
|
||||
library: {
|
||||
type: "commonjs" // required for Obsidian
|
||||
},
|
||||
path: path.resolve(__dirname, "dist"),
|
||||
publicPath: ""
|
||||
}
|
||||
fs.createFile(path.join(destination, ".hotreload"));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
],
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.json$/i,
|
||||
type: "asset/resource",
|
||||
generator: {
|
||||
filename: "[name][ext]"
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.scss$/i,
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
"css-loader",
|
||||
"resolve-url-loader",
|
||||
{
|
||||
loader: "sass-loader",
|
||||
options: {
|
||||
sourceMap: true // required by resolve-url-loader
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
test: /\.ts$/,
|
||||
use: ["ts-loader"]
|
||||
}
|
||||
]
|
||||
},
|
||||
resolve: {
|
||||
extensions: [
|
||||
".ts",
|
||||
".js" // required for development
|
||||
],
|
||||
alias: {
|
||||
root: __dirname,
|
||||
src: path.resolve(__dirname, "src")
|
||||
}
|
||||
},
|
||||
output: {
|
||||
clean: true,
|
||||
filename: "main.js",
|
||||
library: {
|
||||
type: "commonjs" // required for Obsidian
|
||||
},
|
||||
path: path.resolve(__dirname, "dist"),
|
||||
publicPath: ""
|
||||
}
|
||||
});
|
||||
|
|
|
|||
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",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"sync-client",
|
||||
"obsidian-plugin",
|
||||
"test-client",
|
||||
"local-client-cli"
|
||||
],
|
||||
"prettier": {
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 4,
|
||||
"useTabs": true,
|
||||
"endOfLine": "lf"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run build --workspaces",
|
||||
"dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"",
|
||||
"test": "npm run test --workspaces",
|
||||
"lint": "eslint --fix sync-client obsidian-plugin test-client local-client-cli && prettier --write \"**/*.ts\"",
|
||||
"update": "ncu -u -ws"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "9.38.0",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"npm-check-updates": "^19.1.1",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript-eslint": "8.41.0"
|
||||
}
|
||||
"name": "my-workspace",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"sync-client",
|
||||
"obsidian-plugin",
|
||||
"test-client",
|
||||
"local-client-cli"
|
||||
],
|
||||
"prettier": {
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"endOfLine": "lf"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run build --workspaces",
|
||||
"dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"",
|
||||
"test": "npm run test --workspaces",
|
||||
"lint": "eslint --fix sync-client obsidian-plugin test-client local-client-cli && prettier --write \"**/*.ts\"",
|
||||
"update": "ncu -u -ws"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1",
|
||||
"eclint": "^2.8.1",
|
||||
"eslint": "9.38.0",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"npm-check-updates": "^19.1.1",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript-eslint": "8.41.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
"scripts": {
|
||||
"dev": "webpack watch --mode development",
|
||||
"build": "webpack --mode production",
|
||||
"test": "tsx --test src/**/*.test.ts"
|
||||
"test": "tsx --test 'src/**/*.test.ts'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"byte-base64": "^1.1.0",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
export class FileNotFoundError extends Error {
|
||||
public constructor(
|
||||
message: string,
|
||||
public readonly filePath: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "FileNotFoundError";
|
||||
}
|
||||
public constructor(
|
||||
message: string,
|
||||
public readonly filePath: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "FileNotFoundError";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { describe, it } from "node:test";
|
||||
import type {
|
||||
Database,
|
||||
DocumentRecord,
|
||||
RelativePath
|
||||
Database,
|
||||
DocumentRecord,
|
||||
RelativePath
|
||||
} from "../persistence/database";
|
||||
import { FileOperations } from "./file-operations";
|
||||
import { Logger } from "../tracing/logger";
|
||||
|
|
@ -12,224 +12,224 @@ import type { TextWithCursors } from "reconcile-text";
|
|||
import type { ServerConfig, ServerConfigData } from "../services/server-config";
|
||||
|
||||
class MockServerConfig implements Pick<ServerConfig, "getConfig"> {
|
||||
public getConfig(): ServerConfigData {
|
||||
return {
|
||||
mergeableFileExtensions: ["md", "txt"],
|
||||
supportedApiVersion: 1,
|
||||
isAuthenticated: true
|
||||
};
|
||||
}
|
||||
public getConfig(): ServerConfigData {
|
||||
return {
|
||||
mergeableFileExtensions: ["md", "txt"],
|
||||
supportedApiVersion: 1,
|
||||
isAuthenticated: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class MockDatabase implements Partial<Database> {
|
||||
public getLatestDocumentByRelativePath(
|
||||
_find: RelativePath
|
||||
): DocumentRecord | undefined {
|
||||
// no-op
|
||||
return undefined;
|
||||
}
|
||||
public getLatestDocumentByRelativePath(
|
||||
_find: RelativePath
|
||||
): DocumentRecord | undefined {
|
||||
// no-op
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public move(
|
||||
_oldRelativePath: RelativePath,
|
||||
_newRelativePath: RelativePath
|
||||
): void {
|
||||
// no-op
|
||||
}
|
||||
public move(
|
||||
_oldRelativePath: RelativePath,
|
||||
_newRelativePath: RelativePath
|
||||
): void {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
class FakeFileSystemOperations implements FileSystemOperations {
|
||||
public readonly names = new Set<string>();
|
||||
public readonly names = new Set<string>();
|
||||
|
||||
public async listFilesRecursively(
|
||||
_root: RelativePath | undefined
|
||||
): Promise<RelativePath[]> {
|
||||
return ["file.md"];
|
||||
}
|
||||
public async read(_path: RelativePath): Promise<Uint8Array> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
public async write(
|
||||
path: RelativePath,
|
||||
_content: Uint8Array
|
||||
): Promise<void> {
|
||||
this.names.add(path);
|
||||
}
|
||||
public async atomicUpdateText(
|
||||
_path: RelativePath,
|
||||
_updater: (current: TextWithCursors) => TextWithCursors
|
||||
): Promise<string> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
public async getFileSize(_path: RelativePath): Promise<number> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
public async getModificationTime(_path: RelativePath): Promise<Date> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
public async exists(path: RelativePath): Promise<boolean> {
|
||||
return this.names.has(path);
|
||||
}
|
||||
public async createDirectory(_path: RelativePath): Promise<void> {
|
||||
// this is called but irrelevant for this mock
|
||||
}
|
||||
public async delete(_path: RelativePath): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
public async rename(
|
||||
oldPath: RelativePath,
|
||||
newPath: RelativePath
|
||||
): Promise<void> {
|
||||
this.names.delete(oldPath);
|
||||
this.names.add(newPath);
|
||||
}
|
||||
public async listFilesRecursively(
|
||||
_root: RelativePath | undefined
|
||||
): Promise<RelativePath[]> {
|
||||
return ["file.md"];
|
||||
}
|
||||
public async read(_path: RelativePath): Promise<Uint8Array> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
public async write(
|
||||
path: RelativePath,
|
||||
_content: Uint8Array
|
||||
): Promise<void> {
|
||||
this.names.add(path);
|
||||
}
|
||||
public async atomicUpdateText(
|
||||
_path: RelativePath,
|
||||
_updater: (current: TextWithCursors) => TextWithCursors
|
||||
): Promise<string> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
public async getFileSize(_path: RelativePath): Promise<number> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
public async getModificationTime(_path: RelativePath): Promise<Date> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
public async exists(path: RelativePath): Promise<boolean> {
|
||||
return this.names.has(path);
|
||||
}
|
||||
public async createDirectory(_path: RelativePath): Promise<void> {
|
||||
// this is called but irrelevant for this mock
|
||||
}
|
||||
public async delete(_path: RelativePath): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
public async rename(
|
||||
oldPath: RelativePath,
|
||||
newPath: RelativePath
|
||||
): Promise<void> {
|
||||
this.names.delete(oldPath);
|
||||
this.names.add(newPath);
|
||||
}
|
||||
}
|
||||
|
||||
describe("File operations", () => {
|
||||
it("should deconflict renames", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
);
|
||||
it("should deconflict renames", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
);
|
||||
|
||||
await fileOperations.create("a", new Uint8Array());
|
||||
assertSetContainsExactly(fileSystemOperations.names, "a");
|
||||
await fileOperations.move("a", "b");
|
||||
assertSetContainsExactly(fileSystemOperations.names, "b");
|
||||
await fileOperations.create("a", new Uint8Array());
|
||||
assertSetContainsExactly(fileSystemOperations.names, "a");
|
||||
await fileOperations.move("a", "b");
|
||||
assertSetContainsExactly(fileSystemOperations.names, "b");
|
||||
|
||||
await fileOperations.create("c", new Uint8Array());
|
||||
assertSetContainsExactly(fileSystemOperations.names, "b", "c");
|
||||
await fileOperations.create("c", new Uint8Array());
|
||||
assertSetContainsExactly(fileSystemOperations.names, "b", "c");
|
||||
|
||||
await fileOperations.move("c", "b");
|
||||
assertSetContainsExactly(fileSystemOperations.names, "b", "b (1)");
|
||||
await fileOperations.move("c", "b");
|
||||
assertSetContainsExactly(fileSystemOperations.names, "b", "b (1)");
|
||||
|
||||
await fileOperations.create("c", new Uint8Array());
|
||||
await fileOperations.move("c", "b");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"b",
|
||||
"b (1)",
|
||||
"b (2)"
|
||||
);
|
||||
});
|
||||
await fileOperations.create("c", new Uint8Array());
|
||||
await fileOperations.move("c", "b");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"b",
|
||||
"b (1)",
|
||||
"b (2)"
|
||||
);
|
||||
});
|
||||
|
||||
it("should deconflict renames with file extension", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
);
|
||||
it("should deconflict renames with file extension", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
);
|
||||
|
||||
await fileOperations.create("b.md", new Uint8Array());
|
||||
await fileOperations.create("c.md", new Uint8Array());
|
||||
await fileOperations.move("c.md", "b.md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"b.md",
|
||||
"b (1).md"
|
||||
);
|
||||
await fileOperations.create("b.md", new Uint8Array());
|
||||
await fileOperations.create("c.md", new Uint8Array());
|
||||
await fileOperations.move("c.md", "b.md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"b.md",
|
||||
"b (1).md"
|
||||
);
|
||||
|
||||
await fileOperations.create("d.md", new Uint8Array());
|
||||
await fileOperations.move("d.md", "b.md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"b.md",
|
||||
"b (1).md",
|
||||
"b (2).md"
|
||||
);
|
||||
await fileOperations.create("d.md", new Uint8Array());
|
||||
await fileOperations.move("d.md", "b.md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"b.md",
|
||||
"b (1).md",
|
||||
"b (2).md"
|
||||
);
|
||||
|
||||
await fileOperations.create("file-23.md", new Uint8Array());
|
||||
await fileOperations.create("file-23 (1).md", new Uint8Array());
|
||||
await fileOperations.move("file-23.md", "file-23 (1).md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"b.md",
|
||||
"b (1).md",
|
||||
"b (2).md",
|
||||
"file-23 (1).md",
|
||||
"file-23 (2).md"
|
||||
);
|
||||
});
|
||||
await fileOperations.create("file-23.md", new Uint8Array());
|
||||
await fileOperations.create("file-23 (1).md", new Uint8Array());
|
||||
await fileOperations.move("file-23.md", "file-23 (1).md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"b.md",
|
||||
"b (1).md",
|
||||
"b (2).md",
|
||||
"file-23 (1).md",
|
||||
"file-23 (2).md"
|
||||
);
|
||||
});
|
||||
|
||||
it("should deconflict renames with paths", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
);
|
||||
it("should deconflict renames with paths", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
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/e", new Uint8Array());
|
||||
await fileOperations.move("a/b.c/d", "a/b.c/e");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"a/b.c/e",
|
||||
"a/b.c/e (1)"
|
||||
);
|
||||
});
|
||||
await fileOperations.create("a/b.c/d", new Uint8Array());
|
||||
await fileOperations.create("a/b.c/e", new Uint8Array());
|
||||
await fileOperations.move("a/b.c/d", "a/b.c/e");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"a/b.c/e",
|
||||
"a/b.c/e (1)"
|
||||
);
|
||||
});
|
||||
|
||||
it("should continue deconfliction from existing number in filename", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
);
|
||||
it("should continue deconfliction from existing number in filename", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
);
|
||||
|
||||
await fileOperations.create("document (5).md", new Uint8Array());
|
||||
await fileOperations.create("other.md", new Uint8Array());
|
||||
await fileOperations.create("document (5).md", new Uint8Array());
|
||||
await fileOperations.create("other.md", new Uint8Array());
|
||||
|
||||
await fileOperations.move("other.md", "document (5).md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"document (5).md",
|
||||
"document (6).md"
|
||||
);
|
||||
await fileOperations.move("other.md", "document (5).md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"document (5).md",
|
||||
"document (6).md"
|
||||
);
|
||||
|
||||
await fileOperations.create("another.md", new Uint8Array());
|
||||
await fileOperations.move("another.md", "document (5).md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"document (5).md",
|
||||
"document (6).md",
|
||||
"document (7).md"
|
||||
);
|
||||
});
|
||||
await fileOperations.create("another.md", new Uint8Array());
|
||||
await fileOperations.move("another.md", "document (5).md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"document (5).md",
|
||||
"document (6).md",
|
||||
"document (7).md"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle dotfiles correctly", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
);
|
||||
it("should handle dotfiles correctly", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
);
|
||||
|
||||
await fileOperations.create(".gitignore", new Uint8Array());
|
||||
await fileOperations.create("temp", new Uint8Array());
|
||||
await fileOperations.move("temp", ".gitignore");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
".gitignore",
|
||||
".gitignore (1)"
|
||||
);
|
||||
await fileOperations.create(".gitignore", new Uint8Array());
|
||||
await fileOperations.create("temp", new Uint8Array());
|
||||
await fileOperations.move("temp", ".gitignore");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
".gitignore",
|
||||
".gitignore (1)"
|
||||
);
|
||||
|
||||
await fileOperations.create(".config.json", new Uint8Array());
|
||||
await fileOperations.create("temp2", new Uint8Array());
|
||||
await fileOperations.move("temp2", ".config.json");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
".gitignore",
|
||||
".gitignore (1)",
|
||||
".config.json",
|
||||
".config (1).json"
|
||||
);
|
||||
});
|
||||
await fileOperations.create(".config.json", new Uint8Array());
|
||||
await fileOperations.create("temp2", new Uint8Array());
|
||||
await fileOperations.move("temp2", ".config.json");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
".gitignore",
|
||||
".gitignore (1)",
|
||||
".config.json",
|
||||
".config (1).json"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,283 +9,283 @@ import { isBinary } from "../utils/is-binary";
|
|||
import type { ServerConfig } from "../services/server-config";
|
||||
|
||||
export class FileOperations {
|
||||
private static readonly PARENTHESES_REGEX = / \((?<count>\d+)\)$/;
|
||||
private readonly fs: SafeFileSystemOperations;
|
||||
private static readonly PARENTHESES_REGEX = / \((?<count>\d+)\)$/;
|
||||
private readonly fs: SafeFileSystemOperations;
|
||||
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly database: Database,
|
||||
fs: FileSystemOperations,
|
||||
private readonly serverConfig: ServerConfig,
|
||||
private readonly nativeLineEndings = "\n"
|
||||
) {
|
||||
this.fs = new SafeFileSystemOperations(fs, logger);
|
||||
}
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly database: Database,
|
||||
fs: FileSystemOperations,
|
||||
private readonly serverConfig: ServerConfig,
|
||||
private readonly nativeLineEndings = "\n"
|
||||
) {
|
||||
this.fs = new SafeFileSystemOperations(fs, logger);
|
||||
}
|
||||
|
||||
private static getParentDirAndFile(
|
||||
path: RelativePath
|
||||
): [RelativePath, RelativePath] {
|
||||
const pathParts = path.split("/");
|
||||
const fileName = pathParts.pop();
|
||||
if (fileName == null || fileName === "") {
|
||||
throw new Error(`Path '${path}' cannot be empty`);
|
||||
}
|
||||
private static getParentDirAndFile(
|
||||
path: RelativePath
|
||||
): [RelativePath, RelativePath] {
|
||||
const pathParts = path.split("/");
|
||||
const fileName = pathParts.pop();
|
||||
if (fileName == null || fileName === "") {
|
||||
throw new Error(`Path '${path}' cannot be empty`);
|
||||
}
|
||||
|
||||
return [pathParts.join("/"), fileName];
|
||||
}
|
||||
return [pathParts.join("/"), fileName];
|
||||
}
|
||||
|
||||
public async listFilesRecursively(
|
||||
root: RelativePath | undefined = undefined
|
||||
): Promise<RelativePath[]> {
|
||||
return this.fs.listFilesRecursively(root);
|
||||
}
|
||||
public async listFilesRecursively(
|
||||
root: RelativePath | undefined = undefined
|
||||
): Promise<RelativePath[]> {
|
||||
return this.fs.listFilesRecursively(root);
|
||||
}
|
||||
|
||||
public async read(path: RelativePath): Promise<Uint8Array> {
|
||||
return this.fromNativeLineEndings(await this.fs.read(path));
|
||||
}
|
||||
public async read(path: RelativePath): Promise<Uint8Array> {
|
||||
return this.fromNativeLineEndings(await this.fs.read(path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a file at the specified path.
|
||||
*
|
||||
* If a file with the same name already exists, it is moved before creating the new one.
|
||||
* Parent directories are created if necessary.
|
||||
*/
|
||||
public async create(
|
||||
path: RelativePath,
|
||||
newContent: Uint8Array
|
||||
): Promise<void> {
|
||||
await this.ensureClearPath(path);
|
||||
return this.fs.write(path, this.toNativeLineEndings(newContent));
|
||||
}
|
||||
/**
|
||||
* Create a file at the specified path.
|
||||
*
|
||||
* If a file with the same name already exists, it is moved before creating the new one.
|
||||
* Parent directories are created if necessary.
|
||||
*/
|
||||
public async create(
|
||||
path: RelativePath,
|
||||
newContent: Uint8Array
|
||||
): Promise<void> {
|
||||
await this.ensureClearPath(path);
|
||||
return this.fs.write(path, this.toNativeLineEndings(newContent));
|
||||
}
|
||||
|
||||
public async ensureClearPath(path: RelativePath): Promise<void> {
|
||||
if (await this.fs.exists(path)) {
|
||||
const deconflictedPath = await this.deconflictPath(path);
|
||||
try {
|
||||
this.logger.debug(
|
||||
`Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'`
|
||||
);
|
||||
public async ensureClearPath(path: RelativePath): Promise<void> {
|
||||
if (await this.fs.exists(path)) {
|
||||
const deconflictedPath = await this.deconflictPath(path);
|
||||
try {
|
||||
this.logger.debug(
|
||||
`Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'`
|
||||
);
|
||||
|
||||
this.database.move(path, deconflictedPath);
|
||||
await this.fs.rename(path, deconflictedPath, true);
|
||||
} finally {
|
||||
this.fs.unlock(deconflictedPath);
|
||||
}
|
||||
} else {
|
||||
await this.createParentDirectories(path);
|
||||
}
|
||||
}
|
||||
this.database.move(path, deconflictedPath);
|
||||
await this.fs.rename(path, deconflictedPath, true);
|
||||
} finally {
|
||||
this.fs.unlock(deconflictedPath);
|
||||
}
|
||||
} else {
|
||||
await this.createParentDirectories(path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the file at the given path.
|
||||
*
|
||||
* Performs a 3-way merge before writing if the file's content differs from `expectedContent`.
|
||||
* Does not recreate the file if it no longer exists, returning an empty array instead.
|
||||
*/
|
||||
public async write(
|
||||
path: RelativePath,
|
||||
expectedContent: Uint8Array,
|
||||
newContent: Uint8Array
|
||||
): Promise<void> {
|
||||
if (!(await this.fs.exists(path))) {
|
||||
this.logger.debug(
|
||||
`The caller assumed ${path} exists, but it no longer, so we wont recreate it`
|
||||
);
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* Update the file at the given path.
|
||||
*
|
||||
* Performs a 3-way merge before writing if the file's content differs from `expectedContent`.
|
||||
* Does not recreate the file if it no longer exists, returning an empty array instead.
|
||||
*/
|
||||
public async write(
|
||||
path: RelativePath,
|
||||
expectedContent: Uint8Array,
|
||||
newContent: Uint8Array
|
||||
): Promise<void> {
|
||||
if (!(await this.fs.exists(path))) {
|
||||
this.logger.debug(
|
||||
`The caller assumed ${path} exists, but it no longer, so we wont recreate it`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!isFileTypeMergable(
|
||||
path,
|
||||
this.serverConfig.getConfig().mergeableFileExtensions
|
||||
) ||
|
||||
isBinary(expectedContent) ||
|
||||
isBinary(newContent)
|
||||
) {
|
||||
this.logger.debug(
|
||||
`The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it`
|
||||
);
|
||||
await this.fs.write(
|
||||
path,
|
||||
// `newContent` might not be binary so we still have to ensure the line endings are correct
|
||||
this.toNativeLineEndings(newContent)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!isFileTypeMergable(
|
||||
path,
|
||||
this.serverConfig.getConfig().mergeableFileExtensions
|
||||
) ||
|
||||
isBinary(expectedContent) ||
|
||||
isBinary(newContent)
|
||||
) {
|
||||
this.logger.debug(
|
||||
`The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it`
|
||||
);
|
||||
await this.fs.write(
|
||||
path,
|
||||
// `newContent` might not be binary so we still have to ensure the line endings are correct
|
||||
this.toNativeLineEndings(newContent)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedText = new TextDecoder().decode(expectedContent); // this comes from a previous read which must only have \n line endings
|
||||
const newText = new TextDecoder().decode(newContent); // this comes from the server which stores text with \n line endings
|
||||
const expectedText = new TextDecoder().decode(expectedContent); // this comes from a previous read which must only have \n line endings
|
||||
const newText = new TextDecoder().decode(newContent); // this comes from the server which stores text with \n line endings
|
||||
|
||||
await this.fs.atomicUpdateText(
|
||||
path,
|
||||
({ text, cursors }: TextWithCursors): TextWithCursors => {
|
||||
this.logger.debug(
|
||||
`Performing a 3-way merge for ${path} with the expected content`
|
||||
);
|
||||
await this.fs.atomicUpdateText(
|
||||
path,
|
||||
({ text, cursors }: TextWithCursors): TextWithCursors => {
|
||||
this.logger.debug(
|
||||
`Performing a 3-way merge for ${path} with the expected content`
|
||||
);
|
||||
|
||||
text = text.replaceAll(this.nativeLineEndings, "\n");
|
||||
const merged = reconcile(
|
||||
expectedText,
|
||||
{ text, cursors },
|
||||
newText
|
||||
);
|
||||
text = text.replaceAll(this.nativeLineEndings, "\n");
|
||||
const merged = reconcile(
|
||||
expectedText,
|
||||
{ text, cursors },
|
||||
newText
|
||||
);
|
||||
|
||||
const resultText = merged.text.replaceAll(
|
||||
"\n",
|
||||
this.nativeLineEndings
|
||||
);
|
||||
const resultText = merged.text.replaceAll(
|
||||
"\n",
|
||||
this.nativeLineEndings
|
||||
);
|
||||
|
||||
return {
|
||||
text: resultText,
|
||||
cursors: merged.cursors
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
return {
|
||||
text: resultText,
|
||||
cursors: merged.cursors
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async delete(path: RelativePath): Promise<void> {
|
||||
if (await this.exists(path)) {
|
||||
await this.fs.delete(path);
|
||||
await this.deletingEmptyParentDirectoriesOfDeletedFile(path);
|
||||
} else {
|
||||
this.logger.debug(`No need to delete '${path}', it doesn't exist`);
|
||||
}
|
||||
}
|
||||
public async delete(path: RelativePath): Promise<void> {
|
||||
if (await this.exists(path)) {
|
||||
await this.fs.delete(path);
|
||||
await this.deletingEmptyParentDirectoriesOfDeletedFile(path);
|
||||
} else {
|
||||
this.logger.debug(`No need to delete '${path}', it doesn't exist`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getFileSize(path: RelativePath): Promise<number> {
|
||||
return this.fs.getFileSize(path);
|
||||
}
|
||||
public async getFileSize(path: RelativePath): Promise<number> {
|
||||
return this.fs.getFileSize(path);
|
||||
}
|
||||
|
||||
public async exists(path: RelativePath): Promise<boolean> {
|
||||
return this.fs.exists(path);
|
||||
}
|
||||
public async exists(path: RelativePath): Promise<boolean> {
|
||||
return this.fs.exists(path);
|
||||
}
|
||||
|
||||
public async move(
|
||||
oldPath: RelativePath,
|
||||
newPath: RelativePath
|
||||
): Promise<void> {
|
||||
if (oldPath === newPath) {
|
||||
return;
|
||||
}
|
||||
public async move(
|
||||
oldPath: RelativePath,
|
||||
newPath: RelativePath
|
||||
): Promise<void> {
|
||||
if (oldPath === newPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.ensureClearPath(newPath);
|
||||
await this.ensureClearPath(newPath);
|
||||
|
||||
this.database.move(oldPath, newPath);
|
||||
await this.fs.rename(oldPath, newPath);
|
||||
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
|
||||
}
|
||||
this.database.move(oldPath, newPath);
|
||||
await this.fs.rename(oldPath, newPath);
|
||||
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.fs.reset();
|
||||
}
|
||||
public reset(): void {
|
||||
this.fs.reset();
|
||||
}
|
||||
|
||||
private async deletingEmptyParentDirectoriesOfDeletedFile(
|
||||
path: RelativePath
|
||||
): Promise<void> {
|
||||
let directory = path;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
while (true) {
|
||||
[directory] = FileOperations.getParentDirAndFile(directory);
|
||||
if (directory.length === 0) {
|
||||
break;
|
||||
}
|
||||
private async deletingEmptyParentDirectoriesOfDeletedFile(
|
||||
path: RelativePath
|
||||
): Promise<void> {
|
||||
let directory = path;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
while (true) {
|
||||
[directory] = FileOperations.getParentDirAndFile(directory);
|
||||
if (directory.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const remainingContent =
|
||||
await this.fs.listFilesRecursively(directory);
|
||||
if (remainingContent.length === 0) {
|
||||
this.logger.debug(
|
||||
`Folder (${directory}) is now empty, deleting`
|
||||
);
|
||||
await this.fs.delete(directory);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const remainingContent =
|
||||
await this.fs.listFilesRecursively(directory);
|
||||
if (remainingContent.length === 0) {
|
||||
this.logger.debug(
|
||||
`Folder (${directory}) is now empty, deleting`
|
||||
);
|
||||
await this.fs.delete(directory);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fromNativeLineEndings(content: Uint8Array): Uint8Array {
|
||||
if (isBinary(content)) {
|
||||
return content;
|
||||
}
|
||||
private fromNativeLineEndings(content: Uint8Array): Uint8Array {
|
||||
if (isBinary(content)) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let text = decoder.decode(content);
|
||||
text = text.replaceAll(this.nativeLineEndings, "\n");
|
||||
return new TextEncoder().encode(text);
|
||||
}
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let text = decoder.decode(content);
|
||||
text = text.replaceAll(this.nativeLineEndings, "\n");
|
||||
return new TextEncoder().encode(text);
|
||||
}
|
||||
|
||||
private toNativeLineEndings(content: Uint8Array): Uint8Array {
|
||||
if (isBinary(content)) {
|
||||
return content;
|
||||
}
|
||||
private toNativeLineEndings(content: Uint8Array): Uint8Array {
|
||||
if (isBinary(content)) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let text = decoder.decode(content);
|
||||
text = text.replaceAll("\n", this.nativeLineEndings);
|
||||
return new TextEncoder().encode(text);
|
||||
}
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let text = decoder.decode(content);
|
||||
text = text.replaceAll("\n", this.nativeLineEndings);
|
||||
return new TextEncoder().encode(text);
|
||||
}
|
||||
|
||||
private async createParentDirectories(path: string): Promise<void> {
|
||||
const components = path.split("/");
|
||||
if (components.length === 1) {
|
||||
return;
|
||||
}
|
||||
for (let i = 1; i < components.length; i++) {
|
||||
const parentDir = components.slice(0, i).join("/");
|
||||
if (!(await this.fs.exists(parentDir))) {
|
||||
await this.fs.createDirectory(parentDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
private async createParentDirectories(path: string): Promise<void> {
|
||||
const components = path.split("/");
|
||||
if (components.length === 1) {
|
||||
return;
|
||||
}
|
||||
for (let i = 1; i < components.length; i++) {
|
||||
const parentDir = components.slice(0, i).join("/");
|
||||
if (!(await this.fs.exists(parentDir))) {
|
||||
await this.fs.createDirectory(parentDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param path The starting path to deconflict
|
||||
* @returns a non-existent path with a lock acquired on it
|
||||
*/
|
||||
private async deconflictPath(path: RelativePath): Promise<RelativePath> {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let [directory, fileName] = FileOperations.getParentDirAndFile(path);
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param path The starting path to deconflict
|
||||
* @returns a non-existent path with a lock acquired on it
|
||||
*/
|
||||
private async deconflictPath(path: RelativePath): Promise<RelativePath> {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let [directory, fileName] = FileOperations.getParentDirAndFile(path);
|
||||
|
||||
if (directory) {
|
||||
directory += "/";
|
||||
}
|
||||
if (directory) {
|
||||
directory += "/";
|
||||
}
|
||||
|
||||
const nameParts = fileName.split(".");
|
||||
// Handle dotfiles: ".gitignore" should have no extension, ".config.json" should have ".json"
|
||||
const isDotfile = fileName.startsWith(".") && nameParts[0] === "";
|
||||
const extension =
|
||||
nameParts.length > 1 && !(isDotfile && nameParts.length === 2)
|
||||
? "." + nameParts[nameParts.length - 1]
|
||||
: "";
|
||||
let stem = extension ? nameParts.slice(0, -1).join(".") : fileName;
|
||||
let currentCount = Number.parseInt(
|
||||
FileOperations.PARENTHESES_REGEX.exec(stem)?.groups?.count ?? "0"
|
||||
);
|
||||
stem = stem.replace(FileOperations.PARENTHESES_REGEX, "");
|
||||
const nameParts = fileName.split(".");
|
||||
// Handle dotfiles: ".gitignore" should have no extension, ".config.json" should have ".json"
|
||||
const isDotfile = fileName.startsWith(".") && nameParts[0] === "";
|
||||
const extension =
|
||||
nameParts.length > 1 && !(isDotfile && nameParts.length === 2)
|
||||
? "." + nameParts[nameParts.length - 1]
|
||||
: "";
|
||||
let stem = extension ? nameParts.slice(0, -1).join(".") : fileName;
|
||||
let currentCount = Number.parseInt(
|
||||
FileOperations.PARENTHESES_REGEX.exec(stem)?.groups?.count ?? "0"
|
||||
);
|
||||
stem = stem.replace(FileOperations.PARENTHESES_REGEX, "");
|
||||
|
||||
let newName = path;
|
||||
let newName = path;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
while (true) {
|
||||
currentCount++;
|
||||
newName = `${directory}${stem} (${currentCount})${extension}`;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
while (true) {
|
||||
currentCount++;
|
||||
newName = `${directory}${stem} (${currentCount})${extension}`;
|
||||
|
||||
// Avoid multiple deconflictPath calls returning the same path
|
||||
if (this.fs.tryLock(newName)) {
|
||||
const newDocument =
|
||||
this.database.getLatestDocumentByRelativePath(newName);
|
||||
if (
|
||||
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))
|
||||
) {
|
||||
this.fs.unlock(newName);
|
||||
} else {
|
||||
return newName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Avoid multiple deconflictPath calls returning the same path
|
||||
if (this.fs.tryLock(newName)) {
|
||||
const newDocument =
|
||||
this.database.getLatestDocumentByRelativePath(newName);
|
||||
if (
|
||||
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))
|
||||
) {
|
||||
this.fs.unlock(newName);
|
||||
} else {
|
||||
return newName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,35 +3,35 @@ import type { RelativePath } from "../persistence/database";
|
|||
import type { TextWithCursors } from "reconcile-text";
|
||||
|
||||
export interface FileSystemOperations {
|
||||
// List all files under root that should be synced. If root is undefined, return every file.
|
||||
listFilesRecursively: (
|
||||
root: RelativePath | undefined
|
||||
) => Promise<RelativePath[]>;
|
||||
// List all files under root that should be synced. If root is undefined, return every file.
|
||||
listFilesRecursively: (
|
||||
root: RelativePath | undefined
|
||||
) => Promise<RelativePath[]>;
|
||||
|
||||
// Read the content of a file.
|
||||
read: (path: RelativePath) => Promise<Uint8Array>;
|
||||
// Read the content of a file.
|
||||
read: (path: RelativePath) => Promise<Uint8Array>;
|
||||
|
||||
// Create or overwrite a file with the given content.
|
||||
write: (path: RelativePath, content: Uint8Array) => Promise<void>;
|
||||
// Create or overwrite a file with the given content.
|
||||
write: (path: RelativePath, content: Uint8Array) => Promise<void>;
|
||||
|
||||
// Atomically update the content of a text file.
|
||||
atomicUpdateText: (
|
||||
path: RelativePath,
|
||||
updater: (current: TextWithCursors) => TextWithCursors
|
||||
) => Promise<string>;
|
||||
// Atomically update the content of a text file.
|
||||
atomicUpdateText: (
|
||||
path: RelativePath,
|
||||
updater: (current: TextWithCursors) => TextWithCursors
|
||||
) => Promise<string>;
|
||||
|
||||
// Get the size of a file in bytes.
|
||||
getFileSize: (path: RelativePath) => Promise<number>;
|
||||
// Get the size of a file in bytes.
|
||||
getFileSize: (path: RelativePath) => Promise<number>;
|
||||
|
||||
// Check if a file exists.
|
||||
exists: (path: RelativePath) => Promise<boolean>;
|
||||
// Check if a file exists.
|
||||
exists: (path: RelativePath) => Promise<boolean>;
|
||||
|
||||
// Create a directory at the specified path. All parent directories must already exist.
|
||||
createDirectory: (path: RelativePath) => Promise<void>;
|
||||
// Create a directory at the specified path. All parent directories must already exist.
|
||||
createDirectory: (path: RelativePath) => Promise<void>;
|
||||
|
||||
// Delete a file. It is expected that the path points to an existing file.
|
||||
delete: (path: RelativePath) => Promise<void>;
|
||||
// Delete a file. It is expected that the path points to an existing file.
|
||||
delete: (path: RelativePath) => Promise<void>;
|
||||
|
||||
// Rename a file. It is expected that the oldPath points to an existing file and the newPath does not exist.
|
||||
rename: (oldPath: RelativePath, newPath: RelativePath) => Promise<void>;
|
||||
// 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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,160 +11,160 @@ import type { TextWithCursors } from "reconcile-text";
|
|||
* single request in-flight for any one file through the use of locks.
|
||||
*/
|
||||
export class SafeFileSystemOperations implements FileSystemOperations {
|
||||
private readonly locks: Locks<RelativePath>;
|
||||
private readonly locks: Locks<RelativePath>;
|
||||
|
||||
public constructor(
|
||||
private readonly fs: FileSystemOperations,
|
||||
private readonly logger: Logger
|
||||
) {
|
||||
this.locks = new Locks(logger);
|
||||
}
|
||||
public constructor(
|
||||
private readonly fs: FileSystemOperations,
|
||||
private readonly logger: Logger
|
||||
) {
|
||||
this.locks = new Locks(logger);
|
||||
}
|
||||
|
||||
public async listFilesRecursively(
|
||||
root: RelativePath | undefined
|
||||
): Promise<RelativePath[]> {
|
||||
this.logger.debug("Listing all files");
|
||||
const result = await this.fs.listFilesRecursively(root);
|
||||
this.logger.debug(`Listed ${result.length} files`);
|
||||
return result;
|
||||
}
|
||||
public async listFilesRecursively(
|
||||
root: RelativePath | undefined
|
||||
): Promise<RelativePath[]> {
|
||||
this.logger.debug("Listing all files");
|
||||
const result = await this.fs.listFilesRecursively(root);
|
||||
this.logger.debug(`Listed ${result.length} files`);
|
||||
return result;
|
||||
}
|
||||
|
||||
public async read(path: RelativePath): Promise<Uint8Array> {
|
||||
this.logger.debug(`Reading file '${path}'`);
|
||||
return this.safeOperation(
|
||||
path,
|
||||
async () =>
|
||||
this.locks.withLock(path, async () => this.fs.read(path)),
|
||||
"read"
|
||||
);
|
||||
}
|
||||
public async read(path: RelativePath): Promise<Uint8Array> {
|
||||
this.logger.debug(`Reading file '${path}'`);
|
||||
return this.safeOperation(
|
||||
path,
|
||||
async () =>
|
||||
this.locks.withLock(path, async () => this.fs.read(path)),
|
||||
"read"
|
||||
);
|
||||
}
|
||||
|
||||
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
|
||||
this.logger.debug(`Writing to file '${path}'`);
|
||||
return this.locks.withLock(path, async () =>
|
||||
this.fs.write(path, content)
|
||||
);
|
||||
}
|
||||
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
|
||||
this.logger.debug(`Writing to file '${path}'`);
|
||||
return this.locks.withLock(path, async () =>
|
||||
this.fs.write(path, content)
|
||||
);
|
||||
}
|
||||
|
||||
public async atomicUpdateText(
|
||||
path: RelativePath,
|
||||
updater: (current: TextWithCursors) => TextWithCursors
|
||||
): Promise<string> {
|
||||
this.logger.debug(`Atomically updating file '${path}'`);
|
||||
return this.safeOperation(
|
||||
path,
|
||||
async () =>
|
||||
this.locks.withLock(path, async () =>
|
||||
this.fs.atomicUpdateText(path, updater)
|
||||
),
|
||||
"atomicUpdateText"
|
||||
);
|
||||
}
|
||||
public async atomicUpdateText(
|
||||
path: RelativePath,
|
||||
updater: (current: TextWithCursors) => TextWithCursors
|
||||
): Promise<string> {
|
||||
this.logger.debug(`Atomically updating file '${path}'`);
|
||||
return this.safeOperation(
|
||||
path,
|
||||
async () =>
|
||||
this.locks.withLock(path, async () =>
|
||||
this.fs.atomicUpdateText(path, updater)
|
||||
),
|
||||
"atomicUpdateText"
|
||||
);
|
||||
}
|
||||
|
||||
public async getFileSize(path: RelativePath): Promise<number> {
|
||||
// Logging this would be too noisy
|
||||
return this.safeOperation(
|
||||
path,
|
||||
async () =>
|
||||
this.locks.withLock(path, async () =>
|
||||
this.fs.getFileSize(path)
|
||||
),
|
||||
"getFileSize"
|
||||
);
|
||||
}
|
||||
public async getFileSize(path: RelativePath): Promise<number> {
|
||||
// Logging this would be too noisy
|
||||
return this.safeOperation(
|
||||
path,
|
||||
async () =>
|
||||
this.locks.withLock(path, async () =>
|
||||
this.fs.getFileSize(path)
|
||||
),
|
||||
"getFileSize"
|
||||
);
|
||||
}
|
||||
|
||||
public async exists(
|
||||
path: RelativePath,
|
||||
skipLock = false
|
||||
): Promise<boolean> {
|
||||
this.logger.debug(`Checking if file '${path}' exists`);
|
||||
if (skipLock) {
|
||||
return this.fs.exists(path);
|
||||
} else {
|
||||
return this.locks.withLock(path, async () => this.fs.exists(path));
|
||||
}
|
||||
}
|
||||
public async exists(
|
||||
path: RelativePath,
|
||||
skipLock = false
|
||||
): Promise<boolean> {
|
||||
this.logger.debug(`Checking if file '${path}' exists`);
|
||||
if (skipLock) {
|
||||
return this.fs.exists(path);
|
||||
} else {
|
||||
return this.locks.withLock(path, async () => this.fs.exists(path));
|
||||
}
|
||||
}
|
||||
|
||||
public async createDirectory(path: RelativePath): Promise<void> {
|
||||
this.logger.debug(`Creating directory '${path}'`);
|
||||
return this.locks.withLock(path, async () =>
|
||||
this.fs.createDirectory(path)
|
||||
);
|
||||
}
|
||||
public async createDirectory(path: RelativePath): Promise<void> {
|
||||
this.logger.debug(`Creating directory '${path}'`);
|
||||
return this.locks.withLock(path, async () =>
|
||||
this.fs.createDirectory(path)
|
||||
);
|
||||
}
|
||||
|
||||
public async delete(path: RelativePath): Promise<void> {
|
||||
this.logger.debug(`Deleting file '${path}'`);
|
||||
return this.locks.withLock(path, async () => this.fs.delete(path));
|
||||
}
|
||||
public async delete(path: RelativePath): Promise<void> {
|
||||
this.logger.debug(`Deleting file '${path}'`);
|
||||
return this.locks.withLock(path, async () => this.fs.delete(path));
|
||||
}
|
||||
|
||||
public async rename(
|
||||
oldPath: RelativePath,
|
||||
newPath: RelativePath,
|
||||
skipLock = false
|
||||
): Promise<void> {
|
||||
this.logger.debug(`Renaming file '${oldPath}' to '${newPath}'`);
|
||||
return this.safeOperation(
|
||||
oldPath,
|
||||
async () => {
|
||||
if (skipLock) {
|
||||
return this.fs.rename(oldPath, newPath);
|
||||
} else {
|
||||
return this.locks.withLock([oldPath, newPath], async () =>
|
||||
this.fs.rename(oldPath, newPath)
|
||||
);
|
||||
}
|
||||
},
|
||||
"rename"
|
||||
);
|
||||
}
|
||||
public async rename(
|
||||
oldPath: RelativePath,
|
||||
newPath: RelativePath,
|
||||
skipLock = false
|
||||
): Promise<void> {
|
||||
this.logger.debug(`Renaming file '${oldPath}' to '${newPath}'`);
|
||||
return this.safeOperation(
|
||||
oldPath,
|
||||
async () => {
|
||||
if (skipLock) {
|
||||
return this.fs.rename(oldPath, newPath);
|
||||
} else {
|
||||
return this.locks.withLock([oldPath, newPath], async () =>
|
||||
this.fs.rename(oldPath, newPath)
|
||||
);
|
||||
}
|
||||
},
|
||||
"rename"
|
||||
);
|
||||
}
|
||||
|
||||
public tryLock(path: RelativePath): boolean {
|
||||
return this.locks.tryLock(path);
|
||||
}
|
||||
public tryLock(path: RelativePath): boolean {
|
||||
return this.locks.tryLock(path);
|
||||
}
|
||||
|
||||
public async waitForLock(path: RelativePath): Promise<void> {
|
||||
return this.locks.waitForLock(path);
|
||||
}
|
||||
public async waitForLock(path: RelativePath): Promise<void> {
|
||||
return this.locks.waitForLock(path);
|
||||
}
|
||||
|
||||
public unlock(path: RelativePath): void {
|
||||
this.locks.unlock(path);
|
||||
}
|
||||
public unlock(path: RelativePath): void {
|
||||
this.locks.unlock(path);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.locks.reset();
|
||||
}
|
||||
public reset(): void {
|
||||
this.locks.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorate an operation to ensure that the file exists before running it.
|
||||
* If the operation fails, it will check if the file still exists and throw
|
||||
* a FileNotFoundError if it doesn't.
|
||||
*/
|
||||
private async safeOperation<T>(
|
||||
path: RelativePath,
|
||||
operation: () => Promise<T>,
|
||||
operationName: string
|
||||
): Promise<T> {
|
||||
if (!(await this.fs.exists(path))) {
|
||||
throw new FileNotFoundError(
|
||||
`File not found before trying to ${operationName}`,
|
||||
path
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Decorate an operation to ensure that the file exists before running it.
|
||||
* If the operation fails, it will check if the file still exists and throw
|
||||
* a FileNotFoundError if it doesn't.
|
||||
*/
|
||||
private async safeOperation<T>(
|
||||
path: RelativePath,
|
||||
operation: () => Promise<T>,
|
||||
operationName: string
|
||||
): Promise<T> {
|
||||
if (!(await this.fs.exists(path))) {
|
||||
throw new FileNotFoundError(
|
||||
`File not found before trying to ${operationName}`,
|
||||
path
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
// Without locking the file, this isn't atomic, however, it's good enough in practice.
|
||||
// This will only break if the file exists, gets deleted and then immediately
|
||||
// recreated while `operation` is running.
|
||||
if (await this.fs.exists(path)) {
|
||||
throw error;
|
||||
} else {
|
||||
throw new FileNotFoundError(
|
||||
`File not found when trying to ${operationName}`,
|
||||
path
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
// Without locking the file, this isn't atomic, however, it's good enough in practice.
|
||||
// This will only break if the file exists, gets deleted and then immediately
|
||||
// recreated while `operation` is running.
|
||||
if (await this.fs.exists(path)) {
|
||||
throw error;
|
||||
} else {
|
||||
throw new FileNotFoundError(
|
||||
`File not found when trying to ${operationName}`,
|
||||
path
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,17 +5,18 @@ import { slowWebSocketFactory } from "./utils/debugging/slow-web-socket-factory"
|
|||
import { getRandomColor } from "./utils/get-random-color";
|
||||
import { lineAndColumnToPosition } from "./utils/line-and-column-to-position";
|
||||
import { positionToLineAndColumn } from "./utils/position-to-line-and-column";
|
||||
import { removeFromArray } from "./utils/remove-from-array";
|
||||
|
||||
export {
|
||||
SyncType,
|
||||
SyncStatus,
|
||||
type HistoryStats,
|
||||
type HistoryEntry,
|
||||
type SyncDetails,
|
||||
type SyncCreateDetails,
|
||||
type SyncUpdateDetails,
|
||||
type SyncMovedDetails,
|
||||
type SyncDeleteDetails
|
||||
SyncType,
|
||||
SyncStatus,
|
||||
type HistoryStats,
|
||||
type HistoryEntry,
|
||||
type SyncDetails,
|
||||
type SyncCreateDetails,
|
||||
type SyncUpdateDetails,
|
||||
type SyncMovedDetails,
|
||||
type SyncDeleteDetails
|
||||
} from "./tracing/sync-history";
|
||||
export { Logger, LogLevel, LogLine } from "./tracing/logger";
|
||||
export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings";
|
||||
|
|
@ -34,14 +35,17 @@ export { SyncClient } from "./sync-client";
|
|||
export type { TextWithCursors, CursorPosition } from "reconcile-text";
|
||||
|
||||
export const debugging = {
|
||||
slowFetchFactory,
|
||||
slowWebSocketFactory,
|
||||
logToConsole
|
||||
slowFetchFactory,
|
||||
slowWebSocketFactory,
|
||||
logToConsole
|
||||
};
|
||||
|
||||
export { globsToRegexes } from "./utils/globs-to-regexes";
|
||||
|
||||
export const utils = {
|
||||
getRandomColor,
|
||||
positionToLineAndColumn,
|
||||
lineAndColumnToPosition,
|
||||
awaitAll
|
||||
getRandomColor,
|
||||
positionToLineAndColumn,
|
||||
lineAndColumnToPosition,
|
||||
awaitAll,
|
||||
removeFromArray
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,29 +2,30 @@ import type { Logger } from "../tracing/logger";
|
|||
import { EMPTY_HASH } from "../utils/hash";
|
||||
import { CoveredValues } from "../utils/data-structures/min-covered";
|
||||
import { awaitAll } from "../utils/await-all";
|
||||
import { removeFromArray } from "../utils/remove-from-array";
|
||||
|
||||
export type VaultUpdateId = number;
|
||||
export type DocumentId = string;
|
||||
export type RelativePath = string;
|
||||
|
||||
export interface DocumentMetadata {
|
||||
parentVersionId: VaultUpdateId;
|
||||
hash: string;
|
||||
remoteRelativePath?: RelativePath;
|
||||
parentVersionId: VaultUpdateId;
|
||||
hash: string;
|
||||
remoteRelativePath?: RelativePath;
|
||||
}
|
||||
|
||||
export interface StoredDocumentMetadata {
|
||||
relativePath: RelativePath;
|
||||
documentId: DocumentId;
|
||||
parentVersionId: VaultUpdateId;
|
||||
remoteRelativePath?: RelativePath;
|
||||
hash: string;
|
||||
relativePath: RelativePath;
|
||||
documentId: DocumentId;
|
||||
parentVersionId: VaultUpdateId;
|
||||
remoteRelativePath?: RelativePath;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface StoredDatabase {
|
||||
documents: StoredDocumentMetadata[];
|
||||
lastSeenUpdateId: VaultUpdateId | undefined;
|
||||
hasInitialSyncCompleted: boolean;
|
||||
documents: StoredDocumentMetadata[];
|
||||
lastSeenUpdateId: VaultUpdateId | undefined;
|
||||
hasInitialSyncCompleted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -34,339 +35,340 @@ export interface StoredDatabase {
|
|||
* state of the document on disk based on the update events we have seen.
|
||||
*/
|
||||
export interface DocumentRecord {
|
||||
relativePath: RelativePath;
|
||||
documentId: DocumentId;
|
||||
metadata: DocumentMetadata | undefined;
|
||||
isDeleted: boolean;
|
||||
updates: Promise<unknown>[];
|
||||
parallelVersion: number;
|
||||
relativePath: RelativePath;
|
||||
documentId: DocumentId;
|
||||
metadata: DocumentMetadata | undefined;
|
||||
isDeleted: boolean;
|
||||
updates: Promise<unknown>[];
|
||||
parallelVersion: number;
|
||||
}
|
||||
|
||||
export class Database {
|
||||
private documents: DocumentRecord[];
|
||||
private lastSeenUpdateIds: CoveredValues;
|
||||
private hasInitialSyncCompleted: boolean;
|
||||
private documents: DocumentRecord[];
|
||||
private lastSeenUpdateIds: CoveredValues;
|
||||
private hasInitialSyncCompleted: boolean;
|
||||
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
initialState: Partial<StoredDatabase> | undefined,
|
||||
private readonly saveData: (data: StoredDatabase) => Promise<void>
|
||||
) {
|
||||
initialState ??= {};
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
initialState: Partial<StoredDatabase> | undefined,
|
||||
private readonly saveData: (data: StoredDatabase) => Promise<void>
|
||||
) {
|
||||
initialState ??= {};
|
||||
|
||||
this.documents =
|
||||
initialState.documents?.map(
|
||||
({ relativePath, documentId, ...metadata }) => ({
|
||||
relativePath,
|
||||
documentId,
|
||||
metadata,
|
||||
isDeleted: false,
|
||||
updates: [],
|
||||
parallelVersion: 0
|
||||
})
|
||||
) ?? [];
|
||||
this.documents =
|
||||
initialState.documents?.map(
|
||||
({ relativePath, documentId, ...metadata }) => ({
|
||||
relativePath,
|
||||
documentId,
|
||||
metadata,
|
||||
isDeleted: false,
|
||||
updates: [],
|
||||
parallelVersion: 0
|
||||
})
|
||||
) ?? [];
|
||||
|
||||
this.ensureConsistency();
|
||||
this.logger.debug(`Loaded ${this.documents.length} documents`);
|
||||
this.ensureConsistency();
|
||||
this.logger.debug(`Loaded ${this.documents.length} documents`);
|
||||
|
||||
const { lastSeenUpdateId } = initialState;
|
||||
this.logger.debug(`Loaded last seen update id: ${lastSeenUpdateId}`);
|
||||
this.lastSeenUpdateIds = new CoveredValues(
|
||||
Math.max(0, lastSeenUpdateId ?? 0) // the first updateId will be 1 which is the first integer after -1
|
||||
);
|
||||
const { lastSeenUpdateId } = initialState;
|
||||
this.logger.debug(`Loaded last seen update id: ${lastSeenUpdateId}`);
|
||||
this.lastSeenUpdateIds = new CoveredValues(
|
||||
Math.max(0, lastSeenUpdateId ?? 0) // the first updateId will be 1 which is the first integer after -1
|
||||
);
|
||||
|
||||
this.documents.forEach((doc) => {
|
||||
this.lastSeenUpdateIds.add(doc.metadata?.parentVersionId);
|
||||
});
|
||||
this.documents.forEach((doc) => {
|
||||
this.lastSeenUpdateIds.add(doc.metadata?.parentVersionId);
|
||||
});
|
||||
|
||||
this.hasInitialSyncCompleted =
|
||||
initialState.hasInitialSyncCompleted ?? false;
|
||||
this.logger.debug(
|
||||
`Loaded hasInitialSyncCompleted: ${this.hasInitialSyncCompleted}`
|
||||
);
|
||||
}
|
||||
this.hasInitialSyncCompleted =
|
||||
initialState.hasInitialSyncCompleted ?? false;
|
||||
this.logger.debug(
|
||||
`Loaded hasInitialSyncCompleted: ${this.hasInitialSyncCompleted}`
|
||||
);
|
||||
}
|
||||
|
||||
public get length(): number {
|
||||
return this.documents.length;
|
||||
}
|
||||
public get length(): number {
|
||||
return this.documents.length;
|
||||
}
|
||||
|
||||
public get resolvedDocuments(): DocumentRecord[] {
|
||||
const paths = new Map<string, DocumentRecord[]>();
|
||||
this.documents
|
||||
.filter(({ metadata }) => metadata !== undefined)
|
||||
.forEach((record) =>
|
||||
paths.set(record.relativePath, [
|
||||
record,
|
||||
...(paths.get(record.relativePath) ?? [])
|
||||
])
|
||||
);
|
||||
public get resolvedDocuments(): DocumentRecord[] {
|
||||
const paths = new Map<string, DocumentRecord[]>();
|
||||
this.documents
|
||||
// eslint-disable-next-line no-restricted-syntax -- Type narrowing, not removing a specific item
|
||||
.filter(({ metadata }) => metadata !== undefined)
|
||||
.forEach((record) =>
|
||||
paths.set(record.relativePath, [
|
||||
record,
|
||||
...(paths.get(record.relativePath) ?? [])
|
||||
])
|
||||
);
|
||||
|
||||
return Array.from(paths.values()).map((records) => {
|
||||
records.sort(
|
||||
(a, b) => b.parallelVersion - a.parallelVersion // descending
|
||||
);
|
||||
return Array.from(paths.values()).map((records) => {
|
||||
records.sort(
|
||||
(a, b) => b.parallelVersion - a.parallelVersion // descending
|
||||
);
|
||||
|
||||
if (
|
||||
records.length > 1 &&
|
||||
records.some((current, i) =>
|
||||
i === 0
|
||||
? false
|
||||
: records[i - 1].parallelVersion ===
|
||||
current.parallelVersion
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Multiple documents with the same parallel version and path at ${records[0].relativePath}`
|
||||
);
|
||||
}
|
||||
return records[0];
|
||||
});
|
||||
}
|
||||
if (
|
||||
records.length > 1 &&
|
||||
records.some((current, i) =>
|
||||
i === 0
|
||||
? false
|
||||
: records[i - 1].parallelVersion ===
|
||||
current.parallelVersion
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Multiple documents with the same parallel version and path at ${records[0].relativePath}`
|
||||
);
|
||||
}
|
||||
return records[0];
|
||||
});
|
||||
}
|
||||
|
||||
public updateDocumentMetadata(
|
||||
metadata: {
|
||||
parentVersionId: VaultUpdateId;
|
||||
hash: string;
|
||||
remoteRelativePath: RelativePath;
|
||||
},
|
||||
toUpdate: DocumentRecord
|
||||
): void {
|
||||
if (!this.documents.includes(toUpdate)) {
|
||||
throw new Error("Document not found in database");
|
||||
}
|
||||
public updateDocumentMetadata(
|
||||
metadata: {
|
||||
parentVersionId: VaultUpdateId;
|
||||
hash: string;
|
||||
remoteRelativePath: RelativePath;
|
||||
},
|
||||
toUpdate: DocumentRecord
|
||||
): void {
|
||||
if (!this.documents.includes(toUpdate)) {
|
||||
throw new Error("Document not found in database");
|
||||
}
|
||||
|
||||
toUpdate.metadata = metadata;
|
||||
toUpdate.metadata = metadata;
|
||||
|
||||
this.saveInTheBackground();
|
||||
}
|
||||
this.saveInTheBackground();
|
||||
}
|
||||
|
||||
public removeDocumentPromise(promise: Promise<unknown>): void {
|
||||
const entry = this.documents.find(({ updates }) =>
|
||||
updates.includes(promise)
|
||||
);
|
||||
public removeDocumentPromise(promise: Promise<unknown>): void {
|
||||
const entry = this.documents.find(({ updates }) =>
|
||||
updates.includes(promise)
|
||||
);
|
||||
|
||||
if (entry === undefined) {
|
||||
// This method should be idempotent and tolerant of
|
||||
// stragglers calling it after the databse has been reset.
|
||||
return;
|
||||
}
|
||||
if (entry === undefined) {
|
||||
// This method should be idempotent and tolerant of
|
||||
// stragglers calling it after the databse has been reset.
|
||||
return;
|
||||
}
|
||||
|
||||
entry.updates = entry.updates.filter((update) => update !== promise);
|
||||
// No need to save as Promises don't get serialized
|
||||
}
|
||||
removeFromArray(entry.updates, promise);
|
||||
// No need to save as Promises don't get serialized
|
||||
}
|
||||
|
||||
public removeDocument(find: DocumentRecord): void {
|
||||
this.documents = this.documents.filter((document) => document !== find);
|
||||
this.saveInTheBackground();
|
||||
}
|
||||
public removeDocument(find: DocumentRecord): void {
|
||||
removeFromArray(this.documents, find);
|
||||
this.saveInTheBackground();
|
||||
}
|
||||
|
||||
public getLatestDocumentByRelativePath(
|
||||
find: RelativePath
|
||||
): DocumentRecord | undefined {
|
||||
const candidates = this.documents.filter(
|
||||
({ relativePath }) => relativePath === find
|
||||
);
|
||||
candidates.sort((a, b) => b.parallelVersion - a.parallelVersion); // descending
|
||||
return candidates[0];
|
||||
}
|
||||
public getLatestDocumentByRelativePath(
|
||||
find: RelativePath
|
||||
): DocumentRecord | undefined {
|
||||
const candidates = this.documents.filter(
|
||||
({ relativePath }) => relativePath === find
|
||||
);
|
||||
candidates.sort((a, b) => b.parallelVersion - a.parallelVersion); // descending
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
public async getResolvedDocumentByRelativePath(
|
||||
relativePath: RelativePath,
|
||||
promise: Promise<unknown>
|
||||
): Promise<DocumentRecord> {
|
||||
const entry = this.getLatestDocumentByRelativePath(relativePath);
|
||||
public async getResolvedDocumentByRelativePath(
|
||||
relativePath: RelativePath,
|
||||
promise: Promise<unknown>
|
||||
): Promise<DocumentRecord> {
|
||||
const entry = this.getLatestDocumentByRelativePath(relativePath);
|
||||
|
||||
if (entry === undefined) {
|
||||
throw new Error(
|
||||
`Document not found by relative path: ${relativePath}, ${JSON.stringify(
|
||||
this.documents,
|
||||
null,
|
||||
2
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (entry === undefined) {
|
||||
throw new Error(
|
||||
`Document not found by relative path: ${relativePath}, ${JSON.stringify(
|
||||
this.documents,
|
||||
null,
|
||||
2
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const currentPromises = entry.updates;
|
||||
entry.updates = [...currentPromises, promise];
|
||||
await awaitAll(currentPromises);
|
||||
const currentPromises = entry.updates;
|
||||
entry.updates = [...currentPromises, promise];
|
||||
await awaitAll(currentPromises);
|
||||
|
||||
return entry;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
public createNewPendingDocument(
|
||||
documentId: DocumentId,
|
||||
relativePath: RelativePath,
|
||||
promise: Promise<unknown>
|
||||
): DocumentRecord {
|
||||
this.logger.debug(
|
||||
`Creating new pending document: ${relativePath} (${documentId})`
|
||||
);
|
||||
const previousEntry =
|
||||
this.getLatestDocumentByRelativePath(relativePath);
|
||||
public createNewPendingDocument(
|
||||
documentId: DocumentId,
|
||||
relativePath: RelativePath,
|
||||
promise: Promise<unknown>
|
||||
): DocumentRecord {
|
||||
this.logger.debug(
|
||||
`Creating new pending document: ${relativePath} (${documentId})`
|
||||
);
|
||||
const previousEntry =
|
||||
this.getLatestDocumentByRelativePath(relativePath);
|
||||
|
||||
const entry = {
|
||||
relativePath,
|
||||
documentId,
|
||||
metadata: undefined,
|
||||
isDeleted: false,
|
||||
updates: [promise],
|
||||
parallelVersion:
|
||||
previousEntry?.parallelVersion === undefined
|
||||
? 0
|
||||
: previousEntry.parallelVersion + 1
|
||||
};
|
||||
const entry = {
|
||||
relativePath,
|
||||
documentId,
|
||||
metadata: undefined,
|
||||
isDeleted: false,
|
||||
updates: [promise],
|
||||
parallelVersion:
|
||||
previousEntry?.parallelVersion === undefined
|
||||
? 0
|
||||
: previousEntry.parallelVersion + 1
|
||||
};
|
||||
|
||||
this.documents.push(entry);
|
||||
this.saveInTheBackground();
|
||||
this.documents.push(entry);
|
||||
this.saveInTheBackground();
|
||||
|
||||
return entry;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
public createNewEmptyDocument(
|
||||
documentId: DocumentId,
|
||||
parentVersionId: VaultUpdateId,
|
||||
relativePath: RelativePath
|
||||
): DocumentRecord {
|
||||
const entry = {
|
||||
relativePath,
|
||||
documentId,
|
||||
metadata: {
|
||||
parentVersionId,
|
||||
hash: EMPTY_HASH,
|
||||
remoteRelativePath: relativePath
|
||||
},
|
||||
isDeleted: false,
|
||||
updates: [],
|
||||
parallelVersion: 0
|
||||
};
|
||||
public createNewEmptyDocument(
|
||||
documentId: DocumentId,
|
||||
parentVersionId: VaultUpdateId,
|
||||
relativePath: RelativePath
|
||||
): DocumentRecord {
|
||||
const entry = {
|
||||
relativePath,
|
||||
documentId,
|
||||
metadata: {
|
||||
parentVersionId,
|
||||
hash: EMPTY_HASH,
|
||||
remoteRelativePath: relativePath
|
||||
},
|
||||
isDeleted: false,
|
||||
updates: [],
|
||||
parallelVersion: 0
|
||||
};
|
||||
|
||||
this.documents.push(entry);
|
||||
this.saveInTheBackground();
|
||||
this.documents.push(entry);
|
||||
this.saveInTheBackground();
|
||||
|
||||
return entry;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
public getDocumentByDocumentId(
|
||||
find: DocumentId
|
||||
): DocumentRecord | undefined {
|
||||
return this.documents.find(({ documentId }) => documentId === find);
|
||||
}
|
||||
public getDocumentByDocumentId(
|
||||
find: DocumentId
|
||||
): DocumentRecord | undefined {
|
||||
return this.documents.find(({ documentId }) => documentId === find);
|
||||
}
|
||||
|
||||
public move(
|
||||
oldRelativePath: RelativePath,
|
||||
newRelativePath: RelativePath
|
||||
): void {
|
||||
const oldDocument =
|
||||
this.getLatestDocumentByRelativePath(oldRelativePath);
|
||||
public move(
|
||||
oldRelativePath: RelativePath,
|
||||
newRelativePath: RelativePath
|
||||
): void {
|
||||
const oldDocument =
|
||||
this.getLatestDocumentByRelativePath(oldRelativePath);
|
||||
|
||||
if (oldDocument === undefined) {
|
||||
return;
|
||||
}
|
||||
if (oldDocument === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newDocument =
|
||||
this.getLatestDocumentByRelativePath(newRelativePath);
|
||||
if (newDocument?.isDeleted === false) {
|
||||
throw new Error(
|
||||
`Document already exists at new location: ${newRelativePath}`
|
||||
);
|
||||
}
|
||||
const newDocument =
|
||||
this.getLatestDocumentByRelativePath(newRelativePath);
|
||||
if (newDocument?.isDeleted === false) {
|
||||
throw new Error(
|
||||
`Document already exists at new location: ${newRelativePath}`
|
||||
);
|
||||
}
|
||||
|
||||
oldDocument.relativePath = newRelativePath;
|
||||
// We're in a strange state where the target of the move has just got deleted,
|
||||
// however, its metadata might already have a bunch of updates queued up for
|
||||
// the document at the new location. We need to keep these updates.
|
||||
oldDocument.parallelVersion =
|
||||
newDocument !== undefined ? newDocument.parallelVersion + 1 : 0;
|
||||
oldDocument.relativePath = newRelativePath;
|
||||
// We're in a strange state where the target of the move has just got deleted,
|
||||
// however, its metadata might already have a bunch of updates queued up for
|
||||
// the document at the new location. We need to keep these updates.
|
||||
oldDocument.parallelVersion =
|
||||
newDocument !== undefined ? newDocument.parallelVersion + 1 : 0;
|
||||
|
||||
this.saveInTheBackground();
|
||||
}
|
||||
this.saveInTheBackground();
|
||||
}
|
||||
|
||||
public delete(relativePath: RelativePath): void {
|
||||
const candidate = this.getLatestDocumentByRelativePath(relativePath);
|
||||
if (candidate === undefined) {
|
||||
throw new Error(
|
||||
`Document not found by relative path: ${relativePath}`
|
||||
);
|
||||
}
|
||||
candidate.isDeleted = true;
|
||||
}
|
||||
public delete(relativePath: RelativePath): void {
|
||||
const candidate = this.getLatestDocumentByRelativePath(relativePath);
|
||||
if (candidate === undefined) {
|
||||
throw new Error(
|
||||
`Document not found by relative path: ${relativePath}`
|
||||
);
|
||||
}
|
||||
candidate.isDeleted = true;
|
||||
}
|
||||
|
||||
public getHasInitialSyncCompleted(): boolean {
|
||||
return this.hasInitialSyncCompleted;
|
||||
}
|
||||
public getHasInitialSyncCompleted(): boolean {
|
||||
return this.hasInitialSyncCompleted;
|
||||
}
|
||||
|
||||
public setHasInitialSyncCompleted(value: boolean): void {
|
||||
this.hasInitialSyncCompleted = value;
|
||||
this.saveInTheBackground();
|
||||
}
|
||||
public setHasInitialSyncCompleted(value: boolean): void {
|
||||
this.hasInitialSyncCompleted = value;
|
||||
this.saveInTheBackground();
|
||||
}
|
||||
|
||||
public getLastSeenUpdateId(): VaultUpdateId {
|
||||
return this.lastSeenUpdateIds.min;
|
||||
}
|
||||
public getLastSeenUpdateId(): VaultUpdateId {
|
||||
return this.lastSeenUpdateIds.min;
|
||||
}
|
||||
|
||||
public addSeenUpdateId(value: number): void {
|
||||
const previousMin = this.lastSeenUpdateIds.min;
|
||||
this.lastSeenUpdateIds.add(value);
|
||||
if (previousMin !== this.lastSeenUpdateIds.min) {
|
||||
this.saveInTheBackground();
|
||||
}
|
||||
}
|
||||
public addSeenUpdateId(value: number): void {
|
||||
const previousMin = this.lastSeenUpdateIds.min;
|
||||
this.lastSeenUpdateIds.add(value);
|
||||
if (previousMin !== this.lastSeenUpdateIds.min) {
|
||||
this.saveInTheBackground();
|
||||
}
|
||||
}
|
||||
|
||||
public setLastSeenUpdateId(value: number): void {
|
||||
this.lastSeenUpdateIds.min = value;
|
||||
this.saveInTheBackground();
|
||||
}
|
||||
public setLastSeenUpdateId(value: number): void {
|
||||
this.lastSeenUpdateIds.min = value;
|
||||
this.saveInTheBackground();
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.documents = [];
|
||||
this.lastSeenUpdateIds = new CoveredValues(
|
||||
0 // the first updateId will be 1 which is the first integer after -1
|
||||
);
|
||||
this.hasInitialSyncCompleted = false;
|
||||
this.saveInTheBackground();
|
||||
}
|
||||
public reset(): void {
|
||||
this.documents = [];
|
||||
this.lastSeenUpdateIds = new CoveredValues(
|
||||
0 // the first updateId will be 1 which is the first integer after -1
|
||||
);
|
||||
this.hasInitialSyncCompleted = false;
|
||||
this.saveInTheBackground();
|
||||
}
|
||||
|
||||
public async save(): Promise<void> {
|
||||
return this.saveData({
|
||||
documents: this.resolvedDocuments.map(
|
||||
({ relativePath, documentId, metadata }) => ({
|
||||
documentId,
|
||||
relativePath,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
...metadata! // `resolvedDocuments` only returns docs with metadata set
|
||||
})
|
||||
),
|
||||
lastSeenUpdateId: this.lastSeenUpdateIds.min,
|
||||
hasInitialSyncCompleted: this.hasInitialSyncCompleted
|
||||
});
|
||||
}
|
||||
public async save(): Promise<void> {
|
||||
return this.saveData({
|
||||
documents: this.resolvedDocuments.map(
|
||||
({ relativePath, documentId, metadata }) => ({
|
||||
documentId,
|
||||
relativePath,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
...metadata! // `resolvedDocuments` only returns docs with metadata set
|
||||
})
|
||||
),
|
||||
lastSeenUpdateId: this.lastSeenUpdateIds.min,
|
||||
hasInitialSyncCompleted: this.hasInitialSyncCompleted
|
||||
});
|
||||
}
|
||||
|
||||
private ensureConsistency(): void {
|
||||
const idToPath = new Map<string, string[]>();
|
||||
private ensureConsistency(): void {
|
||||
const idToPath = new Map<string, string[]>();
|
||||
|
||||
this.resolvedDocuments.forEach(({ relativePath, documentId }) => {
|
||||
idToPath.set(documentId, [
|
||||
...(idToPath.get(documentId) ?? []),
|
||||
relativePath
|
||||
]);
|
||||
});
|
||||
this.resolvedDocuments.forEach(({ relativePath, documentId }) => {
|
||||
idToPath.set(documentId, [
|
||||
...(idToPath.get(documentId) ?? []),
|
||||
relativePath
|
||||
]);
|
||||
});
|
||||
|
||||
const duplicates = Array.from(idToPath.entries())
|
||||
.filter(([_, paths]) => paths.length > 1)
|
||||
.map(([id, paths]) => `${id} (${paths.join(", ")})`);
|
||||
const duplicates = Array.from(idToPath.entries())
|
||||
.filter(([_, paths]) => paths.length > 1)
|
||||
.map(([id, paths]) => `${id} (${paths.join(", ")})`);
|
||||
|
||||
if (duplicates.length > 0) {
|
||||
throw new Error(
|
||||
"Document IDs are not unique, found duplicates: " +
|
||||
duplicates.join("; ")
|
||||
);
|
||||
}
|
||||
}
|
||||
if (duplicates.length > 0) {
|
||||
throw new Error(
|
||||
"Document IDs are not unique, found duplicates: " +
|
||||
duplicates.join("; ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private saveInTheBackground(): void {
|
||||
this.ensureConsistency();
|
||||
void this.save().catch((error: unknown) => {
|
||||
this.logger.error(`Error saving data: ${error}`);
|
||||
});
|
||||
}
|
||||
private saveInTheBackground(): void {
|
||||
this.ensureConsistency();
|
||||
void this.save().catch((error: unknown) => {
|
||||
this.logger.error(`Error saving data: ${error}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export interface PersistenceProvider<T> {
|
||||
load: () => Promise<T | undefined>;
|
||||
save: (data: T) => Promise<void>;
|
||||
load: () => Promise<T | undefined>;
|
||||
save: (data: T) => Promise<void>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,115 +1,94 @@
|
|||
import type { Logger } from "../tracing/logger";
|
||||
import { awaitAll } from "../utils/await-all";
|
||||
import { Lock } from "../utils/data-structures/locks";
|
||||
import { EventListeners } from "../utils/data-structures/event-listeners";
|
||||
|
||||
export interface SyncSettings {
|
||||
remoteUri: string;
|
||||
token: string;
|
||||
vaultName: string;
|
||||
syncConcurrency: number;
|
||||
isSyncEnabled: boolean;
|
||||
maxFileSizeMB: number;
|
||||
ignorePatterns: string[];
|
||||
webSocketRetryIntervalMs: number;
|
||||
diffCacheSizeMB: number;
|
||||
enableTelemetry: boolean;
|
||||
networkRetryIntervalMs: number;
|
||||
minimumSaveIntervalMs: number;
|
||||
remoteUri: string;
|
||||
token: string;
|
||||
vaultName: string;
|
||||
syncConcurrency: number;
|
||||
isSyncEnabled: boolean;
|
||||
maxFileSizeMB: number;
|
||||
ignorePatterns: string[];
|
||||
webSocketRetryIntervalMs: number;
|
||||
diffCacheSizeMB: number;
|
||||
enableTelemetry: boolean;
|
||||
networkRetryIntervalMs: number;
|
||||
minimumSaveIntervalMs: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: SyncSettings = {
|
||||
remoteUri: "",
|
||||
token: "",
|
||||
vaultName: "default",
|
||||
syncConcurrency: 1,
|
||||
isSyncEnabled: false,
|
||||
maxFileSizeMB: 10,
|
||||
ignorePatterns: [],
|
||||
webSocketRetryIntervalMs: 3500,
|
||||
diffCacheSizeMB: 4,
|
||||
enableTelemetry: false,
|
||||
networkRetryIntervalMs: 1000,
|
||||
minimumSaveIntervalMs: 1000
|
||||
remoteUri: "",
|
||||
token: "",
|
||||
vaultName: "default",
|
||||
syncConcurrency: 1,
|
||||
isSyncEnabled: false,
|
||||
maxFileSizeMB: 10,
|
||||
ignorePatterns: [],
|
||||
webSocketRetryIntervalMs: 3500,
|
||||
diffCacheSizeMB: 4,
|
||||
enableTelemetry: false,
|
||||
networkRetryIntervalMs: 1000,
|
||||
minimumSaveIntervalMs: 1000
|
||||
};
|
||||
|
||||
export class Settings {
|
||||
private settings: SyncSettings;
|
||||
private readonly lock: Lock = new Lock();
|
||||
public readonly onSettingsChanged = new EventListeners<
|
||||
(newSettings: SyncSettings, oldSettings: SyncSettings) => unknown
|
||||
>();
|
||||
|
||||
private readonly onSettingsChangeHandlers: ((
|
||||
newSettings: SyncSettings,
|
||||
oldSettings: SyncSettings
|
||||
) => unknown)[] = [];
|
||||
private settings: SyncSettings;
|
||||
private readonly lock: Lock = new Lock();
|
||||
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
initialState: Partial<SyncSettings> | undefined,
|
||||
private readonly saveData: (data: SyncSettings) => Promise<void>
|
||||
) {
|
||||
this.settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...(initialState ?? {})
|
||||
};
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
initialState: Partial<SyncSettings> | undefined,
|
||||
private readonly saveData: (data: SyncSettings) => Promise<void>
|
||||
) {
|
||||
this.settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...(initialState ?? {})
|
||||
};
|
||||
|
||||
this.logger.debug(
|
||||
`Loaded settings: ${JSON.stringify(this.settings, null, 2)}`
|
||||
);
|
||||
}
|
||||
this.logger.debug(
|
||||
`Loaded settings: ${JSON.stringify(this.settings, null, 2)}`
|
||||
);
|
||||
}
|
||||
|
||||
public getSettings(): SyncSettings {
|
||||
return this.settings;
|
||||
}
|
||||
public getSettings(): SyncSettings {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
public addOnSettingsChangeListener(
|
||||
listener: (settings: SyncSettings, oldSettings: SyncSettings) => unknown
|
||||
): void {
|
||||
this.onSettingsChangeHandlers.push(listener);
|
||||
}
|
||||
public async setSetting<T extends keyof SyncSettings>(
|
||||
key: T,
|
||||
value: SyncSettings[T]
|
||||
): Promise<void> {
|
||||
await this.setSettings({
|
||||
[key]: value
|
||||
});
|
||||
}
|
||||
|
||||
public removeOnSettingsChangeListener(
|
||||
listener: (settings: SyncSettings, oldSettings: SyncSettings) => unknown
|
||||
): void {
|
||||
const index = this.onSettingsChangeHandlers.indexOf(listener);
|
||||
if (index !== -1) {
|
||||
this.onSettingsChangeHandlers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
public async setSettings(value: Partial<SyncSettings>): Promise<void> {
|
||||
await this.lock.withLock(async () => {
|
||||
this.logger.debug(
|
||||
`Updating settings with: ${JSON.stringify(value)}`
|
||||
);
|
||||
const oldSettings = this.settings;
|
||||
this.settings = {
|
||||
...this.settings,
|
||||
...value
|
||||
};
|
||||
|
||||
public async setSetting<T extends keyof SyncSettings>(
|
||||
key: T,
|
||||
value: SyncSettings[T]
|
||||
): Promise<void> {
|
||||
await this.setSettings({
|
||||
[key]: value
|
||||
});
|
||||
}
|
||||
await this.onSettingsChanged.triggerAsync(
|
||||
this.settings,
|
||||
oldSettings
|
||||
);
|
||||
|
||||
public async setSettings(value: Partial<SyncSettings>): Promise<void> {
|
||||
await this.lock.withLock(async () => {
|
||||
this.logger.debug(
|
||||
`Updating settings with: ${JSON.stringify(value)}`
|
||||
);
|
||||
const oldSettings = this.settings;
|
||||
this.settings = {
|
||||
...this.settings,
|
||||
...value
|
||||
};
|
||||
await this.save();
|
||||
});
|
||||
}
|
||||
|
||||
await awaitAll(
|
||||
this.onSettingsChangeHandlers
|
||||
.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);
|
||||
}
|
||||
private async save(): Promise<void> {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export class AuthenticationError extends Error {
|
||||
public constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "AuthenticationError";
|
||||
}
|
||||
public constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "AuthenticationError";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,171 +7,171 @@ import { SyncResetError } from "./sync-reset-error";
|
|||
import { sleep } from "../utils/sleep";
|
||||
|
||||
describe("FetchController", () => {
|
||||
const createMockFetch = (
|
||||
shouldSleep: boolean
|
||||
): Mock<() => Promise<Response>> =>
|
||||
mock.fn(async () => {
|
||||
if (shouldSleep) {
|
||||
await sleep(30);
|
||||
}
|
||||
return Promise.resolve(new Response("OK", { status: 200 }));
|
||||
});
|
||||
const createMockFetch = (
|
||||
shouldSleep: boolean
|
||||
): Mock<() => Promise<Response>> =>
|
||||
mock.fn(async () => {
|
||||
if (shouldSleep) {
|
||||
await sleep(30);
|
||||
}
|
||||
return Promise.resolve(new Response("OK", { status: 200 }));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mock.timers.enable({ apis: ["setTimeout"] });
|
||||
});
|
||||
beforeEach(() => {
|
||||
mock.timers.enable({ apis: ["setTimeout"] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.timers.reset();
|
||||
});
|
||||
afterEach(() => {
|
||||
mock.timers.reset();
|
||||
});
|
||||
|
||||
it("should allow fetch when canFetch is true", async () => {
|
||||
const logger = new Logger();
|
||||
const controller = new FetchController(true, logger);
|
||||
const mockFetch = createMockFetch(false);
|
||||
const controlledFetch = controller.getControlledFetchImplementation(
|
||||
logger,
|
||||
mockFetch
|
||||
);
|
||||
it("should allow fetch when canFetch is true", async () => {
|
||||
const logger = new Logger();
|
||||
const controller = new FetchController(true, logger);
|
||||
const mockFetch = createMockFetch(false);
|
||||
const controlledFetch = controller.getControlledFetchImplementation(
|
||||
logger,
|
||||
mockFetch
|
||||
);
|
||||
|
||||
const response = await controlledFetch("http://example.com");
|
||||
const response = await controlledFetch("http://example.com");
|
||||
|
||||
assert.strictEqual(await response.text(), "OK");
|
||||
assert.strictEqual(mockFetch.mock.calls.length, 1);
|
||||
});
|
||||
assert.strictEqual(await response.text(), "OK");
|
||||
assert.strictEqual(mockFetch.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it("should block fetch until canFetch becomes true", async () => {
|
||||
const logger = new Logger();
|
||||
const controller = new FetchController(false, logger);
|
||||
const mockFetch = createMockFetch(true);
|
||||
const controlledFetch = controller.getControlledFetchImplementation(
|
||||
logger,
|
||||
mockFetch
|
||||
);
|
||||
it("should block fetch until canFetch becomes true", async () => {
|
||||
const logger = new Logger();
|
||||
const controller = new FetchController(false, logger);
|
||||
const mockFetch = createMockFetch(true);
|
||||
const controlledFetch = controller.getControlledFetchImplementation(
|
||||
logger,
|
||||
mockFetch
|
||||
);
|
||||
|
||||
const fetchPromise = controlledFetch("http://example.com");
|
||||
assert.strictEqual(mockFetch.mock.calls.length, 0);
|
||||
const fetchPromise = controlledFetch("http://example.com");
|
||||
assert.strictEqual(mockFetch.mock.calls.length, 0);
|
||||
|
||||
controller.canFetch = true;
|
||||
await Promise.resolve();
|
||||
mock.timers.tick(30);
|
||||
controller.canFetch = true;
|
||||
await Promise.resolve();
|
||||
mock.timers.tick(30);
|
||||
|
||||
const response = await fetchPromise;
|
||||
assert.strictEqual(await response.text(), "OK");
|
||||
assert.strictEqual(mockFetch.mock.calls.length, 1);
|
||||
});
|
||||
const response = await fetchPromise;
|
||||
assert.strictEqual(await response.text(), "OK");
|
||||
assert.strictEqual(mockFetch.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it("should reject during reset", async () => {
|
||||
const logger = new Logger();
|
||||
const controller = new FetchController(true, logger);
|
||||
const mockFetch = createMockFetch(true);
|
||||
const controlledFetch = controller.getControlledFetchImplementation(
|
||||
logger,
|
||||
mockFetch
|
||||
);
|
||||
it("should reject during reset", async () => {
|
||||
const logger = new Logger();
|
||||
const controller = new FetchController(true, logger);
|
||||
const mockFetch = createMockFetch(true);
|
||||
const controlledFetch = controller.getControlledFetchImplementation(
|
||||
logger,
|
||||
mockFetch
|
||||
);
|
||||
|
||||
const firstRequest = controlledFetch("http://example.com");
|
||||
assert.strictEqual(mockFetch.mock.calls.length, 1);
|
||||
const firstRequest = controlledFetch("http://example.com");
|
||||
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(
|
||||
firstRequest,
|
||||
(error: unknown) => error instanceof SyncResetError
|
||||
);
|
||||
await assert.rejects(
|
||||
secondRequest,
|
||||
(error: unknown) => error instanceof SyncResetError
|
||||
);
|
||||
assert.strictEqual(mockFetch.mock.calls.length, 1);
|
||||
});
|
||||
await assert.rejects(
|
||||
firstRequest,
|
||||
(error: unknown) => error instanceof SyncResetError
|
||||
);
|
||||
await assert.rejects(
|
||||
secondRequest,
|
||||
(error: unknown) => error instanceof SyncResetError
|
||||
);
|
||||
assert.strictEqual(mockFetch.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it("should allow fetch after reset finishes", async () => {
|
||||
const logger = new Logger();
|
||||
const controller = new FetchController(true, logger);
|
||||
const mockFetch = createMockFetch(false);
|
||||
const controlledFetch = controller.getControlledFetchImplementation(
|
||||
logger,
|
||||
mockFetch
|
||||
);
|
||||
it("should allow fetch after reset finishes", async () => {
|
||||
const logger = new Logger();
|
||||
const controller = new FetchController(true, logger);
|
||||
const mockFetch = createMockFetch(false);
|
||||
const controlledFetch = controller.getControlledFetchImplementation(
|
||||
logger,
|
||||
mockFetch
|
||||
);
|
||||
|
||||
controller.startReset();
|
||||
controller.finishReset();
|
||||
controller.startReset();
|
||||
controller.finishReset();
|
||||
|
||||
const response = await controlledFetch("http://example.com");
|
||||
assert.strictEqual(await response.text(), "OK");
|
||||
});
|
||||
const response = await controlledFetch("http://example.com");
|
||||
assert.strictEqual(await response.text(), "OK");
|
||||
});
|
||||
|
||||
it("should defer canFetch changes during reset", async () => {
|
||||
const logger = new Logger();
|
||||
const controller = new FetchController(false, logger);
|
||||
const mockFetch = createMockFetch(true);
|
||||
const controlledFetch = controller.getControlledFetchImplementation(
|
||||
logger,
|
||||
mockFetch
|
||||
);
|
||||
it("should defer canFetch changes during reset", async () => {
|
||||
const logger = new Logger();
|
||||
const controller = new FetchController(false, logger);
|
||||
const mockFetch = createMockFetch(true);
|
||||
const controlledFetch = controller.getControlledFetchImplementation(
|
||||
logger,
|
||||
mockFetch
|
||||
);
|
||||
|
||||
controller.startReset();
|
||||
controller.canFetch = true;
|
||||
controller.startReset();
|
||||
controller.canFetch = true;
|
||||
|
||||
await assert.rejects(
|
||||
async () => controlledFetch("http://example.com"),
|
||||
(error: unknown) => error instanceof SyncResetError
|
||||
);
|
||||
await assert.rejects(
|
||||
async () => controlledFetch("http://example.com"),
|
||||
(error: unknown) => error instanceof SyncResetError
|
||||
);
|
||||
|
||||
controller.finishReset();
|
||||
controller.finishReset();
|
||||
|
||||
const fetchPromise = controlledFetch("http://example.com");
|
||||
mock.timers.tick(30);
|
||||
const fetchPromise = controlledFetch("http://example.com");
|
||||
mock.timers.tick(30);
|
||||
|
||||
const response = await fetchPromise;
|
||||
assert.strictEqual(await response.text(), "OK");
|
||||
});
|
||||
const response = await fetchPromise;
|
||||
assert.strictEqual(await response.text(), "OK");
|
||||
});
|
||||
|
||||
it("should handle different input types", async () => {
|
||||
const logger = new Logger();
|
||||
const controller = new FetchController(true, logger);
|
||||
const mockFetch = createMockFetch(false);
|
||||
const controlledFetch = controller.getControlledFetchImplementation(
|
||||
logger,
|
||||
mockFetch
|
||||
);
|
||||
it("should handle different input types", async () => {
|
||||
const logger = new Logger();
|
||||
const controller = new FetchController(true, logger);
|
||||
const mockFetch = createMockFetch(false);
|
||||
const controlledFetch = controller.getControlledFetchImplementation(
|
||||
logger,
|
||||
mockFetch
|
||||
);
|
||||
|
||||
await controlledFetch("http://example.com");
|
||||
await controlledFetch(new URL("http://example.com"));
|
||||
await controlledFetch(
|
||||
new Request("http://example.com", { method: "POST" })
|
||||
);
|
||||
await controlledFetch("http://example.com");
|
||||
await controlledFetch(new URL("http://example.com"));
|
||||
await controlledFetch(
|
||||
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 () => {
|
||||
const logger = new Logger();
|
||||
const controller = new FetchController(true, logger);
|
||||
const mockFetch = mock.fn(async () => {
|
||||
throw new Error("Network error");
|
||||
});
|
||||
const controlledFetch = controller.getControlledFetchImplementation(
|
||||
logger,
|
||||
mockFetch
|
||||
);
|
||||
it("should handle fetch errors", async () => {
|
||||
const logger = new Logger();
|
||||
const controller = new FetchController(true, logger);
|
||||
const mockFetch = mock.fn(async () => {
|
||||
throw new Error("Network error");
|
||||
});
|
||||
const controlledFetch = controller.getControlledFetchImplementation(
|
||||
logger,
|
||||
mockFetch
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
async () => controlledFetch("http://example.com"),
|
||||
(error: unknown) =>
|
||||
error instanceof Error && error.message === "Network error"
|
||||
);
|
||||
});
|
||||
await assert.rejects(
|
||||
async () => controlledFetch("http://example.com"),
|
||||
(error: unknown) =>
|
||||
error instanceof Error && error.message === "Network error"
|
||||
);
|
||||
});
|
||||
|
||||
it("should not create unhandled rejection on reset with no waiting fetches", async () => {
|
||||
const logger = new Logger();
|
||||
const controller = new FetchController(true, logger);
|
||||
it("should not create unhandled rejection on reset with no waiting fetches", async () => {
|
||||
const logger = new Logger();
|
||||
const controller = new FetchController(true, logger);
|
||||
|
||||
controller.startReset();
|
||||
mock.timers.tick(10);
|
||||
controller.finishReset();
|
||||
});
|
||||
controller.startReset();
|
||||
mock.timers.tick(10);
|
||||
controller.finishReset();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,143 +7,143 @@ import { SyncResetError } from "./sync-reset-error";
|
|||
* and aborts outstanding requests when a reset is started.
|
||||
*/
|
||||
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
|
||||
private until: Promise<symbol>;
|
||||
private resolveUntil: (result: symbol) => unknown;
|
||||
private rejectUntil: (reason: unknown) => unknown;
|
||||
// Promise resolves on the next state change: sync enabled/disabled or reset started/ended
|
||||
private until: Promise<symbol>;
|
||||
private resolveUntil: (result: symbol) => unknown;
|
||||
private rejectUntil: (reason: unknown) => unknown;
|
||||
|
||||
public constructor(
|
||||
private _canFetch: boolean,
|
||||
private readonly logger: Logger
|
||||
) {
|
||||
[this.until, this.resolveUntil, this.rejectUntil] =
|
||||
createPromise<symbol>();
|
||||
}
|
||||
public constructor(
|
||||
private _canFetch: boolean,
|
||||
private readonly logger: Logger
|
||||
) {
|
||||
[this.until, this.resolveUntil, this.rejectUntil] =
|
||||
createPromise<symbol>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the fetch implementation can immediately send requests once outside of a reset.
|
||||
*/
|
||||
public get canFetch(): boolean {
|
||||
return this._canFetch;
|
||||
}
|
||||
/**
|
||||
* Whether the fetch implementation can immediately send requests once outside of a reset.
|
||||
*/
|
||||
public get canFetch(): boolean {
|
||||
return this._canFetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param canFetch Whether fetching is enabled
|
||||
*/
|
||||
public set canFetch(canFetch: boolean) {
|
||||
this._canFetch = canFetch;
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param canFetch Whether fetching is enabled
|
||||
*/
|
||||
public set canFetch(canFetch: boolean) {
|
||||
this._canFetch = canFetch;
|
||||
|
||||
if (!this.isResetting) {
|
||||
const previousResolve = this.resolveUntil;
|
||||
[this.until, this.resolveUntil, this.rejectUntil] =
|
||||
createPromise<symbol>();
|
||||
previousResolve(FetchController.UNTIL_RESOLUTION);
|
||||
}
|
||||
}
|
||||
if (!this.isResetting) {
|
||||
const previousResolve = this.resolveUntil;
|
||||
[this.until, this.resolveUntil, this.rejectUntil] =
|
||||
createPromise<symbol>();
|
||||
previousResolve(FetchController.UNTIL_RESOLUTION);
|
||||
}
|
||||
}
|
||||
|
||||
private static getUrlFromInput(input: RequestInfo | URL): string {
|
||||
if (input instanceof URL) {
|
||||
return input.href;
|
||||
}
|
||||
if (typeof input === "string") {
|
||||
return input;
|
||||
}
|
||||
return input.url;
|
||||
}
|
||||
private static getUrlFromInput(input: RequestInfo | URL): string {
|
||||
if (input instanceof URL) {
|
||||
return input.href;
|
||||
}
|
||||
if (typeof input === "string") {
|
||||
return input;
|
||||
}
|
||||
return input.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a reset, causing all ongoing and future fetches to be rejected
|
||||
* with a SyncResetError until finishReset is called.
|
||||
*/
|
||||
public startReset(): void {
|
||||
this.isResetting = true;
|
||||
this.rejectUntil(new SyncResetError());
|
||||
// Catch unhandled rejection if no fetches are waiting
|
||||
this.until.catch(() => {
|
||||
// Intentionally ignore - this rejection is handled by waiting fetches
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Starts a reset, causing all ongoing and future fetches to be rejected
|
||||
* with a SyncResetError until finishReset is called.
|
||||
*/
|
||||
public startReset(): void {
|
||||
this.isResetting = true;
|
||||
this.rejectUntil(new SyncResetError());
|
||||
// Catch unhandled rejection if no fetches are waiting
|
||||
this.until.catch(() => {
|
||||
// Intentionally ignore - this rejection is handled by waiting fetches
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finishes a reset, allowing fetches to proceed or wait again depending on
|
||||
* the current sync settings.
|
||||
*/
|
||||
public finishReset(): void {
|
||||
if (!this.isResetting) {
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* Finishes a reset, allowing fetches to proceed or wait again depending on
|
||||
* the current sync settings.
|
||||
*/
|
||||
public finishReset(): void {
|
||||
if (!this.isResetting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isResetting = false;
|
||||
[this.until, this.resolveUntil, this.rejectUntil] = createPromise();
|
||||
}
|
||||
this.isResetting = false;
|
||||
[this.until, this.resolveUntil, this.rejectUntil] = createPromise();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* |------------------|---------------|-----------------------------------------------------|
|
||||
* | | Sync enabled | Sync disabled |
|
||||
* |------------------|-------------- |-----------------------------------------------------|
|
||||
* | During reset | Rejects with SyncResetError without sending request |
|
||||
* |------------------|-------------- |-----------------------------------------------------|
|
||||
* | Outside of reset | Same as fetch | Blocks until sync is enabled and then same as fetch |
|
||||
* |------------------|---------------|-----------------------------------------------------|
|
||||
*
|
||||
* @param logger for errors
|
||||
* @param fetch to wrap
|
||||
* @returns a wrapped fetch implementation affected by the FetchController state
|
||||
*/
|
||||
public getControlledFetchImplementation(
|
||||
logger: Logger,
|
||||
fetch: typeof globalThis.fetch = globalThis.fetch
|
||||
): typeof globalThis.fetch {
|
||||
return async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit
|
||||
): Promise<Response> => {
|
||||
while (!this.canFetch || this.isResetting) {
|
||||
await this.until;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* |------------------|---------------|-----------------------------------------------------|
|
||||
* | | Sync enabled | Sync disabled |
|
||||
* |------------------|-------------- |-----------------------------------------------------|
|
||||
* | During reset | Rejects with SyncResetError without sending request |
|
||||
* |------------------|-------------- |-----------------------------------------------------|
|
||||
* | Outside of reset | Same as fetch | Blocks until sync is enabled and then same as fetch |
|
||||
* |------------------|---------------|-----------------------------------------------------|
|
||||
*
|
||||
* @param logger for errors
|
||||
* @param fetch to wrap
|
||||
* @returns a wrapped fetch implementation affected by the FetchController state
|
||||
*/
|
||||
public getControlledFetchImplementation(
|
||||
logger: Logger,
|
||||
fetch: typeof globalThis.fetch = globalThis.fetch
|
||||
): typeof globalThis.fetch {
|
||||
return async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit
|
||||
): Promise<Response> => {
|
||||
while (!this.canFetch || this.isResetting) {
|
||||
await this.until;
|
||||
}
|
||||
|
||||
try {
|
||||
// https://github.com/jonbern/fetch-retry/blob/8684ef4e688375f623bd76f13add76dbc1d67cfb/index.js#L67C1-L70C21
|
||||
const _input =
|
||||
typeof Request !== "undefined" && input instanceof Request
|
||||
? input.clone()
|
||||
: input;
|
||||
try {
|
||||
// https://github.com/jonbern/fetch-retry/blob/8684ef4e688375f623bd76f13add76dbc1d67cfb/index.js#L67C1-L70C21
|
||||
const _input =
|
||||
typeof Request !== "undefined" && input instanceof Request
|
||||
? input.clone()
|
||||
: input;
|
||||
|
||||
const fetchPromise = fetch(_input, init);
|
||||
const fetchPromise = fetch(_input, init);
|
||||
|
||||
// We only want to catch rejections from `this.until`
|
||||
let result: symbol | Response | undefined = undefined;
|
||||
do {
|
||||
result = await Promise.race([this.until, fetchPromise]);
|
||||
} while (result === FetchController.UNTIL_RESOLUTION);
|
||||
// We only want to catch rejections from `this.until`
|
||||
let result: symbol | Response | undefined = undefined;
|
||||
do {
|
||||
result = await Promise.race([this.until, fetchPromise]);
|
||||
} while (result === 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) {
|
||||
this.logger.warn(
|
||||
`Fetch for ${FetchController.getUrlFromInput(
|
||||
input
|
||||
)}, got status ${fetchResult.status}`
|
||||
);
|
||||
}
|
||||
if (!fetchResult.ok) {
|
||||
this.logger.warn(
|
||||
`Fetch for ${FetchController.getUrlFromInput(
|
||||
input
|
||||
)}, got status ${fetchResult.status}`
|
||||
);
|
||||
}
|
||||
|
||||
return fetchResult;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Fetch for ${FetchController.getUrlFromInput(
|
||||
input
|
||||
)}, got error: ${error}`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
return fetchResult;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Fetch for ${FetchController.getUrlFromInput(
|
||||
input
|
||||
)}, got error: ${error}`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,83 +5,83 @@ import type { SyncService } from "./sync-service";
|
|||
import type { PingResponse } from "./types/PingResponse";
|
||||
|
||||
export interface ServerConfigData {
|
||||
mergeableFileExtensions: string[];
|
||||
supportedApiVersion: number;
|
||||
isAuthenticated: boolean;
|
||||
mergeableFileExtensions: string[];
|
||||
supportedApiVersion: number;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
export class ServerConfig {
|
||||
private response: Promise<PingResponse> | undefined;
|
||||
private config: ServerConfigData | undefined;
|
||||
private response: Promise<PingResponse> | undefined;
|
||||
private config: ServerConfigData | undefined;
|
||||
|
||||
public constructor(private readonly syncService: SyncService) {}
|
||||
public constructor(private readonly syncService: SyncService) {}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
this.response = this.syncService.ping();
|
||||
this.config = await this.response;
|
||||
public async initialize(): Promise<void> {
|
||||
this.response = this.syncService.ping();
|
||||
this.config = await this.response;
|
||||
|
||||
if (this.config.supportedApiVersion !== SUPPORTED_API_VERSION) {
|
||||
const shouldUpgradeClient =
|
||||
this.config.supportedApiVersion > SUPPORTED_API_VERSION;
|
||||
throw new ServerVersionMismatchError(
|
||||
`Unsupported API version: ${this.config.supportedApiVersion}. Consider upgrading the ${
|
||||
shouldUpgradeClient ? "client" : "sync-server"
|
||||
} to ensure compatibility.`
|
||||
);
|
||||
}
|
||||
if (this.config.supportedApiVersion !== SUPPORTED_API_VERSION) {
|
||||
const shouldUpgradeClient =
|
||||
this.config.supportedApiVersion > SUPPORTED_API_VERSION;
|
||||
throw new ServerVersionMismatchError(
|
||||
`Unsupported API version: ${this.config.supportedApiVersion}. Consider upgrading the ${
|
||||
shouldUpgradeClient ? "client" : "sync-server"
|
||||
} to ensure compatibility.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.config.isAuthenticated) {
|
||||
throw new AuthenticationError(
|
||||
"Failed to authenticate with the sync-server."
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!this.config.isAuthenticated) {
|
||||
throw new AuthenticationError(
|
||||
"Failed to authenticate with the sync-server."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async checkConnection(forceUpdate = false): Promise<{
|
||||
isSuccessful: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
try {
|
||||
let { response } = this;
|
||||
if (!response && !forceUpdate) {
|
||||
throw new Error("ServerConfig not initialized");
|
||||
} else if (forceUpdate) {
|
||||
response = this.response = this.syncService.ping();
|
||||
}
|
||||
public async checkConnection(forceUpdate = false): Promise<{
|
||||
isSuccessful: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
try {
|
||||
let { response } = this;
|
||||
if (!response && !forceUpdate) {
|
||||
throw new Error("ServerConfig not initialized");
|
||||
} else if (forceUpdate) {
|
||||
response = this.response = this.syncService.ping();
|
||||
}
|
||||
|
||||
// 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
|
||||
this.config = result;
|
||||
// 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
|
||||
this.config = result;
|
||||
|
||||
if (result.isAuthenticated) {
|
||||
return {
|
||||
isSuccessful: true,
|
||||
message: `Successfully connected to server (version: ${result.serverVersion}) and authenticated`
|
||||
};
|
||||
}
|
||||
if (result.isAuthenticated) {
|
||||
return {
|
||||
isSuccessful: true,
|
||||
message: `Successfully connected to server (version: ${result.serverVersion}) and authenticated`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isSuccessful: false,
|
||||
message: `Successfully connected to server (version: ${result.serverVersion}) but failed to authenticate`
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
isSuccessful: false,
|
||||
message: `Failed to connect to server: ${e}`
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
isSuccessful: false,
|
||||
message: `Successfully connected to server (version: ${result.serverVersion}) but failed to authenticate`
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
isSuccessful: false,
|
||||
message: `Failed to connect to server: ${e}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public getConfig(): ServerConfigData {
|
||||
if (!this.config) {
|
||||
throw new Error("ServerConfig not initialized");
|
||||
}
|
||||
public getConfig(): ServerConfigData {
|
||||
if (!this.config) {
|
||||
throw new Error("ServerConfig not initialized");
|
||||
}
|
||||
|
||||
return this.config;
|
||||
}
|
||||
return this.config;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.response = undefined;
|
||||
this.config = undefined;
|
||||
}
|
||||
public reset(): void {
|
||||
this.response = undefined;
|
||||
this.config = undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export class ServerVersionMismatchError extends Error {
|
||||
public constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ServerVersionMismatchError";
|
||||
}
|
||||
public constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ServerVersionMismatchError";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export class SyncResetError extends Error {
|
||||
public constructor() {
|
||||
super("SyncClient has been reset, cleaning up");
|
||||
this.name = "SyncResetError";
|
||||
}
|
||||
public constructor() {
|
||||
super("SyncClient has been reset, cleaning up");
|
||||
this.name = "SyncResetError";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type {
|
||||
DocumentId,
|
||||
RelativePath,
|
||||
VaultUpdateId
|
||||
DocumentId,
|
||||
RelativePath,
|
||||
VaultUpdateId
|
||||
} from "../persistence/database";
|
||||
|
||||
import type { Logger } from "../tracing/logger";
|
||||
|
|
@ -19,416 +19,416 @@ import type { DeleteDocumentVersion } from "./types/DeleteDocumentVersion";
|
|||
import type { UpdateTextDocumentVersion } from "./types/UpdateTextDocumentVersion";
|
||||
|
||||
export class SyncService {
|
||||
private readonly client: typeof globalThis.fetch;
|
||||
private readonly pingClient: typeof globalThis.fetch;
|
||||
private readonly client: typeof globalThis.fetch;
|
||||
private readonly pingClient: typeof globalThis.fetch;
|
||||
|
||||
public constructor(
|
||||
private readonly deviceId: string,
|
||||
private readonly fetchController: FetchController,
|
||||
private readonly settings: Settings,
|
||||
private readonly logger: Logger,
|
||||
fetchImplementation: typeof globalThis.fetch = globalThis.fetch
|
||||
) {
|
||||
// ensure that if it's called a method, `this` won't be bound to the instance
|
||||
const unboundFetch: typeof globalThis.fetch = async (...args) =>
|
||||
fetchImplementation(...args);
|
||||
public constructor(
|
||||
private readonly deviceId: string,
|
||||
private readonly fetchController: FetchController,
|
||||
private readonly settings: Settings,
|
||||
private readonly logger: Logger,
|
||||
fetchImplementation: typeof globalThis.fetch = globalThis.fetch
|
||||
) {
|
||||
// ensure that if it's called a method, `this` won't be bound to the instance
|
||||
const unboundFetch: typeof globalThis.fetch = async (...args) =>
|
||||
fetchImplementation(...args);
|
||||
|
||||
this.client = this.fetchController.getControlledFetchImplementation(
|
||||
this.logger,
|
||||
unboundFetch
|
||||
);
|
||||
this.pingClient = unboundFetch;
|
||||
}
|
||||
this.client = this.fetchController.getControlledFetchImplementation(
|
||||
this.logger,
|
||||
unboundFetch
|
||||
);
|
||||
this.pingClient = unboundFetch;
|
||||
}
|
||||
|
||||
private static async errorFromResponse(
|
||||
response: Response
|
||||
): Promise<string> {
|
||||
if (
|
||||
response.headers
|
||||
.get("Content-Type")
|
||||
?.includes("application/json") == true
|
||||
) {
|
||||
const result: SerializedError =
|
||||
(await response.json()) as SerializedError; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return SyncService.formatError(result);
|
||||
}
|
||||
return `HTTP ${response.status}: ${response.statusText}`;
|
||||
}
|
||||
private static async errorFromResponse(
|
||||
response: Response
|
||||
): Promise<string> {
|
||||
if (
|
||||
response.headers
|
||||
.get("Content-Type")
|
||||
?.includes("application/json") == true
|
||||
) {
|
||||
const result: SerializedError =
|
||||
(await response.json()) as SerializedError; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return SyncService.formatError(result);
|
||||
}
|
||||
return `HTTP ${response.status}: ${response.statusText}`;
|
||||
}
|
||||
|
||||
private static formatError(error: SerializedError): string {
|
||||
let result = error.message;
|
||||
if (error.causes.length > 0) {
|
||||
const causes = error.causes.join(", ");
|
||||
result += ` caused by: ${causes}`;
|
||||
}
|
||||
private static formatError(error: SerializedError): string {
|
||||
let result = error.message;
|
||||
if (error.causes.length > 0) {
|
||||
const causes = error.causes.join(", ");
|
||||
result += ` caused by: ${causes}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public async create({
|
||||
documentId,
|
||||
relativePath,
|
||||
contentBytes
|
||||
}: {
|
||||
documentId?: DocumentId;
|
||||
relativePath: RelativePath;
|
||||
contentBytes: Uint8Array;
|
||||
}): Promise<DocumentVersionWithoutContent> {
|
||||
return this.retryForever(async () => {
|
||||
const formData = new FormData();
|
||||
if (documentId !== undefined) {
|
||||
formData.append("document_id", documentId);
|
||||
}
|
||||
formData.append("relative_path", relativePath);
|
||||
formData.append(
|
||||
"content",
|
||||
new Blob([new Uint8Array(contentBytes)])
|
||||
);
|
||||
public async create({
|
||||
documentId,
|
||||
relativePath,
|
||||
contentBytes
|
||||
}: {
|
||||
documentId?: DocumentId;
|
||||
relativePath: RelativePath;
|
||||
contentBytes: Uint8Array;
|
||||
}): Promise<DocumentVersionWithoutContent> {
|
||||
return this.retryForever(async () => {
|
||||
const formData = new FormData();
|
||||
if (documentId !== undefined) {
|
||||
formData.append("document_id", documentId);
|
||||
}
|
||||
formData.append("relative_path", relativePath);
|
||||
formData.append(
|
||||
"content",
|
||||
new Blob([new Uint8Array(contentBytes)])
|
||||
);
|
||||
|
||||
this.logger.debug(
|
||||
`Creating document with id ${documentId} and relative path ${relativePath}`
|
||||
);
|
||||
this.logger.debug(
|
||||
`Creating document with id ${documentId} and relative path ${relativePath}`
|
||||
);
|
||||
|
||||
const response = await this.client(this.getUrl("/documents"), {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers: this.getDefaultHeaders()
|
||||
});
|
||||
const response = await this.client(this.getUrl("/documents"), {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers: this.getDefaultHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to create document: ${await SyncService.errorFromResponse(
|
||||
response
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to create document: ${await SyncService.errorFromResponse(
|
||||
response
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const result: DocumentVersionWithoutContent =
|
||||
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const result: DocumentVersionWithoutContent =
|
||||
(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({
|
||||
parentVersionId,
|
||||
documentId,
|
||||
relativePath,
|
||||
content
|
||||
}: {
|
||||
parentVersionId: VaultUpdateId;
|
||||
documentId: DocumentId;
|
||||
relativePath: RelativePath;
|
||||
content: (number | string)[];
|
||||
}): Promise<DocumentUpdateResponse> {
|
||||
return this.retryForever(async () => {
|
||||
this.logger.debug(
|
||||
`Updating text document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}, content [${content.join(", ")}]`
|
||||
);
|
||||
public async putText({
|
||||
parentVersionId,
|
||||
documentId,
|
||||
relativePath,
|
||||
content
|
||||
}: {
|
||||
parentVersionId: VaultUpdateId;
|
||||
documentId: DocumentId;
|
||||
relativePath: RelativePath;
|
||||
content: (number | string)[];
|
||||
}): Promise<DocumentUpdateResponse> {
|
||||
return this.retryForever(async () => {
|
||||
this.logger.debug(
|
||||
`Updating text document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}, content [${content.join(", ")}]`
|
||||
);
|
||||
|
||||
const request: UpdateTextDocumentVersion = {
|
||||
parentVersionId,
|
||||
relativePath,
|
||||
content
|
||||
};
|
||||
const request: UpdateTextDocumentVersion = {
|
||||
parentVersionId,
|
||||
relativePath,
|
||||
content
|
||||
};
|
||||
|
||||
const response = await this.client(
|
||||
this.getUrl(`/documents/${documentId}/text`),
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(request),
|
||||
headers: this.getDefaultHeaders({ type: "json" })
|
||||
}
|
||||
);
|
||||
const response = await this.client(
|
||||
this.getUrl(`/documents/${documentId}/text`),
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(request),
|
||||
headers: this.getDefaultHeaders({ type: "json" })
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to update document: ${await SyncService.errorFromResponse(
|
||||
response
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to update document: ${await SyncService.errorFromResponse(
|
||||
response
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const result: DocumentUpdateResponse =
|
||||
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const result: DocumentUpdateResponse =
|
||||
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
|
||||
this.logger.debug(
|
||||
`Updated document ${JSON.stringify(result)} with id ${
|
||||
result.documentId
|
||||
}}`
|
||||
);
|
||||
this.logger.debug(
|
||||
`Updated document ${JSON.stringify(result)} with id ${
|
||||
result.documentId
|
||||
}}`
|
||||
);
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
public async putBinary({
|
||||
parentVersionId,
|
||||
documentId,
|
||||
relativePath,
|
||||
contentBytes
|
||||
}: {
|
||||
parentVersionId: VaultUpdateId;
|
||||
documentId: DocumentId;
|
||||
relativePath: RelativePath;
|
||||
contentBytes: Uint8Array;
|
||||
}): Promise<DocumentUpdateResponse> {
|
||||
return this.retryForever(async () => {
|
||||
this.logger.debug(
|
||||
`Updating binary document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}`
|
||||
);
|
||||
const formData = new FormData();
|
||||
formData.append("parent_version_id", parentVersionId.toString());
|
||||
formData.append("relative_path", relativePath);
|
||||
formData.append(
|
||||
"content",
|
||||
new Blob([new Uint8Array(contentBytes)])
|
||||
);
|
||||
public async putBinary({
|
||||
parentVersionId,
|
||||
documentId,
|
||||
relativePath,
|
||||
contentBytes
|
||||
}: {
|
||||
parentVersionId: VaultUpdateId;
|
||||
documentId: DocumentId;
|
||||
relativePath: RelativePath;
|
||||
contentBytes: Uint8Array;
|
||||
}): Promise<DocumentUpdateResponse> {
|
||||
return this.retryForever(async () => {
|
||||
this.logger.debug(
|
||||
`Updating binary document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}`
|
||||
);
|
||||
const formData = new FormData();
|
||||
formData.append("parent_version_id", parentVersionId.toString());
|
||||
formData.append("relative_path", relativePath);
|
||||
formData.append(
|
||||
"content",
|
||||
new Blob([new Uint8Array(contentBytes)])
|
||||
);
|
||||
|
||||
const response = await this.client(
|
||||
this.getUrl(`/documents/${documentId}/binary`),
|
||||
{
|
||||
method: "PUT",
|
||||
body: formData,
|
||||
headers: this.getDefaultHeaders()
|
||||
}
|
||||
);
|
||||
const response = await this.client(
|
||||
this.getUrl(`/documents/${documentId}/binary`),
|
||||
{
|
||||
method: "PUT",
|
||||
body: formData,
|
||||
headers: this.getDefaultHeaders()
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to update document: ${await SyncService.errorFromResponse(
|
||||
response
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to update document: ${await SyncService.errorFromResponse(
|
||||
response
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const result: DocumentUpdateResponse =
|
||||
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const result: DocumentUpdateResponse =
|
||||
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
|
||||
this.logger.debug(
|
||||
`Updated document ${JSON.stringify(result)} with id ${
|
||||
result.documentId
|
||||
}}`
|
||||
);
|
||||
this.logger.debug(
|
||||
`Updated document ${JSON.stringify(result)} with id ${
|
||||
result.documentId
|
||||
}}`
|
||||
);
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
public async delete({
|
||||
documentId,
|
||||
relativePath
|
||||
}: {
|
||||
documentId: DocumentId;
|
||||
relativePath: RelativePath;
|
||||
}): Promise<DocumentVersionWithoutContent> {
|
||||
return this.retryForever(async () => {
|
||||
const request: DeleteDocumentVersion = {
|
||||
relativePath
|
||||
};
|
||||
public async delete({
|
||||
documentId,
|
||||
relativePath
|
||||
}: {
|
||||
documentId: DocumentId;
|
||||
relativePath: RelativePath;
|
||||
}): Promise<DocumentVersionWithoutContent> {
|
||||
return this.retryForever(async () => {
|
||||
const request: DeleteDocumentVersion = {
|
||||
relativePath
|
||||
};
|
||||
|
||||
this.logger.debug(
|
||||
`Delete document with id ${documentId} and relative path ${relativePath}`
|
||||
);
|
||||
this.logger.debug(
|
||||
`Delete document with id ${documentId} and relative path ${relativePath}`
|
||||
);
|
||||
|
||||
const response = await this.client(
|
||||
this.getUrl(`/documents/${documentId}`),
|
||||
{
|
||||
method: "DELETE",
|
||||
body: JSON.stringify(request),
|
||||
headers: this.getDefaultHeaders({ type: "json" })
|
||||
}
|
||||
);
|
||||
const response = await this.client(
|
||||
this.getUrl(`/documents/${documentId}`),
|
||||
{
|
||||
method: "DELETE",
|
||||
body: JSON.stringify(request),
|
||||
headers: this.getDefaultHeaders({ type: "json" })
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to delete document: ${await SyncService.errorFromResponse(
|
||||
response
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to delete document: ${await SyncService.errorFromResponse(
|
||||
response
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const result: DocumentVersionWithoutContent =
|
||||
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const result: DocumentVersionWithoutContent =
|
||||
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
|
||||
this.logger.debug(
|
||||
`Deleted document ${relativePath} with id ${documentId}`
|
||||
);
|
||||
this.logger.debug(
|
||||
`Deleted document ${relativePath} with id ${documentId}`
|
||||
);
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
public async get({
|
||||
documentId
|
||||
}: {
|
||||
documentId: DocumentId;
|
||||
}): Promise<DocumentVersion> {
|
||||
return this.retryForever(async () => {
|
||||
this.logger.debug(`Getting document with id ${documentId}`);
|
||||
public async get({
|
||||
documentId
|
||||
}: {
|
||||
documentId: DocumentId;
|
||||
}): Promise<DocumentVersion> {
|
||||
return this.retryForever(async () => {
|
||||
this.logger.debug(`Getting document with id ${documentId}`);
|
||||
|
||||
const response = await this.client(
|
||||
this.getUrl(`/documents/${documentId}`),
|
||||
{
|
||||
headers: this.getDefaultHeaders()
|
||||
}
|
||||
);
|
||||
const response = await this.client(
|
||||
this.getUrl(`/documents/${documentId}`),
|
||||
{
|
||||
headers: this.getDefaultHeaders()
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to get document: ${await SyncService.errorFromResponse(
|
||||
response
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to get document: ${await SyncService.errorFromResponse(
|
||||
response
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const result: DocumentVersion =
|
||||
(await response.json()) as DocumentVersion; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const result: DocumentVersion =
|
||||
(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({
|
||||
documentId,
|
||||
vaultUpdateId
|
||||
}: {
|
||||
documentId: DocumentId;
|
||||
vaultUpdateId: VaultUpdateId;
|
||||
}): Promise<Uint8Array> {
|
||||
return this.retryForever(async () => {
|
||||
this.logger.debug(
|
||||
`Getting document with id ${documentId} and version ${vaultUpdateId}`
|
||||
);
|
||||
public async getDocumentVersionContent({
|
||||
documentId,
|
||||
vaultUpdateId
|
||||
}: {
|
||||
documentId: DocumentId;
|
||||
vaultUpdateId: VaultUpdateId;
|
||||
}): Promise<Uint8Array> {
|
||||
return this.retryForever(async () => {
|
||||
this.logger.debug(
|
||||
`Getting document with id ${documentId} and version ${vaultUpdateId}`
|
||||
);
|
||||
|
||||
const response = await this.client(
|
||||
this.getUrl(
|
||||
`/documents/${documentId}/versions/${vaultUpdateId}/content`
|
||||
),
|
||||
{
|
||||
headers: this.getDefaultHeaders()
|
||||
}
|
||||
);
|
||||
const response = await this.client(
|
||||
this.getUrl(
|
||||
`/documents/${documentId}/versions/${vaultUpdateId}/content`
|
||||
),
|
||||
{
|
||||
headers: this.getDefaultHeaders()
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to get document: ${await SyncService.errorFromResponse(
|
||||
response
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to get document: ${await SyncService.errorFromResponse(
|
||||
response
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.bytes();
|
||||
this.logger.debug(
|
||||
`Got document version content for document ${documentId} version ${vaultUpdateId}`
|
||||
);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
const result = await response.bytes();
|
||||
this.logger.debug(
|
||||
`Got document version content for document ${documentId} version ${vaultUpdateId}`
|
||||
);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
public async getAll(
|
||||
since?: VaultUpdateId
|
||||
): Promise<FetchLatestDocumentsResponse> {
|
||||
return this.retryForever(async () => {
|
||||
this.logger.debug(
|
||||
"Getting all documents" +
|
||||
(since != null ? ` since ${since}` : "")
|
||||
);
|
||||
public async getAll(
|
||||
since?: VaultUpdateId
|
||||
): Promise<FetchLatestDocumentsResponse> {
|
||||
return this.retryForever(async () => {
|
||||
this.logger.debug(
|
||||
"Getting all documents" +
|
||||
(since != null ? ` since ${since}` : "")
|
||||
);
|
||||
|
||||
const url = new URL(this.getUrl("/documents"));
|
||||
if (since !== undefined) {
|
||||
url.searchParams.append("since", since.toString());
|
||||
}
|
||||
const response = await this.client(url.toString(), {
|
||||
headers: this.getDefaultHeaders()
|
||||
});
|
||||
const url = new URL(this.getUrl("/documents"));
|
||||
if (since !== undefined) {
|
||||
url.searchParams.append("since", since.toString());
|
||||
}
|
||||
const response = await this.client(url.toString(), {
|
||||
headers: this.getDefaultHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to get documents: ${await SyncService.errorFromResponse(
|
||||
response
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to get documents: ${await SyncService.errorFromResponse(
|
||||
response
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const result: FetchLatestDocumentsResponse =
|
||||
(await response.json()) as FetchLatestDocumentsResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const result: FetchLatestDocumentsResponse =
|
||||
(await response.json()) as FetchLatestDocumentsResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
|
||||
this.logger.debug(
|
||||
`Got ${result.latestDocuments.length} document metadata`
|
||||
);
|
||||
this.logger.debug(
|
||||
`Got ${result.latestDocuments.length} document metadata`
|
||||
);
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
public async ping(): Promise<PingResponse> {
|
||||
this.logger.debug("Pinging server");
|
||||
const response = await this.pingClient(this.getUrl("/ping"), {
|
||||
headers: this.getDefaultHeaders()
|
||||
});
|
||||
public async ping(): Promise<PingResponse> {
|
||||
this.logger.debug("Pinging server");
|
||||
const response = await this.pingClient(this.getUrl("/ping"), {
|
||||
headers: this.getDefaultHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to ping server: ${await SyncService.errorFromResponse(
|
||||
response
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to ping server: ${await SyncService.errorFromResponse(
|
||||
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(
|
||||
`Pinged server, got response: ${JSON.stringify(result)}`
|
||||
);
|
||||
this.logger.debug(
|
||||
`Pinged server, got response: ${JSON.stringify(result)}`
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private getUrl(path: string): string {
|
||||
const { vaultName, remoteUri } = this.settings.getSettings();
|
||||
const remoteUriWithoutTrailingSlash = remoteUri.replace(/\/+$/, "");
|
||||
const encodedVaultName = encodeURIComponent(vaultName.trim());
|
||||
return `${remoteUriWithoutTrailingSlash}/vaults/${encodedVaultName}${path}`;
|
||||
}
|
||||
private getUrl(path: string): string {
|
||||
const { vaultName, remoteUri } = this.settings.getSettings();
|
||||
const remoteUriWithoutTrailingSlash = remoteUri.replace(/\/+$/, "");
|
||||
const encodedVaultName = encodeURIComponent(vaultName.trim());
|
||||
return `${remoteUriWithoutTrailingSlash}/vaults/${encodedVaultName}${path}`;
|
||||
}
|
||||
|
||||
private getDefaultHeaders(
|
||||
{ type }: { type?: "json" } = { type: undefined }
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"device-id": this.deviceId,
|
||||
authorization: `Bearer ${this.settings.getSettings().token}`
|
||||
};
|
||||
private getDefaultHeaders(
|
||||
{ type }: { type?: "json" } = { type: undefined }
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"device-id": this.deviceId,
|
||||
authorization: `Bearer ${this.settings.getSettings().token}`
|
||||
};
|
||||
|
||||
if (type === "json") {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
if (type === "json") {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
private async retryForever<T>(fn: () => Promise<T>): Promise<T> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
while (true) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
// We must not retry errors coming from reset
|
||||
if (e instanceof SyncResetError) {
|
||||
throw e;
|
||||
}
|
||||
private async retryForever<T>(fn: () => Promise<T>): Promise<T> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
while (true) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
// We must not retry errors coming from reset
|
||||
if (e instanceof SyncResetError) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
const retryInterval =
|
||||
this.settings.getSettings().networkRetryIntervalMs;
|
||||
this.logger.error(
|
||||
`Failed network call (${e}), retrying in ${retryInterval}ms`
|
||||
);
|
||||
await sleep(retryInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
const retryInterval =
|
||||
this.settings.getSettings().networkRetryIntervalMs;
|
||||
this.logger.error(
|
||||
`Failed network call (${e}), retrying in ${retryInterval}ms`
|
||||
);
|
||||
await sleep(retryInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
||||
|
||||
export interface ClientCursors {
|
||||
userName: string;
|
||||
deviceId: string;
|
||||
documentsWithCursors: DocumentWithCursors[];
|
||||
userName: string;
|
||||
deviceId: string;
|
||||
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.
|
||||
|
||||
export interface CreateDocumentVersion {
|
||||
/**
|
||||
* The client can decide the document id (if it wishes to) in order
|
||||
* to help with syncing. If the client does not provide a document id,
|
||||
* the server will generate one. If the client provides a document id
|
||||
* it must not already exist in the database.
|
||||
*/
|
||||
document_id: string | null;
|
||||
relative_path: string;
|
||||
content: number[];
|
||||
/**
|
||||
* The client can decide the document id (if it wishes to) in order
|
||||
* to help with syncing. If the client does not provide a document id,
|
||||
* the server will generate one. If the client provides a document id
|
||||
* it must not already exist in the database.
|
||||
*/
|
||||
document_id: string | null;
|
||||
relative_path: string;
|
||||
content: number[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
||||
|
||||
export interface CursorPositionFromClient {
|
||||
documentsWithCursors: DocumentWithCursors[];
|
||||
documentsWithCursors: DocumentWithCursors[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
import type { ClientCursors } from "./ClientCursors";
|
||||
|
||||
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.
|
||||
|
||||
export interface CursorSpan {
|
||||
start: number;
|
||||
end: number;
|
||||
start: 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.
|
||||
|
||||
export interface DeleteDocumentVersion {
|
||||
relativePath: string;
|
||||
relativePath: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,5 +6,5 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
|
|||
* Response to an update document request.
|
||||
*/
|
||||
export type DocumentUpdateResponse =
|
||||
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
|
||||
| ({ type: "MergingUpdate" } & DocumentVersion);
|
||||
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
|
||||
| ({ 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.
|
||||
|
||||
export interface DocumentVersion {
|
||||
vaultUpdateId: number;
|
||||
documentId: string;
|
||||
relativePath: string;
|
||||
updatedDate: string;
|
||||
contentBase64: string;
|
||||
isDeleted: boolean;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
vaultUpdateId: number;
|
||||
documentId: string;
|
||||
relativePath: string;
|
||||
updatedDate: string;
|
||||
contentBase64: string;
|
||||
isDeleted: boolean;
|
||||
userId: 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.
|
||||
|
||||
export interface DocumentVersionWithoutContent {
|
||||
vaultUpdateId: number;
|
||||
documentId: string;
|
||||
relativePath: string;
|
||||
updatedDate: string;
|
||||
isDeleted: boolean;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
contentSize: number;
|
||||
vaultUpdateId: number;
|
||||
documentId: string;
|
||||
relativePath: string;
|
||||
updatedDate: string;
|
||||
isDeleted: boolean;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
contentSize: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
import type { CursorSpan } from "./CursorSpan";
|
||||
|
||||
export interface DocumentWithCursors {
|
||||
vault_update_id: number | null;
|
||||
document_id: string;
|
||||
relative_path: string;
|
||||
cursors: CursorSpan[];
|
||||
vault_update_id: number | null;
|
||||
document_id: string;
|
||||
relative_path: string;
|
||||
cursors: CursorSpan[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
|
|||
* Response to a fetch latest documents request.
|
||||
*/
|
||||
export interface FetchLatestDocumentsResponse {
|
||||
latestDocuments: DocumentVersionWithoutContent[];
|
||||
/**
|
||||
* The update ID of the latest document in the response.
|
||||
*/
|
||||
lastUpdateId: bigint;
|
||||
latestDocuments: DocumentVersionWithoutContent[];
|
||||
/**
|
||||
* The update ID of the latest document in the response.
|
||||
*/
|
||||
lastUpdateId: bigint;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,22 +4,22 @@
|
|||
* Response to a ping request.
|
||||
*/
|
||||
export interface PingResponse {
|
||||
/**
|
||||
* Semantic version of the server.
|
||||
*/
|
||||
serverVersion: string;
|
||||
/**
|
||||
* Whether the client is authenticated based on the sent Authorization
|
||||
* header.
|
||||
*/
|
||||
isAuthenticated: boolean;
|
||||
/**
|
||||
* List of file extensions that are allowed to be merged.
|
||||
*/
|
||||
mergeableFileExtensions: string[];
|
||||
/**
|
||||
* API version ensuring backwards & forwards compatibility between the client
|
||||
* and server.
|
||||
*/
|
||||
supportedApiVersion: number;
|
||||
/**
|
||||
* Semantic version of the server.
|
||||
*/
|
||||
serverVersion: string;
|
||||
/**
|
||||
* Whether the client is authenticated based on the sent Authorization
|
||||
* header.
|
||||
*/
|
||||
isAuthenticated: boolean;
|
||||
/**
|
||||
* List of file extensions that are allowed to be merged.
|
||||
*/
|
||||
mergeableFileExtensions: string[];
|
||||
/**
|
||||
* API version ensuring backwards & forwards compatibility between the client
|
||||
* and server.
|
||||
*/
|
||||
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.
|
||||
|
||||
export interface SerializedError {
|
||||
errorType: string;
|
||||
message: string;
|
||||
causes: string[];
|
||||
errorType: string;
|
||||
message: 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.
|
||||
|
||||
export interface UpdateDocumentVersion {
|
||||
parent_version_id: bigint;
|
||||
relative_path: string;
|
||||
content: number[];
|
||||
parent_version_id: bigint;
|
||||
relative_path: string;
|
||||
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.
|
||||
|
||||
export interface UpdateTextDocumentVersion {
|
||||
parentVersionId: number;
|
||||
relativePath: string;
|
||||
content: (number | string)[];
|
||||
parentVersionId: number;
|
||||
relativePath: string;
|
||||
content: (number | string)[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,5 +3,5 @@ import type { CursorPositionFromClient } from "./CursorPositionFromClient";
|
|||
import type { WebSocketHandshake } from "./WebSocketHandshake";
|
||||
|
||||
export type WebSocketClientMessage =
|
||||
| ({ type: "handshake" } & WebSocketHandshake)
|
||||
| ({ type: "cursorPositions" } & CursorPositionFromClient);
|
||||
| ({ type: "handshake" } & WebSocketHandshake)
|
||||
| ({ 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.
|
||||
|
||||
export interface WebSocketHandshake {
|
||||
token: string;
|
||||
deviceId: string;
|
||||
lastSeenVaultUpdateId: number | null;
|
||||
token: string;
|
||||
deviceId: string;
|
||||
lastSeenVaultUpdateId: number | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,5 +3,5 @@ import type { CursorPositionFromServer } from "./CursorPositionFromServer";
|
|||
import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate";
|
||||
|
||||
export type WebSocketServerMessage =
|
||||
| ({ type: "vaultUpdate" } & WebSocketVaultUpdate)
|
||||
| ({ type: "cursorPositions" } & CursorPositionFromServer);
|
||||
| ({ type: "vaultUpdate" } & WebSocketVaultUpdate)
|
||||
| ({ type: "cursorPositions" } & CursorPositionFromServer);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@
|
|||
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
|
||||
|
||||
export interface WebSocketVaultUpdate {
|
||||
documents: DocumentVersionWithoutContent[];
|
||||
isInitialSync: boolean;
|
||||
documents: DocumentVersionWithoutContent[];
|
||||
isInitialSync: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,291 +8,291 @@ import type { Settings } from "../persistence/settings";
|
|||
const WebSocket = require("ws") as typeof globalThis.WebSocket;
|
||||
|
||||
class MockCloseEvent extends Event {
|
||||
public code: number;
|
||||
public reason: string;
|
||||
public code: number;
|
||||
public reason: string;
|
||||
|
||||
public constructor(
|
||||
type: string,
|
||||
options: { code: number; reason: string }
|
||||
) {
|
||||
super(type);
|
||||
this.code = options.code;
|
||||
this.reason = options.reason;
|
||||
}
|
||||
public constructor(
|
||||
type: string,
|
||||
options: { code: number; reason: string }
|
||||
) {
|
||||
super(type);
|
||||
this.code = options.code;
|
||||
this.reason = options.reason;
|
||||
}
|
||||
}
|
||||
|
||||
class MockMessageEvent extends Event {
|
||||
public data: string;
|
||||
public data: string;
|
||||
|
||||
public constructor(type: string, options: { data: string }) {
|
||||
super(type);
|
||||
this.data = options.data;
|
||||
}
|
||||
public constructor(type: string, options: { data: string }) {
|
||||
super(type);
|
||||
this.data = options.data;
|
||||
}
|
||||
}
|
||||
|
||||
class MockWebSocket {
|
||||
public readyState: number = WebSocket.CONNECTING;
|
||||
public onopen: ((event: Event) => void) | null = null;
|
||||
public onclose: ((event: MockCloseEvent) => void) | null = null;
|
||||
public onmessage: ((event: MockMessageEvent) => void) | null = null;
|
||||
public onerror: ((event: Event) => void) | null = null;
|
||||
public readyState: number = WebSocket.CONNECTING;
|
||||
public onopen: ((event: Event) => void) | null = null;
|
||||
public onclose: ((event: MockCloseEvent) => void) | null = null;
|
||||
public onmessage: ((event: MockMessageEvent) => void) | null = null;
|
||||
public onerror: ((event: Event) => void) | null = null;
|
||||
|
||||
public sentMessages: string[] = [];
|
||||
public sentMessages: string[] = [];
|
||||
|
||||
public constructor(public url: string) {
|
||||
setTimeout(() => {
|
||||
if (this.readyState === WebSocket.CONNECTING) {
|
||||
this.readyState = WebSocket.OPEN;
|
||||
this.onopen?.(new Event("open"));
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
public constructor(public url: string) {
|
||||
setTimeout(() => {
|
||||
if (this.readyState === WebSocket.CONNECTING) {
|
||||
this.readyState = WebSocket.OPEN;
|
||||
this.onopen?.(new Event("open"));
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
public send(data: string): void {
|
||||
if (this.readyState !== WebSocket.OPEN) {
|
||||
throw new Error("WebSocket is not open");
|
||||
}
|
||||
this.sentMessages.push(data);
|
||||
}
|
||||
public send(data: string): void {
|
||||
if (this.readyState !== WebSocket.OPEN) {
|
||||
throw new Error("WebSocket is not open");
|
||||
}
|
||||
this.sentMessages.push(data);
|
||||
}
|
||||
|
||||
public close(code?: number, reason?: string): void {
|
||||
this.readyState = WebSocket.CLOSED;
|
||||
this.onclose?.(
|
||||
new MockCloseEvent("close", {
|
||||
code: code ?? 1000,
|
||||
reason: reason ?? ""
|
||||
})
|
||||
);
|
||||
}
|
||||
public close(code?: number, reason?: string): void {
|
||||
this.readyState = WebSocket.CLOSED;
|
||||
this.onclose?.(
|
||||
new MockCloseEvent("close", {
|
||||
code: code ?? 1000,
|
||||
reason: reason ?? ""
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public simulateMessage(data: unknown): void {
|
||||
this.onmessage?.(
|
||||
new MockMessageEvent("message", { data: JSON.stringify(data) })
|
||||
);
|
||||
}
|
||||
public simulateMessage(data: unknown): void {
|
||||
this.onmessage?.(
|
||||
new MockMessageEvent("message", { data: JSON.stringify(data) })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type MockFn<T extends (...args: unknown[]) => unknown> = T & {
|
||||
calls: Parameters<T>[];
|
||||
calls: Parameters<T>[];
|
||||
};
|
||||
|
||||
function createMockFn<T extends (...args: unknown[]) => unknown>(
|
||||
implementation?: T
|
||||
implementation?: T
|
||||
): MockFn<T> {
|
||||
const calls: Parameters<T>[] = [];
|
||||
const mockFn = ((...args: Parameters<T>) => {
|
||||
calls.push(args);
|
||||
return implementation?.(...args);
|
||||
}) as unknown as MockFn<T>;
|
||||
mockFn.calls = calls;
|
||||
return mockFn;
|
||||
const calls: Parameters<T>[] = [];
|
||||
const mockFn = ((...args: Parameters<T>) => {
|
||||
calls.push(args);
|
||||
return implementation?.(...args);
|
||||
}) as unknown as MockFn<T>;
|
||||
mockFn.calls = calls;
|
||||
return mockFn;
|
||||
}
|
||||
|
||||
describe("WebSocketManager", () => {
|
||||
let mockLogger: Logger = undefined as unknown as Logger;
|
||||
let mockSettings: Settings = undefined as unknown as Settings;
|
||||
let deviceId = "test-device-123";
|
||||
let mockLogger: Logger = undefined as unknown as Logger;
|
||||
let mockSettings: Settings = undefined as unknown as Settings;
|
||||
let deviceId = "test-device-123";
|
||||
|
||||
beforeEach(() => {
|
||||
deviceId = "test-device-123";
|
||||
const noop = (): void => {
|
||||
// Intentionally empty for mock
|
||||
};
|
||||
mockLogger = {
|
||||
info: createMockFn(noop),
|
||||
warn: createMockFn(noop),
|
||||
error: createMockFn(noop),
|
||||
debug: createMockFn(noop)
|
||||
} as unknown as Logger;
|
||||
beforeEach(() => {
|
||||
deviceId = "test-device-123";
|
||||
const noop = (): void => {
|
||||
// Intentionally empty for mock
|
||||
};
|
||||
mockLogger = {
|
||||
info: createMockFn(noop),
|
||||
warn: createMockFn(noop),
|
||||
error: createMockFn(noop),
|
||||
debug: createMockFn(noop)
|
||||
} as unknown as Logger;
|
||||
|
||||
mockSettings = {
|
||||
getSettings: () => ({
|
||||
remoteUri: "https://example.com",
|
||||
vaultName: "test-vault",
|
||||
webSocketRetryIntervalMs: 1000
|
||||
})
|
||||
} as unknown as Settings;
|
||||
});
|
||||
mockSettings = {
|
||||
getSettings: () => ({
|
||||
remoteUri: "https://example.com",
|
||||
vaultName: "test-vault",
|
||||
webSocketRetryIntervalMs: 1000
|
||||
})
|
||||
} as unknown as Settings;
|
||||
});
|
||||
|
||||
it("cleans up promises after message handling", async () => {
|
||||
const manager = new WebSocketManager(
|
||||
deviceId,
|
||||
mockLogger,
|
||||
mockSettings,
|
||||
MockWebSocket as unknown as typeof WebSocket
|
||||
);
|
||||
it("cleans up promises after message handling", async () => {
|
||||
const manager = new WebSocketManager(
|
||||
deviceId,
|
||||
mockLogger,
|
||||
mockSettings,
|
||||
MockWebSocket as unknown as typeof WebSocket
|
||||
);
|
||||
|
||||
manager.addRemoteVaultUpdateListener(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
manager.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
manager.onRemoteVaultUpdateReceived.add(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
manager.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const { outstandingPromises } = manager as unknown as {
|
||||
outstandingPromises: Promise<unknown>[];
|
||||
};
|
||||
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||
.webSocket;
|
||||
const { outstandingPromises } = manager as unknown as {
|
||||
outstandingPromises: Promise<unknown>[];
|
||||
};
|
||||
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||
.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);
|
||||
await manager.stop();
|
||||
});
|
||||
assert.strictEqual(outstandingPromises.length, 0);
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
it("cleans up cursor position promises", async () => {
|
||||
const manager = new WebSocketManager(
|
||||
deviceId,
|
||||
mockLogger,
|
||||
mockSettings,
|
||||
MockWebSocket as unknown as typeof WebSocket
|
||||
);
|
||||
it("cleans up cursor position promises", async () => {
|
||||
const manager = new WebSocketManager(
|
||||
deviceId,
|
||||
mockLogger,
|
||||
mockSettings,
|
||||
MockWebSocket as unknown as typeof WebSocket
|
||||
);
|
||||
|
||||
manager.addRemoteCursorsUpdateListener(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
manager.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
manager.onRemoteCursorsUpdateReceived.add(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
manager.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const { outstandingPromises } = manager as unknown as {
|
||||
outstandingPromises: Promise<unknown>[];
|
||||
};
|
||||
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||
.webSocket;
|
||||
const { outstandingPromises } = manager as unknown as {
|
||||
outstandingPromises: Promise<unknown>[];
|
||||
};
|
||||
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||
.webSocket;
|
||||
|
||||
mockWs.simulateMessage({
|
||||
type: "cursorPositions",
|
||||
clients: [{ deviceId: "other-device", cursors: [] }]
|
||||
});
|
||||
mockWs.simulateMessage({
|
||||
type: "cursorPositions",
|
||||
clients: [{ deviceId: "other-device", cursors: [] }]
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
assert.strictEqual(outstandingPromises.length, 0);
|
||||
await manager.stop();
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
assert.strictEqual(outstandingPromises.length, 0);
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
it("logs handshake send errors", async () => {
|
||||
const manager = new WebSocketManager(
|
||||
deviceId,
|
||||
mockLogger,
|
||||
mockSettings,
|
||||
MockWebSocket as unknown as typeof WebSocket
|
||||
);
|
||||
it("logs handshake send errors", async () => {
|
||||
const manager = new WebSocketManager(
|
||||
deviceId,
|
||||
mockLogger,
|
||||
mockSettings,
|
||||
MockWebSocket as unknown as typeof WebSocket
|
||||
);
|
||||
|
||||
manager.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
manager.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||
.webSocket;
|
||||
mockWs.send = (): void => {
|
||||
throw new Error("Buffer full");
|
||||
};
|
||||
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||
.webSocket;
|
||||
mockWs.send = (): void => {
|
||||
throw new Error("Buffer full");
|
||||
};
|
||||
|
||||
assert.throws(() => {
|
||||
manager.sendHandshakeMessage({
|
||||
type: "handshake",
|
||||
token: "test",
|
||||
deviceId: "test",
|
||||
lastSeenVaultUpdateId: null
|
||||
});
|
||||
});
|
||||
assert.throws(() => {
|
||||
manager.sendHandshakeMessage({
|
||||
type: "handshake",
|
||||
token: "test",
|
||||
deviceId: "test",
|
||||
lastSeenVaultUpdateId: null
|
||||
});
|
||||
});
|
||||
|
||||
await manager.stop();
|
||||
});
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
it("completes stop with timeout protection", async () => {
|
||||
const manager = new WebSocketManager(
|
||||
deviceId,
|
||||
mockLogger,
|
||||
mockSettings,
|
||||
MockWebSocket as unknown as typeof WebSocket
|
||||
);
|
||||
it("completes stop with timeout protection", async () => {
|
||||
const manager = new WebSocketManager(
|
||||
deviceId,
|
||||
mockLogger,
|
||||
mockSettings,
|
||||
MockWebSocket as unknown as typeof WebSocket
|
||||
);
|
||||
|
||||
manager.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
manager.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
await manager.stop();
|
||||
assert.ok(true);
|
||||
});
|
||||
await manager.stop();
|
||||
assert.ok(true);
|
||||
});
|
||||
|
||||
it("clears old handlers on reconnection", async () => {
|
||||
const manager = new WebSocketManager(
|
||||
deviceId,
|
||||
mockLogger,
|
||||
mockSettings,
|
||||
MockWebSocket as unknown as typeof WebSocket
|
||||
);
|
||||
it("clears old handlers on reconnection", async () => {
|
||||
const manager = new WebSocketManager(
|
||||
deviceId,
|
||||
mockLogger,
|
||||
mockSettings,
|
||||
MockWebSocket as unknown as typeof WebSocket
|
||||
);
|
||||
|
||||
let statusChangeCount = 0;
|
||||
manager.addWebSocketStatusChangeListener(() => {
|
||||
statusChangeCount++;
|
||||
});
|
||||
let statusChangeCount = 0;
|
||||
manager.onWebSocketStatusChanged.add(() => {
|
||||
statusChangeCount++;
|
||||
});
|
||||
|
||||
manager.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
manager.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const firstWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||
.webSocket;
|
||||
const firstWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||
.webSocket;
|
||||
|
||||
statusChangeCount = 0;
|
||||
statusChangeCount = 0;
|
||||
|
||||
(
|
||||
manager as unknown as { initializeWebSocket: () => void }
|
||||
).initializeWebSocket();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
(
|
||||
manager as unknown as { initializeWebSocket: () => void }
|
||||
).initializeWebSocket();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
statusChangeCount = 0;
|
||||
statusChangeCount = 0;
|
||||
|
||||
// Old handler should be cleared
|
||||
firstWs.onclose?.(
|
||||
new MockCloseEvent("close", { code: 1000, reason: "test" })
|
||||
);
|
||||
// Old handler should be cleared
|
||||
firstWs.onclose?.(
|
||||
new MockCloseEvent("close", { code: 1000, reason: "test" })
|
||||
);
|
||||
|
||||
assert.strictEqual(statusChangeCount, 0);
|
||||
await manager.stop();
|
||||
});
|
||||
assert.strictEqual(statusChangeCount, 0);
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
it("tracks message handling promises", async () => {
|
||||
const manager = new WebSocketManager(
|
||||
deviceId,
|
||||
mockLogger,
|
||||
mockSettings,
|
||||
MockWebSocket as unknown as typeof WebSocket
|
||||
);
|
||||
it("tracks message handling promises", async () => {
|
||||
const manager = new WebSocketManager(
|
||||
deviceId,
|
||||
mockLogger,
|
||||
mockSettings,
|
||||
MockWebSocket as unknown as typeof WebSocket
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations
|
||||
let resolveListener: () => void;
|
||||
const listenerPromise = new Promise<void>((resolve) => {
|
||||
resolveListener = resolve;
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations
|
||||
let resolveListener: () => void;
|
||||
const listenerPromise = new Promise<void>((resolve) => {
|
||||
resolveListener = resolve;
|
||||
});
|
||||
|
||||
manager.addRemoteVaultUpdateListener(async () => {
|
||||
await listenerPromise;
|
||||
});
|
||||
manager.onRemoteVaultUpdateReceived.add(async () => {
|
||||
await listenerPromise;
|
||||
});
|
||||
|
||||
manager.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
manager.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||
.webSocket;
|
||||
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
||||
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||
.webSocket;
|
||||
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 {
|
||||
outstandingPromises: Promise<unknown>[];
|
||||
};
|
||||
const { outstandingPromises } = manager as unknown as {
|
||||
outstandingPromises: Promise<unknown>[];
|
||||
};
|
||||
|
||||
assert.ok(outstandingPromises.length > 0);
|
||||
assert.ok(outstandingPromises.length > 0);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
resolveListener!();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
resolveListener!();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
assert.strictEqual(outstandingPromises.length, 0);
|
||||
await manager.stop();
|
||||
});
|
||||
assert.strictEqual(outstandingPromises.length, 0);
|
||||
await manager.stop();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,296 +6,260 @@ import type { CursorPositionFromClient } from "./types/CursorPositionFromClient"
|
|||
import type { ClientCursors } from "./types/ClientCursors";
|
||||
import { createPromise } from "../utils/create-promise";
|
||||
import type { WebSocketVaultUpdate } from "./types/WebSocketVaultUpdate";
|
||||
import { awaitAll } from "../utils/await-all";
|
||||
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 {
|
||||
private readonly webSocketStatusChangeListeners: ((
|
||||
isConnected: boolean
|
||||
) => unknown)[] = [];
|
||||
public readonly onWebSocketStatusChanged = new EventListeners<
|
||||
(isConnected: boolean) => unknown
|
||||
>();
|
||||
|
||||
private readonly remoteVaultUpdateListeners: ((
|
||||
update: WebSocketVaultUpdate
|
||||
) => Promise<void>)[] = [];
|
||||
public readonly onRemoteVaultUpdateReceived = new EventListeners<
|
||||
(update: WebSocketVaultUpdate) => Promise<void>
|
||||
>();
|
||||
|
||||
private readonly remoteCursorsUpdateListeners: ((
|
||||
cursors: ClientCursors[]
|
||||
) => Promise<void>)[] = [];
|
||||
public readonly onRemoteCursorsUpdateReceived = new EventListeners<
|
||||
(cursors: ClientCursors[]) => Promise<void>
|
||||
>();
|
||||
|
||||
private isStopped = true;
|
||||
private resolveDisconnectingPromise: null | (() => unknown) = null;
|
||||
private reconnectTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
private isStopped = true;
|
||||
private resolveDisconnectingPromise: null | (() => unknown) = null;
|
||||
private reconnectTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
private readonly outstandingPromises: Promise<unknown>[] = [];
|
||||
private readonly outstandingPromises: Promise<unknown>[] = [];
|
||||
|
||||
private webSocket: WebSocket | undefined;
|
||||
private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket;
|
||||
private webSocket: WebSocket | undefined;
|
||||
private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket;
|
||||
|
||||
public constructor(
|
||||
private readonly deviceId: string,
|
||||
private readonly logger: Logger,
|
||||
private readonly settings: Settings,
|
||||
webSocketImplementation?: typeof globalThis.WebSocket
|
||||
) {
|
||||
if (webSocketImplementation) {
|
||||
this.webSocketFactoryImplementation = webSocketImplementation;
|
||||
} else {
|
||||
if (
|
||||
typeof globalThis !== "undefined" &&
|
||||
typeof globalThis.WebSocket === "undefined"
|
||||
) {
|
||||
// eslint-disable-next-line
|
||||
this.webSocketFactoryImplementation = require("ws"); // polyfill for WebSocket in Node.js
|
||||
} else {
|
||||
this.webSocketFactoryImplementation = WebSocket;
|
||||
}
|
||||
}
|
||||
}
|
||||
public constructor(
|
||||
private readonly deviceId: string,
|
||||
private readonly logger: Logger,
|
||||
private readonly settings: Settings,
|
||||
webSocketImplementation?: typeof globalThis.WebSocket
|
||||
) {
|
||||
if (webSocketImplementation) {
|
||||
this.webSocketFactoryImplementation = webSocketImplementation;
|
||||
} else {
|
||||
if (
|
||||
typeof globalThis !== "undefined" &&
|
||||
typeof globalThis.WebSocket === "undefined"
|
||||
) {
|
||||
// eslint-disable-next-line
|
||||
this.webSocketFactoryImplementation = require("ws"); // polyfill for WebSocket in Node.js
|
||||
} else {
|
||||
this.webSocketFactoryImplementation = WebSocket;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public get isWebSocketConnected(): boolean {
|
||||
return (
|
||||
this.webSocket?.readyState ===
|
||||
this.webSocketFactoryImplementation.OPEN
|
||||
);
|
||||
}
|
||||
public get isWebSocketConnected(): boolean {
|
||||
return (
|
||||
this.webSocket?.readyState ===
|
||||
this.webSocketFactoryImplementation.OPEN
|
||||
);
|
||||
}
|
||||
|
||||
public addWebSocketStatusChangeListener(
|
||||
listener: (isConnected: boolean) => unknown
|
||||
): void {
|
||||
this.webSocketStatusChangeListeners.push(listener);
|
||||
}
|
||||
public start(): void {
|
||||
this.isStopped = false;
|
||||
this.initializeWebSocket();
|
||||
}
|
||||
|
||||
public addRemoteCursorsUpdateListener(
|
||||
listener: (cursors: ClientCursors[]) => Promise<void>
|
||||
): void {
|
||||
this.remoteCursorsUpdateListeners.push(listener);
|
||||
}
|
||||
public async stop(): Promise<void> {
|
||||
const [promise, resolve] = createPromise();
|
||||
this.resolveDisconnectingPromise = resolve;
|
||||
|
||||
public addRemoteVaultUpdateListener(
|
||||
listener: (update: WebSocketVaultUpdate) => Promise<void>
|
||||
): void {
|
||||
this.remoteVaultUpdateListeners.push(listener);
|
||||
}
|
||||
this.isStopped = true;
|
||||
|
||||
public start(): void {
|
||||
this.isStopped = false;
|
||||
this.initializeWebSocket();
|
||||
}
|
||||
if (this.reconnectTimeoutId !== undefined) {
|
||||
clearTimeout(this.reconnectTimeoutId);
|
||||
this.reconnectTimeoutId = undefined;
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
const [promise, resolve] = createPromise();
|
||||
this.resolveDisconnectingPromise = resolve;
|
||||
this.webSocket?.close(1000, "WebSocketManager has been stopped");
|
||||
|
||||
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) {
|
||||
clearTimeout(this.reconnectTimeoutId);
|
||||
this.reconnectTimeoutId = undefined;
|
||||
}
|
||||
try {
|
||||
while (this.isWebSocketConnected) {
|
||||
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
|
||||
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);
|
||||
});
|
||||
public async waitUntilFinished(): Promise<void> {
|
||||
await awaitAll(this.outstandingPromises);
|
||||
}
|
||||
|
||||
try {
|
||||
while (this.isWebSocketConnected) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
public sendHandshakeMessage(
|
||||
message: WebSocketClientMessage & { type: "handshake" }
|
||||
): void {
|
||||
const { webSocket } = this;
|
||||
if (!webSocket) {
|
||||
throw new Error(
|
||||
"WebSocket is not connected, cannot send handshake message"
|
||||
);
|
||||
}
|
||||
|
||||
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> {
|
||||
await awaitAll(this.outstandingPromises);
|
||||
}
|
||||
public updateLocalCursors(cursorPositions: CursorPositionFromClient): void {
|
||||
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(
|
||||
message: WebSocketClientMessage & { type: "handshake" }
|
||||
): void {
|
||||
const { webSocket } = this;
|
||||
if (!webSocket) {
|
||||
throw new Error(
|
||||
"WebSocket is not connected, cannot send handshake message"
|
||||
);
|
||||
}
|
||||
const message: WebSocketClientMessage = {
|
||||
type: "cursorPositions",
|
||||
...cursorPositions
|
||||
};
|
||||
|
||||
try {
|
||||
webSocket.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to send handshake message: ${String(error)}`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
try {
|
||||
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)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public updateLocalCursors(cursorPositions: CursorPositionFromClient): void {
|
||||
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;
|
||||
}
|
||||
private initializeWebSocket(): void {
|
||||
// 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 message: WebSocketClientMessage = {
|
||||
type: "cursorPositions",
|
||||
...cursorPositions
|
||||
};
|
||||
const wsUri = new URL(this.settings.getSettings().remoteUri);
|
||||
wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws";
|
||||
wsUri.pathname = `/vaults/${this.settings.getSettings().vaultName}/ws`;
|
||||
|
||||
try {
|
||||
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)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
this.logger.info(`Connecting to WebSocket at ${wsUri.toString()}`);
|
||||
|
||||
private initializeWebSocket(): void {
|
||||
// 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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
this.webSocket = new this.webSocketFactoryImplementation(wsUri);
|
||||
|
||||
const wsUri = new URL(this.settings.getSettings().remoteUri);
|
||||
wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws";
|
||||
wsUri.pathname = `/vaults/${this.settings.getSettings().vaultName}/ws`;
|
||||
this.webSocket.onopen = (): void => {
|
||||
this.logger.info("WebSocket connection opened");
|
||||
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 => {
|
||||
this.logger.info("WebSocket connection opened");
|
||||
this.webSocketStatusChangeListeners.forEach((listener) =>
|
||||
listener(true)
|
||||
);
|
||||
};
|
||||
void this.outstandingPromises.push(messageHandlingPromise); // ignore the returned promise
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Error parsing WebSocket message: ${String(error)}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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.onclose = (event): void => {
|
||||
this.logger.warn(
|
||||
`WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})`
|
||||
);
|
||||
this.onWebSocketStatusChanged.trigger(false);
|
||||
|
||||
// Track the message handling promise
|
||||
const messageHandlingPromise = this.handleWebSocketMessage(
|
||||
message
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
this.logger.error(
|
||||
`Error handling WebSocket message: ${String(error)}`
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
const index = this.outstandingPromises.indexOf(
|
||||
messageHandlingPromise
|
||||
);
|
||||
if (index !== -1) {
|
||||
void this.outstandingPromises.splice(index, 1); // ignore the returned promise
|
||||
}
|
||||
});
|
||||
if (this.isStopped) {
|
||||
this.resolveDisconnectingPromise?.();
|
||||
this.resolveDisconnectingPromise = null;
|
||||
} else {
|
||||
this.reconnectTimeoutId = setTimeout(() => {
|
||||
this.reconnectTimeoutId = undefined;
|
||||
this.initializeWebSocket();
|
||||
}, this.settings.getSettings().webSocketRetryIntervalMs);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
void this.outstandingPromises.push(messageHandlingPromise); // ignore the returned promise
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Error parsing WebSocket message: ${String(error)}`
|
||||
);
|
||||
}
|
||||
};
|
||||
private async handleWebSocketMessage(
|
||||
message: WebSocketServerMessage
|
||||
): Promise<void> {
|
||||
if (message.type === "vaultUpdate") {
|
||||
await this.onRemoteVaultUpdateReceived.triggerAsync(message);
|
||||
|
||||
this.webSocket.onclose = (event): void => {
|
||||
this.logger.warn(
|
||||
`WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})`
|
||||
);
|
||||
this.webSocketStatusChangeListeners.forEach((listener) =>
|
||||
listener(false)
|
||||
);
|
||||
// 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)}`
|
||||
);
|
||||
|
||||
if (this.isStopped) {
|
||||
this.resolveDisconnectingPromise?.();
|
||||
this.resolveDisconnectingPromise = null;
|
||||
} else {
|
||||
this.reconnectTimeoutId = setTimeout(() => {
|
||||
this.reconnectTimeoutId = undefined;
|
||||
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)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.onRemoteCursorsUpdateReceived.triggerAsync(
|
||||
message.clients
|
||||
);
|
||||
} 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 { DIFF_CACHE_SIZE_MB } from "./consts";
|
||||
import { ServerConfig } from "./services/server-config";
|
||||
import type { EventListeners } from "./utils/data-structures/event-listeners";
|
||||
|
||||
export class SyncClient {
|
||||
private hasStartedOfflineSync = false;
|
||||
private hasFinishedOfflineSync = false;
|
||||
private hasStarted = false;
|
||||
private hasBeenDestroyed = false;
|
||||
private unloadTelemetry?: () => void;
|
||||
|
||||
private constructor(
|
||||
private readonly history: SyncHistory,
|
||||
private readonly settings: Settings,
|
||||
private readonly database: Database,
|
||||
private readonly syncer: Syncer,
|
||||
private readonly webSocketManager: WebSocketManager,
|
||||
public readonly logger: Logger,
|
||||
private readonly fetchController: FetchController,
|
||||
private readonly cursorTracker: CursorTracker,
|
||||
private readonly fileChangeNotifier: FileChangeNotifier,
|
||||
private readonly contentCache: FixedSizeDocumentCache,
|
||||
private readonly fileOperations: FileOperations,
|
||||
private readonly serverConfig: ServerConfig,
|
||||
private readonly persistence: PersistenceProvider<
|
||||
Partial<{
|
||||
settings: Partial<SyncSettings>;
|
||||
database: Partial<StoredDatabase>;
|
||||
}>
|
||||
>
|
||||
) {}
|
||||
|
||||
public get documentCount(): number {
|
||||
return this.database.length;
|
||||
}
|
||||
|
||||
public get isWebSocketConnected(): boolean {
|
||||
return this.webSocketManager.isWebSocketConnected;
|
||||
}
|
||||
public static async create({
|
||||
fs,
|
||||
persistence,
|
||||
fetch,
|
||||
webSocket,
|
||||
nativeLineEndings = "\n"
|
||||
}: {
|
||||
fs: FileSystemOperations;
|
||||
persistence: PersistenceProvider<
|
||||
Partial<{
|
||||
settings: Partial<SyncSettings>;
|
||||
database: Partial<StoredDatabase>;
|
||||
}>
|
||||
>;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
webSocket?: typeof globalThis.WebSocket;
|
||||
nativeLineEndings?: string;
|
||||
}): Promise<SyncClient> {
|
||||
const logger = new Logger();
|
||||
|
||||
const deviceId = createClientId();
|
||||
|
||||
logger.info(`Creating SyncClient with client id ${deviceId}`);
|
||||
|
||||
const history = new SyncHistory(logger);
|
||||
|
||||
let state = (await persistence.load()) ?? {
|
||||
settings: undefined,
|
||||
database: undefined
|
||||
};
|
||||
|
||||
const settings = new Settings(
|
||||
logger,
|
||||
state.settings,
|
||||
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
|
||||
// and (2) settings changes are infrequent enough that rate-limiting is not necessary
|
||||
await persistence.save(state);
|
||||
}
|
||||
);
|
||||
|
||||
const rateLimitedSave = rateLimit(
|
||||
persistence.save,
|
||||
() => settings.getSettings().minimumSaveIntervalMs
|
||||
);
|
||||
|
||||
const database = new Database(
|
||||
logger,
|
||||
state.database,
|
||||
async (data): Promise<void> => {
|
||||
state = { ...state, database: data };
|
||||
await rateLimitedSave(state);
|
||||
}
|
||||
);
|
||||
|
||||
const fetchController = new FetchController(
|
||||
settings.getSettings().isSyncEnabled,
|
||||
logger
|
||||
);
|
||||
settings.addOnSettingsChangeListener((newSettings, oldSettings) => {
|
||||
if (oldSettings.isSyncEnabled != newSettings.isSyncEnabled) {
|
||||
fetchController.canFetch = newSettings.isSyncEnabled;
|
||||
}
|
||||
});
|
||||
|
||||
const syncService = new SyncService(
|
||||
deviceId,
|
||||
fetchController,
|
||||
settings,
|
||||
logger,
|
||||
fetch
|
||||
);
|
||||
|
||||
const serverConfig = new ServerConfig(syncService);
|
||||
|
||||
const fileOperations = new FileOperations(
|
||||
logger,
|
||||
database,
|
||||
fs,
|
||||
serverConfig,
|
||||
nativeLineEndings
|
||||
);
|
||||
|
||||
const contentCache = new FixedSizeDocumentCache(
|
||||
1024 * 1024 * DIFF_CACHE_SIZE_MB
|
||||
);
|
||||
const unrestrictedSyncer = new UnrestrictedSyncer(
|
||||
logger,
|
||||
database,
|
||||
settings,
|
||||
syncService,
|
||||
fileOperations,
|
||||
history,
|
||||
contentCache,
|
||||
serverConfig
|
||||
);
|
||||
|
||||
const webSocketManager = new WebSocketManager(
|
||||
deviceId,
|
||||
logger,
|
||||
settings,
|
||||
webSocket
|
||||
);
|
||||
|
||||
const syncer = new Syncer(
|
||||
deviceId,
|
||||
logger,
|
||||
database,
|
||||
settings,
|
||||
syncService,
|
||||
webSocketManager,
|
||||
fileOperations,
|
||||
unrestrictedSyncer
|
||||
);
|
||||
|
||||
const fileChangeNotifier = new FileChangeNotifier();
|
||||
const cursorTracker = new CursorTracker(
|
||||
database,
|
||||
webSocketManager,
|
||||
fileOperations,
|
||||
fileChangeNotifier
|
||||
);
|
||||
const client = new SyncClient(
|
||||
history,
|
||||
settings,
|
||||
database,
|
||||
syncer,
|
||||
webSocketManager,
|
||||
logger,
|
||||
fetchController,
|
||||
cursorTracker,
|
||||
fileChangeNotifier,
|
||||
contentCache,
|
||||
fileOperations,
|
||||
serverConfig,
|
||||
persistence
|
||||
);
|
||||
|
||||
logger.info("SyncClient created successfully");
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
this.checkIfDestroyed("start");
|
||||
|
||||
if (this.hasStarted) {
|
||||
throw new Error("SyncClient has already been started");
|
||||
}
|
||||
this.hasStarted = true;
|
||||
|
||||
if (
|
||||
!this.unloadTelemetry &&
|
||||
this.settings.getSettings().enableTelemetry
|
||||
) {
|
||||
this.unloadTelemetry = setUpTelemetry();
|
||||
}
|
||||
|
||||
this.logger.addOnMessageListener((log): void => {
|
||||
if (log.level === LogLevel.ERROR && Sentry.isInitialized()) {
|
||||
Sentry.captureMessage(log.message);
|
||||
}
|
||||
});
|
||||
|
||||
this.settings.addOnSettingsChangeListener(
|
||||
this.onSettingsChange.bind(this)
|
||||
);
|
||||
|
||||
if (this.settings.getSettings().isSyncEnabled) {
|
||||
this.logger.info("Starting SyncClient");
|
||||
await this.startSyncing();
|
||||
this.logger.info("SyncClient has successfully started");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload settings from disk overriding current in-memory settings.
|
||||
* Missing values will be filled in from DEFAULT_SETTINGS rather than
|
||||
* retaining current in-memory settings.
|
||||
*/
|
||||
public async reloadSettings(): Promise<void> {
|
||||
this.checkIfDestroyed("reloadSettings");
|
||||
|
||||
const state = (await this.persistence.load()) ?? {
|
||||
settings: undefined
|
||||
};
|
||||
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...(state.settings ?? {})
|
||||
};
|
||||
|
||||
await this.setSettings(settings);
|
||||
}
|
||||
|
||||
public async checkConnection(): Promise<NetworkConnectionStatus> {
|
||||
this.checkIfDestroyed("checkConnection");
|
||||
|
||||
const server = await this.serverConfig.checkConnection(true);
|
||||
return {
|
||||
isSuccessful: server.isSuccessful,
|
||||
serverMessage: server.message,
|
||||
isWebSocketConnected: this.webSocketManager.isWebSocketConnected
|
||||
};
|
||||
}
|
||||
|
||||
public getHistoryEntries(): readonly HistoryEntry[] {
|
||||
return this.history.entries;
|
||||
}
|
||||
|
||||
public addSyncHistoryUpdateListener(
|
||||
listener: (stats: HistoryStats) => unknown
|
||||
): void {
|
||||
this.checkIfDestroyed("addSyncHistoryUpdateListener");
|
||||
|
||||
this.history.addSyncHistoryUpdateListener(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the in-flight operations to finish, reset all tracking,
|
||||
* and the local database but retain the settings.
|
||||
* The SyncClient can be used again after calling this method.
|
||||
*/
|
||||
public async reset(): Promise<void> {
|
||||
this.checkIfDestroyed("reset");
|
||||
|
||||
this.logger.info(
|
||||
"Stopping SyncClient to apply changed connection settings"
|
||||
);
|
||||
await this.pause();
|
||||
|
||||
// clear all local state
|
||||
this.logger.info("Resetting SyncClient's local state");
|
||||
this.database.reset();
|
||||
await this.database.save(); // ensure the new database reads as empty
|
||||
this.resetInMemoryState();
|
||||
this.hasStartedOfflineSync = false;
|
||||
this.hasFinishedOfflineSync = false;
|
||||
this.serverConfig.reset();
|
||||
|
||||
await this.startSyncing();
|
||||
}
|
||||
|
||||
public getSettings(): SyncSettings {
|
||||
return this.settings.getSettings();
|
||||
}
|
||||
|
||||
public async setSetting<T extends keyof SyncSettings>(
|
||||
key: T,
|
||||
value: SyncSettings[T]
|
||||
): Promise<void> {
|
||||
this.checkIfDestroyed("setSetting");
|
||||
|
||||
await this.settings.setSetting(key, value);
|
||||
}
|
||||
|
||||
public async setSettings(value: Partial<SyncSettings>): Promise<void> {
|
||||
this.checkIfDestroyed("setSettings");
|
||||
|
||||
await this.settings.setSettings(value);
|
||||
}
|
||||
|
||||
public addOnSettingsChangeListener(
|
||||
listener: (settings: SyncSettings, oldSettings: SyncSettings) => unknown
|
||||
): void {
|
||||
this.checkIfDestroyed("addOnSettingsChangeListener");
|
||||
|
||||
this.settings.addOnSettingsChangeListener(listener);
|
||||
}
|
||||
|
||||
public addRemainingSyncOperationsListener(
|
||||
listener: (remainingOperations: number) => unknown
|
||||
): void {
|
||||
this.checkIfDestroyed("addRemainingSyncOperationsListener");
|
||||
|
||||
this.syncer.addRemainingOperationsListener(listener);
|
||||
}
|
||||
|
||||
public addWebSocketStatusChangeListener(listener: () => unknown): void {
|
||||
this.checkIfDestroyed("addWebSocketStatusChangeListener");
|
||||
|
||||
this.webSocketManager.addWebSocketStatusChangeListener(listener);
|
||||
}
|
||||
|
||||
public async syncLocallyCreatedFile(
|
||||
relativePath: RelativePath
|
||||
): Promise<void> {
|
||||
this.checkIfDestroyed("syncLocallyCreatedFile");
|
||||
|
||||
this.fileChangeNotifier.notifyOfFileChange(relativePath);
|
||||
return this.syncer.syncLocallyCreatedFile(relativePath);
|
||||
}
|
||||
|
||||
public async syncLocallyDeletedFile(
|
||||
relativePath: RelativePath
|
||||
): Promise<void> {
|
||||
this.checkIfDestroyed("syncLocallyDeletedFile");
|
||||
|
||||
this.fileChangeNotifier.notifyOfFileChange(relativePath);
|
||||
return this.syncer.syncLocallyDeletedFile(relativePath);
|
||||
}
|
||||
|
||||
public async syncLocallyUpdatedFile({
|
||||
oldPath,
|
||||
relativePath
|
||||
}: {
|
||||
oldPath?: RelativePath;
|
||||
relativePath: RelativePath;
|
||||
}): Promise<void> {
|
||||
this.checkIfDestroyed("syncLocallyUpdatedFile");
|
||||
|
||||
this.fileChangeNotifier.notifyOfFileChange(relativePath);
|
||||
return this.syncer.syncLocallyUpdatedFile({
|
||||
oldPath,
|
||||
relativePath
|
||||
});
|
||||
}
|
||||
|
||||
public getDocumentSyncingStatus(
|
||||
relativePath: RelativePath
|
||||
): DocumentSyncStatus {
|
||||
this.checkIfDestroyed("getDocumentSyncingStatus");
|
||||
|
||||
if (!this.settings.getSettings().isSyncEnabled) {
|
||||
return DocumentSyncStatus.SYNCING_IS_DISABLED;
|
||||
}
|
||||
|
||||
if (!this.syncer.isFirstSyncComplete || !this.hasFinishedOfflineSync) {
|
||||
return DocumentSyncStatus.SYNCING;
|
||||
}
|
||||
|
||||
const document =
|
||||
this.database.getLatestDocumentByRelativePath(relativePath);
|
||||
if (document === undefined) {
|
||||
return DocumentSyncStatus.SYNCING;
|
||||
}
|
||||
return document.updates.length > 0
|
||||
? DocumentSyncStatus.SYNCING
|
||||
: DocumentSyncStatus.UP_TO_DATE;
|
||||
}
|
||||
|
||||
public async updateLocalCursors(
|
||||
documentToCursors: Record<RelativePath, CursorSpan[]>
|
||||
): Promise<void> {
|
||||
this.checkIfDestroyed("updateLocalCursors");
|
||||
|
||||
await this.cursorTracker.sendLocalCursorsToServer(documentToCursors);
|
||||
}
|
||||
|
||||
public addRemoteCursorsUpdateListener(
|
||||
listener: (cursors: MaybeOutdatedClientCursors[]) => unknown
|
||||
): void {
|
||||
this.checkIfDestroyed("addRemoteCursorsUpdateListener");
|
||||
|
||||
this.cursorTracker.addRemoteCursorsUpdateListener(listener);
|
||||
}
|
||||
|
||||
public async waitUntilFinished(): Promise<void> {
|
||||
this.checkIfDestroyed("waitUntilIdle");
|
||||
await this.syncer.waitUntilFinished();
|
||||
await this.webSocketManager.waitUntilFinished();
|
||||
await this.database.save(); // flush all changes to disk
|
||||
}
|
||||
|
||||
/**
|
||||
* Completely destroy the SyncClient, cancelling all in-progress operations.
|
||||
* After calling this method, the SyncClient cannot be used again.
|
||||
*/
|
||||
public async destroy(): Promise<void> {
|
||||
this.checkIfDestroyed("destroy");
|
||||
|
||||
// cancel everything that's in progress
|
||||
await this.pause();
|
||||
|
||||
this.hasBeenDestroyed = true;
|
||||
|
||||
this.resetInMemoryState();
|
||||
|
||||
this.logger.info("SyncClient has been successfully disposed");
|
||||
|
||||
this.unloadTelemetry?.();
|
||||
}
|
||||
|
||||
private async startSyncing(): Promise<void> {
|
||||
this.checkIfDestroyed("startSyncing");
|
||||
this.fetchController.finishReset();
|
||||
|
||||
await this.serverConfig.initialize();
|
||||
this.webSocketManager.start();
|
||||
|
||||
if (!this.hasStartedOfflineSync) {
|
||||
this.hasStartedOfflineSync = true;
|
||||
await this.syncer.scheduleSyncForOfflineChanges();
|
||||
}
|
||||
|
||||
this.hasFinishedOfflineSync = true;
|
||||
}
|
||||
|
||||
private async pause(): Promise<void> {
|
||||
this.fetchController.startReset();
|
||||
await this.webSocketManager.stop();
|
||||
await this.waitUntilFinished();
|
||||
}
|
||||
|
||||
private resetInMemoryState(): void {
|
||||
this.history.reset();
|
||||
this.contentCache.reset();
|
||||
// don't reset the logger
|
||||
this.cursorTracker.reset();
|
||||
this.syncer.reset();
|
||||
this.fileOperations.reset();
|
||||
}
|
||||
|
||||
private async onSettingsChange(
|
||||
newSettings: SyncSettings,
|
||||
oldSettings: SyncSettings
|
||||
): Promise<void> {
|
||||
this.checkIfDestroyed("onSettingsChange");
|
||||
|
||||
if (
|
||||
newSettings.vaultName !== oldSettings.vaultName ||
|
||||
newSettings.remoteUri !== oldSettings.remoteUri
|
||||
) {
|
||||
await this.reset();
|
||||
}
|
||||
|
||||
if (newSettings.isSyncEnabled !== oldSettings.isSyncEnabled) {
|
||||
if (newSettings.isSyncEnabled) {
|
||||
await this.startSyncing();
|
||||
} else {
|
||||
await this.pause();
|
||||
}
|
||||
}
|
||||
|
||||
if (newSettings.diffCacheSizeMB !== oldSettings.diffCacheSizeMB) {
|
||||
this.contentCache.resize(newSettings.diffCacheSizeMB * 1024 * 1024);
|
||||
}
|
||||
|
||||
if (newSettings.enableTelemetry !== oldSettings.enableTelemetry) {
|
||||
if (newSettings.enableTelemetry) {
|
||||
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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
private hasStartedOfflineSync = false;
|
||||
private hasFinishedOfflineSync = false;
|
||||
private hasStarted = false;
|
||||
private hasBeenDestroyed = false;
|
||||
private unloadTelemetry?: () => void;
|
||||
|
||||
private constructor(
|
||||
private readonly history: SyncHistory,
|
||||
private readonly settings: Settings,
|
||||
private readonly database: Database,
|
||||
private readonly syncer: Syncer,
|
||||
private readonly webSocketManager: WebSocketManager,
|
||||
public readonly logger: Logger,
|
||||
private readonly fetchController: FetchController,
|
||||
private readonly cursorTracker: CursorTracker,
|
||||
private readonly fileChangeNotifier: FileChangeNotifier,
|
||||
private readonly contentCache: FixedSizeDocumentCache,
|
||||
private readonly fileOperations: FileOperations,
|
||||
private readonly serverConfig: ServerConfig,
|
||||
private readonly persistence: PersistenceProvider<
|
||||
Partial<{
|
||||
settings: Partial<SyncSettings>;
|
||||
database: Partial<StoredDatabase>;
|
||||
}>
|
||||
>
|
||||
) {}
|
||||
|
||||
public get documentCount(): number {
|
||||
return this.database.length;
|
||||
}
|
||||
|
||||
public get isWebSocketConnected(): boolean {
|
||||
return this.webSocketManager.isWebSocketConnected;
|
||||
}
|
||||
|
||||
public get onSyncHistoryUpdated(): EventListeners<
|
||||
(stats: HistoryStats) => unknown
|
||||
> {
|
||||
this.checkIfDestroyed("onSyncHistoryUpdated getter");
|
||||
return this.history.onHistoryUpdated;
|
||||
}
|
||||
|
||||
public get onSettingsChanged(): EventListeners<
|
||||
(newSettings: SyncSettings, oldSettings: SyncSettings) => unknown
|
||||
> {
|
||||
this.checkIfDestroyed("onSettingsChanged getter");
|
||||
return this.settings.onSettingsChanged;
|
||||
}
|
||||
|
||||
public get onRemainingOperationsCountChanged(): EventListeners<
|
||||
(remainingOperationsCount: number) => unknown
|
||||
> {
|
||||
this.checkIfDestroyed("onRemainingOperationsCountChanged getter");
|
||||
return this.syncer.onRemainingOperationsCountChanged;
|
||||
}
|
||||
|
||||
public get onWebSocketStatusChanged(): EventListeners<
|
||||
(isConnected: boolean) => unknown
|
||||
> {
|
||||
this.checkIfDestroyed("onWebSocketStatusChanged getter");
|
||||
return this.webSocketManager.onWebSocketStatusChanged;
|
||||
}
|
||||
|
||||
public get onRemoteCursorsUpdated(): EventListeners<
|
||||
(cursors: MaybeOutdatedClientCursors[]) => unknown
|
||||
> {
|
||||
this.checkIfDestroyed("onRemoteCursorsUpdated getter");
|
||||
return this.cursorTracker.onRemoteCursorsUpdated;
|
||||
}
|
||||
|
||||
public static async create({
|
||||
fs,
|
||||
persistence,
|
||||
fetch,
|
||||
webSocket,
|
||||
nativeLineEndings = "\n"
|
||||
}: {
|
||||
fs: FileSystemOperations;
|
||||
persistence: PersistenceProvider<
|
||||
Partial<{
|
||||
settings: Partial<SyncSettings>;
|
||||
database: Partial<StoredDatabase>;
|
||||
}>
|
||||
>;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
webSocket?: typeof globalThis.WebSocket;
|
||||
nativeLineEndings?: string;
|
||||
}): Promise<SyncClient> {
|
||||
const logger = new Logger();
|
||||
|
||||
const deviceId = createClientId();
|
||||
|
||||
logger.info(`Creating SyncClient with client id ${deviceId}`);
|
||||
|
||||
const history = new SyncHistory(logger);
|
||||
|
||||
let state = (await persistence.load()) ?? {
|
||||
settings: undefined,
|
||||
database: undefined
|
||||
};
|
||||
|
||||
const settings = new Settings(
|
||||
logger,
|
||||
state.settings,
|
||||
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
|
||||
// and (2) settings changes are infrequent enough that rate-limiting is not necessary
|
||||
await persistence.save(state);
|
||||
}
|
||||
);
|
||||
|
||||
const rateLimitedSave = rateLimit(
|
||||
persistence.save,
|
||||
() => settings.getSettings().minimumSaveIntervalMs
|
||||
);
|
||||
|
||||
const database = new Database(
|
||||
logger,
|
||||
state.database,
|
||||
async (data): Promise<void> => {
|
||||
state = { ...state, database: data };
|
||||
await rateLimitedSave(state);
|
||||
}
|
||||
);
|
||||
|
||||
const fetchController = new FetchController(
|
||||
settings.getSettings().isSyncEnabled,
|
||||
logger
|
||||
);
|
||||
settings.onSettingsChanged.add((newSettings, oldSettings) => {
|
||||
if (oldSettings.isSyncEnabled != newSettings.isSyncEnabled) {
|
||||
fetchController.canFetch = newSettings.isSyncEnabled;
|
||||
}
|
||||
});
|
||||
|
||||
const syncService = new SyncService(
|
||||
deviceId,
|
||||
fetchController,
|
||||
settings,
|
||||
logger,
|
||||
fetch
|
||||
);
|
||||
|
||||
const serverConfig = new ServerConfig(syncService);
|
||||
|
||||
const fileOperations = new FileOperations(
|
||||
logger,
|
||||
database,
|
||||
fs,
|
||||
serverConfig,
|
||||
nativeLineEndings
|
||||
);
|
||||
|
||||
const contentCache = new FixedSizeDocumentCache(
|
||||
1024 * 1024 * DIFF_CACHE_SIZE_MB
|
||||
);
|
||||
const unrestrictedSyncer = new UnrestrictedSyncer(
|
||||
logger,
|
||||
database,
|
||||
settings,
|
||||
syncService,
|
||||
fileOperations,
|
||||
history,
|
||||
contentCache,
|
||||
serverConfig
|
||||
);
|
||||
|
||||
const webSocketManager = new WebSocketManager(
|
||||
deviceId,
|
||||
logger,
|
||||
settings,
|
||||
webSocket
|
||||
);
|
||||
|
||||
const syncer = new Syncer(
|
||||
deviceId,
|
||||
logger,
|
||||
database,
|
||||
settings,
|
||||
syncService,
|
||||
webSocketManager,
|
||||
fileOperations,
|
||||
unrestrictedSyncer
|
||||
);
|
||||
|
||||
const fileChangeNotifier = new FileChangeNotifier();
|
||||
const cursorTracker = new CursorTracker(
|
||||
database,
|
||||
webSocketManager,
|
||||
fileOperations,
|
||||
fileChangeNotifier
|
||||
);
|
||||
const client = new SyncClient(
|
||||
history,
|
||||
settings,
|
||||
database,
|
||||
syncer,
|
||||
webSocketManager,
|
||||
logger,
|
||||
fetchController,
|
||||
cursorTracker,
|
||||
fileChangeNotifier,
|
||||
contentCache,
|
||||
fileOperations,
|
||||
serverConfig,
|
||||
persistence
|
||||
);
|
||||
|
||||
logger.info("SyncClient created successfully");
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
this.checkIfDestroyed("start");
|
||||
|
||||
if (this.hasStarted) {
|
||||
throw new Error("SyncClient has already been started");
|
||||
}
|
||||
this.hasStarted = true;
|
||||
|
||||
if (
|
||||
!this.unloadTelemetry &&
|
||||
this.settings.getSettings().enableTelemetry
|
||||
) {
|
||||
this.unloadTelemetry = setUpTelemetry();
|
||||
}
|
||||
|
||||
this.logger.onLogEmitted.add((log): void => {
|
||||
if (log.level === LogLevel.ERROR && Sentry.isInitialized()) {
|
||||
Sentry.captureMessage(log.message);
|
||||
}
|
||||
});
|
||||
|
||||
this.settings.onSettingsChanged.add(this.onSettingsChange.bind(this));
|
||||
|
||||
if (this.settings.getSettings().isSyncEnabled) {
|
||||
this.logger.info("Starting SyncClient");
|
||||
await this.startSyncing();
|
||||
this.logger.info("SyncClient has successfully started");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload settings from disk overriding current in-memory settings.
|
||||
* Missing values will be filled in from DEFAULT_SETTINGS rather than
|
||||
* retaining current in-memory settings.
|
||||
*/
|
||||
public async reloadSettings(): Promise<void> {
|
||||
this.checkIfDestroyed("reloadSettings");
|
||||
|
||||
const state = (await this.persistence.load()) ?? {
|
||||
settings: undefined
|
||||
};
|
||||
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...(state.settings ?? {})
|
||||
};
|
||||
|
||||
await this.setSettings(settings);
|
||||
}
|
||||
|
||||
public async checkConnection(): Promise<NetworkConnectionStatus> {
|
||||
this.checkIfDestroyed("checkConnection");
|
||||
|
||||
const server = await this.serverConfig.checkConnection(true);
|
||||
return {
|
||||
isSuccessful: server.isSuccessful,
|
||||
serverMessage: server.message,
|
||||
isWebSocketConnected: this.webSocketManager.isWebSocketConnected
|
||||
};
|
||||
}
|
||||
|
||||
public getHistoryEntries(): readonly HistoryEntry[] {
|
||||
return this.history.entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the in-flight operations to finish, reset all tracking,
|
||||
* and the local database but retain the settings.
|
||||
* The SyncClient can be used again after calling this method.
|
||||
*/
|
||||
public async reset(): Promise<void> {
|
||||
this.checkIfDestroyed("reset");
|
||||
|
||||
this.logger.info(
|
||||
"Stopping SyncClient to apply changed connection settings"
|
||||
);
|
||||
await this.pause();
|
||||
|
||||
// clear all local state
|
||||
this.logger.info("Resetting SyncClient's local state");
|
||||
this.database.reset();
|
||||
await this.database.save(); // ensure the new database reads as empty
|
||||
this.resetInMemoryState();
|
||||
this.hasStartedOfflineSync = false;
|
||||
this.hasFinishedOfflineSync = false;
|
||||
this.serverConfig.reset();
|
||||
|
||||
await this.startSyncing();
|
||||
}
|
||||
|
||||
public getSettings(): SyncSettings {
|
||||
return this.settings.getSettings();
|
||||
}
|
||||
|
||||
public async setSetting<T extends keyof SyncSettings>(
|
||||
key: T,
|
||||
value: SyncSettings[T]
|
||||
): Promise<void> {
|
||||
this.checkIfDestroyed("setSetting");
|
||||
|
||||
await this.settings.setSetting(key, value);
|
||||
}
|
||||
|
||||
public async setSettings(value: Partial<SyncSettings>): Promise<void> {
|
||||
this.checkIfDestroyed("setSettings");
|
||||
|
||||
await this.settings.setSettings(value);
|
||||
}
|
||||
|
||||
public async syncLocallyCreatedFile(
|
||||
relativePath: RelativePath
|
||||
): Promise<void> {
|
||||
this.checkIfDestroyed("syncLocallyCreatedFile");
|
||||
|
||||
this.fileChangeNotifier.notifyOfFileChange(relativePath);
|
||||
return this.syncer.syncLocallyCreatedFile(relativePath);
|
||||
}
|
||||
|
||||
public async syncLocallyDeletedFile(
|
||||
relativePath: RelativePath
|
||||
): Promise<void> {
|
||||
this.checkIfDestroyed("syncLocallyDeletedFile");
|
||||
|
||||
this.fileChangeNotifier.notifyOfFileChange(relativePath);
|
||||
return this.syncer.syncLocallyDeletedFile(relativePath);
|
||||
}
|
||||
|
||||
public async syncLocallyUpdatedFile({
|
||||
oldPath,
|
||||
relativePath
|
||||
}: {
|
||||
oldPath?: RelativePath;
|
||||
relativePath: RelativePath;
|
||||
}): Promise<void> {
|
||||
this.checkIfDestroyed("syncLocallyUpdatedFile");
|
||||
|
||||
this.fileChangeNotifier.notifyOfFileChange(relativePath);
|
||||
return this.syncer.syncLocallyUpdatedFile({
|
||||
oldPath,
|
||||
relativePath
|
||||
});
|
||||
}
|
||||
|
||||
public getDocumentSyncingStatus(
|
||||
relativePath: RelativePath
|
||||
): DocumentSyncStatus {
|
||||
this.checkIfDestroyed("getDocumentSyncingStatus");
|
||||
|
||||
if (!this.settings.getSettings().isSyncEnabled) {
|
||||
return DocumentSyncStatus.SYNCING_IS_DISABLED;
|
||||
}
|
||||
|
||||
if (!this.syncer.isFirstSyncComplete || !this.hasFinishedOfflineSync) {
|
||||
return DocumentSyncStatus.SYNCING;
|
||||
}
|
||||
|
||||
const document =
|
||||
this.database.getLatestDocumentByRelativePath(relativePath);
|
||||
if (document === undefined) {
|
||||
return DocumentSyncStatus.SYNCING;
|
||||
}
|
||||
return document.updates.length > 0
|
||||
? DocumentSyncStatus.SYNCING
|
||||
: DocumentSyncStatus.UP_TO_DATE;
|
||||
}
|
||||
|
||||
public async updateLocalCursors(
|
||||
documentToCursors: Record<RelativePath, CursorSpan[]>
|
||||
): Promise<void> {
|
||||
this.checkIfDestroyed("updateLocalCursors");
|
||||
|
||||
await this.cursorTracker.sendLocalCursorsToServer(documentToCursors);
|
||||
}
|
||||
|
||||
public getTrackedFilePaths(): RelativePath[] {
|
||||
this.checkIfDestroyed("getTrackedFilePaths");
|
||||
|
||||
return this.database.resolvedDocuments
|
||||
.filter((doc) => !doc.isDeleted && doc.metadata !== undefined)
|
||||
.map((doc) => doc.relativePath);
|
||||
}
|
||||
|
||||
public async getAllVaultFiles(): Promise<RelativePath[]> {
|
||||
this.checkIfDestroyed("getAllVaultFiles");
|
||||
|
||||
return this.fileOperations.listFilesRecursively(undefined);
|
||||
}
|
||||
|
||||
public async waitUntilFinished(): Promise<void> {
|
||||
this.checkIfDestroyed("waitUntilIdle");
|
||||
await this.syncer.waitUntilFinished();
|
||||
await this.webSocketManager.waitUntilFinished();
|
||||
await this.database.save(); // flush all changes to disk
|
||||
}
|
||||
|
||||
/**
|
||||
* Completely destroy the SyncClient, cancelling all in-progress operations.
|
||||
* After calling this method, the SyncClient cannot be used again.
|
||||
*/
|
||||
public async destroy(): Promise<void> {
|
||||
this.checkIfDestroyed("destroy");
|
||||
|
||||
// cancel everything that's in progress
|
||||
await this.pause();
|
||||
|
||||
this.hasBeenDestroyed = true;
|
||||
|
||||
this.resetInMemoryState();
|
||||
|
||||
this.logger.info("SyncClient has been successfully disposed");
|
||||
|
||||
this.unloadTelemetry?.();
|
||||
}
|
||||
|
||||
private async startSyncing(): Promise<void> {
|
||||
this.checkIfDestroyed("startSyncing");
|
||||
this.fetchController.finishReset();
|
||||
|
||||
await this.serverConfig.initialize();
|
||||
this.webSocketManager.start();
|
||||
|
||||
if (!this.hasStartedOfflineSync) {
|
||||
this.hasStartedOfflineSync = true;
|
||||
await this.syncer.scheduleSyncForOfflineChanges();
|
||||
}
|
||||
|
||||
this.hasFinishedOfflineSync = true;
|
||||
}
|
||||
|
||||
private async pause(): Promise<void> {
|
||||
this.fetchController.startReset();
|
||||
await this.webSocketManager.stop();
|
||||
await this.waitUntilFinished();
|
||||
}
|
||||
|
||||
private resetInMemoryState(): void {
|
||||
this.history.reset();
|
||||
this.contentCache.reset();
|
||||
// don't reset the logger
|
||||
this.cursorTracker.reset();
|
||||
this.syncer.reset();
|
||||
this.fileOperations.reset();
|
||||
}
|
||||
|
||||
private async onSettingsChange(
|
||||
newSettings: SyncSettings,
|
||||
oldSettings: SyncSettings
|
||||
): Promise<void> {
|
||||
this.checkIfDestroyed("onSettingsChange");
|
||||
|
||||
if (
|
||||
newSettings.vaultName !== oldSettings.vaultName ||
|
||||
newSettings.remoteUri !== oldSettings.remoteUri
|
||||
) {
|
||||
await this.reset();
|
||||
}
|
||||
|
||||
if (newSettings.isSyncEnabled !== oldSettings.isSyncEnabled) {
|
||||
if (newSettings.isSyncEnabled) {
|
||||
await this.startSyncing();
|
||||
} else {
|
||||
await this.pause();
|
||||
}
|
||||
}
|
||||
|
||||
if (newSettings.diffCacheSizeMB !== oldSettings.diffCacheSizeMB) {
|
||||
this.contentCache.resize(newSettings.diffCacheSizeMB * 1024 * 1024);
|
||||
}
|
||||
|
||||
if (newSettings.enableTelemetry !== oldSettings.enableTelemetry) {
|
||||
if (newSettings.enableTelemetry) {
|
||||
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 type { FileChangeNotifier } from "./file-change-notifier";
|
||||
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
|
||||
// valid within a certain version of the document it belongs to. This class tracks previous and the latest
|
||||
// known remote cursor positions, and for each document, tries to return the latest cursor positions that are
|
||||
// not from the future.
|
||||
export class CursorTracker {
|
||||
private readonly updateLock = new Lock();
|
||||
// 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 & {
|
||||
upToDateness: DocumentUpToDateness;
|
||||
})[] = [];
|
||||
private readonly updateLock = new Lock();
|
||||
|
||||
private lastLocalCursorState: DocumentWithCursors[] = [];
|
||||
private lastLocalCursorStateWithoutDirtyDocuments: DocumentWithCursors[] =
|
||||
[];
|
||||
private knownRemoteCursors: (ClientCursors & {
|
||||
upToDateness: DocumentUpToDateness;
|
||||
})[] = [];
|
||||
|
||||
public constructor(
|
||||
private readonly database: Database,
|
||||
private readonly webSocketManager: WebSocketManager,
|
||||
private readonly fileOperations: FileOperations,
|
||||
private readonly fileChangeNotifier: FileChangeNotifier
|
||||
) {
|
||||
this.webSocketManager.addRemoteCursorsUpdateListener(
|
||||
async (clientCursors) => {
|
||||
await this.updateLock.withLock(async () => {
|
||||
// The latest message will contain all active clients, so we can delete the ones
|
||||
// from the local list which are no longer active.
|
||||
const allIds = new Set(
|
||||
clientCursors.map((c) => c.deviceId)
|
||||
);
|
||||
const updatedKnownRemoteCursors =
|
||||
this.knownRemoteCursors.filter((c) =>
|
||||
allIds.has(c.deviceId)
|
||||
);
|
||||
private lastLocalCursorState: DocumentWithCursors[] = [];
|
||||
private lastLocalCursorStateWithoutDirtyDocuments: DocumentWithCursors[] =
|
||||
[];
|
||||
|
||||
for (const cursor of clientCursors.filter((client) =>
|
||||
client.documentsWithCursors.every(
|
||||
(doc) => doc.vault_update_id != null
|
||||
)
|
||||
)) {
|
||||
updatedKnownRemoteCursors.push({
|
||||
...cursor,
|
||||
upToDateness:
|
||||
await this.getDocumentsUpToDateness(cursor)
|
||||
});
|
||||
}
|
||||
public constructor(
|
||||
private readonly database: Database,
|
||||
private readonly webSocketManager: WebSocketManager,
|
||||
private readonly fileOperations: FileOperations,
|
||||
private readonly fileChangeNotifier: FileChangeNotifier
|
||||
) {
|
||||
this.webSocketManager.onRemoteCursorsUpdateReceived.add(
|
||||
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)
|
||||
);
|
||||
|
||||
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.updateLock.withLock(async () => {
|
||||
for (const clientCursor of this.knownRemoteCursors) {
|
||||
if (
|
||||
clientCursor.documentsWithCursors.some(
|
||||
(document) =>
|
||||
document.relative_path === relativePath
|
||||
)
|
||||
) {
|
||||
clientCursor.upToDateness =
|
||||
await this.getDocumentsUpToDateness(clientCursor);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
this.knownRemoteCursors = updatedKnownRemoteCursors;
|
||||
});
|
||||
|
||||
/// Update the local cursors for the given documents.
|
||||
/// Can be called frequently as it only emits an event
|
||||
/// if the state has actually changed.
|
||||
public async sendLocalCursorsToServer(
|
||||
documentToCursors: Record<RelativePath, CursorSpan[]>
|
||||
): Promise<void> {
|
||||
const documentsWithCursors: DocumentWithCursors[] = [];
|
||||
this.onRemoteCursorsUpdated.trigger(
|
||||
this.getRelevantAndPruneKnownClientCursors()
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
for (const [relativePath, cursors] of Object.entries(
|
||||
documentToCursors
|
||||
)) {
|
||||
const record =
|
||||
this.database.getLatestDocumentByRelativePath(relativePath);
|
||||
this.fileChangeNotifier.onFileChanged.add(async (relativePath) =>
|
||||
this.updateLock.withLock(async () => {
|
||||
for (const clientCursor of this.knownRemoteCursors) {
|
||||
if (
|
||||
clientCursor.documentsWithCursors.some(
|
||||
(document) =>
|
||||
document.relative_path === relativePath
|
||||
)
|
||||
) {
|
||||
clientCursor.upToDateness =
|
||||
await this.getDocumentsUpToDateness(clientCursor);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (!record) {
|
||||
continue; // Let's wait for the file to be created before sending cursors
|
||||
}
|
||||
/// Update the local cursors for the given documents.
|
||||
/// Can be called frequently as it only emits an event
|
||||
/// if the state has actually changed.
|
||||
public async sendLocalCursorsToServer(
|
||||
documentToCursors: Record<RelativePath, CursorSpan[]>
|
||||
): Promise<void> {
|
||||
const documentsWithCursors: DocumentWithCursors[] = [];
|
||||
|
||||
if (!record.metadata) {
|
||||
continue; // this is a new document, no need to sync the cursors
|
||||
}
|
||||
for (const [relativePath, cursors] of Object.entries(
|
||||
documentToCursors
|
||||
)) {
|
||||
const record =
|
||||
this.database.getLatestDocumentByRelativePath(relativePath);
|
||||
|
||||
documentsWithCursors.push({
|
||||
relative_path: relativePath,
|
||||
document_id: record.documentId,
|
||||
vault_update_id: record.metadata.parentVersionId,
|
||||
cursors: cursors.map(({ start, end }) => ({
|
||||
start: Math.min(start, end),
|
||||
end: Math.max(start, end)
|
||||
})) // the client might send directional selections
|
||||
});
|
||||
}
|
||||
if (!record) {
|
||||
continue; // Let's wait for the file to be created before sending cursors
|
||||
}
|
||||
|
||||
if (
|
||||
JSON.stringify(this.lastLocalCursorState) ===
|
||||
JSON.stringify(documentsWithCursors)
|
||||
) {
|
||||
// Caching step to avoid reading the edited files all the time
|
||||
return;
|
||||
}
|
||||
this.lastLocalCursorState = documentsWithCursors;
|
||||
if (!record.metadata) {
|
||||
continue; // this is a new document, no need to sync the cursors
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
documentsWithCursors.push({
|
||||
relative_path: relativePath,
|
||||
document_id: record.documentId,
|
||||
vault_update_id: record.metadata.parentVersionId,
|
||||
cursors: cursors.map(({ start, end }) => ({
|
||||
start: Math.min(start, end),
|
||||
end: Math.max(start, end)
|
||||
})) // the client might send directional selections
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
JSON.stringify(this.lastLocalCursorStateWithoutDirtyDocuments) ===
|
||||
JSON.stringify(documentsWithCursors)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
JSON.stringify(this.lastLocalCursorState) ===
|
||||
JSON.stringify(documentsWithCursors)
|
||||
) {
|
||||
// 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
|
||||
// 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())
|
||||
);
|
||||
});
|
||||
}
|
||||
this.lastLocalCursorStateWithoutDirtyDocuments = documentsWithCursors;
|
||||
|
||||
public reset(): void {
|
||||
this.knownRemoteCursors = [];
|
||||
this.lastLocalCursorState = [];
|
||||
this.lastLocalCursorStateWithoutDirtyDocuments = [];
|
||||
this.updateLock.reset();
|
||||
}
|
||||
this.webSocketManager.updateLocalCursors({ documentsWithCursors });
|
||||
}
|
||||
|
||||
private getRelevantAndPruneKnownClientCursors(): MaybeOutdatedClientCursors[] {
|
||||
const result: MaybeOutdatedClientCursors[] = [];
|
||||
const included = new Set<string>();
|
||||
public reset(): void {
|
||||
this.knownRemoteCursors = [];
|
||||
this.lastLocalCursorState = [];
|
||||
this.lastLocalCursorStateWithoutDirtyDocuments = [];
|
||||
this.updateLock.reset();
|
||||
}
|
||||
|
||||
const relevantCursors = [];
|
||||
for (const clientCursors of [...this.knownRemoteCursors].reverse()) {
|
||||
if (included.has(clientCursors.deviceId)) {
|
||||
continue;
|
||||
}
|
||||
private getRelevantAndPruneKnownClientCursors(): MaybeOutdatedClientCursors[] {
|
||||
const result: MaybeOutdatedClientCursors[] = [];
|
||||
const included = new Set<string>();
|
||||
|
||||
if (clientCursors.upToDateness === DocumentUpToDateness.Later) {
|
||||
continue;
|
||||
}
|
||||
const relevantCursors = [];
|
||||
for (const clientCursors of [...this.knownRemoteCursors].reverse()) {
|
||||
if (included.has(clientCursors.deviceId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push({
|
||||
...clientCursors,
|
||||
isOutdated:
|
||||
clientCursors.upToDateness === DocumentUpToDateness.Prior
|
||||
});
|
||||
if (clientCursors.upToDateness === DocumentUpToDateness.Later) {
|
||||
continue;
|
||||
}
|
||||
|
||||
included.add(clientCursors.deviceId);
|
||||
relevantCursors.unshift(clientCursors); // to reverse order back to normal
|
||||
}
|
||||
result.push({
|
||||
...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.
|
||||
// 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));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (
|
||||
results.every((result) => result === DocumentUpToDateness.UpToDate)
|
||||
) {
|
||||
return DocumentUpToDateness.UpToDate;
|
||||
}
|
||||
// We store up-to-dateness on a per-client basis to simplify the implementation.
|
||||
// An individual client won't have too many documents open at once, so this is a reasonable trade-off.
|
||||
private async getDocumentsUpToDateness(
|
||||
clientCursor: ClientCursors
|
||||
): Promise<DocumentUpToDateness> {
|
||||
const results = [];
|
||||
for (const document of clientCursor.documentsWithCursors) {
|
||||
results.push(await this.getDocumentUpToDateness(document));
|
||||
}
|
||||
|
||||
if (
|
||||
results.every(
|
||||
(result) =>
|
||||
result === DocumentUpToDateness.UpToDate ||
|
||||
result === DocumentUpToDateness.Prior
|
||||
)
|
||||
) {
|
||||
return DocumentUpToDateness.Prior;
|
||||
}
|
||||
if (
|
||||
results.every((result) => result === DocumentUpToDateness.UpToDate)
|
||||
) {
|
||||
return DocumentUpToDateness.UpToDate;
|
||||
}
|
||||
|
||||
return DocumentUpToDateness.Later;
|
||||
}
|
||||
if (
|
||||
results.every(
|
||||
(result) =>
|
||||
result === DocumentUpToDateness.UpToDate ||
|
||||
result === DocumentUpToDateness.Prior
|
||||
)
|
||||
) {
|
||||
return DocumentUpToDateness.Prior;
|
||||
}
|
||||
|
||||
private async getDocumentUpToDateness(
|
||||
document: DocumentWithCursors
|
||||
): Promise<DocumentUpToDateness> {
|
||||
const record = this.database.getLatestDocumentByRelativePath(
|
||||
document.relative_path
|
||||
);
|
||||
return DocumentUpToDateness.Later;
|
||||
}
|
||||
|
||||
if (!record) {
|
||||
// the document of the cursor must be from the future
|
||||
return DocumentUpToDateness.Later;
|
||||
}
|
||||
private async getDocumentUpToDateness(
|
||||
document: DocumentWithCursors
|
||||
): Promise<DocumentUpToDateness> {
|
||||
const record = this.database.getLatestDocumentByRelativePath(
|
||||
document.relative_path
|
||||
);
|
||||
|
||||
if (
|
||||
(record.metadata?.parentVersionId ?? 0) <
|
||||
(document.vault_update_id ?? 0)
|
||||
) {
|
||||
return DocumentUpToDateness.Later;
|
||||
} else if (
|
||||
(document.vault_update_id ?? 0) <
|
||||
(record.metadata?.parentVersionId ?? 0)
|
||||
) {
|
||||
// the document of the cursor must be from the past
|
||||
return DocumentUpToDateness.Prior;
|
||||
}
|
||||
if (!record) {
|
||||
// the document of the cursor must be from the future
|
||||
return DocumentUpToDateness.Later;
|
||||
}
|
||||
|
||||
const currentContent = await this.fileOperations.read(
|
||||
document.relative_path
|
||||
);
|
||||
if (
|
||||
(record.metadata?.parentVersionId ?? 0) <
|
||||
(document.vault_update_id ?? 0)
|
||||
) {
|
||||
return DocumentUpToDateness.Later;
|
||||
} else if (
|
||||
(document.vault_update_id ?? 0) <
|
||||
(record.metadata?.parentVersionId ?? 0)
|
||||
) {
|
||||
// the document of the cursor must be from the past
|
||||
return DocumentUpToDateness.Prior;
|
||||
}
|
||||
|
||||
return this.database.getLatestDocumentByRelativePath(
|
||||
document.relative_path
|
||||
)?.metadata?.hash === hash(currentContent)
|
||||
? DocumentUpToDateness.UpToDate
|
||||
: DocumentUpToDateness.Prior;
|
||||
}
|
||||
const currentContent = await this.fileOperations.read(
|
||||
document.relative_path
|
||||
);
|
||||
|
||||
return this.database.getLatestDocumentByRelativePath(
|
||||
document.relative_path
|
||||
)?.metadata?.hash === hash(currentContent)
|
||||
? DocumentUpToDateness.UpToDate
|
||||
: DocumentUpToDateness.Prior;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,12 @@
|
|||
import type { RelativePath } from "../persistence/database";
|
||||
import { EventListeners } from "../utils/data-structures/event-listeners";
|
||||
|
||||
export class FileChangeNotifier {
|
||||
private readonly listeners: ((filePath: RelativePath) => unknown)[] = [];
|
||||
public readonly onFileChanged = new EventListeners<
|
||||
(filePath: RelativePath) => unknown
|
||||
>();
|
||||
|
||||
public addFileChangeListener(
|
||||
listener: (filePath: RelativePath) => unknown
|
||||
): 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));
|
||||
}
|
||||
public notifyOfFileChange(filePath: RelativePath): void {
|
||||
this.onFileChanged.trigger(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 { EventListeners } from "../utils/data-structures/event-listeners";
|
||||
|
||||
export enum LogLevel {
|
||||
DEBUG = "DEBUG",
|
||||
INFO = "INFO",
|
||||
WARNING = "WARNING",
|
||||
ERROR = "ERROR"
|
||||
DEBUG = "DEBUG",
|
||||
INFO = "INFO",
|
||||
WARNING = "WARNING",
|
||||
ERROR = "ERROR"
|
||||
}
|
||||
|
||||
const LOG_LEVEL_ORDER = {
|
||||
[LogLevel.DEBUG]: 0,
|
||||
[LogLevel.INFO]: 1,
|
||||
[LogLevel.WARNING]: 2,
|
||||
[LogLevel.ERROR]: 3
|
||||
[LogLevel.DEBUG]: 0,
|
||||
[LogLevel.INFO]: 1,
|
||||
[LogLevel.WARNING]: 2,
|
||||
[LogLevel.ERROR]: 3
|
||||
};
|
||||
|
||||
export class LogLine {
|
||||
public timestamp = new Date();
|
||||
public constructor(
|
||||
public level: LogLevel,
|
||||
public message: string
|
||||
) {}
|
||||
public timestamp = new Date();
|
||||
public constructor(
|
||||
public level: LogLevel,
|
||||
public message: string
|
||||
) {}
|
||||
}
|
||||
|
||||
export class Logger {
|
||||
private readonly messages: LogLine[] = [];
|
||||
private readonly onMessageListeners: ((message: LogLine) => unknown)[] = [];
|
||||
public readonly onLogEmitted = new EventListeners<
|
||||
(message: LogLine) => unknown
|
||||
>();
|
||||
|
||||
public constructor(
|
||||
...onMessageListeners: ((message: LogLine) => unknown)[]
|
||||
) {
|
||||
this.onMessageListeners = onMessageListeners;
|
||||
}
|
||||
private readonly messages: LogLine[] = [];
|
||||
|
||||
public debug(message: string): void {
|
||||
this.pushMessage(message, LogLevel.DEBUG);
|
||||
}
|
||||
public debug(message: string): void {
|
||||
this.pushMessage(message, LogLevel.DEBUG);
|
||||
}
|
||||
|
||||
public info(message: string): void {
|
||||
this.pushMessage(message, LogLevel.INFO);
|
||||
}
|
||||
public info(message: string): void {
|
||||
this.pushMessage(message, LogLevel.INFO);
|
||||
}
|
||||
|
||||
public warn(message: string): void {
|
||||
this.pushMessage(message, LogLevel.WARNING);
|
||||
}
|
||||
public warn(message: string): void {
|
||||
this.pushMessage(message, LogLevel.WARNING);
|
||||
}
|
||||
|
||||
public error(message: string): void {
|
||||
this.pushMessage(message, LogLevel.ERROR);
|
||||
}
|
||||
public error(message: string): void {
|
||||
this.pushMessage(message, LogLevel.ERROR);
|
||||
}
|
||||
|
||||
public getMessages(mininumSeverity: LogLevel): LogLine[] {
|
||||
return this.messages.filter(
|
||||
(message) =>
|
||||
LOG_LEVEL_ORDER[message.level] >=
|
||||
LOG_LEVEL_ORDER[mininumSeverity]
|
||||
);
|
||||
}
|
||||
public getMessages(mininumSeverity: LogLevel): LogLine[] {
|
||||
return this.messages.filter(
|
||||
(message) =>
|
||||
LOG_LEVEL_ORDER[message.level] >=
|
||||
LOG_LEVEL_ORDER[mininumSeverity]
|
||||
);
|
||||
}
|
||||
|
||||
public addOnMessageListener(listener: (message: LogLine) => unknown): void {
|
||||
this.onMessageListeners.push(listener);
|
||||
}
|
||||
public reset(): void {
|
||||
this.messages.length = 0;
|
||||
this.debug("Logger has been reset");
|
||||
}
|
||||
|
||||
public removeOnMessageListener(
|
||||
listener: (message: LogLine) => unknown
|
||||
): void {
|
||||
const index = this.onMessageListeners.indexOf(listener);
|
||||
if (index !== -1) {
|
||||
this.onMessageListeners.splice(index, 1);
|
||||
}
|
||||
}
|
||||
private pushMessage(message: string, level: LogLevel): void {
|
||||
const logLine = new LogLine(level, message);
|
||||
this.messages.push(logLine);
|
||||
|
||||
public reset(): void {
|
||||
this.messages.length = 0;
|
||||
this.debug("Logger has been reset");
|
||||
}
|
||||
while (this.messages.length > MAX_LOG_MESSAGE_COUNT) {
|
||||
this.messages.shift();
|
||||
}
|
||||
|
||||
private pushMessage(message: string, level: LogLevel): void {
|
||||
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);
|
||||
});
|
||||
}
|
||||
this.onLogEmitted.trigger(logLine);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,185 +1,167 @@
|
|||
import {
|
||||
MAX_HISTORY_ENTRY_COUNT,
|
||||
TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS
|
||||
MAX_HISTORY_ENTRY_COUNT,
|
||||
TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS
|
||||
} from "../consts";
|
||||
import type { RelativePath } from "../persistence/database";
|
||||
import type { Logger } from "./logger";
|
||||
import { removeFromArray } from "../utils/remove-from-array";
|
||||
import { EventListeners } from "../utils/data-structures/event-listeners";
|
||||
|
||||
export interface SyncCreateDetails {
|
||||
type: SyncType.CREATE;
|
||||
relativePath: RelativePath;
|
||||
type: SyncType.CREATE;
|
||||
relativePath: RelativePath;
|
||||
}
|
||||
|
||||
export interface SyncUpdateDetails {
|
||||
type: SyncType.UPDATE;
|
||||
relativePath: RelativePath;
|
||||
type: SyncType.UPDATE;
|
||||
relativePath: RelativePath;
|
||||
}
|
||||
|
||||
export interface SyncMovedDetails {
|
||||
type: SyncType.MOVE;
|
||||
relativePath: RelativePath;
|
||||
movedFrom: RelativePath;
|
||||
type: SyncType.MOVE;
|
||||
relativePath: RelativePath;
|
||||
movedFrom: RelativePath;
|
||||
}
|
||||
|
||||
export interface SyncDeleteDetails {
|
||||
type: SyncType.DELETE;
|
||||
relativePath: RelativePath;
|
||||
type: SyncType.DELETE;
|
||||
relativePath: RelativePath;
|
||||
}
|
||||
|
||||
export interface SyncSkippedDetails {
|
||||
type: SyncType.SKIPPED;
|
||||
relativePath: RelativePath;
|
||||
type: SyncType.SKIPPED;
|
||||
relativePath: RelativePath;
|
||||
}
|
||||
|
||||
export type SyncDetails =
|
||||
| SyncCreateDetails
|
||||
| SyncUpdateDetails
|
||||
| SyncDeleteDetails
|
||||
| SyncMovedDetails
|
||||
| SyncSkippedDetails;
|
||||
| SyncCreateDetails
|
||||
| SyncUpdateDetails
|
||||
| SyncDeleteDetails
|
||||
| SyncMovedDetails
|
||||
| SyncSkippedDetails;
|
||||
|
||||
export interface CommonHistoryEntry {
|
||||
status: SyncStatus;
|
||||
message: string;
|
||||
details: SyncDetails;
|
||||
author?: string;
|
||||
timestamp?: Date;
|
||||
status: SyncStatus;
|
||||
message: string;
|
||||
details: SyncDetails;
|
||||
author?: string;
|
||||
timestamp?: Date;
|
||||
}
|
||||
|
||||
export enum SyncType {
|
||||
CREATE = "CREATE",
|
||||
UPDATE = "UPDATE",
|
||||
DELETE = "DELETE",
|
||||
MOVE = "MOVE",
|
||||
SKIPPED = "SKIPPED"
|
||||
CREATE = "CREATE",
|
||||
UPDATE = "UPDATE",
|
||||
DELETE = "DELETE",
|
||||
MOVE = "MOVE",
|
||||
SKIPPED = "SKIPPED"
|
||||
}
|
||||
|
||||
export enum SyncStatus {
|
||||
SUCCESS = "SUCCESS",
|
||||
ERROR = "ERROR",
|
||||
SKIPPED = "SKIPPED"
|
||||
SUCCESS = "SUCCESS",
|
||||
ERROR = "ERROR",
|
||||
SKIPPED = "SKIPPED"
|
||||
}
|
||||
|
||||
export type HistoryEntry = CommonHistoryEntry & { timestamp: Date };
|
||||
|
||||
export interface HistoryStats {
|
||||
success: number;
|
||||
error: number;
|
||||
success: number;
|
||||
error: number;
|
||||
}
|
||||
|
||||
export class SyncHistory {
|
||||
private _entries: HistoryEntry[] = [];
|
||||
public readonly onHistoryUpdated = new EventListeners<
|
||||
(status: HistoryStats) => unknown
|
||||
>();
|
||||
|
||||
private readonly syncHistoryUpdateListeners: ((
|
||||
status: HistoryStats
|
||||
) => unknown)[] = [];
|
||||
private readonly _entries: HistoryEntry[] = [];
|
||||
|
||||
private status: HistoryStats = {
|
||||
success: 0,
|
||||
error: 0
|
||||
};
|
||||
private status: HistoryStats = {
|
||||
success: 0,
|
||||
error: 0
|
||||
};
|
||||
|
||||
public constructor(private readonly logger: Logger) {}
|
||||
public constructor(private readonly logger: Logger) {}
|
||||
|
||||
public get entries(): readonly HistoryEntry[] {
|
||||
return this._entries;
|
||||
}
|
||||
public get entries(): readonly HistoryEntry[] {
|
||||
return this._entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the entry at the beginning of the history list. If the entry
|
||||
* already in the list, it will get moved to the beginning and updated.
|
||||
*
|
||||
* If the entry list is too long, the oldest entry will be removed.
|
||||
*/
|
||||
public addHistoryEntry(entry: CommonHistoryEntry): void {
|
||||
const historyEntry = {
|
||||
...entry,
|
||||
timestamp: entry.timestamp ?? new Date()
|
||||
};
|
||||
/**
|
||||
* Insert the entry at the beginning of the history list. If the entry
|
||||
* already in the list, it will get moved to the beginning and updated.
|
||||
*
|
||||
* If the entry list is too long, the oldest entry will be removed.
|
||||
*/
|
||||
public addHistoryEntry(entry: CommonHistoryEntry): void {
|
||||
const historyEntry = {
|
||||
...entry,
|
||||
timestamp: entry.timestamp ?? new Date()
|
||||
};
|
||||
|
||||
const candidate = this.findSimilarRecentUpdateEntry(historyEntry);
|
||||
if (candidate !== undefined) {
|
||||
this._entries = this._entries.filter((e) => e !== candidate);
|
||||
}
|
||||
const candidate = this.findSimilarRecentUpdateEntry(historyEntry);
|
||||
if (candidate !== undefined) {
|
||||
removeFromArray(this._entries, candidate);
|
||||
}
|
||||
|
||||
// Insert the entry at the beginning
|
||||
this._entries.unshift(historyEntry);
|
||||
// Insert the entry at the beginning
|
||||
this._entries.unshift(historyEntry);
|
||||
|
||||
if (this._entries.length > MAX_HISTORY_ENTRY_COUNT) {
|
||||
this._entries.pop();
|
||||
}
|
||||
if (this._entries.length > MAX_HISTORY_ENTRY_COUNT) {
|
||||
this._entries.pop();
|
||||
}
|
||||
|
||||
this.updateSuccessCount(historyEntry);
|
||||
}
|
||||
this.updateSuccessCount(historyEntry);
|
||||
}
|
||||
|
||||
public addSyncHistoryUpdateListener(
|
||||
listener: (stats: HistoryStats) => unknown
|
||||
): void {
|
||||
this.syncHistoryUpdateListeners.push(listener);
|
||||
listener({ ...this.status });
|
||||
}
|
||||
public reset(): void {
|
||||
this._entries.length = 0;
|
||||
this.status = {
|
||||
success: 0,
|
||||
error: 0
|
||||
};
|
||||
this.onHistoryUpdated.trigger(this.status);
|
||||
}
|
||||
|
||||
public removeSyncHistoryUpdateListener(
|
||||
listener: (stats: HistoryStats) => unknown
|
||||
): void {
|
||||
const index = this.syncHistoryUpdateListeners.indexOf(listener);
|
||||
if (index !== -1) {
|
||||
this.syncHistoryUpdateListeners.splice(index, 1);
|
||||
}
|
||||
}
|
||||
private findSimilarRecentUpdateEntry(
|
||||
entry: HistoryEntry
|
||||
): HistoryEntry | undefined {
|
||||
if (entry.details.type !== SyncType.UPDATE) {
|
||||
return;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this._entries.length = 0;
|
||||
this.status = {
|
||||
success: 0,
|
||||
error: 0
|
||||
};
|
||||
this.syncHistoryUpdateListeners.forEach((listener) => {
|
||||
listener(this.status);
|
||||
});
|
||||
}
|
||||
const candidate = this._entries.find(
|
||||
(e) =>
|
||||
e.details.type === SyncType.UPDATE &&
|
||||
e.details.relativePath === entry.details.relativePath
|
||||
);
|
||||
if (
|
||||
candidate !== undefined &&
|
||||
(this._entries[0] === candidate ||
|
||||
candidate.timestamp.getTime() +
|
||||
TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS * 1000 >
|
||||
entry.timestamp.getTime())
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
private findSimilarRecentUpdateEntry(
|
||||
entry: HistoryEntry
|
||||
): HistoryEntry | undefined {
|
||||
if (entry.details.type !== SyncType.UPDATE) {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
const candidate = this._entries.find(
|
||||
(e) =>
|
||||
e.details.type === SyncType.UPDATE &&
|
||||
e.details.relativePath === entry.details.relativePath
|
||||
);
|
||||
if (
|
||||
candidate !== undefined &&
|
||||
(this._entries[0] === candidate ||
|
||||
candidate.timestamp.getTime() +
|
||||
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);
|
||||
});
|
||||
}
|
||||
this.onHistoryUpdated.trigger(this.status);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export enum DocumentSyncStatus {
|
||||
UP_TO_DATE = "UP_TO_DATE",
|
||||
SYNCING = "SYNCING",
|
||||
SYNCING_IS_DISABLED = "SYNCING_IS_DISABLED"
|
||||
UP_TO_DATE = "UP_TO_DATE",
|
||||
SYNCING = "SYNCING",
|
||||
SYNCING_IS_DISABLED = "SYNCING_IS_DISABLED"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export enum DocumentUpToDateness {
|
||||
UpToDate = "UpToDate", // easiest case, the client can just show the cursors as-is
|
||||
Prior = "Prior", // The cursors are outdated, so the client has to guess the cursor positions based on local updates. This is only possible if this client's cursor has once been up-to-date in a given document.
|
||||
Later = "Later" // The cursors are from a future version of a document, there's no way we can accuratly show them locally.
|
||||
UpToDate = "UpToDate", // easiest case, the client can just show the cursors as-is
|
||||
Prior = "Prior", // The cursors are outdated, so the client has to guess the cursor positions based on local updates. This is only possible if this client's cursor has once been up-to-date in a given document.
|
||||
Later = "Later" // The cursors are from a future version of a document, there's no way we can accuratly show them locally.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { ClientCursors } from "../services/types/ClientCursors";
|
||||
|
||||
export interface MaybeOutdatedClientCursors extends ClientCursors {
|
||||
isOutdated: boolean;
|
||||
isOutdated: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export interface NetworkConnectionStatus {
|
||||
isSuccessful: boolean;
|
||||
serverMessage: string;
|
||||
isWebSocketConnected: boolean;
|
||||
isSuccessful: boolean;
|
||||
serverMessage: string;
|
||||
isWebSocketConnected: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import assert from "node:assert";
|
||||
|
||||
export function assertSetContainsExactly<T>(set: Set<T>, ...values: T[]): void {
|
||||
assert.ok(
|
||||
set.size === values.length &&
|
||||
Array.from(set).every((value) => values.includes(value)),
|
||||
`Expected set to contain only ${values.map((v) => '"' + v + '"').join(", ")}, but it contained ${Array.from(
|
||||
set
|
||||
)
|
||||
.map((v) => '"' + v + '"')
|
||||
.join(", ")}`
|
||||
);
|
||||
assert.ok(
|
||||
set.size === values.length &&
|
||||
Array.from(set).every((value) => values.includes(value)),
|
||||
`Expected set to contain only ${values.map((v) => '"' + v + '"').join(", ")}, but it contained ${Array.from(
|
||||
set
|
||||
)
|
||||
.map((v) => '"' + v + '"')
|
||||
.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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