mirror of
https://github.com/pageden/pageden-obsidian.git
synced 2026-07-22 07:46:21 +00:00
chore: include plugin source and shrink bundle
This commit is contained in:
parent
5fcb77d457
commit
3d0aaa7adb
25 changed files with 5655 additions and 74 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +1,2 @@
|
|||
.DS_Store
|
||||
node_modules/
|
||||
|
|
|
|||
14
esbuild.config.mjs
Normal file
14
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import esbuild from "esbuild";
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
outfile: "main.js",
|
||||
format: "cjs",
|
||||
platform: "browser",
|
||||
target: "es2022",
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
external: ["obsidian"],
|
||||
logLevel: "info",
|
||||
});
|
||||
144
main.js
144
main.js
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "pageden-sync",
|
||||
"name": "Pageden Sync",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Edit your team's server-owned Markdown documents from Pageden.",
|
||||
"author": "ccfiel",
|
||||
|
|
|
|||
38
package.json
Normal file
38
package.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"name": "pageden-sync",
|
||||
"version": "0.1.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node esbuild.config.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run -c vitest.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/core": "3.26.0",
|
||||
"@tiptap/extension-collaboration": "3.26.0",
|
||||
"@tiptap/extension-link": "3.26.0",
|
||||
"@tiptap/extension-placeholder": "3.26.0",
|
||||
"@tiptap/extension-table": "3.26.0",
|
||||
"@tiptap/extension-table-cell": "3.26.0",
|
||||
"@tiptap/extension-table-header": "3.26.0",
|
||||
"@tiptap/extension-table-row": "3.26.0",
|
||||
"@tiptap/extension-task-item": "3.26.0",
|
||||
"@tiptap/extension-task-list": "3.26.0",
|
||||
"@tiptap/pm": "3.26.0",
|
||||
"@tiptap/starter-kit": "3.26.0",
|
||||
"marked": "^18.0.5",
|
||||
"turndown": "^7.2.4",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"y-websocket": "^3.0.0",
|
||||
"yjs": "^13.6.31"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"esbuild": "^0.28.0",
|
||||
"obsidian": "^1.13.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^2.0.5"
|
||||
}
|
||||
}
|
||||
1869
pnpm-lock.yaml
Normal file
1869
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
112
src/api-client.test.ts
Normal file
112
src/api-client.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { PagedenApiClient, PagedenApiError, type RequestTransport } from "./api-client";
|
||||
|
||||
class FakeTransport implements RequestTransport {
|
||||
calls: Array<{ url: string; method: string; headers?: Record<string, string>; body?: string | ArrayBuffer }> = [];
|
||||
|
||||
constructor(private readonly response: { status: number; text: string; arrayBuffer?: ArrayBuffer }) {}
|
||||
|
||||
async request(options: { url: string; method: string; headers?: Record<string, string>; body?: string | ArrayBuffer }) {
|
||||
this.calls.push(options);
|
||||
return this.response;
|
||||
}
|
||||
}
|
||||
|
||||
describe("Pageden API client", () => {
|
||||
it("sends bearer auth and trims duplicate server URL slashes", async () => {
|
||||
const transport = new FakeTransport({ status: 200, text: JSON.stringify({ folders: [], documents: [] }) });
|
||||
const api = new PagedenApiClient("https://team.example.com///", "pm_live_test", transport);
|
||||
await api.tree("ws 1");
|
||||
|
||||
expect(transport.calls[0]?.url).toBe("https://team.example.com/api/documents/tree?workspaceId=ws%201");
|
||||
expect(transport.calls[0]?.headers?.authorization).toBe("Bearer pm_live_test");
|
||||
});
|
||||
|
||||
it("surfaces conflict errors with currentVersion", async () => {
|
||||
const transport = new FakeTransport({
|
||||
status: 409,
|
||||
text: JSON.stringify({ error: "conflict", currentVersion: "rev2", message: "stale" }),
|
||||
});
|
||||
const api = new PagedenApiClient("https://team.example.com", "pm_live_test", transport);
|
||||
|
||||
await expect(api.push("doc1", { baseVersion: "rev1", checksum: "sha256:x", content: "x" })).rejects.toMatchObject({
|
||||
status: 409,
|
||||
currentVersion: "rev2",
|
||||
} satisfies Partial<PagedenApiError>);
|
||||
});
|
||||
|
||||
it("searches remote documents with bearer auth", async () => {
|
||||
const transport = new FakeTransport({ status: 200, text: JSON.stringify({ results: [] }) });
|
||||
const api = new PagedenApiClient("https://team.example.com", "pm_live_test", transport);
|
||||
await api.search("ws1", "runbook notes", 10);
|
||||
|
||||
expect(transport.calls[0]?.url).toBe("https://team.example.com/api/search?workspaceId=ws1&q=runbook+notes&limit=10");
|
||||
expect(transport.calls[0]?.headers?.authorization).toBe("Bearer pm_live_test");
|
||||
});
|
||||
|
||||
it("creates folders and documents through the normal API", async () => {
|
||||
const folderTransport = new FakeTransport({ status: 201, text: JSON.stringify({ id: "folder1", path: "imported" }) });
|
||||
const api = new PagedenApiClient("https://team.example.com", "pm_live_test", folderTransport);
|
||||
await api.createFolder({ workspaceId: "ws1", parentFolderId: null, name: "Imported", slug: "imported" });
|
||||
|
||||
expect(folderTransport.calls[0]?.url).toBe("https://team.example.com/api/folders");
|
||||
expect(folderTransport.calls[0]?.headers?.authorization).toBe("Bearer pm_live_test");
|
||||
expect(JSON.parse(String(folderTransport.calls[0]?.body ?? "{}"))).toEqual({
|
||||
workspaceId: "ws1",
|
||||
parentFolderId: null,
|
||||
name: "Imported",
|
||||
slug: "imported",
|
||||
});
|
||||
|
||||
const documentTransport = new FakeTransport({ status: 201, text: JSON.stringify({ id: "doc1", version: "rev1", checksum: "sha256:x", path: "imported/note.md" }) });
|
||||
const docApi = new PagedenApiClient("https://team.example.com", "pm_live_test", documentTransport);
|
||||
await docApi.createDocument({ workspaceId: "ws1", folderId: "folder1", title: "Note", slug: "note", content: "# Note\n" });
|
||||
|
||||
expect(documentTransport.calls[0]?.url).toBe("https://team.example.com/api/documents");
|
||||
expect(JSON.parse(String(documentTransport.calls[0]?.body ?? "{}"))).toEqual({
|
||||
workspaceId: "ws1",
|
||||
folderId: "folder1",
|
||||
title: "Note",
|
||||
slug: "note",
|
||||
content: "# Note\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("starts and polls device login without bearer auth", async () => {
|
||||
const transport = new FakeTransport({
|
||||
status: 201,
|
||||
text: JSON.stringify({ deviceCode: "dev", userCode: "ABCD-2345", verificationUri: "https://app/devices", expiresIn: 600, interval: 5 }),
|
||||
});
|
||||
const api = new PagedenApiClient("https://team.example.com", "", transport);
|
||||
await api.deviceStart();
|
||||
|
||||
expect(transport.calls[0]?.url).toBe("https://team.example.com/api/auth/device/start");
|
||||
expect(transport.calls[0]?.headers?.authorization).toBeUndefined();
|
||||
|
||||
const pollTransport = new FakeTransport({ status: 200, text: JSON.stringify({ status: "approved", token: "pm_live_new" }) });
|
||||
const pollApi = new PagedenApiClient("https://team.example.com", "", pollTransport);
|
||||
await expect(pollApi.devicePoll("dev")).resolves.toEqual({ status: "approved", token: "pm_live_new" });
|
||||
expect(pollTransport.calls[0]?.url).toBe("https://team.example.com/api/auth/device/poll");
|
||||
expect(pollTransport.calls[0]?.headers?.authorization).toBeUndefined();
|
||||
expect(JSON.parse(String(pollTransport.calls[0]?.body ?? "{}"))).toEqual({ deviceCode: "dev" });
|
||||
});
|
||||
|
||||
it("uploads and downloads attachment bytes", async () => {
|
||||
const body = new Uint8Array([1, 2, 3]).buffer;
|
||||
const transport = new FakeTransport({
|
||||
status: 201,
|
||||
text: JSON.stringify({ id: "att1", filename: "a b.png", contentType: "image/png", size: 3, sha256: "abc", createdAt: "now" }),
|
||||
});
|
||||
const api = new PagedenApiClient("https://team.example.com", "pm_live_test", transport);
|
||||
await api.uploadAttachment("doc1", "a b.png", body, "image/png");
|
||||
|
||||
expect(transport.calls[0]?.url).toBe("https://team.example.com/api/documents/doc1/attachments?filename=a%20b.png");
|
||||
expect(transport.calls[0]?.headers?.["content-type"]).toBe("image/png");
|
||||
expect(transport.calls[0]?.body).toBe(body);
|
||||
|
||||
const downloadBody = new Uint8Array([9]).buffer;
|
||||
const downloadTransport = new FakeTransport({ status: 200, text: "", arrayBuffer: downloadBody });
|
||||
const downloadApi = new PagedenApiClient("https://team.example.com", "pm_live_test", downloadTransport);
|
||||
await expect(downloadApi.downloadAttachment("att1")).resolves.toBe(downloadBody);
|
||||
});
|
||||
});
|
||||
170
src/api-client.ts
Normal file
170
src/api-client.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { requestUrl } from "obsidian";
|
||||
import type {
|
||||
Attachment,
|
||||
AttachmentListResponse,
|
||||
CreateDocumentResponse,
|
||||
CreateFolderResponse,
|
||||
DevicePollResponse,
|
||||
DeviceStartResponse,
|
||||
MeResponse,
|
||||
RemoteDocumentWithContent,
|
||||
RemoteTree,
|
||||
SearchResponse,
|
||||
WriteResult,
|
||||
} from "./types";
|
||||
|
||||
export class PagedenApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly body: unknown,
|
||||
) {
|
||||
super(`Pageden API error ${status}`);
|
||||
this.name = "PagedenApiError";
|
||||
}
|
||||
|
||||
get code(): string | undefined {
|
||||
if (this.body && typeof this.body === "object" && "error" in this.body) {
|
||||
return String((this.body as { error: unknown }).error);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
get currentVersion(): string | null {
|
||||
if (this.body && typeof this.body === "object" && "currentVersion" in this.body) {
|
||||
const value = (this.body as { currentVersion: unknown }).currentVersion;
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RequestTransport {
|
||||
request(options: {
|
||||
url: string;
|
||||
method: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string | ArrayBuffer;
|
||||
}): Promise<{ status: number; text: string; arrayBuffer?: ArrayBuffer }>;
|
||||
}
|
||||
|
||||
export class ObsidianRequestTransport implements RequestTransport {
|
||||
async request(options: { url: string; method: string; headers?: Record<string, string>; body?: string | ArrayBuffer }) {
|
||||
const response = await requestUrl(options);
|
||||
return { status: response.status, text: response.text, arrayBuffer: response.arrayBuffer };
|
||||
}
|
||||
}
|
||||
|
||||
export class PagedenApiClient {
|
||||
constructor(
|
||||
private readonly serverUrl: string,
|
||||
private readonly token: string,
|
||||
private readonly transport: RequestTransport = new ObsidianRequestTransport(),
|
||||
) {}
|
||||
|
||||
me(): Promise<MeResponse> {
|
||||
return this.request("GET", "/me");
|
||||
}
|
||||
|
||||
async validate(): Promise<void> {
|
||||
await this.me();
|
||||
}
|
||||
|
||||
tree(workspaceId: string): Promise<RemoteTree> {
|
||||
return this.request("GET", `/documents/tree?workspaceId=${encodeURIComponent(workspaceId)}`);
|
||||
}
|
||||
|
||||
document(id: string): Promise<RemoteDocumentWithContent> {
|
||||
return this.request("GET", `/documents/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
createFolder(body: { workspaceId: string; parentFolderId: string | null; name: string; slug: string }): Promise<CreateFolderResponse> {
|
||||
return this.request("POST", "/folders", body);
|
||||
}
|
||||
|
||||
createDocument(body: { workspaceId: string; folderId: string; title: string; slug: string; content: string }): Promise<CreateDocumentResponse> {
|
||||
return this.request("POST", "/documents", body);
|
||||
}
|
||||
|
||||
push(documentId: string, body: { baseVersion: string; checksum: string; content: string }): Promise<WriteResult> {
|
||||
return this.request("POST", `/documents/${encodeURIComponent(documentId)}/push`, body);
|
||||
}
|
||||
|
||||
search(workspaceId: string, q: string, limit = 20): Promise<SearchResponse> {
|
||||
const params = new URLSearchParams({ workspaceId, q, limit: String(limit) });
|
||||
return this.request("GET", `/search?${params.toString()}`);
|
||||
}
|
||||
|
||||
deviceStart(): Promise<DeviceStartResponse> {
|
||||
return this.request("POST", "/auth/device/start", undefined, { auth: false });
|
||||
}
|
||||
|
||||
devicePoll(deviceCode: string): Promise<DevicePollResponse> {
|
||||
return this.request("POST", "/auth/device/poll", { deviceCode }, { auth: false });
|
||||
}
|
||||
|
||||
attachments(documentId: string): Promise<AttachmentListResponse> {
|
||||
return this.request("GET", `/documents/${encodeURIComponent(documentId)}/attachments`);
|
||||
}
|
||||
|
||||
uploadAttachment(documentId: string, filename: string, data: ArrayBuffer, contentType = "application/octet-stream"): Promise<Attachment> {
|
||||
return this.request("POST", `/documents/${encodeURIComponent(documentId)}/attachments?filename=${encodeURIComponent(filename)}`, data, {
|
||||
contentType,
|
||||
raw: true,
|
||||
});
|
||||
}
|
||||
|
||||
async downloadAttachment(attachmentId: string): Promise<ArrayBuffer> {
|
||||
const response = await this.rawRequest("GET", `/attachments/${encodeURIComponent(attachmentId)}`);
|
||||
if (response.arrayBuffer) return response.arrayBuffer;
|
||||
return new TextEncoder().encode(response.text).buffer;
|
||||
}
|
||||
|
||||
async deleteAttachment(attachmentId: string): Promise<void> {
|
||||
await this.request("DELETE", `/attachments/${encodeURIComponent(attachmentId)}`);
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options: { auth?: boolean; contentType?: string; raw?: boolean } = {},
|
||||
): Promise<T> {
|
||||
const response = await this.rawRequest(method, path, body, options);
|
||||
const json = safeJson(response.text);
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new PagedenApiError(response.status, json);
|
||||
}
|
||||
return json as T;
|
||||
}
|
||||
|
||||
private async rawRequest(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options: { auth?: boolean; contentType?: string; raw?: boolean } = {},
|
||||
): Promise<{ status: number; text: string; arrayBuffer?: ArrayBuffer }> {
|
||||
const headers = {
|
||||
...(options.auth === false || !this.token ? {} : { authorization: `Bearer ${this.token}` }),
|
||||
...(body === undefined ? {} : { "content-type": options.contentType ?? "application/json" }),
|
||||
};
|
||||
const response = await this.transport.request({
|
||||
method,
|
||||
url: `${this.serverUrl.replace(/\/+$/, "")}/api${path}`,
|
||||
headers,
|
||||
body: body === undefined ? undefined : options.raw ? (body as ArrayBuffer) : JSON.stringify(body),
|
||||
});
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new PagedenApiError(response.status, safeJson(response.text));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
function safeJson(text: string): unknown {
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
15
src/checksum.test.ts
Normal file
15
src/checksum.test.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { createHash, webcrypto } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canonicalize, checksum } from "./checksum";
|
||||
|
||||
Object.defineProperty(globalThis, "crypto", { value: webcrypto, configurable: true });
|
||||
|
||||
describe("plugin checksum", () => {
|
||||
it("canonicalizes CRLF, bare CR, empty, and trailing-newline variants", async () => {
|
||||
expect(canonicalize("a\r\nb\r")).toBe("a\nb\n");
|
||||
expect(canonicalize("")).toBe("\n");
|
||||
expect(canonicalize("a\n\n\n")).toBe("a\n");
|
||||
const expected = createHash("sha256").update("a\nb\n", "utf8").digest("hex");
|
||||
await expect(checksum("a\r\nb")).resolves.toBe(`sha256:${expected}`);
|
||||
});
|
||||
});
|
||||
13
src/checksum.ts
Normal file
13
src/checksum.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export function canonicalize(content: string): string {
|
||||
return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n*$/, "") + "\n";
|
||||
}
|
||||
|
||||
export async function checksum(content: string): Promise<string> {
|
||||
if (!globalThis.crypto?.subtle) {
|
||||
throw new Error("Web Crypto is required to compute Pageden checksums.");
|
||||
}
|
||||
const bytes = new TextEncoder().encode(canonicalize(content));
|
||||
const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
|
||||
const hex = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
return `sha256:${hex}`;
|
||||
}
|
||||
111
src/import-vault.test.ts
Normal file
111
src/import-vault.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { webcrypto } from "node:crypto";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildVaultImportPreview, extractAttachmentRefs, importVaultToPageden, slugify, type VaultImportFile } from "./import-vault";
|
||||
import type { RemoteDocumentWithContent, RemoteTree, ServerMetaEntry } from "./types";
|
||||
|
||||
Object.defineProperty(globalThis, "crypto", { value: webcrypto, configurable: true });
|
||||
|
||||
const files: VaultImportFile[] = [
|
||||
{ path: "Projects/Launch.md", name: "Launch.md", extension: "md" },
|
||||
{ path: "Projects/assets/diagram.png", name: "diagram.png", extension: "png" },
|
||||
{ path: ".obsidian/app.json", name: "app.json", extension: "json" },
|
||||
{ path: "Scratch.conflict.md", name: "Scratch.conflict.md", extension: "md" },
|
||||
];
|
||||
|
||||
const emptyTree: RemoteTree = { folders: [], documents: [] };
|
||||
|
||||
describe("vault import", () => {
|
||||
it("previews notes, attachments, skipped internal files, and remote path conflicts", () => {
|
||||
const preview = buildVaultImportPreview(files, {
|
||||
folders: [],
|
||||
documents: [
|
||||
{
|
||||
id: "doc-existing",
|
||||
folderId: "folder-existing",
|
||||
title: "Launch",
|
||||
path: "imported-from-obsidian/projects/launch.md",
|
||||
permission: "editor",
|
||||
version: "rev1",
|
||||
checksum: "sha256:x",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(preview).toMatchObject({
|
||||
targetRootSlug: "imported-from-obsidian",
|
||||
notes: 1,
|
||||
attachments: 1,
|
||||
skipped: 2,
|
||||
conflicts: ["imported-from-obsidian/projects/launch.md"],
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts Obsidian and Markdown attachment embeds", () => {
|
||||
expect(extractAttachmentRefs("![[diagram.png|wide]]\n\n")).toEqual([
|
||||
"diagram.png",
|
||||
"assets/chart 1.png",
|
||||
]);
|
||||
});
|
||||
|
||||
it("slugifies names into stable URL-safe segments", () => {
|
||||
expect(slugify("Finance Plan.md")).toBe("finance-plan");
|
||||
expect(slugify("你好")).toBe("untitled");
|
||||
});
|
||||
|
||||
it("creates folders and documents, links metadata, and uploads referenced attachments", async () => {
|
||||
const read = vi.fn(async (path: string) => {
|
||||
if (path === "Projects/Launch.md") return "# Launch\r\n\n![[diagram.png]]\n";
|
||||
throw new Error(`missing ${path}`);
|
||||
});
|
||||
const readBinary = vi.fn(async (path: string) => {
|
||||
if (path === "Projects/assets/diagram.png") return new Uint8Array([1, 2, 3]).buffer;
|
||||
throw new Error(`missing binary ${path}`);
|
||||
});
|
||||
const upsert = vi.fn();
|
||||
const remoteDoc: RemoteDocumentWithContent = {
|
||||
id: "doc1",
|
||||
workspaceId: "ws1",
|
||||
folderId: "folder-projects",
|
||||
title: "Launch",
|
||||
path: "imported-from-obsidian/projects/launch.md",
|
||||
permission: "editor",
|
||||
version: "rev1",
|
||||
checksum: "sha256:server",
|
||||
content: "# Launch\n\n![[diagram.png]]\n",
|
||||
updatedAt: "2026-06-10T00:00:00.000Z",
|
||||
};
|
||||
const api = {
|
||||
tree: vi.fn(async () => emptyTree),
|
||||
createFolder: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: "folder-root", path: "imported-from-obsidian" })
|
||||
.mockResolvedValueOnce({ id: "folder-projects", path: "imported-from-obsidian/projects" }),
|
||||
createDocument: vi.fn(async () => ({ id: "doc1", version: "rev1", checksum: "sha256:server", path: "imported-from-obsidian/projects/launch.md" })),
|
||||
document: vi.fn(async () => remoteDoc),
|
||||
uploadAttachment: vi.fn(async () => ({ id: "att1", filename: "diagram.png", contentType: "image/png", size: 3, sha256: "sha256:a", createdAt: "now" })),
|
||||
};
|
||||
|
||||
const report = await importVaultToPageden({
|
||||
api,
|
||||
vault: { read, readBinary },
|
||||
meta: { upsert },
|
||||
workspaceId: "ws1",
|
||||
files,
|
||||
targetRootName: "Imported from Obsidian",
|
||||
remoteTree: emptyTree,
|
||||
});
|
||||
|
||||
expect(report).toMatchObject({ foldersCreated: 2, documentsCreated: 1, documentsSkipped: 0, attachmentsUploaded: 1 });
|
||||
expect(api.createFolder).toHaveBeenNthCalledWith(1, expect.objectContaining({ parentFolderId: null, slug: "imported-from-obsidian" }));
|
||||
expect(api.createFolder).toHaveBeenNthCalledWith(2, expect.objectContaining({ parentFolderId: "folder-root", slug: "projects" }));
|
||||
expect(api.createDocument).toHaveBeenCalledWith(expect.objectContaining({ folderId: "folder-projects", title: "Launch", slug: "launch" }));
|
||||
expect(api.uploadAttachment).toHaveBeenCalledWith("doc1", "diagram.png", expect.any(ArrayBuffer), "image/png");
|
||||
expect(upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
documentId: "doc1",
|
||||
localPath: "Projects/Launch.md",
|
||||
baseVersion: "rev1",
|
||||
} satisfies Partial<ServerMetaEntry>),
|
||||
);
|
||||
});
|
||||
});
|
||||
373
src/import-vault.ts
Normal file
373
src/import-vault.ts
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
import { normalizePath } from "obsidian";
|
||||
import type { PagedenApiClient } from "./api-client";
|
||||
import { canonicalize } from "./checksum";
|
||||
import type { RemoteFolder, RemoteTree, ServerMetaEntry } from "./types";
|
||||
import type { MetaStoreLike, VaultLike } from "./sync";
|
||||
|
||||
export interface VaultImportFile {
|
||||
path: string;
|
||||
name: string;
|
||||
extension: string;
|
||||
}
|
||||
|
||||
export interface VaultImportPreview {
|
||||
targetRootName: string;
|
||||
targetRootSlug: string;
|
||||
notes: number;
|
||||
attachments: number;
|
||||
skipped: number;
|
||||
conflicts: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface VaultImportReport extends VaultImportPreview {
|
||||
foldersCreated: number;
|
||||
documentsCreated: number;
|
||||
documentsSkipped: number;
|
||||
attachmentsUploaded: number;
|
||||
attachmentWarnings: string[];
|
||||
rows: ImportReportRow[];
|
||||
}
|
||||
|
||||
export type ImportReportRow =
|
||||
| { path: string; status: "created"; message: string }
|
||||
| { path: string; status: "skipped"; message: string }
|
||||
| { path: string; status: "warning"; message: string };
|
||||
|
||||
interface ImportDeps {
|
||||
api: Pick<PagedenApiClient, "tree" | "createFolder" | "createDocument" | "document" | "uploadAttachment">;
|
||||
vault: Pick<VaultLike, "read" | "readBinary">;
|
||||
meta: Pick<MetaStoreLike, "upsert">;
|
||||
workspaceId: string;
|
||||
files: VaultImportFile[];
|
||||
targetRootName: string;
|
||||
remoteTree?: RemoteTree;
|
||||
onProgress?: (current: number, total: number) => void;
|
||||
}
|
||||
|
||||
interface ImportableFiles {
|
||||
notes: VaultImportFile[];
|
||||
attachments: VaultImportFile[];
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
const DEFAULT_ROOT_NAME = "Imported from Obsidian";
|
||||
|
||||
export function buildVaultImportPreview(files: VaultImportFile[], remoteTree: RemoteTree, targetRootName = DEFAULT_ROOT_NAME): VaultImportPreview {
|
||||
const importable = splitImportableFiles(files);
|
||||
const targetRootSlug = slugify(targetRootName);
|
||||
const remoteDocumentPaths = new Set(remoteTree.documents.map((doc) => trimSlashes(doc.path)));
|
||||
const conflicts = importable.notes
|
||||
.map((file) => remotePathForNote(file.path, targetRootSlug))
|
||||
.filter((path) => remoteDocumentPaths.has(path))
|
||||
.sort();
|
||||
|
||||
const warnings = conflicts.length
|
||||
? [`${conflicts.length} note${conflicts.length === 1 ? "" : "s"} already exist in Pageden and will be skipped.`]
|
||||
: [];
|
||||
|
||||
return {
|
||||
targetRootName,
|
||||
targetRootSlug,
|
||||
notes: importable.notes.length,
|
||||
attachments: importable.attachments.length,
|
||||
skipped: importable.skipped,
|
||||
conflicts,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function importVaultToPageden(deps: ImportDeps): Promise<VaultImportReport> {
|
||||
const remoteTree = deps.remoteTree ?? (await deps.api.tree(deps.workspaceId));
|
||||
const preview = buildVaultImportPreview(deps.files, remoteTree, deps.targetRootName);
|
||||
const importable = splitImportableFiles(deps.files);
|
||||
const attachmentIndex = buildAttachmentIndex(importable.attachments);
|
||||
const folderByPath = new Map(remoteTree.folders.map((folder) => [trimSlashes(folder.path), folder]));
|
||||
const documentPaths = new Set(remoteTree.documents.map((doc) => trimSlashes(doc.path)));
|
||||
const targetRootSlug = preview.targetRootSlug;
|
||||
let foldersCreated = 0;
|
||||
let documentsCreated = 0;
|
||||
let documentsSkipped = 0;
|
||||
let attachmentsUploaded = 0;
|
||||
const attachmentWarnings: string[] = [];
|
||||
const rows: ImportReportRow[] = [];
|
||||
|
||||
async function ensureRemoteFolder(localDir: string): Promise<RemoteFolder> {
|
||||
const segments = localDir.split("/").filter(Boolean);
|
||||
let parent: RemoteFolder | null = null;
|
||||
let currentPath = "";
|
||||
|
||||
const rootPath = targetRootSlug;
|
||||
parent = folderByPath.get(rootPath) ?? null;
|
||||
if (!parent) {
|
||||
const created = await deps.api.createFolder({
|
||||
workspaceId: deps.workspaceId,
|
||||
parentFolderId: null,
|
||||
name: deps.targetRootName,
|
||||
slug: targetRootSlug,
|
||||
});
|
||||
parent = folderFromCreate(created.id, null, deps.targetRootName, targetRootSlug, created.path);
|
||||
folderByPath.set(rootPath, parent);
|
||||
foldersCreated += 1;
|
||||
}
|
||||
currentPath = rootPath;
|
||||
|
||||
for (const segment of segments) {
|
||||
const slug = slugify(segment);
|
||||
currentPath = `${currentPath}/${slug}`;
|
||||
const existing = folderByPath.get(currentPath);
|
||||
if (existing) {
|
||||
parent = existing;
|
||||
continue;
|
||||
}
|
||||
const created = await deps.api.createFolder({
|
||||
workspaceId: deps.workspaceId,
|
||||
parentFolderId: parent.id,
|
||||
name: segment,
|
||||
slug,
|
||||
});
|
||||
parent = folderFromCreate(created.id, parent.id, segment, slug, created.path);
|
||||
folderByPath.set(currentPath, parent);
|
||||
foldersCreated += 1;
|
||||
}
|
||||
|
||||
return parent;
|
||||
}
|
||||
|
||||
let noteIndex = 0;
|
||||
for (const note of importable.notes) {
|
||||
noteIndex++;
|
||||
deps.onProgress?.(noteIndex, importable.notes.length);
|
||||
const remotePath = remotePathForNote(note.path, targetRootSlug);
|
||||
if (documentPaths.has(remotePath)) {
|
||||
documentsSkipped += 1;
|
||||
rows.push({ path: note.path, status: "skipped", message: "A document with this path already exists." });
|
||||
continue;
|
||||
}
|
||||
const localDir = dirname(note.path);
|
||||
const folder = await ensureRemoteFolder(localDir);
|
||||
const content = canonicalize(await deps.vault.read(note.path));
|
||||
const title = frontmatterTitle(content) ?? (basename(note.path).replace(/\.md$/i, "") || "Untitled");
|
||||
const created = await deps.api.createDocument({
|
||||
workspaceId: deps.workspaceId,
|
||||
folderId: folder.id,
|
||||
title,
|
||||
slug: slugify(basename(note.path).replace(/\.md$/i, "")),
|
||||
content,
|
||||
});
|
||||
documentPaths.add(trimSlashes(created.path));
|
||||
const remote = await deps.api.document(created.id);
|
||||
await deps.meta.upsert(metaFromImportedRemote(remote, note.path));
|
||||
documentsCreated += 1;
|
||||
rows.push({ path: note.path, status: "created", message: `Created ${created.path}` });
|
||||
|
||||
const refs = extractAttachmentRefs(content);
|
||||
const uploadedForDoc = new Set<string>();
|
||||
for (const ref of refs) {
|
||||
const attachment = resolveAttachmentRef(ref, note.path, attachmentIndex);
|
||||
if (!attachment || uploadedForDoc.has(attachment.path)) continue;
|
||||
uploadedForDoc.add(attachment.path);
|
||||
if (!deps.vault.readBinary) {
|
||||
const message = `Cannot upload ${attachment.path}: this vault adapter does not support binary reads.`;
|
||||
attachmentWarnings.push(message);
|
||||
rows.push({ path: attachment.path, status: "warning", message });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const bytes = await deps.vault.readBinary(attachment.path);
|
||||
await uploadAttachmentWithRetry(deps.api, remote.id, basename(attachment.path), bytes, mimeTypeForPath(attachment.path));
|
||||
attachmentsUploaded += 1;
|
||||
} catch (error) {
|
||||
const message = `Could not upload ${attachment.path}: ${error instanceof Error ? error.message : "unknown error"}`;
|
||||
attachmentWarnings.push(message);
|
||||
rows.push({ path: attachment.path, status: "warning", message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...preview,
|
||||
foldersCreated,
|
||||
documentsCreated,
|
||||
documentsSkipped,
|
||||
attachmentsUploaded,
|
||||
attachmentWarnings,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractAttachmentRefs(markdown: string): string[] {
|
||||
const refs = new Set<string>();
|
||||
const obsidianEmbed = /!\[\[([^\]#|]+)(?:[#|][^\]]*)?\]\]/g;
|
||||
const markdownImage = /!\[[^\]]*]\((?![a-z][a-z0-9+.-]*:)([^)\s]+)(?:\s+"[^"]*")?\)/gi;
|
||||
|
||||
for (const match of markdown.matchAll(obsidianEmbed)) {
|
||||
if (match[1]) refs.add(match[1]);
|
||||
}
|
||||
for (const match of markdown.matchAll(markdownImage)) {
|
||||
if (match[1]) refs.add(decodeURIComponent(match[1]));
|
||||
}
|
||||
return [...refs];
|
||||
}
|
||||
|
||||
export function slugify(value: string): string {
|
||||
const slug = value
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/\.md$/i, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return slug || "untitled";
|
||||
}
|
||||
|
||||
function splitImportableFiles(files: VaultImportFile[]): ImportableFiles {
|
||||
let skipped = 0;
|
||||
const notes: VaultImportFile[] = [];
|
||||
const attachments: VaultImportFile[] = [];
|
||||
for (const file of files) {
|
||||
const path = normalizePath(file.path);
|
||||
if (isIgnoredPath(path)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (file.extension.toLowerCase() === "md") {
|
||||
if (path.endsWith(".conflict.md")) skipped += 1;
|
||||
else notes.push({ ...file, path });
|
||||
} else {
|
||||
attachments.push({ ...file, path });
|
||||
}
|
||||
}
|
||||
notes.sort((a, b) => a.path.localeCompare(b.path));
|
||||
attachments.sort((a, b) => a.path.localeCompare(b.path));
|
||||
return { notes, attachments, skipped };
|
||||
}
|
||||
|
||||
function isIgnoredPath(path: string): boolean {
|
||||
return path.startsWith(".obsidian/") || path.startsWith(".trash/") || path.startsWith(".git/");
|
||||
}
|
||||
|
||||
function buildAttachmentIndex(files: VaultImportFile[]) {
|
||||
const byPath = new Map<string, VaultImportFile>();
|
||||
const byName = new Map<string, VaultImportFile[]>();
|
||||
for (const file of files) {
|
||||
byPath.set(file.path, file);
|
||||
const list = byName.get(file.name) ?? [];
|
||||
list.push(file);
|
||||
byName.set(file.name, list);
|
||||
}
|
||||
return { byPath, byName };
|
||||
}
|
||||
|
||||
function resolveAttachmentRef(
|
||||
ref: string,
|
||||
notePath: string,
|
||||
index: ReturnType<typeof buildAttachmentIndex>,
|
||||
): VaultImportFile | null {
|
||||
const cleanRef = normalizePath(ref.replace(/^<|>$/g, ""));
|
||||
const noteDir = dirname(notePath);
|
||||
const candidates = [
|
||||
normalizePath(`${noteDir}/${cleanRef}`),
|
||||
cleanRef,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const found = index.byPath.get(candidate);
|
||||
if (found) return found;
|
||||
}
|
||||
const byName = index.byName.get(basename(cleanRef));
|
||||
if (byName?.length) return byName[0] ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function remotePathForNote(path: string, targetRootSlug: string): string {
|
||||
const localDir = dirname(path)
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map(slugify)
|
||||
.join("/");
|
||||
const docSlug = slugify(basename(path));
|
||||
return [targetRootSlug, localDir, `${docSlug}.md`].filter(Boolean).join("/");
|
||||
}
|
||||
|
||||
function frontmatterTitle(content: string): string | null {
|
||||
if (!content.startsWith("---\n")) return null;
|
||||
const end = content.indexOf("\n---", 4);
|
||||
if (end === -1) return null;
|
||||
const raw = content.slice(4, end);
|
||||
for (const line of raw.split("\n")) {
|
||||
const match = line.match(/^title:\s*(.+)$/i);
|
||||
if (!match?.[1]) continue;
|
||||
return match[1].trim().replace(/^['"]|['"]$/g, "") || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function uploadAttachmentWithRetry(
|
||||
api: Pick<PagedenApiClient, "uploadAttachment">,
|
||||
documentId: string,
|
||||
name: string,
|
||||
bytes: ArrayBuffer,
|
||||
mimeType: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await api.uploadAttachment(documentId, name, bytes, mimeType);
|
||||
} catch (firstError) {
|
||||
try {
|
||||
await api.uploadAttachment(documentId, name, bytes, mimeType);
|
||||
} catch {
|
||||
throw firstError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function folderFromCreate(id: string, parentFolderId: string | null, name: string, slug: string, path: string): RemoteFolder {
|
||||
return {
|
||||
id,
|
||||
parentFolderId,
|
||||
name,
|
||||
slug,
|
||||
path: trimSlashes(path),
|
||||
permission: "manager",
|
||||
};
|
||||
}
|
||||
|
||||
function metaFromImportedRemote(remote: { id: string; path: string; title: string; version: string | null; checksum: string | null; permission: ServerMetaEntry["permission"]; updatedAt: string }, localPath: string): ServerMetaEntry {
|
||||
if (!remote.version || !remote.checksum) throw new Error("Imported document is missing version metadata.");
|
||||
return {
|
||||
documentId: remote.id,
|
||||
localPath,
|
||||
remotePath: remote.path,
|
||||
title: remote.title,
|
||||
baseVersion: remote.version,
|
||||
checksum: remote.checksum,
|
||||
permission: remote.permission,
|
||||
updatedAt: remote.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function mimeTypeForPath(path: string): string {
|
||||
const ext = path.split(".").pop()?.toLowerCase();
|
||||
if (ext === "png") return "image/png";
|
||||
if (ext === "jpg" || ext === "jpeg") return "image/jpeg";
|
||||
if (ext === "gif") return "image/gif";
|
||||
if (ext === "webp") return "image/webp";
|
||||
if (ext === "mp4") return "video/mp4";
|
||||
if (ext === "webm") return "video/webm";
|
||||
if (ext === "mov") return "video/quicktime";
|
||||
if (ext === "pdf") return "application/pdf";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
function dirname(path: string): string {
|
||||
const normalized = normalizePath(path);
|
||||
const index = normalized.lastIndexOf("/");
|
||||
return index === -1 ? "" : normalized.slice(0, index);
|
||||
}
|
||||
|
||||
function basename(path: string): string {
|
||||
return normalizePath(path).split("/").filter(Boolean).pop() ?? "";
|
||||
}
|
||||
|
||||
function trimSlashes(path: string): string {
|
||||
return path.replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
1291
src/main.ts
Normal file
1291
src/main.ts
Normal file
File diff suppressed because it is too large
Load diff
59
src/metadata.test.ts
Normal file
59
src/metadata.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { ServerMetaStore } from "./metadata";
|
||||
|
||||
class MemoryAdapter {
|
||||
files = new Map<string, string>();
|
||||
|
||||
async exists(path: string): Promise<boolean> {
|
||||
return this.files.has(path);
|
||||
}
|
||||
|
||||
async read(path: string): Promise<string> {
|
||||
const value = this.files.get(path);
|
||||
if (value === undefined) throw new Error("missing");
|
||||
return value;
|
||||
}
|
||||
|
||||
async write(path: string, content: string): Promise<void> {
|
||||
this.files.set(path, content);
|
||||
}
|
||||
}
|
||||
|
||||
describe("server metadata store", () => {
|
||||
it("stores .server-meta.json keyed by documentId in the plugin dir", async () => {
|
||||
const adapter = new MemoryAdapter();
|
||||
const store = new ServerMetaStore(adapter, ".obsidian/plugins/pageden");
|
||||
|
||||
await store.upsert({
|
||||
documentId: "doc1",
|
||||
localPath: "Remote Docs/runbook.md",
|
||||
remotePath: "/engineering/runbook",
|
||||
title: "Runbook",
|
||||
baseVersion: "rev1",
|
||||
checksum: "sha256:one",
|
||||
permission: "editor",
|
||||
updatedAt: "2026-06-04T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(store.filePath).toBe(".obsidian/plugins/pageden/.server-meta.json");
|
||||
expect(await store.getByLocalPath("Remote Docs/runbook.md")).toMatchObject({ documentId: "doc1" });
|
||||
expect(JSON.parse(adapter.files.get(store.filePath) ?? "{}").documents.doc1.baseVersion).toBe("rev1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Obsidian community metadata", () => {
|
||||
it("keeps the root manifest in sync with the plugin manifest", () => {
|
||||
const root = resolve(fileURLToPath(new URL("../", import.meta.url)));
|
||||
const pluginManifest = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8"));
|
||||
const rootManifest = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8"));
|
||||
const pluginVersions = JSON.parse(readFileSync(resolve(root, "versions.json"), "utf8"));
|
||||
const rootVersions = JSON.parse(readFileSync(resolve(root, "versions.json"), "utf8"));
|
||||
|
||||
expect(rootManifest).toEqual(pluginManifest);
|
||||
expect(rootVersions).toEqual(pluginVersions);
|
||||
expect(rootVersions[rootManifest.version]).toBe(rootManifest.minAppVersion);
|
||||
});
|
||||
});
|
||||
68
src/metadata.ts
Normal file
68
src/metadata.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import type { DataAdapter } from "obsidian";
|
||||
import type { ServerMetaAttachmentEntry, ServerMetaEntry, ServerMetaFile } from "./types";
|
||||
|
||||
export class ServerMetaStore {
|
||||
private readonly path: string;
|
||||
|
||||
constructor(
|
||||
private readonly adapter: Pick<DataAdapter, "exists" | "read" | "write">,
|
||||
pluginDir: string,
|
||||
) {
|
||||
this.path = `${pluginDir.replace(/\/+$/, "")}/.server-meta.json`;
|
||||
}
|
||||
|
||||
get filePath(): string {
|
||||
return this.path;
|
||||
}
|
||||
|
||||
async read(): Promise<ServerMetaFile> {
|
||||
if (!(await this.adapter.exists(this.path))) return { documents: {}, attachments: {} };
|
||||
const raw = await this.adapter.read(this.path);
|
||||
const parsed = JSON.parse(raw) as Partial<ServerMetaFile>;
|
||||
return { documents: parsed.documents ?? {}, attachments: parsed.attachments ?? {} };
|
||||
}
|
||||
|
||||
async write(meta: ServerMetaFile): Promise<void> {
|
||||
await this.adapter.write(this.path, JSON.stringify(meta, null, 2));
|
||||
}
|
||||
|
||||
async list(): Promise<ServerMetaEntry[]> {
|
||||
const meta = await this.read();
|
||||
return Object.values(meta.documents);
|
||||
}
|
||||
|
||||
async getByLocalPath(localPath: string): Promise<ServerMetaEntry | null> {
|
||||
const meta = await this.read();
|
||||
return Object.values(meta.documents).find((entry) => entry.localPath === localPath) ?? null;
|
||||
}
|
||||
|
||||
async upsert(entry: ServerMetaEntry): Promise<void> {
|
||||
const meta = await this.read();
|
||||
meta.documents[entry.documentId] = entry;
|
||||
await this.write(meta);
|
||||
}
|
||||
|
||||
async listAttachments(): Promise<ServerMetaAttachmentEntry[]> {
|
||||
const meta = await this.read();
|
||||
return Object.values(meta.attachments ?? {});
|
||||
}
|
||||
|
||||
async listAttachmentsForDocument(documentId: string): Promise<ServerMetaAttachmentEntry[]> {
|
||||
const meta = await this.read();
|
||||
return Object.values(meta.attachments ?? {}).filter((entry) => entry.documentId === documentId);
|
||||
}
|
||||
|
||||
async upsertAttachment(entry: ServerMetaAttachmentEntry): Promise<void> {
|
||||
const meta = await this.read();
|
||||
meta.attachments ??= {};
|
||||
meta.attachments[entry.attachmentId] = entry;
|
||||
await this.write(meta);
|
||||
}
|
||||
|
||||
async removeAttachment(attachmentId: string): Promise<void> {
|
||||
const meta = await this.read();
|
||||
if (!meta.attachments) return;
|
||||
delete meta.attachments[attachmentId];
|
||||
await this.write(meta);
|
||||
}
|
||||
}
|
||||
70
src/runner.test.ts
Normal file
70
src/runner.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createDebouncer, createSyncRunner } from "./runner";
|
||||
|
||||
describe("createSyncRunner", () => {
|
||||
it("runs one pass at a time and coalesces overlapping triggers into a single follow-up", async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
let calls = 0;
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
|
||||
const runner = createSyncRunner(async () => {
|
||||
calls += 1;
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
if (calls === 1) await gate; // hold the first pass open while we trigger more
|
||||
active -= 1;
|
||||
});
|
||||
|
||||
const first = runner.run(); // starts, blocks on gate
|
||||
void runner.run(); // in-flight → pending
|
||||
void runner.run(); // in-flight → still just one pending
|
||||
expect(runner.isRunning()).toBe(true);
|
||||
|
||||
release();
|
||||
await first;
|
||||
// wait microtasks for the coalesced follow-up pass to complete
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(maxActive).toBe(1); // never two passes at once
|
||||
expect(calls).toBe(2); // first + exactly one coalesced follow-up
|
||||
expect(runner.isRunning()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDebouncer", () => {
|
||||
it("collapses repeated schedules for a key into one trailing call", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const d = createDebouncer(2000);
|
||||
const fn = vi.fn();
|
||||
d.schedule("a", fn);
|
||||
d.schedule("a", fn);
|
||||
d.schedule("a", fn);
|
||||
vi.advanceTimersByTime(1999);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("debounces independently per key and cancelAll stops pending timers", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const d = createDebouncer(1000);
|
||||
const a = vi.fn();
|
||||
const b = vi.fn();
|
||||
d.schedule("a", a);
|
||||
d.schedule("b", b);
|
||||
d.cancelAll();
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(a).not.toHaveBeenCalled();
|
||||
expect(b).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
65
src/runner.ts
Normal file
65
src/runner.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// Small, framework-free concurrency helpers used by the background-sync engine. Kept pure so
|
||||
// the loop-safety and debounce behaviour can be unit-tested without an Obsidian runtime.
|
||||
|
||||
/**
|
||||
* Serialises async passes so only one runs at a time. If `run()` is called while a pass is in
|
||||
* flight, exactly one follow-up pass is queued (further calls collapse into that single pending
|
||||
* run) — so overlapping triggers (startup + interval + a modify event) never race the API.
|
||||
*/
|
||||
export function createSyncRunner(pass: () => Promise<void>): { run: () => Promise<void>; isRunning: () => boolean } {
|
||||
let inFlight = false;
|
||||
let pending = false;
|
||||
async function run(): Promise<void> {
|
||||
if (inFlight) {
|
||||
pending = true; // collapse all overlapping triggers into a single follow-up
|
||||
return;
|
||||
}
|
||||
inFlight = true;
|
||||
try {
|
||||
// Loop (not recursion) so sustained triggers can't build an unbounded promise chain.
|
||||
do {
|
||||
pending = false;
|
||||
await pass();
|
||||
} while (pending);
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
}
|
||||
return { run, isRunning: () => inFlight };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-key debouncer: repeated `schedule(key, fn)` calls within `ms` collapse into a single
|
||||
* trailing invocation of the most recent `fn` for that key.
|
||||
*/
|
||||
export function createDebouncer(ms: number): {
|
||||
schedule: (key: string, fn: () => void) => void;
|
||||
cancel: (key: string) => void;
|
||||
cancelAll: () => void;
|
||||
} {
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
function cancel(key: string): void {
|
||||
const existing = timers.get(key);
|
||||
if (existing !== undefined) {
|
||||
clearTimeout(existing);
|
||||
timers.delete(key);
|
||||
}
|
||||
}
|
||||
return {
|
||||
schedule(key: string, fn: () => void): void {
|
||||
cancel(key);
|
||||
timers.set(
|
||||
key,
|
||||
setTimeout(() => {
|
||||
timers.delete(key);
|
||||
fn();
|
||||
}, ms),
|
||||
);
|
||||
},
|
||||
cancel,
|
||||
cancelAll(): void {
|
||||
for (const timer of timers.values()) clearTimeout(timer);
|
||||
timers.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
503
src/sync.test.ts
Normal file
503
src/sync.test.ts
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
import { webcrypto } from "node:crypto";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PagedenApiError } from "./api-client";
|
||||
import {
|
||||
createRemoteDocumentFromLocal,
|
||||
downloadDocument,
|
||||
extractAttachmentPaths,
|
||||
localPathForRemote,
|
||||
pushLocalDocument,
|
||||
pushOrCreateLocalDocument,
|
||||
runBackgroundSyncPass,
|
||||
syncDocumentAttachments,
|
||||
syncLinkedDocument,
|
||||
type VaultLike,
|
||||
} from "./sync";
|
||||
import { checksum } from "./checksum";
|
||||
import type { Attachment, RemoteDocumentWithContent, ServerMetaAttachmentEntry, ServerMetaEntry } from "./types";
|
||||
|
||||
Object.defineProperty(globalThis, "crypto", { value: webcrypto, configurable: true });
|
||||
|
||||
class MemoryVault implements VaultLike {
|
||||
files = new Map<string, string>();
|
||||
binaryFiles = new Map<string, ArrayBuffer>();
|
||||
folders = new Set<string>();
|
||||
|
||||
async read(path: string): Promise<string> {
|
||||
const value = this.files.get(path);
|
||||
if (value === undefined) throw new Error("missing");
|
||||
return value;
|
||||
}
|
||||
|
||||
async write(path: string, content: string): Promise<void> {
|
||||
this.files.set(path, content);
|
||||
}
|
||||
|
||||
async readBinary(path: string): Promise<ArrayBuffer> {
|
||||
const value = this.binaryFiles.get(path);
|
||||
if (value === undefined) throw new Error("missing binary");
|
||||
return value;
|
||||
}
|
||||
|
||||
async writeBinary(path: string, content: ArrayBuffer): Promise<void> {
|
||||
this.binaryFiles.set(path, content);
|
||||
}
|
||||
|
||||
async exists(path: string): Promise<boolean> {
|
||||
return this.files.has(path) || this.binaryFiles.has(path) || this.folders.has(path);
|
||||
}
|
||||
|
||||
async mkdir(path: string): Promise<void> {
|
||||
this.folders.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryMeta {
|
||||
entries = new Map<string, ServerMetaEntry>();
|
||||
attachments = new Map<string, ServerMetaAttachmentEntry>();
|
||||
|
||||
async list(): Promise<ServerMetaEntry[]> {
|
||||
return [...this.entries.values()];
|
||||
}
|
||||
|
||||
async getByLocalPath(path: string): Promise<ServerMetaEntry | null> {
|
||||
return [...this.entries.values()].find((entry) => entry.localPath === path) ?? null;
|
||||
}
|
||||
|
||||
async upsert(entry: ServerMetaEntry): Promise<void> {
|
||||
this.entries.set(entry.documentId, entry);
|
||||
}
|
||||
|
||||
async listAttachmentsForDocument(documentId: string): Promise<ServerMetaAttachmentEntry[]> {
|
||||
return [...this.attachments.values()].filter((entry) => entry.documentId === documentId);
|
||||
}
|
||||
|
||||
async upsertAttachment(entry: ServerMetaAttachmentEntry): Promise<void> {
|
||||
this.attachments.set(entry.attachmentId, entry);
|
||||
}
|
||||
|
||||
async removeAttachment(attachmentId: string): Promise<void> {
|
||||
this.attachments.delete(attachmentId);
|
||||
}
|
||||
}
|
||||
|
||||
const remote: RemoteDocumentWithContent = {
|
||||
id: "doc1",
|
||||
workspaceId: "ws1",
|
||||
folderId: "folder1",
|
||||
title: "Runbook",
|
||||
path: "/engineering/runbook",
|
||||
permission: "editor",
|
||||
version: "rev1",
|
||||
checksum: "sha256:old",
|
||||
content: "# Runbook\r\n",
|
||||
updatedAt: "2026-06-04T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("plugin sync", () => {
|
||||
it("maps remote paths into the configured local folder", () => {
|
||||
expect(localPathForRemote("Remote Docs", "/engineering/runbook")).toBe("Remote Docs/engineering/runbook.md");
|
||||
expect(localPathForRemote("Remote Docs", "root.md")).toBe("Remote Docs/root.md");
|
||||
});
|
||||
|
||||
it("extracts markdown and Obsidian image attachment references", () => {
|
||||
expect(
|
||||
[...extractAttachmentPaths("\n[download](files/report.pdf)\n![[b.jpg|thumb]]\n[doc](other.md)\n", "Remote Docs/e2e/doc.md")],
|
||||
).toEqual(["Remote Docs/e2e/img/a.png", "Remote Docs/e2e/files/report.pdf", "Remote Docs/e2e/b.jpg"]);
|
||||
});
|
||||
|
||||
it("syncs attachments by downloading remote files, uploading changed local references, and deleting missing tracked files", async () => {
|
||||
const vault = new MemoryVault();
|
||||
const meta = new MemoryMeta();
|
||||
const entry: ServerMetaEntry = { documentId: "doc1", localPath: "Remote Docs/e2e/doc.md", remotePath: "/e2e/doc", title: "Doc", baseVersion: "rev1", checksum: "sha256:doc", permission: "editor", updatedAt: "old" };
|
||||
await meta.upsert(entry);
|
||||
await meta.upsertAttachment({ attachmentId: "old", documentId: "doc1", localPath: "Remote Docs/e2e/old.png", filename: "old.png", sha256: "oldsha", size: 1, contentType: "image/png", createdAt: "old" });
|
||||
const oldAttachment: Attachment = { id: "old", filename: "old.png", contentType: "image/png", size: 1, sha256: "oldsha", createdAt: "old" };
|
||||
const remoteAttachment: Attachment = { id: "remote", filename: "server.png", contentType: "image/png", size: 4, sha256: "remotesha", createdAt: "new" };
|
||||
const uploaded: Attachment = { id: "uploaded", filename: "local.png", contentType: "image/png", size: 5, sha256: "uploadsha", createdAt: "later" };
|
||||
await vault.write("Remote Docs/e2e/doc.md", "\n");
|
||||
await vault.writeBinary("Remote Docs/e2e/local.png", new Uint8Array([1, 2, 3, 4, 5]).buffer);
|
||||
const api = {
|
||||
document: vi.fn(),
|
||||
push: vi.fn(),
|
||||
attachments: vi.fn().mockResolvedValue({ attachments: [oldAttachment, remoteAttachment] }),
|
||||
downloadAttachment: vi.fn().mockResolvedValue(new Uint8Array([9, 9, 9, 9]).buffer),
|
||||
uploadAttachment: vi.fn().mockResolvedValue(uploaded),
|
||||
deleteAttachment: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const result = await syncDocumentAttachments({ api, vault, meta, remoteDocsFolder: "Remote Docs" }, entry, "\n");
|
||||
|
||||
expect(result).toMatchObject({ downloaded: 1, uploaded: 1, deleted: 1 });
|
||||
expect(vault.binaryFiles.has("Remote Docs/e2e/server.png")).toBe(true);
|
||||
expect(api.uploadAttachment).toHaveBeenCalledWith("doc1", "local.png", expect.any(ArrayBuffer), "image/png");
|
||||
expect(api.deleteAttachment).toHaveBeenCalledWith("old");
|
||||
expect(await meta.listAttachmentsForDocument("doc1")).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ attachmentId: "remote", localPath: "Remote Docs/e2e/server.png" }),
|
||||
expect.objectContaining({ attachmentId: "uploaded", localPath: "Remote Docs/e2e/local.png" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("downloads a document, creates folders, canonicalizes content, and updates metadata", async () => {
|
||||
const vault = new MemoryVault();
|
||||
const meta = new MemoryMeta();
|
||||
const api = { document: vi.fn().mockResolvedValue(remote), push: vi.fn() };
|
||||
|
||||
const result = await downloadDocument({ api, vault, meta, remoteDocsFolder: "Remote Docs" }, remote);
|
||||
|
||||
expect(result.localPath).toBe("Remote Docs/engineering/runbook.md");
|
||||
expect(vault.folders.has("Remote Docs")).toBe(true);
|
||||
expect(vault.folders.has("Remote Docs/engineering")).toBe(true);
|
||||
expect(vault.files.get(result.localPath)).toBe("# Runbook\n");
|
||||
expect(await meta.getByLocalPath(result.localPath)).toMatchObject({ documentId: "doc1", baseVersion: "rev1" });
|
||||
});
|
||||
|
||||
it("pushes an editor document with baseVersion and advances metadata", async () => {
|
||||
const vault = new MemoryVault();
|
||||
const meta = new MemoryMeta();
|
||||
await meta.upsert({ documentId: "doc1", localPath: "Remote Docs/runbook.md", remotePath: "/runbook", title: "Runbook", baseVersion: "rev1", checksum: "sha256:old", permission: "editor", updatedAt: "old" });
|
||||
await vault.write("Remote Docs/runbook.md", "# Local\r\n");
|
||||
const api = {
|
||||
document: vi.fn(),
|
||||
push: vi.fn().mockResolvedValue({ id: "doc1", version: "rev2", checksum: "sha256:new", updatedAt: "new" }),
|
||||
};
|
||||
|
||||
const result = await pushLocalDocument({ api, vault, meta, remoteDocsFolder: "Remote Docs" }, "Remote Docs/runbook.md");
|
||||
|
||||
expect(result.status).toBe("pushed");
|
||||
expect(api.push).toHaveBeenCalledWith("doc1", expect.objectContaining({ baseVersion: "rev1", content: "# Local\n" }));
|
||||
expect(await meta.getByLocalPath("Remote Docs/runbook.md")).toMatchObject({ baseVersion: "rev2", checksum: "sha256:new" });
|
||||
});
|
||||
|
||||
it("creates a remote document for an unlinked local note and stores sync metadata", async () => {
|
||||
const vault = new MemoryVault();
|
||||
const meta = new MemoryMeta();
|
||||
await vault.write("Remote Docs/imported-from-web/hermes-deployment/ooo.md", "---\ntitle: OOO\n---\n\nTest\r\n");
|
||||
const createdRemote: RemoteDocumentWithContent = {
|
||||
id: "doc-new",
|
||||
workspaceId: "ws1",
|
||||
folderId: "folder-hermes",
|
||||
title: "OOO",
|
||||
path: "imported-from-web/hermes-deployment/ooo.md",
|
||||
permission: "editor",
|
||||
version: "rev-new",
|
||||
checksum: "sha256:new",
|
||||
content: "---\ntitle: OOO\n---\n\nTest\n",
|
||||
updatedAt: "2026-06-14T00:00:00.000Z",
|
||||
};
|
||||
const api = {
|
||||
tree: vi.fn().mockResolvedValue({ folders: [], documents: [] }),
|
||||
createFolder: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: "folder-imported", path: "imported-from-web" })
|
||||
.mockResolvedValueOnce({ id: "folder-hermes", path: "imported-from-web/hermes-deployment" }),
|
||||
createDocument: vi.fn().mockResolvedValue({ id: "doc-new", version: "rev-new", checksum: "sha256:new", path: "imported-from-web/hermes-deployment/ooo.md" }),
|
||||
document: vi.fn().mockResolvedValue(createdRemote),
|
||||
push: vi.fn(),
|
||||
};
|
||||
|
||||
const result = await pushOrCreateLocalDocument(
|
||||
{ api, vault, meta, remoteDocsFolder: "Remote Docs", workspaceId: "ws1" },
|
||||
"Remote Docs/imported-from-web/hermes-deployment/ooo.md",
|
||||
);
|
||||
|
||||
expect(result.status).toBe("created");
|
||||
expect(api.createFolder).toHaveBeenNthCalledWith(1, expect.objectContaining({ parentFolderId: null, slug: "imported-from-web" }));
|
||||
expect(api.createFolder).toHaveBeenNthCalledWith(2, expect.objectContaining({ parentFolderId: "folder-imported", slug: "hermes-deployment" }));
|
||||
expect(api.createDocument).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
folderId: "folder-hermes",
|
||||
title: "OOO",
|
||||
slug: "ooo",
|
||||
content: "---\ntitle: OOO\n---\n\nTest\n",
|
||||
}),
|
||||
);
|
||||
expect(await meta.getByLocalPath("Remote Docs/imported-from-web/hermes-deployment/ooo.md")).toMatchObject({
|
||||
documentId: "doc-new",
|
||||
remotePath: "imported-from-web/hermes-deployment/ooo.md",
|
||||
baseVersion: "rev-new",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create over an existing remote path for an unlinked local note", async () => {
|
||||
const vault = new MemoryVault();
|
||||
const meta = new MemoryMeta();
|
||||
await vault.write("Remote Docs/team/plan.md", "# Local\n");
|
||||
const api = {
|
||||
tree: vi.fn().mockResolvedValue({
|
||||
folders: [{ id: "folder-team", parentFolderId: null, name: "Team", slug: "team", path: "team", permission: "manager" }],
|
||||
documents: [{ id: "doc-existing", folderId: "folder-team", title: "Plan", path: "team/plan.md", permission: "editor", version: "rev1", checksum: "sha256:x" }],
|
||||
}),
|
||||
createFolder: vi.fn(),
|
||||
createDocument: vi.fn(),
|
||||
document: vi.fn(),
|
||||
push: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
createRemoteDocumentFromLocal({ api, vault, meta, remoteDocsFolder: "Remote Docs", workspaceId: "ws1" }, "Remote Docs/team/plan.md"),
|
||||
).rejects.toThrow(/already exists/);
|
||||
expect(api.createDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks viewer-only pushes before calling the API", async () => {
|
||||
const meta = new MemoryMeta();
|
||||
await meta.upsert({ documentId: "doc1", localPath: "Remote Docs/runbook.md", remotePath: "/runbook", title: "Runbook", baseVersion: "rev1", checksum: "sha256:old", permission: "viewer", updatedAt: "old" });
|
||||
const api = { document: vi.fn(), push: vi.fn() };
|
||||
|
||||
const result = await pushLocalDocument({ api, vault: new MemoryVault(), meta, remoteDocsFolder: "Remote Docs" }, "Remote Docs/runbook.md");
|
||||
|
||||
expect(result.status).toBe("blocked_viewer");
|
||||
expect(api.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps local edits and writes a conflict copy of the server version on 409", async () => {
|
||||
const vault = new MemoryVault();
|
||||
const meta = new MemoryMeta();
|
||||
await meta.upsert({ documentId: "doc1", localPath: "Remote Docs/runbook.md", remotePath: "/runbook", title: "Runbook", baseVersion: "rev1", checksum: "sha256:old", permission: "editor", updatedAt: "old" });
|
||||
await vault.write("Remote Docs/runbook.md", "# Local\n");
|
||||
const api = {
|
||||
push: vi.fn().mockRejectedValue(new PagedenApiError(409, { error: "conflict", currentVersion: "rev2" })),
|
||||
document: vi.fn().mockResolvedValue({ ...remote, content: "# Server\n", version: "rev2", checksum: "sha256:server" }),
|
||||
};
|
||||
|
||||
const result = await pushLocalDocument({ api, vault, meta, remoteDocsFolder: "Remote Docs" }, "Remote Docs/runbook.md");
|
||||
|
||||
expect(result.status).toBe("conflict");
|
||||
expect(vault.files.get("Remote Docs/runbook.md")).toBe("# Local\n");
|
||||
expect(vault.files.get("Remote Docs/runbook.conflict.md")).toBe("# Server\n");
|
||||
expect(await meta.getByLocalPath("Remote Docs/runbook.md")).toMatchObject({ baseVersion: "rev2", checksum: "sha256:server" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("background sync", () => {
|
||||
const entry = (over: Partial<ServerMetaEntry> = {}): ServerMetaEntry => ({
|
||||
documentId: "doc1",
|
||||
localPath: "Remote Docs/runbook.md",
|
||||
remotePath: "/runbook",
|
||||
title: "Runbook",
|
||||
baseVersion: "rev1",
|
||||
checksum: "sha256:tracked",
|
||||
permission: "editor",
|
||||
updatedAt: "old",
|
||||
...over,
|
||||
});
|
||||
|
||||
it("is a no-op when the server version matches and local has not diverged", async () => {
|
||||
const vault = new MemoryVault();
|
||||
const meta = new MemoryMeta();
|
||||
await vault.write("Remote Docs/runbook.md", "# Same\n");
|
||||
const e = entry({ checksum: await checksum("# Same\n") });
|
||||
await meta.upsert(e);
|
||||
const api = { document: vi.fn().mockResolvedValue({ ...remote, version: "rev1", content: "# Same\n" }), push: vi.fn() };
|
||||
|
||||
const result = await syncLinkedDocument({ api, vault, meta, remoteDocsFolder: "Remote Docs" }, e);
|
||||
|
||||
expect(result.status).toBe("unchanged");
|
||||
expect(api.push).not.toHaveBeenCalled();
|
||||
expect(vault.files.get("Remote Docs/runbook.md")).toBe("# Same\n");
|
||||
});
|
||||
|
||||
it("pulls server content (canonicalizing CRLF) and advances meta when the server moved", async () => {
|
||||
const vault = new MemoryVault();
|
||||
const meta = new MemoryMeta();
|
||||
await vault.write("Remote Docs/runbook.md", "# Same\n");
|
||||
const e = entry({ checksum: await checksum("# Same\n") });
|
||||
await meta.upsert(e);
|
||||
const api = {
|
||||
document: vi.fn().mockResolvedValue({ ...remote, version: "rev2", checksum: "sha256:server2", content: "# Server v2\r\n" }),
|
||||
push: vi.fn(),
|
||||
};
|
||||
|
||||
const result = await syncLinkedDocument({ api, vault, meta, remoteDocsFolder: "Remote Docs" }, e);
|
||||
|
||||
expect(result.status).toBe("pulled");
|
||||
expect(api.push).not.toHaveBeenCalled();
|
||||
expect(vault.files.get("Remote Docs/runbook.md")).toBe("# Server v2\n"); // CRLF canonicalized
|
||||
expect(await meta.getByLocalPath("Remote Docs/runbook.md")).toMatchObject({ baseVersion: "rev2", checksum: "sha256:server2" });
|
||||
});
|
||||
|
||||
it("pushes local edits when local diverged and the server has not moved", async () => {
|
||||
const vault = new MemoryVault();
|
||||
const meta = new MemoryMeta();
|
||||
await vault.write("Remote Docs/runbook.md", "# Local edit\n");
|
||||
const e = entry({ checksum: "sha256:tracked" }); // tracked != checksum of local → diverged
|
||||
await meta.upsert(e);
|
||||
const api = {
|
||||
document: vi.fn().mockResolvedValue({ ...remote, version: "rev1" }), // server unchanged
|
||||
push: vi.fn().mockResolvedValue({ id: "doc1", version: "rev2", checksum: "sha256:new", updatedAt: "new" }),
|
||||
};
|
||||
|
||||
const result = await syncLinkedDocument({ api, vault, meta, remoteDocsFolder: "Remote Docs" }, e);
|
||||
|
||||
expect(result.status).toBe("pushed");
|
||||
expect(api.push).toHaveBeenCalledWith("doc1", expect.objectContaining({ baseVersion: "rev1", content: "# Local edit\n" }));
|
||||
});
|
||||
|
||||
it("writes a conflict copy and preserves local when the server moved AND local diverged", async () => {
|
||||
const vault = new MemoryVault();
|
||||
const meta = new MemoryMeta();
|
||||
await vault.write("Remote Docs/runbook.md", "# Local edit\n");
|
||||
const e = entry({ checksum: "sha256:tracked" });
|
||||
await meta.upsert(e);
|
||||
const api = {
|
||||
document: vi.fn().mockResolvedValue({ ...remote, version: "rev2", checksum: "sha256:server", content: "# Server\n" }),
|
||||
push: vi.fn().mockRejectedValue(new PagedenApiError(409, { error: "conflict", currentVersion: "rev2" })),
|
||||
};
|
||||
|
||||
const result = await syncLinkedDocument({ api, vault, meta, remoteDocsFolder: "Remote Docs" }, e);
|
||||
|
||||
expect(result.status).toBe("conflict");
|
||||
expect(vault.files.get("Remote Docs/runbook.md")).toBe("# Local edit\n"); // never clobbered
|
||||
expect(vault.files.get("Remote Docs/runbook.conflict.md")).toBe("# Server\n");
|
||||
});
|
||||
|
||||
it("reports a 404 document as gone and leaves the local file untouched", async () => {
|
||||
const vault = new MemoryVault();
|
||||
const meta = new MemoryMeta();
|
||||
await vault.write("Remote Docs/runbook.md", "# Same\n");
|
||||
const e = entry({ checksum: await checksum("# Same\n") });
|
||||
await meta.upsert(e);
|
||||
const api = { document: vi.fn().mockRejectedValue(new PagedenApiError(404, { error: "not_found" })), push: vi.fn() };
|
||||
|
||||
const result = await syncLinkedDocument({ api, vault, meta, remoteDocsFolder: "Remote Docs" }, e);
|
||||
|
||||
expect(result.status).toBe("gone");
|
||||
expect(api.push).not.toHaveBeenCalled();
|
||||
expect(vault.files.get("Remote Docs/runbook.md")).toBe("# Same\n");
|
||||
});
|
||||
|
||||
it("does NOT auto-push while an unresolved conflict copy exists (sticky conflict)", async () => {
|
||||
const vault = new MemoryVault();
|
||||
const meta = new MemoryMeta();
|
||||
await vault.write("Remote Docs/runbook.md", "# Local edit\n");
|
||||
await vault.write("Remote Docs/runbook.conflict.md", "# Server side\n"); // unresolved conflict
|
||||
const e = entry({ checksum: "sha256:tracked" }); // local diverged
|
||||
await meta.upsert(e);
|
||||
const api = { document: vi.fn().mockResolvedValue({ ...remote, version: "rev2" }), push: vi.fn() };
|
||||
|
||||
const result = await syncLinkedDocument({ api, vault, meta, remoteDocsFolder: "Remote Docs" }, e);
|
||||
|
||||
expect(result.status).toBe("conflict_pending");
|
||||
expect(api.push).not.toHaveBeenCalled();
|
||||
expect(vault.files.get("Remote Docs/runbook.md")).toBe("# Local edit\n");
|
||||
});
|
||||
|
||||
it("does not clobber a local edit that lands between the divergence check and the write (TOCTOU)", async () => {
|
||||
class RaceVault extends MemoryVault {
|
||||
reads = 0;
|
||||
async read(path: string): Promise<string> {
|
||||
this.reads += 1;
|
||||
// 1st read (divergence check): matches tracked. 2nd read (pre-write guard): user just edited.
|
||||
return this.reads === 1 ? "# Same\n" : "# Edited mid-flight\n";
|
||||
}
|
||||
}
|
||||
const vault = new RaceVault();
|
||||
await vault.write("Remote Docs/runbook.md", "# Same\n");
|
||||
const meta = new MemoryMeta();
|
||||
const e = entry({ checksum: await checksum("# Same\n") });
|
||||
await meta.upsert(e);
|
||||
const api = { document: vi.fn().mockResolvedValue({ ...remote, version: "rev2", checksum: "sha256:server2", content: "# Server v2\n" }), push: vi.fn() };
|
||||
|
||||
const result = await syncLinkedDocument({ api, vault, meta, remoteDocsFolder: "Remote Docs" }, e);
|
||||
|
||||
expect(result.status).toBe("conflict");
|
||||
expect(api.push).not.toHaveBeenCalled();
|
||||
expect(vault.files.get("Remote Docs/runbook.conflict.md")).toBe("# Server v2\n");
|
||||
// original local file was not overwritten by the pull
|
||||
expect(vault.files.get("Remote Docs/runbook.md")).toBe("# Same\n");
|
||||
});
|
||||
|
||||
it("does not push when the server has downgraded the document to viewer", async () => {
|
||||
const vault = new MemoryVault();
|
||||
const meta = new MemoryMeta();
|
||||
await vault.write("Remote Docs/runbook.md", "# Local edit\n");
|
||||
const e = entry({ checksum: "sha256:tracked", permission: "editor" }); // diverged, meta says editor
|
||||
await meta.upsert(e);
|
||||
const api = { document: vi.fn().mockResolvedValue({ ...remote, version: "rev1", permission: "viewer" }), push: vi.fn() };
|
||||
|
||||
const result = await syncLinkedDocument({ api, vault, meta, remoteDocsFolder: "Remote Docs" }, e);
|
||||
|
||||
expect(result.status).toBe("blocked_viewer");
|
||||
expect(api.push).not.toHaveBeenCalled();
|
||||
expect(await meta.getByLocalPath("Remote Docs/runbook.md")).toMatchObject({ permission: "viewer" });
|
||||
});
|
||||
|
||||
it("does not resurrect a tracked file the user deleted locally", async () => {
|
||||
const vault = new MemoryVault(); // file absent
|
||||
const meta = new MemoryMeta();
|
||||
const e = entry({ checksum: "sha256:tracked" });
|
||||
await meta.upsert(e);
|
||||
const api = { document: vi.fn().mockResolvedValue({ ...remote, version: "rev2", content: "# Server\n" }), push: vi.fn() };
|
||||
|
||||
const result = await syncLinkedDocument({ api, vault, meta, remoteDocsFolder: "Remote Docs" }, e);
|
||||
|
||||
expect(result.status).toBe("missing_local");
|
||||
expect(vault.files.has("Remote Docs/runbook.md")).toBe(false);
|
||||
expect(api.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runBackgroundSyncPass tallies results and isolates per-document errors", async () => {
|
||||
const vault = new MemoryVault();
|
||||
const meta = new MemoryMeta();
|
||||
await vault.write("Remote Docs/a.md", "# A\n");
|
||||
await meta.upsert(entry({ documentId: "docA", localPath: "Remote Docs/a.md", checksum: await checksum("# A\n") }));
|
||||
await meta.upsert(entry({ documentId: "docB", localPath: "Remote Docs/b.md", checksum: await checksum("# B\n") }));
|
||||
const api = {
|
||||
document: vi.fn().mockImplementation((id: string) => {
|
||||
if (id === "docA") return Promise.resolve({ ...remote, id: "docA", version: "rev1", content: "# A\n" });
|
||||
return Promise.reject(new PagedenApiError(500, { error: "server_error" }));
|
||||
}),
|
||||
push: vi.fn(),
|
||||
};
|
||||
|
||||
const summary = await runBackgroundSyncPass({ api, vault, meta, remoteDocsFolder: "Remote Docs" });
|
||||
|
||||
expect(summary.unchanged).toBe(1);
|
||||
expect(summary.errors).toBe(1);
|
||||
});
|
||||
|
||||
it("runBackgroundSyncPass creates unlinked local notes under the remote docs folder", async () => {
|
||||
const vault = new MemoryVault();
|
||||
const meta = new MemoryMeta();
|
||||
await vault.write("Remote Docs/team/new-plan.md", "# New plan\n");
|
||||
const createdRemote: RemoteDocumentWithContent = {
|
||||
id: "doc-new",
|
||||
workspaceId: "ws1",
|
||||
folderId: "folder-team",
|
||||
title: "New Plan",
|
||||
path: "team/new-plan.md",
|
||||
permission: "editor",
|
||||
version: "rev-new",
|
||||
checksum: "sha256:new",
|
||||
content: "# New plan\n",
|
||||
updatedAt: "2026-06-14T00:00:00.000Z",
|
||||
};
|
||||
const api = {
|
||||
document: vi.fn().mockResolvedValue(createdRemote),
|
||||
push: vi.fn(),
|
||||
tree: vi.fn().mockResolvedValue({
|
||||
folders: [{ id: "folder-team", parentFolderId: null, name: "Team", slug: "team", path: "team", permission: "manager" }],
|
||||
documents: [],
|
||||
}),
|
||||
createFolder: vi.fn(),
|
||||
createDocument: vi.fn().mockResolvedValue({ id: "doc-new", version: "rev-new", checksum: "sha256:new", path: "team/new-plan.md" }),
|
||||
};
|
||||
|
||||
const summary = await runBackgroundSyncPass({
|
||||
api,
|
||||
vault,
|
||||
meta,
|
||||
remoteDocsFolder: "Remote Docs",
|
||||
workspaceId: "ws1",
|
||||
localMarkdownPaths: async () => ["Remote Docs/team/new-plan.md"],
|
||||
});
|
||||
|
||||
expect(summary.created).toBe(1);
|
||||
expect(summary.errors).toBe(0);
|
||||
expect(api.createDocument).toHaveBeenCalledWith(expect.objectContaining({ folderId: "folder-team", slug: "new-plan" }));
|
||||
expect(await meta.getByLocalPath("Remote Docs/team/new-plan.md")).toMatchObject({ documentId: "doc-new", baseVersion: "rev-new" });
|
||||
});
|
||||
});
|
||||
628
src/sync.ts
Normal file
628
src/sync.ts
Normal file
|
|
@ -0,0 +1,628 @@
|
|||
import { normalizePath } from "obsidian";
|
||||
import type { PagedenApiClient } from "./api-client";
|
||||
import { PagedenApiError } from "./api-client";
|
||||
import { canonicalize, checksum } from "./checksum";
|
||||
import type { Attachment, RemoteDocument, RemoteDocumentWithContent, RemoteFolder, RemoteTree, ServerMetaAttachmentEntry, ServerMetaEntry, WriteResult } from "./types";
|
||||
|
||||
export interface VaultLike {
|
||||
read(path: string): Promise<string>;
|
||||
write(path: string, content: string): Promise<void>;
|
||||
readBinary?(path: string): Promise<ArrayBuffer>;
|
||||
writeBinary?(path: string, content: ArrayBuffer): Promise<void>;
|
||||
exists(path: string): Promise<boolean>;
|
||||
mkdir(path: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MetaStoreLike {
|
||||
list(): Promise<ServerMetaEntry[]>;
|
||||
getByLocalPath(path: string): Promise<ServerMetaEntry | null>;
|
||||
upsert(entry: ServerMetaEntry): Promise<void>;
|
||||
listAttachmentsForDocument?(documentId: string): Promise<ServerMetaAttachmentEntry[]>;
|
||||
upsertAttachment?(entry: ServerMetaAttachmentEntry): Promise<void>;
|
||||
removeAttachment?(attachmentId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface SyncDeps {
|
||||
api: Pick<PagedenApiClient, "document" | "push"> &
|
||||
Partial<Pick<PagedenApiClient, "attachments" | "uploadAttachment" | "downloadAttachment" | "deleteAttachment">>;
|
||||
vault: VaultLike;
|
||||
meta: MetaStoreLike;
|
||||
remoteDocsFolder: string;
|
||||
}
|
||||
|
||||
export interface CreateRemoteDeps extends SyncDeps {
|
||||
api: SyncDeps["api"] & Pick<PagedenApiClient, "tree" | "createFolder" | "createDocument">;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface BackgroundSyncDeps extends SyncDeps {
|
||||
api: SyncDeps["api"] & Partial<Pick<PagedenApiClient, "tree" | "createFolder" | "createDocument">>;
|
||||
workspaceId?: string;
|
||||
localMarkdownPaths?: () => Promise<string[]>;
|
||||
}
|
||||
|
||||
export interface DownloadResult {
|
||||
localPath: string;
|
||||
meta: ServerMetaEntry;
|
||||
attachments: AttachmentSyncSummary;
|
||||
}
|
||||
|
||||
export interface PushResult {
|
||||
status: "pushed" | "created" | "blocked_viewer" | "conflict";
|
||||
result?: WriteResult;
|
||||
conflictPath?: string;
|
||||
serverPath?: string;
|
||||
meta?: ServerMetaEntry;
|
||||
}
|
||||
|
||||
export function localPathForRemote(remoteDocsFolder: string, remotePath: string): string {
|
||||
const withoutLeadingSlash = remotePath.replace(/^\/+/, "");
|
||||
const withExtension = withoutLeadingSlash.endsWith(".md") ? withoutLeadingSlash : `${withoutLeadingSlash}.md`;
|
||||
return normalizePath(`${remoteDocsFolder}/${withExtension}`);
|
||||
}
|
||||
|
||||
export async function ensureFolder(vault: VaultLike, folderPath: string): Promise<void> {
|
||||
const parts = normalizePath(folderPath).split("/").filter(Boolean);
|
||||
let current = "";
|
||||
for (const part of parts) {
|
||||
current = current ? `${current}/${part}` : part;
|
||||
if (!(await vault.exists(current))) await vault.mkdir(current);
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadDocument(deps: SyncDeps, doc: Pick<RemoteDocument, "id">): Promise<DownloadResult> {
|
||||
const remote = await deps.api.document(doc.id);
|
||||
const localPath = localPathForRemote(deps.remoteDocsFolder, remote.path);
|
||||
const parent = localPath.split("/").slice(0, -1).join("/");
|
||||
if (parent) await ensureFolder(deps.vault, parent);
|
||||
const content = canonicalize(remote.content);
|
||||
await deps.vault.write(localPath, content);
|
||||
const meta = metaFromRemote(remote, localPath);
|
||||
await deps.meta.upsert(meta);
|
||||
const attachments = hasAttachmentSupport(deps) ? await syncDocumentAttachments(deps, meta, content) : emptyAttachmentSummary();
|
||||
return { localPath, meta, attachments };
|
||||
}
|
||||
|
||||
export async function pushLocalDocument(deps: SyncDeps, localPath: string): Promise<PushResult> {
|
||||
const meta = await deps.meta.getByLocalPath(localPath);
|
||||
if (!meta) throw new Error("This file is not linked to a Pageden document.");
|
||||
if (meta.permission === "viewer") return { status: "blocked_viewer" };
|
||||
|
||||
const content = canonicalize(await deps.vault.read(localPath));
|
||||
try {
|
||||
const result = await deps.api.push(meta.documentId, {
|
||||
baseVersion: meta.baseVersion,
|
||||
checksum: await checksum(content),
|
||||
content,
|
||||
});
|
||||
const nextMeta = { ...meta, baseVersion: result.version, checksum: result.checksum, updatedAt: result.updatedAt };
|
||||
await deps.meta.upsert(nextMeta);
|
||||
if (hasAttachmentSupport(deps)) await syncDocumentAttachments(deps, nextMeta, content);
|
||||
return { status: "pushed", result };
|
||||
} catch (error) {
|
||||
if (error instanceof PagedenApiError && error.status === 409) {
|
||||
const server = await deps.api.document(meta.documentId);
|
||||
const conflictPath = await writeConflictFile(deps.vault, localPath, server);
|
||||
await deps.meta.upsert({ ...meta, baseVersion: server.version ?? meta.baseVersion, checksum: server.checksum ?? meta.checksum, permission: server.permission, updatedAt: server.updatedAt });
|
||||
return { status: "conflict", conflictPath, serverPath: server.path };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function pushOrCreateLocalDocument(deps: CreateRemoteDeps, localPath: string): Promise<PushResult> {
|
||||
const meta = await deps.meta.getByLocalPath(localPath);
|
||||
if (meta) return pushLocalDocument(deps, localPath);
|
||||
return createRemoteDocumentFromLocal(deps, localPath);
|
||||
}
|
||||
|
||||
export async function createRemoteDocumentFromLocal(deps: CreateRemoteDeps, localPath: string): Promise<PushResult> {
|
||||
const content = canonicalize(await deps.vault.read(localPath));
|
||||
const tree = await deps.api.tree(deps.workspaceId);
|
||||
const target = targetPathForLocal(deps.remoteDocsFolder, localPath);
|
||||
const existingPath = target.documentPath;
|
||||
if (tree.documents.some((doc) => trimSlashes(doc.path) === existingPath)) {
|
||||
throw new Error(`A Pageden document already exists at "${existingPath}". Download it first to link this local note safely.`);
|
||||
}
|
||||
|
||||
const folder = await ensureRemoteFolderPath(deps.api, deps.workspaceId, tree, target.folderSegments);
|
||||
const created = await deps.api.createDocument({
|
||||
workspaceId: deps.workspaceId,
|
||||
folderId: folder.id,
|
||||
title: frontmatterTitle(content) ?? target.title,
|
||||
slug: target.documentSlug,
|
||||
content,
|
||||
});
|
||||
const remote = await deps.api.document(created.id);
|
||||
const meta = metaFromRemote(remote, localPath);
|
||||
await deps.meta.upsert(meta);
|
||||
if (hasAttachmentSupport(deps)) await syncDocumentAttachments(deps, meta, content);
|
||||
return {
|
||||
status: "created",
|
||||
result: { id: remote.id, version: meta.baseVersion, checksum: meta.checksum, updatedAt: meta.updatedAt },
|
||||
meta,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeConflictFile(vault: VaultLike, localPath: string, server: RemoteDocumentWithContent): Promise<string> {
|
||||
const stem = localPath.replace(/\.md$/i, "");
|
||||
const conflictPath = normalizePath(`${stem}.conflict.md`);
|
||||
await vault.write(conflictPath, canonicalize(server.content));
|
||||
return conflictPath;
|
||||
}
|
||||
|
||||
function metaFromRemote(remote: RemoteDocumentWithContent, localPath: string): ServerMetaEntry {
|
||||
if (!remote.version || !remote.checksum) {
|
||||
throw new Error("Remote document is missing version metadata.");
|
||||
}
|
||||
return {
|
||||
documentId: remote.id,
|
||||
localPath,
|
||||
remotePath: remote.path,
|
||||
title: remote.title,
|
||||
baseVersion: remote.version,
|
||||
checksum: remote.checksum,
|
||||
permission: remote.permission,
|
||||
updatedAt: remote.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureRemoteFolderPath(
|
||||
api: Pick<PagedenApiClient, "createFolder">,
|
||||
workspaceId: string,
|
||||
tree: RemoteTree,
|
||||
folderSegments: string[],
|
||||
): Promise<RemoteFolder> {
|
||||
const folderByPath = new Map(tree.folders.map((folder) => [trimSlashes(folder.path), folder]));
|
||||
let parent: RemoteFolder | null = null;
|
||||
let currentPath = "";
|
||||
|
||||
for (const segment of folderSegments) {
|
||||
const slug = slugify(segment);
|
||||
currentPath = currentPath ? `${currentPath}/${slug}` : slug;
|
||||
const existing = folderByPath.get(currentPath);
|
||||
if (existing) {
|
||||
parent = existing;
|
||||
continue;
|
||||
}
|
||||
const created = await api.createFolder({
|
||||
workspaceId,
|
||||
parentFolderId: parent?.id ?? null,
|
||||
name: segment,
|
||||
slug,
|
||||
});
|
||||
parent = {
|
||||
id: created.id,
|
||||
parentFolderId: parent?.id ?? null,
|
||||
name: segment,
|
||||
slug,
|
||||
path: trimSlashes(created.path),
|
||||
permission: "manager",
|
||||
};
|
||||
folderByPath.set(currentPath, parent);
|
||||
}
|
||||
|
||||
if (!parent) {
|
||||
throw new Error("Create the note inside a folder so Pageden knows where to place it.");
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
function targetPathForLocal(remoteDocsFolder: string, localPath: string) {
|
||||
const normalizedLocal = normalizePath(localPath);
|
||||
const normalizedRoot = normalizePath(remoteDocsFolder).replace(/\/+$/, "");
|
||||
const relativePath = normalizedLocal === normalizedRoot
|
||||
? ""
|
||||
: normalizedLocal.startsWith(`${normalizedRoot}/`)
|
||||
? normalizedLocal.slice(normalizedRoot.length + 1)
|
||||
: normalizedLocal;
|
||||
const parts = relativePath.split("/").filter(Boolean);
|
||||
const filename = parts.pop() ?? "untitled.md";
|
||||
const folderSegments = parts.map((part) => part.replace(/\.md$/i, ""));
|
||||
const title = filename.replace(/\.md$/i, "") || "Untitled";
|
||||
const documentSlug = slugify(filename);
|
||||
const documentPath = [...folderSegments.map(slugify), `${documentSlug}.md`].filter(Boolean).join("/");
|
||||
return { folderSegments, title, documentSlug, documentPath };
|
||||
}
|
||||
|
||||
function frontmatterTitle(content: string): string | null {
|
||||
if (!content.startsWith("---\n")) return null;
|
||||
const end = content.indexOf("\n---", 4);
|
||||
if (end === -1) return null;
|
||||
const raw = content.slice(4, end);
|
||||
for (const line of raw.split("\n")) {
|
||||
const match = line.match(/^title:\s*(.+)$/i);
|
||||
if (!match?.[1]) continue;
|
||||
return match[1].trim().replace(/^['"]|['"]$/g, "") || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
const slug = value
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/\.md$/i, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return slug || "untitled";
|
||||
}
|
||||
|
||||
function trimSlashes(path: string): string {
|
||||
return path.replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background sync (Milestone 6). Pure orchestration over the same primitives the manual
|
||||
// commands use, so behaviour (canonicalization, opaque version, 409 → *.conflict.md, never
|
||||
// overwrite local) stays identical. The Obsidian-facing engine in main.ts only wires lifecycle,
|
||||
// events, debounce, status, and the applyingRemoteWrite guard around these.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type DocSyncStatus =
|
||||
| "unchanged"
|
||||
| "created"
|
||||
| "pulled"
|
||||
| "pushed"
|
||||
| "conflict"
|
||||
| "conflict_pending"
|
||||
| "blocked_viewer"
|
||||
| "missing_local"
|
||||
| "gone";
|
||||
|
||||
// The server copy of an unresolved conflict is written beside the local file as `<name>.conflict.md`.
|
||||
export function conflictSiblingPath(localPath: string): string {
|
||||
return `${localPath.replace(/\.md$/i, "")}.conflict.md`;
|
||||
}
|
||||
|
||||
export interface DocSyncResult {
|
||||
documentId: string;
|
||||
localPath: string;
|
||||
status: DocSyncStatus;
|
||||
conflictPath?: string;
|
||||
}
|
||||
|
||||
export interface SyncPassSummary {
|
||||
unchanged: number;
|
||||
created: number;
|
||||
pulled: number;
|
||||
pushed: number;
|
||||
conflicts: number;
|
||||
conflictsPending: number;
|
||||
blockedViewer: number;
|
||||
missingLocal: number;
|
||||
gone: number;
|
||||
attachmentsDownloaded: number;
|
||||
attachmentsUploaded: number;
|
||||
attachmentsDeleted: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export interface AttachmentSyncSummary {
|
||||
downloaded: number;
|
||||
uploaded: number;
|
||||
deleted: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
// Reconcile one linked document. Decision table:
|
||||
// local diverged (checksum != tracked) -> push (a stale base yields a clean 409 -> conflict)
|
||||
// server moved & local unchanged -> pull (write server content, advance meta)
|
||||
// server moved & local missing -> pull (re-materialize the file)
|
||||
// otherwise -> unchanged
|
||||
export async function syncLinkedDocument(deps: SyncDeps, entry: ServerMetaEntry): Promise<DocSyncResult> {
|
||||
const base = { documentId: entry.documentId, localPath: entry.localPath };
|
||||
|
||||
let remote: RemoteDocumentWithContent;
|
||||
try {
|
||||
remote = await deps.api.document(entry.documentId);
|
||||
} catch (error) {
|
||||
// Existence-hiding: a deleted document or a revoked grant returns 404. Report it as gone and
|
||||
// leave the local file untouched (the user keeps their copy); don't retry-loop.
|
||||
if (error instanceof PagedenApiError && error.status === 404) return { ...base, status: "gone" };
|
||||
throw error;
|
||||
}
|
||||
|
||||
const serverMoved = (remote.version ?? "") !== entry.baseVersion;
|
||||
const localExists = await deps.vault.exists(entry.localPath);
|
||||
|
||||
// A previously-recorded conflict freezes the document until the user resolves it (deletes the
|
||||
// *.conflict.md sibling). Without this, background auto-push would silently push the local side
|
||||
// on the next pass and discard the server changes the user never reconciled.
|
||||
if (await deps.vault.exists(conflictSiblingPath(entry.localPath))) {
|
||||
return { ...base, status: "conflict_pending", conflictPath: conflictSiblingPath(entry.localPath) };
|
||||
}
|
||||
|
||||
let localCanonical = "";
|
||||
if (localExists) {
|
||||
localCanonical = canonicalize(await deps.vault.read(entry.localPath));
|
||||
const localDiverged = (await checksum(localCanonical)) !== entry.checksum;
|
||||
if (localDiverged) {
|
||||
// Reconcile permission from the live fetch: a downgrade to viewer means we must NOT push,
|
||||
// even if the stored meta still says editor.
|
||||
if (remote.permission === "viewer") {
|
||||
if (serverMoved) {
|
||||
const conflictPath = await writeConflictFile(deps.vault, entry.localPath, remote);
|
||||
await deps.meta.upsert(metaFromRemote(remote, entry.localPath));
|
||||
return { ...base, status: "conflict", conflictPath };
|
||||
}
|
||||
await deps.meta.upsert({ ...entry, permission: "viewer" });
|
||||
return { ...base, status: "blocked_viewer" };
|
||||
}
|
||||
const pushed = await pushLocalDocument(deps, entry.localPath);
|
||||
if (pushed.status === "pushed") return { ...base, status: "pushed" };
|
||||
if (pushed.status === "conflict") return { ...base, status: "conflict", conflictPath: pushed.conflictPath };
|
||||
return { ...base, status: "blocked_viewer" };
|
||||
}
|
||||
}
|
||||
|
||||
if (!localExists) {
|
||||
// The user deleted a tracked file locally; don't resurrect it. Leave meta so a manual
|
||||
// re-download can re-link it.
|
||||
return { ...base, status: "missing_local" };
|
||||
}
|
||||
|
||||
if (serverMoved) {
|
||||
// Re-read immediately before writing to close the TOCTOU window between the divergence check
|
||||
// above and the write: if the file changed in between, treat it as a conflict, never clobber.
|
||||
const fresh = canonicalize(await deps.vault.read(entry.localPath));
|
||||
if ((await checksum(fresh)) !== entry.checksum) {
|
||||
const conflictPath = await writeConflictFile(deps.vault, entry.localPath, remote);
|
||||
await deps.meta.upsert(metaFromRemote(remote, entry.localPath));
|
||||
return { ...base, status: "conflict", conflictPath };
|
||||
}
|
||||
await applyRemotePull(deps, entry.localPath, remote);
|
||||
return { ...base, status: "pulled" };
|
||||
}
|
||||
|
||||
// Unchanged content; still reconcile a permission change so the UI/gating stays accurate.
|
||||
if (remote.permission !== entry.permission) {
|
||||
await deps.meta.upsert({ ...entry, permission: remote.permission });
|
||||
}
|
||||
if (hasAttachmentSupport(deps)) {
|
||||
await syncDocumentAttachments(deps, { ...entry, permission: remote.permission }, localCanonical);
|
||||
}
|
||||
return { ...base, status: "unchanged" };
|
||||
}
|
||||
|
||||
async function applyRemotePull(deps: SyncDeps, localPath: string, remote: RemoteDocumentWithContent): Promise<void> {
|
||||
const parent = localPath.split("/").slice(0, -1).join("/");
|
||||
if (parent) await ensureFolder(deps.vault, parent);
|
||||
const content = canonicalize(remote.content);
|
||||
await deps.vault.write(localPath, content);
|
||||
const meta = metaFromRemote(remote, localPath);
|
||||
await deps.meta.upsert(meta);
|
||||
if (hasAttachmentSupport(deps)) await syncDocumentAttachments(deps, meta, content);
|
||||
}
|
||||
|
||||
// Run one pass over every linked document. Per-document errors are isolated so one failure
|
||||
// (or one offline document) does not abort the rest of the pass.
|
||||
export async function runBackgroundSyncPass(
|
||||
deps: BackgroundSyncDeps,
|
||||
onResult?: (result: DocSyncResult) => void,
|
||||
): Promise<SyncPassSummary> {
|
||||
const summary: SyncPassSummary = {
|
||||
unchanged: 0,
|
||||
created: 0,
|
||||
pulled: 0,
|
||||
pushed: 0,
|
||||
conflicts: 0,
|
||||
conflictsPending: 0,
|
||||
blockedViewer: 0,
|
||||
missingLocal: 0,
|
||||
gone: 0,
|
||||
attachmentsDownloaded: 0,
|
||||
attachmentsUploaded: 0,
|
||||
attachmentsDeleted: 0,
|
||||
errors: 0,
|
||||
};
|
||||
const entries = await deps.meta.list();
|
||||
const linkedLocalPaths = new Set(entries.map((entry) => normalizePath(entry.localPath)));
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
const result = await syncLinkedDocument(deps, entry);
|
||||
onResult?.(result);
|
||||
switch (result.status) {
|
||||
case "unchanged": summary.unchanged += 1; break;
|
||||
case "created": summary.created += 1; break;
|
||||
case "pulled": summary.pulled += 1; break;
|
||||
case "pushed": summary.pushed += 1; break;
|
||||
case "conflict": summary.conflicts += 1; break;
|
||||
case "conflict_pending": summary.conflictsPending += 1; break;
|
||||
case "blocked_viewer": summary.blockedViewer += 1; break;
|
||||
case "missing_local": summary.missingLocal += 1; break;
|
||||
case "gone": summary.gone += 1; break;
|
||||
}
|
||||
} catch {
|
||||
summary.errors += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (canCreateUnlinkedLocalDocuments(deps)) {
|
||||
for (const localPath of await deps.localMarkdownPaths()) {
|
||||
const normalizedPath = normalizePath(localPath);
|
||||
if (linkedLocalPaths.has(normalizedPath) || normalizedPath.endsWith(".conflict.md")) continue;
|
||||
try {
|
||||
const result = await createRemoteDocumentFromLocal(deps, normalizedPath);
|
||||
if (result.meta) linkedLocalPaths.add(normalizePath(result.meta.localPath));
|
||||
summary.created += 1;
|
||||
onResult?.({ documentId: result.meta?.documentId ?? "", localPath: normalizedPath, status: "created" });
|
||||
} catch {
|
||||
summary.errors += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
function canCreateUnlinkedLocalDocuments(deps: BackgroundSyncDeps): deps is CreateRemoteDeps & { localMarkdownPaths: () => Promise<string[]> } {
|
||||
return Boolean(deps.workspaceId && deps.localMarkdownPaths && deps.api.tree && deps.api.createFolder && deps.api.createDocument);
|
||||
}
|
||||
|
||||
export async function syncDocumentAttachments(
|
||||
deps: SyncDeps,
|
||||
entry: ServerMetaEntry,
|
||||
markdown?: string,
|
||||
): Promise<AttachmentSyncSummary> {
|
||||
const api = requireAttachmentApi(deps);
|
||||
const meta = requireAttachmentMeta(deps);
|
||||
const vault = requireBinaryVault(deps);
|
||||
const summary = emptyAttachmentSummary();
|
||||
const remote = await api.attachments(entry.documentId);
|
||||
const tracked = await meta.listAttachmentsForDocument(entry.documentId);
|
||||
const trackedById = new Map(tracked.map((item) => [item.attachmentId, item]));
|
||||
const remoteById = new Map(remote.attachments.map((item) => [item.id, item]));
|
||||
const content = markdown ?? (await deps.vault.exists(entry.localPath) ? await deps.vault.read(entry.localPath) : "");
|
||||
const referenced = entry.permission === "viewer" ? new Set<string>() : extractAttachmentPaths(content, entry.localPath);
|
||||
|
||||
for (const old of tracked) {
|
||||
if (!remoteById.has(old.attachmentId)) await meta.removeAttachment(old.attachmentId);
|
||||
}
|
||||
|
||||
if (entry.permission !== "viewer") {
|
||||
for (const old of tracked) {
|
||||
if (!remoteById.has(old.attachmentId) || referenced.has(old.localPath) || (await deps.vault.exists(old.localPath))) continue;
|
||||
await api.deleteAttachment(old.attachmentId);
|
||||
await meta.removeAttachment(old.attachmentId);
|
||||
remoteById.delete(old.attachmentId);
|
||||
summary.deleted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const attachment of remote.attachments) {
|
||||
if (!remoteById.has(attachment.id)) continue;
|
||||
const localPath = trackedById.get(attachment.id)?.localPath ?? attachmentLocalPath(entry.localPath, attachment.filename);
|
||||
if (!(await deps.vault.exists(localPath)) || trackedById.get(attachment.id)?.sha256 !== attachment.sha256) {
|
||||
await ensureParent(deps.vault, localPath);
|
||||
await vault.writeBinary(localPath, await api.downloadAttachment(attachment.id));
|
||||
summary.downloaded += 1;
|
||||
}
|
||||
await meta.upsertAttachment(metaFromAttachment(entry.documentId, localPath, attachment));
|
||||
}
|
||||
|
||||
if (entry.permission === "viewer") return summary;
|
||||
const trackedByPath = new Map((await meta.listAttachmentsForDocument(entry.documentId)).map((item) => [item.localPath, item]));
|
||||
|
||||
for (const localPath of referenced) {
|
||||
if (!(await deps.vault.exists(localPath))) {
|
||||
summary.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const bytes = await vault.readBinary(localPath);
|
||||
const sha256 = await sha256Hex(bytes);
|
||||
const existing = trackedByPath.get(localPath);
|
||||
if (existing?.sha256 === sha256) {
|
||||
summary.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const uploaded = await api.uploadAttachment(entry.documentId, localPath.split("/").pop() ?? "attachment", bytes, contentTypeForPath(localPath));
|
||||
if (existing) {
|
||||
await api.deleteAttachment(existing.attachmentId);
|
||||
await meta.removeAttachment(existing.attachmentId);
|
||||
summary.deleted += 1;
|
||||
}
|
||||
await meta.upsertAttachment(metaFromAttachment(entry.documentId, localPath, uploaded));
|
||||
summary.uploaded += 1;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
function emptyAttachmentSummary(): AttachmentSyncSummary {
|
||||
return { downloaded: 0, uploaded: 0, deleted: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
function hasAttachmentSupport(deps: SyncDeps): boolean {
|
||||
return Boolean(
|
||||
deps.api.attachments &&
|
||||
deps.api.uploadAttachment &&
|
||||
deps.api.downloadAttachment &&
|
||||
deps.api.deleteAttachment &&
|
||||
deps.vault.readBinary &&
|
||||
deps.vault.writeBinary &&
|
||||
deps.meta.listAttachmentsForDocument &&
|
||||
deps.meta.upsertAttachment &&
|
||||
deps.meta.removeAttachment,
|
||||
);
|
||||
}
|
||||
|
||||
function requireAttachmentApi(deps: SyncDeps): Required<Pick<PagedenApiClient, "attachments" | "uploadAttachment" | "downloadAttachment" | "deleteAttachment">> {
|
||||
if (!deps.api.attachments || !deps.api.uploadAttachment || !deps.api.downloadAttachment || !deps.api.deleteAttachment) {
|
||||
throw new Error("Attachment sync requires attachment API methods.");
|
||||
}
|
||||
return deps.api as Required<Pick<PagedenApiClient, "attachments" | "uploadAttachment" | "downloadAttachment" | "deleteAttachment">>;
|
||||
}
|
||||
|
||||
function requireAttachmentMeta(deps: SyncDeps): Required<Pick<MetaStoreLike, "listAttachmentsForDocument" | "upsertAttachment" | "removeAttachment">> {
|
||||
if (!deps.meta.listAttachmentsForDocument || !deps.meta.upsertAttachment || !deps.meta.removeAttachment) {
|
||||
throw new Error("Attachment sync requires attachment metadata methods.");
|
||||
}
|
||||
return deps.meta as Required<Pick<MetaStoreLike, "listAttachmentsForDocument" | "upsertAttachment" | "removeAttachment">>;
|
||||
}
|
||||
|
||||
function requireBinaryVault(deps: SyncDeps): Required<Pick<VaultLike, "readBinary" | "writeBinary">> {
|
||||
if (!deps.vault.readBinary || !deps.vault.writeBinary) {
|
||||
throw new Error("Attachment sync requires binary vault methods.");
|
||||
}
|
||||
return deps.vault as Required<Pick<VaultLike, "readBinary" | "writeBinary">>;
|
||||
}
|
||||
|
||||
function metaFromAttachment(documentId: string, localPath: string, attachment: Attachment): ServerMetaAttachmentEntry {
|
||||
return {
|
||||
attachmentId: attachment.id,
|
||||
documentId,
|
||||
localPath,
|
||||
filename: attachment.filename,
|
||||
sha256: attachment.sha256,
|
||||
size: attachment.size,
|
||||
contentType: attachment.contentType,
|
||||
createdAt: attachment.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function attachmentLocalPath(documentPath: string, filename: string): string {
|
||||
const parent = documentPath.split("/").slice(0, -1).join("/");
|
||||
return normalizePath(`${parent}/${filename}`);
|
||||
}
|
||||
|
||||
async function ensureParent(vault: VaultLike, path: string): Promise<void> {
|
||||
const parent = path.split("/").slice(0, -1).join("/");
|
||||
if (parent) await ensureFolder(vault, parent);
|
||||
}
|
||||
|
||||
export function extractAttachmentPaths(markdown: string, documentPath: string): Set<string> {
|
||||
const parent = documentPath.split("/").slice(0, -1).join("/");
|
||||
const out = new Set<string>();
|
||||
const push = (raw: string) => {
|
||||
const target = raw.trim();
|
||||
if (!target || target.startsWith("#") || /^[a-z][a-z0-9+.-]*:/i.test(target) || target.endsWith(".md")) return;
|
||||
const clean = target.split(/[?#]/)[0] ?? "";
|
||||
if (!clean) return;
|
||||
out.add(normalizePath(clean.startsWith("/") ? clean.slice(1) : `${parent}/${clean}`));
|
||||
};
|
||||
for (const match of markdown.matchAll(/!\[[^\]]*]\(([^)]+)\)|\[[^\]]+]\(([^)]+)\)/g)) {
|
||||
push(match[1] ?? match[2] ?? "");
|
||||
}
|
||||
for (const match of markdown.matchAll(/!\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?]]/g)) {
|
||||
push(match[1] ?? "");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function sha256Hex(bytes: ArrayBuffer): Promise<string> {
|
||||
const hash = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function contentTypeForPath(path: string): string {
|
||||
const ext = path.split(".").pop()?.toLowerCase();
|
||||
if (ext === "png") return "image/png";
|
||||
if (ext === "jpg" || ext === "jpeg") return "image/jpeg";
|
||||
if (ext === "gif") return "image/gif";
|
||||
if (ext === "webp") return "image/webp";
|
||||
if (ext === "svg") return "image/svg+xml";
|
||||
if (ext === "pdf") return "application/pdf";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
5
src/turndown-plugin-gfm.d.ts
vendored
Normal file
5
src/turndown-plugin-gfm.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
declare module "turndown-plugin-gfm" {
|
||||
import type TurndownService from "turndown";
|
||||
|
||||
export function gfm(service: TurndownService): void;
|
||||
}
|
||||
132
src/types.ts
Normal file
132
src/types.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
export type Role = "owner" | "manager" | "editor" | "viewer";
|
||||
|
||||
export interface PagedenSettings {
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
workspaceId: string;
|
||||
remoteDocsFolder: string;
|
||||
autoSyncEnabled: boolean;
|
||||
autoSyncIntervalMinutes: number;
|
||||
userName?: string;
|
||||
workspaceName?: string;
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
user: { id: string; email: string; name: string };
|
||||
workspaces: { id: string; name: string; role: string }[];
|
||||
}
|
||||
|
||||
export interface RemoteFolder {
|
||||
id: string;
|
||||
parentFolderId: string | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
path: string;
|
||||
permission: Role | null;
|
||||
}
|
||||
|
||||
export interface RemoteDocument {
|
||||
id: string;
|
||||
folderId: string;
|
||||
title: string;
|
||||
path: string;
|
||||
permission: Role;
|
||||
version: string | null;
|
||||
checksum: string | null;
|
||||
}
|
||||
|
||||
export interface RemoteTree {
|
||||
folders: RemoteFolder[];
|
||||
documents: RemoteDocument[];
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
path: string;
|
||||
permission: Role;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
results: SearchResult[];
|
||||
}
|
||||
|
||||
export interface RemoteDocumentWithContent extends RemoteDocument {
|
||||
workspaceId: string;
|
||||
content: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateFolderResponse {
|
||||
id: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface CreateDocumentResponse {
|
||||
id: string;
|
||||
version: string;
|
||||
checksum: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ServerMetaEntry {
|
||||
documentId: string;
|
||||
localPath: string;
|
||||
remotePath: string;
|
||||
title: string;
|
||||
baseVersion: string;
|
||||
checksum: string;
|
||||
permission: Role;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AttachmentListResponse {
|
||||
attachments: Attachment[];
|
||||
}
|
||||
|
||||
export interface ServerMetaAttachmentEntry {
|
||||
attachmentId: string;
|
||||
documentId: string;
|
||||
localPath: string;
|
||||
filename: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
contentType: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ServerMetaFile {
|
||||
documents: Record<string, ServerMetaEntry>;
|
||||
attachments?: Record<string, ServerMetaAttachmentEntry>;
|
||||
}
|
||||
|
||||
export interface WriteResult {
|
||||
id: string;
|
||||
version: string;
|
||||
checksum: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DeviceStartResponse {
|
||||
deviceCode: string;
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
expiresIn: number;
|
||||
interval: number;
|
||||
}
|
||||
|
||||
export type DevicePollResponse =
|
||||
| { status: "pending" }
|
||||
| { status: "denied" }
|
||||
| { status: "expired" }
|
||||
| { status: "consumed" }
|
||||
| { status: "approved"; token: string };
|
||||
14
test/obsidian-stub.ts
Normal file
14
test/obsidian-stub.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export function normalizePath(path: string): string {
|
||||
return path.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export async function requestUrl(): Promise<never> {
|
||||
throw new Error("requestUrl is not available in unit tests; inject a RequestTransport.");
|
||||
}
|
||||
|
||||
export class Plugin {}
|
||||
export class PluginSettingTab {}
|
||||
export class Setting {}
|
||||
export class Modal {}
|
||||
export class Notice {}
|
||||
export class TFile {}
|
||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"noEmit": true,
|
||||
"declaration": false,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src", "esbuild.config.mjs", "vitest.config.ts"]
|
||||
}
|
||||
|
|
@ -3,5 +3,6 @@
|
|||
"0.1.1": "1.5.0",
|
||||
"0.1.2": "1.5.0",
|
||||
"0.1.3": "1.5.0",
|
||||
"0.1.4": "1.5.0"
|
||||
"0.1.4": "1.5.0",
|
||||
"0.1.5": "1.5.0"
|
||||
}
|
||||
|
|
|
|||
14
vitest.config.ts
Normal file
14
vitest.config.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
obsidian: fileURLToPath(new URL("./test/obsidian-stub.ts", import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue