9d4eb9a62a
- Add `dryRun` option to preview proposed tag/link changes without applying - Add `targetFolder` config to restrict auto-tagging/linking to specific paths - Add `normalizeTags` with vocabulary building to canonicalize generated tags against existing vault tags - Update settings UI with new toggles and text inputs for both auto-tag and auto-link sections
385 lines
13 KiB
TypeScript
385 lines
13 KiB
TypeScript
import {
|
|
AutoTagger,
|
|
AutoLinker,
|
|
normalizeTag,
|
|
buildTagVocabulary,
|
|
normalizeTagsAgainstVocabulary,
|
|
} 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,
|
|
});
|
|
|
|
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();
|
|
mockApp = createMockApp();
|
|
tagger = new AutoTagger(mockVault as any, mockApp as any, 'http://localhost:11434', 'llama3', {
|
|
enabled: true,
|
|
maxTagsPerNote: 5,
|
|
minNoteLength: 50,
|
|
maxNoteLength: 8000,
|
|
tagPromptTemplate: 'Tags for {{title}}: {{content}}',
|
|
dryRun: false,
|
|
targetFolder: '',
|
|
normalizeTags: true,
|
|
});
|
|
});
|
|
|
|
describe('getUntaggedNotes', () => {
|
|
it('should return files without frontmatter', async () => {
|
|
const files = [{ path: 'note1.md' }, { path: 'note2.md' }] as any[];
|
|
mockGetMarkdownFiles.mockReturnValue(files);
|
|
mockApp.metadataCache.getFileCache
|
|
.mockReturnValueOnce(null)
|
|
.mockReturnValueOnce({ frontmatter: { tags: 'existing' } });
|
|
|
|
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);
|
|
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);
|
|
});
|
|
|
|
it('should skip files with existing tags', async () => {
|
|
const files = [{ path: 'note1.md' }] as any[];
|
|
mockGetMarkdownFiles.mockReturnValue(files);
|
|
mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter: { tags: ['ai', 'ml'] } });
|
|
|
|
const result = await tagger.getUntaggedNotes();
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it('should respect targetFolder', async () => {
|
|
tagger.updateConfig({ ...(tagger as any).config, targetFolder: 'Projects' });
|
|
const files = [
|
|
{ path: 'Projects/note1.md' },
|
|
{ path: 'Archive/note2.md' },
|
|
{ path: 'Projects/Sub/note3.md' },
|
|
] as any[];
|
|
mockGetMarkdownFiles.mockReturnValue(files);
|
|
mockApp.metadataCache.getFileCache.mockReturnValue(null);
|
|
|
|
const result = await tagger.getUntaggedNotes();
|
|
expect(result).toHaveLength(2);
|
|
expect(result.map((f: any) => f.path)).toEqual([
|
|
'Projects/note1.md',
|
|
'Projects/Sub/note3.md',
|
|
]);
|
|
});
|
|
});
|
|
|
|
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');
|
|
mockApp.metadataCache.getFileCache.mockReturnValue(null);
|
|
|
|
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');
|
|
mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter: {} });
|
|
|
|
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');
|
|
mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter: { tags: 'old' } });
|
|
|
|
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);
|
|
mockApp.metadataCache.getFileCache.mockReturnValue(null);
|
|
mockRead.mockResolvedValue('Short');
|
|
|
|
const result = await tagger.run();
|
|
expect(result.skipped).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it('should return dry-run proposals when dryRun is enabled', async () => {
|
|
tagger.updateConfig({ ...tagger['config'], dryRun: true });
|
|
const files = [{ path: 'note.md', basename: 'Note' }] as any[];
|
|
mockGetMarkdownFiles.mockReturnValue(files);
|
|
mockApp.metadataCache.getFileCache.mockReturnValue(null);
|
|
mockRead.mockResolvedValue('A longer note about AI and machine learning with lots of interesting content that exceeds the minimum length requirement for tagging.');
|
|
|
|
// Mock the ollamaClient on the tagger instance
|
|
(tagger as any).ollamaClient = {
|
|
chat: jest.fn().mockResolvedValue({
|
|
content: 'ai, machine-learning',
|
|
role: 'assistant',
|
|
}),
|
|
};
|
|
|
|
const result = await tagger.run();
|
|
expect(result.dryRun).toBeDefined();
|
|
expect(result.dryRun!.length).toBe(1);
|
|
expect(result.dryRun![0].proposedTags).toContain('ai');
|
|
expect(result.dryRun![0].proposedTags).toContain('machine-learning');
|
|
});
|
|
});
|
|
|
|
describe('normalizeTag', () => {
|
|
it('should lowercase and hyphenate tags', () => {
|
|
expect(normalizeTag('Machine Learning')).toBe('machine-learning');
|
|
expect(normalizeTag('AI')).toBe('ai');
|
|
expect(normalizeTag('obsidian-plugin')).toBe('obsidian-plugin');
|
|
});
|
|
|
|
it('should strip special characters', () => {
|
|
expect(normalizeTag('C++')).toBe('c');
|
|
expect(normalizeTag('Node.js')).toBe('nodejs');
|
|
});
|
|
|
|
it('should trim dashes', () => {
|
|
expect(normalizeTag('-leading')).toBe('leading');
|
|
expect(normalizeTag('trailing-')).toBe('trailing');
|
|
});
|
|
});
|
|
|
|
describe('buildTagVocabulary', () => {
|
|
it('should collect existing tags from vault frontmatter', () => {
|
|
const mockFiles = [{ path: 'a.md' }, { path: 'b.md' }] as any[];
|
|
const vault = createMockVault();
|
|
vault.getMarkdownFiles.mockReturnValue(mockFiles);
|
|
;(vault as any).app = {
|
|
metadataCache: {
|
|
getFileCache: jest.fn().mockImplementation((f: any) => {
|
|
if (f.path === 'a.md') return { frontmatter: { tags: ['machine-learning', 'ai'] } };
|
|
if (f.path === 'b.md') return { frontmatter: { tags: 'obsidian-plugin' } };
|
|
return null;
|
|
}),
|
|
},
|
|
};
|
|
|
|
const vocab = buildTagVocabulary(vault as any, (vault as any).app);
|
|
expect(vocab.get('machine-learning')).toBe('machine-learning');
|
|
expect(vocab.get('ai')).toBe('ai');
|
|
expect(vocab.get('obsidian-plugin')).toBe('obsidian-plugin');
|
|
});
|
|
});
|
|
|
|
describe('normalizeTagsAgainstVocabulary', () => {
|
|
it('should prefer canonical forms from vocabulary', () => {
|
|
const vocab = new Map([
|
|
['machine-learning', 'machine-learning'],
|
|
['obsidian', 'Obsidian'],
|
|
]);
|
|
const result = normalizeTagsAgainstVocabulary(
|
|
['Machine Learning', 'obsidian', 'new-tag'],
|
|
vocab
|
|
);
|
|
expect(result).toContain('machine-learning');
|
|
expect(result).toContain('Obsidian');
|
|
expect(result).toContain('new-tag');
|
|
});
|
|
|
|
it('should deduplicate normalized tags', () => {
|
|
const vocab = new Map();
|
|
const result = normalizeTagsAgainstVocabulary(['ai', 'AI', 'Ai'], vocab);
|
|
expect(result).toEqual(['ai']);
|
|
});
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it('should respect targetFolder', async () => {
|
|
linker.setTargetFolder('Projects');
|
|
const files = [{ path: 'Projects/note1.md' }, { path: 'Archive/note2.md' }] as any[];
|
|
mockVault.getMarkdownFiles.mockReturnValue(files);
|
|
mockVault.read.mockResolvedValue('Content');
|
|
mockIndexer.searchVault!.mockResolvedValue([]);
|
|
|
|
const result = await linker.run();
|
|
expect(mockVault.read).toHaveBeenCalledTimes(1);
|
|
expect(mockVault.read).toHaveBeenCalledWith(files[0]);
|
|
});
|
|
|
|
it('should return dry-run proposals when dryRun is true', async () => {
|
|
const files = [{ path: 'note.md' }] as any[];
|
|
mockVault.getMarkdownFiles.mockReturnValue(files);
|
|
mockVault.read.mockResolvedValue('Content');
|
|
mockIndexer.searchVault!.mockResolvedValue([
|
|
{ path: 'other.md', title: 'Other', score: 0.9, content: '' },
|
|
]);
|
|
|
|
const result = await linker.run(true);
|
|
expect(result.dryRun).toBeDefined();
|
|
expect(result.dryRun!.length).toBe(1);
|
|
expect(result.dryRun![0].relatedNotes[0].path).toBe('other.md');
|
|
expect(mockVault.modify).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|