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