pocket_cli/cards/
snippet.rs

1use crate::cards::{Card, CardConfig, CardCommand};
2use crate::utils::{read_clipboard, summarize_text, SummaryMetadata};
3use crate::models::{Entry, ContentType};
4use crate::storage::StorageManager;
5use anyhow::{Result, anyhow, Context};
6use std::path::PathBuf;
7use std::fs;
8
9/// Card for enhanced snippet functionality
10pub struct SnippetCard {
11    /// Name of the card
12    name: String,
13    
14    /// Version of the card
15    version: String,
16    
17    /// Description of the card
18    description: String,
19    
20    /// Configuration for the card
21    config: SnippetCardConfig,
22    
23    /// Path to the Pocket data directory
24    data_dir: PathBuf,
25}
26
27/// Configuration for the snippet card
28#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
29pub struct SnippetCardConfig {
30    /// Whether to automatically summarize snippets
31    pub auto_summarize: bool,
32    
33    /// Maximum length for auto-generated summaries
34    pub max_summary_length: usize,
35    
36    /// Whether to include summaries in search results
37    pub search_in_summaries: bool,
38    
39    /// Weight to give summary matches in search results (0.0-1.0)
40    pub summary_search_weight: f32,
41}
42
43impl Default for SnippetCardConfig {
44    fn default() -> Self {
45        Self {
46            auto_summarize: true,
47            max_summary_length: 150,
48            search_in_summaries: true,
49            summary_search_weight: 0.7,
50        }
51    }
52}
53
54impl SnippetCard {
55    /// Creates a new snippet card
56    pub fn new(data_dir: impl AsRef<std::path::Path>) -> Self {
57        Self {
58            name: "snippet".to_string(),
59            version: env!("CARGO_PKG_VERSION").to_string(),
60            description: "Enhanced snippet functionality with clipboard and summarization features".to_string(),
61            config: SnippetCardConfig::default(),
62            data_dir: data_dir.as_ref().to_path_buf(),
63        }
64    }
65    
66    /// Adds a snippet from a file or editor
67    pub fn add(&self, 
68              file: Option<&str>,
69              message: Option<&str>,
70              use_editor: bool, 
71              use_clipboard: bool,
72              backpack: Option<&str>,
73              summarize: Option<&str>) -> Result<String> {
74        // Initialize content
75        let content = if let Some(file_path) = file {
76            // Read from file
77            fs::read_to_string(file_path)
78                .context(format!("Failed to read file: {}", file_path))?
79        } else if use_editor {
80            // Open editor
81            crate::utils::open_editor(None)
82                .context("Failed to open editor")?
83        } else if use_clipboard {
84            // Read from clipboard
85            read_clipboard()
86                .context("Failed to read from clipboard")?
87        } else {
88            // No content source provided
89            return Err(anyhow!("No content source provided. Use --file, --editor, or --clipboard options"));
90        };
91
92        if content.trim().is_empty() {
93            return Err(anyhow!("Content is empty"));
94        }
95        
96        // Detect content type
97        let content_type = if let Some(file_path) = file {
98            let path = PathBuf::from(file_path);
99            crate::utils::detect_content_type(Some(&path), Some(&content))
100        } else {
101            crate::utils::detect_content_type(None, Some(&content))
102        };
103        
104        // Create a title from message, first line, or first 50 chars if no lines
105        let title = if let Some(msg) = message {
106            msg.to_string()
107        } else {
108            content.lines().next()
109                .unwrap_or(&content[..std::cmp::min(50, content.len())])
110                .to_string()
111        };
112        
113        // Create entry
114        let mut entry = Entry::new(title, content_type, None, vec![]);
115        
116        // Create summary metadata
117        let summary = if let Some(manual_summary) = summarize {
118            // User provided a summary, use it
119            SummaryMetadata::new(manual_summary.to_string(), false)
120        } else if self.config.auto_summarize {
121            // Auto-generate a summary
122            let summary = summarize_text(&content)
123                .unwrap_or_else(|_| {
124                    // Fallback: use first line or first 100 chars
125                    content.lines().next()
126                        .unwrap_or(&content[..std::cmp::min(100, content.len())])
127                        .to_string()
128                });
129                
130            // Truncate if needed
131            let summary = if summary.len() > self.config.max_summary_length {
132                format!("{}...", &summary[..self.config.max_summary_length - 3])
133            } else {
134                summary
135            };
136            
137            SummaryMetadata::new(summary, true)
138        } else {
139            // No summarization requested
140            SummaryMetadata::new("".to_string(), true)
141        };
142        
143        // Add summary metadata to entry
144        entry.add_metadata("summary", &summary.to_json());
145        
146        // Save the entry
147        let storage = StorageManager::new()?;
148        storage.save_entry(&entry, &content, backpack)?;
149        
150        Ok(entry.id)
151    }
152    
153    /// Adds a snippet from clipboard content
154    pub fn add_from_clipboard(&self, 
155                              user_summary: Option<&str>, 
156                              backpack: Option<&str>) -> Result<String> {
157        // Read content from clipboard
158        let content = read_clipboard()
159            .context("Failed to read from clipboard")?;
160            
161        if content.trim().is_empty() {
162            return Err(anyhow!("Clipboard is empty"));
163        }
164        
165        // Detect content type
166        let content_type = crate::utils::detect_content_type(None, Some(&content));
167        
168        // Create a title from the first line, or first 50 chars if no lines
169        let title = content.lines().next()
170            .unwrap_or(&content[..std::cmp::min(50, content.len())])
171            .to_string();
172        
173        // Create entry
174        let mut entry = Entry::new(title, content_type, None, vec![]);
175        
176        // Create summary metadata
177        let summary = if let Some(manual_summary) = user_summary {
178            // User provided a summary, use it
179            SummaryMetadata::new(manual_summary.to_string(), false)
180        } else if self.config.auto_summarize {
181            // Auto-generate a summary
182            let summary = summarize_text(&content)
183                .unwrap_or_else(|_| {
184                    // Fallback: use first line or first 100 chars
185                    content.lines().next()
186                        .unwrap_or(&content[..std::cmp::min(100, content.len())])
187                        .to_string()
188                });
189                
190            // Truncate if needed
191            let summary = if summary.len() > self.config.max_summary_length {
192                format!("{}...", &summary[..self.config.max_summary_length - 3])
193            } else {
194                summary
195            };
196            
197            SummaryMetadata::new(summary, true)
198        } else {
199            // No summarization requested
200            SummaryMetadata::new("".to_string(), true)
201        };
202        
203        // Add summary metadata to entry
204        entry.add_metadata("summary", &summary.to_json());
205        
206        // Save the entry
207        let storage = StorageManager::new()?;
208        storage.save_entry(&entry, &content, backpack)?;
209        
210        Ok(entry.id)
211    }
212    
213    /// Searches for snippets, including in summaries if configured
214    pub fn search(&self, query: &str, limit: usize, backpack: Option<&str>) -> Result<Vec<(Entry, String, Option<SummaryMetadata>)>> {
215        let storage = StorageManager::new()?;
216        
217        // Basic search first
218        let mut results = Vec::new();
219        let entries = storage.search_entries(query, backpack, limit)?;
220        
221        for (entry, content) in entries {
222            // Load summary metadata if it exists
223            let summary = if let Some(summary_json) = entry.get_metadata("summary") {
224                match SummaryMetadata::from_json(summary_json) {
225                    Ok(summary) => Some(summary),
226                    Err(_) => None,
227                }
228            } else {
229                None
230            };
231            
232            results.push((entry, content, summary));
233        }
234        
235        // If searching in summaries is enabled, also search in summaries
236        if self.config.search_in_summaries {
237            let all_entries = storage.list_entries(backpack)?;
238            
239            for entry in all_entries {
240                // Skip entries already in results
241                if results.iter().any(|(e, _, _)| e.id == entry.id) {
242                    continue;
243                }
244                
245                // Get summary metadata
246                if let Some(summary_json) = entry.get_metadata("summary") {
247                    if let Ok(summary) = SummaryMetadata::from_json(summary_json) {
248                        // Check if query matches summary
249                        if summary.summary.to_lowercase().contains(&query.to_lowercase()) {
250                            // Load the entry content
251                            if let Ok((entry, content)) = storage.load_entry(&entry.id, backpack) {
252                                results.push((entry, content, Some(summary)));
253                                
254                                // Check if we've reached the limit
255                                if results.len() >= limit {
256                                    break;
257                                }
258                            }
259                        }
260                    }
261                }
262            }
263        }
264        
265        Ok(results)
266    }
267}
268
269impl Card for SnippetCard {
270    fn name(&self) -> &str {
271        &self.name
272    }
273    
274    fn version(&self) -> &str {
275        &self.version
276    }
277    
278    fn description(&self) -> &str {
279        &self.description
280    }
281    
282    fn initialize(&mut self, config: &CardConfig) -> Result<()> {
283        // Load configuration if available
284        if let Some(options) = &config.options.get("config") {
285            if let Ok(card_config) = serde_json::from_value::<SnippetCardConfig>((*options).clone()) {
286                self.config = card_config;
287            }
288        }
289        
290        Ok(())
291    }
292    
293    fn execute(&self, command: &str, args: &[String]) -> Result<()> {
294        match command {
295            "add" => {
296                let mut file = None;
297                let mut message = None;
298                let mut use_editor = false;
299                let mut use_clipboard = false;
300                let mut backpack = None;
301                let mut summarize = None;
302                
303                // Parse arguments
304                let mut i = 0;
305                while i < args.len() {
306                    if args[i].starts_with("--file=") {
307                        file = Some(args[i][7..].to_string());
308                        i += 1;
309                    } else if args[i] == "--file" {
310                        if i + 1 < args.len() {
311                            file = Some(args[i + 1].clone());
312                            i += 2;
313                        } else {
314                            return Err(anyhow!("--file requires a file path"));
315                        }
316                    } else if args[i].starts_with("--message=") {
317                        message = Some(args[i][10..].to_string());
318                        i += 1;
319                    } else if args[i] == "--message" {
320                        if i + 1 < args.len() {
321                            message = Some(args[i + 1].clone());
322                            i += 2;
323                        } else {
324                            return Err(anyhow!("--message requires a message string"));
325                        }
326                    } else if args[i] == "--editor" {
327                        use_editor = true;
328                        i += 1;
329                    } else if args[i] == "--clipboard" {
330                        use_clipboard = true;
331                        i += 1;
332                    } else if args[i].starts_with("--backpack=") {
333                        backpack = Some(args[i][11..].to_string());
334                        i += 1;
335                    } else if args[i] == "--backpack" {
336                        if i + 1 < args.len() {
337                            backpack = Some(args[i + 1].clone());
338                            i += 2;
339                        } else {
340                            return Err(anyhow!("--backpack requires a backpack name"));
341                        }
342                    } else if args[i].starts_with("--summarize=") {
343                        summarize = Some(args[i][12..].to_string());
344                        i += 1;
345                    } else if args[i] == "--summarize" {
346                        if i + 1 < args.len() {
347                            summarize = Some(args[i + 1].clone());
348                            i += 2;
349                        } else {
350                            return Err(anyhow!("--summarize requires a summary string"));
351                        }
352                    } else {
353                        i += 1;
354                    }
355                }
356                
357                // Add snippet
358                let id = self.add(file.as_deref(), message.as_deref(), use_editor, use_clipboard, backpack.as_deref(), summarize.as_deref())?;
359                println!("Added snippet with ID: {}", id);
360                Ok(())
361            },
362            "add-from-clipboard" => {
363                let mut user_summary = None;
364                let mut backpack = None;
365                
366                // Parse arguments
367                let mut i = 0;
368                while i < args.len() {
369                    match args[i].as_str() {
370                        "--summarize" => {
371                            if i + 1 < args.len() {
372                                user_summary = Some(args[i + 1].as_str());
373                                i += 2;
374                            } else {
375                                return Err(anyhow!("--summarize requires a summary string"));
376                            }
377                        },
378                        "--backpack" => {
379                            if i + 1 < args.len() {
380                                backpack = Some(args[i + 1].as_str());
381                                i += 2;
382                            } else {
383                                return Err(anyhow!("--backpack requires a backpack name"));
384                            }
385                        },
386                        _ => {
387                            i += 1;
388                        }
389                    }
390                }
391                
392                // Add from clipboard
393                let id = self.add_from_clipboard(user_summary, backpack)?;
394                println!("Added snippet from clipboard with ID: {}", id);
395                Ok(())
396            },
397            "search" => {
398                if args.is_empty() {
399                    return Err(anyhow!("search requires a query string"));
400                }
401                
402                let query = &args[0];
403                let limit = if args.len() > 1 {
404                    args[1].parse().unwrap_or(10)
405                } else {
406                    10
407                };
408                
409                let mut backpack = None;
410                let mut i = 2;
411                while i < args.len() {
412                    match args[i].as_str() {
413                        "--backpack" => {
414                            if i + 1 < args.len() {
415                                backpack = Some(args[i + 1].as_str());
416                                i += 2;
417                            } else {
418                                return Err(anyhow!("--backpack requires a backpack name"));
419                            }
420                        },
421                        _ => {
422                            i += 1;
423                        }
424                    }
425                }
426                
427                // Search
428                let results = self.search(query, limit, backpack)?;
429                
430                if results.is_empty() {
431                    println!("No results found");
432                    return Ok(());
433                }
434                
435                println!("Search results for '{}':", query);
436                for (i, (entry, content, summary)) in results.iter().enumerate() {
437                    println!("{}. {} ({})", i + 1, entry.title, entry.id);
438                    
439                    // Show summary if available
440                    if let Some(summary) = summary {
441                        println!("   Summary: {}", summary.summary);
442                    }
443                    
444                    // Show snippet of content
445                    let preview = if content.len() > 100 {
446                        format!("{}...", &content[..97])
447                    } else {
448                        content.clone()
449                    };
450                    println!("   Content: {}", preview.replace('\n', " "));
451                    println!();
452                }
453                
454                Ok(())
455            },
456            "config" => {
457                // Show current configuration
458                println!("Snippet card configuration:");
459                println!("  Auto-summarize: {}", self.config.auto_summarize);
460                println!("  Max summary length: {}", self.config.max_summary_length);
461                println!("  Search in summaries: {}", self.config.search_in_summaries);
462                println!("  Summary search weight: {}", self.config.summary_search_weight);
463                Ok(())
464            },
465            _ => Err(anyhow!("Unknown command: {}", command))
466        }
467    }
468    
469    fn commands(&self) -> Vec<CardCommand> {
470        vec![
471            CardCommand {
472                name: "add".to_string(),
473                description: "Add a new snippet from a file or editor".to_string(),
474                usage: "pocket cards execute snippet add [--file=FILE] [--message=MESSAGE] [--editor] [--backpack=BACKPACK] [--summarize=SUMMARY]".to_string(),
475            },
476            CardCommand {
477                name: "add-from-clipboard".to_string(),
478                description: "Add a snippet from clipboard content".to_string(),
479                usage: "pocket cards execute snippet add-from-clipboard [--summarize SUMMARY] [--backpack BACKPACK]".to_string(),
480            },
481            CardCommand {
482                name: "search".to_string(),
483                description: "Search for snippets, including in summaries".to_string(),
484                usage: "pocket cards execute snippet search QUERY [LIMIT] [--backpack BACKPACK]".to_string(),
485            },
486            CardCommand {
487                name: "config".to_string(),
488                description: "Show current snippet card configuration".to_string(),
489                usage: "pocket cards execute snippet config".to_string(),
490            },
491        ]
492    }
493    
494    fn cleanup(&mut self) -> Result<()> {
495        Ok(())
496    }
497}