From ec004dd25f9243f78b193144fa1d7dcb13ad9694 Mon Sep 17 00:00:00 2001 From: Chris Lettieri Date: Sun, 27 Jul 2025 11:20:50 -0400 Subject: [PATCH] custom lens, date range filtering, note header date parsing --- src/main.ts | 105 ++++++-- src/services/distill-service.ts | 152 +++++++++-- src/services/file-service.ts | 86 +++++- src/services/openai-service.ts | 21 +- src/types/settings.ts | 2 + src/ui/prompt-selection-modal.ts | 138 ++++++++++ src/ui/recent-activity-modal.ts | 440 ++++++++++++++++++++++++++++++- src/ui/settings-tab.ts | 18 +- styles.css | 41 +++ 9 files changed, 951 insertions(+), 52 deletions(-) create mode 100644 src/ui/prompt-selection-modal.ts diff --git a/src/main.ts b/src/main.ts index ea94276..68f97c6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,7 @@ import { OpenAugiSettingTab } from './ui/settings-tab'; import { LoadingIndicator } from './ui/loading-indicator'; import { sanitizeFilename, createFileWithCollisionHandling } from './utils/filename-utils'; import { RecentActivityModal, RecentActivityConfig } from './ui/recent-activity-modal'; +import { PromptSelectionModal, PromptSelectionConfig } from './ui/prompt-selection-modal'; /** * A simple tokeinzer to estimate the number of tokens @@ -168,6 +169,23 @@ export default class OpenAugiPlugin extends Plugin { * @param rootFile The root file containing links to distill */ private async distillLinkedNotes(rootFile: TFile): Promise { + // Show prompt selection modal + const modal = new PromptSelectionModal( + this.app, + this.settings.promptsFolder, + async (config: PromptSelectionConfig) => { + await this.executeDistillLinkedNotes(rootFile, config); + } + ); + modal.open(); + } + + /** + * Execute the distillation of linked notes with the selected prompt configuration + * @param rootFile The root note file + * @param promptConfig The prompt configuration from the modal + */ + private async executeDistillLinkedNotes(rootFile: TFile, promptConfig: PromptSelectionConfig): Promise { try { // Show loading indicator this.loadingIndicator?.show('Distilling linked notes'); @@ -210,12 +228,24 @@ export default class OpenAugiPlugin extends Plugin { // Show notice about linked files new Notice(`Found ${linkedFiles.length} linked notes to process.`); + // Read custom prompt if selected + let customPrompt: string | undefined; + if (promptConfig.useCustomPrompt && promptConfig.selectedPrompt) { + try { + customPrompt = await this.app.vault.read(promptConfig.selectedPrompt); + } catch (error) { + console.error('Failed to read custom prompt:', error); + new Notice('Failed to read custom prompt, using default'); + } + } + // Let the distill service handle all the content aggregation (no time filtering) const distilledData = await this.distillService.distillFromRootNote( rootFile, undefined, undefined, - 0 // No time filtering for regular distill command + 0, // No time filtering for regular distill command + customPrompt ); // Write result to files @@ -257,7 +287,15 @@ export default class OpenAugiPlugin extends Plugin { this.app, this.settings.recentActivityDefaults, async (config: RecentActivityConfig) => { - await this.executeRecentActivityDistill(config); + // After recent activity config, show prompt selection + const promptModal = new PromptSelectionModal( + this.app, + this.settings.promptsFolder, + async (promptConfig: PromptSelectionConfig) => { + await this.executeRecentActivityDistill(config, promptConfig); + } + ); + promptModal.open(); } ); modal.open(); @@ -266,8 +304,9 @@ export default class OpenAugiPlugin extends Plugin { /** * Execute the recent activity distillation with the given configuration * @param config The configuration for recent activity distillation + * @param promptConfig The prompt configuration from the modal */ - private async executeRecentActivityDistill(config: RecentActivityConfig): Promise { + private async executeRecentActivityDistill(config: RecentActivityConfig, promptConfig: PromptSelectionConfig): Promise { try { // Check API key if (!this.settings.apiKey) { @@ -286,15 +325,23 @@ export default class OpenAugiPlugin extends Plugin { this.settings ); - // Get recently modified notes - const recentFiles = await this.distillService.getRecentlyModifiedNotes( - config.daysBack, - config.excludeFolders - ); + // Use selected notes if provided, otherwise get all recent notes + let recentFiles: TFile[]; + if (config.selectedNotes && config.selectedNotes.length > 0) { + recentFiles = config.selectedNotes; + } else { + // Fallback to getting all recent notes (shouldn't happen with new UI) + recentFiles = await this.distillService.getRecentlyModifiedNotes( + config.daysBack, + config.excludeFolders, + config.useDateRange ? config.fromDate : undefined, + config.useDateRange ? config.toDate : undefined + ); + } if (recentFiles.length === 0 && !config.rootNote) { this.loadingIndicator?.hide(); - new Notice(`No notes modified in the last ${config.daysBack} days`); + new Notice(`No notes selected for processing`); return; } @@ -306,8 +353,8 @@ export default class OpenAugiPlugin extends Plugin { // Show notice about discovered files const message = config.rootNote - ? `Found ${recentFiles.length} recent notes plus root note: ${config.rootNote.basename}` - : `Found ${recentFiles.length} notes modified in the last ${config.daysBack} days`; + ? `Processing ${recentFiles.length} selected notes plus root note: ${config.rootNote.basename}` + : `Processing ${recentFiles.length} selected notes`; new Notice(message); // Update loading message @@ -317,9 +364,16 @@ export default class OpenAugiPlugin extends Plugin { const timeWindow = config.filterJournalSections ? config.daysBack : 0; // Create a synthetic root file for the distillation + let timeWindowDesc: string; + if (config.useDateRange && config.fromDate && config.toDate) { + timeWindowDesc = `from ${config.fromDate} to ${config.toDate}`; + } else { + timeWindowDesc = `in the last ${config.daysBack} days`; + } + const syntheticRootContent = `# Recent Activity Summary -This is an automated summary of notes modified in the last ${config.daysBack} days. +This is an automated summary of notes modified ${timeWindowDesc}. ${config.rootNote ? `\nUsing [[${config.rootNote.basename}]] as context root.` : ''} ## Recently Modified Notes: @@ -342,17 +396,38 @@ ${allFiles.map(f => `- [[${f.basename}]]`).join('\n')}`; ); // Combine with synthetic root content - const combinedContent = `# Recent Activity: Last ${config.daysBack} Days\n\n${syntheticRootContent}\n\n${aggregatedContent}`; + const combinedContent = `# Recent Activity: ${timeWindowDesc}\n\n${syntheticRootContent}\n\n${aggregatedContent}`; + + // Read custom prompt if selected + let customPrompt: string | undefined; + if (promptConfig.useCustomPrompt && promptConfig.selectedPrompt) { + try { + customPrompt = await this.app.vault.read(promptConfig.selectedPrompt); + } catch (error) { + console.error('Failed to read custom prompt:', error); + new Notice('Failed to read custom prompt, using default'); + } + } // Distill the recent activity const distilledData = await this.distillService.distillFromRootNote( tempRootFile, combinedContent, - sourceNotes + sourceNotes, + undefined, // No time window needed here since we already filtered + customPrompt ); // Update the distilled data to reflect recent activity - distilledData.summary = `## Recent Activity Summary (Last ${config.daysBack} Days)\n\n${distilledData.summary}`; + const summaryTimeDesc = config.useDateRange && config.fromDate && config.toDate + ? `${config.fromDate} to ${config.toDate}` + : `Last ${config.daysBack} Days`; + distilledData.summary = `## Recent Activity Summary (${summaryTimeDesc})\n\n${distilledData.summary}`; + + // Remove the temp file from source notes before writing + distilledData.sourceNotes = distilledData.sourceNotes?.filter( + note => !note.includes('temp-recent-activity') + ); // Write result to files const summaryPath = await this.fileService.writeDistilledFiles(tempRootFile, distilledData); diff --git a/src/services/distill-service.ts b/src/services/distill-service.ts index df03d57..7cace02 100644 --- a/src/services/distill-service.ts +++ b/src/services/distill-service.ts @@ -80,10 +80,37 @@ ${content} * Get recently modified notes based on specified criteria * @param daysBack Number of days to look back * @param excludeFolders Folders to exclude from search + * @param fromDate Optional start date for date range mode + * @param toDate Optional end date for date range mode * @returns Array of recently modified files */ - async getRecentlyModifiedNotes(daysBack: number, excludeFolders: string[]): Promise { - const cutoffTime = Date.now() - (daysBack * 24 * 60 * 60 * 1000); + async getRecentlyModifiedNotes( + daysBack: number, + excludeFolders: string[], + fromDate?: string, + toDate?: string + ): Promise { + let startTime: number; + let endTime: number; + let cutoffDate: Date; + + if (fromDate && toDate) { + // Use date range + const from = new Date(fromDate); + from.setHours(0, 0, 0, 0); + startTime = from.getTime(); + cutoffDate = from; + + const to = new Date(toDate); + to.setHours(23, 59, 59, 999); + endTime = to.getTime(); + } else { + // Use days back + endTime = Date.now(); + startTime = endTime - (daysBack * 24 * 60 * 60 * 1000); + cutoffDate = new Date(startTime); + } + const recentFiles: TFile[] = []; const files = this.app.vault.getMarkdownFiles(); @@ -98,21 +125,43 @@ ${content} continue; } - // Check modification time - const stats = await this.app.vault.adapter.stat(file.path); - if (stats && stats.mtime >= cutoffTime) { + let includeFile = false; + + // First, check if filename starts with a date + const filenameDate = this.extractDateFromFilename(file.basename); + if (filenameDate) { + const fileTime = filenameDate.getTime(); + if (fileTime >= startTime && fileTime <= endTime) { + includeFile = true; + } + } + + // If not included by filename date, check modification time + if (!includeFile) { + const stats = await this.app.vault.adapter.stat(file.path); + if (stats && stats.mtime >= startTime && stats.mtime <= endTime) { + includeFile = true; + } + } + + if (includeFile) { recentFiles.push(file); } } - // Sort by modification time, most recent first - recentFiles.sort((a, b) => { - const aStats = this.app.vault.adapter.stat(a.path); - const bStats = this.app.vault.adapter.stat(b.path); - return (bStats as any).mtime - (aStats as any).mtime; - }); + // Sort by effective date (filename date or modification time), most recent first + const filesWithDates = await Promise.all( + recentFiles.map(async (file) => { + const filenameDate = this.extractDateFromFilename(file.basename); + const stats = await this.app.vault.adapter.stat(file.path); + const effectiveTime = filenameDate ? filenameDate.getTime() : (stats?.mtime || 0); + return { file, effectiveTime }; + }) + ); - return recentFiles; + filesWithDates.sort((a, b) => b.effectiveTime - a.effectiveTime); + + return filesWithDates.map(item => item.file); } /** @@ -304,10 +353,40 @@ ${content} async getLinkedNotes(file: TFile): Promise { let linkedFiles: TFile[] = []; - // First check if content contains dataview query and settings allow using dataview - if (this.settings.useDataviewIfAvailable) { - const fileContent = await this.app.vault.read(file); + // Read file content once + const fileContent = await this.app.vault.read(file); + + // Check if this is a collection note with checkboxes + const hasCheckboxLinks = /- \[[ x]\] \[\[.*?\]\]/.test(fileContent); + + if (hasCheckboxLinks) { + // Extract only checked links from checkbox format + const checkboxRegex = /- \[x\] \[\[(.*?)\]\]/gi; + let match; + while ((match = checkboxRegex.exec(fileContent)) !== null) { + let linkPath = match[1]; + + // Handle aliased links: [[path|alias]] -> path + if (linkPath.includes("|")) { + linkPath = linkPath.split("|")[0]; + } + + // Resolve the file + const linkedFile = this.app.metadataCache.getFirstLinkpathDest(linkPath, file.path); + + if (linkedFile && linkedFile instanceof TFile) { + if (!linkedFiles.some(existingFile => existingFile.path === linkedFile.path)) { + linkedFiles.push(linkedFile); + } + } + } + // For collection notes, only use checked items + return linkedFiles; + } + + // Check if content contains dataview query and settings allow using dataview + if (this.settings.useDataviewIfAvailable) { if (this.containsDataviewQuery(fileContent)) { const queries = this.extractDataviewQueries(fileContent); @@ -428,6 +507,33 @@ ${content} return isNaN(date.getTime()) ? null : date; } + /** + * Extract date from filename if it starts with YYYY-MM-DD format + * @param filename The filename to parse + * @returns Date object or null if filename doesn't start with a date + */ + private extractDateFromFilename(filename: string): Date | null { + // Match YYYY-MM-DD at the start of the filename + const dateMatch = filename.match(/^(\d{4})-(\d{2})-(\d{2})/); + + if (dateMatch) { + const year = parseInt(dateMatch[1]); + const month = parseInt(dateMatch[2]); + const day = parseInt(dateMatch[3]); + + // Validate the date components + if (month >= 1 && month <= 12 && day >= 1 && day <= 31) { + const date = new Date(year, month - 1, day); + // Check if the date is valid + if (!isNaN(date.getTime())) { + return date; + } + } + } + + return null; + } + /** * Check if a note is journal-style (contains headers with configured date format) * @param content The content to check @@ -435,6 +541,10 @@ ${content} */ private isJournalStyleNote(content: string): boolean { const dateFormat = this.settings.recentActivityDefaults.dateHeaderFormat; + // Skip journal parsing if dateHeaderFormat is empty + if (!dateFormat || dateFormat.trim() === '') { + return false; + } const dateHeaderRegex = this.dateFormatToRegex(dateFormat); return dateHeaderRegex.test(content); } @@ -446,6 +556,10 @@ ${content} */ private parseDateFromHeader(header: string): Date | null { const dateFormat = this.settings.recentActivityDefaults.dateHeaderFormat; + // Skip journal parsing if dateHeaderFormat is empty + if (!dateFormat || dateFormat.trim() === '') { + return null; + } return this.extractDateFromHeader(header, dateFormat); } @@ -585,13 +699,15 @@ ${content} * @param preparedContent Optional preprocessed combined content * @param preparedSourceNotes Optional preprocessed source notes * @param timeWindowDays Optional time window in days for filtering journal content + * @param customPrompt Optional custom prompt to use for processing * @returns Distilled content as a DistillResponse */ async distillFromRootNote( rootFile: TFile, preparedContent?: string, preparedSourceNotes?: string[], - timeWindowDays?: number + timeWindowDays?: number, + customPrompt?: string ): Promise { let combinedContent: string; let sourceNotes: string[]; @@ -624,8 +740,8 @@ ${content} // Log the combined content before sending to AI await this.logDistillContext(combinedContent, rootFile.basename); - // Send to OpenAI for distillation - const distilledContent = await this.openAIService.distillContent(combinedContent); + // Send to OpenAI for distillation with optional custom prompt + const distilledContent = await this.openAIService.distillContent(combinedContent, customPrompt); // Add source notes to the response distilledContent.sourceNotes = [rootFile.basename, ...sourceNotes]; diff --git a/src/services/file-service.ts b/src/services/file-service.ts index 709ccf4..33c8800 100644 --- a/src/services/file-service.ts +++ b/src/services/file-service.ts @@ -18,6 +18,37 @@ export class FileService { this.backlinkMapper = new BacklinkMapper(); } + /** + * Generate a session folder name based on type and timestamp + * @param type Type of distillation: 'transcript', 'distill', or 'recent' + * @param rootName Optional root note name for context + * @returns Formatted folder name + */ + private generateSessionFolderName(type: 'transcript' | 'distill' | 'recent', rootName?: string): string { + const now = new Date(); + const timestamp = now.toISOString() + .replace(/T/, ' ') + .replace(/\..+/, '') + .replace(/:/g, '-'); + + switch (type) { + case 'transcript': + return `Transcript ${timestamp}`; + case 'distill': + if (rootName) { + const sanitizedRoot = sanitizeFilename(rootName); + // Truncate root name if too long + const truncatedRoot = sanitizedRoot.length > 30 + ? sanitizedRoot.substring(0, 30) + '...' + : sanitizedRoot; + return `Distill ${truncatedRoot} ${timestamp}`; + } + return `Distill ${timestamp}`; + case 'recent': + return `Recent Activity ${timestamp}`; + } + } + /** * Ensure output directories exist */ @@ -44,6 +75,14 @@ export class FileService { // Ensure directories exist await this.ensureDirectoriesExist(); + // Create session folder for atomic notes + const sessionFolder = this.generateSessionFolderName('transcript'); + const sessionPath = `${this.notesFolder}/${sessionFolder}`; + const sessionExists = await this.vault.adapter.exists(sessionPath); + if (!sessionExists) { + await this.vault.createFolder(sessionPath); + } + // Sanitize filename const sanitizedFilename = sanitizeFilename(filename); @@ -72,7 +111,7 @@ export class FileService { summaryContent ); - // Output Notes + // Output Notes to session folder for (const note of data.notes) { // Sanitize note title for filename const sanitizedTitle = sanitizeFilename(note.title); @@ -80,7 +119,7 @@ export class FileService { const processedContent = this.backlinkMapper.processBacklinks(note.content); await createFileWithCollisionHandling( this.vault, - `${this.notesFolder}/${sanitizedTitle}.md`, + `${sessionPath}/${sanitizedTitle}.md`, processedContent ); } @@ -95,8 +134,41 @@ export class FileService { // Ensure directories exist await this.ensureDirectoriesExist(); - // Sanitize filename - const sanitizedFilename = sanitizeFilename(rootFile.basename); + // Determine session type based on rootFile + const isRecentActivity = rootFile.basename.includes('temp-recent-activity'); + const sessionType = isRecentActivity ? 'recent' : 'distill'; + const sessionFolder = this.generateSessionFolderName( + sessionType, + isRecentActivity ? undefined : rootFile.basename + ); + const sessionPath = `${this.notesFolder}/${sessionFolder}`; + + // Create session folder for atomic notes + const sessionExists = await this.vault.adapter.exists(sessionPath); + if (!sessionExists) { + await this.vault.createFolder(sessionPath); + } + + // Generate appropriate filename based on content + let summaryFilename: string; + if (isRecentActivity) { + // For recent activity, try to extract a meaningful title from the first atomic note + // or use a descriptive name + if (data.notes.length > 0) { + const firstNoteTitle = sanitizeFilename(data.notes[0].title); + const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + summaryFilename = `Recent Activity ${timestamp} - ${firstNoteTitle}`; + } else { + const timestamp = new Date().toISOString() + .replace(/T/, ' ') + .replace(/\..+/, '') + .replace(/:/g, '-'); + summaryFilename = `Recent Activity Summary ${timestamp}`; + } + } else { + // For regular distillation, use the root file name + summaryFilename = `${sanitizeFilename(rootFile.basename)} - distilled`; + } // Register all note titles for backlink processing this.backlinkMapper = new BacklinkMapper(); // Reset the mapper @@ -127,11 +199,11 @@ export class FileService { // Output Summary const summaryPath = await createFileWithCollisionHandling( this.vault, - `${this.summaryFolder}/${sanitizedFilename} - distilled.md`, + `${this.summaryFolder}/${summaryFilename}.md`, summaryContent ); - // Output Notes + // Output Notes to session folder for (const note of data.notes) { // Sanitize note title for filename const sanitizedTitle = sanitizeFilename(note.title); @@ -139,7 +211,7 @@ export class FileService { const processedContent = this.backlinkMapper.processBacklinks(note.content); await createFileWithCollisionHandling( this.vault, - `${this.notesFolder}/${sanitizedTitle}.md`, + `${sessionPath}/${sanitizedTitle}.md`, processedContent ); } diff --git a/src/services/openai-service.ts b/src/services/openai-service.ts index 77b7ee6..7c88e5e 100644 --- a/src/services/openai-service.ts +++ b/src/services/openai-service.ts @@ -208,14 +208,21 @@ export class OpenAIService { /** * Get the distillation prompt for the OpenAI API * @param content The aggregated content from linked notes + * @param customPrompt Optional custom prompt to replace the default instructions * @returns The formatted prompt */ - private getDistillPrompt(content: string): string { + private getDistillPrompt(content: string, customPrompt?: string): string { // Extract any custom context from the content (which should include the root note) const customContext = this.extractCustomContext(content); - // Base prompt - let prompt = ` + let prompt: string; + + if (customPrompt) { + // Use the custom prompt as the main instructions + prompt = customPrompt; + } else { + // Use the default prompt + prompt = ` You are an expert knowledge curator helping users distill and organize information from their notes. Your task is to analyze multiple related notes and create a coherent set of atomic notes and a summary. # Instructions @@ -242,8 +249,9 @@ export class OpenAIService { - Highlight connections between ideas - Use \`[[Backlinks]]\` to connect to relevant atomic notes `; + } - // Add custom context if available + // Add custom context if available (this is from the context: section in notes) if (customContext) { prompt += `\n\n${customContext}`; } @@ -257,14 +265,15 @@ export class OpenAIService { /** * Call the OpenAI API to distill content from linked notes * @param content The aggregated content from linked notes + * @param customPrompt Optional custom prompt to replace the default instructions * @returns Distilled content data */ - async distillContent(content: string): Promise { + async distillContent(content: string, customPrompt?: string): Promise { if (!this.apiKey) { throw new Error('OpenAI API key not set'); } - const prompt = this.getDistillPrompt(content); + const prompt = this.getDistillPrompt(content, customPrompt); try { const response = await fetch('https://api.openai.com/v1/chat/completions', { diff --git a/src/types/settings.ts b/src/types/settings.ts index 3cb9177..ad23145 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -11,6 +11,7 @@ export interface OpenAugiSettings { apiKey: string; summaryFolder: string; notesFolder: string; + promptsFolder: string; useDataviewIfAvailable: boolean; enableDistillLogging: boolean; recentActivityDefaults: RecentActivitySettings; @@ -20,6 +21,7 @@ export const DEFAULT_SETTINGS: OpenAugiSettings = { apiKey: '', summaryFolder: 'OpenAugi/Summaries', notesFolder: 'OpenAugi/Notes', + promptsFolder: 'OpenAugi/Prompts', useDataviewIfAvailable: true, enableDistillLogging: false, recentActivityDefaults: { diff --git a/src/ui/prompt-selection-modal.ts b/src/ui/prompt-selection-modal.ts new file mode 100644 index 0000000..64ae0ed --- /dev/null +++ b/src/ui/prompt-selection-modal.ts @@ -0,0 +1,138 @@ +import { App, Modal, Setting, TFile } from 'obsidian'; + +export interface PromptSelectionConfig { + selectedPrompt?: TFile; + useCustomPrompt: boolean; +} + +export class PromptSelectionModal extends Modal { + private config: PromptSelectionConfig; + private onSubmit: (config: PromptSelectionConfig) => void; + private promptsFolder: string; + private availablePrompts: TFile[] = []; + + constructor( + app: App, + promptsFolder: string, + onSubmit: (config: PromptSelectionConfig) => void + ) { + super(app); + this.promptsFolder = promptsFolder; + this.config = { useCustomPrompt: false }; + this.onSubmit = onSubmit; + } + + async onOpen() { + const { contentEl } = this; + contentEl.empty(); + + contentEl.createEl('h2', { text: 'Select Processing Lens' }); + + contentEl.createEl('p', { + text: 'Choose a custom prompt to guide how the content is processed, or use the default prompt.', + cls: 'openaugi-prompt-description' + }); + + // Load available prompts + await this.loadAvailablePrompts(); + + if (this.availablePrompts.length === 0) { + contentEl.createEl('p', { + text: `No prompt files found in "${this.promptsFolder}". Create markdown files in this folder to use as custom prompts.`, + cls: 'openaugi-prompt-warning' + }); + } else { + // Show preview of selected prompt + const previewEl = contentEl.createDiv({ cls: 'openaugi-prompt-preview' }); + previewEl.style.display = 'none'; + + // Create dropdown for prompt selection + new Setting(contentEl) + .setName('Custom prompt') + .setDesc('Select a prompt template to customize processing') + .addDropdown(dropdown => { + dropdown.addOption('', 'Use default prompt'); + + this.availablePrompts.forEach(prompt => { + dropdown.addOption(prompt.path, prompt.basename); + }); + + dropdown.onChange(async value => { + if (value) { + this.config.useCustomPrompt = true; + this.config.selectedPrompt = this.availablePrompts.find(p => p.path === value); + previewEl.style.display = 'block'; + await this.updatePreview(previewEl); + } else { + this.config.useCustomPrompt = false; + this.config.selectedPrompt = undefined; + previewEl.style.display = 'none'; + } + }); + }); + } + + // Action buttons + new Setting(contentEl) + .addButton(button => button + .setButtonText('Cancel') + .onClick(() => this.close()) + ) + .addButton(button => button + .setButtonText('Continue') + .setCta() + .onClick(() => { + this.onSubmit(this.config); + this.close(); + }) + ); + } + + private async loadAvailablePrompts() { + try { + // Check if prompts folder exists + if (!await this.app.vault.adapter.exists(this.promptsFolder)) { + return; + } + + // Get all markdown files in the prompts folder + const files = this.app.vault.getMarkdownFiles(); + this.availablePrompts = files.filter(file => + file.path.startsWith(this.promptsFolder + '/') || + file.path === this.promptsFolder + ); + + // Sort by name + this.availablePrompts.sort((a, b) => a.basename.localeCompare(b.basename)); + } catch (error) { + console.error('Error loading prompts:', error); + } + } + + private async updatePreview(previewEl: HTMLElement) { + previewEl.empty(); + + if (this.config.selectedPrompt) { + try { + const content = await this.app.vault.read(this.config.selectedPrompt); + const preview = content.length > 300 ? content.substring(0, 300) + '...' : content; + + previewEl.createEl('h4', { text: 'Preview:' }); + previewEl.createEl('pre', { + text: preview, + cls: 'openaugi-prompt-preview-content' + }); + } catch (error) { + previewEl.createEl('p', { + text: 'Unable to preview prompt', + cls: 'openaugi-prompt-error' + }); + } + } + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} \ No newline at end of file diff --git a/src/ui/recent-activity-modal.ts b/src/ui/recent-activity-modal.ts index 577d208..850c05e 100644 --- a/src/ui/recent-activity-modal.ts +++ b/src/ui/recent-activity-modal.ts @@ -1,13 +1,30 @@ -import { App, Modal, Setting, TFile } from 'obsidian'; +import { App, Modal, Setting, TFile, ToggleComponent, Notice } from 'obsidian'; import { RecentActivitySettings } from '../types/settings'; +import { createFileWithCollisionHandling } from '../utils/filename-utils'; export interface RecentActivityConfig extends RecentActivitySettings { rootNote?: TFile; + selectedNotes?: TFile[]; + useDateRange?: boolean; + fromDate?: string; + toDate?: string; +} + +interface NoteSelection { + file: TFile; + selected: boolean; + modifiedTime: number; } export class RecentActivityModal extends Modal { private config: RecentActivityConfig; private onSubmit: (config: RecentActivityConfig) => void; + private noteSelections: NoteSelection[] = []; + private notesListEl: HTMLElement | null = null; + private previewButton: HTMLElement | null = null; + private daysBackSetting: Setting | null = null; + private fromDateSetting: Setting | null = null; + private toDateSetting: Setting | null = null; constructor( app: App, @@ -25,20 +42,95 @@ export class RecentActivityModal extends Modal { contentEl.createEl('h2', { text: 'Configure Recent Activity Distillation' }); + // Date range toggle new Setting(contentEl) + .setName('Use specific date range') + .setDesc('Toggle between "last N days" and specific date range') + .addToggle(toggle => toggle + .setValue(this.config.useDateRange || false) + .onChange(async value => { + this.config.useDateRange = value; + this.refreshDateInputs(); + await this.updateNotesList(); + }) + ); + + // Container for date inputs + const dateInputsContainer = contentEl.createDiv('date-inputs-container'); + + // Days back setting (shown when not using date range) + const daysBackSetting = new Setting(dateInputsContainer) .setName('Days to look back') .setDesc('Include notes modified within this many days') .addText(text => text .setPlaceholder('7') .setValue(String(this.config.daysBack)) - .onChange(value => { + .onChange(async value => { const days = parseInt(value); if (!isNaN(days) && days > 0) { this.config.daysBack = days; + await this.updateNotesList(); } }) ); + // Date range inputs (shown when using date range) + const fromDateSetting = new Setting(dateInputsContainer) + .setName('From date') + .setDesc('Start date for the range (YYYY-MM-DD)') + .addText(text => { + // Set default from date to 7 days ago + if (!this.config.fromDate) { + const defaultFrom = new Date(); + defaultFrom.setDate(defaultFrom.getDate() - 7); + this.config.fromDate = defaultFrom.toISOString().split('T')[0]; + } + + text + .setPlaceholder('YYYY-MM-DD') + .setValue(this.config.fromDate) + .onChange(async value => { + if (this.isValidDate(value)) { + this.config.fromDate = value; + await this.updateNotesList(); + } + }); + + // Add date input type for better UX + text.inputEl.type = 'date'; + }); + + const toDateSetting = new Setting(dateInputsContainer) + .setName('To date') + .setDesc('End date for the range (YYYY-MM-DD)') + .addText(text => { + // Set default to date to today + if (!this.config.toDate) { + this.config.toDate = new Date().toISOString().split('T')[0]; + } + + text + .setPlaceholder('YYYY-MM-DD') + .setValue(this.config.toDate) + .onChange(async value => { + if (this.isValidDate(value)) { + this.config.toDate = value; + await this.updateNotesList(); + } + }); + + // Add date input type for better UX + text.inputEl.type = 'date'; + }); + + // Store references for show/hide + this.daysBackSetting = daysBackSetting; + this.fromDateSetting = fromDateSetting; + this.toDateSetting = toDateSetting; + + // Initial visibility + this.refreshDateInputs(); + new Setting(contentEl) .setName('Filter journal sections by date') .setDesc('For notes with date headers, only include sections within the time window') @@ -80,15 +172,57 @@ export class RecentActivityModal extends Modal { .addText(text => text .setPlaceholder('Templates, Archive, OpenAugi') .setValue(this.config.excludeFolders.join(', ')) - .onChange(value => { + .onChange(async value => { this.config.excludeFolders = value .split(',') .map(f => f.trim()) .filter(f => f.length > 0); + await this.updateNotesList(); }) ); + // Preview button to show/refresh the notes list + const previewSetting = new Setting(contentEl) + .setName('Select notes to include') + .setDesc('Choose which notes to include in the distillation') + .addButton(button => { + this.previewButton = button.buttonEl; + button + .setButtonText('Select Notes') + .onClick(async () => { + if (this.notesListEl && this.notesListEl.style.display !== 'none') { + // Hide the list + this.notesListEl.style.display = 'none'; + button.setButtonText('Select Notes'); + } else { + // Show/update the list + await this.updateNotesList(); + if (this.notesListEl) { + this.notesListEl.style.display = 'block'; + } + button.setButtonText('Hide Selection'); + } + }); + }); + + // Container for the notes list (initially hidden) + this.notesListEl = contentEl.createDiv('recent-notes-list'); + this.notesListEl.style.display = 'none'; + this.notesListEl.style.maxHeight = '300px'; + this.notesListEl.style.overflowY = 'auto'; + this.notesListEl.style.border = '1px solid var(--background-modifier-border)'; + this.notesListEl.style.borderRadius = '4px'; + this.notesListEl.style.padding = '10px'; + this.notesListEl.style.marginBottom = '20px'; + + // Action buttons new Setting(contentEl) + .addButton(button => button + .setButtonText('Save as Collection') + .onClick(async () => { + await this.saveAsCollection(); + }) + ) .addButton(button => button .setButtonText('Cancel') .onClick(() => this.close()) @@ -97,14 +231,314 @@ export class RecentActivityModal extends Modal { .setButtonText('Distill') .setCta() .onClick(() => { + // Only include selected notes + this.config.selectedNotes = this.noteSelections + .filter(ns => ns.selected) + .map(ns => ns.file); this.onSubmit(this.config); this.close(); }) ); } + private async updateNotesList(): Promise { + if (!this.notesListEl) return; + + // Clear existing content + this.notesListEl.empty(); + + // Show loading + this.notesListEl.createEl('div', { + text: 'Loading recent notes...', + cls: 'loading-text' + }); + + try { + // Get recent notes using the distill service logic + const files = await this.getRecentlyModifiedNotes( + this.config.daysBack, + this.config.excludeFolders + ); + + // Clear loading text + this.notesListEl.empty(); + + if (files.length === 0) { + this.notesListEl.createEl('div', { + text: 'No notes found in the specified time range.', + cls: 'no-notes-text' + }); + return; + } + + // Create header with select all + const headerEl = this.notesListEl.createDiv('notes-list-header'); + headerEl.style.marginBottom = '10px'; + headerEl.style.paddingBottom = '10px'; + headerEl.style.borderBottom = '1px solid var(--background-modifier-border)'; + + // Create descriptive header text + let headerText: string; + if (this.config.useDateRange && this.config.fromDate && this.config.toDate) { + headerText = `Found ${files.length} notes (${this.config.fromDate} to ${this.config.toDate})`; + } else { + headerText = `Found ${files.length} notes (last ${this.config.daysBack} days)`; + } + + const selectAllSetting = new Setting(headerEl) + .setName(headerText) + .addToggle(toggle => toggle + .setValue(true) + .onChange(value => { + // Update all selections + this.noteSelections.forEach(ns => ns.selected = value); + // Update all checkboxes + this.notesListEl?.querySelectorAll('.note-checkbox input[type="checkbox"]') + .forEach((checkbox: HTMLInputElement) => { + checkbox.checked = value; + }); + }) + ); + + // Initialize note selections + this.noteSelections = await Promise.all(files.map(async file => { + const stats = await this.app.vault.adapter.stat(file.path); + return { + file, + selected: true, + modifiedTime: stats?.mtime || 0 + }; + })); + + // Sort by modification time (most recent first) + this.noteSelections.sort((a, b) => b.modifiedTime - a.modifiedTime); + + // Create individual note items + const listEl = this.notesListEl.createDiv('notes-items'); + this.noteSelections.forEach((noteSelection, index) => { + const itemEl = listEl.createDiv('note-item'); + itemEl.style.marginBottom = '8px'; + + const itemSetting = new Setting(itemEl) + .setClass('note-item-setting') + .setName(noteSelection.file.basename) + .setDesc(this.formatNoteInfo(noteSelection)) + .addToggle(toggle => { + toggle + .setValue(noteSelection.selected) + .onChange(value => { + noteSelection.selected = value; + // Update select all toggle if needed + const allSelected = this.noteSelections.every(ns => ns.selected); + const selectAllToggle = headerEl.querySelector('input[type="checkbox"]') as HTMLInputElement; + if (selectAllToggle) { + selectAllToggle.checked = allSelected; + } + }); + toggle.toggleEl.addClass('note-checkbox'); + }); + }); + + } catch (error) { + console.error('Error loading recent notes:', error); + this.notesListEl.empty(); + this.notesListEl.createEl('div', { + text: 'Error loading notes. Check console for details.', + cls: 'error-text' + }); + } + } + + private formatNoteInfo(noteSelection: NoteSelection): string { + const date = new Date(noteSelection.modifiedTime); + const dateStr = date.toLocaleDateString(); + const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const folder = noteSelection.file.parent?.path || 'Root'; + return `${folder} • Modified: ${dateStr} ${timeStr}`; + } + + private refreshDateInputs(): void { + if (!this.daysBackSetting || !this.fromDateSetting || !this.toDateSetting) return; + + if (this.config.useDateRange) { + // Hide days back, show date range + this.daysBackSetting.settingEl.style.display = 'none'; + this.fromDateSetting.settingEl.style.display = ''; + this.toDateSetting.settingEl.style.display = ''; + } else { + // Show days back, hide date range + this.daysBackSetting.settingEl.style.display = ''; + this.fromDateSetting.settingEl.style.display = 'none'; + this.toDateSetting.settingEl.style.display = 'none'; + } + } + + private isValidDate(dateStr: string): boolean { + const date = new Date(dateStr); + return !isNaN(date.getTime()) && /^\d{4}-\d{2}-\d{2}$/.test(dateStr); + } + + private async getRecentlyModifiedNotes(daysBack: number, excludeFolders: string[]): Promise { + let startTime: number; + let endTime: number; + + if (this.config.useDateRange && this.config.fromDate && this.config.toDate) { + // Use date range + const fromDate = new Date(this.config.fromDate); + fromDate.setHours(0, 0, 0, 0); + startTime = fromDate.getTime(); + + const toDate = new Date(this.config.toDate); + toDate.setHours(23, 59, 59, 999); + endTime = toDate.getTime(); + } else { + // Use days back + endTime = Date.now(); + startTime = endTime - (daysBack * 24 * 60 * 60 * 1000); + } + + const recentFiles: TFile[] = []; + const files = this.app.vault.getMarkdownFiles(); + + for (const file of files) { + // Check if file is in excluded folder + const isExcluded = excludeFolders.some(folder => + file.path.startsWith(folder + '/') || file.path.includes('/' + folder + '/') + ); + + if (isExcluded) { + continue; + } + + let includeFile = false; + + // Check if filename starts with a date (YYYY-MM-DD format) + const dateMatch = file.basename.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (dateMatch) { + const year = parseInt(dateMatch[1]); + const month = parseInt(dateMatch[2]); + const day = parseInt(dateMatch[3]); + + if (month >= 1 && month <= 12 && day >= 1 && day <= 31) { + const fileDate = new Date(year, month - 1, day); + const fileTime = fileDate.getTime(); + + if (!isNaN(fileTime) && fileTime >= startTime && fileTime <= endTime) { + includeFile = true; + } + } + } + + // If not included by filename date, check modification time + if (!includeFile) { + const stats = await this.app.vault.adapter.stat(file.path); + if (stats && stats.mtime >= startTime && stats.mtime <= endTime) { + includeFile = true; + } + } + + if (includeFile) { + recentFiles.push(file); + } + } + + return recentFiles; + } + onClose() { const { contentEl } = this; contentEl.empty(); } + + /** + * Save the current selection as a collection note + */ + private async saveAsCollection(): Promise { + if (this.noteSelections.length === 0) { + new Notice('No notes to save. Please show and select notes first.'); + return; + } + + // Generate collection content + const now = new Date(); + const dateStr = now.toLocaleDateString(); + const timeStr = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + + let timeWindowDesc: string; + if (this.config.useDateRange && this.config.fromDate && this.config.toDate) { + timeWindowDesc = `Date Range: ${this.config.fromDate} to ${this.config.toDate}`; + } else { + timeWindowDesc = `Last ${this.config.daysBack} days`; + } + + let content = `# Recent Activity Collection +Created: ${dateStr} ${timeStr} +Time Window: ${timeWindowDesc} +${this.config.rootNote ? `Context Root: [[${this.config.rootNote.basename}]]` : ''} + +## Notes + +`; + + // Add notes with checkboxes + const selectedCount = this.noteSelections.filter(ns => ns.selected).length; + content += `Selected ${selectedCount} of ${this.noteSelections.length} notes:\n\n`; + + for (const noteSelection of this.noteSelections) { + const checkbox = noteSelection.selected ? '[x]' : '[ ]'; + const date = new Date(noteSelection.modifiedTime); + const modifiedStr = date.toLocaleDateString(); + content += `- ${checkbox} [[${noteSelection.file.basename}]] - Modified: ${modifiedStr}\n`; + } + + // Add instructions + content += `\n## Instructions + +This collection was generated from recent activity. You can: +1. Check/uncheck notes to adjust the selection +2. Run "Distill Linked Notes" on this note to process the checked items +3. Add additional notes manually using standard Obsidian links + +## Configuration + +The following settings were used: +- Time window: ${timeWindowDesc} +- Filter journal sections: ${this.config.filterJournalSections} +- Excluded folders: ${this.config.excludeFolders.join(', ')} +`; + + // Create the collection file + try { + const collectionsFolder = 'OpenAugi/Collections'; + + // Ensure collections folder exists + if (!await this.app.vault.adapter.exists(collectionsFolder)) { + await this.app.vault.createFolder(collectionsFolder); + } + + const timestamp = now.toISOString() + .replace(/T/, ' ') + .replace(/\..+/, '') + .replace(/:/g, '-'); + const filename = `Recent Activity ${timestamp}.md`; + const filepath = `${collectionsFolder}/${filename}`; + + const createdPath = await createFileWithCollisionHandling( + this.app.vault, + filepath, + content + ); + + new Notice(`Collection saved to ${createdPath}`); + + // Open the created file + const file = this.app.vault.getAbstractFileByPath(createdPath); + if (file instanceof TFile) { + await this.app.workspace.getLeaf().openFile(file); + } + } catch (error) { + console.error('Failed to save collection:', error); + new Notice('Failed to save collection. Check console for details.'); + } + } } \ No newline at end of file diff --git a/src/ui/settings-tab.ts b/src/ui/settings-tab.ts index c52f227..57541ab 100644 --- a/src/ui/settings-tab.ts +++ b/src/ui/settings-tab.ts @@ -63,6 +63,18 @@ export class OpenAugiSettingTab extends PluginSettingTab { }) ); + new Setting(containerEl) + .setName('Prompts folder') + .setDesc('Folder path where custom prompt templates are stored') + .addText(text => text + .setPlaceholder('OpenAugi/Prompts') + .setValue(this.plugin.settings.promptsFolder) + .onChange(async (value) => { + this.plugin.settings.promptsFolder = value; + await this.plugin.saveSettings(); + }) + ); + // Check if Dataview plugin is installed // @ts-ignore - Dataview API is not typed const dataviewPluginInstalled = this.app.plugins.plugins["dataview"] !== undefined; @@ -112,12 +124,12 @@ export class OpenAugiSettingTab extends PluginSettingTab { new Setting(containerEl) .setName('Date header format') - .setDesc('Markdown format for date headers in journal notes. Use YYYY for year, MM for month, DD for day.') + .setDesc('Markdown format for date headers in journal notes. Use YYYY for year, MM for month, DD for day. Leave empty to disable journal parsing.') .addText(text => text - .setPlaceholder('### YYYY-MM-DD') .setValue(this.plugin.settings.recentActivityDefaults.dateHeaderFormat) .onChange(async (value) => { - if (value.includes('YYYY') && value.includes('MM') && value.includes('DD')) { + // Allow empty value to disable journal parsing + if (value === '' || (value.includes('YYYY') && value.includes('MM') && value.includes('DD'))) { this.plugin.settings.recentActivityDefaults.dateHeaderFormat = value; await this.plugin.saveSettings(); } diff --git a/styles.css b/styles.css index f2a1c27..e33d9c7 100644 --- a/styles.css +++ b/styles.css @@ -47,4 +47,45 @@ .openaugi-dot.active { opacity: 1; +} + +/* Prompt Selection Modal Styles */ +.openaugi-prompt-description { + color: var(--text-muted); + margin-bottom: 1em; +} + +.openaugi-prompt-warning { + color: var(--text-warning); + background-color: var(--background-modifier-warning); + padding: 0.5em; + border-radius: 4px; + margin: 1em 0; +} + +.openaugi-prompt-preview { + margin-top: 1em; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + padding: 1em; + background-color: var(--background-secondary); +} + +.openaugi-prompt-preview h4 { + margin-top: 0; + color: var(--text-muted); + font-size: 0.9em; +} + +.openaugi-prompt-preview-content { + font-size: 0.85em; + line-height: 1.4; + color: var(--text-normal); + white-space: pre-wrap; + overflow-wrap: break-word; +} + +.openaugi-prompt-error { + color: var(--text-error); + font-style: italic; } \ No newline at end of file