//! Folder-label harvesting (cold-start companion to clustering). //! //! Turns an already-organized library's folder structure into candidate tags: each VFS //! directory that directly contains samples becomes a suggested tag (normalized from the //! folder name) over those samples. The user reviews/edits the mapping, then applies it. //! //! Applied harvest tags carry `source = 'harvest'` provenance, sticky, like manual and //! cluster tags (the rules engine never reconciles them away). See [`crate::rules`]. use crate::db::Database; use crate::error::Result; use crate::tags::validate_tag; use tracing::instrument; /// A candidate label derived from one VFS folder. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FolderLabel { /// The raw directory name (e.g. `808 Kicks`). pub folder_name: String, /// Normalized, validated tag suggestion (e.g. `808-kicks`). The user can /// re-namespace it before applying. pub suggested_tag: String, /// Hashes of the samples directly under this folder. pub sample_hashes: Vec, } /// Normalize a folder name into a valid flat tag, or `None` if it can't form one. /// /// Lowercases, maps every non-alphanumeric run to a single hyphen, trims hyphens, and /// validates against the tag grammar. pub fn normalize_to_tag(folder_name: &str) -> Option { let mut out = String::with_capacity(folder_name.len()); let mut last_dash = false; for ch in folder_name.chars() { if ch.is_ascii_alphanumeric() { out.push(ch.to_ascii_lowercase()); last_dash = false; } else if !last_dash { out.push('-'); last_dash = true; } } let trimmed = out.trim_matches('-').to_string(); if trimmed.is_empty() { return None; } validate_tag(&trimmed).ok().map(|()| trimmed) } /// Scan the VFS for directories that directly contain samples and propose a label per /// folder. Folders whose name can't normalize to a valid tag are skipped. Ordered by /// folder name. #[instrument(skip_all)] pub fn harvest_folder_labels(db: &Database) -> Result> { let mut stmt = db.conn().prepare( "SELECT d.id, d.name, s.sample_hash FROM vfs_nodes d JOIN vfs_nodes s ON s.parent_id = d.id AND s.node_type = 'sample' WHERE d.node_type = 'directory' AND s.sample_hash IS NOT NULL ORDER BY d.name, d.id, s.sample_hash", )?; let rows = stmt.query_map([], |row| { Ok(( row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, )) })?; // Group hashes by directory id, not bare name: two sibling folders both // named e.g. "Kicks" (under 808/ and 909/) are distinct folders and must // each propose their own label, not merge into one union. let mut order: Vec = Vec::new(); let mut by_folder: std::collections::HashMap)> = std::collections::HashMap::new(); for r in rows { let (id, name, hash) = r?; by_folder .entry(id) .or_insert_with(|| { order.push(id); (name, Vec::new()) }) .1 .push(hash); } let mut labels = Vec::new(); for id in order { if let Some((folder_name, sample_hashes)) = by_folder.remove(&id) && let Some(suggested_tag) = normalize_to_tag(&folder_name) { labels.push(FolderLabel { folder_name, suggested_tag, sample_hashes, }); } } Ok(labels) } /// Apply a harvested tag to the given samples (`source = 'harvest'` provenance). /// Returns the number of samples that gained the tag. #[instrument(skip_all)] pub fn apply_harvested_tag(db: &Database, hashes: &[String], tag: &str) -> Result { // One transaction for the whole harvest label, not an autocommit per sample. db.transaction_core(|tx| { let mut added = 0; for hash in hashes { if crate::rules::apply_tag_sourced(db, tx, hash, tag, "harvest")? { added += 1; } } Ok(added) }) } #[cfg(test)] mod tests { use super::*; #[test] fn normalize_handles_messy_names() { assert_eq!(normalize_to_tag("808 Kicks").as_deref(), Some("808-kicks")); assert_eq!(normalize_to_tag(" Hi-Hats!! ").as_deref(), Some("hi-hats")); assert_eq!( normalize_to_tag("Vocals/Chops").as_deref(), Some("vocals-chops") ); assert_eq!(normalize_to_tag("___").as_deref(), None); assert_eq!(normalize_to_tag("").as_deref(), None); } fn setup(db: &Database) -> (i64, i64) { let conn = db.conn(); conn.execute( "INSERT INTO vfs (name, created_at, modified_at, sync_files) VALUES ('Lib', 0, 0, 0)", [], ) .unwrap(); let vfs_id = conn.last_insert_rowid(); // Directory "808 Kicks" with two samples. conn.execute( "INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, created_at) VALUES (?1, NULL, '808 Kicks', 'directory', 0)", [vfs_id], ) .unwrap(); let dir_id = conn.last_insert_rowid(); for h in ["k1", "k2"] { conn.execute( "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) VALUES (?1, ?1, 'wav', 1, 0, 0)", [h], ) .unwrap(); conn.execute( "INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, sample_hash, created_at) VALUES (?1, ?2, ?3, 'sample', ?3, 0)", rusqlite::params![vfs_id, dir_id, h], ) .unwrap(); } (vfs_id, dir_id) } #[test] fn harvest_and_apply() { let db = Database::open_in_memory().unwrap(); setup(&db); let labels = harvest_folder_labels(&db).unwrap(); assert_eq!(labels.len(), 1); assert_eq!(labels[0].folder_name, "808 Kicks"); assert_eq!(labels[0].suggested_tag, "808-kicks"); assert_eq!(labels[0].sample_hashes.len(), 2); // Apply under a re-namespaced tag. let n = apply_harvested_tag(&db, &labels[0].sample_hashes, "instrument.drum.kick").unwrap(); assert_eq!(n, 2); assert!( crate::tags::get_sample_tags(&db, "k1") .unwrap() .contains(&"instrument.drum.kick".to_string()) ); let prov = crate::rules::sample_tag_provenance(&db, "k1").unwrap(); assert_eq!(prov[0].1, "harvest"); } }