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>
This commit is contained in:
Luca Oelfke 2026-02-11 13:31:11 +01:00
parent acb82971b4
commit 06a228847f
4 changed files with 187 additions and 12 deletions

View file

@ -96,12 +96,12 @@ export class BacklinkResolver {
// === Forward Link Resolution (multi-depth) === // === Forward Link Resolution (multi-depth) ===
interface ForwardLinkResult { export interface ForwardLinkResult {
path: string; path: string;
depth: number; depth: number;
} }
function resolveForwardLinks( export function resolveForwardLinks(
app: App, app: App,
startPath: string, startPath: string,
maxDepth: number maxDepth: number

View file

@ -243,7 +243,7 @@ export default class PromptfirePlugin extends Plugin {
if (template) { if (template) {
const engine = new TemplateEngine(this.app); const engine = new TemplateEngine(this.app);
const context = await engine.buildContext(combined); const context = await engine.buildContext(combined);
combined = engine.processTemplate(template.content, context); combined = await engine.processTemplate(template.content, context);
templateName = template.name; templateName = template.name;
} }
} }
@ -429,7 +429,7 @@ export default class PromptfirePlugin extends Plugin {
if (template) { if (template) {
const engine = new TemplateEngine(this.app); const engine = new TemplateEngine(this.app);
const context = await engine.buildContext(combined); const context = await engine.buildContext(combined);
combined = engine.processTemplate(template.content, context); combined = await engine.processTemplate(template.content, context);
templateName = template.name; templateName = template.name;
} }
} }
@ -603,7 +603,7 @@ export default class PromptfirePlugin extends Plugin {
if (template) { if (template) {
const engine = new TemplateEngine(this.app); const engine = new TemplateEngine(this.app);
const context = await engine.buildContext(combined); const context = await engine.buildContext(combined);
combined = engine.processTemplate(template.content, context); combined = await engine.processTemplate(template.content, context);
templateName = template.name; templateName = template.name;
} }
} }
@ -800,7 +800,7 @@ export default class PromptfirePlugin extends Plugin {
templateName = template.name; templateName = template.name;
const engine = new TemplateEngine(this.app); const engine = new TemplateEngine(this.app);
const context = await engine.buildContext(combined); const context = await engine.buildContext(combined);
combined = engine.processTemplate(template.content, context); combined = await engine.processTemplate(template.content, context);
} else { } else {
new Notice(`Template "${preset.template}" not found`, 5000); new Notice(`Template "${preset.template}" not found`, 5000);
} }
@ -811,7 +811,7 @@ export default class PromptfirePlugin extends Plugin {
templateName = template.name; templateName = template.name;
const engine = new TemplateEngine(this.app); const engine = new TemplateEngine(this.app);
const context = await engine.buildContext(combined); const context = await engine.buildContext(combined);
combined = engine.processTemplate(template.content, context); combined = await engine.processTemplate(template.content, context);
} }
} }

View file

@ -116,7 +116,7 @@ export class TemplateModal extends Modal {
try { try {
const engine = new TemplateEngine(this.app); const engine = new TemplateEngine(this.app);
const context = await engine.buildContext('[Generated context would appear here]'); const context = await engine.buildContext('[Generated context would appear here]');
const processed = engine.processTemplate(this.content, context); const processed = await engine.processTemplate(this.content, context);
// Show preview in a simple modal // Show preview in a simple modal
const previewModal = new Modal(this.app); const previewModal = new Modal(this.app);

View file

@ -1,4 +1,11 @@
import { App, MarkdownView } from 'obsidian'; import { App, MarkdownView, TFile } from 'obsidian';
import {
BacklinkResolver,
ContextIntelligence,
DEFAULT_INTELLIGENCE_SETTINGS,
resolveForwardLinks,
} from './context-intelligence';
import { TagCollector } from './presets';
// === Types === // === Types ===
@ -187,10 +194,10 @@ export class TemplateEngine {
/** /**
* Process a template with the given context * Process a template with the given context
*/ */
processTemplate(template: string, context: TemplateContext): string { async processTemplate(template: string, context: TemplateContext): Promise<string> {
let result = template; let result = template;
// Simple variable replacement // 1. Static variable replacement
result = result.replace(/\{\{context\}\}/g, context.context); result = result.replace(/\{\{context\}\}/g, context.context);
result = result.replace(/\{\{selection\}\}/g, context.selection); result = result.replace(/\{\{selection\}\}/g, context.selection);
result = result.replace(/\{\{active_note\}\}/g, context.activeNote); result = result.replace(/\{\{active_note\}\}/g, context.activeNote);
@ -200,12 +207,174 @@ export class TemplateEngine {
result = result.replace(/\{\{datetime\}\}/g, context.datetime); result = result.replace(/\{\{datetime\}\}/g, context.datetime);
result = result.replace(/\{\{vault_name\}\}/g, context.vaultName); result = result.replace(/\{\{vault_name\}\}/g, context.vaultName);
// Process conditionals: {{#if variable}}...{{/if}} // 2. Dynamic variable resolution
result = await this.resolveDynamicVariables(result);
// 3. Process conditionals: {{#if variable}}...{{/if}}
result = this.processConditionals(result, context); result = this.processConditionals(result, context);
return result; return result;
} }
private async resolveDynamicVariables(template: string): Promise<string> {
const pattern = /\{\{(backlinks|forward_links|recent_modified|shared_tags|folder_siblings|smart_context)(?::(\d+))?\}\}/g;
const matches: { full: string; name: string; limit: number }[] = [];
let match;
while ((match = pattern.exec(template)) !== null) {
matches.push({
full: match[0],
name: match[1] as string,
limit: match[2] ? parseInt(match[2], 10) : 10,
});
}
for (const m of matches) {
let resolved = '';
switch (m.name) {
case 'backlinks':
resolved = await this.resolveBacklinks(m.limit);
break;
case 'forward_links':
resolved = await this.resolveForwardLinksDynamic(m.limit);
break;
case 'recent_modified':
resolved = await this.resolveRecentModified(m.limit);
break;
case 'shared_tags':
resolved = await this.resolveSharedTags(m.limit);
break;
case 'folder_siblings':
resolved = await this.resolveFolderSiblings(m.limit);
break;
case 'smart_context':
resolved = await this.resolveSmartContext(m.limit);
break;
}
template = template.replace(m.full, resolved);
}
return template;
}
private getActiveFile(): TFile | null {
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
return view?.file ?? null;
}
private async formatNoteList(files: TFile[], limit: number): Promise<string> {
const limited = files.slice(0, limit);
const parts: string[] = [];
for (const file of limited) {
const content = await this.app.vault.cachedRead(file);
parts.push(`# === ${file.name} ===\n\n${content}`);
}
return parts.join('\n\n---\n\n');
}
private async resolveBacklinks(limit: number): Promise<string> {
const active = this.getActiveFile();
if (!active) return '';
const resolver = new BacklinkResolver(this.app);
const paths = resolver.getBacklinks(active.path);
const files: TFile[] = [];
for (const path of paths) {
const file = this.app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) files.push(file);
}
return this.formatNoteList(files, limit);
}
private async resolveForwardLinksDynamic(limit: number): Promise<string> {
const active = this.getActiveFile();
if (!active) return '';
const results = resolveForwardLinks(this.app, active.path, 1);
const files: TFile[] = [];
for (const r of results) {
const file = this.app.vault.getAbstractFileByPath(r.path);
if (file instanceof TFile) files.push(file);
}
return this.formatNoteList(files, limit);
}
private async resolveRecentModified(limit: number): Promise<string> {
const active = this.getActiveFile();
const activePath = active?.path ?? '';
const files = this.app.vault.getMarkdownFiles()
.filter(f => {
if (f.path === activePath) return false;
if (f.path.startsWith('.obsidian')) return false;
if (f.path.startsWith('_context')) return false;
return true;
})
.sort((a, b) => b.stat.mtime - a.stat.mtime);
return this.formatNoteList(files, limit);
}
private async resolveSharedTags(limit: number): Promise<string> {
const active = this.getActiveFile();
if (!active) return '';
const cache = this.app.metadataCache.getFileCache(active);
if (!cache) return '';
const tags: string[] = [];
if (cache.frontmatter?.tags) {
const fmTags = cache.frontmatter.tags;
if (Array.isArray(fmTags)) {
tags.push(...fmTags.map((t: unknown) => typeof t === 'string' ? t : String(t)));
} else if (typeof fmTags === 'string') {
tags.push(fmTags);
}
}
if (cache.tags) {
tags.push(...cache.tags.map(t => t.tag.startsWith('#') ? t.tag.substring(1) : t.tag));
}
if (tags.length === 0) return '';
const collector = new TagCollector(this.app);
const files = collector.collectFilesWithTags(tags)
.filter(f => f.path !== active.path);
return this.formatNoteList(files, limit);
}
private async resolveFolderSiblings(limit: number): Promise<string> {
const active = this.getActiveFile();
if (!active) return '';
const parentPath = active.parent?.path ?? '';
const files = this.app.vault.getMarkdownFiles()
.filter(f => f.path !== active.path && (f.parent?.path ?? '') === parentPath);
return this.formatNoteList(files, limit);
}
private async resolveSmartContext(limit: number): Promise<string> {
const active = this.getActiveFile();
if (!active) return '';
const intelligence = new ContextIntelligence(this.app);
const result = await intelligence.analyze(active, DEFAULT_INTELLIGENCE_SETTINGS);
const files = result.scoredNotes
.filter(n => n.selected)
.map(n => n.file);
return this.formatNoteList(files, limit);
}
/** /**
* Process conditional blocks * Process conditional blocks
* Supports: {{#if variable}}content{{/if}} * Supports: {{#if variable}}content{{/if}}
@ -323,6 +492,12 @@ export function getAvailablePlaceholders(): { name: string; description: string
{ name: '{{time}}', description: 'Current time (HH:MM)' }, { name: '{{time}}', description: 'Current time (HH:MM)' },
{ name: '{{datetime}}', description: 'Current date and time' }, { name: '{{datetime}}', description: 'Current date and time' },
{ name: '{{vault_name}}', description: 'Name of the vault' }, { name: '{{vault_name}}', description: 'Name of the vault' },
{ name: '{{backlinks}}', description: 'Content of notes linking to active note' },
{ name: '{{forward_links}}', description: 'Content of notes linked from active note' },
{ name: '{{recent_modified:N}}', description: 'Content of N most recently modified notes' },
{ name: '{{shared_tags}}', description: 'Content of notes sharing tags with active note' },
{ name: '{{folder_siblings}}', description: 'Content of notes in the same folder' },
{ name: '{{smart_context}}', description: 'Top-scored related notes via intelligence engine' },
{ name: '{{#if variable}}...{{/if}}', description: 'Conditional block' }, { name: '{{#if variable}}...{{/if}}', description: 'Conditional block' },
{ name: '{{#if variable}}...{{else}}...{{/if}}', description: 'Conditional with else' }, { name: '{{#if variable}}...{{else}}...{{/if}}', description: 'Conditional with else' },
]; ];