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
This commit is contained in:
2026-05-20 20:36:47 +02:00
parent 3c7c4d58bb
commit 106abfa718
13 changed files with 419 additions and 139 deletions
+10
View File
@@ -29,10 +29,20 @@ export class Workspace {
export class App {
vault: Vault;
workspace: Workspace;
metadataCache: {
getFileCache: jest.Mock;
getFirstLinkpathDest: jest.Mock;
resolvedLinks: Record<string, Record<string, number>>;
};
constructor() {
this.vault = new Vault();
this.workspace = new Workspace();
this.metadataCache = {
getFileCache: jest.fn().mockReturnValue(null),
getFirstLinkpathDest: jest.fn().mockReturnValue(null),
resolvedLinks: {},
};
}
}
+96 -36
View File
@@ -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<string, unknown> {
@@ -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<string, unknown> = { ...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<string | undefined> {
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<string | undefined> {
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<string, string>;
fields: Record<string, unknown>;
} {
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<string, string> = {};
const fields: Record<string, unknown> = {};
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, string>): string {
const lines = Object.entries(fields).map(([key, value]) => `${key}: ${value}`);
private serializeFrontmatter(fields: Record<string, unknown>): 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`;
}
}
+56 -34
View File
@@ -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<string, unknown>)['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<TFile[]> {
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 };
+1 -1
View File
@@ -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(
+57 -28
View File
@@ -21,6 +21,14 @@ export interface ExtractedContent {
firstParagraph?: string;
}
interface CachedMetadataLike {
frontmatter?: Record<string, unknown>;
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
+3 -3
View File
@@ -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);
+26 -13
View File
@@ -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<VaultIndexEntry> {
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<string, unknown>)
: 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,
+63 -11
View File
@@ -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<string, string>;
fields: Record<string, unknown>;
} {
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<string, string> = {};
const fields: Record<string, unknown> = {};
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, string>): string {
const lines = Object.entries(fields).map(([key, value]) => `${key}: ${value}`);
private serializeFrontmatter(fields: Record<string, unknown>): 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<string, unknown>)) {
@@ -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);
}
+11 -2
View File
@@ -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<void> {
async indexFile(
file: TFile,
content: string,
cache?: {
frontmatter?: Record<string, unknown>;
headings?: Array<{ heading: string; level: number }>;
}
): Promise<void> {
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);
+60 -3
View File
@@ -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',
+27 -7
View File
@@ -24,14 +24,22 @@ const createMockVault = () => ({
modify: mockModify,
});
const createMockApp = () => ({
metadataCache: {
getFileCache: jest.fn().mockReturnValue(null),
},
});
describe('AutoTagger', () => {
let tagger: AutoTagger;
let mockVault: ReturnType<typeof createMockVault>;
let mockApp: ReturnType<typeof createMockApp>;
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();
+1
View File
@@ -22,6 +22,7 @@ describe('NoteContextBuilder', () => {
},
metadataCache: {
getCache: jest.fn().mockReturnValue(null),
getFileCache: jest.fn().mockReturnValue(null),
resolvedLinks: {},
},
};
+8 -1
View File
@@ -14,6 +14,9 @@ interface MockVault {
delete: (file: any) => Promise<void>;
}
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();
});