Auto tagging obsidian notes w/ AI
0
fork

Configure Feed

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

refactor: use AIProvider enum instead of string literals

- Convert provider string literals to AIProvider enum
- Use enum values in all provider comparisons
- Update MODEL_CONFIGS access to use the enum
- Maintain consistent code style

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>

+145 -88
+145 -88
main.ts
··· 10 10 requestUrl, 11 11 } from "obsidian"; 12 12 13 - type AIProvider = 'anthropic' | 'openai' | 'mistral' | 'google' | 'custom'; 13 + // type AIProvider = 'Anthropic' | 'OpenAI' | 'Mistral' | 'Google' | 'Custom'; 14 + 15 + enum AIProvider { 16 + Anthropic = "anthropic", 17 + OpenAI = "openai", 18 + Mistral = "mistral", 19 + Google = "google", 20 + Custom = "custom", 21 + } 14 22 15 23 interface AITaggerSettings { 16 24 provider: AIProvider; ··· 24 32 25 33 // Define prompt templates 26 34 const PROMPT_TEMPLATES = { 27 - standard: "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}", 28 - descriptive: "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}", 29 - academic: "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}", 30 - concise: "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}", 35 + standard: 36 + "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}", 37 + descriptive: 38 + "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}", 39 + academic: 40 + "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}", 41 + concise: 42 + "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}", 31 43 custom: "", 32 44 }; 33 45 ··· 78 90 }, 79 91 custom: { 80 92 apiUrl: "", 81 - models: [ 82 - { id: "custom-model", name: "Custom Model" }, 83 - ], 93 + models: [{ id: "custom-model", name: "Custom Model" }], 84 94 defaultModel: "custom-model", 85 95 apiKeyUrl: "", 86 - } 96 + }, 87 97 }; 88 98 89 99 const DEFAULT_SETTINGS: AITaggerSettings = { 90 - provider: "anthropic", 100 + provider: AIProvider.Anthropic, 91 101 apiKey: "", 92 - modelName: MODEL_CONFIGS.anthropic.defaultModel, 102 + modelName: MODEL_CONFIGS[AIProvider.Anthropic].defaultModel, 93 103 maxTags: 5, 94 104 promptOption: "standard", 95 105 promptTemplate: PROMPT_TEMPLATES.standard, ··· 118 128 ).open(); 119 129 return; 120 130 } 121 - 122 - if (this.settings.provider === "custom" && !this.settings.customEndpoint) { 131 + 132 + if ( 133 + this.settings.provider === AIProvider.Custom && 134 + !this.settings.customEndpoint 135 + ) { 123 136 new ConfirmModal( 124 137 this.app, 125 138 "Custom API endpoint missing", ··· 129 142 ).open(); 130 143 return; 131 144 } 132 - 145 + 133 146 // Tag the current note directly when clicking the ribbon icon 134 147 this.tagCurrentNote(); 135 148 } ··· 155 168 ).open(); 156 169 return true; 157 170 } 158 - 159 - if (this.settings.provider === "custom" && !this.settings.customEndpoint) { 171 + 172 + if ( 173 + this.settings.provider === AIProvider.Custom && 174 + !this.settings.customEndpoint 175 + ) { 160 176 new ConfirmModal( 161 177 this.app, 162 178 "Custom API endpoint missing", ··· 166 182 ).open(); 167 183 return true; 168 184 } 169 - 185 + 170 186 this.tagCurrentNote(); 171 187 } 172 188 return true; ··· 190 206 ).open(); 191 207 return; 192 208 } 193 - 194 - if (this.settings.provider === "custom" && !this.settings.customEndpoint) { 209 + 210 + if ( 211 + this.settings.provider === "custom" && 212 + !this.settings.customEndpoint 213 + ) { 195 214 new ConfirmModal( 196 215 this.app, 197 216 "Custom API endpoint missing", ··· 204 223 205 224 new ConfirmModal( 206 225 this.app, 207 - `This will tag all notes in your vault using the ${MODEL_CONFIGS[this.settings.provider].models.find(m => m.id === this.settings.modelName)?.name || this.settings.modelName} model. This may take a while and consume API credits. Do you want to continue?`, 226 + `This will tag all notes in your vault using the ${ 227 + MODEL_CONFIGS[this.settings.provider].models.find( 228 + (m) => m.id === this.settings.modelName 229 + )?.name || this.settings.modelName 230 + } model. This may take a while and consume API credits. Do you want to continue?`, 208 231 () => this.tagAllNotes() 209 232 ).open(); 210 233 }, ··· 233 256 ); 234 257 return; 235 258 } 236 - 237 - if (this.settings.provider === "custom" && !this.settings.customEndpoint) { 259 + 260 + if (this.settings.provider === AIProvider.Custom && !this.settings.customEndpoint) { 238 261 new Notice( 239 262 "Custom API endpoint not configured. Please add your endpoint URL in the plugin settings." 240 263 ); ··· 251 274 const content = await this.app.vault.read(file); 252 275 253 276 // Create a persistent notice 254 - const notice = new Notice("Analyzing note content and generating tags...", 0); 277 + const notice = new Notice( 278 + "Analyzing note content and generating tags...", 279 + 0 280 + ); 255 281 256 282 try { 257 283 const tags = await this.generateTags(content); 258 284 await this.updateNoteFrontmatter(file, tags); 259 - 285 + 260 286 // Update the notice with success message 261 287 notice.setMessage(`Successfully added tags: ${tags.join(", ")}`); 262 - 288 + 263 289 // Hide after 3 seconds 264 290 setTimeout(() => { 265 291 notice.hide(); 266 292 }, 3000); 267 293 } catch (error) { 268 294 console.error("Error tagging note:", error); 269 - const errorMessage = error instanceof Error ? error.message : String(error); 270 - 295 + const errorMessage = 296 + error instanceof Error ? error.message : String(error); 297 + 271 298 // Update the notice with error message 272 299 notice.setMessage(`Error tagging note: ${errorMessage}`); 273 - 300 + 274 301 // Hide after 5 seconds for error messages (giving more time to read) 275 302 setTimeout(() => { 276 303 notice.hide(); ··· 285 312 ); 286 313 return; 287 314 } 288 - 289 - if (this.settings.provider === "custom" && !this.settings.customEndpoint) { 315 + 316 + if (this.settings.provider === AIProvider.Custom && !this.settings.customEndpoint) { 290 317 new Notice( 291 318 "Custom API endpoint not configured. Please add your endpoint URL in the plugin settings." 292 319 ); ··· 296 323 const files = this.app.vault.getMarkdownFiles(); 297 324 let processed = 0; 298 325 let successful = 0; 299 - 326 + 300 327 // Create a persistent notice that we'll update 301 328 const notice = new Notice(`Starting to tag ${files.length} notes...`, 0); 302 329 303 330 for (const file of files) { 304 331 try { 305 332 // Update the notice with current file 306 - notice.setMessage(`Processing: ${file.path}\nProgress: ${processed}/${files.length} (${successful} successful)`); 307 - 333 + notice.setMessage( 334 + `Processing: ${file.path}\nProgress: ${processed}/${files.length} (${successful} successful)` 335 + ); 336 + 308 337 const content = await this.app.vault.read(file); 309 338 const tags = await this.generateTags(content); 310 339 await this.updateNoteFrontmatter(file, tags); ··· 317 346 } 318 347 319 348 // Final notice with completion message 320 - notice.setMessage(`Completed tagging ${successful}/${files.length} notes successfully`); 321 - 349 + notice.setMessage( 350 + `Completed tagging ${successful}/${files.length} notes successfully` 351 + ); 352 + 322 353 // Hide the notice after 3 seconds 323 354 setTimeout(() => { 324 355 notice.hide(); ··· 332 363 ); 333 364 } 334 365 335 - if (this.settings.provider === "custom" && !this.settings.customEndpoint) { 366 + if (this.settings.provider === AIProvider.Custom && !this.settings.customEndpoint) { 336 367 throw new Error( 337 368 "Custom API endpoint not configured. Please add your endpoint URL in the plugin settings." 338 369 ); ··· 346 377 try { 347 378 let response; 348 379 let tagText: string; 349 - 380 + 350 381 // Get provider configuration 351 382 const providerConfig = MODEL_CONFIGS[this.settings.provider]; 352 - 383 + 353 384 switch (this.settings.provider) { 354 - case "anthropic": 385 + case AIProvider.Anthropic: 355 386 response = await requestUrl({ 356 387 url: providerConfig.apiUrl, 357 388 method: "POST", ··· 371 402 ], 372 403 }), 373 404 }); 374 - 405 + 375 406 if (response.status !== 200) { 376 407 throw new Error( 377 408 `API returned status code ${response.status}: ${JSON.stringify( ··· 379 410 )}` 380 411 ); 381 412 } 382 - 413 + 383 414 tagText = response.json.content[0].text; 384 415 break; 385 - 386 - case "openai": 387 - case "mistral": 388 - case "custom": 416 + 417 + case AIProvider.OpenAI: 418 + case AIProvider.Mistral: 419 + case AIProvider.Custom: 389 420 // OpenAI and Mistral use the same API format 390 421 // For custom endpoints, we assume OpenAI compatible format 391 - const endpoint = this.settings.provider === "custom" 392 - ? this.settings.customEndpoint 393 - : providerConfig.apiUrl; 394 - 422 + const endpoint = 423 + this.settings.provider === AIProvider.Custom 424 + ? this.settings.customEndpoint 425 + : providerConfig.apiUrl; 426 + 395 427 response = await requestUrl({ 396 428 url: endpoint, 397 429 method: "POST", 398 430 headers: { 399 431 "Content-Type": "application/json", 400 - "Authorization": `Bearer ${this.settings.apiKey}`, 432 + Authorization: `Bearer ${this.settings.apiKey}`, 401 433 }, 402 434 body: JSON.stringify({ 403 435 model: this.settings.modelName, ··· 411 443 temperature: 0.3, 412 444 }), 413 445 }); 414 - 446 + 415 447 if (response.status !== 200) { 416 448 throw new Error( 417 449 `API returned status code ${response.status}: ${JSON.stringify( ··· 419 451 )}` 420 452 ); 421 453 } 422 - 454 + 423 455 tagText = response.json.choices[0].message.content; 424 456 break; 425 - 426 - case "google": 457 + 458 + case AIProvider.Google: 427 459 // Google Gemini has a different API format 428 460 const apiEndpoint = `${providerConfig.apiUrl}${this.settings.modelName}:generateContent?key=${this.settings.apiKey}`; 429 - 461 + 430 462 response = await requestUrl({ 431 463 url: apiEndpoint, 432 464 method: "POST", ··· 446 478 }, 447 479 }), 448 480 }); 449 - 481 + 450 482 if (response.status !== 200) { 451 483 throw new Error( 452 484 `API returned status code ${response.status}: ${JSON.stringify( ··· 454 486 )}` 455 487 ); 456 488 } 457 - 489 + 458 490 tagText = response.json.candidates[0].content.parts[0].text; 459 491 break; 460 - 492 + 461 493 default: 462 494 throw new Error(`Unknown provider: ${this.settings.provider}`); 463 495 } ··· 469 501 .filter((tag: string) => tag.length > 0); 470 502 } catch (error) { 471 503 console.error(`Error calling ${this.settings.provider} API:`, error); 472 - const errorMessage = error instanceof Error ? error.message : String(error); 504 + const errorMessage = 505 + error instanceof Error ? error.message : String(error); 473 506 throw new Error(`Failed to generate tags: ${errorMessage}`); 474 507 } 475 508 } ··· 537 570 // Open settings tab 538 571 // Using type assertion with a more specific interface would be better 539 572 // if we had access to the internal Obsidian API types 540 - if ('setting' in this.app) { 541 - const appWithSetting = this.app as unknown as { setting: { open: () => void; openTabById: (id: string) => void } }; 573 + if ("setting" in this.app) { 574 + const appWithSetting = this.app as unknown as { 575 + setting: { open: () => void; openTabById: (id: string) => void }; 576 + }; 542 577 appWithSetting.setting.open(); 543 578 appWithSetting.setting.openTabById("ai-tagger"); 544 579 } ··· 596 631 .setDesc("Select which AI provider to use for generating tags.") 597 632 .addDropdown((dropdown) => { 598 633 dropdown 599 - .addOption("anthropic", "Anthropic (Claude)") 600 - .addOption("openai", "OpenAI (GPT)") 601 - .addOption("mistral", "Mistral AI") 602 - .addOption("google", "Google (Gemini)") 603 - .addOption("custom", "Custom Endpoint (OpenAI Compatible)") 634 + .addOption(AIProvider.Anthropic, "Anthropic (Claude)") 635 + .addOption(AIProvider.OpenAI, "OpenAI (GPT)") 636 + .addOption(AIProvider.Mistral, "Mistral AI") 637 + .addOption(AIProvider.Google, "Google (Gemini)") 638 + .addOption(AIProvider.Custom, "Custom Endpoint (OpenAI Compatible)") 604 639 .setValue(this.plugin.settings.provider) 605 640 .onChange(async (value) => { 606 641 const newProvider = value as AIProvider; 607 642 this.plugin.settings.provider = newProvider; 608 - 643 + 609 644 // Update model to default for the selected provider 610 - if (newProvider !== "custom") { 611 - this.plugin.settings.modelName = MODEL_CONFIGS[newProvider].defaultModel; 645 + if (newProvider !== AIProvider.Custom) { 646 + this.plugin.settings.modelName = 647 + MODEL_CONFIGS[newProvider].defaultModel; 612 648 } 613 - 649 + 614 650 await this.plugin.saveSettings(); 615 651 this.display(); // Refresh to show provider-specific options 616 652 }); ··· 618 654 }); 619 655 620 656 // Custom endpoint setting (only shown for custom provider) 621 - if (this.plugin.settings.provider === "custom") { 657 + if (this.plugin.settings.provider === AIProvider.Custom) { 622 658 new Setting(containerEl) 623 659 .setName("Custom API Endpoint") 624 - .setDesc("Enter the URL for your custom OpenAI-compatible API endpoint.") 660 + .setDesc( 661 + "Enter the URL for your custom OpenAI-compatible API endpoint." 662 + ) 625 663 .addText((text) => 626 664 text 627 665 .setPlaceholder("https://your-api-endpoint.com/v1/chat/completions") ··· 635 673 636 674 // Get the current provider config 637 675 const providerConfig = MODEL_CONFIGS[this.plugin.settings.provider]; 638 - 676 + 639 677 // API Key with provider-specific description 640 678 new Setting(containerEl) 641 679 .setName("API key") 642 680 .setDesc( 643 - `Your ${this.plugin.settings.provider === "custom" ? "" : this.plugin.settings.provider} API key. Required to use the AI service. ${ 644 - providerConfig.apiKeyUrl ? `Get it from ${providerConfig.apiKeyUrl} if you don't have one already.` : "" 681 + `Your ${ 682 + this.plugin.settings.provider === AIProvider.Custom 683 + ? "" 684 + : this.plugin.settings.provider 685 + } API key. Required to use the AI service. ${ 686 + providerConfig.apiKeyUrl 687 + ? `Get it from ${providerConfig.apiKeyUrl} if you don't have one already.` 688 + : "" 645 689 } We recommend using a dedicated key for this plugin.` 646 690 ) 647 691 .addText((text) => ··· 660 704 .setDesc("Choose which AI model to use for tag generation.") 661 705 .addDropdown((dropdown) => { 662 706 // Add models for the selected provider 663 - providerConfig.models.forEach(model => { 707 + providerConfig.models.forEach((model) => { 664 708 dropdown.addOption(model.id, model.name); 665 709 }); 666 - 710 + 667 711 // If current model isn't in the list, add it 668 - if (!providerConfig.models.some(m => m.id === this.plugin.settings.modelName)) { 669 - dropdown.addOption(this.plugin.settings.modelName, this.plugin.settings.modelName + " (Custom)"); 712 + if ( 713 + !providerConfig.models.some( 714 + (m) => m.id === this.plugin.settings.modelName 715 + ) 716 + ) { 717 + dropdown.addOption( 718 + this.plugin.settings.modelName, 719 + this.plugin.settings.modelName + " (Custom)" 720 + ); 670 721 } 671 - 672 - dropdown.setValue(this.plugin.settings.modelName) 722 + 723 + dropdown 724 + .setValue(this.plugin.settings.modelName) 673 725 .onChange(async (value) => { 674 726 this.plugin.settings.modelName = value; 675 727 await this.plugin.saveSettings(); 676 728 }); 677 - 729 + 678 730 return dropdown; 679 731 }); 680 732 ··· 695 747 // Prompt option dropdown 696 748 new Setting(containerEl) 697 749 .setName("Prompt style") 698 - .setDesc("Choose a predefined prompt style or create your own custom prompt.") 750 + .setDesc( 751 + "Choose a predefined prompt style or create your own custom prompt." 752 + ) 699 753 .addDropdown((dropdown) => { 700 754 dropdown 701 755 .addOption("standard", "Standard") ··· 706 760 .setValue(this.plugin.settings.promptOption) 707 761 .onChange(async (value) => { 708 762 this.plugin.settings.promptOption = value; 709 - 763 + 710 764 // Update prompt template if not using custom 711 765 if (value !== "custom") { 712 - this.plugin.settings.promptTemplate = PROMPT_TEMPLATES[value as keyof typeof PROMPT_TEMPLATES]; 713 - 766 + this.plugin.settings.promptTemplate = 767 + PROMPT_TEMPLATES[value as keyof typeof PROMPT_TEMPLATES]; 768 + 714 769 // Force refresh to update the textarea with the new template 715 770 this.display(); 716 771 } else if (this.plugin.settings.promptTemplate === "") { ··· 718 773 this.plugin.settings.promptTemplate = PROMPT_TEMPLATES.standard; 719 774 this.display(); 720 775 } 721 - 776 + 722 777 await this.plugin.saveSettings(); 723 778 }); 724 779 return dropdown; ··· 744 799 // Show the current template as read-only if not using custom 745 800 new Setting(containerEl) 746 801 .setName("Current prompt template") 747 - .setDesc("This is the prompt template that will be used (read-only). Switch to Custom if you want to edit it.") 802 + .setDesc( 803 + "This is the prompt template that will be used (read-only). Switch to Custom if you want to edit it." 804 + ) 748 805 .addTextArea((textarea) => { 749 806 textarea 750 807 .setValue(this.plugin.settings.promptTemplate)