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
}