feat: add modular source system and prompt templates

Sources:
- Add ContextSource system for additional context (freetext, files, shell)
- Support prefix/suffix positioning for sources
- Include source management UI in settings with add/edit/delete/test
- Platform checks for desktop-only features (file access, shell commands)

Templates:
- Add PromptTemplate system with placeholder support
- Placeholders: {{context}}, {{selection}}, {{active_note}}, {{date}}, etc.
- Conditional blocks: {{#if variable}}...{{else}}...{{/if}}
- 5 built-in starter templates (Code Review, Summary, Q&A, Continue, Explain)
- Template management in settings with import/export as JSON
- Template selection dropdown in generator modal

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Luca G. Oelfke 2026-02-06 10:29:56 +01:00
parent 2d08546847
commit 1ad0adeb06
No known key found for this signature in database
GPG key ID: E22BABF67200F864
7 changed files with 1747 additions and 17 deletions

View file

@ -1,5 +1,6 @@
import { App, Modal, Setting, TFolder } from 'obsidian'; import { App, Modal, Notice, Setting, TFolder } from 'obsidian';
import ClaudeContextPlugin from './main'; import ClaudeContextPlugin from './main';
import { createFreetextSource, SourcePosition } from './sources';
interface FolderConfig { interface FolderConfig {
name: string; name: string;
@ -77,11 +78,19 @@ export class ContextGeneratorModal extends Modal {
plugin: ClaudeContextPlugin; plugin: ClaudeContextPlugin;
config: ContextConfig; config: ContextConfig;
// Additional context state
temporaryFreetext: string = '';
temporaryPosition: SourcePosition = 'prefix';
saveAsDefault: boolean = false;
selectedTemplateId: string | null = null;
constructor(app: App, plugin: ClaudeContextPlugin) { constructor(app: App, plugin: ClaudeContextPlugin) {
super(app); super(app);
this.plugin = plugin; this.plugin = plugin;
this.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG)); this.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
this.config.folders = this.scanVaultStructure().map(name => ({ name, purpose: '' })); this.config.folders = this.scanVaultStructure().map(name => ({ name, purpose: '' }));
// Initialize with default template
this.selectedTemplateId = this.plugin.settings.defaultTemplateId;
} }
onOpen() { onOpen() {
@ -271,6 +280,90 @@ export class ContextGeneratorModal extends Modal {
.setValue(this.config.generateFiles.examples) .setValue(this.config.generateFiles.examples)
.onChange(v => this.config.generateFiles.examples = v)); .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 === // === GENERATE BUTTON ===
contentEl.createEl('hr'); contentEl.createEl('hr');

View file

@ -2,6 +2,8 @@ import { MarkdownView, Notice, Plugin, TFile, TFolder } from 'obsidian';
import { ClaudeContextSettings, ClaudeContextSettingTab, DEFAULT_SETTINGS } from './settings'; import { ClaudeContextSettings, ClaudeContextSettingTab, DEFAULT_SETTINGS } from './settings';
import { ContextGeneratorModal } from './generator'; import { ContextGeneratorModal } from './generator';
import { PreviewModal } from './preview'; import { PreviewModal } from './preview';
import { SourceRegistry, formatSourceOutput } from './sources';
import { TemplateEngine, PromptTemplate } from './templates';
export default class ClaudeContextPlugin extends Plugin { export default class ClaudeContextPlugin extends Plugin {
settings: ClaudeContextSettings; settings: ClaudeContextSettings;
@ -43,7 +45,11 @@ export default class ClaudeContextPlugin extends Plugin {
await this.saveData(this.settings); 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); const folder = this.app.vault.getAbstractFileByPath(this.settings.contextFolder);
if (!folder || !(folder instanceof TFolder)) { if (!folder || !(folder instanceof TFolder)) {
@ -70,16 +76,53 @@ export default class ClaudeContextPlugin extends Plugin {
return; 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) { for (const file of files) {
const content = await this.app.vault.read(file); const content = await this.app.vault.read(file);
if (this.settings.includeFilenames) { if (this.settings.includeFilenames) {
parts.push(`# === ${file.name} ===\n\n${content}`); vaultParts.push(`# === ${file.name} ===\n\n${content}`);
} else { } else {
parts.push(content); vaultParts.push(content);
} }
} }
outputParts.push(vaultParts.join(`\n\n${this.settings.separator}\n\n`));
// Include active note // Include active note
if (forceIncludeNote || this.settings.includeActiveNote) { if (forceIncludeNote || this.settings.includeActiveNote) {
@ -87,24 +130,57 @@ export default class ClaudeContextPlugin extends Plugin {
if (activeView?.file) { if (activeView?.file) {
const content = await this.app.vault.read(activeView.file); const content = await this.app.vault.read(activeView.file);
if (this.settings.includeFilenames) { if (this.settings.includeFilenames) {
parts.push(`# === ACTIVE: ${activeView.file.name} ===\n\n${content}`); outputParts.push(`# === ACTIVE: ${activeView.file.name} ===\n\n${content}`);
} else { } 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 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) { if (this.settings.showPreview) {
new PreviewModal(this.app, combined, fileCount, async () => { new PreviewModal(this.app, combined, totalCount, async () => {
await navigator.clipboard.writeText(combined); await navigator.clipboard.writeText(combined);
new Notice(`Copied ${fileCount} files to clipboard`); this.showCopyNotice(fileCount, sourceCount, templateName);
}).open(); }).open();
} else { } else {
await navigator.clipboard.writeText(combined); 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);
}
} }

View file

@ -1,5 +1,9 @@
import { App, PluginSettingTab, Setting } from 'obsidian'; import { App, Notice, PluginSettingTab, Setting } from 'obsidian';
import ClaudeContextPlugin from './main'; 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 { export interface ClaudeContextSettings {
contextFolder: string; contextFolder: string;
@ -8,6 +12,10 @@ export interface ClaudeContextSettings {
showPreview: boolean; showPreview: boolean;
includeActiveNote: boolean; includeActiveNote: boolean;
excludedFiles: string[]; excludedFiles: string[];
sources: ContextSource[];
showSourceLabels: boolean;
promptTemplates: PromptTemplate[];
defaultTemplateId: string | null;
} }
export const DEFAULT_SETTINGS: ClaudeContextSettings = { export const DEFAULT_SETTINGS: ClaudeContextSettings = {
@ -17,6 +25,10 @@ export const DEFAULT_SETTINGS: ClaudeContextSettings = {
showPreview: false, showPreview: false,
includeActiveNote: false, includeActiveNote: false,
excludedFiles: [], excludedFiles: [],
sources: [],
showSourceLabels: true,
promptTemplates: [],
defaultTemplateId: null,
}; };
export class ClaudeContextSettingTab extends PluginSettingTab { export class ClaudeContextSettingTab extends PluginSettingTab {
@ -96,5 +108,311 @@ export class ClaudeContextSettingTab extends PluginSettingTab {
.filter(s => s.length > 0); .filter(s => s.length > 0);
await this.plugin.saveSettings(); 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';
}
} }
} }

358
src/source-modal.ts Normal file
View file

@ -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();
}
}

241
src/sources.ts Normal file
View file

@ -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<ResolvedSource> {
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<ResolvedSource[]> {
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<string> {
return config.content || '';
}
private async resolveFileSource(config: FileConfig): Promise<string> {
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<string> {
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<void> {
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<string> {
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 '📌';
}
}

315
src/template-modal.ts Normal file
View file

@ -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();
}
}

329
src/templates.ts Normal file
View file

@ -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<TemplateContext> {
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<string, keyof TemplateContext> = {
'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' },
];
}