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