Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,7 @@ const App: React.FC = () => {
vaultPath: effectiveVaultPath,
folder: obsidianSettings.folder || 'plannotator',
plan: markdown,
...(obsidianSettings.filenameFormat && { filenameFormat: obsidianSettings.filenameFormat }),
};
}

Expand Down Expand Up @@ -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') {
Expand Down
63 changes: 50 additions & 13 deletions packages/server/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, string> = {
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 ---
Expand Down Expand Up @@ -208,7 +245,7 @@ export async function saveToObsidian(config: ObsidianConfig): Promise<Integratio
mkdirSync(targetFolder, { recursive: true });

// Generate filename and full path
const filename = generateFilename(plan);
const filename = generateFilename(plan, config.filenameFormat);
const filePath = join(targetFolder, filename);

// Generate content with frontmatter and backlink
Expand Down
1 change: 1 addition & 0 deletions packages/ui/components/ExportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export const ExportModal: React.FC<ExportModalProps> = ({
vaultPath: effectiveVaultPath,
folder: obsidianSettings.folder || 'plannotator',
plan: markdown,
...(obsidianSettings.filenameFormat && { filenameFormat: obsidianSettings.filenameFormat }),
};
}
if (target === 'bear') {
Expand Down
32 changes: 32 additions & 0 deletions packages/ui/components/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getObsidianSettings,
saveObsidianSettings,
CUSTOM_PATH_SENTINEL,
DEFAULT_FILENAME_FORMAT,
type ObsidianSettings,
} from '../utils/obsidian';
import {
Expand Down Expand Up @@ -630,6 +631,37 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
</div>
</div>

<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">Filename Format</label>
<input
type="text"
value={obsidian.filenameFormat || ''}
onChange={(e) => 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"
/>
<div className="text-[10px] text-muted-foreground/70">
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>
</div>
<div className="text-[10px] text-muted-foreground/70">
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<string, string> = {
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';
})()}
</div>
</div>

<div className="text-[10px] text-muted-foreground/70">
Plans saved to: {obsidian.vaultPath === CUSTOM_PATH_SENTINEL
? (obsidian.customPath || '...')
Expand Down
7 changes: 7 additions & 0 deletions packages/ui/utils/obsidian.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@ 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__';

// 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
*/
Expand All @@ -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}')
}

/**
Expand All @@ -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,
};
}

Expand All @@ -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 || '');
}

/**
Expand Down