Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions apps/pi-extension/server/serverAnnotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<void>[] = [];
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);
}
Expand Down
103 changes: 103 additions & 0 deletions packages/server/annotate.test.ts
Original file line number Diff line number Diff line change
@@ -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 = "<html><body>Plannotator</body></html>";

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`, {

Check failure on line 27 in packages/server/annotate.test.ts

View workflow job for this annotation

GitHub Actions / test

TypeError: fetch() URL is invalid

at <anonymous> (/home/runner/work/plannotator/plannotator/packages/server/annotate.test.ts:27:30)

Check failure on line 27 in packages/server/annotate.test.ts

View workflow job for this annotation

GitHub Actions / test

TypeError: fetch() URL is invalid

at <anonymous> (/home/runner/work/plannotator/plannotator/packages/server/annotate.test.ts:27:30)
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`, {

Check failure on line 59 in packages/server/annotate.test.ts

View workflow job for this annotation

GitHub Actions / test

TypeError: fetch() URL is invalid

at <anonymous> (/home/runner/work/plannotator/plannotator/packages/server/annotate.test.ts:59:30)

Check failure on line 59 in packages/server/annotate.test.ts

View workflow job for this annotation

GitHub Actions / test

TypeError: fetch() URL is invalid

at <anonymous> (/home/runner/work/plannotator/plannotator/packages/server/annotate.test.ts:59:30)
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`, {

Check failure on line 82 in packages/server/annotate.test.ts

View workflow job for this annotation

GitHub Actions / test

TypeError: fetch() URL is invalid

at <anonymous> (/home/runner/work/plannotator/plannotator/packages/server/annotate.test.ts:82:30)

Check failure on line 82 in packages/server/annotate.test.ts

View workflow job for this annotation

GitHub Actions / test

TypeError: fetch() URL is invalid

at <anonymous> (/home/runner/work/plannotator/plannotator/packages/server/annotate.test.ts:82:30)
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();
}
});
});
48 changes: 48 additions & 0 deletions packages/server/annotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<void>[] = [];
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();

Expand Down
42 changes: 42 additions & 0 deletions packages/server/integrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
stripH1,
buildHashtags,
buildBearContent,
saveToObsidian,
} from "./integrations";

describe("extractTitle", () => {
Expand Down Expand Up @@ -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();
});
});
Loading