// Package normalize provides string normalization helpers used for // fuzzy matching across NaviWatcher (artist names, album titles, etc.). // // It is the single shared home for normalization logic; previously this // lived inside the musicbrainz package but is needed by the scanner engine // and any other consumer that compares strings. package normalize import ( "regexp" "strings" "unicode" ) // Precompiled regexes — compiled once at package init. var ( bracketRe = regexp.MustCompile(`\[[^\]]*\]`) parenRe = regexp.MustCompile(`\([^)]*\)`) yearRe = regexp.MustCompile(`\b(1[0-9]{3}|2[0-9]{3})\b`) spaceRe = regexp.MustCompile(`\s+`) // bareYearRe matches a title that is *only* a single year (with optional // surrounding whitespace), e.g. "1989" or "2112". Used to decide whether a // title that collapses entirely to a year should keep it (so it matches // itself) or be treated as a distinct reissue that must collapse to empty. bareYearRe = regexp.MustCompile(`^\s*(1[0-9]{3}|2[0-9]{3})\s*$`) ) // NormalizeString normalizes a string for fuzzy matching by: // - Converting to lowercase // - Removing special characters (keeping only letters, digits, and spaces) // - Removing years (4-digit numbers that look like years) // - Removing bracketed keywords (e.g., [Deluxe], [Remastered]) // - Collapsing multiple spaces into one // - Trimming leading/trailing whitespace func NormalizeString(s string) string { // Capture the original input; used after stripping to tell a bare year // title apart from a title that merely collapses to a year. original := s // Convert to lowercase s = strings.ToLower(s) // Remove bracketed content first (e.g., [Deluxe Edition], [Remastered 2020]) s = bracketRe.ReplaceAllString(s, "") // Remove parenthesized content (e.g., (Deluxe), (Remastered)) s = parenRe.ReplaceAllString(s, "") // Remove years (4-digit numbers between 1000-2999). If stripping the year // empties the entire string, decide what to keep: // - A bare year title (e.g. "1989", "2112") has no other words, so keep // the year so it can still match itself (the user owns that album). // - A title that had OTHER words alongside the year (e.g. "1989 (Deluxe)") // collapses to empty on purpose: it is a distinct release group that // must NOT be considered already-present just because the user owns the // standard "1989". Collapsing to empty makes it score 0.0 against a // plain "1989", correctly reporting the reissue as missing. The check // is against the original (brackets intact) so a title like "1989 // [2020]" is correctly NOT treated as a bare year. stripped := yearRe.ReplaceAllString(s, "") if strings.TrimSpace(stripped) == "" { if bareYearRe.MatchString(strings.TrimSpace(original)) { s = strings.TrimSpace(s) } else { s = "" } } else { s = stripped } // Replace common separators with spaces before stripping other special chars s = strings.ReplaceAll(s, "-", " ") s = strings.ReplaceAll(s, "_", " ") // Keep only letters, digits, and spaces var b strings.Builder for _, r := range s { if unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.IsSpace(r) { b.WriteRune(r) } } s = b.String() // Collapse multiple spaces s = spaceRe.ReplaceAllString(s, " ") // Trim s = strings.TrimSpace(s) return s } // NormalizeArtistName normalizes an artist name for comparison. // It applies NormalizeString and additionally handles common prefixes. func NormalizeArtistName(name string) string { name = NormalizeString(name) // Remove common leading articles for better matching prefixes := []string{"the ", "a ", "an "} for _, prefix := range prefixes { if strings.HasPrefix(name, prefix) { name = strings.TrimPrefix(name, prefix) break } } return strings.TrimSpace(name) }