Files
jellybit/internal/layout/name.go
T
av b9f0929d0c layout: непомещающееся целевое имя уводит задачу в review вместо failed
- предел длины компонента (255 байт) проверяется в BuildLinks до первой
  операции с ФС: ни каталога, ни ссылки при отказе не создаётся
- причина пустого предпросмотра считается на показе (ReviewData.PreviewError)
  и печатается в панели действий и в карточке Telegram: у задачи без
  записанной причины взять её больше неоткуда
2026-08-10 12:16:44 +03:00

172 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package layout
import (
"fmt"
"path/filepath"
"strings"
)
// maxComponentBytes — предел длины одного компонента целевого пути в БАЙТАХ
// UTF-8, а не в символах: ядро меряет NAME_MAX в байтах, и кириллическое
// название упирается в предел вдвое раньше латинского той же длины в знаках.
// Значение — NAME_MAX у ext4/xfs/btrfs; у ядра оно не выясняется, потому что
// раскладка обязана отказать до обращения к диску (см. spec file-layout).
// Цена обеих сторон: на ФС с меньшим пределом (часть зашифрованных) имя пройдёт
// проверку и упрётся в ядро — останется сегодняшний failed; на ФС с бо́льшим мы
// откажем строже, чем нужно. Лечение — правка этой константы, а не настройка:
// значение, которое некому выставить осознанно, не гибкость.
const maxComponentBytes = 255
// checkComponentLengths проверяет, что каждый компонент пути dst ПОД корнем
// root помещается в maxComponentBytes. Корень не проверяется: его каталоги задаёт
// оператор, и жаловаться на них раскладка не вправе. Возвращает ошибку,
// обёртывающую ErrNameTooLong и называющую непомещающийся компонент и его длину.
// Чистая функция: к диску не обращается.
func checkComponentLengths(root, dst string) error {
rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(dst))
if err != nil {
return fmt.Errorf("layout: relative target %q: %w", dst, err)
}
for c := range strings.SplitSeq(rel, string(filepath.Separator)) {
if len(c) > maxComponentBytes {
return fmt.Errorf("%w: %q — %d байт при пределе %d",
ErrNameTooLong, shorten(c), len(c), maxComponentBytes)
}
}
return nil
}
// errNameSample — сколько рун непомещающегося имени показать в тексте ошибки.
// Текст уезжает в error_msg, а оттуда в баннер ревью и в карточку Telegram:
// имя целиком (а оно по условию длиннее 255 байт) заняло бы там весь экран.
// Точную длину несёт число рядом, поэтому образца хватает, чтобы узнать имя.
const errNameSample = 40
// shorten оставляет от имени начало и конец, выкидывая середину. Режет по рунам:
// обрыв посреди многобайтовой буквы дал бы в сообщении мусор.
func shorten(s string) string {
r := []rune(s)
if len(r) <= errNameSample {
return s
}
head := errNameSample / 2
return string(r[:head]) + "…" + string(r[len(r)-head:])
}
// sanitizeComponent чистит один компонент пути (имя папки/файла): убирает
// разделители, управляющие символы и неудобные для ФС/SMB знаки, схлопывает
// пробелы и срезает точки/пробелы по краям. Кириллица и пробелы внутри
// сохраняются. Результат гарантированно не содержит '/', '\\', ".." целиком
// и не пуст (иначе ошибка у вызывающего).
func sanitizeComponent(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r < 0x20 || r == 0x7f: // управляющие
b.WriteByte(' ')
case strings.ContainsRune(`/\:*?"<>|`, r): // разделители и недопустимые в SMB/NTFS
b.WriteByte(' ')
default:
b.WriteRune(r)
}
}
out := strings.Join(strings.Fields(b.String()), " ") // схлопнуть пробелы
out = strings.Trim(out, " .") // края: ни точек, ни пробелов
return out
}
// titleYear строит базу "Название (Год)" или "Название" при year == 0.
func titleYear(title string, year int) (string, error) {
t := sanitizeComponent(title)
if t == "" {
return "", fmt.Errorf("layout: empty title after sanitization (%q)", title)
}
if year > 0 {
return fmt.Sprintf("%s (%d)", t, year), nil
}
return t, nil
}
// planBase выбирает базу имени плана: унаследованная от живого якоря
// FolderBase (правило сходимости папки) перекрывает Title/Year. База в любом
// случае санитизируется (FolderBase пришла с диска — прогон идемпотентен).
func planBase(p Plan) (string, error) {
if p.FolderBase != "" {
b := sanitizeComponent(p.FolderBase)
if b == "" {
return "", fmt.Errorf("layout: empty folder base after sanitization (%q)", p.FolderBase)
}
return b, nil
}
return titleYear(p.Title, p.Year)
}
// folderName добавляет provider-тег к базе: "Название (Год) [tmdbid-123]".
func folderName(base, providerTag string) string {
tag := sanitizeComponent(providerTag)
if tag == "" {
return base
}
return fmt.Sprintf("%s [%s]", base, tag)
}
// folderBase восстанавливает базу имени ("Название (Год)") из имени папки
// тайтла, снимая хвостовой provider-тег " [...]" (любой, а не только текущий:
// у переоценённого якоря тег мог остаться старым). Тег без пробела перед "["
// или незакрытый — не трогаем. Результат санитизируется; пустой → "" (вызывающий
// трактует как «нет якоря»).
func folderBase(folder string) string {
base := folder
if i := strings.LastIndex(folder, " ["); i >= 0 && strings.HasSuffix(folder, "]") {
base = folder[:i]
}
return sanitizeComponent(base)
}
// seasonFolder — "Season 00" (спецвыпуски) / "Season 01" / ...
func seasonFolder(season int) string {
return fmt.Sprintf("Season %02d", season)
}
// episodeStem — "Название (Год) S01E02"; при двойной серии episodeEnd>episode
// даёт "S01E02-E03".
func episodeStem(base string, season, episode, episodeEnd int) string {
if episodeEnd > episode {
return fmt.Sprintf("%s S%02dE%02d-E%02d", base, season, episode, episodeEnd)
}
return fmt.Sprintf("%s S%02dE%02d", base, season, episode)
}
// subtitleSuffix — ".ru", ".ru.forced" и т.п. (флаги после языка).
func subtitleSuffix(lang string, flags []string) string {
var b strings.Builder
if l := sanitizeComponent(lang); l != "" {
b.WriteByte('.')
b.WriteString(strings.ToLower(l))
}
for _, f := range flags {
if f = sanitizeComponent(f); f != "" {
b.WriteByte('.')
b.WriteString(strings.ToLower(f))
}
}
return b.String()
}
// ext возвращает расширение файла источника в нижнем регистре (с точкой).
func ext(src string) string {
return strings.ToLower(filepath.Ext(src))
}
// underRoot сообщает, лежит ли clean-путь p строго под каталогом root
// (после filepath.Clean у обоих). Защита от traversal: даже если имя
// прошло санитизацию, итог обязан остаться внутри библиотеки.
func underRoot(root, p string) bool {
root = filepath.Clean(root)
p = filepath.Clean(p)
if p == root {
return false // сам корень — не цель для файла
}
return strings.HasPrefix(p, root+string(filepath.Separator))
}