/** * Export Modal with tabs for Share, Annotations, and Notes * * Share tab (default): Shows shareable URL with copy button * Annotations tab: Shows human-readable annotations output with copy/download * Notes tab: Save plan to Obsidian/Bear without approving */ import React, { useState, useEffect } from 'react'; import { getObsidianSettings, getEffectiveVaultPath } from '../utils/obsidian'; import { getBearSettings } from '../utils/bear'; import { getOctarineSettings } from '../utils/octarine'; import { wrapFeedbackForAgent } from '../utils/parser'; import { copyTextToClipboard } from '../utils/clipboard'; import { noteServerSessionMismatch, withServerSession } from '../utils/serverSession'; import { OverlayScrollArea } from './OverlayScrollArea'; /** POST body shape sent to the notes endpoint (mirrors what the Notes tab builds today). */ interface SaveToNotesPayload { obsidian?: object; bear?: object; octarine?: object; } /** Parsed response from the notes endpoint. */ interface SaveToNotesResult { results?: Record; } /** Default save-to-notes wire: today's literal POST to /api/save-notes. */ async function defaultSaveToNotes(body: SaveToNotesPayload): Promise { const res = await fetch('/api/save-notes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, // The stale-tab guard (a no-op for a host that never adopted a session). body: JSON.stringify(withServerSession(body)), }); if (await noteServerSessionMismatch(res)) { return { results: Object.fromEntries(Object.keys(body).map((k) => [k, { success: false, error: 'This review was replaced. Reload the page.' }])) }; } return res.json(); } interface ExportModalProps { isOpen: boolean; onClose: () => void; shareUrl: string; shareUrlSize: string; /** Short share URL from the paste service (empty string when unavailable) */ shortShareUrl?: string; /** Whether the short URL is currently being generated */ isGeneratingShortUrl?: boolean; /** Error from the last short URL generation attempt (empty string = no error) */ shortUrlError?: string; /** Generate a short URL on demand (user clicks "Create short link") */ onGenerateShortUrl?: () => void | Promise; annotationsOutput: string; annotationCount: number; taterSprite?: React.ReactNode; sharingEnabled?: boolean; markdown?: string; isApiMode?: boolean; initialTab?: Tab; /** Override the save-to-notes wire. Default: POST /api/save-notes (today's behavior). */ onSaveToNotes?: (payload: SaveToNotesPayload) => Promise; /** * Wrap the annotations output for the clipboard Copy button. Annotate * sessions pass a mode-aware wrapper so Copy matches Send Feedback instead * of the plan-deny framing (#1107). Default: plan-deny wrap (today's * plan-review behavior). */ wrapCopiedAnnotations?: (feedback: string) => string; } type Tab = 'share' | 'annotations' | 'notes'; type SaveTarget = 'obsidian' | 'bear' | 'octarine'; type SaveStatus = 'idle' | 'saving' | 'success' | 'error'; export const ExportModal: React.FC = ({ isOpen, onClose, shareUrl, shareUrlSize, shortShareUrl = '', isGeneratingShortUrl = false, shortUrlError = '', onGenerateShortUrl, annotationsOutput, annotationCount, taterSprite, sharingEnabled = true, markdown, isApiMode = false, initialTab, onSaveToNotes = defaultSaveToNotes, wrapCopiedAnnotations = wrapFeedbackForAgent, }) => { 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 [saveErrors, setSaveErrors] = useState>({}); // Reset tab when modal opens useEffect(() => { if (isOpen) { setActiveTab(initialTab || (sharingEnabled ? 'share' : 'annotations')); } }, [isOpen, initialTab, sharingEnabled]); // Reset save status when modal opens useEffect(() => { if (isOpen) { setSaveStatus({ obsidian: 'idle', bear: 'idle', octarine: 'idle' }); setSaveErrors({}); } }, [isOpen]); if (!isOpen) return null; const showNotesTab = isApiMode && !!markdown; const obsidianSettings = getObsidianSettings(); const bearSettings = getBearSettings(); const octarineSettings = getOctarineSettings(); 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 handleCopy = async (text: string, which: 'short' | 'full' | 'annotations') => { if (await copyTextToClipboard(text)) { setCopied(which); setTimeout(() => setCopied(false), 2000); } else { console.error('Failed to copy'); } }; const handleCopyAnnotations = async () => { await handleCopy(wrapCopiedAnnotations(annotationsOutput), 'annotations'); }; // Warn when the hash URL may be too long for messaging apps const urlIsLarge = shareUrl.length > 2048; // Hash-based sharing unavailable (e.g. HTML render mode) — show only short link const hashUnavailable = !shareUrl && !!onGenerateShortUrl; const handleDownloadAnnotations = () => { const blob = new Blob([annotationsOutput], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'annotations.md'; a.click(); URL.revokeObjectURL(url); }; const handleSaveToNotes = async (target: SaveTarget) => { if (!markdown) return; setSaveStatus(prev => ({ ...prev, [target]: 'saving' })); setSaveErrors(prev => { const next = { ...prev }; delete next[target]; return next; }); const body: { obsidian?: object; bear?: object; octarine?: object } = {}; if (target === 'obsidian') { body.obsidian = { vaultPath: effectiveVaultPath, folder: obsidianSettings.folder || 'plannotator', plan: markdown, ...(obsidianSettings.filenameFormat && { filenameFormat: obsidianSettings.filenameFormat }), ...(obsidianSettings.filenameSeparator && obsidianSettings.filenameSeparator !== 'space' && { filenameSeparator: obsidianSettings.filenameSeparator }), }; } if (target === 'bear') { body.bear = { plan: markdown }; } if (target === 'octarine') { body.octarine = { plan: markdown, workspace: octarineSettings.workspace, folder: octarineSettings.folder || 'plannotator', }; } try { const data = await onSaveToNotes(body); const result = data.results?.[target]; if (result?.success) { setSaveStatus(prev => ({ ...prev, [target]: 'success' })); } else { setSaveStatus(prev => ({ ...prev, [target]: 'error' })); setSaveErrors(prev => ({ ...prev, [target]: result?.error || 'Save failed' })); } } catch { setSaveStatus(prev => ({ ...prev, [target]: 'error' })); setSaveErrors(prev => ({ ...prev, [target]: 'Save failed' })); } }; const handleSaveAll = async () => { const targets: SaveTarget[] = []; if (isObsidianReady) targets.push('obsidian'); if (isBearReady) targets.push('bear'); if (isOctarineReady) targets.push('octarine'); await Promise.all(targets.map(t => handleSaveToNotes(t))); }; const readyCount = [isObsidianReady, isBearReady, isOctarineReady].filter(Boolean).length; // Determine which tabs to show const showTabs = sharingEnabled || showNotesTab; return (
e.stopPropagation()} > {taterSprite} {/* Header */}

Export

{annotationCount} annotation{annotationCount !== 1 ? 's' : ''}
{/* Body */}
{/* Tabs */} {showTabs && (
{sharingEnabled && ( )} {showNotesTab && ( )}
)} {/* Tab content */} {activeTab === 'share' && sharingEnabled ? (
{/* Short URL — primary copy target when available */} {shortShareUrl ? (
(e.target as HTMLInputElement).select()} />

Encrypted short link. The browser uploads AES-256-GCM ciphertext; the decryption key stays in the URL fragment.

) : isGeneratingShortUrl ? (
Generating short link...
) : onGenerateShortUrl ? (
{urlIsLarge && (

This URL may be too long for some messaging apps.

)} {shortUrlError && (

({shortUrlError})

)}
) : null} {/* Full hash URL — hidden when hash-based sharing is unavailable (HTML mode) */} {!hashUnavailable &&