a8d8936b11
- Add AutoTagger: scans untagged notes, generates AI tags via Ollama, and applies them to frontmatter - Add AutoLinker: finds semantically related notes via vault search and appends a 'Related Notes' section with wiki-links - Add settings UI for both features with configurable thresholds, prompt templates, and limits - Add commands: 'Auto-Tag Untagged Notes' and 'Auto-Link Related Notes' - Add auto-organizer.test.ts with 17 tests covering tagging, linking, frontmatter manipulation, and filtering
227 lines
7.4 KiB
TypeScript
227 lines
7.4 KiB
TypeScript
import { AutoTagger, AutoLinker } from '../src/auto-organizer';
|
|
import { OllamaClient } from '../src/ollama-client';
|
|
|
|
// Mock dependencies
|
|
jest.mock('../src/ollama-client');
|
|
jest.mock('../src/utils', () => ({
|
|
Logger: {
|
|
info: jest.fn(),
|
|
warn: jest.fn(),
|
|
error: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
// Mock Obsidian
|
|
const mockModify = jest.fn();
|
|
const mockCachedRead = jest.fn();
|
|
const mockRead = jest.fn();
|
|
const mockGetMarkdownFiles = jest.fn();
|
|
|
|
const createMockVault = () => ({
|
|
getMarkdownFiles: mockGetMarkdownFiles,
|
|
cachedRead: mockCachedRead,
|
|
read: mockRead,
|
|
modify: mockModify,
|
|
});
|
|
|
|
describe('AutoTagger', () => {
|
|
let tagger: AutoTagger;
|
|
let mockVault: ReturnType<typeof createMockVault>;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockVault = createMockVault();
|
|
tagger = new AutoTagger(mockVault as any, 'http://localhost:11434', 'llama3', {
|
|
enabled: true,
|
|
maxTagsPerNote: 5,
|
|
minNoteLength: 50,
|
|
maxNoteLength: 8000,
|
|
tagPromptTemplate: 'Tags for {{title}}: {{content}}',
|
|
});
|
|
});
|
|
|
|
describe('getUntaggedNotes', () => {
|
|
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');
|
|
|
|
const result = await tagger.getUntaggedNotes();
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].path).toBe('note1.md');
|
|
});
|
|
|
|
it('should return files with empty tags', async () => {
|
|
const files = [{ path: 'note1.md' }] as any[];
|
|
mockGetMarkdownFiles.mockReturnValue(files);
|
|
mockCachedRead.mockResolvedValue('---\ntags: \n---\nContent');
|
|
|
|
const result = await tagger.getUntaggedNotes();
|
|
expect(result).toHaveLength(1);
|
|
});
|
|
|
|
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');
|
|
|
|
const result = await tagger.getUntaggedNotes();
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('parseTagResponse', () => {
|
|
it('should parse comma-separated tags', () => {
|
|
const tags = (tagger as any).parseTagResponse('ai, machine-learning, obsidian');
|
|
expect(tags).toEqual(['ai', 'machine-learning', 'obsidian']);
|
|
});
|
|
|
|
it('should clean up hashtags and quotes', () => {
|
|
const tags = (tagger as any).parseTagResponse('#ai, "machine learning", #obsidian');
|
|
expect(tags).toEqual(['ai', 'machine learning', 'obsidian']);
|
|
});
|
|
|
|
it('should limit to max tags', () => {
|
|
const tags = (tagger as any).parseTagResponse('a, b, c, d, e, f, g');
|
|
expect(tags).toHaveLength(5);
|
|
});
|
|
|
|
it('should filter empty tags', () => {
|
|
const tags = (tagger as any).parseTagResponse('ai,, , ml');
|
|
expect(tags).toEqual(['ai', 'ml']);
|
|
});
|
|
});
|
|
|
|
describe('applyTags', () => {
|
|
it('should add frontmatter to note without it', async () => {
|
|
const file = { path: 'note.md' } as any;
|
|
mockRead.mockResolvedValue('Just content');
|
|
|
|
await tagger.applyTags(file, ['ai', 'ml']);
|
|
|
|
expect(mockModify).toHaveBeenCalledWith(file, '---\ntags: ai, ml\n---\n\nJust content');
|
|
});
|
|
|
|
it('should update existing frontmatter with tags', async () => {
|
|
const file = { path: 'note.md' } as any;
|
|
mockRead.mockResolvedValue('---\ndate: 2024-01-01\n---\nContent');
|
|
|
|
await tagger.applyTags(file, ['ai']);
|
|
|
|
expect(mockModify).toHaveBeenCalledWith(file, expect.stringContaining('tags: ai'));
|
|
});
|
|
|
|
it('should replace existing tags line', async () => {
|
|
const file = { path: 'note.md' } as any;
|
|
mockRead.mockResolvedValue('---\ntags: old\n---\nContent');
|
|
|
|
await tagger.applyTags(file, ['new']);
|
|
|
|
expect(mockModify).toHaveBeenCalledWith(file, expect.stringContaining('tags: new'));
|
|
expect(mockModify).not.toHaveBeenCalledWith(file, expect.stringContaining('tags: old'));
|
|
});
|
|
});
|
|
|
|
describe('run', () => {
|
|
it('should return early when disabled', async () => {
|
|
tagger.updateConfig({ ...tagger['config'], enabled: false });
|
|
const result = await tagger.run();
|
|
expect(result.tagged).toBe(0);
|
|
});
|
|
|
|
it('should skip notes that are too short', async () => {
|
|
const files = [{ path: 'note.md' }] as any[];
|
|
mockGetMarkdownFiles.mockReturnValue(files);
|
|
mockCachedRead.mockResolvedValue('---\n---\nContent');
|
|
mockRead.mockResolvedValue('Short');
|
|
|
|
const result = await tagger.run();
|
|
expect(result.skipped).toBeGreaterThanOrEqual(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('AutoLinker', () => {
|
|
let linker: AutoLinker;
|
|
let mockVault: ReturnType<typeof createMockVault>;
|
|
let mockIndexer: { searchVault: jest.Mock };
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockVault = createMockVault();
|
|
mockIndexer = {
|
|
searchVault: jest.fn(),
|
|
};
|
|
|
|
linker = new AutoLinker(mockVault as any, mockIndexer as any, {
|
|
enabled: true,
|
|
maxLinksPerNote: 3,
|
|
similarityThreshold: 0.5,
|
|
});
|
|
});
|
|
|
|
describe('findRelatedNotes', () => {
|
|
it('should return related notes excluding self', async () => {
|
|
const file = { path: 'note.md', basename: 'Note' } as any;
|
|
mockVault.read.mockResolvedValue('Content about AI');
|
|
mockIndexer.searchVault!.mockResolvedValue([
|
|
{ path: 'other.md', title: 'Other', score: 0.9, content: '' },
|
|
{ path: 'note.md', title: 'Note', score: 0.95, content: '' },
|
|
]);
|
|
|
|
const result = await linker.findRelatedNotes(file);
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].path).toBe('other.md');
|
|
});
|
|
|
|
it('should filter by similarity threshold', async () => {
|
|
const file = { path: 'note.md', basename: 'Note' } as any;
|
|
mockVault.read.mockResolvedValue('Content');
|
|
mockIndexer.searchVault!.mockResolvedValue([
|
|
{ path: 'high.md', title: 'High', score: 0.8, content: '' },
|
|
{ path: 'low.md', title: 'Low', score: 0.3, content: '' },
|
|
]);
|
|
|
|
const result = await linker.findRelatedNotes(file);
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].path).toBe('high.md');
|
|
});
|
|
});
|
|
|
|
describe('addRelatedLinks', () => {
|
|
it('should add Related Notes section', async () => {
|
|
const file = { path: 'note.md' } as any;
|
|
mockVault.read.mockResolvedValue('# Note\n\nContent');
|
|
|
|
await linker.addRelatedLinks(file, [{ path: 'other.md', title: 'Other' }]);
|
|
|
|
expect(mockVault.modify).toHaveBeenCalledWith(
|
|
file,
|
|
expect.stringContaining('## Related Notes')
|
|
);
|
|
expect(mockVault.modify).toHaveBeenCalledWith(
|
|
file,
|
|
expect.stringContaining('[[Other|other]]')
|
|
);
|
|
});
|
|
|
|
it('should skip if Related Notes already exists', async () => {
|
|
const file = { path: 'note.md' } as any;
|
|
mockVault.read.mockResolvedValue('# Note\n\n## Related Notes\nAlready linked');
|
|
|
|
await linker.addRelatedLinks(file, [{ path: 'other.md', title: 'Other' }]);
|
|
expect(mockVault.modify).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('run', () => {
|
|
it('should return early when disabled', async () => {
|
|
linker.updateConfig({ enabled: false, maxLinksPerNote: 3, similarityThreshold: 0.5 });
|
|
const result = await linker.run();
|
|
expect(result.linked).toBe(0);
|
|
});
|
|
});
|
|
});
|