@plannotator/ui 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -0
- package/assets/diff-options.png +0 -0
- package/assets/icon-codex.png +0 -0
- package/assets/look-flat.png +0 -0
- package/assets/look-grid.png +0 -0
- package/assets/review-sections.png +0 -0
- package/assets/review-tree.png +0 -0
- package/assets/workspaces.webp +0 -0
- package/components/AISettingsTab.tsx +145 -0
- package/components/ActionMenu.tsx +101 -0
- package/components/AgentControls.tsx +216 -0
- package/components/AgentsTab.tsx +1294 -0
- package/components/AnnotationPanel.tsx +731 -0
- package/components/AnnotationSidebar.tsx +86 -0
- package/components/AnnotationToolbar.tsx +323 -0
- package/components/AnnotationToolstrip.tsx +355 -0
- package/components/ApproveDropdown.tsx +170 -0
- package/components/AttachmentsButton.tsx +410 -0
- package/components/BlockRenderer.tsx +163 -0
- package/components/BorderTrail.tsx +38 -0
- package/components/CodeFilePicker.tsx +64 -0
- package/components/CodeFilePopout.tsx +602 -0
- package/components/CodePathValidationContext.tsx +14 -0
- package/components/CommentPopover.tsx +512 -0
- package/components/CompletionOverlay.tsx +99 -0
- package/components/ConfirmDialog.tsx +128 -0
- package/components/DocBadges.tsx +231 -0
- package/components/EditorAnnotationCard.tsx +65 -0
- package/components/ExportModal.tsx +565 -0
- package/components/FloatingQuickLabelPicker.tsx +144 -0
- package/components/GitHubIcon.tsx +16 -0
- package/components/GitLabIcon.tsx +30 -0
- package/components/GraphvizBlock.tsx +511 -0
- package/components/ImageAnnotator/Canvas.tsx +118 -0
- package/components/ImageAnnotator/Toolbar.tsx +218 -0
- package/components/ImageAnnotator/index.tsx +272 -0
- package/components/ImageAnnotator/types.ts +39 -0
- package/components/ImageAnnotator/utils.ts +147 -0
- package/components/ImageThumbnail.tsx +126 -0
- package/components/ImportModal.tsx +146 -0
- package/components/InlineMarkdown.tsx +1094 -0
- package/components/KeyboardShortcuts.tsx +208 -0
- package/components/Landing.tsx +496 -0
- package/components/ListItemBody.tsx +62 -0
- package/components/ListMarker.tsx +73 -0
- package/components/LookAndFeelAnnouncementDialog.tsx +230 -0
- package/components/MarkdownEditor.tsx +44 -0
- package/components/MenuVersionSection.tsx +91 -0
- package/components/MermaidBlock.tsx +579 -0
- package/components/ModeToggle.tsx +68 -0
- package/components/OpenInAppButton.tsx +299 -0
- package/components/OverlayScrollArea.tsx +82 -0
- package/components/PermissionModeSetup.tsx +92 -0
- package/components/PinpointOverlay.tsx +99 -0
- package/components/PlanAIAnnouncementDialog.tsx +187 -0
- package/components/PlanHeaderMenu.tsx +299 -0
- package/components/PopoutDialog.tsx +91 -0
- package/components/Popover.tsx +27 -0
- package/components/ProviderIcons.tsx +51 -0
- package/components/PullRequestIcon.tsx +12 -0
- package/components/QuickLabelDropdown.tsx +63 -0
- package/components/RenderedMarkdown.tsx +54 -0
- package/components/RepoIcon.tsx +12 -0
- package/components/ResizeHandle.tsx +86 -0
- package/components/ReviewAgentsIcon.tsx +18 -0
- package/components/SearchableSelect.tsx +157 -0
- package/components/Settings.tsx +2220 -0
- package/components/SparklesIcon.tsx +45 -0
- package/components/StickyHeaderLane.tsx +260 -0
- package/components/TableOfContents.tsx +157 -0
- package/components/TaterSpritePullup.tsx +34 -0
- package/components/TaterSpriteRunning.tsx +54 -0
- package/components/TaterSpriteSitting.tsx +37 -0
- package/components/TextShimmer.tsx +57 -0
- package/components/ThemeProvider.tsx +162 -0
- package/components/ThemeTab.tsx +139 -0
- package/components/ToolbarButtons.tsx +116 -0
- package/components/Tooltip.tsx +45 -0
- package/components/Viewer.tsx +977 -0
- package/components/ai/AIProviderBar.tsx +95 -0
- package/components/ai/DocumentAIChatPanel.tsx +313 -0
- package/components/blocks/AlertBlock.tsx +58 -0
- package/components/blocks/Callout.tsx +60 -0
- package/components/blocks/CodeBlock.tsx +75 -0
- package/components/blocks/HtmlBlock.tsx +122 -0
- package/components/blocks/MathBlock.tsx +36 -0
- package/components/blocks/TableBlock.tsx +146 -0
- package/components/blocks/TablePopout.tsx +279 -0
- package/components/blocks/TableToolbar.tsx +153 -0
- package/components/blocks/proseBody.tsx +102 -0
- package/components/core/button.tsx +44 -0
- package/components/core/textarea.tsx +26 -0
- package/components/diagramLanguages.ts +14 -0
- package/components/goal-setup/GoalSetupSurface.tsx +1354 -0
- package/components/html-viewer/HtmlViewer.tsx +386 -0
- package/components/html-viewer/bridge-script.ts +505 -0
- package/components/html-viewer/index.ts +1 -0
- package/components/html-viewer/useHtmlAnnotation.ts +400 -0
- package/components/icons/AgentIcons.tsx +66 -0
- package/components/icons/AppIcon.tsx +56 -0
- package/components/icons/MessagesIcon.tsx +11 -0
- package/components/icons/ObsidianIcons.tsx +208 -0
- package/components/icons/app/android-studio.svg +369 -0
- package/components/icons/app/antigravity.svg +97 -0
- package/components/icons/app/cursor.svg +16 -0
- package/components/icons/app/file-explorer.svg +20 -0
- package/components/icons/app/finder.png +0 -0
- package/components/icons/app/ghostty.svg +13 -0
- package/components/icons/app/iterm2.svg +13 -0
- package/components/icons/app/powershell.svg +14 -0
- package/components/icons/app/sublime-text.svg +17 -0
- package/components/icons/app/terminal.png +0 -0
- package/components/icons/app/textmate.png +0 -0
- package/components/icons/app/vscode.svg +39 -0
- package/components/icons/app/warp.png +0 -0
- package/components/icons/app/xcode.png +0 -0
- package/components/icons/app/zed-dark.svg +15 -0
- package/components/icons/app/zed.svg +15 -0
- package/components/icons/themeIcons.tsx +47 -0
- package/components/mermaidSvg.ts +33 -0
- package/components/plan-diff/PlanCleanDiffView.tsx +884 -0
- package/components/plan-diff/PlanDiffBadge.tsx +48 -0
- package/components/plan-diff/PlanDiffModeSwitcher.tsx +103 -0
- package/components/plan-diff/PlanDiffViewer.tsx +214 -0
- package/components/plan-diff/PlanRawDiffView.tsx +102 -0
- package/components/plan-diff/VSCodeIcon.tsx +132 -0
- package/components/settings/HooksTab.tsx +208 -0
- package/components/sidebar/ArchiveBrowser.tsx +98 -0
- package/components/sidebar/CountBadge.tsx +12 -0
- package/components/sidebar/FileBrowser.tsx +498 -0
- package/components/sidebar/MessagesBrowser.tsx +109 -0
- package/components/sidebar/SidebarContainer.tsx +353 -0
- package/components/sidebar/SidebarTabs.tsx +155 -0
- package/components/sidebar/VersionBrowser.tsx +141 -0
- package/components/types.d.ts +7 -0
- package/components/ui/badge.tsx +44 -0
- package/components/ui/button.tsx +83 -0
- package/components/ui/card.tsx +58 -0
- package/components/ui/dialog.tsx +106 -0
- package/components/ui/dropdown-menu.tsx +240 -0
- package/components/ui/state-pill.tsx +45 -0
- package/components/ui/tabs.tsx +46 -0
- package/components/ui/textarea.tsx +25 -0
- package/config/configStore.ts +214 -0
- package/config/index.ts +4 -0
- package/config/reviewView.ts +42 -0
- package/config/settings.ts +310 -0
- package/config/useConfig.ts +20 -0
- package/configure.ts +69 -0
- package/globals.d.ts +14 -0
- package/hooks/pfm/useCodeFilePopout.ts +111 -0
- package/hooks/useAIChat.ts +532 -0
- package/hooks/useAIProviderConfig.ts +115 -0
- package/hooks/useActiveSection.ts +78 -0
- package/hooks/useAgentJobs.ts +306 -0
- package/hooks/useAgentSettings.ts +579 -0
- package/hooks/useAgents.ts +90 -0
- package/hooks/useAnnotationDraft.ts +523 -0
- package/hooks/useAnnotationHighlighter.ts +1170 -0
- package/hooks/useArchive.ts +171 -0
- package/hooks/useAutoClose.ts +104 -0
- package/hooks/useCodeAnnotationDraft.ts +194 -0
- package/hooks/useCodeFilePopout.ts +1 -0
- package/hooks/useDismissOnOutsideAndEscape.ts +50 -0
- package/hooks/useDraggable.ts +108 -0
- package/hooks/useEditorAnnotations.ts +64 -0
- package/hooks/useExternalAnnotationHighlights.ts +105 -0
- package/hooks/useExternalAnnotations.ts +268 -0
- package/hooks/useFileBrowser.ts +402 -0
- package/hooks/useInputMethodSwitch.ts +89 -0
- package/hooks/useIsMobile.ts +17 -0
- package/hooks/useLinkedDoc.ts +494 -0
- package/hooks/useOverlayViewport.ts +36 -0
- package/hooks/usePinpoint.ts +182 -0
- package/hooks/usePlanDiff.ts +176 -0
- package/hooks/usePrintMode.ts +27 -0
- package/hooks/useResizablePanel.ts +173 -0
- package/hooks/useScrollViewport.ts +38 -0
- package/hooks/useSharing.ts +441 -0
- package/hooks/useSidebar.ts +55 -0
- package/hooks/useUpdateCheck.ts +132 -0
- package/hooks/useValidatedCodePaths.ts +94 -0
- package/icons/GitUser.tsx +17 -0
- package/lib/utils.ts +11 -0
- package/package.json +117 -0
- package/plannotator.webp +0 -0
- package/print.css +427 -0
- package/shortcuts/code-review/ai.shortcuts.ts +25 -0
- package/shortcuts/code-review/allFilesDiff.shortcuts.ts +46 -0
- package/shortcuts/code-review/annotationToolbar.shortcuts.ts +30 -0
- package/shortcuts/code-review/fileTree.shortcuts.ts +35 -0
- package/shortcuts/code-review/prComments.shortcuts.ts +18 -0
- package/shortcuts/code-review/suggestionModal.shortcuts.ts +25 -0
- package/shortcuts/code-review/tourDialog.shortcuts.ts +18 -0
- package/shortcuts/core.ts +399 -0
- package/shortcuts/index.ts +21 -0
- package/shortcuts/plan-review/annotationPanel.shortcuts.ts +25 -0
- package/shortcuts/plan-review/annotationToolbar.shortcuts.ts +39 -0
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +21 -0
- package/shortcuts/plan-review/goalSetup.shortcuts.ts +10 -0
- package/shortcuts/plan-review/imageAnnotator.shortcuts.ts +42 -0
- package/shortcuts/plan-review/inputMethod.shortcuts.ts +30 -0
- package/shortcuts/plan-review/sidebar.shortcuts.ts +34 -0
- package/shortcuts/plan-review/viewer.shortcuts.ts +24 -0
- package/shortcuts/runtime.ts +259 -0
- package/sprite_package_additional/index.html +34 -0
- package/sprite_package_additional/sprite.png +0 -0
- package/sprite_package_new/index.html +34 -0
- package/sprite_package_new/sprite.png +0 -0
- package/sprite_package_pulluphang/index.html +34 -0
- package/sprite_package_pulluphang/sprite.png +0 -0
- package/styles.css +1 -0
- package/theme.css +893 -0
- package/themes/adwaita.css +62 -0
- package/themes/andromeeda.css +66 -0
- package/themes/aurora-x.css +66 -0
- package/themes/ayu-dark.css +66 -0
- package/themes/caffeine.css +113 -0
- package/themes/catppuccin.css +62 -0
- package/themes/claude-plus.css +60 -0
- package/themes/cursor-hc.css +34 -0
- package/themes/cursor-midnight.css +34 -0
- package/themes/cursor.css +62 -0
- package/themes/dark-plus.css +66 -0
- package/themes/doom-64.css +109 -0
- package/themes/dracula.css +33 -0
- package/themes/everforest-hard.css +62 -0
- package/themes/everforest-soft.css +62 -0
- package/themes/everforest.css +62 -0
- package/themes/github.css +66 -0
- package/themes/gruvbox.css +62 -0
- package/themes/houston.css +66 -0
- package/themes/kanagawa-dragon.css +34 -0
- package/themes/kanagawa-lotus.css +34 -0
- package/themes/kanagawa-wave.css +34 -0
- package/themes/laserwave.css +66 -0
- package/themes/material.css +62 -0
- package/themes/min.css +66 -0
- package/themes/monokai-pro.css +34 -0
- package/themes/neutral.css +59 -0
- package/themes/night-owl.css +66 -0
- package/themes/nord.css +66 -0
- package/themes/one-dark-pro.css +66 -0
- package/themes/one-light.css +66 -0
- package/themes/paulmillr.css +34 -0
- package/themes/plannotator.css +60 -0
- package/themes/plastic.css +66 -0
- package/themes/poimandres.css +66 -0
- package/themes/quantum-rose.css +109 -0
- package/themes/red.css +66 -0
- package/themes/rose-pine.css +62 -0
- package/themes/simple.css +124 -0
- package/themes/slack.css +66 -0
- package/themes/snazzy-light.css +66 -0
- package/themes/soft-pop.css +60 -0
- package/themes/solar-dusk.css +109 -0
- package/themes/solarized.css +66 -0
- package/themes/synthwave-84.css +34 -0
- package/themes/terminal.css +62 -0
- package/themes/tinacious.css +57 -0
- package/themes/tokyo-night.css +62 -0
- package/themes/vesper.css +62 -0
- package/themes/vitesse-black.css +66 -0
- package/themes/vitesse.css +62 -0
- package/types.ts +264 -0
- package/utils/agentSwitch.ts +71 -0
- package/utils/aiChatFormat.ts +33 -0
- package/utils/aiPrompt.ts +29 -0
- package/utils/aiProvider.ts +217 -0
- package/utils/anchors.ts +11 -0
- package/utils/annotateAgentTerminal.ts +29 -0
- package/utils/annotationHelpers.ts +101 -0
- package/utils/bear.ts +59 -0
- package/utils/blockTargeting.ts +240 -0
- package/utils/callback.ts +99 -0
- package/utils/commentContent.ts +8 -0
- package/utils/defaultNotesApp.ts +20 -0
- package/utils/diffFonts.ts +33 -0
- package/utils/editorMode.ts +31 -0
- package/utils/fileBrowser.ts +40 -0
- package/utils/generateId.ts +7 -0
- package/utils/generateIdentity.ts +29 -0
- package/utils/identity.ts +112 -0
- package/utils/inlineTransforms.ts +41 -0
- package/utils/inputMethod.ts +17 -0
- package/utils/lookAndFeelAnnouncement.ts +18 -0
- package/utils/obsidian.ts +201 -0
- package/utils/octarine.ts +53 -0
- package/utils/parser.ts +991 -0
- package/utils/permissionMode.ts +77 -0
- package/utils/planAIAnnouncement.ts +17 -0
- package/utils/planAgentInstructions.ts +137 -0
- package/utils/planDiffEngine.ts +590 -0
- package/utils/planSave.ts +49 -0
- package/utils/platform.ts +11 -0
- package/utils/quickLabels.ts +77 -0
- package/utils/reviewAgentInstructions.ts +182 -0
- package/utils/sanitizeHtml.ts +45 -0
- package/utils/sharing.ts +350 -0
- package/utils/slugify.ts +37 -0
- package/utils/storage.ts +141 -0
- package/utils/themeRegistry.ts +567 -0
- package/utils/uiPreferences.ts +34 -0
- package/utils/upload.ts +56 -0
- package/utils/wideMode.ts +48 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quick Labels — preset annotation labels for one-click feedback
|
|
3
|
+
*
|
|
4
|
+
* Labels are stored in cookies (same pattern as other settings)
|
|
5
|
+
* so they persist across different port-based sessions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { storage } from './storage';
|
|
9
|
+
|
|
10
|
+
const STORAGE_KEY = 'plannotator-quick-labels';
|
|
11
|
+
|
|
12
|
+
export interface QuickLabel {
|
|
13
|
+
id: string; // kebab-case identifier e.g. "needs-tests"
|
|
14
|
+
emoji: string; // single emoji e.g. "🧪"
|
|
15
|
+
text: string; // display text e.g. "Needs tests"
|
|
16
|
+
color: string; // key into LABEL_COLOR_MAP
|
|
17
|
+
tip?: string; // optional instruction injected into feedback for the agent
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Inline styles for label colors (avoids Tailwind dynamic class purging) */
|
|
21
|
+
export const LABEL_COLOR_MAP: Record<string, { bg: string; text: string; darkText: string }> = {
|
|
22
|
+
blue: { bg: 'rgba(59,130,246,0.15)', text: '#2563eb', darkText: '#60a5fa' },
|
|
23
|
+
red: { bg: 'rgba(239,68,68,0.15)', text: '#dc2626', darkText: '#f87171' },
|
|
24
|
+
orange: { bg: 'rgba(249,115,22,0.15)', text: '#ea580c', darkText: '#fb923c' },
|
|
25
|
+
yellow: { bg: 'rgba(234,179,8,0.15)', text: '#ca8a04', darkText: '#facc15' },
|
|
26
|
+
purple: { bg: 'rgba(147,51,234,0.15)', text: '#9333ea', darkText: '#a78bfa' },
|
|
27
|
+
teal: { bg: 'rgba(20,184,166,0.15)', text: '#0d9488', darkText: '#2dd4bf' },
|
|
28
|
+
pink: { bg: 'rgba(236,72,153,0.15)', text: '#db2777', darkText: '#f472b6' },
|
|
29
|
+
green: { bg: 'rgba(34,197,94,0.15)', text: '#16a34a', darkText: '#4ade80' },
|
|
30
|
+
cyan: { bg: 'rgba(8,145,178,0.15)', text: '#0891b2', darkText: '#22d3ee' },
|
|
31
|
+
amber: { bg: 'rgba(180,83,9,0.15)', text: '#b45309', darkText: '#fbbf24' },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_QUICK_LABELS: QuickLabel[] = [
|
|
35
|
+
{ id: 'clarify-this', emoji: '❓', text: 'Clarify this', color: 'yellow' },
|
|
36
|
+
{ id: 'missing-overview', emoji: '🗺️', text: 'Missing overview', color: 'purple', tip: 'Provide a narrative overview of what is being built, why it is being built, and how it will be built. Add this before the implementation details.' },
|
|
37
|
+
{ id: 'verify-this', emoji: '🔍', text: 'Verify this', color: 'orange', tip: 'This seems like an assumption. Verify by reading the actual code before proceeding.' },
|
|
38
|
+
{ id: 'give-me-an-example', emoji: '🔬', text: 'Give me an example', color: 'cyan', tip: 'This is too abstract. Show a before/after, a sample input/output, or a specific scenario so I can see how this actually works.' },
|
|
39
|
+
{ id: 'match-existing-patterns', emoji: '🧬', text: 'Match existing patterns', color: 'teal', tip: 'Search the codebase for existing patterns, components, or utilities that already solve this. Reuse what exists rather than introducing a new approach.' },
|
|
40
|
+
{ id: 'consider-alternatives', emoji: '🔄', text: 'Consider alternatives', color: 'pink', tip: 'Propose 2-3 alternative approaches with trade-offs based on the actual codebase. Also check the Plannotator plans directory (PLANNOTATOR_DATA_DIR or ~/.plannotator/plans/) for prior plan versions that may have already explored or rejected similar approaches.' },
|
|
41
|
+
{ id: 'ensure-no-regression', emoji: '📉', text: 'Ensure no regression', color: 'amber', tip: 'Verify that this change will not break existing behavior. Identify what could regress and how to protect against it.' },
|
|
42
|
+
{ id: 'out-of-scope', emoji: '🚫', text: 'Out of scope', color: 'red', tip: 'This is not part of the current task. Remove it and stay focused on what was actually requested.' },
|
|
43
|
+
{ id: 'needs-tests', emoji: '🧪', text: 'Needs tests', color: 'blue' },
|
|
44
|
+
{ id: 'nice-approach', emoji: '👍', text: 'Nice approach', color: 'green' },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
export function getQuickLabels(): QuickLabel[] {
|
|
48
|
+
const raw = storage.getItem(STORAGE_KEY);
|
|
49
|
+
if (!raw) return DEFAULT_QUICK_LABELS;
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(raw) as QuickLabel[];
|
|
52
|
+
return parsed.length > 0 ? parsed : DEFAULT_QUICK_LABELS;
|
|
53
|
+
} catch {
|
|
54
|
+
return DEFAULT_QUICK_LABELS;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function saveQuickLabels(labels: QuickLabel[]): void {
|
|
59
|
+
storage.setItem(STORAGE_KEY, JSON.stringify(labels));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function resetQuickLabels(): void {
|
|
63
|
+
storage.removeItem(STORAGE_KEY);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Find a configured label whose "emoji text" matches an annotation's text field */
|
|
67
|
+
export function findLabelByText(annotationText: string): QuickLabel | undefined {
|
|
68
|
+
return getQuickLabels().find(l => `${l.emoji} ${l.text}` === annotationText);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Get color styles for a label, respecting dark mode */
|
|
72
|
+
export function getLabelColors(color: string): { bg: string; text: string } {
|
|
73
|
+
const colors = LABEL_COLOR_MAP[color];
|
|
74
|
+
if (!colors) return { bg: 'rgba(128,128,128,0.15)', text: '#666' };
|
|
75
|
+
const isDark = document.documentElement.classList.contains('dark');
|
|
76
|
+
return { bg: colors.bg, text: isDark ? colors.darkText : colors.text };
|
|
77
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the clipboard payload that teaches an external agent (Claude Code,
|
|
3
|
+
* Codex, custom scripts, etc.) how to post annotations into a live Plannotator
|
|
4
|
+
* **code-review** session via the /api/external-annotations HTTP API.
|
|
5
|
+
*
|
|
6
|
+
* The body is intentionally short so an agent can read it top-to-bottom and
|
|
7
|
+
* start posting in under a minute. Edit freely — this file is the single source
|
|
8
|
+
* of truth for the review agent-facing contract surface.
|
|
9
|
+
*
|
|
10
|
+
* The only dynamic value is `origin`, which is interpolated at click time from
|
|
11
|
+
* `window.location.origin` so the agent gets the correct base URL whether the
|
|
12
|
+
* server is running on a random local port or the fixed remote port (19432).
|
|
13
|
+
*/
|
|
14
|
+
export function buildReviewAgentInstructions(origin: string): string {
|
|
15
|
+
return `# Plannotator — External Review Comments
|
|
16
|
+
|
|
17
|
+
You can submit review feedback on the user's current code-review session by POSTing annotations to a small HTTP API. The user will see them immediately — pinned inline to the relevant diff lines, plus entries in a sidebar — and can accept, edit, or delete them.
|
|
18
|
+
|
|
19
|
+
This is one-way submission. Any tool can post: linters, agents, scripts. The user does not see who you are unless you tell them via \`text\` or \`author\`.
|
|
20
|
+
|
|
21
|
+
## Base URL
|
|
22
|
+
${origin}
|
|
23
|
+
|
|
24
|
+
All endpoints below are relative to that base. No authentication.
|
|
25
|
+
|
|
26
|
+
## Workflow
|
|
27
|
+
1. Read the diff so you know what to comment on.
|
|
28
|
+
2. POST your annotations (single or batch).
|
|
29
|
+
3. Optionally clean up your previous annotations before reposting on a re-run.
|
|
30
|
+
|
|
31
|
+
There is no "send" or "done" step — each POST is live the moment it lands.
|
|
32
|
+
|
|
33
|
+
## Reading the diff
|
|
34
|
+
|
|
35
|
+
\`\`\`sh
|
|
36
|
+
curl -s ${origin}/api/diff | jq -r .rawPatch
|
|
37
|
+
\`\`\`
|
|
38
|
+
|
|
39
|
+
This returns the unified diff (\`rawPatch\`) the user is reviewing. Comment line numbers come straight from the diff's hunk headers:
|
|
40
|
+
|
|
41
|
+
\`\`\`
|
|
42
|
+
@@ -<oldStart>,<oldLen> +<newStart>,<newLen> @@
|
|
43
|
+
\`\`\`
|
|
44
|
+
|
|
45
|
+
- For added or unchanged context lines, count from \`newStart\` and post with \`"side": "new"\`.
|
|
46
|
+
- For removed lines, count from \`oldStart\` and post with \`"side": "old"\`.
|
|
47
|
+
|
|
48
|
+
Use the **file line number**, not the line's position within the diff. \`side\` defaults to \`"new"\` — only set \`"old"\` when commenting on a deleted line.
|
|
49
|
+
|
|
50
|
+
## Three kinds of comment (\`scope\`)
|
|
51
|
+
|
|
52
|
+
- **Line comment** (\`scope: "line"\`, the default) — pinned to a specific line range in one file. Requires \`filePath\`, \`lineStart\`, \`lineEnd\`. Use for feedback about specific code.
|
|
53
|
+
- **File comment** (\`scope: "file"\`) — pinned to a whole file, shown as a banner in that file's header. Requires \`filePath\`; no line numbers. Use for feedback about the file as a whole.
|
|
54
|
+
- **General comment** (\`scope: "general"\`) — not tied to any file. Sidebar entry only. Use for review-level feedback like "the error handling is inconsistent across these changes."
|
|
55
|
+
|
|
56
|
+
## Posting a line comment
|
|
57
|
+
|
|
58
|
+
\`\`\`sh
|
|
59
|
+
curl -s ${origin}/api/external-annotations \\
|
|
60
|
+
-H 'Content-Type: application/json' \\
|
|
61
|
+
-d '{
|
|
62
|
+
"source": "claude-code",
|
|
63
|
+
"scope": "line",
|
|
64
|
+
"filePath": "src/server/auth.ts",
|
|
65
|
+
"lineStart": 42,
|
|
66
|
+
"lineEnd": 42,
|
|
67
|
+
"side": "new",
|
|
68
|
+
"text": "This reads the token before checking it exists — guard against undefined."
|
|
69
|
+
}'
|
|
70
|
+
\`\`\`
|
|
71
|
+
|
|
72
|
+
## Posting a suggestion (proposed replacement code)
|
|
73
|
+
|
|
74
|
+
\`\`\`sh
|
|
75
|
+
curl -s ${origin}/api/external-annotations \\
|
|
76
|
+
-H 'Content-Type: application/json' \\
|
|
77
|
+
-d '{
|
|
78
|
+
"source": "claude-code",
|
|
79
|
+
"type": "suggestion",
|
|
80
|
+
"filePath": "src/server/auth.ts",
|
|
81
|
+
"lineStart": 42,
|
|
82
|
+
"lineEnd": 44,
|
|
83
|
+
"text": "Guard against a missing token.",
|
|
84
|
+
"suggestedCode": "const token = req.headers.authorization;\\nif (!token) return res.status(401).end();"
|
|
85
|
+
}'
|
|
86
|
+
\`\`\`
|
|
87
|
+
|
|
88
|
+
## Posting a file comment
|
|
89
|
+
|
|
90
|
+
\`\`\`sh
|
|
91
|
+
curl -s ${origin}/api/external-annotations \\
|
|
92
|
+
-H 'Content-Type: application/json' \\
|
|
93
|
+
-d '{
|
|
94
|
+
"source": "claude-code",
|
|
95
|
+
"scope": "file",
|
|
96
|
+
"filePath": "src/server/auth.ts",
|
|
97
|
+
"text": "This module mixes parsing and validation — consider splitting them."
|
|
98
|
+
}'
|
|
99
|
+
\`\`\`
|
|
100
|
+
|
|
101
|
+
## Posting a general comment
|
|
102
|
+
|
|
103
|
+
\`\`\`sh
|
|
104
|
+
curl -s ${origin}/api/external-annotations \\
|
|
105
|
+
-H 'Content-Type: application/json' \\
|
|
106
|
+
-d '{
|
|
107
|
+
"source": "claude-code",
|
|
108
|
+
"scope": "general",
|
|
109
|
+
"text": "Error handling is inconsistent across these files — some throw, some return null."
|
|
110
|
+
}'
|
|
111
|
+
\`\`\`
|
|
112
|
+
|
|
113
|
+
All endpoints return \`201 {"ids": ["<uuid>"]}\` on success, \`400 {"error": "..."}\` on validation failure.
|
|
114
|
+
|
|
115
|
+
### Fields
|
|
116
|
+
|
|
117
|
+
| Field | Required | Notes |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| \`source\` | yes | Stable identifier for *you* (e.g. \`"claude-code"\`, \`"codex"\`, \`"my-linter"\`). Reuse the same value for every annotation you post — it lets you clean up your own later. Pick something specific enough that it won't collide with other tools running against the same session. |
|
|
120
|
+
| \`scope\` | no | \`"line"\` (default), \`"file"\`, or \`"general"\`. |
|
|
121
|
+
| \`filePath\` | for \`line\` / \`file\` | Repo-relative path exactly as it appears in the diff (e.g. \`"src/server/auth.ts"\`). |
|
|
122
|
+
| \`lineStart\` | for \`line\` | File line number where the comment starts. |
|
|
123
|
+
| \`lineEnd\` | for \`line\` | File line number where the comment ends. Use the same value as \`lineStart\` for a single line. |
|
|
124
|
+
| \`side\` | no | \`"new"\` (default) for added/context lines, \`"old"\` for deleted lines. |
|
|
125
|
+
| \`type\` | no | \`"comment"\` (default), \`"suggestion"\`, or \`"concern"\`. |
|
|
126
|
+
| \`text\` | yes* | The comment body the user will read. |
|
|
127
|
+
| \`suggestedCode\` | no | Proposed replacement code, rendered as a suggestion block. *Either \`text\` or \`suggestedCode\` is required. |
|
|
128
|
+
| \`author\` | no | Human-readable label shown next to the comment (e.g. \`"Claude Opus"\`). |
|
|
129
|
+
|
|
130
|
+
A \`scope: "line"\` annotation must carry \`lineStart\` and \`lineEnd\` — a line comment missing its line numbers is rejected, not silently downgraded. If a line isn't present in the current diff view, the comment still appears in the sidebar but won't pin inline.
|
|
131
|
+
|
|
132
|
+
## Batching
|
|
133
|
+
|
|
134
|
+
\`\`\`sh
|
|
135
|
+
curl -s ${origin}/api/external-annotations \\
|
|
136
|
+
-H 'Content-Type: application/json' \\
|
|
137
|
+
-d '{
|
|
138
|
+
"annotations": [
|
|
139
|
+
{"source": "claude-code", "filePath": "src/server/auth.ts", "lineStart": 42, "lineEnd": 42, "text": "Guard against undefined token."},
|
|
140
|
+
{"source": "claude-code", "scope": "file", "filePath": "src/server/auth.ts", "text": "Consider splitting parsing from validation."},
|
|
141
|
+
{"source": "claude-code", "scope": "general", "text": "Overall the changes look solid. Add tests for the 401 path."}
|
|
142
|
+
]
|
|
143
|
+
}'
|
|
144
|
+
\`\`\`
|
|
145
|
+
|
|
146
|
+
Batches are atomic: if any item fails validation, the whole batch is rejected with an error like \`annotations[2] missing required "lineStart" field\`.
|
|
147
|
+
|
|
148
|
+
## Listing and deleting
|
|
149
|
+
|
|
150
|
+
\`\`\`sh
|
|
151
|
+
# List everything (yours and others')
|
|
152
|
+
curl -s ${origin}/api/external-annotations | jq
|
|
153
|
+
|
|
154
|
+
# Delete one annotation by id — works on any source, including the user's
|
|
155
|
+
curl -s -X DELETE "${origin}/api/external-annotations?id=<uuid>"
|
|
156
|
+
|
|
157
|
+
# Delete all annotations from one source — the standard cleanup before reposting
|
|
158
|
+
curl -s -X DELETE "${origin}/api/external-annotations?source=claude-code"
|
|
159
|
+
|
|
160
|
+
# Delete everything in the session
|
|
161
|
+
curl -s -X DELETE ${origin}/api/external-annotations
|
|
162
|
+
\`\`\`
|
|
163
|
+
|
|
164
|
+
You have full delete authority. Use it responsibly.
|
|
165
|
+
|
|
166
|
+
## Cleaning up on a re-run
|
|
167
|
+
|
|
168
|
+
If you re-run on the same session, your previous annotations are still there. POSTing again will create duplicates. Standard pattern:
|
|
169
|
+
|
|
170
|
+
\`\`\`sh
|
|
171
|
+
curl -s -X DELETE "${origin}/api/external-annotations?source=claude-code"
|
|
172
|
+
curl -s ${origin}/api/external-annotations -H 'Content-Type: application/json' -d '{ ...fresh annotations... }'
|
|
173
|
+
\`\`\`
|
|
174
|
+
|
|
175
|
+
This is why \`source\` matters. Pick a stable identifier and stick with it.
|
|
176
|
+
|
|
177
|
+
## Notes
|
|
178
|
+
- The diff can change underneath you. If the user switches diff type, base branch, or PR, refetch \`/api/diff\` — your prior line numbers may no longer match.
|
|
179
|
+
- No idempotency. Posting the same annotation twice creates two entries.
|
|
180
|
+
- This API is local to the user's machine. Treat it as a UI surface, not a public service.
|
|
181
|
+
`;
|
|
182
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import DOMPurify from 'dompurify';
|
|
2
|
+
import { marked } from 'marked';
|
|
3
|
+
|
|
4
|
+
const ALLOWED_TAGS = [
|
|
5
|
+
'sub', 'sup', 'b', 'i', 'em', 'strong', 'br', 'hr', 'p', 'span',
|
|
6
|
+
'del', 'ins', 'mark', 'small', 'abbr', 'kbd', 'var', 'samp',
|
|
7
|
+
'details', 'summary', 'blockquote', 'ul', 'ol', 'li',
|
|
8
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'code',
|
|
9
|
+
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
|
10
|
+
'a', 'img', 'div', 'section', 'article', 'aside', 'header', 'footer',
|
|
11
|
+
'video', 'source', 'picture',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const ALLOWED_ATTR = [
|
|
15
|
+
'href', 'src', 'alt', 'title', 'rel', 'target', 'width', 'height', 'align',
|
|
16
|
+
'open', // preserve <details open> default-expanded state
|
|
17
|
+
// <video>/<source> — deliberately no `autoplay`, so embedded clips don't play
|
|
18
|
+
// on their own when a comment/description scrolls into view.
|
|
19
|
+
'controls', 'poster', 'muted', 'loop', 'playsinline', 'type',
|
|
20
|
+
// <picture>/<source> and responsive <img> — without these the alternate-format
|
|
21
|
+
// selection is inert and <source> renders as a no-op.
|
|
22
|
+
'srcset', 'media', 'sizes',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Render and sanitize the content of a raw HTML block for injection via
|
|
27
|
+
* innerHTML. Content is first run through `marked` so that markdown nested
|
|
28
|
+
* between HTML tags (e.g. `**bun**` inside `<details>…</details>`) renders
|
|
29
|
+
* as real `<strong>`, matching GitHub's flavored behavior. Then DOMPurify
|
|
30
|
+
* strips anything outside the allowlist — no event handlers, no inline
|
|
31
|
+
* styles, no scripts.
|
|
32
|
+
*/
|
|
33
|
+
export function sanitizeBlockHtml(html: string): string {
|
|
34
|
+
const rendered = marked.parse(html, { async: false, gfm: true, breaks: false }) as string;
|
|
35
|
+
return DOMPurify.sanitize(rendered, { ALLOWED_TAGS, ALLOWED_ATTR });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Sanitize inline HTML that is already HTML (no markdown pass) — same allowlist
|
|
40
|
+
* as {@link sanitizeBlockHtml}. Used for inline GitHub content (e.g. PR comment
|
|
41
|
+
* spans) so the allowlist lives in exactly one place.
|
|
42
|
+
*/
|
|
43
|
+
export function sanitizeInlineHtml(html: string): string {
|
|
44
|
+
return DOMPurify.sanitize(html, { ALLOWED_TAGS, ALLOWED_ATTR });
|
|
45
|
+
}
|
package/utils/sharing.ts
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable sharing utilities for Plannotator
|
|
3
|
+
*
|
|
4
|
+
* Enables sharing plan + annotations via URL hash using:
|
|
5
|
+
* - Native CompressionStream/DecompressionStream (deflate-raw)
|
|
6
|
+
* - Base64url encoding for URL safety
|
|
7
|
+
*
|
|
8
|
+
* Inspired by textarea.my's approach.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Annotation, AnnotationType, type ImageAttachment } from '../types';
|
|
12
|
+
import { compress, decompress } from '@plannotator/core/compress';
|
|
13
|
+
import { encrypt, decrypt } from '@plannotator/core/crypto';
|
|
14
|
+
|
|
15
|
+
// Image in shareable format: plain string (old) or [path, name] tuple (new)
|
|
16
|
+
type ShareableImage = string | [string, string];
|
|
17
|
+
|
|
18
|
+
// Minimal shareable annotation format: [type, originalText, text?, author?, images?, quickLabel?]
|
|
19
|
+
export type ShareableAnnotation =
|
|
20
|
+
| ['D', string, string | null, ShareableImage[]?] // Deletion: type, original, author, images
|
|
21
|
+
| ['C', string, string, string | null, ShareableImage[]?, (1)?] // Comment: type, original, comment, author, images, isQuickLabel
|
|
22
|
+
| ['G', string, string | null, ShareableImage[]?]; // Global Comment: type, comment, author, images
|
|
23
|
+
|
|
24
|
+
export interface SharePayload {
|
|
25
|
+
p: string; // plan markdown
|
|
26
|
+
a: ShareableAnnotation[];
|
|
27
|
+
g?: ShareableImage[]; // global attachments (path strings or [path, name] tuples)
|
|
28
|
+
d?: (string | null)[]; // diffContext per annotation, parallel to `a`
|
|
29
|
+
s?: (string | undefined)[]; // source per annotation (external tool identifier), parallel to `a`
|
|
30
|
+
h?: string; // raw HTML content (direct HTML rendering mode)
|
|
31
|
+
r?: 'html'; // render mode flag (omitted = markdown)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Convert ShareableImage[] to ImageAttachment[] (handles old plain-string format)
|
|
36
|
+
*/
|
|
37
|
+
export function parseShareableImages(raw: ShareableImage[] | undefined): ImageAttachment[] | undefined {
|
|
38
|
+
if (!raw?.length) return undefined;
|
|
39
|
+
return raw.map(img => {
|
|
40
|
+
if (typeof img === 'string') {
|
|
41
|
+
// Old format: plain path string — derive name from filename
|
|
42
|
+
const name = img.split('/').pop()?.replace(/\.[^.]+$/, '') || 'image';
|
|
43
|
+
return { path: img, name };
|
|
44
|
+
}
|
|
45
|
+
return { path: img[0], name: img[1] };
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Convert ImageAttachment[] to ShareableImage[] for compact serialization
|
|
51
|
+
*/
|
|
52
|
+
export function toShareableImages(images: ImageAttachment[] | undefined): ShareableImage[] | undefined {
|
|
53
|
+
if (!images?.length) return undefined;
|
|
54
|
+
return images.map(img => [img.path, img.name]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Re-export compress/decompress from shared package (single source of truth)
|
|
58
|
+
export { compress, decompress };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Convert full Annotation objects to minimal shareable format
|
|
62
|
+
*/
|
|
63
|
+
export function toShareable(annotations: Annotation[]): ShareableAnnotation[] {
|
|
64
|
+
return annotations.map(ann => {
|
|
65
|
+
const author = ann.author || null;
|
|
66
|
+
const images = toShareableImages(ann.images);
|
|
67
|
+
|
|
68
|
+
// Handle GLOBAL_COMMENT specially - it starts with 'G' (from GLOBAL_COMMENT)
|
|
69
|
+
if (ann.type === AnnotationType.GLOBAL_COMMENT) {
|
|
70
|
+
return ['G', ann.text || '', author, images] as ShareableAnnotation;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (ann.type === AnnotationType.DELETION) {
|
|
74
|
+
return ['D', ann.originalText, author, images] as ShareableAnnotation;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// COMMENT
|
|
78
|
+
if (ann.isQuickLabel) {
|
|
79
|
+
return ['C', ann.originalText, ann.text || '', author, images ?? undefined, 1] as ShareableAnnotation;
|
|
80
|
+
}
|
|
81
|
+
return ['C', ann.originalText, ann.text || '', author, images] as ShareableAnnotation;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Convert shareable format back to full Annotation objects
|
|
87
|
+
* Note: blockId, offsets, and meta will need to be populated separately
|
|
88
|
+
* by finding the text in the rendered document.
|
|
89
|
+
*/
|
|
90
|
+
export function fromShareable(data: ShareableAnnotation[], diffContexts?: (string | null)[] | null, sources?: (string | undefined)[] | null): Annotation[] {
|
|
91
|
+
const typeMap: Record<string, AnnotationType> = {
|
|
92
|
+
'D': AnnotationType.DELETION,
|
|
93
|
+
'C': AnnotationType.COMMENT,
|
|
94
|
+
'G': AnnotationType.GLOBAL_COMMENT,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return data.map((item, index) => {
|
|
98
|
+
const type = item[0];
|
|
99
|
+
|
|
100
|
+
// Handle global comments specially: ['G', text, author, images?]
|
|
101
|
+
if (type === 'G') {
|
|
102
|
+
const text = item[1] as string;
|
|
103
|
+
const author = item[2] as string | null;
|
|
104
|
+
const rawImages = item[3] as ShareableImage[] | undefined;
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
id: `shared-${index}-${Date.now()}`,
|
|
108
|
+
blockId: '',
|
|
109
|
+
startOffset: 0,
|
|
110
|
+
endOffset: 0,
|
|
111
|
+
type: AnnotationType.GLOBAL_COMMENT,
|
|
112
|
+
text: text || undefined,
|
|
113
|
+
originalText: '',
|
|
114
|
+
createdA: Date.now() + index,
|
|
115
|
+
author: author || undefined,
|
|
116
|
+
images: parseShareableImages(rawImages),
|
|
117
|
+
...(sources?.[index] ? { source: sources[index] } : {}),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const originalText = item[1];
|
|
122
|
+
// For deletion: [type, original, author, images?]
|
|
123
|
+
// For others: [type, original, text, author, images?]
|
|
124
|
+
const text = type === 'D' ? undefined : item[2] as string;
|
|
125
|
+
const author = type === 'D' ? item[2] as string | null : item[3] as string | null;
|
|
126
|
+
const rawImages = type === 'D' ? item[3] as ShareableImage[] | undefined : item[4] as ShareableImage[] | undefined;
|
|
127
|
+
// Comment annotations may have isQuickLabel flag at index 5
|
|
128
|
+
const isQuickLabel = type === 'C' && item.length > 5 && item[5] === 1 ? true : undefined;
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
id: `shared-${index}-${Date.now()}`,
|
|
132
|
+
blockId: '', // Will be populated during highlight restoration
|
|
133
|
+
startOffset: 0,
|
|
134
|
+
endOffset: 0,
|
|
135
|
+
type: typeMap[type],
|
|
136
|
+
text: text || undefined,
|
|
137
|
+
originalText,
|
|
138
|
+
createdA: Date.now() + index, // Preserve order
|
|
139
|
+
author: author || undefined,
|
|
140
|
+
images: parseShareableImages(rawImages),
|
|
141
|
+
...(isQuickLabel ? { isQuickLabel } : {}),
|
|
142
|
+
...(diffContexts?.[index] ? { diffContext: diffContexts[index] as Annotation['diffContext'] } : {}),
|
|
143
|
+
...(sources?.[index] ? { source: sources[index] } : {}),
|
|
144
|
+
// startMeta/endMeta will be set by web-highlighter
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function buildDiffContextArray(annotations: Annotation[]): (string | null)[] | null {
|
|
150
|
+
const arr = annotations.map(a => a.diffContext || null);
|
|
151
|
+
return arr.some(v => v !== null) ? arr : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function buildSourceArray(annotations: Annotation[]): (string | undefined)[] | null {
|
|
155
|
+
const arr = annotations.map(a => a.source || undefined);
|
|
156
|
+
return arr.some(v => v !== undefined) ? arr : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Generate a full shareable URL from plan and annotations
|
|
161
|
+
*/
|
|
162
|
+
export async function generateShareUrl(
|
|
163
|
+
markdown: string,
|
|
164
|
+
annotations: Annotation[],
|
|
165
|
+
globalAttachments?: ImageAttachment[],
|
|
166
|
+
baseUrl: string = DEFAULT_SHARE_BASE,
|
|
167
|
+
rawHtml?: string,
|
|
168
|
+
): Promise<string | null> {
|
|
169
|
+
// HTML content is too large for URL hashes — force paste service path
|
|
170
|
+
if (rawHtml) return null;
|
|
171
|
+
const diffContexts = buildDiffContextArray(annotations);
|
|
172
|
+
const sources = buildSourceArray(annotations);
|
|
173
|
+
const payload: SharePayload = {
|
|
174
|
+
p: markdown,
|
|
175
|
+
a: toShareable(annotations),
|
|
176
|
+
g: globalAttachments?.length ? toShareableImages(globalAttachments) : undefined,
|
|
177
|
+
...(diffContexts ? { d: diffContexts } : {}),
|
|
178
|
+
...(sources ? { s: sources } : {}),
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const hash = await compress(payload);
|
|
182
|
+
return `${baseUrl}/#${hash}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Parse a share URL hash and return the payload
|
|
187
|
+
* Returns null if no valid hash or parsing fails
|
|
188
|
+
*/
|
|
189
|
+
export async function parseShareHash(): Promise<SharePayload | null> {
|
|
190
|
+
const raw = window.location.hash.slice(1); // Remove leading #
|
|
191
|
+
const hash = raw.split('?')[0]; // Strip callback params (?cb=...&ct=...)
|
|
192
|
+
|
|
193
|
+
if (!hash) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
return (await decompress(hash)) as SharePayload;
|
|
199
|
+
} catch (e) {
|
|
200
|
+
console.warn('Failed to parse share hash:', e);
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Get the size of a URL in a human-readable format
|
|
207
|
+
*/
|
|
208
|
+
export function formatUrlSize(url: string): string {
|
|
209
|
+
const bytes = new Blob([url]).size;
|
|
210
|
+
if (bytes < 1024) {
|
|
211
|
+
return `${bytes} B`;
|
|
212
|
+
}
|
|
213
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Short URL support (paste-service backed)
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
const DEFAULT_PASTE_API = 'https://plannotator-paste.plannotator.workers.dev';
|
|
221
|
+
const DEFAULT_SHARE_BASE = 'https://share.plannotator.ai';
|
|
222
|
+
|
|
223
|
+
export class ShortShareUrlError extends Error {
|
|
224
|
+
constructor(message: string) {
|
|
225
|
+
super(message);
|
|
226
|
+
this.name = 'ShortShareUrlError';
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Create a short share URL by posting compressed plan data to the paste service.
|
|
232
|
+
*
|
|
233
|
+
* Returns `{ shortUrl, id }` on success, or `null` when the paste service is
|
|
234
|
+
* unavailable (e.g. self-hosted environments without a paste backend). Callers
|
|
235
|
+
* should fall back to the hash-based URL in that case.
|
|
236
|
+
*
|
|
237
|
+
* The request has a 5-second timeout so UI responsiveness is not affected.
|
|
238
|
+
*/
|
|
239
|
+
export async function createShortShareUrl(
|
|
240
|
+
markdown: string,
|
|
241
|
+
annotations: Annotation[],
|
|
242
|
+
globalAttachments?: ImageAttachment[],
|
|
243
|
+
options?: {
|
|
244
|
+
/** Override the paste API base URL (default: https://plannotator-paste.plannotator.workers.dev) */
|
|
245
|
+
pasteApiUrl?: string;
|
|
246
|
+
/** Override the share site base URL used in the returned short link */
|
|
247
|
+
shareBaseUrl?: string;
|
|
248
|
+
},
|
|
249
|
+
rawHtml?: string,
|
|
250
|
+
): Promise<{ shortUrl: string; id: string } | null> {
|
|
251
|
+
const pasteApi = options?.pasteApiUrl ?? DEFAULT_PASTE_API;
|
|
252
|
+
const shareBase = options?.shareBaseUrl ?? DEFAULT_SHARE_BASE;
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
const diffContexts = buildDiffContextArray(annotations);
|
|
256
|
+
const sources = buildSourceArray(annotations);
|
|
257
|
+
const payload: SharePayload = {
|
|
258
|
+
p: markdown,
|
|
259
|
+
a: toShareable(annotations),
|
|
260
|
+
g: globalAttachments?.length ? toShareableImages(globalAttachments) : undefined,
|
|
261
|
+
...(diffContexts ? { d: diffContexts } : {}),
|
|
262
|
+
...(sources ? { s: sources } : {}),
|
|
263
|
+
...(rawHtml ? { h: rawHtml, r: 'html' as const } : {}),
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const compressed = await compress(payload);
|
|
267
|
+
|
|
268
|
+
// Encrypt before uploading — server only sees ciphertext
|
|
269
|
+
const { ciphertext, key } = await encrypt(compressed);
|
|
270
|
+
|
|
271
|
+
const response = await fetch(`${pasteApi}/api/paste`, {
|
|
272
|
+
method: 'POST',
|
|
273
|
+
headers: { 'Content-Type': 'application/json' },
|
|
274
|
+
body: JSON.stringify({ data: ciphertext }),
|
|
275
|
+
signal: AbortSignal.timeout(5_000),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
if (!response.ok) {
|
|
279
|
+
if (response.status === 413) {
|
|
280
|
+
throw new ShortShareUrlError(await readPasteError(response, 'Share payload is too large'));
|
|
281
|
+
}
|
|
282
|
+
console.warn(`[sharing] Paste service returned ${response.status}`);
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const result = (await response.json()) as { id: string };
|
|
287
|
+
// Embed paste origin in fragment when non-default so the share portal can
|
|
288
|
+
// fetch from the right service without a server.
|
|
289
|
+
const pasteParam = pasteApi !== DEFAULT_PASTE_API
|
|
290
|
+
? `&paste=${btoa(pasteApi).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')}`
|
|
291
|
+
: '';
|
|
292
|
+
const shortUrl = `${shareBase}/p/${result.id}#key=${key}${pasteParam}`;
|
|
293
|
+
|
|
294
|
+
return { shortUrl, id: result.id };
|
|
295
|
+
} catch (e) {
|
|
296
|
+
if (e instanceof ShortShareUrlError) {
|
|
297
|
+
throw e;
|
|
298
|
+
}
|
|
299
|
+
// Service unavailable — expected for self-hosted setups without a paste backend.
|
|
300
|
+
// The caller is responsible for falling back to hash-based sharing silently.
|
|
301
|
+
console.debug('[sharing] Short URL service unavailable, using hash-based sharing:', e);
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function readPasteError(response: Response, fallback: string): Promise<string> {
|
|
307
|
+
try {
|
|
308
|
+
const body = (await response.json()) as { error?: unknown };
|
|
309
|
+
return typeof body.error === 'string' && body.error.trim() ? body.error : fallback;
|
|
310
|
+
} catch {
|
|
311
|
+
return fallback;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Load plan data from a paste service using the paste ID embedded in a short URL.
|
|
317
|
+
*
|
|
318
|
+
* Fetches the compressed payload from `<pasteApiUrl>/api/paste/<pasteId>` and
|
|
319
|
+
* decompresses it into a `SharePayload`. Returns `null` on any failure.
|
|
320
|
+
*/
|
|
321
|
+
export async function loadFromPasteId(
|
|
322
|
+
pasteId: string,
|
|
323
|
+
pasteApiUrl: string = DEFAULT_PASTE_API,
|
|
324
|
+
encryptionKey?: string
|
|
325
|
+
): Promise<SharePayload | null> {
|
|
326
|
+
try {
|
|
327
|
+
const response = await fetch(`${pasteApiUrl}/api/paste/${pasteId}`, {
|
|
328
|
+
signal: AbortSignal.timeout(10_000),
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
if (!response.ok) {
|
|
332
|
+
console.warn(`[sharing] Paste fetch returned ${response.status} for id ${pasteId}`);
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const result = (await response.json()) as { data: string };
|
|
337
|
+
|
|
338
|
+
if (encryptionKey) {
|
|
339
|
+
// Encrypted path: decrypt ciphertext, then decompress
|
|
340
|
+
const compressed = await decrypt(result.data, encryptionKey);
|
|
341
|
+
return await decompress(compressed) as SharePayload;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Legacy unencrypted path: decompress directly
|
|
345
|
+
return await decompress(result.data) as SharePayload;
|
|
346
|
+
} catch (e) {
|
|
347
|
+
console.warn('[sharing] Failed to load from paste ID:', e);
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
}
|