max / audiofiles
7 files changed,
+945 insertions,
-161 deletions
| @@ -4,6 +4,17 @@ | |||
| 4 | 4 | //! output stream for sample preview playback. Requires a valid license key | |
| 5 | 5 | //! before the browser is accessible — the activation result is cached locally | |
| 6 | 6 | //! so the app works offline after the first activation. | |
| 7 | + | //! | |
| 8 | + | //! ## Why immediate-mode GUI (egui) instead of Tauri/webview | |
| 9 | + | //! | |
| 10 | + | //! - **Waveform rendering:** Scrolling and zooming a 10-minute waveform at 60fps needs | |
| 11 | + | //! GPU-backed drawing, not DOM layout. egui's painter gives direct control over vertex | |
| 12 | + | //! buffers — no JS/CSS performance cliff for large datasets. | |
| 13 | + | //! - **No JS dependency:** The entire app is a single Rust binary. No Node.js build step, | |
| 14 | + | //! no npm dependencies, no webview security surface. | |
| 15 | + | //! - **Drag-out FFI:** Native drag-and-drop into DAWs requires platform pasteboard APIs | |
| 16 | + | //! (NSPasteboardItem on macOS, OLE on Windows). A webview can't initiate OS-level drags | |
| 17 | + | //! with file promises. | |
| 7 | 18 | ||
| 8 | 19 | mod audio; | |
| 9 | 20 | mod license; | |
| @@ -223,11 +234,150 @@ mod tests { | |||
| 223 | 234 | let result = load_api_key(dir.path()); | |
| 224 | 235 | assert_eq!(result, Some("roundtrip-key".to_string())); | |
| 225 | 236 | } | |
| 237 | + | ||
| 238 | + | // ── Initial screen resolution ── | |
| 239 | + | ||
| 240 | + | fn make_license_cache() -> license::LicenseCache { | |
| 241 | + | license::LicenseCache { | |
| 242 | + | key_code: "bright-castle-forest-river-falcon".to_string(), | |
| 243 | + | machine_id: "test-machine".to_string(), | |
| 244 | + | activated_at: "2026-04-01T00:00:00Z".to_string(), | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | fn make_registry(dir: &Path) -> VaultRegistry { | |
| 249 | + | VaultRegistry { | |
| 250 | + | vaults: vec![vault::VaultEntry { | |
| 251 | + | name: "Library".to_string(), | |
| 252 | + | path: dir.to_path_buf(), | |
| 253 | + | }], | |
| 254 | + | active: dir.to_path_buf(), | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | #[test] | |
| 259 | + | fn initial_screen_licensed_with_registry() { | |
| 260 | + | let dir = tempfile::tempdir().unwrap(); | |
| 261 | + | let reg = Some(make_registry(dir.path())); | |
| 262 | + | let status = license::LicenseStatus::Licensed(make_license_cache()); | |
| 263 | + | assert_eq!(resolve_initial_screen(®, &status), AppScreen::Browser); | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | #[test] | |
| 267 | + | fn initial_screen_licensed_without_registry() { | |
| 268 | + | let status = license::LicenseStatus::Licensed(make_license_cache()); | |
| 269 | + | assert_eq!(resolve_initial_screen(&None, &status), AppScreen::VaultSetup); | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | #[test] | |
| 273 | + | fn initial_screen_unlicensed_with_registry() { | |
| 274 | + | let dir = tempfile::tempdir().unwrap(); | |
| 275 | + | let reg = Some(make_registry(dir.path())); | |
| 276 | + | let status = license::LicenseStatus::Unlicensed; | |
| 277 | + | assert_eq!(resolve_initial_screen(®, &status), AppScreen::Activation); | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | #[test] | |
| 281 | + | fn initial_screen_unlicensed_without_registry() { | |
| 282 | + | let status = license::LicenseStatus::Unlicensed; | |
| 283 | + | assert_eq!(resolve_initial_screen(&None, &status), AppScreen::Activation); | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | // ── License migration ── | |
| 287 | + | ||
| 288 | + | #[test] | |
| 289 | + | fn migrate_license_copies_files() { | |
| 290 | + | let src = tempfile::tempdir().unwrap(); | |
| 291 | + | let dst = tempfile::tempdir().unwrap(); | |
| 292 | + | std::fs::write(src.path().join("license.json"), r#"{"key_code":"k","machine_id":"m","activated_at":"t"}"#).unwrap(); | |
| 293 | + | std::fs::write(src.path().join("machine_id"), "mid-123").unwrap(); | |
| 294 | + | ||
| 295 | + | migrate_license_to_config(dst.path(), src.path()); | |
| 296 | + | ||
| 297 | + | assert_eq!( | |
| 298 | + | std::fs::read_to_string(dst.path().join("license.json")).unwrap(), | |
| 299 | + | r#"{"key_code":"k","machine_id":"m","activated_at":"t"}"# | |
| 300 | + | ); | |
| 301 | + | assert_eq!( | |
| 302 | + | std::fs::read_to_string(dst.path().join("machine_id")).unwrap(), | |
| 303 | + | "mid-123" | |
| 304 | + | ); | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | #[test] | |
| 308 | + | fn migrate_license_skips_when_same_dir() { | |
| 309 | + | let dir = tempfile::tempdir().unwrap(); | |
| 310 | + | // Should not panic or overwrite — same source and dest | |
| 311 | + | migrate_license_to_config(dir.path(), dir.path()); | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | #[test] | |
| 315 | + | fn migrate_license_does_not_overwrite_existing() { | |
| 316 | + | let src = tempfile::tempdir().unwrap(); | |
| 317 | + | let dst = tempfile::tempdir().unwrap(); | |
| 318 | + | std::fs::write(src.path().join("license.json"), "old").unwrap(); | |
| 319 | + | std::fs::write(dst.path().join("license.json"), "existing").unwrap(); | |
| 320 | + | ||
| 321 | + | migrate_license_to_config(dst.path(), src.path()); | |
| 322 | + | ||
| 323 | + | // Destination file should be unchanged | |
| 324 | + | assert_eq!( | |
| 325 | + | std::fs::read_to_string(dst.path().join("license.json")).unwrap(), | |
| 326 | + | "existing" | |
| 327 | + | ); | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | #[test] | |
| 331 | + | fn migrate_license_handles_missing_source() { | |
| 332 | + | let src = tempfile::tempdir().unwrap(); | |
| 333 | + | let dst = tempfile::tempdir().unwrap(); | |
| 334 | + | // No files in source — should not create anything in dest | |
| 335 | + | migrate_license_to_config(dst.path(), src.path()); | |
| 336 | + | assert!(!dst.path().join("license.json").exists()); | |
| 337 | + | assert!(!dst.path().join("machine_id").exists()); | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | // ── Key masking ── | |
| 341 | + | ||
| 342 | + | #[test] | |
| 343 | + | fn mask_key_five_words() { | |
| 344 | + | assert_eq!( | |
| 345 | + | mask_key("bright-castle-forest-river-falcon"), | |
| 346 | + | "bright-...-falcon" | |
| 347 | + | ); | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | #[test] | |
| 351 | + | fn mask_key_two_words() { | |
| 352 | + | assert_eq!(mask_key("alpha-beta"), "alpha-...-beta"); | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | #[test] | |
| 356 | + | fn mask_key_single_word() { | |
| 357 | + | assert_eq!(mask_key("onlyoneword"), "***"); | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | // ── Vault name lookup ── | |
| 361 | + | ||
| 362 | + | #[test] | |
| 363 | + | fn vault_name_for_path_found() { | |
| 364 | + | let dir = tempfile::tempdir().unwrap(); | |
| 365 | + | let reg = make_registry(dir.path()); | |
| 366 | + | assert_eq!(vault_name_for_path(®, dir.path()), "Library"); | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | #[test] | |
| 370 | + | fn vault_name_for_path_not_found() { | |
| 371 | + | let dir = tempfile::tempdir().unwrap(); | |
| 372 | + | let reg = make_registry(dir.path()); | |
| 373 | + | assert_eq!(vault_name_for_path(®, Path::new("/nonexistent")), "Library"); | |
| 374 | + | } | |
| 226 | 375 | } | |
| 227 | 376 | ||
| 228 | 377 | // ── App ── | |
| 229 | 378 | ||
| 230 | 379 | /// Which screen the app is showing. | |
| 380 | + | #[derive(Debug, PartialEq)] | |
| 231 | 381 | enum AppScreen { | |
| 232 | 382 | /// License activation gate — no browser access until a valid key is entered. | |
| 233 | 383 | Activation, | |
| @@ -237,6 +387,22 @@ enum AppScreen { | |||
| 237 | 387 | Browser, | |
| 238 | 388 | } | |
| 239 | 389 | ||
| 390 | + | /// Determine the initial screen based on vault registry and license status. | |
| 391 | + | /// | |
| 392 | + | /// This is the pure decision logic extracted from `AudioFilesApp::new()` so it | |
| 393 | + | /// can be tested without constructing the full app. | |
| 394 | + | fn resolve_initial_screen( | |
| 395 | + | vault_registry: &Option<VaultRegistry>, | |
| 396 | + | license_status: &license::LicenseStatus, | |
| 397 | + | ) -> AppScreen { | |
| 398 | + | match (vault_registry, license_status) { | |
| 399 | + | (Some(_), license::LicenseStatus::Licensed(_)) => AppScreen::Browser, | |
| 400 | + | (Some(_), license::LicenseStatus::Unlicensed) => AppScreen::Activation, | |
| 401 | + | (None, license::LicenseStatus::Licensed(_)) => AppScreen::VaultSetup, | |
| 402 | + | (None, license::LicenseStatus::Unlicensed) => AppScreen::Activation, | |
| 403 | + | } | |
| 404 | + | } | |
| 405 | + | ||
| 240 | 406 | struct AudioFilesApp { | |
| 241 | 407 | screen: AppScreen, | |
| 242 | 408 | browser: Option<BrowserState>, | |
| @@ -298,7 +464,9 @@ impl AudioFilesApp { | |||
| 298 | 464 | } | |
| 299 | 465 | }; | |
| 300 | 466 | ||
| 301 | - | let (screen, data_dir, browser, error, sync_manager, license_cache) = | |
| 467 | + | let screen = resolve_initial_screen(&vault_registry, &license_status); | |
| 468 | + | ||
| 469 | + | let (data_dir, browser, error, sync_manager, license_cache) = | |
| 302 | 470 | match (&vault_registry, &license_status) { | |
| 303 | 471 | // Registry exists and user is licensed → open the active vault | |
| 304 | 472 | (Some(reg), license::LicenseStatus::Licensed(cache)) => { | |
| @@ -306,22 +474,22 @@ impl AudioFilesApp { | |||
| 306 | 474 | let _ = std::fs::create_dir_all(&data_dir); | |
| 307 | 475 | let sync_manager = create_sync_manager(&data_dir, runtime.handle()); | |
| 308 | 476 | let (browser, error) = init_browser(&data_dir, shared.clone(), &vault_name_for_path(reg, &data_dir)); | |
| 309 | - | (AppScreen::Browser, data_dir, browser, error, sync_manager, Some(cache.clone())) | |
| 477 | + | (data_dir, browser, error, sync_manager, Some(cache.clone())) | |
| 310 | 478 | } | |
| 311 | 479 | // Registry exists but unlicensed (deactivated and reactivated) | |
| 312 | 480 | (Some(reg), license::LicenseStatus::Unlicensed) => { | |
| 313 | 481 | tracing::info!("No valid license, showing activation screen"); | |
| 314 | - | (AppScreen::Activation, reg.active.clone(), None, None, None, None) | |
| 482 | + | (reg.active.clone(), None, None, None, None) | |
| 315 | 483 | } | |
| 316 | 484 | // No registry + licensed → vault setup (existing user upgrading) | |
| 317 | 485 | (None, license::LicenseStatus::Licensed(cache)) => { | |
| 318 | 486 | tracing::info!("Licensed but no vault registry, showing vault setup"); | |
| 319 | - | (AppScreen::VaultSetup, default_vault.clone(), None, None, None, Some(cache.clone())) | |
| 487 | + | (default_vault.clone(), None, None, None, Some(cache.clone())) | |
| 320 | 488 | } | |
| 321 | 489 | // No registry + unlicensed → activation first | |
| 322 | 490 | (None, license::LicenseStatus::Unlicensed) => { | |
| 323 | 491 | tracing::info!("No license, showing activation screen"); | |
| 324 | - | (AppScreen::Activation, default_vault.clone(), None, None, None, None) | |
| 492 | + | (default_vault.clone(), None, None, None, None) | |
| 325 | 493 | } | |
| 326 | 494 | }; | |
| 327 | 495 | ||
| @@ -802,7 +970,9 @@ impl eframe::App for AudioFilesApp { | |||
| 802 | 970 | } | |
| 803 | 971 | ui.add_space(4.0); | |
| 804 | 972 | ui.horizontal(|ui| { | |
| 805 | - | if ui.button("Download").clicked() && !download_url.is_empty() { | |
| 973 | + | if ui.button("Download").clicked() | |
| 974 | + | && download_url.starts_with("https://") | |
| 975 | + | { | |
| 806 | 976 | let _ = open::that(&download_url); | |
| 807 | 977 | } | |
| 808 | 978 | if ui.button("Not Now").clicked() { |
| @@ -4,7 +4,7 @@ use egui; | |||
| 4 | 4 | use egui_extras::{Column, TableBuilder}; | |
| 5 | 5 | ||
| 6 | 6 | use crate::state::{BrowserState, SortColumn, SortDirection}; | |
| 7 | - | use audiofiles_core::vfs::NodeType; | |
| 7 | + | use audiofiles_core::vfs::{NodeType, VfsNodeWithAnalysis}; | |
| 8 | 8 | ||
| 9 | 9 | use super::file_list_menus::{draw_background_context_menu, draw_context_menu, draw_multi_context_menu}; | |
| 10 | 10 | use super::instrument_panel::DragPayload; | |
| @@ -191,159 +191,19 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 191 | 191 | ||
| 192 | 192 | // Name (with inline icon) | |
| 193 | 193 | row.col(|ui| { | |
| 194 | - | let icon = match node.node.node_type { | |
| 195 | - | NodeType::Directory => "\u{1F4C1} ", | |
| 196 | - | NodeType::Sample if node.cloud_only => "\u{2601} ", | |
| 197 | - | NodeType::Sample => "\u{1F50A} ", | |
| 198 | - | }; | |
| 199 | - | let label = format!("{}{}", icon, node.node.name); | |
| 200 | - | let resp = if node.cloud_only { | |
| 201 | - | ui.selectable_label( | |
| 202 | - | selected, | |
| 203 | - | egui::RichText::new(&label).color(theme::text_muted()), | |
| 204 | - | ) | |
| 205 | - | } else { | |
| 206 | - | ui.selectable_label(selected, &label) | |
| 207 | - | }; | |
| 208 | - | ||
| 209 | - | // Add drag sense for native OS drag-out (Finder/DAW). | |
| 210 | - | // Response::interact() re-registers the SAME widget id with | |
| 211 | - | // click+drag sense so egui tracks drags on the selectable_label. | |
| 212 | 194 | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 213 | - | let resp = if !node.cloud_only && node.node.node_type == NodeType::Sample { | |
| 214 | - | resp.interact(egui::Sense::drag()) | |
| 215 | - | } else { | |
| 216 | - | resp | |
| 217 | - | }; | |
| 218 | - | ||
| 219 | - | if resp.clicked() { | |
| 220 | - | handle_click(state, row_idx, ui); | |
| 221 | - | } | |
| 222 | - | ||
| 223 | - | if resp.double_clicked() { | |
| 224 | - | match node.node.node_type { | |
| 225 | - | NodeType::Directory => { | |
| 226 | - | state.selection.set_single(row_idx); | |
| 227 | - | state.enter_directory(); | |
| 228 | - | } | |
| 229 | - | NodeType::Sample => { | |
| 230 | - | if !node.cloud_only { | |
| 231 | - | if let Some(hash) = &node.node.sample_hash { | |
| 232 | - | let hash = hash.clone(); | |
| 233 | - | state.trigger_preview(&hash); | |
| 234 | - | } | |
| 235 | - | } | |
| 236 | - | } | |
| 237 | - | } | |
| 238 | - | } | |
| 239 | - | ||
| 240 | - | // Drag source for instrument zone assignment (not for cloud-only) | |
| 241 | - | if state.instrument_visible && node.node.node_type == NodeType::Sample && !node.cloud_only { | |
| 242 | - | if let Some(hash) = &node.node.sample_hash { | |
| 243 | - | resp.dnd_set_drag_payload(DragPayload { | |
| 244 | - | hash: hash.to_string(), | |
| 245 | - | name: node.node.name.clone(), | |
| 246 | - | }); | |
| 247 | - | } | |
| 248 | - | } | |
| 249 | - | ||
| 250 | - | // Native OS drag-out to Finder/DAW (only when instrument panel is closed) | |
| 251 | - | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 252 | - | if !os_drag_blocked | |
| 253 | - | && !state.instrument_visible | |
| 254 | - | && node.node.node_type == NodeType::Sample | |
| 255 | - | && !node.cloud_only | |
| 256 | - | && resp.dragged() | |
| 257 | - | && resp.drag_delta().length() > 4.0 | |
| 258 | - | { | |
| 259 | - | if !state.selection.contains(row_idx) { | |
| 260 | - | state.selection.set_single(row_idx); | |
| 261 | - | } | |
| 262 | - | start_os_drag(state); | |
| 263 | - | } | |
| 264 | - | ||
| 265 | - | // Context menu: show bulk operations when right-clicking | |
| 266 | - | // a row that's part of a multi-selection, otherwise show | |
| 267 | - | // single-item actions (preview, copy path, delete). | |
| 268 | - | resp.context_menu(|ui| { | |
| 269 | - | if state.selection.count() > 1 && state.selection.contains(row_idx) { | |
| 270 | - | draw_multi_context_menu(ui, state); | |
| 271 | - | } else { | |
| 272 | - | draw_context_menu(ui, state, row_idx, node); | |
| 273 | - | } | |
| 274 | - | }); | |
| 195 | + | let drag_blocked = os_drag_blocked; | |
| 196 | + | #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] | |
| 197 | + | let drag_blocked = false; | |
| 198 | + | draw_name_column(ui, state, node, row_idx, selected, drag_blocked); | |
| 275 | 199 | }); | |
| 276 | 200 | ||
| 277 | - | // Duration | |
| 278 | - | if show_duration { | |
| 279 | - | row.col(|ui| { | |
| 280 | - | if let Some(dur) = node.duration { | |
| 281 | - | ui.label( | |
| 282 | - | egui::RichText::new(widgets::format_duration(dur)) | |
| 283 | - | .color(theme::text_secondary()), | |
| 284 | - | ); | |
| 285 | - | } | |
| 286 | - | }); | |
| 287 | - | } | |
| 288 | - | ||
| 289 | - | // Classification | |
| 290 | - | if show_classification { | |
| 291 | - | row.col(|ui| { | |
| 292 | - | if let Some(ref class) = node.classification { | |
| 293 | - | widgets::classification_badge(ui, class); | |
| 294 | - | } | |
| 295 | - | }); | |
| 296 | - | } | |
| 297 | - | ||
| 298 | - | // BPM | |
| 299 | - | if show_bpm { | |
| 300 | - | row.col(|ui| { | |
| 301 | - | if let Some(bpm) = node.bpm { | |
| 302 | - | ui.label( | |
| 303 | - | egui::RichText::new(widgets::format_bpm(bpm)) | |
| 304 | - | .color(theme::text_secondary()), | |
| 305 | - | ); | |
| 306 | - | } | |
| 307 | - | }); | |
| 308 | - | } | |
| 309 | - | ||
| 310 | - | // Key | |
| 311 | - | if show_key { | |
| 312 | - | row.col(|ui| { | |
| 313 | - | if let Some(ref key) = node.musical_key { | |
| 314 | - | ui.label( | |
| 315 | - | egui::RichText::new(key.as_str()) | |
| 316 | - | .color(theme::text_secondary()), | |
| 317 | - | ); | |
| 318 | - | } | |
| 319 | - | }); | |
| 320 | - | } | |
| 321 | - | ||
| 322 | - | // Peak dB | |
| 323 | - | if show_peak_db { | |
| 324 | - | row.col(|ui| { | |
| 325 | - | if let Some(peak) = node.peak_db { | |
| 326 | - | ui.label( | |
| 327 | - | egui::RichText::new(format!("{:.1}", peak)) | |
| 328 | - | .color(theme::text_secondary()), | |
| 329 | - | ); | |
| 330 | - | } | |
| 331 | - | }); | |
| 332 | - | } | |
| 333 | - | ||
| 334 | - | // Tags | |
| 335 | - | if show_tags { | |
| 336 | - | row.col(|ui| { | |
| 337 | - | if !node.tags.is_empty() { | |
| 338 | - | let tag_str = node.tags.join(", "); | |
| 339 | - | ui.label( | |
| 340 | - | egui::RichText::new(tag_str) | |
| 341 | - | .small() | |
| 342 | - | .color(theme::text_secondary()), | |
| 343 | - | ); | |
| 344 | - | } | |
| 345 | - | }); | |
| 346 | - | } | |
| 201 | + | // Analysis columns (duration, classification, BPM, key, peak dB, tags) | |
| 202 | + | draw_analysis_columns( | |
| 203 | + | &mut row, node, | |
| 204 | + | show_duration, show_classification, show_bpm, | |
| 205 | + | show_key, show_peak_db, show_tags, | |
| 206 | + | ); | |
| 347 | 207 | ||
| 348 | 208 | // Play button | |
| 349 | 209 | row.col(|ui| { | |
| @@ -412,6 +272,186 @@ fn handle_click(state: &mut BrowserState, row_idx: usize, ui: &egui::Ui) { | |||
| 412 | 272 | state.refresh_selected_detail(); | |
| 413 | 273 | } | |
| 414 | 274 | ||
| 275 | + | /// Draw the Name column contents for a single file-list row. | |
| 276 | + | /// | |
| 277 | + | /// Renders the icon + label, handles click/double-click, drag payloads | |
| 278 | + | /// (instrument zone assignment and native OS drag-out), and the context menu. | |
| 279 | + | #[allow(unused_variables)] | |
| 280 | + | fn draw_name_column( | |
| 281 | + | ui: &mut egui::Ui, | |
| 282 | + | state: &mut BrowserState, | |
| 283 | + | node: &VfsNodeWithAnalysis, | |
| 284 | + | row_idx: usize, | |
| 285 | + | selected: bool, | |
| 286 | + | os_drag_blocked: bool, | |
| 287 | + | ) { | |
| 288 | + | let icon = match node.node.node_type { | |
| 289 | + | NodeType::Directory => "\u{1F4C1} ", | |
| 290 | + | NodeType::Sample if node.cloud_only => "\u{2601} ", | |
| 291 | + | NodeType::Sample => "\u{1F50A} ", | |
| 292 | + | }; | |
| 293 | + | let label = format!("{}{}", icon, node.node.name); | |
| 294 | + | let resp = if node.cloud_only { | |
| 295 | + | ui.selectable_label( | |
| 296 | + | selected, | |
| 297 | + | egui::RichText::new(&label).color(theme::text_muted()), | |
| 298 | + | ) | |
| 299 | + | } else { | |
| 300 | + | ui.selectable_label(selected, &label) | |
| 301 | + | }; | |
| 302 | + | ||
| 303 | + | // Add drag sense for native OS drag-out (Finder/DAW). | |
| 304 | + | // Response::interact() re-registers the SAME widget id with | |
| 305 | + | // click+drag sense so egui tracks drags on the selectable_label. | |
| 306 | + | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 307 | + | let resp = if !node.cloud_only && node.node.node_type == NodeType::Sample { | |
| 308 | + | resp.interact(egui::Sense::drag()) | |
| 309 | + | } else { | |
| 310 | + | resp | |
| 311 | + | }; | |
| 312 | + | ||
| 313 | + | if resp.clicked() { | |
| 314 | + | handle_click(state, row_idx, ui); | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | if resp.double_clicked() { | |
| 318 | + | match node.node.node_type { | |
| 319 | + | NodeType::Directory => { | |
| 320 | + | state.selection.set_single(row_idx); | |
| 321 | + | state.enter_directory(); | |
| 322 | + | } | |
| 323 | + | NodeType::Sample => { | |
| 324 | + | if !node.cloud_only { | |
| 325 | + | if let Some(hash) = &node.node.sample_hash { | |
| 326 | + | let hash = hash.clone(); | |
| 327 | + | state.trigger_preview(&hash); | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | // Drag source for instrument zone assignment (not for cloud-only) | |
| 335 | + | if state.instrument_visible && node.node.node_type == NodeType::Sample && !node.cloud_only { | |
| 336 | + | if let Some(hash) = &node.node.sample_hash { | |
| 337 | + | resp.dnd_set_drag_payload(DragPayload { | |
| 338 | + | hash: hash.to_string(), | |
| 339 | + | name: node.node.name.clone(), | |
| 340 | + | }); | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | // Native OS drag-out to Finder/DAW (only when instrument panel is closed) | |
| 345 | + | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 346 | + | if !os_drag_blocked | |
| 347 | + | && !state.instrument_visible | |
| 348 | + | && node.node.node_type == NodeType::Sample | |
| 349 | + | && !node.cloud_only | |
| 350 | + | && resp.dragged() | |
| 351 | + | && resp.drag_delta().length() > 4.0 | |
| 352 | + | { | |
| 353 | + | if !state.selection.contains(row_idx) { | |
| 354 | + | state.selection.set_single(row_idx); | |
| 355 | + | } | |
| 356 | + | start_os_drag(state); | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | // Context menu: show bulk operations when right-clicking | |
| 360 | + | // a row that's part of a multi-selection, otherwise show | |
| 361 | + | // single-item actions (preview, copy path, delete). | |
| 362 | + | resp.context_menu(|ui| { | |
| 363 | + | if state.selection.count() > 1 && state.selection.contains(row_idx) { | |
| 364 | + | draw_multi_context_menu(ui, state); | |
| 365 | + | } else { | |
| 366 | + | draw_context_menu(ui, state, row_idx, node); | |
| 367 | + | } | |
| 368 | + | }); | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | /// Draw the analysis data columns (duration, classification, BPM, key, peak dB, tags) | |
| 372 | + | /// for a single file-list row. Each visible column emits a `row.col()` call. | |
| 373 | + | fn draw_analysis_columns( | |
| 374 | + | row: &mut egui_extras::TableRow, | |
| 375 | + | node: &VfsNodeWithAnalysis, | |
| 376 | + | show_duration: bool, | |
| 377 | + | show_classification: bool, | |
| 378 | + | show_bpm: bool, | |
| 379 | + | show_key: bool, | |
| 380 | + | show_peak_db: bool, | |
| 381 | + | show_tags: bool, | |
| 382 | + | ) { | |
| 383 | + | // Duration | |
| 384 | + | if show_duration { | |
| 385 | + | row.col(|ui| { | |
| 386 | + | if let Some(dur) = node.duration { | |
| 387 | + | ui.label( | |
| 388 | + | egui::RichText::new(widgets::format_duration(dur)) | |
| 389 | + | .color(theme::text_secondary()), | |
| 390 | + | ); | |
| 391 | + | } | |
| 392 | + | }); | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | // Classification | |
| 396 | + | if show_classification { | |
| 397 | + | row.col(|ui| { | |
| 398 | + | if let Some(ref class) = node.classification { | |
| 399 | + | widgets::classification_badge(ui, class); | |
| 400 | + | } | |
| 401 | + | }); | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | // BPM | |
| 405 | + | if show_bpm { | |
| 406 | + | row.col(|ui| { | |
| 407 | + | if let Some(bpm) = node.bpm { | |
| 408 | + | ui.label( | |
| 409 | + | egui::RichText::new(widgets::format_bpm(bpm)) | |
| 410 | + | .color(theme::text_secondary()), | |
| 411 | + | ); | |
| 412 | + | } | |
| 413 | + | }); | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | // Key | |
| 417 | + | if show_key { | |
| 418 | + | row.col(|ui| { | |
| 419 | + | if let Some(ref key) = node.musical_key { | |
| 420 | + | ui.label( | |
| 421 | + | egui::RichText::new(key.as_str()) | |
| 422 | + | .color(theme::text_secondary()), | |
| 423 | + | ); | |
| 424 | + | } | |
| 425 | + | }); | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | // Peak dB | |
| 429 | + | if show_peak_db { | |
| 430 | + | row.col(|ui| { | |
| 431 | + | if let Some(peak) = node.peak_db { | |
| 432 | + | ui.label( | |
| 433 | + | egui::RichText::new(format!("{:.1}", peak)) | |
| 434 | + | .color(theme::text_secondary()), | |
| 435 | + | ); | |
| 436 | + | } | |
| 437 | + | }); | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | // Tags | |
| 441 | + | if show_tags { | |
| 442 | + | row.col(|ui| { | |
| 443 | + | if !node.tags.is_empty() { | |
| 444 | + | let tag_str = node.tags.join(", "); | |
| 445 | + | ui.label( | |
| 446 | + | egui::RichText::new(tag_str) | |
| 447 | + | .small() | |
| 448 | + | .color(theme::text_secondary()), | |
| 449 | + | ); | |
| 450 | + | } | |
| 451 | + | }); | |
| 452 | + | } | |
| 453 | + | } | |
| 454 | + | ||
| 415 | 455 | /// Draw a clickable column header label. Shows an up/down arrow when this column | |
| 416 | 456 | /// is the active sort key. Uses `std::mem::discriminant` so we can compare enum | |
| 417 | 457 | /// variants without requiring `PartialEq` on the payload. |
| @@ -315,11 +315,12 @@ pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 315 | 315 | // Inline rename modal | |
| 316 | 316 | if let Some((rename_id, _)) = state.collection_rename_target.clone() { | |
| 317 | 317 | ui.horizontal(|ui| { | |
| 318 | - | let resp = ui.text_edit_singleline(&mut state.collection_rename_target.as_mut().unwrap().1); | |
| 318 | + | let Some((_, buf)) = state.collection_rename_target.as_mut() else { return; }; | |
| 319 | + | let resp = ui.text_edit_singleline(buf); | |
| 319 | 320 | if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { | |
| 320 | - | let new_name = state.collection_rename_target.as_ref().unwrap().1.clone(); | |
| 321 | - | if !new_name.trim().is_empty() { | |
| 322 | - | let _ = state.backend.rename_collection(rename_id, new_name.trim()); | |
| 321 | + | let new_name = buf.trim().to_string(); | |
| 322 | + | if !new_name.is_empty() { | |
| 323 | + | let _ = state.backend.rename_collection(rename_id, &new_name); | |
| 323 | 324 | state.refresh_collections(); | |
| 324 | 325 | } | |
| 325 | 326 | state.collection_rename_target = None; |
| @@ -36,14 +36,25 @@ struct MelFilterbank { | |||
| 36 | 36 | filters: Vec<MelFilter>, | |
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | + | /// Convert frequency in Hz to the mel scale: `2595 * log10(1 + hz/700)`. | |
| 40 | + | /// The mel scale approximates human pitch perception — equal mel intervals | |
| 41 | + | /// sound equally spaced to the ear, even though the Hz intervals grow wider | |
| 42 | + | /// at higher frequencies. | |
| 39 | 43 | fn hz_to_mel(hz: f64) -> f64 { | |
| 40 | 44 | 2595.0 * (1.0 + hz / 700.0).log10() | |
| 41 | 45 | } | |
| 42 | 46 | ||
| 47 | + | /// Inverse mel scale: convert mel value back to Hz. | |
| 43 | 48 | fn mel_to_hz(mel: f64) -> f64 { | |
| 44 | 49 | 700.0 * (10.0_f64.powf(mel / 2595.0) - 1.0) | |
| 45 | 50 | } | |
| 46 | 51 | ||
| 52 | + | /// Build a mel-spaced triangular filterbank for MFCC computation. | |
| 53 | + | /// | |
| 54 | + | /// Creates `num_filters` overlapping triangular filters spanning 0 Hz to Nyquist, | |
| 55 | + | /// spaced uniformly on the mel scale. Each filter has a rising slope from its left | |
| 56 | + | /// edge to its center and a falling slope from center to right edge — this smoothly | |
| 57 | + | /// bins FFT magnitudes into perceptually meaningful frequency bands. | |
| 47 | 58 | fn build_mel_filterbank(fft_size: usize, sample_rate: u32, num_filters: usize) -> MelFilterbank { | |
| 48 | 59 | let spectrum_len = fft_size / 2 + 1; | |
| 49 | 60 | let freq_bin_hz = sample_rate as f64 / fft_size as f64; |
| @@ -1,4 +1,13 @@ | |||
| 1 | 1 | //! Content-addressed sample storage: imports files by SHA-256 hash, deduplicates, and manages on-disk blobs. | |
| 2 | + | //! | |
| 3 | + | //! ## Why content-addressed storage | |
| 4 | + | //! | |
| 5 | + | //! - **Dedup by design:** Importing the same file twice is a no-op (same hash = same row). | |
| 6 | + | //! Users often have the same sample in multiple folders. | |
| 7 | + | //! - **Sync-friendly:** Hash is a stable, globally unique identifier across devices. No UUID | |
| 8 | + | //! collisions, no server-assigned IDs, no coordination needed during offline edits. | |
| 9 | + | //! - **Cloud eviction:** Setting `cloud_only=true` deletes the local blob while keeping the | |
| 10 | + | //! metadata row. The hash lets SyncKit re-download the exact file from blob storage later. | |
| 2 | 11 | ||
| 3 | 12 | use std::fs; | |
| 4 | 13 | use std::io::Read; |
| @@ -3,6 +3,15 @@ | |||
| 3 | 3 | //! Loads TOML manifests (bundled + user) describing hardware sampler constraints, | |
| 4 | 4 | //! and optionally runs Rhai scripts for custom export logic. | |
| 5 | 5 | //! | |
| 6 | + | //! ## Why Rhai | |
| 7 | + | //! | |
| 8 | + | //! - **Sandboxed:** No filesystem, network, or FFI access from scripts. Unlike Lua or | |
| 9 | + | //! Python plugins, a malicious Rhai script cannot escape the sandbox. | |
| 10 | + | //! - **Resource-capped:** Configurable operation count, expression depth, and array size | |
| 11 | + | //! limits prevent infinite loops and memory exhaustion. | |
| 12 | + | //! - **Rust-native:** Compiles as a regular Rust crate — no external interpreter binary, | |
| 13 | + | //! no C bindings, no build-time code generation. | |
| 14 | + | //! | |
| 6 | 15 | //! # Architecture | |
| 7 | 16 | //! | |
| 8 | 17 | //! - `manifest` — TOML parsing into `DeviceProfile` | |
| @@ -115,6 +124,8 @@ fn compile_plugin_hooks( | |||
| 115 | 124 | #[cfg(test)] | |
| 116 | 125 | mod tests { | |
| 117 | 126 | use super::*; | |
| 127 | + | use crate::hooks::{run_validate_sample, run_transform_filename}; | |
| 128 | + | use crate::types::{RhaiSampleInfo, RhaiExportContext}; | |
| 118 | 129 | ||
| 119 | 130 | #[test] | |
| 120 | 131 | fn load_user_plugins_from_empty_dir() { | |
| @@ -174,4 +185,346 @@ validate_sample = "hooks/validate.rhai" | |||
| 174 | 185 | assert!(result.is_ok()); | |
| 175 | 186 | assert!(registry.is_empty()); | |
| 176 | 187 | } | |
| 188 | + | ||
| 189 | + | // ── Full plugin lifecycle (Item 32) ── | |
| 190 | + | ||
| 191 | + | fn sample_info(rate: u32, channels: u16) -> RhaiSampleInfo { | |
| 192 | + | RhaiSampleInfo { | |
| 193 | + | hash: "abc123def456".to_string(), | |
| 194 | + | name: "kick_drum.wav".to_string(), | |
| 195 | + | extension: "wav".to_string(), | |
| 196 | + | sample_rate: rate, | |
| 197 | + | bit_depth: 16, | |
| 198 | + | channels, | |
| 199 | + | duration: 0.5, | |
| 200 | + | file_size: 44100, | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | fn export_ctx() -> RhaiExportContext { | |
| 205 | + | RhaiExportContext { | |
| 206 | + | device_name: "Test Device".to_string(), | |
| 207 | + | destination: "/tmp/export".to_string(), | |
| 208 | + | filename: "kick_drum".to_string(), | |
| 209 | + | extension: "wav".to_string(), | |
| 210 | + | index: 3, | |
| 211 | + | total: 20, | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | /// Create a temp plugin directory with manifest and hook scripts. | |
| 216 | + | /// Returns the temp dir (must be kept alive for the duration of the test). | |
| 217 | + | fn create_test_plugin( | |
| 218 | + | dir: &std::path::Path, | |
| 219 | + | name: &str, | |
| 220 | + | validate_script: &str, | |
| 221 | + | transform_script: &str, | |
| 222 | + | ) { | |
| 223 | + | let plugin_dir = dir.join(name); | |
| 224 | + | std::fs::create_dir_all(plugin_dir.join("hooks")).unwrap(); | |
| 225 | + | ||
| 226 | + | std::fs::write( | |
| 227 | + | plugin_dir.join("manifest.toml"), | |
| 228 | + | format!( | |
| 229 | + | r#" | |
| 230 | + | [device] | |
| 231 | + | name = "{name}" | |
| 232 | + | manufacturer = "Test Co" | |
| 233 | + | version = "1.0" | |
| 234 | + | ||
| 235 | + | [audio] | |
| 236 | + | formats = ["wav"] | |
| 237 | + | sample_rates = [44100, 48000] | |
| 238 | + | bit_depths = [16, 24] | |
| 239 | + | channels = "mono" | |
| 240 | + | ||
| 241 | + | [hooks] | |
| 242 | + | validate_sample = "hooks/validate.rhai" | |
| 243 | + | transform_filename = "hooks/transform.rhai" | |
| 244 | + | "# | |
| 245 | + | ), | |
| 246 | + | ) | |
| 247 | + | .unwrap(); | |
| 248 | + | ||
| 249 | + | std::fs::write( | |
| 250 | + | plugin_dir.join("hooks").join("validate.rhai"), | |
| 251 | + | validate_script, | |
| 252 | + | ) | |
| 253 | + | .unwrap(); | |
| 254 | + | ||
| 255 | + | std::fs::write( | |
| 256 | + | plugin_dir.join("hooks").join("transform.rhai"), | |
| 257 | + | transform_script, | |
| 258 | + | ) | |
| 259 | + | .unwrap(); | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | #[test] | |
| 263 | + | fn full_plugin_lifecycle() { | |
| 264 | + | // 1. Create temp dir with a plugin: manifest + two hook scripts | |
| 265 | + | let dir = tempfile::tempdir().unwrap(); | |
| 266 | + | create_test_plugin( | |
| 267 | + | dir.path(), | |
| 268 | + | "Lifecycle Sampler", | |
| 269 | + | // validate: accept mono 44100 or 48000 | |
| 270 | + | "info.channels == 1 && (info.sample_rate == 44100 || info.sample_rate == 48000)", | |
| 271 | + | // transform: uppercase + zero-padded index | |
| 272 | + | r#"to_upper(name) + "_" + format_index(ctx.index, 3)"#, | |
| 273 | + | ); | |
| 274 | + | ||
| 275 | + | // 2. Discover plugins from temp dir | |
| 276 | + | let discovered = loader::discover_plugins(dir.path()); | |
| 277 | + | assert_eq!(discovered.len(), 1); | |
| 278 | + | assert_eq!(discovered[0].manifest.device.name, "Lifecycle Sampler"); | |
| 279 | + | ||
| 280 | + | // 3. Load into registry (manifest -> profile + compile hooks) | |
| 281 | + | let mut registry = PluginRegistry::new(); | |
| 282 | + | load_user_plugins(&mut registry, dir.path()).unwrap(); | |
| 283 | + | ||
| 284 | + | assert_eq!(registry.len(), 1); | |
| 285 | + | let plugin = registry.get("Lifecycle Sampler").unwrap(); | |
| 286 | + | assert!(!plugin.bundled); | |
| 287 | + | assert_eq!(plugin.profile.name, "Lifecycle Sampler"); | |
| 288 | + | assert!(plugin.hooks.validate_sample.is_some()); | |
| 289 | + | assert!(plugin.hooks.transform_filename.is_some()); | |
| 290 | + | assert!(plugin.hooks.pre_export.is_none()); | |
| 291 | + | assert!(plugin.hooks.post_export.is_none()); | |
| 292 | + | ||
| 293 | + | // 4. Run validate_sample -- mono 44100 should pass | |
| 294 | + | let valid = run_validate_sample( | |
| 295 | + | registry.engine(), | |
| 296 | + | plugin.hooks.validate_sample.as_ref().unwrap(), | |
| 297 | + | sample_info(44100, 1), | |
| 298 | + | ).unwrap(); | |
| 299 | + | assert!(valid); | |
| 300 | + | ||
| 301 | + | // 5. Run validate_sample -- stereo should fail | |
| 302 | + | let invalid = run_validate_sample( | |
| 303 | + | registry.engine(), | |
| 304 | + | plugin.hooks.validate_sample.as_ref().unwrap(), | |
| 305 | + | sample_info(44100, 2), | |
| 306 | + | ).unwrap(); | |
| 307 | + | assert!(!invalid); | |
| 308 | + | ||
| 309 | + | // 6. Run transform_filename | |
| 310 | + | let transformed = run_transform_filename( | |
| 311 | + | registry.engine(), | |
| 312 | + | plugin.hooks.transform_filename.as_ref().unwrap(), | |
| 313 | + | "kick_drum".to_string(), | |
| 314 | + | export_ctx(), | |
| 315 | + | ).unwrap(); | |
| 316 | + | assert_eq!(transformed, "KICK_DRUM_003"); | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | #[test] | |
| 320 | + | fn validate_sample_returns_true_for_matching_criteria() { | |
| 321 | + | let dir = tempfile::tempdir().unwrap(); | |
| 322 | + | let plugin_dir = dir.path().join("validator"); | |
| 323 | + | std::fs::create_dir_all(plugin_dir.join("hooks")).unwrap(); | |
| 324 | + | ||
| 325 | + | std::fs::write( | |
| 326 | + | plugin_dir.join("manifest.toml"), | |
| 327 | + | r#" | |
| 328 | + | [device] | |
| 329 | + | name = "Validator" | |
| 330 | + | manufacturer = "Test" | |
| 331 | + | version = "1.0" | |
| 332 | + | ||
| 333 | + | [audio] | |
| 334 | + | formats = ["wav"] | |
| 335 | + | sample_rates = [44100] | |
| 336 | + | bit_depths = [16] | |
| 337 | + | channels = "both" | |
| 338 | + | ||
| 339 | + | [hooks] | |
| 340 | + | validate_sample = "hooks/validate.rhai" | |
| 341 | + | "#, | |
| 342 | + | ).unwrap(); | |
| 343 | + | ||
| 344 | + | // Script checks: sample rate is 44100, bit depth is 16, file size under 1MB | |
| 345 | + | std::fs::write( | |
| 346 | + | plugin_dir.join("hooks").join("validate.rhai"), | |
| 347 | + | "info.sample_rate == 44100 && info.bit_depth == 16 && info.file_size < 1048576", | |
| 348 | + | ).unwrap(); | |
| 349 | + | ||
| 350 | + | let mut registry = PluginRegistry::new(); | |
| 351 | + | load_user_plugins(&mut registry, dir.path()).unwrap(); | |
| 352 | + | let plugin = registry.get("Validator").unwrap(); | |
| 353 | + | let ast = plugin.hooks.validate_sample.as_ref().unwrap(); | |
| 354 | + | ||
| 355 | + | // Matching sample -- should return true | |
| 356 | + | let result = run_validate_sample( | |
| 357 | + | registry.engine(), | |
| 358 | + | ast, | |
| 359 | + | sample_info(44100, 1), | |
| 360 | + | ).unwrap(); | |
| 361 | + | assert!(result, "expected true for matching sample"); | |
| 362 | + | ||
| 363 | + | // Wrong sample rate -- should return false | |
| 364 | + | let result = run_validate_sample( | |
| 365 | + | registry.engine(), | |
| 366 | + | ast, | |
| 367 | + | sample_info(48000, 1), | |
| 368 | + | ).unwrap(); | |
| 369 | + | assert!(!result, "expected false for non-matching sample rate"); | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | #[test] | |
| 373 | + | fn transform_filename_renames_with_context() { | |
| 374 | + | let dir = tempfile::tempdir().unwrap(); | |
| 375 | + | let plugin_dir = dir.path().join("renamer"); | |
| 376 | + | std::fs::create_dir_all(plugin_dir.join("hooks")).unwrap(); | |
| 377 | + | ||
| 378 | + | std::fs::write( | |
| 379 | + | plugin_dir.join("manifest.toml"), | |
| 380 | + | r#" | |
| 381 | + | [device] | |
| 382 | + | name = "Renamer" | |
| 383 | + | manufacturer = "Test" | |
| 384 | + | version = "1.0" | |
| 385 | + | ||
| 386 | + | [audio] | |
| 387 | + | formats = ["wav"] | |
| 388 | + | sample_rates = [44100] | |
| 389 | + | bit_depths = [16] | |
| 390 | + | channels = "both" | |
| 391 | + | ||
| 392 | + | [hooks] | |
| 393 | + | transform_filename = "hooks/transform.rhai" | |
| 394 | + | "#, | |
| 395 | + | ).unwrap(); | |
| 396 | + | ||
| 397 | + | // Script: lowercase name + device abbreviation + padded index | |
| 398 | + | std::fs::write( | |
| 399 | + | plugin_dir.join("hooks").join("transform.rhai"), | |
| 400 | + | r#"let stem = to_lower(name); stem + "_" + truncate(ctx.device_name, 3) + format_index(ctx.index, 4)"#, | |
| 401 | + | ).unwrap(); | |
| 402 | + | ||
| 403 | + | let mut registry = PluginRegistry::new(); | |
| 404 | + | load_user_plugins(&mut registry, dir.path()).unwrap(); | |
| 405 | + | let plugin = registry.get("Renamer").unwrap(); | |
| 406 | + | let ast = plugin.hooks.transform_filename.as_ref().unwrap(); | |
| 407 | + | ||
| 408 | + | let result = run_transform_filename( | |
| 409 | + | registry.engine(), | |
| 410 | + | ast, | |
| 411 | + | "KICK_DRUM".to_string(), | |
| 412 | + | export_ctx(), | |
| 413 | + | ).unwrap(); | |
| 414 | + | assert_eq!(result, "kick_drum_Tes0003"); | |
| 415 | + | ||
| 416 | + | // Different name and index | |
| 417 | + | let mut ctx2 = export_ctx(); | |
| 418 | + | ctx2.index = 0; | |
| 419 | + | let result = run_transform_filename( | |
| 420 | + | registry.engine(), | |
| 421 | + | ast, | |
| 422 | + | "HiHat".to_string(), | |
| 423 | + | ctx2, | |
| 424 | + | ).unwrap(); | |
| 425 | + | assert_eq!(result, "hihat_Tes0000"); | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | #[test] | |
| 429 | + | fn lifecycle_with_all_four_hooks() { | |
| 430 | + | let dir = tempfile::tempdir().unwrap(); | |
| 431 | + | let plugin_dir = dir.path().join("full-hooks"); | |
| 432 | + | std::fs::create_dir_all(plugin_dir.join("hooks")).unwrap(); | |
| 433 | + | ||
| 434 | + | std::fs::write( | |
| 435 | + | plugin_dir.join("manifest.toml"), | |
| 436 | + | r#" | |
| 437 | + | [device] | |
| 438 | + | name = "Full Hooks Device" | |
| 439 | + | manufacturer = "Test" | |
| 440 | + | version = "1.0" | |
| 441 | + | ||
| 442 | + | [audio] | |
| 443 | + | formats = ["wav", "aiff"] | |
| 444 | + | sample_rates = [44100, 48000] | |
| 445 | + | bit_depths = [16, 24] | |
| 446 | + | channels = "both" | |
| 447 | + | ||
| 448 | + | [naming] | |
| 449 | + | case = "upper" | |
| 450 | + | separator = "_" | |
| 451 | + | max_length = 16 | |
| 452 | + | strip_special = true | |
| 453 | + | ||
| 454 | + | [limits] | |
| 455 | + | max_file_size_bytes = 134217728 | |
| 456 | + | ||
| 457 | + | [hooks] | |
| 458 | + | validate_sample = "hooks/validate.rhai" | |
| 459 | + | transform_filename = "hooks/transform.rhai" | |
| 460 | + | pre_export = "hooks/pre_export.rhai" | |
| 461 | + | post_export = "hooks/post_export.rhai" | |
| 462 | + | "#, | |
| 463 | + | ).unwrap(); | |
| 464 | + | ||
| 465 | + | std::fs::write( | |
| 466 | + | plugin_dir.join("hooks").join("validate.rhai"), | |
| 467 | + | "info.duration < 30.0", | |
| 468 | + | ).unwrap(); | |
| 469 | + | std::fs::write( | |
| 470 | + | plugin_dir.join("hooks").join("transform.rhai"), | |
| 471 | + | r#"to_upper(name)"#, | |
| 472 | + | ).unwrap(); | |
| 473 | + | std::fs::write( | |
| 474 | + | plugin_dir.join("hooks").join("pre_export.rhai"), | |
| 475 | + | r#"let x = ctx.total; x"#, | |
| 476 | + | ).unwrap(); | |
| 477 | + | std::fs::write( | |
| 478 | + | plugin_dir.join("hooks").join("post_export.rhai"), | |
| 479 | + | r#"let done = ctx.index == ctx.total - 1; done"#, | |
| 480 | + | ).unwrap(); | |
| 481 | + | ||
| 482 | + | let mut registry = PluginRegistry::new(); | |
| 483 | + | load_user_plugins(&mut registry, dir.path()).unwrap(); | |
| 484 | + | let plugin = registry.get("Full Hooks Device").unwrap(); | |
| 485 | + | ||
| 486 | + | // All four hooks should be compiled | |
| 487 | + | assert!(plugin.hooks.validate_sample.is_some()); | |
| 488 | + | assert!(plugin.hooks.transform_filename.is_some()); | |
| 489 | + | assert!(plugin.hooks.pre_export.is_some()); | |
| 490 | + | assert!(plugin.hooks.post_export.is_some()); | |
| 491 | + | assert!(!plugin.hooks.is_empty()); | |
| 492 | + | ||
| 493 | + | // Profile should have naming and limits from manifest | |
| 494 | + | assert!(plugin.profile.naming.is_some()); | |
| 495 | + | assert!(plugin.profile.limits.is_some()); | |
| 496 | + | assert_eq!(plugin.profile.audio.formats.len(), 2); | |
| 497 | + | assert_eq!(plugin.profile.audio.sample_rates, vec![44100, 48000]); | |
| 498 | + | ||
| 499 | + | // Run validate: 0.5s duration passes, but we can test it | |
| 500 | + | let valid = run_validate_sample( | |
| 501 | + | registry.engine(), | |
| 502 | + | plugin.hooks.validate_sample.as_ref().unwrap(), | |
| 503 | + | sample_info(44100, 1), | |
| 504 | + | ).unwrap(); | |
| 505 | + | assert!(valid); | |
| 506 | + | ||
| 507 | + | // Run transform | |
| 508 | + | let name = run_transform_filename( | |
| 509 | + | registry.engine(), | |
| 510 | + | plugin.hooks.transform_filename.as_ref().unwrap(), | |
| 511 | + | "kick".to_string(), | |
| 512 | + | export_ctx(), | |
| 513 | + | ).unwrap(); | |
| 514 | + | assert_eq!(name, "KICK"); | |
| 515 | + | ||
| 516 | + | // Run pre_export (side-effect hook, should not error) | |
| 517 | + | hooks::run_pre_export( | |
| 518 | + | registry.engine(), | |
| 519 | + | plugin.hooks.pre_export.as_ref().unwrap(), | |
| 520 | + | export_ctx(), | |
| 521 | + | ).unwrap(); | |
| 522 | + | ||
| 523 | + | // Run post_export (side-effect hook, should not error) | |
| 524 | + | hooks::run_post_export( | |
| 525 | + | registry.engine(), | |
| 526 | + | plugin.hooks.post_export.as_ref().unwrap(), | |
| 527 | + | export_ctx(), | |
| 528 | + | ).unwrap(); | |
| 529 | + | } | |
| 177 | 530 | } |
| @@ -765,4 +765,204 @@ mod tests { | |||
| 765 | 765 | // Second call: already cloud_only=1, query only selects cloud_only=0 | |
| 766 | 766 | assert_eq!(mark_cloud_only_samples(conn, tmp.path()).unwrap(), 0); | |
| 767 | 767 | } | |
| 768 | + | ||
| 769 | + | // ── Integration: multi-table snapshot ── | |
| 770 | + | ||
| 771 | + | #[test] | |
| 772 | + | fn snapshot_captures_tags_collections_and_members() { | |
| 773 | + | let db = setup_test_db(); | |
| 774 | + | let conn = db.conn(); | |
| 775 | + | ||
| 776 | + | // Suppress triggers during data setup | |
| 777 | + | set_sync_state(conn, "applying_remote", "1").unwrap(); | |
| 778 | + | ||
| 779 | + | insert_sample(conn, "s1", "kick.wav", "wav"); | |
| 780 | + | insert_sample(conn, "s2", "snare.wav", "wav"); | |
| 781 | + | ||
| 782 | + | // Tags | |
| 783 | + | conn.execute( | |
| 784 | + | "INSERT INTO tags (sample_hash, tag) VALUES ('s1', 'drums')", | |
| 785 | + | [], | |
| 786 | + | ).unwrap(); | |
| 787 | + | conn.execute( | |
| 788 | + | "INSERT INTO tags (sample_hash, tag) VALUES ('s2', 'perc')", | |
| 789 | + | [], | |
| 790 | + | ).unwrap(); | |
| 791 | + | ||
| 792 | + | // Collection | |
| 793 | + | let now = chrono::Utc::now().timestamp(); | |
| 794 | + | conn.execute( | |
| 795 | + | "INSERT INTO collections (name, description, created_at) VALUES ('Kit', NULL, ?1)", | |
| 796 | + | [now], | |
| 797 | + | ).unwrap(); | |
| 798 | + | let coll_id = conn.last_insert_rowid(); | |
| 799 | + | ||
| 800 | + | // Collection members | |
| 801 | + | conn.execute( | |
| 802 | + | "INSERT INTO collection_members (collection_id, sample_hash, added_at) VALUES (?1, 's1', ?2)", | |
| 803 | + | rusqlite::params![coll_id, now], | |
| 804 | + | ).unwrap(); | |
| 805 | + | ||
| 806 | + | set_sync_state(conn, "applying_remote", "0").unwrap(); | |
| 807 | + | clear_changelog(conn); | |
| 808 | + | ||
| 809 | + | let total = create_initial_snapshot(conn).unwrap(); | |
| 810 | + | // 2 samples + 2 tags + 1 collection + 1 collection_member = 6 | |
| 811 | + | assert_eq!(total, 6); | |
| 812 | + | ||
| 813 | + | assert_eq!(changelog_count(conn, Some("samples"), Some("INSERT")), 2); | |
| 814 | + | assert_eq!(changelog_count(conn, Some("tags"), Some("INSERT")), 2); | |
| 815 | + | assert_eq!(changelog_count(conn, Some("collections"), Some("INSERT")), 1); | |
| 816 | + | assert_eq!(changelog_count(conn, Some("collection_members"), Some("INSERT")), 1); | |
| 817 | + | } | |
| 818 | + | ||
| 819 | + | #[test] | |
| 820 | + | fn snapshot_changelog_entries_contain_valid_json() { | |
| 821 | + | let db = setup_test_db(); | |
| 822 | + | let conn = db.conn(); | |
| 823 | + | ||
| 824 | + | set_sync_state(conn, "applying_remote", "1").unwrap(); | |
| 825 | + | insert_sample(conn, "json_test", "pad.wav", "wav"); | |
| 826 | + | set_sync_state(conn, "applying_remote", "0").unwrap(); | |
| 827 | + | clear_changelog(conn); | |
| 828 | + | ||
| 829 | + | create_initial_snapshot(conn).unwrap(); | |
| 830 | + | ||
| 831 | + | let data: String = conn.query_row( | |
| 832 | + | "SELECT data FROM sync_changelog WHERE table_name = 'samples' AND row_id = 'json_test'", | |
| 833 | + | [], | |
| 834 | + | |row| row.get(0), | |
| 835 | + | ).unwrap(); | |
| 836 | + | ||
| 837 | + | let parsed: serde_json::Value = serde_json::from_str(&data).unwrap(); | |
| 838 | + | assert_eq!(parsed["hash"], "json_test"); | |
| 839 | + | assert_eq!(parsed["original_name"], "pad.wav"); | |
| 840 | + | assert_eq!(parsed["file_extension"], "wav"); | |
| 841 | + | assert!(parsed.get("file_size").is_some()); | |
| 842 | + | assert!(parsed.get("import_date").is_some()); | |
| 843 | + | } | |
| 844 | + | ||
| 845 | + | #[test] | |
| 846 | + | fn snapshot_after_adding_data_does_not_duplicate() { | |
| 847 | + | let db = setup_test_db(); | |
| 848 | + | let conn = db.conn(); | |
| 849 | + | ||
| 850 | + | set_sync_state(conn, "applying_remote", "1").unwrap(); | |
| 851 | + | insert_sample(conn, "first", "one.wav", "wav"); | |
| 852 | + | set_sync_state(conn, "applying_remote", "0").unwrap(); | |
| 853 | + | clear_changelog(conn); | |
| 854 | + | ||
| 855 | + | let first = create_initial_snapshot(conn).unwrap(); | |
| 856 | + | assert_eq!(first, 1); | |
| 857 | + | ||
| 858 | + | // Add more data after snapshot (these go through normal triggers) | |
| 859 | + | insert_sample(conn, "second", "two.wav", "wav"); | |
| 860 | + | ||
| 861 | + | // Second snapshot call should return 0 (flag already set) | |
| 862 | + | let second = create_initial_snapshot(conn).unwrap(); | |
| 863 | + | assert_eq!(second, 0); | |
| 864 | + | ||
| 865 | + | // Total changelog: 1 from snapshot + 1 from trigger | |
| 866 | + | assert_eq!(changelog_count(conn, Some("samples"), None), 2); | |
| 867 | + | } | |
| 868 | + | ||
| 869 | + | #[test] | |
| 870 | + | fn cleanup_preserves_recent_pushed_entries() { | |
| 871 | + | let db = setup_test_db(); | |
| 872 | + | let conn = db.conn(); | |
| 873 | + | clear_changelog(conn); | |
| 874 | + | ||
| 875 | + | // Entry pushed exactly 6 days ago (within 7-day window) | |
| 876 | + | conn.execute( | |
| 877 | + | "INSERT INTO sync_changelog (table_name, op, row_id, timestamp, pushed) \ | |
| 878 | + | VALUES ('samples', 'INSERT', 'six_days', datetime('now', '-6 days'), 1)", | |
| 879 | + | [], | |
| 880 | + | ).unwrap(); | |
| 881 | + | ||
| 882 | + | // Entry pushed exactly 8 days ago (outside 7-day window) | |
| 883 | + | conn.execute( | |
| 884 | + | "INSERT INTO sync_changelog (table_name, op, row_id, timestamp, pushed) \ | |
| 885 | + | VALUES ('samples', 'INSERT', 'eight_days', datetime('now', '-8 days'), 1)", | |
| 886 | + | [], | |
| 887 | + | ).unwrap(); | |
| 888 | + | ||
| 889 | + | let deleted = cleanup_changelog(conn).unwrap(); | |
| 890 | + | assert_eq!(deleted, 1); | |
| 891 | + | ||
| 892 | + | // six_days should remain, eight_days should be deleted | |
| 893 | + | let remaining_id: String = conn.query_row( | |
| 894 | + | "SELECT row_id FROM sync_changelog", | |
| 895 | + | [], | |
| 896 | + | |r| r.get(0), | |
| 897 | + | ).unwrap(); | |
| 898 | + | assert_eq!(remaining_id, "six_days"); | |
| 899 | + | } | |
| 900 | + | ||
| 901 | + | #[test] | |
| 902 | + | fn enforce_retention_at_exact_cap_is_noop() { | |
| 903 | + | let db = setup_test_db(); | |
| 904 | + | let conn = db.conn(); | |
| 905 | + | clear_changelog(conn); | |
| 906 | + | ||
| 907 | + | for i in 0..MAX_CHANGELOG_ENTRIES { | |
| 908 | + | conn.execute( | |
| 909 | + | "INSERT INTO sync_changelog (table_name, op, row_id, data) \ | |
| 910 | + | VALUES ('samples', 'UPDATE', ?1, '{}')", | |
| 911 | + | [format!("row-{}", i)], | |
| 912 | + | ).unwrap(); | |
| 913 | + | } | |
| 914 | + | ||
| 915 | + | let deleted = enforce_changelog_retention(conn).unwrap(); | |
| 916 | + | assert_eq!(deleted, 0); | |
| 917 | + | ||
| 918 | + | let count: i64 = conn.query_row( | |
| 919 | + | "SELECT COUNT(*) FROM sync_changelog", [], |r| r.get(0), | |
| 920 | + | ).unwrap(); | |
| 921 | + | assert_eq!(count, MAX_CHANGELOG_ENTRIES); | |
| 922 | + | } | |
| 923 | + | ||
| 924 | + | #[test] | |
| 925 | + | fn cleanup_and_retention_combined() { | |
| 926 | + | let db = setup_test_db(); | |
| 927 | + | let conn = db.conn(); | |
| 928 | + | clear_changelog(conn); | |
| 929 | + | ||
| 930 | + | // Insert many old pushed entries (should be cleaned by cleanup) | |
| 931 | + | for i in 0..100 { | |
| 932 | + | conn.execute( | |
| 933 | + | "INSERT INTO sync_changelog (table_name, op, row_id, timestamp, pushed) \ | |
| 934 | + | VALUES ('samples', 'UPDATE', ?1, datetime('now', '-14 days'), 1)", | |
| 935 | + | [format!("old-{}", i)], | |
| 936 | + | ).unwrap(); | |
| 937 | + | } | |
| 938 | + | ||
| 939 | + | // Insert entries at the retention cap to test combined behavior | |
| 940 | + | for i in 0..MAX_CHANGELOG_ENTRIES { | |
| 941 | + | conn.execute( | |
| 942 | + | "INSERT INTO sync_changelog (table_name, op, row_id, data) \ | |
| 943 | + | VALUES ('samples', 'INSERT', ?1, '{}')", | |
| 944 | + | [format!("new-{}", i)], | |
| 945 | + | ).unwrap(); | |
| 946 | + | } | |
| 947 | + | ||
| 948 | + | // cleanup removes old pushed entries | |
| 949 | + | let cleaned = cleanup_changelog(conn).unwrap(); | |
| 950 | + | assert_eq!(cleaned, 100); | |
| 951 | + | ||
| 952 | + | // retention should be a noop now (exactly MAX_CHANGELOG_ENTRIES remain) | |
| 953 | + | let retained = enforce_changelog_retention(conn).unwrap(); | |
| 954 | + | assert_eq!(retained, 0); | |
| 955 | + | } | |
| 956 | + | ||
| 957 | + | #[test] | |
| 958 | + | fn sync_state_get_and_set_roundtrip() { | |
| 959 | + | let db = setup_test_db(); | |
| 960 | + | let conn = db.conn(); | |
| 961 | + | ||
| 962 | + | set_sync_state(conn, "auto_sync_enabled", "1").unwrap(); | |
| 963 | + | assert_eq!(get_sync_state(conn, "auto_sync_enabled").unwrap(), "1"); | |
| 964 | + | ||
| 965 | + | set_sync_state(conn, "auto_sync_enabled", "0").unwrap(); | |
| 966 | + | assert_eq!(get_sync_state(conn, "auto_sync_enabled").unwrap(), "0"); | |
| 967 | + | } | |
| 768 | 968 | } |