mirror of
https://github.com/bitsofchris/openaugi-obsidian-plugin.git
synced 2026-07-22 12:40:27 +00:00
Merge pull request #3 from bitsofchris/custom-prompts
distill recent notes, file name collision, interactive context selection
This commit is contained in:
commit
3857b1ab07
14 changed files with 4426 additions and 58 deletions
144
CLAUDE.md
Normal file
144
CLAUDE.md
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
# OpenAugi Obsidian Plugin - Technical Overview
|
||||
|
||||
## Project Purpose
|
||||
OpenAugi is an Obsidian plugin that transforms voice notes and linked notes into organized, atomic notes using AI (GPT-4). It helps users process unstructured thoughts into a structured "second brain" by breaking down content into self-contained ideas.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Core Technologies
|
||||
- **Language**: TypeScript
|
||||
- **Build Tool**: esbuild
|
||||
- **Target Platform**: Obsidian (Electron-based)
|
||||
- **AI Service**: OpenAI GPT-4.1-2025-04-14
|
||||
- **Node Version**: 18+
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
/
|
||||
├── src/
|
||||
│ ├── main.ts # Plugin entry point, command registration
|
||||
│ ├── services/
|
||||
│ │ ├── openai.service.ts # AI processing logic
|
||||
│ │ ├── file.service.ts # File operations, output management
|
||||
│ │ └── distill.service.ts # Linked note extraction, content aggregation
|
||||
│ ├── ui/
|
||||
│ │ └── settings.ts # Settings tab UI component
|
||||
│ └── utils/
|
||||
│ └── filename.utils.ts # Filename sanitization, backlink mapping
|
||||
├── manifest.json # Obsidian plugin metadata
|
||||
├── package.json # Dependencies and scripts
|
||||
├── tsconfig.json # TypeScript configuration
|
||||
└── esbuild.config.mjs # Build configuration
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. Voice Transcript Parsing
|
||||
- Processes voice transcripts into atomic notes (one idea per note)
|
||||
- Extracts actionable tasks and creates summaries
|
||||
- Supports "auggie" voice commands for special behaviors
|
||||
- Estimates token usage before processing
|
||||
|
||||
### 2. Linked Notes Distillation
|
||||
- Analyzes a root note and all its linked notes
|
||||
- Supports both standard Obsidian links and Dataview queries
|
||||
- Deduplicates and merges overlapping ideas
|
||||
- Creates comprehensive summaries with source attribution
|
||||
|
||||
### 3. Custom Context Instructions
|
||||
- Users can add `context:` sections to notes for focused extraction
|
||||
- Context instructions guide AI processing behavior
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Build Commands
|
||||
```bash
|
||||
# Development build with hot reload
|
||||
npm run dev
|
||||
|
||||
# Production build
|
||||
npm run build
|
||||
|
||||
# Type checking
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
### Code Standards
|
||||
- TypeScript with strict mode enabled
|
||||
- ESLint configuration for code quality
|
||||
- No external runtime dependencies (only Obsidian API)
|
||||
|
||||
### Testing
|
||||
Currently no automated tests. Manual testing through Obsidian's developer console.
|
||||
|
||||
## API Integration
|
||||
|
||||
### OpenAI Service
|
||||
- Model: GPT-4.1-2025-04-14
|
||||
- Temperature: 0.7 for parsing, 0.3 for distilling
|
||||
- Structured output using JSON schema
|
||||
- Token estimation before API calls
|
||||
|
||||
### File Operations
|
||||
- Creates atomic notes in configurable folders
|
||||
- Generates summaries with backlinks
|
||||
- Handles special characters in filenames
|
||||
- Maintains backlink mappings for navigation
|
||||
|
||||
## Configuration
|
||||
|
||||
### User Settings
|
||||
- `openaiApiKey`: Required for AI processing
|
||||
- `summaryFolderPath`: Default "OpenAugi/Summaries"
|
||||
- `notesFolderPath`: Default "OpenAugi/Notes"
|
||||
- `useDataview`: Enable/disable Dataview integration
|
||||
|
||||
### Build Configuration
|
||||
- Target: ES2018/ES6
|
||||
- Platform: Browser (Electron)
|
||||
- External: Obsidian modules
|
||||
- Sourcemaps enabled for development
|
||||
|
||||
## Output Structure
|
||||
|
||||
### Summary Files
|
||||
- Format: `[original-name] - summary.md` or `[original-name] - distilled.md`
|
||||
- Contains: Summary, atomic note links, extracted tasks
|
||||
- For distilled notes: Shows source note references
|
||||
|
||||
### Atomic Notes
|
||||
- Self-contained ideas with context
|
||||
- Includes relevant backlinks
|
||||
- Organized by timestamp or topic
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Adding New Features
|
||||
1. Extend services in `/src/services/`
|
||||
2. Update command registration in `main.ts`
|
||||
3. Add settings if needed in `settings.ts`
|
||||
|
||||
### Debugging
|
||||
- Use Obsidian's developer console (Ctrl+Shift+I)
|
||||
- Check console for error messages
|
||||
- Enable verbose logging in development
|
||||
|
||||
### Publishing
|
||||
1. Update version in `manifest.json` and `package.json`
|
||||
2. Build production bundle: `npm run build`
|
||||
3. Create release with `main.js`, `manifest.json`, and `styles.css`
|
||||
|
||||
## Important Considerations
|
||||
|
||||
- Always handle API errors gracefully
|
||||
- Respect rate limits and token usage
|
||||
- Sanitize filenames to prevent filesystem issues
|
||||
- Maintain backwards compatibility with existing notes
|
||||
- Test with various note structures and edge cases
|
||||
|
||||
## Future Enhancements
|
||||
- Support for additional AI models
|
||||
- Batch processing optimization
|
||||
- Enhanced Dataview query support
|
||||
- Customizable output templates
|
||||
- Integration with other Obsidian plugins
|
||||
289
README.md
289
README.md
|
|
@ -61,14 +61,166 @@ This command analyzes a set of linked notes and synthesizes them into a coherent
|
|||
2. Open this root note
|
||||
3. Hit `CMD+P` (or `CTRL+P` on Windows/Linux) to open the command palette
|
||||
4. Run `OpenAugi: Distill Linked Notes`
|
||||
5. **NEW**: Select a custom prompt "lens" or use the default prompt
|
||||
|
||||
The plugin will:
|
||||
- Show a prompt selection modal where you can choose a processing lens
|
||||
- Gather all linked notes
|
||||
- Analyze their content together
|
||||
- Analyze their content together using your selected lens
|
||||
- Create new atomic notes that synthesize and organize the information
|
||||
- Generate a summary that connects the key concepts
|
||||
- Extract any actionable tasks found across the notes
|
||||
|
||||
### 3. Distill Recent Activity
|
||||
|
||||
This command automatically discovers and distills notes that have been recently modified or have date prefixes in their filenames, perfect for daily/weekly reviews.
|
||||
|
||||
**Usage:**
|
||||
1. Hit `CMD+P` (or `CTRL+P` on Windows/Linux) to open the command palette
|
||||
2. Run `OpenAugi: Distill Recent Activity`
|
||||
3. Configure your preferences in the modal:
|
||||
- **Time Window Selection**:
|
||||
- **Default Mode**: "Days to look back" - specify how many days of activity to include (default: 7)
|
||||
- **Date Range Mode**: Toggle "Use specific date range" to select exact start and end dates
|
||||
- **Filter journal sections**: For journal-style notes with date headers, only include recent sections
|
||||
- **Root note**: Optionally specify a note to provide additional context
|
||||
- **Exclude folders**: Skip folders like Templates or Archive
|
||||
4. Click "Select Notes" to preview and choose which notes to include:
|
||||
- View all discovered notes with their modification dates
|
||||
- Check/uncheck individual notes to customize your selection
|
||||
- Use "Select All" toggle for bulk selection
|
||||
5. Optional: Click "Save as Collection" to create a persistent checkbox list for future use
|
||||
6. Click "Distill" to process
|
||||
7. Select a custom prompt "lens" or use the default prompt
|
||||
|
||||
**The plugin will:**
|
||||
- Show discovered notes in an interactive checkbox list
|
||||
- Process only the notes you've selected
|
||||
- For journal-style notes with date headers (e.g., `### 2024-01-20`), extract only recent sections
|
||||
- Synthesize the activity into organized atomic notes using your selected lens
|
||||
- Create atomic notes in a timestamped session folder (e.g., `OpenAugi/Notes/Recent Activity 2024-01-20 14-30-52/`)
|
||||
- Generate a summary with a descriptive name based on content
|
||||
|
||||
**Date Range Selection:**
|
||||
Toggle between two modes for selecting your time window:
|
||||
- **"Last N days"**: Quick selection for recent activity (1 day, 7 days, etc.)
|
||||
- **Specific date range**: Choose exact start and end dates using date pickers
|
||||
- Perfect for reviewing specific periods like "last week" or "this month"
|
||||
- Includes all notes modified or dated within the range
|
||||
|
||||
**Note Selection Interface:**
|
||||
The new selection interface lets you:
|
||||
- Preview all notes that match your criteria before processing
|
||||
- See each note's folder location and modification time
|
||||
- Exclude specific notes by unchecking them
|
||||
- Save your selection as a collection for future reference
|
||||
|
||||
**Save as Collection:**
|
||||
Create a persistent record of your note selection:
|
||||
- Saves to `OpenAugi/Collections/` folder
|
||||
- Preserves checked/unchecked states
|
||||
- Can be used later with "Distill Linked Notes" command
|
||||
- Perfect for creating curated sets of notes for repeated analysis
|
||||
|
||||
**Date-Based Note Discovery:**
|
||||
Notes with filenames beginning with `YYYY-MM-DD` format are automatically included if their date falls within your specified time window, regardless of when they were last modified. This is perfect for:
|
||||
- Meeting notes dated by when they occurred
|
||||
- Daily logs or journal entries
|
||||
- Event-based documentation
|
||||
- Any notes you want to organize by their content date rather than modification time
|
||||
|
||||
## Custom Prompt Lenses
|
||||
|
||||
**NEW**: OpenAugi now supports custom prompt templates that act as "lenses" to focus processing on specific aspects of your notes.
|
||||
|
||||
### How Custom Prompts Work
|
||||
|
||||
Custom prompts allow you to guide OpenAugi's AI processing with specific perspectives or focus areas. When you run distillation commands, you can select from your custom prompts to process notes through different "lenses" - extracting different types of insights from the same content.
|
||||
|
||||
### Using Custom Prompts
|
||||
|
||||
1. When you run "Distill Linked Notes" or "Distill Recent Activity", you'll see a prompt selection modal
|
||||
2. Choose from any prompt in your prompts folder (default: `OpenAugi/Prompts`)
|
||||
3. The selected prompt replaces the default processing instructions while keeping the structured output format
|
||||
4. Or select "Use default prompt" for general-purpose processing
|
||||
|
||||
### Understanding the Prompt System
|
||||
|
||||
#### Default Prompt
|
||||
When no custom prompt is selected, OpenAugi uses its built-in default prompt that:
|
||||
- Acts as an "expert knowledge curator"
|
||||
- Creates atomic notes focusing on distinct concepts and ideas
|
||||
- Deduplicates and merges overlapping information
|
||||
- Extracts genuinely actionable tasks
|
||||
- Generates comprehensive summaries highlighting connections
|
||||
|
||||
#### How Custom Prompts Replace the Default
|
||||
When you select a custom prompt:
|
||||
1. **Your custom prompt replaces** the default instruction section
|
||||
2. **The system always adds**:
|
||||
- Any custom context from your notes (if present)
|
||||
- JSON output structure requirements
|
||||
- The actual note content to process
|
||||
|
||||
This means your custom prompt changes HOW the content is analyzed, but not the OUTPUT format.
|
||||
|
||||
### Creating Your Own Prompts
|
||||
|
||||
To create a custom prompt:
|
||||
|
||||
1. Create a new markdown file in your prompts folder
|
||||
2. Write your instructions following this structure:
|
||||
- Start with a brief description of the lens perspective
|
||||
- Include sections for:
|
||||
- Creating atomic notes (with your specific focus)
|
||||
- Extracting tasks (if relevant to your lens)
|
||||
- Creating a summary (with your desired emphasis)
|
||||
3. Save the file with a descriptive name (e.g., "Technical Documentation.md")
|
||||
|
||||
#### Template Structure
|
||||
```markdown
|
||||
You are an expert [your role] helping users [your purpose].
|
||||
|
||||
# Instructions
|
||||
[Your specific focus and approach]
|
||||
|
||||
### 1. Create Atomic Notes
|
||||
- [Your specific criteria for what makes a good atomic note]
|
||||
- [How to identify relevant concepts for your lens]
|
||||
- [Any special formatting or emphasis]
|
||||
|
||||
### 2. Extract Tasks
|
||||
- [What counts as a task in your context]
|
||||
- [How to format and prioritize tasks]
|
||||
|
||||
### 3. Create a Summary
|
||||
- [What to emphasize in the summary]
|
||||
- [How to structure the summary for your use case]
|
||||
```
|
||||
|
||||
### Example Prompts Included
|
||||
|
||||
- **Research Focus**: Extracts academic insights, methodologies, and research questions
|
||||
- **Project Management**: Focuses on deliverables, timelines, and actionable tasks
|
||||
- **Personal Reflection**: Captures personal insights, emotions, and growth moments
|
||||
|
||||
### Tips for Writing Effective Prompts
|
||||
|
||||
- Be specific about what types of information to extract
|
||||
- Maintain the structure of atomic notes, tasks, and summary
|
||||
- Use clear, directive language
|
||||
- Keep the focus narrow for best results
|
||||
- Remember that Obsidian backlinks should still be used to connect ideas
|
||||
- Test your prompt with various content types to ensure consistency
|
||||
|
||||
### Important Notes
|
||||
|
||||
- The JSON output structure is always preserved regardless of the prompt
|
||||
- Custom prompts only affect the instructional content, not the response format
|
||||
- Test your prompts with different types of notes to ensure they work as expected
|
||||
- Custom prompts work in addition to (not instead of) any custom context in your notes
|
||||
- If no prompt files exist in your prompts folder, only the default option will be available
|
||||
|
||||
## Custom Context
|
||||
|
||||
You can guide how OpenAugi processes your content by adding a custom context section to your notes:
|
||||
|
|
@ -92,23 +244,138 @@ Add this section to your root note before running the distill command.
|
|||
|
||||
The custom context allows you to narrow the focus of processing to extract specific types of information relevant to your current needs.
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Daily/Weekly Reviews
|
||||
Use "Distill Recent Activity" to automatically summarize your work:
|
||||
- Set to 1 day for daily reviews
|
||||
- Set to 7 days for weekly reviews
|
||||
- Automatically captures all your recent thoughts and work
|
||||
- Perfect for identifying patterns and progress
|
||||
|
||||
### Project Summaries
|
||||
Use "Distill Linked Notes" with a project hub note:
|
||||
- Create a note that links to all project-related notes
|
||||
- Run distillation to get a comprehensive project overview
|
||||
- Extract all tasks and decisions across the project
|
||||
|
||||
### Research Synthesis
|
||||
Combine both commands for research workflows:
|
||||
- Use "Distill Recent Activity" to review recent research notes
|
||||
- Use "Distill Linked Notes" on topic-specific collections
|
||||
- Add custom context to focus on findings, methodologies, or insights
|
||||
|
||||
### Journal Processing
|
||||
Take advantage of journal-style note support:
|
||||
- Keep daily journal entries with date headers
|
||||
- Use "Distill Recent Activity" to extract recent insights
|
||||
- Only relevant date sections are processed, keeping context focused
|
||||
|
||||
## Requirements
|
||||
Note: this requires an OpenAI API key to work.
|
||||
|
||||
Your content is sent directly to OpenAI for processing using the best model for this task. The cost to use this plugin depends on the API credits consumed. For me ~5 minutes of voice note is about 2-3 cents of processing.
|
||||
|
||||
## Configuration Settings
|
||||
|
||||
OpenAugi provides several configuration options in Settings → OpenAugi:
|
||||
|
||||
### Basic Settings
|
||||
- **OpenAI API Key**: Your API key for processing (required)
|
||||
- **Summaries Folder**: Where summary files are saved (default: `OpenAugi/Summaries`)
|
||||
- **Notes Folder**: Where atomic notes are saved (default: `OpenAugi/Notes`)
|
||||
- **Prompts Folder**: Where custom prompt templates are stored (default: `OpenAugi/Prompts`)
|
||||
- **Use Dataview**: Enable processing of dataview queries in distillation
|
||||
|
||||
### Recent Activity Settings
|
||||
Configure defaults for the "Distill Recent Activity" command:
|
||||
|
||||
- **Default Days to Look Back**: How many days of activity to include by default (default: 7)
|
||||
- **Filter Journal Sections**: When enabled, only includes recent sections from journal-style notes
|
||||
- **Date Header Format**: Customize the format for date headers in journal notes (default: `### YYYY-MM-DD`)
|
||||
- Examples: `## DD/MM/YYYY`, `#### YYYY.MM.DD`, `### [YYYY-MM-DD]`
|
||||
- Must include YYYY (year), MM (month), and DD (day) placeholders
|
||||
- **Exclude Folders**: Comma-separated list of folders to skip (default: `Templates, Archive, OpenAugi`)
|
||||
|
||||
### Advanced Settings
|
||||
- **Enable Distill Logging**: Logs the full context sent to AI for debugging (saved to `OpenAugi/Logs`)
|
||||
|
||||
## Journal-Style Notes Support
|
||||
|
||||
OpenAugi has special support for journal-style notes that use date headers. When processing recent activity:
|
||||
|
||||
1. **Automatic Detection**: Notes with date headers matching your configured format are automatically detected
|
||||
2. **Smart Filtering**: Only sections with dates within your specified time window are included
|
||||
3. **Flexible Formats**: Configure your preferred date header format in settings
|
||||
|
||||
Example journal note:
|
||||
```markdown
|
||||
# My Journal
|
||||
|
||||
### 2024-01-20
|
||||
Today's thoughts and activities...
|
||||
|
||||
### 2024-01-19
|
||||
Yesterday's reflections...
|
||||
|
||||
### 2024-01-10
|
||||
Older content that may be filtered out...
|
||||
```
|
||||
|
||||
When using "Distill Recent Activity" with a 7-day window, only the recent sections would be processed.
|
||||
|
||||
## Output Structure
|
||||
|
||||
OpenAugi creates two types of files:
|
||||
1. **Summary files** - Placed in your designated summary folder, containing:
|
||||
- A concise summary of the content
|
||||
- Links to all atomic notes
|
||||
- Tasks extracted from the content
|
||||
|
||||
2. **Atomic notes** - Placed in your designated notes folder, each containing:
|
||||
- A single, self-contained idea
|
||||
- Context and supporting details
|
||||
- Backlinks to related notes where relevant
|
||||
OpenAugi creates organized outputs for better note management:
|
||||
|
||||
### Summary Files
|
||||
Placed in your designated summary folder (`OpenAugi/Summaries` by default):
|
||||
- **Transcript summaries**: `[original-name] - summary.md`
|
||||
- **Distilled notes**: `[root-note-name] - distilled.md`
|
||||
- **Recent activity**: `Recent Activity YYYY-MM-DD - [first-note-title].md`
|
||||
|
||||
Each summary contains:
|
||||
- A concise overview of the processed content
|
||||
- Links to all generated atomic notes
|
||||
- Extracted tasks with context
|
||||
- Source note references (for distillations)
|
||||
|
||||
### Atomic Notes
|
||||
Organized in session-specific folders within your notes folder (`OpenAugi/Notes` by default):
|
||||
- **Transcript sessions**: `Transcript YYYY-MM-DD HH-mm-ss/`
|
||||
- **Distill sessions**: `Distill [RootNoteName] YYYY-MM-DD HH-mm-ss/`
|
||||
- **Recent activity sessions**: `Recent Activity YYYY-MM-DD HH-mm-ss/`
|
||||
|
||||
Each atomic note contains:
|
||||
- A single, self-contained idea
|
||||
- Relevant context and supporting details
|
||||
- Backlinks to related concepts
|
||||
- Clear, descriptive titles
|
||||
|
||||
### Collection Notes
|
||||
Saved note selections for future processing (`OpenAugi/Collections` by default):
|
||||
- **Format**: `Recent Activity YYYY-MM-DD HH-mm-ss.md`
|
||||
- Contains checkbox lists of selected notes
|
||||
- Preserves selection criteria and configuration
|
||||
- Can be processed later using "Distill Linked Notes"
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Checkbox-Based Collections
|
||||
OpenAugi recognizes and processes checkbox-style note collections:
|
||||
```markdown
|
||||
- [x] [[Note to include]]
|
||||
- [ ] [[Note to exclude]]
|
||||
- [x] [[Another included note]]
|
||||
```
|
||||
When running "Distill Linked Notes" on such a collection, only checked items are processed.
|
||||
|
||||
### Session Folders
|
||||
All atomic notes from a single processing session are kept together in timestamped folders, making it easy to:
|
||||
- Review all outputs from a specific session
|
||||
- Move or archive related notes together
|
||||
- Track the evolution of your ideas over time
|
||||
- Avoid mixing notes from different contexts
|
||||
|
||||
# Get involved, let's build augmented intelligence
|
||||
|
||||
|
|
|
|||
2406
package-lock.json
generated
Normal file
2406
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -16,11 +16,9 @@
|
|||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||
"@typescript-eslint/parser": "5.29.0",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.17.3",
|
||||
"esbuild": "^0.17.3",
|
||||
"obsidian": "^1.8.7",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"dependencies": {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
237
src/main.ts
237
src/main.ts
|
|
@ -5,7 +5,9 @@ import { FileService } from './services/file-service';
|
|||
import { DistillService } from './services/distill-service';
|
||||
import { OpenAugiSettingTab } from './ui/settings-tab';
|
||||
import { LoadingIndicator } from './ui/loading-indicator';
|
||||
import { sanitizeFilename } from './utils/filename-utils';
|
||||
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
|
||||
|
|
@ -67,6 +69,15 @@ export default class OpenAugiPlugin extends Plugin {
|
|||
}
|
||||
});
|
||||
|
||||
// Add command to distill recent activity
|
||||
this.addCommand({
|
||||
id: 'distill-recent-activity',
|
||||
name: 'Distill recent activity',
|
||||
callback: async () => {
|
||||
await this.distillRecentActivity();
|
||||
}
|
||||
});
|
||||
|
||||
// Add settings tab
|
||||
this.addSettingTab(new OpenAugiSettingTab(this.app, this));
|
||||
}
|
||||
|
|
@ -158,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');
|
||||
|
|
@ -177,9 +205,6 @@ export default class OpenAugiPlugin extends Plugin {
|
|||
this.settings
|
||||
);
|
||||
|
||||
// Get root content for initial notice
|
||||
const rootContent = await this.app.vault.read(rootFile);
|
||||
|
||||
// Get linked files
|
||||
let linkedFiles = await this.distillService.getLinkedNotes(rootFile);
|
||||
|
||||
|
|
@ -200,21 +225,27 @@ export default class OpenAugiPlugin extends Plugin {
|
|||
return;
|
||||
}
|
||||
|
||||
// Aggregate linked content
|
||||
const { content: linkedContent, sourceNotes } = await this.distillService.aggregateContent(linkedFiles);
|
||||
// Show notice about linked files
|
||||
new Notice(`Found ${linkedFiles.length} linked notes to process.`);
|
||||
|
||||
// Combine content
|
||||
const combinedContent = `# Root Note: ${rootFile.basename}\n\n${rootContent}\n\n${linkedContent}`;
|
||||
const combinedTokens = estimateTokens(combinedContent);
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
|
||||
// Show combined content notice
|
||||
new Notice(`Combined content from ${linkedFiles.length} linked notes\nTotal characters: ${combinedContent.length}\nEst. total tokens: ${combinedTokens}`);
|
||||
|
||||
// Distill content from linked notes
|
||||
// Let the distill service handle all the content aggregation (no time filtering)
|
||||
const distilledData = await this.distillService.distillFromRootNote(
|
||||
rootFile,
|
||||
combinedContent,
|
||||
sourceNotes
|
||||
rootFile,
|
||||
undefined,
|
||||
undefined,
|
||||
0, // No time filtering for regular distill command
|
||||
customPrompt
|
||||
);
|
||||
|
||||
// Write result to files
|
||||
|
|
@ -246,4 +277,178 @@ export default class OpenAugiPlugin extends Plugin {
|
|||
// Reinitialize services with new settings
|
||||
this.initializeServices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Distill recent activity based on user configuration
|
||||
*/
|
||||
private async distillRecentActivity(): Promise<void> {
|
||||
// Show configuration modal
|
||||
const modal = new RecentActivityModal(
|
||||
this.app,
|
||||
this.settings.recentActivityDefaults,
|
||||
async (config: RecentActivityConfig) => {
|
||||
// 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, promptConfig: PromptSelectionConfig): Promise<void> {
|
||||
try {
|
||||
// Check API key
|
||||
if (!this.settings.apiKey) {
|
||||
new Notice('Please set your OpenAI API key in the settings');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading indicator
|
||||
this.loadingIndicator?.show('Discovering recent activity...');
|
||||
|
||||
// Update services with latest API key
|
||||
this.openAIService = new OpenAIService(this.settings.apiKey);
|
||||
this.distillService = new DistillService(
|
||||
this.app,
|
||||
this.openAIService,
|
||||
this.settings
|
||||
);
|
||||
|
||||
// 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 selected for processing`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare files list including root note if provided
|
||||
let allFiles = [...recentFiles];
|
||||
if (config.rootNote && !recentFiles.some(f => f.path === config.rootNote!.path)) {
|
||||
allFiles = [config.rootNote, ...recentFiles];
|
||||
}
|
||||
|
||||
// Show notice about discovered files
|
||||
const message = config.rootNote
|
||||
? `Processing ${recentFiles.length} selected notes plus root note: ${config.rootNote.basename}`
|
||||
: `Processing ${recentFiles.length} selected notes`;
|
||||
new Notice(message);
|
||||
|
||||
// Update loading message
|
||||
this.loadingIndicator?.show('Processing recent activity...');
|
||||
|
||||
// Use appropriate time window for filtering
|
||||
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 ${timeWindowDesc}.
|
||||
${config.rootNote ? `\nUsing [[${config.rootNote.basename}]] as context root.` : ''}
|
||||
|
||||
## Recently Modified Notes:
|
||||
${allFiles.map(f => `- [[${f.basename}]]`).join('\n')}`;
|
||||
|
||||
// Ensure OpenAugi folder exists
|
||||
if (!await this.app.vault.adapter.exists('OpenAugi')) {
|
||||
await this.app.vault.createFolder('OpenAugi');
|
||||
}
|
||||
|
||||
// Create a temporary root file
|
||||
const tempRootPath = `OpenAugi/temp-recent-activity-${Date.now()}.md`;
|
||||
await createFileWithCollisionHandling(this.app.vault, tempRootPath, syntheticRootContent);
|
||||
const tempRootFile = this.app.vault.getAbstractFileByPath(tempRootPath) as TFile;
|
||||
|
||||
// Aggregate content with time filtering
|
||||
const { content: aggregatedContent, sourceNotes } = await this.distillService.aggregateContent(
|
||||
allFiles,
|
||||
timeWindow
|
||||
);
|
||||
|
||||
// Combine with synthetic root content
|
||||
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,
|
||||
undefined, // No time window needed here since we already filtered
|
||||
customPrompt
|
||||
);
|
||||
|
||||
// Update the distilled data to reflect recent activity
|
||||
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);
|
||||
|
||||
// Clean up temporary file
|
||||
await this.app.vault.delete(tempRootFile);
|
||||
|
||||
// Hide loading indicator
|
||||
this.loadingIndicator?.hide();
|
||||
|
||||
// Show success message
|
||||
new Notice(`Successfully distilled recent activity\nCreated ${distilledData.notes.length} atomic notes`);
|
||||
|
||||
// Open the summary file in a new tab
|
||||
await this.openFileInNewTab(summaryPath);
|
||||
} catch (error) {
|
||||
// Hide loading indicator
|
||||
this.loadingIndicator?.hide();
|
||||
|
||||
console.error('Failed to distill recent activity:', error);
|
||||
new Notice('Failed to distill recent activity. Check console for details.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { App, TFile, MetadataCache, Component } from 'obsidian';
|
||||
import { createFileWithCollisionHandling } from '../utils/filename-utils';
|
||||
import { OpenAIService } from './openai-service';
|
||||
import { DistillResponse } from '../types/transcript';
|
||||
import { OpenAugiSettings } from '../types/settings';
|
||||
|
|
@ -27,6 +28,142 @@ export class DistillService {
|
|||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the distill input context to a file for debugging
|
||||
* @param content The full content being sent to AI
|
||||
* @param rootFileName The name of the root file being processed
|
||||
*/
|
||||
private async logDistillContext(content: string, rootFileName: string): Promise<void> {
|
||||
// Only log if enabled in settings
|
||||
if (!this.settings.enableDistillLogging) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create log folder if it doesn't exist
|
||||
const logFolderPath = 'OpenAugi/Logs';
|
||||
if (!await this.app.vault.adapter.exists(logFolderPath)) {
|
||||
await this.app.vault.createFolder(logFolderPath);
|
||||
}
|
||||
|
||||
// Create timestamp for log file
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const logFileName = `distill-log-${rootFileName}-${timestamp}.md`;
|
||||
const logFilePath = `${logFolderPath}/${logFileName}`;
|
||||
|
||||
// Format log content
|
||||
const logContent = `# Distill Context Log
|
||||
**Root File**: ${rootFileName}
|
||||
**Timestamp**: ${new Date().toISOString()}
|
||||
**Total Characters**: ${content.length}
|
||||
**Estimated Tokens**: ${estimateTokens(content)}
|
||||
|
||||
---
|
||||
|
||||
## Full Input Context:
|
||||
|
||||
${content}
|
||||
|
||||
---
|
||||
*End of log*`;
|
||||
|
||||
// Write log file
|
||||
await createFileWithCollisionHandling(this.app.vault, logFilePath, logContent);
|
||||
|
||||
console.log(`Distill context logged to: ${logFilePath}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to log distill context:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[],
|
||||
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();
|
||||
|
||||
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;
|
||||
|
||||
// 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 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 };
|
||||
})
|
||||
);
|
||||
|
||||
filesWithDates.sort((a, b) => b.effectiveTime - a.effectiveTime);
|
||||
|
||||
return filesWithDates.map(item => item.file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string contains a dataview query
|
||||
* @param content The content to check
|
||||
|
|
@ -216,10 +353,40 @@ export class DistillService {
|
|||
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);
|
||||
|
||||
|
|
@ -289,12 +456,207 @@ export class DistillService {
|
|||
return uniqueFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert date format string to regex pattern
|
||||
* @param format The date format string (e.g., "### YYYY-MM-DD")
|
||||
* @returns Regex pattern for matching date headers
|
||||
*/
|
||||
private dateFormatToRegex(format: string): RegExp {
|
||||
// Escape special regex characters except for the date placeholders
|
||||
let pattern = format
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape special chars
|
||||
.replace(/YYYY/g, '\\d{4}') // Replace YYYY with year pattern
|
||||
.replace(/MM/g, '\\d{2}') // Replace MM with month pattern
|
||||
.replace(/DD/g, '\\d{2}'); // Replace DD with day pattern
|
||||
|
||||
return new RegExp(`^${pattern}\\s*$`, 'm');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract date from header using the configured format
|
||||
* @param header The header line to parse
|
||||
* @param format The date format string
|
||||
* @returns Date object or null if not a valid date header
|
||||
*/
|
||||
private extractDateFromHeader(header: string, format: string): Date | null {
|
||||
const regex = this.dateFormatToRegex(format);
|
||||
if (!regex.test(header)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find positions of date components in format
|
||||
const yearPos = format.indexOf('YYYY');
|
||||
const monthPos = format.indexOf('MM');
|
||||
const dayPos = format.indexOf('DD');
|
||||
|
||||
if (yearPos === -1 || monthPos === -1 || dayPos === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract date components from the header at the same positions
|
||||
const year = header.substr(yearPos, 4);
|
||||
const month = header.substr(monthPos, 2);
|
||||
const day = header.substr(dayPos, 2);
|
||||
|
||||
// Validate extracted values are numbers
|
||||
if (!/^\d{4}$/.test(year) || !/^\d{2}$/.test(month) || !/^\d{2}$/.test(day)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(`${year}-${month}-${day}T00:00:00`);
|
||||
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
|
||||
* @returns True if the note contains date headers
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a date from a header
|
||||
* @param header The header line to parse
|
||||
* @returns Date object or null if not a valid date header
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract sections from content based on date headers
|
||||
* @param content The content to parse
|
||||
* @returns Array of sections with their dates
|
||||
*/
|
||||
private getDateSections(content: string): Array<{date: Date | null, content: string}> {
|
||||
const lines = content.split('\n');
|
||||
const sections: Array<{date: Date | null, content: string}> = [];
|
||||
let currentSection: string[] = [];
|
||||
let currentDate: Date | null = null;
|
||||
let hasFoundFirstDate = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const parsedDate = this.parseDateFromHeader(line);
|
||||
|
||||
if (parsedDate) {
|
||||
// Found a date header
|
||||
if (!hasFoundFirstDate) {
|
||||
// First date header - save any content before it as undated
|
||||
if (currentSection.length > 0) {
|
||||
sections.push({
|
||||
date: null,
|
||||
content: currentSection.join('\n').trim()
|
||||
});
|
||||
}
|
||||
hasFoundFirstDate = true;
|
||||
} else if (currentSection.length > 0) {
|
||||
// Save the previous section
|
||||
sections.push({
|
||||
date: currentDate,
|
||||
content: currentSection.join('\n').trim()
|
||||
});
|
||||
}
|
||||
|
||||
// Start new section with the date header
|
||||
currentDate = parsedDate;
|
||||
currentSection = [line];
|
||||
} else {
|
||||
// Regular content line
|
||||
currentSection.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last section
|
||||
if (currentSection.length > 0) {
|
||||
sections.push({
|
||||
date: currentDate,
|
||||
content: currentSection.join('\n').trim()
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract content from a note within a specific date range
|
||||
* @param content The full note content
|
||||
* @param daysBack Number of days to look back from today
|
||||
* @returns Filtered content within the date range
|
||||
*/
|
||||
private extractContentByDateRange(content: string, daysBack: number): string {
|
||||
if (!this.isJournalStyleNote(content)) {
|
||||
// Not a journal-style note, return full content
|
||||
return content;
|
||||
}
|
||||
|
||||
const sections = this.getDateSections(content);
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - daysBack);
|
||||
cutoffDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const filteredSections: string[] = [];
|
||||
|
||||
for (const section of sections) {
|
||||
if (section.date === null) {
|
||||
// Include undated content (header/intro)
|
||||
filteredSections.push(section.content);
|
||||
} else if (section.date >= cutoffDate) {
|
||||
// Include sections within the date range
|
||||
filteredSections.push(section.content);
|
||||
}
|
||||
}
|
||||
|
||||
return filteredSections.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate content from a set of files
|
||||
* @param files Array of files to aggregate content from
|
||||
* @param timeWindowDays Optional time window in days for filtering journal content
|
||||
* @returns Aggregated content as string along with source file names
|
||||
*/
|
||||
async aggregateContent(files: TFile[]): Promise<{content: string, sourceNotes: string[]}> {
|
||||
async aggregateContent(files: TFile[], timeWindowDays?: number): Promise<{content: string, sourceNotes: string[]}> {
|
||||
let aggregatedContent = "";
|
||||
const sourceNotes: string[] = [];
|
||||
|
||||
|
|
@ -307,7 +669,18 @@ export class DistillService {
|
|||
continue;
|
||||
}
|
||||
|
||||
const content = await this.app.vault.read(file);
|
||||
let content = await this.app.vault.read(file);
|
||||
|
||||
// Apply time filtering if specified
|
||||
if (timeWindowDays !== undefined && timeWindowDays > 0) {
|
||||
content = this.extractContentByDateRange(content, timeWindowDays);
|
||||
|
||||
// Skip this file if no content remains after filtering
|
||||
if (!content.trim()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
aggregatedContent += `\n\n# Note: ${file.basename}\n\n${content}`;
|
||||
sourceNotes.push(file.basename);
|
||||
|
||||
|
|
@ -325,12 +698,16 @@ export class DistillService {
|
|||
* @param rootFile The root note file to distill from
|
||||
* @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[]
|
||||
preparedSourceNotes?: string[],
|
||||
timeWindowDays?: number,
|
||||
customPrompt?: string
|
||||
): Promise<DistillResponse> {
|
||||
let combinedContent: string;
|
||||
let sourceNotes: string[];
|
||||
|
|
@ -344,10 +721,15 @@ export class DistillService {
|
|||
const linkedFiles = await this.getLinkedNotes(rootFile);
|
||||
|
||||
// Get content from root note
|
||||
const rootContent = await this.app.vault.read(rootFile);
|
||||
let rootContent = await this.app.vault.read(rootFile);
|
||||
|
||||
// Aggregate content from all linked notes
|
||||
const aggregatedResult = await this.aggregateContent(linkedFiles);
|
||||
// Apply time filtering to root note if specified
|
||||
if (timeWindowDays !== undefined && timeWindowDays > 0) {
|
||||
rootContent = this.extractContentByDateRange(rootContent, timeWindowDays);
|
||||
}
|
||||
|
||||
// Aggregate content from all linked notes with time filtering
|
||||
const aggregatedResult = await this.aggregateContent(linkedFiles, timeWindowDays);
|
||||
const linkedContent = aggregatedResult.content;
|
||||
sourceNotes = aggregatedResult.sourceNotes;
|
||||
|
||||
|
|
@ -355,8 +737,11 @@ export class DistillService {
|
|||
combinedContent = `# Root Note: ${rootFile.basename}\n\n${rootContent}\n\n${linkedContent}`;
|
||||
}
|
||||
|
||||
// Send to OpenAI for distillation
|
||||
const distilledContent = await this.openAIService.distillContent(combinedContent);
|
||||
// Log the combined content before sending to AI
|
||||
await this.logDistillContext(combinedContent, rootFile.basename);
|
||||
|
||||
// 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];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { App, TFile, Vault } from 'obsidian';
|
||||
import { sanitizeFilename, BacklinkMapper } from '../utils/filename-utils';
|
||||
import { sanitizeFilename, BacklinkMapper, createFileWithCollisionHandling } from '../utils/filename-utils';
|
||||
import { TranscriptResponse, DistillResponse } from '../types/transcript';
|
||||
|
||||
/**
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
@ -66,15 +105,23 @@ export class FileService {
|
|||
}
|
||||
|
||||
// Output Summary with tasks
|
||||
await this.vault.create(`${this.summaryFolder}/${sanitizedFilename} - summary.md`, summaryContent);
|
||||
await createFileWithCollisionHandling(
|
||||
this.vault,
|
||||
`${this.summaryFolder}/${sanitizedFilename} - summary.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);
|
||||
// Process content to ensure backlinks use sanitized filenames
|
||||
const processedContent = this.backlinkMapper.processBacklinks(note.content);
|
||||
await this.vault.create(`${this.notesFolder}/${sanitizedTitle}.md`, processedContent);
|
||||
await createFileWithCollisionHandling(
|
||||
this.vault,
|
||||
`${sessionPath}/${sanitizedTitle}.md`,
|
||||
processedContent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -87,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
|
||||
|
|
@ -117,16 +197,23 @@ export class FileService {
|
|||
}
|
||||
|
||||
// Output Summary
|
||||
const summaryPath = `${this.summaryFolder}/${sanitizedFilename} - distilled.md`;
|
||||
await this.vault.create(summaryPath, summaryContent);
|
||||
const summaryPath = await createFileWithCollisionHandling(
|
||||
this.vault,
|
||||
`${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);
|
||||
// Process content to ensure backlinks use sanitized filenames
|
||||
const processedContent = this.backlinkMapper.processBacklinks(note.content);
|
||||
await this.vault.create(`${this.notesFolder}/${sanitizedTitle}.md`, processedContent);
|
||||
await createFileWithCollisionHandling(
|
||||
this.vault,
|
||||
`${sessionPath}/${sanitizedTitle}.md`,
|
||||
processedContent
|
||||
);
|
||||
}
|
||||
|
||||
return summaryPath;
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,33 @@
|
|||
import { App } from 'obsidian';
|
||||
|
||||
export interface RecentActivitySettings {
|
||||
daysBack: number;
|
||||
excludeFolders: string[];
|
||||
filterJournalSections: boolean;
|
||||
dateHeaderFormat: string;
|
||||
}
|
||||
|
||||
export interface OpenAugiSettings {
|
||||
apiKey: string;
|
||||
summaryFolder: string;
|
||||
notesFolder: string;
|
||||
promptsFolder: string;
|
||||
useDataviewIfAvailable: boolean;
|
||||
enableDistillLogging: boolean;
|
||||
recentActivityDefaults: RecentActivitySettings;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: OpenAugiSettings = {
|
||||
apiKey: '',
|
||||
summaryFolder: 'OpenAugi/Summaries',
|
||||
notesFolder: 'OpenAugi/Notes',
|
||||
useDataviewIfAvailable: true
|
||||
promptsFolder: 'OpenAugi/Prompts',
|
||||
useDataviewIfAvailable: true,
|
||||
enableDistillLogging: false,
|
||||
recentActivityDefaults: {
|
||||
daysBack: 7,
|
||||
excludeFolders: ['Templates', 'Archive', 'OpenAugi'],
|
||||
filterJournalSections: true,
|
||||
dateHeaderFormat: '### YYYY-MM-DD'
|
||||
}
|
||||
};
|
||||
138
src/ui/prompt-selection-modal.ts
Normal file
138
src/ui/prompt-selection-modal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
544
src/ui/recent-activity-modal.ts
Normal file
544
src/ui/recent-activity-modal.ts
Normal file
|
|
@ -0,0 +1,544 @@
|
|||
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,
|
||||
defaultConfig: RecentActivitySettings,
|
||||
onSubmit: (config: RecentActivityConfig) => void
|
||||
) {
|
||||
super(app);
|
||||
this.config = { ...defaultConfig };
|
||||
this.onSubmit = onSubmit;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
|
||||
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(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')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.config.filterJournalSections)
|
||||
.onChange(value => {
|
||||
this.config.filterJournalSections = value;
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Root note for context (optional)')
|
||||
.setDesc('Enter the name of a note to provide additional context. Leave empty to skip.')
|
||||
.addText(text => text
|
||||
.setPlaceholder('Note name (without .md)')
|
||||
.setValue(this.config.rootNote?.basename || '')
|
||||
.onChange(value => {
|
||||
if (value) {
|
||||
// Try to find the file
|
||||
const files = this.app.vault.getMarkdownFiles();
|
||||
const matchingFile = files.find(f =>
|
||||
f.basename.toLowerCase() === value.toLowerCase() ||
|
||||
f.path.toLowerCase() === value.toLowerCase() ||
|
||||
f.path.toLowerCase() === value.toLowerCase() + '.md'
|
||||
);
|
||||
|
||||
if (matchingFile) {
|
||||
this.config.rootNote = matchingFile;
|
||||
}
|
||||
} else {
|
||||
this.config.rootNote = undefined;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Exclude folders')
|
||||
.setDesc('Comma-separated list of folders to exclude (e.g., Templates, Archive)')
|
||||
.addText(text => text
|
||||
.setPlaceholder('Templates, Archive, OpenAugi')
|
||||
.setValue(this.config.excludeFolders.join(', '))
|
||||
.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())
|
||||
)
|
||||
.addButton(button => button
|
||||
.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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -80,5 +92,74 @@ export class OpenAugiSettingTab extends PluginSettingTab {
|
|||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
// Recent Activity Settings Header
|
||||
containerEl.createEl('h3', { text: 'Recent Activity Settings' });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Default days to look back')
|
||||
.setDesc('Default number of days for the "Distill recent activity" command')
|
||||
.addText(text => text
|
||||
.setPlaceholder('7')
|
||||
.setValue(String(this.plugin.settings.recentActivityDefaults.daysBack))
|
||||
.onChange(async (value) => {
|
||||
const days = parseInt(value);
|
||||
if (!isNaN(days) && days > 0) {
|
||||
this.plugin.settings.recentActivityDefaults.daysBack = days;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Filter journal sections by date')
|
||||
.setDesc('For notes with date headers, only include sections within the time window')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.recentActivityDefaults.filterJournalSections)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.recentActivityDefaults.filterJournalSections = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
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. Leave empty to disable journal parsing.')
|
||||
.addText(text => text
|
||||
.setValue(this.plugin.settings.recentActivityDefaults.dateHeaderFormat)
|
||||
.onChange(async (value) => {
|
||||
// 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();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Exclude folders')
|
||||
.setDesc('Comma-separated list of folders to exclude from recent activity (e.g., Templates, Archive)')
|
||||
.addText(text => text
|
||||
.setPlaceholder('Templates, Archive, OpenAugi')
|
||||
.setValue(this.plugin.settings.recentActivityDefaults.excludeFolders.join(', '))
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.recentActivityDefaults.excludeFolders = value
|
||||
.split(',')
|
||||
.map(f => f.trim())
|
||||
.filter(f => f.length > 0);
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Enable distill logging')
|
||||
.setDesc('Log the full input context sent to AI when distilling notes. Logs are saved to OpenAugi/Logs folder.')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.enableDistillLogging)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.enableDistillLogging = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { Vault } from 'obsidian';
|
||||
|
||||
/**
|
||||
* Sanitizes a filename to remove characters that aren't allowed in filenames
|
||||
* @param filename The filename to sanitize
|
||||
|
|
@ -7,6 +9,49 @@ export function sanitizeFilename(filename: string): string {
|
|||
return filename.replace(/[\\/:*?"<>|]/g, ' - ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a file with collision handling - appends numbers if file exists
|
||||
* @param vault The Obsidian vault instance
|
||||
* @param basePath The desired file path
|
||||
* @param content The file content
|
||||
* @returns The actual path where the file was created
|
||||
*/
|
||||
export async function createFileWithCollisionHandling(
|
||||
vault: Vault,
|
||||
basePath: string,
|
||||
content: string
|
||||
): Promise<string> {
|
||||
let finalPath = basePath;
|
||||
let counter = 1;
|
||||
|
||||
// Try the original path first
|
||||
if (!await vault.adapter.exists(finalPath)) {
|
||||
await vault.create(finalPath, content);
|
||||
return finalPath;
|
||||
}
|
||||
|
||||
// Extract base name and extension
|
||||
const pathMatch = basePath.match(/^(.*)(\.[^.]+)$/);
|
||||
const baseWithoutExt = pathMatch ? pathMatch[1] : basePath;
|
||||
const extension = pathMatch ? pathMatch[2] : '';
|
||||
|
||||
// Try appending numbers
|
||||
while (counter < 100) { // Prevent infinite loop
|
||||
finalPath = `${baseWithoutExt}-${counter}${extension}`;
|
||||
if (!await vault.adapter.exists(finalPath)) {
|
||||
await vault.create(finalPath, content);
|
||||
return finalPath;
|
||||
}
|
||||
counter++;
|
||||
}
|
||||
|
||||
// If still can't find unique name, use timestamp
|
||||
const timestamp = Date.now();
|
||||
finalPath = `${baseWithoutExt}-${timestamp}${extension}`;
|
||||
await vault.create(finalPath, content);
|
||||
return finalPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps between note titles and their sanitized filenames
|
||||
* This helps ensure backlinks point to the correct files
|
||||
|
|
|
|||
41
styles.css
41
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;
|
||||
}
|
||||
Loading…
Reference in a new issue