feat: add auto-tag and auto-link note organization features

- 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
This commit is contained in:
2026-05-20 00:12:30 +02:00
parent cb621c83b5
commit a8d8936b11
7 changed files with 1186 additions and 41 deletions
+413 -41
View File
@@ -3064,7 +3064,7 @@ __export(main_exports, {
default: () => OllamaPlugin
});
module.exports = __toCommonJS(main_exports);
var import_obsidian4 = require("obsidian");
var import_obsidian5 = require("obsidian");
// src/chat-view.ts
var import_obsidian3 = require("obsidian");
@@ -9437,6 +9437,18 @@ var DEFAULT_SETTINGS = {
collectionName: "ollama_vault_index",
embeddingModel: "nomic-embed-text",
chromaURL: "http://localhost:8000"
},
autoTagConfig: {
enabled: false,
maxTagsPerNote: 5,
minNoteLength: 50,
maxNoteLength: 8e3,
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}}"
},
autoLinkConfig: {
enabled: false,
maxLinksPerNote: 3,
similarityThreshold: 0.6
}
};
@@ -9915,8 +9927,226 @@ var VaultVectorStore = class {
}
};
// src/auto-organizer.ts
var import_obsidian4 = require("obsidian");
var AutoTagger = class {
constructor(vault, ollamaUrl, model, config) {
this.vault = vault;
this.config = config;
this.ollamaClient = new OllamaClient(ollamaUrl, model);
}
updateConfig(config) {
this.config = config;
}
/**
* Find all markdown files that lack a `tags` frontmatter field.
*/
async getUntaggedNotes() {
const files = this.vault.getMarkdownFiles();
const untagged = [];
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") {
untagged.push(file);
}
} catch {
}
}
return untagged;
}
/**
* Generate tags for a single note using the AI.
*/
async generateTags(file) {
try {
const content = await this.vault.read(file);
const length = content.length;
if (length < this.config.minNoteLength || length > this.config.maxNoteLength) {
Logger.info(`Skipping ${file.path}: content length ${length} out of range`, "auto-tagger");
return [];
}
const truncated = content.substring(0, this.config.maxNoteLength);
const prompt = this.config.tagPromptTemplate.replace(/\{\{maxTags\}\}/g, String(this.config.maxTagsPerNote)).replace(/\{\{title\}\}/g, file.basename).replace(/\{\{content\}\}/g, truncated);
const response = await this.ollamaClient.chat([{ role: "user", content: prompt }]);
const tags = this.parseTagResponse(response.content);
Logger.info(`Generated tags for ${file.path}: ${tags.join(", ")}`, "auto-tagger");
return tags;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
Logger.warn(`Failed to generate tags for ${file.path}: ${errorMessage}`, "auto-tagger");
return [];
}
}
/**
* Apply tags to a note's frontmatter.
*/
async applyTags(file, tags) {
if (tags.length === 0) return;
try {
const content = await this.vault.read(file);
const existingFrontmatter = content.match(/^---\n([\s\S]*?)\n---\n/);
let newContent;
if (existingFrontmatter) {
const frontmatterText = existingFrontmatter[1];
const hasTagsLine = /^tags:/m.test(frontmatterText);
if (hasTagsLine) {
const updatedFrontmatter = frontmatterText.replace(
/^tags:.*$/m,
`tags: ${tags.join(", ")}`
);
newContent = content.replace(existingFrontmatter[0], `---
${updatedFrontmatter}
---
`);
} else {
const updatedFrontmatter = `tags: ${tags.join(", ")}
${frontmatterText}`;
newContent = content.replace(existingFrontmatter[0], `---
${updatedFrontmatter}
---
`);
}
} else {
newContent = `---
tags: ${tags.join(", ")}
---
${content}`;
}
await this.vault.modify(file, newContent);
Logger.info(`Tagged ${file.path} with: ${tags.join(", ")}`, "auto-tagger");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
Logger.warn(`Failed to apply tags to ${file.path}: ${errorMessage}`, "auto-tagger");
}
}
/**
* Run auto-tagging on all untagged notes.
*/
async run() {
if (!this.config.enabled) {
new import_obsidian4.Notice("Auto-tagging is disabled in settings.");
return { tagged: 0, skipped: 0 };
}
const untagged = await this.getUntaggedNotes();
if (untagged.length === 0) {
new import_obsidian4.Notice("No untagged notes found.");
return { tagged: 0, skipped: 0 };
}
new import_obsidian4.Notice(`Auto-tagging ${untagged.length} notes...`);
let tagged = 0;
let skipped = 0;
for (const file of untagged) {
const tags = await this.generateTags(file);
if (tags.length > 0) {
await this.applyTags(file, tags);
tagged++;
} else {
skipped++;
}
await new Promise((resolve) => setTimeout(resolve, 300));
}
new import_obsidian4.Notice(`Auto-tagging complete: ${tagged} tagged, ${skipped} skipped.`);
return { tagged, skipped };
}
parseTagResponse(response) {
return response.split(/[,\n]+/).map((t) => t.trim().replace(/^#+/, "").replace(/['"]+/g, "")).filter((t) => t.length > 0 && t.length < 50).slice(0, this.config.maxTagsPerNote);
}
};
var AutoLinker = class {
constructor(vault, vaultIndexer, config) {
this.vault = vault;
this.vaultIndexer = vaultIndexer;
this.config = config;
}
updateConfig(config) {
this.config = config;
}
/**
* Find related notes for a given file using semantic search.
*/
async findRelatedNotes(file) {
try {
const content = await this.vault.read(file);
const truncated = content.substring(0, 2e3);
const results = await this.vaultIndexer.searchVault(
truncated,
this.config.maxLinksPerNote + 5
);
return results.filter((r) => r.path !== file.path && r.score >= this.config.similarityThreshold).slice(0, this.config.maxLinksPerNote).map((r) => ({ path: r.path, title: r.title, score: r.score }));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
Logger.warn(`Failed to find related notes for ${file.path}: ${errorMessage}`, "auto-linker");
return [];
}
}
/**
* Add a "Related Notes" section to a note if it doesn't already exist.
*/
async addRelatedLinks(file, related) {
if (related.length === 0) return;
try {
const content = await this.vault.read(file);
if (/^## Related Notes/m.test(content)) {
Logger.info(`Skipping ${file.path}: already has Related Notes section`, "auto-linker");
return;
}
const links = related.map((r) => `- [[${r.title}|${r.path.replace(/\.md$/, "")}]]`).join("\n");
const section = `
## Related Notes
${links}
`;
await this.vault.modify(file, content + section);
Logger.info(`Added ${related.length} related links to ${file.path}`, "auto-linker");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
Logger.warn(`Failed to add links to ${file.path}: ${errorMessage}`, "auto-linker");
}
}
/**
* Run auto-linking on all notes.
*/
async run() {
if (!this.config.enabled) {
new import_obsidian4.Notice("Auto-linking is disabled in settings.");
return { linked: 0, skipped: 0 };
}
const files = this.vault.getMarkdownFiles();
new import_obsidian4.Notice(`Auto-linking ${files.length} notes...`);
let linked = 0;
let skipped = 0;
for (const file of files) {
const related = await this.findRelatedNotes(file);
if (related.length > 0) {
await this.addRelatedLinks(file, related);
linked++;
} else {
skipped++;
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
new import_obsidian4.Notice(`Auto-linking complete: ${linked} linked, ${skipped} skipped.`);
return { linked, skipped };
}
};
// src/main.ts
var OllamaPlugin = class extends import_obsidian4.Plugin {
var OllamaPlugin = class extends import_obsidian5.Plugin {
constructor() {
super(...arguments);
this.settings = DEFAULT_SETTINGS;
@@ -9945,7 +10175,7 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
name: "Clear Semantic Cache",
callback: async () => {
await this.clearSemanticCache();
new import_obsidian4.Notice("Semantic cache cleared.");
new import_obsidian5.Notice("Semantic cache cleared.");
}
});
this.addCommand({
@@ -9953,16 +10183,38 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
name: "Clear Vault Index",
callback: async () => {
await this.clearVaultIndex();
new import_obsidian4.Notice("Vault index cleared.");
new import_obsidian5.Notice("Vault index cleared.");
}
});
this.addCommand({
id: "rebuild-vault-index",
name: "Rebuild Vault Index",
callback: async () => {
new import_obsidian4.Notice("Rebuilding vault index...");
new import_obsidian5.Notice("Rebuilding vault index...");
await this.rebuildVaultIndex();
new import_obsidian4.Notice("Vault index rebuilt.");
new import_obsidian5.Notice("Vault index rebuilt.");
}
});
this.addCommand({
id: "auto-tag-notes",
name: "Auto-Tag Untagged Notes",
callback: async () => {
await this.initializeAutoOrganizer();
if (this.autoTagger) {
new import_obsidian5.Notice("Auto-tagging untagged notes...");
await this.autoTagger.run();
}
}
});
this.addCommand({
id: "auto-link-notes",
name: "Auto-Link Related Notes",
callback: async () => {
await this.initializeAutoOrganizer();
if (this.autoLinker) {
new import_obsidian5.Notice("Auto-linking related notes...");
await this.autoLinker.run();
}
}
});
this.addSettingTab(new OllamaSettingTab(this.app, this));
@@ -9974,7 +10226,7 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
try {
await this.semanticCache.initialize();
} catch {
new import_obsidian4.Notice("Semantic cache initialization failed. Check console for details.");
new import_obsidian5.Notice("Semantic cache initialization failed. Check console for details.");
}
}
this.registerVaultEventListeners();
@@ -9993,6 +10245,24 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
async saveSettings() {
await this.saveData(this.settings);
}
async initializeAutoOrganizer() {
if (!this.autoTagger) {
this.autoTagger = new AutoTagger(
this.app.vault,
this.settings.ollamaUrl,
this.settings.model,
this.settings.autoTagConfig
);
} else {
this.autoTagger.updateConfig(this.settings.autoTagConfig);
}
if (!this.autoLinker) {
const vaultIndexer = new VaultIndexer(this.app.vault, void 0, this.vaultVectorStore);
this.autoLinker = new AutoLinker(this.app.vault, vaultIndexer, this.settings.autoLinkConfig);
} else {
this.autoLinker.updateConfig(this.settings.autoLinkConfig);
}
}
async initializeVaultVectorStore() {
this.cancelBackgroundIndexing();
await this.awaitBackgroundIndexing();
@@ -10010,7 +10280,7 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
this.indexingAbortController = void 0;
});
} catch {
new import_obsidian4.Notice("Vault vector store initialization failed. Check console for details.");
new import_obsidian5.Notice("Vault vector store initialization failed. Check console for details.");
}
}
cancelBackgroundIndexing() {
@@ -10061,7 +10331,7 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
this.settings.lastIndexTime = Date.now();
await this.saveSettings();
Logger.info(`Vault indexing complete: ${indexed}/${files.length} files indexed.`, "main");
new import_obsidian4.Notice(`Vault index updated: ${indexed} files indexed.`);
new import_obsidian5.Notice(`Vault index updated: ${indexed} files indexed.`);
}
}
async rebuildVaultIndex() {
@@ -10087,7 +10357,7 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
registerVaultEventListeners() {
this.registerEvent(
this.app.vault.on("create", (file) => {
if (file instanceof import_obsidian4.TFile && file.extension === "md" && this.vaultVectorStore) {
if (file instanceof import_obsidian5.TFile && file.extension === "md" && this.vaultVectorStore) {
void this.app.vault.read(file).then((content) => {
if (!this.currentIndexingPromise) {
void this.vaultVectorStore?.indexFile(file, content);
@@ -10098,7 +10368,7 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
);
this.registerEvent(
this.app.vault.on("modify", (file) => {
if (file instanceof import_obsidian4.TFile && file.extension === "md" && this.vaultVectorStore) {
if (file instanceof import_obsidian5.TFile && file.extension === "md" && this.vaultVectorStore) {
void this.app.vault.read(file).then((content) => {
if (!this.currentIndexingPromise) {
void this.vaultVectorStore?.indexFile(file, content);
@@ -10109,14 +10379,14 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
);
this.registerEvent(
this.app.vault.on("delete", (file) => {
if (file instanceof import_obsidian4.TFile && file.extension === "md" && this.vaultVectorStore) {
if (file instanceof import_obsidian5.TFile && file.extension === "md" && this.vaultVectorStore) {
void this.vaultVectorStore.deleteFile(file.path);
}
})
);
this.registerEvent(
this.app.vault.on("rename", (file, oldPath) => {
if (file instanceof import_obsidian4.TFile && file.extension === "md" && this.vaultVectorStore) {
if (file instanceof import_obsidian5.TFile && file.extension === "md" && this.vaultVectorStore) {
void this.vaultVectorStore.deleteFile(oldPath);
void this.app.vault.read(file).then((content) => {
if (!this.currentIndexingPromise) {
@@ -10156,7 +10426,7 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
});
}
};
var OllamaSettingTab = class extends import_obsidian4.PluginSettingTab {
var OllamaSettingTab = class extends import_obsidian5.PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
@@ -10165,42 +10435,42 @@ var OllamaSettingTab = class extends import_obsidian4.PluginSettingTab {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "Ollama Settings" });
new import_obsidian4.Setting(containerEl).setName("Ollama URL").setDesc("URL for your Ollama instance (default: http://localhost:11434)").addText(
new import_obsidian5.Setting(containerEl).setName("Ollama URL").setDesc("URL for your Ollama instance (default: http://localhost:11434)").addText(
(text) => text.setValue(this.plugin.settings.ollamaUrl).onChange(async (value) => {
this.plugin.settings.ollamaUrl = value;
await this.plugin.saveSettings();
})
);
new import_obsidian4.Setting(containerEl).setName("Model").setDesc("Ollama model to use (default: llama3)").addText(
new import_obsidian5.Setting(containerEl).setName("Model").setDesc("Ollama model to use (default: llama3)").addText(
(text) => text.setValue(this.plugin.settings.model).onChange(async (value) => {
this.plugin.settings.model = value;
await this.plugin.saveSettings();
})
);
new import_obsidian4.Setting(containerEl).setName("Vault Search Limit").setDesc("Maximum number of vault entries to include in context (default: 3)").addText(
new import_obsidian5.Setting(containerEl).setName("Vault Search Limit").setDesc("Maximum number of vault entries to include in context (default: 3)").addText(
(text) => text.setValue(String(this.plugin.settings.vaultSearchLimit)).onChange(async (value) => {
const parsed = parseInt(value);
if (!isNaN(parsed) && parsed > 0) {
this.plugin.settings.vaultSearchLimit = parsed;
await this.plugin.saveSettings();
} else {
new import_obsidian4.Notice("Vault search limit must be a positive integer.");
new import_obsidian5.Notice("Vault search limit must be a positive integer.");
}
})
);
new import_obsidian4.Setting(containerEl).setName("Max Message History").setDesc("Maximum number of messages to keep in conversation history (default: 50)").addText(
new import_obsidian5.Setting(containerEl).setName("Max Message History").setDesc("Maximum number of messages to keep in conversation history (default: 50)").addText(
(text) => text.setValue(String(this.plugin.settings.maxMessageHistory)).onChange(async (value) => {
const parsed = parseInt(value);
if (!isNaN(parsed) && parsed > 0) {
this.plugin.settings.maxMessageHistory = parsed;
await this.plugin.saveSettings();
} else {
new import_obsidian4.Notice("Max message history must be a positive integer.");
new import_obsidian5.Notice("Max message history must be a positive integer.");
}
})
);
containerEl.createEl("h3", { text: "Vault Semantic Index" });
new import_obsidian4.Setting(containerEl).setName("Enable Vault Semantic Index").setDesc(
new import_obsidian5.Setting(containerEl).setName("Enable Vault Semantic Index").setDesc(
"Automatically index vault notes into a vector database for semantic/RAG search. Requires ChromaDB and an embedding model."
).addToggle(
(toggle) => toggle.setValue(this.plugin.settings.vaultIndexConfig.enabled).onChange(async (value) => {
@@ -10208,7 +10478,7 @@ var OllamaSettingTab = class extends import_obsidian4.PluginSettingTab {
await this.plugin.saveSettings();
this.plugin.notifyChatViews();
if (value) {
new import_obsidian4.Notice("Vault semantic index enabled. Rebuilding index...");
new import_obsidian5.Notice("Vault semantic index enabled. Rebuilding index...");
await this.plugin.initializeVaultVectorStore();
await this.plugin.rebuildVaultIndex();
} else {
@@ -10218,7 +10488,7 @@ var OllamaSettingTab = class extends import_obsidian4.PluginSettingTab {
}
})
);
new import_obsidian4.Setting(containerEl).setName("Vault Index ChromaDB URL").setDesc(
new import_obsidian5.Setting(containerEl).setName("Vault Index ChromaDB URL").setDesc(
"URL for your ChromaDB instance used for the vault index (default: http://localhost:8000)"
).addText(
(text) => text.setValue(this.plugin.settings.vaultIndexConfig.chromaURL || "http://localhost:8000").onChange(async (value) => {
@@ -10227,7 +10497,7 @@ var OllamaSettingTab = class extends import_obsidian4.PluginSettingTab {
await this.plugin.saveSettings();
})
);
new import_obsidian4.Setting(containerEl).setName("Vault Index Embedding Model").setDesc(
new import_obsidian5.Setting(containerEl).setName("Vault Index Embedding Model").setDesc(
"Ollama model used to generate embeddings for vault notes (default: nomic-embed-text)"
).addText(
(text) => text.setValue(this.plugin.settings.vaultIndexConfig.embeddingModel).onChange(async (value) => {
@@ -10235,7 +10505,7 @@ var OllamaSettingTab = class extends import_obsidian4.PluginSettingTab {
await this.plugin.saveSettings();
})
);
new import_obsidian4.Setting(containerEl).setName("Vault Index Similarity Threshold").setDesc(
new import_obsidian5.Setting(containerEl).setName("Vault Index Similarity Threshold").setDesc(
"Minimum cosine similarity (0\u20131) for a vault search hit. Higher values require closer matches (default: 0.75)."
).addText(
(text) => text.setValue(String(this.plugin.settings.vaultIndexConfig.similarityThreshold)).onChange(async (value) => {
@@ -10244,54 +10514,54 @@ var OllamaSettingTab = class extends import_obsidian4.PluginSettingTab {
this.plugin.settings.vaultIndexConfig.similarityThreshold = parsed;
await this.plugin.saveSettings();
} else {
new import_obsidian4.Notice("Similarity threshold must be a number between 0 and 1.");
new import_obsidian5.Notice("Similarity threshold must be a number between 0 and 1.");
}
})
);
new import_obsidian4.Setting(containerEl).setName("Rebuild Vault Index").setDesc("Delete and rebuild the entire vault semantic index").addButton(
new import_obsidian5.Setting(containerEl).setName("Rebuild Vault Index").setDesc("Delete and rebuild the entire vault semantic index").addButton(
(button) => button.setButtonText("Rebuild Index").onClick(async () => {
try {
new import_obsidian4.Notice("Rebuilding vault index...");
new import_obsidian5.Notice("Rebuilding vault index...");
await this.plugin.rebuildVaultIndex();
new import_obsidian4.Notice("Vault index rebuilt.");
new import_obsidian5.Notice("Vault index rebuilt.");
} catch {
new import_obsidian4.Notice("Failed to rebuild vault index. Is ChromaDB running?");
new import_obsidian5.Notice("Failed to rebuild vault index. Is ChromaDB running?");
}
})
);
new import_obsidian4.Setting(containerEl).setName("Clear Vault Index").setDesc("Delete all indexed vault notes from ChromaDB").addButton(
new import_obsidian5.Setting(containerEl).setName("Clear Vault Index").setDesc("Delete all indexed vault notes from ChromaDB").addButton(
(button) => button.setButtonText("Clear Index").onClick(async () => {
try {
await this.plugin.clearVaultIndex();
new import_obsidian4.Notice("Vault index cleared.");
new import_obsidian5.Notice("Vault index cleared.");
} catch {
new import_obsidian4.Notice("Failed to clear vault index. Is ChromaDB running?");
new import_obsidian5.Notice("Failed to clear vault index. Is ChromaDB running?");
}
})
);
containerEl.createEl("h3", { text: "Semantic Cache" });
new import_obsidian4.Setting(containerEl).setName("Enable Semantic Cache").setDesc("Use semantic cache to store and retrieve previous responses").addToggle(
new import_obsidian5.Setting(containerEl).setName("Enable Semantic Cache").setDesc("Use semantic cache to store and retrieve previous responses").addToggle(
(toggle) => toggle.setValue(this.plugin.settings.cacheConfig.enabled).onChange(async (value) => {
this.plugin.settings.cacheConfig.enabled = value;
await this.plugin.saveSettings();
this.plugin.notifyChatViews();
})
);
new import_obsidian4.Setting(containerEl).setName("ChromaDB URL").setDesc("URL for your ChromaDB instance (default: http://localhost:8000)").addText(
new import_obsidian5.Setting(containerEl).setName("ChromaDB URL").setDesc("URL for your ChromaDB instance (default: http://localhost:8000)").addText(
(text) => text.setValue(this.plugin.settings.cacheConfig.chromaURL || "http://localhost:8000").onChange(async (value) => {
const trimmed = value.trim();
this.plugin.settings.cacheConfig.chromaURL = trimmed && trimmed.includes("://") ? trimmed : "http://localhost:8000";
await this.plugin.saveSettings();
})
);
new import_obsidian4.Setting(containerEl).setName("Cache Embedding Model").setDesc("Ollama model used to generate embeddings for the semantic cache").addText(
new import_obsidian5.Setting(containerEl).setName("Cache Embedding Model").setDesc("Ollama model used to generate embeddings for the semantic cache").addText(
(text) => text.setValue(this.plugin.settings.cacheConfig.embeddingModel).onChange(async (value) => {
this.plugin.settings.cacheConfig.embeddingModel = value;
await this.plugin.saveSettings();
this.plugin.notifyChatViews();
})
);
new import_obsidian4.Setting(containerEl).setName("Cache Similarity Threshold").setDesc(
new import_obsidian5.Setting(containerEl).setName("Cache Similarity Threshold").setDesc(
"Minimum cosine similarity (0\u20131) for a cache hit. Higher values require closer matches."
).addText(
(text) => text.setValue(String(this.plugin.settings.cacheConfig.similarityThreshold)).onChange(async (value) => {
@@ -10300,17 +10570,119 @@ var OllamaSettingTab = class extends import_obsidian4.PluginSettingTab {
this.plugin.settings.cacheConfig.similarityThreshold = parsed;
await this.plugin.saveSettings();
} else {
new import_obsidian4.Notice("Similarity threshold must be a number between 0 and 1.");
new import_obsidian5.Notice("Similarity threshold must be a number between 0 and 1.");
}
})
);
new import_obsidian4.Setting(containerEl).setName("Clear Semantic Cache").setDesc("Delete all cached responses from ChromaDB").addButton(
new import_obsidian5.Setting(containerEl).setName("Clear Semantic Cache").setDesc("Delete all cached responses from ChromaDB").addButton(
(button) => button.setButtonText("Clear Cache").onClick(async () => {
try {
await this.plugin.clearSemanticCache();
new import_obsidian4.Notice("Semantic cache cleared.");
new import_obsidian5.Notice("Semantic cache cleared.");
} catch {
new import_obsidian4.Notice("Failed to clear semantic cache. Is ChromaDB running?");
new import_obsidian5.Notice("Failed to clear semantic cache. Is ChromaDB running?");
}
})
);
containerEl.createEl("h3", { text: "Auto-Organize" });
containerEl.createEl("h4", { text: "Auto-Tagging" });
new import_obsidian5.Setting(containerEl).setName("Enable Auto-Tagging").setDesc("Use AI to automatically suggest and apply tags to untagged notes").addToggle(
(toggle) => toggle.setValue(this.plugin.settings.autoTagConfig.enabled).onChange(async (value) => {
this.plugin.settings.autoTagConfig.enabled = value;
await this.plugin.saveSettings();
})
);
new import_obsidian5.Setting(containerEl).setName("Max Tags Per Note").setDesc("Maximum number of tags to generate for each note (default: 5)").addText(
(text) => text.setValue(String(this.plugin.settings.autoTagConfig.maxTagsPerNote)).onChange(async (value) => {
const parsed = parseInt(value);
if (!isNaN(parsed) && parsed > 0 && parsed <= 20) {
this.plugin.settings.autoTagConfig.maxTagsPerNote = parsed;
await this.plugin.saveSettings();
} else {
new import_obsidian5.Notice("Max tags must be between 1 and 20.");
}
})
);
new import_obsidian5.Setting(containerEl).setName("Min Note Length").setDesc("Minimum character length for a note to be tagged (default: 50)").addText(
(text) => text.setValue(String(this.plugin.settings.autoTagConfig.minNoteLength)).onChange(async (value) => {
const parsed = parseInt(value);
if (!isNaN(parsed) && parsed >= 0) {
this.plugin.settings.autoTagConfig.minNoteLength = parsed;
await this.plugin.saveSettings();
} else {
new import_obsidian5.Notice("Min note length must be a non-negative integer.");
}
})
);
new import_obsidian5.Setting(containerEl).setName("Max Note Length").setDesc("Maximum characters of content sent to the model for tagging (default: 8000)").addText(
(text) => text.setValue(String(this.plugin.settings.autoTagConfig.maxNoteLength)).onChange(async (value) => {
const parsed = parseInt(value);
if (!isNaN(parsed) && parsed > 0) {
this.plugin.settings.autoTagConfig.maxNoteLength = parsed;
await this.plugin.saveSettings();
} else {
new import_obsidian5.Notice("Max note length must be a positive integer.");
}
})
);
new import_obsidian5.Setting(containerEl).setName("Tag Prompt Template").setDesc(
"Prompt template for tag generation. Use {{maxTags}}, {{title}}, {{content}} as placeholders."
).addTextArea(
(text) => text.setValue(this.plugin.settings.autoTagConfig.tagPromptTemplate).onChange(async (value) => {
this.plugin.settings.autoTagConfig.tagPromptTemplate = value;
await this.plugin.saveSettings();
})
);
new import_obsidian5.Setting(containerEl).setName("Run Auto-Tagging Now").setDesc("Process all untagged notes and generate tags").addButton(
(button) => button.setButtonText("Auto-Tag Notes").onClick(async () => {
try {
await this.plugin.initializeAutoOrganizer();
if (this.plugin.autoTagger) {
await this.plugin.autoTagger.run();
}
} catch {
new import_obsidian5.Notice("Auto-tagging failed. Check console for details.");
}
})
);
containerEl.createEl("h4", { text: "Auto-Linking" });
new import_obsidian5.Setting(containerEl).setName("Enable Auto-Linking").setDesc('Add "Related Notes" sections to notes based on semantic similarity').addToggle(
(toggle) => toggle.setValue(this.plugin.settings.autoLinkConfig.enabled).onChange(async (value) => {
this.plugin.settings.autoLinkConfig.enabled = value;
await this.plugin.saveSettings();
})
);
new import_obsidian5.Setting(containerEl).setName("Max Links Per Note").setDesc("Maximum number of related notes to link (default: 3)").addText(
(text) => text.setValue(String(this.plugin.settings.autoLinkConfig.maxLinksPerNote)).onChange(async (value) => {
const parsed = parseInt(value);
if (!isNaN(parsed) && parsed > 0 && parsed <= 10) {
this.plugin.settings.autoLinkConfig.maxLinksPerNote = parsed;
await this.plugin.saveSettings();
} else {
new import_obsidian5.Notice("Max links must be between 1 and 10.");
}
})
);
new import_obsidian5.Setting(containerEl).setName("Auto-Link Similarity Threshold").setDesc("Minimum similarity score for notes to be considered related (default: 0.6)").addText(
(text) => text.setValue(String(this.plugin.settings.autoLinkConfig.similarityThreshold)).onChange(async (value) => {
const parsed = parseFloat(value);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
this.plugin.settings.autoLinkConfig.similarityThreshold = parsed;
await this.plugin.saveSettings();
} else {
new import_obsidian5.Notice("Similarity threshold must be between 0 and 1.");
}
})
);
new import_obsidian5.Setting(containerEl).setName("Run Auto-Linking Now").setDesc("Process all notes and add related note links").addButton(
(button) => button.setButtonText("Auto-Link Notes").onClick(async () => {
try {
await this.plugin.initializeAutoOrganizer();
if (this.plugin.autoLinker) {
await this.plugin.autoLinker.run();
}
} catch {
new import_obsidian5.Notice("Auto-linking failed. Check console for details.");
}
})
);
+300
View File
@@ -0,0 +1,300 @@
import { Vault, TFile, Notice } from 'obsidian';
import { OllamaClient } from './ollama-client';
import { VaultIndexer } from './vault-indexer';
import { VaultVectorStore } from './vault-vector-store';
import { Logger } from './utils';
export interface AutoOrganizeConfig {
enabled: boolean;
maxTagsPerNote: number;
minNoteLength: number;
maxNoteLength: number;
tagPromptTemplate: string;
}
export const DEFAULT_AUTO_ORGANIZE_CONFIG: AutoOrganizeConfig = {
enabled: false,
maxTagsPerNote: 5,
minNoteLength: 50,
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}}',
};
/**
* Automatically tags untagged notes using the AI model.
*/
export class AutoTagger {
private vault: Vault;
private ollamaClient: OllamaClient;
private config: AutoOrganizeConfig;
constructor(vault: Vault, ollamaUrl: string, model: string, config: AutoOrganizeConfig) {
this.vault = vault;
this.config = config;
this.ollamaClient = new OllamaClient(ollamaUrl, model);
}
updateConfig(config: AutoOrganizeConfig): void {
this.config = config;
}
/**
* Find all markdown files that lack a `tags` frontmatter field.
*/
async getUntaggedNotes(): Promise<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') {
untagged.push(file);
}
} catch {
// skip files that can't be read
}
}
return untagged;
}
/**
* Generate tags for a single note using the AI.
*/
async generateTags(file: TFile): Promise<string[]> {
try {
const content = await this.vault.read(file);
const length = content.length;
if (length < this.config.minNoteLength || length > this.config.maxNoteLength) {
Logger.info(`Skipping ${file.path}: content length ${length} out of range`, 'auto-tagger');
return [];
}
const truncated = content.substring(0, this.config.maxNoteLength);
const prompt = this.config.tagPromptTemplate
.replace(/\{\{maxTags\}\}/g, String(this.config.maxTagsPerNote))
.replace(/\{\{title\}\}/g, file.basename)
.replace(/\{\{content\}\}/g, truncated);
const response = await this.ollamaClient.chat([{ role: 'user', content: prompt }]);
const tags = this.parseTagResponse(response.content);
Logger.info(`Generated tags for ${file.path}: ${tags.join(', ')}`, 'auto-tagger');
return tags;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
Logger.warn(`Failed to generate tags for ${file.path}: ${errorMessage}`, 'auto-tagger');
return [];
}
}
/**
* Apply tags to a note's frontmatter.
*/
async applyTags(file: TFile, tags: string[]): Promise<void> {
if (tags.length === 0) return;
try {
const content = await this.vault.read(file);
const existingFrontmatter = content.match(/^---\n([\s\S]*?)\n---\n/);
let newContent: string;
if (existingFrontmatter) {
// Update existing frontmatter
const frontmatterText = existingFrontmatter[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`);
} else {
// Add tags line to existing frontmatter
const updatedFrontmatter = `tags: ${tags.join(', ')}\n${frontmatterText}`;
newContent = content.replace(existingFrontmatter[0], `---\n${updatedFrontmatter}\n---\n`);
}
} else {
// Add new frontmatter block
newContent = `---\ntags: ${tags.join(', ')}\n---\n\n${content}`;
}
await this.vault.modify(file, newContent);
Logger.info(`Tagged ${file.path} with: ${tags.join(', ')}`, 'auto-tagger');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
Logger.warn(`Failed to apply tags to ${file.path}: ${errorMessage}`, 'auto-tagger');
}
}
/**
* Run auto-tagging on all untagged notes.
*/
async run(): Promise<{ tagged: number; skipped: number }> {
if (!this.config.enabled) {
new Notice('Auto-tagging is disabled in settings.');
return { tagged: 0, skipped: 0 };
}
const untagged = await this.getUntaggedNotes();
if (untagged.length === 0) {
new Notice('No untagged notes found.');
return { tagged: 0, skipped: 0 };
}
new Notice(`Auto-tagging ${untagged.length} notes...`);
let tagged = 0;
let skipped = 0;
for (const file of untagged) {
const tags = await this.generateTags(file);
if (tags.length > 0) {
await this.applyTags(file, tags);
tagged++;
} else {
skipped++;
}
// Small delay to avoid overloading Ollama
await new Promise((resolve) => setTimeout(resolve, 300));
}
new Notice(`Auto-tagging complete: ${tagged} tagged, ${skipped} skipped.`);
return { tagged, skipped };
}
private parseTagResponse(response: string): string[] {
return response
.split(/[,\n]+/)
.map((t) => t.trim().replace(/^#+/, '').replace(/['"]+/g, ''))
.filter((t) => t.length > 0 && t.length < 50)
.slice(0, this.config.maxTagsPerNote);
}
}
/**
* Automatically adds wiki-links to related notes based on semantic similarity.
*/
export class AutoLinker {
private vault: Vault;
private vaultIndexer: VaultIndexer;
private config: { enabled: boolean; maxLinksPerNote: number; similarityThreshold: number };
constructor(
vault: Vault,
vaultIndexer: VaultIndexer,
config: { enabled: boolean; maxLinksPerNote: number; similarityThreshold: number }
) {
this.vault = vault;
this.vaultIndexer = vaultIndexer;
this.config = config;
}
updateConfig(config: {
enabled: boolean;
maxLinksPerNote: number;
similarityThreshold: number;
}): void {
this.config = config;
}
/**
* Find related notes for a given file using semantic search.
*/
async findRelatedNotes(file: TFile): Promise<{ path: string; title: string; score: number }[]> {
try {
const content = await this.vault.read(file);
const truncated = content.substring(0, 2000);
const results = await this.vaultIndexer.searchVault(
truncated,
this.config.maxLinksPerNote + 5
);
// Filter out self and low-similarity results
return results
.filter((r) => r.path !== file.path && r.score >= this.config.similarityThreshold)
.slice(0, this.config.maxLinksPerNote)
.map((r) => ({ path: r.path, title: r.title, score: r.score }));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
Logger.warn(`Failed to find related notes for ${file.path}: ${errorMessage}`, 'auto-linker');
return [];
}
}
/**
* Add a "Related Notes" section to a note if it doesn't already exist.
*/
async addRelatedLinks(file: TFile, related: { path: string; title: string }[]): Promise<void> {
if (related.length === 0) return;
try {
const content = await this.vault.read(file);
// Skip if already has a Related Notes section
if (/^## Related Notes/m.test(content)) {
Logger.info(`Skipping ${file.path}: already has Related Notes section`, 'auto-linker');
return;
}
const links = related
.map((r) => `- [[${r.title}|${r.path.replace(/\.md$/, '')}]]`)
.join('\n');
const section = `\n\n## Related Notes\n\n${links}\n`;
await this.vault.modify(file, content + section);
Logger.info(`Added ${related.length} related links to ${file.path}`, 'auto-linker');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
Logger.warn(`Failed to add links to ${file.path}: ${errorMessage}`, 'auto-linker');
}
}
/**
* Run auto-linking on all notes.
*/
async run(): Promise<{ linked: number; skipped: number }> {
if (!this.config.enabled) {
new Notice('Auto-linking is disabled in settings.');
return { linked: 0, skipped: 0 };
}
const files = this.vault.getMarkdownFiles();
new Notice(`Auto-linking ${files.length} notes...`);
let linked = 0;
let skipped = 0;
for (const file of files) {
const related = await this.findRelatedNotes(file);
if (related.length > 0) {
await this.addRelatedLinks(file, related);
linked++;
} else {
skipped++;
}
// Small delay between files
await new Promise((resolve) => setTimeout(resolve, 200));
}
new Notice(`Auto-linking complete: ${linked} linked, ${skipped} skipped.`);
return { linked, skipped };
}
}
+13
View File
@@ -18,4 +18,17 @@ export const DEFAULT_SETTINGS = {
embeddingModel: 'nomic-embed-text',
chromaURL: 'http://localhost:8000',
},
autoTagConfig: {
enabled: false,
maxTagsPerNote: 5,
minNoteLength: 50,
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}}',
},
autoLinkConfig: {
enabled: false,
maxLinksPerNote: 3,
similarityThreshold: 0.6,
},
};
+210
View File
@@ -3,6 +3,8 @@ import { ChatView } from './chat-view';
import { DEFAULT_SETTINGS } from './constants';
import { SemanticCacheService } from './semantic-cache';
import { VaultVectorStore } from './vault-vector-store';
import { VaultIndexer } from './vault-indexer';
import { AutoTagger, AutoLinker } from './auto-organizer';
import { PluginSettings } from './types';
import { Logger } from './utils';
@@ -10,6 +12,8 @@ export default class OllamaPlugin extends Plugin {
settings: PluginSettings = DEFAULT_SETTINGS;
semanticCache?: SemanticCacheService;
vaultVectorStore?: VaultVectorStore;
autoTagger?: AutoTagger;
autoLinker?: AutoLinker;
private indexingAbortController?: AbortController;
private currentIndexingPromise?: Promise<void>;
@@ -72,6 +76,32 @@ export default class OllamaPlugin extends Plugin {
},
});
// Add a command to auto-tag untagged notes
this.addCommand({
id: 'auto-tag-notes',
name: 'Auto-Tag Untagged Notes',
callback: async () => {
await this.initializeAutoOrganizer();
if (this.autoTagger) {
new Notice('Auto-tagging untagged notes...');
await this.autoTagger.run();
}
},
});
// Add a command to auto-link related notes
this.addCommand({
id: 'auto-link-notes',
name: 'Auto-Link Related Notes',
callback: async () => {
await this.initializeAutoOrganizer();
if (this.autoLinker) {
new Notice('Auto-linking related notes...');
await this.autoLinker.run();
}
},
});
// Add a settings tab
this.addSettingTab(new OllamaSettingTab(this.app, this));
@@ -114,6 +144,26 @@ export default class OllamaPlugin extends Plugin {
await this.saveData(this.settings);
}
async initializeAutoOrganizer(): Promise<void> {
if (!this.autoTagger) {
this.autoTagger = new AutoTagger(
this.app.vault,
this.settings.ollamaUrl,
this.settings.model,
this.settings.autoTagConfig
);
} else {
this.autoTagger.updateConfig(this.settings.autoTagConfig);
}
if (!this.autoLinker) {
const vaultIndexer = new VaultIndexer(this.app.vault, undefined, this.vaultVectorStore);
this.autoLinker = new AutoLinker(this.app.vault, vaultIndexer, this.settings.autoLinkConfig);
} else {
this.autoLinker.updateConfig(this.settings.autoLinkConfig);
}
}
async initializeVaultVectorStore(): Promise<void> {
// Abort any ongoing indexing before re-initializing
this.cancelBackgroundIndexing();
@@ -553,6 +603,166 @@ class OllamaSettingTab extends PluginSettingTab {
}
})
);
// Auto-Organize Settings
containerEl.createEl('h3', { text: 'Auto-Organize' });
// Auto-Tag Settings
containerEl.createEl('h4', { text: 'Auto-Tagging' });
new Setting(containerEl)
.setName('Enable Auto-Tagging')
.setDesc('Use AI to automatically suggest and apply tags to untagged notes')
.addToggle((toggle) =>
toggle.setValue(this.plugin.settings.autoTagConfig.enabled).onChange(async (value) => {
this.plugin.settings.autoTagConfig.enabled = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Max Tags Per Note')
.setDesc('Maximum number of tags to generate for each note (default: 5)')
.addText((text) =>
text
.setValue(String(this.plugin.settings.autoTagConfig.maxTagsPerNote))
.onChange(async (value) => {
const parsed = parseInt(value);
if (!isNaN(parsed) && parsed > 0 && parsed <= 20) {
this.plugin.settings.autoTagConfig.maxTagsPerNote = parsed;
await this.plugin.saveSettings();
} else {
new Notice('Max tags must be between 1 and 20.');
}
})
);
new Setting(containerEl)
.setName('Min Note Length')
.setDesc('Minimum character length for a note to be tagged (default: 50)')
.addText((text) =>
text
.setValue(String(this.plugin.settings.autoTagConfig.minNoteLength))
.onChange(async (value) => {
const parsed = parseInt(value);
if (!isNaN(parsed) && parsed >= 0) {
this.plugin.settings.autoTagConfig.minNoteLength = parsed;
await this.plugin.saveSettings();
} else {
new Notice('Min note length must be a non-negative integer.');
}
})
);
new Setting(containerEl)
.setName('Max Note Length')
.setDesc('Maximum characters of content sent to the model for tagging (default: 8000)')
.addText((text) =>
text
.setValue(String(this.plugin.settings.autoTagConfig.maxNoteLength))
.onChange(async (value) => {
const parsed = parseInt(value);
if (!isNaN(parsed) && parsed > 0) {
this.plugin.settings.autoTagConfig.maxNoteLength = parsed;
await this.plugin.saveSettings();
} else {
new Notice('Max note length must be a positive integer.');
}
})
);
new Setting(containerEl)
.setName('Tag Prompt Template')
.setDesc(
'Prompt template for tag generation. Use {{maxTags}}, {{title}}, {{content}} as placeholders.'
)
.addTextArea((text) =>
text
.setValue(this.plugin.settings.autoTagConfig.tagPromptTemplate)
.onChange(async (value) => {
this.plugin.settings.autoTagConfig.tagPromptTemplate = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Run Auto-Tagging Now')
.setDesc('Process all untagged notes and generate tags')
.addButton((button) =>
button.setButtonText('Auto-Tag Notes').onClick(async () => {
try {
await this.plugin.initializeAutoOrganizer();
if (this.plugin.autoTagger) {
await this.plugin.autoTagger.run();
}
} catch {
new Notice('Auto-tagging failed. Check console for details.');
}
})
);
// Auto-Link Settings
containerEl.createEl('h4', { text: 'Auto-Linking' });
new Setting(containerEl)
.setName('Enable Auto-Linking')
.setDesc('Add "Related Notes" sections to notes based on semantic similarity')
.addToggle((toggle) =>
toggle.setValue(this.plugin.settings.autoLinkConfig.enabled).onChange(async (value) => {
this.plugin.settings.autoLinkConfig.enabled = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Max Links Per Note')
.setDesc('Maximum number of related notes to link (default: 3)')
.addText((text) =>
text
.setValue(String(this.plugin.settings.autoLinkConfig.maxLinksPerNote))
.onChange(async (value) => {
const parsed = parseInt(value);
if (!isNaN(parsed) && parsed > 0 && parsed <= 10) {
this.plugin.settings.autoLinkConfig.maxLinksPerNote = parsed;
await this.plugin.saveSettings();
} else {
new Notice('Max links must be between 1 and 10.');
}
})
);
new Setting(containerEl)
.setName('Auto-Link Similarity Threshold')
.setDesc('Minimum similarity score for notes to be considered related (default: 0.6)')
.addText((text) =>
text
.setValue(String(this.plugin.settings.autoLinkConfig.similarityThreshold))
.onChange(async (value) => {
const parsed = parseFloat(value);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
this.plugin.settings.autoLinkConfig.similarityThreshold = parsed;
await this.plugin.saveSettings();
} else {
new Notice('Similarity threshold must be between 0 and 1.');
}
})
);
new Setting(containerEl)
.setName('Run Auto-Linking Now')
.setDesc('Process all notes and add related note links')
.addButton((button) =>
button.setButtonText('Auto-Link Notes').onClick(async () => {
try {
await this.plugin.initializeAutoOrganizer();
if (this.plugin.autoLinker) {
await this.plugin.autoLinker.run();
}
} catch {
new Notice('Auto-linking failed. Check console for details.');
}
})
);
}
hide() {
+12
View File
@@ -193,6 +193,18 @@ export interface PluginSettings {
lastIndexTime: number;
cacheConfig: CacheConfig;
vaultIndexConfig: VaultIndexConfig;
autoTagConfig: {
enabled: boolean;
maxTagsPerNote: number;
minNoteLength: number;
maxNoteLength: number;
tagPromptTemplate: string;
};
autoLinkConfig: {
enabled: boolean;
maxLinksPerNote: number;
similarityThreshold: number;
};
}
// ============================================================
+226
View File
@@ -0,0 +1,226 @@
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);
});
});
});
+12
View File
@@ -46,6 +46,18 @@ const mockSettings: PluginSettings = {
embeddingModel: 'nomic-embed-text',
chromaURL: 'http://localhost:8000',
},
autoTagConfig: {
enabled: false,
maxTagsPerNote: 5,
minNoteLength: 50,
maxNoteLength: 8000,
tagPromptTemplate: 'Tags: {{content}}',
},
autoLinkConfig: {
enabled: false,
maxLinksPerNote: 3,
similarityThreshold: 0.6,
},
};
describe('ChatView', () => {