diff --git a/src/generator.ts b/src/generator.ts index 3328c73..7cbd883 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -1,5 +1,6 @@ -import { App, Modal, Setting, TFolder } from 'obsidian'; +import { App, Modal, Notice, Setting, TFolder } from 'obsidian'; import ClaudeContextPlugin from './main'; +import { createFreetextSource, SourcePosition } from './sources'; interface FolderConfig { name: string; @@ -77,11 +78,19 @@ export class ContextGeneratorModal extends Modal { plugin: ClaudeContextPlugin; config: ContextConfig; + // Additional context state + temporaryFreetext: string = ''; + temporaryPosition: SourcePosition = 'prefix'; + saveAsDefault: boolean = false; + selectedTemplateId: string | null = null; + constructor(app: App, plugin: ClaudeContextPlugin) { super(app); this.plugin = plugin; this.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG)); this.config.folders = this.scanVaultStructure().map(name => ({ name, purpose: '' })); + // Initialize with default template + this.selectedTemplateId = this.plugin.settings.defaultTemplateId; } onOpen() { @@ -271,9 +280,93 @@ export class ContextGeneratorModal extends Modal { .setValue(this.config.generateFiles.examples) .onChange(v => this.config.generateFiles.examples = v)); + // === ADDITIONAL CONTEXT SECTION === + contentEl.createEl('h3', { text: 'Additional Context (this session)' }); + + new Setting(contentEl) + .setName('Temporary freetext') + .setDesc('Add context for this session only') + .addTextArea(text => { + text.setPlaceholder('Enter additional context that will be included when copying...') + .setValue(this.temporaryFreetext) + .onChange(v => this.temporaryFreetext = v); + text.inputEl.rows = 4; + text.inputEl.style.width = '100%'; + }); + + new Setting(contentEl) + .setName('Position') + .addDropdown(dropdown => dropdown + .addOption('prefix', 'Prefix (before vault content)') + .addOption('suffix', 'Suffix (after vault content)') + .setValue(this.temporaryPosition) + .onChange(v => this.temporaryPosition = v as SourcePosition)); + + new Setting(contentEl) + .setName('Save as default') + .setDesc('Save this freetext as a permanent source') + .addToggle(toggle => toggle + .setValue(this.saveAsDefault) + .onChange(v => this.saveAsDefault = v)); + + // Show saved sources count + const enabledCount = this.plugin.settings.sources.filter(s => s.enabled).length; + const sourcesInfo = contentEl.createEl('p', { + text: `Saved sources: ${enabledCount} enabled (manage in settings)`, + cls: 'setting-item-description' + }); + sourcesInfo.style.marginTop = '10px'; + + // === PROMPT TEMPLATE SECTION === + contentEl.createEl('h3', { text: 'Prompt Template' }); + + new Setting(contentEl) + .setName('Template') + .setDesc('Wrap context with a prompt template') + .addDropdown(dropdown => { + dropdown.addOption('', 'None (plain context)'); + for (const template of this.plugin.settings.promptTemplates) { + dropdown.addOption(template.id, template.name); + } + dropdown.setValue(this.selectedTemplateId || ''); + dropdown.onChange(v => { + this.selectedTemplateId = v || null; + }); + }); + + // Show template count + const templateCount = this.plugin.settings.promptTemplates.length; + const templateInfo = contentEl.createEl('p', { + text: `${templateCount} template(s) available (manage in settings)`, + cls: 'setting-item-description' + }); + templateInfo.style.marginTop = '5px'; + + // Copy with context button + new Setting(contentEl) + .addButton(btn => btn + .setButtonText('Copy Context Now') + .onClick(async () => { + // Save freetext if requested + if (this.saveAsDefault && this.temporaryFreetext.trim()) { + const source = createFreetextSource( + 'Generator Context', + this.temporaryFreetext, + this.temporaryPosition + ); + this.plugin.settings.sources.push(source); + await this.plugin.saveSettings(); + new Notice('Freetext saved as default source'); + } + + // Copy context with selected template + await this.plugin.copyContextToClipboard(false, this.temporaryFreetext, this.selectedTemplateId); + this.close(); + })); + // === GENERATE BUTTON === contentEl.createEl('hr'); - + new Setting(contentEl) .addButton(btn => btn .setButtonText('Generate') diff --git a/src/main.ts b/src/main.ts index eb8cdd2..96b7ddf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,8 @@ import { MarkdownView, Notice, Plugin, TFile, TFolder } from 'obsidian'; import { ClaudeContextSettings, ClaudeContextSettingTab, DEFAULT_SETTINGS } from './settings'; import { ContextGeneratorModal } from './generator'; import { PreviewModal } from './preview'; +import { SourceRegistry, formatSourceOutput } from './sources'; +import { TemplateEngine, PromptTemplate } from './templates'; export default class ClaudeContextPlugin extends Plugin { settings: ClaudeContextSettings; @@ -43,9 +45,13 @@ export default class ClaudeContextPlugin extends Plugin { await this.saveData(this.settings); } - async copyContextToClipboard(forceIncludeNote = false) { + async copyContextToClipboard( + forceIncludeNote = false, + temporaryFreetext?: string, + templateId?: string | null + ) { const folder = this.app.vault.getAbstractFileByPath(this.settings.contextFolder); - + if (!folder || !(folder instanceof TFolder)) { new Notice(`Folder "${this.settings.contextFolder}" not found`); return; @@ -54,8 +60,8 @@ export default class ClaudeContextPlugin extends Plugin { const excludedFiles = this.settings.excludedFiles.map(f => f.toLowerCase()); const files = folder.children - .filter((f): f is TFile => - f instanceof TFile && + .filter((f): f is TFile => + f instanceof TFile && f.extension === 'md' && !excludedFiles.includes(f.name.toLowerCase()) ) @@ -70,16 +76,53 @@ export default class ClaudeContextPlugin extends Plugin { return; } - const parts: string[] = []; - + // Resolve additional sources + const registry = new SourceRegistry(); + const enabledSources = this.settings.sources.filter(s => s.enabled); + const resolvedSources = await registry.resolveAll(enabledSources); + + // Check for source errors + 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); + } + + // Separate prefix and suffix sources + const prefixSources = resolvedSources.filter(r => r.source.position === 'prefix' && !r.error); + const suffixSources = resolvedSources.filter(r => r.source.position === 'suffix' && !r.error); + + // Build output parts + const outputParts: string[] = []; + + // Add prefix sources + for (const resolved of prefixSources) { + const formatted = formatSourceOutput(resolved, this.settings.showSourceLabels); + if (formatted) { + outputParts.push(formatted); + } + } + + // Add temporary freetext if provided (as prefix) + if (temporaryFreetext?.trim()) { + if (this.settings.showSourceLabels) { + outputParts.push(`# === PREFIX: Session Context ===\n\n${temporaryFreetext}`); + } else { + outputParts.push(temporaryFreetext); + } + } + + // Add vault content + const vaultParts: string[] = []; for (const file of files) { const content = await this.app.vault.read(file); if (this.settings.includeFilenames) { - parts.push(`# === ${file.name} ===\n\n${content}`); + vaultParts.push(`# === ${file.name} ===\n\n${content}`); } else { - parts.push(content); + vaultParts.push(content); } } + outputParts.push(vaultParts.join(`\n\n${this.settings.separator}\n\n`)); // Include active note if (forceIncludeNote || this.settings.includeActiveNote) { @@ -87,24 +130,57 @@ export default class ClaudeContextPlugin extends Plugin { if (activeView?.file) { const content = await this.app.vault.read(activeView.file); if (this.settings.includeFilenames) { - parts.push(`# === ACTIVE: ${activeView.file.name} ===\n\n${content}`); + outputParts.push(`# === ACTIVE: ${activeView.file.name} ===\n\n${content}`); } else { - parts.push(`--- ACTIVE NOTE ---\n\n${content}`); + outputParts.push(`--- ACTIVE NOTE ---\n\n${content}`); } } } - const combined = parts.join(`\n\n${this.settings.separator}\n\n`); + // 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`); + const sourceCount = prefixSources.length + suffixSources.length + (temporaryFreetext?.trim() ? 1 : 0); const fileCount = files.length + (forceIncludeNote || this.settings.includeActiveNote ? 1 : 0); + const totalCount = fileCount + sourceCount; + + // Apply template if specified + const effectiveTemplateId = templateId !== undefined ? templateId : this.settings.defaultTemplateId; + let templateName: string | null = null; + + if (effectiveTemplateId) { + const template = this.settings.promptTemplates.find(t => t.id === effectiveTemplateId); + if (template) { + const engine = new TemplateEngine(this.app); + const context = await engine.buildContext(combined); + combined = engine.processTemplate(template.content, context); + templateName = template.name; + } + } if (this.settings.showPreview) { - new PreviewModal(this.app, combined, fileCount, async () => { + new PreviewModal(this.app, combined, totalCount, async () => { await navigator.clipboard.writeText(combined); - new Notice(`Copied ${fileCount} files to clipboard`); + this.showCopyNotice(fileCount, sourceCount, templateName); }).open(); } else { await navigator.clipboard.writeText(combined); - new Notice(`Copied ${fileCount} files to clipboard`); + this.showCopyNotice(fileCount, sourceCount, templateName); } } + + private showCopyNotice(fileCount: number, sourceCount: number, templateName: string | null) { + const totalCount = fileCount + sourceCount; + let message = `Copied ${totalCount} items to clipboard (${fileCount} files, ${sourceCount} sources)`; + if (templateName) { + message += ` using template "${templateName}"`; + } + new Notice(message); + } } diff --git a/src/settings.ts b/src/settings.ts index d9dd8b1..0130370 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,5 +1,9 @@ -import { App, PluginSettingTab, Setting } from 'obsidian'; +import { App, Notice, PluginSettingTab, Setting } from 'obsidian'; import ClaudeContextPlugin from './main'; +import { ContextSource, getSourceIcon, SourceRegistry } from './sources'; +import { SourceModal } from './source-modal'; +import { PromptTemplate, STARTER_TEMPLATES } from './templates'; +import { TemplateModal, TemplateImportExportModal } from './template-modal'; export interface ClaudeContextSettings { contextFolder: string; @@ -8,6 +12,10 @@ export interface ClaudeContextSettings { showPreview: boolean; includeActiveNote: boolean; excludedFiles: string[]; + sources: ContextSource[]; + showSourceLabels: boolean; + promptTemplates: PromptTemplate[]; + defaultTemplateId: string | null; } export const DEFAULT_SETTINGS: ClaudeContextSettings = { @@ -17,6 +25,10 @@ export const DEFAULT_SETTINGS: ClaudeContextSettings = { showPreview: false, includeActiveNote: false, excludedFiles: [], + sources: [], + showSourceLabels: true, + promptTemplates: [], + defaultTemplateId: null, }; export class ClaudeContextSettingTab extends PluginSettingTab { @@ -96,5 +108,311 @@ export class ClaudeContextSettingTab extends PluginSettingTab { .filter(s => s.length > 0); await this.plugin.saveSettings(); })); + + // === CONTEXT SOURCES SECTION === + containerEl.createEl('h3', { text: 'Context Sources' }); + + const sourcesDesc = containerEl.createEl('p', { + text: 'Add additional context sources like freetext, external files, or shell command output.', + cls: 'setting-item-description' + }); + sourcesDesc.style.marginBottom = '10px'; + + const buttonContainer = containerEl.createDiv({ cls: 'sources-button-container' }); + buttonContainer.style.display = 'flex'; + buttonContainer.style.gap = '8px'; + buttonContainer.style.marginBottom = '15px'; + + const addFreetextBtn = buttonContainer.createEl('button', { text: '+ Freetext' }); + addFreetextBtn.addEventListener('click', () => { + new SourceModal(this.app, this.plugin, 'freetext', null, () => this.display()).open(); + }); + + const addFileBtn = buttonContainer.createEl('button', { text: '+ File' }); + addFileBtn.addEventListener('click', () => { + new SourceModal(this.app, this.plugin, 'file', null, () => this.display()).open(); + }); + + const addShellBtn = buttonContainer.createEl('button', { text: '+ Shell' }); + addShellBtn.addEventListener('click', () => { + new SourceModal(this.app, this.plugin, 'shell', null, () => this.display()).open(); + }); + + // Sources list + const sourcesContainer = containerEl.createDiv({ cls: 'sources-list-container' }); + this.renderSourcesList(sourcesContainer); + + new Setting(containerEl) + .setName('Show source labels') + .setDesc('Add position and name labels to source output') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.showSourceLabels) + .onChange(async (value) => { + this.plugin.settings.showSourceLabels = value; + await this.plugin.saveSettings(); + })); + + // === PROMPT TEMPLATES SECTION === + containerEl.createEl('h3', { text: 'Prompt Templates' }); + + const templatesDesc = containerEl.createEl('p', { + text: 'Create reusable prompt templates that wrap around your context.', + cls: 'setting-item-description' + }); + templatesDesc.style.marginBottom = '10px'; + + const templateButtonContainer = containerEl.createDiv({ cls: 'templates-button-container' }); + templateButtonContainer.style.display = 'flex'; + templateButtonContainer.style.gap = '8px'; + templateButtonContainer.style.marginBottom = '15px'; + + const addTemplateBtn = templateButtonContainer.createEl('button', { text: '+ New Template' }); + addTemplateBtn.addEventListener('click', () => { + new TemplateModal(this.app, this.plugin, null, () => this.display()).open(); + }); + + const importBtn = templateButtonContainer.createEl('button', { text: 'Import' }); + importBtn.addEventListener('click', () => { + new TemplateImportExportModal(this.app, this.plugin, 'import', () => this.display()).open(); + }); + + const exportBtn = templateButtonContainer.createEl('button', { text: 'Export' }); + exportBtn.addEventListener('click', () => { + new TemplateImportExportModal(this.app, this.plugin, 'export', () => {}).open(); + }); + + // Starter templates + const hasStarterTemplates = this.plugin.settings.promptTemplates.some(t => t.isBuiltin); + if (!hasStarterTemplates) { + const starterContainer = containerEl.createDiv(); + starterContainer.style.padding = '10px'; + starterContainer.style.backgroundColor = 'var(--background-secondary)'; + starterContainer.style.borderRadius = '4px'; + starterContainer.style.marginBottom = '15px'; + + const starterText = starterContainer.createEl('p', { + text: `Add ${STARTER_TEMPLATES.length} starter templates to get started quickly.` + }); + starterText.style.margin = '0 0 10px 0'; + + const addStarterBtn = starterContainer.createEl('button', { text: 'Add Starter Templates' }); + addStarterBtn.addEventListener('click', async () => { + this.plugin.settings.promptTemplates.push(...STARTER_TEMPLATES); + await this.plugin.saveSettings(); + new Notice(`Added ${STARTER_TEMPLATES.length} starter templates`); + this.display(); + }); + } + + // Templates list + const templatesContainer = containerEl.createDiv({ cls: 'templates-list-container' }); + this.renderTemplatesList(templatesContainer); + + // Default template setting + new Setting(containerEl) + .setName('Default template') + .setDesc('Template to use by default when copying context') + .addDropdown(dropdown => { + dropdown.addOption('', 'None (plain context)'); + for (const template of this.plugin.settings.promptTemplates) { + dropdown.addOption(template.id, template.name); + } + dropdown.setValue(this.plugin.settings.defaultTemplateId || ''); + dropdown.onChange(async (value) => { + this.plugin.settings.defaultTemplateId = value || null; + await this.plugin.saveSettings(); + }); + }); + } + + private renderSourcesList(container: HTMLElement) { + container.empty(); + + if (this.plugin.settings.sources.length === 0) { + const emptyMsg = container.createEl('p', { + text: 'No sources configured yet.', + cls: 'setting-item-description' + }); + emptyMsg.style.fontStyle = 'italic'; + return; + } + + const list = container.createDiv({ cls: 'sources-list' }); + list.style.border = '1px solid var(--background-modifier-border)'; + list.style.borderRadius = '4px'; + list.style.marginBottom = '15px'; + + for (const source of this.plugin.settings.sources) { + const row = list.createDiv({ cls: 'source-row' }); + row.style.display = 'flex'; + row.style.alignItems = 'center'; + row.style.padding = '8px 12px'; + row.style.borderBottom = '1px solid var(--background-modifier-border)'; + row.style.gap = '10px'; + + // Icon + const icon = row.createEl('span', { text: getSourceIcon(source.type) }); + icon.style.fontSize = '16px'; + + // Name + const name = row.createEl('span', { text: source.name }); + name.style.flex = '1'; + name.style.fontWeight = '500'; + + // Position badge + const position = row.createEl('span', { text: source.position }); + position.style.padding = '2px 6px'; + position.style.borderRadius = '3px'; + position.style.fontSize = '11px'; + position.style.backgroundColor = 'var(--background-modifier-hover)'; + + // Enabled toggle + const toggleContainer = row.createDiv(); + const toggle = toggleContainer.createEl('input', { type: 'checkbox' }); + toggle.checked = source.enabled; + toggle.addEventListener('change', async () => { + source.enabled = toggle.checked; + await this.plugin.saveSettings(); + }); + + // Edit button + const editBtn = row.createEl('button', { text: '✎' }); + editBtn.style.padding = '2px 8px'; + editBtn.addEventListener('click', () => { + new SourceModal(this.app, this.plugin, source.type, source, () => this.display()).open(); + }); + + // Delete button + const deleteBtn = row.createEl('button', { text: '✕' }); + deleteBtn.style.padding = '2px 8px'; + deleteBtn.addEventListener('click', async () => { + this.plugin.settings.sources = this.plugin.settings.sources.filter(s => s.id !== source.id); + await this.plugin.saveSettings(); + this.display(); + }); + + // Test button + const testBtn = row.createEl('button', { text: '▶' }); + testBtn.title = 'Test source'; + testBtn.style.padding = '2px 8px'; + testBtn.addEventListener('click', async () => { + const registry = new SourceRegistry(); + const result = await registry.resolveSource(source); + if (result.error) { + new (await import('obsidian')).Notice(`Error: ${result.error}`); + } else { + const preview = result.content.substring(0, 200) + (result.content.length > 200 ? '...' : ''); + new (await import('obsidian')).Notice(`Success (${result.content.length} chars):\n${preview}`); + } + }); + } + + // Remove bottom border from last item + const lastRow = list.lastElementChild as HTMLElement; + if (lastRow) { + lastRow.style.borderBottom = 'none'; + } + } + + private renderTemplatesList(container: HTMLElement) { + container.empty(); + + if (this.plugin.settings.promptTemplates.length === 0) { + const emptyMsg = container.createEl('p', { + text: 'No templates configured yet.', + cls: 'setting-item-description' + }); + emptyMsg.style.fontStyle = 'italic'; + return; + } + + const list = container.createDiv({ cls: 'templates-list' }); + list.style.border = '1px solid var(--background-modifier-border)'; + list.style.borderRadius = '4px'; + list.style.marginBottom = '15px'; + + for (const template of this.plugin.settings.promptTemplates) { + const row = list.createDiv({ cls: 'template-row' }); + row.style.display = 'flex'; + row.style.alignItems = 'center'; + row.style.padding = '8px 12px'; + row.style.borderBottom = '1px solid var(--background-modifier-border)'; + row.style.gap = '10px'; + + // Icon + const icon = row.createEl('span', { text: template.isBuiltin ? '📦' : '📝' }); + icon.style.fontSize = '16px'; + + // Name and description + const textContainer = row.createDiv(); + textContainer.style.flex = '1'; + + const name = textContainer.createEl('span', { text: template.name }); + name.style.fontWeight = '500'; + + if (template.description) { + const desc = textContainer.createEl('div', { text: template.description }); + desc.style.fontSize = '11px'; + desc.style.color = 'var(--text-muted)'; + } + + // Builtin badge + if (template.isBuiltin) { + const badge = row.createEl('span', { text: 'builtin' }); + badge.style.padding = '2px 6px'; + badge.style.borderRadius = '3px'; + badge.style.fontSize = '11px'; + badge.style.backgroundColor = 'var(--background-modifier-hover)'; + } + + // Edit button (only for non-builtin) + if (!template.isBuiltin) { + const editBtn = row.createEl('button', { text: '✎' }); + editBtn.style.padding = '2px 8px'; + editBtn.addEventListener('click', () => { + new TemplateModal(this.app, this.plugin, template, () => this.display()).open(); + }); + } + + // Duplicate button + const duplicateBtn = row.createEl('button', { text: '⧉' }); + duplicateBtn.title = 'Duplicate'; + duplicateBtn.style.padding = '2px 8px'; + duplicateBtn.addEventListener('click', async () => { + const { generateTemplateId } = await import('./templates'); + const duplicate: PromptTemplate = { + id: generateTemplateId(), + name: `${template.name} (copy)`, + description: template.description, + content: template.content, + isBuiltin: false, + }; + this.plugin.settings.promptTemplates.push(duplicate); + await this.plugin.saveSettings(); + new Notice(`Duplicated template: ${template.name}`); + this.display(); + }); + + // Delete button + const deleteBtn = row.createEl('button', { text: '✕' }); + deleteBtn.style.padding = '2px 8px'; + deleteBtn.addEventListener('click', async () => { + this.plugin.settings.promptTemplates = this.plugin.settings.promptTemplates.filter( + t => t.id !== template.id + ); + // Clear default if this was it + if (this.plugin.settings.defaultTemplateId === template.id) { + this.plugin.settings.defaultTemplateId = null; + } + await this.plugin.saveSettings(); + this.display(); + }); + } + + // Remove bottom border from last item + const lastRow = list.lastElementChild as HTMLElement; + if (lastRow) { + lastRow.style.borderBottom = 'none'; + } } } diff --git a/src/source-modal.ts b/src/source-modal.ts new file mode 100644 index 0000000..c5a24f6 --- /dev/null +++ b/src/source-modal.ts @@ -0,0 +1,358 @@ +import { App, Modal, Notice, Setting } from 'obsidian'; +import ClaudeContextPlugin from './main'; +import { + ContextSource, + SourceType, + SourcePosition, + FreetextConfig, + FileConfig, + ShellConfig, + generateSourceId, + SourceRegistry, +} from './sources'; + +export class SourceModal extends Modal { + plugin: ClaudeContextPlugin; + sourceType: SourceType; + existingSource: ContextSource | null; + onSave: () => void; + + // Form state + name: string = ''; + enabled: boolean = true; + position: SourcePosition = 'prefix'; + + // Freetext config + freetextContent: string = ''; + + // File config + filePath: string = ''; + recursive: boolean = false; + filePattern: string = '*'; + + // Shell config + shellCommand: string = ''; + shellArgs: string = ''; + shellCwd: string = ''; + shellTimeout: number = 5000; + + constructor( + app: App, + plugin: ClaudeContextPlugin, + sourceType: SourceType, + existingSource: ContextSource | null, + onSave: () => void + ) { + super(app); + this.plugin = plugin; + this.sourceType = sourceType; + this.existingSource = existingSource; + this.onSave = onSave; + + // Load existing source data if editing + if (existingSource) { + this.name = existingSource.name; + this.enabled = existingSource.enabled; + this.position = existingSource.position; + + switch (existingSource.type) { + case 'freetext': + this.freetextContent = (existingSource.config as FreetextConfig).content; + break; + case 'file': + case 'folder': + const fileConfig = existingSource.config as FileConfig; + this.filePath = fileConfig.path; + this.recursive = fileConfig.recursive || false; + this.filePattern = fileConfig.pattern || '*'; + break; + case 'shell': + const shellConfig = existingSource.config as ShellConfig; + this.shellCommand = shellConfig.command; + this.shellArgs = shellConfig.args.join(' '); + this.shellCwd = shellConfig.cwd || ''; + this.shellTimeout = shellConfig.timeout || 5000; + break; + } + } + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('claude-context-source-modal'); + + const title = this.existingSource ? 'Edit Source' : 'Add Source'; + const typeLabel = this.getTypeLabel(); + contentEl.createEl('h2', { text: `${title}: ${typeLabel}` }); + + // Common fields + new Setting(contentEl) + .setName('Name') + .setDesc('Display name for this source') + .addText(text => text + .setPlaceholder('My Context') + .setValue(this.name) + .onChange(v => this.name = v)); + + new Setting(contentEl) + .setName('Position') + .setDesc('Where to insert this content') + .addDropdown(dropdown => dropdown + .addOption('prefix', 'Prefix (before vault content)') + .addOption('suffix', 'Suffix (after vault content)') + .setValue(this.position) + .onChange(v => this.position = v as SourcePosition)); + + new Setting(contentEl) + .setName('Enabled') + .addToggle(toggle => toggle + .setValue(this.enabled) + .onChange(v => this.enabled = v)); + + // Type-specific fields + contentEl.createEl('hr'); + + switch (this.sourceType) { + case 'freetext': + this.renderFreetextFields(contentEl); + break; + case 'file': + case 'folder': + this.renderFileFields(contentEl); + break; + case 'shell': + this.renderShellFields(contentEl); + break; + } + + // Buttons + contentEl.createEl('hr'); + + const buttonContainer = contentEl.createDiv({ cls: 'button-container' }); + buttonContainer.style.display = 'flex'; + buttonContainer.style.justifyContent = 'flex-end'; + buttonContainer.style.gap = '10px'; + + const testBtn = buttonContainer.createEl('button', { text: 'Test' }); + testBtn.addEventListener('click', () => this.testSource()); + + const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel' }); + cancelBtn.addEventListener('click', () => this.close()); + + const saveBtn = buttonContainer.createEl('button', { text: 'Save', cls: 'mod-cta' }); + saveBtn.addEventListener('click', () => this.save()); + } + + private getTypeLabel(): string { + switch (this.sourceType) { + case 'freetext': return 'Freetext'; + case 'file': return 'File'; + case 'folder': return 'Folder'; + case 'shell': return 'Shell Command'; + default: return 'Unknown'; + } + } + + private renderFreetextFields(container: HTMLElement) { + new Setting(container) + .setName('Content') + .setDesc('Text content to include') + .addTextArea(text => { + text.setPlaceholder('Enter your context text here...') + .setValue(this.freetextContent) + .onChange(v => this.freetextContent = v); + text.inputEl.rows = 8; + text.inputEl.style.width = '100%'; + }); + } + + private renderFileFields(container: HTMLElement) { + new Setting(container) + .setName('Path') + .setDesc('Absolute path to file or folder') + .addText(text => text + .setPlaceholder('/home/user/project/README.md') + .setValue(this.filePath) + .onChange(v => this.filePath = v)); + + new Setting(container) + .setName('Recursive') + .setDesc('Read folder contents recursively') + .addToggle(toggle => toggle + .setValue(this.recursive) + .onChange(v => this.recursive = v)); + + new Setting(container) + .setName('Pattern') + .setDesc('Glob pattern for file matching (e.g. *.md, *.ts)') + .addText(text => text + .setPlaceholder('*') + .setValue(this.filePattern) + .onChange(v => this.filePattern = v)); + } + + private renderShellFields(container: HTMLElement) { + new Setting(container) + .setName('Command') + .setDesc('Command to execute (e.g. git, ls, cat)') + .addText(text => text + .setPlaceholder('git') + .setValue(this.shellCommand) + .onChange(v => this.shellCommand = v)); + + new Setting(container) + .setName('Arguments') + .setDesc('Space-separated arguments') + .addText(text => text + .setPlaceholder('log --oneline -10') + .setValue(this.shellArgs) + .onChange(v => this.shellArgs = v)); + + new Setting(container) + .setName('Working directory') + .setDesc('Optional working directory for the command') + .addText(text => text + .setPlaceholder('/home/user/project') + .setValue(this.shellCwd) + .onChange(v => this.shellCwd = v)); + + new Setting(container) + .setName('Timeout (ms)') + .setDesc('Maximum execution time') + .addText(text => text + .setPlaceholder('5000') + .setValue(String(this.shellTimeout)) + .onChange(v => { + const num = parseInt(v, 10); + if (!isNaN(num) && num > 0) { + this.shellTimeout = num; + } + })); + } + + private buildSource(): ContextSource { + let config; + let type: SourceType = this.sourceType; + + switch (this.sourceType) { + case 'freetext': + config = { content: this.freetextContent } as FreetextConfig; + break; + case 'file': + case 'folder': + config = { + path: this.filePath, + recursive: this.recursive, + pattern: this.filePattern, + } as FileConfig; + type = this.recursive ? 'folder' : 'file'; + break; + case 'shell': + config = { + command: this.shellCommand, + args: this.shellArgs.split(/\s+/).filter(s => s), + cwd: this.shellCwd || undefined, + timeout: this.shellTimeout, + } as ShellConfig; + break; + default: + throw new Error(`Unknown source type: ${this.sourceType}`); + } + + return { + id: this.existingSource?.id || generateSourceId(), + type, + name: this.name || this.getDefaultName(), + enabled: this.enabled, + position: this.position, + config, + }; + } + + private getDefaultName(): string { + switch (this.sourceType) { + case 'freetext': + return 'Freetext'; + case 'file': + case 'folder': + return this.filePath.split('/').pop() || 'File'; + case 'shell': + return `${this.shellCommand} ${this.shellArgs}`.trim(); + default: + return 'Source'; + } + } + + private async testSource() { + const source = this.buildSource(); + const registry = new SourceRegistry(); + + try { + const result = await registry.resolveSource(source); + + if (result.error) { + new Notice(`Error: ${result.error}`); + } else { + const preview = result.content.substring(0, 500); + const suffix = result.content.length > 500 ? '\n\n... (truncated)' : ''; + new Notice(`Success (${result.content.length} chars):\n${preview}${suffix}`, 10000); + } + } catch (error) { + new Notice(`Test failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + private async save() { + if (!this.validate()) { + return; + } + + const source = this.buildSource(); + + if (this.existingSource) { + // Update existing + const index = this.plugin.settings.sources.findIndex(s => s.id === this.existingSource!.id); + if (index >= 0) { + this.plugin.settings.sources[index] = source; + } + } else { + // Add new + this.plugin.settings.sources.push(source); + } + + await this.plugin.saveSettings(); + this.onSave(); + this.close(); + } + + private validate(): boolean { + switch (this.sourceType) { + case 'freetext': + if (!this.freetextContent.trim()) { + new Notice('Content is required'); + return false; + } + break; + case 'file': + case 'folder': + if (!this.filePath.trim()) { + new Notice('Path is required'); + return false; + } + break; + case 'shell': + if (!this.shellCommand.trim()) { + new Notice('Command is required'); + return false; + } + break; + } + return true; + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/src/sources.ts b/src/sources.ts new file mode 100644 index 0000000..029baaa --- /dev/null +++ b/src/sources.ts @@ -0,0 +1,241 @@ +import { Platform } from 'obsidian'; + +// === Types === + +export type SourceType = 'freetext' | 'file' | 'folder' | 'shell'; +export type SourcePosition = 'prefix' | 'suffix'; + +export interface FreetextConfig { + content: string; +} + +export interface FileConfig { + path: string; + recursive?: boolean; + pattern?: string; +} + +export interface ShellConfig { + command: string; + args: string[]; + cwd?: string; + timeout?: number; +} + +export type SourceConfig = FreetextConfig | FileConfig | ShellConfig; + +export interface ContextSource { + id: string; + type: SourceType; + name: string; + enabled: boolean; + position: SourcePosition; + config: SourceConfig; +} + +export interface ResolvedSource { + source: ContextSource; + content: string; + error?: string; +} + +// === Helper functions === + +export function generateSourceId(): string { + return Date.now().toString(36) + Math.random().toString(36).substring(2, 9); +} + +export function createFreetextSource(name: string, content: string, position: SourcePosition = 'prefix'): ContextSource { + return { + id: generateSourceId(), + type: 'freetext', + name, + enabled: true, + position, + config: { content } as FreetextConfig, + }; +} + +export function createFileSource(name: string, path: string, position: SourcePosition = 'suffix', recursive = false, pattern?: string): ContextSource { + return { + id: generateSourceId(), + type: recursive ? 'folder' : 'file', + name, + enabled: true, + position, + config: { path, recursive, pattern } as FileConfig, + }; +} + +export function createShellSource(name: string, command: string, args: string[], position: SourcePosition = 'suffix', cwd?: string, timeout = 5000): ContextSource { + return { + id: generateSourceId(), + type: 'shell', + name, + enabled: true, + position, + config: { command, args, cwd, timeout } as ShellConfig, + }; +} + +// === Source Registry === + +export class SourceRegistry { + async resolveSource(source: ContextSource): Promise { + try { + if (!source.enabled) { + return { source, content: '', error: 'Source is disabled' }; + } + + let content: string; + + switch (source.type) { + case 'freetext': + content = await this.resolveFreetextSource(source.config as FreetextConfig); + break; + case 'file': + content = await this.resolveFileSource(source.config as FileConfig); + break; + case 'folder': + content = await this.resolveFolderSource(source.config as FileConfig); + break; + case 'shell': + content = await this.resolveShellSource(source.config as ShellConfig); + break; + default: + throw new Error(`Unknown source type: ${source.type}`); + } + + return { source, content }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { source, content: '', error: errorMessage }; + } + } + + async resolveAll(sources: ContextSource[]): Promise { + const results: ResolvedSource[] = []; + + for (const source of sources) { + const resolved = await this.resolveSource(source); + results.push(resolved); + } + + return results; + } + + private async resolveFreetextSource(config: FreetextConfig): Promise { + return config.content || ''; + } + + private async resolveFileSource(config: FileConfig): Promise { + if (!Platform.isDesktopApp) { + throw new Error('External file access only available on desktop'); + } + + const fs = require('fs').promises; + const content = await fs.readFile(config.path, 'utf-8'); + return content; + } + + private async resolveFolderSource(config: FileConfig): Promise { + if (!Platform.isDesktopApp) { + throw new Error('External file access only available on desktop'); + } + + const fs = require('fs').promises; + const path = require('path'); + + const pattern = config.pattern || '*'; + const contents: string[] = []; + + await this.readDirectoryRecursive(config.path, pattern, contents, fs, path); + + return contents.join('\n\n---\n\n'); + } + + private async readDirectoryRecursive( + dirPath: string, + pattern: string, + contents: string[], + fs: typeof import('fs').promises, + path: typeof import('path') + ): Promise { + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + + if (entry.isDirectory()) { + await this.readDirectoryRecursive(fullPath, pattern, contents, fs, path); + } else if (entry.isFile()) { + if (this.matchesPattern(entry.name, pattern)) { + try { + const content = await fs.readFile(fullPath, 'utf-8'); + contents.push(`# === ${fullPath} ===\n\n${content}`); + } catch { + // Skip files that can't be read + } + } + } + } + } + + private matchesPattern(filename: string, pattern: string): boolean { + if (pattern === '*') return true; + + // Simple glob matching + const regex = new RegExp( + '^' + pattern + .replace(/\./g, '\\.') + .replace(/\*/g, '.*') + .replace(/\?/g, '.') + '$' + ); + + return regex.test(filename); + } + + private async resolveShellSource(config: ShellConfig): Promise { + if (!Platform.isDesktopApp) { + throw new Error('Shell commands only available on desktop'); + } + + const { execFile } = require('child_process'); + const { promisify } = require('util'); + const execFileAsync = promisify(execFile); + + const { stdout } = await execFileAsync(config.command, config.args, { + cwd: config.cwd, + timeout: config.timeout || 5000, + maxBuffer: 1024 * 1024, // 1MB limit + }); + + return stdout; + } +} + +// === Formatting === + +export function formatSourceOutput( + resolved: ResolvedSource, + showLabels: boolean +): string { + if (!resolved.content) return ''; + + if (showLabels) { + const positionLabel = resolved.source.position.toUpperCase(); + return `# === ${positionLabel}: ${resolved.source.name} ===\n\n${resolved.content}`; + } + + return resolved.content; +} + +export function getSourceIcon(type: SourceType): string { + switch (type) { + case 'freetext': return '📝'; + case 'file': return '📄'; + case 'folder': return '📁'; + case 'shell': return '💻'; + default: return '📌'; + } +} diff --git a/src/template-modal.ts b/src/template-modal.ts new file mode 100644 index 0000000..bbf3d8c --- /dev/null +++ b/src/template-modal.ts @@ -0,0 +1,315 @@ +import { App, Modal, Notice, Setting } from 'obsidian'; +import ClaudeContextPlugin from './main'; +import { + PromptTemplate, + generateTemplateId, + getAvailablePlaceholders, + TemplateEngine, +} from './templates'; + +export class TemplateModal extends Modal { + plugin: ClaudeContextPlugin; + existingTemplate: PromptTemplate | null; + onSave: () => void; + + // Form state + name: string = ''; + description: string = ''; + content: string = ''; + + constructor( + app: App, + plugin: ClaudeContextPlugin, + existingTemplate: PromptTemplate | null, + onSave: () => void + ) { + super(app); + this.plugin = plugin; + this.existingTemplate = existingTemplate; + this.onSave = onSave; + + if (existingTemplate) { + this.name = existingTemplate.name; + this.description = existingTemplate.description || ''; + this.content = existingTemplate.content; + } + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('claude-context-template-modal'); + + const title = this.existingTemplate ? 'Edit Template' : 'New Template'; + contentEl.createEl('h2', { text: title }); + + new Setting(contentEl) + .setName('Name') + .setDesc('Display name for this template') + .addText(text => text + .setPlaceholder('e.g. Code Review') + .setValue(this.name) + .onChange(v => this.name = v)); + + new Setting(contentEl) + .setName('Description') + .setDesc('Optional description of what this template does') + .addText(text => text + .setPlaceholder('e.g. Review code for bugs and improvements') + .setValue(this.description) + .onChange(v => this.description = v)); + + new Setting(contentEl) + .setName('Template content') + .setDesc('Use placeholders like {{context}}, {{selection}}, etc.') + .addTextArea(text => { + text.setPlaceholder('Enter your prompt template here...\n\n{{context}}') + .setValue(this.content) + .onChange(v => this.content = v); + text.inputEl.rows = 15; + text.inputEl.style.width = '100%'; + text.inputEl.style.fontFamily = 'monospace'; + }); + + // Placeholder reference + contentEl.createEl('h4', { text: 'Available Placeholders' }); + const placeholderList = contentEl.createDiv({ cls: 'placeholder-list' }); + placeholderList.style.fontSize = '12px'; + placeholderList.style.marginBottom = '15px'; + placeholderList.style.padding = '10px'; + placeholderList.style.backgroundColor = 'var(--background-secondary)'; + placeholderList.style.borderRadius = '4px'; + + for (const placeholder of getAvailablePlaceholders()) { + const row = placeholderList.createDiv(); + row.style.marginBottom = '4px'; + + const code = row.createEl('code', { text: placeholder.name }); + code.style.marginRight = '8px'; + + row.createEl('span', { text: `- ${placeholder.description}` }); + } + + // Buttons + const buttonContainer = contentEl.createDiv({ cls: 'button-container' }); + buttonContainer.style.display = 'flex'; + buttonContainer.style.justifyContent = 'flex-end'; + buttonContainer.style.gap = '10px'; + buttonContainer.style.marginTop = '15px'; + + const previewBtn = buttonContainer.createEl('button', { text: 'Preview' }); + previewBtn.addEventListener('click', () => this.previewTemplate()); + + const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel' }); + cancelBtn.addEventListener('click', () => this.close()); + + const saveBtn = buttonContainer.createEl('button', { text: 'Save', cls: 'mod-cta' }); + saveBtn.addEventListener('click', () => this.save()); + } + + private async previewTemplate() { + if (!this.content.trim()) { + new Notice('Template content is empty'); + return; + } + + try { + const engine = new TemplateEngine(this.app); + const context = await engine.buildContext('[Generated context would appear here]'); + const processed = engine.processTemplate(this.content, context); + + // Show preview in a simple modal + const previewModal = new Modal(this.app); + previewModal.contentEl.createEl('h2', { text: 'Template Preview' }); + + const previewContainer = previewModal.contentEl.createDiv(); + previewContainer.style.maxHeight = '400px'; + previewContainer.style.overflow = 'auto'; + previewContainer.style.padding = '10px'; + previewContainer.style.backgroundColor = 'var(--background-secondary)'; + previewContainer.style.borderRadius = '4px'; + previewContainer.style.fontFamily = 'monospace'; + previewContainer.style.fontSize = '12px'; + previewContainer.style.whiteSpace = 'pre-wrap'; + previewContainer.setText(processed); + + const closeBtn = previewModal.contentEl.createEl('button', { text: 'Close' }); + closeBtn.style.marginTop = '15px'; + closeBtn.addEventListener('click', () => previewModal.close()); + + previewModal.open(); + } catch (error) { + new Notice(`Preview failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + private async save() { + if (!this.name.trim()) { + new Notice('Name is required'); + return; + } + + if (!this.content.trim()) { + new Notice('Template content is required'); + return; + } + + const template: PromptTemplate = { + id: this.existingTemplate?.id || generateTemplateId(), + name: this.name.trim(), + description: this.description.trim() || undefined, + content: this.content, + isBuiltin: false, + }; + + if (this.existingTemplate) { + // Update existing + const index = this.plugin.settings.promptTemplates.findIndex( + t => t.id === this.existingTemplate!.id + ); + if (index >= 0) { + this.plugin.settings.promptTemplates[index] = template; + } + } else { + // Add new + this.plugin.settings.promptTemplates.push(template); + } + + await this.plugin.saveSettings(); + this.onSave(); + this.close(); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} + +// Import/Export Modal +export class TemplateImportExportModal extends Modal { + plugin: ClaudeContextPlugin; + mode: 'import' | 'export'; + onComplete: () => void; + + importText: string = ''; + + constructor( + app: App, + plugin: ClaudeContextPlugin, + mode: 'import' | 'export', + onComplete: () => void + ) { + super(app); + this.plugin = plugin; + this.mode = mode; + this.onComplete = onComplete; + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + + if (this.mode === 'export') { + this.renderExport(contentEl); + } else { + this.renderImport(contentEl); + } + } + + private renderExport(container: HTMLElement) { + container.createEl('h2', { text: 'Export Templates' }); + + const userTemplates = this.plugin.settings.promptTemplates.filter(t => !t.isBuiltin); + + if (userTemplates.length === 0) { + container.createEl('p', { text: 'No custom templates to export.' }); + return; + } + + container.createEl('p', { text: `Exporting ${userTemplates.length} custom template(s)...` }); + + const { exportTemplates } = require('./templates'); + const json = exportTemplates(userTemplates); + + const textArea = container.createEl('textarea'); + textArea.value = json; + textArea.style.width = '100%'; + textArea.style.height = '300px'; + textArea.style.fontFamily = 'monospace'; + textArea.style.fontSize = '12px'; + textArea.readOnly = true; + + const buttonContainer = container.createDiv(); + buttonContainer.style.display = 'flex'; + buttonContainer.style.gap = '10px'; + buttonContainer.style.marginTop = '15px'; + + const copyBtn = buttonContainer.createEl('button', { text: 'Copy to Clipboard', cls: 'mod-cta' }); + copyBtn.addEventListener('click', async () => { + await navigator.clipboard.writeText(json); + new Notice('Templates copied to clipboard'); + }); + + const closeBtn = buttonContainer.createEl('button', { text: 'Close' }); + closeBtn.addEventListener('click', () => this.close()); + } + + private renderImport(container: HTMLElement) { + container.createEl('h2', { text: 'Import Templates' }); + container.createEl('p', { text: 'Paste your template JSON below:' }); + + const textArea = container.createEl('textarea'); + textArea.style.width = '100%'; + textArea.style.height = '300px'; + textArea.style.fontFamily = 'monospace'; + textArea.style.fontSize = '12px'; + textArea.placeholder = '{\n "version": 1,\n "templates": [...]\n}'; + textArea.addEventListener('input', () => { + this.importText = textArea.value; + }); + + const buttonContainer = container.createDiv(); + buttonContainer.style.display = 'flex'; + buttonContainer.style.gap = '10px'; + buttonContainer.style.marginTop = '15px'; + + const importBtn = buttonContainer.createEl('button', { text: 'Import', cls: 'mod-cta' }); + importBtn.addEventListener('click', () => this.doImport()); + + const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel' }); + cancelBtn.addEventListener('click', () => this.close()); + } + + private async doImport() { + if (!this.importText.trim()) { + new Notice('Please paste template JSON'); + return; + } + + try { + const { importTemplates } = require('./templates'); + const templates = importTemplates(this.importText); + + if (templates.length === 0) { + new Notice('No templates found in the provided JSON'); + return; + } + + // Add imported templates + this.plugin.settings.promptTemplates.push(...templates); + await this.plugin.saveSettings(); + + new Notice(`Imported ${templates.length} template(s)`); + this.onComplete(); + this.close(); + } catch (error) { + new Notice(`Import failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/src/templates.ts b/src/templates.ts new file mode 100644 index 0000000..f246030 --- /dev/null +++ b/src/templates.ts @@ -0,0 +1,329 @@ +import { App, MarkdownView } from 'obsidian'; + +// === Types === + +export interface PromptTemplate { + id: string; + name: string; + description?: string; + content: string; + isBuiltin?: boolean; +} + +export interface TemplateContext { + context: string; + selection: string; + activeNote: string; + activeNoteName: string; + date: string; + time: string; + datetime: string; + vaultName: string; +} + +// === ID Generation === + +export function generateTemplateId(): string { + return 'tpl_' + Date.now().toString(36) + Math.random().toString(36).substring(2, 9); +} + +// === Starter Templates === + +export const STARTER_TEMPLATES: PromptTemplate[] = [ + { + id: 'builtin_code_review', + name: 'Code Review', + description: 'Review code for bugs, improvements, and best practices', + content: `You are an experienced code reviewer. Please review the following code/context and provide feedback on: + +1. **Bugs & Issues**: Identify any bugs, logic errors, or potential runtime issues +2. **Code Quality**: Comment on readability, naming conventions, and structure +3. **Performance**: Point out any performance concerns or optimization opportunities +4. **Security**: Flag any security vulnerabilities or unsafe practices +5. **Suggestions**: Provide specific, actionable improvement suggestions + +## Context + +{{context}} + +{{#if selection}} +## Selected Code to Review + +{{selection}} +{{/if}} + +Please structure your review clearly with the categories above.`, + isBuiltin: true, + }, + { + id: 'builtin_summary', + name: 'Summary', + description: 'Summarize the provided context concisely', + content: `Please provide a clear and concise summary of the following content. + +## Content + +{{context}} + +{{#if active_note}} +## Current Note + +{{active_note}} +{{/if}} + +Summarize the key points, main ideas, and important details. Keep the summary focused and easy to understand.`, + isBuiltin: true, + }, + { + id: 'builtin_qa', + name: 'Question & Answer', + description: 'Answer questions based on the provided context', + content: `You are a helpful assistant with access to the following context. Use this information to answer questions accurately. + +## Context + +{{context}} + +{{#if selection}} +## Question + +{{selection}} +{{/if}} + +Please answer based on the provided context. If the answer is not in the context, say so clearly.`, + isBuiltin: true, + }, + { + id: 'builtin_continue', + name: 'Continue Writing', + description: 'Continue writing in the same style and tone', + content: `Continue writing the following content in the same style, tone, and format. + +## Context for Reference + +{{context}} + +## Content to Continue + +{{#if selection}} +{{selection}} +{{else}} +{{active_note}} +{{/if}} + +Continue naturally from where the content ends. Maintain consistency with the existing style and voice.`, + isBuiltin: true, + }, + { + id: 'builtin_explain', + name: 'Explain', + description: 'Explain the content in simple terms', + content: `Please explain the following content in clear, simple terms. + +{{#if selection}} +## Content to Explain + +{{selection}} +{{else}} +## Content to Explain + +{{active_note}} +{{/if}} + +{{#if context}} +## Additional Context + +{{context}} +{{/if}} + +Explain this as if you were teaching someone who is new to the topic. Use examples where helpful.`, + isBuiltin: true, + }, +]; + +// === Template Engine === + +export class TemplateEngine { + private app: App; + + constructor(app: App) { + this.app = app; + } + + /** + * Build the template context from the current app state + */ + async buildContext(generatedContext: string): Promise { + const now = new Date(); + + // Get selection from active editor + let selection = ''; + let activeNote = ''; + let activeNoteName = ''; + + const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); + if (activeView) { + const editor = activeView.editor; + selection = editor.getSelection() || ''; + + if (activeView.file) { + activeNote = await this.app.vault.read(activeView.file); + activeNoteName = activeView.file.basename; + } + } + + return { + context: generatedContext, + selection, + activeNote, + activeNoteName, + date: this.formatDate(now), + time: this.formatTime(now), + datetime: this.formatDateTime(now), + vaultName: this.app.vault.getName(), + }; + } + + /** + * Process a template with the given context + */ + processTemplate(template: string, context: TemplateContext): string { + let result = template; + + // Simple variable replacement + result = result.replace(/\{\{context\}\}/g, context.context); + result = result.replace(/\{\{selection\}\}/g, context.selection); + result = result.replace(/\{\{active_note\}\}/g, context.activeNote); + result = result.replace(/\{\{active_note_name\}\}/g, context.activeNoteName); + result = result.replace(/\{\{date\}\}/g, context.date); + result = result.replace(/\{\{time\}\}/g, context.time); + result = result.replace(/\{\{datetime\}\}/g, context.datetime); + result = result.replace(/\{\{vault_name\}\}/g, context.vaultName); + + // Process conditionals: {{#if variable}}...{{/if}} + result = this.processConditionals(result, context); + + return result; + } + + /** + * Process conditional blocks + * Supports: {{#if variable}}content{{/if}} + * Supports: {{#if variable}}content{{else}}other content{{/if}} + */ + private processConditionals(template: string, context: TemplateContext): string { + // Pattern for {{#if variable}}...{{/if}} with optional {{else}} + const conditionalPattern = /\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g; + + return template.replace(conditionalPattern, (match, variable, content) => { + const value = this.getContextValue(variable, context); + const isTruthy = value !== '' && value !== undefined && value !== null; + + // Check for else clause + const elseParts = content.split(/\{\{else\}\}/); + const ifContent = elseParts[0]; + const elseContent = elseParts.length > 1 ? elseParts[1] : ''; + + return isTruthy ? ifContent : elseContent; + }); + } + + /** + * Get a value from the context by variable name + */ + private getContextValue(variable: string, context: TemplateContext): string { + const mapping: Record = { + 'context': 'context', + 'selection': 'selection', + 'active_note': 'activeNote', + 'activeNote': 'activeNote', + 'active_note_name': 'activeNoteName', + 'activeNoteName': 'activeNoteName', + 'date': 'date', + 'time': 'time', + 'datetime': 'datetime', + 'vault_name': 'vaultName', + 'vaultName': 'vaultName', + }; + + const key = mapping[variable]; + return key ? context[key] : ''; + } + + private formatDate(date: Date): string { + const parts = date.toISOString().split('T'); + return parts[0] || ''; + } + + private formatTime(date: Date): string { + const parts = date.toTimeString().split(' '); + const timePart = parts[0] || ''; + return timePart.substring(0, 5); + } + + private formatDateTime(date: Date): string { + return `${this.formatDate(date)} ${this.formatTime(date)}`; + } +} + +// === Export/Import === + +export interface TemplateExport { + version: 1; + exportedAt: string; + templates: PromptTemplate[]; +} + +export function exportTemplates(templates: PromptTemplate[]): string { + const exportData: TemplateExport = { + version: 1, + exportedAt: new Date().toISOString(), + templates: templates.filter(t => !t.isBuiltin), + }; + return JSON.stringify(exportData, null, 2); +} + +export function importTemplates(json: string): PromptTemplate[] { + try { + const data = JSON.parse(json); + + // Handle both versioned export format and simple array + let templates: PromptTemplate[]; + + if (data.version && Array.isArray(data.templates)) { + templates = data.templates; + } else if (Array.isArray(data)) { + templates = data; + } else { + throw new Error('Invalid template format'); + } + + // Validate and regenerate IDs to avoid conflicts + return templates.map(t => ({ + id: generateTemplateId(), + name: t.name || 'Imported Template', + description: t.description, + content: t.content || '', + isBuiltin: false, + })); + } catch (error) { + throw new Error(`Failed to parse templates: ${error instanceof Error ? error.message : String(error)}`); + } +} + +// === Helpers === + +export function getAvailablePlaceholders(): { name: string; description: string }[] { + return [ + { name: '{{context}}', description: 'The generated vault context' }, + { name: '{{selection}}', description: 'Currently selected text in the editor' }, + { name: '{{active_note}}', description: 'Content of the active note' }, + { name: '{{active_note_name}}', description: 'Name of the active note' }, + { name: '{{date}}', description: 'Current date (YYYY-MM-DD)' }, + { name: '{{time}}', description: 'Current time (HH:MM)' }, + { name: '{{datetime}}', description: 'Current date and time' }, + { name: '{{vault_name}}', description: 'Name of the vault' }, + { name: '{{#if variable}}...{{/if}}', description: 'Conditional block' }, + { name: '{{#if variable}}...{{else}}...{{/if}}', description: 'Conditional with else' }, + ]; +}