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:
parent
acb82971b4
commit
06a228847f
4 changed files with 187 additions and 12 deletions
|
|
@ -96,12 +96,12 @@ export class BacklinkResolver {
|
|||
|
||||
// === Forward Link Resolution (multi-depth) ===
|
||||
|
||||
interface ForwardLinkResult {
|
||||
export interface ForwardLinkResult {
|
||||
path: string;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
function resolveForwardLinks(
|
||||
export function resolveForwardLinks(
|
||||
app: App,
|
||||
startPath: string,
|
||||
maxDepth: number
|
||||
|
|
|
|||
10
src/main.ts
10
src/main.ts
|
|
@ -243,7 +243,7 @@ export default class PromptfirePlugin extends Plugin {
|
|||
if (template) {
|
||||
const engine = new TemplateEngine(this.app);
|
||||
const context = await engine.buildContext(combined);
|
||||
combined = engine.processTemplate(template.content, context);
|
||||
combined = await engine.processTemplate(template.content, context);
|
||||
templateName = template.name;
|
||||
}
|
||||
}
|
||||
|
|
@ -429,7 +429,7 @@ export default class PromptfirePlugin extends Plugin {
|
|||
if (template) {
|
||||
const engine = new TemplateEngine(this.app);
|
||||
const context = await engine.buildContext(combined);
|
||||
combined = engine.processTemplate(template.content, context);
|
||||
combined = await engine.processTemplate(template.content, context);
|
||||
templateName = template.name;
|
||||
}
|
||||
}
|
||||
|
|
@ -603,7 +603,7 @@ export default class PromptfirePlugin extends Plugin {
|
|||
if (template) {
|
||||
const engine = new TemplateEngine(this.app);
|
||||
const context = await engine.buildContext(combined);
|
||||
combined = engine.processTemplate(template.content, context);
|
||||
combined = await engine.processTemplate(template.content, context);
|
||||
templateName = template.name;
|
||||
}
|
||||
}
|
||||
|
|
@ -800,7 +800,7 @@ export default class PromptfirePlugin extends Plugin {
|
|||
templateName = template.name;
|
||||
const engine = new TemplateEngine(this.app);
|
||||
const context = await engine.buildContext(combined);
|
||||
combined = engine.processTemplate(template.content, context);
|
||||
combined = await engine.processTemplate(template.content, context);
|
||||
} else {
|
||||
new Notice(`Template "${preset.template}" not found`, 5000);
|
||||
}
|
||||
|
|
@ -811,7 +811,7 @@ export default class PromptfirePlugin extends Plugin {
|
|||
templateName = template.name;
|
||||
const engine = new TemplateEngine(this.app);
|
||||
const context = await engine.buildContext(combined);
|
||||
combined = engine.processTemplate(template.content, context);
|
||||
combined = await engine.processTemplate(template.content, context);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ export class TemplateModal extends Modal {
|
|||
try {
|
||||
const engine = new TemplateEngine(this.app);
|
||||
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
|
||||
const previewModal = new Modal(this.app);
|
||||
|
|
|
|||
183
src/templates.ts
183
src/templates.ts
|
|
@ -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 ===
|
||||
|
||||
|
|
@ -187,10 +194,10 @@ export class TemplateEngine {
|
|||
/**
|
||||
* Process a template with the given context
|
||||
*/
|
||||
processTemplate(template: string, context: TemplateContext): string {
|
||||
async processTemplate(template: string, context: TemplateContext): Promise<string> {
|
||||
let result = template;
|
||||
|
||||
// Simple variable replacement
|
||||
// 1. Static variable replacement
|
||||
result = result.replace(/\{\{context\}\}/g, context.context);
|
||||
result = result.replace(/\{\{selection\}\}/g, context.selection);
|
||||
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(/\{\{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);
|
||||
|
||||
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
|
||||
* Supports: {{#if variable}}content{{/if}}
|
||||
|
|
@ -323,6 +492,12 @@ export function getAvailablePlaceholders(): { name: string; description: string
|
|||
{ name: '{{time}}', description: 'Current time (HH:MM)' },
|
||||
{ name: '{{datetime}}', description: 'Current date and time' },
|
||||
{ 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}}...{{else}}...{{/if}}', description: 'Conditional with else' },
|
||||
];
|
||||
|
|
|
|||
Loading…
Reference in a new issue