Apply editorconfig
This commit is contained in:
parent
496db06213
commit
4150eb2720
131 changed files with 17155 additions and 14368 deletions
|
|
@ -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
3590
docs/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,8 @@
|
||||||
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
<stop offset="0%" style="stop-color:#4A90E2;stop-opacity:1" />
|
<stop offset="0%" style="stop-color:#4A90E2;stop-opacity:1" />
|
||||||
<stop offset="100%" style="stop-color:#357ABD;stop-opacity:1" />
|
<stop offset="100%" style="stop-color:#357ABD;stop-opacity:1" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
|
|
@ -25,23 +25,23 @@
|
||||||
|
|
||||||
<!-- Link chain -->
|
<!-- Link chain -->
|
||||||
<g opacity="0.9">
|
<g opacity="0.9">
|
||||||
<!-- Left link -->
|
<!-- Left link -->
|
||||||
<ellipse cx="-30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
<ellipse cx="-30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
||||||
<!-- Right link -->
|
<!-- Right link -->
|
||||||
<ellipse cx="30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
<ellipse cx="30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
||||||
<!-- Center link connecting them -->
|
<!-- Center link connecting them -->
|
||||||
<ellipse cx="0" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
<ellipse cx="0" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- Sync arrows (subtle) -->
|
<!-- Sync arrows (subtle) -->
|
||||||
<g opacity="0.5">
|
<g opacity="0.5">
|
||||||
<!-- Clockwise arrow top-right -->
|
<!-- Clockwise arrow top-right -->
|
||||||
<path d="M 35 -35 Q 50 -35 50 -20 L 50 -15" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
|
<path d="M 35 -35 Q 50 -35 50 -20 L 50 -15" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
<polygon points="50,-15 47,-22 53,-22" fill="url(#grad1)"/>
|
<polygon points="50,-15 47,-22 53,-22" fill="url(#grad1)"/>
|
||||||
|
|
||||||
<!-- Counter-clockwise arrow bottom-left -->
|
<!-- Counter-clockwise arrow bottom-left -->
|
||||||
<path d="M -35 25 Q -50 25 -50 10 L -50 5" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
|
<path d="M -35 25 Q -50 25 -50 10 L -50 5" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
<polygon points="-50,5 -47,12 -53,12" fill="url(#grad1)"/>
|
<polygon points="-50,5 -47,12 -53,12" fill="url(#grad1)"/>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 2 KiB |
|
|
@ -3,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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -4,227 +4,227 @@ import { parseArgs } from "./args";
|
||||||
import { LogLevel } from "sync-client";
|
import { LogLevel } from "sync-client";
|
||||||
|
|
||||||
test("parseArgs - parse basic arguments", () => {
|
test("parseArgs - parse basic arguments", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default"
|
"default"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.equal(args.localPath, "/path/to/vault");
|
assert.equal(args.localPath, "/path/to/vault");
|
||||||
assert.equal(args.remoteUri, "https://sync.example.com");
|
assert.equal(args.remoteUri, "https://sync.example.com");
|
||||||
assert.equal(args.token, "mytoken");
|
assert.equal(args.token, "mytoken");
|
||||||
assert.equal(args.vaultName, "default");
|
assert.equal(args.vaultName, "default");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - parse long form arguments", () => {
|
test("parseArgs - parse long form arguments", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"--local-path",
|
"--local-path",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"--remote-uri",
|
"--remote-uri",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"--token",
|
"--token",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"--vault-name",
|
"--vault-name",
|
||||||
"default"
|
"default"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.equal(args.localPath, "/path/to/vault");
|
assert.equal(args.localPath, "/path/to/vault");
|
||||||
assert.equal(args.remoteUri, "https://sync.example.com");
|
assert.equal(args.remoteUri, "https://sync.example.com");
|
||||||
assert.equal(args.token, "mytoken");
|
assert.equal(args.token, "mytoken");
|
||||||
assert.equal(args.vaultName, "default");
|
assert.equal(args.vaultName, "default");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - parse with optional arguments", () => {
|
test("parseArgs - parse with optional arguments", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default",
|
"default",
|
||||||
"--sync-concurrency",
|
"--sync-concurrency",
|
||||||
"5",
|
"5",
|
||||||
"--max-file-size-mb",
|
"--max-file-size-mb",
|
||||||
"20"
|
"20"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.equal(args.syncConcurrency, 5);
|
assert.equal(args.syncConcurrency, 5);
|
||||||
assert.equal(args.maxFileSizeMB, 20);
|
assert.equal(args.maxFileSizeMB, 20);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - parse with multiple ignore patterns", () => {
|
test("parseArgs - parse with multiple ignore patterns", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default",
|
"default",
|
||||||
"--ignore-pattern",
|
"--ignore-pattern",
|
||||||
".git/**",
|
".git/**",
|
||||||
"*.tmp"
|
"*.tmp"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.deepEqual(args.ignorePatterns, [".git/**", "*.tmp"]);
|
assert.deepEqual(args.ignorePatterns, [".git/**", "*.tmp"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - throws on missing required arguments", () => {
|
test("parseArgs - throws on missing required arguments", () => {
|
||||||
assert.throws(() => {
|
assert.throws(() => {
|
||||||
parseArgs(["node", "cli.js", "-r", "https://sync.example.com"]);
|
parseArgs(["node", "cli.js", "-r", "https://sync.example.com"]);
|
||||||
}, /required option/);
|
}, /required option/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - throws on missing remote uri", () => {
|
test("parseArgs - throws on missing remote uri", () => {
|
||||||
assert.throws(() => {
|
assert.throws(() => {
|
||||||
parseArgs([
|
parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default"
|
"default"
|
||||||
]);
|
]);
|
||||||
}, /--remote-uri/);
|
}, /--remote-uri/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - throws on missing token", () => {
|
test("parseArgs - throws on missing token", () => {
|
||||||
assert.throws(() => {
|
assert.throws(() => {
|
||||||
parseArgs([
|
parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-v",
|
"-v",
|
||||||
"default"
|
"default"
|
||||||
]);
|
]);
|
||||||
}, /--token/);
|
}, /--token/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - throws on missing vault name", () => {
|
test("parseArgs - throws on missing vault name", () => {
|
||||||
assert.throws(() => {
|
assert.throws(() => {
|
||||||
parseArgs([
|
parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken"
|
"mytoken"
|
||||||
]);
|
]);
|
||||||
}, /--vault-name/);
|
}, /--vault-name/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - default log level is INFO", () => {
|
test("parseArgs - default log level is INFO", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default"
|
"default"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.equal(args.logLevel, LogLevel.INFO);
|
assert.equal(args.logLevel, LogLevel.INFO);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - parse DEBUG log level", () => {
|
test("parseArgs - parse DEBUG log level", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default",
|
"default",
|
||||||
"--log-level",
|
"--log-level",
|
||||||
"DEBUG"
|
"DEBUG"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.equal(args.logLevel, LogLevel.DEBUG);
|
assert.equal(args.logLevel, LogLevel.DEBUG);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - parse ERROR log level", () => {
|
test("parseArgs - parse ERROR log level", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default",
|
"default",
|
||||||
"--log-level",
|
"--log-level",
|
||||||
"ERROR"
|
"ERROR"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.equal(args.logLevel, LogLevel.ERROR);
|
assert.equal(args.logLevel, LogLevel.ERROR);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - log level is case insensitive", () => {
|
test("parseArgs - log level is case insensitive", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default",
|
"default",
|
||||||
"--log-level",
|
"--log-level",
|
||||||
"debug"
|
"debug"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.equal(args.logLevel, LogLevel.DEBUG);
|
assert.equal(args.logLevel, LogLevel.DEBUG);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - throws on invalid log level", () => {
|
test("parseArgs - throws on invalid log level", () => {
|
||||||
assert.throws(() => {
|
assert.throws(() => {
|
||||||
parseArgs([
|
parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default",
|
"default",
|
||||||
"--log-level",
|
"--log-level",
|
||||||
"INVALID"
|
"INVALID"
|
||||||
]);
|
]);
|
||||||
}, /Invalid log level/);
|
}, /Invalid log level/);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,134 +3,134 @@ import packageJson from "../package.json";
|
||||||
import { LogLevel } from "sync-client";
|
import { LogLevel } from "sync-client";
|
||||||
|
|
||||||
export interface CliArgs {
|
export interface CliArgs {
|
||||||
remoteUri: string;
|
remoteUri: string;
|
||||||
token: string;
|
token: string;
|
||||||
vaultName: string;
|
vaultName: string;
|
||||||
localPath: string;
|
localPath: string;
|
||||||
syncConcurrency?: number;
|
syncConcurrency?: number;
|
||||||
maxFileSizeMB?: number;
|
maxFileSizeMB?: number;
|
||||||
ignorePatterns?: string[];
|
ignorePatterns?: string[];
|
||||||
webSocketRetryIntervalMs?: number;
|
webSocketRetryIntervalMs?: number;
|
||||||
logLevel: LogLevel;
|
logLevel: LogLevel;
|
||||||
health?: string;
|
health?: string;
|
||||||
enableTelemetry?: boolean;
|
enableTelemetry?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseArgs(argv: string[]): CliArgs {
|
export function parseArgs(argv: string[]): CliArgs {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
program
|
program
|
||||||
.name("vaultlink")
|
.name("vaultlink")
|
||||||
.description(
|
.description(
|
||||||
"VaultLink Local CLI - Sync your vault to the local filesystem"
|
"VaultLink Local CLI - Sync your vault to the local filesystem"
|
||||||
)
|
)
|
||||||
.version(packageJson.version)
|
.version(packageJson.version)
|
||||||
.option("-l, --local-path <path>", "Local directory path to sync")
|
.option("-l, --local-path <path>", "Local directory path to sync")
|
||||||
.option("-r, --remote-uri <uri>", "Remote server URI")
|
.option("-r, --remote-uri <uri>", "Remote server URI")
|
||||||
.option("-t, --token <token>", "Authentication token")
|
.option("-t, --token <token>", "Authentication token")
|
||||||
.option("-v, --vault-name <name>", "Vault name")
|
.option("-v, --vault-name <name>", "Vault name")
|
||||||
.option(
|
.option(
|
||||||
"--sync-concurrency <number>",
|
"--sync-concurrency <number>",
|
||||||
"[OPTIONAL] Number of concurrent sync operations",
|
"[OPTIONAL] Number of concurrent sync operations",
|
||||||
parseInt
|
parseInt
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--max-file-size-mb <number>",
|
"--max-file-size-mb <number>",
|
||||||
"[OPTIONAL] Maximum file size in MB",
|
"[OPTIONAL] Maximum file size in MB",
|
||||||
parseInt
|
parseInt
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--ignore-pattern <pattern...>",
|
"--ignore-pattern <pattern...>",
|
||||||
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
|
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--websocket-retry-interval-ms <number>",
|
"--websocket-retry-interval-ms <number>",
|
||||||
"[OPTIONAL] WebSocket retry interval in milliseconds",
|
"[OPTIONAL] WebSocket retry interval in milliseconds",
|
||||||
parseInt
|
parseInt
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--log-level <level>",
|
"--log-level <level>",
|
||||||
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)",
|
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)",
|
||||||
"INFO"
|
"INFO"
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--health <path>",
|
"--health <path>",
|
||||||
"[OPTIONAL] Path to health status file for Docker healthcheck"
|
"[OPTIONAL] Path to health status file for Docker healthcheck"
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--enable-telemetry",
|
"--enable-telemetry",
|
||||||
"[OPTIONAL] Enable telemetry (disabled by default)"
|
"[OPTIONAL] Enable telemetry (disabled by default)"
|
||||||
)
|
)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
"after",
|
"after",
|
||||||
`
|
`
|
||||||
Examples:
|
Examples:
|
||||||
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default
|
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default
|
||||||
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
|
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
|
||||||
--ignore-pattern ".git/**" --ignore-pattern "*.tmp"
|
--ignore-pattern ".git/**" --ignore-pattern "*.tmp"
|
||||||
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
|
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
|
||||||
--log-level DEBUG
|
--log-level DEBUG
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
|
|
||||||
program.parse(argv);
|
program.parse(argv);
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
const localPath = opts.localPath as string | undefined;
|
const localPath = opts.localPath as string | undefined;
|
||||||
const remoteUri = opts.remoteUri as string | undefined;
|
const remoteUri = opts.remoteUri as string | undefined;
|
||||||
const token = opts.token as string | undefined;
|
const token = opts.token as string | undefined;
|
||||||
const vaultName = opts.vaultName as string | undefined;
|
const vaultName = opts.vaultName as string | undefined;
|
||||||
const syncConcurrency = opts.syncConcurrency as number | undefined;
|
const syncConcurrency = opts.syncConcurrency as number | undefined;
|
||||||
const maxFileSizeMb = opts.maxFileSizeMb as number | undefined;
|
const maxFileSizeMb = opts.maxFileSizeMb as number | undefined;
|
||||||
const ignorePattern = opts.ignorePattern as string[] | undefined;
|
const ignorePattern = opts.ignorePattern as string[] | undefined;
|
||||||
const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as
|
const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as
|
||||||
| number
|
| number
|
||||||
| undefined;
|
| undefined;
|
||||||
const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO";
|
const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO";
|
||||||
const health = opts.health as string | undefined;
|
const health = opts.health as string | undefined;
|
||||||
const enableTelemetry = opts.enableTelemetry as boolean | undefined;
|
const enableTelemetry = opts.enableTelemetry as boolean | undefined;
|
||||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
||||||
|
|
||||||
if (localPath === undefined) {
|
if (localPath === undefined) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"required option '-l, --local-path <path>' not specified"
|
"required option '-l, --local-path <path>' not specified"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (remoteUri === undefined) {
|
if (remoteUri === undefined) {
|
||||||
throw new Error("required option '--remote-uri <uri>' not specified");
|
throw new Error("required option '--remote-uri <uri>' not specified");
|
||||||
}
|
}
|
||||||
if (token === undefined) {
|
if (token === undefined) {
|
||||||
throw new Error("required option '--token <token>' not specified");
|
throw new Error("required option '--token <token>' not specified");
|
||||||
}
|
}
|
||||||
if (vaultName === undefined) {
|
if (vaultName === undefined) {
|
||||||
throw new Error("required option '--vault-name <name>' not specified");
|
throw new Error("required option '--vault-name <name>' not specified");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and parse log level
|
// Validate and parse log level
|
||||||
const logLevelUpper = logLevelStr.toUpperCase();
|
const logLevelUpper = logLevelStr.toUpperCase();
|
||||||
const validLogLevels = Object.values(LogLevel);
|
const validLogLevels = Object.values(LogLevel);
|
||||||
const isLogLevel = (value: string): value is LogLevel => {
|
const isLogLevel = (value: string): value is LogLevel => {
|
||||||
return (validLogLevels as readonly string[]).includes(value);
|
return (validLogLevels as readonly string[]).includes(value);
|
||||||
};
|
};
|
||||||
if (!isLogLevel(logLevelUpper)) {
|
if (!isLogLevel(logLevelUpper)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid log level '${logLevelStr}'. Valid values are: ${validLogLevels.join(", ")}`
|
`Invalid log level '${logLevelStr}'. Valid values are: ${validLogLevels.join(", ")}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const logLevel = logLevelUpper;
|
const logLevel = logLevelUpper;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
localPath,
|
localPath,
|
||||||
remoteUri,
|
remoteUri,
|
||||||
token,
|
token,
|
||||||
vaultName,
|
vaultName,
|
||||||
syncConcurrency,
|
syncConcurrency,
|
||||||
maxFileSizeMB: maxFileSizeMb,
|
maxFileSizeMB: maxFileSizeMb,
|
||||||
ignorePatterns: ignorePattern,
|
ignorePatterns: ignorePattern,
|
||||||
webSocketRetryIntervalMs: websocketRetryIntervalMs,
|
webSocketRetryIntervalMs: websocketRetryIntervalMs,
|
||||||
logLevel,
|
logLevel,
|
||||||
health,
|
health,
|
||||||
enableTelemetry
|
enableTelemetry
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@ import * as fs from "fs/promises";
|
||||||
import * as fsSync from "fs";
|
import * as fsSync from "fs";
|
||||||
import type { NetworkConnectionStatus } from "sync-client";
|
import type { NetworkConnectionStatus } from "sync-client";
|
||||||
import {
|
import {
|
||||||
SyncClient,
|
SyncClient,
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
type SyncSettings,
|
type SyncSettings,
|
||||||
type StoredDatabase
|
type StoredDatabase
|
||||||
} from "sync-client";
|
} from "sync-client";
|
||||||
import { parseArgs } from "./args";
|
import { parseArgs } from "./args";
|
||||||
import { NodeFileSystemOperations } from "./node-filesystem";
|
import { NodeFileSystemOperations } from "./node-filesystem";
|
||||||
|
|
@ -16,229 +16,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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,58 +9,58 @@ import * as fs from "fs";
|
||||||
import type { NetworkConnectionStatus } from "sync-client";
|
import type { NetworkConnectionStatus } from "sync-client";
|
||||||
|
|
||||||
function isHealthStatus(value: unknown): value is NetworkConnectionStatus {
|
function isHealthStatus(value: unknown): value is NetworkConnectionStatus {
|
||||||
if (typeof value !== "object" || value === null) {
|
if (typeof value !== "object" || value === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
"isSuccessful" in value &&
|
"isSuccessful" in value &&
|
||||||
typeof value.isSuccessful === "boolean" &&
|
typeof value.isSuccessful === "boolean" &&
|
||||||
"isWebSocketConnected" in value &&
|
"isWebSocketConnected" in value &&
|
||||||
typeof value.isWebSocketConnected === "boolean" &&
|
typeof value.isWebSocketConnected === "boolean" &&
|
||||||
"serverMessage" in value &&
|
"serverMessage" in value &&
|
||||||
typeof value.serverMessage === "string"
|
typeof value.serverMessage === "string"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function main(): void {
|
function main(): void {
|
||||||
if (process.argv.length < 3) {
|
if (process.argv.length < 3) {
|
||||||
console.error("Usage: healthcheck <path-to-health-file>");
|
console.error("Usage: healthcheck <path-to-health-file>");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const [, , healthFile] = process.argv;
|
const [, , healthFile] = process.argv;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if health file exists
|
// Check if health file exists
|
||||||
if (!fs.existsSync(healthFile)) {
|
if (!fs.existsSync(healthFile)) {
|
||||||
console.error(`Health file does not exist: ${healthFile}`);
|
console.error(`Health file does not exist: ${healthFile}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read and parse health status
|
// Read and parse health status
|
||||||
const content = fs.readFileSync(healthFile, "utf-8");
|
const content = fs.readFileSync(healthFile, "utf-8");
|
||||||
const parsed: unknown = JSON.parse(content);
|
const parsed: unknown = JSON.parse(content);
|
||||||
|
|
||||||
// Validate the parsed object using type guard
|
// Validate the parsed object using type guard
|
||||||
if (!isHealthStatus(parsed)) {
|
if (!isHealthStatus(parsed)) {
|
||||||
throw new Error("Invalid health status format");
|
throw new Error("Invalid health status format");
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = parsed;
|
const status = parsed;
|
||||||
|
|
||||||
if (!status.isSuccessful || !status.isWebSocketConnected) {
|
if (!status.isSuccessful || !status.isWebSocketConnected) {
|
||||||
console.error("Not connected to server: " + status.serverMessage);
|
console.error("Not connected to server: " + status.serverMessage);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Healthy: Connected to server");
|
console.log("Healthy: Connected to server");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`Health check failed: ${error instanceof Error ? error.message : String(error)}`
|
`Health check failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
|
|
|
||||||
|
|
@ -2,85 +2,85 @@ import { LogLevel, type LogLine } from "sync-client";
|
||||||
|
|
||||||
// ANSI color codes
|
// ANSI color codes
|
||||||
export const colors = {
|
export const colors = {
|
||||||
reset: "\x1b[0m",
|
reset: "\x1b[0m",
|
||||||
bold: "\x1b[1m",
|
bold: "\x1b[1m",
|
||||||
dim: "\x1b[2m",
|
dim: "\x1b[2m",
|
||||||
|
|
||||||
// Foreground colors
|
// Foreground colors
|
||||||
red: "\x1b[31m",
|
red: "\x1b[31m",
|
||||||
green: "\x1b[32m",
|
green: "\x1b[32m",
|
||||||
yellow: "\x1b[33m",
|
yellow: "\x1b[33m",
|
||||||
blue: "\x1b[34m",
|
blue: "\x1b[34m",
|
||||||
magenta: "\x1b[35m",
|
magenta: "\x1b[35m",
|
||||||
cyan: "\x1b[36m",
|
cyan: "\x1b[36m",
|
||||||
gray: "\x1b[90m"
|
gray: "\x1b[90m"
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function colorize(text: string, color: keyof typeof colors): string {
|
export function colorize(text: string, color: keyof typeof colors): string {
|
||||||
return `${colors[color]}${text}${colors.reset}`;
|
return `${colors[color]}${text}${colors.reset}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function to apply multiple color modifiers to text
|
* Helper function to apply multiple color modifiers to text
|
||||||
*/
|
*/
|
||||||
export function styleText(
|
export function styleText(
|
||||||
text: string,
|
text: string,
|
||||||
...modifiers: (keyof typeof colors)[]
|
...modifiers: (keyof typeof colors)[]
|
||||||
): string {
|
): string {
|
||||||
const prefix = modifiers.map((m) => colors[m]).join("");
|
const prefix = modifiers.map((m) => colors[m]).join("");
|
||||||
return `${prefix}${text}${colors.reset}`;
|
return `${prefix}${text}${colors.reset}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(date: Date): string {
|
function formatTimestamp(date: Date): string {
|
||||||
const [time] = date.toTimeString().split(" ");
|
const [time] = date.toTimeString().split(" ");
|
||||||
const ms = date.getMilliseconds().toString().padStart(3, "0");
|
const ms = date.getMilliseconds().toString().padStart(3, "0");
|
||||||
return colorize(`${time}.${ms}`, "gray");
|
return colorize(`${time}.${ms}`, "gray");
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLevel(level: LogLevel): string {
|
function formatLevel(level: LogLevel): string {
|
||||||
const levelStr = level.padEnd(7);
|
const levelStr = level.padEnd(7);
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case LogLevel.DEBUG:
|
case LogLevel.DEBUG:
|
||||||
return colorize(levelStr, "cyan");
|
return colorize(levelStr, "cyan");
|
||||||
case LogLevel.INFO:
|
case LogLevel.INFO:
|
||||||
return colorize(levelStr, "green");
|
return colorize(levelStr, "green");
|
||||||
case LogLevel.WARNING:
|
case LogLevel.WARNING:
|
||||||
return colorize(levelStr, "yellow");
|
return colorize(levelStr, "yellow");
|
||||||
case LogLevel.ERROR:
|
case LogLevel.ERROR:
|
||||||
return colorize(levelStr, "red");
|
return colorize(levelStr, "red");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMessage(message: string, level: LogLevel): string {
|
function formatMessage(message: string, level: LogLevel): string {
|
||||||
// Highlight important parts of the message
|
// Highlight important parts of the message
|
||||||
let formatted = message;
|
let formatted = message;
|
||||||
|
|
||||||
// Highlight file paths
|
// Highlight file paths
|
||||||
formatted = formatted.replace(
|
formatted = formatted.replace(
|
||||||
/(['"])([^'"]*?\.(json|txt|md|js|ts))(['"])/g,
|
/(['"])([^'"]*?\.(json|txt|md|js|ts))(['"])/g,
|
||||||
(_, q1, path, _ext, q2) => q1 + colorize(path, "magenta") + q2
|
(_, q1, path, _ext, q2) => q1 + colorize(path, "magenta") + q2
|
||||||
);
|
);
|
||||||
|
|
||||||
// Highlight numbers
|
// Highlight numbers
|
||||||
formatted = formatted.replace(/\b(\d+)\b/g, (num) => colorize(num, "cyan"));
|
formatted = formatted.replace(/\b(\d+)\b/g, (num) => colorize(num, "cyan"));
|
||||||
|
|
||||||
// Highlight patterns like /regex/
|
// Highlight patterns like /regex/
|
||||||
formatted = formatted.replace(/(\/\^[^$]*\$\/)/g, (pattern) =>
|
formatted = formatted.replace(/(\/\^[^$]*\$\/)/g, (pattern) =>
|
||||||
colorize(pattern, "yellow")
|
colorize(pattern, "yellow")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Make error messages bold
|
// Make error messages bold
|
||||||
if (level === LogLevel.ERROR) {
|
if (level === LogLevel.ERROR) {
|
||||||
formatted = colorize(formatted, "bold");
|
formatted = colorize(formatted, "bold");
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatted;
|
return formatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatLogLine(logLine: LogLine): string {
|
export function formatLogLine(logLine: LogLine): string {
|
||||||
const timestamp = formatTimestamp(logLine.timestamp);
|
const timestamp = formatTimestamp(logLine.timestamp);
|
||||||
const level = formatLevel(logLine.level);
|
const level = formatLevel(logLine.level);
|
||||||
const message = formatMessage(logLine.message, logLine.level);
|
const message = formatMessage(logLine.message, logLine.level);
|
||||||
|
|
||||||
return `${timestamp} ${level} ${message}`;
|
return `${timestamp} ${level} ${message}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,157 +6,157 @@ import * as os from "os";
|
||||||
import { NodeFileSystemOperations } from "./node-filesystem";
|
import { NodeFileSystemOperations } from "./node-filesystem";
|
||||||
|
|
||||||
test("NodeFileSystemOperations - read and write files", async () => {
|
test("NodeFileSystemOperations - read and write files", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = new TextEncoder().encode("Hello, world!");
|
const content = new TextEncoder().encode("Hello, world!");
|
||||||
await fsOps.write("test.txt", content);
|
await fsOps.write("test.txt", content);
|
||||||
|
|
||||||
const readContent = await fsOps.read("test.txt");
|
const readContent = await fsOps.read("test.txt");
|
||||||
assert.equal(new TextDecoder().decode(readContent), "Hello, world!");
|
assert.equal(new TextDecoder().decode(readContent), "Hello, world!");
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NodeFileSystemOperations - create nested directories with forward slashes", async () => {
|
test("NodeFileSystemOperations - create nested directories with forward slashes", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = new TextEncoder().encode("Nested file");
|
const content = new TextEncoder().encode("Nested file");
|
||||||
// Always use forward slashes in API
|
// Always use forward slashes in API
|
||||||
await fsOps.write("dir1/dir2/test.txt", content);
|
await fsOps.write("dir1/dir2/test.txt", content);
|
||||||
|
|
||||||
const readContent = await fsOps.read("dir1/dir2/test.txt");
|
const readContent = await fsOps.read("dir1/dir2/test.txt");
|
||||||
assert.equal(new TextDecoder().decode(readContent), "Nested file");
|
assert.equal(new TextDecoder().decode(readContent), "Nested file");
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NodeFileSystemOperations - exists with forward slashes", async () => {
|
test("NodeFileSystemOperations - exists with forward slashes", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
assert.equal(await fsOps.exists("test.txt"), false);
|
assert.equal(await fsOps.exists("test.txt"), false);
|
||||||
|
|
||||||
await fsOps.write("test.txt", new TextEncoder().encode("test"));
|
await fsOps.write("test.txt", new TextEncoder().encode("test"));
|
||||||
|
|
||||||
assert.equal(await fsOps.exists("test.txt"), true);
|
assert.equal(await fsOps.exists("test.txt"), true);
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NodeFileSystemOperations - delete with forward slashes", async () => {
|
test("NodeFileSystemOperations - delete with forward slashes", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fsOps.write("test.txt", new TextEncoder().encode("test"));
|
await fsOps.write("test.txt", new TextEncoder().encode("test"));
|
||||||
assert.equal(await fsOps.exists("test.txt"), true);
|
assert.equal(await fsOps.exists("test.txt"), true);
|
||||||
|
|
||||||
await fsOps.delete("test.txt");
|
await fsOps.delete("test.txt");
|
||||||
assert.equal(await fsOps.exists("test.txt"), false);
|
assert.equal(await fsOps.exists("test.txt"), false);
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NodeFileSystemOperations - rename with forward slashes", async () => {
|
test("NodeFileSystemOperations - rename with forward slashes", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = new TextEncoder().encode("test content");
|
const content = new TextEncoder().encode("test content");
|
||||||
await fsOps.write("old.txt", content);
|
await fsOps.write("old.txt", content);
|
||||||
|
|
||||||
await fsOps.rename("old.txt", "new.txt");
|
await fsOps.rename("old.txt", "new.txt");
|
||||||
|
|
||||||
assert.equal(await fsOps.exists("old.txt"), false);
|
assert.equal(await fsOps.exists("old.txt"), false);
|
||||||
assert.equal(await fsOps.exists("new.txt"), true);
|
assert.equal(await fsOps.exists("new.txt"), true);
|
||||||
|
|
||||||
const readContent = await fsOps.read("new.txt");
|
const readContent = await fsOps.read("new.txt");
|
||||||
assert.equal(new TextDecoder().decode(readContent), "test content");
|
assert.equal(new TextDecoder().decode(readContent), "test content");
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NodeFileSystemOperations - rename to nested path with forward slashes", async () => {
|
test("NodeFileSystemOperations - rename to nested path with forward slashes", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = new TextEncoder().encode("test content");
|
const content = new TextEncoder().encode("test content");
|
||||||
await fsOps.write("old.txt", content);
|
await fsOps.write("old.txt", content);
|
||||||
|
|
||||||
await fsOps.rename("old.txt", "dir1/dir2/new.txt");
|
await fsOps.rename("old.txt", "dir1/dir2/new.txt");
|
||||||
|
|
||||||
assert.equal(await fsOps.exists("old.txt"), false);
|
assert.equal(await fsOps.exists("old.txt"), false);
|
||||||
assert.equal(await fsOps.exists("dir1/dir2/new.txt"), true);
|
assert.equal(await fsOps.exists("dir1/dir2/new.txt"), true);
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NodeFileSystemOperations - getFileSize", async () => {
|
test("NodeFileSystemOperations - getFileSize", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = new TextEncoder().encode("Hello!");
|
const content = new TextEncoder().encode("Hello!");
|
||||||
await fsOps.write("test.txt", content);
|
await fsOps.write("test.txt", content);
|
||||||
|
|
||||||
const size = await fsOps.getFileSize("test.txt");
|
const size = await fsOps.getFileSize("test.txt");
|
||||||
assert.equal(size, content.length);
|
assert.equal(size, content.length);
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NodeFileSystemOperations - atomicUpdateText", async () => {
|
test("NodeFileSystemOperations - atomicUpdateText", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fsOps.write("test.txt", new TextEncoder().encode("Hello"));
|
await fsOps.write("test.txt", new TextEncoder().encode("Hello"));
|
||||||
|
|
||||||
const result = await fsOps.atomicUpdateText("test.txt", (current) => ({
|
const result = await fsOps.atomicUpdateText("test.txt", (current) => ({
|
||||||
text: current.text + " World",
|
text: current.text + " World",
|
||||||
cursors: []
|
cursors: []
|
||||||
}));
|
}));
|
||||||
|
|
||||||
assert.equal(result, "Hello World");
|
assert.equal(result, "Hello World");
|
||||||
|
|
||||||
const content = await fsOps.read("test.txt");
|
const content = await fsOps.read("test.txt");
|
||||||
assert.equal(new TextDecoder().decode(content), "Hello World");
|
assert.equal(new TextDecoder().decode(content), "Hello World");
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NodeFileSystemOperations - handles paths with forward slashes on all platforms", async () => {
|
test("NodeFileSystemOperations - handles paths with forward slashes on all platforms", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// API should always accept forward slashes
|
// API should always accept forward slashes
|
||||||
const testPath = "deep/nested/directory/file.txt";
|
const testPath = "deep/nested/directory/file.txt";
|
||||||
const content = new TextEncoder().encode("test");
|
const content = new TextEncoder().encode("test");
|
||||||
|
|
||||||
await fsOps.write(testPath, content);
|
await fsOps.write(testPath, content);
|
||||||
assert.equal(await fsOps.exists(testPath), true);
|
assert.equal(await fsOps.exists(testPath), true);
|
||||||
|
|
||||||
const readContent = await fsOps.read(testPath);
|
const readContent = await fsOps.read(testPath);
|
||||||
assert.equal(new TextDecoder().decode(readContent), "test");
|
assert.equal(new TextDecoder().decode(readContent), "test");
|
||||||
|
|
||||||
await fsOps.delete(testPath);
|
await fsOps.delete(testPath);
|
||||||
assert.equal(await fsOps.exists(testPath), false);
|
assert.equal(await fsOps.exists(testPath), false);
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,205 +2,205 @@ import * as fs from "fs/promises";
|
||||||
import type { Dirent } from "fs";
|
import type { Dirent } from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import type {
|
import type {
|
||||||
FileSystemOperations,
|
FileSystemOperations,
|
||||||
RelativePath,
|
RelativePath,
|
||||||
TextWithCursors
|
TextWithCursors
|
||||||
} from "sync-client";
|
} from "sync-client";
|
||||||
|
|
||||||
export class NodeFileSystemOperations implements FileSystemOperations {
|
export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
public constructor(private readonly basePath: string) {}
|
public constructor(private readonly basePath: string) {}
|
||||||
|
|
||||||
public async listFilesRecursively(
|
public async listFilesRecursively(
|
||||||
directory: RelativePath | undefined
|
directory: RelativePath | undefined
|
||||||
): Promise<RelativePath[]> {
|
): Promise<RelativePath[]> {
|
||||||
const files: RelativePath[] = [];
|
const files: RelativePath[] = [];
|
||||||
await this.walkDirectory(
|
await this.walkDirectory(
|
||||||
directory !== undefined ? this.toNativePath(directory) : "",
|
directory !== undefined ? this.toNativePath(directory) : "",
|
||||||
files
|
files
|
||||||
);
|
);
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async read(relativePath: RelativePath): Promise<Uint8Array> {
|
public async read(relativePath: RelativePath): Promise<Uint8Array> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
this.toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
return await fs.readFile(fullPath);
|
return await fs.readFile(fullPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to read file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to read file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async write(
|
public async write(
|
||||||
relativePath: RelativePath,
|
relativePath: RelativePath,
|
||||||
content: Uint8Array
|
content: Uint8Array
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
this.toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
const dir = path.dirname(fullPath);
|
const dir = path.dirname(fullPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(dir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
await fs.writeFile(fullPath, content);
|
await fs.writeFile(fullPath, content);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async atomicUpdateText(
|
public async atomicUpdateText(
|
||||||
relativePath: RelativePath,
|
relativePath: RelativePath,
|
||||||
updater: (current: TextWithCursors) => TextWithCursors
|
updater: (current: TextWithCursors) => TextWithCursors
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
this.toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentContent = await fs.readFile(fullPath, "utf-8");
|
const currentContent = await fs.readFile(fullPath, "utf-8");
|
||||||
const result = updater({ text: currentContent, cursors: [] });
|
const result = updater({ text: currentContent, cursors: [] });
|
||||||
await fs.writeFile(fullPath, result.text, "utf-8");
|
await fs.writeFile(fullPath, result.text, "utf-8");
|
||||||
return result.text;
|
return result.text;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to atomically update file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to atomically update file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getFileSize(relativePath: RelativePath): Promise<number> {
|
public async getFileSize(relativePath: RelativePath): Promise<number> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
this.toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const stats = await fs.stat(fullPath);
|
const stats = await fs.stat(fullPath);
|
||||||
return stats.size;
|
return stats.size;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to get file size for ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to get file size for ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async exists(relativePath: RelativePath): Promise<boolean> {
|
public async exists(relativePath: RelativePath): Promise<boolean> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
this.toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await fs.access(fullPath);
|
await fs.access(fullPath);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createDirectory(relativePath: RelativePath): Promise<void> {
|
public async createDirectory(relativePath: RelativePath): Promise<void> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
this.toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(fullPath, { recursive: false });
|
await fs.mkdir(fullPath, { recursive: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to create directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to create directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(relativePath: RelativePath): Promise<void> {
|
public async delete(relativePath: RelativePath): Promise<void> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
this.toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await fs.unlink(fullPath);
|
await fs.unlink(fullPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to delete file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to delete file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async rename(
|
public async rename(
|
||||||
oldPath: RelativePath,
|
oldPath: RelativePath,
|
||||||
newPath: RelativePath
|
newPath: RelativePath
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const oldFullPath = path.join(
|
const oldFullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(oldPath)
|
this.toNativePath(oldPath)
|
||||||
);
|
);
|
||||||
const newFullPath = path.join(
|
const newFullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(newPath)
|
this.toNativePath(newPath)
|
||||||
);
|
);
|
||||||
const newDir = path.dirname(newFullPath);
|
const newDir = path.dirname(newFullPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(newDir, { recursive: true });
|
await fs.mkdir(newDir, { recursive: true });
|
||||||
await fs.rename(oldFullPath, newFullPath);
|
await fs.rename(oldFullPath, newFullPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to rename file from ${oldFullPath} to ${newFullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to rename file from ${oldFullPath} to ${newFullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async walkDirectory(
|
private async walkDirectory(
|
||||||
relativePath: string,
|
relativePath: string,
|
||||||
files: RelativePath[]
|
files: RelativePath[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const fullPath = path.join(this.basePath, relativePath);
|
const fullPath = path.join(this.basePath, relativePath);
|
||||||
let entries: Dirent[] = [];
|
let entries: Dirent[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
entries = await fs.readdir(fullPath, { withFileTypes: true });
|
entries = await fs.readdir(fullPath, { withFileTypes: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to read directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to read directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const entryName = entry.name;
|
const entryName = entry.name;
|
||||||
const entryRelativePath = path.join(relativePath, entryName);
|
const entryRelativePath = path.join(relativePath, entryName);
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
await this.walkDirectory(entryRelativePath, files);
|
await this.walkDirectory(entryRelativePath, files);
|
||||||
} else if (entry.isFile()) {
|
} else if (entry.isFile()) {
|
||||||
// Always return forward slashes
|
// Always return forward slashes
|
||||||
files.push(this.toUnixPath(entryRelativePath));
|
files.push(this.toUnixPath(entryRelativePath));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a forward-slash path to native platform path separators
|
* Convert a forward-slash path to native platform path separators
|
||||||
*/
|
*/
|
||||||
private toNativePath(relativePath: string): string {
|
private toNativePath(relativePath: string): string {
|
||||||
if (path.sep === "\\") {
|
if (path.sep === "\\") {
|
||||||
return relativePath.replace(/\//g, "\\");
|
return relativePath.replace(/\//g, "\\");
|
||||||
}
|
}
|
||||||
return relativePath;
|
return relativePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a native platform path to forward slashes
|
* Convert a native platform path to forward slashes
|
||||||
*/
|
*/
|
||||||
private toUnixPath(nativePath: string): string {
|
private toUnixPath(nativePath: string): string {
|
||||||
if (path.sep === "\\") {
|
if (path.sep === "\\") {
|
||||||
return nativePath.replace(/\\/g, "/");
|
return nativePath.replace(/\\/g, "/");
|
||||||
}
|
}
|
||||||
return nativePath;
|
return nativePath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -85,8 +85,3 @@ If you have multiple URLs, you can also do:
|
||||||
## API Documentation
|
## API Documentation
|
||||||
|
|
||||||
See https://github.com/obsidianmd/obsidian-api
|
See https://github.com/obsidianmd/obsidian-api
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
{
|
{
|
||||||
"id": "vault-link",
|
"id": "vault-link",
|
||||||
"name": "VaultLink",
|
"name": "VaultLink",
|
||||||
"version": "0.12.0",
|
"version": "0.12.0",
|
||||||
"minAppVersion": "0.0.0",
|
"minAppVersion": "0.0.0",
|
||||||
"description": "Self-hosted synchronization and collaboration for your Vault.",
|
"description": "Self-hosted synchronization and collaboration for your Vault.",
|
||||||
"author": "Andras Schmelczer",
|
"author": "Andras Schmelczer",
|
||||||
"authorUrl": "https://schmelczer.dev",
|
"authorUrl": "https://schmelczer.dev",
|
||||||
"isDesktopOnly": false
|
"isDesktopOnly": false
|
||||||
}
|
}
|
||||||
|
|
@ -2,175 +2,175 @@ import type { Stat, Vault, Workspace } from "obsidian";
|
||||||
import { MarkdownView, normalizePath } from "obsidian";
|
import { MarkdownView, normalizePath } from "obsidian";
|
||||||
import type { CursorPosition, TextWithCursors } from "sync-client";
|
import type { CursorPosition, TextWithCursors } from "sync-client";
|
||||||
import {
|
import {
|
||||||
utils,
|
utils,
|
||||||
type FileSystemOperations,
|
type FileSystemOperations,
|
||||||
type RelativePath
|
type RelativePath
|
||||||
} from "sync-client";
|
} from "sync-client";
|
||||||
import { getSelectionsFromEditor } from "./views/cursors/get-selections-from-editor";
|
import { getSelectionsFromEditor } from "./views/cursors/get-selections-from-editor";
|
||||||
|
|
||||||
export class ObsidianFileSystemOperations implements FileSystemOperations {
|
export class ObsidianFileSystemOperations implements FileSystemOperations {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly vault: Vault,
|
private readonly vault: Vault,
|
||||||
private readonly workspace: Workspace
|
private readonly workspace: Workspace
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async listFilesRecursively(
|
public async listFilesRecursively(
|
||||||
root: RelativePath | undefined
|
root: RelativePath | undefined
|
||||||
): Promise<RelativePath[]> {
|
): Promise<RelativePath[]> {
|
||||||
// Let's implement this by hand because vault.adapter.listAllFiles doesn't always return all files.
|
// Let's implement this by hand because vault.adapter.listAllFiles doesn't always return all files.
|
||||||
const allFiles = [];
|
const allFiles = [];
|
||||||
const remainingFolders = [root ?? this.vault.getRoot().path];
|
const remainingFolders = [root ?? this.vault.getRoot().path];
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
while (true) {
|
while (true) {
|
||||||
const folder = remainingFolders.pop();
|
const folder = remainingFolders.pop();
|
||||||
if (folder == undefined) {
|
if (folder == undefined) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This would be a very bad idea to sync as it would mess with
|
// This would be a very bad idea to sync as it would mess with
|
||||||
// the integrity of the sync database.
|
// the integrity of the sync database.
|
||||||
if (folder.endsWith(".obsidian/plugins/vault-link/data.json")) {
|
if (folder.endsWith(".obsidian/plugins/vault-link/data.json")) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = await this.vault.adapter.list(normalizePath(folder));
|
const files = await this.vault.adapter.list(normalizePath(folder));
|
||||||
allFiles.push(...files.files);
|
allFiles.push(...files.files);
|
||||||
remainingFolders.push(...files.folders);
|
remainingFolders.push(...files.folders);
|
||||||
}
|
}
|
||||||
|
|
||||||
return allFiles;
|
return allFiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async read(path: RelativePath): Promise<Uint8Array> {
|
public async read(path: RelativePath): Promise<Uint8Array> {
|
||||||
path = normalizePath(path);
|
path = normalizePath(path);
|
||||||
const view = this.workspace.getActiveViewOfType(MarkdownView);
|
const view = this.workspace.getActiveViewOfType(MarkdownView);
|
||||||
if (view?.file?.path === path) {
|
if (view?.file?.path === path) {
|
||||||
return new TextEncoder().encode(view.editor.getValue());
|
return new TextEncoder().encode(view.editor.getValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Uint8Array(await this.vault.adapter.readBinary(path));
|
return new Uint8Array(await this.vault.adapter.readBinary(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
|
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
|
||||||
path = normalizePath(path);
|
path = normalizePath(path);
|
||||||
|
|
||||||
const view = this.workspace.getActiveViewOfType(MarkdownView);
|
const view = this.workspace.getActiveViewOfType(MarkdownView);
|
||||||
if (view?.file?.path === path) {
|
if (view?.file?.path === path) {
|
||||||
const position = view.editor.getCursor();
|
const position = view.editor.getCursor();
|
||||||
view.editor.setValue(new TextDecoder().decode(content));
|
view.editor.setValue(new TextDecoder().decode(content));
|
||||||
view.editor.setCursor(position);
|
view.editor.setCursor(position);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.vault.adapter.writeBinary(
|
return this.vault.adapter.writeBinary(
|
||||||
path,
|
path,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
content.buffer as ArrayBuffer
|
content.buffer as ArrayBuffer
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async atomicUpdateText(
|
public async atomicUpdateText(
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
updater: (current: TextWithCursors) => TextWithCursors
|
updater: (current: TextWithCursors) => TextWithCursors
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
path = normalizePath(path);
|
path = normalizePath(path);
|
||||||
|
|
||||||
const view = this.workspace.getActiveViewOfType(MarkdownView);
|
const view = this.workspace.getActiveViewOfType(MarkdownView);
|
||||||
|
|
||||||
if (view?.file?.path === path) {
|
if (view?.file?.path === path) {
|
||||||
const text = view.editor.getValue();
|
const text = view.editor.getValue();
|
||||||
|
|
||||||
const cursors: CursorPosition[] = getSelectionsFromEditor(
|
const cursors: CursorPosition[] = getSelectionsFromEditor(
|
||||||
view.editor
|
view.editor
|
||||||
).flatMap(({ id, start: anchor, end: head }) => [
|
).flatMap(({ id, start: anchor, end: head }) => [
|
||||||
{
|
{
|
||||||
id: 2 * id,
|
id: 2 * id,
|
||||||
position: anchor
|
position: anchor
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2 * id + 1,
|
id: 2 * id + 1,
|
||||||
position: head
|
position: head
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = updater({
|
const result = updater({
|
||||||
text,
|
text,
|
||||||
cursors
|
cursors
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.text === text) {
|
if (result.text === text) {
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
view.editor.setValue(result.text);
|
view.editor.setValue(result.text);
|
||||||
|
|
||||||
const selections = [];
|
const selections = [];
|
||||||
for (let i = 0; i < result.cursors.length / 2; i++) {
|
for (let i = 0; i < result.cursors.length / 2; i++) {
|
||||||
const from = result.cursors[2 * i];
|
const from = result.cursors[2 * i];
|
||||||
const to = result.cursors[2 * i + 1];
|
const to = result.cursors[2 * i + 1];
|
||||||
const { line: fromLine, column: fromColumn } =
|
const { line: fromLine, column: fromColumn } =
|
||||||
utils.positionToLineAndColumn(result.text, from.position);
|
utils.positionToLineAndColumn(result.text, from.position);
|
||||||
|
|
||||||
const { line: toLine, column: toColumn } =
|
const { line: toLine, column: toColumn } =
|
||||||
utils.positionToLineAndColumn(result.text, to.position);
|
utils.positionToLineAndColumn(result.text, to.position);
|
||||||
|
|
||||||
selections.push({
|
selections.push({
|
||||||
anchor: { line: fromLine, ch: fromColumn },
|
anchor: { line: fromLine, ch: fromColumn },
|
||||||
head: { line: toLine, ch: toColumn }
|
head: { line: toLine, ch: toColumn }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
view.editor.setSelections(selections);
|
view.editor.setSelections(selections);
|
||||||
|
|
||||||
return result.text;
|
return result.text;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.vault.adapter.process(
|
return this.vault.adapter.process(
|
||||||
path,
|
path,
|
||||||
(text) =>
|
(text) =>
|
||||||
updater({
|
updater({
|
||||||
text,
|
text,
|
||||||
cursors: []
|
cursors: []
|
||||||
}).text
|
}).text
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getFileSize(path: RelativePath): Promise<number> {
|
public async getFileSize(path: RelativePath): Promise<number> {
|
||||||
return (await this.statFile(path)).size;
|
return (await this.statFile(path)).size;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getModificationTime(path: RelativePath): Promise<Date> {
|
public async getModificationTime(path: RelativePath): Promise<Date> {
|
||||||
return new Date((await this.statFile(path)).mtime);
|
return new Date((await this.statFile(path)).mtime);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async exists(path: RelativePath): Promise<boolean> {
|
public async exists(path: RelativePath): Promise<boolean> {
|
||||||
return this.vault.adapter.exists(normalizePath(path));
|
return this.vault.adapter.exists(normalizePath(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createDirectory(path: RelativePath): Promise<void> {
|
public async createDirectory(path: RelativePath): Promise<void> {
|
||||||
return this.vault.adapter.mkdir(normalizePath(path));
|
return this.vault.adapter.mkdir(normalizePath(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(path: RelativePath): Promise<void> {
|
public async delete(path: RelativePath): Promise<void> {
|
||||||
if (!(await this.vault.adapter.trashSystem(normalizePath(path)))) {
|
if (!(await this.vault.adapter.trashSystem(normalizePath(path)))) {
|
||||||
return this.vault.adapter.remove(normalizePath(path));
|
return this.vault.adapter.remove(normalizePath(path));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async rename(
|
public async rename(
|
||||||
oldPath: RelativePath,
|
oldPath: RelativePath,
|
||||||
newPath: RelativePath
|
newPath: RelativePath
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return this.vault.adapter.rename(oldPath, newPath);
|
return this.vault.adapter.rename(oldPath, newPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async statFile(path: string): Promise<Stat> {
|
private async statFile(path: string): Promise<Stat> {
|
||||||
const file = await this.vault.adapter.stat(normalizePath(path));
|
const file = await this.vault.adapter.stat(normalizePath(path));
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
throw new Error(`File not found: ${path}`);
|
throw new Error(`File not found: ${path}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,16 @@ import type { Editor } from "obsidian";
|
||||||
import { utils } from "sync-client";
|
import { utils } from "sync-client";
|
||||||
|
|
||||||
export interface Selection {
|
export interface Selection {
|
||||||
id: number;
|
id: number;
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSelectionsFromEditor(editor: Editor): Selection[] {
|
export function getSelectionsFromEditor(editor: Editor): Selection[] {
|
||||||
const text = editor.getValue();
|
const text = editor.getValue();
|
||||||
return editor.listSelections().map(({ anchor, head }, i) => ({
|
return editor.listSelections().map(({ anchor, head }, i) => ({
|
||||||
id: i,
|
id: i,
|
||||||
start: utils.lineAndColumnToPosition(text, anchor.line, anchor.ch),
|
start: utils.lineAndColumnToPosition(text, anchor.line, anchor.ch),
|
||||||
end: utils.lineAndColumnToPosition(text, head.line, head.ch)
|
end: utils.lineAndColumnToPosition(text, head.line, head.ch)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,46 +5,46 @@ import type { Selection } from "./get-selections-from-editor";
|
||||||
import { getSelectionsFromEditor } from "./get-selections-from-editor";
|
import { getSelectionsFromEditor } from "./get-selections-from-editor";
|
||||||
|
|
||||||
export class LocalCursorUpdateListener {
|
export class LocalCursorUpdateListener {
|
||||||
private static readonly UPDATE_INTERVAL_MS = 50;
|
private static readonly UPDATE_INTERVAL_MS = 50;
|
||||||
private readonly eventHandle: NodeJS.Timeout;
|
private readonly eventHandle: NodeJS.Timeout;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly client: SyncClient,
|
private readonly client: SyncClient,
|
||||||
private readonly workspace: Workspace
|
private readonly workspace: Workspace
|
||||||
) {
|
) {
|
||||||
this.eventHandle = setInterval(() => {
|
this.eventHandle = setInterval(() => {
|
||||||
this.updateAllSelections();
|
this.updateAllSelections();
|
||||||
}, LocalCursorUpdateListener.UPDATE_INTERVAL_MS);
|
}, LocalCursorUpdateListener.UPDATE_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
clearInterval(this.eventHandle);
|
clearInterval(this.eventHandle);
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateAllSelections(): void {
|
private updateAllSelections(): void {
|
||||||
const currentCursors = this.getAllSelections();
|
const currentCursors = this.getAllSelections();
|
||||||
this.client
|
this.client
|
||||||
.updateLocalCursors(currentCursors)
|
.updateLocalCursors(currentCursors)
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
this.client.logger.error(
|
this.client.logger.error(
|
||||||
`Failed to update local cursors: ${error}`
|
`Failed to update local cursors: ${error}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getAllSelections(): Record<string, Selection[]> {
|
private getAllSelections(): Record<string, Selection[]> {
|
||||||
const cursors: Record<string, Selection[]> = {};
|
const cursors: Record<string, Selection[]> = {};
|
||||||
this.workspace
|
this.workspace
|
||||||
.getLeavesOfType("markdown")
|
.getLeavesOfType("markdown")
|
||||||
.map((leaf) => leaf.view)
|
.map((leaf) => leaf.view)
|
||||||
.filter((view) => view instanceof MarkdownView)
|
.filter((view) => view instanceof MarkdownView)
|
||||||
.forEach((view) => {
|
.forEach((view) => {
|
||||||
const { file } = view;
|
const { file } = view;
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cursors[file.path] = getSelectionsFromEditor(view.editor);
|
cursors[file.path] = getSelectionsFromEditor(view.editor);
|
||||||
});
|
});
|
||||||
return cursors;
|
return cursors;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue