Skip to main content

max / audiofiles

19.1 KB · 475 lines History Blame Raw
1 //! Library state: smart folders, collections, similarity search, refresh helpers, mirror.
2
3 use super::*;
4
5 impl BrowserState {
6 // --- Misc helpers ---
7
8 /// Absolute filesystem path to the focused sample, or `None` if no sample is selected.
9 pub fn selected_sample_path(&self) -> Option<String> {
10 let node = self.selected_node()?;
11 let hash = node.node.sample_hash.as_ref()?;
12 let path = self.resolve_sample_path(hash).ok()?;
13 Some(path.to_string_lossy().into_owned())
14 }
15
16 /// Reload the VFS list from the database and reset navigation to root.
17 pub fn refresh_vfs_list(&mut self) {
18 match self.backend.list_vfs() {
19 Ok(list) => self.vfs_list = Arc::new(list),
20 Err(e) => {
21 error!("Failed to refresh VFS list: {e}");
22 return;
23 }
24 }
25 if self.current_vfs_idx >= self.vfs_list.len() {
26 self.current_vfs_idx = 0;
27 }
28 self.current_dir = None;
29 self.breadcrumb.clear();
30 self.selection.clear();
31 self.refresh_contents();
32 self.refresh_collections();
33 self.check_loose_files_integrity();
34 }
35
36 /// Run an integrity check for loose-files mode vaults. Updates `loose_files_missing_count`
37 /// and shows the warning overlay if any source files are missing.
38 pub fn check_loose_files_integrity(&mut self) {
39 if !self.settings.is_loose_files {
40 self.loose_files_missing_count = 0;
41 return;
42 }
43 match self.backend.check_vault_integrity() {
44 Ok((_valid, missing)) => {
45 self.loose_files_missing_count = missing;
46 if missing > 0 {
47 self.show_loose_files_warning = true;
48 }
49 }
50 Err(e) => {
51 warn!("Loose-files integrity check failed: {e}");
52 }
53 }
54 }
55
56 /// Purge all loose-files mode samples whose source files are missing.
57 /// Refreshes the VFS listing afterward.
58 pub fn purge_missing_loose_files(&mut self) {
59 match self.backend.purge_missing_loose_files() {
60 Ok(purged) => {
61 self.status = format!("Purged {purged} missing samples");
62 self.loose_files_missing_count = 0;
63 self.show_loose_files_warning = false;
64 self.refresh_contents();
65 }
66 Err(e) => {
67 self.status = format!("Purge failed: {e}");
68 }
69 }
70 }
71
72 /// Dismiss the loose-files integrity warning without purging.
73 pub fn dismiss_loose_files_warning(&mut self) {
74 self.show_loose_files_warning = false;
75 }
76
77 /// Locate missing loose-files mode samples by walking `search_root` and
78 /// hash-verifying candidates. The dialog stays open with an updated
79 /// `loose_files_missing_count` so the user can run Locate again against a
80 /// different directory if some samples are still missing.
81 pub fn locate_missing_loose_files(&mut self, search_root: std::path::PathBuf) {
82 match self.backend.relocate_missing_loose_files(&search_root) {
83 Ok((relocated, still_missing)) => {
84 self.loose_files_missing_count = still_missing;
85 self.status = if relocated == 0 {
86 "No matching files found in that folder.".to_string()
87 } else if still_missing == 0 {
88 format!("Relocated {relocated} samples. All missing files found.")
89 } else {
90 format!(
91 "Relocated {relocated} samples. {still_missing} still missing.",
92 )
93 };
94 if still_missing == 0 {
95 self.show_loose_files_warning = false;
96 }
97 self.refresh_contents();
98 }
99 Err(e) => {
100 self.status = format!("Could not locate sample on disk \u{2014} {e}");
101 }
102 }
103 }
104
105 /// User-initiated local orphan cleanup. Wraps the backend call's
106 /// trigger suppression so the cleanup doesn't push DELETE samples
107 /// rows over sync to other devices. Returns silently on the empty
108 /// case; on success surfaces a count in the status line.
109 pub fn cleanup_orphans_now(&mut self) {
110 match self.backend.cleanup_orphans_local() {
111 Ok(0) => self.status = "No orphaned samples to clean up.".to_string(),
112 Ok(n) => {
113 self.status = format!(
114 "Removed {n} orphaned sample{}.",
115 if n == 1 { "" } else { "s" }
116 );
117 self.refresh_contents();
118 }
119 Err(e) => self.status = format!("Cleanup failed: {e}"),
120 }
121 }
122
123 /// Dismiss the first-launch hint and persist the preference.
124 pub fn dismiss_first_launch_hint(&mut self) {
125 self.show_first_launch_hint = false;
126 let _ = self.backend.set_config("hints_dismissed", "1");
127 }
128
129 /// Re-surface the welcome screen for a user who dismissed it. Persists the
130 /// reset so the welcome renders again on next launch until re-dismissed.
131 pub fn show_welcome(&mut self) {
132 self.show_first_launch_hint = true;
133 let _ = self.backend.set_config("hints_dismissed", "0");
134 }
135
136 /// Dismiss the sync-intro banner and persist the preference. Called both
137 /// from the banner's "Maybe later" button and (implicitly) when the user
138 /// clicks "Set up sync" — the banner should not re-appear after either.
139 pub fn dismiss_sync_intro(&mut self) {
140 self.show_sync_intro = false;
141 let _ = self.backend.set_config("sync_intro_dismissed", "1");
142 }
143
144 /// Whether the user has work in progress that would be interrupted by a
145 /// library switch. Used to gate the library picker with a confirm modal so
146 /// an accidental click doesn't cancel an active import or bulk operation.
147 pub fn has_in_flight_work(&self) -> bool {
148 !matches!(self.import_mode, crate::state::ImportMode::None)
149 || self.bulk_modal.is_some()
150 || self.pending_import_preflight.is_some()
151 }
152
153 /// Re-surface the VFS first-run banner ("a vault is your sample collection…").
154 /// Used from Settings / Help to bring back onboarding context after dismissal.
155 pub fn reset_vfs_explanation(&mut self) {
156 self.show_vfs_banner = true;
157 let _ = self.backend.set_config("vfs_explained", "0");
158 }
159
160 /// Globally rename a tag: every sample that carries `old_tag` will instead
161 /// carry `new_tag`. Used by the tag context menu in the sidebar. Refreshes
162 /// the cached tag list and posts a status message with the affected count.
163 pub fn rename_tag_globally(&mut self, old_tag: &str, new_tag: &str) {
164 match self.backend.rename_tag_globally(old_tag, new_tag) {
165 Ok(count) => {
166 self.refresh_all_tags();
167 // Also retire the old name from any active filter so the user
168 // isn't filtering on a tag that no longer exists.
169 self.search_filter.required_tags.retain(|t| t != old_tag);
170 self.apply_search();
171 self.status = format!("Renamed tag: {old_tag}{new_tag} ({count} sample{})",
172 if count == 1 { "" } else { "s" });
173 }
174 Err(e) => self.status = format!("Rename failed: {e}"),
175 }
176 }
177
178 /// Refresh the cached list of all tags.
179 pub fn refresh_all_tags(&mut self) {
180 self.all_tags = Arc::new(self.backend.list_all_tags().unwrap_or_else(|e| {
181 warn!("Failed to refresh tags: {e}");
182 Vec::new()
183 }));
184 }
185
186 /// Save the current search filter as a dynamic collection with the given name.
187 pub fn save_dynamic_collection(&mut self, name: &str) {
188 match self.backend.create_dynamic_collection(name, &self.search_filter) {
189 Ok(_) => {
190 self.status = format!("Saved collection: {name}");
191 }
192 Err(e) => {
193 self.status = format!("Failed to save collection: {e}");
194 }
195 }
196 self.refresh_collections();
197 }
198
199 /// Activate a dynamic collection: apply its filter and refresh.
200 pub fn activate_dynamic_collection(&mut self, _id: CollectionId, filter: &SearchFilter) {
201 self.active_collection = None; // not a manual-collection view
202 self.search_filter = filter.clone();
203 self.search_query = filter.text_query.clone();
204 self.similarity_search_hash = None;
205 self.similarity_source_name = None;
206 self.selection.clear();
207 self.refresh_contents();
208 }
209
210 // --- Collections ---
211
212 /// Refresh the collection list from the database.
213 pub fn refresh_collections(&mut self) {
214 self.collections = self.backend.list_collections()
215 .unwrap_or_else(|e| {
216 warn!("Failed to load collections: {e}");
217 Vec::new()
218 });
219 }
220
221 /// Activate a collection: show its members in the file list.
222 pub fn activate_collection(&mut self, id: CollectionId) {
223 let hashes = match self.backend.list_collection_members(id) {
224 Ok(h) => h,
225 Err(e) => {
226 self.status = format!("Failed to load collection: {e}");
227 return;
228 }
229 };
230 let Some(vfs_id) = self.current_vfs_id() else { return };
231 let hash_refs: Vec<&str> = hashes.iter().map(|s| s.as_str()).collect();
232 let nodes = self.backend.find_nodes_by_hashes(vfs_id, &hash_refs)
233 .unwrap_or_default();
234 let count = nodes.len();
235 self.contents = Arc::new(nodes);
236 self.active_collection = Some(id);
237 self.similarity_search_hash = None;
238 self.similarity_source_name = None;
239 self.selection.clear();
240 self.status = format!("{count} samples in collection");
241 }
242
243 /// Deactivate collection view and return to normal browsing.
244 pub fn deactivate_collection(&mut self) {
245 self.active_collection = None;
246 self.refresh_contents();
247 }
248
249 // --- Similarity search ---
250
251 /// Find samples similar to the given hash and display them.
252 pub fn find_similar(&mut self, hash: &str) {
253 match self.backend.find_similar(hash, 50) {
254 Ok(results) => {
255 let Some(vfs_id) = self.current_vfs_id() else { return };
256 let hashes: Vec<&str> = results.iter().map(|r| r.hash.as_str()).collect();
257 let nodes = self.backend.find_nodes_by_hashes(vfs_id, &hashes)
258 .unwrap_or_default();
259 let count = nodes.len();
260 self.contents = Arc::new(nodes);
261 self.similarity_search_hash = Some(hash.to_string());
262 self.similarity_source_name = self.backend.sample_original_name(hash).ok();
263 self.selection.clear();
264 self.status = format!("Found {count} similar samples");
265 }
266 Err(e) => {
267 self.status = format!("Similarity search failed: {e}");
268 }
269 }
270 }
271
272 /// Find near-duplicates of the given sample by comparing peak envelope fingerprints.
273 pub fn find_near_duplicates(&mut self, hash: &str) {
274 match self.backend.find_near_duplicates(hash, 50) {
275 Ok(results) => {
276 let Some(vfs_id) = self.current_vfs_id() else { return };
277 let hashes: Vec<&str> = results.iter().map(|r| r.hash.as_str()).collect();
278 let nodes = self.backend.find_nodes_by_hashes(vfs_id, &hashes)
279 .unwrap_or_default();
280 let count = nodes.len();
281 self.contents = Arc::new(nodes);
282 self.similarity_search_hash = Some(hash.to_string());
283 self.similarity_source_name = self.backend.sample_original_name(hash).ok();
284 self.selection.clear();
285 self.status = format!("Found {count} near-duplicates");
286 }
287 Err(e) => {
288 self.status = format!("Duplicate search failed: {e}");
289 }
290 }
291 }
292
293 /// Clear similarity search mode and return to normal browsing.
294 pub fn clear_similarity_search(&mut self) {
295 self.similarity_search_hash = None;
296 self.similarity_source_name = None;
297 self.similarity_source_name = None;
298 self.refresh_contents();
299 }
300
301 // --- Column config ---
302
303 /// Load column config from the user_config table.
304 pub fn load_column_config(&mut self) {
305 if let Ok(Some(json)) = self.backend.get_config("column_config")
306 && let Ok(parsed) = serde_json::from_str::<ColumnConfig>(&json) {
307 self.column_config = parsed;
308 }
309 }
310
311 /// Save the current theme ID to the user_config table.
312 pub fn save_theme_preference(&self) {
313 let _ = self.backend.set_config("theme", &self.current_theme_id);
314 }
315
316 /// Reset column visibility, sort, and row density to defaults. The Settings
317 /// → Appearance "Reset columns" button calls this — a column accidentally
318 /// dragged to 5 px stays that wide forever in egui memory until the app
319 /// restarts, so the tooltip notes that widths recover on next launch.
320 pub fn reset_columns(&mut self) {
321 self.column_config = ColumnConfig::default();
322 self.row_height = 24.0;
323 self.sort_column = SortColumn::Name;
324 self.sort_direction = SortDirection::Ascending;
325 self.save_column_config();
326 let _ = self.backend.set_config("row_height", "24");
327 self.status = "Columns reset to defaults".to_string();
328 }
329
330 /// Invert the current selection across the visible rows, then strip the
331 /// ".." parent entry (it's not a sample and must never participate in a
332 /// bulk operation). Used by Cmd+Shift+I and the matching menu items.
333 pub fn invert_selection(&mut self) {
334 let len = self.visible_len();
335 self.selection.invert(len);
336 if self.current_dir.is_some() {
337 self.selection.selected.remove(&0);
338 if self.selection.focus == 0 && len > 1 {
339 self.selection.focus = 1;
340 }
341 if self.selection.anchor == 0 && len > 1 {
342 self.selection.anchor = 1;
343 }
344 }
345 self.refresh_selected_tags();
346 self.refresh_selected_detail();
347 }
348
349 /// Persist the per-classification dismissed-suggestion map.
350 fn save_dismissed_suggestions(&self) {
351 // unwrap is safe: HashMap<String, Vec<String>> serialises cleanly.
352 let json = serde_json::to_string(&self.dismissed_suggestions).unwrap();
353 let _ = self.backend.set_config("suggestions.dismissed", &json);
354 }
355
356 /// Mark a tag suggestion as rejected for the given classification so it
357 /// stops appearing on every future sample of that class.
358 pub fn dismiss_suggestion(&mut self, classification: &str, tag: &str) {
359 let entry = self
360 .dismissed_suggestions
361 .entry(classification.to_string())
362 .or_default();
363 if !entry.iter().any(|t| t == tag) {
364 entry.push(tag.to_string());
365 self.save_dismissed_suggestions();
366 // M-1: remember the most recent dismissal so the detail panel can
367 // surface an inline Undo for ~5 seconds. Replaces the previous
368 // last-dismissed marker; older dismissals are no longer reachable
369 // via the inline affordance (they're still in `dismissed_suggestions`
370 // and recoverable via Settings → Reset suggestions).
371 self.last_dismissed_suggestion = Some((
372 classification.to_string(),
373 tag.to_string(),
374 std::time::Instant::now(),
375 ));
376 }
377 }
378
379 /// Re-enable the most recently dismissed suggestion (M-1). Clears the
380 /// inline-Undo marker on success or when the entry is gone (already cleared
381 /// by Settings → Reset suggestions, for example).
382 pub fn undo_last_dismissal(&mut self) {
383 let Some((class, tag, _)) = self.last_dismissed_suggestion.take() else {
384 return;
385 };
386 if let Some(entry) = self.dismissed_suggestions.get_mut(&class) {
387 entry.retain(|t| t != &tag);
388 if entry.is_empty() {
389 self.dismissed_suggestions.remove(&class);
390 }
391 self.save_dismissed_suggestions();
392 self.status = format!("Restored suggestion: {tag}");
393 }
394 }
395
396 /// Clear every dismissed suggestion across all classifications. Surfaced
397 /// from Settings → Reset suggestions so a user who has over-dismissed
398 /// during exploration can start fresh.
399 pub fn reset_dismissed_suggestions(&mut self) {
400 let n: usize = self.dismissed_suggestions.values().map(|v| v.len()).sum();
401 self.dismissed_suggestions.clear();
402 self.save_dismissed_suggestions();
403 self.status = format!("Reset {n} dismissed suggestion{}", if n == 1 { "" } else { "s" });
404 }
405
406 /// Save column config to the user_config table.
407 pub fn save_column_config(&self) {
408 // unwrap is safe: ColumnConfig contains only primitive fields (bools, enums)
409 // with derived Serialize impls, so serialisation cannot fail.
410 let json = serde_json::to_string(&self.column_config).unwrap();
411 let _ = self.backend.set_config("column_config", &json);
412 }
413
414 // --- VFS Mirror ---
415
416 /// Mark the VFS mirror as needing a re-sync.
417 pub fn mark_mirror_dirty(&mut self) {
418 if self.mirror_enabled {
419 self.mirror_dirty = true;
420 }
421 }
422
423 /// Run a mirror sync if the dirty flag is set. Returns true if a sync ran.
424 pub fn sync_mirror_if_dirty(&mut self) -> bool {
425 if !self.mirror_enabled || !self.mirror_dirty {
426 return false;
427 }
428 self.mirror_dirty = false;
429 match self.backend.sync_vfs_mirror(&self.mirror_path) {
430 Ok((dirs, links, removed)) => {
431 if dirs + links + removed > 0 {
432 tracing::debug!(dirs, links, removed, "Mirror synced");
433 }
434 true
435 }
436 Err(e) => {
437 tracing::warn!("Mirror sync failed: {e}");
438 false
439 }
440 }
441 }
442
443 /// Enable or disable the VFS mirror. Persists the setting.
444 pub fn set_mirror_enabled(&mut self, enabled: bool) {
445 self.mirror_enabled = enabled;
446 let _ = self
447 .backend
448 .set_config("mirror_enabled", if enabled { "1" } else { "0" });
449
450 if enabled {
451 // Run initial sync immediately.
452 self.mirror_dirty = true;
453 self.sync_mirror_if_dirty();
454 } else {
455 // Remove the mirror directory.
456 let _ = audiofiles_core::vfs_mirror::remove_mirror(&self.mirror_path);
457 }
458 }
459
460 /// Set the mirror path. Persists the setting.
461 pub fn set_mirror_path(&mut self, path: PathBuf) {
462 // If mirror is enabled, remove old mirror before switching.
463 if self.mirror_enabled {
464 let _ = audiofiles_core::vfs_mirror::remove_mirror(&self.mirror_path);
465 }
466 self.mirror_path = path;
467 let _ = self
468 .backend
469 .set_config("mirror_path", &self.mirror_path.to_string_lossy());
470 if self.mirror_enabled {
471 self.mirror_dirty = true;
472 }
473 }
474 }
475