This commit is contained in:
Andras Schmelczer 2026-09-23 08:23:17 +01:00
commit 4c002f9acd
11 changed files with 14543 additions and 4367 deletions

View file

@ -1,63 +1,32 @@
# 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.
The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definition format, which contains TSDoc comments describing what it does.
From `frontend`:
**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.
- Adds a command "Open Sample Modal" which opens a Modal.
- Adds a plugin setting tab to the settings page.
- Registers a global click event and output 'click' to the console.
- Registers a global interval which logs 'setInterval' to the console.
Desktop Obsidian shares the CLI's Node filesystem and atomic metadata adapters.
The complete client record remains in the plugin's `data.json`; it includes
settings, sync state, queued logical notifications and the server history
checkpoint. Mobile uses Obsidian's storage and plugin data APIs, writing files
directly and updating text through `DataAdapter.process`.
## 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. Atomic text updates apply synchronously to the active editor, whose
normal save mechanism persists them; closed files use the storage adapter.
Quick starting guide for new plugin devs:
- 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.
- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it).
- 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
Adapter tests cover unsaved text, cursor restoration, direct updates and
storage failures. The repository E2E runner builds the plugin and tests its
filesystem adapter against the server alongside real CLI processes. These tests
do not launch Obsidian itself.

View file

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

View file

@ -0,0 +1,136 @@
import type { DataAdapter } from "obsidian";
import type {
FileSystemOperations,
FileSnapshot,
TextWithCursors
} from "sync-client";
/** Mobile storage delegates content updates and moves to the host adapter. */
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 read(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.adapter.writeBinary(
path,
new Uint8Array(snapshot.content).buffer
);
}
public async atomicUpdateText(
path: string,
updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> {
return this.adapter.process(
this.check(path),
(text) => updater({ text, cursors: [] }).text
);
}
public async rename(from: string, to: string): Promise<void> {
this.check(from);
this.check(to);
const snapshot = await this.read(from);
if (!snapshot) throw new Error(`Missing source: ${from}`);
await this.createDirectory(to.split("/").slice(0, -1).join("/"));
await this.adapter.copy(from, to);
const current = await this.read(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 deleteDirectory(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.deleteDirectory(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,188 @@
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.read("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 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.read("note.md"))!.content), value);
assert.deepEqual(selections, [
{ anchor: { line: 0, ch: 1 }, head: { line: 1, ch: 3 } }
]);
value = "unsaved again";
selections = [{ anchor: { line: 0, ch: 0 }, head: { line: 0, ch: 2 } }];
const updated = await adapter.atomicUpdateText("note.md", (current) => {
assert.equal(current.text, "unsaved again");
return {
text: `prefix ${current.text}`,
cursors: [
{ id: 0, position: 7 },
{ id: 1, position: 9 }
]
};
});
assert.equal(updated, "prefix unsaved again");
assert.equal(value, updated);
assert.deepEqual(selections, [
{ anchor: { line: 0, ch: 7 }, head: { line: 0, ch: 9 } }
]);
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.read("moved.md"))!.content), value);
assert.equal(await adapter.read("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.read(p))!.content).buffer,
writeBinary: (p: string, content: ArrayBuffer) =>
fs.writeFile(path.join(root, p), new Uint8Array(content)),
process: (p: string, updater: (text: string) => string) =>
disk.atomicUpdateText(p, ({ text }) => ({
text: updater(text),
cursors: []
})),
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 updates files directly and moves preserve occupied destinations", 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");
await adapter.write("folder/note.md", { content: bytes("first") });
await adapter.write("folder/note.md", { content: bytes("second") });
assert.equal(
await adapter.atomicUpdateText("folder/note.md", ({ text }) => {
assert.equal(text, "second");
return { text: `${text} updated`, cursors: [] };
}),
"second updated"
);
const original = (await adapter.read("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.read("folder/note.md"), original);
assert.equal(
text((await adapter.read("destination.md"))!.content),
"occupied"
);
await assert.rejects(adapter.deleteDirectory("folder"));
await adapter.rename("folder/note.md", "moved.md");
assert.deepEqual(await adapter.read("moved.md"), original);
await adapter.deleteDirectory("folder");
assert.equal(await adapter.exists("folder"), false);
assert.equal(await adapter.exists(".vault-link-sync"), false);
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.read("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,176 +1,130 @@
import type { Stat, Vault, Workspace } from "obsidian";
import { MarkdownView, normalizePath } from "obsidian";
import type { CursorPosition, TextWithCursors } from "sync-client";
import type { Editor } from "obsidian";
import {
utils,
type FileSystemOperations,
type RelativePath
type FileSnapshot,
type TextWithCursors
} from "sync-client";
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 {
public constructor(
private readonly vault: Vault,
private readonly workspace: Workspace
) {}
private readonly disk: FileSystemOperations,
private readonly activeEditor: () =>
| { path: string; editor: Editor }
| undefined
) { }
public async listFilesRecursively(
root: RelativePath | undefined
): Promise<RelativePath[]> {
// Let's implement this by hand because vault.adapter.listAllFiles doesn't always return all files.
const allFiles = [];
const remainingFolders = [root ?? this.vault.getRoot().path];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
const folder = remainingFolders.pop();
if (folder == undefined) {
break;
}
// This would be a very bad idea to sync as it would mess with
// the integrity of the sync database.
if (folder.endsWith(".obsidian/plugins/vault-link/data.json")) {
continue;
}
const files = await this.vault.adapter.list(normalizePath(folder));
allFiles.push(...files.files);
remainingFolders.push(...files.folders);
}
return allFiles;
public async listFilesRecursively(root?: string): Promise<string[]> {
return this.disk.listFilesRecursively(root);
}
public async exists(path: string): Promise<boolean> {
return this.disk.exists(path);
}
public async createDirectory(path: string): Promise<void> {
return this.disk.createDirectory(path);
}
public async deleteDirectory(path: string): Promise<void> {
return this.disk.deleteDirectory(path);
}
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.read(from);
if (!snapshot) throw new Error(`Missing source: ${from}`);
await this.disk.rename(from, to);
await this.write(to, snapshot);
}
public async read(path: RelativePath): Promise<Uint8Array> {
path = normalizePath(path);
const view = this.workspace.getActiveViewOfType(MarkdownView);
if (view?.file?.path === path) {
return new TextEncoder().encode(view.editor.getValue());
}
return new Uint8Array(await this.vault.adapter.readBinary(path));
public async stat(path: string): ReturnType<FileSystemOperations["stat"]> {
const entry = await this.disk.stat(path);
const view = this.activeEditor();
if (entry?.kind === "file" && view?.path === path)
return {
...entry,
size: new TextEncoder().encode(view.editor.getValue())
.byteLength
};
return entry;
}
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;
public async read(path: string): Promise<FileSnapshot | undefined> {
// Validate the disk path even when an editor is open.
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.read(path);
}
return this.vault.adapter.writeBinary(
path,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
content.buffer as ArrayBuffer
);
public async write(path: string, snapshot: FileSnapshot): Promise<void> {
await this.disk.write(path, snapshot);
const view = this.activeEditor();
if (view?.path !== path) return;
this.updateEditor(view.editor, {
text: new TextDecoder().decode(snapshot.content),
cursors: snapshot.cursors ?? []
});
}
public async atomicUpdateText(
path: RelativePath,
path: string,
updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> {
path = normalizePath(path);
if ((await this.disk.stat(path))?.kind !== "file")
throw new Error(`Missing text file: ${path}`);
const view = this.activeEditor();
if (view?.path !== path)
return this.disk.atomicUpdateText(path, updater);
const current = {
text: view.editor.getValue(),
cursors: getSelectionsFromEditor(view.editor).flatMap(
({ id, start, end }) => [
{ id: 2 * id, position: start },
{ id: 2 * id + 1, position: end }
]
)
};
const result = updater(current);
if (result.text !== current.text)
this.updateEditor(view.editor, result);
return result.text;
}
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({
private updateEditor(
editor: Editor,
{ text, cursors: positions }: TextWithCursors
): void {
editor.setValue(text);
const cursors = [...positions].sort((a, b) => a.id - b.id);
const selections = [];
for (let i = 0; i + 1 < cursors.length; i += 2) {
const anchor = utils.positionToLineAndColumn(
text,
cursors
cursors[i].position
);
const head = utils.positionToLineAndColumn(
text,
cursors[i + 1].position
);
selections.push({
anchor: { line: anchor.line, ch: anchor.column },
head: { line: head.line, ch: head.column }
});
if (result.text === text) {
return text;
}
view.editor.setValue(result.text);
const selections = [];
for (let i = 0; i < result.cursors.length / 2; i++) {
const from = result.cursors[2 * i];
const to = result.cursors[2 * i + 1];
const { line: fromLine, column: fromColumn } =
utils.positionToLineAndColumn(result.text, from.position);
const { line: toLine, column: toColumn } =
utils.positionToLineAndColumn(result.text, to.position);
selections.push({
anchor: { line: fromLine, ch: fromColumn },
head: { line: toLine, ch: toColumn }
});
}
view.editor.setSelections(selections);
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 (!(await this.vault.adapter.trashSystem(normalizePath(path)))) {
return this.vault.adapter.remove(normalizePath(path));
}
}
public async rename(
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
return this.vault.adapter.rename(oldPath, newPath);
}
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;
if (selections.length) editor.setSelections(selections);
}
}

View file

@ -1,11 +1,16 @@
import type {
MarkdownView,
Editor,
MarkdownFileInfo,
TAbstractFile,
WorkspaceLeaf
} from "obsidian";
import { Notice, Platform, Plugin, TFile } from "obsidian";
import {
Notice,
Plugin,
TFile,
MarkdownView,
FileSystemAdapter
} from "obsidian";
import "../manifest.json";
import { HistoryView } from "./views/history/history-view";
import { StatusBar } from "./views/status-bar/status-bar";
@ -14,11 +19,14 @@ import { StatusDescription } from "./views/status-description/status-description
import {
SyncClient,
rateLimit,
DEFAULT_SETTINGS,
Logger,
debugging
debugging,
type FileSystemOperations,
type PersistenceProvider,
type StoredClient
} from "sync-client";
import { ObsidianFileSystemOperations } from "./obsidian-file-system";
import { MobileFileSystemOperations } from "./mobile-file-system";
import { SyncSettingsTab } from "./views/settings/settings-tab";
import { EditorStatusDisplayManager } from "./views/editor-status-display-manager/editor-status-display-manager";
import { remoteCursorsTheme } from "./views/cursors/remote-cursor-theme";
@ -38,7 +46,7 @@ export default class VaultLinkPlugin extends Plugin {
() => Promise<unknown>
>();
private readonly syncClient: SyncClient | undefined;
private syncClient: SyncClient | undefined;
private settingsTab: SyncSettingsTab | undefined;
public async onload(): Promise<void> {
@ -53,14 +61,19 @@ export default class VaultLinkPlugin extends Plugin {
// eslint-disable-next-line
(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.registerEditorEvents(client);
this.register(async () => {
await client.waitUntilFinished();
this.syncClient = undefined;
this.rateLimitedUpdatesPerFile.clear();
await client.destroy();
});
@ -116,23 +129,56 @@ export default class VaultLinkPlugin extends Plugin {
}
private async createSyncClient(): Promise<SyncClient> {
DEFAULT_SETTINGS.ignorePatterns.push(
".obsidian/**",
".git/**",
".trash/**",
"**/.DS_Store"
);
const client = await SyncClient.create({
fs: new ObsidianFileSystemOperations(
this.app.vault,
this.app.workspace
),
persistence: {
const { vault, workspace } = this.app;
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/**",
".trash/**",
"**/.DS_Store"
])
]
}
};
},
save: (stored) => persistence.save(stored)
},
nativeLineEndings: Platform.isWin ? "\r\n" : "\n",
...(IS_DEBUG_BUILD
? {
fetch: debugging.slowFetchFactory(1),
@ -142,7 +188,7 @@ export default class VaultLinkPlugin extends Plugin {
});
if (IS_DEBUG_BUILD) {
debugging.logToConsole(client.logger);
debugging.logToConsole(client);
}
return client;
@ -196,11 +242,6 @@ export default class VaultLinkPlugin extends Plugin {
this.register(() => {
editorStatusDisplayManager.dispose();
});
this.register(() => {
// eslint-disable-next-line
(globalThis as any).VAULT_LINK_RUNNING_INSTANCE = null;
});
}
private addRibbonIcons(): void {
@ -233,7 +274,7 @@ export default class VaultLinkPlugin extends Plugin {
),
this.app.vault.on("create", (file: TAbstractFile) => {
if (file instanceof TFile) {
client.syncLocallyCreatedFile(file.path);
void this.rateLimitedUpdate(file.path, client);
}
}),
this.app.vault.on("modify", async (file: TAbstractFile) => {
@ -242,17 +283,17 @@ export default class VaultLinkPlugin extends Plugin {
}
}),
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(
"rename",
(file: TAbstractFile, oldPath: string) => {
if (file instanceof TFile) {
client.syncLocallyUpdatedFile({
oldPath,
relativePath: file.path
});
}
void client.syncLocallyUpdatedFile({
oldPath,
relativePath: file.path
});
}
)
].forEach((event) => {
@ -268,7 +309,8 @@ export default class VaultLinkPlugin extends Plugin {
this.rateLimitedUpdatesPerFile.set(
path,
rateLimit(async () => {
client.syncLocallyUpdatedFile({
if (this.syncClient !== client) return;
await client.syncLocallyUpdatedFile({
relativePath: path
});
}, MIN_WAIT_BETWEEN_UPDATES_IN_MS)

View file

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

View file

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

View file

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

View file

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

View file

@ -12,6 +12,11 @@ module.exports = (env, argv) => ({
ignored: "**/node_modules"
},
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",
electron: "commonjs electron",
"@codemirror/autocomplete": "commonjs @codemirror/autocomplete",