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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
36 changes: 36 additions & 0 deletions apps/marketing/src/content/docs/guides/notion-integration.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion apps/marketing/src/content/docs/reference/api-endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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"
}
```
Expand Down
11 changes: 11 additions & 0 deletions apps/pi-extension/server/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -244,13 +246,15 @@ export async function handleSaveNotesRequest(
obsidian?: IntegrationResult;
bear?: IntegrationResult;
octarine?: IntegrationResult;
notion?: 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;
const notionConfig = body.notion as NotionConfig | undefined;
if (obsConfig?.vaultPath && obsConfig?.plan) {
promises.push(
saveToObsidian(obsConfig).then((r) => {
Expand All @@ -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)
Expand Down
47 changes: 45 additions & 2 deletions apps/pi-extension/server/integrations.ts
Original file line number Diff line number Diff line change
@@ -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
*/
Expand All @@ -12,6 +12,7 @@ import {
type ObsidianConfig,
type BearConfig,
type OctarineConfig,
type NotionConfig,
type IntegrationResult,
extractTitle,
generateFrontmatter,
Expand All @@ -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,
Expand Down Expand Up @@ -193,3 +194,45 @@ export async function saveToOctarine(
};
}
}

const NOTION_API_VERSION = "2026-03-11";

export async function saveToNotion(
config: NotionConfig,
): Promise<IntegrationResult> {
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." };
}
}
10 changes: 10 additions & 0 deletions apps/pi-extension/server/serverPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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) => {
Expand All @@ -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)
Expand Down
40 changes: 34 additions & 6 deletions packages/editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -154,6 +155,7 @@ type NoteAutoSaveResults = {
obsidian?: boolean;
bear?: boolean;
octarine?: boolean;
notion?: boolean;
};

type MessageAnnotationState = {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;

Expand All @@ -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;

Expand Down Expand Up @@ -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(),
};

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -3875,13 +3901,15 @@ 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}
agentInstructionsEnabled={isApiMode && !archive.archiveMode && !annotateMode && !goalSetupMode}
obsidianConfigured={isObsidianConfigured()}
bearConfigured={getBearSettings().enabled}
octarineConfigured={isOctarineConfigured()}
notionConfigured={isNotionConfigured()}
/>

{/* Linked document error banner */}
Expand Down
6 changes: 6 additions & 0 deletions packages/editor/components/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ interface AppHeaderProps {
onSaveToObsidian: () => void;
onSaveToBear: () => void;
onSaveToOctarine: () => void;
onSaveToNotion: () => void;

// PlanHeaderMenu config
appVersion: string;
Expand All @@ -89,6 +90,7 @@ interface AppHeaderProps {
obsidianConfigured: boolean;
bearConfigured: boolean;
octarineConfigured: boolean;
notionConfigured: boolean;
}

export const AppHeader = React.memo<AppHeaderProps>(({
Expand Down Expand Up @@ -150,13 +152,15 @@ export const AppHeader = React.memo<AppHeaderProps>(({
onSaveToObsidian,
onSaveToBear,
onSaveToOctarine,
onSaveToNotion,
appVersion,
updateInfo,
isWSL,
agentInstructionsEnabled,
obsidianConfigured,
bearConfigured,
octarineConfigured,
notionConfigured,
}) => {
return (
<header data-app-header="true" className="h-12 flex items-center justify-between px-2 md:px-4 border-b border-border/50 bg-card/50 backdrop-blur-xl sticky top-0 z-[50]">
Expand Down Expand Up @@ -367,12 +371,14 @@ export const AppHeader = React.memo<AppHeaderProps>(({
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}
/>
</div>
</header>
Expand Down
Loading