diff --git a/main.js b/main.js index 0c65215..f459c28 100644 --- a/main.js +++ b/main.js @@ -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."); } }) ); diff --git a/src/auto-organizer.ts b/src/auto-organizer.ts new file mode 100644 index 0000000..ec44fa5 --- /dev/null +++ b/src/auto-organizer.ts @@ -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 { + 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 { + 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 { + 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 { + 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 }; + } +} diff --git a/src/constants.ts b/src/constants.ts index 22f5fa0..f851dc7 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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, + }, }; diff --git a/src/main.ts b/src/main.ts index f8049c2..18c150f 100755 --- a/src/main.ts +++ b/src/main.ts @@ -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; @@ -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 { + 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 { // 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() { diff --git a/src/types.ts b/src/types.ts index 33f7abc..f38b295 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; + }; } // ============================================================ diff --git a/tests/auto-organizer.test.ts b/tests/auto-organizer.test.ts new file mode 100644 index 0000000..c797d37 --- /dev/null +++ b/tests/auto-organizer.test.ts @@ -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; + + 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; + 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); + }); + }); +}); diff --git a/tests/chat-view.test.ts b/tests/chat-view.test.ts index 0b75da0..1205313 100755 --- a/tests/chat-view.test.ts +++ b/tests/chat-view.test.ts @@ -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', () => {