package normalize import ( "fmt" "path/filepath" "strings" "unicode" "golang.org/x/text/unicode/norm" ) // portableSymbols survive in a name. They are legal on ext4, APFS, NTFS and // exFAT, and in SMB and NFS shares, so there is nothing to gain by flattening // them. They do need quoting in a shell, which is the price of a title staying // readable. const portableSymbols = "()[]+&" // hostileSymbols are illegal on at least one filesystem people actually use -- // Windows forbids every one of them -- so they cannot be kept as they are. const hostileSymbols = `<>:"/\|?*` func Name(s string) string { runes := []rune(norm.NFKD.String(strings.TrimSpace(s))) var b strings.Builder sep := false // emit writes one separator and collapses a run of them. emit := func(c rune) { if sep || b.Len() == 0 { return } b.WriteRune(c) sep = true } for i, r := range runes { switch { case r == '\'' || r == '\u2019' || r == '\u02bc': continue case unicode.Is(unicode.Mn, r): continue case r < 128 && ((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')): b.WriteRune(r) sep = false case r < 128 && r >= 'A' && r <= 'Z': b.WriteRune(r + ('a' - 'A')) sep = false case r < 128 && strings.ContainsRune(portableSymbols, r): b.WriteRune(r) sep = false case r < 128 && strings.ContainsRune(hostileSymbols, r): // Inside a word it becomes an underscore, so while(1<2) reads as // while(1_2) rather than while(1-2). Next to a space it is just // another separator, so "Album: Title" stays album-title instead of // running the two words together as album_title. if embeddedInWord(runes, i) { emit('_') } else { emit('-') } default: emit('-') } } out := strings.Trim(b.String(), "-_") // A name that is only punctuation is no more usable than an empty one, and // the plan already knows how to ask the user for a real name. Without this // a title of "()" would quietly create a directory called "()". if !strings.ContainsFunc(out, func(r rune) bool { return (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') }) { return "" } return out } // embeddedInWord reports whether the rune at i has non-space characters on both // sides of it. func embeddedInWord(runes []rune, i int) bool { if i == 0 || i+1 >= len(runes) { return false } return !unicode.IsSpace(runes[i-1]) && !unicode.IsSpace(runes[i+1]) } // TrackFile builds a target file name. withDisc is false when the disc number // should be left out, which only makes sense for a single-disc album: two discs // sharing a numbering scheme produce two files both called 01_something. func TrackFile(disc, track, width int, title, ext string, withDisc bool) string { if ext != "" && !strings.HasPrefix(ext, ".") { ext = "." + ext } if !withDisc { return fmt.Sprintf("%0*d_%s%s", width, track, Name(title), ext) } return fmt.Sprintf("%d-%0*d_%s%s", disc, width, track, Name(title), ext) } func PadWidth(maxTrack int) int { switch { case maxTrack < 100: return 2 case maxTrack < 1000: return 3 default: return 4 } } func DirName(s string) string { return Name(s) } type Collision struct { Target string Causes []string } func (c Collision) Error() string { return fmt.Sprintf("normalisation collision on %q from %s", c.Target, strings.Join(c.Causes, ", ")) } func TrackName(target string) string { parts := strings.SplitN(target, "_", 2) if len(parts) != 2 { return target } name := parts[1] if i := strings.LastIndexByte(name, '.'); i >= 0 { name = name[:i] } return name } func DetectCollisions(items [][2]string) []Collision { byName := map[string][]string{} var order []string for _, it := range items { src, name := it[0], TrackName(it[1]) if _, ok := byName[name]; !ok { order = append(order, name) } dup := false for _, existing := range byName[name] { if existing == src { dup = true break } } if !dup { byName[name] = append(byName[name], src) } } var out []Collision for _, name := range order { if len(byName[name]) > 1 { out = append(out, Collision{Target: name, Causes: byName[name]}) } } return out } // TargetClashes reports tracks that would be written to exactly the same file. // // DetectCollisions cannot see this one. It groups by the title half of a target // and folds repeats of the same title together, so two tracks that share both a // title and a track number pass it unnoticed even though their whole target is // the same string. That pair is not merely untidy: the second write replaces the // first, so the album is filed a track short and every later check still passes. func TargetClashes(items [][2]string) []Collision { byName := map[string][]string{} var order []string for _, it := range items { name := filepath.Base(it[1]) if _, ok := byName[name]; !ok { order = append(order, name) } byName[name] = append(byName[name], it[0]) } var out []Collision for _, name := range order { if len(byName[name]) < 2 { continue } out = append(out, Collision{Target: name, Causes: byName[name]}) } return out } func Truncate(name string, max int) (string, bool) { if len(name) <= max { return name, false } ext := "" if i := strings.LastIndexByte(name, '.'); i >= 0 { if len(name)-i <= 6 { ext = name[i:] name = name[:i] } } budget := max - len(ext) if budget < 1 { budget = 1 } cut := name if len(cut) > budget { cut = cut[:budget] if i := strings.LastIndexByte(cut, '-'); i > 0 { cut = cut[:i] } } cut = strings.Trim(cut, "-") return cut + ext, true }