git/intake/main
internal/normalize/normalize.go
212 lines5 KB last change raw · history
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
}