diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts index a8e797bae..bb1814e83 100644 --- a/apps/pi-extension/server/serverAnnotate.ts +++ b/apps/pi-extension/server/serverAnnotate.ts @@ -25,6 +25,15 @@ import { handleObsidianDocRequest, } from "./reference.js"; import { warmFileListCache } from "../generated/resolve-file.js"; +import { + type BearConfig, + type IntegrationResult, + type ObsidianConfig, + type OctarineConfig, + saveToBear, + saveToObsidian, + saveToOctarine, +} from "./integrations.js"; import { createExternalAnnotationHandler } from "./external-annotations.js"; export interface AnnotateServerResult { @@ -179,6 +188,50 @@ export async function startAnnotateServer(options: { const message = err instanceof Error ? err.message : "Failed to process feedback"; json(res, { error: message }, 500); } + } else if (url.pathname === "/api/save-notes" && req.method === "POST") { + const results: { + obsidian?: IntegrationResult; + bear?: IntegrationResult; + octarine?: IntegrationResult; + } = {}; + try { + const body = await parseBody(req); + const promises: Promise[] = []; + const obsConfig = body.obsidian as ObsidianConfig | undefined; + const bearConfig = body.bear as BearConfig | undefined; + const octConfig = body.octarine as OctarineConfig | undefined; + if (obsConfig?.vaultPath && obsConfig?.plan) { + promises.push( + saveToObsidian(obsConfig).then((r) => { + results.obsidian = r; + }), + ); + } + if (bearConfig?.plan) { + promises.push( + saveToBear(bearConfig).then((r) => { + results.bear = r; + }), + ); + } + if (octConfig?.plan && octConfig?.workspace) { + promises.push( + saveToOctarine(octConfig).then((r) => { + results.octarine = r; + }), + ); + } + await Promise.allSettled(promises); + for (const [name, result] of Object.entries(results)) { + if (!result?.success && result) + console.error(`[${name}] Save failed: ${result.error}`); + } + } catch (err) { + console.error(`[Save Notes] Error:`, err); + json(res, { error: "Save failed" }, 500); + return; + } + json(res, { ok: true, results }); } else { html(res, options.htmlContent); } diff --git a/packages/server/annotate.test.ts b/packages/server/annotate.test.ts new file mode 100644 index 000000000..268ab8720 --- /dev/null +++ b/packages/server/annotate.test.ts @@ -0,0 +1,103 @@ +/** + * Annotate Server Route Tests + * + * Verifies the /api/save-notes endpoint behaves correctly. + * Passes in CI (bun on PATH, clean env); may fail locally due to + * PLANNOTATOR_PORT env var pollution from parallel test files. + * + * Run: bun test packages/server/annotate.test.ts + */ + +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "fs"; +import { startAnnotateServer } from "./annotate"; + +const MINIMAL_HTML = "Plannotator"; + +describe("/api/save-notes endpoint", () => { + test("POST saves to Obsidian vault and returns success", async () => { + const tmpDir = mkdtempSync("/tmp/plannotator-annotate-"); + const server = await startAnnotateServer({ + markdown: "# Test", + filePath: "/tmp/test.md", + htmlContent: MINIMAL_HTML, + }); + + try { + const response = await fetch(`${server.url}/api/save-notes`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + obsidian: { + vaultPath: tmpDir, + folder: "plannotator", + plan: "# Test Plan\n\nContent here", + }, + }), + }); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json).toHaveProperty("ok", true); + expect(json).toHaveProperty("results"); + expect(json.results.obsidian).toHaveProperty("success", true); + expect(json.results.obsidian).toHaveProperty("path"); + } finally { + server.stop(); + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + test("POST returns 200 with empty results when no integrations", async () => { + const server = await startAnnotateServer({ + markdown: "# Test", + filePath: "/tmp/test.md", + htmlContent: MINIMAL_HTML, + }); + + try { + const response = await fetch(`${server.url}/api/save-notes`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json).toHaveProperty("ok", true); + expect(json.results).toEqual({}); + } finally { + server.stop(); + } + }); + + test("POST with missing vault returns integration error, not server error", async () => { + const server = await startAnnotateServer({ + markdown: "# Test", + filePath: "/tmp/test.md", + htmlContent: MINIMAL_HTML, + }); + + try { + const response = await fetch(`${server.url}/api/save-notes`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + obsidian: { + vaultPath: "/nonexistent-vault-path", + folder: "plannotator", + plan: "# Test Plan\n\nContent here", + }, + }), + }); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json).toHaveProperty("ok", true); + expect(json.results.obsidian).toHaveProperty("success", false); + expect(json.results.obsidian).toHaveProperty("error"); + } finally { + server.stop(); + } + }); +}); diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index 4c99eea7f..18c45cedd 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -15,6 +15,8 @@ import { isRemoteSession, getServerHostname, getServerPort } from "./remote"; import { getRepoInfo } from "./repo"; import type { Origin } from "@plannotator/shared/agents"; import { handleImage, handleUpload, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleFavicon } from "./shared-handlers"; +import { saveToObsidian, saveToBear, saveToOctarine } from "./integrations"; +import type { ObsidianConfig, BearConfig, OctarineConfig, IntegrationResult } from "./integrations"; import { handleDoc, handleDocExists, handleFileBrowserFiles, handleObsidianVaults, handleObsidianFiles, handleObsidianDoc } from "./reference-handlers"; import { warmFileListCache } from "@plannotator/shared/resolve-file"; import { contentHash, deleteDraft } from "./draft"; @@ -335,6 +337,52 @@ export async function startAnnotateServer( } } + // API: Save notes to external integrations (Obsidian, Bear, Octarine) + if (url.pathname === "/api/save-notes" && req.method === "POST") { + const results: { + obsidian?: IntegrationResult; + bear?: IntegrationResult; + octarine?: IntegrationResult; + } = {}; + try { + const body = (await req.json()) as { + obsidian?: ObsidianConfig; + bear?: BearConfig; + octarine?: OctarineConfig; + }; + const promises: Promise[] = []; + if (body.obsidian?.vaultPath && body.obsidian?.plan) { + promises.push( + saveToObsidian(body.obsidian).then((r) => { + results.obsidian = r; + }), + ); + } + if (body.bear?.plan) { + promises.push( + saveToBear(body.bear).then((r) => { + results.bear = r; + }), + ); + } + if (body.octarine?.plan && body.octarine?.workspace) { + promises.push( + saveToOctarine(body.octarine).then((r) => { + results.octarine = r; + }), + ); + } + await Promise.allSettled(promises); + for (const [name, result] of Object.entries(results)) { + if (!result?.success && result) + console.error(`[${name}] Save failed: ${result.error}`); + } + } catch (err) { + console.error("[Integration] Error:", err); + } + return Response.json({ ok: true, results }); + } + // Favicon if (url.pathname === "/favicon.svg") return handleFavicon(); diff --git a/packages/server/integrations.test.ts b/packages/server/integrations.test.ts index 9215276fd..84f518453 100644 --- a/packages/server/integrations.test.ts +++ b/packages/server/integrations.test.ts @@ -11,6 +11,7 @@ import { stripH1, buildHashtags, buildBearContent, + saveToObsidian, } from "./integrations"; describe("extractTitle", () => { @@ -170,3 +171,44 @@ describe("extractTags", () => { expect(tags.length).toBeLessThanOrEqual(7); }); }); + +describe("saveToObsidian", () => { + test("writes plan file to temp vault", async () => { + const { mkdtempSync } = await import("fs"); + const { join } = await import("path"); + const tmpDir = mkdtempSync("/tmp/plannotator-vault-"); + try { + const result = await saveToObsidian({ + vaultPath: tmpDir, + folder: "plannotator", + plan: "# Test Plan\n\nSome content", + }); + + expect(result.success).toBe(true); + expect(result.path).toBeString(); + expect(result.path).toContain(tmpDir); + expect(result.path).toContain("plannotator"); + + // File should exist and contain the plan + const exists = Bun.file(result.path!).size > 0; + expect(exists).toBe(true); + + const content = await Bun.file(result.path!).text(); + expect(content).toContain("# Test Plan"); + expect(content).toContain("[[Plannotator Plans]]"); + } finally { + const { rmSync } = await import("fs"); + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + test("fails when vault path does not exist", async () => { + const result = await saveToObsidian({ + vaultPath: "/nonexistent/vault", + folder: "plannotator", + plan: "# Plan", + }); + expect(result.success).toBe(false); + expect(result.error).toBeString(); + }); +});