Skip to content

Commit d5e0ff6

Browse files
backnotpropclaude
andauthored
feat: Octarine notes integration + auto-save for all integrations (#297)
* feat: Octarine notes integration + auto-save for all integrations Add Octarine as a third notes app integration alongside Obsidian and Bear. Uses the octarine:// URI scheme to create notes via deep links, following the same x-callback-url pattern as Bear. - New file: packages/ui/utils/octarine.ts (cookie-backed settings) - Server: saveToOctarine() in integrations.ts, wired into /api/approve and /api/save-notes endpoints - UI: Octarine tab in Settings, card in Export > Notes, dropdown button, Cmd+S shortcut support - Parallelize all integration saves with Promise.allSettled (was sequential) - Add auto-save on plan arrival toggle to Bear and Octarine (Obsidian already had this), consolidate into a single effect + single API call Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add YAML frontmatter properties to Octarine notes Prepend Octarine-compatible YAML frontmatter with tags, Status, Author, and Last Edited properties. Uses the same extractTags() as Obsidian for auto-generated tags (project name, title words, code fence languages). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use fresh=true for Octarine saves to prevent content duplication Octarine's create action appends by default. If auto-save fires on plan arrival and the user then approves within the same minute, the same path gets hit twice — doubling the content. Using fresh=true replaces instead. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: trim workspace and folder in saveToOctarine before building URI The UI checks workspace.trim().length > 0 but the server used raw values. Accidental whitespace in settings would cause Octarine saves to fail. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use isOctarineConfigured() for client-side Octarine guards Auto-save and approve checks used raw workspace truthiness, so whitespace-only workspace would trigger a save attempt that the server rejects. Now uses isOctarineConfigured() which trims before checking. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: prevent Bear duplicate notes and align default-save dropdown gates Bear creates a new note on every save, so skip it on approve when arrival auto-save already succeeded. Gate the default-save dropdown by actual configuration (vault path for Obsidian, workspace for Octarine) instead of just the enabled toggle, matching the shortcut behavior. Self-heal stale defaults back to "ask". Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3bbefa9 commit d5e0ff6

8 files changed

Lines changed: 488 additions & 65 deletions

File tree

packages/editor/App.tsx

Lines changed: 114 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay'
2020
import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner';
2121
import { getObsidianSettings, getEffectiveVaultPath, isObsidianConfigured, CUSTOM_PATH_SENTINEL } from '@plannotator/ui/utils/obsidian';
2222
import { getBearSettings } from '@plannotator/ui/utils/bear';
23+
import { getOctarineSettings, isOctarineConfigured } from '@plannotator/ui/utils/octarine';
2324
import { getDefaultNotesApp } from '@plannotator/ui/utils/defaultNotesApp';
2425
import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch';
2526
import { getPlanSaveSettings } from '@plannotator/ui/utils/planSave';
@@ -51,6 +52,12 @@ import { PlanDiffViewer } from '@plannotator/ui/components/plan-diff/PlanDiffVie
5152
import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiffModeSwitcher';
5253
import { DEMO_PLAN_CONTENT } from './demoPlan';
5354

55+
type NoteAutoSaveResults = {
56+
obsidian?: boolean;
57+
bear?: boolean;
58+
octarine?: boolean;
59+
};
60+
5461
const App: React.FC = () => {
5562
const [markdown, setMarkdown] = useState(DEMO_PLAN_CONTENT);
5663
const [annotations, setAnnotations] = useState<Annotation[]>([]);
@@ -366,47 +373,92 @@ const App: React.FC = () => {
366373
setBlocks(parseMarkdownToBlocks(markdown));
367374
}, [markdown]);
368375

369-
// Auto-save to Obsidian on plan arrival (if enabled)
376+
// Auto-save to notes apps on plan arrival (each gated by its autoSave toggle)
370377
const autoSaveAttempted = useRef(false);
378+
const autoSaveResultsRef = useRef<NoteAutoSaveResults>({});
379+
const autoSavePromiseRef = useRef<Promise<NoteAutoSaveResults> | null>(null);
380+
381+
useEffect(() => {
382+
autoSaveAttempted.current = false;
383+
autoSaveResultsRef.current = {};
384+
autoSavePromiseRef.current = null;
385+
}, [markdown]);
386+
371387
useEffect(() => {
372388
if (!isApiMode || !markdown || isSharedSession || annotateMode) return;
373389
if (autoSaveAttempted.current) return;
374390

375-
const obsSettings = getObsidianSettings();
376-
if (!obsSettings.autoSave || !obsSettings.enabled) return;
391+
const body: { obsidian?: object; bear?: object; octarine?: object } = {};
392+
const targets: string[] = [];
377393

378-
const vaultPath = getEffectiveVaultPath(obsSettings);
379-
if (!vaultPath) return;
394+
const obsSettings = getObsidianSettings();
395+
if (obsSettings.autoSave && obsSettings.enabled) {
396+
const vaultPath = getEffectiveVaultPath(obsSettings);
397+
if (vaultPath) {
398+
body.obsidian = {
399+
vaultPath,
400+
folder: obsSettings.folder || 'plannotator',
401+
plan: markdown,
402+
...(obsSettings.filenameFormat && { filenameFormat: obsSettings.filenameFormat }),
403+
...(obsSettings.filenameSeparator && obsSettings.filenameSeparator !== 'space' && { filenameSeparator: obsSettings.filenameSeparator }),
404+
};
405+
targets.push('Obsidian');
406+
}
407+
}
380408

381-
autoSaveAttempted.current = true;
409+
const bearSettings = getBearSettings();
410+
if (bearSettings.autoSave && bearSettings.enabled) {
411+
body.bear = {
412+
plan: markdown,
413+
customTags: bearSettings.customTags,
414+
tagPosition: bearSettings.tagPosition,
415+
};
416+
targets.push('Bear');
417+
}
382418

383-
const body = {
384-
obsidian: {
385-
vaultPath,
386-
folder: obsSettings.folder || 'plannotator',
419+
const octSettings = getOctarineSettings();
420+
if (octSettings.autoSave && isOctarineConfigured()) {
421+
body.octarine = {
387422
plan: markdown,
388-
...(obsSettings.filenameFormat && { filenameFormat: obsSettings.filenameFormat }),
389-
...(obsSettings.filenameSeparator && obsSettings.filenameSeparator !== 'space' && { filenameSeparator: obsSettings.filenameSeparator }),
390-
},
391-
};
423+
workspace: octSettings.workspace,
424+
folder: octSettings.folder || 'plannotator',
425+
};
426+
targets.push('Octarine');
427+
}
428+
429+
if (targets.length === 0) return;
430+
autoSaveAttempted.current = true;
392431

393-
fetch('/api/save-notes', {
432+
const autoSavePromise = fetch('/api/save-notes', {
394433
method: 'POST',
395434
headers: { 'Content-Type': 'application/json' },
396435
body: JSON.stringify(body),
397436
})
398437
.then(res => res.json())
399438
.then(data => {
400-
if (data.results?.obsidian?.success) {
401-
setNoteSaveToast({ type: 'success', message: 'Auto-saved to Obsidian' });
439+
const results: NoteAutoSaveResults = {
440+
...(body.obsidian ? { obsidian: Boolean(data.results?.obsidian?.success) } : {}),
441+
...(body.bear ? { bear: Boolean(data.results?.bear?.success) } : {}),
442+
...(body.octarine ? { octarine: Boolean(data.results?.octarine?.success) } : {}),
443+
};
444+
autoSaveResultsRef.current = results;
445+
446+
const failed = targets.filter(t => !data.results?.[t.toLowerCase()]?.success);
447+
if (failed.length === 0) {
448+
setNoteSaveToast({ type: 'success', message: `Auto-saved to ${targets.join(' & ')}` });
402449
} else {
403-
setNoteSaveToast({ type: 'error', message: 'Auto-save to Obsidian failed' });
450+
setNoteSaveToast({ type: 'error', message: `Auto-save failed for ${failed.join(' & ')}` });
404451
}
452+
453+
return results;
405454
})
406455
.catch(() => {
407-
setNoteSaveToast({ type: 'error', message: 'Auto-save to Obsidian failed' });
456+
autoSaveResultsRef.current = {};
457+
setNoteSaveToast({ type: 'error', message: 'Auto-save failed' });
458+
return {};
408459
})
409460
.finally(() => setTimeout(() => setNoteSaveToast(null), 3000));
461+
autoSavePromiseRef.current = autoSavePromise;
410462
}, [isApiMode, markdown, isSharedSession, annotateMode]);
411463

412464
// Global paste listener for image attachments
@@ -471,11 +523,15 @@ const App: React.FC = () => {
471523
try {
472524
const obsidianSettings = getObsidianSettings();
473525
const bearSettings = getBearSettings();
526+
const octarineSettings = getOctarineSettings();
474527
const agentSwitchSettings = getAgentSwitchSettings();
475528
const planSaveSettings = getPlanSaveSettings();
529+
const autoSaveResults = bearSettings.autoSave && autoSavePromiseRef.current
530+
? await autoSavePromiseRef.current
531+
: autoSaveResultsRef.current;
476532

477533
// Build request body - include integrations if enabled
478-
const body: { obsidian?: object; bear?: object; feedback?: string; agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string } = {};
534+
const body: { obsidian?: object; bear?: object; octarine?: object; feedback?: string; agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string } = {};
479535

480536
// Include permission mode for Claude Code
481537
if (origin === 'claude-code') {
@@ -505,14 +561,24 @@ const App: React.FC = () => {
505561
};
506562
}
507563

508-
if (bearSettings.enabled) {
564+
// Bear creates a new note each time, so don't send it again on approve
565+
// if the arrival auto-save already succeeded.
566+
if (bearSettings.enabled && !(bearSettings.autoSave && autoSaveResults.bear)) {
509567
body.bear = {
510568
plan: markdown,
511569
customTags: bearSettings.customTags,
512570
tagPosition: bearSettings.tagPosition,
513571
};
514572
}
515573

574+
if (isOctarineConfigured()) {
575+
body.octarine = {
576+
plan: markdown,
577+
workspace: octarineSettings.workspace,
578+
folder: octarineSettings.folder || 'plannotator',
579+
};
580+
}
581+
516582
// Include annotations as feedback if any exist (for OpenCode "approve with notes")
517583
const hasDocAnnotations = Array.from(linkedDocHook.getDocAnnotations().values()).some(
518584
(d) => d.annotations.length > 0 || d.globalAttachments.length > 0
@@ -717,9 +783,9 @@ const App: React.FC = () => {
717783
setTimeout(() => setNoteSaveToast(null), 3000);
718784
};
719785

720-
const handleQuickSaveToNotes = async (target: 'obsidian' | 'bear') => {
786+
const handleQuickSaveToNotes = async (target: 'obsidian' | 'bear' | 'octarine') => {
721787
setShowExportDropdown(false);
722-
const body: { obsidian?: object; bear?: object } = {};
788+
const body: { obsidian?: object; bear?: object; octarine?: object } = {};
723789

724790
if (target === 'obsidian') {
725791
const s = getObsidianSettings();
@@ -742,7 +808,16 @@ const App: React.FC = () => {
742808
tagPosition: bs.tagPosition,
743809
};
744810
}
811+
if (target === 'octarine') {
812+
const os = getOctarineSettings();
813+
body.octarine = {
814+
plan: markdown,
815+
workspace: os.workspace,
816+
folder: os.folder || 'plannotator',
817+
};
818+
}
745819

820+
const targetName = target === 'obsidian' ? 'Obsidian' : target === 'bear' ? 'Bear' : 'Octarine';
746821
try {
747822
const res = await fetch('/api/save-notes', {
748823
method: 'POST',
@@ -752,7 +827,7 @@ const App: React.FC = () => {
752827
const data = await res.json();
753828
const result = data.results?.[target];
754829
if (result?.success) {
755-
setNoteSaveToast({ type: 'success', message: `Saved to ${target === 'obsidian' ? 'Obsidian' : 'Bear'}` });
830+
setNoteSaveToast({ type: 'success', message: `Saved to ${targetName}` });
756831
} else {
757832
setNoteSaveToast({ type: 'error', message: result?.error || 'Save failed' });
758833
}
@@ -780,13 +855,16 @@ const App: React.FC = () => {
780855
const defaultApp = getDefaultNotesApp();
781856
const obsOk = isObsidianConfigured();
782857
const bearOk = getBearSettings().enabled;
858+
const octOk = isOctarineConfigured();
783859

784860
if (defaultApp === 'download') {
785861
handleDownloadAnnotations();
786862
} else if (defaultApp === 'obsidian' && obsOk) {
787863
handleQuickSaveToNotes('obsidian');
788864
} else if (defaultApp === 'bear' && bearOk) {
789865
handleQuickSaveToNotes('bear');
866+
} else if (defaultApp === 'octarine' && octOk) {
867+
handleQuickSaveToNotes('octarine');
790868
} else {
791869
setInitialExportTab('notes');
792870
setShowExport(true);
@@ -1025,7 +1103,18 @@ const App: React.FC = () => {
10251103
Save to Bear
10261104
</button>
10271105
)}
1028-
{isApiMode && !isObsidianConfigured() && !getBearSettings().enabled && (
1106+
{isApiMode && isOctarineConfigured() && (
1107+
<button
1108+
onClick={() => handleQuickSaveToNotes('octarine')}
1109+
className="w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors flex items-center gap-2"
1110+
>
1111+
<svg className="w-3.5 h-3.5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
1112+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
1113+
</svg>
1114+
Save to Octarine
1115+
</button>
1116+
)}
1117+
{isApiMode && !isObsidianConfigured() && !getBearSettings().enabled && !isOctarineConfigured() && (
10291118
<div className="px-3 py-2 text-[10px] text-muted-foreground">
10301119
No notes apps configured.
10311120
</div>

packages/server/index.ts

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ import { openEditorDiff } from "./ide";
1515
import {
1616
saveToObsidian,
1717
saveToBear,
18+
saveToOctarine,
1819
type ObsidianConfig,
1920
type BearConfig,
21+
type OctarineConfig,
2022
type IntegrationResult,
2123
} from "./integrations";
2224
import {
@@ -274,29 +276,33 @@ export async function startPlannotatorServer(
274276

275277
// API: Save to notes (decoupled from approve/deny)
276278
if (url.pathname === "/api/save-notes" && req.method === "POST") {
277-
const results: { obsidian?: IntegrationResult; bear?: IntegrationResult } = {};
279+
const results: { obsidian?: IntegrationResult; bear?: IntegrationResult; octarine?: IntegrationResult } = {};
278280

279281
try {
280282
const body = (await req.json()) as {
281283
obsidian?: ObsidianConfig;
282284
bear?: BearConfig;
285+
octarine?: OctarineConfig;
283286
};
284287

288+
// Run integrations in parallel — they're independent
289+
const promises: Promise<void>[] = [];
285290
if (body.obsidian?.vaultPath && body.obsidian?.plan) {
286-
results.obsidian = await saveToObsidian(body.obsidian);
287-
if (results.obsidian.success) {
288-
console.error(`[Obsidian] Saved plan to: ${results.obsidian.path}`);
289-
} else {
290-
console.error(`[Obsidian] Save failed: ${results.obsidian.error}`);
291-
}
291+
promises.push(saveToObsidian(body.obsidian).then(r => { results.obsidian = r; }));
292292
}
293-
294293
if (body.bear?.plan) {
295-
results.bear = await saveToBear(body.bear);
296-
if (results.bear.success) {
297-
console.error(`[Bear] Saved plan to Bear`);
298-
} else {
299-
console.error(`[Bear] Save failed: ${results.bear.error}`);
294+
promises.push(saveToBear(body.bear).then(r => { results.bear = r; }));
295+
}
296+
if (body.octarine?.plan && body.octarine?.workspace) {
297+
promises.push(saveToOctarine(body.octarine).then(r => { results.octarine = r; }));
298+
}
299+
await Promise.allSettled(promises);
300+
301+
for (const [name, result] of Object.entries(results)) {
302+
if (result?.success) {
303+
console.error(`[${name}] Saved plan${result.path ? ` to: ${result.path}` : ''}`);
304+
} else if (result) {
305+
console.error(`[${name}] Save failed: ${result.error}`);
300306
}
301307
}
302308
} catch (err) {
@@ -319,6 +325,7 @@ export async function startPlannotatorServer(
319325
const body = (await req.json().catch(() => ({}))) as {
320326
obsidian?: ObsidianConfig;
321327
bear?: BearConfig;
328+
octarine?: OctarineConfig;
322329
feedback?: string;
323330
agentSwitch?: string;
324331
planSave?: { enabled: boolean; customPath?: string };
@@ -346,23 +353,25 @@ export async function startPlannotatorServer(
346353
planSaveCustomPath = body.planSave.customPath;
347354
}
348355

349-
// Obsidian integration
356+
// Run integrations in parallel — they're independent
357+
const integrationResults: Record<string, IntegrationResult> = {};
358+
const integrationPromises: Promise<void>[] = [];
350359
if (body.obsidian?.vaultPath && body.obsidian?.plan) {
351-
const result = await saveToObsidian(body.obsidian);
352-
if (result.success) {
353-
console.error(`[Obsidian] Saved plan to: ${result.path}`);
354-
} else {
355-
console.error(`[Obsidian] Save failed: ${result.error}`);
356-
}
360+
integrationPromises.push(saveToObsidian(body.obsidian).then(r => { integrationResults.obsidian = r; }));
357361
}
358-
359-
// Bear integration
360362
if (body.bear?.plan) {
361-
const result = await saveToBear(body.bear);
362-
if (result.success) {
363-
console.error(`[Bear] Saved plan to Bear`);
364-
} else {
365-
console.error(`[Bear] Save failed: ${result.error}`);
363+
integrationPromises.push(saveToBear(body.bear).then(r => { integrationResults.bear = r; }));
364+
}
365+
if (body.octarine?.plan && body.octarine?.workspace) {
366+
integrationPromises.push(saveToOctarine(body.octarine).then(r => { integrationResults.octarine = r; }));
367+
}
368+
await Promise.allSettled(integrationPromises);
369+
370+
for (const [name, result] of Object.entries(integrationResults)) {
371+
if (result?.success) {
372+
console.error(`[${name}] Saved plan${result.path ? ` to: ${result.path}` : ''}`);
373+
} else if (result) {
374+
console.error(`[${name}] Save failed: ${result.error}`);
366375
}
367376
}
368377
} catch (err) {

0 commit comments

Comments
 (0)