Add dry-run mode, target folder filtering, and tag normalization

- 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
This commit is contained in:
2026-05-20 22:04:05 +02:00
parent 2db7c34920
commit 9d4eb9a62a
6 changed files with 373 additions and 8 deletions
+174 -6
View File
@@ -9,6 +9,9 @@ export interface AutoOrganizeConfig {
minNoteLength: number;
maxNoteLength: number;
tagPromptTemplate: string;
dryRun: boolean;
targetFolder: string;
normalizeTags: boolean;
}
export const DEFAULT_AUTO_ORGANIZE_CONFIG: AutoOrganizeConfig = {
@@ -18,8 +21,96 @@ export const DEFAULT_AUTO_ORGANIZE_CONFIG: AutoOrganizeConfig = {
maxNoteLength: 8000,
tagPromptTemplate:
'Given the following note, suggest {{maxTags}} relevant, concise tags that describe its content.\n\nReturn ONLY a comma-separated list of tags (no quotes, no numbering, no explanations).\n\nTitle: {{title}}\n\nContent:\n{{content}}',
dryRun: false,
targetFolder: '',
normalizeTags: true,
};
export interface ProposedTagChange {
file: TFile;
proposedTags: string[];
currentTags?: string[];
}
export interface ProposedLinkChange {
file: TFile;
relatedNotes: { path: string; title: string; score: number }[];
}
export interface DryRunResult {
tagChanges: ProposedTagChange[];
linkChanges: ProposedLinkChange[];
}
/**
* Normalize a tag string to lowercase, hyphenated, trimmed form.
*/
export function normalizeTag(raw: string): string {
return raw
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
/**
* Build a vocabulary map from existing vault tags.
* Maps normalized tag -> preferred canonical form (first seen).
*/
export function buildTagVocabulary(vault: Vault, app: App): Map<string, string> {
const vocab = new Map<string, string>();
const files = vault.getMarkdownFiles();
for (const file of files) {
try {
const cache = app.metadataCache.getFileCache(file);
const frontmatter = cache?.frontmatter;
const rawTags: unknown = frontmatter?.tags;
const tagList: string[] = [];
if (Array.isArray(rawTags)) {
tagList.push(...rawTags.map(String));
} else if (typeof rawTags === 'string') {
tagList.push(
...rawTags
.split(/[,\n]+/)
.map((t) => t.trim())
.filter((t) => t.length > 0)
);
}
for (const tag of tagList) {
const norm = normalizeTag(tag);
if (norm.length > 0 && !vocab.has(norm)) {
vocab.set(norm, tag);
}
}
} catch {
// skip
}
}
return vocab;
}
/**
* Normalize a list of tags against a vocabulary.
*/
export function normalizeTagsAgainstVocabulary(
tags: string[],
vocab: Map<string, string>
): string[] {
const result: string[] = [];
const seen = new Set<string>();
for (const tag of tags) {
const norm = normalizeTag(tag);
if (seen.has(norm)) continue;
seen.add(norm);
// Use canonical form if in vocabulary, otherwise use normalized form
const canonical = vocab.get(norm);
result.push(canonical ?? norm);
}
return result;
}
/**
* Automatically tags untagged notes using the AI model.
*/
@@ -46,6 +137,18 @@ export class AutoTagger {
this.config = config;
}
/**
* Check if a file is inside the target folder.
*/
private isInTargetFolder(file: TFile): boolean {
if (!this.config.targetFolder || this.config.targetFolder.trim().length === 0) {
return true;
}
const target = this.config.targetFolder.replace(/\/$/, '').trim();
const fileFolder = file.path.split('/').slice(0, -1).join('/');
return fileFolder === target || fileFolder.startsWith(`${target}/`);
}
/**
* Check if a note has meaningful tags using metadataCache.
*/
@@ -70,13 +173,14 @@ export class AutoTagger {
/**
* Find all markdown files that lack a `tags` frontmatter field.
* Respects targetFolder config.
*/
getUntaggedNotes(): TFile[] {
const files = this.vault.getMarkdownFiles();
const untagged: TFile[] = [];
for (const file of files) {
try {
if (!this.hasTags(file)) {
if (!this.hasTags(file) && this.isInTargetFolder(file)) {
untagged.push(file);
}
} catch {
@@ -107,7 +211,13 @@ export class AutoTagger {
const response = await this.ollamaClient.chat([{ role: 'user', content: prompt }]);
const tags = this.parseTagResponse(response.content);
let tags = this.parseTagResponse(response.content);
if (this.config.normalizeTags) {
const vocab = buildTagVocabulary(this.vault, this.app);
tags = normalizeTagsAgainstVocabulary(tags, vocab);
}
Logger.info(`Generated tags for ${file.path}: ${tags.join(', ')}`, 'auto-tagger');
return tags;
} catch (error) {
@@ -167,8 +277,9 @@ export class AutoTagger {
/**
* Run auto-tagging on all untagged notes.
* If dryRun is enabled, returns proposed changes without applying.
*/
async run(): Promise<{ tagged: number; skipped: number }> {
async run(): Promise<{ tagged: number; skipped: number; dryRun?: ProposedTagChange[] }> {
if (!this.config.enabled) {
new Notice('Auto-tagging is disabled in settings.');
return { tagged: 0, skipped: 0 };
@@ -180,6 +291,23 @@ export class AutoTagger {
return { tagged: 0, skipped: 0 };
}
if (this.config.dryRun) {
new Notice(`Dry-run: evaluating ${untagged.length} notes...`);
const proposals: ProposedTagChange[] = [];
let skipped = 0;
for (const file of untagged) {
const tags = await this.generateTags(file);
if (tags.length > 0) {
proposals.push({ file, proposedTags: tags });
} else {
skipped++;
}
await new Promise((resolve) => setTimeout(resolve, 300));
}
new Notice(`Dry-run complete: ${proposals.length} proposed tag changes, ${skipped} skipped.`);
return { tagged: proposals.length, skipped, dryRun: proposals };
}
new Notice(`Auto-tagging ${untagged.length} notes...`);
let tagged = 0;
let skipped = 0;
@@ -216,15 +344,18 @@ export class AutoLinker {
private vault: Vault;
private vaultIndexer: VaultIndexer;
private config: { enabled: boolean; maxLinksPerNote: number; similarityThreshold: number };
private targetFolder: string;
constructor(
vault: Vault,
vaultIndexer: VaultIndexer,
config: { enabled: boolean; maxLinksPerNote: number; similarityThreshold: number }
config: { enabled: boolean; maxLinksPerNote: number; similarityThreshold: number },
targetFolder: string = ''
) {
this.vault = vault;
this.vaultIndexer = vaultIndexer;
this.config = config;
this.targetFolder = targetFolder;
}
updateConfig(config: {
@@ -235,6 +366,22 @@ export class AutoLinker {
this.config = config;
}
setTargetFolder(folder: string): void {
this.targetFolder = folder;
}
/**
* Check if a file is inside the target folder.
*/
private isInTargetFolder(file: TFile): boolean {
if (!this.targetFolder || this.targetFolder.trim().length === 0) {
return true;
}
const target = this.targetFolder.replace(/\/$/, '').trim();
const fileFolder = file.path.split('/').slice(0, -1).join('/');
return fileFolder === target || fileFolder.startsWith(`${target}/`);
}
/**
* Find related notes for a given file using semantic search.
*/
@@ -290,14 +437,35 @@ export class AutoLinker {
/**
* Run auto-linking on all notes.
* If dryRun is enabled, returns proposed changes without applying.
*/
async run(): Promise<{ linked: number; skipped: number }> {
async run(
dryRun = false
): Promise<{ linked: number; skipped: number; dryRun?: ProposedLinkChange[] }> {
if (!this.config.enabled) {
new Notice('Auto-linking is disabled in settings.');
return { linked: 0, skipped: 0 };
}
const files = this.vault.getMarkdownFiles();
const files = this.vault.getMarkdownFiles().filter((f) => this.isInTargetFolder(f));
if (dryRun) {
new Notice(`Dry-run: evaluating ${files.length} notes for links...`);
const proposals: ProposedLinkChange[] = [];
let skipped = 0;
for (const file of files) {
const related = await this.findRelatedNotes(file);
if (related.length > 0) {
proposals.push({ file, relatedNotes: related });
} else {
skipped++;
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
new Notice(`Dry-run complete: ${proposals.length} proposed link changes.`);
return { linked: proposals.length, skipped, dryRun: proposals };
}
new Notice(`Auto-linking ${files.length} notes...`);
let linked = 0;
+5
View File
@@ -27,10 +27,15 @@ export const DEFAULT_SETTINGS = {
maxNoteLength: 8000,
tagPromptTemplate:
'Given the following note, suggest {{maxTags}} relevant, concise tags that describe its content.\n\nReturn ONLY a comma-separated list of tags (no quotes, no numbering, no explanations).\n\nTitle: {{title}}\n\nContent:\n{{content}}',
dryRun: false,
targetFolder: '',
normalizeTags: true,
},
autoLinkConfig: {
enabled: false,
maxLinksPerNote: 3,
similarityThreshold: 0.6,
targetFolder: '',
dryRun: false,
},
};
+45 -1
View File
@@ -159,7 +159,7 @@ export default class OllamaPlugin extends Plugin {
if (!this.autoLinker) {
const vaultIndexer = new VaultIndexer(this.app.vault, undefined, this.vaultVectorStore);
this.autoLinker = new AutoLinker(this.app.vault, vaultIndexer, this.settings.autoLinkConfig);
this.autoLinker = new AutoLinker(this.app.vault, vaultIndexer, this.settings.autoLinkConfig, this.settings.autoLinkConfig.targetFolder);
} else {
this.autoLinker.updateConfig(this.settings.autoLinkConfig);
}
@@ -709,6 +709,28 @@ class OllamaSettingTab extends PluginSettingTab {
})
);
new Setting(containerEl)
.setName('Normalize Tags')
.setDesc('Normalize generated tags against existing vault tag vocabulary (e.g., prefer "machine-learning" over "machine learning")')
.addToggle((toggle) =>
toggle.setValue(this.plugin.settings.autoTagConfig.normalizeTags).onChange(async (value) => {
this.plugin.settings.autoTagConfig.normalizeTags = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Target Folder (Auto-Tag)')
.setDesc('Only auto-tag notes inside this folder path. Leave empty for all notes.')
.addText((text) =>
text
.setValue(this.plugin.settings.autoTagConfig.targetFolder)
.onChange(async (value) => {
this.plugin.settings.autoTagConfig.targetFolder = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Tag Prompt Template')
.setDesc(
@@ -769,6 +791,28 @@ class OllamaSettingTab extends PluginSettingTab {
})
);
new Setting(containerEl)
.setName('Target Folder (Auto-Link)')
.setDesc('Only add related links to notes inside this folder path. Leave empty for all notes.')
.addText((text) =>
text
.setValue(this.plugin.settings.autoLinkConfig.targetFolder)
.onChange(async (value) => {
this.plugin.settings.autoLinkConfig.targetFolder = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Dry Run Mode')
.setDesc('Preview proposed link changes without applying them')
.addToggle((toggle) =>
toggle.setValue(this.plugin.settings.autoLinkConfig.dryRun).onChange(async (value) => {
this.plugin.settings.autoLinkConfig.dryRun = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Auto-Link Similarity Threshold')
.setDesc('Minimum similarity score for notes to be considered related (default: 0.6)')
+5
View File
@@ -239,11 +239,16 @@ export interface PluginSettings {
minNoteLength: number;
maxNoteLength: number;
tagPromptTemplate: string;
dryRun: boolean;
targetFolder: string;
normalizeTags: boolean;
};
autoLinkConfig: {
enabled: boolean;
maxLinksPerNote: number;
similarityThreshold: number;
targetFolder: string;
dryRun: boolean;
};
}
+139 -1
View File
@@ -1,4 +1,10 @@
import { AutoTagger, AutoLinker } from '../src/auto-organizer';
import {
AutoTagger,
AutoLinker,
normalizeTag,
buildTagVocabulary,
normalizeTagsAgainstVocabulary,
} from '../src/auto-organizer';
import { OllamaClient } from '../src/ollama-client';
// Mock dependencies
@@ -45,6 +51,9 @@ describe('AutoTagger', () => {
minNoteLength: 50,
maxNoteLength: 8000,
tagPromptTemplate: 'Tags for {{title}}: {{content}}',
dryRun: false,
targetFolder: '',
normalizeTags: true,
});
});
@@ -87,6 +96,24 @@ describe('AutoTagger', () => {
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', () => {
@@ -160,6 +187,90 @@ describe('AutoTagger', () => {
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']);
});
});
});
@@ -242,5 +353,32 @@ describe('AutoLinker', () => {
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();
});
});
});
+5
View File
@@ -59,11 +59,16 @@ const mockSettings: PluginSettings = {
minNoteLength: 50,
maxNoteLength: 8000,
tagPromptTemplate: 'Tags: {{content}}',
dryRun: false,
targetFolder: '',
normalizeTags: true,
},
autoLinkConfig: {
enabled: false,
maxLinksPerNote: 3,
similarityThreshold: 0.6,
targetFolder: '',
dryRun: false,
},
};