simplify 3
Some checks failed
Check / build (pull_request) Has been cancelled
E2E tests / build (pull_request) Has been cancelled
Publish CLI / publish-docker (pull_request) Has been cancelled
Publish server Docker image / publish-docker (pull_request) Has been cancelled

This commit is contained in:
Andras Schmelczer 2026-09-22 08:00:29 +01:00
commit 96d720eed9
39 changed files with 1318 additions and 995 deletions

View file

@ -54,7 +54,12 @@ updates only wake the loop to reread current bytes. Namespace changes detected
before mutations invalidate the plan. Notifications during application are incorporated before mutations invalidate the plan. Notifications during application are incorporated
between files using their document identities; the next scan rereads content. Content is reread between files using their document identities; the next scan rereads content. Content is reread
before replacement. Ignored and oversized occupants stay in place. Adapters before replacement. Ignored and oversized occupants stay in place. Adapters
report atomic editor saves as updates and suppress engine-generated notifications. use raw filesystem events only to wake a scan. These include engine writes and
atomic editor replacements, so they must not be reported as logical create/delete
notifications. Integrations with reliable user-action events can persist those
explicit notifications to preserve rename identities, as Obsidian does for
`Vault.rename`. Raw watchers infer a move
only when the missing and new files have a unique matching, nonempty content hash.
Metadata persistence must replace a complete value atomically, leaving a readable Metadata persistence must replace a complete value atomically, leaving a readable
old/new value after interruption. Metadata and user files need not describe the old/new value after interruption. Metadata and user files need not describe the
@ -83,5 +88,10 @@ transient errors retry according to client settings. Observer callback failures
are isolated from the background sync loop. are isolated from the background sync loop.
API v5 clients and servers must be deployed together. V4 databases migrate without API v5 clients and servers must be deployed together. V4 databases migrate without
removing history. The old Obsidian and CLI adapters still require the filesystem removing history. CLI and desktop Obsidian share the Node filesystem adapter and
and persistence contract required by the core engine. complete-record metadata persistence. Node file moves use exclusive creation
followed by unlink, so interruption may leave two ordinary files. Metadata alone
is flushed before an atomic rename; there is no user-file durability protocol.
Obsidian mobile uses the host storage API. Its exclusive-copy API requires a
scratch input file in the ignored internal directory; leftovers are never replayed
or used for recovery. Mobile metadata persistence uses Obsidian's data store.

View file

@ -205,10 +205,21 @@ docker build -f local-client-cli/Dockerfile -t vault-link-cli:test .
1. Creates `.vaultlink` directory for sync metadata 1. Creates `.vaultlink` directory for sync metadata
2. Performs initial sync of local files to server 2. Performs initial sync of local files to server
3. Watches filesystem for changes using Node's `fs.watch` 3. Uses filesystem events to wake a fresh scan, including atomic editor saves
4. Syncs changes bidirectionally in real-time 4. Syncs changes bidirectionally in real-time
5. Handles graceful shutdown on SIGINT/SIGTERM 5. Handles graceful shutdown on SIGINT/SIGTERM
The complete client record (database, settings, queued logical notifications and
server history checkpoint) is saved atomically in `.vaultlink/sync-data.json`.
Existing database-only files migrate on the next save. Invalid or unreadable
metadata is reported instead of silently starting with empty state.
File moves use an exclusive copy followed by deletion of the source. An
interruption may leave both files; the next scan reconciles the visible files.
Raw watcher events do not distinguish user edits from sync writes. A rename
preserves identity when the scan finds a unique matching nonempty content hash;
a simultaneous rename and edit may instead appear as deletion and creation.
## License ## License
MIT MIT

View file

@ -7,12 +7,12 @@ import {
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
LogLevel, LogLevel,
LogLine, LogLine,
type SyncSettings, type SyncSettings
type StoredDatabase
} from "sync-client"; } from "sync-client";
import { parseArgs } from "./args"; import { parseArgs } from "./args";
import { NodeFileSystemOperations, VAULTLINK_DIR } from "./node-filesystem"; import { NodeFileSystemOperations, VAULTLINK_DIR } from "./node-filesystem";
import { FileWatcher } from "./file-watcher"; import { FileWatcher } from "./file-watcher";
import { ClientPersistence } from "./client-persistence";
import { formatLogLine } from "./logger-formatter"; import { formatLogLine } from "./logger-formatter";
import packageJson from "../package.json"; import packageJson from "../package.json";
@ -114,32 +114,7 @@ async function main(): Promise<void> {
const client = await SyncClient.create({ const client = await SyncClient.create({
fs: fileSystem, fs: fileSystem,
persistence: { persistence: new ClientPersistence(dataFile, settings)
load: async () => {
let database: Partial<StoredDatabase> | undefined = undefined;
try {
const content = await fs.readFile(dataFile, "utf-8");
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
database = JSON.parse(content) as Partial<StoredDatabase>;
} catch {
emitBoot(
LogLevel.WARNING,
`Cannot read data file at ${dataFile}`
);
}
return {
settings,
database
};
},
save: async ({ database: persistedDatabase }) => {
await fs.writeFile(
dataFile,
JSON.stringify(persistedDatabase, null, 2)
);
}
}
}); });
if (args.health !== undefined) { if (args.health !== undefined) {
@ -212,7 +187,6 @@ async function main(): Promise<void> {
client.logger.info(`${signal} received, shutting down gracefully`); client.logger.info(`${signal} received, shutting down gracefully`);
fileWatcher.stop(); fileWatcher.stop();
await client.waitUntilFinished();
await client.destroy(); await client.destroy();
if (totalSyncOps > 0) { if (totalSyncOps > 0) {

View file

@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import { ClientPersistence } from "./client-persistence";
import type { StoredClient } from "sync-client";
test("CLI persistence retains the whole record and migrates database-only saves", async (t) => {
const directory = await fs.mkdtemp(
path.join(tmpdir(), "vaultlink-metadata-")
);
t.after(() => fs.rm(directory, { recursive: true, force: true }));
const file = path.join(directory, "state.json");
const store = new ClientPersistence(file, { token: "current-token" });
assert.deepEqual(await store.load(), {
settings: { token: "current-token" }
});
const record: StoredClient = {
settings: { token: "old-token", vaultName: "vault" },
database: { lastSeenUpdateId: 12 },
localChanges: [
{ type: "delete", path: "removed.md", changeId: "queued" }
],
localChangesVaultKey: "vault",
historyCheckpoint: { vaultKey: "vault", checkpoint: "12:incarnation" }
};
await store.save(record);
assert.deepEqual(await store.load(), {
...record,
settings: { ...record.settings, token: "current-token" }
});
const legacy = {
fileManifest: { fileManifestId: 0, entries: {} },
lastSeenUpdateId: 0
};
await fs.writeFile(file, JSON.stringify(legacy));
assert.deepEqual((await store.load()).database, legacy);
await fs.writeFile(file, "{truncated");
await assert.rejects(store.load(), SyntaxError);
await fs.rm(file);
await fs.mkdir(file);
await assert.rejects(store.load(), /EISDIR/);
});
for (const afterReplacement of [false, true]) {
test(`failed metadata save ${afterReplacement ? "after" : "before"} replacement leaves a complete record`, async (t) => {
const directory = await fs.mkdtemp(
path.join(tmpdir(), "vaultlink-save-")
);
t.after(() => fs.rm(directory, { recursive: true, force: true }));
const store = new ClientPersistence(
path.join(directory, "state.json"),
{}
);
const old: StoredClient = {
historyCheckpoint: { vaultKey: "v", checkpoint: "1:old" }
};
const next: StoredClient = {
...old,
localChanges: [{ type: "delete", path: "note.md", changeId: "new" }]
};
await store.save(old);
const rename = fs.rename;
t.mock.method(
fs,
"rename",
async (...args: Parameters<typeof fs.rename>) => {
if (afterReplacement) await rename(...args);
throw new Error("interrupted replacement");
}
);
await assert.rejects(store.save(next), /interrupted replacement/);
assert.deepEqual(await store.load(), {
...(afterReplacement ? next : old),
settings: {}
});
assert.deepEqual(await fs.readdir(directory), ["state.json"]);
});
}

View file

@ -0,0 +1,67 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { randomUUID } from "node:crypto";
import type {
PersistenceProvider,
StoredClient,
StoredDatabase,
SyncSettings
} from "sync-client";
/** Only client metadata needs an atomic replacement; user files do not. */
export class ClientPersistence implements PersistenceProvider<StoredClient> {
public constructor(
private readonly file: string,
private readonly settings: Partial<SyncSettings>
) {}
public async load(): Promise<StoredClient> {
const content = await fs
.readFile(this.file, "utf8")
.catch((error: unknown) => {
if (
error instanceof Error &&
"code" in error &&
error.code === "ENOENT"
)
return undefined;
throw error;
});
if (content === undefined) return { settings: this.settings };
const parsed: unknown = JSON.parse(content);
if (
parsed === null ||
typeof parsed !== "object" ||
Array.isArray(parsed)
)
throw new Error("Invalid client metadata");
// The sync engine validates and migrates the persisted fields.
const data = parsed as StoredClient & Partial<StoredDatabase>;
// Earlier CLI versions saved the database alone.
const stored: StoredClient =
"fileManifest" in data ? { database: data } : data;
return {
...stored,
settings: { ...stored.settings, ...this.settings }
};
}
public async save(data: StoredClient): Promise<void> {
await fs.mkdir(path.dirname(this.file), { recursive: true });
const temporary = `${this.file}.${randomUUID()}.tmp`;
try {
const handle = await fs.open(temporary, "wx", 0o600);
try {
await handle.writeFile(JSON.stringify(data));
// Flush the complete value before exposing its name. A crash can
// keep the previous value, but must not expose truncated JSON.
await handle.sync();
} finally {
await handle.close();
}
await fs.rename(temporary, this.file);
} finally {
await fs.rm(temporary, { force: true });
}
}
}

View file

@ -1,93 +1,54 @@
import Watcher from "watcher"; import Watcher from "watcher";
import * as path from "path"; import * as path from "node:path";
import type { SyncClient, RelativePath } from "sync-client"; import type { SyncClient } from "sync-client";
import { toUnixPath, matchesGlob } from "./path-utils"; import { toUnixPath, matchesGlob } from "./path-utils";
/** OS events include sync writes and editor temporary-file replacements. They
* wake reconciliation; only a fresh scan determines the current namespace. */
export class FileWatcher { export class FileWatcher {
private watcher: Watcher | undefined; private watcher: Watcher | undefined;
private isRunning = false;
private readonly ignorePatterns: string[];
public constructor( public constructor(
private readonly basePath: string, private readonly basePath: string,
private readonly client: SyncClient, private readonly client: SyncClient,
ignorePatterns: string[] = [] private readonly ignorePatterns: string[] = []
) { ) {}
this.ignorePatterns = ignorePatterns;
}
public start(): void { public start(): void {
if (this.isRunning) { if (this.watcher) return;
return;
}
this.isRunning = true;
this.watcher = new Watcher(this.basePath, { this.watcher = new Watcher(this.basePath, {
recursive: true, recursive: true,
renameDetection: true, renameDetection: false,
renameTimeout: 125,
ignoreInitial: true, ignoreInitial: true,
ignore: (filePath: string): boolean => this.shouldIgnore(filePath) ignore: (filePath: string): boolean =>
this.ignorePatterns.some((pattern) =>
matchesGlob(this.toRelativePath(filePath), pattern)
)
}); });
this.watcher.on("all", (_event: string, filePath: string) => {
this.watcher.on("add", (filePath: string) => { void this.client
this.handleCreate(this.toRelativePath(filePath)); .syncLocallyUpdatedFile({
}); relativePath: this.toRelativePath(filePath)
})
this.watcher.on("change", (filePath: string) => { .catch((error: unknown) => {
this.handleChange(this.toRelativePath(filePath)); this.client.logger.error(
}); `File notification failed: ${error}`
this.watcher.on("unlink", (filePath: string) => {
this.handleDelete(this.toRelativePath(filePath));
});
this.watcher.on("rename", (oldPath: string, newPath: string) => {
this.handleRename(
this.toRelativePath(oldPath),
this.toRelativePath(newPath)
); );
}); });
});
this.watcher.on("error", (error: Error) => {
this.client.logger.error(`File watcher failed: ${error.message}`);
});
this.client.logger.info("File watcher started"); this.client.logger.info("File watcher started");
} }
public stop(): void { public stop(): void {
if (this.watcher !== undefined) { this.watcher?.close();
this.watcher.close();
this.watcher = undefined; this.watcher = undefined;
}
this.isRunning = false;
this.client.logger.info("File watcher stopped"); this.client.logger.info("File watcher stopped");
} }
private shouldIgnore(filePath: string): boolean { private toRelativePath(absolutePath: string): string {
const rel = toUnixPath(path.relative(this.basePath, filePath));
return this.ignorePatterns.some((pattern) => matchesGlob(rel, pattern));
}
private handleCreate(relativePath: RelativePath): void {
this.client.syncLocallyCreatedFile(relativePath);
}
private handleChange(relativePath: RelativePath): void {
this.client.syncLocallyUpdatedFile({ relativePath });
}
private handleDelete(relativePath: RelativePath): void {
this.client.syncLocallyDeletedFile(relativePath);
}
private handleRename(oldPath: RelativePath, newPath: RelativePath): void {
this.client.logger.info(`File renamed: ${oldPath} -> ${newPath}`);
this.client.syncLocallyUpdatedFile({
oldPath,
relativePath: newPath
});
}
private toRelativePath(absolutePath: string): RelativePath {
return toUnixPath(path.relative(this.basePath, absolutePath)); return toUnixPath(path.relative(this.basePath, absolutePath));
} }
} }

View file

@ -11,10 +11,13 @@ test("NodeFileSystemOperations - read and write files", async () => {
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.readSnapshot("test.txt");
assert.equal(new TextDecoder().decode(readContent), "Hello, world!"); assert.equal(
new TextDecoder().decode(readContent!.content),
"Hello, world!"
);
} finally { } finally {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
@ -27,10 +30,13 @@ test("NodeFileSystemOperations - create nested directories with forward slashes"
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.readSnapshot("dir1/dir2/test.txt");
assert.equal(new TextDecoder().decode(readContent), "Nested file"); assert.equal(
new TextDecoder().decode(readContent!.content),
"Nested file"
);
} finally { } finally {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
@ -43,7 +49,9 @@ test("NodeFileSystemOperations - exists with forward slashes", async () => {
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", {
content: new TextEncoder().encode("test")
});
assert.equal(await fsOps.exists("test.txt"), true); assert.equal(await fsOps.exists("test.txt"), true);
} finally { } finally {
@ -56,7 +64,9 @@ test("NodeFileSystemOperations - deleteFile with forward slashes", async () => {
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", {
content: new TextEncoder().encode("test")
});
assert.equal(await fsOps.exists("test.txt"), true); assert.equal(await fsOps.exists("test.txt"), true);
await fsOps.deleteFile("test.txt"); await fsOps.deleteFile("test.txt");
@ -74,15 +84,18 @@ test("NodeFileSystemOperations - rename with forward slashes", async () => {
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.readSnapshot("new.txt");
assert.equal(new TextDecoder().decode(readContent), "test content"); assert.equal(
new TextDecoder().decode(readContent!.content),
"test content"
);
} finally { } finally {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
@ -94,7 +107,7 @@ test("NodeFileSystemOperations - rename to nested path with forward slashes", as
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");
@ -105,42 +118,21 @@ test("NodeFileSystemOperations - rename to nested path with forward slashes", as
} }
}); });
test("NodeFileSystemOperations - getFileSize", async () => { test("NodeFileSystemOperations - stat", 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.stat("test.txt"))!.size;
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 () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
try {
await fsOps.write("test.txt", new TextEncoder().encode("Hello"));
const result = await fsOps.atomicUpdateText("test.txt", (current) => ({
text: current.text + " World",
cursors: []
}));
assert.equal(result, "Hello World");
const content = await fsOps.read("test.txt");
assert.equal(new TextDecoder().decode(content), "Hello World");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
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);
@ -150,11 +142,11 @@ test("NodeFileSystemOperations - handles paths with forward slashes on all platf
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.readSnapshot(testPath);
assert.equal(new TextDecoder().decode(readContent), "test"); assert.equal(new TextDecoder().decode(readContent!.content), "test");
await fsOps.deleteFile(testPath); await fsOps.deleteFile(testPath);
assert.equal(await fsOps.exists(testPath), false); assert.equal(await fsOps.exists(testPath), false);
@ -186,10 +178,9 @@ test("NodeFileSystemOperations - delete does not remove files", async () => {
const fsOps = new NodeFileSystemOperations(tempDir); const fsOps = new NodeFileSystemOperations(tempDir);
try { try {
await fsOps.write( await fsOps.write("parent/file.txt", {
"parent/file.txt", content: new TextEncoder().encode("preserve me")
new TextEncoder().encode("preserve me") });
);
await assert.rejects(async () => fsOps.delete("parent")); await assert.rejects(async () => fsOps.delete("parent"));
assert.equal(await fsOps.exists("parent/file.txt"), true); assert.equal(await fsOps.exists("parent/file.txt"), true);

View file

@ -1,239 +1,188 @@
import * as fs from "fs/promises"; import assert from "node:assert/strict";
import type { Dirent } from "fs"; import * as fs from "node:fs/promises";
import * as path from "path"; import * as path from "node:path";
import { randomUUID } from "crypto"; import { constants, type Stats } from "node:fs";
import type { import type { FileSystemOperations, FileSnapshot } from "sync-client";
FileSystemOperations,
RelativePath, function hasCode(error: unknown, ...codes: string[]): boolean {
TextWithCursors return (
} from "sync-client"; error instanceof Error &&
import { toUnixPath } from "./path-utils"; "code" in error &&
typeof error.code === "string" &&
codes.includes(error.code)
);
}
// VaultLink's per-vault metadata directory. Holds the persisted sync database
// and the tmp files atomicWrite renames into place; the matching `${VAULTLINK_DIR}/**`
// ignore pattern keeps everything in here invisible to the file watcher.
export const VAULTLINK_DIR = ".vaultlink"; export const VAULTLINK_DIR = ".vaultlink";
/** Preflights reject unsafe paths and links. This is not a sandbox against a
* hostile process concurrently replacing ancestor directories. */
export class NodeFileSystemOperations implements FileSystemOperations { export class NodeFileSystemOperations implements FileSystemOperations {
public constructor(private readonly basePath: string) {} public constructor(private readonly root: string) {}
public async stat(
public async listFilesRecursively( relative: string
directory: RelativePath | undefined ): ReturnType<FileSystemOperations["stat"]> {
): Promise<RelativePath[]> { const info = await this.info(await this.resolve(relative));
const files: RelativePath[] = []; return info
await this.walkDirectory(directory ?? "", files); ? {
return files; kind: info.isFile()
? ("file" as const)
: ("directory" as const),
size: info.size
} }
: undefined;
public async read(relativePath: RelativePath): Promise<Uint8Array> { }
const fullPath = path.join(this.basePath, relativePath); public async exists(relative: string): Promise<boolean> {
return (await this.stat(relative)) !== undefined;
}
public async readSnapshot(
relative: string
): Promise<FileSnapshot | undefined> {
const absolute = await this.resolve(relative);
const handle = await fs
.open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW)
.catch((error: unknown) => {
if (hasCode(error, "ENOENT", "ENOTDIR")) return undefined;
throw error;
});
if (!handle) return undefined;
try { try {
return await fs.readFile(fullPath); const before = await handle.stat({ bigint: true });
} catch (error) { assert(
throw new Error( before.isFile() && before.nlink === 1n,
`Failed to read file ${fullPath}: ${error instanceof Error ? error.message : String(error)}` "Not an independent regular file"
); );
const content = new Uint8Array(await handle.readFile());
const after = await handle.stat({ bigint: true });
const atPath = await fs.lstat(absolute, { bigint: true });
assert(
before.ino === after.ino &&
after.ino === atPath.ino &&
before.size === after.size &&
before.mtimeNs === after.mtimeNs &&
before.ctimeNs === after.ctimeNs,
"File changed during read"
);
return { content };
} finally {
await handle.close();
}
}
public async listFilesRecursively(root = ""): Promise<string[]> {
const result: string[] = [];
for (const name of await fs.readdir(await this.resolve(root))) {
const relative = root ? `${root}/${name}` : name;
const entry = await this.stat(relative);
assert(entry, `File changed during scan: ${relative}`);
if (entry.kind === "directory")
result.push(...(await this.listFilesRecursively(relative)));
else result.push(relative);
}
return result.sort();
}
public async createDirectory(relative: string): Promise<void> {
await this.resolve(relative);
let current = this.root;
for (const component of relative.split("/").filter(Boolean)) {
const next = path.join(current, component);
try {
await fs.mkdir(next);
} catch (error) {
if (!hasCode(error, "EEXIST")) throw error;
}
assert((await fs.lstat(next)).isDirectory());
current = next;
} }
} }
public async write( public async write(
relativePath: RelativePath, relative: string,
content: Uint8Array snapshot: FileSnapshot
): Promise<void> { ): Promise<void> {
const fullPath = path.join(this.basePath, relativePath); const destination = await this.resolve(relative);
const dir = path.dirname(fullPath); await this.createDirectory(
path.posix.dirname(relative) === "."
try { ? ""
await fs.mkdir(dir, { recursive: true }); : path.posix.dirname(relative)
await this.atomicWrite(fullPath, content);
} catch (error) {
throw new Error(
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
); );
} await fs.writeFile(destination, snapshot.content, {
} flag: "wx",
mode: 0o600
public async atomicUpdateText(
relativePath: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> {
const fullPath = path.join(this.basePath, relativePath);
try {
const currentContent = await fs.readFile(fullPath, "utf-8");
const result = updater({ text: currentContent, cursors: [] });
await this.atomicWrite(fullPath, result.text, "utf-8");
return result.text;
} catch (error) {
throw new Error(
`Failed to atomically update file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async getFileSize(relativePath: RelativePath): Promise<number> {
const fullPath = path.join(this.basePath, relativePath);
try {
const stats = await fs.stat(fullPath);
return stats.size;
} catch (error) {
throw new Error(
`Failed to get file size for ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async exists(relativePath: RelativePath): Promise<boolean> {
const fullPath = path.join(this.basePath, relativePath);
try {
await fs.access(fullPath);
return true;
} catch {
return false;
}
}
public async createDirectory(relativePath: RelativePath): Promise<void> {
const fullPath = path.join(this.basePath, relativePath);
try {
await fs.mkdir(fullPath, { recursive: false });
} catch (error) {
throw new Error(
`Failed to create directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async delete(relativePath: RelativePath): Promise<void> {
if (!relativePath) {
throw new Error("Cannot delete the vault root");
}
const fullPath = path.join(this.basePath, relativePath);
try {
await this.deleteDirectoryTree(fullPath);
} catch (error) {
throw new Error(
`Failed to delete directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async deleteFile(relativePath: RelativePath): Promise<void> {
const fullPath = path.join(this.basePath, relativePath);
try {
const entry = await fs.lstat(fullPath).catch((error: unknown) => {
if (this.isMissing(error)) return undefined;
throw error;
}); });
}
public async rename(from: string, to: string): Promise<void> {
const snapshot = await this.readSnapshot(from);
assert(snapshot, `Missing source: ${from}`);
// Node's rename overwrites destinations on POSIX. Exclusive copy followed
// by unlink leaves ordinary discoverable files if interrupted.
await this.write(to, snapshot);
const current = await this.readSnapshot(from);
assert(
current &&
Buffer.from(current.content).equals(
Buffer.from(snapshot.content)
),
`Source changed during move: ${from}`
);
await this.deleteFile(from);
}
public async delete(relative: string): Promise<void> {
assert(relative, "Cannot prune root");
const absolute = await this.resolve(relative);
const entry = await this.info(absolute);
if (!entry) return; if (!entry) return;
if (!entry.isFile() || entry.nlink !== 1) { assert(entry.isDirectory(), "Cannot delete regular file");
throw new Error("Cannot unlink a non-regular or linked file"); for (const child of await fs.readdir(absolute))
await this.delete(`${relative}/${child}`);
await fs.rmdir(absolute);
} }
public async deleteFile(relative: string): Promise<void> {
await fs.unlink(fullPath).catch((error: unknown) => { const absolute = await this.resolve(relative);
if (!this.isMissing(error)) throw error; const entry = await this.info(absolute);
});
} catch (error) {
throw new Error(
`Failed to delete file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async rename(
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
const oldFullPath = path.join(this.basePath, oldPath);
const newFullPath = path.join(this.basePath, newPath);
const newDir = path.dirname(newFullPath);
try {
await fs.mkdir(newDir, { recursive: true });
await fs.rename(oldFullPath, newFullPath);
} catch (error) {
throw new Error(
`Failed to rename file from ${oldFullPath} to ${newFullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
private async atomicWrite(
fullPath: string,
content: Uint8Array | string,
encoding?: BufferEncoding
): Promise<void> {
const tmpDir = path.join(this.basePath, VAULTLINK_DIR);
await fs.mkdir(tmpDir, { recursive: true });
const tmpPath = path.join(tmpDir, `atomic-write-${randomUUID()}.tmp`);
try {
await fs.writeFile(tmpPath, content, encoding);
await fs.rename(tmpPath, fullPath);
} catch (error) {
await fs.unlink(tmpPath).catch(() => undefined);
throw error;
}
}
private async deleteDirectoryTree(fullPath: string): Promise<void> {
const entry = await fs.lstat(fullPath).catch((error: unknown) => {
if (this.isMissing(error)) return undefined;
throw error;
});
if (!entry) return; if (!entry) return;
if (!entry.isDirectory()) { assert(entry.isFile() && entry.nlink === 1, "Cannot unlink non-file");
throw new Error("Cannot delete a regular file as a directory"); await fs.unlink(absolute).catch((error: unknown) => {
if (!hasCode(error, "ENOENT")) throw error;
});
} }
private async resolve(relative: string): Promise<string> {
for (const child of await fs.readdir(fullPath, { assert(
withFileTypes: true relative === "" ||
})) { relative
if (!child.isDirectory()) { .split("/")
throw new Error( .every(
`Directory contains a non-directory entry: ${path.join(fullPath, child.name)}` (p) =>
p !== "" &&
p !== "." &&
p !== ".." &&
!p.includes("\\") &&
!p.includes("\0") &&
(process.platform !== "win32" || !p.includes(":"))
),
"Unsafe path"
);
assert(
(await fs.lstat(this.root)).isDirectory(),
"Vault root must be a directory, not a symlink"
);
let current = this.root;
for (const component of relative.split("/").filter(Boolean)) {
current = path.join(current, component);
const entry = await this.info(current);
if (entry)
assert(
entry.isDirectory() ||
(entry.isFile() && entry.nlink === 1),
"Symlink, hardlink or special file"
); );
} }
await this.deleteDirectoryTree(path.join(fullPath, child.name)); return current;
} }
private async info(absolute: string): Promise<Stats | undefined> {
await fs.rmdir(fullPath);
}
private isMissing(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
error.code === "ENOENT"
);
}
private async walkDirectory(
relativePath: string,
files: RelativePath[]
): Promise<void> {
const fullPath = path.join(this.basePath, relativePath);
let entries: Dirent[] = [];
try { try {
entries = await fs.readdir(fullPath, { withFileTypes: true }); return await fs.lstat(absolute);
} catch (error) { } catch (error) {
throw new Error( if (hasCode(error, "ENOENT", "ENOTDIR")) return undefined;
`Failed to read directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}` throw error;
);
}
for (const entry of entries) {
const entryName = entry.name;
const entryRelativePath = path.join(relativePath, entryName);
if (entry.isDirectory()) {
await this.walkDirectory(entryRelativePath, files);
} else if (entry.isFile()) {
// Always return forward slashes
files.push(toUnixPath(entryRelativePath));
}
} }
} }
} }

View file

@ -7,6 +7,7 @@ module.exports = {
healthcheck: "./src/healthcheck.ts" healthcheck: "./src/healthcheck.ts"
}, },
target: "node", target: "node",
externals: { bufferutil: "bufferutil", "utf-8-validate": "utf-8-validate" },
mode: "production", mode: "production",
optimization: { optimization: {
minimize: false minimize: false

View file

@ -1,63 +1,33 @@
# Obsidian Sample Plugin # VaultLink Obsidian plugin
This is a sample plugin for Obsidian (https://obsidian.md). Syncs an Obsidian vault with the VaultLink server, including active editor text,
selections, remote cursors, history and connection status. Use an API v5 server.
The vault configuration directory, Git metadata and trash are ignored by default.
This project uses TypeScript to provide type checking and documentation. From `frontend`:
The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definition format, which contains TSDoc comments describing what it does.
**Note:** The Obsidian API is still in early alpha and is subject to change at any time! ```sh
npm run build -w sync-client -w vault-link-obsidian-plugin
npm run test -w vault-link-obsidian-plugin
```
This sample plugin demonstrates some of the basic functionality the plugin API can do. The production bundle is in `obsidian-plugin/dist`: `main.js`, `manifest.json`
and `styles.css`. Copy those files into the vault's plugin directory to install.
- Adds a ribbon icon, which shows a Notice when clicked. Desktop Obsidian shares the CLI's Node filesystem and atomic metadata adapters.
- Adds a command "Open Sample Modal" which opens a Modal. The complete client record remains in the plugin's `data.json`; it includes
- Adds a plugin setting tab to the settings page. settings, sync state, queued logical notifications and the server history
- Registers a global click event and output 'click' to the console. checkpoint. Mobile uses Obsidian's storage and plugin data APIs. Because mobile
- Registers a global interval which logs 'setInterval' to the console. writes overwrite existing files, exclusive creation uses a disposable input for
`DataAdapter.copy` in `.vault-link-sync`. This is not a filesystem journal or a
recovery payload.
## First time developing plugins? Raw create/modify/delete events wake a fresh scan, including events caused by
sync and editor replacements. Obsidian's logical rename event preserves file
and directory move identities. Editor snapshots include unsaved text and
selections; content application updates both disk and the active editor.
Quick starting guide for new plugin devs: Adapter tests cover unsaved text, cursor restoration, exclusive creation and
storage failures. The repository E2E runner builds the plugin and tests its
- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with. filesystem adapter against the server alongside real CLI processes. These tests
- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it). do not launch Obsidian itself.
- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder.
- Install NodeJS, then run `npm i` in the command line under your repo folder.
- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`.
- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`.
- Reload Obsidian to load the new version of your plugin.
- Enable plugin in settings window.
- For updates to the Obsidian API run `npm update` in the command line under your repo folder.
## Releasing new releases
- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release.
- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible.
- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases
- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release.
- Publish the release.
> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`.
> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json`
## Adding your plugin to the community plugin list
- Check the [plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines).
- Publish an initial version.
- Make sure you have a `README.md` file in the root of your repo.
- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin.
## How to use
- Clone this repo.
- Make sure your NodeJS is at least v16 (`node --version`).
- `npm i` or `yarn` to install dependencies.
- `npm run dev` to start compilation in watch mode.
## Manually installing the plugin
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
## API Documentation
See https://github.com/obsidianmd/obsidian-api

View file

@ -6,7 +6,7 @@
"scripts": { "scripts": {
"dev": "webpack watch --mode development", "dev": "webpack watch --mode development",
"build": "webpack --mode production", "build": "webpack --mode production",
"test": "echo \"no tests defined\" && exit 0", "test": "tsx --test 'src/**/*.test.ts'",
"version": "node version-bump.mjs" "version": "node version-bump.mjs"
}, },
"keywords": [], "keywords": [],

View file

@ -0,0 +1,130 @@
import type { DataAdapter } from "obsidian";
import type { FileSystemOperations, FileSnapshot } from "sync-client";
/** Mobile storage has no Node filesystem. DataAdapter.copy provides its
* documented exclusive-create primitive; writeBinary overwrites existing files. */
export class MobileFileSystemOperations implements FileSystemOperations {
public constructor(private readonly adapter: DataAdapter) {}
public async stat(path: string): ReturnType<FileSystemOperations["stat"]> {
const entry = await this.adapter.stat(this.check(path));
return entry
? {
kind:
entry.type === "file"
? ("file" as const)
: ("directory" as const),
size: entry.size
}
: undefined;
}
public async exists(path: string): Promise<boolean> {
return (await this.stat(path)) !== undefined;
}
public async listFilesRecursively(root = ""): Promise<string[]> {
const entries = await this.adapter.list(this.check(root));
const files = [...entries.files];
for (const folder of entries.folders)
files.push(...(await this.listFilesRecursively(folder)));
return files;
}
public async readSnapshot(path: string): Promise<FileSnapshot | undefined> {
this.check(path);
const before = await this.adapter.stat(path);
if (!before) return undefined;
if (before.type !== "file") throw new Error(`Not a file: ${path}`);
const content = new Uint8Array(await this.adapter.readBinary(path));
const after = await this.adapter.stat(path);
if (
!after ||
before.size !== after.size ||
before.mtime !== after.mtime ||
before.ctime !== after.ctime
)
throw new Error(`File changed during read: ${path}`);
return { content };
}
public async createDirectory(path: string): Promise<void> {
this.check(path);
let current = "";
for (const part of path.split("/").filter(Boolean)) {
current = current ? `${current}/${part}` : part;
if (!(await this.exists(current))) {
try {
await this.adapter.mkdir(current);
} catch (error) {
if ((await this.stat(current))?.kind !== "directory")
throw error;
}
}
if ((await this.stat(current))?.kind !== "directory")
throw new Error(`Not a directory: ${current}`);
}
}
public async write(path: string, snapshot: FileSnapshot): Promise<void> {
this.check(path);
await this.createDirectory(path.split("/").slice(0, -1).join("/"));
await this.createDirectory(".vault-link-sync");
const temporary = `.vault-link-sync/${crypto.randomUUID()}.tmp`;
try {
await this.adapter.writeBinary(
temporary,
new Uint8Array(snapshot.content).buffer
);
await this.adapter.copy(temporary, path);
} finally {
if (await this.adapter.exists(temporary))
await this.adapter.remove(temporary);
}
}
public async rename(from: string, to: string): Promise<void> {
this.check(from);
this.check(to);
const snapshot = await this.readSnapshot(from);
if (!snapshot) throw new Error(`Missing source: ${from}`);
await this.write(to, snapshot);
const current = await this.readSnapshot(from);
if (
!current ||
current.content.length !== snapshot.content.length ||
current.content.some((byte, i) => byte !== snapshot.content[i])
)
throw new Error(`Source changed during move: ${from}`);
await this.deleteFile(from);
}
public async deleteFile(path: string): Promise<void> {
const entry = await this.stat(path);
if (!entry) return;
if (entry.kind !== "file") throw new Error(`Not a file: ${path}`);
await this.adapter.remove(path);
}
public async delete(path: string): Promise<void> {
if (!path) throw new Error("Cannot prune root");
const entry = await this.stat(path);
if (!entry) return;
if (entry.kind !== "directory")
throw new Error(`Not a directory: ${path}`);
const children = await this.adapter.list(path);
if (children.files.length)
throw new Error(`Directory contains files: ${path}`);
for (const folder of children.folders) await this.delete(folder);
await this.adapter.rmdir(path, false);
}
private check(path: string): string {
if (
path !== "" &&
path
.split("/")
.some(
(part) =>
!part ||
part === "." ||
part === ".." ||
part.includes("\\") ||
part.includes("\0")
)
)
throw new Error(`Unsafe path: ${path}`);
return path;
}
}

View file

@ -0,0 +1,167 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import * as fs from "node:fs/promises";
import { constants } from "node:fs";
import * as path from "node:path";
import { tmpdir } from "node:os";
import type { DataAdapter, Editor, EditorSelection } from "obsidian";
import { NodeFileSystemOperations } from "../../local-client-cli/src/node-filesystem";
import { ObsidianFileSystemOperations } from "./obsidian-file-system";
import { MobileFileSystemOperations } from "./mobile-file-system";
const bytes = (text: string) => new TextEncoder().encode(text);
const text = (content: Uint8Array) => new TextDecoder().decode(content);
test("Obsidian snapshots include unsaved editor text and restore merged selections", async (t) => {
const root = await fs.mkdtemp(path.join(tmpdir(), "vaultlink-editor-"));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const disk = new NodeFileSystemOperations(root);
await disk.write("note.md", { content: bytes("saved") });
let value = "unsaved\ntext";
let selections: EditorSelection[] = [
{ anchor: { line: 0, ch: 2 }, head: { line: 1, ch: 3 } }
];
const editor = {
getValue: () => value,
listSelections: () => selections,
setValue: (next: string) => {
value = next;
},
setSelections: (next: EditorSelection[]) => {
selections = next;
}
} as Editor;
const adapter = new ObsidianFileSystemOperations(disk, () => ({
path: "note.md",
editor
}));
const snapshot = await adapter.readSnapshot("note.md");
assert.equal(text(snapshot!.content), value);
assert.deepEqual(snapshot!.cursors, [
{ id: 0, position: 2 },
{ id: 1, position: 11 }
]);
assert.equal((await adapter.stat("note.md"))!.size, bytes(value).length);
await assert.rejects(
adapter.write("note.md", { content: bytes("collision") })
);
assert.equal(value, "unsaved\ntext");
await adapter.deleteFile("note.md");
await adapter.write("note.md", {
content: bytes("new\ntext"),
cursors: [
{ id: 1, position: 7 },
{ id: 0, position: 1 }
]
});
assert.equal(value, "new\ntext");
assert.equal(text((await disk.readSnapshot("note.md"))!.content), value);
assert.deepEqual(selections, [
{ anchor: { line: 0, ch: 1 }, head: { line: 1, ch: 3 } }
]);
value = "edited before rename";
selections = [{ anchor: { line: 0, ch: 1 }, head: { line: 0, ch: 4 } }];
await adapter.rename("note.md", "moved.md");
assert.equal(text((await disk.readSnapshot("moved.md"))!.content), value);
assert.equal(await adapter.readSnapshot("note.md"), undefined);
});
// A storage host with Obsidian's documented semantics: writes replace, copies
// reject existing destinations. No runtime Obsidian module is needed in Node.
async function mobile(root: string) {
const disk = new NodeFileSystemOperations(root);
const host = {
stat: async (p: string) => {
const entry = await disk.stat(p);
return entry
? {
type: entry.kind === "file" ? "file" : "folder",
size: entry.size,
mtime: 1,
ctime: 1
}
: null;
},
exists: (p: string) => disk.exists(p),
readBinary: async (p: string) =>
new Uint8Array((await disk.readSnapshot(p))!.content).buffer,
writeBinary: (p: string, content: ArrayBuffer) =>
fs.writeFile(path.join(root, p), new Uint8Array(content)),
copy: (from: string, to: string) =>
fs.copyFile(
path.join(root, from),
path.join(root, to),
constants.COPYFILE_EXCL
),
mkdir: (p: string) => disk.createDirectory(p),
remove: (p: string) => disk.deleteFile(p),
rmdir: (p: string) => fs.rmdir(path.join(root, p)),
list: async (p: string) => {
const entries = await fs.readdir(path.join(root, p), {
withFileTypes: true
});
return {
files: entries
.filter((e) => e.isFile())
.map((e) => (p ? `${p}/${e.name}` : e.name)),
folders: entries
.filter((e) => e.isDirectory())
.map((e) => (p ? `${p}/${e.name}` : e.name))
};
}
} as unknown as DataAdapter;
return { adapter: new MobileFileSystemOperations(host), host };
}
test("mobile writes use exclusive copies and never overwrite a competing writer", async (t) => {
const root = await fs.mkdtemp(path.join(tmpdir(), "vaultlink-mobile-"));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const { adapter } = await mobile(root);
await adapter.createDirectory("folder/nested");
const writes = await Promise.allSettled([
adapter.write("folder/note.md", { content: bytes("first") }),
adapter.write("folder/note.md", { content: bytes("second") })
]);
assert.equal(writes.filter((w) => w.status === "fulfilled").length, 1);
const original = (await adapter.readSnapshot("folder/note.md"))!;
await adapter.write("destination.md", { content: bytes("occupied") });
await assert.rejects(adapter.rename("folder/note.md", "destination.md"));
assert.deepEqual(await adapter.readSnapshot("folder/note.md"), original);
assert.equal(
text((await adapter.readSnapshot("destination.md"))!.content),
"occupied"
);
await assert.rejects(adapter.delete("folder"));
await adapter.rename("folder/note.md", "moved.md");
assert.deepEqual(await adapter.readSnapshot("moved.md"), original);
await adapter.delete("folder");
assert.equal(await adapter.exists("folder"), false);
assert.deepEqual(await fs.readdir(path.join(root, ".vault-link-sync")), []);
await assert.rejects(
adapter.write("../escape", { content: bytes("bad") }),
/Unsafe/
);
});
test("mobile scans propagate storage errors and snapshots reject read races", async (t) => {
const root = await fs.mkdtemp(
path.join(tmpdir(), "vaultlink-mobile-read-")
);
t.after(() => fs.rm(root, { recursive: true, force: true }));
const { adapter, host } = await mobile(root);
await adapter.write("note.md", { content: bytes("before") });
const read = host.readBinary.bind(host);
t.mock.method(host, "readBinary", async (p: string) => {
const content = await read(p);
await fs.writeFile(path.join(root, p), "changed length");
return content;
});
await assert.rejects(
adapter.readSnapshot("note.md"),
/changed during read/
);
t.mock.method(host, "list", async () => {
throw new Error("storage unavailable");
});
await assert.rejects(adapter.listFilesRecursively(), /storage unavailable/);
});

View file

@ -1,204 +1,105 @@
import type { Stat, Vault, Workspace } from "obsidian"; import type { Editor } from "obsidian";
import { MarkdownView, normalizePath } from "obsidian";
import type { CursorPosition, TextWithCursors } from "sync-client";
import { import {
utils, utils,
type FileSystemOperations, type FileSystemOperations,
type RelativePath type FileSnapshot
} from "sync-client"; } from "sync-client";
import { getSelectionsFromEditor } from "./views/cursors/get-selections-from-editor"; import { getSelectionsFromEditor } from "./views/cursors/get-selections-from-editor";
/** Editor contents and selections are one synchronous snapshot. Disk namespace
* operations remain the responsibility of the platform adapter. */
export class ObsidianFileSystemOperations implements FileSystemOperations { export class ObsidianFileSystemOperations implements FileSystemOperations {
public constructor( public constructor(
private readonly vault: Vault, private readonly disk: FileSystemOperations,
private readonly workspace: Workspace private readonly activeEditor: () =>
| { path: string; editor: Editor }
| undefined
) {} ) {}
public async listFilesRecursively( public async listFilesRecursively(root?: string): Promise<string[]> {
root: RelativePath | undefined return this.disk.listFilesRecursively(root);
): Promise<RelativePath[]> { }
// Let's implement this by hand because vault.adapter.listAllFiles doesn't always return all files. public async exists(path: string): Promise<boolean> {
const allFiles = []; return this.disk.exists(path);
const remainingFolders = [root ?? this.vault.getRoot().path]; }
public async createDirectory(path: string): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition return this.disk.createDirectory(path);
while (true) { }
const folder = remainingFolders.pop(); public async delete(path: string): Promise<void> {
if (folder == undefined) { return this.disk.delete(path);
break; }
public async deleteFile(path: string): Promise<void> {
return this.disk.deleteFile(path);
}
public async rename(from: string, to: string): Promise<void> {
const snapshot = await this.readSnapshot(from);
if (!snapshot) throw new Error(`Missing source: ${from}`);
await this.write(to, snapshot);
const current = await this.readSnapshot(from);
if (
!current ||
current.content.length !== snapshot.content.length ||
current.content.some((byte, i) => byte !== snapshot.content[i])
)
throw new Error(`Source changed during move: ${from}`);
await this.deleteFile(from);
} }
// This would be a very bad idea to sync as it would mess with public async stat(path: string): ReturnType<FileSystemOperations["stat"]> {
// the integrity of the sync database. const entry = await this.disk.stat(path);
if (folder.endsWith(".obsidian/plugins/vault-link/data.json")) { const view = this.activeEditor();
continue; if (entry?.kind === "file" && view?.path === path)
return {
...entry,
size: new TextEncoder().encode(view.editor.getValue())
.byteLength
};
return entry;
} }
const files = await this.vault.adapter.list(normalizePath(folder)); public async readSnapshot(path: string): Promise<FileSnapshot | undefined> {
allFiles.push(...files.files); // Validate the disk path even when an editor is open.
remainingFolders.push(...files.folders); const entry = await this.disk.stat(path);
if (!entry) return undefined;
const view = this.activeEditor();
if (entry.kind === "file" && view?.path === path) {
return {
content: new TextEncoder().encode(view.editor.getValue()),
cursors: getSelectionsFromEditor(view.editor).flatMap(
({ id, start, end }) => [
{ id: 2 * id, position: start },
{ id: 2 * id + 1, position: end }
]
)
};
}
return this.disk.readSnapshot(path);
} }
return allFiles; public async write(path: string, snapshot: FileSnapshot): Promise<void> {
} await this.disk.write(path, snapshot);
const view = this.activeEditor();
public async read(path: RelativePath): Promise<Uint8Array> { if (view?.path !== path) return;
path = normalizePath(path); const text = new TextDecoder().decode(snapshot.content);
const view = this.workspace.getActiveViewOfType(MarkdownView); view.editor.setValue(text);
if (view?.file?.path === path) { const cursors = [...(snapshot.cursors ?? [])].sort(
return new TextEncoder().encode(view.editor.getValue()); (a, b) => a.id - b.id
}
return new Uint8Array(await this.vault.adapter.readBinary(path));
}
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
path = normalizePath(path);
const view = this.workspace.getActiveViewOfType(MarkdownView);
if (view?.file?.path === path) {
const position = view.editor.getCursor();
view.editor.setValue(new TextDecoder().decode(content));
view.editor.setCursor(position);
return;
}
return this.vault.adapter.writeBinary(
path,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
content.buffer as ArrayBuffer
); );
}
public async atomicUpdateText(
path: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> {
path = normalizePath(path);
const view = this.workspace.getActiveViewOfType(MarkdownView);
if (view?.file?.path === path) {
const text = view.editor.getValue();
const cursors: CursorPosition[] = getSelectionsFromEditor(
view.editor
).flatMap(({ id, start: anchor, end: head }) => [
{
id: 2 * id,
position: anchor
},
{
id: 2 * id + 1,
position: head
}
]);
const result = updater({
text,
cursors
});
if (result.text === text) {
return text;
}
view.editor.setValue(result.text);
const selections = []; const selections = [];
for (let i = 0; i < result.cursors.length / 2; i++) { for (let i = 0; i + 1 < cursors.length; i += 2) {
const from = result.cursors[2 * i]; const anchor = utils.positionToLineAndColumn(
const to = result.cursors[2 * i + 1]; text,
const { line: fromLine, column: fromColumn } = cursors[i].position
utils.positionToLineAndColumn(result.text, from.position); );
const head = utils.positionToLineAndColumn(
const { line: toLine, column: toColumn } = text,
utils.positionToLineAndColumn(result.text, to.position); cursors[i + 1].position
);
selections.push({ selections.push({
anchor: { line: fromLine, ch: fromColumn }, anchor: { line: anchor.line, ch: anchor.column },
head: { line: toLine, ch: toColumn } head: { line: head.line, ch: head.column }
}); });
} }
view.editor.setSelections(selections); if (selections.length) view.editor.setSelections(selections);
return result.text;
}
return this.vault.adapter.process(
path,
(text) =>
updater({
text,
cursors: []
}).text
);
}
public async getFileSize(path: RelativePath): Promise<number> {
return (await this.statFile(path)).size;
}
public async getModificationTime(path: RelativePath): Promise<Date> {
return new Date((await this.statFile(path)).mtime);
}
public async exists(path: RelativePath): Promise<boolean> {
return this.vault.adapter.exists(normalizePath(path));
}
public async createDirectory(path: RelativePath): Promise<void> {
return this.vault.adapter.mkdir(normalizePath(path));
}
public async delete(path: RelativePath): Promise<void> {
if (!path) {
throw new Error("Cannot delete the vault root");
}
await this.deleteDirectoryTree(normalizePath(path));
}
public async deleteFile(path: RelativePath): Promise<void> {
path = normalizePath(path);
const entry = await this.vault.adapter.stat(path);
if (!entry) return;
if (entry.type !== "file") {
throw new Error(`Cannot delete non-file: ${path}`);
}
await this.vault.adapter.remove(path);
}
public async rename(
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
return this.vault.adapter.rename(oldPath, newPath);
}
private async statFile(path: string): Promise<Stat> {
const file = await this.vault.adapter.stat(normalizePath(path));
if (!file) {
throw new Error(`File not found: ${path}`);
}
return file;
}
private async deleteDirectoryTree(path: string): Promise<void> {
const entry = await this.vault.adapter.stat(path);
if (!entry) return;
if (entry.type !== "folder") {
throw new Error(`Cannot delete non-directory: ${path}`);
}
const children = await this.vault.adapter.list(path);
if (children.files.length > 0) {
throw new Error(`Cannot delete non-empty directory: ${path}`);
}
for (const folder of children.folders) {
await this.deleteDirectoryTree(folder);
}
await this.vault.adapter.rmdir(path, false);
} }
} }

View file

@ -1,11 +1,16 @@
import type { import type {
MarkdownView,
Editor, Editor,
MarkdownFileInfo, MarkdownFileInfo,
TAbstractFile, TAbstractFile,
WorkspaceLeaf WorkspaceLeaf
} from "obsidian"; } from "obsidian";
import { Notice, Plugin, TFile } from "obsidian"; import {
Notice,
Plugin,
TFile,
MarkdownView,
FileSystemAdapter
} from "obsidian";
import "../manifest.json"; import "../manifest.json";
import { HistoryView } from "./views/history/history-view"; import { HistoryView } from "./views/history/history-view";
import { StatusBar } from "./views/status-bar/status-bar"; import { StatusBar } from "./views/status-bar/status-bar";
@ -14,11 +19,14 @@ import { StatusDescription } from "./views/status-description/status-description
import { import {
SyncClient, SyncClient,
rateLimit, rateLimit,
DEFAULT_SETTINGS,
Logger, Logger,
debugging debugging,
type FileSystemOperations,
type PersistenceProvider,
type StoredClient
} from "sync-client"; } from "sync-client";
import { ObsidianFileSystemOperations } from "./obsidian-file-system"; import { ObsidianFileSystemOperations } from "./obsidian-file-system";
import { MobileFileSystemOperations } from "./mobile-file-system";
import { SyncSettingsTab } from "./views/settings/settings-tab"; import { SyncSettingsTab } from "./views/settings/settings-tab";
import { EditorStatusDisplayManager } from "./views/editor-status-display-manager/editor-status-display-manager"; import { EditorStatusDisplayManager } from "./views/editor-status-display-manager/editor-status-display-manager";
import { remoteCursorsTheme } from "./views/cursors/remote-cursor-theme"; import { remoteCursorsTheme } from "./views/cursors/remote-cursor-theme";
@ -38,7 +46,7 @@ export default class VaultLinkPlugin extends Plugin {
() => Promise<unknown> () => Promise<unknown>
>(); >();
private readonly syncClient: SyncClient | undefined; private syncClient: SyncClient | undefined;
private settingsTab: SyncSettingsTab | undefined; private settingsTab: SyncSettingsTab | undefined;
public async onload(): Promise<void> { public async onload(): Promise<void> {
@ -53,14 +61,19 @@ export default class VaultLinkPlugin extends Plugin {
// eslint-disable-next-line // eslint-disable-next-line
(globalThis as any).VAULT_LINK_RUNNING_INSTANCE = this; (globalThis as any).VAULT_LINK_RUNNING_INSTANCE = this;
const client = await this.createSyncClient(); this.register(() => {
// eslint-disable-next-line
(globalThis as any).VAULT_LINK_RUNNING_INSTANCE = null;
});
const client = (this.syncClient = await this.createSyncClient());
this.registerObsidianExtensions(client); this.registerObsidianExtensions(client);
this.registerEditorEvents(client); this.registerEditorEvents(client);
this.register(async () => { this.register(async () => {
await client.waitUntilFinished(); this.syncClient = undefined;
this.rateLimitedUpdatesPerFile.clear();
await client.destroy(); await client.destroy();
}); });
@ -116,21 +129,55 @@ export default class VaultLinkPlugin extends Plugin {
} }
private async createSyncClient(): Promise<SyncClient> { private async createSyncClient(): Promise<SyncClient> {
DEFAULT_SETTINGS.ignorePatterns.push( const { vault, workspace } = this.app;
".obsidian/**", let disk: FileSystemOperations;
let persistence: PersistenceProvider<StoredClient>;
if (vault.adapter instanceof FileSystemAdapter) {
// Keep Node modules unevaluated on mobile.
const { NodeFileSystemOperations } =
require("../../local-client-cli/src/node-filesystem") as typeof import("../../local-client-cli/src/node-filesystem");
const { ClientPersistence } =
require("../../local-client-cli/src/client-persistence") as typeof import("../../local-client-cli/src/client-persistence");
const root = vault.adapter.getBasePath();
disk = new NodeFileSystemOperations(root);
persistence = new ClientPersistence(
`${root}/${this.manifest.dir ?? `${vault.configDir}/plugins/${this.manifest.id}`}/data.json`,
{}
);
} else {
disk = new MobileFileSystemOperations(vault.adapter);
persistence = {
load: this.loadData.bind(this),
save: this.saveData.bind(this)
};
}
const client = await SyncClient.create({
fs: new ObsidianFileSystemOperations(disk, () => {
const view = workspace.getActiveViewOfType(MarkdownView);
return view?.file
? { path: view.file.path, editor: view.editor }
: undefined;
}),
persistence: {
load: async () => {
const stored = await persistence.load();
return {
...stored,
settings: {
...stored?.settings,
ignorePatterns: [
...new Set([
...(stored?.settings?.ignorePatterns ?? []),
`${vault.configDir}/**`,
".git/**", ".git/**",
".trash/**", ".trash/**",
"**/.DS_Store" "**/.DS_Store"
); ])
]
const client = await SyncClient.create({ }
fs: new ObsidianFileSystemOperations( };
this.app.vault, },
this.app.workspace save: (stored) => persistence.save(stored)
),
persistence: {
load: this.loadData.bind(this),
save: this.saveData.bind(this)
}, },
...(IS_DEBUG_BUILD ...(IS_DEBUG_BUILD
? { ? {
@ -141,7 +188,7 @@ export default class VaultLinkPlugin extends Plugin {
}); });
if (IS_DEBUG_BUILD) { if (IS_DEBUG_BUILD) {
debugging.logToConsole(client.logger); debugging.logToConsole(client);
} }
return client; return client;
@ -195,11 +242,6 @@ export default class VaultLinkPlugin extends Plugin {
this.register(() => { this.register(() => {
editorStatusDisplayManager.dispose(); editorStatusDisplayManager.dispose();
}); });
this.register(() => {
// eslint-disable-next-line
(globalThis as any).VAULT_LINK_RUNNING_INSTANCE = null;
});
} }
private addRibbonIcons(): void { private addRibbonIcons(): void {
@ -232,7 +274,7 @@ export default class VaultLinkPlugin extends Plugin {
), ),
this.app.vault.on("create", (file: TAbstractFile) => { this.app.vault.on("create", (file: TAbstractFile) => {
if (file instanceof TFile) { if (file instanceof TFile) {
client.syncLocallyCreatedFile(file.path); void this.rateLimitedUpdate(file.path, client);
} }
}), }),
this.app.vault.on("modify", async (file: TAbstractFile) => { this.app.vault.on("modify", async (file: TAbstractFile) => {
@ -241,18 +283,18 @@ export default class VaultLinkPlugin extends Plugin {
} }
}), }),
this.app.vault.on("delete", (file: TAbstractFile) => { this.app.vault.on("delete", (file: TAbstractFile) => {
client.syncLocallyDeletedFile(file.path); void this.rateLimitedUpdate(file.path, client);
}), }),
// Vault.rename is a logical move (including folders). Engine file
// moves use copy/unlink, so they cannot generate this event.
this.app.vault.on( this.app.vault.on(
"rename", "rename",
(file: TAbstractFile, oldPath: string) => { (file: TAbstractFile, oldPath: string) => {
if (file instanceof TFile) { void client.syncLocallyUpdatedFile({
client.syncLocallyUpdatedFile({
oldPath, oldPath,
relativePath: file.path relativePath: file.path
}); });
} }
}
) )
].forEach((event) => { ].forEach((event) => {
this.registerEvent(event); this.registerEvent(event);
@ -267,7 +309,8 @@ export default class VaultLinkPlugin extends Plugin {
this.rateLimitedUpdatesPerFile.set( this.rateLimitedUpdatesPerFile.set(
path, path,
rateLimit(async () => { rateLimit(async () => {
client.syncLocallyUpdatedFile({ if (this.syncClient !== client) return;
await client.syncLocallyUpdatedFile({
relativePath: path relativePath: path
}); });
}, MIN_WAIT_BETWEEN_UPDATES_IN_MS) }, MIN_WAIT_BETWEEN_UPDATES_IN_MS)

View file

@ -36,7 +36,7 @@ export function renderCursorsInFileExplorer(
(parent) => { (parent) => {
cursors.forEach((cursor) => { cursors.forEach((cursor) => {
cursor.documentsWithCursors.forEach((document) => { cursor.documentsWithCursors.forEach((document) => {
if (document.relativePath.startsWith(key)) { if (document.relative_path.startsWith(key)) {
parent.appendChild( parent.appendChild(
createSpan({ createSpan({
text: cursor.userName, text: cursor.userName,

View file

@ -61,7 +61,7 @@ export class RemoteCursorsPluginValue implements PluginValue {
return clientCursors.flatMap((cursor) => return clientCursors.flatMap((cursor) =>
cursor.cursors.map((span) => ({ cursor.cursors.map((span) => ({
name: client.userName, name: client.userName,
path: cursor.relativePath, path: cursor.relative_path,
deviceId: client.deviceId, deviceId: client.deviceId,
isOutdated: client.isOutdated, isOutdated: client.isOutdated,
span: { ...span } span: { ...span }

View file

@ -88,7 +88,7 @@ export class StatusDescription {
text: ` and has indexed approximately ` text: ` and has indexed approximately `
}); });
container.createSpan({ container.createSpan({
text: `${this.syncClient.syncedDocumentCount}`, text: `${this.syncClient.documentCount}`,
cls: "number" cls: "number"
}); });
container.createSpan({ container.createSpan({

View file

@ -4,6 +4,7 @@
"module": "ESNext", "module": "ESNext",
"target": "ES2023", "target": "ES2023",
"strict": true, "strict": true,
"skipLibCheck": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"lib": ["DOM", "ES2024"] "lib": ["DOM", "ES2024"]

View file

@ -12,6 +12,11 @@ module.exports = (env, argv) => ({
ignored: "**/node_modules" ignored: "**/node_modules"
}, },
externals: { externals: {
"node:assert/strict": "commonjs node:assert/strict",
"node:fs/promises": "commonjs node:fs/promises",
"node:fs": "commonjs node:fs",
"node:path": "commonjs node:path",
"node:crypto": "commonjs node:crypto",
obsidian: "commonjs obsidian", obsidian: "commonjs obsidian",
electron: "commonjs electron", electron: "commonjs electron",
"@codemirror/autocomplete": "commonjs @codemirror/autocomplete", "@codemirror/autocomplete": "commonjs @codemirror/autocomplete",

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