Skip to content

Commit d2a159c

Browse files
backnotpropclaude
andauthored
Add customizable filename format for Obsidian exports (#202)
* feat: add customizable filename format for Obsidian export (#167) Add a filename format setting to the Obsidian integration that supports template variables ({title}, {YYYY}, {MM}, {DD}, {Mon}, {D}, {HH}, {h}, {hh}, {mm}, {ss}, {ampm}). Default format preserves existing behavior. Settings UI shows a live preview of the resulting filename. https://claude.ai/code/session_01QycvXHHvAy38N36PVP3jFF * fix: guard against double .md extension in custom filename format Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7004321 commit d2a159c

5 files changed

Lines changed: 97 additions & 14 deletions

File tree

packages/editor/App.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,7 @@ const App: React.FC = () => {
649649
vaultPath: effectiveVaultPath,
650650
folder: obsidianSettings.folder || 'plannotator',
651651
plan: markdown,
652+
...(obsidianSettings.filenameFormat && { filenameFormat: obsidianSettings.filenameFormat }),
652653
};
653654
}
654655

@@ -857,7 +858,12 @@ const App: React.FC = () => {
857858
const s = getObsidianSettings();
858859
const vaultPath = getEffectiveVaultPath(s);
859860
if (vaultPath) {
860-
body.obsidian = { vaultPath, folder: s.folder || 'plannotator', plan: markdown };
861+
body.obsidian = {
862+
vaultPath,
863+
folder: s.folder || 'plannotator',
864+
plan: markdown,
865+
...(s.filenameFormat && { filenameFormat: s.filenameFormat }),
866+
};
861867
}
862868
}
863869
if (target === 'bear') {

packages/server/integrations.ts

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface ObsidianConfig {
1313
vaultPath: string;
1414
folder: string;
1515
plan: string;
16+
filenameFormat?: string; // Custom format string, e.g. '{YYYY}-{MM}-{DD} - {title}'
1617
}
1718

1819
export interface BearConfig {
@@ -104,26 +105,62 @@ export function extractTitle(markdown: string): string {
104105
return 'Plan';
105106
}
106107

108+
/** Default filename format matching original behavior */
109+
export const DEFAULT_FILENAME_FORMAT = '{title} - {Mon} {D}, {YYYY} {h}-{mm}{ampm}';
110+
107111
/**
108-
* Generate human-readable filename: Title - Mon D, YYYY H-MMam.md
109-
* Example: User Authentication - Jan 2, 2026 2-30pm.md
112+
* Generate filename from a format string with variable substitution.
113+
*
114+
* Supported variables:
115+
* {title} - Plan title from first H1 heading
116+
* {YYYY} - 4-digit year
117+
* {MM} - 2-digit month (01-12)
118+
* {DD} - 2-digit day (01-31)
119+
* {Mon} - Abbreviated month name (Jan, Feb, ...)
120+
* {D} - Day without leading zero
121+
* {HH} - 2-digit hour, 24h (00-23)
122+
* {h} - Hour without leading zero, 12h
123+
* {hh} - 2-digit hour, 12h (01-12)
124+
* {mm} - 2-digit minutes (00-59)
125+
* {ss} - 2-digit seconds (00-59)
126+
* {ampm} - am/pm
127+
*
128+
* Default format: '{title} - {Mon} {D}, {YYYY} {h}-{mm}{ampm}'
129+
* Example output: 'User Authentication - Jan 2, 2026 2-30pm.md'
110130
*/
111-
export function generateFilename(markdown: string): string {
131+
export function generateFilename(markdown: string, format?: string): string {
112132
const title = extractTitle(markdown);
113133
const now = new Date();
114134

115135
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
116136
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
117-
const month = months[now.getMonth()];
118-
const day = now.getDate();
119-
const year = now.getFullYear();
120-
121-
let hours = now.getHours();
122-
const minutes = now.getMinutes().toString().padStart(2, '0');
123-
const ampm = hours >= 12 ? 'pm' : 'am';
124-
hours = hours % 12 || 12;
125137

126-
return `${title} - ${month} ${day}, ${year} ${hours}-${minutes}${ampm}.md`;
138+
const hour24 = now.getHours();
139+
const hour12 = hour24 % 12 || 12;
140+
const ampm = hour24 >= 12 ? 'pm' : 'am';
141+
142+
const vars: Record<string, string> = {
143+
title,
144+
YYYY: String(now.getFullYear()),
145+
MM: String(now.getMonth() + 1).padStart(2, '0'),
146+
DD: String(now.getDate()).padStart(2, '0'),
147+
Mon: months[now.getMonth()],
148+
D: String(now.getDate()),
149+
HH: String(hour24).padStart(2, '0'),
150+
h: String(hour12),
151+
hh: String(hour12).padStart(2, '0'),
152+
mm: String(now.getMinutes()).padStart(2, '0'),
153+
ss: String(now.getSeconds()).padStart(2, '0'),
154+
ampm,
155+
};
156+
157+
const template = format?.trim() || DEFAULT_FILENAME_FORMAT;
158+
const result = template.replace(/\{(\w+)\}/g, (match, key) => vars[key] ?? match);
159+
160+
// Sanitize: remove characters invalid in filenames
161+
const sanitized = result.replace(/[<>:"/\\|?*]/g, '').replace(/\s+/g, ' ').trim();
162+
163+
return sanitized.endsWith('.md') ? sanitized : `${sanitized}.md`;
127164
}
128165

129166
// --- Obsidian Integration ---
@@ -208,7 +245,7 @@ export async function saveToObsidian(config: ObsidianConfig): Promise<Integratio
208245
mkdirSync(targetFolder, { recursive: true });
209246

210247
// Generate filename and full path
211-
const filename = generateFilename(plan);
248+
const filename = generateFilename(plan, config.filenameFormat);
212249
const filePath = join(targetFolder, filename);
213250

214251
// Generate content with frontmatter and backlink

packages/ui/components/ExportModal.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export const ExportModal: React.FC<ExportModalProps> = ({
124124
vaultPath: effectiveVaultPath,
125125
folder: obsidianSettings.folder || 'plannotator',
126126
plan: markdown,
127+
...(obsidianSettings.filenameFormat && { filenameFormat: obsidianSettings.filenameFormat }),
127128
};
128129
}
129130
if (target === 'bear') {

packages/ui/components/Settings.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
getObsidianSettings,
77
saveObsidianSettings,
88
CUSTOM_PATH_SENTINEL,
9+
DEFAULT_FILENAME_FORMAT,
910
type ObsidianSettings,
1011
} from '../utils/obsidian';
1112
import {
@@ -630,6 +631,37 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
630631
</div>
631632
</div>
632633

634+
<div className="space-y-1.5">
635+
<label className="text-xs text-muted-foreground">Filename Format</label>
636+
<input
637+
type="text"
638+
value={obsidian.filenameFormat || ''}
639+
onChange={(e) => handleObsidianChange({ filenameFormat: e.target.value || undefined })}
640+
placeholder={DEFAULT_FILENAME_FORMAT}
641+
className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
642+
/>
643+
<div className="text-[10px] text-muted-foreground/70">
644+
Variables: <code className="text-[10px]">{'{title}'}</code> <code className="text-[10px]">{'{YYYY}'}</code> <code className="text-[10px]">{'{MM}'}</code> <code className="text-[10px]">{'{DD}'}</code> <code className="text-[10px]">{'{Mon}'}</code> <code className="text-[10px]">{'{D}'}</code> <code className="text-[10px]">{'{HH}'}</code> <code className="text-[10px]">{'{h}'}</code> <code className="text-[10px]">{'{hh}'}</code> <code className="text-[10px]">{'{mm}'}</code> <code className="text-[10px]">{'{ss}'}</code> <code className="text-[10px]">{'{ampm}'}</code>
645+
</div>
646+
<div className="text-[10px] text-muted-foreground/70">
647+
Preview: {(() => {
648+
const fmt = obsidian.filenameFormat?.trim() || DEFAULT_FILENAME_FORMAT;
649+
const now = new Date();
650+
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
651+
const h24 = now.getHours(); const h12 = h24 % 12 || 12;
652+
const vars: Record<string, string> = {
653+
title: 'My Plan Title', YYYY: String(now.getFullYear()),
654+
MM: String(now.getMonth()+1).padStart(2,'0'), DD: String(now.getDate()).padStart(2,'0'),
655+
Mon: months[now.getMonth()], D: String(now.getDate()),
656+
HH: String(h24).padStart(2,'0'), h: String(h12), hh: String(h12).padStart(2,'0'),
657+
mm: String(now.getMinutes()).padStart(2,'0'), ss: String(now.getSeconds()).padStart(2,'0'),
658+
ampm: h24 >= 12 ? 'pm' : 'am',
659+
};
660+
return fmt.replace(/\{(\w+)\}/g, (m, k) => vars[k] ?? m) + '.md';
661+
})()}
662+
</div>
663+
</div>
664+
633665
<div className="text-[10px] text-muted-foreground/70">
634666
Plans saved to: {obsidian.vaultPath === CUSTOM_PATH_SENTINEL
635667
? (obsidian.customPath || '...')

packages/ui/utils/obsidian.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,17 @@ const STORAGE_KEY_ENABLED = 'plannotator-obsidian-enabled';
1313
const STORAGE_KEY_VAULT = 'plannotator-obsidian-vault';
1414
const STORAGE_KEY_FOLDER = 'plannotator-obsidian-folder';
1515
const STORAGE_KEY_CUSTOM_PATH = 'plannotator-obsidian-custom-path';
16+
const STORAGE_KEY_FILENAME_FORMAT = 'plannotator-obsidian-filename-format';
1617

1718
// Sentinel value for custom path selection
1819
export const CUSTOM_PATH_SENTINEL = '__custom__';
1920

2021
// Default folder name in the vault
2122
const DEFAULT_FOLDER = 'plannotator';
2223

24+
// Default filename format — matches the original hardcoded behavior
25+
export const DEFAULT_FILENAME_FORMAT = '{title} - {Mon} {D}, {YYYY} {h}-{mm}{ampm}';
26+
2327
/**
2428
* Obsidian integration settings
2529
*/
@@ -28,6 +32,7 @@ export interface ObsidianSettings {
2832
vaultPath: string; // Selected vault path OR '__custom__' sentinel
2933
folder: string;
3034
customPath?: string; // User-entered path when vaultPath === '__custom__'
35+
filenameFormat?: string; // Custom filename format (e.g. '{YYYY}-{MM}-{DD} - {title}')
3136
}
3237

3338
/**
@@ -39,6 +44,7 @@ export function getObsidianSettings(): ObsidianSettings {
3944
vaultPath: storage.getItem(STORAGE_KEY_VAULT) || '',
4045
folder: storage.getItem(STORAGE_KEY_FOLDER) || DEFAULT_FOLDER,
4146
customPath: storage.getItem(STORAGE_KEY_CUSTOM_PATH) || undefined,
47+
filenameFormat: storage.getItem(STORAGE_KEY_FILENAME_FORMAT) || undefined,
4248
};
4349
}
4450

@@ -50,6 +56,7 @@ export function saveObsidianSettings(settings: ObsidianSettings): void {
5056
storage.setItem(STORAGE_KEY_VAULT, settings.vaultPath);
5157
storage.setItem(STORAGE_KEY_FOLDER, settings.folder);
5258
storage.setItem(STORAGE_KEY_CUSTOM_PATH, settings.customPath || '');
59+
storage.setItem(STORAGE_KEY_FILENAME_FORMAT, settings.filenameFormat || '');
5360
}
5461

5562
/**

0 commit comments

Comments
 (0)