mirror of
https://github.com/bitsofchris/openaugi-obsidian-plugin.git
synced 2026-07-22 05:46:42 +00:00
distill recent notes, file name collision
This commit is contained in:
parent
9d4ea7df84
commit
6085173752
11 changed files with 3329 additions and 33 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
|
||||
94
README.md
94
README.md
|
|
@ -69,6 +69,25 @@ The plugin will:
|
|||
- 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, 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:
|
||||
- **Days to look back**: How many days of activity to include (default: 7)
|
||||
- **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
|
||||
|
||||
The plugin will:
|
||||
- Automatically find all notes modified within your specified time window
|
||||
- For journal-style notes with date headers (e.g., `### 2024-01-20`), extract only recent sections
|
||||
- Synthesize the recent activity into organized atomic notes
|
||||
- Create a comprehensive summary of your recent work
|
||||
|
||||
## Custom Context
|
||||
|
||||
You can guide how OpenAugi processes your content by adding a custom context section to your notes:
|
||||
|
|
@ -92,11 +111,85 @@ 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`)
|
||||
- **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:
|
||||
|
|
@ -104,6 +197,7 @@ OpenAugi creates two types of files:
|
|||
- A concise summary of the content
|
||||
- Links to all atomic notes
|
||||
- Tasks extracted from the content
|
||||
- For distilled notes: Links to source notes
|
||||
|
||||
2. **Atomic notes** - Placed in your designated notes folder, each containing:
|
||||
- A single, self-contained idea
|
||||
|
|
|
|||
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": {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
164
src/main.ts
164
src/main.ts
|
|
@ -5,7 +5,8 @@ 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';
|
||||
|
||||
/**
|
||||
* A simple tokeinzer to estimate the number of tokens
|
||||
|
|
@ -67,6 +68,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));
|
||||
}
|
||||
|
|
@ -177,9 +187,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 +207,15 @@ 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);
|
||||
|
||||
// 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
|
||||
);
|
||||
|
||||
// Write result to files
|
||||
|
|
@ -246,4 +247,133 @@ 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) => {
|
||||
await this.executeRecentActivityDistill(config);
|
||||
}
|
||||
);
|
||||
modal.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the recent activity distillation with the given configuration
|
||||
* @param config The configuration for recent activity distillation
|
||||
*/
|
||||
private async executeRecentActivityDistill(config: RecentActivityConfig): 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
|
||||
);
|
||||
|
||||
// Get recently modified notes
|
||||
const recentFiles = await this.distillService.getRecentlyModifiedNotes(
|
||||
config.daysBack,
|
||||
config.excludeFolders
|
||||
);
|
||||
|
||||
if (recentFiles.length === 0 && !config.rootNote) {
|
||||
this.loadingIndicator?.hide();
|
||||
new Notice(`No notes modified in the last ${config.daysBack} days`);
|
||||
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
|
||||
? `Found ${recentFiles.length} recent notes plus root note: ${config.rootNote.basename}`
|
||||
: `Found ${recentFiles.length} notes modified in the last ${config.daysBack} days`;
|
||||
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
|
||||
const syntheticRootContent = `# Recent Activity Summary
|
||||
|
||||
This is an automated summary of notes modified in the last ${config.daysBack} days.
|
||||
${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: Last ${config.daysBack} Days\n\n${syntheticRootContent}\n\n${aggregatedContent}`;
|
||||
|
||||
// Distill the recent activity
|
||||
const distilledData = await this.distillService.distillFromRootNote(
|
||||
tempRootFile,
|
||||
combinedContent,
|
||||
sourceNotes
|
||||
);
|
||||
|
||||
// Update the distilled data to reflect recent activity
|
||||
distilledData.summary = `## Recent Activity Summary (Last ${config.daysBack} Days)\n\n${distilledData.summary}`;
|
||||
|
||||
// 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,93 @@ 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
|
||||
* @returns Array of recently modified files
|
||||
*/
|
||||
async getRecentlyModifiedNotes(daysBack: number, excludeFolders: string[]): Promise<TFile[]> {
|
||||
const cutoffTime = Date.now() - (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;
|
||||
}
|
||||
|
||||
// Check modification time
|
||||
const stats = await this.app.vault.adapter.stat(file.path);
|
||||
if (stats && stats.mtime >= cutoffTime) {
|
||||
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;
|
||||
});
|
||||
|
||||
return recentFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string contains a dataview query
|
||||
* @param content The content to check
|
||||
|
|
@ -289,12 +377,172 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
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;
|
||||
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 +555,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 +584,14 @@ 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
|
||||
* @returns Distilled content as a DistillResponse
|
||||
*/
|
||||
async distillFromRootNote(
|
||||
rootFile: TFile,
|
||||
preparedContent?: string,
|
||||
preparedSourceNotes?: string[]
|
||||
preparedSourceNotes?: string[],
|
||||
timeWindowDays?: number
|
||||
): Promise<DistillResponse> {
|
||||
let combinedContent: string;
|
||||
let sourceNotes: string[];
|
||||
|
|
@ -344,10 +605,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,6 +621,9 @@ export class DistillService {
|
|||
combinedContent = `# Root Note: ${rootFile.basename}\n\n${rootContent}\n\n${linkedContent}`;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
/**
|
||||
|
|
@ -66,7 +66,11 @@ 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
|
||||
for (const note of data.notes) {
|
||||
|
|
@ -74,7 +78,11 @@ export class FileService {
|
|||
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,
|
||||
`${this.notesFolder}/${sanitizedTitle}.md`,
|
||||
processedContent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -117,8 +125,11 @@ 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}/${sanitizedFilename} - distilled.md`,
|
||||
summaryContent
|
||||
);
|
||||
|
||||
// Output Notes
|
||||
for (const note of data.notes) {
|
||||
|
|
@ -126,7 +137,11 @@ export class FileService {
|
|||
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,
|
||||
`${this.notesFolder}/${sanitizedTitle}.md`,
|
||||
processedContent
|
||||
);
|
||||
}
|
||||
|
||||
return summaryPath;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,31 @@
|
|||
import { App } from 'obsidian';
|
||||
|
||||
export interface RecentActivitySettings {
|
||||
daysBack: number;
|
||||
excludeFolders: string[];
|
||||
filterJournalSections: boolean;
|
||||
dateHeaderFormat: string;
|
||||
}
|
||||
|
||||
export interface OpenAugiSettings {
|
||||
apiKey: string;
|
||||
summaryFolder: string;
|
||||
notesFolder: string;
|
||||
useDataviewIfAvailable: boolean;
|
||||
enableDistillLogging: boolean;
|
||||
recentActivityDefaults: RecentActivitySettings;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: OpenAugiSettings = {
|
||||
apiKey: '',
|
||||
summaryFolder: 'OpenAugi/Summaries',
|
||||
notesFolder: 'OpenAugi/Notes',
|
||||
useDataviewIfAvailable: true
|
||||
useDataviewIfAvailable: true,
|
||||
enableDistillLogging: false,
|
||||
recentActivityDefaults: {
|
||||
daysBack: 7,
|
||||
excludeFolders: ['Templates', 'Archive', 'OpenAugi'],
|
||||
filterJournalSections: true,
|
||||
dateHeaderFormat: '### YYYY-MM-DD'
|
||||
}
|
||||
};
|
||||
110
src/ui/recent-activity-modal.ts
Normal file
110
src/ui/recent-activity-modal.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { App, Modal, Setting, TFile } from 'obsidian';
|
||||
import { RecentActivitySettings } from '../types/settings';
|
||||
|
||||
export interface RecentActivityConfig extends RecentActivitySettings {
|
||||
rootNote?: TFile;
|
||||
}
|
||||
|
||||
export class RecentActivityModal extends Modal {
|
||||
private config: RecentActivityConfig;
|
||||
private onSubmit: (config: RecentActivityConfig) => void;
|
||||
|
||||
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' });
|
||||
|
||||
new Setting(contentEl)
|
||||
.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 => {
|
||||
const days = parseInt(value);
|
||||
if (!isNaN(days) && days > 0) {
|
||||
this.config.daysBack = days;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
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(value => {
|
||||
this.config.excludeFolders = value
|
||||
.split(',')
|
||||
.map(f => f.trim())
|
||||
.filter(f => f.length > 0);
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(contentEl)
|
||||
.addButton(button => button
|
||||
.setButtonText('Cancel')
|
||||
.onClick(() => this.close())
|
||||
)
|
||||
.addButton(button => button
|
||||
.setButtonText('Distill')
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
this.onSubmit(this.config);
|
||||
this.close();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
|
@ -80,5 +80,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.')
|
||||
.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')) {
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue