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; +}