diff --git a/src-tauri/src/overlap.rs b/src-tauri/src/overlap.rs index ba521db..3c3998a 100644 --- a/src-tauri/src/overlap.rs +++ b/src-tauri/src/overlap.rs @@ -87,10 +87,13 @@ fn key(username: &str) -> String { // ---------------------------------------------------------------- names -/// Bracketed chunks go — "(2019)", "[FLAC]", "{web}" — then only letters and -/// digits remain, lowercased; a leading "the" goes too. "Sigur Rós" and -/// "sigur ros" are not the same string here, and that is a known miss. +/// Bracketed chunks go — "(2019)", "[FLAC]", "{web}" — and so does a +/// dash-led edition tail — "Pinkerton - Deluxe Edition", "Foo - EP" — then +/// only letters and digits remain, lowercased; a leading "the" goes too. +/// "Sigur Rós" and "sigur ros" are not the same string here, and that is a +/// known miss. pub(crate) fn norm(s: &str) -> String { + let s = strip_edition_tail(s); let mut out = String::with_capacity(s.len()); let mut depth = 0i32; for c in s.chars() { @@ -115,6 +118,41 @@ pub(crate) fn norm(s: &str) -> String { out } +/// The catalogue spells an edition after a spaced dash where a folder would +/// bracket it — "Pinkerton - Deluxe Edition" is the gap card, "Pinkerton +/// (Deluxe Edition)" the folder — so a dash-led tail that reads as an +/// edition goes the way brackets do, and only that tail: "Boards of Canada - +/// Geogaddi" is a name with a dash in it and stays whole. Found 2026-09-09 +/// when the deluxe card beside a marked Pinkerton carried no "has it". +fn strip_edition_tail(s: &str) -> &str { + let mut s = s.trim_end(); + loop { + let Some((at, len)) = [" - ", " – ", " — "] + .iter() + .filter_map(|d| s.rfind(d).map(|i| (i, d.len()))) + .max_by_key(|(i, _)| *i) + else { + return s; + }; + let tail: String = s[at + len..] + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(|c| c.to_lowercase()) + .collect(); + if !is_edition_tail(&tail) { + return s; + } + s = s[..at].trim_end(); + } +} + +fn is_edition_tail(t: &str) -> bool { + matches!(t, "ep" | "single") + || ["deluxe", "remaster", "edition", "expanded", "anniversary", "reissue", "bonus", "explicit"] + .iter() + .any(|w| t.contains(w)) +} + fn is_disc_folder(s: &str) -> bool { let n: String = s.to_lowercase().chars().filter(|c| c.is_alphanumeric()).collect(); for p in ["cd", "disc", "disk", "vol", "volume", "side"] { @@ -352,4 +390,21 @@ mod tests { assert_eq!(norm("2000 - Spirit They're Gone Spirit They've Vanished"), "spirittheyregonespirittheyvevanished"); assert!(is_disc_folder("Disc 2") && is_disc_folder("cd1") && !is_disc_folder("Discovery")); } + + #[test] + fn dash_led_editions_go_the_way_brackets_do() { + assert_eq!(norm("Pinkerton - Deluxe Edition"), "pinkerton"); + assert_eq!(norm("Pinkerton – Deluxe Edition"), "pinkerton"); + assert_eq!(norm("Pinkerton — Deluxe Edition"), "pinkerton"); + assert_eq!(norm("Pinkerton - Deluxe Edition (2010)"), "pinkerton"); + assert_eq!(norm("Album - Deluxe Edition - Remastered"), "album"); + assert_eq!(norm("Kid A - 2019 Remaster"), "kida"); + assert_eq!(norm("Foo - EP"), "foo"); + assert_eq!(norm("Foo - Single"), "foo"); + assert_eq!(norm("Pinkerton (Deluxe Edition) [2010]"), norm("Pinkerton - Deluxe Edition")); + // A dash inside a name is not an edition. + assert_eq!(norm("Boards of Canada - Geogaddi"), "boardsofcanadageogaddi"); + assert_eq!(norm("Album - Live"), "albumlive"); + assert_eq!(norm("1996 - Expert Knob Twiddlers"), "expertknobtwiddlers"); + } }