ci / module boundaries / boundaries (push) Successful in 7s
ci / db / db (push) Successful in 2m33s
ci / api / api (push) Successful in 1m10s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 3m45s
ci / ts / ts (packages + web) (push) Successful in 6m29s
673 lines
19 KiB
Go
673 lines
19 KiB
Go
package meta
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"path"
|
|
"regexp"
|
|
"regexp/syntax"
|
|
"strconv"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"golang.org/x/text/unicode/norm"
|
|
)
|
|
|
|
// Шаблон пути для записи (write_template, docs/uploads.md#7-раскладка-в-хранилище-как-принято-на-сервере):
|
|
//
|
|
// {albumartist}/{year} - {album}/{disc:1-}{track:02} - {title}.{ext}
|
|
//
|
|
// {name} — поле; {track:02} — номер с нулями до ширины 2; {disc:1-} — номер диска с суффиксом «-», только если
|
|
// дисков в альбоме больше одного; [ ... ] — необязательный фрагмент, опускается целиком, если пусто любое поле внутри.
|
|
|
|
// Пределы длины пути (байты UTF-8).
|
|
const (
|
|
MaxSegmentBytes = 200
|
|
MaxPathBytes = 1000
|
|
MaxTemplateLen = 500
|
|
)
|
|
|
|
var templateFields = map[string]bool{
|
|
"artist": true, "albumartist": true, "album": true, "title": true, "genre": true,
|
|
"year": true, "track": true, "disc": true, "ext": true,
|
|
}
|
|
|
|
// TemplateError — шаблон не разобрался (показывается под полем ввода).
|
|
type TemplateError struct{ Reason string }
|
|
|
|
func (e *TemplateError) Error() string { return "template: " + e.Reason }
|
|
|
|
// MissingFieldError — у трека нет значения обязательного (не в [ ]) поля шаблона.
|
|
type MissingFieldError struct{ Field string }
|
|
|
|
func (e *MissingFieldError) Error() string { return "template: missing field " + e.Field }
|
|
|
|
// TemplateInput — значения для шаблона.
|
|
type TemplateInput struct {
|
|
Fields Fields
|
|
DiscCount int // дисков в альбоме (для {disc:1-})
|
|
Ext string // расширение по реальному формату, без точки
|
|
}
|
|
|
|
type tnode struct {
|
|
lit string
|
|
field string
|
|
pad int // {track:02}
|
|
suffix string // {disc:1-}: выводится «<n><suffix>», только если дисков > 1
|
|
multi bool // есть суффикс-форма
|
|
opt []tnode
|
|
isOpt bool
|
|
}
|
|
|
|
// Template — разобранный шаблон пути.
|
|
type Template struct {
|
|
src string
|
|
nodes []tnode
|
|
}
|
|
|
|
// String — исходный текст шаблона.
|
|
func (t Template) String() string { return t.src }
|
|
|
|
// ParseTemplate разбирает и проверяет шаблон: поля известны, путь относительный, заканчивается на .{ext},
|
|
// есть {title}.
|
|
func ParseTemplate(src string) (Template, error) {
|
|
src = strings.TrimSpace(src)
|
|
if src == "" {
|
|
return Template{}, &TemplateError{"empty"}
|
|
}
|
|
if len(src) > MaxTemplateLen {
|
|
return Template{}, &TemplateError{fmt.Sprintf("longer than %d characters", MaxTemplateLen)}
|
|
}
|
|
if strings.HasPrefix(src, "/") || strings.Contains(src, "\\") {
|
|
return Template{}, &TemplateError{"must be a relative path with / separators"}
|
|
}
|
|
nodes, rest, err := parseNodes(src, false)
|
|
if err != nil {
|
|
return Template{}, err
|
|
}
|
|
if rest != "" {
|
|
return Template{}, &TemplateError{"unexpected ]"}
|
|
}
|
|
for _, seg := range strings.Split(src, "/") {
|
|
if seg == "" || seg == "." || seg == ".." {
|
|
return Template{}, &TemplateError{"empty, . or .. path segment"}
|
|
}
|
|
}
|
|
if !strings.HasSuffix(src, ".{ext}") {
|
|
return Template{}, &TemplateError{"must end with .{ext}"}
|
|
}
|
|
if !strings.Contains(src, "{title}") {
|
|
return Template{}, &TemplateError{"must contain {title}"}
|
|
}
|
|
return Template{src: src, nodes: nodes}, nil
|
|
}
|
|
|
|
func parseNodes(s string, inOpt bool) ([]tnode, string, error) {
|
|
var out []tnode
|
|
var lit strings.Builder
|
|
flush := func() {
|
|
if lit.Len() > 0 {
|
|
out = append(out, tnode{lit: lit.String()})
|
|
lit.Reset()
|
|
}
|
|
}
|
|
for len(s) > 0 {
|
|
switch s[0] {
|
|
case '{':
|
|
end := strings.IndexByte(s, '}')
|
|
if end < 0 {
|
|
return nil, "", &TemplateError{"unclosed {"}
|
|
}
|
|
n, err := parsePlaceholder(s[1:end])
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
flush()
|
|
out = append(out, n)
|
|
s = s[end+1:]
|
|
case '}':
|
|
return nil, "", &TemplateError{"unexpected }"}
|
|
case '[':
|
|
if inOpt {
|
|
return nil, "", &TemplateError{"nested [ ] are not supported"}
|
|
}
|
|
inner, rest, err := parseNodes(s[1:], true)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if !strings.HasPrefix(rest, "]") {
|
|
return nil, "", &TemplateError{"unclosed ["}
|
|
}
|
|
flush()
|
|
out = append(out, tnode{isOpt: true, opt: inner})
|
|
s = rest[1:]
|
|
case ']':
|
|
if !inOpt {
|
|
return nil, "", &TemplateError{"unexpected ]"}
|
|
}
|
|
flush()
|
|
return out, s, nil
|
|
default:
|
|
lit.WriteByte(s[0])
|
|
s = s[1:]
|
|
}
|
|
}
|
|
if inOpt {
|
|
return nil, "", &TemplateError{"unclosed ["}
|
|
}
|
|
flush()
|
|
return out, "", nil
|
|
}
|
|
|
|
func parsePlaceholder(body string) (tnode, error) {
|
|
name, format, hasFormat := strings.Cut(body, ":")
|
|
if !templateFields[name] {
|
|
return tnode{}, &TemplateError{fmt.Sprintf("unknown field {%s}", name)}
|
|
}
|
|
n := tnode{field: name}
|
|
if !hasFormat {
|
|
return n, nil
|
|
}
|
|
if name != "track" && name != "disc" {
|
|
return tnode{}, &TemplateError{fmt.Sprintf("{%s} has no format", name)}
|
|
}
|
|
switch {
|
|
case len(format) == 2 && format[0] == '0' && format[1] >= '1' && format[1] <= '9':
|
|
n.pad = int(format[1] - '0')
|
|
case name == "disc" && strings.HasPrefix(format, "1") && len(format) <= 4 && !strings.ContainsAny(format[1:], "/{}[]"):
|
|
n.multi, n.suffix = true, format[1:]
|
|
default:
|
|
return tnode{}, &TemplateError{fmt.Sprintf("bad format %q for {%s}", format, name)}
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// fieldValue — значение поля для пути (до очистки), "" — нет значения.
|
|
func fieldValue(in TemplateInput, n tnode) string {
|
|
f := in.Fields
|
|
switch n.field {
|
|
case "artist":
|
|
return f.Artist
|
|
case "albumartist":
|
|
if f.AlbumArtist != "" {
|
|
return f.AlbumArtist
|
|
}
|
|
return f.Artist
|
|
case "album":
|
|
return f.Album
|
|
case "title":
|
|
return f.Title
|
|
case "genre":
|
|
return f.Genre
|
|
case "year":
|
|
if y := parseYear(f.Year); y > 0 {
|
|
return strconv.Itoa(y)
|
|
}
|
|
return ""
|
|
case "ext":
|
|
return strings.ToLower(strings.TrimPrefix(in.Ext, "."))
|
|
case "track", "disc":
|
|
raw := f.Track
|
|
if n.field == "disc" {
|
|
raw = f.Disc
|
|
}
|
|
num := parseNumber(raw)
|
|
if num <= 0 {
|
|
return ""
|
|
}
|
|
if n.multi {
|
|
if in.DiscCount <= 1 {
|
|
return ""
|
|
}
|
|
return strconv.Itoa(num) + n.suffix
|
|
}
|
|
return fmt.Sprintf("%0*d", n.pad, num)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Render строит относительный путь по шаблону. Значения очищаются (запрещённые символы → `_`, NFC),
|
|
// сегменты обрезаются до MaxSegmentBytes, путь — до MaxPathBytes с сохранением расширения.
|
|
func (t Template) Render(in TemplateInput) (string, error) {
|
|
var b strings.Builder
|
|
for _, n := range t.nodes {
|
|
switch {
|
|
case n.isOpt:
|
|
part, ok := renderOptional(in, n.opt)
|
|
if ok {
|
|
b.WriteString(part)
|
|
}
|
|
case n.field != "":
|
|
v := cleanValue(fieldValue(in, n))
|
|
if v == "" {
|
|
// {disc:1-} у однодискового альбома пуст по смыслу, это не ошибка.
|
|
if n.multi {
|
|
continue
|
|
}
|
|
return "", &MissingFieldError{Field: n.field}
|
|
}
|
|
b.WriteString(v)
|
|
default:
|
|
b.WriteString(n.lit)
|
|
}
|
|
}
|
|
return finishPath(b.String())
|
|
}
|
|
|
|
// RenderWithoutMissingYear omits an unknown year and its punctuation for uploads.
|
|
// Other required metadata still uses strict rendering. Optional fragments retain
|
|
// their usual all-or-nothing semantics.
|
|
func (t Template) RenderWithoutMissingYear(in TemplateInput) (string, error) {
|
|
if fieldValue(in, tnode{field: "year"}) != "" {
|
|
return t.Render(in)
|
|
}
|
|
var src strings.Builder
|
|
for _, n := range t.nodes {
|
|
if n.isOpt {
|
|
if _, ok := renderOptional(in, n.opt); ok {
|
|
src.WriteString("[")
|
|
for _, o := range n.opt {
|
|
src.WriteString(templateNodeSource(o))
|
|
}
|
|
src.WriteString("]")
|
|
}
|
|
} else {
|
|
src.WriteString(templateNodeSource(n))
|
|
}
|
|
}
|
|
s := src.String()
|
|
for _, re := range missingYearParts {
|
|
s = re.ReplaceAllString(s, "")
|
|
}
|
|
// A year-only directory disappears as a whole.
|
|
var segments []string
|
|
for _, seg := range strings.Split(s, "/") {
|
|
if strings.TrimSpace(seg) != "" {
|
|
segments = append(segments, seg)
|
|
}
|
|
}
|
|
tpl, err := ParseTemplate(strings.Join(segments, "/"))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return tpl.Render(in)
|
|
}
|
|
|
|
var missingYearParts = []*regexp.Regexp{
|
|
regexp.MustCompile(`\(\s*\{year\}\s*\)`),
|
|
regexp.MustCompile(`\{year\}[ \t]*[-–—_][ \t]*`),
|
|
regexp.MustCompile(`[ \t]*[-–—_][ \t]*\{year\}`),
|
|
regexp.MustCompile(`\{year\}`),
|
|
}
|
|
|
|
func templateNodeSource(n tnode) string {
|
|
if n.field == "" {
|
|
return n.lit
|
|
}
|
|
if n.multi {
|
|
return "{" + n.field + ":1" + n.suffix + "}"
|
|
}
|
|
if n.pad > 0 {
|
|
return fmt.Sprintf("{%s:0%d}", n.field, n.pad)
|
|
}
|
|
return "{" + n.field + "}"
|
|
}
|
|
|
|
func renderOptional(in TemplateInput, nodes []tnode) (string, bool) {
|
|
var b strings.Builder
|
|
for _, n := range nodes {
|
|
if n.field == "" {
|
|
b.WriteString(n.lit)
|
|
continue
|
|
}
|
|
v := cleanValue(fieldValue(in, n))
|
|
if v == "" {
|
|
return "", false
|
|
}
|
|
b.WriteString(v)
|
|
}
|
|
return b.String(), true
|
|
}
|
|
|
|
// cleanValue — значение поля внутри сегмента: без разделителей пути и запрещённых в именах символов.
|
|
func cleanValue(s string) string {
|
|
s = norm.NFC.String(strings.TrimSpace(s))
|
|
return strings.Map(func(r rune) rune {
|
|
if r < 0x20 || r == 0x7f || strings.ContainsRune(`/\:*?"<>|`, r) {
|
|
return '_'
|
|
}
|
|
return r
|
|
}, s)
|
|
}
|
|
|
|
// finishPath нормализует сегменты: без точек и пробелов в конце, без скрытых имён, длина в пределах.
|
|
func finishPath(p string) (string, error) {
|
|
segs := strings.Split(p, "/")
|
|
for i, seg := range segs {
|
|
last := i == len(segs)-1
|
|
if last {
|
|
ext := path.Ext(seg)
|
|
base := trimSegment(strings.TrimSuffix(seg, ext))
|
|
base = truncateBytes(base, MaxSegmentBytes-len(ext))
|
|
seg = base + ext
|
|
} else {
|
|
seg = truncateBytes(trimSegment(seg), MaxSegmentBytes)
|
|
seg = strings.TrimRight(seg, ". ")
|
|
}
|
|
if strings.HasPrefix(seg, ".") {
|
|
seg = "_" + seg[1:]
|
|
}
|
|
if seg == "" || seg == "_" && last {
|
|
return "", &MissingFieldError{Field: "title"}
|
|
}
|
|
segs[i] = seg
|
|
}
|
|
out := strings.Join(segs, "/")
|
|
if over := len(out) - MaxPathBytes; over > 0 {
|
|
lastSeg := segs[len(segs)-1]
|
|
ext := path.Ext(lastSeg)
|
|
base := strings.TrimSuffix(lastSeg, ext)
|
|
if len(base)-over < 1 {
|
|
return "", &TemplateError{"path is too long"}
|
|
}
|
|
segs[len(segs)-1] = truncateBytes(base, len(base)-over) + ext
|
|
out = strings.Join(segs, "/")
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func trimSegment(s string) string { return strings.TrimRight(strings.TrimSpace(s), ". ") }
|
|
|
|
// truncateBytes обрезает строку до n байт по границе символа.
|
|
func truncateBytes(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
if n <= 0 {
|
|
return ""
|
|
}
|
|
for n > 0 && !utf8.RuneStart(s[n]) {
|
|
n--
|
|
}
|
|
return strings.TrimRight(s[:n], " ")
|
|
}
|
|
|
|
// --- Автоподбор и проверка обратимости ---
|
|
|
|
// ErrNoTemplate — ни одно выражение источника не удалось превратить в шаблон.
|
|
var ErrNoTemplate = errors.New("template: no pattern can be converted")
|
|
|
|
// SuggestTemplate выбирает выражение, совпадающее с наибольшим числом путей выборки, и строит шаблон,
|
|
// порождающий такие же пути: именованные группы → поля, литералы между ними → текст, необязательная группа → [ ].
|
|
// Ширина номера трека берётся из выборки (01 → {track:02}).
|
|
func SuggestTemplate(exprs []string, samplePaths []string) (string, error) {
|
|
type cand struct {
|
|
expr string
|
|
re *regexp.Regexp
|
|
count int
|
|
}
|
|
var cands []cand
|
|
for _, e := range exprs {
|
|
re, err := regexp.Compile(e)
|
|
if err != nil || re.SubexpIndex("skip") >= 0 {
|
|
continue
|
|
}
|
|
c := cand{expr: e, re: re}
|
|
for _, p := range samplePaths {
|
|
if re.MatchString(p) {
|
|
c.count++
|
|
}
|
|
}
|
|
if c.count > 0 {
|
|
cands = append(cands, c)
|
|
}
|
|
}
|
|
// Стабильно по убыванию совпадений: при равенстве — порядок шаблонов источника.
|
|
for i := 1; i < len(cands); i++ {
|
|
for j := i; j > 0 && cands[j].count > cands[j-1].count; j-- {
|
|
cands[j], cands[j-1] = cands[j-1], cands[j]
|
|
}
|
|
}
|
|
for _, c := range cands {
|
|
pad := trackWidth(c.re, samplePaths)
|
|
if tpl, err := regexToTemplate(c.expr, pad); err == nil {
|
|
if _, err := ParseTemplate(tpl); err == nil {
|
|
return tpl, nil
|
|
}
|
|
}
|
|
}
|
|
return "", ErrNoTemplate
|
|
}
|
|
|
|
// trackWidth — ширина номера трека в путях выборки (0 — без дополнения нулями).
|
|
func trackWidth(re *regexp.Regexp, paths []string) int {
|
|
gi := re.SubexpIndex("track")
|
|
if gi < 0 {
|
|
return 0
|
|
}
|
|
widths := map[int]int{}
|
|
for _, p := range paths {
|
|
m := re.FindStringSubmatch(p)
|
|
if m == nil || m[gi] == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(m[gi], "0") {
|
|
widths[len(m[gi])]++
|
|
} else {
|
|
widths[0]++
|
|
}
|
|
}
|
|
best, n := 0, -1
|
|
for w, c := range widths {
|
|
if c > n || c == n && w > best {
|
|
best, n = w, c
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
func regexToTemplate(expr string, trackPad int) (string, error) {
|
|
re, err := syntax.Parse(expr, syntax.Perl)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
nodes := []*syntax.Regexp{re}
|
|
if re.Op == syntax.OpConcat {
|
|
nodes = re.Sub
|
|
}
|
|
var b strings.Builder
|
|
for i, n := range nodes {
|
|
// Хвост «\.\w+$» / «\.(flac|mp3)$» — расширение.
|
|
if isExtTail(nodes, i) {
|
|
b.WriteString("{ext}")
|
|
continue
|
|
}
|
|
s, err := convertNode(n, trackPad, false)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
b.WriteString(s)
|
|
}
|
|
return b.String(), nil
|
|
}
|
|
|
|
func isExtTail(nodes []*syntax.Regexp, i int) bool {
|
|
rest := nodes[i:]
|
|
for len(rest) > 0 && rest[len(rest)-1].Op == syntax.OpEndText {
|
|
rest = rest[:len(rest)-1]
|
|
}
|
|
if len(rest) != 1 || i == 0 {
|
|
return false
|
|
}
|
|
prev := nodes[i-1]
|
|
if prev.Op != syntax.OpLiteral || !strings.HasSuffix(string(prev.Rune), ".") {
|
|
return false
|
|
}
|
|
n := rest[0]
|
|
if n.Op == syntax.OpCapture && n.Name == "" {
|
|
n = n.Sub[0]
|
|
}
|
|
switch n.Op {
|
|
case syntax.OpPlus, syntax.OpStar, syntax.OpRepeat, syntax.OpAlternate, syntax.OpCharClass, syntax.OpConcat:
|
|
return true
|
|
case syntax.OpLiteral:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func convertNode(n *syntax.Regexp, trackPad int, inOpt bool) (string, error) {
|
|
switch n.Op {
|
|
case syntax.OpBeginText, syntax.OpEndText, syntax.OpBeginLine, syntax.OpEndLine, syntax.OpEmptyMatch:
|
|
return "", nil
|
|
case syntax.OpLiteral:
|
|
s := string(n.Rune)
|
|
if strings.ContainsAny(s, "{}[]") {
|
|
return "", errors.New("template: literal with braces")
|
|
}
|
|
return s, nil
|
|
case syntax.OpConcat:
|
|
var b strings.Builder
|
|
for _, sub := range n.Sub {
|
|
s, err := convertNode(sub, trackPad, inOpt)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
b.WriteString(s)
|
|
}
|
|
return b.String(), nil
|
|
case syntax.OpCapture:
|
|
if n.Name == "" {
|
|
return convertNode(n.Sub[0], trackPad, inOpt)
|
|
}
|
|
if !templateFields[n.Name] {
|
|
return "", fmt.Errorf("template: group %s", n.Name)
|
|
}
|
|
if n.Name == "track" && trackPad > 1 {
|
|
return fmt.Sprintf("{track:0%d}", trackPad), nil
|
|
}
|
|
return "{" + n.Name + "}", nil
|
|
case syntax.OpQuest:
|
|
if inOpt {
|
|
return "", errors.New("template: nested optional")
|
|
}
|
|
sub := n.Sub[0]
|
|
if sub.Op == syntax.OpCapture && sub.Name == "" {
|
|
sub = sub.Sub[0]
|
|
}
|
|
// (?:(?P<disc>\d)-)? → {disc:1-}
|
|
if sub.Op == syntax.OpConcat && len(sub.Sub) == 2 && sub.Sub[0].Op == syntax.OpCapture &&
|
|
sub.Sub[0].Name == "disc" && sub.Sub[1].Op == syntax.OpLiteral {
|
|
return "{disc:1" + string(sub.Sub[1].Rune) + "}", nil
|
|
}
|
|
s, err := convertNode(sub, trackPad, true)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !strings.Contains(s, "{") {
|
|
return "", errors.New("template: optional part without fields")
|
|
}
|
|
return "[" + s + "]", nil
|
|
}
|
|
return "", fmt.Errorf("template: unsupported %v", n.Op)
|
|
}
|
|
|
|
// TemplateSample — существующий файл источника и его метаданные из каталога.
|
|
type TemplateSample struct {
|
|
RelPath string
|
|
Input TemplateInput
|
|
}
|
|
|
|
// TemplateMismatch — пример расхождения для показа админу.
|
|
type TemplateMismatch struct {
|
|
RelPath string
|
|
Rendered string
|
|
Fields []string // поля, которые регэкспы распознали не так (или "pattern", если ни одно не совпало)
|
|
Error string // шаблон не построился (нет поля)
|
|
}
|
|
|
|
// TemplateCheck — итог проверки обратимости.
|
|
type TemplateCheck struct {
|
|
Total int
|
|
RoundTrip int // путь разобран регэкспами обратно без изменений
|
|
SamePath int // путь совпал с реальным
|
|
OK bool
|
|
Mismatches []TemplateMismatch // до 10 примеров
|
|
}
|
|
|
|
// MinSamePathRatio — доля путей, которые должны совпасть с реальными.
|
|
const MinSamePathRatio = 0.9
|
|
|
|
// CheckTemplate применяет шаблон к файлам выборки и прогоняет результат через регэкспы источника:
|
|
// поля должны распознаться обратно без изменений, а в ≥ 90% случаев путь — совпасть с реальным.
|
|
func CheckTemplate(t Template, patterns []Pattern, samples []TemplateSample) TemplateCheck {
|
|
res := TemplateCheck{Total: len(samples)}
|
|
for _, s := range samples {
|
|
rendered, err := t.Render(s.Input)
|
|
if err != nil {
|
|
res.add(TemplateMismatch{RelPath: s.RelPath, Error: err.Error()})
|
|
continue
|
|
}
|
|
same := norm.NFC.String(rendered) == norm.NFC.String(s.RelPath)
|
|
if same {
|
|
res.SamePath++
|
|
}
|
|
bad := roundTrip(t, patterns, s.Input, rendered)
|
|
if len(bad) == 0 {
|
|
res.RoundTrip++
|
|
}
|
|
if len(bad) > 0 || !same {
|
|
res.add(TemplateMismatch{RelPath: s.RelPath, Rendered: rendered, Fields: bad})
|
|
}
|
|
}
|
|
res.OK = res.Total > 0 && res.RoundTrip == res.Total &&
|
|
float64(res.SamePath) >= MinSamePathRatio*float64(res.Total)
|
|
return res
|
|
}
|
|
|
|
func (c *TemplateCheck) add(m TemplateMismatch) {
|
|
if len(c.Mismatches) < 10 {
|
|
c.Mismatches = append(c.Mismatches, m)
|
|
}
|
|
}
|
|
|
|
// roundTrip — поля шаблона, которые регэкспы не вернули такими, какими они записаны в путь.
|
|
func roundTrip(t Template, patterns []Pattern, in TemplateInput, rendered string) []string {
|
|
m := MatchPath(patterns, rendered)
|
|
if m.Index < 0 || m.Skip {
|
|
return []string{"pattern"}
|
|
}
|
|
var bad []string
|
|
check := func(n tnode, inOpt bool) {
|
|
if n.field == "" || n.field == "ext" {
|
|
return
|
|
}
|
|
want := cleanValue(fieldValue(in, n))
|
|
if n.multi {
|
|
want = strings.TrimSuffix(want, n.suffix)
|
|
}
|
|
got := m.Fields[n.field]
|
|
if n.field == "track" || n.field == "disc" {
|
|
if want == "" && got == "" || parseNumber(want) == parseNumber(got) && want != "" {
|
|
return
|
|
}
|
|
} else if strings.TrimSpace(want) == got {
|
|
return
|
|
}
|
|
if want == "" && inOpt {
|
|
return
|
|
}
|
|
bad = append(bad, n.field)
|
|
}
|
|
for _, n := range t.nodes {
|
|
if n.isOpt {
|
|
for _, o := range n.opt {
|
|
check(o, true)
|
|
}
|
|
continue
|
|
}
|
|
check(n, false)
|
|
}
|
|
return bad
|
|
}
|