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:
parent
2d08546847
commit
1ad0adeb06
7 changed files with 1747 additions and 17 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import { App, Modal, Setting, TFolder } from 'obsidian';
|
||||
import { App, Modal, Notice, Setting, TFolder } from 'obsidian';
|
||||
import ClaudeContextPlugin from './main';
|
||||
import { createFreetextSource, SourcePosition } from './sources';
|
||||
|
||||
interface FolderConfig {
|
||||
name: string;
|
||||
|
|
@ -77,11 +78,19 @@ export class ContextGeneratorModal extends Modal {
|
|||
plugin: ClaudeContextPlugin;
|
||||
config: ContextConfig;
|
||||
|
||||
// Additional context state
|
||||
temporaryFreetext: string = '';
|
||||
temporaryPosition: SourcePosition = 'prefix';
|
||||
saveAsDefault: boolean = false;
|
||||
selectedTemplateId: string | null = null;
|
||||
|
||||
constructor(app: App, plugin: ClaudeContextPlugin) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
|
||||
this.config.folders = this.scanVaultStructure().map(name => ({ name, purpose: '' }));
|
||||
// Initialize with default template
|
||||
this.selectedTemplateId = this.plugin.settings.defaultTemplateId;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
|
|
@ -271,6 +280,90 @@ export class ContextGeneratorModal extends Modal {
|
|||
.setValue(this.config.generateFiles.examples)
|
||||
.onChange(v => this.config.generateFiles.examples = v));
|
||||
|
||||
// === ADDITIONAL CONTEXT SECTION ===
|
||||
contentEl.createEl('h3', { text: 'Additional Context (this session)' });
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Temporary freetext')
|
||||
.setDesc('Add context for this session only')
|
||||
.addTextArea(text => {
|
||||
text.setPlaceholder('Enter additional context that will be included when copying...')
|
||||
.setValue(this.temporaryFreetext)
|
||||
.onChange(v => this.temporaryFreetext = v);
|
||||
text.inputEl.rows = 4;
|
||||
text.inputEl.style.width = '100%';
|
||||
});
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Position')
|
||||
.addDropdown(dropdown => dropdown
|
||||
.addOption('prefix', 'Prefix (before vault content)')
|
||||
.addOption('suffix', 'Suffix (after vault content)')
|
||||
.setValue(this.temporaryPosition)
|
||||
.onChange(v => this.temporaryPosition = v as SourcePosition));
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Save as default')
|
||||
.setDesc('Save this freetext as a permanent source')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.saveAsDefault)
|
||||
.onChange(v => this.saveAsDefault = v));
|
||||
|
||||
// Show saved sources count
|
||||
const enabledCount = this.plugin.settings.sources.filter(s => s.enabled).length;
|
||||
const sourcesInfo = contentEl.createEl('p', {
|
||||
text: `Saved sources: ${enabledCount} enabled (manage in settings)`,
|
||||
cls: 'setting-item-description'
|
||||
});
|
||||
sourcesInfo.style.marginTop = '10px';
|
||||
|
||||
// === PROMPT TEMPLATE SECTION ===
|
||||
contentEl.createEl('h3', { text: 'Prompt Template' });
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Template')
|
||||
.setDesc('Wrap context with a prompt template')
|
||||
.addDropdown(dropdown => {
|
||||
dropdown.addOption('', 'None (plain context)');
|
||||
for (const template of this.plugin.settings.promptTemplates) {
|
||||
dropdown.addOption(template.id, template.name);
|
||||
}
|
||||
dropdown.setValue(this.selectedTemplateId || '');
|
||||
dropdown.onChange(v => {
|
||||
this.selectedTemplateId = v || null;
|
||||
});
|
||||
});
|
||||
|
||||
// Show template count
|
||||
const templateCount = this.plugin.settings.promptTemplates.length;
|
||||
const templateInfo = contentEl.createEl('p', {
|
||||
text: `${templateCount} template(s) available (manage in settings)`,
|
||||
cls: 'setting-item-description'
|
||||
});
|
||||
templateInfo.style.marginTop = '5px';
|
||||
|
||||
// Copy with context button
|
||||
new Setting(contentEl)
|
||||
.addButton(btn => btn
|
||||
.setButtonText('Copy Context Now')
|
||||
.onClick(async () => {
|
||||
// Save freetext if requested
|
||||
if (this.saveAsDefault && this.temporaryFreetext.trim()) {
|
||||
const source = createFreetextSource(
|
||||
'Generator Context',
|
||||
this.temporaryFreetext,
|
||||
this.temporaryPosition
|
||||
);
|
||||
this.plugin.settings.sources.push(source);
|
||||
await this.plugin.saveSettings();
|
||||
new Notice('Freetext saved as default source');
|
||||
}
|
||||
|
||||
// Copy context with selected template
|
||||
await this.plugin.copyContextToClipboard(false, this.temporaryFreetext, this.selectedTemplateId);
|
||||
this.close();
|
||||
}));
|
||||
|
||||
// === GENERATE BUTTON ===
|
||||
contentEl.createEl('hr');
|
||||
|
||||
|
|
|
|||
96
src/main.ts
96
src/main.ts
|
|
@ -2,6 +2,8 @@ import { MarkdownView, Notice, Plugin, TFile, TFolder } from 'obsidian';
|
|||
import { ClaudeContextSettings, ClaudeContextSettingTab, DEFAULT_SETTINGS } from './settings';
|
||||
import { ContextGeneratorModal } from './generator';
|
||||
import { PreviewModal } from './preview';
|
||||
import { SourceRegistry, formatSourceOutput } from './sources';
|
||||
import { TemplateEngine, PromptTemplate } from './templates';
|
||||
|
||||
export default class ClaudeContextPlugin extends Plugin {
|
||||
settings: ClaudeContextSettings;
|
||||
|
|
@ -43,7 +45,11 @@ export default class ClaudeContextPlugin extends Plugin {
|
|||
await this.saveData(this.settings);
|
||||
}
|
||||
|
||||
async copyContextToClipboard(forceIncludeNote = false) {
|
||||
async copyContextToClipboard(
|
||||
forceIncludeNote = false,
|
||||
temporaryFreetext?: string,
|
||||
templateId?: string | null
|
||||
) {
|
||||
const folder = this.app.vault.getAbstractFileByPath(this.settings.contextFolder);
|
||||
|
||||
if (!folder || !(folder instanceof TFolder)) {
|
||||
|
|
@ -70,16 +76,53 @@ export default class ClaudeContextPlugin extends Plugin {
|
|||
return;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
// Resolve additional sources
|
||||
const registry = new SourceRegistry();
|
||||
const enabledSources = this.settings.sources.filter(s => s.enabled);
|
||||
const resolvedSources = await registry.resolveAll(enabledSources);
|
||||
|
||||
// Check for source errors
|
||||
const errors = resolvedSources.filter(r => r.error);
|
||||
if (errors.length > 0) {
|
||||
const errorNames = errors.map(e => e.source.name).join(', ');
|
||||
new Notice(`Some sources failed: ${errorNames}`, 5000);
|
||||
}
|
||||
|
||||
// Separate prefix and suffix sources
|
||||
const prefixSources = resolvedSources.filter(r => r.source.position === 'prefix' && !r.error);
|
||||
const suffixSources = resolvedSources.filter(r => r.source.position === 'suffix' && !r.error);
|
||||
|
||||
// Build output parts
|
||||
const outputParts: string[] = [];
|
||||
|
||||
// Add prefix sources
|
||||
for (const resolved of prefixSources) {
|
||||
const formatted = formatSourceOutput(resolved, this.settings.showSourceLabels);
|
||||
if (formatted) {
|
||||
outputParts.push(formatted);
|
||||
}
|
||||
}
|
||||
|
||||
// Add temporary freetext if provided (as prefix)
|
||||
if (temporaryFreetext?.trim()) {
|
||||
if (this.settings.showSourceLabels) {
|
||||
outputParts.push(`# === PREFIX: Session Context ===\n\n${temporaryFreetext}`);
|
||||
} else {
|
||||
outputParts.push(temporaryFreetext);
|
||||
}
|
||||
}
|
||||
|
||||
// Add vault content
|
||||
const vaultParts: string[] = [];
|
||||
for (const file of files) {
|
||||
const content = await this.app.vault.read(file);
|
||||
if (this.settings.includeFilenames) {
|
||||
parts.push(`# === ${file.name} ===\n\n${content}`);
|
||||
vaultParts.push(`# === ${file.name} ===\n\n${content}`);
|
||||
} else {
|
||||
parts.push(content);
|
||||
vaultParts.push(content);
|
||||
}
|
||||
}
|
||||
outputParts.push(vaultParts.join(`\n\n${this.settings.separator}\n\n`));
|
||||
|
||||
// Include active note
|
||||
if (forceIncludeNote || this.settings.includeActiveNote) {
|
||||
|
|
@ -87,24 +130,57 @@ export default class ClaudeContextPlugin extends Plugin {
|
|||
if (activeView?.file) {
|
||||
const content = await this.app.vault.read(activeView.file);
|
||||
if (this.settings.includeFilenames) {
|
||||
parts.push(`# === ACTIVE: ${activeView.file.name} ===\n\n${content}`);
|
||||
outputParts.push(`# === ACTIVE: ${activeView.file.name} ===\n\n${content}`);
|
||||
} else {
|
||||
parts.push(`--- ACTIVE NOTE ---\n\n${content}`);
|
||||
outputParts.push(`--- ACTIVE NOTE ---\n\n${content}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const combined = parts.join(`\n\n${this.settings.separator}\n\n`);
|
||||
// Add suffix sources
|
||||
for (const resolved of suffixSources) {
|
||||
const formatted = formatSourceOutput(resolved, this.settings.showSourceLabels);
|
||||
if (formatted) {
|
||||
outputParts.push(formatted);
|
||||
}
|
||||
}
|
||||
|
||||
let combined = outputParts.join(`\n\n${this.settings.separator}\n\n`);
|
||||
const sourceCount = prefixSources.length + suffixSources.length + (temporaryFreetext?.trim() ? 1 : 0);
|
||||
const fileCount = files.length + (forceIncludeNote || this.settings.includeActiveNote ? 1 : 0);
|
||||
const totalCount = fileCount + sourceCount;
|
||||
|
||||
// Apply template if specified
|
||||
const effectiveTemplateId = templateId !== undefined ? templateId : this.settings.defaultTemplateId;
|
||||
let templateName: string | null = null;
|
||||
|
||||
if (effectiveTemplateId) {
|
||||
const template = this.settings.promptTemplates.find(t => t.id === effectiveTemplateId);
|
||||
if (template) {
|
||||
const engine = new TemplateEngine(this.app);
|
||||
const context = await engine.buildContext(combined);
|
||||
combined = engine.processTemplate(template.content, context);
|
||||
templateName = template.name;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.settings.showPreview) {
|
||||
new PreviewModal(this.app, combined, fileCount, async () => {
|
||||
new PreviewModal(this.app, combined, totalCount, async () => {
|
||||
await navigator.clipboard.writeText(combined);
|
||||
new Notice(`Copied ${fileCount} files to clipboard`);
|
||||
this.showCopyNotice(fileCount, sourceCount, templateName);
|
||||
}).open();
|
||||
} else {
|
||||
await navigator.clipboard.writeText(combined);
|
||||
new Notice(`Copied ${fileCount} files to clipboard`);
|
||||
this.showCopyNotice(fileCount, sourceCount, templateName);
|
||||
}
|
||||
}
|
||||
|
||||
private showCopyNotice(fileCount: number, sourceCount: number, templateName: string | null) {
|
||||
const totalCount = fileCount + sourceCount;
|
||||
let message = `Copied ${totalCount} items to clipboard (${fileCount} files, ${sourceCount} sources)`;
|
||||
if (templateName) {
|
||||
message += ` using template "${templateName}"`;
|
||||
}
|
||||
new Notice(message);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
320
src/settings.ts
320
src/settings.ts
|
|
@ -1,5 +1,9 @@
|
|||
import { App, PluginSettingTab, Setting } from 'obsidian';
|
||||
import { App, Notice, PluginSettingTab, Setting } from 'obsidian';
|
||||
import ClaudeContextPlugin from './main';
|
||||
import { ContextSource, getSourceIcon, SourceRegistry } from './sources';
|
||||
import { SourceModal } from './source-modal';
|
||||
import { PromptTemplate, STARTER_TEMPLATES } from './templates';
|
||||
import { TemplateModal, TemplateImportExportModal } from './template-modal';
|
||||
|
||||
export interface ClaudeContextSettings {
|
||||
contextFolder: string;
|
||||
|
|
@ -8,6 +12,10 @@ export interface ClaudeContextSettings {
|
|||
showPreview: boolean;
|
||||
includeActiveNote: boolean;
|
||||
excludedFiles: string[];
|
||||
sources: ContextSource[];
|
||||
showSourceLabels: boolean;
|
||||
promptTemplates: PromptTemplate[];
|
||||
defaultTemplateId: string | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: ClaudeContextSettings = {
|
||||
|
|
@ -17,6 +25,10 @@ export const DEFAULT_SETTINGS: ClaudeContextSettings = {
|
|||
showPreview: false,
|
||||
includeActiveNote: false,
|
||||
excludedFiles: [],
|
||||
sources: [],
|
||||
showSourceLabels: true,
|
||||
promptTemplates: [],
|
||||
defaultTemplateId: null,
|
||||
};
|
||||
|
||||
export class ClaudeContextSettingTab extends PluginSettingTab {
|
||||
|
|
@ -96,5 +108,311 @@ export class ClaudeContextSettingTab extends PluginSettingTab {
|
|||
.filter(s => s.length > 0);
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// === CONTEXT SOURCES SECTION ===
|
||||
containerEl.createEl('h3', { text: 'Context Sources' });
|
||||
|
||||
const sourcesDesc = containerEl.createEl('p', {
|
||||
text: 'Add additional context sources like freetext, external files, or shell command output.',
|
||||
cls: 'setting-item-description'
|
||||
});
|
||||
sourcesDesc.style.marginBottom = '10px';
|
||||
|
||||
const buttonContainer = containerEl.createDiv({ cls: 'sources-button-container' });
|
||||
buttonContainer.style.display = 'flex';
|
||||
buttonContainer.style.gap = '8px';
|
||||
buttonContainer.style.marginBottom = '15px';
|
||||
|
||||
const addFreetextBtn = buttonContainer.createEl('button', { text: '+ Freetext' });
|
||||
addFreetextBtn.addEventListener('click', () => {
|
||||
new SourceModal(this.app, this.plugin, 'freetext', null, () => this.display()).open();
|
||||
});
|
||||
|
||||
const addFileBtn = buttonContainer.createEl('button', { text: '+ File' });
|
||||
addFileBtn.addEventListener('click', () => {
|
||||
new SourceModal(this.app, this.plugin, 'file', null, () => this.display()).open();
|
||||
});
|
||||
|
||||
const addShellBtn = buttonContainer.createEl('button', { text: '+ Shell' });
|
||||
addShellBtn.addEventListener('click', () => {
|
||||
new SourceModal(this.app, this.plugin, 'shell', null, () => this.display()).open();
|
||||
});
|
||||
|
||||
// Sources list
|
||||
const sourcesContainer = containerEl.createDiv({ cls: 'sources-list-container' });
|
||||
this.renderSourcesList(sourcesContainer);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Show source labels')
|
||||
.setDesc('Add position and name labels to source output')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.showSourceLabels)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.showSourceLabels = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// === PROMPT TEMPLATES SECTION ===
|
||||
containerEl.createEl('h3', { text: 'Prompt Templates' });
|
||||
|
||||
const templatesDesc = containerEl.createEl('p', {
|
||||
text: 'Create reusable prompt templates that wrap around your context.',
|
||||
cls: 'setting-item-description'
|
||||
});
|
||||
templatesDesc.style.marginBottom = '10px';
|
||||
|
||||
const templateButtonContainer = containerEl.createDiv({ cls: 'templates-button-container' });
|
||||
templateButtonContainer.style.display = 'flex';
|
||||
templateButtonContainer.style.gap = '8px';
|
||||
templateButtonContainer.style.marginBottom = '15px';
|
||||
|
||||
const addTemplateBtn = templateButtonContainer.createEl('button', { text: '+ New Template' });
|
||||
addTemplateBtn.addEventListener('click', () => {
|
||||
new TemplateModal(this.app, this.plugin, null, () => this.display()).open();
|
||||
});
|
||||
|
||||
const importBtn = templateButtonContainer.createEl('button', { text: 'Import' });
|
||||
importBtn.addEventListener('click', () => {
|
||||
new TemplateImportExportModal(this.app, this.plugin, 'import', () => this.display()).open();
|
||||
});
|
||||
|
||||
const exportBtn = templateButtonContainer.createEl('button', { text: 'Export' });
|
||||
exportBtn.addEventListener('click', () => {
|
||||
new TemplateImportExportModal(this.app, this.plugin, 'export', () => {}).open();
|
||||
});
|
||||
|
||||
// Starter templates
|
||||
const hasStarterTemplates = this.plugin.settings.promptTemplates.some(t => t.isBuiltin);
|
||||
if (!hasStarterTemplates) {
|
||||
const starterContainer = containerEl.createDiv();
|
||||
starterContainer.style.padding = '10px';
|
||||
starterContainer.style.backgroundColor = 'var(--background-secondary)';
|
||||
starterContainer.style.borderRadius = '4px';
|
||||
starterContainer.style.marginBottom = '15px';
|
||||
|
||||
const starterText = starterContainer.createEl('p', {
|
||||
text: `Add ${STARTER_TEMPLATES.length} starter templates to get started quickly.`
|
||||
});
|
||||
starterText.style.margin = '0 0 10px 0';
|
||||
|
||||
const addStarterBtn = starterContainer.createEl('button', { text: 'Add Starter Templates' });
|
||||
addStarterBtn.addEventListener('click', async () => {
|
||||
this.plugin.settings.promptTemplates.push(...STARTER_TEMPLATES);
|
||||
await this.plugin.saveSettings();
|
||||
new Notice(`Added ${STARTER_TEMPLATES.length} starter templates`);
|
||||
this.display();
|
||||
});
|
||||
}
|
||||
|
||||
// Templates list
|
||||
const templatesContainer = containerEl.createDiv({ cls: 'templates-list-container' });
|
||||
this.renderTemplatesList(templatesContainer);
|
||||
|
||||
// Default template setting
|
||||
new Setting(containerEl)
|
||||
.setName('Default template')
|
||||
.setDesc('Template to use by default when copying context')
|
||||
.addDropdown(dropdown => {
|
||||
dropdown.addOption('', 'None (plain context)');
|
||||
for (const template of this.plugin.settings.promptTemplates) {
|
||||
dropdown.addOption(template.id, template.name);
|
||||
}
|
||||
dropdown.setValue(this.plugin.settings.defaultTemplateId || '');
|
||||
dropdown.onChange(async (value) => {
|
||||
this.plugin.settings.defaultTemplateId = value || null;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private renderSourcesList(container: HTMLElement) {
|
||||
container.empty();
|
||||
|
||||
if (this.plugin.settings.sources.length === 0) {
|
||||
const emptyMsg = container.createEl('p', {
|
||||
text: 'No sources configured yet.',
|
||||
cls: 'setting-item-description'
|
||||
});
|
||||
emptyMsg.style.fontStyle = 'italic';
|
||||
return;
|
||||
}
|
||||
|
||||
const list = container.createDiv({ cls: 'sources-list' });
|
||||
list.style.border = '1px solid var(--background-modifier-border)';
|
||||
list.style.borderRadius = '4px';
|
||||
list.style.marginBottom = '15px';
|
||||
|
||||
for (const source of this.plugin.settings.sources) {
|
||||
const row = list.createDiv({ cls: 'source-row' });
|
||||
row.style.display = 'flex';
|
||||
row.style.alignItems = 'center';
|
||||
row.style.padding = '8px 12px';
|
||||
row.style.borderBottom = '1px solid var(--background-modifier-border)';
|
||||
row.style.gap = '10px';
|
||||
|
||||
// Icon
|
||||
const icon = row.createEl('span', { text: getSourceIcon(source.type) });
|
||||
icon.style.fontSize = '16px';
|
||||
|
||||
// Name
|
||||
const name = row.createEl('span', { text: source.name });
|
||||
name.style.flex = '1';
|
||||
name.style.fontWeight = '500';
|
||||
|
||||
// Position badge
|
||||
const position = row.createEl('span', { text: source.position });
|
||||
position.style.padding = '2px 6px';
|
||||
position.style.borderRadius = '3px';
|
||||
position.style.fontSize = '11px';
|
||||
position.style.backgroundColor = 'var(--background-modifier-hover)';
|
||||
|
||||
// Enabled toggle
|
||||
const toggleContainer = row.createDiv();
|
||||
const toggle = toggleContainer.createEl('input', { type: 'checkbox' });
|
||||
toggle.checked = source.enabled;
|
||||
toggle.addEventListener('change', async () => {
|
||||
source.enabled = toggle.checked;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
|
||||
// Edit button
|
||||
const editBtn = row.createEl('button', { text: '✎' });
|
||||
editBtn.style.padding = '2px 8px';
|
||||
editBtn.addEventListener('click', () => {
|
||||
new SourceModal(this.app, this.plugin, source.type, source, () => this.display()).open();
|
||||
});
|
||||
|
||||
// Delete button
|
||||
const deleteBtn = row.createEl('button', { text: '✕' });
|
||||
deleteBtn.style.padding = '2px 8px';
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
this.plugin.settings.sources = this.plugin.settings.sources.filter(s => s.id !== source.id);
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
});
|
||||
|
||||
// Test button
|
||||
const testBtn = row.createEl('button', { text: '▶' });
|
||||
testBtn.title = 'Test source';
|
||||
testBtn.style.padding = '2px 8px';
|
||||
testBtn.addEventListener('click', async () => {
|
||||
const registry = new SourceRegistry();
|
||||
const result = await registry.resolveSource(source);
|
||||
if (result.error) {
|
||||
new (await import('obsidian')).Notice(`Error: ${result.error}`);
|
||||
} else {
|
||||
const preview = result.content.substring(0, 200) + (result.content.length > 200 ? '...' : '');
|
||||
new (await import('obsidian')).Notice(`Success (${result.content.length} chars):\n${preview}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Remove bottom border from last item
|
||||
const lastRow = list.lastElementChild as HTMLElement;
|
||||
if (lastRow) {
|
||||
lastRow.style.borderBottom = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
private renderTemplatesList(container: HTMLElement) {
|
||||
container.empty();
|
||||
|
||||
if (this.plugin.settings.promptTemplates.length === 0) {
|
||||
const emptyMsg = container.createEl('p', {
|
||||
text: 'No templates configured yet.',
|
||||
cls: 'setting-item-description'
|
||||
});
|
||||
emptyMsg.style.fontStyle = 'italic';
|
||||
return;
|
||||
}
|
||||
|
||||
const list = container.createDiv({ cls: 'templates-list' });
|
||||
list.style.border = '1px solid var(--background-modifier-border)';
|
||||
list.style.borderRadius = '4px';
|
||||
list.style.marginBottom = '15px';
|
||||
|
||||
for (const template of this.plugin.settings.promptTemplates) {
|
||||
const row = list.createDiv({ cls: 'template-row' });
|
||||
row.style.display = 'flex';
|
||||
row.style.alignItems = 'center';
|
||||
row.style.padding = '8px 12px';
|
||||
row.style.borderBottom = '1px solid var(--background-modifier-border)';
|
||||
row.style.gap = '10px';
|
||||
|
||||
// Icon
|
||||
const icon = row.createEl('span', { text: template.isBuiltin ? '📦' : '📝' });
|
||||
icon.style.fontSize = '16px';
|
||||
|
||||
// Name and description
|
||||
const textContainer = row.createDiv();
|
||||
textContainer.style.flex = '1';
|
||||
|
||||
const name = textContainer.createEl('span', { text: template.name });
|
||||
name.style.fontWeight = '500';
|
||||
|
||||
if (template.description) {
|
||||
const desc = textContainer.createEl('div', { text: template.description });
|
||||
desc.style.fontSize = '11px';
|
||||
desc.style.color = 'var(--text-muted)';
|
||||
}
|
||||
|
||||
// Builtin badge
|
||||
if (template.isBuiltin) {
|
||||
const badge = row.createEl('span', { text: 'builtin' });
|
||||
badge.style.padding = '2px 6px';
|
||||
badge.style.borderRadius = '3px';
|
||||
badge.style.fontSize = '11px';
|
||||
badge.style.backgroundColor = 'var(--background-modifier-hover)';
|
||||
}
|
||||
|
||||
// Edit button (only for non-builtin)
|
||||
if (!template.isBuiltin) {
|
||||
const editBtn = row.createEl('button', { text: '✎' });
|
||||
editBtn.style.padding = '2px 8px';
|
||||
editBtn.addEventListener('click', () => {
|
||||
new TemplateModal(this.app, this.plugin, template, () => this.display()).open();
|
||||
});
|
||||
}
|
||||
|
||||
// Duplicate button
|
||||
const duplicateBtn = row.createEl('button', { text: '⧉' });
|
||||
duplicateBtn.title = 'Duplicate';
|
||||
duplicateBtn.style.padding = '2px 8px';
|
||||
duplicateBtn.addEventListener('click', async () => {
|
||||
const { generateTemplateId } = await import('./templates');
|
||||
const duplicate: PromptTemplate = {
|
||||
id: generateTemplateId(),
|
||||
name: `${template.name} (copy)`,
|
||||
description: template.description,
|
||||
content: template.content,
|
||||
isBuiltin: false,
|
||||
};
|
||||
this.plugin.settings.promptTemplates.push(duplicate);
|
||||
await this.plugin.saveSettings();
|
||||
new Notice(`Duplicated template: ${template.name}`);
|
||||
this.display();
|
||||
});
|
||||
|
||||
// Delete button
|
||||
const deleteBtn = row.createEl('button', { text: '✕' });
|
||||
deleteBtn.style.padding = '2px 8px';
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
this.plugin.settings.promptTemplates = this.plugin.settings.promptTemplates.filter(
|
||||
t => t.id !== template.id
|
||||
);
|
||||
// Clear default if this was it
|
||||
if (this.plugin.settings.defaultTemplateId === template.id) {
|
||||
this.plugin.settings.defaultTemplateId = null;
|
||||
}
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
});
|
||||
}
|
||||
|
||||
// Remove bottom border from last item
|
||||
const lastRow = list.lastElementChild as HTMLElement;
|
||||
if (lastRow) {
|
||||
lastRow.style.borderBottom = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
358
src/source-modal.ts
Normal file
358
src/source-modal.ts
Normal 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
241
src/sources.ts
Normal 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
315
src/template-modal.ts
Normal 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
329
src/templates.ts
Normal 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' },
|
||||
];
|
||||
}
|
||||
Loading…
Reference in a new issue