chore: include plugin source and shrink bundle

This commit is contained in:
Chris Ian Fiel 2026-06-14 16:21:04 +08:00
parent 5fcb77d457
commit 3d0aaa7adb
25 changed files with 5655 additions and 74 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
.DS_Store
node_modules/

14
esbuild.config.mjs Normal file
View 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

File diff suppressed because one or more lines are too long

View file

@ -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
View 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

File diff suppressed because it is too large Load diff

112
src/api-client.test.ts Normal file
View 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
View 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
View 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
View 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
View 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![chart](assets/chart%201.png)\n![remote](https://example.com/x.png)")).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
View 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

File diff suppressed because it is too large Load diff

59
src/metadata.test.ts Normal file
View 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
View 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
View 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
View 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
View 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("![one](img/a.png)\n[download](files/report.pdf)\n![[b.jpg|thumb]]\n[doc](other.md)\n![remote](https://x.test/a.png)", "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", "![local](local.png)\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, "![local](local.png)\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
View 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
View 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
View 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
View 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
View 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"]
}

View file

@ -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
View 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",
},
});