diff --git a/src/main.ts b/src/main.ts index 172e047..e356f9f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,7 +7,13 @@ import { TemplateEngine } from './templates'; import { HistoryManager, HistoryMetadata } from './history'; import { HistoryModal } from './history-modal'; import { ContentSelector, FileSelection } from './content-selector'; -import { FileSelectorModal, FileSelectionResult } from './file-selector-modal'; +import { FileSelectorModal } from './file-selector-modal'; +import { + parsePresetFromFrontmatter, + PresetExecutor, + formatPresetErrors, + ContextPreset, +} from './presets'; export default class ClaudeContextPlugin extends Plugin { settings: ClaudeContextSettings; @@ -52,6 +58,12 @@ export default class ClaudeContextPlugin extends Plugin { callback: () => this.openFileSelector() }); + this.addCommand({ + id: 'copy-context-from-preset', + name: 'Copy context from frontmatter preset', + callback: () => this.copyContextFromPreset() + }); + this.addSettingTab(new ClaudeContextSettingTab(this.app, this)); } @@ -374,4 +386,206 @@ export default class ClaudeContextPlugin extends Plugin { return checkAll(selection.headings); } + + async copyContextFromPreset() { + // Get active file + const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!activeView?.file) { + new Notice('No active note'); + return; + } + + const activeFile = activeView.file; + + // Parse preset from frontmatter + const cache = this.app.metadataCache.getFileCache(activeFile); + const parseResult = parsePresetFromFrontmatter(cache); + + if (!parseResult.valid || !parseResult.preset) { + // No preset found, fall back to generator modal + if (parseResult.errors.includes('No ai-context block in frontmatter') || + parseResult.errors.includes('No frontmatter found')) { + new Notice('No ai-context preset found. Opening generator...'); + new ContextGeneratorModal(this.app, this).open(); + return; + } + + // Show validation errors + new Notice(`Invalid ai-context preset:\n${formatPresetErrors(parseResult)}`, 10000); + return; + } + + // Show warnings if any + if (parseResult.warnings.length > 0) { + new Notice(`Preset warnings:\n${parseResult.warnings.join('\n')}`, 5000); + } + + const preset = parseResult.preset; + + // Execute preset + await this.executePreset(activeFile, preset); + } + + private async executePreset(activeFile: TFile, preset: ContextPreset) { + // Get base context files + const folder = this.app.vault.getAbstractFileByPath(this.settings.contextFolder); + let contextFiles: TFile[] = []; + + if (folder instanceof TFolder) { + const excludedFiles = this.settings.excludedFiles.map(f => f.toLowerCase()); + contextFiles = folder.children + .filter((f): f is TFile => + f instanceof TFile && + f.extension === 'md' && + !excludedFiles.includes(f.name.toLowerCase()) + ) + .sort((a, b) => { + if (a.basename === 'VAULT') return -1; + if (b.basename === 'VAULT') return 1; + return a.basename.localeCompare(b.basename); + }); + } + + // Execute preset to collect files + const executor = new PresetExecutor(this.app); + const result = await executor.execute(activeFile, preset, contextFiles); + + if (result.files.length === 0) { + new Notice('No files matched the preset criteria'); + return; + } + + // Show truncation warning + if (result.truncated) { + new Notice(`Token budget exceeded. Some files were excluded. (~${result.totalTokens} tokens)`, 5000); + } + + // Resolve additional sources + const registry = new SourceRegistry(); + const enabledSources = this.settings.sources.filter(s => s.enabled); + const resolvedSources = await registry.resolveAll(enabledSources); + + const errors = resolvedSources.filter(r => r.error); + if (errors.length > 0) { + const errorNames = errors.map(e => e.source.name).join(', '); + new Notice(`Some sources failed: ${errorNames}`, 5000); + } + + const prefixSources = resolvedSources.filter(r => r.source.position === 'prefix' && !r.error); + const suffixSources = resolvedSources.filter(r => r.source.position === 'suffix' && !r.error); + + // Build output + const outputParts: string[] = []; + + // Add prefix sources + for (const resolved of prefixSources) { + const formatted = formatSourceOutput(resolved, this.settings.showSourceLabels); + if (formatted) { + outputParts.push(formatted); + } + } + + // Add context files + const vaultParts: string[] = []; + for (const file of result.files) { + const content = await this.app.vault.read(file); + if (this.settings.includeFilenames) { + vaultParts.push(`# === ${file.name} ===\n\n${content}`); + } else { + vaultParts.push(content); + } + } + outputParts.push(vaultParts.join(`\n\n${this.settings.separator}\n\n`)); + + // Include active note if specified + const includeActive = preset.includeActiveNote ?? this.settings.includeActiveNote; + if (includeActive) { + const content = await this.app.vault.read(activeFile); + if (this.settings.includeFilenames) { + outputParts.push(`# === ACTIVE: ${activeFile.name} ===\n\n${content}`); + } else { + outputParts.push(`--- ACTIVE NOTE ---\n\n${content}`); + } + } + + // Add suffix sources + for (const resolved of suffixSources) { + const formatted = formatSourceOutput(resolved, this.settings.showSourceLabels); + if (formatted) { + outputParts.push(formatted); + } + } + + let combined = outputParts.join(`\n\n${this.settings.separator}\n\n`); + + // Apply template + let templateId: string | null = null; + let templateName: string | null = null; + + if (preset.template) { + // Find template by name + const template = this.settings.promptTemplates.find( + t => t.name.toLowerCase() === preset.template!.toLowerCase() + ); + if (template) { + templateId = template.id; + templateName = template.name; + const engine = new TemplateEngine(this.app); + const context = await engine.buildContext(combined); + combined = engine.processTemplate(template.content, context); + } else { + new Notice(`Template "${preset.template}" not found`, 5000); + } + } else if (this.settings.defaultTemplateId) { + const template = this.settings.promptTemplates.find(t => t.id === this.settings.defaultTemplateId); + if (template) { + templateId = template.id; + templateName = template.name; + const engine = new TemplateEngine(this.app); + const context = await engine.buildContext(combined); + combined = engine.processTemplate(template.content, context); + } + } + + // Prepare history metadata + const sourceCount = prefixSources.length + suffixSources.length; + const fileCount = result.files.length + (includeActive ? 1 : 0); + + const historyMetadata: Omit = { + templateId, + templateName, + includedFiles: result.files.map(f => f.path), + includedSources: [ + ...prefixSources.map(r => r.source.name), + ...suffixSources.map(r => r.source.name), + ], + activeNote: includeActive ? activeFile.path : null, + userNote: `Preset from: ${activeFile.basename}`, + }; + + // Copy and save + const copyAndSave = async () => { + await navigator.clipboard.writeText(combined); + + let message = `Copied ${fileCount} files`; + if (result.linkedFiles.length > 0) { + message += ` (${result.linkedFiles.length} linked)`; + } + if (result.taggedFiles.length > 0) { + message += ` (${result.taggedFiles.length} by tag)`; + } + if (templateName) { + message += ` using "${templateName}"`; + } + new Notice(message); + + await this.historyManager.saveEntry(combined, historyMetadata); + }; + + if (this.settings.showPreview) { + new PreviewModal(this.app, combined, fileCount + sourceCount, copyAndSave).open(); + } else { + await copyAndSave(); + } + } } diff --git a/src/presets.ts b/src/presets.ts new file mode 100644 index 0000000..7fe0510 --- /dev/null +++ b/src/presets.ts @@ -0,0 +1,511 @@ +import { App, TFile, CachedMetadata } from 'obsidian'; +import { estimateTokens } from './history'; + +// === Types === + +export interface ContextPreset { + template?: string; + includeLinked?: boolean; + linkDepth?: number; + includeTags?: string[]; + excludePaths?: string[]; + excludeTags?: string[]; + maxTokens?: number; + includeActiveNote?: boolean; +} + +export interface PresetValidationResult { + valid: boolean; + preset: ContextPreset | null; + errors: string[]; + warnings: string[]; +} + +export interface LinkedNoteResult { + file: TFile; + depth: number; + linkPath: string[]; +} + +// === Frontmatter Parsing === + +/** + * Extract and validate ai-context preset from file's frontmatter + */ +export function parsePresetFromFrontmatter( + cache: CachedMetadata | null +): PresetValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + if (!cache?.frontmatter) { + return { valid: false, preset: null, errors: ['No frontmatter found'], warnings }; + } + + const aiContext = cache.frontmatter['ai-context']; + + if (!aiContext) { + return { valid: false, preset: null, errors: ['No ai-context block in frontmatter'], warnings }; + } + + if (typeof aiContext !== 'object' || Array.isArray(aiContext)) { + return { valid: false, preset: null, errors: ['ai-context must be an object'], warnings }; + } + + const preset: ContextPreset = {}; + + // Parse template + if (aiContext.template !== undefined) { + if (typeof aiContext.template === 'string') { + preset.template = aiContext.template; + } else { + errors.push('template must be a string'); + } + } + + // Parse includeLinked + if (aiContext['include-linked'] !== undefined) { + if (typeof aiContext['include-linked'] === 'boolean') { + preset.includeLinked = aiContext['include-linked']; + } else { + errors.push('include-linked must be a boolean'); + } + } + + // Parse linkDepth + if (aiContext['link-depth'] !== undefined) { + const depth = Number(aiContext['link-depth']); + if (!isNaN(depth) && depth >= 0 && depth <= 10) { + preset.linkDepth = depth; + } else { + errors.push('link-depth must be a number between 0 and 10'); + } + } + + // Parse includeTags + if (aiContext['include-tags'] !== undefined) { + if (Array.isArray(aiContext['include-tags'])) { + const tags = aiContext['include-tags'].filter((t: unknown) => typeof t === 'string'); + if (tags.length > 0) { + preset.includeTags = tags.map((t: string) => t.startsWith('#') ? t : `#${t}`); + } + } else { + errors.push('include-tags must be an array of strings'); + } + } + + // Parse excludePaths + if (aiContext['exclude-paths'] !== undefined) { + if (Array.isArray(aiContext['exclude-paths'])) { + const paths = aiContext['exclude-paths'].filter((p: unknown) => typeof p === 'string'); + if (paths.length > 0) { + preset.excludePaths = paths; + } + } else { + errors.push('exclude-paths must be an array of strings'); + } + } + + // Parse excludeTags + if (aiContext['exclude-tags'] !== undefined) { + if (Array.isArray(aiContext['exclude-tags'])) { + const tags = aiContext['exclude-tags'].filter((t: unknown) => typeof t === 'string'); + if (tags.length > 0) { + preset.excludeTags = tags.map((t: string) => t.startsWith('#') ? t : `#${t}`); + } + } else { + errors.push('exclude-tags must be an array of strings'); + } + } + + // Parse maxTokens + if (aiContext['max-tokens'] !== undefined) { + const tokens = Number(aiContext['max-tokens']); + if (!isNaN(tokens) && tokens > 0) { + preset.maxTokens = tokens; + } else { + errors.push('max-tokens must be a positive number'); + } + } + + // Parse includeActiveNote + if (aiContext['include-active-note'] !== undefined) { + if (typeof aiContext['include-active-note'] === 'boolean') { + preset.includeActiveNote = aiContext['include-active-note']; + } else { + errors.push('include-active-note must be a boolean'); + } + } + + // Warn about unknown fields + const knownFields = [ + 'template', 'include-linked', 'link-depth', 'include-tags', + 'exclude-paths', 'exclude-tags', 'max-tokens', 'include-active-note' + ]; + for (const key of Object.keys(aiContext)) { + if (!knownFields.includes(key)) { + warnings.push(`Unknown field: ${key}`); + } + } + + return { + valid: errors.length === 0, + preset: errors.length === 0 ? preset : null, + errors, + warnings, + }; +} + +// === Link Traversal === + +export class LinkTraverser { + private app: App; + private visited: Set = new Set(); + private results: LinkedNoteResult[] = []; + + constructor(app: App) { + this.app = app; + } + + /** + * Traverse links from a starting file up to a given depth + */ + async traverseLinks( + startFile: TFile, + maxDepth: number, + excludePaths: string[] = [], + excludeTags: string[] = [] + ): Promise { + this.visited.clear(); + this.results = []; + + await this.traverse(startFile, 0, maxDepth, [], excludePaths, excludeTags); + + return this.results; + } + + private async traverse( + file: TFile, + currentDepth: number, + maxDepth: number, + linkPath: string[], + excludePaths: string[], + excludeTags: string[] + ): Promise { + // Check if already visited + if (this.visited.has(file.path)) { + return; + } + + // Check excluded paths + if (this.isPathExcluded(file.path, excludePaths)) { + return; + } + + // Check excluded tags + if (this.hasExcludedTag(file, excludeTags)) { + return; + } + + this.visited.add(file.path); + + // Add to results (except starting file at depth 0) + if (currentDepth > 0) { + this.results.push({ + file, + depth: currentDepth, + linkPath: [...linkPath, file.basename], + }); + } + + // Stop if max depth reached + if (currentDepth >= maxDepth) { + return; + } + + // Get outgoing links + const cache = this.app.metadataCache.getFileCache(file); + if (!cache?.links) { + return; + } + + for (const link of cache.links) { + const linkedFile = this.app.metadataCache.getFirstLinkpathDest( + link.link, + file.path + ); + + if (linkedFile instanceof TFile) { + await this.traverse( + linkedFile, + currentDepth + 1, + maxDepth, + [...linkPath, file.basename], + excludePaths, + excludeTags + ); + } + } + } + + private isPathExcluded(path: string, excludePaths: string[]): boolean { + for (const excludePath of excludePaths) { + if (path.startsWith(excludePath) || path.includes(`/${excludePath}`)) { + return true; + } + } + return false; + } + + private hasExcludedTag(file: TFile, excludeTags: string[]): boolean { + if (excludeTags.length === 0) { + return false; + } + + const cache = this.app.metadataCache.getFileCache(file); + if (!cache?.frontmatter?.tags) { + return false; + } + + const fileTags: string[] = Array.isArray(cache.frontmatter.tags) + ? cache.frontmatter.tags + : [cache.frontmatter.tags]; + + for (const fileTag of fileTags) { + const normalizedTag = fileTag.startsWith('#') ? fileTag : `#${fileTag}`; + if (excludeTags.includes(normalizedTag)) { + return true; + } + } + + return false; + } +} + +// === Tag-based File Collection === + +export class TagCollector { + private app: App; + + constructor(app: App) { + this.app = app; + } + + /** + * Collect all files that have any of the specified tags + */ + collectFilesWithTags( + tags: string[], + excludePaths: string[] = [] + ): TFile[] { + const files: TFile[] = []; + const normalizedTags = tags.map(t => t.startsWith('#') ? t.substring(1) : t); + + for (const file of this.app.vault.getMarkdownFiles()) { + // Check excluded paths + let excluded = false; + for (const excludePath of excludePaths) { + if (file.path.startsWith(excludePath) || file.path.includes(`/${excludePath}`)) { + excluded = true; + break; + } + } + if (excluded) continue; + + // Check tags + const cache = this.app.metadataCache.getFileCache(file); + if (!cache) continue; + + // Check frontmatter tags + const frontmatterTags = this.getFrontmatterTags(cache); + // Check inline tags + const inlineTags = cache.tags?.map(t => t.tag.substring(1)) || []; + + const allTags = [...frontmatterTags, ...inlineTags]; + + for (const tag of allTags) { + // Support hierarchical tag matching + for (const searchTag of normalizedTags) { + if (tag === searchTag || tag.startsWith(`${searchTag}/`)) { + files.push(file); + break; + } + } + } + } + + return files; + } + + private getFrontmatterTags(cache: CachedMetadata): string[] { + if (!cache.frontmatter?.tags) { + return []; + } + + const tags = cache.frontmatter.tags; + if (Array.isArray(tags)) { + return tags.map(t => typeof t === 'string' ? t : String(t)); + } + if (typeof tags === 'string') { + return [tags]; + } + return []; + } +} + +// === Preset Executor === + +export interface PresetExecutionResult { + files: TFile[]; + linkedFiles: LinkedNoteResult[]; + taggedFiles: TFile[]; + truncated: boolean; + totalTokens: number; +} + +export class PresetExecutor { + private app: App; + private linkTraverser: LinkTraverser; + private tagCollector: TagCollector; + + constructor(app: App) { + this.app = app; + this.linkTraverser = new LinkTraverser(app); + this.tagCollector = new TagCollector(app); + } + + /** + * Execute a preset and collect all files to include + */ + async execute( + activeFile: TFile, + preset: ContextPreset, + contextFiles: TFile[] + ): Promise { + const allFiles = new Set(contextFiles); + let linkedFiles: LinkedNoteResult[] = []; + let taggedFiles: TFile[] = []; + + // Include linked notes + if (preset.includeLinked) { + const depth = preset.linkDepth ?? 1; + linkedFiles = await this.linkTraverser.traverseLinks( + activeFile, + depth, + preset.excludePaths || [], + preset.excludeTags || [] + ); + + for (const result of linkedFiles) { + allFiles.add(result.file); + } + } + + // Include notes with specified tags + if (preset.includeTags && preset.includeTags.length > 0) { + taggedFiles = this.tagCollector.collectFilesWithTags( + preset.includeTags, + preset.excludePaths || [] + ); + + for (const file of taggedFiles) { + allFiles.add(file); + } + } + + // Convert to array and apply exclusions + let files = Array.from(allFiles); + + // Apply path exclusions + if (preset.excludePaths && preset.excludePaths.length > 0) { + files = files.filter(f => { + for (const excludePath of preset.excludePaths!) { + if (f.path.startsWith(excludePath) || f.path.includes(`/${excludePath}`)) { + return false; + } + } + return true; + }); + } + + // Apply tag exclusions + if (preset.excludeTags && preset.excludeTags.length > 0) { + files = files.filter(f => { + const cache = this.app.metadataCache.getFileCache(f); + if (!cache?.frontmatter?.tags) return true; + + const fileTags: string[] = Array.isArray(cache.frontmatter.tags) + ? cache.frontmatter.tags + : [cache.frontmatter.tags]; + + for (const fileTag of fileTags) { + const normalizedTag = fileTag.startsWith('#') ? fileTag : `#${fileTag}`; + if (preset.excludeTags!.includes(normalizedTag)) { + return false; + } + } + return true; + }); + } + + // Check token budget + let totalTokens = 0; + let truncated = false; + + if (preset.maxTokens) { + const filesWithTokens: { file: TFile; tokens: number }[] = []; + + for (const file of files) { + const content = await this.app.vault.read(file); + const tokens = estimateTokens(content); + filesWithTokens.push({ file, tokens }); + } + + // Sort by tokens (smallest first) to maximize included files + filesWithTokens.sort((a, b) => a.tokens - b.tokens); + + const includedFiles: TFile[] = []; + for (const { file, tokens } of filesWithTokens) { + if (totalTokens + tokens <= preset.maxTokens) { + includedFiles.push(file); + totalTokens += tokens; + } else { + truncated = true; + } + } + + files = includedFiles; + } else { + // Calculate total tokens without truncation + for (const file of files) { + const content = await this.app.vault.read(file); + totalTokens += estimateTokens(content); + } + } + + return { + files, + linkedFiles, + taggedFiles, + truncated, + totalTokens, + }; + } +} + +// === Helper === + +export function formatPresetErrors(result: PresetValidationResult): string { + let message = ''; + + if (result.errors.length > 0) { + message += 'Errors:\n' + result.errors.map(e => ` • ${e}`).join('\n'); + } + + if (result.warnings.length > 0) { + if (message) message += '\n\n'; + message += 'Warnings:\n' + result.warnings.map(w => ` • ${w}`).join('\n'); + } + + return message; +}