Skip to content

Commit 92599dc

Browse files
backnotpropclaude
andcommitted
feat: filename separator + auto-save on arrival for Obsidian
Filename separator: adds a dropdown in Obsidian settings to replace spaces with dashes or underscores in generated filenames. Fixes issues with CLI tools fumbling space-escaped paths inside Obsidian vaults. The setting flows through the save request to generateFilename() as a post-processing step. Live preview reflects the choice. Auto-save on arrival: adds an opt-in toggle that automatically saves plans to Obsidian the moment they load in the browser, before the user approves or denies. This fixes the workflow where approving from the Claude Code CLI kills the hook server before the user can Cmd+S. Guarded by a ref to fire only once per session. Closes #235, closes #228. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 632be20 commit 92599dc

File tree

3 files changed

+96
-1
lines changed

3 files changed

+96
-1
lines changed

packages/editor/App.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,49 @@ const App: React.FC = () => {
675675
setBlocks(parseMarkdownToBlocks(markdown));
676676
}, [markdown]);
677677

678+
// Auto-save to Obsidian on plan arrival (if enabled)
679+
const autoSaveAttempted = useRef(false);
680+
useEffect(() => {
681+
if (!isApiMode || !markdown || isSharedSession || annotateMode) return;
682+
if (autoSaveAttempted.current) return;
683+
684+
const obsSettings = getObsidianSettings();
685+
if (!obsSettings.autoSave || !obsSettings.enabled) return;
686+
687+
const vaultPath = getEffectiveVaultPath(obsSettings);
688+
if (!vaultPath) return;
689+
690+
autoSaveAttempted.current = true;
691+
692+
const body = {
693+
obsidian: {
694+
vaultPath,
695+
folder: obsSettings.folder || 'plannotator',
696+
plan: markdown,
697+
...(obsSettings.filenameFormat && { filenameFormat: obsSettings.filenameFormat }),
698+
...(obsSettings.filenameSeparator && obsSettings.filenameSeparator !== 'space' && { filenameSeparator: obsSettings.filenameSeparator }),
699+
},
700+
};
701+
702+
fetch('/api/save-notes', {
703+
method: 'POST',
704+
headers: { 'Content-Type': 'application/json' },
705+
body: JSON.stringify(body),
706+
})
707+
.then(res => res.json())
708+
.then(data => {
709+
if (data.results?.obsidian?.success) {
710+
setNoteSaveToast({ type: 'success', message: 'Auto-saved to Obsidian' });
711+
} else {
712+
setNoteSaveToast({ type: 'error', message: 'Auto-save to Obsidian failed' });
713+
}
714+
})
715+
.catch(() => {
716+
setNoteSaveToast({ type: 'error', message: 'Auto-save to Obsidian failed' });
717+
})
718+
.finally(() => setTimeout(() => setNoteSaveToast(null), 3000));
719+
}, [isApiMode, markdown, isSharedSession, annotateMode]);
720+
678721
// Global paste listener for image attachments
679722
useEffect(() => {
680723
const handlePaste = (e: ClipboardEvent) => {
@@ -767,6 +810,7 @@ const App: React.FC = () => {
767810
folder: obsidianSettings.folder || 'plannotator',
768811
plan: markdown,
769812
...(obsidianSettings.filenameFormat && { filenameFormat: obsidianSettings.filenameFormat }),
813+
...(obsidianSettings.filenameSeparator && obsidianSettings.filenameSeparator !== 'space' && { filenameSeparator: obsidianSettings.filenameSeparator }),
770814
};
771815
}
772816

@@ -980,6 +1024,7 @@ const App: React.FC = () => {
9801024
folder: s.folder || 'plannotator',
9811025
plan: markdown,
9821026
...(s.filenameFormat && { filenameFormat: s.filenameFormat }),
1027+
...(s.filenameSeparator && s.filenameSeparator !== 'space' && { filenameSeparator: s.filenameSeparator }),
9831028
};
9841029
}
9851030
}

packages/ui/components/Settings.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -732,11 +732,30 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
732732
mm: String(now.getMinutes()).padStart(2,'0'), ss: String(now.getSeconds()).padStart(2,'0'),
733733
ampm: h24 >= 12 ? 'pm' : 'am',
734734
};
735-
return fmt.replace(/\{(\w+)\}/g, (m, k) => vars[k] ?? m) + '.md';
735+
let preview = fmt.replace(/\{(\w+)\}/g, (m, k) => vars[k] ?? m) + '.md';
736+
if (obsidian.filenameSeparator === 'dash') preview = preview.replace(/ /g, '-');
737+
else if (obsidian.filenameSeparator === 'underscore') preview = preview.replace(/ /g, '_');
738+
return preview;
736739
})()}
737740
</div>
738741
</div>
739742

743+
<div className="space-y-1.5">
744+
<label className="text-xs text-muted-foreground">Filename Separator</label>
745+
<select
746+
value={obsidian.filenameSeparator || 'space'}
747+
onChange={(e) => handleObsidianChange({ filenameSeparator: e.target.value as 'space' | 'dash' | 'underscore' })}
748+
className="w-full px-3 py-2 bg-muted rounded-lg text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
749+
>
750+
<option value="space">Spaces (default)</option>
751+
<option value="dash">Dashes (-)</option>
752+
<option value="underscore">Underscores (_)</option>
753+
</select>
754+
<div className="text-[10px] text-muted-foreground/70">
755+
Replaces spaces in the generated filename. Useful when working with CLI tools in your vault.
756+
</div>
757+
</div>
758+
740759
<div className="text-[10px] text-muted-foreground/70">
741760
Plans saved to: {obsidian.vaultPath === CUSTOM_PATH_SENTINEL
742761
? (obsidian.customPath || '...')
@@ -756,6 +775,27 @@ tags: [plan, ...]
756775

757776
<div className="border-t border-border/30" />
758777

778+
<div className="flex items-center justify-between">
779+
<div>
780+
<div className="text-xs font-medium">Auto-save on Plan Arrival</div>
781+
<div className="text-[10px] text-muted-foreground">
782+
Automatically save to Obsidian when a plan loads, before you approve or deny
783+
</div>
784+
</div>
785+
<button
786+
role="switch"
787+
aria-checked={obsidian.autoSave}
788+
onClick={() => handleObsidianChange({ autoSave: !obsidian.autoSave })}
789+
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${
790+
obsidian.autoSave ? 'bg-primary' : 'bg-muted'
791+
}`}
792+
>
793+
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
794+
obsidian.autoSave ? 'translate-x-6' : 'translate-x-1'
795+
}`} />
796+
</button>
797+
</div>
798+
759799
<div className="flex items-center justify-between">
760800
<div>
761801
<div className="text-xs font-medium">Vault Browser</div>

packages/ui/utils/obsidian.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ const STORAGE_KEY_FOLDER = 'plannotator-obsidian-folder';
1515
const STORAGE_KEY_CUSTOM_PATH = 'plannotator-obsidian-custom-path';
1616
const STORAGE_KEY_FILENAME_FORMAT = 'plannotator-obsidian-filename-format';
1717
const STORAGE_KEY_VAULT_BROWSER = 'plannotator-obsidian-vault-browser';
18+
const STORAGE_KEY_AUTOSAVE = 'plannotator-obsidian-autosave';
19+
const STORAGE_KEY_FILENAME_SEPARATOR = 'plannotator-obsidian-filename-separator';
1820

1921
// Sentinel value for custom path selection
2022
export const CUSTOM_PATH_SENTINEL = '__custom__';
@@ -28,12 +30,16 @@ export const DEFAULT_FILENAME_FORMAT = '{title} - {Mon} {D}, {YYYY} {h}-{mm}{amp
2830
/**
2931
* Obsidian integration settings
3032
*/
33+
export type FilenameSeparator = 'space' | 'dash' | 'underscore';
34+
3135
export interface ObsidianSettings {
3236
enabled: boolean;
3337
vaultPath: string; // Selected vault path OR '__custom__' sentinel
3438
folder: string;
3539
customPath?: string; // User-entered path when vaultPath === '__custom__'
3640
filenameFormat?: string; // Custom filename format (e.g. '{YYYY}-{MM}-{DD} - {title}')
41+
filenameSeparator: FilenameSeparator; // Replace spaces in filename with dash/underscore
42+
autoSave: boolean; // Auto-save to Obsidian on plan arrival
3743
vaultBrowserEnabled: boolean; // Show vault file browser in sidebar
3844
}
3945

@@ -47,6 +53,8 @@ export function getObsidianSettings(): ObsidianSettings {
4753
folder: storage.getItem(STORAGE_KEY_FOLDER) || DEFAULT_FOLDER,
4854
customPath: storage.getItem(STORAGE_KEY_CUSTOM_PATH) || undefined,
4955
filenameFormat: storage.getItem(STORAGE_KEY_FILENAME_FORMAT) || undefined,
56+
filenameSeparator: (storage.getItem(STORAGE_KEY_FILENAME_SEPARATOR) as FilenameSeparator) || 'space',
57+
autoSave: storage.getItem(STORAGE_KEY_AUTOSAVE) === 'true',
5058
vaultBrowserEnabled: storage.getItem(STORAGE_KEY_VAULT_BROWSER) === 'true',
5159
};
5260
}
@@ -60,6 +68,8 @@ export function saveObsidianSettings(settings: ObsidianSettings): void {
6068
storage.setItem(STORAGE_KEY_FOLDER, settings.folder);
6169
storage.setItem(STORAGE_KEY_CUSTOM_PATH, settings.customPath || '');
6270
storage.setItem(STORAGE_KEY_FILENAME_FORMAT, settings.filenameFormat || '');
71+
storage.setItem(STORAGE_KEY_FILENAME_SEPARATOR, settings.filenameSeparator || 'space');
72+
storage.setItem(STORAGE_KEY_AUTOSAVE, String(settings.autoSave));
6373
storage.setItem(STORAGE_KEY_VAULT_BROWSER, String(settings.vaultBrowserEnabled));
6474
}
6575

0 commit comments

Comments
 (0)