simplify 3
This commit is contained in:
parent
82929d7479
commit
96d720eed9
11 changed files with 1318 additions and 995 deletions
|
|
@ -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.
|
||||
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. Because mobile
|
||||
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:
|
||||
|
||||
- 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, exclusive creation 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.
|
||||
|
|
|
|||
|
|
@ -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": [],
|
||||
|
|
|
|||
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 { MarkdownView, normalizePath } from "obsidian";
|
||||
import type { CursorPosition, TextWithCursors } from "sync-client";
|
||||
import type { Editor } from "obsidian";
|
||||
import {
|
||||
utils,
|
||||
type FileSystemOperations,
|
||||
type RelativePath
|
||||
type FileSnapshot
|
||||
} 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 delete(path: string): Promise<void> {
|
||||
return this.disk.delete(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.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 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 readSnapshot(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.readSnapshot(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;
|
||||
const text = new TextDecoder().decode(snapshot.content);
|
||||
view.editor.setValue(text);
|
||||
const cursors = [...(snapshot.cursors ?? [])].sort(
|
||||
(a, b) => a.id - b.id
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
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 (!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);
|
||||
if (selections.length) view.editor.setSelections(selections);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import type {
|
||||
MarkdownView,
|
||||
Editor,
|
||||
MarkdownFileInfo,
|
||||
TAbstractFile,
|
||||
WorkspaceLeaf
|
||||
} from "obsidian";
|
||||
import { Notice, 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,21 +129,55 @@ 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)
|
||||
},
|
||||
...(IS_DEBUG_BUILD
|
||||
? {
|
||||
|
|
@ -141,7 +188,7 @@ export default class VaultLinkPlugin extends Plugin {
|
|||
});
|
||||
|
||||
if (IS_DEBUG_BUILD) {
|
||||
debugging.logToConsole(client.logger);
|
||||
debugging.logToConsole(client);
|
||||
}
|
||||
|
||||
return client;
|
||||
|
|
@ -195,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 {
|
||||
|
|
@ -232,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) => {
|
||||
|
|
@ -241,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) => {
|
||||
|
|
@ -267,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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"module": "ESNext",
|
||||
"target": "ES2023",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"lib": ["DOM", "ES2024"]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue