obsidian-promptfire/src/main.ts
luca-tty 06a228847f feat: add dynamic template variables for vault-level data
Expose backlinks, forward links, recent notes, shared tags, folder
siblings, and smart context directly in templates via {{variable}}
and {{variable:N}} syntax. Makes processTemplate async to support
the new resolvers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 13:31:11 +01:00

859 lines
27 KiB
TypeScript

import { MarkdownView, Notice, Plugin, TFile, TFolder } from 'obsidian';
import { PromptfireSettings, PromptfireSettingTab, DEFAULT_SETTINGS } from './settings';
import { ContextGeneratorModal } from './generator';
import { PreviewModal } from './preview';
import { SourceRegistry, formatSourceOutput } from './sources';
import { TemplateEngine } from './templates';
import { HistoryManager, HistoryMetadata } from './history';
import { HistoryModal } from './history-modal';
import { ContentSelector, FileSelection } from './content-selector';
import { FileSelectorModal } from './file-selector-modal';
import {
parsePresetFromFrontmatter,
PresetExecutor,
formatPresetErrors,
ContextPreset,
} from './presets';
import {
OutputTarget,
TargetExecutor,
TargetResult,
saveTargetToFile,
} from './targets';
import {
ContextIntelligence,
ScoredNote,
DEFAULT_INTELLIGENCE_SETTINGS,
} from './context-intelligence';
import { SmartContextModal } from './smart-context-modal';
export default class PromptfirePlugin extends Plugin {
settings: PromptfireSettings;
historyManager: HistoryManager;
async onload() {
await this.loadSettings();
this.historyManager = new HistoryManager(this.app, this.settings.history);
// Ribbon icon
this.addRibbonIcon('clipboard-copy', 'Copy Promptfire context', () => {
this.copyContextToClipboard();
});
this.addCommand({
id: 'copy-context',
name: 'Copy context to clipboard',
callback: () => this.copyContextToClipboard()
});
this.addCommand({
id: 'copy-context-with-note',
name: 'Copy context with current note',
callback: () => this.copyContextToClipboard(true)
});
this.addCommand({
id: 'generate-context',
name: 'Generate context files',
callback: () => new ContextGeneratorModal(this.app, this).open()
});
this.addCommand({
id: 'view-history',
name: 'View context history',
callback: () => this.openHistory()
});
this.addCommand({
id: 'copy-context-selective',
name: 'Copy context (select sections)',
callback: () => this.openFileSelector()
});
this.addCommand({
id: 'copy-context-from-preset',
name: 'Copy context from frontmatter preset',
callback: () => this.copyContextFromPreset()
});
this.addCommand({
id: 'copy-smart-context',
name: 'Copy smart context (auto-detect related notes)',
callback: () => this.copySmartContext()
});
this.addSettingTab(new PromptfireSettingTab(this.app, this));
}
openFileSelector() {
new FileSelectorModal(this.app, this, async (result) => {
await this.copyContextWithSelections(result.selections);
}).open();
}
async loadSettings() {
const loaded = await this.loadData();
this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded);
// Ensure nested objects are properly merged
if (loaded?.history) {
this.settings.history = Object.assign({}, DEFAULT_SETTINGS.history, loaded.history);
}
if (loaded?.intelligence) {
this.settings.intelligence = Object.assign({}, DEFAULT_INTELLIGENCE_SETTINGS, loaded.intelligence);
if (loaded.intelligence.weights) {
this.settings.intelligence.weights = Object.assign(
{}, DEFAULT_INTELLIGENCE_SETTINGS.weights, loaded.intelligence.weights
);
}
}
}
async saveSettings() {
await this.saveData(this.settings);
// Update history manager with new settings
if (this.historyManager) {
this.historyManager.updateSettings(this.settings.history);
}
}
openHistory() {
new HistoryModal(this.app, this, this.historyManager).open();
}
async runHistoryCleanup(): Promise<number> {
return await this.historyManager.cleanup();
}
async copyContextToClipboard(
forceIncludeNote = false,
temporaryFreetext?: string,
templateId?: string | null,
targets?: OutputTarget[]
) {
const folder = this.app.vault.getAbstractFileByPath(this.settings.contextFolder);
if (!folder || !(folder instanceof TFolder)) {
new Notice(`Folder "${this.settings.contextFolder}" not found`);
return;
}
const excludedFiles = this.settings.excludedFiles.map(f => f.toLowerCase());
const files = folder.children
.filter((f): f is TFile =>
f instanceof TFile &&
f.extension === 'md' &&
!excludedFiles.includes(f.name.toLowerCase())
)
.sort((a, b) => {
if (a.basename === 'VAULT') return -1;
if (b.basename === 'VAULT') return 1;
return a.basename.localeCompare(b.basename);
});
if (files.length === 0) {
new Notice(`No markdown files in "${this.settings.contextFolder}"`);
return;
}
// 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) {
vaultParts.push(`# === ${file.name} ===\n\n${content}`);
} else {
vaultParts.push(content);
}
}
outputParts.push(vaultParts.join(`\n\n${this.settings.separator}\n\n`));
// Track active note for history
let activeNotePath: string | null = null;
// Include active note
if (forceIncludeNote || this.settings.includeActiveNote) {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (activeView?.file) {
activeNotePath = activeView.file.path;
const content = await this.app.vault.read(activeView.file);
if (this.settings.includeFilenames) {
outputParts.push(`# === ACTIVE: ${activeView.file.name} ===\n\n${content}`);
} else {
outputParts.push(`--- ACTIVE NOTE ---\n\n${content}`);
}
}
}
// Add suffix sources
for (const resolved of suffixSources) {
const formatted = formatSourceOutput(resolved, this.settings.showSourceLabels);
if (formatted) {
outputParts.push(formatted);
}
}
let combined = outputParts.join(`\n\n${this.settings.separator}\n\n`);
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 = await engine.processTemplate(template.content, context);
templateName = template.name;
}
}
// Prepare history metadata
const historyMetadata: Omit<HistoryMetadata, 'charCount' | 'estimatedTokens'> = {
templateId: effectiveTemplateId || null,
templateName,
includedFiles: files.map(f => f.path),
includedSources: [
...prefixSources.map(r => r.source.name),
...suffixSources.map(r => r.source.name),
...(temporaryFreetext?.trim() ? ['Session Context'] : []),
],
activeNote: activeNotePath,
};
// Process targets if provided
if (targets && targets.length > 0) {
await this.processTargets(combined, targets, historyMetadata, fileCount, sourceCount, templateName);
} else {
// Copy and save to history (legacy mode)
const copyAndSave = async () => {
await navigator.clipboard.writeText(combined);
this.showCopyNotice(fileCount, sourceCount, templateName);
// Save to history
await this.historyManager.saveEntry(combined, historyMetadata);
};
if (this.settings.showPreview) {
new PreviewModal(this.app, combined, totalCount, copyAndSave).open();
} else {
await copyAndSave();
}
}
}
private async processTargets(
rawContent: string,
targets: OutputTarget[],
historyMetadata: Omit<HistoryMetadata, 'charCount' | 'estimatedTokens'>,
fileCount: number,
sourceCount: number,
templateName: string | null
) {
const executor = new TargetExecutor();
const results: TargetResult[] = [];
// Process each target
for (const target of targets) {
const result = executor.processForTarget(rawContent, target);
results.push(result);
}
// Determine primary target
const primaryId = this.settings.primaryTargetId ||
(targets.find(t => t.enabled)?.id ?? targets[0]?.id);
const primaryResult = results.find(r => r.target.id === primaryId) || results[0];
if (!primaryResult) {
new Notice('No targets processed');
return;
}
const secondaryResults = results.filter(r => r !== primaryResult);
// Copy primary to clipboard
await navigator.clipboard.writeText(primaryResult.content);
// Save secondary results as files
const savedFiles: string[] = [];
for (const result of secondaryResults) {
const file = await saveTargetToFile(this.app, result, this.settings.targetOutputFolder);
if (file) {
savedFiles.push(file.path);
}
}
// Show notice
let message = `Copied to clipboard: ${primaryResult.target.name}`;
if (primaryResult.truncated) {
message += ` (truncated, ${primaryResult.sectionsDropped} sections dropped)`;
}
if (savedFiles.length > 0) {
message += `. Saved ${savedFiles.length} file(s)`;
}
new Notice(message, 5000);
// Save primary to history
await this.historyManager.saveEntry(primaryResult.content, {
...historyMetadata,
userNote: `Target: ${primaryResult.target.name}${primaryResult.truncated ? ' (truncated)' : ''}`,
});
}
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);
}
async copyContextWithSelections(
selections: FileSelection[],
templateId?: string | null
) {
const selector = new ContentSelector(this.app);
// Filter to selected files only
const selectedFiles = selections.filter(s => s.selected);
if (selectedFiles.length === 0) {
new Notice('No files selected');
return;
}
// 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 selected vault content
const vaultParts: string[] = [];
for (const selection of selectedFiles) {
const content = selector.extractSelectedContent(selection);
if (content) {
if (this.settings.includeFilenames) {
// Include heading info if partial selection
const isPartial = !this.isFullFileSelected(selection);
const suffix = isPartial ? ' (partial)' : '';
vaultParts.push(`# === ${selection.file.name}${suffix} ===\n\n${content}`);
} else {
vaultParts.push(content);
}
}
}
outputParts.push(vaultParts.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;
const fileCount = selectedFiles.length;
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 = await engine.processTemplate(template.content, context);
templateName = template.name;
}
}
// Prepare history metadata
const historyMetadata: Omit<HistoryMetadata, 'charCount' | 'estimatedTokens'> = {
templateId: effectiveTemplateId || null,
templateName,
includedFiles: selectedFiles.map(s => {
const isPartial = !this.isFullFileSelected(s);
return isPartial ? `${s.file.path} (partial)` : s.file.path;
}),
includedSources: [
...prefixSources.map(r => r.source.name),
...suffixSources.map(r => r.source.name),
],
activeNote: null,
};
// Copy and save
const copyAndSave = async () => {
await navigator.clipboard.writeText(combined);
this.showCopyNotice(fileCount, sourceCount, templateName);
await this.historyManager.saveEntry(combined, historyMetadata);
};
if (this.settings.showPreview) {
new PreviewModal(this.app, combined, totalCount, copyAndSave).open();
} else {
await copyAndSave();
}
}
private isFullFileSelected(selection: FileSelection): boolean {
if (selection.headings.length === 0) return true;
const checkAll = (headings: import('./content-selector').HeadingNode[]): boolean => {
for (const h of headings) {
if (!h.selected) return false;
if (!checkAll(h.children)) return false;
}
return true;
};
return checkAll(selection.headings);
}
async copySmartContext() {
if (!this.settings.intelligence.enabled) {
new Notice('Context intelligence is disabled. Enable it in Settings > Intelligence.');
return;
}
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!activeView?.file) {
new Notice('No active note');
return;
}
const activeFile = activeView.file;
const intelligence = new ContextIntelligence(this.app);
const result = await intelligence.analyze(activeFile, this.settings.intelligence);
if (result.scoredNotes.length === 0) {
new Notice('No related notes found for this note');
return;
}
new SmartContextModal(this.app, result, async (selectedNotes) => {
await this.assembleAndCopySmartContext(activeFile, selectedNotes);
}).open();
}
private async assembleAndCopySmartContext(activeFile: TFile, selectedNotes: ScoredNote[]) {
if (selectedNotes.length === 0) {
new Notice('No notes selected');
return;
}
// Resolve additional sources
const registry = new SourceRegistry();
const enabledSources = this.settings.sources.filter(s => s.enabled);
const resolvedSources = await registry.resolveAll(enabledSources);
const errors = resolvedSources.filter(r => r.error);
if (errors.length > 0) {
const errorNames = errors.map(e => e.source.name).join(', ');
new Notice(`Some sources failed: ${errorNames}`, 5000);
}
const prefixSources = resolvedSources.filter(r => r.source.position === 'prefix' && !r.error);
const suffixSources = resolvedSources.filter(r => r.source.position === 'suffix' && !r.error);
// Build output
const outputParts: string[] = [];
// Prefix sources
for (const resolved of prefixSources) {
const formatted = formatSourceOutput(resolved, this.settings.showSourceLabels);
if (formatted) outputParts.push(formatted);
}
// Context folder files
const folder = this.app.vault.getAbstractFileByPath(this.settings.contextFolder);
if (folder instanceof TFolder) {
const excludedFiles = this.settings.excludedFiles.map(f => f.toLowerCase());
const contextFiles = folder.children
.filter((f): f is TFile =>
f instanceof TFile &&
f.extension === 'md' &&
!excludedFiles.includes(f.name.toLowerCase())
)
.sort((a, b) => {
if (a.basename === 'VAULT') return -1;
if (b.basename === 'VAULT') return 1;
return a.basename.localeCompare(b.basename);
});
if (contextFiles.length > 0) {
const vaultParts: string[] = [];
for (const file of contextFiles) {
const content = await this.app.vault.read(file);
if (this.settings.includeFilenames) {
vaultParts.push(`# === ${file.name} ===\n\n${content}`);
} else {
vaultParts.push(content);
}
}
outputParts.push(vaultParts.join(`\n\n${this.settings.separator}\n\n`));
}
}
// Smart context notes (sorted by score)
const smartParts: string[] = [];
for (const note of selectedNotes) {
const content = await this.app.vault.read(note.file);
if (this.settings.includeFilenames) {
smartParts.push(`# === SMART: ${note.file.name} (${note.score} pts) ===\n\n${content}`);
} else {
smartParts.push(content);
}
}
if (smartParts.length > 0) {
outputParts.push(smartParts.join(`\n\n${this.settings.separator}\n\n`));
}
// Active note
if (this.settings.intelligence.includeActiveNote) {
const content = await this.app.vault.read(activeFile);
if (this.settings.includeFilenames) {
outputParts.push(`# === ACTIVE: ${activeFile.name} ===\n\n${content}`);
} else {
outputParts.push(`--- ACTIVE NOTE ---\n\n${content}`);
}
}
// Suffix sources
for (const resolved of suffixSources) {
const formatted = formatSourceOutput(resolved, this.settings.showSourceLabels);
if (formatted) outputParts.push(formatted);
}
let combined = outputParts.join(`\n\n${this.settings.separator}\n\n`);
// Apply template
const effectiveTemplateId = 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 = await engine.processTemplate(template.content, context);
templateName = template.name;
}
}
// Prepare history metadata
const historyMetadata: Omit<HistoryMetadata, 'charCount' | 'estimatedTokens'> = {
templateId: effectiveTemplateId || null,
templateName,
includedFiles: selectedNotes.map(n => n.file.path),
includedSources: [
...prefixSources.map(r => r.source.name),
...suffixSources.map(r => r.source.name),
],
activeNote: this.settings.intelligence.includeActiveNote ? activeFile.path : null,
userNote: `Smart context from: ${activeFile.basename}`,
};
// Copy and save
await navigator.clipboard.writeText(combined);
const noteCount = selectedNotes.length + (this.settings.intelligence.includeActiveNote ? 1 : 0);
let message = `Copied smart context: ${noteCount} notes`;
if (templateName) {
message += ` using "${templateName}"`;
}
new Notice(message);
await this.historyManager.saveEntry(combined, historyMetadata);
}
async copyContextFromPreset() {
// Get active file
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!activeView?.file) {
new Notice('No active note');
return;
}
const activeFile = activeView.file;
// Parse preset from frontmatter
const cache = this.app.metadataCache.getFileCache(activeFile);
const parseResult = parsePresetFromFrontmatter(cache);
if (!parseResult.valid || !parseResult.preset) {
// No preset found, fall back to generator modal
if (parseResult.errors.includes('No ai-context block in frontmatter') ||
parseResult.errors.includes('No frontmatter found')) {
new Notice('No ai-context preset found. Opening generator...');
new ContextGeneratorModal(this.app, this).open();
return;
}
// Show validation errors
new Notice(`Invalid ai-context preset:\n${formatPresetErrors(parseResult)}`, 10000);
return;
}
// Show warnings if any
if (parseResult.warnings.length > 0) {
new Notice(`Preset warnings:\n${parseResult.warnings.join('\n')}`, 5000);
}
const preset = parseResult.preset;
// Route based on mode
if (preset.mode === 'auto') {
// Use intelligence engine
const intelligence = new ContextIntelligence(this.app);
const intelSettings = {
...this.settings.intelligence,
...(preset.maxTokens ? { maxTokens: preset.maxTokens } : {}),
};
const result = await intelligence.analyze(activeFile, intelSettings);
if (result.scoredNotes.length === 0) {
new Notice('No related notes found for this note');
return;
}
new SmartContextModal(this.app, result, async (selectedNotes) => {
await this.assembleAndCopySmartContext(activeFile, selectedNotes);
}).open();
return;
}
// Execute preset (manual mode)
await this.executePreset(activeFile, preset);
}
private async executePreset(activeFile: TFile, preset: ContextPreset) {
// Get base context files
const folder = this.app.vault.getAbstractFileByPath(this.settings.contextFolder);
let contextFiles: TFile[] = [];
if (folder instanceof TFolder) {
const excludedFiles = this.settings.excludedFiles.map(f => f.toLowerCase());
contextFiles = folder.children
.filter((f): f is TFile =>
f instanceof TFile &&
f.extension === 'md' &&
!excludedFiles.includes(f.name.toLowerCase())
)
.sort((a, b) => {
if (a.basename === 'VAULT') return -1;
if (b.basename === 'VAULT') return 1;
return a.basename.localeCompare(b.basename);
});
}
// Execute preset to collect files
const executor = new PresetExecutor(this.app);
const result = await executor.execute(activeFile, preset, contextFiles);
if (result.files.length === 0) {
new Notice('No files matched the preset criteria');
return;
}
// Show truncation warning
if (result.truncated) {
new Notice(`Token budget exceeded. Some files were excluded. (~${result.totalTokens} tokens)`, 5000);
}
// Resolve additional sources
const registry = new SourceRegistry();
const enabledSources = this.settings.sources.filter(s => s.enabled);
const resolvedSources = await registry.resolveAll(enabledSources);
const errors = resolvedSources.filter(r => r.error);
if (errors.length > 0) {
const errorNames = errors.map(e => e.source.name).join(', ');
new Notice(`Some sources failed: ${errorNames}`, 5000);
}
const prefixSources = resolvedSources.filter(r => r.source.position === 'prefix' && !r.error);
const suffixSources = resolvedSources.filter(r => r.source.position === 'suffix' && !r.error);
// Build output
const outputParts: string[] = [];
// Add prefix sources
for (const resolved of prefixSources) {
const formatted = formatSourceOutput(resolved, this.settings.showSourceLabels);
if (formatted) {
outputParts.push(formatted);
}
}
// Add context files
const vaultParts: string[] = [];
for (const file of result.files) {
const content = await this.app.vault.read(file);
if (this.settings.includeFilenames) {
vaultParts.push(`# === ${file.name} ===\n\n${content}`);
} else {
vaultParts.push(content);
}
}
outputParts.push(vaultParts.join(`\n\n${this.settings.separator}\n\n`));
// Include active note if specified
const includeActive = preset.includeActiveNote ?? this.settings.includeActiveNote;
if (includeActive) {
const content = await this.app.vault.read(activeFile);
if (this.settings.includeFilenames) {
outputParts.push(`# === ACTIVE: ${activeFile.name} ===\n\n${content}`);
} else {
outputParts.push(`--- ACTIVE NOTE ---\n\n${content}`);
}
}
// Add suffix sources
for (const resolved of suffixSources) {
const formatted = formatSourceOutput(resolved, this.settings.showSourceLabels);
if (formatted) {
outputParts.push(formatted);
}
}
let combined = outputParts.join(`\n\n${this.settings.separator}\n\n`);
// Apply template
let templateId: string | null = null;
let templateName: string | null = null;
if (preset.template) {
// Find template by name
const template = this.settings.promptTemplates.find(
t => t.name.toLowerCase() === preset.template!.toLowerCase()
);
if (template) {
templateId = template.id;
templateName = template.name;
const engine = new TemplateEngine(this.app);
const context = await engine.buildContext(combined);
combined = await engine.processTemplate(template.content, context);
} else {
new Notice(`Template "${preset.template}" not found`, 5000);
}
} else if (this.settings.defaultTemplateId) {
const template = this.settings.promptTemplates.find(t => t.id === this.settings.defaultTemplateId);
if (template) {
templateId = template.id;
templateName = template.name;
const engine = new TemplateEngine(this.app);
const context = await engine.buildContext(combined);
combined = await engine.processTemplate(template.content, context);
}
}
// Prepare history metadata
const sourceCount = prefixSources.length + suffixSources.length;
const fileCount = result.files.length + (includeActive ? 1 : 0);
const historyMetadata: Omit<HistoryMetadata, 'charCount' | 'estimatedTokens'> = {
templateId,
templateName,
includedFiles: result.files.map(f => f.path),
includedSources: [
...prefixSources.map(r => r.source.name),
...suffixSources.map(r => r.source.name),
],
activeNote: includeActive ? activeFile.path : null,
userNote: `Preset from: ${activeFile.basename}`,
};
// Copy and save
const copyAndSave = async () => {
await navigator.clipboard.writeText(combined);
let message = `Copied ${fileCount} files`;
if (result.linkedFiles.length > 0) {
message += ` (${result.linkedFiles.length} linked)`;
}
if (result.taggedFiles.length > 0) {
message += ` (${result.taggedFiles.length} by tag)`;
}
if (templateName) {
message += ` using "${templateName}"`;
}
new Notice(message);
await this.historyManager.saveEntry(combined, historyMetadata);
};
if (this.settings.showPreview) {
new PreviewModal(this.app, combined, fileCount + sourceCount, copyAndSave).open();
} else {
await copyAndSave();
}
}
}