From 9c844a647a18cbb54a67d54341380a8a6ddcb5b1 Mon Sep 17 00:00:00 2001 From: franktronicsprog Date: Fri, 10 Jul 2026 12:19:36 +0200 Subject: [PATCH] feat: add Notion notes integration Adds Notion as a fourth note destination alongside Obsidian, Bear, and Octarine. Approved plans (and arrival auto-save, manual export, and the Cmd/Ctrl+S default action) can create a child page under a chosen Notion page via POST /v1/pages with the plan title and Notion-flavored Markdown. The Notion API token is read only from NOTION_TOKEN in the server process environment. It is never stored in cookies, sent from the browser, or included in any API response, snapshot, or log. The UI only persists the non-secret parent page id, enable flag, and auto-save preference. - shared: NotionConfig type + optional url on IntegrationResult - server (Bun + Pi): saveToNotion, wired into /api/save-notes and the approve path with the same Promise.allSettled isolation - ui: notion settings util, Settings tab, ExportModal card, header menu action, and the three App.tsx export triggers - tests: Notion request/token-safety, dispatcher, and page-id parsing - docs: Notion integration guide + README/API endpoint updates Closes #434 --- README.md | 2 + .../content/docs/guides/notion-integration.md | 36 ++++++++ .../content/docs/reference/api-endpoints.md | 3 +- apps/pi-extension/server/handlers.ts | 11 +++ apps/pi-extension/server/integrations.ts | 47 +++++++++- apps/pi-extension/server/serverPlan.ts | 10 +++ packages/editor/App.tsx | 40 +++++++-- packages/editor/components/AppHeader.tsx | 6 ++ packages/server/index.ts | 6 ++ packages/server/integrations.test.ts | 68 +++++++++++++++ packages/server/integrations.ts | 56 +++++++++++- packages/server/shared-handlers.test.ts | 24 ++++++ packages/server/shared-handlers.ts | 12 ++- packages/shared/integrations-common.ts | 6 ++ packages/ui/components/ExportModal.tsx | 81 +++++++++++++++-- packages/ui/components/PlanHeaderMenu.tsx | 16 +++- packages/ui/components/Settings.tsx | 86 ++++++++++++++++++- packages/ui/utils/defaultNotesApp.ts | 2 +- packages/ui/utils/notion.test.ts | 16 ++++ packages/ui/utils/notion.ts | 38 ++++++++ 20 files changed, 538 insertions(+), 28 deletions(-) create mode 100644 apps/marketing/src/content/docs/guides/notion-integration.md create mode 100644 packages/ui/utils/notion.test.ts create mode 100644 packages/ui/utils/notion.ts diff --git a/README.md b/README.md index 54125b512..89c378f62 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,8 @@ You run /plannotator-review **Bear**: Save plans as Bear notes with nested tags and project metadata. +**Notion**: Save plans as Markdown child pages under a shared Notion page. Set `NOTION_TOKEN` in the environment that starts Plannotator, then configure the parent page in Settings. + **GitHub / GitLab**: Pass any PR or MR URL to `/plannotator-review` and review it with the full diff viewer, annotations, and file tree. --- diff --git a/apps/marketing/src/content/docs/guides/notion-integration.md b/apps/marketing/src/content/docs/guides/notion-integration.md new file mode 100644 index 000000000..289a0a55f --- /dev/null +++ b/apps/marketing/src/content/docs/guides/notion-integration.md @@ -0,0 +1,36 @@ +--- +title: "Notion Integration" +description: "Save Plannotator plans as child pages in Notion." +sidebar: + order: 23 +section: "Guides" +--- + +Plannotator can save plans to Notion as child pages of a page you choose. The integration uses your local Notion integration token; Plannotator never stores the token in browser settings. + +## Setup + +1. Create an internal integration or personal access token in the [Notion developer portal](https://www.notion.com/developers). +2. Copy its token into the environment that starts Plannotator: + + ```bash + export NOTION_TOKEN="ntn_..." + ``` + +3. In Notion, open the page under which plans should be created and share it with your integration. +4. Open **Settings > Notion** in Plannotator, enable Notion, and paste the parent page URL or ID. + +The token persists through future sessions wherever you keep your environment secrets. It is read only by the local Plannotator server and is never sent from the browser. + +## Exporting + +Use **Export > Notes**, **Save to Notion** in the header menu, or set Notion as the default `Cmd/Ctrl+S` destination. You can also enable **Auto-save on Plan Arrival**. + +Each export creates a child page whose title is the plan's first H1. The plan body is sent as Notion-flavored Markdown. + +## Troubleshooting + +- `Set NOTION_TOKEN`: start Plannotator with `NOTION_TOKEN` defined. +- `token is invalid or revoked`: create or restore a valid Notion token. +- `Share the parent page`: add your Notion integration to the parent page's connections before exporting. +- Markdown extensions unique to Plannotator may not render exactly as they do in Plannotator. diff --git a/apps/marketing/src/content/docs/reference/api-endpoints.md b/apps/marketing/src/content/docs/reference/api-endpoints.md index b1511e93d..677a34efa 100644 --- a/apps/marketing/src/content/docs/reference/api-endpoints.md +++ b/apps/marketing/src/content/docs/reference/api-endpoints.md @@ -22,7 +22,7 @@ Used during plan review (`ExitPlanMode` hook). | `/api/image` | GET | Serve a local image by path query param | | `/api/upload` | POST | Upload an image, returns `{ path, originalName }` | | `/api/obsidian/vaults` | GET | Detect available Obsidian vaults | -| `/api/save-notes` | POST | Save plan to Obsidian/Bear on demand | +| `/api/save-notes` | POST | Save plan to Obsidian/Bear/Octarine/Notion on demand | | `/api/external-annotations/stream` | GET | SSE stream for real-time external annotations | | `/api/external-annotations` | GET | Snapshot of external annotations (`?since=N` for version gating) | | `/api/external-annotations` | POST | Add external annotations (single or batch) | @@ -54,6 +54,7 @@ Body: "planSave": { "enabled": true, "customPath": null }, "obsidian": { "vaultPath": "/path/to/vault", "folder": "plannotator", "plan": "..." }, "bear": { "plan": "..." }, + "notion": { "parentPageId": "01234567-89ab-cdef-0123-456789abcdef", "plan": "..." }, "feedback": "optional annotations if present" } ``` diff --git a/apps/pi-extension/server/handlers.ts b/apps/pi-extension/server/handlers.ts index b8e4f2ab4..2bdcad817 100644 --- a/apps/pi-extension/server/handlers.ts +++ b/apps/pi-extension/server/handlers.ts @@ -17,9 +17,11 @@ import { type IntegrationResult, type ObsidianConfig, type OctarineConfig, + type NotionConfig, saveToBear, saveToObsidian, saveToOctarine, + saveToNotion, } from "./integrations.js"; type Res = import("node:http").ServerResponse; @@ -244,6 +246,7 @@ export async function handleSaveNotesRequest( obsidian?: IntegrationResult; bear?: IntegrationResult; octarine?: IntegrationResult; + notion?: IntegrationResult; } = {}; try { const body = await parseBody(req); @@ -251,6 +254,7 @@ export async function handleSaveNotesRequest( const obsConfig = body.obsidian as ObsidianConfig | undefined; const bearConfig = body.bear as BearConfig | undefined; const octConfig = body.octarine as OctarineConfig | undefined; + const notionConfig = body.notion as NotionConfig | undefined; if (obsConfig?.vaultPath && obsConfig?.plan) { promises.push( saveToObsidian(obsConfig).then((r) => { @@ -272,6 +276,13 @@ export async function handleSaveNotesRequest( }), ); } + if (notionConfig?.plan && notionConfig?.parentPageId) { + promises.push( + saveToNotion(notionConfig).then((r) => { + results.notion = r; + }), + ); + } await Promise.allSettled(promises); for (const [name, result] of Object.entries(results)) { if (!result?.success && result) diff --git a/apps/pi-extension/server/integrations.ts b/apps/pi-extension/server/integrations.ts index a68bcb303..b0ebf7596 100644 --- a/apps/pi-extension/server/integrations.ts +++ b/apps/pi-extension/server/integrations.ts @@ -1,5 +1,5 @@ /** - * Note-taking app integrations (Obsidian, Bear, Octarine). + * Note-taking app integrations (Obsidian, Bear, Octarine, Notion). * Node.js equivalents of packages/server/integrations.ts. * Config types, save functions, tag extraction, filename generation */ @@ -12,6 +12,7 @@ import { type ObsidianConfig, type BearConfig, type OctarineConfig, + type NotionConfig, type IntegrationResult, extractTitle, generateFrontmatter, @@ -25,7 +26,7 @@ import { import { sanitizeTag } from "../generated/project.js"; import { resolveUserPath } from "../generated/resolve-file.js"; -export type { ObsidianConfig, BearConfig, OctarineConfig, IntegrationResult }; +export type { ObsidianConfig, BearConfig, OctarineConfig, NotionConfig, IntegrationResult }; export { extractTitle, generateFrontmatter, @@ -193,3 +194,45 @@ export async function saveToOctarine( }; } } + +const NOTION_API_VERSION = "2026-03-11"; + +export async function saveToNotion( + config: NotionConfig, +): Promise { + const token = process.env.NOTION_TOKEN?.trim(); + if (!token) { + return { success: false, error: "Notion is not configured. Set NOTION_TOKEN." }; + } + const parentPageId = config.parentPageId.trim(); + if (!parentPageId) { + return { success: false, error: "Notion parent page is required." }; + } + + try { + const response = await fetch("https://api.notion.com/v1/pages", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "Notion-Version": NOTION_API_VERSION, + }, + body: JSON.stringify({ + parent: { page_id: parentPageId }, + properties: { title: { title: [{ text: { content: extractTitle(config.plan) } }] } }, + markdown: config.plan, + }), + }); + const data = await response.json().catch(() => null) as { message?: unknown; url?: unknown } | null; + if (!response.ok) { + const detail = typeof data?.message === "string" ? ` ${data.message}` : ""; + if (response.status === 401) return { success: false, error: `Notion token is invalid or revoked.${detail}` }; + if (response.status === 403) return { success: false, error: `Notion cannot access this page. Share the parent page with your Notion integration.${detail}` }; + if (response.status === 429) return { success: false, error: `Notion rate limit exceeded.${detail}` }; + return { success: false, error: `Notion export failed (${response.status}).${detail}` }; + } + return { success: true, ...(typeof data?.url === "string" ? { url: data.url } : {}) }; + } catch (err) { + return { success: false, error: err instanceof Error ? `Notion export failed: ${err.message}` : "Notion export failed." }; + } +} diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index 16f7dee07..c9116173e 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -33,9 +33,11 @@ import { type IntegrationResult, type ObsidianConfig, type OctarineConfig, + type NotionConfig, saveToBear, saveToObsidian, saveToOctarine, + saveToNotion, } from "./integrations.js"; import { listenOnPort } from "./network.js"; @@ -357,6 +359,7 @@ export async function startPlanReviewServer(options: { const obsConfig = body.obsidian as ObsidianConfig | undefined; const bearConfig = body.bear as BearConfig | undefined; const octConfig = body.octarine as OctarineConfig | undefined; + const notionConfig = body.notion as NotionConfig | undefined; if (obsConfig?.vaultPath && obsConfig?.plan) { integrationPromises.push( saveToObsidian(obsConfig).then((r) => { @@ -378,6 +381,13 @@ export async function startPlanReviewServer(options: { }), ); } + if (notionConfig?.plan && notionConfig?.parentPageId) { + integrationPromises.push( + saveToNotion(notionConfig).then((r) => { + integrationResults.notion = r; + }), + ); + } await Promise.allSettled(integrationPromises); for (const [name, result] of Object.entries(integrationResults)) { if (!result?.success && result) diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 056126839..899f51670 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -32,6 +32,7 @@ import { LookAndFeelAnnouncementDialog } from '@plannotator/ui/components/LookAn import { getObsidianSettings, getEffectiveVaultPath, isObsidianConfigured, CUSTOM_PATH_SENTINEL } from '@plannotator/ui/utils/obsidian'; import { getBearSettings } from '@plannotator/ui/utils/bear'; import { getOctarineSettings, isOctarineConfigured } from '@plannotator/ui/utils/octarine'; +import { getNotionSettings, isNotionConfigured, normalizeNotionPageId } from '@plannotator/ui/utils/notion'; import { getDefaultNotesApp } from '@plannotator/ui/utils/defaultNotesApp'; import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch'; import { getPlanSaveSettings } from '@plannotator/ui/utils/planSave'; @@ -154,6 +155,7 @@ type NoteAutoSaveResults = { obsidian?: boolean; bear?: boolean; octarine?: boolean; + notion?: boolean; }; type MessageAnnotationState = { @@ -2382,7 +2384,7 @@ const App: React.FC = () => { if (!isApiMode || !markdown || isSharedSession || annotateMode || archive.archiveMode) return; if (autoSaveAttempted.current) return; - const body: { obsidian?: object; bear?: object; octarine?: object } = {}; + const body: { obsidian?: object; bear?: object; octarine?: object; notion?: object } = {}; const targets: string[] = []; const obsSettings = getObsidianSettings(); @@ -2420,6 +2422,13 @@ const App: React.FC = () => { targets.push('Octarine'); } + const notionSettings = getNotionSettings(); + const parentPageId = normalizeNotionPageId(notionSettings.parentPageId); + if (notionSettings.autoSave && notionSettings.enabled && parentPageId) { + body.notion = { plan: markdown, parentPageId }; + targets.push('Notion'); + } + if (targets.length === 0) return; autoSaveAttempted.current = true; @@ -2434,6 +2443,7 @@ const App: React.FC = () => { ...(body.obsidian ? { obsidian: Boolean(data.results?.obsidian?.success) } : {}), ...(body.bear ? { bear: Boolean(data.results?.bear?.success) } : {}), ...(body.octarine ? { octarine: Boolean(data.results?.octarine?.success) } : {}), + ...(body.notion ? { notion: Boolean(data.results?.notion?.success) } : {}), }; autoSaveResultsRef.current = results; @@ -2597,13 +2607,14 @@ const App: React.FC = () => { const obsidianSettings = getObsidianSettings(); const bearSettings = getBearSettings(); const octarineSettings = getOctarineSettings(); + const notionSettings = getNotionSettings(); const planSaveSettings = getPlanSaveSettings(); - const autoSaveResults = bearSettings.autoSave && autoSavePromiseRef.current + const autoSaveResults = (bearSettings.autoSave || notionSettings.autoSave) && autoSavePromiseRef.current ? await autoSavePromiseRef.current : autoSaveResultsRef.current; // Build request body - include integrations if enabled - const body: { draftGeneration: number; obsidian?: object; bear?: object; octarine?: object; feedback?: string; agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string } = { + const body: { draftGeneration: number; obsidian?: object; bear?: object; octarine?: object; notion?: object; feedback?: string; agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string } = { draftGeneration: getDraftGeneration(), }; @@ -2652,6 +2663,11 @@ const App: React.FC = () => { }; } + const parentPageId = normalizeNotionPageId(notionSettings.parentPageId); + if (notionSettings.enabled && parentPageId && !(notionSettings.autoSave && autoSaveResults.notion)) { + body.notion = { plan: currentMarkdown, parentPageId }; + } + // Include annotations as feedback if any exist (for OpenCode "approve with notes"). // Direct edits count as feedback too — without the editsSection check here, // an edit-only approval would silently drop the user's changes. @@ -3315,8 +3331,8 @@ const App: React.FC = () => { toast.success('Downloaded annotations'); }; - const handleQuickSaveToNotes = async (target: 'obsidian' | 'bear' | 'octarine') => { - const body: { obsidian?: object; bear?: object; octarine?: object } = {}; + const handleQuickSaveToNotes = async (target: 'obsidian' | 'bear' | 'octarine' | 'notion') => { + const body: { obsidian?: object; bear?: object; octarine?: object; notion?: object } = {}; // Mid-edit saves describe the live buffer, matching handleApprove. const quickSaveMarkdown = isEditingMarkdown ? markdownEditorHandleRef.current?.getMarkdown() ?? displayedMarkdown @@ -3352,7 +3368,13 @@ const App: React.FC = () => { }; } - const targetName = target === 'obsidian' ? 'Obsidian' : target === 'bear' ? 'Bear' : 'Octarine'; + if (target === 'notion') { + const ns = getNotionSettings(); + const parentPageId = normalizeNotionPageId(ns.parentPageId); + if (parentPageId) body.notion = { plan: quickSaveMarkdown, parentPageId }; + } + + const targetName = target === 'obsidian' ? 'Obsidian' : target === 'bear' ? 'Bear' : target === 'octarine' ? 'Octarine' : 'Notion'; try { const res = await fetch('/api/save-notes', { method: 'POST', @@ -3606,6 +3628,7 @@ const App: React.FC = () => { const obsOk = isObsidianConfigured(); const bearOk = getBearSettings().enabled; const octOk = isOctarineConfigured(); + const notionOk = isNotionConfigured(); if (defaultApp === 'download') { handleDownloadAnnotations(); @@ -3615,6 +3638,8 @@ const App: React.FC = () => { handleQuickSaveToNotes('bear'); } else if (defaultApp === 'octarine' && octOk) { handleQuickSaveToNotes('octarine'); + } else if (defaultApp === 'notion' && notionOk) { + handleQuickSaveToNotes('notion'); } else { setInitialExportTab('notes'); setShowExport(true); @@ -3765,6 +3790,7 @@ const App: React.FC = () => { const handleOpenImport = useCallback(() => setShowImport(true), []); const handleSaveToObsidian = useCallback(() => headerHandlersRef.current.handleQuickSaveToNotes('obsidian'), []); const handleSaveToOctarine = useCallback(() => headerHandlersRef.current.handleQuickSaveToNotes('octarine'), []); + const handleSaveToNotion = useCallback(() => headerHandlersRef.current.handleQuickSaveToNotes('notion'), []); const handleSaveToBear = useCallback(() => headerHandlersRef.current.handleQuickSaveToNotes('bear'), []); const planMaxWidth = useMemo(() => { @@ -3875,6 +3901,7 @@ const App: React.FC = () => { onSaveToObsidian={handleSaveToObsidian} onSaveToBear={handleSaveToBear} onSaveToOctarine={handleSaveToOctarine} + onSaveToNotion={handleSaveToNotion} appVersion={typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'} updateInfo={updateInfo} isWSL={isWSL} @@ -3882,6 +3909,7 @@ const App: React.FC = () => { obsidianConfigured={isObsidianConfigured()} bearConfigured={getBearSettings().enabled} octarineConfigured={isOctarineConfigured()} + notionConfigured={isNotionConfigured()} /> {/* Linked document error banner */} diff --git a/packages/editor/components/AppHeader.tsx b/packages/editor/components/AppHeader.tsx index 24a1b8639..b976bf825 100644 --- a/packages/editor/components/AppHeader.tsx +++ b/packages/editor/components/AppHeader.tsx @@ -80,6 +80,7 @@ interface AppHeaderProps { onSaveToObsidian: () => void; onSaveToBear: () => void; onSaveToOctarine: () => void; + onSaveToNotion: () => void; // PlanHeaderMenu config appVersion: string; @@ -89,6 +90,7 @@ interface AppHeaderProps { obsidianConfigured: boolean; bearConfigured: boolean; octarineConfigured: boolean; + notionConfigured: boolean; } export const AppHeader = React.memo(({ @@ -150,6 +152,7 @@ export const AppHeader = React.memo(({ onSaveToObsidian, onSaveToBear, onSaveToOctarine, + onSaveToNotion, appVersion, updateInfo, isWSL, @@ -157,6 +160,7 @@ export const AppHeader = React.memo(({ obsidianConfigured, bearConfigured, octarineConfigured, + notionConfigured, }) => { return (
@@ -367,12 +371,14 @@ export const AppHeader = React.memo(({ onSaveToObsidian={onSaveToObsidian} onSaveToBear={onSaveToBear} onSaveToOctarine={onSaveToOctarine} + onSaveToNotion={onSaveToNotion} sharingEnabled={canShareCurrentSession} isApiMode={isApiMode} agentInstructionsEnabled={agentInstructionsEnabled} obsidianConfigured={!goalSetupMode && obsidianConfigured} bearConfigured={!goalSetupMode && bearConfigured} octarineConfigured={!goalSetupMode && octarineConfigured} + notionConfigured={!goalSetupMode && notionConfigured} />
diff --git a/packages/server/index.ts b/packages/server/index.ts index 218b3bb3e..342490ec2 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -20,9 +20,11 @@ import { saveToObsidian, saveToBear, saveToOctarine, + saveToNotion, type ObsidianConfig, type BearConfig, type OctarineConfig, + type NotionConfig, type IntegrationResult, } from "./integrations"; import { @@ -453,6 +455,7 @@ export async function startPlannotatorServer( obsidian?: ObsidianConfig; bear?: BearConfig; octarine?: OctarineConfig; + notion?: NotionConfig; feedback?: string; agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; @@ -494,6 +497,9 @@ export async function startPlannotatorServer( if (body.octarine?.plan && body.octarine?.workspace) { integrationPromises.push(saveToOctarine(body.octarine).then(r => { integrationResults.octarine = r; })); } + if (body.notion?.plan && body.notion?.parentPageId) { + integrationPromises.push(saveToNotion(body.notion).then(r => { integrationResults.notion = r; })); + } await Promise.allSettled(integrationPromises); for (const [name, result] of Object.entries(integrationResults)) { diff --git a/packages/server/integrations.test.ts b/packages/server/integrations.test.ts index 22cf81350..08acc27b8 100644 --- a/packages/server/integrations.test.ts +++ b/packages/server/integrations.test.ts @@ -13,6 +13,8 @@ import { buildHashtags, buildBearContent, saveToObsidian, + saveToNotion, + NOTION_API_VERSION, } from "./integrations"; describe("extractTitle", () => { @@ -209,3 +211,69 @@ describe("saveToObsidian", () => { expect(result.error).toBeString(); }); }); + +describe("saveToNotion", () => { + const originalToken = process.env.NOTION_TOKEN; + const originalFetch = globalThis.fetch; + + function restore(): void { + if (originalToken === undefined) delete process.env.NOTION_TOKEN; + else process.env.NOTION_TOKEN = originalToken; + globalThis.fetch = originalFetch; + } + + test("requires a token without making a request", async () => { + delete process.env.NOTION_TOKEN; + try { + const result = await saveToNotion({ plan: "# Test Plan", parentPageId: "page-id" }); + expect(result).toEqual({ success: false, error: "Notion is not configured. Set NOTION_TOKEN." }); + } finally { + restore(); + } + }); + + test("creates a markdown child page without exposing the token", async () => { + process.env.NOTION_TOKEN = "secret-token"; + let request: Request | undefined; + globalThis.fetch = async (input, init) => { + request = new Request(input, init); + return Response.json({ url: "https://www.notion.so/test-page" }); + }; + + try { + const result = await saveToNotion({ + plan: "# Implementation Plan: Test Export\n\nContent", + parentPageId: "parent-page-id", + }); + + expect(result).toEqual({ success: true, url: "https://www.notion.so/test-page" }); + expect(request?.url).toBe("https://api.notion.com/v1/pages"); + expect(request?.headers.get("Authorization")).toBe("Bearer secret-token"); + expect(request?.headers.get("Notion-Version")).toBe(NOTION_API_VERSION); + expect(await request?.json()).toEqual({ + parent: { page_id: "parent-page-id" }, + properties: { title: { title: [{ text: { content: "Test Export" } }] } }, + markdown: "# Implementation Plan: Test Export\n\nContent", + }); + } finally { + restore(); + } + }); + + test("explains a page access failure without leaking the token", async () => { + process.env.NOTION_TOKEN = "secret-token"; + globalThis.fetch = async () => Response.json( + { message: "Could not find page" }, + { status: 403 }, + ); + + try { + const result = await saveToNotion({ plan: "# Test Plan", parentPageId: "page-id" }); + expect(result.success).toBe(false); + expect(result.error).toContain("Share the parent page"); + expect(result.error).not.toContain("secret-token"); + } finally { + restore(); + } + }); +}); diff --git a/packages/server/integrations.ts b/packages/server/integrations.ts index 1f7b4b770..42e08abd6 100644 --- a/packages/server/integrations.ts +++ b/packages/server/integrations.ts @@ -1,5 +1,5 @@ /** - * Note-taking app integrations (Obsidian, Bear) + * Note-taking app integrations (Obsidian, Bear, Octarine, Notion) */ import { $ } from "bun"; @@ -11,6 +11,7 @@ import { type ObsidianConfig, type BearConfig, type OctarineConfig, + type NotionConfig, type IntegrationResult, extractTitle, generateFrontmatter, @@ -23,7 +24,7 @@ import { } from "@plannotator/shared/integrations-common"; import { resolveUserPath } from "@plannotator/shared/resolve-file"; -export type { ObsidianConfig, BearConfig, OctarineConfig, IntegrationResult }; +export type { ObsidianConfig, BearConfig, OctarineConfig, NotionConfig, IntegrationResult }; export { detectObsidianVaults, extractTitle, generateFrontmatter, generateFilename, generateOctarineFrontmatter, stripH1, buildHashtags, buildBearContent }; /** @@ -212,3 +213,54 @@ export async function saveToOctarine( return { success: false, error: message }; } } + +export const NOTION_API_VERSION = "2026-03-11"; + +/** Save a plan as a child page of a Notion page using the user's local token. */ +export async function saveToNotion( + config: NotionConfig, +): Promise { + const token = process.env.NOTION_TOKEN?.trim(); + if (!token) { + return { success: false, error: "Notion is not configured. Set NOTION_TOKEN." }; + } + + const parentPageId = config.parentPageId.trim(); + if (!parentPageId) { + return { success: false, error: "Notion parent page is required." }; + } + + try { + const response = await fetch("https://api.notion.com/v1/pages", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "Notion-Version": NOTION_API_VERSION, + }, + body: JSON.stringify({ + parent: { page_id: parentPageId }, + properties: { + title: { title: [{ text: { content: extractTitle(config.plan) } }] }, + }, + markdown: config.plan, + }), + }); + + const data = await response.json().catch(() => null) as { message?: unknown; url?: unknown } | null; + if (!response.ok) { + const detail = typeof data?.message === "string" ? ` ${data.message}` : ""; + if (response.status === 401) return { success: false, error: `Notion token is invalid or revoked.${detail}` }; + if (response.status === 403) return { success: false, error: `Notion cannot access this page. Share the parent page with your Notion integration.${detail}` }; + if (response.status === 429) return { success: false, error: `Notion rate limit exceeded.${detail}` }; + return { success: false, error: `Notion export failed (${response.status}).${detail}` }; + } + + return { success: true, ...(typeof data?.url === "string" ? { url: data.url } : {}) }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? `Notion export failed: ${err.message}` : "Notion export failed.", + }; + } +} diff --git a/packages/server/shared-handlers.test.ts b/packages/server/shared-handlers.test.ts index 2856c46e2..5fb054d85 100644 --- a/packages/server/shared-handlers.test.ts +++ b/packages/server/shared-handlers.test.ts @@ -69,6 +69,30 @@ describe("handleSaveNotes", () => { expect(json.results.obsidian).toHaveProperty("error"); }); + test("dispatches a Notion export without exposing its token", async () => { + const originalToken = process.env.NOTION_TOKEN; + const originalFetch = globalThis.fetch; + process.env.NOTION_TOKEN = "secret-token"; + globalThis.fetch = async () => Response.json({ url: "https://www.notion.so/saved-plan" }); + + try { + const response = await handleSaveNotes( + saveNotesRequest({ + notion: { plan: "# Test Plan", parentPageId: "parent-page-id" }, + }), + ); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.results.notion).toEqual({ success: true, url: "https://www.notion.so/saved-plan" }); + expect(JSON.stringify(json)).not.toContain("secret-token"); + } finally { + globalThis.fetch = originalFetch; + if (originalToken === undefined) delete process.env.NOTION_TOKEN; + else process.env.NOTION_TOKEN = originalToken; + } + }); + test("an unparseable body returns a 500 JSON error (not SPA HTML)", async () => { const badRequest = new Request("http://localhost/api/save-notes", { method: "POST", diff --git a/packages/server/shared-handlers.ts b/packages/server/shared-handlers.ts index 1859b6c82..ed1c62010 100644 --- a/packages/server/shared-handlers.ts +++ b/packages/server/shared-handlers.ts @@ -12,8 +12,8 @@ import { openBrowser as openBrowserImpl } from "./browser"; import { validateImagePath, validateUploadExtension, UPLOAD_DIR } from "./image"; import { saveDraft, loadDraft, deleteDraft, getDraftGeneration } from "./draft"; import { FAVICON_PNG_BYTES } from "@plannotator/shared/favicon"; -import { saveToObsidian, saveToBear, saveToOctarine } from "./integrations"; -import type { ObsidianConfig, BearConfig, OctarineConfig, IntegrationResult } from "./integrations"; +import { saveToObsidian, saveToBear, saveToOctarine, saveToNotion } from "./integrations"; +import type { ObsidianConfig, BearConfig, OctarineConfig, NotionConfig, IntegrationResult } from "./integrations"; function normalizeDraftGeneration(value: unknown): number | undefined { if (typeof value !== "number") return undefined; @@ -228,15 +228,16 @@ export async function handleServerReady( } } -/** Save to external note apps (Obsidian, Bear, Octarine). Used by plan + annotate servers. */ +/** Save to external note apps. Used by plan + annotate servers. */ export async function handleSaveNotes(req: Request): Promise { - const results: { obsidian?: IntegrationResult; bear?: IntegrationResult; octarine?: IntegrationResult } = {}; + const results: { obsidian?: IntegrationResult; bear?: IntegrationResult; octarine?: IntegrationResult; notion?: IntegrationResult } = {}; try { const body = (await req.json()) as { obsidian?: ObsidianConfig; bear?: BearConfig; octarine?: OctarineConfig; + notion?: NotionConfig; }; const promises: Promise[] = []; @@ -249,6 +250,9 @@ export async function handleSaveNotes(req: Request): Promise { if (body.octarine?.plan && body.octarine?.workspace) { promises.push(saveToOctarine(body.octarine).then(r => { results.octarine = r; })); } + if (body.notion?.plan && body.notion?.parentPageId) { + promises.push(saveToNotion(body.notion).then(r => { results.notion = r; })); + } await Promise.allSettled(promises); for (const [name, result] of Object.entries(results)) { diff --git a/packages/shared/integrations-common.ts b/packages/shared/integrations-common.ts index 50dd6a647..afe5f264c 100644 --- a/packages/shared/integrations-common.ts +++ b/packages/shared/integrations-common.ts @@ -23,10 +23,16 @@ export interface OctarineConfig { folder: string; } +export interface NotionConfig { + plan: string; + parentPageId: string; +} + export interface IntegrationResult { success: boolean; error?: string; path?: string; + url?: string; } /** diff --git a/packages/ui/components/ExportModal.tsx b/packages/ui/components/ExportModal.tsx index 3d1d28cd7..2c2b520ea 100644 --- a/packages/ui/components/ExportModal.tsx +++ b/packages/ui/components/ExportModal.tsx @@ -10,6 +10,7 @@ import React, { useState, useEffect } from 'react'; import { getObsidianSettings, getEffectiveVaultPath } from '../utils/obsidian'; import { getBearSettings } from '../utils/bear'; import { getOctarineSettings } from '../utils/octarine'; +import { getNotionSettings, normalizeNotionPageId } from '../utils/notion'; import { wrapFeedbackForAgent } from '../utils/parser'; import { OverlayScrollArea } from './OverlayScrollArea'; @@ -18,11 +19,12 @@ interface SaveToNotesPayload { obsidian?: object; bear?: object; octarine?: object; + notion?: object; } /** Parsed response from the notes endpoint. */ interface SaveToNotesResult { - results?: Record; + results?: Record; } /** Default save-to-notes wire: today's literal POST to /api/save-notes. */ @@ -61,7 +63,7 @@ interface ExportModalProps { type Tab = 'share' | 'annotations' | 'notes'; -type SaveTarget = 'obsidian' | 'bear' | 'octarine'; +type SaveTarget = 'obsidian' | 'bear' | 'octarine' | 'notion'; type SaveStatus = 'idle' | 'saving' | 'success' | 'error'; export const ExportModal: React.FC = ({ @@ -85,8 +87,9 @@ export const ExportModal: React.FC = ({ const defaultTab = initialTab || (sharingEnabled ? 'share' : 'annotations'); const [activeTab, setActiveTab] = useState(defaultTab); const [copied, setCopied] = useState<'short' | 'full' | 'annotations' | false>(false); - const [saveStatus, setSaveStatus] = useState>({ obsidian: 'idle', bear: 'idle', octarine: 'idle' }); + const [saveStatus, setSaveStatus] = useState>({ obsidian: 'idle', bear: 'idle', octarine: 'idle', notion: 'idle' }); const [saveErrors, setSaveErrors] = useState>({}); + const [savedUrls, setSavedUrls] = useState>>({}); // Reset tab when modal opens useEffect(() => { @@ -98,8 +101,9 @@ export const ExportModal: React.FC = ({ // Reset save status when modal opens useEffect(() => { if (isOpen) { - setSaveStatus({ obsidian: 'idle', bear: 'idle', octarine: 'idle' }); + setSaveStatus({ obsidian: 'idle', bear: 'idle', octarine: 'idle', notion: 'idle' }); setSaveErrors({}); + setSavedUrls({}); } }, [isOpen]); @@ -109,10 +113,13 @@ export const ExportModal: React.FC = ({ const obsidianSettings = getObsidianSettings(); const bearSettings = getBearSettings(); const octarineSettings = getOctarineSettings(); + const notionSettings = getNotionSettings(); const effectiveVaultPath = getEffectiveVaultPath(obsidianSettings); const isObsidianReady = obsidianSettings.enabled && effectiveVaultPath.trim().length > 0; const isBearReady = bearSettings.enabled; const isOctarineReady = octarineSettings.enabled && octarineSettings.workspace.trim().length > 0; + const notionParentPageId = normalizeNotionPageId(notionSettings.parentPageId); + const isNotionReady = notionSettings.enabled && notionParentPageId !== null; const handleCopy = async (text: string, which: 'short' | 'full' | 'annotations') => { try { @@ -149,7 +156,7 @@ export const ExportModal: React.FC = ({ setSaveStatus(prev => ({ ...prev, [target]: 'saving' })); setSaveErrors(prev => { const next = { ...prev }; delete next[target]; return next; }); - const body: { obsidian?: object; bear?: object; octarine?: object } = {}; + const body: { obsidian?: object; bear?: object; octarine?: object; notion?: object } = {}; if (target === 'obsidian') { body.obsidian = { @@ -170,6 +177,9 @@ export const ExportModal: React.FC = ({ folder: octarineSettings.folder || 'plannotator', }; } + if (target === 'notion' && notionParentPageId) { + body.notion = { plan: markdown, parentPageId: notionParentPageId }; + } try { const data = await onSaveToNotes(body); @@ -177,6 +187,7 @@ export const ExportModal: React.FC = ({ if (result?.success) { setSaveStatus(prev => ({ ...prev, [target]: 'success' })); + if (result.url) setSavedUrls(prev => ({ ...prev, [target]: result.url! })); } else { setSaveStatus(prev => ({ ...prev, [target]: 'error' })); setSaveErrors(prev => ({ ...prev, [target]: result?.error || 'Save failed' })); @@ -192,10 +203,11 @@ export const ExportModal: React.FC = ({ if (isObsidianReady) targets.push('obsidian'); if (isBearReady) targets.push('bear'); if (isOctarineReady) targets.push('octarine'); + if (isNotionReady) targets.push('notion'); await Promise.all(targets.map(t => handleSaveToNotes(t))); }; - const readyCount = [isObsidianReady, isBearReady, isOctarineReady].filter(Boolean).length; + const readyCount = [isObsidianReady, isBearReady, isOctarineReady, isNotionReady].filter(Boolean).length; // Determine which tabs to show const showTabs = sharingEnabled || showNotesTab; @@ -521,12 +533,67 @@ export const ExportModal: React.FC = ({ )} + {/* Notion */} +
+
+
+ + Notion +
+ {isNotionReady ? ( + + ) : ( + Not configured + )} +
+ {isNotionReady && ( +
+ Parent page: {notionParentPageId} +
+ )} + {savedUrls.notion && ( + + Open saved page + + )} + {!isNotionReady && ( +
+ Enable in Settings > Saving > Notion +
+ )} + {saveErrors.notion && ( +
{saveErrors.notion}
+ )} +
+ {/* Save All button */} {readyCount >= 2 && (
@@ -2184,6 +2202,66 @@ tags: [plan, ...] )} + {/* === NOTION TAB === */} + {activeTab === 'notion' && ( + <> +
+
+
Notion
+
Save plans as child pages in Notion
+
+ +
+ {notion.enabled && ( +
+
+ + handleNotionChange({ parentPageId: e.target.value })} + onBlur={(e) => { + const parentPageId = normalizeNotionPageId(e.target.value); + if (parentPageId) handleNotionChange({ parentPageId }); + }} + placeholder="https://www.notion.so/..." + className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50" + /> +
+ Share this parent page with your Notion integration before exporting. +
+
+
+
Set NOTION_TOKEN in the environment that starts Plannotator.
+
The token is never stored in Plannotator settings or sent by your browser.
+
+
+
+
+
Auto-save on Plan Arrival
+
Automatically create a Notion page when a plan loads, before you approve or deny.
+
+ +
+
+ )} + + )} +
diff --git a/packages/ui/utils/defaultNotesApp.ts b/packages/ui/utils/defaultNotesApp.ts index aa944bc73..e5934a90b 100644 --- a/packages/ui/utils/defaultNotesApp.ts +++ b/packages/ui/utils/defaultNotesApp.ts @@ -9,7 +9,7 @@ import { storage } from './storage'; const STORAGE_KEY = 'plannotator-default-notes-app'; -export type DefaultNotesApp = 'obsidian' | 'bear' | 'octarine' | 'download' | 'ask'; +export type DefaultNotesApp = 'obsidian' | 'bear' | 'octarine' | 'notion' | 'download' | 'ask'; export function getDefaultNotesApp(): DefaultNotesApp { return (storage.getItem(STORAGE_KEY) as DefaultNotesApp) || 'ask'; diff --git a/packages/ui/utils/notion.test.ts b/packages/ui/utils/notion.test.ts new file mode 100644 index 000000000..0092b9db1 --- /dev/null +++ b/packages/ui/utils/notion.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from 'bun:test'; +import { normalizeNotionPageId } from './notion'; + +describe('normalizeNotionPageId', () => { + test('normalizes a compact page ID', () => { + expect(normalizeNotionPageId('0123456789abcdef0123456789abcdef')).toBe('01234567-89ab-cdef-0123-456789abcdef'); + }); + + test('extracts a page ID from a Notion URL', () => { + expect(normalizeNotionPageId('https://www.notion.so/My-page-0123456789abcdef0123456789abcdef')).toBe('01234567-89ab-cdef-0123-456789abcdef'); + }); + + test('rejects values without a Notion page ID', () => { + expect(normalizeNotionPageId('not a page')).toBeNull(); + }); +}); diff --git a/packages/ui/utils/notion.ts b/packages/ui/utils/notion.ts new file mode 100644 index 000000000..91c7a107f --- /dev/null +++ b/packages/ui/utils/notion.ts @@ -0,0 +1,38 @@ +import { storage } from './storage'; + +const STORAGE_KEY_ENABLED = 'plannotator-notion-enabled'; +const STORAGE_KEY_PARENT_PAGE_ID = 'plannotator-notion-parent-page-id'; +const STORAGE_KEY_AUTOSAVE = 'plannotator-notion-autosave'; + +export interface NotionSettings { + enabled: boolean; + parentPageId: string; + autoSave: boolean; +} + +/** Extract and normalize a Notion page UUID from either an ID or a page URL. */ +export function normalizeNotionPageId(value: string): string | null { + const match = value.trim().match(/[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}/i); + if (!match) return null; + const compact = match[0].replace(/-/g, '').toLowerCase(); + return `${compact.slice(0, 8)}-${compact.slice(8, 12)}-${compact.slice(12, 16)}-${compact.slice(16, 20)}-${compact.slice(20)}`; +} + +export function getNotionSettings(): NotionSettings { + return { + enabled: storage.getItem(STORAGE_KEY_ENABLED) === 'true', + parentPageId: storage.getItem(STORAGE_KEY_PARENT_PAGE_ID) ?? '', + autoSave: storage.getItem(STORAGE_KEY_AUTOSAVE) === 'true', + }; +} + +export function saveNotionSettings(settings: NotionSettings): void { + storage.setItem(STORAGE_KEY_ENABLED, String(settings.enabled)); + storage.setItem(STORAGE_KEY_PARENT_PAGE_ID, settings.parentPageId); + storage.setItem(STORAGE_KEY_AUTOSAVE, String(settings.autoSave)); +} + +export function isNotionConfigured(): boolean { + const settings = getNotionSettings(); + return settings.enabled && normalizeNotionPageId(settings.parentPageId) !== null; +}