From 106abfa718c1aeaf3961ce0a9f1eafd2c052f358 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Wed, 20 May 2026 20:36:47 +0200 Subject: [PATCH] Integrate Obsidian metadataCache across extraction and tooling Replaces regex-based parsing of frontmatter and headings with Obsidian's metadataCache where available, falling back to regex when the cache is unavailable. Propagates App dependency through constructors to enable cache access. Key changes: - ContentExtractor accepts optional cache for frontmatter/headings - ToolExecutor uses cache for section replacement and frontmatter - NoteContextBuilder resolves titles/tags from cache - VaultVectorStore passes cache through indexing pipeline - AutoTagger checks cache for existing tags instead of content - Mock updated with metadataCache stubs for tests --- __mocks__/obsidian.ts | 10 ++ src/action-preview-builder.ts | 132 +++++++++++++++++++-------- src/auto-organizer.ts | 90 +++++++++++------- src/chat-view.ts | 2 +- src/indexing-pipeline/extraction.ts | 85 +++++++++++------ src/main.ts | 6 +- src/note-context-builder.ts | 39 +++++--- src/tool-executor.ts | 74 ++++++++++++--- src/vault-vector-store.ts | 13 ++- tests/action-preview-builder.test.ts | 63 ++++++++++++- tests/auto-organizer.test.ts | 34 +++++-- tests/note-context-builder.test.ts | 1 + tests/tool-executor.test.ts | 9 +- 13 files changed, 419 insertions(+), 139 deletions(-) diff --git a/__mocks__/obsidian.ts b/__mocks__/obsidian.ts index a8e1711..fc05d12 100755 --- a/__mocks__/obsidian.ts +++ b/__mocks__/obsidian.ts @@ -29,10 +29,20 @@ export class Workspace { export class App { vault: Vault; workspace: Workspace; + metadataCache: { + getFileCache: jest.Mock; + getFirstLinkpathDest: jest.Mock; + resolvedLinks: Record>; + }; constructor() { this.vault = new Vault(); this.workspace = new Workspace(); + this.metadataCache = { + getFileCache: jest.fn().mockReturnValue(null), + getFirstLinkpathDest: jest.fn().mockReturnValue(null), + resolvedLinks: {}, + }; } } diff --git a/src/action-preview-builder.ts b/src/action-preview-builder.ts index 3fe05b8..9a05630 100644 --- a/src/action-preview-builder.ts +++ b/src/action-preview-builder.ts @@ -1,6 +1,6 @@ // src/action-preview-builder.ts -import { Vault, TFile } from 'obsidian'; +import { Vault, TFile, App } from 'obsidian'; import type { ToolCall, ProposedAction } from './types'; import { safeParseJson } from './utils'; @@ -22,9 +22,11 @@ export function isWriteTool(name: string): boolean { export class ActionPreviewBuilder { private vault: Vault; + private app: App; - constructor(vault: Vault) { + constructor(vault: Vault, app: App) { this.vault = vault; + this.app = app; } private parseArgs(toolCall: ToolCall): Record { @@ -129,23 +131,48 @@ export class ActionPreviewBuilder { let after = before ?? ''; if (before) { - const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const headingRegex = new RegExp(`^(#{1,6}\\s+)${escapedHeading}\\s*$`, 'm'); - const match = before.match(headingRegex); - if (match) { - const headingLevel = match[1].length; - const headingIndex = match.index!; - const afterHeading = headingIndex + match[0].length; - const nextHeadingRegex = new RegExp(`^(#{1,${headingLevel}}\\s)`, 'm'); - const nextMatch = nextHeadingRegex.exec(before.slice(afterHeading)); - const sectionEnd = nextMatch ? afterHeading + nextMatch.index : before.length; - after = - before.slice(0, headingIndex) + - match[0] + - '\n' + - content + - '\n' + - before.slice(sectionEnd); + const file = this.getFileSafe(path); + const cache = file ? this.app.metadataCache.getFileCache(file) : null; + if (cache?.headings) { + const targetHeading = cache.headings.find((h) => h.heading === heading); + if (targetHeading) { + const startOffset = targetHeading.position.start.offset; + const headingLevel = targetHeading.level; + // Find the next heading at the same or higher level (fewer #) + const nextHeading = cache.headings.find( + (h) => h.position.start.offset > startOffset && h.level <= headingLevel + ); + const sectionEnd = nextHeading ? nextHeading.position.start.offset : before.length; + after = + before.slice(0, startOffset) + + '#'.repeat(headingLevel) + + ' ' + + heading + + '\n' + + content + + '\n' + + before.slice(sectionEnd); + } + } else { + // Fallback to regex when metadataCache is unavailable + const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const headingRegex = new RegExp(`^(#{1,6}\\s+)${escapedHeading}\\s*$`, 'm'); + const match = before.match(headingRegex); + if (match) { + const headingLevel = match[1].length; + const headingIndex = match.index!; + const afterHeading = headingIndex + match[0].length; + const nextHeadingRegex = new RegExp(`^(#{1,${headingLevel}}\\s)`, 'm'); + const nextMatch = nextHeadingRegex.exec(before.slice(afterHeading)); + const sectionEnd = nextMatch ? afterHeading + nextMatch.index : before.length; + after = + before.slice(0, headingIndex) + + match[0] + + '\n' + + content + + '\n' + + before.slice(sectionEnd); + } } } @@ -173,17 +200,14 @@ export class ActionPreviewBuilder { let after = before ?? ''; if (fields && typeof fields === 'object' && !Array.isArray(fields)) { - const parsed = this.parseFrontmatter(before ?? ''); - const newFields = { ...parsed.fields }; + const file = this.getFileSafe(path); + const parsed = this.parseFrontmatter(file, before ?? ''); + const newFields: Record = { ...parsed.fields }; for (const [key, value] of Object.entries(fields)) { if (value === null || value === undefined) { delete newFields[key]; - } else if (typeof value === 'string') { - newFields[key] = value; - } else if (Array.isArray(value)) { - newFields[key] = value.join(', '); } else { - newFields[key] = this.strArg(value); + newFields[key] = value; } } const newFrontmatter = this.serializeFrontmatter(newFields); @@ -291,31 +315,53 @@ export class ActionPreviewBuilder { }; } - private async readFileSafe(path: string): Promise { + private getFileSafe(path: string): TFile | null { try { const file = this.vault.getAbstractFileByPath(path); if (file instanceof TFile) { - return await this.vault.cachedRead(file); + return file; } } catch { // ignore } + return null; + } + + private async readFileSafe(path: string): Promise { + const file = this.getFileSafe(path); + if (file) { + try { + return await this.vault.cachedRead(file); + } catch { + // ignore + } + } return undefined; } - private parseFrontmatter(content: string): { + private parseFrontmatter( + file: TFile | null, + content: string + ): { exists: boolean; - raw: string; - fields: Record; + fields: Record; } { + if (file) { + const cache = this.app.metadataCache.getFileCache(file); + if (cache?.frontmatter) { + return { exists: true, fields: { ...cache.frontmatter } }; + } + } + + // Fallback to regex parsing when metadataCache is unavailable const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/; const match = content.match(frontmatterRegex); if (!match) { - return { exists: false, raw: '', fields: {} }; + return { exists: false, fields: {} }; } const raw = match[1]; - const fields: Record = {}; + const fields: Record = {}; for (const line of raw.split('\n')) { const idx = line.indexOf(':'); if (idx > 0) { @@ -327,11 +373,25 @@ export class ActionPreviewBuilder { } } - return { exists: true, raw, fields }; + return { exists: true, fields }; } - private serializeFrontmatter(fields: Record): string { - const lines = Object.entries(fields).map(([key, value]) => `${key}: ${value}`); + private serializeFrontmatter(fields: Record): string { + const lines: string[] = []; + for (const [key, value] of Object.entries(fields)) { + if (value === null || value === undefined) { + continue; + } + if (Array.isArray(value)) { + lines.push(`${key}: [${value.join(', ')}]`); + } else if (typeof value === 'string') { + lines.push(`${key}: ${value}`); + } else if (typeof value === 'number' || typeof value === 'boolean') { + lines.push(`${key}: ${value}`); + } else { + lines.push(`${key}: ${JSON.stringify(value)}`); + } + } return `---\n${lines.join('\n')}\n---\n`; } } diff --git a/src/auto-organizer.ts b/src/auto-organizer.ts index 7b2695c..082ffbe 100644 --- a/src/auto-organizer.ts +++ b/src/auto-organizer.ts @@ -1,4 +1,4 @@ -import { Vault, TFile, Notice } from 'obsidian'; +import { Vault, TFile, Notice, App } from 'obsidian'; import { OllamaClient } from './ollama-client'; import { VaultIndexer } from './vault-indexer'; import { Logger } from './utils'; @@ -25,11 +25,19 @@ export const DEFAULT_AUTO_ORGANIZE_CONFIG: AutoOrganizeConfig = { */ export class AutoTagger { private vault: Vault; + private app: App; private ollamaClient: OllamaClient; private config: AutoOrganizeConfig; - constructor(vault: Vault, ollamaUrl: string, model: string, config: AutoOrganizeConfig) { + constructor( + vault: Vault, + app: App, + ollamaUrl: string, + model: string, + config: AutoOrganizeConfig + ) { this.vault = vault; + this.app = app; this.config = config; this.ollamaClient = new OllamaClient(ollamaUrl, model); } @@ -38,30 +46,37 @@ export class AutoTagger { this.config = config; } + /** + * Check if a note has meaningful tags using metadataCache. + */ + private hasTags(file: TFile): boolean { + const cache = this.app.metadataCache.getFileCache(file); + if (!cache?.frontmatter) { + return false; + } + const tags: unknown = (cache.frontmatter as Record)['tags']; + if (tags === undefined || tags === null) { + return false; + } + if (Array.isArray(tags)) { + return tags.length > 0; + } + if (typeof tags === 'string') { + const trimmed = tags.trim(); + return trimmed.length > 0 && trimmed !== '[]' && trimmed !== 'null'; + } + return false; + } + /** * Find all markdown files that lack a `tags` frontmatter field. */ - async getUntaggedNotes(): Promise { + getUntaggedNotes(): TFile[] { const files = this.vault.getMarkdownFiles(); const untagged: TFile[] = []; for (const file of files) { try { - const content = await this.vault.cachedRead(file); - const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/); - if (!frontmatterMatch) { - untagged.push(file); - continue; - } - - const frontmatterText = frontmatterMatch[1]; - const tagsMatch = frontmatterText.match(/^tags:\s*(.+)$/m); - if (!tagsMatch) { - untagged.push(file); - continue; - } - - const tagsValue = tagsMatch[1].trim(); - if (tagsValue === '' || tagsValue === '[]' || tagsValue === 'null') { + if (!this.hasTags(file)) { untagged.push(file); } } catch { @@ -110,25 +125,32 @@ export class AutoTagger { try { const content = await this.vault.read(file); - const existingFrontmatter = content.match(/^---\n([\s\S]*?)\n---\n/); + const cache = this.app.metadataCache.getFileCache(file); + const hasFrontmatter = !!cache?.frontmatter; let newContent: string; - if (existingFrontmatter) { + if (hasFrontmatter) { // Update existing frontmatter - const frontmatterText = existingFrontmatter[1]; - const hasTagsLine = /^tags:/m.test(frontmatterText); + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/); + if (frontmatterMatch) { + const frontmatterText = frontmatterMatch[1]; + const hasTagsLine = /^tags:/m.test(frontmatterText); - if (hasTagsLine) { - // Replace existing tags line - const updatedFrontmatter = frontmatterText.replace( - /^tags:.*$/m, - `tags: ${tags.join(', ')}` - ); - newContent = content.replace(existingFrontmatter[0], `---\n${updatedFrontmatter}\n---\n`); + if (hasTagsLine) { + // Replace existing tags line + const updatedFrontmatter = frontmatterText.replace( + /^tags:.*$/m, + `tags: ${tags.join(', ')}` + ); + newContent = content.replace(frontmatterMatch[0], `---\n${updatedFrontmatter}\n---\n`); + } else { + // Add tags line to existing frontmatter + const updatedFrontmatter = `tags: ${tags.join(', ')}\n${frontmatterText}`; + newContent = content.replace(frontmatterMatch[0], `---\n${updatedFrontmatter}\n---\n`); + } } else { - // Add tags line to existing frontmatter - const updatedFrontmatter = `tags: ${tags.join(', ')}\n${frontmatterText}`; - newContent = content.replace(existingFrontmatter[0], `---\n${updatedFrontmatter}\n---\n`); + // MetadataCache says frontmatter exists but regex didn't find it — add new block + newContent = `---\ntags: ${tags.join(', ')}\n---\n\n${content}`; } } else { // Add new frontmatter block @@ -152,7 +174,7 @@ export class AutoTagger { return { tagged: 0, skipped: 0 }; } - const untagged = await this.getUntaggedNotes(); + const untagged = this.getUntaggedNotes(); if (untagged.length === 0) { new Notice('No untagged notes found.'); return { tagged: 0, skipped: 0 }; diff --git a/src/chat-view.ts b/src/chat-view.ts index 2006896..674b483 100755 --- a/src/chat-view.ts +++ b/src/chat-view.ts @@ -58,7 +58,7 @@ export class ChatView extends ItemView { ); this.vaultIndexer = new VaultIndexer(this.app.vault, undefined, vectorStore); this.toolExecutor = new ToolExecutor(this.app.vault, this.app); - this.actionPreviewBuilder = new ActionPreviewBuilder(this.app.vault); + this.actionPreviewBuilder = new ActionPreviewBuilder(this.app.vault, this.app); this.noteContextBuilder = new NoteContextBuilder(this.app.vault, this.app, this.vaultIndexer); this.conversationStateManager = new ConversationStateManager(); this.workflowEngine = new WorkflowEngine( diff --git a/src/indexing-pipeline/extraction.ts b/src/indexing-pipeline/extraction.ts index b49fc97..6760ba6 100644 --- a/src/indexing-pipeline/extraction.ts +++ b/src/indexing-pipeline/extraction.ts @@ -21,6 +21,14 @@ export interface ExtractedContent { firstParagraph?: string; } +interface CachedMetadataLike { + frontmatter?: Record; + headings?: Array<{ + heading: string; + level: number; + }>; +} + /** * Extracts raw content from a vault file including: * - Markdown content @@ -30,44 +38,65 @@ export interface ExtractedContent { * - First paragraph */ export class ContentExtractor { - extractFromFile(file: VaultFile, content: string): ExtractedContent { + extractFromFile(file: VaultFile, content: string, cache?: CachedMetadataLike): ExtractedContent { const frontmatter: Frontmatter = {}; const headings: string[] = []; const embeddedCodeBlocks: string[] = []; let firstParagraph: string | undefined; - // Extract frontmatter - const frontmatterMatch = content.match(/^---(.*?)---/s); - if (frontmatterMatch) { - try { - const frontmatterContent = frontmatterMatch[1]; - const lines = frontmatterContent.trim().split('\n'); - for (const line of lines) { - const [key, ...valueParts] = line.split(':'); - if (!key) continue; - const value = valueParts.join(':').trim(); - if (key.trim() === 'title') { - if (value) { - frontmatter.title = value; - } - } else if (key.trim() === 'tags') { - if (value) { - frontmatter.tags = value; - } - } else { - // Store other frontmatter fields as-is - frontmatter[key.trim()] = value; + // Extract frontmatter from metadataCache if available, otherwise fall back to regex + if (cache?.frontmatter) { + const fm = cache.frontmatter; + for (const [key, value] of Object.entries(fm)) { + if (key === 'title' && typeof value === 'string') { + frontmatter.title = value; + } else if (key === 'tags') { + if (Array.isArray(value)) { + frontmatter.tags = value.join(', '); + } else if (typeof value === 'string') { + frontmatter.tags = value; } + } else { + frontmatter[key] = value; + } + } + } else { + const frontmatterMatch = content.match(/^---(.*?)---/s); + if (frontmatterMatch) { + try { + const frontmatterContent = frontmatterMatch[1]; + const lines = frontmatterContent.trim().split('\n'); + for (const line of lines) { + const [key, ...valueParts] = line.split(':'); + if (!key) continue; + const value = valueParts.join(':').trim(); + if (key.trim() === 'title') { + if (value) { + frontmatter.title = value; + } + } else if (key.trim() === 'tags') { + if (value) { + frontmatter.tags = value; + } + } else { + // Store other frontmatter fields as-is + frontmatter[key.trim()] = value; + } + } + } catch { + // If frontmatter parsing fails, continue with empty frontmatter } - } catch { - // If frontmatter parsing fails, continue with empty frontmatter } } - // Extract headings - const headingMatches = content.match(/^#{1,6} (.*?)$/gm); - if (headingMatches) { - headings.push(...headingMatches.map((h: string) => h.replace(/^#{1,6} /, ''))); + // Extract headings from metadataCache if available, otherwise fall back to regex + if (cache?.headings) { + headings.push(...cache.headings.map((h) => h.heading)); + } else { + const headingMatches = content.match(/^#{1,6} (.*?)$/gm); + if (headingMatches) { + headings.push(...headingMatches.map((h: string) => h.replace(/^#{1,6} /, ''))); + } } // Extract embedded code blocks diff --git a/src/main.ts b/src/main.ts index b3b6719..1aa75f7 100755 --- a/src/main.ts +++ b/src/main.ts @@ -146,8 +146,7 @@ export default class OllamaPlugin extends Plugin { initializeAutoOrganizer(): void { if (!this.autoTagger) { - this.autoTagger = new AutoTagger( - this.app.vault, + this.autoTagger = new AutoTagger(this.app.vault, this.app, this.settings.ollamaUrl, this.settings.model, this.settings.autoTagConfig @@ -232,7 +231,8 @@ export default class OllamaPlugin extends Plugin { try { const content = await this.app.vault.read(file); if (signal.aborted) break; - await this.vaultVectorStore.indexFile(file, content); + const cache = this.app.metadataCache.getFileCache(file); + await this.vaultVectorStore.indexFile(file, content, cache ?? undefined); indexed++; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/note-context-builder.ts b/src/note-context-builder.ts index 76b06ee..8ce4953 100644 --- a/src/note-context-builder.ts +++ b/src/note-context-builder.ts @@ -131,24 +131,37 @@ export class NoteContextBuilder { } /** - * Reads file content and builds a VaultIndexEntry. + * Reads file content and builds a VaultIndexEntry using metadataCache. */ private async fileToIndexEntry(file: TFile): Promise { try { const content = await this.vault.cachedRead(file); - // Simple frontmatter + title parsing - const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/; - const fmMatch = content.match(frontmatterRegex); - let tags: string | undefined; - if (fmMatch) { - const tagMatch = fmMatch[1].match(/^tags:\s*(.+)$/m); - if (tagMatch) { - tags = tagMatch[1].trim(); - } + const cache = this.app.metadataCache.getFileCache(file); + + // Resolve title from metadataCache: frontmatter > first heading > basename + let title = file.basename; + const frontmatter = cache?.frontmatter + ? (cache.frontmatter as unknown as Record) + : undefined; + if ( + frontmatter?.title && + typeof frontmatter.title === 'string' + ) { + title = frontmatter.title; + } else if (cache?.headings && cache.headings.length > 0) { + title = cache.headings[0].heading; } - const titleMatch = content.match(/^# (.+)$/m); - const title = titleMatch ? titleMatch[1] : file.basename; - const body = content.replace(frontmatterRegex, '').slice(0, 500); + + // Resolve tags from metadataCache + let tags: string | undefined; + const frontmatterTags = frontmatter?.tags; + if (Array.isArray(frontmatterTags)) { + tags = frontmatterTags.join(', '); + } else if (typeof frontmatterTags === 'string') { + tags = frontmatterTags; + } + + const body = content.replace(/^---\n[\s\S]*?\n---\n/, '').slice(0, 500); return { path: file.path, title, diff --git a/src/tool-executor.ts b/src/tool-executor.ts index 6d74212..a01f3e0 100644 --- a/src/tool-executor.ts +++ b/src/tool-executor.ts @@ -260,6 +260,35 @@ export class ToolExecutor { } const fileContent = await this.readFileContent(path); + const file = this.getFile(path); + const cache = this.app.metadataCache.getFileCache(file); + + if (cache?.headings) { + const targetHeading = cache.headings.find((h) => h.heading === heading); + if (targetHeading) { + const startOffset = targetHeading.position.start.offset; + const headingLevel = targetHeading.level; + const nextHeading = cache.headings.find( + (h) => h.position.start.offset > startOffset && h.level <= headingLevel + ); + const sectionEnd = nextHeading ? nextHeading.position.start.offset : fileContent.length; + + const newFileContent = + fileContent.slice(0, startOffset) + + '#'.repeat(headingLevel) + + ' ' + + heading + + '\n' + + content + + '\n' + + fileContent.slice(sectionEnd); + + await this.writeFileContent(path, newFileContent); + return { success: true, message: `Section "${heading}" replaced successfully` }; + } + } + + // Fallback to regex when metadataCache is unavailable const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const headingRegex = new RegExp(`^(#{1,6})\\s+${escapedHeading}\\s*$`, 'm'); const match = fileContent.match(headingRegex); @@ -291,19 +320,27 @@ export class ToolExecutor { return { success: true, message: `Section "${heading}" replaced successfully` }; } - private parseFrontmatter(content: string): { + private parseFrontmatter( + file: TFile, + content: string + ): { exists: boolean; - raw: string; - fields: Record; + fields: Record; } { + const cache = this.app.metadataCache.getFileCache(file); + if (cache?.frontmatter) { + return { exists: true, fields: { ...cache.frontmatter } }; + } + + // Fallback to regex parsing when metadataCache is unavailable const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/; const match = content.match(frontmatterRegex); if (!match) { - return { exists: false, raw: '', fields: {} }; + return { exists: false, fields: {} }; } const raw = match[1]; - const fields: Record = {}; + const fields: Record = {}; for (const line of raw.split('\n')) { const idx = line.indexOf(':'); if (idx > 0) { @@ -315,11 +352,25 @@ export class ToolExecutor { } } - return { exists: true, raw, fields }; + return { exists: true, fields }; } - private serializeFrontmatter(fields: Record): string { - const lines = Object.entries(fields).map(([key, value]) => `${key}: ${value}`); + private serializeFrontmatter(fields: Record): string { + const lines: string[] = []; + for (const [key, value] of Object.entries(fields)) { + if (value === null || value === undefined) { + continue; + } + if (Array.isArray(value)) { + lines.push(`${key}: [${value.join(', ')}]`); + } else if (typeof value === 'string') { + lines.push(`${key}: ${value}`); + } else if (typeof value === 'number' || typeof value === 'boolean') { + lines.push(`${key}: ${value}`); + } else { + lines.push(`${key}: ${JSON.stringify(value)}`); + } + } return `---\n${lines.join('\n')}\n---\n`; } @@ -338,7 +389,8 @@ export class ToolExecutor { } const content = await this.readFileContent(path); - const parsed = this.parseFrontmatter(content); + const file = this.getFile(path); + const parsed = this.parseFrontmatter(file, content); const newFields = { ...parsed.fields }; for (const [key, value] of Object.entries(fields as Record)) { @@ -347,9 +399,9 @@ export class ToolExecutor { } else if (typeof value === 'string') { newFields[key] = value; } else if (Array.isArray(value)) { - newFields[key] = value.join(', '); + newFields[key] = value; } else if (typeof value === 'number' || typeof value === 'boolean') { - newFields[key] = String(value); + newFields[key] = value; } else { newFields[key] = JSON.stringify(value); } diff --git a/src/vault-vector-store.ts b/src/vault-vector-store.ts index 76de1ce..bf164aa 100644 --- a/src/vault-vector-store.ts +++ b/src/vault-vector-store.ts @@ -57,8 +57,16 @@ export class VaultVectorStore { /** * Index a single vault file by generating an embedding and storing it in ChromaDB. + * Optionally accepts cached metadata from Obsidian's metadataCache. */ - async indexFile(file: TFile, content: string): Promise { + async indexFile( + file: TFile, + content: string, + cache?: { + frontmatter?: Record; + headings?: Array<{ heading: string; level: number }>; + } + ): Promise { if (!this.collection || !this.config.enabled) return; if (!content.trim()) { // Remove empty files from index if they exist @@ -69,7 +77,8 @@ export class VaultVectorStore { try { const extracted = this.extractor.extractFromFile( { basename: file.basename, path: file.path }, - content + content, + cache ); const normalized = this.normalizer.normalize(extracted); diff --git a/tests/action-preview-builder.test.ts b/tests/action-preview-builder.test.ts index 8d5d4d2..82d1987 100644 --- a/tests/action-preview-builder.test.ts +++ b/tests/action-preview-builder.test.ts @@ -43,13 +43,23 @@ describe('ActionPreviewBuilder', () => { getAbstractFileByPath: jest.Mock; cachedRead: jest.Mock; }; + let mockApp: { + metadataCache: { + getFileCache: jest.Mock; + }; + }; beforeEach(() => { mockVault = { getAbstractFileByPath: jest.fn(), cachedRead: jest.fn().mockResolvedValue(''), }; - builder = new ActionPreviewBuilder(mockVault as unknown as any); + mockApp = { + metadataCache: { + getFileCache: jest.fn().mockReturnValue(null), + }, + }; + builder = new ActionPreviewBuilder(mockVault as unknown as any, mockApp as unknown as any); }); describe('buildPreview', () => { @@ -87,9 +97,16 @@ describe('ActionPreviewBuilder', () => { expect(preview.preview?.after).toBe('Existing content\nAppended'); }); - it('should build preview for replace_note_section', async () => { + it('should build preview for replace_note_section using metadataCache', async () => { mockVault.getAbstractFileByPath.mockReturnValue(new TFile()); mockVault.cachedRead.mockResolvedValue('# Title\n\n## Section A\nOld\n\n## Section B\nOther'); + mockApp.metadataCache.getFileCache.mockReturnValue({ + headings: [ + { heading: 'Title', level: 1, position: { start: { offset: 0 } } }, + { heading: 'Section A', level: 2, position: { start: { offset: 9 } } }, + { heading: 'Section B', level: 2, position: { start: { offset: 24 } } }, + ], + }); const call: ToolCall = { id: 'call_3', @@ -105,9 +122,31 @@ describe('ActionPreviewBuilder', () => { expect(preview.preview?.after).not.toContain('Old'); }); - it('should build preview for update_frontmatter', async () => { + it('should build preview for replace_note_section with regex fallback', async () => { + mockVault.getAbstractFileByPath.mockReturnValue(new TFile()); + mockVault.cachedRead.mockResolvedValue('# Title\n\n## Section A\nOld\n\n## Section B\nOther'); + mockApp.metadataCache.getFileCache.mockReturnValue(null); + + const call: ToolCall = { + id: 'call_3b', + type: 'function', + function: { + name: 'replace_note_section', + arguments: JSON.stringify({ path: 'Note.md', heading: 'Section A', content: 'New' }), + }, + }; + const preview = await builder.buildPreview(call); + expect(preview.operation).toBe('replace_section'); + expect(preview.preview?.after).toContain('New'); + expect(preview.preview?.after).not.toContain('Old'); + }); + + it('should build preview for update_frontmatter using metadataCache', async () => { mockVault.getAbstractFileByPath.mockReturnValue(new TFile()); mockVault.cachedRead.mockResolvedValue('---\ntitle: Old\n---\nBody'); + mockApp.metadataCache.getFileCache.mockReturnValue({ + frontmatter: { title: 'Old' }, + }); const call: ToolCall = { id: 'call_4', @@ -122,6 +161,24 @@ describe('ActionPreviewBuilder', () => { expect(preview.preview?.after).toContain('title: New'); }); + it('should build preview for update_frontmatter with regex fallback', async () => { + mockVault.getAbstractFileByPath.mockReturnValue(new TFile()); + mockVault.cachedRead.mockResolvedValue('---\ntitle: Old\n---\nBody'); + mockApp.metadataCache.getFileCache.mockReturnValue(null); + + const call: ToolCall = { + id: 'call_4b', + type: 'function', + function: { + name: 'update_frontmatter', + arguments: JSON.stringify({ path: 'Note.md', fields: { title: 'New' } }), + }, + }; + const preview = await builder.buildPreview(call); + expect(preview.operation).toBe('update_frontmatter'); + expect(preview.preview?.after).toContain('title: New'); + }); + it('should build preview for rename_note', async () => { const call: ToolCall = { id: 'call_5', diff --git a/tests/auto-organizer.test.ts b/tests/auto-organizer.test.ts index c797d37..0eebf6f 100644 --- a/tests/auto-organizer.test.ts +++ b/tests/auto-organizer.test.ts @@ -24,14 +24,22 @@ const createMockVault = () => ({ modify: mockModify, }); +const createMockApp = () => ({ + metadataCache: { + getFileCache: jest.fn().mockReturnValue(null), + }, +}); + describe('AutoTagger', () => { let tagger: AutoTagger; let mockVault: ReturnType; + let mockApp: ReturnType; beforeEach(() => { jest.clearAllMocks(); mockVault = createMockVault(); - tagger = new AutoTagger(mockVault as any, 'http://localhost:11434', 'llama3', { + mockApp = createMockApp(); + tagger = new AutoTagger(mockVault as any, mockApp as any, 'http://localhost:11434', 'llama3', { enabled: true, maxTagsPerNote: 5, minNoteLength: 50, @@ -44,9 +52,9 @@ describe('AutoTagger', () => { it('should return files without frontmatter', async () => { const files = [{ path: 'note1.md' }, { path: 'note2.md' }] as any[]; mockGetMarkdownFiles.mockReturnValue(files); - mockCachedRead - .mockResolvedValueOnce('No frontmatter here') - .mockResolvedValueOnce('---\ntags: existing\n---\nContent'); + mockApp.metadataCache.getFileCache + .mockReturnValueOnce(null) + .mockReturnValueOnce({ frontmatter: { tags: 'existing' } }); const result = await tagger.getUntaggedNotes(); expect(result).toHaveLength(1); @@ -56,7 +64,16 @@ describe('AutoTagger', () => { it('should return files with empty tags', async () => { const files = [{ path: 'note1.md' }] as any[]; mockGetMarkdownFiles.mockReturnValue(files); - mockCachedRead.mockResolvedValue('---\ntags: \n---\nContent'); + mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter: { tags: '' } }); + + const result = await tagger.getUntaggedNotes(); + expect(result).toHaveLength(1); + }); + + it('should return files with empty array tags', async () => { + const files = [{ path: 'note1.md' }] as any[]; + mockGetMarkdownFiles.mockReturnValue(files); + mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter: { tags: [] } }); const result = await tagger.getUntaggedNotes(); expect(result).toHaveLength(1); @@ -65,7 +82,7 @@ describe('AutoTagger', () => { it('should skip files with existing tags', async () => { const files = [{ path: 'note1.md' }] as any[]; mockGetMarkdownFiles.mockReturnValue(files); - mockCachedRead.mockResolvedValue('---\ntags: ai, ml\n---\nContent'); + mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter: { tags: ['ai', 'ml'] } }); const result = await tagger.getUntaggedNotes(); expect(result).toHaveLength(0); @@ -98,6 +115,7 @@ describe('AutoTagger', () => { it('should add frontmatter to note without it', async () => { const file = { path: 'note.md' } as any; mockRead.mockResolvedValue('Just content'); + mockApp.metadataCache.getFileCache.mockReturnValue(null); await tagger.applyTags(file, ['ai', 'ml']); @@ -107,6 +125,7 @@ describe('AutoTagger', () => { it('should update existing frontmatter with tags', async () => { const file = { path: 'note.md' } as any; mockRead.mockResolvedValue('---\ndate: 2024-01-01\n---\nContent'); + mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter: {} }); await tagger.applyTags(file, ['ai']); @@ -116,6 +135,7 @@ describe('AutoTagger', () => { it('should replace existing tags line', async () => { const file = { path: 'note.md' } as any; mockRead.mockResolvedValue('---\ntags: old\n---\nContent'); + mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter: { tags: 'old' } }); await tagger.applyTags(file, ['new']); @@ -134,7 +154,7 @@ describe('AutoTagger', () => { it('should skip notes that are too short', async () => { const files = [{ path: 'note.md' }] as any[]; mockGetMarkdownFiles.mockReturnValue(files); - mockCachedRead.mockResolvedValue('---\n---\nContent'); + mockApp.metadataCache.getFileCache.mockReturnValue(null); mockRead.mockResolvedValue('Short'); const result = await tagger.run(); diff --git a/tests/note-context-builder.test.ts b/tests/note-context-builder.test.ts index a77ed14..2450bfb 100644 --- a/tests/note-context-builder.test.ts +++ b/tests/note-context-builder.test.ts @@ -22,6 +22,7 @@ describe('NoteContextBuilder', () => { }, metadataCache: { getCache: jest.fn().mockReturnValue(null), + getFileCache: jest.fn().mockReturnValue(null), resolvedLinks: {}, }, }; diff --git a/tests/tool-executor.test.ts b/tests/tool-executor.test.ts index fdad990..9c207ec 100755 --- a/tests/tool-executor.test.ts +++ b/tests/tool-executor.test.ts @@ -14,6 +14,9 @@ interface MockVault { delete: (file: any) => Promise; } interface MockApp { + metadataCache: { + getFileCache: jest.Mock; + }; // Mock app properties if needed } interface MockNotice { @@ -53,7 +56,11 @@ describe('ToolExecutor', () => { rename: jest.fn().mockResolvedValue(undefined), delete: jest.fn().mockResolvedValue(undefined), }; - mockApp = {} as MockApp; + mockApp = { + metadataCache: { + getFileCache: jest.fn().mockReturnValue(null), + }, + } as MockApp; executor = new ToolExecutor(mockVault as unknown as any, mockApp as unknown as any); jest.clearAllMocks(); });