Skip to main content

max / audiofiles

Rename unsafe_mode to loose_files throughout User-facing copy and identifiers all use "Loose-files mode" / `loose_files`. The old "Unsafe mode" name was intentionally discouraging — keeping it that way, but the new name is less alarmist while still steering users toward the safe default. Schema change lives in MIGRATION_017 (sync-exclusion trigger WHEN clauses referencing the new key literal). The runtime row-copy from the legacy `unsafe_mode` config row to `loose_files` happens once per vault on the open path in `main.rs`, alongside a new `Backend::delete_config` method that retires the old key. MIGRATION_016's body is left referencing the old literal — it's historical and only runs once. Also adds `relocate_missing_loose_files` (store + Backend trait + impl): walks a user-chosen directory, hash-verifies basename candidates, and repoints `source_path` for any matches. Surfaced from the integrity warning as "Locate…" so users can recover after moving their source tree instead of being stuck with Purge or Dismiss. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-21 00:08 UTC
Commit: 020089415ae3b012dc00c8f945319c9cc041bd7c
Parent: d8089fb
22 files changed, +438 insertions, -235 deletions
@@ -376,15 +376,29 @@ impl AudioFilesApp {
376 376 self.screen = AppScreen::Browser;
377 377 self.sync_vault_list_to_browser();
378 378 self.sync_license_to_browser();
379 - // Read unsafe_mode from the vault's DB and run integrity check
379 + // Read loose_files from the vault's DB and run integrity check.
380 380 if let Some(ref mut browser) = self.browser {
381 - browser.settings.is_unsafe_mode = browser
382 - .backend
383 - .get_config("unsafe_mode")
384 - .ok()
385 - .flatten()
386 - .is_some_and(|v| v == "1");
387 - browser.check_unsafe_integrity();
381 + // Runtime half of the unsafe_mode -> loose_files rename. The
382 + // schema-only half (sync-trigger rewrite) lives in MIGRATION_017.
383 + // We copy the legacy row here, on the vault-open path, so it
384 + // runs exactly once per vault DB. Idempotent: once `loose_files`
385 + // is set, this branch never fires again. The retired
386 + // `unsafe_mode` row is deleted via `delete_config`. Safe to
387 + // remove this block once every active vault has been opened at
388 + // least once after this release.
389 + let loose = match browser.backend.get_config("loose_files") {
390 + Ok(Some(v)) => Some(v),
391 + _ => match browser.backend.get_config("unsafe_mode") {
392 + Ok(Some(v)) => {
393 + let _ = browser.backend.set_config("loose_files", &v);
394 + let _ = browser.backend.delete_config("unsafe_mode");
395 + Some(v)
396 + }
397 + _ => None,
398 + },
399 + };
400 + browser.settings.is_loose_files = loose.is_some_and(|v| v == "1");
401 + browser.check_loose_files_integrity();
388 402 }
389 403 }
390 404 }
@@ -523,14 +537,14 @@ impl AudioFilesApp {
523 537 self.switch_vault(path);
524 538 return;
525 539 }
526 - VaultAction::CreateVault { name, path, unsafe_mode } => {
540 + VaultAction::CreateVault { name, path, loose_files } => {
527 541 let switch_path = path.clone();
528 542 if self.with_vault_registry(|reg| vault::create_vault(reg, &name, &path)) {
529 543 self.switch_vault(switch_path);
530 - if unsafe_mode {
544 + if loose_files {
531 545 if let Some(ref mut browser) = self.browser {
532 - let _ = browser.backend.set_config("unsafe_mode", "1");
533 - browser.settings.is_unsafe_mode = true;
546 + let _ = browser.backend.set_config("loose_files", "1");
547 + browser.settings.is_loose_files = true;
534 548 }
535 549 }
536 550 return;
@@ -625,12 +625,20 @@ impl Backend for DirectBackend {
625 625
626 626 fn check_vault_integrity(&self) -> BackendResult<(usize, usize)> {
627 627 let db = self.db.lock();
628 - Ok(audiofiles_core::store::check_unsafe_integrity(&db)?)
628 + Ok(audiofiles_core::store::check_loose_files_integrity(&db)?)
629 629 }
630 630
631 - fn purge_missing_unsafe(&self) -> BackendResult<usize> {
631 + fn purge_missing_loose_files(&self) -> BackendResult<usize> {
632 632 let db = self.db.lock();
633 - Ok(audiofiles_core::store::purge_missing_unsafe(&db)?)
633 + Ok(audiofiles_core::store::purge_missing_loose_files(&db)?)
634 + }
635 +
636 + fn relocate_missing_loose_files(
637 + &self,
638 + search_root: &std::path::Path,
639 + ) -> BackendResult<(usize, usize)> {
640 + let db = self.db.lock();
641 + Ok(audiofiles_core::store::relocate_missing_loose_files(&db, search_root)?)
634 642 }
635 643
636 644 // --- Export ---
@@ -689,6 +697,14 @@ impl Backend for DirectBackend {
689 697 Ok(())
690 698 }
691 699
700 + fn delete_config(&self, key: &str) -> BackendResult<()> {
701 + let db = self.db.lock();
702 + db.conn()
703 + .execute("DELETE FROM user_config WHERE key = ?1", [key])
704 + .map_err(audiofiles_core::error::CoreError::Db)?;
705 + Ok(())
706 + }
707 +
692 708 fn set_vfs_sync_files(&self, id: VfsId, enabled: bool) -> BackendResult<()> {
693 709 let db = self.db.lock();
694 710 vfs::set_vfs_sync_files(&db, id, enabled)?;
@@ -340,17 +340,26 @@ pub trait Backend: Send + Sync {
340 340 /// Remove samples no longer referenced by any VFS node. Returns count removed.
341 341 fn remove_orphaned_samples(&self) -> BackendResult<usize>;
342 342
343 - /// Look up the source_path for an unsafe-mode sample. Returns None for normal samples.
343 + /// Look up the source_path for an loose-files mode sample. Returns None for normal samples.
344 344 fn sample_source_path(&self, hash: &str) -> BackendResult<Option<String>>;
345 345
346 - /// Relocate an unsafe-mode sample to a new path (verifies hash match).
346 + /// Relocate an loose-files mode sample to a new path (verifies hash match).
347 347 fn relocate_sample(&self, hash: &str, new_path: &Path) -> BackendResult<()>;
348 348
349 - /// Check integrity of unsafe-mode samples. Returns (valid, missing).
349 + /// Check integrity of loose-files mode samples. Returns (valid, missing).
350 350 fn check_vault_integrity(&self) -> BackendResult<(usize, usize)>;
351 351
352 - /// Delete all unsafe-mode samples whose source files are missing. Returns count purged.
353 - fn purge_missing_unsafe(&self) -> BackendResult<usize>;
352 + /// Delete all loose-files mode samples whose source files are missing. Returns count purged.
353 + fn purge_missing_loose_files(&self) -> BackendResult<usize>;
354 +
355 + /// Search `search_root` for files matching missing loose-files samples by hash;
356 + /// update their source_path to the new location. Returns
357 + /// `(relocated, still_missing)` so the caller can decide whether to prompt
358 + /// again with a different directory.
359 + fn relocate_missing_loose_files(
360 + &self,
361 + search_root: &std::path::Path,
362 + ) -> BackendResult<(usize, usize)>;
354 363
355 364 // --- Export ---
356 365
@@ -377,6 +386,9 @@ pub trait Backend: Send + Sync {
377 386 /// Set a user config value.
378 387 fn set_config(&self, key: &str, value: &str) -> BackendResult<()>;
379 388
389 + /// Delete a user config value by key. No-op if the key does not exist.
390 + fn delete_config(&self, key: &str) -> BackendResult<()>;
391 +
380 392 /// Set whether a VFS should sync audio file blobs to cloud.
381 393 fn set_vfs_sync_files(&self, id: VfsId, enabled: bool) -> BackendResult<()>;
382 394
@@ -109,8 +109,8 @@ pub fn draw_browser(
109 109 if state.dir_rename_target.is_some() {
110 110 overlays::draw_dir_rename_modal(ctx, state);
111 111 }
112 - if state.show_unsafe_warning {
113 - overlays::draw_unsafe_warning(ctx, state);
112 + if state.show_loose_files_warning {
113 + overlays::draw_loose_files_warning(ctx, state);
114 114 }
115 115
116 116 // Settings window
@@ -186,10 +186,10 @@ fn import_single_file(
186 186 parent_id: Option<NodeId>,
187 187 store: &SampleStore,
188 188 db: &Database,
189 - unsafe_mode: bool,
189 + loose_files: bool,
190 190 ) -> Result<ImportFileResult, CoreError> {
191 - let hash = if unsafe_mode {
192 - store.import_unsafe(path, db)?
191 + let hash = if loose_files {
192 + store.import_loose_files(path, db)?
193 193 } else {
194 194 store.import(path, db)?
195 195 };
@@ -225,7 +225,7 @@ struct ImportContext<'a> {
225 225 errors: &'a mut usize,
226 226 duplicates: &'a mut usize,
227 227 imported: &'a mut Vec<(String, String)>,
228 - unsafe_mode: bool,
228 + loose_files: bool,
229 229 }
230 230
231 231 impl ImportContext<'_> {
@@ -251,7 +251,7 @@ impl ImportContext<'_> {
251 251 let name = audiofiles_core::util::get_filename(path, "unknown");
252 252 self.send_progress(name);
253 253
254 - match import_single_file(path, vfs_id, parent_id, self.store, self.db, self.unsafe_mode) {
254 + match import_single_file(path, vfs_id, parent_id, self.store, self.db, self.loose_files) {
255 255 Ok(ImportFileResult::Imported(hash, ext)) => {
256 256 self.imported.push((hash, ext));
257 257 *self.completed += 1;
@@ -545,11 +545,11 @@ fn worker_loop(
545 545
546 546 let _ = event_tx.send(ImportEvent::WalkComplete { total, total_bytes });
547 547
548 - // Check if unsafe mode is enabled for this vault
549 - let unsafe_mode = db
548 + // Check if loose-files mode is enabled for this vault
549 + let loose_files = db
550 550 .conn()
551 551 .query_row(
552 - "SELECT value FROM user_config WHERE key = 'unsafe_mode'",
552 + "SELECT value FROM user_config WHERE key = 'loose_files'",
553 553 [],
554 554 |row| row.get::<_, String>(0),
555 555 )
@@ -572,7 +572,7 @@ fn worker_loop(
572 572 errors: &mut errors,
573 573 duplicates: &mut duplicates,
574 574 imported: &mut imported,
575 - unsafe_mode,
575 + loose_files,
576 576 };
577 577
578 578 let (cancelled, folders) = if flat {
@@ -271,14 +271,14 @@ impl BrowserState {
271 271
272 272 self.import_file_errors.clear();
273 273 self.analysis_errors.clear();
274 - let unsafe_mode = self.settings.is_unsafe_mode;
274 + let loose_files = self.settings.is_loose_files;
275 275 self.import_mode = ImportMode::Importing {
276 276 total: 0,
277 277 completed: 0,
278 278 current_name: String::new(),
279 279 walking: true,
280 280 total_bytes: 0,
281 - unsafe_mode,
281 + loose_files,
282 282 };
283 283 }
284 284
@@ -296,9 +296,9 @@ impl BrowserState {
296 296 match event {
297 297 // --- Import events ---
298 298 BackendEvent::ImportWalkComplete { total, total_bytes } => {
299 - let unsafe_mode = matches!(
299 + let loose_files = matches!(
300 300 &self.import_mode,
301 - ImportMode::Importing { unsafe_mode: true, .. }
301 + ImportMode::Importing { loose_files: true, .. }
302 302 );
303 303 self.import_mode = ImportMode::Importing {
304 304 total,
@@ -306,7 +306,7 @@ impl BrowserState {
306 306 current_name: String::new(),
307 307 walking: false,
308 308 total_bytes,
309 - unsafe_mode,
309 + loose_files,
310 310 };
311 311 }
312 312 BackendEvent::ImportProgress {
@@ -314,8 +314,8 @@ impl BrowserState {
314 314 total,
315 315 current_name,
316 316 } => {
317 - let (prev_bytes, unsafe_mode) = match &self.import_mode {
318 - ImportMode::Importing { total_bytes, unsafe_mode, .. } => (*total_bytes, *unsafe_mode),
317 + let (prev_bytes, loose_files) = match &self.import_mode {
318 + ImportMode::Importing { total_bytes, loose_files, .. } => (*total_bytes, *loose_files),
319 319 _ => (0, false),
320 320 };
321 321 self.import_mode = ImportMode::Importing {
@@ -324,7 +324,7 @@ impl BrowserState {
324 324 current_name,
325 325 walking: false,
326 326 total_bytes: prev_bytes,
327 - unsafe_mode,
327 + loose_files,
328 328 };
329 329 }
330 330 BackendEvent::ImportFileError { path, error } => {
@@ -30,37 +30,37 @@ impl BrowserState {
30 30 self.selection.clear();
31 31 self.refresh_contents();
32 32 self.refresh_collections();
33 - self.check_unsafe_integrity();
33 + self.check_loose_files_integrity();
34 34 }
35 35
36 - /// Run an integrity check for unsafe-mode vaults. Updates `unsafe_missing_count`
36 + /// Run an integrity check for loose-files mode vaults. Updates `loose_files_missing_count`
37 37 /// and shows the warning overlay if any source files are missing.
38 - pub fn check_unsafe_integrity(&mut self) {
39 - if !self.settings.is_unsafe_mode {
40 - self.unsafe_missing_count = 0;
38 + pub fn check_loose_files_integrity(&mut self) {
39 + if !self.settings.is_loose_files {
40 + self.loose_files_missing_count = 0;
41 41 return;
42 42 }
43 43 match self.backend.check_vault_integrity() {
44 44 Ok((_valid, missing)) => {
45 - self.unsafe_missing_count = missing;
45 + self.loose_files_missing_count = missing;
46 46 if missing > 0 {
47 - self.show_unsafe_warning = true;
47 + self.show_loose_files_warning = true;
48 48 }
49 49 }
50 50 Err(e) => {
51 - warn!("Unsafe integrity check failed: {e}");
51 + warn!("Loose-files integrity check failed: {e}");
52 52 }
53 53 }
54 54 }
55 55
56 - /// Purge all unsafe-mode samples whose source files are missing.
56 + /// Purge all loose-files mode samples whose source files are missing.
57 57 /// Refreshes the VFS listing afterward.
58 - pub fn purge_missing_unsafe(&mut self) {
59 - match self.backend.purge_missing_unsafe() {
58 + pub fn purge_missing_loose_files(&mut self) {
59 + match self.backend.purge_missing_loose_files() {
60 60 Ok(purged) => {
61 61 self.status = format!("Purged {purged} missing samples");
62 - self.unsafe_missing_count = 0;
63 - self.show_unsafe_warning = false;
62 + self.loose_files_missing_count = 0;
63 + self.show_loose_files_warning = false;
64 64 self.refresh_contents();
65 65 }
66 66 Err(e) => {
@@ -69,9 +69,9 @@ impl BrowserState {
69 69 }
70 70 }
71 71
72 - /// Dismiss the unsafe integrity warning without purging.
73 - pub fn dismiss_unsafe_warning(&mut self) {
74 - self.show_unsafe_warning = false;
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 75 }
76 76
77 77 /// Dismiss the first-launch hint and persist the preference.
@@ -220,11 +220,11 @@ pub struct BrowserState {
220 220 // Settings (consolidated window)
221 221 pub settings: SettingsUiState,
222 222
223 - // Unsafe mode integrity
224 - /// Number of unsafe-mode samples with missing source files (0 = healthy or not unsafe).
225 - pub unsafe_missing_count: usize,
223 + // Loose-files mode integrity
224 + /// Number of loose-files mode samples with missing source files (0 = healthy or not loose-files).
225 + pub loose_files_missing_count: usize,
226 226 /// Whether to show the integrity warning overlay.
227 - pub show_unsafe_warning: bool,
227 + pub show_loose_files_warning: bool,
228 228 }
229 229
230 230 impl BrowserState {
@@ -382,8 +382,8 @@ impl BrowserState {
382 382 mirror_dirty: mirror_enabled,
383 383 sync: SyncUiState::default(),
384 384 settings: SettingsUiState { name: vault_name.to_string(), ..Default::default() },
385 - unsafe_missing_count: 0,
386 - show_unsafe_warning: false,
385 + loose_files_missing_count: 0,
386 + show_loose_files_warning: false,
387 387 })
388 388 }
389 389 }
@@ -775,7 +775,7 @@ mod import_and_analysis {
775 775 current_name: "file.wav".to_string(),
776 776 walking: false,
777 777 total_bytes: 0,
778 - unsafe_mode: false,
778 + loose_files: false,
779 779 };
780 780 state.cancel_import();
781 781 assert!(matches!(state.import_mode, ImportMode::None));
@@ -793,7 +793,7 @@ mod import_and_analysis {
793 793 current_name: "file.wav".to_string(),
794 794 walking: false,
795 795 total_bytes: 0,
796 - unsafe_mode: false,
796 + loose_files: false,
797 797 };
798 798 state.retry_import();
799 799 assert!(matches!(state.import_mode, ImportMode::ConfigureImport { .. }));
@@ -809,7 +809,7 @@ mod import_and_analysis {
809 809 current_name: "file.wav".to_string(),
810 810 walking: false,
811 811 total_bytes: 0,
812 - unsafe_mode: false,
812 + loose_files: false,
813 813 };
814 814 state.retry_import();
815 815 // With no last_import_source, it cancels but cannot reopen config
@@ -160,7 +160,7 @@ pub enum VaultAction {
160 160 /// Switch to a different vault.
161 161 SwitchVault(PathBuf),
162 162 /// Create a new vault and switch to it.
163 - CreateVault { name: String, path: PathBuf, unsafe_mode: bool },
163 + CreateVault { name: String, path: PathBuf, loose_files: bool },
164 164 /// Add an existing vault directory to the registry.
165 165 AddExistingVault { name: String, path: PathBuf },
166 166 /// Remove a vault from the registry (no file deletion).
@@ -191,11 +191,11 @@ pub struct SettingsUiState {
191 191 /// Inline rename: (path, new_name_buffer).
192 192 pub rename_target: Option<(PathBuf, String)>,
193 193
194 - /// Unsafe mode checkbox state for vault creation.
195 - pub create_unsafe_mode: bool,
194 + /// Loose-files mode checkbox state for vault creation.
195 + pub create_loose_files: bool,
196 196
197 - /// Whether the active vault has unsafe mode enabled (read from DB on vault load).
198 - pub is_unsafe_mode: bool,
197 + /// Whether the active vault has loose-files mode enabled (read from DB on vault load).
198 + pub is_loose_files: bool,
199 199
200 200 /// Cached storage statistics from the last scan.
201 201 pub storage_cache: Option<crate::backend::StorageStats>,
@@ -343,7 +343,7 @@ pub enum ImportMode {
343 343 current_name: String,
344 344 walking: bool,
345 345 total_bytes: u64,
346 - unsafe_mode: bool,
346 + loose_files: bool,
347 347 },
348 348 TagFolders {
349 349 entries: Vec<FolderTagEntry>,
@@ -19,15 +19,15 @@ fn format_bytes(bytes: u64) -> String {
19 19
20 20 /// Draw the folder import progress screen.
21 21 pub fn draw_import_progress(ctx: &egui::Context, state: &mut BrowserState) {
22 - let (total, completed, current_name, walking, total_bytes, unsafe_mode) = match &state.import_mode {
22 + let (total, completed, current_name, walking, total_bytes, loose_files) = match &state.import_mode {
23 23 ImportMode::Importing {
24 24 total,
25 25 completed,
26 26 current_name,
27 27 walking,
28 28 total_bytes,
29 - unsafe_mode,
30 - } => (*total, *completed, current_name.clone(), *walking, *total_bytes, *unsafe_mode),
29 + loose_files,
30 + } => (*total, *completed, current_name.clone(), *walking, *total_bytes, *loose_files),
31 31 _ => return,
32 32 };
33 33
@@ -44,7 +44,7 @@ pub fn draw_import_progress(ctx: &egui::Context, state: &mut BrowserState) {
44 44 // Storage estimate
45 45 if total_bytes > 0 {
46 46 let size_label = format_bytes(total_bytes);
47 - let storage_text = if unsafe_mode {
47 + let storage_text = if loose_files {
48 48 format!("{total} files, {size_label} total (referenced in place, no copies)")
49 49 } else {
50 50 format!("{total} files, ~{size_label} will be duplicated into vault")
@@ -52,7 +52,7 @@ pub fn draw_import_progress(ctx: &egui::Context, state: &mut BrowserState) {
52 52 ui.label(
53 53 egui::RichText::new(storage_text)
54 54 .small()
55 - .color(if unsafe_mode { theme::accent_yellow() } else { theme::text_secondary() }),
55 + .color(if loose_files { theme::accent_yellow() } else { theme::text_secondary() }),
56 56 );
57 57 ui.add_space(4.0);
58 58 }
@@ -178,14 +178,14 @@ pub fn draw_confirm_dialog(ctx: &egui::Context, state: &mut BrowserState) {
178 178 });
179 179 }
180 180
181 - /// Draw the unsafe mode integrity warning overlay.
182 - pub fn draw_unsafe_warning(ctx: &egui::Context, state: &mut BrowserState) {
183 - let count = state.unsafe_missing_count;
181 + /// Draw the loose-files mode integrity warning overlay.
182 + pub fn draw_loose_files_warning(ctx: &egui::Context, state: &mut BrowserState) {
183 + let count = state.loose_files_missing_count;
184 184 if count == 0 {
185 185 return;
186 186 }
187 187
188 - egui::Window::new("Unsafe Mode Warning")
188 + egui::Window::new("Loose-files mode warning")
189 189 .collapsible(false)
190 190 .resizable(false)
191 191 .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
@@ -211,10 +211,10 @@ pub fn draw_unsafe_warning(ctx: &egui::Context, state: &mut BrowserState) {
211 211 .on_hover_text("Permanently remove these samples and their metadata from the vault")
212 212 .clicked()
213 213 {
214 - state.purge_missing_unsafe();
214 + state.purge_missing_loose_files();
215 215 }
216 216 if ui.button("Dismiss").clicked() {
217 - state.dismiss_unsafe_warning();
217 + state.dismiss_loose_files_warning();
218 218 }
219 219 });
220 220 });
@@ -153,11 +153,11 @@ fn draw_storage_section(ui: &mut egui::Ui, state: &mut BrowserState) {
153 153 ui.separator();
154 154 ui.add_space(4.0);
155 155
156 - // Unsafe mode indicator for active vault
157 - if state.settings.is_unsafe_mode {
156 + // Loose-files mode indicator for active vault
157 + if state.settings.is_loose_files {
158 158 ui.add_space(4.0);
159 159 ui.label(
160 - egui::RichText::new("This vault uses unsafe mode. Samples are referenced in place, not duplicated.")
160 + egui::RichText::new("This vault uses loose-files mode. Samples are referenced in place, not duplicated.")
161 161 .small()
162 162 .color(theme::accent_yellow()),
163 163 );
@@ -184,10 +184,10 @@ fn draw_storage_section(ui: &mut egui::Ui, state: &mut BrowserState) {
184 184 }
185 185 });
186 186 ui.checkbox(
187 - &mut state.settings.create_unsafe_mode,
188 - "Unsafe mode",
187 + &mut state.settings.create_loose_files,
188 + "Loose-files mode",
189 189 ).on_hover_text("Reference files in place instead of duplicating them into the vault. Saves disk space but samples break if moved or deleted. Cannot be changed later.");
190 - if state.settings.create_unsafe_mode {
190 + if state.settings.create_loose_files {
191 191 ui.label(
192 192 egui::RichText::new("Samples will not be duplicated. Moving or deleting originals will break references. This cannot be undone.")
193 193 .small()
@@ -201,11 +201,11 @@ fn draw_storage_section(ui: &mut egui::Ui, state: &mut BrowserState) {
201 201 if ui.add_enabled(can_create, egui::Button::new("Create New")).clicked() {
202 202 if let Some(path) = state.settings.create_path.take() {
203 203 let name = state.settings.create_name.trim().to_string();
204 - let unsafe_mode = state.settings.create_unsafe_mode;
204 + let loose_files = state.settings.create_loose_files;
205 205 state.settings.pending_action =
206 - Some(crate::state::VaultAction::CreateVault { name, path, unsafe_mode });
206 + Some(crate::state::VaultAction::CreateVault { name, path, loose_files });
207 207 state.settings.create_name.clear();
208 - state.settings.create_unsafe_mode = false;
208 + state.settings.create_loose_files = false;
209 209 should_close = true;
210 210 }
211 211 }
@@ -219,7 +219,7 @@ fn draw_storage_section(ui: &mut egui::Ui, state: &mut BrowserState) {
219 219 state.settings.pending_action =
220 220 Some(crate::state::VaultAction::AddExistingVault { name, path });
221 221 state.settings.create_name.clear();
222 - state.settings.create_unsafe_mode = false;
222 + state.settings.create_loose_files = false;
223 223 }
224 224 }
225 225 });
@@ -602,8 +602,8 @@ END;
602 602 "#;
603 603
604 604 const MIGRATION_013: &str = r#"
605 - -- Unsafe mode: remember original file path instead of copying into vault.
606 - -- NULL = normal (blob in samples/), non-NULL = unsafe (blob at this path).
605 + -- Loose-files mode: remember original file path instead of copying into vault.
606 + -- NULL = normal (blob in samples/), non-NULL = loose-files (blob at this path).
607 607 -- Intentionally excluded from sync triggers — source_path is device-local.
608 608 ALTER TABLE samples ADD COLUMN source_path TEXT;
609 609 "#;
@@ -630,8 +630,8 @@ DROP TABLE IF EXISTS smart_folders;
630 630 "#;
631 631
632 632 const MIGRATION_016: &str = r#"
633 - -- Exclude unsafe_mode from sync: a compromised server or second device should
634 - -- not be able to silently flip a security-relevant setting.
633 + -- Exclude loose-files mode from sync: a compromised server or second device
634 + -- should not be able to silently flip a security-relevant setting.
635 635 DROP TRIGGER IF EXISTS sync_user_config_insert;
636 636 DROP TRIGGER IF EXISTS sync_user_config_update;
637 637 DROP TRIGGER IF EXISTS sync_user_config_delete;
@@ -666,6 +666,48 @@ BEGIN
666 666 END;
667 667 "#;
668 668
669 + const MIGRATION_017: &str = r#"
670 + -- Schema-only half of the 'unsafe_mode' -> 'loose_files' rename.
671 + -- Recreates the sync-exclusion triggers to reference the new key literal
672 + -- in their WHEN clauses (triggers can't parameterize key names, so the
673 + -- rewrite has to live in a migration). The runtime row-copy
674 + -- (unsafe_mode value -> loose_files row) lives in main.rs at the
675 + -- vault-open path; doing it there avoids running it against every
676 + -- attached/auxiliary DB that goes through migrate().
677 + DROP TRIGGER IF EXISTS sync_user_config_insert;
678 + DROP TRIGGER IF EXISTS sync_user_config_update;
679 + DROP TRIGGER IF EXISTS sync_user_config_delete;
680 +
681 + CREATE TRIGGER sync_user_config_insert AFTER INSERT ON user_config
682 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
683 + AND NEW.key NOT LIKE 'sync_%'
684 + AND NEW.key != 'loose_files'
685 + BEGIN
686 + INSERT INTO sync_changelog (table_name, op, row_id, data)
687 + VALUES ('user_config', 'INSERT', NEW.key,
688 + json_object('key', NEW.key, 'value', NEW.value));
689 + END;
690 +
691 + CREATE TRIGGER sync_user_config_update AFTER UPDATE ON user_config
692 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
693 + AND NEW.key NOT LIKE 'sync_%'
694 + AND NEW.key != 'loose_files'
695 + BEGIN
696 + INSERT INTO sync_changelog (table_name, op, row_id, data)
697 + VALUES ('user_config', 'UPDATE', NEW.key,
698 + json_object('key', NEW.key, 'value', NEW.value));
699 + END;
700 +
701 + CREATE TRIGGER sync_user_config_delete AFTER DELETE ON user_config
702 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
703 + AND OLD.key NOT LIKE 'sync_%'
704 + AND OLD.key != 'loose_files'
705 + BEGIN
706 + INSERT INTO sync_changelog (table_name, op, row_id, data)
707 + VALUES ('user_config', 'DELETE', OLD.key, NULL);
708 + END;
709 + "#;
710 +
669 711 impl Database {
670 712 /// Open (or create) the database at the given path and run migrations.
671 713 #[instrument(skip_all)]
@@ -730,6 +772,7 @@ impl Database {
730 772 MIGRATION_014,
731 773 MIGRATION_015,
732 774 MIGRATION_016,
775 + MIGRATION_017,
733 776 ];
734 777
735 778 for (i, sql) in MIGRATIONS.iter().enumerate() {
@@ -878,7 +921,7 @@ mod tests {
878 921 .conn()
879 922 .query_row("PRAGMA user_version", [], |row| row.get(0))
880 923 .unwrap();
881 - assert_eq!(version, 16);
924 + assert_eq!(version, 17);
882 925 }
883 926
884 927 #[test]
@@ -889,7 +932,7 @@ mod tests {
889 932 .conn()
890 933 .query_row("PRAGMA user_version", [], |row| row.get(0))
891 934 .unwrap();
892 - assert_eq!(version, 16);
935 + assert_eq!(version, 17);
893 936 }
894 937
895 938 #[test]
@@ -96,7 +96,7 @@ pub struct ExportItem {
96 96 pub duration: Option<f64>,
97 97 /// Tags associated with this sample (populated by `enrich_with_tags`).
98 98 pub tags: Vec<String>,
99 - /// Original file path for unsafe-mode samples (populated from samples.source_path).
99 + /// Original file path for loose-files mode samples (populated from samples.source_path).
100 100 /// When set, the export runner uses this path instead of the store.
101 101 pub source_path: Option<PathBuf>,
102 102 }
@@ -63,7 +63,7 @@ pub fn run_export(
63 63 if sp.exists() {
64 64 sp.clone()
65 65 } else {
66 - // Fallback to store path for unsafe samples whose source moved
66 + // Fallback to store path for loose-files samples whose source moved
67 67 match store.sample_path(&item.hash, &item.ext) {
68 68 Ok(p) => p,
69 69 Err(e) => {
@@ -311,9 +311,9 @@ pub fn sample_original_name(db: &Database, hash: &str) -> Result<String> {
311 311 query_sample_field(db, hash, "original_name")
312 312 }
313 313
314 - // --- Unsafe mode ---
314 + // --- Loose-files mode ---
315 315
316 - /// Look up the source_path for a sample (unsafe mode imports only).
316 + /// Look up the source_path for a sample (loose-files mode imports only).
317 317 ///
318 318 /// Returns `Ok(None)` for normal-mode samples (source_path is NULL).
319 319 pub fn sample_source_path(db: &Database, hash: &str) -> Result<Option<String>> {
@@ -331,7 +331,7 @@ pub fn sample_source_path(db: &Database, hash: &str) -> Result<Option<String>> {
331 331
332 332 /// Resolve the actual file path for a sample, checking source_path first.
333 333 ///
334 - /// For unsafe-mode samples (source_path is set), returns the source path if
334 + /// For loose-files mode samples (source_path is set), returns the source path if
335 335 /// the file exists, otherwise falls back to the store path. For normal samples,
336 336 /// returns the store path directly.
337 337 pub fn resolve_file_path(store: &SampleStore, db: &Database, hash: &str, ext: &str) -> Result<PathBuf> {
@@ -353,7 +353,7 @@ pub fn resolve_file_path(store: &SampleStore, db: &Database, hash: &str, ext: &s
353 353
354 354 /// Update the source_path for a sample after verifying the new file's hash matches.
355 355 ///
356 - /// Used to relocate an unsafe-mode sample whose original file has moved.
356 + /// Used to relocate an loose-files mode sample whose original file has moved.
357 357 pub fn relocate_sample(
358 358 store: &SampleStore,
359 359 db: &Database,
@@ -396,11 +396,11 @@ pub fn relocate_sample(
396 396 Ok(())
397 397 }
398 398
399 - /// Check integrity of unsafe-mode samples.
399 + /// Check integrity of loose-files mode samples.
400 400 ///
401 401 /// Returns `(valid, missing)` — counts of source_path entries where the file
402 402 /// exists vs. does not exist on disk.
403 - pub fn check_unsafe_integrity(db: &Database) -> Result<(usize, usize)> {
403 + pub fn check_loose_files_integrity(db: &Database) -> Result<(usize, usize)> {
404 404 let mut stmt = db.conn().prepare(
405 405 "SELECT source_path FROM samples WHERE source_path IS NOT NULL",
406 406 )?;
@@ -420,10 +420,128 @@ pub fn check_unsafe_integrity(db: &Database) -> Result<(usize, usize)> {
420 420 Ok((valid, missing))
421 421 }
422 422
423 - /// Delete all unsafe-mode samples whose source files no longer exist on disk.
423 + /// Try to re-locate loose-files mode samples whose source files have moved.
424 + ///
425 + /// Walks `search_root` recursively, building a basename map of candidate
426 + /// files. For each missing sample, looks up its basename, then hash-verifies
427 + /// candidates (cheapest: size-check before re-hashing the full file). On hash
428 + /// match, the sample's `source_path` is updated to the new location.
429 + ///
430 + /// Returns `(relocated, still_missing)`. `relocated` counts samples whose
431 + /// `source_path` was successfully repointed; `still_missing` is the residual
432 + /// count for the dialog to surface so the user can run Locate again against a
433 + /// different directory.
434 + pub fn relocate_missing_loose_files(
435 + db: &Database,
436 + search_root: &Path,
437 + ) -> Result<(usize, usize)> {
438 + // 1. Gather missing samples — hash, basename of stored source_path, and
439 + // the recorded file_size (used for cheap pre-filter before re-hashing).
440 + let mut stmt = db.conn().prepare(
441 + "SELECT hash, source_path, file_size FROM samples \
442 + WHERE source_path IS NOT NULL",
443 + )?;
444 + let rows: Vec<(String, String, i64)> = stmt
445 + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?
446 + .collect::<std::result::Result<Vec<_>, _>>()?;
447 + let missing: Vec<(String, String, i64)> = rows
448 + .into_iter()
449 + .filter(|(_, source_path, _)| !Path::new(source_path).exists())
450 + .collect();
451 +
452 + if missing.is_empty() {
453 + return Ok((0, 0));
454 + }
455 +
456 + // 2. Walk search_root, build basename -> Vec<PathBuf>. Lowercased so a
457 + // case-changed filesystem (e.g. moved between macOS/Linux) still
458 + // matches. Bounded by what the filesystem returns; large trees walk
459 + // once and stay in memory for the duration of this call.
460 + let mut candidates: std::collections::HashMap<String, Vec<PathBuf>> =
461 + std::collections::HashMap::new();
462 + let mut dirs = vec![search_root.to_path_buf()];
463 + while let Some(d) = dirs.pop() {
464 + let Ok(entries) = std::fs::read_dir(&d) else { continue };
465 + for entry in entries.flatten() {
466 + let path = entry.path();
467 + if path.is_dir() {
468 + if !crate::util::is_macos_metadata_dir(&path) {
469 + dirs.push(path);
470 + }
471 + } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
472 + candidates
473 + .entry(name.to_lowercase())
474 + .or_default()
475 + .push(path);
476 + }
477 + }
478 + }
479 +
480 + // 3. For each missing sample, check candidates with matching basename.
481 + // Size check filters out same-name-different-file collisions before we
482 + // spend cycles hashing. Hash verify is the authoritative match.
483 + let mut relocated_pairs: Vec<(String, String)> = Vec::new();
484 + for (hash, source_path, file_size) in &missing {
485 + let Some(basename) = Path::new(source_path)
486 + .file_name()
487 + .and_then(|n| n.to_str())
488 + else { continue };
489 + let key = basename.to_lowercase();
490 + let Some(paths) = candidates.get(&key) else { continue };
491 + for cand in paths {
492 + let Ok(md) = std::fs::metadata(cand) else { continue };
493 + if md.len() as i64 != *file_size {
494 + continue;
495 + }
496 + // Hash verify. Bail on first match; sample hashes are unique so a
497 + // second match for the same hash would be redundant.
498 + let Ok(mut file) = fs::File::open(cand) else { continue };
499 + let mut hasher = Sha256::new();
500 + let mut buf = [0u8; 8192];
501 + let ok = loop {
502 + let Ok(n) = file.read(&mut buf) else { break false };
503 + if n == 0 {
504 + break true;
505 + }
506 + hasher.update(&buf[..n]);
507 + };
508 + if !ok {
509 + continue;
510 + }
511 + let computed = format!("{:x}", hasher.finalize());
512 + if computed == *hash {
513 + let abs = cand
514 + .canonicalize()
515 + .map(|p| p.to_string_lossy().to_string())
516 + .unwrap_or_else(|_| cand.to_string_lossy().to_string());
517 + relocated_pairs.push((hash.clone(), abs));
518 + break;
519 + }
520 + }
521 + }
522 +
523 + // 4. Atomic update of all relocated source_paths.
524 + let relocated = relocated_pairs.len();
525 + if relocated > 0 {
526 + db.transaction(|| {
527 + for (hash, new_path) in &relocated_pairs {
528 + db.conn().execute(
529 + "UPDATE samples SET source_path = ?1 WHERE hash = ?2",
530 + rusqlite::params![new_path, hash],
531 + )?;
532 + }
533 + Ok(())
534 + })?;
535 + }
536 +
537 + let still_missing = missing.len() - relocated;
538 + Ok((relocated, still_missing))
539 + }
540 +
541 + /// Delete all loose-files mode samples whose source files no longer exist on disk.
424 542 ///
425 543 /// Returns the number of samples purged. CASCADE handles VFS nodes, tags, etc.
426 - pub fn purge_missing_unsafe(db: &Database) -> Result<usize> {
544 + pub fn purge_missing_loose_files(db: &Database) -> Result<usize> {
427 545 let mut stmt = db.conn().prepare(
428 546 "SELECT hash, source_path FROM samples WHERE source_path IS NOT NULL",
429 547 )?;
@@ -453,12 +571,12 @@ pub fn purge_missing_unsafe(db: &Database) -> Result<usize> {
453 571 }
454 572
455 573 impl SampleStore {
456 - /// Import a file in unsafe mode: hash it but do NOT copy to the store.
574 + /// Import a file in loose-files mode: hash it but do NOT copy to the store.
457 575 ///
458 576 /// Records the original absolute path as `source_path` in the database.
459 577 /// The file stays where it is on disk.
460 578 #[instrument(skip_all)]
461 - pub fn import_unsafe(&self, path: &Path, db: &Database) -> Result<String> {
579 + pub fn import_loose_files(&self, path: &Path, db: &Database) -> Result<String> {
462 580 if !crate::util::is_audio_file(path) {
463 581 return Err(CoreError::Internal(format!(
464 582 "not a supported audio file: {}",
@@ -812,14 +930,14 @@ mod tests {
812 930 assert!(!store.exists(&hash, "wav").unwrap());
813 931 }
814 932
815 - // --- Unsafe mode tests ---
933 + // --- Loose-files mode tests ---
816 934
817 935 #[test]
818 - fn import_unsafe_does_not_copy_file() {
936 + fn import_loose_files_does_not_copy_file() {
819 937 let (dir, db, store) = setup();
820 - let src = create_test_file(&dir, "kick.wav", b"unsafe kick data");
938 + let src = create_test_file(&dir, "kick.wav", b"loose kick data");
821 939
822 - let hash = store.import_unsafe(&src, &db).unwrap();
940 + let hash = store.import_loose_files(&src, &db).unwrap();
823 941
824 942 // No file in the store
825 943 assert!(!store.exists(&hash, "wav").unwrap());
@@ -838,12 +956,12 @@ mod tests {
838 956 }
839 957
840 958 #[test]
841 - fn import_unsafe_deduplicates() {
959 + fn import_loose_files_deduplicates() {
842 960 let (dir, db, store) = setup();
843 - let src = create_test_file(&dir, "kick.wav", b"same unsafe content");
961 + let src = create_test_file(&dir, "kick.wav", b"same loose content");
844 962
845 - let hash1 = store.import_unsafe(&src, &db).unwrap();
846 - let hash2 = store.import_unsafe(&src, &db).unwrap();
963 + let hash1 = store.import_loose_files(&src, &db).unwrap();
964 + let hash2 = store.import_loose_files(&src, &db).unwrap();
847 965 assert_eq!(hash1, hash2);
848 966
849 967 let count: i64 = db
@@ -863,10 +981,10 @@ mod tests {
863 981 }
864 982
865 983 #[test]
866 - fn sample_source_path_returns_path_for_unsafe() {
984 + fn sample_source_path_returns_path_for_loose_files() {
867 985 let (dir, db, store) = setup();
868 - let src = create_test_file(&dir, "kick.wav", b"unsafe import");
869 - let hash = store.import_unsafe(&src, &db).unwrap();
986 + let src = create_test_file(&dir, "kick.wav", b"loose import");
987 + let hash = store.import_loose_files(&src, &db).unwrap();
870 988
871 989 let sp = sample_source_path(&db, &hash).unwrap();
872 990 assert!(sp.is_some());
@@ -875,8 +993,8 @@ mod tests {
875 993 #[test]
876 994 fn resolve_file_path_prefers_source_path() {
877 995 let (dir, db, store) = setup();
878 - let src = create_test_file(&dir, "kick.wav", b"unsafe resolve test");
879 - let hash = store.import_unsafe(&src, &db).unwrap();
996 + let src = create_test_file(&dir, "kick.wav", b"loose resolve test");
997 + let hash = store.import_loose_files(&src, &db).unwrap();
880 998
881 999 let resolved = resolve_file_path(&store, &db, &hash, "wav").unwrap();
882 1000 // Should resolve to the original file, not the store
@@ -899,7 +1017,7 @@ mod tests {
899 1017 fn relocate_sample_rejects_hash_mismatch() {
900 1018 let (dir, db, store) = setup();
901 1019 let src = create_test_file(&dir, "kick.wav", b"original content");
902 - let hash = store.import_unsafe(&src, &db).unwrap();
1020 + let hash = store.import_loose_files(&src, &db).unwrap();
903 1021
904 1022 let wrong_file = create_test_file(&dir, "snare.wav", b"different content");
905 1023 let result = relocate_sample(&store, &db, &hash, &wrong_file);
@@ -911,7 +1029,7 @@ mod tests {
911 1029 fn relocate_sample_updates_source_path() {
912 1030 let (dir, db, store) = setup();
913 1031 let src = create_test_file(&dir, "kick.wav", b"relocate content");
914 - let hash = store.import_unsafe(&src, &db).unwrap();
1032 + let hash = store.import_loose_files(&src, &db).unwrap();
915 1033
916 1034 // Move the file
917 1035 let new_loc = dir.path().join("moved_kick.wav");
@@ -924,37 +1042,37 @@ mod tests {
924 1042 }
925 1043
926 1044 #[test]
927 - fn check_unsafe_integrity_counts_correctly() {
1045 + fn check_loose_files_integrity_counts_correctly() {
928 1046 let (dir, db, store) = setup();
929 1047 let src1 = create_test_file(&dir, "kick.wav", b"integrity kick");
930 1048 let src2 = create_test_file(&dir, "snare.wav", b"integrity snare");
931 1049
932 - store.import_unsafe(&src1, &db).unwrap();
933 - let hash2 = store.import_unsafe(&src2, &db).unwrap();
1050 + store.import_loose_files(&src1, &db).unwrap();
1051 + let hash2 = store.import_loose_files(&src2, &db).unwrap();
934 1052
935 1053 // Delete snare from disk to simulate missing file
936 1054 let sp = sample_source_path(&db, &hash2).unwrap().unwrap();
937 1055 fs::remove_file(&sp).unwrap();
938 1056
939 - let (valid, missing) = check_unsafe_integrity(&db).unwrap();
1057 + let (valid, missing) = check_loose_files_integrity(&db).unwrap();
940 1058 assert_eq!(valid, 1);
941 1059 assert_eq!(missing, 1);
942 1060 }
943 1061
944 1062 #[test]
945 - fn purge_missing_unsafe_removes_only_missing() {
1063 + fn purge_missing_loose_files_removes_only_missing() {
946 1064 let (dir, db, store) = setup();
947 1065 let src1 = create_test_file(&dir, "kick.wav", b"purge kick");
948 1066 let src2 = create_test_file(&dir, "snare.wav", b"purge snare");
949 1067
950 - let hash1 = store.import_unsafe(&src1, &db).unwrap();
951 - let hash2 = store.import_unsafe(&src2, &db).unwrap();
1068 + let hash1 = store.import_loose_files(&src1, &db).unwrap();
1069 + let hash2 = store.import_loose_files(&src2, &db).unwrap();
952 1070
953 1071 // Delete snare from disk
954 1072 let sp = sample_source_path(&db, &hash2).unwrap().unwrap();
955 1073 fs::remove_file(&sp).unwrap();
956 1074
957 - let purged = purge_missing_unsafe(&db).unwrap();
1075 + let purged = purge_missing_loose_files(&db).unwrap();
958 1076 assert_eq!(purged, 1);
959 1077
960 1078 // kick still exists, snare is gone
@@ -966,12 +1084,12 @@ mod tests {
966 1084 }
967 1085
968 1086 #[test]
969 - fn purge_missing_unsafe_noop_when_all_valid() {
1087 + fn purge_missing_loose_files_noop_when_all_valid() {
970 1088 let (dir, db, store) = setup();
971 1089 let src = create_test_file(&dir, "kick.wav", b"all valid");
972 - store.import_unsafe(&src, &db).unwrap();
1090 + store.import_loose_files(&src, &db).unwrap();
973 1091
974 - let purged = purge_missing_unsafe(&db).unwrap();
1092 + let purged = purge_missing_loose_files(&db).unwrap();
975 1093 assert_eq!(purged, 0);
976 1094 }
977 1095 }
@@ -30,7 +30,7 @@ pub fn create_initial_snapshot(conn: &Connection) -> Result<i64> {
30 30 ("tags", "SELECT sample_hash || ':' || tag, json_object('sample_hash', sample_hash, 'tag', tag) FROM tags"),
31 31 ("collections", "SELECT CAST(id AS TEXT), json_object('id', id, 'name', name, 'description', description, 'created_at', created_at, 'filter_json', filter_json) FROM collections"),
32 32 ("collection_members", "SELECT CAST(collection_id AS TEXT) || ':' || sample_hash, json_object('collection_id', collection_id, 'sample_hash', sample_hash, 'added_at', added_at) FROM collection_members"),
33 - ("user_config", "SELECT key, json_object('key', key, 'value', value) FROM user_config WHERE key NOT LIKE 'sync_%' AND key != 'unsafe_mode'"),
33 + ("user_config", "SELECT key, json_object('key', key, 'value', value) FROM user_config WHERE key NOT LIKE 'sync_%' AND key != 'loose_files'"),
34 34 ("edit_history", "SELECT CAST(id AS TEXT), json_object('id', id, 'source_hash', source_hash, 'result_hash', result_hash, 'operation', operation, 'params_json', params_json, 'created_at', created_at) FROM edit_history"),
35 35 ];
36 36
@@ -940,21 +940,21 @@ mod tests {
940 940 }
941 941
942 942 #[test]
943 - fn unsafe_mode_excluded_from_sync() {
943 + fn loose_files_excluded_from_sync() {
944 944 let db = setup_test_db();
945 945 let conn = db.conn();
946 946 clear_changelog(conn);
947 947
948 - // Insert unsafe_mode — should NOT fire trigger
948 + // Insert loose_files — should NOT fire trigger
949 949 conn.execute(
950 - "INSERT INTO user_config (key, value) VALUES ('unsafe_mode', '1')",
950 + "INSERT INTO user_config (key, value) VALUES ('loose_files', '1')",
951 951 [],
952 952 ).unwrap();
953 953 assert_eq!(changelog_count(conn, Some("user_config"), None), 0);
954 954
955 - // Update unsafe_mode — should NOT fire trigger
955 + // Update loose_files — should NOT fire trigger
956 956 conn.execute(
957 - "UPDATE user_config SET value = '0' WHERE key = 'unsafe_mode'",
957 + "UPDATE user_config SET value = '0' WHERE key = 'loose_files'",
958 958 [],
959 959 ).unwrap();
960 960 assert_eq!(changelog_count(conn, Some("user_config"), None), 0);
@@ -146,9 +146,9 @@ Build with `pwsh -File dist\build-msi-native.ps1` (or `bash dist/build-msi.sh` f
146 146 - [ ] Quit during analysis — relaunch resumes cleanly, no DB corruption
147 147 - [ ] Disk full during import — graceful error
148 148
149 - ### Unsafe Mode
149 + ### Loose-files mode
150 150
151 - - [ ] Enable Unsafe Mode (intentionally discouraging UI) — warning shown
151 + - [ ] Enable Loose-files mode (intentionally discouraging UI) — warning shown
152 152 - [ ] Unsafe operations available; default-off after restart
153 153
154 154 ---
@@ -0,0 +1,91 @@
1 + # Loose-files mode
2 +
3 + ## Overview
4 +
5 + Per-vault opt-in mode where audiofiles references samples at their original disk location instead of copying them into the vault's `samples/` directory. Trades safety for disk space savings.
6 +
7 + In normal mode, every import copies the file into `vault/samples/<hash>.<ext>`. In loose-files mode, the database records the original path and no copy is made. The file stays where the user put it.
8 +
9 + ## Enabling
10 +
11 + Loose-files mode is a vault-level setting, toggled in vault settings. Changing it only affects future imports — samples already in the vault are not moved or copied retroactively.
12 +
13 + A vault stores its mode as a preference row:
14 +
15 + | Key | Value | Default |
16 + |-----|-------|---------|
17 + | `loose_files` | `0` or `1` | `0` |
18 +
19 + When loose-files mode is on, the vault settings UI shows a persistent warning:
20 + > **Loose-files mode is on.** Samples are not copied into this vault. Moving, renaming, or deleting originals will break references.
21 +
22 + ## Import Behavior
23 +
24 + ### Normal Mode (unchanged)
25 +
26 + 1. Hash file → copy to `samples/<hash>.<ext>` → insert `samples` row
27 + 2. File is self-contained in the vault forever
28 +
29 + ### Loose-files mode
30 +
31 + 1. Hash file → record original absolute path → insert `samples` row
32 + 2. No copy is made
33 + 3. The `samples` row gets an additional `source_path TEXT` populated with the original absolute path
34 +
35 + If a sample with the same hash already exists (duplicate detection still works), skip it as usual regardless of mode.
36 +
37 + ## Schema Change
38 +
39 + Add one column to `samples`:
40 +
41 + ```sql
42 + ALTER TABLE samples ADD COLUMN source_path TEXT;
43 + ```
44 +
45 + - `NULL` → normal mode sample (blob lives in `samples/`)
46 + - Non-`NULL` → loose-files mode sample (blob lives at this path)
47 +
48 + This is the only way to tell which mode a sample was imported under. A vault in loose-files mode can contain a mix of both kinds if the mode was toggled between imports.
49 +
50 + ## Playback and Access
51 +
52 + When resolving a sample's file path:
53 +
54 + 1. If `source_path` is `NULL`, use `vault/samples/<hash>.<ext>` (current behavior)
55 + 2. If `source_path` is non-`NULL`, use `source_path`
56 +
57 + ## Graceful Recovery
58 +
59 + Loose-files mode makes a best-effort attempt to handle files that have gone missing. It does not try to be clever.
60 +
61 + ### On Access (Playback, Preview, Export, Analysis)
62 +
63 + If `source_path` points to a file that no longer exists:
64 +
65 + 1. Check `vault/samples/<hash>.<ext>` as a fallback — the user may have re-imported in normal mode or manually placed the file there
66 + 2. If the fallback also misses, mark the sample as **unavailable** in the UI (grayed out, struck-through name, tooltip: "Original file not found at `<path>`")
67 + 3. Do not delete the metadata row — the sample keeps its tags, VFS position, and analysis data
68 +
69 + ### Relocate
70 +
71 + Provide a **Relocate** action on unavailable samples:
72 +
73 + - User picks a new file
74 + - App verifies the SHA-256 hash matches the sample's `hash`
75 + - If it matches, update `source_path` to the new location
76 + - If it doesn't match, reject with: "Hash mismatch — this is a different file"
77 +
78 + No batch relocate, no folder scanning, no automatic search. Keep it manual and simple.
79 +
80 + ### Vault Integrity Check
81 +
82 + Add a "Check vault" action (in vault settings, next to the loose-files mode toggle) that scans all `source_path` entries and reports how many are valid vs. missing. Informational only — it does not fix anything, just gives the user a count.
83 +
84 + ## What This Does NOT Do
85 +
86 + - Does not watch the filesystem for changes
87 + - Does not auto-relocate or search for moved files
88 + - Does not create symlinks or hardlinks
89 + - Does not support relative paths — `source_path` is always absolute
90 + - Does not convert existing samples between modes (no retroactive copy-in or copy-out)
91 + - Does not sync `source_path` values via SyncKit — paths are device-local and meaningless on other machines
M docs/todo.md +1 -1