Skip to content

Commit 1f5178b

Browse files
backnotpropclaude
andcommitted
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>
1 parent e1bd27a commit 1f5178b

8 files changed

Lines changed: 409 additions & 61 deletions

File tree

packages/editor/App.tsx

Lines changed: 80 additions & 23 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';
@@ -366,29 +367,52 @@ const App: React.FC = () => {
366367
setBlocks(parseMarkdownToBlocks(markdown));
367368
}, [markdown]);
368369

369-
// Auto-save to Obsidian on plan arrival (if enabled)
370+
// Auto-save to notes apps on plan arrival (each gated by its autoSave toggle)
370371
const autoSaveAttempted = useRef(false);
371372
useEffect(() => {
372373
if (!isApiMode || !markdown || isSharedSession || annotateMode) return;
373374
if (autoSaveAttempted.current) return;
374375

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

378-
const vaultPath = getEffectiveVaultPath(obsSettings);
379-
if (!vaultPath) return;
379+
const obsSettings = getObsidianSettings();
380+
if (obsSettings.autoSave && obsSettings.enabled) {
381+
const vaultPath = getEffectiveVaultPath(obsSettings);
382+
if (vaultPath) {
383+
body.obsidian = {
384+
vaultPath,
385+
folder: obsSettings.folder || 'plannotator',
386+
plan: markdown,
387+
...(obsSettings.filenameFormat && { filenameFormat: obsSettings.filenameFormat }),
388+
...(obsSettings.filenameSeparator && obsSettings.filenameSeparator !== 'space' && { filenameSeparator: obsSettings.filenameSeparator }),
389+
};
390+
targets.push('Obsidian');
391+
}
392+
}
380393

381-
autoSaveAttempted.current = true;
394+
const bearSettings = getBearSettings();
395+
if (bearSettings.autoSave && bearSettings.enabled) {
396+
body.bear = {
397+
plan: markdown,
398+
customTags: bearSettings.customTags,
399+
tagPosition: bearSettings.tagPosition,
400+
};
401+
targets.push('Bear');
402+
}
382403

383-
const body = {
384-
obsidian: {
385-
vaultPath,
386-
folder: obsSettings.folder || 'plannotator',
404+
const octSettings = getOctarineSettings();
405+
if (octSettings.autoSave && octSettings.enabled && octSettings.workspace) {
406+
body.octarine = {
387407
plan: markdown,
388-
...(obsSettings.filenameFormat && { filenameFormat: obsSettings.filenameFormat }),
389-
...(obsSettings.filenameSeparator && obsSettings.filenameSeparator !== 'space' && { filenameSeparator: obsSettings.filenameSeparator }),
390-
},
391-
};
408+
workspace: octSettings.workspace,
409+
folder: octSettings.folder || 'plannotator',
410+
};
411+
targets.push('Octarine');
412+
}
413+
414+
if (targets.length === 0) return;
415+
autoSaveAttempted.current = true;
392416

393417
fetch('/api/save-notes', {
394418
method: 'POST',
@@ -397,14 +421,15 @@ const App: React.FC = () => {
397421
})
398422
.then(res => res.json())
399423
.then(data => {
400-
if (data.results?.obsidian?.success) {
401-
setNoteSaveToast({ type: 'success', message: 'Auto-saved to Obsidian' });
424+
const failed = targets.filter(t => !data.results?.[t.toLowerCase()]?.success);
425+
if (failed.length === 0) {
426+
setNoteSaveToast({ type: 'success', message: `Auto-saved to ${targets.join(' & ')}` });
402427
} else {
403-
setNoteSaveToast({ type: 'error', message: 'Auto-save to Obsidian failed' });
428+
setNoteSaveToast({ type: 'error', message: `Auto-save failed for ${failed.join(' & ')}` });
404429
}
405430
})
406431
.catch(() => {
407-
setNoteSaveToast({ type: 'error', message: 'Auto-save to Obsidian failed' });
432+
setNoteSaveToast({ type: 'error', message: 'Auto-save failed' });
408433
})
409434
.finally(() => setTimeout(() => setNoteSaveToast(null), 3000));
410435
}, [isApiMode, markdown, isSharedSession, annotateMode]);
@@ -471,11 +496,12 @@ const App: React.FC = () => {
471496
try {
472497
const obsidianSettings = getObsidianSettings();
473498
const bearSettings = getBearSettings();
499+
const octarineSettings = getOctarineSettings();
474500
const agentSwitchSettings = getAgentSwitchSettings();
475501
const planSaveSettings = getPlanSaveSettings();
476502

477503
// 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 } = {};
504+
const body: { obsidian?: object; bear?: object; octarine?: object; feedback?: string; agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string } = {};
479505

480506
// Include permission mode for Claude Code
481507
if (origin === 'claude-code') {
@@ -513,6 +539,14 @@ const App: React.FC = () => {
513539
};
514540
}
515541

542+
if (octarineSettings.enabled && octarineSettings.workspace) {
543+
body.octarine = {
544+
plan: markdown,
545+
workspace: octarineSettings.workspace,
546+
folder: octarineSettings.folder || 'plannotator',
547+
};
548+
}
549+
516550
// Include annotations as feedback if any exist (for OpenCode "approve with notes")
517551
const hasDocAnnotations = Array.from(linkedDocHook.getDocAnnotations().values()).some(
518552
(d) => d.annotations.length > 0 || d.globalAttachments.length > 0
@@ -717,9 +751,9 @@ const App: React.FC = () => {
717751
setTimeout(() => setNoteSaveToast(null), 3000);
718752
};
719753

720-
const handleQuickSaveToNotes = async (target: 'obsidian' | 'bear') => {
754+
const handleQuickSaveToNotes = async (target: 'obsidian' | 'bear' | 'octarine') => {
721755
setShowExportDropdown(false);
722-
const body: { obsidian?: object; bear?: object } = {};
756+
const body: { obsidian?: object; bear?: object; octarine?: object } = {};
723757

724758
if (target === 'obsidian') {
725759
const s = getObsidianSettings();
@@ -742,7 +776,16 @@ const App: React.FC = () => {
742776
tagPosition: bs.tagPosition,
743777
};
744778
}
779+
if (target === 'octarine') {
780+
const os = getOctarineSettings();
781+
body.octarine = {
782+
plan: markdown,
783+
workspace: os.workspace,
784+
folder: os.folder || 'plannotator',
785+
};
786+
}
745787

788+
const targetName = target === 'obsidian' ? 'Obsidian' : target === 'bear' ? 'Bear' : 'Octarine';
746789
try {
747790
const res = await fetch('/api/save-notes', {
748791
method: 'POST',
@@ -752,7 +795,7 @@ const App: React.FC = () => {
752795
const data = await res.json();
753796
const result = data.results?.[target];
754797
if (result?.success) {
755-
setNoteSaveToast({ type: 'success', message: `Saved to ${target === 'obsidian' ? 'Obsidian' : 'Bear'}` });
798+
setNoteSaveToast({ type: 'success', message: `Saved to ${targetName}` });
756799
} else {
757800
setNoteSaveToast({ type: 'error', message: result?.error || 'Save failed' });
758801
}
@@ -780,13 +823,16 @@ const App: React.FC = () => {
780823
const defaultApp = getDefaultNotesApp();
781824
const obsOk = isObsidianConfigured();
782825
const bearOk = getBearSettings().enabled;
826+
const octOk = isOctarineConfigured();
783827

784828
if (defaultApp === 'download') {
785829
handleDownloadAnnotations();
786830
} else if (defaultApp === 'obsidian' && obsOk) {
787831
handleQuickSaveToNotes('obsidian');
788832
} else if (defaultApp === 'bear' && bearOk) {
789833
handleQuickSaveToNotes('bear');
834+
} else if (defaultApp === 'octarine' && octOk) {
835+
handleQuickSaveToNotes('octarine');
790836
} else {
791837
setInitialExportTab('notes');
792838
setShowExport(true);
@@ -1025,7 +1071,18 @@ const App: React.FC = () => {
10251071
Save to Bear
10261072
</button>
10271073
)}
1028-
{isApiMode && !isObsidianConfigured() && !getBearSettings().enabled && (
1074+
{isApiMode && isOctarineConfigured() && (
1075+
<button
1076+
onClick={() => handleQuickSaveToNotes('octarine')}
1077+
className="w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors flex items-center gap-2"
1078+
>
1079+
<svg className="w-3.5 h-3.5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
1080+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
1081+
</svg>
1082+
Save to Octarine
1083+
</button>
1084+
)}
1085+
{isApiMode && !isObsidianConfigured() && !getBearSettings().enabled && !isOctarineConfigured() && (
10291086
<div className="px-3 py-2 text-[10px] text-muted-foreground">
10301087
No notes apps configured.
10311088
</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) {

packages/server/integrations.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ export interface BearConfig {
2323
tagPosition?: 'prepend' | 'append';
2424
}
2525

26+
export interface OctarineConfig {
27+
plan: string;
28+
workspace: string;
29+
folder: string;
30+
}
31+
2632
export interface IntegrationResult {
2733
success: boolean;
2834
error?: string;
@@ -320,3 +326,28 @@ export async function saveToBear(config: BearConfig): Promise<IntegrationResult>
320326
return { success: false, error: message };
321327
}
322328
}
329+
330+
// --- Octarine Integration ---
331+
332+
/**
333+
* Save plan to Octarine using octarine:// URI scheme
334+
*/
335+
export async function saveToOctarine(config: OctarineConfig): Promise<IntegrationResult> {
336+
try {
337+
const { plan, workspace, folder } = config;
338+
339+
const filename = generateFilename(plan);
340+
// Strip .md — Octarine auto-adds it
341+
const basename = filename.replace(/\.md$/, '');
342+
const path = folder ? `${folder}/${basename}` : basename;
343+
344+
const url = `octarine://create?path=${encodeURIComponent(path)}&content=${encodeURIComponent(plan)}&workspace=${encodeURIComponent(workspace)}&openAfter=false`;
345+
346+
await $`open ${url}`.quiet();
347+
348+
return { success: true, path };
349+
} catch (err) {
350+
const message = err instanceof Error ? err.message : "Unknown error";
351+
return { success: false, error: message };
352+
}
353+
}

0 commit comments

Comments
 (0)