use std::path::{Path, PathBuf}; use tracing::{error, warn}; use super::*; /// Count audio files in a directory (recursive). Used for the import dry-run preview. fn count_audio_files(dir: &Path) -> usize { walk_folder_stats(dir).0 } /// Walk a directory and return `(audio_file_count, total_bytes)`. Used by the /// Quick-Import preflight to decide whether to prompt for confirmation on /// large imports. fn walk_folder_stats(dir: &Path) -> (usize, u64) { let mut count = 0usize; let mut bytes = 0u64; let mut dirs = vec![dir.to_path_buf()]; while let Some(d) = dirs.pop() { if let Ok(entries) = std::fs::read_dir(&d) { for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { if !audiofiles_core::util::is_macos_metadata_dir(&path) { dirs.push(path); } } else if audiofiles_core::util::is_audio_file(&path) { count += 1; if let Ok(md) = entry.metadata() { bytes = bytes.saturating_add(md.len()); } } } } } (count, bytes) } /// Thresholds above which Quick-Import prompts for confirmation before /// starting. Below either limit the import begins immediately to keep the /// common small-import path frictionless. const QUICK_IMPORT_PREFLIGHT_FILE_THRESHOLD: usize = 100; const QUICK_IMPORT_PREFLIGHT_BYTE_THRESHOLD: u64 = 1_073_741_824; // 1 GiB /// Pending Quick-Import that the user must confirm before files are touched. /// Surfaced via the preflight modal when the folder is large enough to make /// an accidental commit costly. #[derive(Debug, Clone)] pub struct ImportPreflight { pub source: PathBuf, pub file_count: usize, pub total_bytes: u64, } impl BrowserState { /// Quick-import a single file or directory via drag-and-drop into the current folder. /// After importing, starts the analysis flow so columns (BPM, Key, etc.) get populated. pub fn import_path(&mut self, path: &Path) { if !path.exists() { self.status = format!("Path not found: {}", path.display()); return; } let Some(vfs_id) = self.current_vfs_id() else { self.status = "No VFS available".to_string(); return; }; let parent_id = self.current_dir; let mut hashes = Vec::new(); if path.is_file() { match self.import_single_file(path, vfs_id, parent_id) { Ok(Some(hash_ext)) => { self.status = format!("Imported: {}", path.display()); hashes.push(hash_ext); } Ok(None) => self.status = format!("Imported: {}", path.display()), Err(e) => self.status = format!("Error: {e}"), } } else if path.is_dir() { let mut count = 0; let mut errors = 0; self.import_directory_recursive(path, vfs_id, parent_id, &mut count, &mut errors, &mut hashes); self.status = format!("Imported {count} files ({errors} errors)"); } self.refresh_contents(); if !hashes.is_empty() { self.start_analysis_flow(hashes); } } /// Import one audio file: hash into the content-addressed store, then link into the VFS. /// Returns `Ok(Some((hash, ext)))` on successful import, `Ok(None)` if the file was a /// duplicate (NameConflict/UNIQUE), or `Err` on failure. fn import_single_file( &self, path: &Path, vfs_id: VfsId, parent_id: Option, ) -> Result, Box> { let hash = self.backend.import_file(path)?; let name = path .file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown") .to_string(); let ext = path .extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_string(); match self.backend.create_sample_link(vfs_id, parent_id, &name, &hash) { Ok(_) => Ok(Some((hash, ext))), Err(crate::backend::BackendError::Core(CoreError::NameConflict(_))) => Ok(None), Err(crate::backend::BackendError::Core(CoreError::Db(ref sqlite_err))) if sqlite_err.to_string().contains("UNIQUE") => { Ok(None) } Err(e) => Err(Box::new(e)), } } /// Recursively import a directory tree, mirroring its folder structure in the VFS. /// On NameConflict for a directory, reuses the existing directory node rather than failing. /// /// NOTE: A parallel implementation exists in `import.rs` (`import_directory_recursive`) /// for the background import worker. That version adds progress events and cancellation. /// Both share the same traversal behavior (sorted, audio-only, skipped-dir filtering). /// Kept separate because the cancellation/progress channel differences make a shared /// abstraction more complex than the duplication. fn import_directory_recursive( &self, dir: &Path, vfs_id: VfsId, parent_id: Option, count: &mut usize, errors: &mut usize, hashes: &mut Vec<(String, String)>, ) { let entries = match fs::read_dir(dir) { Ok(e) => e, Err(_) => { *errors += 1; return; } }; let mut paths: Vec = entries.flatten().map(|e| e.path()).collect(); paths.sort(); for path in paths { if path.is_dir() { if audiofiles_core::util::is_macos_metadata_dir(&path) { continue; } let dir_name = audiofiles_core::util::get_filename(&path, "folder"); // Create the directory node, or reuse an existing one if a // name conflict occurs (idempotent for re-imports). let dir_node_id = match self.backend.create_directory(vfs_id, parent_id, &dir_name) { Ok(id) => Some(id), Err(crate::backend::BackendError::Core(CoreError::NameConflict(_))) => { self.backend.list_children(vfs_id, parent_id) .unwrap_or_default() .iter() .find(|n| n.name == dir_name && n.node_type == NodeType::Directory) .map(|n| n.id) } Err(_) => { *errors += 1; continue; } }; self.import_directory_recursive( &path, vfs_id, dir_node_id.or(parent_id), count, errors, hashes, ); } else if path.is_file() && audiofiles_core::util::is_audio_file(&path) { match self.import_single_file(&path, vfs_id, parent_id) { Ok(Some(hash_ext)) => { *count += 1; hashes.push(hash_ext); } Ok(None) => *count += 1, Err(_) => *errors += 1, } } } } /// Begin the analysis workflow by showing the configuration screen. pub fn start_analysis_flow(&mut self, sample_hashes: Vec<(String, String)>) { if sample_hashes.is_empty() { return; } self.import_mode = ImportMode::ConfigureAnalysis { sample_hashes, config: AnalysisConfig::default(), }; } /// Spawn the background analysis worker and start processing samples. pub fn run_analysis(&mut self, sample_hashes: Vec<(String, String)>, config: AnalysisConfig) { // Stash parameters so the retry button can restart analysis. self.last_analysis_hashes = sample_hashes.clone(); self.last_analysis_config = Some(config.clone()); let total = sample_hashes.len(); self.pending_review_items.clear(); self.analysis_errors.clear(); self.operation_progress = Some(crate::state::OperationProgress::new()); self.import_mode = ImportMode::Analyzing { completed: 0, total, current_name: String::new(), }; self.status = format!("Analyzing {total} samples..."); let _ = self.backend.start_analysis(sample_hashes, config); } /// Cancel the running analysis batch and land in the acknowledgement /// screen (C-3) so the user sees what was analysed vs what was discarded. /// Falls through to `None` only when there's no meaningful progress to /// acknowledge (cancel before any work happened). pub fn cancel_analysis(&mut self) { let progress = match &self.import_mode { ImportMode::Analyzing { completed, total, .. } => Some((*completed, *total)), _ => None, }; let _ = self.backend.cancel_analysis(); self.pending_review_items.clear(); self.status = "Analysis cancelled".to_string(); self.import_mode = match progress { Some((completed, total)) if total > 0 => ImportMode::OperationCancelled { kind: crate::state::CancelKind::Analysis, completed, total, destination: None, }, _ => ImportMode::None, }; } // --- Folder import --- /// Quick import: choose folder → import as new vault → analyze with defaults. /// Skips ConfigureImport, TagFolders, and ConfigureAnalysis screens. /// /// Large folders (≥ 100 files OR ≥ 1 GiB) route through a preflight /// confirmation modal so accidental imports of huge directories /// (Downloads, Music libraries, root folders) don't commit before the /// user has seen what they're about to do. Small folders import /// immediately to keep the common path frictionless. pub fn quick_import_folder(&mut self, source: PathBuf) { let (file_count, total_bytes) = walk_folder_stats(&source); // M-9: skip preflight entirely when the user has opted out persistently. let should_preflight = !self.import_preflight_disabled && (file_count >= QUICK_IMPORT_PREFLIGHT_FILE_THRESHOLD || total_bytes >= QUICK_IMPORT_PREFLIGHT_BYTE_THRESHOLD); if should_preflight { self.pending_import_preflight = Some(ImportPreflight { source, file_count, total_bytes, }); } else { self.start_quick_import_now(source); } } /// Bypass the preflight and start the Quick-Import. Called both directly /// (small folders) and from the preflight modal's Continue button. pub fn start_quick_import_now(&mut self, source: PathBuf) { let vfs_name = source .file_name() .and_then(|n| n.to_str()) .unwrap_or("folder") .to_string(); self.quick_import = true; let strategy = ImportStrategy::NewVfs { vfs_name }; self.start_folder_import(source, strategy); } /// Accept the pending preflight and start the import. pub fn accept_import_preflight(&mut self) { if let Some(p) = self.pending_import_preflight.take() { self.start_quick_import_now(p.source); } } /// Discard the pending preflight without starting an import. pub fn cancel_import_preflight(&mut self) { self.pending_import_preflight = None; } /// Open the import configuration modal for a dropped or selected folder. pub fn show_import_options(&mut self, source: PathBuf) { let source_name = source .file_name() .and_then(|n| n.to_str()) .unwrap_or("folder") .to_string(); let available_vfs = self.backend.list_vfs().unwrap_or_else(|e| { warn!("Failed to list VFS: {e}"); Vec::new() }); // Dry-run: count audio files in the source folder let audio_file_count = count_audio_files(&source); self.import_mode = ImportMode::ConfigureImport { source, source_name: source_name.clone(), strategy: ImportStrategy::NewVfs { vfs_name: source_name.clone(), }, available_vfs, selected_merge_vfs_idx: 0, new_vfs_name: source_name, audio_file_count, }; } /// Replace the source folder on the ConfigureImport screen with a new /// one. Re-runs the dry-run audio-file count and updates `source_name`, /// but preserves the user's strategy choice and any custom vault name /// they've already typed (M-5). pub fn change_import_source(&mut self, new_source: PathBuf) { let new_name = new_source .file_name() .and_then(|n| n.to_str()) .unwrap_or("folder") .to_string(); let new_count = count_audio_files(&new_source); if let ImportMode::ConfigureImport { source, source_name, audio_file_count, .. } = &mut self.import_mode { *source = new_source; *source_name = new_name; *audio_file_count = new_count; } } /// Spawn the background import worker to walk and import a folder. pub fn start_folder_import(&mut self, source: PathBuf, strategy: ImportStrategy) { // Stash source path so the retry button can re-open the config screen. self.last_import_source = Some(source.clone()); let strategy_desc = match strategy { ImportStrategy::Flat { vfs_id, parent_id } => { ImportStrategyDesc::Flat { vfs_id, parent_id } } ImportStrategy::NewVfs { vfs_name } => ImportStrategyDesc::NewVfs { vfs_name }, ImportStrategy::MergeIntoVfs { vfs_id, parent_id } => { ImportStrategyDesc::MergeIntoVfs { vfs_id, parent_id } } }; let _ = self.backend.start_import(&source, strategy_desc); self.import_file_errors.clear(); self.analysis_errors.clear(); self.operation_progress = Some(crate::state::OperationProgress::new()); let loose_files = self.settings.is_loose_files; self.import_mode = ImportMode::Importing { total: 0, completed: 0, current_name: String::new(), walking: true, walking_count: 0, total_bytes: 0, loose_files, }; } /// Poll all backend workers for events. Returns `true` if any events were processed. /// Called each frame to drain import, analysis, and export progress. pub fn poll_workers(&mut self) -> bool { let events = self.backend.poll_events(); if events.is_empty() { return false; } use crate::backend::BackendEvent; for event in events { match event { // --- Import events --- BackendEvent::ImportWalkProgress { count, total_bytes } => { if let ImportMode::Importing { walking: true, walking_count, total_bytes: bytes_slot, .. } = &mut self.import_mode { *walking_count = count; *bytes_slot = total_bytes; } } BackendEvent::ImportWalkComplete { total, total_bytes } => { let loose_files = matches!( &self.import_mode, ImportMode::Importing { loose_files: true, .. } ); self.import_mode = ImportMode::Importing { total, completed: 0, current_name: String::new(), walking: false, walking_count: 0, total_bytes, loose_files, }; } BackendEvent::ImportProgress { completed, total, current_name, } => { let (prev_bytes, loose_files) = match &self.import_mode { ImportMode::Importing { total_bytes, loose_files, .. } => (*total_bytes, *loose_files), _ => (0, false), }; self.import_mode = ImportMode::Importing { total, completed, current_name, walking: false, walking_count: 0, total_bytes: prev_bytes, loose_files, }; } BackendEvent::ImportFileError { path, error } => { self.import_file_errors.push(super::ImportFileError { path, error }); } BackendEvent::ImportComplete { imported, total_files: _, errors, duplicates, folders, } => { self.vfs_list = Arc::new(self.backend.list_vfs().unwrap_or_else(|e| { error!("Failed to refresh VFS list: {e}"); Vec::new() })); self.refresh_contents(); self.refresh_all_tags(); // First successful import auto-dismisses the welcome hint — // the user has moved past onboarding. if !imported.is_empty() && self.show_first_launch_hint { self.dismiss_first_launch_hint(); } let mut parts = vec![format!("Imported {} files", imported.len())]; if duplicates > 0 { parts.push(format!("{duplicates} duplicates skipped")); } if errors > 0 { parts.push(format!("{errors} errors")); } self.status = parts.join(", "); if !folders.is_empty() && !self.quick_import { let entries = folders .into_iter() .map(|f| FolderTagEntry { folder: ImportedFolder { name: f.name, samples: f.samples, }, tag_input: String::new(), }) .collect(); self.import_mode = ImportMode::TagFolders { entries, sample_hashes: imported, }; } else if !imported.is_empty() { if self.quick_import { // Skip ConfigureAnalysis — run with defaults immediately self.run_analysis(imported, AnalysisConfig::default()); } else { self.start_analysis_flow(imported); } } else if self.has_import_errors() { self.import_mode = ImportMode::ReviewErrors; } else { self.quick_import = false; self.import_mode = ImportMode::None; } return true; } // --- Analysis events --- BackendEvent::AnalysisProgress { completed, total, current_name, } => { self.import_mode = ImportMode::Analyzing { completed, total, current_name, }; } BackendEvent::AnalysisSampleDone { result, suggestions, } => { let result = *result; let _ = self.backend.save_analysis(&result); let hash = audiofiles_core::SampleHash::new(result.hash.clone()); let name = self.backend.sample_original_name(&hash) .unwrap_or_else(|_| hash.to_string()); self.pending_review_items.push(ReviewItem { hash, name, suggestions: suggestions .into_iter() .map(|s| SuggestionState { accepted: true, suggestion: s, }) .collect(), result, }); } BackendEvent::AnalysisSampleError { hash, error } => { let name = self.backend.sample_original_name(&hash) .unwrap_or_else(|_| hash.clone()); self.analysis_errors.push(super::AnalysisFileError { hash, name, error }); } BackendEvent::AnalysisBatchComplete => { let items = std::mem::take(&mut self.pending_review_items); let error_count = self.analysis_errors.len(); if self.quick_import { // Auto-accept all suggestions in quick mode for item in &items { for s in &item.suggestions { let _ = self.backend.add_tag(&item.hash, &s.suggestion.tag); } } self.quick_import = false; self.refresh_contents(); self.refresh_vfs_list(); let count = items.len(); self.import_mode = ImportMode::None; self.status = if error_count == 0 { format!("Quick import complete: {count} samples analyzed") } else { format!("Quick import complete: {count} samples analyzed ({error_count} errors)") }; } else if items.is_empty() { if self.has_import_errors() { self.import_mode = ImportMode::ReviewErrors; } else { self.import_mode = ImportMode::None; self.status = "Analysis complete, no results".to_string(); } } else { let count = items.len(); self.import_mode = ImportMode::ReviewSuggestions { items, current_idx: 0, sort: crate::state::ReviewSort::ImportOrder, }; self.status = if error_count == 0 { format!("Analyzed {count} samples") } else { format!("Analyzed {count} samples ({error_count} errors)") }; } } // --- Export events --- BackendEvent::ExportProgress { completed, total, current_name, } => { self.import_mode = ImportMode::Exporting { completed, total, current_name, }; } BackendEvent::ExportComplete { total, errors } => { let error_count = errors.len(); if error_count == 0 { self.status = format!("Exported {total} files"); } else { self.status = format!("Exported {total} files ({error_count} errors)"); } self.import_mode = ImportMode::ExportComplete { total, errors }; return true; } // --- Cleanup events --- BackendEvent::CleanupProgress { completed, total, current_name, } => { self.import_mode = ImportMode::Cleaning { completed, total, current_name, }; } BackendEvent::CleanupComplete { removed, errors } => { self.refresh_vfs_list(); if errors > 0 { self.status = format!("Library deleted ({removed} samples removed, {errors} errors)"); } else if removed > 0 { self.status = format!("Library deleted ({removed} samples removed)"); } else { self.status = "Library deleted".to_string(); } self.import_mode = ImportMode::None; return true; } // --- Edit events --- BackendEvent::EditStarted { hash: _ } => { self.edit.in_progress = true; } BackendEvent::EditComplete { source_hash, result_path, operation, } => { self.edit.in_progress = false; self.handle_edit_complete(source_hash, result_path, operation); } BackendEvent::EditError { hash: _, error } => { self.edit.in_progress = false; self.status = format!("Edit failed: {error}"); } } } true } /// Cancel the running folder import. Lands in the acknowledgement screen /// (C-3) when there's meaningful progress to acknowledge (post-walk import /// of N/M files); falls through to `None` when no files have committed yet /// (walking phase, or cancel before start). pub fn cancel_import(&mut self) { let progress = match &self.import_mode { ImportMode::Importing { completed, total, walking, .. } if !*walking && *total > 0 => Some((*completed, *total)), _ => None, }; let _ = self.backend.cancel_import(); self.refresh_contents(); self.status = "Import cancelled".to_string(); self.import_mode = match progress { Some((completed, total)) => ImportMode::OperationCancelled { kind: crate::state::CancelKind::Import, completed, total, destination: None, }, None => ImportMode::None, }; } /// Cancel the current import and re-open the import configuration screen so /// the user can adjust settings and retry. pub fn retry_import(&mut self) { self.cancel_import(); if let Some(source) = self.last_import_source.clone() { self.show_import_options(source); } } /// Cancel the current analysis and restart it with the same parameters. pub fn retry_analysis(&mut self) { self.cancel_analysis(); let hashes = std::mem::take(&mut self.last_analysis_hashes); if let Some(config) = self.last_analysis_config.take() && !hashes.is_empty() { self.run_analysis(hashes, config); } } /// Apply user-entered tags to each imported folder's samples, then start analysis. pub fn apply_folder_tags(&mut self) { self.tag_folders_apply_all_input.clear(); let mode = std::mem::replace(&mut self.import_mode, ImportMode::None); if let ImportMode::TagFolders { entries, sample_hashes, } = mode { // Stash entries + hashes so the Back button on ConfigureAnalysis // (C-1) can rehydrate this screen. tag_input strings are preserved // verbatim — re-applying via add_tag is safe (INSERT OR IGNORE). self.last_folder_tags = Some((entries.clone(), sample_hashes.clone())); let mut applied = 0usize; for entry in &entries { if entry.tag_input.trim().is_empty() { continue; } let tag_strs: Vec<&str> = entry .tag_input .split(',') .map(|s| s.trim()) .filter(|s| !s.is_empty()) .collect(); for tag_str in &tag_strs { if audiofiles_core::tags::validate_tag(tag_str).is_err() { continue; } for (hash, _ext) in &entry.folder.samples { let _ = self.backend.add_tag(hash, tag_str); applied += 1; } } } self.status = format!("Applied {applied} folder tags"); self.refresh_all_tags(); self.start_analysis_flow(sample_hashes); } else { self.import_mode = mode; } } /// Skip folder tagging and proceed directly to analysis. pub fn skip_folder_tags(&mut self) { self.tag_folders_apply_all_input.clear(); let mode = std::mem::replace(&mut self.import_mode, ImportMode::None); if let ImportMode::TagFolders { entries, sample_hashes } = mode { // Stash entries so Back from ConfigureAnalysis (C-1) can return the // user to the tagging screen even when they skipped initially. self.last_folder_tags = Some((entries.clone(), sample_hashes.clone())); self.start_analysis_flow(sample_hashes); } else { self.import_mode = mode; } } /// Restore the most recently-shown TagFolders screen from the stash. Called /// from the Back button on ConfigureAnalysis. No-op if nothing's stashed. pub fn back_to_tag_folders(&mut self) { if let Some((entries, sample_hashes)) = self.last_folder_tags.take() { self.import_mode = ImportMode::TagFolders { entries, sample_hashes, }; } } /// Commit accepted tag suggestions from analysis review, then return to idle. pub fn apply_accepted_suggestions(&mut self) { if let ImportMode::ReviewSuggestions { ref items, .. } = self.import_mode { let mut applied = 0usize; for item in items { for sug in &item.suggestions { if sug.accepted { let _ = self.backend.add_tag(&item.hash, &sug.suggestion.tag); applied += 1; } } } self.status = format!("Applied {applied} tags"); } if self.has_import_errors() { self.import_mode = ImportMode::ReviewErrors; } else { self.import_mode = ImportMode::None; } self.refresh_contents(); self.refresh_all_tags(); } /// Returns `true` if there are any accumulated import or analysis errors. pub fn has_import_errors(&self) -> bool { !self.import_file_errors.is_empty() || !self.analysis_errors.is_empty() } /// Dismiss all import/analysis errors and return to normal browsing. pub fn dismiss_import_errors(&mut self) { self.import_file_errors.clear(); self.analysis_errors.clear(); self.import_mode = ImportMode::None; self.refresh_contents(); } /// Remove a failed sample from the store by its index in `analysis_errors`. pub fn remove_failed_sample(&mut self, index: usize) { if index >= self.analysis_errors.len() { return; } let hash = self.analysis_errors[index].hash.clone(); if let Err(e) = self.backend.remove_sample(&hash) { warn!("Failed to remove sample {hash}: {e}"); return; } self.analysis_errors.remove(index); } /// Remove all failed samples from the store and return to normal browsing. pub fn remove_all_failed_samples(&mut self) { for err in self.analysis_errors.drain(..) { if let Err(e) = self.backend.remove_sample(&err.hash) { warn!("Failed to remove sample {}: {e}", err.hash); } } self.import_file_errors.clear(); self.import_mode = ImportMode::None; self.refresh_contents(); } // --- Export --- /// Begin the export workflow: collect items from the current VFS/selection and show config. pub fn start_export_flow(&mut self, node_ids: Option>) { let Some(vfs_id) = self.current_vfs_id() else { self.status = "No VFS available".to_string(); return; }; let mut items = if let Some(ids) = node_ids { // Export specific selected items: collect subtrees for each let mut all_items = Vec::new(); for id in ids { let node = match self.backend.get_node(id) { Ok(n) => n, Err(_) => continue, }; match node.node_type { NodeType::Directory => { if let Ok(sub_items) = self.backend.collect_export_items(vfs_id, Some(id)) { all_items.extend(sub_items); } } NodeType::Sample => { if let Some(hash) = &node.sample_hash { let ext = self.backend.sample_extension(hash) .unwrap_or_default(); all_items.push(audiofiles_core::export::ExportItem { hash: hash.clone(), ext, relative_path: std::path::PathBuf::from(&node.name), name: node.name.clone(), bpm: None, musical_key: None, classification: None, duration: None, tags: Vec::new(), source_path: None, }); } } } } all_items } else if self.active_collection.is_some() { // Export collection contents self.contents.iter().filter_map(|node| { let hash = node.node.sample_hash.as_ref()?; let ext = self.backend.sample_extension(hash).unwrap_or_default(); Some(audiofiles_core::export::ExportItem { hash: hash.clone(), ext, relative_path: std::path::PathBuf::from(&node.node.name), name: node.node.name.clone(), bpm: node.bpm, musical_key: node.musical_key.clone(), classification: node.classification.clone(), duration: node.duration, tags: node.tags.clone(), source_path: None, }) }).collect() } else { // Export entire current VFS subtree self.backend.collect_export_items(vfs_id, self.current_dir) .unwrap_or_default() }; let _ = self.backend.enrich_export_with_tags(&mut items); if items.is_empty() { self.status = "No samples to export".to_string(); return; } let default_dest = dirs::download_dir() .or_else(dirs::home_dir) .unwrap_or_else(|| std::path::PathBuf::from(".")) .join("audiofiles Export"); let available_profiles = self.backend.list_device_profiles().unwrap_or_default(); self.import_mode = ImportMode::ConfigureExport { items, config: audiofiles_core::export::ExportConfig { format: audiofiles_core::export::ExportFormat::Original, sample_rate: None, bit_depth: None, channels: audiofiles_core::export::ExportChannels::Original, naming_pattern: None, flatten: false, metadata_sidecar: false, destination: default_dest, device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }, available_profiles, }; } /// Spawn the export worker and start processing. pub fn run_export(&mut self, items: Vec, config: audiofiles_core::export::ExportConfig) { // Stash destination so the cancel-acknowledgement screen can name it // if the user cancels mid-export (C-3). The Exporting variant doesn't // carry destination — it's consumed by the worker — but the path is // still useful in the UI for "files already written remain at ". self.last_export_destination = Some(config.destination.clone()); self.operation_progress = Some(crate::state::OperationProgress::new()); let _ = self.backend.start_export(items, config); self.import_mode = ImportMode::Exporting { completed: 0, total: 0, current_name: String::new(), }; } /// Cancel the running cleanup and return to idle state. pub fn cancel_cleanup(&mut self) { let _ = self.backend.cancel_cleanup(); self.import_mode = ImportMode::None; self.refresh_vfs_list(); self.status = "Cleanup cancelled".to_string(); } /// Cancel the running export. Lands in the acknowledgement screen (C-3) /// when meaningful progress has happened, surfacing the destination folder /// so the user knows where partial files may sit. Falls through to `None` /// when no items have been written yet. pub fn cancel_export(&mut self) { let progress = match &self.import_mode { ImportMode::Exporting { completed, total, .. } if *total > 0 => { Some((*completed, *total)) } _ => None, }; let _ = self.backend.cancel_export(); self.status = "Export cancelled".to_string(); self.import_mode = match progress { Some((completed, total)) => ImportMode::OperationCancelled { kind: crate::state::CancelKind::Export, completed, total, destination: self.last_export_destination.clone(), }, None => ImportMode::None, }; } // --- Edit operations (floating editor window) --- /// Open the floating sample editor for the given hash. pub fn open_edit_window(&mut self, hash: &str) { // Load analysis for total frames and result mode preference let analysis = self.backend.get_analysis(hash).ok().flatten(); let total_frames = analysis.as_ref() .map(|a| (a.duration * a.sample_rate as f64) as usize) .unwrap_or(0); self.edit.hash = Some(hash.to_string()); self.edit.show_window = true; // Reset all params to defaults self.edit.trim_start = 0.0; self.edit.trim_end = 1.0; self.edit.total_frames = total_frames; self.edit.gain_db = 0.0; self.edit.norm_peak = true; self.edit.norm_target = -0.1; self.edit.fade_in = true; self.edit.fade_duration_ms = 100.0; self.edit.fade_curve = audiofiles_core::edit::FadeCurve::Linear; // Cache result mode from user_config self.edit.result_mode = match self.backend.get_config("edit_result_mode").ok().flatten().as_deref() { Some("replace") => Some(super::EditResultMode::Replace), Some("sibling") => Some(super::EditResultMode::Sibling), _ => None, }; } /// Close the floating sample editor. pub fn close_edit_window(&mut self) { self.edit.show_window = false; self.edit.hash = None; } /// M-11: best-effort cancel of the in-flight edit. Signals the worker via /// the backend and clears `in_progress` so the UI is interactive again. /// The worker may still finish writing its output; if it does, the result /// path will eventually be handled by `handle_edit_complete` as normal. pub fn cancel_edit_operation(&mut self) { let _ = self.backend.cancel_edit(); self.edit.in_progress = false; self.status = "Edit cancelled.".to_string(); } /// M-12: discard the pending edit result without applying it. The result /// file held in `pending_result` is dropped; if the temp path still exists /// on disk it is removed. pub fn discard_edit_result(&mut self) { if let Some(pending) = self.edit.pending_result.take() { let _ = std::fs::remove_file(&pending.result_path); } self.edit.result_prompt = false; self.status = "Edit result discarded.".to_string(); } /// Apply trim to the current edit target. pub fn apply_edit_trim(&mut self) { let hash = match &self.edit.hash { Some(h) => h.clone(), None => return, }; let start = (self.edit.trim_start * self.edit.total_frames as f32) as usize; let end = (self.edit.trim_end * self.edit.total_frames as f32) as usize; let op = audiofiles_core::edit::EditOperation::Trim { start_frame: start, end_frame: end }; let ext = self.backend.sample_extension(&hash).unwrap_or_default(); if let Err(e) = self.backend.start_edit(&hash, &ext, op) { self.status = format!("Edit failed: {e}"); } } /// Apply gain adjustment to the current edit target. pub fn apply_edit_gain(&mut self) { let hash = match &self.edit.hash { Some(h) => h.clone(), None => return, }; let op = audiofiles_core::edit::EditOperation::Gain { db: self.edit.gain_db }; let ext = self.backend.sample_extension(&hash).unwrap_or_default(); if let Err(e) = self.backend.start_edit(&hash, &ext, op) { self.status = format!("Edit failed: {e}"); } } /// Apply normalize to the current edit target. pub fn apply_edit_normalize(&mut self) { let hash = match &self.edit.hash { Some(h) => h.clone(), None => return, }; let op = if self.edit.norm_peak { audiofiles_core::edit::EditOperation::NormalizePeak { target_db: self.edit.norm_target } } else { audiofiles_core::edit::EditOperation::NormalizeLufs { target_lufs: self.edit.norm_target } }; let ext = self.backend.sample_extension(&hash).unwrap_or_default(); if let Err(e) = self.backend.start_edit(&hash, &ext, op) { self.status = format!("Edit failed: {e}"); } } /// Apply reverse to the current edit target. pub fn apply_edit_reverse(&mut self) { let hash = match &self.edit.hash { Some(h) => h.clone(), None => return, }; let op = audiofiles_core::edit::EditOperation::Reverse; let ext = self.backend.sample_extension(&hash).unwrap_or_default(); if let Err(e) = self.backend.start_edit(&hash, &ext, op) { self.status = format!("Edit failed: {e}"); } } /// Apply fade to the current edit target. pub fn apply_edit_fade(&mut self) { let hash = match &self.edit.hash { Some(h) => h.clone(), None => return, }; // Convert ms to frames using sample rate from analysis let sample_rate = self.backend.get_analysis(&hash).ok().flatten() .map(|a| a.sample_rate) .unwrap_or(44100); let frames = ((self.edit.fade_duration_ms / 1000.0) * sample_rate as f64) as usize; let op = if self.edit.fade_in { audiofiles_core::edit::EditOperation::FadeIn { frames, curve: self.edit.fade_curve } } else { audiofiles_core::edit::EditOperation::FadeOut { frames, curve: self.edit.fade_curve } }; let ext = self.backend.sample_extension(&hash).unwrap_or_default(); if let Err(e) = self.backend.start_edit(&hash, &ext, op) { self.status = format!("Edit failed: {e}"); } } /// Insert silence at the configured position and duration. pub fn apply_edit_insert_silence(&mut self) { let hash = match &self.edit.hash { Some(h) => h.clone(), None => return, }; let sample_rate = self.backend.get_analysis(&hash).ok().flatten() .map(|a| a.sample_rate) .unwrap_or(44100); let start_frame = ((self.edit.silence_position_ms / 1000.0) * sample_rate as f64) as usize; let duration_frames = ((self.edit.silence_duration_ms / 1000.0) * sample_rate as f64) as usize; let op = audiofiles_core::edit::EditOperation::InsertSilence { start_frame, duration_frames }; let ext = self.backend.sample_extension(&hash).unwrap_or_default(); if let Err(e) = self.backend.start_edit(&hash, &ext, op) { self.status = format!("Edit failed: {e}"); } } /// Remove a range of frames between the configured start and end positions. pub fn apply_edit_remove_range(&mut self) { let hash = match &self.edit.hash { Some(h) => h.clone(), None => return, }; let sample_rate = self.backend.get_analysis(&hash).ok().flatten() .map(|a| a.sample_rate) .unwrap_or(44100); let start_frame = ((self.edit.remove_start_ms / 1000.0) * sample_rate as f64) as usize; let end_frame = ((self.edit.remove_end_ms / 1000.0) * sample_rate as f64) as usize; if start_frame >= end_frame { self.status = "Remove range: start must be before end".to_string(); return; } let op = audiofiles_core::edit::EditOperation::RemoveRange { start_frame, end_frame }; let ext = self.backend.sample_extension(&hash).unwrap_or_default(); if let Err(e) = self.backend.start_edit(&hash, &ext, op) { self.status = format!("Edit failed: {e}"); } } /// Apply an edit operation to all selected samples (batch edit). /// Skips directories and samples without hashes. pub fn batch_edit(&mut self, op_factory: impl Fn(&str) -> Option) { let hashes = self.selected_sample_hashes(); if hashes.is_empty() { self.status = "No samples selected for batch edit".to_string(); return; } let mut applied = 0; let mut errors = 0; for hash in &hashes { let Some(op) = op_factory(hash) else { continue; }; let ext = self.backend.sample_extension(hash).unwrap_or_default(); match self.backend.start_edit(hash, &ext, op) { Ok(()) => applied += 1, Err(_) => errors += 1, } } self.status = if errors > 0 { format!("Batch edit: {applied} applied, {errors} failed") } else { format!("Batch edit: {applied} samples processed") }; } /// Batch normalize (peak) all selected samples. pub fn batch_normalize_peak(&mut self, target_db: f64) { self.batch_edit(|_hash| { Some(audiofiles_core::edit::EditOperation::NormalizePeak { target_db }) }); } /// Batch normalize (LUFS) all selected samples. pub fn batch_normalize_lufs(&mut self, target_lufs: f64) { self.batch_edit(|_hash| { Some(audiofiles_core::edit::EditOperation::NormalizeLufs { target_lufs }) }); } /// Batch reverse all selected samples. pub fn batch_reverse(&mut self) { self.batch_edit(|_hash| { Some(audiofiles_core::edit::EditOperation::Reverse) }); } /// Batch apply gain to all selected samples. pub fn batch_gain(&mut self, db: f64) { self.batch_edit(|_hash| { Some(audiofiles_core::edit::EditOperation::Gain { db }) }); } /// Save the user's preferred edit result mode. pub fn set_edit_result_mode(&mut self, mode: super::EditResultMode) { let mode_str = match mode { super::EditResultMode::Replace => "replace", super::EditResultMode::Sibling => "sibling", }; let _ = self.backend.set_config("edit_result_mode", mode_str); self.edit.result_mode = Some(mode); } /// Handle a completed edit: import result, update VFS, record history. fn handle_edit_complete( &mut self, source_hash: String, result_path: PathBuf, operation: audiofiles_core::edit::EditOperation, ) { let op_name = operation.display_name().to_string(); // Use cached result mode preference let mode = match self.edit.result_mode { Some(m) => m, None => { // No preference set — store result and show prompt self.edit.pending_result = Some(super::PendingEditResult { source_hash, result_path, operation, }); self.edit.result_prompt = true; return; } }; self.finalize_edit(source_hash, result_path, operation, mode); self.status = format!("Edit applied — {op_name}"); } /// Apply the chosen edit result mode to a pending edit. pub fn confirm_edit_result(&mut self, mode: super::EditResultMode, remember: bool) { if remember { self.set_edit_result_mode(mode); } if let Some(pending) = self.edit.pending_result.take() { let op_name = pending.operation.display_name().to_string(); self.finalize_edit(pending.source_hash, pending.result_path, pending.operation, mode); self.status = format!("Edit applied — {op_name}"); } self.edit.result_prompt = false; } /// Common finalization: import result file, update VFS, record history, trigger analysis. fn finalize_edit( &mut self, source_hash: String, result_path: PathBuf, operation: audiofiles_core::edit::EditOperation, mode: super::EditResultMode, ) { // 1. Import the result file into the content-addressed store. Always // clean up the temp regardless of outcome — the prior code only removed // on the success branch, leaking the temp on every import failure. // (The temp is the user's edit output; after import_file copies it // into the store, we own the canonical copy by hash.) let import_result = self.backend.import_file(&result_path); let _ = std::fs::remove_file(&result_path); let new_hash = match import_result { Ok(h) => h, Err(e) => { self.status = format!("Failed to import edit result: {e}"); return; } }; // 2. Record in edit_history let _ = self.backend.record_edit_history(&source_hash, &new_hash, &operation); // 3. Update VFS based on mode let Some(vfs_id) = self.current_vfs_id() else { self.status = "No VFS available".to_string(); return; }; let source_hashes = [source_hash.as_str()]; let source_nodes = self.backend.find_nodes_by_hashes(vfs_id, &source_hashes) .unwrap_or_default(); let new_ext = self.backend.sample_extension(&new_hash).unwrap_or_else(|_| "wav".to_string()); // C-1 part 2: record the pre-edit VFS positions so `undo_last_edit` // can walk them back. Captured before deletion in Replace mode and // after creation in Sibling mode. let mut replace_targets: Vec<(Option, String)> = Vec::new(); let mut sibling_node_id: Option = None; match mode { super::EditResultMode::Replace => { // Update each VFS node that references the source hash for node in &source_nodes { // Delete old node and create new one with same name/parent let parent_id = node.node.parent_id; let name = node.node.name.clone(); replace_targets.push((parent_id, name.clone())); let _ = self.backend.delete_node(node.node.id); let _ = self.backend.create_sample_link(vfs_id, parent_id, &name, &new_hash); } } super::EditResultMode::Sibling => { // Create a sibling node next to the original if let Some(node) = source_nodes.first() { let parent_id = node.node.parent_id; let (stem, _ext) = split_name_ext(&node.node.name); let sibling_name = format!("{stem}_edited.{new_ext}"); if let Ok(id) = self .backend .create_sample_link(vfs_id, parent_id, &sibling_name, &new_hash) { sibling_node_id = Some(id); } } } } // C-1 part 2: stash the entry only if there's actually something to // undo (no VFS nodes → nothing happened that needs reversing). if !replace_targets.is_empty() || sibling_node_id.is_some() { self.edit.last_undo = Some(super::EditUndoEntry { op_name: operation.display_name().to_string(), source_hash: source_hash.clone(), result_hash: new_hash.clone(), mode, vfs_id, replace_targets, sibling_node_id, created_at: std::time::Instant::now(), }); } // 4. Trigger analysis on the new sample let hashes = vec![(new_hash, new_ext)]; let _ = self.backend.start_analysis(hashes, AnalysisConfig::default()); // 5. Refresh the file list self.refresh_contents(); } /// C-1 part 2: reverse the most recent finalized edit. Replace mode walks /// the VFS nodes back to the source hash; Sibling mode deletes the /// created sibling. The original sample blob is preserved by the /// content-addressed store so no audio data needs to be restored. /// /// Returns silently with no-op if `last_undo` is empty. pub fn undo_last_edit(&mut self) { let Some(entry) = self.edit.last_undo.take() else { return; }; match entry.mode { super::EditResultMode::Replace => { // Re-find any nodes now pointing at result_hash and clear them // first — the edit may have produced multiple nodes when the // sample appeared in multiple places, and we recreate from the // captured (parent_id, name) list. let result_hashes = [entry.result_hash.as_str()]; if let Ok(result_nodes) = self .backend .find_nodes_by_hashes(entry.vfs_id, &result_hashes) { for node in &result_nodes { let _ = self.backend.delete_node(node.node.id); } } for (parent_id, name) in &entry.replace_targets { let _ = self.backend.create_sample_link( entry.vfs_id, *parent_id, name, &entry.source_hash, ); } } super::EditResultMode::Sibling => { if let Some(id) = entry.sibling_node_id { let _ = self.backend.delete_node(id); } } } // Drop the matching edit_history row so the audit trail reflects the // undo. Best-effort — the UI undo affordance already disappeared. let _ = self .backend .delete_edit_history(&entry.source_hash, &entry.result_hash); self.status = format!("Reverted {}", entry.op_name); self.refresh_contents(); } }