custom lens, date range filtering, note header date parsing

This commit is contained in:
Chris Lettieri 2025-07-27 11:20:50 -04:00
parent 6085173752
commit ec004dd25f
9 changed files with 951 additions and 52 deletions

View file

@ -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<void> {
// 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<void> {
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<void> {
private async executeRecentActivityDistill(config: RecentActivityConfig, promptConfig: PromptSelectionConfig): Promise<void> {
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);

View file

@ -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<TFile[]> {
const cutoffTime = Date.now() - (daysBack * 24 * 60 * 60 * 1000);
async getRecentlyModifiedNotes(
daysBack: number,
excludeFolders: string[],
fromDate?: string,
toDate?: string
): Promise<TFile[]> {
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<TFile[]> {
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<DistillResponse> {
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];

View file

@ -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
);
}

View file

@ -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<DistillResponse> {
async distillContent(content: string, customPrompt?: string): Promise<DistillResponse> {
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', {

View file

@ -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: {

View file

@ -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();
}
}

View file

@ -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<void> {
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<TFile[]> {
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<void> {
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.');
}
}
}

View file

@ -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();
}

View file

@ -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;
}