1+ package cmd
2+
3+ import (
4+ "fmt"
5+ "strconv"
6+ "strings"
7+
8+ "github.com/spf13/cobra"
9+ "github.com/streed/ml-notes/internal/autotag"
10+ "github.com/streed/ml-notes/internal/models"
11+ )
12+
13+ var autoTagCmd = & cobra.Command {
14+ Use : "auto-tag" ,
15+ Short : "Automatically generate tags for notes using AI" ,
16+ Long : `Use AI to analyze note content and automatically suggest or apply tags.
17+ This feature uses your configured Ollama instance to intelligently analyze
18+ note content and generate relevant organizational tags.
19+
20+ Examples:
21+ ml-notes auto-tag 123 # Suggest tags for note 123
22+ ml-notes auto-tag 123 456 789 # Suggest tags for multiple notes
23+ ml-notes auto-tag --all # Suggest tags for all notes
24+ ml-notes auto-tag --apply 123 # Apply suggested tags automatically
25+ ml-notes auto-tag --all --apply # Auto-tag all notes (use with caution!)
26+ ml-notes auto-tag --recent 10 --apply # Auto-tag 10 most recent notes` ,
27+ RunE : runAutoTag ,
28+ }
29+
30+ var (
31+ autoTagApply bool
32+ autoTagAll bool
33+ autoTagRecent int
34+ autoTagOverwrite bool
35+ autoTagDryRun bool
36+ )
37+
38+ func init () {
39+ rootCmd .AddCommand (autoTagCmd )
40+ autoTagCmd .Flags ().BoolVar (& autoTagApply , "apply" , false , "Automatically apply suggested tags to notes" )
41+ autoTagCmd .Flags ().BoolVar (& autoTagAll , "all" , false , "Process all notes in the database" )
42+ autoTagCmd .Flags ().IntVar (& autoTagRecent , "recent" , 0 , "Process N most recent notes" )
43+ autoTagCmd .Flags ().BoolVar (& autoTagOverwrite , "overwrite" , false , "Overwrite existing tags (default: merge with existing)" )
44+ autoTagCmd .Flags ().BoolVar (& autoTagDryRun , "dry-run" , false , "Show what would be tagged without applying changes" )
45+ }
46+
47+ func runAutoTag (_ * cobra.Command , args []string ) error {
48+ // Create auto-tagger service
49+ autoTagger := autotag .NewAutoTagger (appConfig )
50+
51+ // Check if auto-tagging is available
52+ if ! autoTagger .IsAvailable () {
53+ return fmt .Errorf ("auto-tagging is not available. Please ensure:\n " +
54+ "1. Summarization is enabled (ml-notes config set enable-summarization true)\n " +
55+ "2. Ollama is running and accessible\n " +
56+ "3. A summarization model is configured" )
57+ }
58+
59+ // Determine which notes to process
60+ var notes []* models.Note
61+ var err error
62+
63+ if autoTagAll {
64+ fmt .Println ("🔍 Loading all notes for auto-tagging..." )
65+ notes , err = noteRepo .List (0 , 0 ) // Get all notes
66+ if err != nil {
67+ return fmt .Errorf ("failed to load notes: %w" , err )
68+ }
69+ } else if autoTagRecent > 0 {
70+ fmt .Printf ("🔍 Loading %d most recent notes for auto-tagging...\n " , autoTagRecent )
71+ notes , err = noteRepo .List (autoTagRecent , 0 )
72+ if err != nil {
73+ return fmt .Errorf ("failed to load recent notes: %w" , err )
74+ }
75+ } else if len (args ) > 0 {
76+ // Process specific note IDs
77+ fmt .Printf ("🔍 Loading %d specified notes for auto-tagging...\n " , len (args ))
78+ for _ , arg := range args {
79+ id , err := strconv .Atoi (arg )
80+ if err != nil {
81+ return fmt .Errorf ("invalid note ID '%s': must be a number" , arg )
82+ }
83+
84+ note , err := noteRepo .GetByID (id )
85+ if err != nil {
86+ return fmt .Errorf ("failed to get note %d: %w" , id , err )
87+ }
88+ notes = append (notes , note )
89+ }
90+ } else {
91+ return fmt .Errorf ("must specify note IDs, --all, or --recent N" )
92+ }
93+
94+ if len (notes ) == 0 {
95+ fmt .Println ("No notes found to process." )
96+ return nil
97+ }
98+
99+ fmt .Printf ("🤖 Processing %d notes with AI auto-tagging...\n " , len (notes ))
100+
101+ if autoTagDryRun {
102+ fmt .Println ("🧪 DRY RUN MODE - No changes will be applied\n " )
103+ } else if autoTagApply {
104+ fmt .Println ("⚡ APPLY MODE - Tags will be automatically applied\n " )
105+ } else {
106+ fmt .Println ("💡 SUGGESTION MODE - Tags will be suggested but not applied\n " )
107+ }
108+
109+ // Process notes for auto-tagging
110+ successCount := 0
111+ errorCount := 0
112+
113+ for i , note := range notes {
114+ fmt .Printf ("📝 Processing note %d/%d (ID: %d): %s\n " , i + 1 , len (notes ), note .ID , truncateTitle (note .Title , 50 ))
115+
116+ // Get suggested tags
117+ suggestedTags , err := autoTagger .SuggestTags (note )
118+ if err != nil {
119+ fmt .Printf (" ❌ Error: %v\n " , err )
120+ errorCount ++
121+ continue
122+ }
123+
124+ if len (suggestedTags ) == 0 {
125+ fmt .Printf (" ⚠️ No tags suggested\n " )
126+ continue
127+ }
128+
129+ // Show suggestions
130+ fmt .Printf (" 🏷️ Suggested tags: %s\n " , strings .Join (suggestedTags , ", " ))
131+
132+ // Show existing tags if any
133+ if len (note .Tags ) > 0 {
134+ fmt .Printf (" 🏷️ Existing tags: %s\n " , strings .Join (note .Tags , ", " ))
135+ }
136+
137+ // Determine final tags
138+ var finalTags []string
139+ if autoTagOverwrite || len (note .Tags ) == 0 {
140+ finalTags = suggestedTags
141+ } else {
142+ // Merge with existing tags, avoiding duplicates
143+ tagSet := make (map [string ]bool )
144+ for _ , tag := range note .Tags {
145+ tagSet [tag ] = true
146+ finalTags = append (finalTags , tag )
147+ }
148+ for _ , tag := range suggestedTags {
149+ if ! tagSet [tag ] {
150+ finalTags = append (finalTags , tag )
151+ }
152+ }
153+ }
154+
155+ // Apply tags if requested and not in dry-run mode
156+ if autoTagApply && ! autoTagDryRun {
157+ if err := noteRepo .UpdateTags (note .ID , finalTags ); err != nil {
158+ fmt .Printf (" ❌ Failed to apply tags: %v\n " , err )
159+ errorCount ++
160+ continue
161+ }
162+
163+ fmt .Printf (" ✅ Applied tags: %s\n " , strings .Join (finalTags , ", " ))
164+ } else if autoTagDryRun {
165+ fmt .Printf (" 🧪 Would apply tags: %s\n " , strings .Join (finalTags , ", " ))
166+ }
167+
168+ successCount ++
169+ fmt .Println () // Empty line for readability
170+ }
171+
172+ // Summary
173+ fmt .Println (strings .Repeat ("=" , 60 ))
174+ fmt .Printf ("📊 Auto-tagging Summary:\n " )
175+ fmt .Printf (" ✅ Successfully processed: %d notes\n " , successCount )
176+ if errorCount > 0 {
177+ fmt .Printf (" ❌ Errors encountered: %d notes\n " , errorCount )
178+ }
179+
180+ if autoTagDryRun {
181+ fmt .Printf ("\n 🧪 This was a dry run. To apply the changes, run the same command without --dry-run\n " )
182+ } else if ! autoTagApply {
183+ fmt .Printf ("\n 💡 Tags were suggested but not applied. To apply automatically, use --apply flag\n " )
184+ }
185+
186+ return nil
187+ }
188+
189+ // truncateTitle helper function to truncate long titles for display
190+ func truncateTitle (title string , maxLen int ) string {
191+ if len (title ) <= maxLen {
192+ return title
193+ }
194+ return title [:maxLen - 3 ] + "..."
195+ }
0 commit comments