Apply editorconfig

This commit is contained in:
Andras Schmelczer 2025-12-07 13:38:23 +00:00
commit 4150eb2720
131 changed files with 17155 additions and 14368 deletions

View file

@ -87,7 +87,7 @@ Rust structs generate TypeScript types via ts-rs crate, stored in `sync-server/b
## Testing ## Testing
### Running Tests ### Running Tests
- Server: `cargo test --verbose` - Server: `cargo test --verbose`
- Frontend: `npm run test` (runs Jest across all workspaces) - Frontend: `npm run test` (runs Jest across all workspaces)
- E2E: `scripts/e2e.sh` - E2E: `scripts/e2e.sh`
@ -107,4 +107,4 @@ Rust structs generate TypeScript types via ts-rs crate, stored in `sync-server/b
### TypeScript ### TypeScript
- Prettier configuration: 4-space tabs, trailing commas removed, LF line endings - Prettier configuration: 4-space tabs, trailing commas removed, LF line endings
- ESLint with unused imports plugin - ESLint with unused imports plugin
- Consistent across all three frontend packages - Consistent across all three frontend packages

View file

@ -1,60 +1,60 @@
import { defineConfig } from "vitepress" import { defineConfig } from "vitepress"
export default defineConfig({ export default defineConfig({
title: "VaultLink", title: "VaultLink",
description: "Self-hosted real-time synchronisation for Obsidian", description: "Self-hosted real-time synchronisation for Obsidian",
base: "/vault-link/", base: "/vault-link/",
themeConfig: { themeConfig: {
logo: "/logo.svg", logo: "/logo.svg",
nav: [ nav: [
{ text: "Home", link: "/" }, { text: "Home", link: "/" },
{ text: "Guide", link: "/guide/getting-started" }, { text: "Guide", link: "/guide/getting-started" },
{ text: "Architecture", link: "/architecture/" }, { text: "Architecture", link: "/architecture/" },
{ text: "GitHub", link: "https://github.com/schmelczer/vault-link" } { text: "GitHub", link: "https://github.com/schmelczer/vault-link" }
], ],
sidebar: [ sidebar: [
{ {
text: "Introduction", text: "Introduction",
items: [ items: [
{ text: "What is VaultLink?", link: "/guide/what-is-vaultlink" }, { text: "What is VaultLink?", link: "/guide/what-is-vaultlink" },
{ text: "Getting Started", link: "/guide/getting-started" }, { text: "Getting Started", link: "/guide/getting-started" },
{ text: "Limitations", link: "/guide/limitations" }, { text: "Limitations", link: "/guide/limitations" },
{ text: "Comparison with Alternatives", link: "/guide/alternatives" } { text: "Comparison with Alternatives", link: "/guide/alternatives" }
] ]
}, },
{ {
text: "Setup", text: "Setup",
items: [ items: [
{ text: "Server Setup", link: "/guide/server-setup" }, { text: "Server Setup", link: "/guide/server-setup" },
{ text: "Obsidian Plugin", link: "/guide/obsidian-plugin" }, { text: "Obsidian Plugin", link: "/guide/obsidian-plugin" },
{ text: "CLI Client", link: "/guide/cli-client" } { text: "CLI Client", link: "/guide/cli-client" }
] ]
}, },
{ {
text: "Configuration", text: "Configuration",
items: [ items: [
{ text: "Server Configuration", link: "/config/server" }, { text: "Server Configuration", link: "/config/server" },
{ text: "Authentication", link: "/config/authentication" }, { text: "Authentication", link: "/config/authentication" },
{ text: "Advanced Options", link: "/config/advanced" } { text: "Advanced Options", link: "/config/advanced" }
] ]
}, },
{ {
text: "Architecture", text: "Architecture",
items: [ items: [
{ text: "Overview", link: "/architecture/" }, { text: "Overview", link: "/architecture/" },
{ text: "Sync Algorithm", link: "/architecture/sync-algorithm" }, { text: "Sync Algorithm", link: "/architecture/sync-algorithm" },
{ text: "Data Flow", link: "/architecture/data-flow" } { text: "Data Flow", link: "/architecture/data-flow" }
] ]
} }
], ],
socialLinks: [{ icon: "github", link: "https://github.com/schmelczer/vault-link" }], socialLinks: [{ icon: "github", link: "https://github.com/schmelczer/vault-link" }],
footer: { footer: {
message: "Released under the MIT License.", message: "Released under the MIT License.",
copyright: "Copyright © 2024-present Andras Schmelczer" copyright: "Copyright © 2024-present Andras Schmelczer"
}, },
search: { search: {
provider: "local" provider: "local"
} }
}, },
head: [["link", { rel: "icon", type: "image/svg+xml", href: "/vault-link/logo.svg" }]] head: [["link", { rel: "icon", type: "image/svg+xml", href: "/vault-link/logo.svg" }]]
}) })

3590
docs/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -22,4 +22,4 @@
"vitepress": "^1.6.4", "vitepress": "^1.6.4",
"vue": "^3.5.24" "vue": "^3.5.24"
} }
} }

View file

@ -1,8 +1,8 @@
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"> <svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs> <defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4A90E2;stop-opacity:1" /> <stop offset="0%" style="stop-color:#4A90E2;stop-opacity:1" />
<stop offset="100%" style="stop-color:#357ABD;stop-opacity:1" /> <stop offset="100%" style="stop-color:#357ABD;stop-opacity:1" />
</linearGradient> </linearGradient>
</defs> </defs>
@ -25,23 +25,23 @@
<!-- Link chain --> <!-- Link chain -->
<g opacity="0.9"> <g opacity="0.9">
<!-- Left link --> <!-- Left link -->
<ellipse cx="-30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/> <ellipse cx="-30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
<!-- Right link --> <!-- Right link -->
<ellipse cx="30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/> <ellipse cx="30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
<!-- Center link connecting them --> <!-- Center link connecting them -->
<ellipse cx="0" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/> <ellipse cx="0" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
</g> </g>
<!-- Sync arrows (subtle) --> <!-- Sync arrows (subtle) -->
<g opacity="0.5"> <g opacity="0.5">
<!-- Clockwise arrow top-right --> <!-- Clockwise arrow top-right -->
<path d="M 35 -35 Q 50 -35 50 -20 L 50 -15" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/> <path d="M 35 -35 Q 50 -35 50 -20 L 50 -15" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
<polygon points="50,-15 47,-22 53,-22" fill="url(#grad1)"/> <polygon points="50,-15 47,-22 53,-22" fill="url(#grad1)"/>
<!-- Counter-clockwise arrow bottom-left --> <!-- Counter-clockwise arrow bottom-left -->
<path d="M -35 25 Q -50 25 -50 10 L -50 5" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/> <path d="M -35 25 Q -50 25 -50 10 L -50 5" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
<polygon points="-50,5 -47,12 -53,12" fill="url(#grad1)"/> <polygon points="-50,5 -47,12 -53,12" fill="url(#grad1)"/>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

View file

@ -3,92 +3,92 @@ import tseslint from "typescript-eslint";
import unusedImports from "eslint-plugin-unused-imports"; import unusedImports from "eslint-plugin-unused-imports";
export default [ export default [
{ {
ignores: [ ignores: [
"sync-client/src/services/types.ts", "sync-client/src/services/types.ts",
"**/dist/", "**/dist/",
"**/*.mjs", "**/*.mjs",
"**/*.js" "**/*.js"
] ]
}, },
...tseslint.config({ ...tseslint.config({
plugins: { plugins: {
"unused-imports": unusedImports "unused-imports": unusedImports
}, },
extends: [eslint.configs.recommended, tseslint.configs.all], extends: [eslint.configs.recommended, tseslint.configs.all],
rules: { rules: {
"no-unused-vars": "off", "no-unused-vars": "off",
"@typescript-eslint/restrict-template-expressions": "off", "@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-floating-promises": [ "@typescript-eslint/no-floating-promises": [
"error", "error",
{
allowForKnownSafeCalls: [
{ from: "package", name: ["suite", "test"], package: "node:test" },
],
},
],
"@typescript-eslint/parameter-properties": "off",
"@typescript-eslint/require-await": "off",
"@typescript-eslint/class-methods-use-this": "off",
"@typescript-eslint/consistent-return": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/max-params": "off",
"@typescript-eslint/no-magic-numbers": "off",
"@typescript-eslint/prefer-readonly-parameter-types": "off",
"@typescript-eslint/naming-convention": "off",
"no-restricted-properties": [
"error",
{
object: "Promise",
property: "all",
message: "Use `awaitAll` instead of Promise.all to always await all promises."
},
{ {
object: "Promise", allowForKnownSafeCalls: [
property: "allSettled", { from: "package", name: ["suite", "test"], package: "node:test" },
message: "Use `awaitAll` instead of Promise.allSettled to always await all promises and throw on errors." ],
}, },
{ ],
object: "String", "@typescript-eslint/parameter-properties": "off",
property: "replace", "@typescript-eslint/require-await": "off",
message: "Use replaceAll instead of replace to replace all occurrences of a substring." "@typescript-eslint/class-methods-use-this": "off",
} "@typescript-eslint/consistent-return": "off",
], "@typescript-eslint/no-unsafe-argument": "off",
"no-restricted-syntax": [ "@typescript-eslint/max-params": "off",
"error", "@typescript-eslint/no-magic-numbers": "off",
{ "@typescript-eslint/prefer-readonly-parameter-types": "off",
selector: "CallExpression[callee.property.name='splice'][arguments.length=2][arguments.1.type='Literal'][arguments.1.value=1]", "@typescript-eslint/naming-convention": "off",
message: "Use `removeFromArray(array, item)` instead of manually using indexOf + splice(index, 1). Import from 'sync-client/src/utils/remove-from-array'." "no-restricted-properties": [
}, "error",
{ {
selector: "CallExpression[callee.property.name='filter'] > ArrowFunctionExpression[body.type='BinaryExpression'][body.operator='!==']", object: "Promise",
message: "Use `removeFromArray(array, item)` instead of filter(x => x !== item) for better performance. Import from 'sync-client/src/utils/remove-from-array'." property: "all",
}, message: "Use `awaitAll` instead of Promise.all to always await all promises."
{ },
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'." object: "Promise",
}, property: "allSettled",
{ message: "Use `awaitAll` instead of Promise.allSettled to always await all promises and throw on errors."
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'." {
} object: "String",
], property: "replace",
"unused-imports/no-unused-vars": [ message: "Use replaceAll instead of replace to replace all occurrences of a substring."
"warn", }
{ ],
vars: "all", "no-restricted-syntax": [
varsIgnorePattern: "^_", "error",
args: "after-used", {
argsIgnorePattern: "^_" 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'."
] },
}, {
languageOptions: { selector: "CallExpression[callee.property.name='filter'] > ArrowFunctionExpression[body.type='BinaryExpression'][body.operator='!==']",
parserOptions: { message: "Use `removeFromArray(array, item)` instead of filter(x => x !== item) for better performance. Import from 'sync-client/src/utils/remove-from-array'."
projectService: true, },
tsconfigRootDir: import.meta.dirname {
} 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
}
}
})
]; ];

View file

@ -4,227 +4,227 @@ import { parseArgs } from "./args";
import { LogLevel } from "sync-client"; import { LogLevel } from "sync-client";
test("parseArgs - parse basic arguments", () => { test("parseArgs - parse basic arguments", () => {
const args = parseArgs([ const args = parseArgs([
"node", "node",
"cli.js", "cli.js",
"-l", "-l",
"/path/to/vault", "/path/to/vault",
"-r", "-r",
"https://sync.example.com", "https://sync.example.com",
"-t", "-t",
"mytoken", "mytoken",
"-v", "-v",
"default" "default"
]); ]);
assert.equal(args.localPath, "/path/to/vault"); assert.equal(args.localPath, "/path/to/vault");
assert.equal(args.remoteUri, "https://sync.example.com"); assert.equal(args.remoteUri, "https://sync.example.com");
assert.equal(args.token, "mytoken"); assert.equal(args.token, "mytoken");
assert.equal(args.vaultName, "default"); assert.equal(args.vaultName, "default");
}); });
test("parseArgs - parse long form arguments", () => { test("parseArgs - parse long form arguments", () => {
const args = parseArgs([ const args = parseArgs([
"node", "node",
"cli.js", "cli.js",
"--local-path", "--local-path",
"/path/to/vault", "/path/to/vault",
"--remote-uri", "--remote-uri",
"https://sync.example.com", "https://sync.example.com",
"--token", "--token",
"mytoken", "mytoken",
"--vault-name", "--vault-name",
"default" "default"
]); ]);
assert.equal(args.localPath, "/path/to/vault"); assert.equal(args.localPath, "/path/to/vault");
assert.equal(args.remoteUri, "https://sync.example.com"); assert.equal(args.remoteUri, "https://sync.example.com");
assert.equal(args.token, "mytoken"); assert.equal(args.token, "mytoken");
assert.equal(args.vaultName, "default"); assert.equal(args.vaultName, "default");
}); });
test("parseArgs - parse with optional arguments", () => { test("parseArgs - parse with optional arguments", () => {
const args = parseArgs([ const args = parseArgs([
"node", "node",
"cli.js", "cli.js",
"-l", "-l",
"/path/to/vault", "/path/to/vault",
"-r", "-r",
"https://sync.example.com", "https://sync.example.com",
"-t", "-t",
"mytoken", "mytoken",
"-v", "-v",
"default", "default",
"--sync-concurrency", "--sync-concurrency",
"5", "5",
"--max-file-size-mb", "--max-file-size-mb",
"20" "20"
]); ]);
assert.equal(args.syncConcurrency, 5); assert.equal(args.syncConcurrency, 5);
assert.equal(args.maxFileSizeMB, 20); assert.equal(args.maxFileSizeMB, 20);
}); });
test("parseArgs - parse with multiple ignore patterns", () => { test("parseArgs - parse with multiple ignore patterns", () => {
const args = parseArgs([ const args = parseArgs([
"node", "node",
"cli.js", "cli.js",
"-l", "-l",
"/path/to/vault", "/path/to/vault",
"-r", "-r",
"https://sync.example.com", "https://sync.example.com",
"-t", "-t",
"mytoken", "mytoken",
"-v", "-v",
"default", "default",
"--ignore-pattern", "--ignore-pattern",
".git/**", ".git/**",
"*.tmp" "*.tmp"
]); ]);
assert.deepEqual(args.ignorePatterns, [".git/**", "*.tmp"]); assert.deepEqual(args.ignorePatterns, [".git/**", "*.tmp"]);
}); });
test("parseArgs - throws on missing required arguments", () => { test("parseArgs - throws on missing required arguments", () => {
assert.throws(() => { assert.throws(() => {
parseArgs(["node", "cli.js", "-r", "https://sync.example.com"]); parseArgs(["node", "cli.js", "-r", "https://sync.example.com"]);
}, /required option/); }, /required option/);
}); });
test("parseArgs - throws on missing remote uri", () => { test("parseArgs - throws on missing remote uri", () => {
assert.throws(() => { assert.throws(() => {
parseArgs([ parseArgs([
"node", "node",
"cli.js", "cli.js",
"-l", "-l",
"/path/to/vault", "/path/to/vault",
"-t", "-t",
"mytoken", "mytoken",
"-v", "-v",
"default" "default"
]); ]);
}, /--remote-uri/); }, /--remote-uri/);
}); });
test("parseArgs - throws on missing token", () => { test("parseArgs - throws on missing token", () => {
assert.throws(() => { assert.throws(() => {
parseArgs([ parseArgs([
"node", "node",
"cli.js", "cli.js",
"-l", "-l",
"/path/to/vault", "/path/to/vault",
"-r", "-r",
"https://sync.example.com", "https://sync.example.com",
"-v", "-v",
"default" "default"
]); ]);
}, /--token/); }, /--token/);
}); });
test("parseArgs - throws on missing vault name", () => { test("parseArgs - throws on missing vault name", () => {
assert.throws(() => { assert.throws(() => {
parseArgs([ parseArgs([
"node", "node",
"cli.js", "cli.js",
"-l", "-l",
"/path/to/vault", "/path/to/vault",
"-r", "-r",
"https://sync.example.com", "https://sync.example.com",
"-t", "-t",
"mytoken" "mytoken"
]); ]);
}, /--vault-name/); }, /--vault-name/);
}); });
test("parseArgs - default log level is INFO", () => { test("parseArgs - default log level is INFO", () => {
const args = parseArgs([ const args = parseArgs([
"node", "node",
"cli.js", "cli.js",
"-l", "-l",
"/path/to/vault", "/path/to/vault",
"-r", "-r",
"https://sync.example.com", "https://sync.example.com",
"-t", "-t",
"mytoken", "mytoken",
"-v", "-v",
"default" "default"
]); ]);
assert.equal(args.logLevel, LogLevel.INFO); assert.equal(args.logLevel, LogLevel.INFO);
}); });
test("parseArgs - parse DEBUG log level", () => { test("parseArgs - parse DEBUG log level", () => {
const args = parseArgs([ const args = parseArgs([
"node", "node",
"cli.js", "cli.js",
"-l", "-l",
"/path/to/vault", "/path/to/vault",
"-r", "-r",
"https://sync.example.com", "https://sync.example.com",
"-t", "-t",
"mytoken", "mytoken",
"-v", "-v",
"default", "default",
"--log-level", "--log-level",
"DEBUG" "DEBUG"
]); ]);
assert.equal(args.logLevel, LogLevel.DEBUG); assert.equal(args.logLevel, LogLevel.DEBUG);
}); });
test("parseArgs - parse ERROR log level", () => { test("parseArgs - parse ERROR log level", () => {
const args = parseArgs([ const args = parseArgs([
"node", "node",
"cli.js", "cli.js",
"-l", "-l",
"/path/to/vault", "/path/to/vault",
"-r", "-r",
"https://sync.example.com", "https://sync.example.com",
"-t", "-t",
"mytoken", "mytoken",
"-v", "-v",
"default", "default",
"--log-level", "--log-level",
"ERROR" "ERROR"
]); ]);
assert.equal(args.logLevel, LogLevel.ERROR); assert.equal(args.logLevel, LogLevel.ERROR);
}); });
test("parseArgs - log level is case insensitive", () => { test("parseArgs - log level is case insensitive", () => {
const args = parseArgs([ const args = parseArgs([
"node", "node",
"cli.js", "cli.js",
"-l", "-l",
"/path/to/vault", "/path/to/vault",
"-r", "-r",
"https://sync.example.com", "https://sync.example.com",
"-t", "-t",
"mytoken", "mytoken",
"-v", "-v",
"default", "default",
"--log-level", "--log-level",
"debug" "debug"
]); ]);
assert.equal(args.logLevel, LogLevel.DEBUG); assert.equal(args.logLevel, LogLevel.DEBUG);
}); });
test("parseArgs - throws on invalid log level", () => { test("parseArgs - throws on invalid log level", () => {
assert.throws(() => { assert.throws(() => {
parseArgs([ parseArgs([
"node", "node",
"cli.js", "cli.js",
"-l", "-l",
"/path/to/vault", "/path/to/vault",
"-r", "-r",
"https://sync.example.com", "https://sync.example.com",
"-t", "-t",
"mytoken", "mytoken",
"-v", "-v",
"default", "default",
"--log-level", "--log-level",
"INVALID" "INVALID"
]); ]);
}, /Invalid log level/); }, /Invalid log level/);
}); });

View file

@ -3,134 +3,134 @@ import packageJson from "../package.json";
import { LogLevel } from "sync-client"; import { LogLevel } from "sync-client";
export interface CliArgs { export interface CliArgs {
remoteUri: string; remoteUri: string;
token: string; token: string;
vaultName: string; vaultName: string;
localPath: string; localPath: string;
syncConcurrency?: number; syncConcurrency?: number;
maxFileSizeMB?: number; maxFileSizeMB?: number;
ignorePatterns?: string[]; ignorePatterns?: string[];
webSocketRetryIntervalMs?: number; webSocketRetryIntervalMs?: number;
logLevel: LogLevel; logLevel: LogLevel;
health?: string; health?: string;
enableTelemetry?: boolean; enableTelemetry?: boolean;
} }
export function parseArgs(argv: string[]): CliArgs { export function parseArgs(argv: string[]): CliArgs {
const program = new Command(); const program = new Command();
program program
.name("vaultlink") .name("vaultlink")
.description( .description(
"VaultLink Local CLI - Sync your vault to the local filesystem" "VaultLink Local CLI - Sync your vault to the local filesystem"
) )
.version(packageJson.version) .version(packageJson.version)
.option("-l, --local-path <path>", "Local directory path to sync") .option("-l, --local-path <path>", "Local directory path to sync")
.option("-r, --remote-uri <uri>", "Remote server URI") .option("-r, --remote-uri <uri>", "Remote server URI")
.option("-t, --token <token>", "Authentication token") .option("-t, --token <token>", "Authentication token")
.option("-v, --vault-name <name>", "Vault name") .option("-v, --vault-name <name>", "Vault name")
.option( .option(
"--sync-concurrency <number>", "--sync-concurrency <number>",
"[OPTIONAL] Number of concurrent sync operations", "[OPTIONAL] Number of concurrent sync operations",
parseInt parseInt
) )
.option( .option(
"--max-file-size-mb <number>", "--max-file-size-mb <number>",
"[OPTIONAL] Maximum file size in MB", "[OPTIONAL] Maximum file size in MB",
parseInt parseInt
) )
.option( .option(
"--ignore-pattern <pattern...>", "--ignore-pattern <pattern...>",
"[OPTIONAL] Patterns to ignore (can be specified multiple times)" "[OPTIONAL] Patterns to ignore (can be specified multiple times)"
) )
.option( .option(
"--websocket-retry-interval-ms <number>", "--websocket-retry-interval-ms <number>",
"[OPTIONAL] WebSocket retry interval in milliseconds", "[OPTIONAL] WebSocket retry interval in milliseconds",
parseInt parseInt
) )
.option( .option(
"--log-level <level>", "--log-level <level>",
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)", "[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)",
"INFO" "INFO"
) )
.option( .option(
"--health <path>", "--health <path>",
"[OPTIONAL] Path to health status file for Docker healthcheck" "[OPTIONAL] Path to health status file for Docker healthcheck"
) )
.option( .option(
"--enable-telemetry", "--enable-telemetry",
"[OPTIONAL] Enable telemetry (disabled by default)" "[OPTIONAL] Enable telemetry (disabled by default)"
) )
.addHelpText( .addHelpText(
"after", "after",
` `
Examples: Examples:
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default $ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\ $ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
--ignore-pattern ".git/**" --ignore-pattern "*.tmp" --ignore-pattern ".git/**" --ignore-pattern "*.tmp"
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\ $ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
--log-level DEBUG --log-level DEBUG
` `
); );
program.parse(argv); program.parse(argv);
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ /* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
const opts = program.opts(); const opts = program.opts();
const localPath = opts.localPath as string | undefined; const localPath = opts.localPath as string | undefined;
const remoteUri = opts.remoteUri as string | undefined; const remoteUri = opts.remoteUri as string | undefined;
const token = opts.token as string | undefined; const token = opts.token as string | undefined;
const vaultName = opts.vaultName as string | undefined; const vaultName = opts.vaultName as string | undefined;
const syncConcurrency = opts.syncConcurrency as number | undefined; const syncConcurrency = opts.syncConcurrency as number | undefined;
const maxFileSizeMb = opts.maxFileSizeMb as number | undefined; const maxFileSizeMb = opts.maxFileSizeMb as number | undefined;
const ignorePattern = opts.ignorePattern as string[] | undefined; const ignorePattern = opts.ignorePattern as string[] | undefined;
const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as
| number | number
| undefined; | undefined;
const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO"; const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO";
const health = opts.health as string | undefined; const health = opts.health as string | undefined;
const enableTelemetry = opts.enableTelemetry as boolean | undefined; const enableTelemetry = opts.enableTelemetry as boolean | undefined;
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
if (localPath === undefined) { if (localPath === undefined) {
throw new Error( throw new Error(
"required option '-l, --local-path <path>' not specified" "required option '-l, --local-path <path>' not specified"
); );
} }
if (remoteUri === undefined) { if (remoteUri === undefined) {
throw new Error("required option '--remote-uri <uri>' not specified"); throw new Error("required option '--remote-uri <uri>' not specified");
} }
if (token === undefined) { if (token === undefined) {
throw new Error("required option '--token <token>' not specified"); throw new Error("required option '--token <token>' not specified");
} }
if (vaultName === undefined) { if (vaultName === undefined) {
throw new Error("required option '--vault-name <name>' not specified"); throw new Error("required option '--vault-name <name>' not specified");
} }
// Validate and parse log level // Validate and parse log level
const logLevelUpper = logLevelStr.toUpperCase(); const logLevelUpper = logLevelStr.toUpperCase();
const validLogLevels = Object.values(LogLevel); const validLogLevels = Object.values(LogLevel);
const isLogLevel = (value: string): value is LogLevel => { const isLogLevel = (value: string): value is LogLevel => {
return (validLogLevels as readonly string[]).includes(value); return (validLogLevels as readonly string[]).includes(value);
}; };
if (!isLogLevel(logLevelUpper)) { if (!isLogLevel(logLevelUpper)) {
throw new Error( throw new Error(
`Invalid log level '${logLevelStr}'. Valid values are: ${validLogLevels.join(", ")}` `Invalid log level '${logLevelStr}'. Valid values are: ${validLogLevels.join(", ")}`
); );
} }
const logLevel = logLevelUpper; const logLevel = logLevelUpper;
return { return {
localPath, localPath,
remoteUri, remoteUri,
token, token,
vaultName, vaultName,
syncConcurrency, syncConcurrency,
maxFileSizeMB: maxFileSizeMb, maxFileSizeMB: maxFileSizeMb,
ignorePatterns: ignorePattern, ignorePatterns: ignorePattern,
webSocketRetryIntervalMs: websocketRetryIntervalMs, webSocketRetryIntervalMs: websocketRetryIntervalMs,
logLevel, logLevel,
health, health,
enableTelemetry enableTelemetry
}; };
} }

View file

@ -3,11 +3,11 @@ import * as fs from "fs/promises";
import * as fsSync from "fs"; import * as fsSync from "fs";
import type { NetworkConnectionStatus } from "sync-client"; import type { NetworkConnectionStatus } from "sync-client";
import { import {
SyncClient, SyncClient,
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
LogLevel, LogLevel,
type SyncSettings, type SyncSettings,
type StoredDatabase type StoredDatabase
} from "sync-client"; } from "sync-client";
import { parseArgs } from "./args"; import { parseArgs } from "./args";
import { NodeFileSystemOperations } from "./node-filesystem"; import { NodeFileSystemOperations } from "./node-filesystem";
@ -16,229 +16,229 @@ import { formatLogLine, colorize, styleText } from "./logger-formatter";
import packageJson from "../package.json"; import packageJson from "../package.json";
function writeHealthStatus( function writeHealthStatus(
filePath: string, filePath: string,
connectionStatus: NetworkConnectionStatus connectionStatus: NetworkConnectionStatus
): void { ): void {
try { try {
fsSync.writeFileSync(filePath, JSON.stringify(connectionStatus)); fsSync.writeFileSync(filePath, JSON.stringify(connectionStatus));
} catch (error) { } catch (error) {
console.error( console.error(
`Failed to write health status to ${filePath}: ${error instanceof Error ? error.message : String(error)}` `Failed to write health status to ${filePath}: ${error instanceof Error ? error.message : String(error)}`
); );
} }
} }
const LOG_LEVEL_ORDER = { const LOG_LEVEL_ORDER = {
[LogLevel.DEBUG]: 0, [LogLevel.DEBUG]: 0,
[LogLevel.INFO]: 1, [LogLevel.INFO]: 1,
[LogLevel.WARNING]: 2, [LogLevel.WARNING]: 2,
[LogLevel.ERROR]: 3 [LogLevel.ERROR]: 3
}; };
async function main(): Promise<void> { async function main(): Promise<void> {
const args = parseArgs(process.argv); const args = parseArgs(process.argv);
const absolutePath = path.resolve(args.localPath); const absolutePath = path.resolve(args.localPath);
try { try {
const stats = await fs.stat(absolutePath); const stats = await fs.stat(absolutePath);
if (!stats.isDirectory()) { if (!stats.isDirectory()) {
console.error( console.error(
colorize(`Error: ${absolutePath} is not a directory`, "red") colorize(`Error: ${absolutePath} is not a directory`, "red")
); );
process.exit(1); process.exit(1);
} }
} catch (error) { } catch (error) {
console.error( console.error(
colorize( colorize(
`Error: Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`, `Error: Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`,
"red" "red"
) )
); );
process.exit(1); process.exit(1);
} }
console.log( console.log(
styleText("VaultLink Local CLI", "bold", "cyan") + styleText("VaultLink Local CLI", "bold", "cyan") +
colorize(` v${packageJson.version}`, "dim") colorize(` v${packageJson.version}`, "dim")
); );
console.log(colorize("=".repeat(50), "dim")); console.log(colorize("=".repeat(50), "dim"));
console.log( console.log(
`${colorize("Local path:", "dim")} ${colorize(absolutePath, "green")}` `${colorize("Local path:", "dim")} ${colorize(absolutePath, "green")}`
); );
console.log( console.log(
`${colorize("Remote URI:", "dim")} ${colorize(args.remoteUri, "cyan")}` `${colorize("Remote URI:", "dim")} ${colorize(args.remoteUri, "cyan")}`
); );
console.log( console.log(
`${colorize("Vault name:", "dim")} ${colorize(args.vaultName, "green")}` `${colorize("Vault name:", "dim")} ${colorize(args.vaultName, "green")}`
); );
console.log(""); console.log("");
const dataDir = path.join(absolutePath, ".vaultlink"); const dataDir = path.join(absolutePath, ".vaultlink");
const dataFile = path.join(dataDir, "sync-data.json"); const dataFile = path.join(dataDir, "sync-data.json");
await fs.mkdir(dataDir, { recursive: true }); await fs.mkdir(dataDir, { recursive: true });
const fileSystem = new NodeFileSystemOperations(absolutePath); const fileSystem = new NodeFileSystemOperations(absolutePath);
const ignorePatterns = [ const ignorePatterns = [
...(args.ignorePatterns ?? []), ...(args.ignorePatterns ?? []),
".vaultlink/**", ".vaultlink/**",
".git/**" ".git/**"
]; ];
const settings: SyncSettings = { const settings: SyncSettings = {
...DEFAULT_SETTINGS, ...DEFAULT_SETTINGS,
remoteUri: args.remoteUri, remoteUri: args.remoteUri,
token: args.token, token: args.token,
vaultName: args.vaultName, vaultName: args.vaultName,
syncConcurrency: syncConcurrency:
args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency, args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency,
maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB, maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB,
ignorePatterns, ignorePatterns,
webSocketRetryIntervalMs: webSocketRetryIntervalMs:
args.webSocketRetryIntervalMs ?? args.webSocketRetryIntervalMs ??
DEFAULT_SETTINGS.webSocketRetryIntervalMs, DEFAULT_SETTINGS.webSocketRetryIntervalMs,
isSyncEnabled: true, isSyncEnabled: true,
enableTelemetry: enableTelemetry:
args.enableTelemetry ?? DEFAULT_SETTINGS.enableTelemetry args.enableTelemetry ?? DEFAULT_SETTINGS.enableTelemetry
}; };
const client = await SyncClient.create({ const client = await SyncClient.create({
fs: fileSystem, fs: fileSystem,
persistence: { persistence: {
load: async () => { load: async () => {
let database: Partial<StoredDatabase> | undefined = undefined; let database: Partial<StoredDatabase> | undefined = undefined;
try { try {
const content = await fs.readFile(dataFile, "utf-8"); const content = await fs.readFile(dataFile, "utf-8");
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
database = JSON.parse(content) as Partial<StoredDatabase>; database = JSON.parse(content) as Partial<StoredDatabase>;
} catch { } catch {
console.error( console.error(
colorize( colorize(
`Cannot read data file at ${dataFile}`, `Cannot read data file at ${dataFile}`,
"yellow" "yellow"
) )
); );
} }
return { return {
settings, settings,
database database
}; };
}, },
save: async ({ database: persistedDatabase }) => { save: async ({ database: persistedDatabase }) => {
// settings can't be updated when running with this CLI // settings can't be updated when running with this CLI
await fs.writeFile( await fs.writeFile(
dataFile, dataFile,
JSON.stringify(persistedDatabase, null, 2) JSON.stringify(persistedDatabase, null, 2)
); );
} }
}, },
nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n" nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n"
}); });
if (args.health !== undefined) { if (args.health !== undefined) {
const healthFile = args.health; const healthFile = args.health;
const healthInterval = setInterval(() => { const healthInterval = setInterval(() => {
void client.checkConnection().then((status) => { void client.checkConnection().then((status) => {
writeHealthStatus(healthFile, status); writeHealthStatus(healthFile, status);
}); });
}, 30 * 1000); // every 30 seconds }, 30 * 1000); // every 30 seconds
const clearHealthInterval = (): void => { const clearHealthInterval = (): void => {
clearInterval(healthInterval); clearInterval(healthInterval);
}; };
process.on("SIGINT", clearHealthInterval); process.on("SIGINT", clearHealthInterval);
process.on("SIGTERM", clearHealthInterval); process.on("SIGTERM", clearHealthInterval);
process.on("exit", clearHealthInterval); process.on("exit", clearHealthInterval);
} }
// Add colored log formatter with level filtering // Add colored log formatter with level filtering
client.logger.addOnMessageListener((logLine) => { client.logger.addOnMessageListener((logLine) => {
// Only show messages at or above the configured log level // Only show messages at or above the configured log level
if (LOG_LEVEL_ORDER[logLine.level] >= LOG_LEVEL_ORDER[args.logLevel]) { if (LOG_LEVEL_ORDER[logLine.level] >= LOG_LEVEL_ORDER[args.logLevel]) {
console.log(formatLogLine(logLine)); console.log(formatLogLine(logLine));
} }
}); });
client.logger.info("Starting sync client"); client.logger.info("Starting sync client");
const fileWatcher = new FileWatcher(absolutePath, client); const fileWatcher = new FileWatcher(absolutePath, client);
client.addWebSocketStatusChangeListener(() => { client.addWebSocketStatusChangeListener(() => {
const isConnected = client.isWebSocketConnected; const isConnected = client.isWebSocketConnected;
client.logger.info( client.logger.info(
`WebSocket status changed: ${isConnected ? "connected" : "disconnected"}` `WebSocket status changed: ${isConnected ? "connected" : "disconnected"}`
); );
}); });
client.addRemainingSyncOperationsListener((remaining) => { client.addRemainingSyncOperationsListener((remaining) => {
if (remaining === 0) { if (remaining === 0) {
client.logger.info("All sync operations completed"); client.logger.info("All sync operations completed");
} else { } else {
client.logger.info(`${remaining} sync operations remaining`); client.logger.info(`${remaining} sync operations remaining`);
} }
}); });
const gracefulShutdown = async (signal: string): Promise<void> => { const gracefulShutdown = async (signal: string): Promise<void> => {
console.log( console.log(
colorize( colorize(
`\n${signal} received. Shutting down gracefully...`, `\n${signal} received. Shutting down gracefully...`,
"yellow" "yellow"
) )
); );
fileWatcher.stop(); fileWatcher.stop();
await client.waitUntilFinished(); await client.waitUntilFinished();
await client.destroy(); await client.destroy();
console.log(colorize("Shutdown complete", "green")); console.log(colorize("Shutdown complete", "green"));
process.exit(0); process.exit(0);
}; };
process.on("SIGINT", () => { process.on("SIGINT", () => {
void gracefulShutdown("SIGINT"); void gracefulShutdown("SIGINT");
}); });
process.on("SIGTERM", () => { process.on("SIGTERM", () => {
void gracefulShutdown("SIGTERM"); void gracefulShutdown("SIGTERM");
}); });
try { try {
const connectionStatus = await client.checkConnection(); const connectionStatus = await client.checkConnection();
if (!connectionStatus.isSuccessful) { if (!connectionStatus.isSuccessful) {
console.error( console.error(
colorize( colorize(
`Error: Cannot connect to server: ${connectionStatus.serverMessage}`, `Error: Cannot connect to server: ${connectionStatus.serverMessage}`,
"red" "red"
) )
); );
process.exit(1); process.exit(1);
} }
console.log(`${colorize("✓", "green")} Server connection successful`); console.log(`${colorize("✓", "green")} Server connection successful`);
console.log(colorize("Press Ctrl+C to stop", "dim")); console.log(colorize("Press Ctrl+C to stop", "dim"));
console.log(""); console.log("");
await client.start(); await client.start();
fileWatcher.start(); fileWatcher.start();
} catch (error) { } catch (error) {
console.error( console.error(
colorize( colorize(
`Fatal error: ${error instanceof Error ? error.message : String(error)}`, `Fatal error: ${error instanceof Error ? error.message : String(error)}`,
"red" "red"
) )
); );
fileWatcher.stop(); fileWatcher.stop();
await client.destroy(); await client.destroy();
process.exit(1); process.exit(1);
} }
} }
main().catch((error: unknown) => { main().catch((error: unknown) => {
console.error( console.error(
colorize( colorize(
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`, `Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
"red" "red"
) )
); );
process.exit(1); process.exit(1);
}); });

View file

@ -9,58 +9,58 @@ import * as fs from "fs";
import type { NetworkConnectionStatus } from "sync-client"; import type { NetworkConnectionStatus } from "sync-client";
function isHealthStatus(value: unknown): value is NetworkConnectionStatus { function isHealthStatus(value: unknown): value is NetworkConnectionStatus {
if (typeof value !== "object" || value === null) { if (typeof value !== "object" || value === null) {
return false; return false;
} }
return ( return (
"isSuccessful" in value && "isSuccessful" in value &&
typeof value.isSuccessful === "boolean" && typeof value.isSuccessful === "boolean" &&
"isWebSocketConnected" in value && "isWebSocketConnected" in value &&
typeof value.isWebSocketConnected === "boolean" && typeof value.isWebSocketConnected === "boolean" &&
"serverMessage" in value && "serverMessage" in value &&
typeof value.serverMessage === "string" typeof value.serverMessage === "string"
); );
} }
function main(): void { function main(): void {
if (process.argv.length < 3) { if (process.argv.length < 3) {
console.error("Usage: healthcheck <path-to-health-file>"); console.error("Usage: healthcheck <path-to-health-file>");
process.exit(1); process.exit(1);
} }
const [, , healthFile] = process.argv; const [, , healthFile] = process.argv;
try { try {
// Check if health file exists // Check if health file exists
if (!fs.existsSync(healthFile)) { if (!fs.existsSync(healthFile)) {
console.error(`Health file does not exist: ${healthFile}`); console.error(`Health file does not exist: ${healthFile}`);
process.exit(1); process.exit(1);
} }
// Read and parse health status // Read and parse health status
const content = fs.readFileSync(healthFile, "utf-8"); const content = fs.readFileSync(healthFile, "utf-8");
const parsed: unknown = JSON.parse(content); const parsed: unknown = JSON.parse(content);
// Validate the parsed object using type guard // Validate the parsed object using type guard
if (!isHealthStatus(parsed)) { if (!isHealthStatus(parsed)) {
throw new Error("Invalid health status format"); throw new Error("Invalid health status format");
} }
const status = parsed; const status = parsed;
if (!status.isSuccessful || !status.isWebSocketConnected) { if (!status.isSuccessful || !status.isWebSocketConnected) {
console.error("Not connected to server: " + status.serverMessage); console.error("Not connected to server: " + status.serverMessage);
process.exit(1); process.exit(1);
} }
console.log("Healthy: Connected to server"); console.log("Healthy: Connected to server");
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
console.error( console.error(
`Health check failed: ${error instanceof Error ? error.message : String(error)}` `Health check failed: ${error instanceof Error ? error.message : String(error)}`
); );
process.exit(1); process.exit(1);
} }
} }
main(); main();

View file

@ -2,85 +2,85 @@ import { LogLevel, type LogLine } from "sync-client";
// ANSI color codes // ANSI color codes
export const colors = { export const colors = {
reset: "\x1b[0m", reset: "\x1b[0m",
bold: "\x1b[1m", bold: "\x1b[1m",
dim: "\x1b[2m", dim: "\x1b[2m",
// Foreground colors // Foreground colors
red: "\x1b[31m", red: "\x1b[31m",
green: "\x1b[32m", green: "\x1b[32m",
yellow: "\x1b[33m", yellow: "\x1b[33m",
blue: "\x1b[34m", blue: "\x1b[34m",
magenta: "\x1b[35m", magenta: "\x1b[35m",
cyan: "\x1b[36m", cyan: "\x1b[36m",
gray: "\x1b[90m" gray: "\x1b[90m"
} as const; } as const;
export function colorize(text: string, color: keyof typeof colors): string { export function colorize(text: string, color: keyof typeof colors): string {
return `${colors[color]}${text}${colors.reset}`; return `${colors[color]}${text}${colors.reset}`;
} }
/** /**
* Helper function to apply multiple color modifiers to text * Helper function to apply multiple color modifiers to text
*/ */
export function styleText( export function styleText(
text: string, text: string,
...modifiers: (keyof typeof colors)[] ...modifiers: (keyof typeof colors)[]
): string { ): string {
const prefix = modifiers.map((m) => colors[m]).join(""); const prefix = modifiers.map((m) => colors[m]).join("");
return `${prefix}${text}${colors.reset}`; return `${prefix}${text}${colors.reset}`;
} }
function formatTimestamp(date: Date): string { function formatTimestamp(date: Date): string {
const [time] = date.toTimeString().split(" "); const [time] = date.toTimeString().split(" ");
const ms = date.getMilliseconds().toString().padStart(3, "0"); const ms = date.getMilliseconds().toString().padStart(3, "0");
return colorize(`${time}.${ms}`, "gray"); return colorize(`${time}.${ms}`, "gray");
} }
function formatLevel(level: LogLevel): string { function formatLevel(level: LogLevel): string {
const levelStr = level.padEnd(7); const levelStr = level.padEnd(7);
switch (level) { switch (level) {
case LogLevel.DEBUG: case LogLevel.DEBUG:
return colorize(levelStr, "cyan"); return colorize(levelStr, "cyan");
case LogLevel.INFO: case LogLevel.INFO:
return colorize(levelStr, "green"); return colorize(levelStr, "green");
case LogLevel.WARNING: case LogLevel.WARNING:
return colorize(levelStr, "yellow"); return colorize(levelStr, "yellow");
case LogLevel.ERROR: case LogLevel.ERROR:
return colorize(levelStr, "red"); return colorize(levelStr, "red");
} }
} }
function formatMessage(message: string, level: LogLevel): string { function formatMessage(message: string, level: LogLevel): string {
// Highlight important parts of the message // Highlight important parts of the message
let formatted = message; let formatted = message;
// Highlight file paths // Highlight file paths
formatted = formatted.replace( formatted = formatted.replace(
/(['"])([^'"]*?\.(json|txt|md|js|ts))(['"])/g, /(['"])([^'"]*?\.(json|txt|md|js|ts))(['"])/g,
(_, q1, path, _ext, q2) => q1 + colorize(path, "magenta") + q2 (_, q1, path, _ext, q2) => q1 + colorize(path, "magenta") + q2
); );
// Highlight numbers // Highlight numbers
formatted = formatted.replace(/\b(\d+)\b/g, (num) => colorize(num, "cyan")); formatted = formatted.replace(/\b(\d+)\b/g, (num) => colorize(num, "cyan"));
// Highlight patterns like /regex/ // Highlight patterns like /regex/
formatted = formatted.replace(/(\/\^[^$]*\$\/)/g, (pattern) => formatted = formatted.replace(/(\/\^[^$]*\$\/)/g, (pattern) =>
colorize(pattern, "yellow") colorize(pattern, "yellow")
); );
// Make error messages bold // Make error messages bold
if (level === LogLevel.ERROR) { if (level === LogLevel.ERROR) {
formatted = colorize(formatted, "bold"); formatted = colorize(formatted, "bold");
} }
return formatted; return formatted;
} }
export function formatLogLine(logLine: LogLine): string { export function formatLogLine(logLine: LogLine): string {
const timestamp = formatTimestamp(logLine.timestamp); const timestamp = formatTimestamp(logLine.timestamp);
const level = formatLevel(logLine.level); const level = formatLevel(logLine.level);
const message = formatMessage(logLine.message, logLine.level); const message = formatMessage(logLine.message, logLine.level);
return `${timestamp} ${level} ${message}`; return `${timestamp} ${level} ${message}`;
} }

View file

@ -6,157 +6,157 @@ import * as os from "os";
import { NodeFileSystemOperations } from "./node-filesystem"; import { NodeFileSystemOperations } from "./node-filesystem";
test("NodeFileSystemOperations - read and write files", async () => { test("NodeFileSystemOperations - read and write files", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir); const fsOps = new NodeFileSystemOperations(tempDir);
try { try {
const content = new TextEncoder().encode("Hello, world!"); const content = new TextEncoder().encode("Hello, world!");
await fsOps.write("test.txt", content); await fsOps.write("test.txt", content);
const readContent = await fsOps.read("test.txt"); const readContent = await fsOps.read("test.txt");
assert.equal(new TextDecoder().decode(readContent), "Hello, world!"); assert.equal(new TextDecoder().decode(readContent), "Hello, world!");
} finally { } finally {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
}); });
test("NodeFileSystemOperations - create nested directories with forward slashes", async () => { test("NodeFileSystemOperations - create nested directories with forward slashes", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir); const fsOps = new NodeFileSystemOperations(tempDir);
try { try {
const content = new TextEncoder().encode("Nested file"); const content = new TextEncoder().encode("Nested file");
// Always use forward slashes in API // Always use forward slashes in API
await fsOps.write("dir1/dir2/test.txt", content); await fsOps.write("dir1/dir2/test.txt", content);
const readContent = await fsOps.read("dir1/dir2/test.txt"); const readContent = await fsOps.read("dir1/dir2/test.txt");
assert.equal(new TextDecoder().decode(readContent), "Nested file"); assert.equal(new TextDecoder().decode(readContent), "Nested file");
} finally { } finally {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
}); });
test("NodeFileSystemOperations - exists with forward slashes", async () => { test("NodeFileSystemOperations - exists with forward slashes", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir); const fsOps = new NodeFileSystemOperations(tempDir);
try { try {
assert.equal(await fsOps.exists("test.txt"), false); assert.equal(await fsOps.exists("test.txt"), false);
await fsOps.write("test.txt", new TextEncoder().encode("test")); await fsOps.write("test.txt", new TextEncoder().encode("test"));
assert.equal(await fsOps.exists("test.txt"), true); assert.equal(await fsOps.exists("test.txt"), true);
} finally { } finally {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
}); });
test("NodeFileSystemOperations - delete with forward slashes", async () => { test("NodeFileSystemOperations - delete with forward slashes", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir); const fsOps = new NodeFileSystemOperations(tempDir);
try { try {
await fsOps.write("test.txt", new TextEncoder().encode("test")); await fsOps.write("test.txt", new TextEncoder().encode("test"));
assert.equal(await fsOps.exists("test.txt"), true); assert.equal(await fsOps.exists("test.txt"), true);
await fsOps.delete("test.txt"); await fsOps.delete("test.txt");
assert.equal(await fsOps.exists("test.txt"), false); assert.equal(await fsOps.exists("test.txt"), false);
} finally { } finally {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
}); });
test("NodeFileSystemOperations - rename with forward slashes", async () => { test("NodeFileSystemOperations - rename with forward slashes", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir); const fsOps = new NodeFileSystemOperations(tempDir);
try { try {
const content = new TextEncoder().encode("test content"); const content = new TextEncoder().encode("test content");
await fsOps.write("old.txt", content); await fsOps.write("old.txt", content);
await fsOps.rename("old.txt", "new.txt"); await fsOps.rename("old.txt", "new.txt");
assert.equal(await fsOps.exists("old.txt"), false); assert.equal(await fsOps.exists("old.txt"), false);
assert.equal(await fsOps.exists("new.txt"), true); assert.equal(await fsOps.exists("new.txt"), true);
const readContent = await fsOps.read("new.txt"); const readContent = await fsOps.read("new.txt");
assert.equal(new TextDecoder().decode(readContent), "test content"); assert.equal(new TextDecoder().decode(readContent), "test content");
} finally { } finally {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
}); });
test("NodeFileSystemOperations - rename to nested path with forward slashes", async () => { test("NodeFileSystemOperations - rename to nested path with forward slashes", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir); const fsOps = new NodeFileSystemOperations(tempDir);
try { try {
const content = new TextEncoder().encode("test content"); const content = new TextEncoder().encode("test content");
await fsOps.write("old.txt", content); await fsOps.write("old.txt", content);
await fsOps.rename("old.txt", "dir1/dir2/new.txt"); await fsOps.rename("old.txt", "dir1/dir2/new.txt");
assert.equal(await fsOps.exists("old.txt"), false); assert.equal(await fsOps.exists("old.txt"), false);
assert.equal(await fsOps.exists("dir1/dir2/new.txt"), true); assert.equal(await fsOps.exists("dir1/dir2/new.txt"), true);
} finally { } finally {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
}); });
test("NodeFileSystemOperations - getFileSize", async () => { test("NodeFileSystemOperations - getFileSize", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir); const fsOps = new NodeFileSystemOperations(tempDir);
try { try {
const content = new TextEncoder().encode("Hello!"); const content = new TextEncoder().encode("Hello!");
await fsOps.write("test.txt", content); await fsOps.write("test.txt", content);
const size = await fsOps.getFileSize("test.txt"); const size = await fsOps.getFileSize("test.txt");
assert.equal(size, content.length); assert.equal(size, content.length);
} finally { } finally {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
}); });
test("NodeFileSystemOperations - atomicUpdateText", async () => { test("NodeFileSystemOperations - atomicUpdateText", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir); const fsOps = new NodeFileSystemOperations(tempDir);
try { try {
await fsOps.write("test.txt", new TextEncoder().encode("Hello")); await fsOps.write("test.txt", new TextEncoder().encode("Hello"));
const result = await fsOps.atomicUpdateText("test.txt", (current) => ({ const result = await fsOps.atomicUpdateText("test.txt", (current) => ({
text: current.text + " World", text: current.text + " World",
cursors: [] cursors: []
})); }));
assert.equal(result, "Hello World"); assert.equal(result, "Hello World");
const content = await fsOps.read("test.txt"); const content = await fsOps.read("test.txt");
assert.equal(new TextDecoder().decode(content), "Hello World"); assert.equal(new TextDecoder().decode(content), "Hello World");
} finally { } finally {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
}); });
test("NodeFileSystemOperations - handles paths with forward slashes on all platforms", async () => { test("NodeFileSystemOperations - handles paths with forward slashes on all platforms", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir); const fsOps = new NodeFileSystemOperations(tempDir);
try { try {
// API should always accept forward slashes // API should always accept forward slashes
const testPath = "deep/nested/directory/file.txt"; const testPath = "deep/nested/directory/file.txt";
const content = new TextEncoder().encode("test"); const content = new TextEncoder().encode("test");
await fsOps.write(testPath, content); await fsOps.write(testPath, content);
assert.equal(await fsOps.exists(testPath), true); assert.equal(await fsOps.exists(testPath), true);
const readContent = await fsOps.read(testPath); const readContent = await fsOps.read(testPath);
assert.equal(new TextDecoder().decode(readContent), "test"); assert.equal(new TextDecoder().decode(readContent), "test");
await fsOps.delete(testPath); await fsOps.delete(testPath);
assert.equal(await fsOps.exists(testPath), false); assert.equal(await fsOps.exists(testPath), false);
} finally { } finally {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
}); });

View file

@ -2,205 +2,205 @@ import * as fs from "fs/promises";
import type { Dirent } from "fs"; import type { Dirent } from "fs";
import * as path from "path"; import * as path from "path";
import type { import type {
FileSystemOperations, FileSystemOperations,
RelativePath, RelativePath,
TextWithCursors TextWithCursors
} from "sync-client"; } from "sync-client";
export class NodeFileSystemOperations implements FileSystemOperations { export class NodeFileSystemOperations implements FileSystemOperations {
public constructor(private readonly basePath: string) {} public constructor(private readonly basePath: string) {}
public async listFilesRecursively( public async listFilesRecursively(
directory: RelativePath | undefined directory: RelativePath | undefined
): Promise<RelativePath[]> { ): Promise<RelativePath[]> {
const files: RelativePath[] = []; const files: RelativePath[] = [];
await this.walkDirectory( await this.walkDirectory(
directory !== undefined ? this.toNativePath(directory) : "", directory !== undefined ? this.toNativePath(directory) : "",
files files
); );
return files; return files;
} }
public async read(relativePath: RelativePath): Promise<Uint8Array> { public async read(relativePath: RelativePath): Promise<Uint8Array> {
const fullPath = path.join( const fullPath = path.join(
this.basePath, this.basePath,
this.toNativePath(relativePath) this.toNativePath(relativePath)
); );
try { try {
return await fs.readFile(fullPath); return await fs.readFile(fullPath);
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Failed to read file ${fullPath}: ${error instanceof Error ? error.message : String(error)}` `Failed to read file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
); );
} }
} }
public async write( public async write(
relativePath: RelativePath, relativePath: RelativePath,
content: Uint8Array content: Uint8Array
): Promise<void> { ): Promise<void> {
const fullPath = path.join( const fullPath = path.join(
this.basePath, this.basePath,
this.toNativePath(relativePath) this.toNativePath(relativePath)
); );
const dir = path.dirname(fullPath); const dir = path.dirname(fullPath);
try { try {
await fs.mkdir(dir, { recursive: true }); await fs.mkdir(dir, { recursive: true });
await fs.writeFile(fullPath, content); await fs.writeFile(fullPath, content);
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}` `Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
); );
} }
} }
public async atomicUpdateText( public async atomicUpdateText(
relativePath: RelativePath, relativePath: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> { ): Promise<string> {
const fullPath = path.join( const fullPath = path.join(
this.basePath, this.basePath,
this.toNativePath(relativePath) this.toNativePath(relativePath)
); );
try { try {
const currentContent = await fs.readFile(fullPath, "utf-8"); const currentContent = await fs.readFile(fullPath, "utf-8");
const result = updater({ text: currentContent, cursors: [] }); const result = updater({ text: currentContent, cursors: [] });
await fs.writeFile(fullPath, result.text, "utf-8"); await fs.writeFile(fullPath, result.text, "utf-8");
return result.text; return result.text;
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Failed to atomically update file ${fullPath}: ${error instanceof Error ? error.message : String(error)}` `Failed to atomically update file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
); );
} }
} }
public async getFileSize(relativePath: RelativePath): Promise<number> { public async getFileSize(relativePath: RelativePath): Promise<number> {
const fullPath = path.join( const fullPath = path.join(
this.basePath, this.basePath,
this.toNativePath(relativePath) this.toNativePath(relativePath)
); );
try { try {
const stats = await fs.stat(fullPath); const stats = await fs.stat(fullPath);
return stats.size; return stats.size;
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Failed to get file size for ${fullPath}: ${error instanceof Error ? error.message : String(error)}` `Failed to get file size for ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
); );
} }
} }
public async exists(relativePath: RelativePath): Promise<boolean> { public async exists(relativePath: RelativePath): Promise<boolean> {
const fullPath = path.join( const fullPath = path.join(
this.basePath, this.basePath,
this.toNativePath(relativePath) this.toNativePath(relativePath)
); );
try { try {
await fs.access(fullPath); await fs.access(fullPath);
return true; return true;
} catch { } catch {
return false; return false;
} }
} }
public async createDirectory(relativePath: RelativePath): Promise<void> { public async createDirectory(relativePath: RelativePath): Promise<void> {
const fullPath = path.join( const fullPath = path.join(
this.basePath, this.basePath,
this.toNativePath(relativePath) this.toNativePath(relativePath)
); );
try { try {
await fs.mkdir(fullPath, { recursive: false }); await fs.mkdir(fullPath, { recursive: false });
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Failed to create directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}` `Failed to create directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
); );
} }
} }
public async delete(relativePath: RelativePath): Promise<void> { public async delete(relativePath: RelativePath): Promise<void> {
const fullPath = path.join( const fullPath = path.join(
this.basePath, this.basePath,
this.toNativePath(relativePath) this.toNativePath(relativePath)
); );
try { try {
await fs.unlink(fullPath); await fs.unlink(fullPath);
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Failed to delete file ${fullPath}: ${error instanceof Error ? error.message : String(error)}` `Failed to delete file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
); );
} }
} }
public async rename( public async rename(
oldPath: RelativePath, oldPath: RelativePath,
newPath: RelativePath newPath: RelativePath
): Promise<void> { ): Promise<void> {
const oldFullPath = path.join( const oldFullPath = path.join(
this.basePath, this.basePath,
this.toNativePath(oldPath) this.toNativePath(oldPath)
); );
const newFullPath = path.join( const newFullPath = path.join(
this.basePath, this.basePath,
this.toNativePath(newPath) this.toNativePath(newPath)
); );
const newDir = path.dirname(newFullPath); const newDir = path.dirname(newFullPath);
try { try {
await fs.mkdir(newDir, { recursive: true }); await fs.mkdir(newDir, { recursive: true });
await fs.rename(oldFullPath, newFullPath); await fs.rename(oldFullPath, newFullPath);
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Failed to rename file from ${oldFullPath} to ${newFullPath}: ${error instanceof Error ? error.message : String(error)}` `Failed to rename file from ${oldFullPath} to ${newFullPath}: ${error instanceof Error ? error.message : String(error)}`
); );
} }
} }
private async walkDirectory( private async walkDirectory(
relativePath: string, relativePath: string,
files: RelativePath[] files: RelativePath[]
): Promise<void> { ): Promise<void> {
const fullPath = path.join(this.basePath, relativePath); const fullPath = path.join(this.basePath, relativePath);
let entries: Dirent[] = []; let entries: Dirent[] = [];
try { try {
entries = await fs.readdir(fullPath, { withFileTypes: true }); entries = await fs.readdir(fullPath, { withFileTypes: true });
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Failed to read directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}` `Failed to read directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
); );
} }
for (const entry of entries) { for (const entry of entries) {
const entryName = entry.name; const entryName = entry.name;
const entryRelativePath = path.join(relativePath, entryName); const entryRelativePath = path.join(relativePath, entryName);
if (entry.isDirectory()) { if (entry.isDirectory()) {
await this.walkDirectory(entryRelativePath, files); await this.walkDirectory(entryRelativePath, files);
} else if (entry.isFile()) { } else if (entry.isFile()) {
// Always return forward slashes // Always return forward slashes
files.push(this.toUnixPath(entryRelativePath)); files.push(this.toUnixPath(entryRelativePath));
} }
} }
} }
/** /**
* Convert a forward-slash path to native platform path separators * Convert a forward-slash path to native platform path separators
*/ */
private toNativePath(relativePath: string): string { private toNativePath(relativePath: string): string {
if (path.sep === "\\") { if (path.sep === "\\") {
return relativePath.replace(/\//g, "\\"); return relativePath.replace(/\//g, "\\");
} }
return relativePath; return relativePath;
} }
/** /**
* Convert a native platform path to forward slashes * Convert a native platform path to forward slashes
*/ */
private toUnixPath(nativePath: string): string { private toUnixPath(nativePath: string): string {
if (path.sep === "\\") { if (path.sep === "\\") {
return nativePath.replace(/\\/g, "/"); return nativePath.replace(/\\/g, "/");
} }
return nativePath; return nativePath;
} }
} }

View file

@ -0,0 +1 @@

View file

@ -85,8 +85,3 @@ If you have multiple URLs, you can also do:
## API Documentation ## API Documentation
See https://github.com/obsidianmd/obsidian-api See https://github.com/obsidianmd/obsidian-api

View file

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

View file

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

View file

@ -12,4 +12,4 @@
font-size: var(--font-smallest); font-size: var(--font-smallest);
font-style: italic; font-style: italic;
} }
} }

View file

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

View file

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

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