mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
75968541c2
* 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>
202 lines
6.9 KiB
TypeScript
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}`;
|
|
}
|