simplify 3
This commit is contained in:
parent
82929d7479
commit
96d720eed9
39 changed files with 1318 additions and 995 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
80
frontend/local-client-cli/src/client-persistence.test.ts
Normal file
80
frontend/local-client-cli/src/client-persistence.test.ts
Normal 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"]);
|
||||||
|
});
|
||||||
|
}
|
||||||
67
frontend/local-client-cli/src/client-persistence.ts
Normal file
67
frontend/local-client-cli/src/client-persistence.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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": [],
|
||||||
|
|
|
||||||
130
frontend/obsidian-plugin/src/mobile-file-system.ts
Normal file
130
frontend/obsidian-plugin/src/mobile-file-system.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
167
frontend/obsidian-plugin/src/obsidian-file-system.test.ts
Normal file
167
frontend/obsidian-plugin/src/obsidian-file-system.test.ts
Normal 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/);
|
||||||
|
});
|
||||||
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue