diff --git a/src/export-profile-modal.ts b/src/export-profile-modal.ts new file mode 100644 index 0000000..9adb2ec --- /dev/null +++ b/src/export-profile-modal.ts @@ -0,0 +1,213 @@ +import { App, Modal, Notice, Setting } from 'obsidian'; +import PromptfirePlugin from './main'; +import { + ExportProfile, + generateProfileId, + getTargetIcon, +} from './targets'; + +export class ExportProfileModal extends Modal { + plugin: PromptfirePlugin; + profile: ExportProfile | null; + isNew: boolean; + onSave: () => void; + + // Form state + name: string = ''; + description: string = ''; + selectedTargetIds: Set = new Set(); + primaryTargetId: string | null = null; + + constructor( + app: App, + plugin: PromptfirePlugin, + profile: ExportProfile | null, + onSave: () => void + ) { + super(app); + this.plugin = plugin; + this.profile = profile; + this.isNew = profile === null; + this.onSave = onSave; + + if (profile) { + this.name = profile.name; + this.description = profile.description || ''; + this.selectedTargetIds = new Set(profile.targetIds); + this.primaryTargetId = profile.primaryTargetId; + } + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('export-profile-modal'); + + contentEl.createEl('h2', { text: this.isNew ? 'Add Export Profile' : 'Edit Export Profile' }); + + // Name + new Setting(contentEl) + .setName('Name') + .setDesc('Display name for this profile') + .addText(text => text + .setPlaceholder('e.g. Full LLM Export') + .setValue(this.name) + .onChange(v => this.name = v)); + + // Description + new Setting(contentEl) + .setName('Description') + .setDesc('Optional description') + .addTextArea(text => { + text.setPlaceholder('Describe what this profile does...') + .setValue(this.description) + .onChange(v => this.description = v); + text.inputEl.rows = 2; + text.inputEl.style.width = '100%'; + }); + + // Target checklist + contentEl.createEl('h3', { text: 'Targets' }); + + const targets = this.plugin.settings.targets; + if (targets.length === 0) { + contentEl.createEl('p', { + text: 'No targets configured. Create targets first in Settings > Output.', + cls: 'setting-item-description', + }); + } else { + const checklistContainer = contentEl.createDiv({ cls: 'profile-targets-checklist' }); + checklistContainer.style.display = 'flex'; + checklistContainer.style.flexDirection = 'column'; + checklistContainer.style.gap = '8px'; + checklistContainer.style.padding = '10px'; + checklistContainer.style.backgroundColor = 'var(--background-secondary)'; + checklistContainer.style.borderRadius = '4px'; + checklistContainer.style.marginBottom = '15px'; + + for (const target of targets) { + const row = checklistContainer.createDiv(); + row.style.display = 'flex'; + row.style.alignItems = 'center'; + row.style.gap = '10px'; + + const checkbox = row.createEl('input', { type: 'checkbox' }); + checkbox.checked = this.selectedTargetIds.has(target.id); + checkbox.addEventListener('change', () => { + if (checkbox.checked) { + this.selectedTargetIds.add(target.id); + } else { + this.selectedTargetIds.delete(target.id); + if (this.primaryTargetId === target.id) { + this.primaryTargetId = null; + } + } + this.refreshPrimaryDropdown(); + }); + + row.createEl('span', { text: getTargetIcon(target.format) }); + + const label = row.createEl('span', { text: target.name }); + label.style.flex = '1'; + + const tokenInfo = row.createEl('span', { + text: `${target.maxTokens.toLocaleString()} tokens · ${target.format}` + }); + tokenInfo.style.fontSize = '11px'; + tokenInfo.style.color = 'var(--text-muted)'; + } + } + + // Primary target dropdown + this.primaryDropdownContainer = contentEl.createDiv(); + this.refreshPrimaryDropdown(); + + // Buttons + const buttonContainer = contentEl.createDiv(); + buttonContainer.style.display = 'flex'; + buttonContainer.style.justifyContent = 'flex-end'; + buttonContainer.style.gap = '10px'; + buttonContainer.style.marginTop = '20px'; + + 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 primaryDropdownContainer: HTMLElement | null = null; + + private refreshPrimaryDropdown() { + if (!this.primaryDropdownContainer) return; + this.primaryDropdownContainer.empty(); + + const selectedTargets = this.plugin.settings.targets.filter( + t => this.selectedTargetIds.has(t.id) + ); + + if (selectedTargets.length === 0) return; + + // Validate current primary is still in selection + if (this.primaryTargetId && !this.selectedTargetIds.has(this.primaryTargetId)) { + this.primaryTargetId = null; + } + + new Setting(this.primaryDropdownContainer) + .setName('Primary target') + .setDesc('This target\'s output goes to clipboard; others are saved as files') + .addDropdown(dropdown => { + dropdown.addOption('', 'First selected target'); + for (const target of selectedTargets) { + dropdown.addOption(target.id, target.name); + } + dropdown.setValue(this.primaryTargetId || ''); + dropdown.onChange(v => this.primaryTargetId = v || null); + }); + } + + async save() { + if (!this.name.trim()) { + new Notice('Name is required'); + return; + } + + if (this.selectedTargetIds.size === 0) { + new Notice('Select at least one target'); + return; + } + + // Validate primary is in selection + if (this.primaryTargetId && !this.selectedTargetIds.has(this.primaryTargetId)) { + this.primaryTargetId = null; + } + + const profileData: ExportProfile = { + id: this.profile?.id || generateProfileId(), + name: this.name.trim(), + description: this.description.trim() || undefined, + targetIds: Array.from(this.selectedTargetIds), + primaryTargetId: this.primaryTargetId, + }; + + if (this.isNew) { + this.plugin.settings.exportProfiles.push(profileData); + } else { + const index = this.plugin.settings.exportProfiles.findIndex(p => p.id === this.profile!.id); + if (index >= 0) { + this.plugin.settings.exportProfiles[index] = profileData; + } + } + + await this.plugin.saveSettings(); + new Notice(this.isNew ? 'Profile added' : 'Profile updated'); + this.onSave(); + this.close(); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + this.primaryDropdownContainer = null; + } +} diff --git a/src/generator.ts b/src/generator.ts index 7bffc76..2741663 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -2,7 +2,7 @@ import { App, Modal, Notice, Setting, TFile, TFolder } from 'obsidian'; import PromptfirePlugin from './main'; import { createFreetextSource, SourcePosition } from './sources'; import { estimateTokens } from './history'; -import { OutputTarget, OutputFormat, getTargetIcon, formatTokenCount } from './targets'; +import { OutputTarget, ExportProfile, OutputFormat, getTargetIcon, formatTokenCount } from './targets'; interface FolderConfig { name: string; @@ -425,6 +425,29 @@ export class ContextGeneratorModal extends Modal { if (this.plugin.settings.targets.length > 0) { this.targetsHeadingEl = configZone.createEl('h3', { text: 'Output Targets' }); + // Profile dropdown (only if profiles exist) + if (this.plugin.settings.exportProfiles.length > 0) { + new Setting(configZone) + .setName('Profile') + .setDesc('Pre-select targets from an export profile') + .addDropdown(dropdown => { + dropdown.addOption('', 'Manual selection'); + for (const profile of this.plugin.settings.exportProfiles) { + dropdown.addOption(profile.id, profile.name); + } + dropdown.setValue(''); + dropdown.onChange(v => { + if (v) { + const profile = this.plugin.settings.exportProfiles.find(p => p.id === v); + if (profile) { + this.selectedTargetIds = new Set(profile.targetIds); + this.onOpen(); + } + } + }); + }); + } + const targetsContainer = configZone.createDiv({ cls: 'targets-checkboxes' }); targetsContainer.style.display = 'flex'; targetsContainer.style.flexDirection = 'column'; diff --git a/src/main.ts b/src/main.ts index 164c65f..74684ea 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,6 +18,7 @@ import { } from './presets'; import { OutputTarget, + ExportProfile, TargetExecutor, TargetResult, saveTargetToFile, @@ -92,6 +93,12 @@ export default class PromptfirePlugin extends Plugin { callback: () => this.copySmartContext() }); + this.addCommand({ + id: 'export-multi-target', + name: 'Export context (multi-target)', + callback: () => this.exportMultiTarget() + }); + this.addSettingTab(new PromptfireSettingTab(this.app, this)); } @@ -116,6 +123,12 @@ export default class PromptfirePlugin extends Plugin { ); } } + if (!loaded?.exportProfiles) { + this.settings.exportProfiles = []; + } + if (loaded?.activeProfileId === undefined) { + this.settings.activeProfileId = null; + } } async saveSettings() { @@ -277,7 +290,8 @@ export default class PromptfirePlugin extends Plugin { forceIncludeNote = false, temporaryFreetext?: string, templateId?: string | null, - targets?: OutputTarget[] + targets?: OutputTarget[], + profilePrimaryTargetId?: string | null ) { const folder = this.app.vault.getAbstractFileByPath(this.settings.contextFolder); @@ -412,7 +426,7 @@ export default class PromptfirePlugin extends Plugin { // Process targets if provided if (targets && targets.length > 0) { - await this.processTargets(combined, targets, historyMetadata, fileCount, sourceCount, templateName); + await this.processTargets(combined, targets, historyMetadata, fileCount, sourceCount, templateName, profilePrimaryTargetId); } else { // Copy and save to history (legacy mode) const copyAndSave = async () => { @@ -437,7 +451,8 @@ export default class PromptfirePlugin extends Plugin { historyMetadata: Omit, fileCount: number, sourceCount: number, - templateName: string | null + templateName: string | null, + overridePrimaryId?: string | null ) { const executor = new TargetExecutor(); const results: TargetResult[] = []; @@ -449,7 +464,8 @@ export default class PromptfirePlugin extends Plugin { } // Determine primary target - const primaryId = this.settings.primaryTargetId || + const primaryId = overridePrimaryId ?? + this.settings.primaryTargetId ?? (targets.find(t => t.enabled)?.id ?? targets[0]?.id); const primaryResult = results.find(r => r.target.id === primaryId) || results[0]; @@ -626,6 +642,36 @@ export default class PromptfirePlugin extends Plugin { return checkAll(selection.headings); } + async exportMultiTarget() { + const profileId = this.settings.activeProfileId; + if (!profileId) { + new Notice('No active export profile. Set one in Settings > Output > Export Profiles.'); + return; + } + + const profile = this.settings.exportProfiles.find(p => p.id === profileId); + if (!profile) { + new Notice('Active export profile not found. Check Settings > Output > Export Profiles.'); + return; + } + + const targets = profile.targetIds + .map(id => this.settings.targets.find(t => t.id === id)) + .filter((t): t is OutputTarget => t != null); + + if (targets.length === 0) { + new Notice(`Profile "${profile.name}" has no valid targets.`); + return; + } + + if (targets.length < profile.targetIds.length) { + const missing = profile.targetIds.length - targets.length; + new Notice(`${missing} target(s) in profile no longer exist and were skipped.`, 5000); + } + + await this.copyContextToClipboard(false, undefined, undefined, targets, profile.primaryTargetId); + } + async copySmartContext() { if (!this.settings.intelligence.enabled) { new Notice('Context intelligence is disabled. Enable it in Settings > Intelligence.'); diff --git a/src/settings.ts b/src/settings.ts index 4670cb9..58aace6 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -5,8 +5,9 @@ import { SourceModal } from './source-modal'; import { PromptTemplate, STARTER_TEMPLATES } from './templates'; import { TemplateModal, TemplateImportExportModal } from './template-modal'; import { HistorySettings, DEFAULT_HISTORY_SETTINGS } from './history'; -import { OutputTarget, BUILTIN_TARGETS, getTargetIcon } from './targets'; +import { OutputTarget, ExportProfile, BUILTIN_TARGETS, BUILTIN_PROFILES, getTargetIcon } from './targets'; import { TargetModal } from './target-modal'; +import { ExportProfileModal } from './export-profile-modal'; import { IntelligenceSettings, DEFAULT_INTELLIGENCE_SETTINGS } from './context-intelligence'; export interface PromptfireSettings { @@ -24,6 +25,8 @@ export interface PromptfireSettings { targets: OutputTarget[]; primaryTargetId: string | null; targetOutputFolder: string; + exportProfiles: ExportProfile[]; + activeProfileId: string | null; lastSettingsTab: string; collapsedSections: string[]; generatorPreviewOpen: boolean; @@ -45,6 +48,8 @@ export const DEFAULT_SETTINGS: PromptfireSettings = { targets: [], primaryTargetId: null, targetOutputFolder: '_context/outputs', + exportProfiles: [], + activeProfileId: null, lastSettingsTab: 'general', collapsedSections: [], generatorPreviewOpen: false, @@ -530,6 +535,21 @@ export class PromptfireSettingTab extends PluginSettingTab { this.setDynamicDesc(primaryTargetSetting, 'This target\'s output is copied to clipboard', totalTargets > 0 ? `${enabledTargets} of ${totalTargets} enabled` : ''); + new Setting(oc) + .setName('Active export profile') + .setDesc('Profile used by the "Export context (multi-target)" command') + .addDropdown(dropdown => { + dropdown.addOption('', 'None'); + for (const profile of this.plugin.settings.exportProfiles) { + dropdown.addOption(profile.id, profile.name); + } + dropdown.setValue(this.plugin.settings.activeProfileId || ''); + dropdown.onChange(async (value) => { + this.plugin.settings.activeProfileId = value || null; + await this.plugin.saveSettings(); + }); + }); + const outputFolderSetting = new Setting(oc) .setName('Output folder') .addText(text => text @@ -541,6 +561,37 @@ export class PromptfireSettingTab extends PluginSettingTab { this.describeOutputFolder(outputFolderSetting, this.plugin.settings.targetOutputFolder); })); this.describeOutputFolder(outputFolderSetting, this.plugin.settings.targetOutputFolder); + + // Section: Export Profiles + const profilesSection = new CollapsibleSection(el, 'output-profiles', 'Export profiles', this.plugin); + const pc = profilesSection.contentEl; + + const profileButtonContainer = pc.createDiv({ cls: 'profiles-button-container' }); + profileButtonContainer.style.display = 'flex'; + profileButtonContainer.style.gap = '8px'; + profileButtonContainer.style.marginBottom = '15px'; + + const addProfileBtn = profileButtonContainer.createEl('button', { text: '+ New Profile' }); + addProfileBtn.addEventListener('click', () => { + new ExportProfileModal(this.app, this.plugin, null, () => this.display()).open(); + }); + + const addBuiltinProfilesBtn = profileButtonContainer.createEl('button', { text: 'Add Built-in Profiles' }); + addBuiltinProfilesBtn.addEventListener('click', async () => { + const existingIds = this.plugin.settings.exportProfiles.map(p => p.id); + const newProfiles = BUILTIN_PROFILES.filter(p => !existingIds.includes(p.id)); + if (newProfiles.length === 0) { + new Notice('All built-in profiles already added'); + return; + } + this.plugin.settings.exportProfiles.push(...newProfiles); + await this.plugin.saveSettings(); + new Notice(`Added ${newProfiles.length} built-in profile(s)`); + this.display(); + }); + + const profilesContainer = pc.createDiv({ cls: 'profiles-list-container' }); + this.renderProfilesList(profilesContainer); } // === TAB: History === @@ -1100,6 +1151,13 @@ export class PromptfireSettingTab extends PluginSettingTab { if (this.plugin.settings.primaryTargetId === target.id) { this.plugin.settings.primaryTargetId = null; } + // Clean up profile references + for (const profile of this.plugin.settings.exportProfiles) { + profile.targetIds = profile.targetIds.filter(id => id !== target.id); + if (profile.primaryTargetId === target.id) { + profile.primaryTargetId = null; + } + } await this.plugin.saveSettings(); this.display(); }); @@ -1111,4 +1169,86 @@ export class PromptfireSettingTab extends PluginSettingTab { lastTargetRow.style.borderBottom = 'none'; } } + + private renderProfilesList(container: HTMLElement) { + container.empty(); + + if (this.plugin.settings.exportProfiles.length === 0) { + const emptyMsg = container.createEl('p', { + text: 'No export profiles configured yet.', + cls: 'setting-item-description' + }); + emptyMsg.style.fontStyle = 'italic'; + return; + } + + const list = container.createDiv({ cls: 'profiles-list' }); + list.style.border = '1px solid var(--background-modifier-border)'; + list.style.borderRadius = '4px'; + list.style.marginBottom = '15px'; + + for (const profile of this.plugin.settings.exportProfiles) { + const row = list.createDiv({ cls: 'profile-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'; + + // Name and info + const textContainer = row.createDiv(); + textContainer.style.flex = '1'; + + const name = textContainer.createEl('span', { text: profile.name }); + name.style.fontWeight = '500'; + + // Show target names + const targetNames = profile.targetIds + .map(id => this.plugin.settings.targets.find(t => t.id === id)?.name) + .filter(Boolean) + .join(', '); + if (targetNames) { + const info = textContainer.createEl('div', { text: targetNames }); + info.style.fontSize = '11px'; + info.style.color = 'var(--text-muted)'; + } + + // Active badge + if (this.plugin.settings.activeProfileId === profile.id) { + const badge = row.createEl('span', { text: 'active' }); + badge.style.padding = '2px 6px'; + badge.style.borderRadius = '3px'; + badge.style.fontSize = '11px'; + badge.style.backgroundColor = 'var(--interactive-accent)'; + badge.style.color = 'var(--text-on-accent)'; + } + + // Edit button + const editBtn = row.createEl('button', { text: '✎' }); + editBtn.style.padding = '2px 8px'; + editBtn.addEventListener('click', () => { + new ExportProfileModal(this.app, this.plugin, profile, () => this.display()).open(); + }); + + // Delete button + const deleteBtn = row.createEl('button', { text: '✕' }); + deleteBtn.style.padding = '2px 8px'; + deleteBtn.addEventListener('click', async () => { + this.plugin.settings.exportProfiles = this.plugin.settings.exportProfiles.filter( + p => p.id !== profile.id + ); + if (this.plugin.settings.activeProfileId === profile.id) { + this.plugin.settings.activeProfileId = null; + } + await this.plugin.saveSettings(); + this.display(); + }); + } + + // Remove bottom border from last item + const lastProfileRow = list.lastElementChild as HTMLElement; + if (lastProfileRow) { + lastProfileRow.style.borderBottom = 'none'; + } + } } diff --git a/src/target-modal.ts b/src/target-modal.ts index 65ea269..5f99c85 100644 --- a/src/target-modal.ts +++ b/src/target-modal.ts @@ -21,6 +21,7 @@ export class TargetModal extends Modal { wrapperPrefix: string = ''; wrapperSuffix: string = ''; separator: string = '\n\n---\n\n'; + outputPath: string = ''; enabled: boolean = true; constructor( @@ -44,6 +45,7 @@ export class TargetModal extends Modal { this.wrapperPrefix = target.wrapper?.prefix || ''; this.wrapperSuffix = target.wrapper?.suffix || ''; this.separator = target.separator || '\n\n---\n\n'; + this.outputPath = target.outputPath || ''; this.enabled = target.enabled; } } @@ -138,6 +140,15 @@ export class TargetModal extends Modal { text.inputEl.style.width = '100%'; }); + // Output path + new Setting(contentEl) + .setName('Output path') + .setDesc('Custom folder for this target\'s file output (leave empty for default)') + .addText(text => text + .setPlaceholder('_context/outputs') + .setValue(this.outputPath) + .onChange(v => this.outputPath = v)); + // Enabled toggle new Setting(contentEl) .setName('Enabled') @@ -180,6 +191,7 @@ export class TargetModal extends Modal { format: this.format, strategy: this.strategy, separator: this.separator, + outputPath: this.outputPath.trim() || undefined, enabled: this.enabled, isBuiltin: false, }; diff --git a/src/targets.ts b/src/targets.ts index 04ac5b3..6a33e3e 100644 --- a/src/targets.ts +++ b/src/targets.ts @@ -17,6 +17,7 @@ export interface OutputTarget { suffix?: string; }; separator?: string; + outputPath?: string; enabled: boolean; isBuiltin?: boolean; } @@ -29,12 +30,24 @@ export interface TargetResult { sectionsDropped: number; } +export interface ExportProfile { + id: string; + name: string; + description?: string; + targetIds: string[]; + primaryTargetId: string | null; +} + // === ID Generation === export function generateTargetId(): string { return 'target_' + Date.now().toString(36) + Math.random().toString(36).substring(2, 9); } +export function generateProfileId(): string { + return 'profile_' + Date.now().toString(36) + Math.random().toString(36).substring(2, 9); +} + // === Built-in Targets === export const BUILTIN_TARGETS: OutputTarget[] = [ @@ -74,6 +87,16 @@ export const BUILTIN_TARGETS: OutputTarget[] = [ }, ]; +export const BUILTIN_PROFILES: ExportProfile[] = [ + { + id: 'builtin_profile_full', + name: 'Full LLM Export', + description: 'All built-in targets: XML for Claude, Markdown for GPT-4o, Plain for compact', + targetIds: ['builtin_promptfire', 'builtin_gpt4', 'builtin_compact'], + primaryTargetId: 'builtin_promptfire', + }, +]; + // === Content Transformer === export class ContentTransformer { @@ -480,14 +503,15 @@ export async function saveTargetToFile( result: TargetResult, outputFolder: string ): Promise { + const effectiveFolder = result.target.outputPath || outputFolder; const filename = `context-${result.target.name.toLowerCase().replace(/\s+/g, '-')}.md`; - const path = `${outputFolder}/${filename}`; + const path = `${effectiveFolder}/${filename}`; try { // Ensure folder exists - const folder = app.vault.getAbstractFileByPath(outputFolder); + const folder = app.vault.getAbstractFileByPath(effectiveFolder); if (!folder) { - await app.vault.createFolder(outputFolder); + await app.vault.createFolder(effectiveFolder); } // Create or update file