diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 4dc269d7..d8131244 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -649,6 +649,7 @@ const App: React.FC = () => { vaultPath: effectiveVaultPath, folder: obsidianSettings.folder || 'plannotator', plan: markdown, + ...(obsidianSettings.filenameFormat && { filenameFormat: obsidianSettings.filenameFormat }), }; } @@ -857,7 +858,12 @@ const App: React.FC = () => { const s = getObsidianSettings(); const vaultPath = getEffectiveVaultPath(s); if (vaultPath) { - body.obsidian = { vaultPath, folder: s.folder || 'plannotator', plan: markdown }; + body.obsidian = { + vaultPath, + folder: s.folder || 'plannotator', + plan: markdown, + ...(s.filenameFormat && { filenameFormat: s.filenameFormat }), + }; } } if (target === 'bear') { diff --git a/packages/server/integrations.ts b/packages/server/integrations.ts index b60d9d07..3a62d278 100644 --- a/packages/server/integrations.ts +++ b/packages/server/integrations.ts @@ -13,6 +13,7 @@ export interface ObsidianConfig { vaultPath: string; folder: string; plan: string; + filenameFormat?: string; // Custom format string, e.g. '{YYYY}-{MM}-{DD} - {title}' } export interface BearConfig { @@ -104,26 +105,62 @@ export function extractTitle(markdown: string): string { return 'Plan'; } +/** Default filename format matching original behavior */ +export const DEFAULT_FILENAME_FORMAT = '{title} - {Mon} {D}, {YYYY} {h}-{mm}{ampm}'; + /** - * Generate human-readable filename: Title - Mon D, YYYY H-MMam.md - * Example: User Authentication - Jan 2, 2026 2-30pm.md + * Generate filename from a format string with variable substitution. + * + * Supported variables: + * {title} - Plan title from first H1 heading + * {YYYY} - 4-digit year + * {MM} - 2-digit month (01-12) + * {DD} - 2-digit day (01-31) + * {Mon} - Abbreviated month name (Jan, Feb, ...) + * {D} - Day without leading zero + * {HH} - 2-digit hour, 24h (00-23) + * {h} - Hour without leading zero, 12h + * {hh} - 2-digit hour, 12h (01-12) + * {mm} - 2-digit minutes (00-59) + * {ss} - 2-digit seconds (00-59) + * {ampm} - am/pm + * + * Default format: '{title} - {Mon} {D}, {YYYY} {h}-{mm}{ampm}' + * Example output: 'User Authentication - Jan 2, 2026 2-30pm.md' */ -export function generateFilename(markdown: string): string { +export function generateFilename(markdown: string, format?: string): string { const title = extractTitle(markdown); const now = new Date(); const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - const month = months[now.getMonth()]; - const day = now.getDate(); - const year = now.getFullYear(); - - let hours = now.getHours(); - const minutes = now.getMinutes().toString().padStart(2, '0'); - const ampm = hours >= 12 ? 'pm' : 'am'; - hours = hours % 12 || 12; - return `${title} - ${month} ${day}, ${year} ${hours}-${minutes}${ampm}.md`; + const hour24 = now.getHours(); + const hour12 = hour24 % 12 || 12; + const ampm = hour24 >= 12 ? 'pm' : 'am'; + + const vars: Record = { + title, + YYYY: String(now.getFullYear()), + MM: String(now.getMonth() + 1).padStart(2, '0'), + DD: String(now.getDate()).padStart(2, '0'), + Mon: months[now.getMonth()], + D: String(now.getDate()), + HH: String(hour24).padStart(2, '0'), + h: String(hour12), + hh: String(hour12).padStart(2, '0'), + mm: String(now.getMinutes()).padStart(2, '0'), + ss: String(now.getSeconds()).padStart(2, '0'), + ampm, + }; + + const template = format?.trim() || DEFAULT_FILENAME_FORMAT; + const result = template.replace(/\{(\w+)\}/g, (match, key) => vars[key] ?? match); + + // Sanitize: remove characters invalid in filenames + const sanitized = result.replace(/[<>:"/\\|?*]/g, '').replace(/\s+/g, ' ').trim(); + + return sanitized.endsWith('.md') ? sanitized : `${sanitized}.md`; } // --- Obsidian Integration --- @@ -208,7 +245,7 @@ export async function saveToObsidian(config: ObsidianConfig): Promise = ({ vaultPath: effectiveVaultPath, folder: obsidianSettings.folder || 'plannotator', plan: markdown, + ...(obsidianSettings.filenameFormat && { filenameFormat: obsidianSettings.filenameFormat }), }; } if (target === 'bear') { diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index 8c447370..30df54b1 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -6,6 +6,7 @@ import { getObsidianSettings, saveObsidianSettings, CUSTOM_PATH_SENTINEL, + DEFAULT_FILENAME_FORMAT, type ObsidianSettings, } from '../utils/obsidian'; import { @@ -630,6 +631,37 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange +
+ + handleObsidianChange({ filenameFormat: e.target.value || undefined })} + placeholder={DEFAULT_FILENAME_FORMAT} + 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" + /> +
+ Variables: {'{title}'} {'{YYYY}'} {'{MM}'} {'{DD}'} {'{Mon}'} {'{D}'} {'{HH}'} {'{h}'} {'{hh}'} {'{mm}'} {'{ss}'} {'{ampm}'} +
+
+ Preview: {(() => { + const fmt = obsidian.filenameFormat?.trim() || DEFAULT_FILENAME_FORMAT; + const now = new Date(); + const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + const h24 = now.getHours(); const h12 = h24 % 12 || 12; + const vars: Record = { + title: 'My Plan Title', YYYY: String(now.getFullYear()), + MM: String(now.getMonth()+1).padStart(2,'0'), DD: String(now.getDate()).padStart(2,'0'), + Mon: months[now.getMonth()], D: String(now.getDate()), + HH: String(h24).padStart(2,'0'), h: String(h12), hh: String(h12).padStart(2,'0'), + mm: String(now.getMinutes()).padStart(2,'0'), ss: String(now.getSeconds()).padStart(2,'0'), + ampm: h24 >= 12 ? 'pm' : 'am', + }; + return fmt.replace(/\{(\w+)\}/g, (m, k) => vars[k] ?? m) + '.md'; + })()} +
+
+
Plans saved to: {obsidian.vaultPath === CUSTOM_PATH_SENTINEL ? (obsidian.customPath || '...') diff --git a/packages/ui/utils/obsidian.ts b/packages/ui/utils/obsidian.ts index b871578b..91933633 100644 --- a/packages/ui/utils/obsidian.ts +++ b/packages/ui/utils/obsidian.ts @@ -13,6 +13,7 @@ const STORAGE_KEY_ENABLED = 'plannotator-obsidian-enabled'; const STORAGE_KEY_VAULT = 'plannotator-obsidian-vault'; const STORAGE_KEY_FOLDER = 'plannotator-obsidian-folder'; const STORAGE_KEY_CUSTOM_PATH = 'plannotator-obsidian-custom-path'; +const STORAGE_KEY_FILENAME_FORMAT = 'plannotator-obsidian-filename-format'; // Sentinel value for custom path selection export const CUSTOM_PATH_SENTINEL = '__custom__'; @@ -20,6 +21,9 @@ export const CUSTOM_PATH_SENTINEL = '__custom__'; // Default folder name in the vault const DEFAULT_FOLDER = 'plannotator'; +// Default filename format — matches the original hardcoded behavior +export const DEFAULT_FILENAME_FORMAT = '{title} - {Mon} {D}, {YYYY} {h}-{mm}{ampm}'; + /** * Obsidian integration settings */ @@ -28,6 +32,7 @@ export interface ObsidianSettings { vaultPath: string; // Selected vault path OR '__custom__' sentinel folder: string; customPath?: string; // User-entered path when vaultPath === '__custom__' + filenameFormat?: string; // Custom filename format (e.g. '{YYYY}-{MM}-{DD} - {title}') } /** @@ -39,6 +44,7 @@ export function getObsidianSettings(): ObsidianSettings { vaultPath: storage.getItem(STORAGE_KEY_VAULT) || '', folder: storage.getItem(STORAGE_KEY_FOLDER) || DEFAULT_FOLDER, customPath: storage.getItem(STORAGE_KEY_CUSTOM_PATH) || undefined, + filenameFormat: storage.getItem(STORAGE_KEY_FILENAME_FORMAT) || undefined, }; } @@ -50,6 +56,7 @@ export function saveObsidianSettings(settings: ObsidianSettings): void { storage.setItem(STORAGE_KEY_VAULT, settings.vaultPath); storage.setItem(STORAGE_KEY_FOLDER, settings.folder); storage.setItem(STORAGE_KEY_CUSTOM_PATH, settings.customPath || ''); + storage.setItem(STORAGE_KEY_FILENAME_FORMAT, settings.filenameFormat || ''); } /**