Files
backnotprop__plannotator/packages/ui/utils/obsidian.ts
Michael Ramos 75968541c2 feat: Obsidian saving enhancements — separator, auto-save, sanitization (#240)
* fix: harden title sanitization for Obsidian filenames

Strip parentheses, brackets, curly braces, hash, tilde, and backtick
from plan titles when generating Obsidian filenames. Re-trim after
stripping to handle edge cases where stripped chars leave leading/trailing
whitespace. Closes part of #235.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 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>

* fix: pass filenameSeparator in ExportModal Obsidian save path

The ExportModal's save-to-Obsidian callsite was missing the
filenameSeparator config, causing the separator preference to be
silently ignored when saving via the Export Modal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:27:11 -08:00

202 lines
6.9 KiB
TypeScript

/**
* Obsidian Integration Utility
*
* Manages settings for auto-saving plans to Obsidian vaults.
* Settings are stored in cookies (like other settings) so they persist
* across different ports used by the hook server.
*/
import { storage } from './storage';
// Storage keys
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';
const STORAGE_KEY_VAULT_BROWSER = 'plannotator-obsidian-vault-browser';
const STORAGE_KEY_AUTOSAVE = 'plannotator-obsidian-autosave';
const STORAGE_KEY_FILENAME_SEPARATOR = 'plannotator-obsidian-filename-separator';
// 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
*/
export type FilenameSeparator = 'space' | 'dash' | 'underscore';
export interface ObsidianSettings {
enabled: boolean;
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}')
filenameSeparator: FilenameSeparator; // Replace spaces in filename with dash/underscore
autoSave: boolean; // Auto-save to Obsidian on plan arrival
vaultBrowserEnabled: boolean; // Show vault file browser in sidebar
}
/**
* Get current Obsidian settings from storage
*/
export function getObsidianSettings(): ObsidianSettings {
return {
enabled: storage.getItem(STORAGE_KEY_ENABLED) === 'true',
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,
filenameSeparator: (storage.getItem(STORAGE_KEY_FILENAME_SEPARATOR) as FilenameSeparator) || 'space',
autoSave: storage.getItem(STORAGE_KEY_AUTOSAVE) === 'true',
vaultBrowserEnabled: storage.getItem(STORAGE_KEY_VAULT_BROWSER) === 'true',
};
}
/**
* Save Obsidian settings to storage
*/
export function saveObsidianSettings(settings: ObsidianSettings): void {
storage.setItem(STORAGE_KEY_ENABLED, String(settings.enabled));
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 || '');
storage.setItem(STORAGE_KEY_FILENAME_SEPARATOR, settings.filenameSeparator || 'space');
storage.setItem(STORAGE_KEY_AUTOSAVE, String(settings.autoSave));
storage.setItem(STORAGE_KEY_VAULT_BROWSER, String(settings.vaultBrowserEnabled));
}
/**
* Get the effective vault path, resolving custom path if selected
*/
export function getEffectiveVaultPath(settings: ObsidianSettings): string {
if (settings.vaultPath === CUSTOM_PATH_SENTINEL) {
return settings.customPath || '';
}
return settings.vaultPath;
}
/**
* Check if Obsidian integration is properly configured
*/
export function isObsidianConfigured(): boolean {
const settings = getObsidianSettings();
const effectivePath = getEffectiveVaultPath(settings);
return settings.enabled && effectivePath.trim().length > 0;
}
/**
* Check if the vault browser sidebar tab should be shown
*/
export function isVaultBrowserEnabled(): boolean {
const settings = getObsidianSettings();
const effectivePath = getEffectiveVaultPath(settings);
return settings.enabled && settings.vaultBrowserEnabled && effectivePath.trim().length > 0;
}
/**
* Extract tags from markdown content using simple heuristics
*
* Extracts:
* - Words from the first H1 title (excluding common words)
* - Code fence languages (```typescript, ```sql, etc.)
* - Always includes "plan" as base tag
*
* @param markdown - The markdown content to extract tags from
* @returns Array of lowercase tag strings (max 6)
*/
export function extractTags(markdown: string): string[] {
const tags = new Set<string>(['plannotator']);
// Common words to exclude from title extraction
const stopWords = new Set([
'the', 'and', 'for', 'with', 'this', 'that', 'from', 'into',
'plan', 'implementation', 'overview', 'phase', 'step', 'steps',
]);
// 1. Extract from first H1 title
// Matches: "# Title" or "# Implementation Plan: Title" or "# Plan: Title"
const h1Match = markdown.match(/^#\s+(?:Implementation\s+Plan:|Plan:)?\s*(.+)$/im);
if (h1Match) {
const titleWords = h1Match[1]
.toLowerCase()
.replace(/[^\w\s-]/g, ' ') // Remove special chars except hyphens
.split(/\s+/)
.filter(word => word.length > 2 && !stopWords.has(word));
// Add first 3 meaningful words from title
titleWords.slice(0, 3).forEach(word => tags.add(word));
}
// 2. Extract code fence languages
// Matches: ```typescript, ```sql, ```rust, etc.
const langMatches = markdown.matchAll(/```(\w+)/g);
const seenLangs = new Set<string>();
for (const [, lang] of langMatches) {
const normalizedLang = lang.toLowerCase();
// Skip generic/config languages and duplicates
if (!seenLangs.has(normalizedLang) &&
!['json', 'yaml', 'yml', 'text', 'txt', 'markdown', 'md'].includes(normalizedLang)) {
seenLangs.add(normalizedLang);
tags.add(normalizedLang);
}
}
// Return max 6 tags
return Array.from(tags).slice(0, 6);
}
/**
* Generate YAML frontmatter for an Obsidian note
*
* @param tags - Array of tags to include
* @returns Frontmatter string including opening and closing ---
*/
export function generateFrontmatter(tags: string[]): string {
const now = new Date().toISOString();
const tagList = tags.map(t => t.toLowerCase()).join(', ');
return `---
created: ${now}
source: plannotator
tags: [${tagList}]
---`;
}
/**
* Generate a filename for the plan note
* Format: YYYY-MM-DD-HHmm.md (e.g., 2026-01-02-1430.md)
*
* @returns Filename string
*/
export function generateFilename(): string {
const now = new Date();
const timestamp = now.toISOString()
.slice(0, 16) // "2026-01-02T14:30"
.replace('T', '-') // "2026-01-02-14:30"
.replace(/:/g, ''); // "2026-01-02-1430"
return `${timestamp}.md`;
}
/**
* Prepare the full note content with frontmatter
*
* @param markdown - The plan markdown content
* @returns Full note content with frontmatter prepended
*/
export function prepareNoteContent(markdown: string): string {
const tags = extractTags(markdown);
const frontmatter = generateFrontmatter(tags);
return `${frontmatter}\n\n${markdown}`;
}