nameback_core/
lib.rs

1use anyhow::Result;
2use std::collections::HashSet;
3use std::path::{Path, PathBuf};
4
5// Internal modules (private)
6mod code_docstring;
7mod deps;
8mod deps_check;
9mod detector;
10mod dir_context;
11mod extractor;
12mod format_handlers;
13mod generator;
14mod geocoding;
15mod image_ocr;
16mod key_phrases;
17mod location_timestamp;
18mod pdf_content;
19mod renamer;
20mod scorer;
21mod series_detector;
22mod stem_analyzer;
23mod text_content;
24mod video_ocr;
25
26// Re-export public types
27pub use deps_check::{detect_needed_dependencies, Dependency, DependencyNeeds};
28pub use detector::FileCategory;
29
30/// Configuration options for the rename engine
31#[derive(Debug, Clone)]
32pub struct RenameConfig {
33    /// Skip hidden files and directories (starting with .)
34    pub skip_hidden: bool,
35    /// Include GPS location in filenames (for photos/videos)
36    pub include_location: bool,
37    /// Include formatted timestamp in filenames
38    pub include_timestamp: bool,
39    /// Use multi-frame video analysis (slower but better OCR)
40    pub multiframe_video: bool,
41    /// Use geocoding to convert GPS coordinates to city names (defaults to true)
42    /// When false, shows coordinates like "47.6N_122.3W" instead of "Seattle_WA"
43    pub geocode: bool,
44}
45
46impl Default for RenameConfig {
47    fn default() -> Self {
48        Self {
49            skip_hidden: false,
50            include_location: true, // Include GPS location by default
51            include_timestamp: true, // Include timestamps by default
52            multiframe_video: true, // Multi-frame video analysis is now the default
53            geocode: true, // Geocoding is enabled by default
54        }
55    }
56}
57
58/// Result of analyzing a single file
59#[derive(Debug, Clone)]
60pub struct FileAnalysis {
61    /// Original file path
62    pub original_path: PathBuf,
63    /// Original filename
64    pub original_name: String,
65    /// Proposed new filename (None if no suitable name found)
66    pub proposed_name: Option<String>,
67    /// File category detected
68    pub file_category: FileCategory,
69}
70
71/// Result of a rename operation
72#[derive(Debug, Clone)]
73pub struct RenameResult {
74    /// Original file path
75    pub original_path: PathBuf,
76    /// New filename applied
77    pub new_name: String,
78    /// Whether the rename was successful
79    pub success: bool,
80    /// Error message if failed
81    pub error: Option<String>,
82}
83
84/// Main rename engine that handles file analysis and renaming
85pub struct RenameEngine {
86    config: RenameConfig,
87}
88
89impl RenameEngine {
90    /// Create a new rename engine with the given configuration
91    pub fn new(config: RenameConfig) -> Self {
92        Self { config }
93    }
94
95    /// Create a rename engine with default configuration
96    pub fn default() -> Self {
97        Self::new(RenameConfig::default())
98    }
99
100    /// Analyze all files in a directory and return proposed renames
101    /// This does not perform any actual renaming - use for preview
102    pub fn analyze_directory(&self, directory: &Path) -> Result<Vec<FileAnalysis>> {
103        let mut analyses = Vec::new();
104
105        // Scan files
106        let files = self.scan_files(directory)?;
107
108        // Detect file series (e.g., IMG_001.jpg, IMG_002.jpg, etc.)
109        let series_list = series_detector::detect_series(&files);
110        log::info!("Detected {} file series", series_list.len());
111
112        // Build a map of file paths to their series
113        let mut file_series_map = std::collections::HashMap::new();
114        for series in &series_list {
115            for (file_path, _) in &series.files {
116                file_series_map.insert(file_path.clone(), series.clone());
117            }
118        }
119
120        // Pre-populate existing names
121        let mut existing_names = HashSet::new();
122        for file_path in &files {
123            if let Some(filename) = file_path.file_name() {
124                if let Some(name) = filename.to_str() {
125                    existing_names.insert(name.to_string());
126                }
127            }
128        }
129
130        // Analyze each file
131        for file_path in files {
132            match self.analyze_file(&file_path, &mut existing_names) {
133                Ok(mut analysis) => {
134                    // Check if this file is part of a series
135                    if let Some(series) = file_series_map.get(&file_path) {
136                        // Apply series naming if we have a proposed name
137                        if let Some(proposed_name) = &analysis.proposed_name {
138                            // Extract just the base name without extension
139                            let base_name = if let Some(pos) = proposed_name.rfind('.') {
140                                &proposed_name[..pos]
141                            } else {
142                                proposed_name
143                            };
144
145                            // Apply series naming pattern
146                            if let Some(series_name) = series_detector::apply_series_naming(
147                                series,
148                                &file_path,
149                                base_name,
150                            ) {
151                                analysis.proposed_name = Some(series_name);
152                            }
153                        }
154                    }
155                    analyses.push(analysis);
156                },
157                Err(e) => {
158                    log::warn!("Failed to analyze {}: {}", file_path.display(), e);
159                    // Still add to results but with no proposed name
160                    if let Some(name) = file_path.file_name().and_then(|n| n.to_str()) {
161                        analyses.push(FileAnalysis {
162                            original_path: file_path.clone(),
163                            original_name: name.to_string(),
164                            proposed_name: None,
165                            file_category: FileCategory::Unknown,
166                        });
167                    }
168                }
169            }
170        }
171
172        Ok(analyses)
173    }
174
175    /// Rename files based on analysis results
176    /// Only renames files where analysis.proposed_name is Some()
177    pub fn rename_files(&self, analyses: &[FileAnalysis], dry_run: bool) -> Vec<RenameResult> {
178        let mut results = Vec::new();
179
180        for analysis in analyses {
181            if let Some(new_name) = &analysis.proposed_name {
182                match renamer::rename_file(&analysis.original_path, new_name, dry_run) {
183                    Ok(_) => {
184                        results.push(RenameResult {
185                            original_path: analysis.original_path.clone(),
186                            new_name: new_name.clone(),
187                            success: true,
188                            error: None,
189                        });
190                    }
191                    Err(e) => {
192                        results.push(RenameResult {
193                            original_path: analysis.original_path.clone(),
194                            new_name: new_name.clone(),
195                            success: false,
196                            error: Some(e.to_string()),
197                        });
198                    }
199                }
200            }
201        }
202
203        results
204    }
205
206    /// Analyze and rename files in one step (like the original CLI behavior)
207    pub fn process_directory(&self, directory: &Path, dry_run: bool) -> Result<Vec<RenameResult>> {
208        let analyses = self.analyze_directory(directory)?;
209        Ok(self.rename_files(&analyses, dry_run))
210    }
211
212    // Private helper methods
213
214    fn scan_files(&self, directory: &Path) -> Result<Vec<PathBuf>> {
215        use walkdir::WalkDir;
216
217        let mut files = Vec::new();
218
219        for entry in WalkDir::new(directory)
220            .follow_links(false)
221            .into_iter()
222            .filter_entry(|e| {
223                if self.config.skip_hidden {
224                    !e.file_name()
225                        .to_str()
226                        .map(|s| s.starts_with('.'))
227                        .unwrap_or(false)
228                } else {
229                    true
230                }
231            })
232        {
233            match entry {
234                Ok(entry) => {
235                    if entry.file_type().is_file() {
236                        files.push(entry.path().to_path_buf());
237                    }
238                }
239                Err(e) => {
240                    log::warn!("Failed to access entry: {}", e);
241                }
242            }
243        }
244
245        Ok(files)
246    }
247
248    fn analyze_file(
249        &self,
250        file_path: &Path,
251        existing_names: &mut HashSet<String>,
252    ) -> Result<FileAnalysis> {
253        // Detect file type
254        let file_category = detector::detect_file_type(file_path)?;
255
256        let original_name = file_path
257            .file_name()
258            .and_then(|n| n.to_str())
259            .unwrap_or("unknown")
260            .to_string();
261
262        // Skip unknown file types
263        if file_category == FileCategory::Unknown {
264            return Ok(FileAnalysis {
265                original_path: file_path.to_path_buf(),
266                original_name,
267                proposed_name: None,
268                file_category,
269            });
270        }
271
272        // Extract metadata with configuration
273        let metadata = match extractor::extract_metadata(file_path, &self.config) {
274            Ok(m) => m,
275            Err(_) => {
276                return Ok(FileAnalysis {
277                    original_path: file_path.to_path_buf(),
278                    original_name,
279                    proposed_name: None,
280                    file_category,
281                });
282            }
283        };
284
285        // Extract candidate name
286        let candidate_name = metadata.extract_name(&file_category, file_path);
287
288        let proposed_name = candidate_name.map(|name| {
289            let extension = file_path.extension();
290            generator::generate_filename_with_metadata(&name, extension, existing_names, Some(&metadata))
291        });
292
293        Ok(FileAnalysis {
294            original_path: file_path.to_path_buf(),
295            original_name,
296            proposed_name,
297            file_category,
298        })
299    }
300}
301
302/// Check if all required dependencies are installed
303pub fn check_dependencies() -> Result<()> {
304    deps::print_dependency_status();
305    Ok(())
306}
307
308/// Install missing dependencies (interactive)
309pub fn install_dependencies() -> Result<()> {
310    deps::run_installer().map_err(|e| anyhow::anyhow!(e))
311}
312
313/// Install dependencies with progress callback
314pub fn install_dependencies_with_progress(
315    progress: Option<deps::ProgressCallback>,
316) -> Result<()> {
317    deps::run_installer_with_progress(progress).map_err(|e| anyhow::anyhow!(e))
318}
319
320/// Re-export progress callback type
321pub use deps::ProgressCallback;