Auto tagging obsidian notes w/ AI
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #4 from jaspermayone/add-new-features

Add new features

authored by

Jasper Mayone and committed by
GitHub
8f6bfe2c fa107831

+1586 -444
+14
.eslintrc.js
··· 1 + module.exports = { 2 + root: true, 3 + parser: '@typescript-eslint/parser', 4 + plugins: ['@typescript-eslint'], 5 + extends: [ 6 + 'eslint:recommended', 7 + 'plugin:@typescript-eslint/recommended', 8 + ], 9 + rules: { 10 + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 11 + '@typescript-eslint/ban-ts-comment': 'off', 12 + 'no-console': ['warn', { allow: ['warn', 'error'] }], 13 + }, 14 + };
+3 -439
main.ts
··· 1 - import { 2 - App, 3 - MarkdownView, 4 - Modal, 5 - Notice, 6 - Plugin, 7 - PluginSettingTab, 8 - Setting, 9 - TFile, 10 - requestUrl, 11 - } from "obsidian"; 12 - 13 - interface AITaggerSettings { 14 - apiKey: string; 15 - modelName: string; 16 - maxTags: number; 17 - promptTemplate: string; 18 - } 19 - 20 - const DEFAULT_SETTINGS: AITaggerSettings = { 21 - apiKey: "", 22 - modelName: "claude-3-5-sonnet-20240620", 23 - maxTags: 5, 24 - promptTemplate: 25 - "Generate {maxTags} relevant tags for the following note content. Return only the tags as a comma-separated list, without any additional commentary. Tags should be lowercase and use hyphens for multi-word tags.\n\nContent:\n{content}", 26 - }; 27 - 28 - export default class AITaggerPlugin extends Plugin { 29 - settings: AITaggerSettings; 30 - 31 - async onload() { 32 - await this.loadSettings(); 33 - 34 - // Create an icon in the left ribbon 35 - const ribbonIconEl = this.addRibbonIcon( 36 - "tag", 37 - "Auto-tag with AI", 38 - (evt: MouseEvent) => { 39 - if (!this.settings.apiKey) { 40 - new ConfirmModal( 41 - this.app, 42 - "AI tagging API key missing", 43 - () => {}, 44 - true, 45 - "API key not configured. Please add your API key in the plugin settings." 46 - ).open(); 47 - return; 48 - } 49 - // Tag the current note directly when clicking the ribbon icon 50 - this.tagCurrentNote(); 51 - } 52 - ); 53 - ribbonIconEl.addClass("ai-tagger-ribbon-class"); 54 - 55 - // Add command to tag current note 56 - this.addCommand({ 57 - id: "tag-current-note", 58 - name: "Tag current note with AI", 59 - checkCallback: (checking: boolean) => { 60 - const markdownView = 61 - this.app.workspace.getActiveViewOfType(MarkdownView); 62 - if (markdownView) { 63 - if (!checking) { 64 - if (!this.settings.apiKey) { 65 - new ConfirmModal( 66 - this.app, 67 - "AI tagging API key missing", 68 - () => {}, 69 - true, 70 - "API key not configured. Please add your API key in the plugin settings." 71 - ).open(); 72 - return true; 73 - } 74 - this.tagCurrentNote(); 75 - } 76 - return true; 77 - } 78 - return false; 79 - }, 80 - }); 81 - 82 - // Add command to tag all notes (but might need to limit this or add pagination) 83 - this.addCommand({ 84 - id: "tag-all-notes", 85 - name: "Tag all notes with AI", 86 - callback: () => { 87 - if (!this.settings.apiKey) { 88 - new ConfirmModal( 89 - this.app, 90 - "AI tagging API key missing", 91 - () => {}, 92 - true, 93 - "API key not configured. Please add your API key in the plugin settings." 94 - ).open(); 95 - return; 96 - } 97 - 98 - new ConfirmModal( 99 - this.app, 100 - "This will tag all notes in your vault. This may take a while and consume API credits. Do you want to continue?", 101 - () => this.tagAllNotes() 102 - ).open(); 103 - }, 104 - }); 105 - 106 - // Add settings tab 107 - this.addSettingTab(new AITaggerSettingTab(this.app, this)); 108 - } 109 - 110 - onunload() { 111 - // Nothing specific to clean up 112 - } 113 - 114 - async loadSettings() { 115 - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 116 - } 117 - 118 - async saveSettings() { 119 - await this.saveData(this.settings); 120 - } 121 - 122 - async tagCurrentNote() { 123 - if (!this.settings.apiKey) { 124 - new Notice( 125 - "API key not configured. Please add your API key in the plugin settings." 126 - ); 127 - return; 128 - } 129 - 130 - const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); 131 - if (!activeView || !activeView.file) { 132 - new Notice("No active note to tag"); 133 - return; 134 - } 135 - 136 - const file = activeView.file; 137 - const content = await this.app.vault.read(file); 138 - 139 - // Create a persistent notice 140 - const notice = new Notice("Analyzing note content and generating tags...", 0); 141 - 142 - try { 143 - const tags = await this.generateTags(content); 144 - await this.updateNoteFrontmatter(file, tags); 145 - 146 - // Update the notice with success message 147 - notice.setMessage(`Successfully added tags: ${tags.join(", ")}`); 148 - 149 - // Hide after 3 seconds 150 - setTimeout(() => { 151 - notice.hide(); 152 - }, 3000); 153 - } catch (error) { 154 - console.error("Error tagging note:", error); 155 - const errorMessage = error instanceof Error ? error.message : String(error); 156 - 157 - // Update the notice with error message 158 - notice.setMessage(`Error tagging note: ${errorMessage}`); 159 - 160 - // Hide after 5 seconds for error messages (giving more time to read) 161 - setTimeout(() => { 162 - notice.hide(); 163 - }, 5000); 164 - } 165 - } 166 - 167 - async tagAllNotes() { 168 - if (!this.settings.apiKey) { 169 - new Notice( 170 - "API key not configured. Please add your API key in the plugin settings." 171 - ); 172 - return; 173 - } 174 - 175 - const files = this.app.vault.getMarkdownFiles(); 176 - let processed = 0; 177 - let successful = 0; 178 - 179 - // Create a persistent notice that we'll update 180 - const notice = new Notice(`Starting to tag ${files.length} notes...`, 0); 181 - 182 - for (const file of files) { 183 - try { 184 - // Update the notice with current file 185 - notice.setMessage(`Processing: ${file.path}\nProgress: ${processed}/${files.length} (${successful} successful)`); 186 - 187 - const content = await this.app.vault.read(file); 188 - const tags = await this.generateTags(content); 189 - await this.updateNoteFrontmatter(file, tags); 190 - successful++; 191 - } catch (error) { 192 - console.error(`Error tagging note ${file.path}:`, error); 193 - } 194 - 195 - processed++; 196 - } 197 - 198 - // Final notice with completion message 199 - notice.setMessage(`Completed tagging ${successful}/${files.length} notes successfully`); 200 - 201 - // Hide the notice after 3 seconds 202 - setTimeout(() => { 203 - notice.hide(); 204 - }, 3000); 205 - } 206 - 207 - async generateTags(content: string): Promise<string[]> { 208 - if (!this.settings.apiKey) { 209 - throw new Error( 210 - "API key not configured. Please add your API key in the plugin settings." 211 - ); 212 - } 213 - 214 - // Replace placeholders in the prompt template 215 - const prompt = this.settings.promptTemplate 216 - .replace("{maxTags}", this.settings.maxTags.toString()) 217 - .replace("{content}", content); 218 - 219 - try { 220 - const response = await requestUrl({ 221 - url: "https://api.anthropic.com/v1/messages", 222 - method: "POST", 223 - headers: { 224 - "Content-Type": "application/json", 225 - "x-api-key": this.settings.apiKey, 226 - "anthropic-version": "2023-06-01", 227 - }, 228 - body: JSON.stringify({ 229 - model: this.settings.modelName, 230 - max_tokens: 1000, 231 - messages: [ 232 - { 233 - role: "user", 234 - content: prompt, 235 - }, 236 - ], 237 - }), 238 - }); 239 - 240 - if (response.status !== 200) { 241 - throw new Error( 242 - `API returned status code ${response.status}: ${JSON.stringify( 243 - response.json 244 - )}` 245 - ); 246 - } 247 - 248 - const result = response.json; 249 - const tagText = result.content[0].text; 250 - 251 - // Split by commas and trim whitespace 252 - return tagText 253 - .split(",") 254 - .map((tag: string) => tag.trim()) 255 - .filter((tag: string) => tag.length > 0); 256 - } catch (error) { 257 - console.error("Error calling Claude API:", error); 258 - const errorMessage = error instanceof Error ? error.message : String(error); 259 - throw new Error(`Failed to generate tags: ${errorMessage}`); 260 - } 261 - } 262 - 263 - async updateNoteFrontmatter(file: TFile, newTags: string[]): Promise<void> { 264 - // Use FileManager.processFrontMatter to atomically update the frontmatter 265 - await this.app.fileManager.processFrontMatter(file, (frontmatter) => { 266 - // Add new tags to existing tags or create new tags field 267 - if (!frontmatter.tags) { 268 - frontmatter.tags = newTags; 269 - } else { 270 - // Handle case where tags is a string 271 - if (typeof frontmatter.tags === "string") { 272 - frontmatter.tags = [frontmatter.tags, ...newTags]; 273 - } 274 - // Handle case where tags is already an array 275 - else if (Array.isArray(frontmatter.tags)) { 276 - frontmatter.tags = [...frontmatter.tags, ...newTags]; 277 - } 278 - 279 - // Remove duplicates 280 - frontmatter.tags = [...new Set(frontmatter.tags)]; 281 - } 282 - }); 283 - } 284 - } 285 - 286 - class ConfirmModal extends Modal { 287 - onConfirmCallback: () => void; 288 - message: string; 289 - hasError: boolean; 290 - errorMessage: string; 291 - 292 - constructor( 293 - app: App, 294 - message: string, 295 - onConfirmCallback: () => void, 296 - hasError = false, 297 - errorMessage = "" 298 - ) { 299 - super(app); 300 - this.message = message; 301 - this.onConfirmCallback = onConfirmCallback; 302 - this.hasError = hasError; 303 - this.errorMessage = errorMessage; 304 - } 305 - 306 - onOpen() { 307 - const { contentEl } = this; 308 - 309 - contentEl.createEl("p", { text: this.message }); 310 - 311 - if (this.hasError) { 312 - const errorEl = contentEl.createEl("p", { text: this.errorMessage }); 313 - errorEl.addClass("ai-tagger-error-message"); 314 - 315 - const buttonContainer = contentEl.createDiv(); 316 - buttonContainer.addClass("ai-tagger-modal-buttons"); 317 - 318 - const settingsButton = buttonContainer.createEl("button", { 319 - text: "Open settings", 320 - }); 321 - settingsButton.addEventListener("click", () => { 322 - this.close(); 323 - // Open settings tab 324 - // Using type assertion with a more specific interface would be better 325 - // if we had access to the internal Obsidian API types 326 - if ('setting' in this.app) { 327 - const appWithSetting = this.app as unknown as { setting: { open: () => void; openTabById: (id: string) => void } }; 328 - appWithSetting.setting.open(); 329 - appWithSetting.setting.openTabById("ai-tagger"); 330 - } 331 - }); 332 - 333 - const cancelButton = buttonContainer.createEl("button", { 334 - text: "Cancel", 335 - }); 336 - cancelButton.addEventListener("click", () => { 337 - this.close(); 338 - }); 339 - } else { 340 - const buttonContainer = contentEl.createDiv(); 341 - buttonContainer.addClass("ai-tagger-modal-buttons"); 342 - 343 - const confirmButton = buttonContainer.createEl("button", { 344 - text: "Confirm", 345 - }); 346 - confirmButton.addEventListener("click", () => { 347 - this.onConfirmCallback(); 348 - this.close(); 349 - }); 350 - 351 - const cancelButton = buttonContainer.createEl("button", { 352 - text: "Cancel", 353 - }); 354 - cancelButton.addEventListener("click", () => { 355 - this.close(); 356 - }); 357 - } 358 - } 359 - 360 - onClose() { 361 - const { contentEl } = this; 362 - contentEl.empty(); 363 - } 364 - } 365 - 366 - class AITaggerSettingTab extends PluginSettingTab { 367 - plugin: AITaggerPlugin; 368 - 369 - constructor(app: App, plugin: AITaggerPlugin) { 370 - super(app, plugin); 371 - this.plugin = plugin; 372 - } 373 - 374 - display(): void { 375 - const { containerEl } = this; 376 - 377 - containerEl.empty(); 378 - 379 - new Setting(containerEl) 380 - .setName("API key") 381 - .setDesc( 382 - "Your Anthropic API key. Required to use the AI service. Get it from https://console.anthropic.com/ if you don't have one already. We recommend using a dedicated key for this plugin." 383 - ) 384 - .addText((text) => 385 - text 386 - .setPlaceholder("Enter your API key") 387 - .setValue(this.plugin.settings.apiKey) 388 - .onChange(async (value) => { 389 - this.plugin.settings.apiKey = value; 390 - await this.plugin.saveSettings(); 391 - }) 392 - ); 393 - 394 - new Setting(containerEl) 395 - .setName("AI model") 396 - .setDesc("Choose which AI model to use for tag generation.") 397 - .addDropdown((dropdown) => 398 - dropdown 399 - .addOption("claude-3-5-sonnet-20240620", "Claude 3.5 Sonnet") 400 - .addOption("claude-3-opus-20240229", "Claude 3 Opus") 401 - .addOption("claude-3-sonnet-20240229", "Claude 3 Sonnet") 402 - .addOption("claude-3-haiku-20240307", "Claude 3 Haiku") 403 - .setValue(this.plugin.settings.modelName) 404 - .onChange(async (value) => { 405 - this.plugin.settings.modelName = value; 406 - await this.plugin.saveSettings(); 407 - }) 408 - ); 409 - 410 - new Setting(containerEl) 411 - .setName("Maximum number of tags") 412 - .setDesc("Set the maximum number of tags to generate per note.") 413 - .addSlider((slider) => 414 - slider 415 - .setLimits(1, 20, 1) 416 - .setValue(this.plugin.settings.maxTags) 417 - .setDynamicTooltip() 418 - .onChange(async (value) => { 419 - this.plugin.settings.maxTags = value; 420 - await this.plugin.saveSettings(); 421 - }) 422 - ); 423 - 424 - new Setting(containerEl) 425 - .setName("Prompt template") 426 - .setDesc( 427 - "Customize the prompt sent to the AI. Use {maxTags} and {content} as placeholders." 428 - ) 429 - .addTextArea((textarea) => 430 - textarea 431 - .setValue(this.plugin.settings.promptTemplate) 432 - .onChange(async (value) => { 433 - this.plugin.settings.promptTemplate = value; 434 - await this.plugin.saveSettings(); 435 - }) 436 - ) 437 - .setClass("ai-tagger-wide-setting"); 438 - } 439 - } 1 + // Re-export from src directory 2 + import AITaggerPlugin from './src/main'; 3 + export default AITaggerPlugin;
+4 -2
package.json
··· 1 1 { 2 2 "name": "obsidian-ai-tagger", 3 - "version": "1.0.0", 3 + "version": "1.1.0", 4 4 "description": "Automatically tag your notes using AI and update frontmatter with generated tags", 5 5 "main": "main.js", 6 6 "scripts": { 7 7 "dev": "node esbuild.config.mjs", 8 8 "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 - "version": "node version-bump.mjs && git add manifest.json versions.json" 9 + "version": "node version-bump.mjs && git add manifest.json versions.json", 10 + "lint": "eslint src/**/*.ts", 11 + "lint:fix": "eslint src/**/*.ts --fix" 10 12 }, 11 13 "keywords": [ 12 14 "obsidian",
+2
src/i18n/index.ts
··· 1 + export { i18n } from './translationService'; 2 + export { languageNames } from './locales';
+86
src/i18n/locales/ar.json
··· 1 + { 2 + "settings": { 3 + "title": "إعدادات AI Tagger", 4 + "provider": { 5 + "title": "مزود الذكاء الاصطناعي", 6 + "desc": "اختر مزود الذكاء الاصطناعي لإنشاء العلامات.", 7 + "anthropic": "Anthropic (Claude)", 8 + "openai": "OpenAI (GPT)", 9 + "mistral": "Mistral AI", 10 + "google": "Google (Gemini)", 11 + "custom": "نقطة نهاية مخصصة (متوافقة مع OpenAI)" 12 + }, 13 + "customEndpoint": { 14 + "title": "نقطة نهاية API مخصصة", 15 + "desc": "أدخل عنوان URL لنقطة نهاية API المتوافقة مع OpenAI.", 16 + "placeholder": "https://your-api-endpoint.com/v1/chat/completions" 17 + }, 18 + "apiKey": { 19 + "title": "مفتاح API", 20 + "desc": "مفتاح API الخاص بـ {provider}. مطلوب لاستخدام خدمة الذكاء الاصطناعي.", 21 + "getKey": "احصل عليه من {url} إذا لم يكن لديك واحد بالفعل.", 22 + "recommendation": "نوصي باستخدام مفتاح مخصص لهذا البرنامج المساعد.", 23 + "placeholder": "أدخل مفتاح API الخاص بك" 24 + }, 25 + "model": { 26 + "title": "نموذج الذكاء الاصطناعي", 27 + "desc": "اختر نموذج الذكاء الاصطناعي المستخدم لإنشاء العلامات.", 28 + "custom": "{model} (مخصص)" 29 + }, 30 + "maxTags": { 31 + "title": "الحد الأقصى لعدد العلامات", 32 + "desc": "حدد الحد الأقصى لعدد العلامات لإنشائها لكل ملاحظة." 33 + }, 34 + "promptStyle": { 35 + "title": "نمط المطالبة", 36 + "desc": "اختر نمط مطالبة محدد مسبقًا أو قم بإنشاء مطالبة مخصصة خاصة بك.", 37 + "standard": "قياسي", 38 + "descriptive": "وصفي", 39 + "academic": "أكاديمي", 40 + "concise": "موجز", 41 + "custom": "مخصص" 42 + }, 43 + "customPrompt": { 44 + "title": "قالب مطالبة مخصص", 45 + "desc": "قم بتخصيص المطالبة المرسلة إلى الذكاء الاصطناعي. استخدم {maxTags} و {content} كعناصر نائبة." 46 + }, 47 + "currentPrompt": { 48 + "title": "قالب المطالبة الحالي", 49 + "desc": "هذا هو قالب المطالبة الذي سيتم استخدامه (للقراءة فقط). قم بالتبديل إلى مخصص إذا كنت ترغب في تعديله." 50 + }, 51 + "language": { 52 + "title": "اللغة", 53 + "desc": "اختر لغة واجهة البرنامج المساعد." 54 + } 55 + }, 56 + "modals": { 57 + "confirm": "تأكيد", 58 + "cancel": "إلغاء", 59 + "settings": "فتح الإعدادات", 60 + "configError": "خطأ في تكوين علامات الذكاء الاصطناعي", 61 + "tagAll": "سيقوم هذا بوضع علامات على جميع الملاحظات في خزانتك باستخدام نموذج {model}. قد يستغرق هذا بعض الوقت ويستهلك رصيد API. هل تريد المتابعة؟" 62 + }, 63 + "notices": { 64 + "noActiveNote": "لا توجد ملاحظة نشطة للتعليم", 65 + "analyzing": "تحليل محتوى الملاحظة وإنشاء العلامات...", 66 + "tagSuccess": "تمت إضافة العلامات بنجاح: {tags}", 67 + "tagError": "خطأ في وضع علامة على الملاحظة: {error}", 68 + "startingBulk": "بدء وضع علامات على الملاحظات...", 69 + "processingNote": "معالجة: {file}\nالتقدم: {processed}/{total} ({successful} ناجح)", 70 + "bulkComplete": "اكتمل وضع العلامات: {successful}/{total} ملاحظات بنجاح", 71 + "bulkError": "خطأ أثناء وضع العلامات بالجملة: {error}" 72 + }, 73 + "errors": { 74 + "apiKeyMissing": "مفتاح API غير مكوّن. يرجى إضافة مفتاح API الخاص بك في إعدادات البرنامج المساعد.", 75 + "endpointMissing": "نقطة نهاية API المخصصة غير مكوّنة. يرجى إضافة عنوان URL الخاص بنقطة النهاية في إعدادات البرنامج المساعد.", 76 + "apiError": "فشل في إنشاء العلامات: {error}", 77 + "unknownProvider": "مزود غير معروف: {provider}" 78 + }, 79 + "commands": { 80 + "tagCurrent": "وضع علامة على الملاحظة الحالية باستخدام الذكاء الاصطناعي", 81 + "tagAll": "وضع علامات على جميع الملاحظات باستخدام الذكاء الاصطناعي" 82 + }, 83 + "ribbon": { 84 + "tooltip": "وضع علامات تلقائية باستخدام الذكاء الاصطناعي" 85 + } 86 + }
+86
src/i18n/locales/de.json
··· 1 + { 2 + "settings": { 3 + "title": "AI Tagger Einstellungen", 4 + "provider": { 5 + "title": "KI-Anbieter", 6 + "desc": "Wählen Sie, welchen KI-Anbieter Sie für die Generierung von Tags verwenden möchten.", 7 + "anthropic": "Anthropic (Claude)", 8 + "openai": "OpenAI (GPT)", 9 + "mistral": "Mistral AI", 10 + "google": "Google (Gemini)", 11 + "custom": "Benutzerdefinierter Endpunkt (OpenAI-kompatibel)" 12 + }, 13 + "customEndpoint": { 14 + "title": "Benutzerdefinierter API-Endpunkt", 15 + "desc": "Geben Sie die URL für Ihren benutzerdefinierten OpenAI-kompatiblen API-Endpunkt ein.", 16 + "placeholder": "https://your-api-endpoint.com/v1/chat/completions" 17 + }, 18 + "apiKey": { 19 + "title": "API-Schlüssel", 20 + "desc": "Ihr {provider} API-Schlüssel. Erforderlich, um den KI-Dienst zu nutzen.", 21 + "getKey": "Holen Sie ihn von {url}, wenn Sie noch keinen haben.", 22 + "recommendation": "Wir empfehlen, einen dedizierten Schlüssel für dieses Plugin zu verwenden.", 23 + "placeholder": "Geben Sie Ihren API-Schlüssel ein" 24 + }, 25 + "model": { 26 + "title": "KI-Modell", 27 + "desc": "Wählen Sie, welches KI-Modell für die Tag-Generierung verwendet werden soll.", 28 + "custom": "{model} (Benutzerdefiniert)" 29 + }, 30 + "maxTags": { 31 + "title": "Maximale Anzahl an Tags", 32 + "desc": "Legen Sie die maximale Anzahl an Tags fest, die pro Notiz generiert werden sollen." 33 + }, 34 + "promptStyle": { 35 + "title": "Prompt-Stil", 36 + "desc": "Wählen Sie einen vordefinierten Prompt-Stil oder erstellen Sie Ihren eigenen benutzerdefinierten Prompt.", 37 + "standard": "Standard", 38 + "descriptive": "Beschreibend", 39 + "academic": "Akademisch", 40 + "concise": "Prägnant", 41 + "custom": "Benutzerdefiniert" 42 + }, 43 + "customPrompt": { 44 + "title": "Benutzerdefinierte Prompt-Vorlage", 45 + "desc": "Passen Sie den an die KI gesendeten Prompt an. Verwenden Sie {maxTags} und {content} als Platzhalter." 46 + }, 47 + "currentPrompt": { 48 + "title": "Aktuelle Prompt-Vorlage", 49 + "desc": "Dies ist die Prompt-Vorlage, die verwendet wird (schreibgeschützt). Wechseln Sie zu Benutzerdefiniert, wenn Sie sie bearbeiten möchten." 50 + }, 51 + "language": { 52 + "title": "Sprache", 53 + "desc": "Wählen Sie die Sprache für die Plugin-Oberfläche." 54 + } 55 + }, 56 + "modals": { 57 + "confirm": "Bestätigen", 58 + "cancel": "Abbrechen", 59 + "settings": "Einstellungen öffnen", 60 + "configError": "KI-Tagging Konfigurationsfehler", 61 + "tagAll": "Dies wird alle Notizen in Ihrem Vault mit dem Modell {model} taggen. Dies kann einige Zeit dauern und API-Guthaben verbrauchen. Möchten Sie fortfahren?" 62 + }, 63 + "notices": { 64 + "noActiveNote": "Keine aktive Notiz zum Taggen", 65 + "analyzing": "Analysiere Notizinhalt und generiere Tags...", 66 + "tagSuccess": "Tags erfolgreich hinzugefügt: {tags}", 67 + "tagError": "Fehler beim Taggen der Notiz: {error}", 68 + "startingBulk": "Beginne mit dem Taggen von Notizen...", 69 + "processingNote": "Verarbeite: {file}\nFortschritt: {processed}/{total} ({successful} erfolgreich)", 70 + "bulkComplete": "Tagging von {successful}/{total} Notizen erfolgreich abgeschlossen", 71 + "bulkError": "Fehler beim Massen-Tagging: {error}" 72 + }, 73 + "errors": { 74 + "apiKeyMissing": "API-Schlüssel nicht konfiguriert. Bitte fügen Sie Ihren API-Schlüssel in den Plugin-Einstellungen hinzu.", 75 + "endpointMissing": "Benutzerdefinierter API-Endpunkt nicht konfiguriert. Bitte fügen Sie Ihre Endpunkt-URL in den Plugin-Einstellungen hinzu.", 76 + "apiError": "Fehler beim Generieren von Tags: {error}", 77 + "unknownProvider": "Unbekannter Anbieter: {provider}" 78 + }, 79 + "commands": { 80 + "tagCurrent": "Aktuelle Notiz mit KI taggen", 81 + "tagAll": "Alle Notizen mit KI taggen" 82 + }, 83 + "ribbon": { 84 + "tooltip": "Auto-Tagging mit KI" 85 + } 86 + }
+86
src/i18n/locales/en.json
··· 1 + { 2 + "settings": { 3 + "title": "AI Tagger Settings", 4 + "provider": { 5 + "title": "AI Provider", 6 + "desc": "Select which AI provider to use for generating tags.", 7 + "anthropic": "Anthropic (Claude)", 8 + "openai": "OpenAI (GPT)", 9 + "mistral": "Mistral AI", 10 + "google": "Google (Gemini)", 11 + "custom": "Custom Endpoint (OpenAI Compatible)" 12 + }, 13 + "customEndpoint": { 14 + "title": "Custom API Endpoint", 15 + "desc": "Enter the URL for your custom OpenAI-compatible API endpoint.", 16 + "placeholder": "https://your-api-endpoint.com/v1/chat/completions" 17 + }, 18 + "apiKey": { 19 + "title": "API Key", 20 + "desc": "Your {provider} API key. Required to use the AI service.", 21 + "getKey": "Get it from {url} if you don't have one already.", 22 + "recommendation": "We recommend using a dedicated key for this plugin.", 23 + "placeholder": "Enter your API key" 24 + }, 25 + "model": { 26 + "title": "AI Model", 27 + "desc": "Choose which AI model to use for tag generation.", 28 + "custom": "{model} (Custom)" 29 + }, 30 + "maxTags": { 31 + "title": "Maximum number of tags", 32 + "desc": "Set the maximum number of tags to generate per note." 33 + }, 34 + "promptStyle": { 35 + "title": "Prompt Style", 36 + "desc": "Choose a predefined prompt style or create your own custom prompt.", 37 + "standard": "Standard", 38 + "descriptive": "Descriptive", 39 + "academic": "Academic", 40 + "concise": "Concise", 41 + "custom": "Custom" 42 + }, 43 + "customPrompt": { 44 + "title": "Custom prompt template", 45 + "desc": "Customize the prompt sent to the AI. Use {maxTags} and {content} as placeholders." 46 + }, 47 + "currentPrompt": { 48 + "title": "Current prompt template", 49 + "desc": "This is the prompt template that will be used (read-only). Switch to Custom if you want to edit it." 50 + }, 51 + "language": { 52 + "title": "Language", 53 + "desc": "Choose the language for the plugin interface." 54 + } 55 + }, 56 + "modals": { 57 + "confirm": "Confirm", 58 + "cancel": "Cancel", 59 + "settings": "Open settings", 60 + "configError": "AI tagging configuration error", 61 + "tagAll": "This will tag all notes in your vault using the {model} model. This may take a while and consume API credits. Do you want to continue?" 62 + }, 63 + "notices": { 64 + "noActiveNote": "No active note to tag", 65 + "analyzing": "Analyzing note content and generating tags...", 66 + "tagSuccess": "Successfully added tags: {tags}", 67 + "tagError": "Error tagging note: {error}", 68 + "startingBulk": "Starting to tag notes...", 69 + "processingNote": "Processing: {file}\nProgress: {processed}/{total} ({successful} successful)", 70 + "bulkComplete": "Completed tagging {successful}/{total} notes successfully", 71 + "bulkError": "Error during bulk tagging: {error}" 72 + }, 73 + "errors": { 74 + "apiKeyMissing": "API key not configured. Please add your API key in the plugin settings.", 75 + "endpointMissing": "Custom API endpoint not configured. Please add your endpoint URL in the plugin settings.", 76 + "apiError": "Failed to generate tags: {error}", 77 + "unknownProvider": "Unknown provider: {provider}" 78 + }, 79 + "commands": { 80 + "tagCurrent": "Tag current note with AI", 81 + "tagAll": "Tag all notes with AI" 82 + }, 83 + "ribbon": { 84 + "tooltip": "Auto-tag with AI" 85 + } 86 + }
+86
src/i18n/locales/es.json
··· 1 + { 2 + "settings": { 3 + "title": "Configuración de AI Tagger", 4 + "provider": { 5 + "title": "Proveedor de IA", 6 + "desc": "Seleccione qué proveedor de IA usar para generar etiquetas.", 7 + "anthropic": "Anthropic (Claude)", 8 + "openai": "OpenAI (GPT)", 9 + "mistral": "Mistral AI", 10 + "google": "Google (Gemini)", 11 + "custom": "Endpoint personalizado (Compatible con OpenAI)" 12 + }, 13 + "customEndpoint": { 14 + "title": "Endpoint API Personalizado", 15 + "desc": "Ingrese la URL para su endpoint API compatible con OpenAI.", 16 + "placeholder": "https://your-api-endpoint.com/v1/chat/completions" 17 + }, 18 + "apiKey": { 19 + "title": "Clave API", 20 + "desc": "Su clave API de {provider}. Necesaria para usar el servicio de IA.", 21 + "getKey": "Obténgala desde {url} si aún no tiene una.", 22 + "recommendation": "Recomendamos usar una clave dedicada para este plugin.", 23 + "placeholder": "Ingrese su clave API" 24 + }, 25 + "model": { 26 + "title": "Modelo de IA", 27 + "desc": "Elija qué modelo de IA usar para generar etiquetas.", 28 + "custom": "{model} (Personalizado)" 29 + }, 30 + "maxTags": { 31 + "title": "Número máximo de etiquetas", 32 + "desc": "Establezca el número máximo de etiquetas a generar por nota." 33 + }, 34 + "promptStyle": { 35 + "title": "Estilo de prompt", 36 + "desc": "Elija un estilo de prompt predefinido o cree su propio prompt personalizado.", 37 + "standard": "Estándar", 38 + "descriptive": "Descriptivo", 39 + "academic": "Académico", 40 + "concise": "Conciso", 41 + "custom": "Personalizado" 42 + }, 43 + "customPrompt": { 44 + "title": "Plantilla de prompt personalizada", 45 + "desc": "Personalice el prompt enviado a la IA. Use {maxTags} y {content} como marcadores de posición." 46 + }, 47 + "currentPrompt": { 48 + "title": "Plantilla de prompt actual", 49 + "desc": "Esta es la plantilla de prompt que se utilizará (solo lectura). Cambie a Personalizado si desea editarla." 50 + }, 51 + "language": { 52 + "title": "Idioma", 53 + "desc": "Elija el idioma para la interfaz del plugin." 54 + } 55 + }, 56 + "modals": { 57 + "confirm": "Confirmar", 58 + "cancel": "Cancelar", 59 + "settings": "Abrir configuración", 60 + "configError": "Error de configuración de etiquetado IA", 61 + "tagAll": "Esto etiquetará todas las notas en su bóveda usando el modelo {model}. Esto puede llevar tiempo y consumir créditos de API. ¿Desea continuar?" 62 + }, 63 + "notices": { 64 + "noActiveNote": "No hay nota activa para etiquetar", 65 + "analyzing": "Analizando contenido de la nota y generando etiquetas...", 66 + "tagSuccess": "Etiquetas añadidas con éxito: {tags}", 67 + "tagError": "Error al etiquetar nota: {error}", 68 + "startingBulk": "Comenzando a etiquetar notas...", 69 + "processingNote": "Procesando: {file}\nProgreso: {processed}/{total} ({successful} exitosas)", 70 + "bulkComplete": "Etiquetado completado: {successful}/{total} notas con éxito", 71 + "bulkError": "Error durante el etiquetado masivo: {error}" 72 + }, 73 + "errors": { 74 + "apiKeyMissing": "Clave API no configurada. Por favor, añada su clave API en la configuración del plugin.", 75 + "endpointMissing": "Endpoint API personalizado no configurado. Por favor, añada su URL de endpoint en la configuración del plugin.", 76 + "apiError": "Error al generar etiquetas: {error}", 77 + "unknownProvider": "Proveedor desconocido: {provider}" 78 + }, 79 + "commands": { 80 + "tagCurrent": "Etiquetar nota actual con IA", 81 + "tagAll": "Etiquetar todas las notas con IA" 82 + }, 83 + "ribbon": { 84 + "tooltip": "Auto-etiquetar con IA" 85 + } 86 + }
+29
src/i18n/locales/index.ts
··· 1 + import ar from "./ar.json"; 2 + import de from "./de.json"; 3 + import en from "./en.json"; 4 + import es from "./es.json"; 5 + 6 + export type TranslationKey = keyof typeof en; 7 + 8 + export const locales = { 9 + ar, 10 + de, 11 + en, 12 + es, 13 + }; 14 + 15 + export type LanguageCode = keyof typeof locales; 16 + export const languageCodes: LanguageCode[] = Object.keys( 17 + locales 18 + ) as LanguageCode[]; 19 + 20 + export const languageCodesList = Object.entries(locales).map(([key]) => ({ 21 + code: key, 22 + })); 23 + 24 + export const languageNames = { 25 + ar: "العربية", 26 + de: "Deutsch", 27 + en: "English", 28 + es: "Español", 29 + };
+163
src/i18n/translationService.ts
··· 1 + import { App } from "obsidian"; 2 + import { LanguageCode, locales } from "./locales"; 3 + 4 + class TranslationService { 5 + private currentLanguage: LanguageCode = "en"; // Default to English 6 + private app: App | null = null; 7 + 8 + constructor() { 9 + // Default to browser language initially 10 + // Will be overridden by Obsidian language or user setting 11 + const browserLang = window.navigator.language.split("-")[0] as LanguageCode; 12 + if (this.isLanguageSupported(browserLang)) { 13 + this.currentLanguage = browserLang; 14 + } 15 + } 16 + 17 + // Store bound event handler for proper cleanup 18 + private boundCheckForLanguageChanges: (() => void) | null = null; 19 + 20 + /** 21 + * Initialize the service with the Obsidian app instance 22 + * This allows getting the app's configured language and listening for changes 23 + */ 24 + public initializeApp(app: App): void { 25 + this.app = app; 26 + 27 + // Set up event listener for when Obsidian's language changes 28 + // Obsidian doesn't directly expose a language change event, but when the app 29 + // locale changes, many UI elements will be updated and we can listen for those changes 30 + this.boundCheckForLanguageChanges = this.checkForLanguageChanges.bind(this); 31 + // @ts-expect-error 32 + this.app.workspace.on("layout-change", this.boundCheckForLanguageChanges); 33 + } 34 + 35 + /** 36 + * Clean up event listeners when plugin is unloaded 37 + */ 38 + public cleanup(): void { 39 + if (this.app && this.boundCheckForLanguageChanges) { 40 + this.app.workspace.off( 41 + "layout-change", 42 + this.boundCheckForLanguageChanges 43 + ); 44 + this.boundCheckForLanguageChanges = null; 45 + } 46 + } 47 + 48 + /** 49 + * Check if Obsidian's language has changed and update if needed 50 + */ 51 + private checkForLanguageChanges(): void { 52 + if (!this.app) return; 53 + 54 + const currentObsidianLang = this.getObsidianLanguage(); 55 + if (currentObsidianLang !== this.currentLanguage) { 56 + this.setLanguage(currentObsidianLang); 57 + } 58 + } 59 + 60 + /** 61 + * Get the ISO code for the currently configured Obsidian app language 62 + * @returns The language code (defaults to 'en') 63 + */ 64 + public getObsidianLanguage(): LanguageCode { 65 + if (!this.app) return "en"; 66 + 67 + // Get language from Obsidian 68 + // @ts-ignore - getLanguage() exists but isn't in the type definitions 69 + const obsidianLang = this.app.vault.getConfig("language") || "en"; 70 + 71 + // Handle special cases or normalize language code if needed 72 + const normalizedLang = this.normalizeLanguageCode(obsidianLang); 73 + 74 + return this.isLanguageSupported(normalizedLang) 75 + ? (normalizedLang as LanguageCode) 76 + : "en"; 77 + } 78 + 79 + /** 80 + * Normalize language codes to match our supported formats 81 + */ 82 + private normalizeLanguageCode(langCode: string): string { 83 + // Handle specific language code mappings 84 + const mappings: Record<string, string> = { 85 + "zh-cn": "zh", 86 + "zh-hans": "zh", 87 + "zh-hant": "zh-TW", 88 + "pt-pt": "pt", 89 + "pt-br": "pt-BR", 90 + }; 91 + 92 + // Return the mapped value if it exists, otherwise return the original 93 + return mappings[langCode.toLowerCase()] || langCode; 94 + } 95 + 96 + /** 97 + * Set the current language for translations 98 + */ 99 + public setLanguage(lang: LanguageCode): void { 100 + if (this.isLanguageSupported(lang)) { 101 + this.currentLanguage = lang; 102 + } else { 103 + console.warn( 104 + `Language ${lang} is not supported. Falling back to English.` 105 + ); 106 + this.currentLanguage = "en"; // Fallback to English 107 + } 108 + } 109 + 110 + /** 111 + * Get the current language being used for translations 112 + */ 113 + public getCurrentLanguage(): LanguageCode { 114 + return this.currentLanguage; 115 + } 116 + 117 + /** 118 + * Translate a key with optional replacements 119 + * @param key The dot-notation key for the translation 120 + * @param replacements An object of values to replace in the translation 121 + * @returns The translated string 122 + * 123 + * Example: 124 + * t('settings.apiKey.desc', { provider: 'OpenAI' }) 125 + */ 126 + public t(key: string, replacements?: Record<string, string>): string { 127 + const locale = this.getLocale(); 128 + 129 + // Get the translation from the nested key 130 + const translation = key 131 + .split(".") 132 + .reduce( 133 + (obj, key) => (obj && typeof obj === "object" ? obj[key] : undefined), 134 + locale 135 + ); 136 + 137 + if (typeof translation !== "string") { 138 + console.warn(`Translation key "${key}" not found. Falling back to key.`); 139 + return key; 140 + } 141 + 142 + // Replace placeholders if provided 143 + if (replacements) { 144 + return Object.entries(replacements).reduce( 145 + (str, [key, value]) => str.replace(new RegExp(`{${key}}`, "g"), value), 146 + translation 147 + ); 148 + } 149 + 150 + return translation; 151 + } 152 + 153 + private isLanguageSupported(lang: string): boolean { 154 + return Object.keys(locales).includes(lang); 155 + } 156 + 157 + private getLocale(): any { 158 + return locales[this.currentLanguage] || locales.en; 159 + } 160 + } 161 + 162 + // Singleton instance to be used across the app 163 + export const i18n = new TranslationService();
+199
src/main.ts
··· 1 + import { MarkdownView, Plugin } from "obsidian"; 2 + 3 + import { DEFAULT_SETTINGS } from "./models/constants"; 4 + import { AITaggerSettings } from "./models/types"; 5 + import { tagAllNotes, tagSingleNote } from "./services/noteService"; 6 + import { NotificationService } from "./services/notificationService"; 7 + import { AITaggerSettingTab } from "./ui/AITaggerSettingTab"; 8 + import { ConfirmModal } from "./ui/ConfirmModal"; 9 + import { getModelName, validateApiSettings } from "./utils/validationUtils"; 10 + 11 + export default class AITaggerPlugin extends Plugin { 12 + settings: AITaggerSettings; 13 + 14 + async onload() { 15 + await this.loadSettings(); 16 + 17 + // Initialize language service with app instance and set language to Obsidian's language 18 + const { i18n } = await import("./i18n"); 19 + i18n.initializeApp(this.app); 20 + 21 + // Always use Obsidian's language setting 22 + const obsidianLang = i18n.getObsidianLanguage(); 23 + i18n.setLanguage(obsidianLang); 24 + 25 + // Create an icon in the left ribbon 26 + const ribbonIconEl = this.addRibbonIcon( 27 + "tag", 28 + i18n.t("ribbon.tooltip"), 29 + this.handleRibbonClick.bind(this) 30 + ); 31 + ribbonIconEl.addClass("ai-tagger-ribbon-class"); 32 + 33 + // Add command to tag current note 34 + this.addCommand({ 35 + id: "tag-current-note", 36 + name: i18n.t("commands.tagCurrent"), 37 + checkCallback: this.checkAndTagCurrentNote.bind(this), 38 + }); 39 + 40 + // Add command to tag all notes 41 + this.addCommand({ 42 + id: "tag-all-notes", 43 + name: i18n.t("commands.tagAll"), 44 + callback: this.confirmAndTagAllNotes.bind(this), 45 + }); 46 + 47 + // Add settings tab 48 + this.addSettingTab(new AITaggerSettingTab(this.app, this)); 49 + } 50 + 51 + onunload() { 52 + // Clean up the translation service's event listeners 53 + import("./i18n").then(({ i18n }) => { 54 + i18n.cleanup(); 55 + }); 56 + } 57 + 58 + async loadSettings() { 59 + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 60 + } 61 + 62 + async saveSettings() { 63 + await this.saveData(this.settings); 64 + } 65 + 66 + private handleRibbonClick() { 67 + const validation = validateApiSettings(this.settings); 68 + 69 + if (!validation.valid) { 70 + new ConfirmModal( 71 + this.app, 72 + "AI tagging configuration error", 73 + () => { 74 + null; 75 + }, 76 + true, 77 + validation.error 78 + ).open(); 79 + return; 80 + } 81 + 82 + // Tag the current note directly when clicking the ribbon icon 83 + this.tagCurrentNote(); 84 + } 85 + 86 + private checkAndTagCurrentNote(checking: boolean): boolean { 87 + const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); 88 + 89 + if (!markdownView) { 90 + return false; 91 + } 92 + 93 + if (checking) { 94 + return true; 95 + } 96 + 97 + const validation = validateApiSettings(this.settings); 98 + 99 + if (!validation.valid) { 100 + new ConfirmModal( 101 + this.app, 102 + "AI tagging configuration error", 103 + () => { 104 + null; 105 + }, 106 + true, 107 + validation.error 108 + ).open(); 109 + return true; 110 + } 111 + 112 + this.tagCurrentNote(); 113 + return true; 114 + } 115 + 116 + private confirmAndTagAllNotes() { 117 + const validation = validateApiSettings(this.settings); 118 + 119 + if (!validation.valid) { 120 + new ConfirmModal( 121 + this.app, 122 + "AI tagging configuration error", 123 + () => { 124 + null; 125 + }, 126 + true, 127 + validation.error 128 + ).open(); 129 + return; 130 + } 131 + 132 + const modelName = getModelName(this.settings); 133 + 134 + new ConfirmModal( 135 + this.app, 136 + `This will tag all notes in your vault using the ${modelName} model. This may take a while and consume API credits. Do you want to continue?`, 137 + () => this.tagAllNotes() 138 + ).open(); 139 + } 140 + 141 + private async tagCurrentNote() { 142 + const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); 143 + if (!activeView || !activeView.file) { 144 + NotificationService.showNotice("No active note to tag"); 145 + return; 146 + } 147 + 148 + const file = activeView.file; 149 + 150 + // Create a persistent notice 151 + const notification = NotificationService.showPersistentNotice( 152 + "Analyzing note content and generating tags..." 153 + ); 154 + 155 + try { 156 + const result = await tagSingleNote(this.app, file, this.settings); 157 + 158 + if (result.success) { 159 + notification.setSuccess( 160 + `Successfully added tags: ${result.tags.join(", ")}` 161 + ); 162 + } else { 163 + notification.setError(`Error tagging note: ${result.error}`); 164 + } 165 + } catch (error) { 166 + console.error("Error tagging note:", error); 167 + const errorMessage = 168 + error instanceof Error ? error.message : String(error); 169 + notification.setError(`Error tagging note: ${errorMessage}`); 170 + } 171 + } 172 + 173 + private async tagAllNotes() { 174 + // Create a persistent notice that we'll update 175 + const notification = NotificationService.showPersistentNotice( 176 + "Starting to tag notes..." 177 + ); 178 + 179 + try { 180 + const result = await tagAllNotes( 181 + this.app, 182 + this.settings, 183 + (processed, successful, total, currentFile) => { 184 + notification.setProgress(processed, successful, total, currentFile); 185 + } 186 + ); 187 + 188 + // Final notice with completion message 189 + notification.setSuccess( 190 + `Completed tagging ${result.successful}/${result.total} notes successfully` 191 + ); 192 + } catch (error) { 193 + console.error("Error during bulk tagging:", error); 194 + const errorMessage = 195 + error instanceof Error ? error.message : String(error); 196 + notification.setError(`Error during bulk tagging: ${errorMessage}`); 197 + } 198 + } 199 + }
+83
src/models/constants.ts
··· 1 + import { 2 + AIProvider, 3 + AITaggerSettings, 4 + ProviderConfig, 5 + ServiceTypeEnum, 6 + } from "./types"; 7 + 8 + // Define prompt templates 9 + export const PROMPT_TEMPLATES = { 10 + standard: 11 + "Generate {maxTags} relevant tags for the following note content. Return only the tags as a comma-separated list, without any additional commentary. Tags should be lowercase and use hyphens for multi-word tags.\n\nContent:\n{content}", 12 + descriptive: 13 + "Analyze the following note content and generate {maxTags} descriptive tags that capture the main topics, concepts, and themes. Return only the tags as a comma-separated list, without any additional commentary. Tags should be lowercase and use hyphens for multi-word tags.\n\nContent:\n{content}", 14 + academic: 15 + "Review the following academic or research note and generate {maxTags} specific tags that would help categorize this content in an academic context. Include relevant field-specific terminology. Return only the tags as a comma-separated list, without any additional commentary. Tags should be lowercase and use hyphens for multi-word tags.\n\nContent:\n{content}", 16 + concise: 17 + "Generate {maxTags} short, concise tags for the following note content. Focus on single-word tags when possible. Return only the tags as a comma-separated list, without any additional commentary. Tags should be lowercase.\n\nContent:\n{content}", 18 + custom: "", 19 + }; 20 + 21 + // Define model configurations for each provider 22 + export const MODEL_CONFIGS: Record<AIProvider, ProviderConfig> = { 23 + [AIProvider.Anthropic]: { 24 + apiUrl: "https://api.anthropic.com/v1/messages", 25 + models: [ 26 + { id: "claude-3-5-sonnet-20240620", name: "Claude 3.5 Sonnet" }, 27 + { id: "claude-3-opus-20240229", name: "Claude 3 Opus" }, 28 + { id: "claude-3-sonnet-20240229", name: "Claude 3 Sonnet" }, 29 + { id: "claude-3-haiku-20240307", name: "Claude 3 Haiku" }, 30 + ], 31 + defaultModel: "claude-3-5-sonnet-20240620", 32 + apiKeyUrl: "https://console.anthropic.com/", 33 + }, 34 + [AIProvider.OpenAI]: { 35 + apiUrl: "https://api.openai.com/v1/chat/completions", 36 + models: [ 37 + { id: "gpt-4o", name: "GPT-4o" }, 38 + { id: "gpt-4-turbo", name: "GPT-4 Turbo" }, 39 + { id: "gpt-4", name: "GPT-4" }, 40 + { id: "gpt-3.5-turbo", name: "GPT-3.5 Turbo" }, 41 + ], 42 + defaultModel: "gpt-3.5-turbo", 43 + apiKeyUrl: "https://platform.openai.com/api-keys", 44 + }, 45 + [AIProvider.Mistral]: { 46 + apiUrl: "https://api.mistral.ai/v1/chat/completions", 47 + models: [ 48 + { id: "mistral-large-latest", name: "Mistral Large" }, 49 + { id: "mistral-medium-latest", name: "Mistral Medium" }, 50 + { id: "mistral-small-latest", name: "Mistral Small" }, 51 + { id: "open-mistral-7b", name: "Open Mistral 7B" }, 52 + ], 53 + defaultModel: "mistral-small-latest", 54 + apiKeyUrl: "https://console.mistral.ai/api-keys/", 55 + }, 56 + [AIProvider.Google]: { 57 + apiUrl: "https://generativelanguage.googleapis.com/v1beta/models/", 58 + models: [ 59 + { id: "gemini-1.5-pro", name: "Gemini 1.5 Pro" }, 60 + { id: "gemini-1.5-flash", name: "Gemini 1.5 Flash" }, 61 + { id: "gemini-1.0-pro", name: "Gemini 1.0 Pro" }, 62 + ], 63 + defaultModel: "gemini-1.5-flash", 64 + apiKeyUrl: "https://aistudio.google.com/app/apikey", 65 + }, 66 + [AIProvider.Custom]: { 67 + apiUrl: "", 68 + models: [{ id: "custom-model", name: "Custom Model" }], 69 + defaultModel: "custom-model", 70 + apiKeyUrl: "", 71 + }, 72 + }; 73 + 74 + export const DEFAULT_SETTINGS: AITaggerSettings = { 75 + serviceType: ServiceTypeEnum.CLOUD, 76 + provider: AIProvider.Anthropic, 77 + apiKey: "", 78 + modelName: MODEL_CONFIGS[AIProvider.Anthropic].defaultModel, 79 + maxTags: 5, 80 + promptOption: "standard", 81 + promptTemplate: PROMPT_TEMPLATES.standard, 82 + customEndpoint: "", 83 + };
+45
src/models/types.ts
··· 1 + import { TFile } from "obsidian"; 2 + 3 + export enum AIProvider { 4 + Anthropic = "anthropic", 5 + OpenAI = "openai", 6 + Mistral = "mistral", 7 + Google = "google", 8 + Custom = "custom", 9 + } 10 + 11 + export type ServiceType = "Cloud Service" | "Local Service"; 12 + 13 + export enum ServiceTypeEnum { 14 + CLOUD = "Cloud Service", 15 + LOCAL = "Local Service", 16 + } 17 + export interface AITaggerSettings { 18 + provider: AIProvider; 19 + apiKey: string; 20 + modelName: string; 21 + maxTags: number; 22 + promptOption: string; 23 + promptTemplate: string; 24 + customEndpoint: string; 25 + serviceType: ServiceType; 26 + } 27 + 28 + export interface ModelInfo { 29 + id: string; 30 + name: string; 31 + } 32 + 33 + export interface ProviderConfig { 34 + apiUrl: string; 35 + models: ModelInfo[]; 36 + defaultModel: string; 37 + apiKeyUrl: string; 38 + } 39 + 40 + export interface TaggingResult { 41 + file: TFile; 42 + tags: string[]; 43 + success: boolean; 44 + error?: string; 45 + }
+88
src/services/noteService.ts
··· 1 + import { App, TFile } from "obsidian"; 2 + import { TaggingResult } from "../models/types"; 3 + import { generateTags } from "./tagGenerator"; 4 + import { AITaggerSettings } from "../models/types"; 5 + 6 + export async function updateNoteFrontmatter( 7 + app: App, 8 + file: TFile, 9 + newTags: string[] 10 + ): Promise<void> { 11 + // Use FileManager.processFrontMatter to atomically update the frontmatter 12 + await app.fileManager.processFrontMatter(file, (frontmatter) => { 13 + // Add new tags to existing tags or create new tags field 14 + if (!frontmatter.tags) { 15 + frontmatter.tags = newTags; 16 + } else { 17 + // Handle case where tags is a string 18 + if (typeof frontmatter.tags === "string") { 19 + frontmatter.tags = [frontmatter.tags, ...newTags]; 20 + } 21 + // Handle case where tags is already an array 22 + else if (Array.isArray(frontmatter.tags)) { 23 + frontmatter.tags = [...frontmatter.tags, ...newTags]; 24 + } 25 + 26 + // Remove duplicates 27 + frontmatter.tags = [...new Set(frontmatter.tags)]; 28 + } 29 + }); 30 + } 31 + 32 + export async function tagSingleNote( 33 + app: App, 34 + file: TFile, 35 + settings: AITaggerSettings 36 + ): Promise<TaggingResult> { 37 + try { 38 + const content = await app.vault.read(file); 39 + const tags = await generateTags(content, settings); 40 + await updateNoteFrontmatter(app, file, tags); 41 + 42 + return { 43 + file, 44 + tags, 45 + success: true 46 + }; 47 + } catch (error) { 48 + console.error(`Error tagging note ${file.path}:`, error); 49 + const errorMessage = error instanceof Error ? error.message : String(error); 50 + 51 + return { 52 + file, 53 + tags: [], 54 + success: false, 55 + error: errorMessage 56 + }; 57 + } 58 + } 59 + 60 + export async function tagAllNotes( 61 + app: App, 62 + settings: AITaggerSettings, 63 + progressCallback?: (processed: number, successful: number, total: number, currentFile: string) => void 64 + ): Promise<{ successful: number, total: number }> { 65 + const files = app.vault.getMarkdownFiles(); 66 + let processed = 0; 67 + let successful = 0; 68 + 69 + for (const file of files) { 70 + try { 71 + // Report progress if callback is provided 72 + if (progressCallback) { 73 + progressCallback(processed, successful, files.length, file.path); 74 + } 75 + 76 + const content = await app.vault.read(file); 77 + const tags = await generateTags(content, settings); 78 + await updateNoteFrontmatter(app, file, tags); 79 + successful++; 80 + } catch (error) { 81 + console.error(`Error tagging note ${file.path}:`, error); 82 + } 83 + 84 + processed++; 85 + } 86 + 87 + return { successful, total: files.length }; 88 + }
+73
src/services/notificationService.ts
··· 1 + import { Notice } from "obsidian"; 2 + 3 + interface PersistentNoticeOptions { 4 + duration?: number; 5 + errorDuration?: number; 6 + } 7 + 8 + export class NotificationService { 9 + private static defaultOptions: PersistentNoticeOptions = { 10 + duration: 3000, // 3 seconds for regular notices 11 + errorDuration: 5000, // 5 seconds for error notices 12 + }; 13 + 14 + /** 15 + * Shows a persistent notice that can be updated 16 + */ 17 + static showPersistentNotice( 18 + initialMessage: string, 19 + options: PersistentNoticeOptions = {} 20 + ): { 21 + notice: Notice; 22 + setSuccess: (message: string) => void; 23 + setError: (message: string) => void; 24 + setProgress: ( 25 + processed: number, 26 + successful: number, 27 + total: number, 28 + currentFile: string 29 + ) => void; 30 + } { 31 + const opts = { ...this.defaultOptions, ...options }; 32 + const notice = new Notice(initialMessage, 0); // 0 means don't auto-hide 33 + 34 + return { 35 + notice, 36 + 37 + setSuccess: (message: string) => { 38 + notice.setMessage(message); 39 + setTimeout(() => notice.hide(), opts.duration); 40 + }, 41 + 42 + setError: (message: string) => { 43 + notice.setMessage(message); 44 + setTimeout(() => notice.hide(), opts.errorDuration); 45 + }, 46 + 47 + setProgress: ( 48 + processed: number, 49 + successful: number, 50 + total: number, 51 + currentFile: string 52 + ) => { 53 + notice.setMessage( 54 + `Processing: ${currentFile}\nProgress: ${processed}/${total} (${successful} successful)` 55 + ); 56 + }, 57 + }; 58 + } 59 + 60 + /** 61 + * Shows a simple notice 62 + */ 63 + static showNotice(message: string, duration = 3000): Notice { 64 + return new Notice(message, duration); 65 + } 66 + 67 + /** 68 + * Shows an error notice 69 + */ 70 + static showError(message: string, duration = 5000): Notice { 71 + return new Notice(message, duration); 72 + } 73 + }
+180
src/services/tagGenerator.ts
··· 1 + import { requestUrl } from "obsidian"; 2 + import { MODEL_CONFIGS } from "../models/constants"; 3 + import { AIProvider, AITaggerSettings } from "../models/types"; 4 + 5 + export async function generateTags( 6 + content: string, 7 + settings: AITaggerSettings 8 + ): Promise<string[]> { 9 + validateSettings(settings); 10 + 11 + // Replace placeholders in the prompt template 12 + const prompt = settings.promptTemplate 13 + .replace("{maxTags}", settings.maxTags.toString()) 14 + .replace("{content}", content); 15 + 16 + try { 17 + const tagText = await fetchTagsFromProvider(settings, prompt); 18 + 19 + // Split by commas and trim whitespace 20 + return tagText 21 + .split(",") 22 + .map((tag: string) => tag.trim()) 23 + .filter((tag: string) => tag.length > 0); 24 + } catch (error) { 25 + console.error(`Error calling ${settings.provider} API:`, error); 26 + const errorMessage = error instanceof Error ? error.message : String(error); 27 + throw new Error(`Failed to generate tags: ${errorMessage}`); 28 + } 29 + } 30 + 31 + function validateSettings(settings: AITaggerSettings): void { 32 + if (!settings.apiKey) { 33 + throw new Error( 34 + "API key not configured. Please add your API key in the plugin settings." 35 + ); 36 + } 37 + 38 + if (settings.provider === AIProvider.Custom && !settings.customEndpoint) { 39 + throw new Error( 40 + "Custom API endpoint not configured. Please add your endpoint URL in the plugin settings." 41 + ); 42 + } 43 + } 44 + 45 + async function fetchTagsFromProvider( 46 + settings: AITaggerSettings, 47 + prompt: string 48 + ): Promise<string> { 49 + const providerConfig = MODEL_CONFIGS[settings.provider]; 50 + 51 + switch (settings.provider) { 52 + case AIProvider.Anthropic: 53 + return await fetchFromAnthropic(settings, prompt, providerConfig.apiUrl); 54 + 55 + case AIProvider.OpenAI: 56 + case AIProvider.Mistral: 57 + case AIProvider.Custom: 58 + const endpoint = 59 + settings.provider === AIProvider.Custom 60 + ? settings.customEndpoint 61 + : providerConfig.apiUrl; 62 + return await fetchFromOpenAICompatible(settings, prompt, endpoint); 63 + 64 + case AIProvider.Google: 65 + return await fetchFromGoogle(settings, prompt, providerConfig.apiUrl); 66 + 67 + default: 68 + throw new Error(`Unknown provider: ${settings.provider}`); 69 + } 70 + } 71 + 72 + async function fetchFromAnthropic( 73 + settings: AITaggerSettings, 74 + prompt: string, 75 + apiUrl: string 76 + ): Promise<string> { 77 + const response = await requestUrl({ 78 + url: apiUrl, 79 + method: "POST", 80 + headers: { 81 + "Content-Type": "application/json", 82 + "x-api-key": settings.apiKey, 83 + "anthropic-version": "2023-06-01", 84 + }, 85 + body: JSON.stringify({ 86 + model: settings.modelName, 87 + max_tokens: 1000, 88 + messages: [ 89 + { 90 + role: "user", 91 + content: prompt, 92 + }, 93 + ], 94 + }), 95 + }); 96 + 97 + if (response.status !== 200) { 98 + throw new Error( 99 + `API returned status code ${response.status}: ${JSON.stringify( 100 + response.json 101 + )}` 102 + ); 103 + } 104 + 105 + return response.json.content[0].text; 106 + } 107 + 108 + async function fetchFromOpenAICompatible( 109 + settings: AITaggerSettings, 110 + prompt: string, 111 + endpoint: string 112 + ): Promise<string> { 113 + const response = await requestUrl({ 114 + url: endpoint, 115 + method: "POST", 116 + headers: { 117 + "Content-Type": "application/json", 118 + Authorization: `Bearer ${settings.apiKey}`, 119 + }, 120 + body: JSON.stringify({ 121 + model: settings.modelName, 122 + messages: [ 123 + { 124 + role: "user", 125 + content: prompt, 126 + }, 127 + ], 128 + max_tokens: 1000, 129 + temperature: 0.3, 130 + }), 131 + }); 132 + 133 + if (response.status !== 200) { 134 + throw new Error( 135 + `API returned status code ${response.status}: ${JSON.stringify( 136 + response.json 137 + )}` 138 + ); 139 + } 140 + 141 + return response.json.choices[0].message.content; 142 + } 143 + 144 + async function fetchFromGoogle( 145 + settings: AITaggerSettings, 146 + prompt: string, 147 + apiUrl: string 148 + ): Promise<string> { 149 + const apiEndpoint = `${apiUrl}${settings.modelName}:generateContent?key=${settings.apiKey}`; 150 + 151 + const response = await requestUrl({ 152 + url: apiEndpoint, 153 + method: "POST", 154 + headers: { 155 + "Content-Type": "application/json", 156 + }, 157 + body: JSON.stringify({ 158 + contents: [ 159 + { 160 + role: "user", 161 + parts: [{ text: prompt }], 162 + }, 163 + ], 164 + generationConfig: { 165 + temperature: 0.2, 166 + maxOutputTokens: 1000, 167 + }, 168 + }), 169 + }); 170 + 171 + if (response.status !== 200) { 172 + throw new Error( 173 + `API returned status code ${response.status}: ${JSON.stringify( 174 + response.json 175 + )}` 176 + ); 177 + } 178 + 179 + return response.json.candidates[0].content.parts[0].text; 180 + }
+223
src/ui/AITaggerSettingTab.ts
··· 1 + import { App, PluginSettingTab, Setting } from "obsidian"; 2 + import { i18n } from "../i18n"; 3 + import AITaggerPlugin from "../main"; 4 + import { MODEL_CONFIGS, PROMPT_TEMPLATES } from "../models/constants"; 5 + import { AIProvider } from "../models/types"; 6 + 7 + export class AITaggerSettingTab extends PluginSettingTab { 8 + plugin: AITaggerPlugin; 9 + 10 + constructor(app: App, plugin: AITaggerPlugin) { 11 + super(app, plugin); 12 + this.plugin = plugin; 13 + } 14 + 15 + display(): void { 16 + const { containerEl } = this; 17 + containerEl.empty(); 18 + 19 + this.addProviderSection(containerEl); 20 + this.addApiSection(containerEl); 21 + this.addTaggingOptionsSection(containerEl); 22 + this.addPromptSection(containerEl); 23 + } 24 + 25 + private addProviderSection(containerEl: HTMLElement): void { 26 + new Setting(containerEl); 27 + // setting for cloud vs local service 28 + 29 + // AI Provider selection 30 + new Setting(containerEl) 31 + .setName(i18n.t("settings.provider.title")) 32 + .setDesc(i18n.t("settings.provider.desc")) 33 + .addDropdown((dropdown) => { 34 + dropdown 35 + .addOption( 36 + AIProvider.Anthropic, 37 + i18n.t("settings.provider.anthropic") 38 + ) 39 + .addOption(AIProvider.OpenAI, i18n.t("settings.provider.openai")) 40 + .addOption(AIProvider.Mistral, i18n.t("settings.provider.mistral")) 41 + .addOption(AIProvider.Google, i18n.t("settings.provider.google")) 42 + .addOption(AIProvider.Custom, i18n.t("settings.provider.custom")) 43 + .setValue(this.plugin.settings.provider) 44 + .onChange(async (value) => { 45 + const newProvider = value as AIProvider; 46 + this.plugin.settings.provider = newProvider; 47 + 48 + // Update model to default for the selected provider 49 + if (newProvider !== AIProvider.Custom) { 50 + this.plugin.settings.modelName = 51 + MODEL_CONFIGS[newProvider].defaultModel; 52 + } 53 + 54 + await this.plugin.saveSettings(); 55 + this.display(); // Refresh to show provider-specific options 56 + }); 57 + return dropdown; 58 + }); 59 + 60 + // Custom endpoint setting (only shown for custom provider) 61 + if (this.plugin.settings.provider === AIProvider.Custom) { 62 + new Setting(containerEl) 63 + .setName(i18n.t("settings.customEndpoint.title")) 64 + .setDesc(i18n.t("settings.customEndpoint.desc")) 65 + .addText((text) => 66 + text 67 + .setPlaceholder(i18n.t("settings.customEndpoint.placeholder")) 68 + .setValue(this.plugin.settings.customEndpoint) 69 + .onChange(async (value) => { 70 + this.plugin.settings.customEndpoint = value; 71 + await this.plugin.saveSettings(); 72 + }) 73 + ); 74 + } 75 + } 76 + 77 + private addApiSection(containerEl: HTMLElement): void { 78 + // Get the current provider config 79 + const providerConfig = MODEL_CONFIGS[this.plugin.settings.provider]; 80 + 81 + // API Key with provider-specific description 82 + const providerName = 83 + this.plugin.settings.provider === AIProvider.Custom 84 + ? "" 85 + : this.plugin.settings.provider; 86 + 87 + new Setting(containerEl) 88 + .setName(i18n.t("settings.apiKey.title")) 89 + .setDesc( 90 + i18n.t("settings.apiKey.desc", { provider: providerName }) + 91 + " " + 92 + (providerConfig.apiKeyUrl 93 + ? i18n.t("settings.apiKey.getKey", { 94 + url: providerConfig.apiKeyUrl, 95 + }) 96 + : "") + 97 + " " + 98 + i18n.t("settings.apiKey.recommendation") 99 + ) 100 + .addText((text) => 101 + text 102 + .setPlaceholder(i18n.t("settings.apiKey.placeholder")) 103 + .setValue(this.plugin.settings.apiKey) 104 + .onChange(async (value) => { 105 + this.plugin.settings.apiKey = value; 106 + await this.plugin.saveSettings(); 107 + }) 108 + ); 109 + 110 + // AI model selection (provider-specific) 111 + new Setting(containerEl) 112 + .setName(i18n.t("settings.model.title")) 113 + .setDesc(i18n.t("settings.model.desc")) 114 + .addDropdown((dropdown) => { 115 + // Add models for the selected provider 116 + providerConfig.models.forEach((model) => { 117 + dropdown.addOption(model.id, model.name); 118 + }); 119 + 120 + // If current model isn't in the list, add it 121 + if ( 122 + !providerConfig.models.some( 123 + (m) => m.id === this.plugin.settings.modelName 124 + ) 125 + ) { 126 + dropdown.addOption( 127 + this.plugin.settings.modelName, 128 + i18n.t("settings.model.custom", { 129 + model: this.plugin.settings.modelName, 130 + }) 131 + ); 132 + } 133 + 134 + dropdown 135 + .setValue(this.plugin.settings.modelName) 136 + .onChange(async (value) => { 137 + this.plugin.settings.modelName = value; 138 + await this.plugin.saveSettings(); 139 + }); 140 + 141 + return dropdown; 142 + }); 143 + } 144 + 145 + private addTaggingOptionsSection(containerEl: HTMLElement): void { 146 + new Setting(containerEl) 147 + .setName(i18n.t("settings.maxTags.title")) 148 + .setDesc(i18n.t("settings.maxTags.desc")) 149 + .addSlider((slider) => 150 + slider 151 + .setLimits(1, 20, 1) 152 + .setValue(this.plugin.settings.maxTags) 153 + .setDynamicTooltip() 154 + .onChange(async (value) => { 155 + this.plugin.settings.maxTags = value; 156 + await this.plugin.saveSettings(); 157 + }) 158 + ); 159 + } 160 + 161 + private addPromptSection(containerEl: HTMLElement): void { 162 + // Prompt option dropdown 163 + new Setting(containerEl) 164 + .setName(i18n.t("settings.promptStyle.title")) 165 + .setDesc(i18n.t("settings.promptStyle.desc")) 166 + .addDropdown((dropdown) => { 167 + dropdown 168 + .addOption("standard", i18n.t("settings.promptStyle.standard")) 169 + .addOption("descriptive", i18n.t("settings.promptStyle.descriptive")) 170 + .addOption("academic", i18n.t("settings.promptStyle.academic")) 171 + .addOption("concise", i18n.t("settings.promptStyle.concise")) 172 + .addOption("custom", i18n.t("settings.promptStyle.custom")) 173 + .setValue(this.plugin.settings.promptOption) 174 + .onChange(async (value) => { 175 + this.plugin.settings.promptOption = value; 176 + 177 + // Update prompt template if not using custom 178 + if (value !== "custom") { 179 + this.plugin.settings.promptTemplate = 180 + PROMPT_TEMPLATES[value as keyof typeof PROMPT_TEMPLATES]; 181 + 182 + // Force refresh to update the textarea with the new template 183 + this.display(); 184 + } else if (this.plugin.settings.promptTemplate === "") { 185 + // If switching to custom and no custom template yet, initialize with standard 186 + this.plugin.settings.promptTemplate = PROMPT_TEMPLATES.standard; 187 + this.display(); 188 + } 189 + 190 + await this.plugin.saveSettings(); 191 + }); 192 + return dropdown; 193 + }); 194 + 195 + // Only show prompt template textarea if custom option is selected 196 + if (this.plugin.settings.promptOption === "custom") { 197 + new Setting(containerEl) 198 + .setName(i18n.t("settings.customPrompt.title")) 199 + .setDesc(i18n.t("settings.customPrompt.desc")) 200 + .addTextArea((textarea) => 201 + textarea 202 + .setValue(this.plugin.settings.promptTemplate) 203 + .onChange(async (value) => { 204 + this.plugin.settings.promptTemplate = value; 205 + await this.plugin.saveSettings(); 206 + }) 207 + ) 208 + .setClass("ai-tagger-wide-setting"); 209 + } else { 210 + // Show the current template as read-only if not using custom 211 + new Setting(containerEl) 212 + .setName(i18n.t("settings.currentPrompt.title")) 213 + .setDesc(i18n.t("settings.currentPrompt.desc")) 214 + .addTextArea((textarea) => { 215 + textarea 216 + .setValue(this.plugin.settings.promptTemplate) 217 + .setDisabled(true); 218 + return textarea; 219 + }) 220 + .setClass("ai-tagger-wide-setting"); 221 + } 222 + } 223 + }
+84
src/ui/ConfirmModal.ts
··· 1 + import { App, Modal } from "obsidian"; 2 + import { i18n } from "../i18n"; 3 + 4 + export class ConfirmModal extends Modal { 5 + private onConfirmCallback: () => void; 6 + private message: string; 7 + private hasError: boolean; 8 + private errorMessage: string; 9 + 10 + constructor( 11 + app: App, 12 + message: string, 13 + onConfirmCallback: () => void, 14 + hasError = false, 15 + errorMessage = "" 16 + ) { 17 + super(app); 18 + this.message = message; 19 + this.onConfirmCallback = onConfirmCallback; 20 + this.hasError = hasError; 21 + this.errorMessage = errorMessage; 22 + } 23 + 24 + onOpen() { 25 + const { contentEl } = this; 26 + 27 + contentEl.createEl("p", { text: this.message }); 28 + 29 + if (this.hasError) { 30 + const errorEl = contentEl.createEl("p", { text: this.errorMessage }); 31 + errorEl.addClass("ai-tagger-error-message"); 32 + 33 + const buttonContainer = contentEl.createDiv(); 34 + buttonContainer.addClass("ai-tagger-modal-buttons"); 35 + 36 + const settingsButton = buttonContainer.createEl("button", { 37 + text: i18n.t("modals.settings"), 38 + }); 39 + settingsButton.addEventListener("click", () => { 40 + this.close(); 41 + // Open settings tab 42 + // Using type assertion with a more specific interface would be better 43 + // if we had access to the internal Obsidian API types 44 + if ("setting" in this.app) { 45 + const appWithSetting = this.app as unknown as { 46 + setting: { open: () => void; openTabById: (id: string) => void }; 47 + }; 48 + appWithSetting.setting.open(); 49 + appWithSetting.setting.openTabById("ai-tagger"); 50 + } 51 + }); 52 + 53 + const cancelButton = buttonContainer.createEl("button", { 54 + text: i18n.t("modals.cancel"), 55 + }); 56 + cancelButton.addEventListener("click", () => { 57 + this.close(); 58 + }); 59 + } else { 60 + const buttonContainer = contentEl.createDiv(); 61 + buttonContainer.addClass("ai-tagger-modal-buttons"); 62 + 63 + const confirmButton = buttonContainer.createEl("button", { 64 + text: i18n.t("modals.confirm"), 65 + }); 66 + confirmButton.addEventListener("click", () => { 67 + this.onConfirmCallback(); 68 + this.close(); 69 + }); 70 + 71 + const cancelButton = buttonContainer.createEl("button", { 72 + text: i18n.t("modals.cancel"), 73 + }); 74 + cancelButton.addEventListener("click", () => { 75 + this.close(); 76 + }); 77 + } 78 + } 79 + 80 + onClose() { 81 + const { contentEl } = this; 82 + contentEl.empty(); 83 + } 84 + }
+40
src/utils/validationUtils.ts
··· 1 + import { AIProvider, AITaggerSettings } from "../models/types"; 2 + 3 + export function validateApiSettings(settings: AITaggerSettings): { valid: boolean; error?: string } { 4 + if (!settings.apiKey) { 5 + return { 6 + valid: false, 7 + error: "API key not configured. Please add your API key in the plugin settings." 8 + }; 9 + } 10 + 11 + if (settings.provider === AIProvider.Custom && !settings.customEndpoint) { 12 + return { 13 + valid: false, 14 + error: "Custom API endpoint not configured. Please add your endpoint URL in the plugin settings." 15 + }; 16 + } 17 + 18 + return { valid: true }; 19 + } 20 + 21 + export function getModelName(settings: AITaggerSettings): string { 22 + const provider = settings.provider; 23 + const modelId = settings.modelName; 24 + 25 + const model = provider === AIProvider.Custom 26 + ? { name: modelId } 27 + : findModelById(provider, modelId); 28 + 29 + return model?.name || modelId; 30 + } 31 + 32 + function findModelById(provider: AIProvider, modelId: string) { 33 + const models = require("../models/constants").MODEL_CONFIGS[provider].models; 34 + return models.find((m: { id: string; name: string }) => m.id === modelId); 35 + } 36 + 37 + export function formatTagList(tags: string[]): string { 38 + if (tags.length === 0) return "No tags generated"; 39 + return tags.join(", "); 40 + }
+6
styles.css
··· 25 25 resize: vertical; 26 26 } 27 27 28 + /* Styling for disabled textarea */ 29 + .ai-tagger-wide-setting textarea:disabled { 30 + opacity: 0.75; 31 + background-color: var(--background-secondary); 32 + } 33 + 28 34 /* Add a subtle highlight for successfully tagged notes if needed */ 29 35 .ai-tagged { 30 36 border-left: 2px solid var(--interactive-accent);
+3 -1
tsconfig.json
··· 11 11 "importHelpers": true, 12 12 "isolatedModules": true, 13 13 "strictNullChecks": true, 14 + "allowSyntheticDefaultImports": true, 15 + "resolveJsonModule": true, 14 16 "lib": [ 15 17 "DOM", 16 18 "ES5", ··· 21 23 "include": [ 22 24 "**/*.ts" 23 25 ] 24 - } 26 + }
+3 -2
versions.json
··· 1 1 { 2 - "1.0.0": "0.15.0" 3 - } 2 + "1.0.0": "0.15.0", 3 + "1.1.0": "0.15.0" 4 + }