Initializing
Back to Projects
Year2024
DomainFullstack
AccessOpen Source
Complexity0 / 10
TypeScriptObsidian PluginAI/LLMGoogle Gemini
FullstackArchived

AI Generated Frontmatter (Obsidian Plugin)

An Obsidian plugin that automatically generates AI-powered YAML frontmatter for notes using Google's Gemini API, with customizable prompts and automatic processing options.

# AI Generated Frontmatter (Obsidian Plugin)

An Obsidian plugin that automatically generates structured YAML frontmatter for notes using Google's Gemini AI. The plugin analyzes note content and creates intelligent metadata including note type, tags, entities, relationships, and temporal information.

Purpose and Philosophy

The Problem

Obsidian users often struggle with consistent note organization. While frontmatter (YAML metadata at the top of markdown files) enables powerful features like Dataview queries, property views, and relationship tracking, manually adding metadata is tedious and inconsistent.

The Solution

This plugin leverages AI to automatically generate meaningful frontmatter by:

  • Analyzing note content to understand its context and type
  • Extracting entities like people, projects, and concepts using Obsidian conventions
  • Detecting relationships between notes
  • Applying consistent taxonomy based on vault-specific terminology

Core Philosophy

The plugin follows these principles:

  1. Extract, Don't Invent — Only include properties directly supported by content
  2. Taxonomy First — Use existing vault terminology when detectable
  3. Minimal Viable Metadata — Maximum 8 properties unless critical
  4. Obsidian-Native Formats — Support Dataview and Relationships plugins

Architecture

Parsing system architecture diagram...

Features

1. AI-Powered Generation

Uses Google Gemini API to analyze note content and generate appropriate frontmatter:

typescript
interface GeneratedFrontmatter {
  type: 'meeting' | 'research' | 'person' | 'project' | 
        'reference' | 'idea' | 'task' | 'journal' | 'note';
  created: string;  // ISO 8601
  tags: string[];
  entities: string[];
  related: string[][];
  event?: string;
}

Example Output:

yaml
---
type: research
created: 2025-05-18
tags: [ai, machine_learning, nlp]
entities: [Google, OpenAI, Anthropic]
related: [["AI Research Overview"], ["LLM Comparison"]]
---

2. Multiple Processing Modes

#### Manual Mode (Default) Trigger generation via command palette:

  • Open command palette (Ctrl/Cmd + P)
  • Search "Generate AI Frontmatter"
  • Processes currently active note

#### Automatic Mode Optionally process on:

  • New note creation — When a new .md file is created
  • Note modification — When content changes (with debounce)

3. Folder Filtering

Control which notes get processed:

typescript
// Settings
includedFolders: string;  // Only process these folders
excludedFolders: string;  // Skip these folders (overrides include)
code
Included: 01-Projects, 02-Areas/People
Excluded: 00-Templates, 03-Resources/Attachments

4. Customizable AI Prompt

The default prompt instructs the AI to:

  1. Identify note type from: meeting, research, person, project, reference, idea, task, journal, note
  2. Extract entities:
  • People: @JohnDoe (titlecase format)
  • Projects: #ProjectName (PascalCase)
  • Concepts: #snake_case
  1. Detect temporal context: created date, event date (if explicitly mentioned)
  2. Determine relationships: related notes with double-bracket format

5. Content Management

typescript
contentLimit: number;    // Max characters sent to API (default: 4000)
skipNotesWithExistingFrontmatter: boolean;  // Skip if frontmatter exists

6. Performance Controls

typescript
debounceTime: number;    // Wait time after last keystroke (default: 5000ms)

Prevents excessive API calls while typing by debouncing the modify event.

Settings Configuration

SettingDefaultDescription
Gemini API Key(none)Google Gemini API key
Modelgemini-1.5-flash-latestAI model to use
Temperature0.3AI creativity level (0-1)
Auto-process new notesfalseProcess on creation
Auto-process modified notesfalseProcess on modification
Debounce delay5000msWait after typing stops
Skip existing frontmattertrueDon't overwrite existing
Included folders(all)Process only these
Excluded folders(none)Skip these folders
Content limit4000Max chars sent to API
Custom prompt(default)Modify AI instructions

Implementation Details

Plugin Structure

code
obsidian-ai-frontmatter/
├── main.ts           # Main plugin code (483 lines)
├── main.js           # Compiled JavaScript
├── manifest.json     # Plugin manifest
├── styles.css        # Plugin styles
├── package.json      # Dependencies
├── tsconfig.json     # TypeScript config
└── rollup.config.js  # Build config

Key Classes

#### AIGeneratedFrontmatter (Main Plugin)

typescript
export default class AIGeneratedFrontmatter extends Plugin {
    settings: AIGeneratedFrontmatterSettings;
    private isProcessing: Set<string> = new Set();
    private debouncedProcessFile: (file: TFile) => void;
    
    async onload() {
        // Register command
        // Register vault events
        // Load settings
    }
    
    async processFile(file: TFile, force: boolean = false) {
        // Main processing logic
    }
    
    private shouldProcess(file: TFile): boolean {
        // Folder filtering logic
    }
}

#### AISettingsTab (Settings UI)

typescript
class AISettingsTab extends PluginSettingTab {
    display(): void {
        // Creates all settings controls
    }
}

Gemini API Integration

typescript
async function queryGemini(content: string, settings: AIGeneratedFrontmatterSettings): Promise<string> {
    const truncatedContent = content.substring(0, settings.contentLimit);
    const prompt = settings.customPrompt.replace('{CONTENT}', truncatedContent);
    
    const response = await requestUrl({
        url: `https://generativelanguage.googleapis.com/v1beta/models/${settings.model}:generateContent?key=${settings.apiKey}`,
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
            contents: [{ parts: [{ text: prompt }] }],
            generationConfig: {
                temperature: settings.temperature,
                response_mime_type: "text/plain",
            }
        })
    });
    
    return response.json.candidates[0].content.parts[0].text.trim();
}

YAML Parsing

typescript
private cleanYamlResponse(response: string): string | null {
    // Try to extract YAML between --- markers
    const yamlRegex = /---\s*([\s\S]+?)\s*---/;
    const match = response.match(yamlRegex);
    
    if (match && match[1]) {
        return `---\n${match[1].trim()}\n---`;
    }
    
    // Fallback: if response contains any key:value pairs
    if (response.includes(':')) {
        return `---\n${response.trim()}\n---`;
    }
    
    return null;
}

Installation

From Release

  1. Copy main.js, manifest.json, and styles.css to vault's plugin folder:
code
   vault/.obsidian/plugins/obsidian-ai-frontmatter/
  1. Enable plugin in Obsidian settings

From Source

bash
# Clone or download source
cd obsidian-ai-frontmatter

# Install dependencies
npm install

# Build
npm run build

# Copy output to vault

Usage

First-Time Setup

  1. Go to Settings → Plugins → AI Generated Frontmatter
  2. Enter your Gemini API key
  3. Configure optional settings

Generate Frontmatter Manually

  1. Open the note
  2. Press Ctrl/Cmd + P
  3. Search "Generate AI Frontmatter"
  4. Press Enter

Enable Auto-Processing

  1. Open plugin settings
  2. Toggle "Auto-process new notes" or "Auto-process modified notes"
  3. Configure folder filters if needed

API Key Setup

  1. Go to Google AI Studio
  2. Create a new API key
  3. Copy to plugin settings

Note: The plugin stores your API key locally in the vault's plugin data. It is never sent anywhere except directly to Google's Gemini API.

Default Prompt Engineering

The default prompt is carefully crafted to produce Obsidian-friendly metadata:

yaml
## Note Content
{CONTENT}

The AI is instructed to:

  • Return ONLY valid YAML between --- markers
  • Use ISO 8601 dates
  • Use array format for tags
  • Include required properties: type, created
  • Avoid forbidden properties: content, body, file_path
  • Extract @People, #Projects, and #concepts as entities

Use Cases

  1. Bulk Organization — Run on existing vault to add metadata to unorganized notes
  2. Daily Notes — Automatically tag and categorize daily journal entries
  3. Research — Extract entities and relationships from research notes
  4. Project Tracking — Identify project notes and link related tasks
  5. Meeting Notes — Tag meetings and extract attendees

Limitations

  • API Cost: Uses Google Gemini API (free tier available)
  • API Rate Limits: Subject to Google's rate limits
  • Content Length: Limited by contentLimit setting (default 4000 chars)
  • Language: Best results with English content
  • Privacy: Note content sent to Google for processing

Future Enhancements

Potential improvements:

  • Support for other AI providers (OpenAI, Anthropic)
  • Bulk processing for entire folders
  • Custom property templates
  • Dataview field support
  • Bi-directional link detection
  • Template variable support

Tech Stack Summary

ComponentTechnology
LanguageTypeScript 4.7.4
FrameworkObsidian API
AI ProviderGoogle Gemini API
Build Toolesbuild
Type CheckingTypeScript (noEmit)
Package Managernpm

Portfolio Context

This plugin demonstrates:

  • Obsidian Plugin Development — Full plugin architecture with settings, commands, events
  • API Integration — External AI API integration with error handling
  • TypeScript — Type-safe development with Obsidian types
  • User Experience — Configuration UI with debouncing and folder filtering
  • AI Prompt Engineering — Carefully crafted prompts for structured output

Detailed Implementation Analysis

Event Registration System

The plugin registers multiple event handlers for different triggers:

typescript
this.registerEvent(
    this.app.vault.on('create', (file) => {
        if (this.settings.autoProcessNewNotes && file instanceof TFile) {
            setTimeout(() => this.processFile(file), 500);
        }
    })
);

this.registerEvent(
    this.app.vault.on('modify', (file) => {
        if (this.settings.autoProcessModifiedNotes && file instanceof TFile) {
            this.debouncedProcessFile(file);
        }
    })
);

Why setTimeout for create events?

  • When a note is created, it may not have immediately readable content
  • The 500ms delay ensures the file is fully written before processing

Why debounce for modify events?

  • Users type continuously while editing
  • Each keystroke triggers a modify event
  • Without debouncing, we'd make dozens of API calls per note edit
  • The debounce function waits for the user to stop typing before processing

Processing Pipeline

The main processFile method implements a complete pipeline:

typescript
async processFile(file: TFile, force: boolean = false) {
    // Step 1: Check if we should process this file
    if (!this.shouldProcess(file) || this.isProcessing.has(file.path)) {
        return;
    }

    // Step 2: Mark file as being processed (prevent duplicates)
    this.isProcessing.add(file.path);
    
    try {
        // Step 3: Read file content
        const fileContent = await this.app.vault.cachedRead(file);
        
        // Step 4: Check for existing frontmatter
        const frontmatter = this.app.metadataCache.getFileCache(file)?.frontmatter;

        // Step 5: Skip if frontmatter exists (unless force is true)
        if (!force && this.settings.skipNotesWithExistingFrontmatter && frontmatter) {
            console.log(`Skipping '${file.path}' as it already has frontmatter.`);
            return;
        }
        
        // Step 6: Extract content to analyze (strip existing frontmatter)
        const contentToAnalyze = frontmatter
            ? fileContent.substring(frontmatter.position.end.offset)
            : fileContent;

        // Step 7: Validate we have content to analyze
        if (!contentToAnalyze.trim()) {
            new Notice('No content to analyze.');
            return;
        }

        // Step 8: Show processing notification
        const notice = new Notice('Generating AI frontmatter...', 0);

        try {
            // Step 9: Call Gemini API
            const generatedYaml = await queryGemini(contentToAnalyze, this.settings);
            
            // Step 10: Clean and validate the response
            const cleanedYaml = this.cleanYamlResponse(generatedYaml);

            if (!cleanedYaml) {
                new Notice('AI returned empty or invalid frontmatter.');
                return;
            }
            
            // Step 11: Insert frontmatter into note
            await this.app.vault.process(file, (data) => {
                const existingFm = this.app.metadataCache.getFileCache(file)?.frontmatter;
                if (existingFm) {
                    // Replace existing frontmatter
                    return cleanedYaml + '\n' + data.substring(existingFm.position.end.offset).trim();
                } else {
                    // Add new frontmatter at top
                    return cleanedYaml + '\n\n' + data;
                }
            });

            notice.setMessage('AI frontmatter generated successfully!');
        } catch (error) {
            console.error(error);
            new Notice(`Error generating frontmatter: ${error.message}`, 8000);
        } finally {
            notice.hide();
        }
    } catch (error) {
        console.error(`Error processing file ${file.path}:`, error);
        new Notice(`Failed to read or process file: ${file.path}`);
    } finally {
        // Step 12: Clean up processing state
        this.isProcessing.delete(file.path);
    }
}

File Filtering Logic

The shouldProcess method implements folder-based filtering:

typescript
private shouldProcess(file: TFile): boolean {
    // Basic validation
    if (!file || !(file instanceof TFile) || file.extension !== 'md') {
        return false;
    }

    const path = file.path.toLowerCase();
    
    // Parse included folders
    const included = this.settings.includedFolders
        .split(/[\n,]/)
        .map(f => f.trim().toLowerCase())
        .filter(f => f.length > 0);
        
    // Parse excluded folders
    const excluded = this.settings.excludedFolders
        .split(/[\n,]/)
        .map(f => f.trim().toLowerCase())
        .filter(f => f.length > 0);

    // Excluded folders take priority
    if (excluded.length > 0 && excluded.some(folder => path.startsWith(folder))) {
        return false;
    }

    // If included folders are specified, must match one
    if (included.length > 0 && !included.some(folder => path.startsWith(folder))) {
        return false;
    }
    
    return true;
}

Error Handling

The plugin implements comprehensive error handling:

typescript
async function queryGemini(content: string, settings: AIGeneratedFrontmatterSettings): Promise<string> {
    if (!settings.apiKey) {
        throw new Error("Gemini API key is not configured.");
    }

    try {
        const response = await requestUrl({ /* ... */ });
        const responseData = response.json;

        if (responseData?.candidates?.[0]?.content?.parts?.[0]?.text) {
            return responseData.candidates[0].content.parts[0].text.trim();
        } else {
            throw new Error("Invalid response structure from Gemini API");
        }
    } catch (error) {
        // Specific error handling for common issues
        if (error.message.includes("400")) {
             throw new Error("API Error: Bad request. Check your API key and model name.");
        }
        if (error.message.includes("429")) {
             throw new Error("API Error: Rate limit exceeded. Please wait and try again.");
        }
        throw new Error(`Failed to query Gemini API. ${error.message}`);
    }
}

Concurrent Processing Prevention

The plugin prevents processing the same file twice simultaneously:

typescript
private isProcessing: Set<string> = new Set();

// In processFile:
if (this.isProcessing.has(file.path)) {
    return;  // Already processing this file
}
this.isProcessing.add(file.path);
// ... processing ...
this.isProcessing.delete(file.path);  // In finally block

This prevents race conditions when:

  • User triggers manual processing while auto-processing
  • Multiple rapid modification events fire
  • User opens same file in multiple panes

Command Registration

The plugin registers a command available in the command palette:

typescript
this.addCommand({
    id: 'generate-ai-frontmatter',
    name: 'Generate AI Frontmatter',
    checkCallback: (checking: boolean) => {
        const file = this.app.workspace.getActiveFile();
        if (file) {
            if (!checking) {
                // Force process for command (ignores existing frontmatter)
                this.processFile(file, true);
            }
            return true;
        }
        return false;
    }
});

The command uses force: true to override the "skip existing frontmatter" setting when users explicitly trigger generation.

Settings Persistence

Settings are stored in the vault's plugin data:

typescript
async loadSettings() {
    this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}

async saveSettings() {
    await this.saveData(this.settings);
    // Recreate debounced function with new settings
    this.debouncedProcessFile = debounce(
        (file: TFile) => this.processFile(file),
        this.settings.debounceTime,
        true
    );
}

This ensures:

  • Settings persist across Obsidian restarts
  • API key is stored securely (locally only)
  • Debounce time updates apply immediately

Performance Considerations

Content Limiting

typescript
const truncatedContent = content.substring(0, settings.contentLimit);

The default 4000 character limit:

  • Reduces API costs
  • Keeps processing fast
  • Provides enough context for good metadata
  • Can be adjusted in settings for longer notes

Cached Reading

typescript
const fileContent = await this.app.vault.cachedRead(file);

Uses cached read instead of raw read for better performance on large vaults.

Comparison with Alternatives

FeatureAI Generated FrontmatterManualOther Plugins
AutomationAI-poweredManual entryTemplate-based
IntelligenceUnderstands contentN/AFixed rules
CustomizationCustom promptsFull controlTemplate variables
LearningAdapts to vaultN/ANone
CostAPI calls (free tier)FreeFree

Security Considerations

  1. API Key Storage: Stored in local plugin data, never transmitted except to Google
  2. Content Privacy: Note content sent to Google for processing (consider for sensitive notes)
  3. No Cloud Sync: All processing happens locally in Obsidian
  4. Network Requests: Only to Google's Gemini API, no third-party servers

Troubleshooting Guide

Common Issues

IssueCauseSolution
"API key not configured"No API key enteredAdd key in plugin settings
"Invalid response"Wrong API key or modelVerify API key, check model name
"Rate limit exceeded"Too many requestsWait, reduce auto-processing
"No content to analyze"Empty noteAdd content to note first
"Skipping - already has frontmatter"Existing frontmatterUse command to override

Debug Mode

Check Obsidian developer console (Ctrl+Shift+I) for detailed logs:

typescript
console.log(`Processing '${file.path}'`);
console.error("Gemini API Error:", error);

Extending the Plugin

Adding Custom Properties

Modify the prompt to extract additional properties:

yaml
## Content Analysis
- Identify PRIMARY NOTE TYPE: [meeting|research|person|project|reference|idea|task|journal|note]
- Extract entities:
  People: @JohnDoe (titlecase)
  Projects: #ProjectName (PascalCase)
- Detect temporal context:
  created: <current ISO date>
  event: <date> (only if explicitly mentioned)
- Add priority if task:
  priority: [high, medium, low] (only for type:task)

Adding New AI Providers

To add OpenAI support:

typescript
// Add to settings
apiProvider: 'gemini' | 'openai',
openaiModel: string,

// Modify query function
async function queryAI(content: string, settings: AIGeneratedFrontmatterSettings): Promise<string> {
    if (settings.apiProvider === 'gemini') {
        return await queryGemini(content, settings);
    } else if (settings.apiProvider === 'openai') {
        return await queryOpenAI(content, settings);
    }
}

Development Workflow

bash
# Install dependencies
npm install

# Development mode (watch for changes)
npm run dev

# Production build
npm run build

# Copy to vault
cp main.js manifest.json styles.css ~/.obsidian/plugins/obsidian-ai-frontmatter/

Version History

VersionDateChanges
1.0.0July 2025Initial release with Gemini API

Conclusion

This plugin represents a practical application of AI for personal knowledge management. By automatically generating structured frontmatter, it helps users maintain consistent metadata across their Obsidian vault without manual effort. The plugin demonstrates modern plugin development patterns including event-driven architecture, configurable processing, and external API integration.

Architecture Feedback

Spotted a potential optimization or antipattern? Let me know.

Submit a Technical Suggestion