From b9f0929d0c93fc176c4c78858a48aa63d1def584 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Mon, 10 Aug 2026 12:16:44 +0300 Subject: [PATCH] =?UTF-8?q?layout:=20=D0=BD=D0=B5=D0=BF=D0=BE=D0=BC=D0=B5?= =?UTF-8?q?=D1=89=D0=B0=D1=8E=D1=89=D0=B5=D0=B5=D1=81=D1=8F=20=D1=86=D0=B5?= =?UTF-8?q?=D0=BB=D0=B5=D0=B2=D0=BE=D0=B5=20=D0=B8=D0=BC=D1=8F=20=D1=83?= =?UTF-8?q?=D0=B2=D0=BE=D0=B4=D0=B8=D1=82=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87?= =?UTF-8?q?=D1=83=20=D0=B2=20review=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE?= =?UTF-8?q?=20failed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - предел длины компонента (255 байт) проверяется в BuildLinks до первой операции с ФС: ни каталога, ни ссылки при отказе не создаётся - причина пустого предпросмотра считается на показе (ReviewData.PreviewError) и печатается в панели действий и в карточке Telegram: у задачи без записанной причины взять её больше неоткуда --- .../ADR-2026-08-10-reason-computed-on-read.md | 77 ++++++ docs/adr/README.md | 1 + docs/architecture.md | 2 + docs/conventions/errors.md | 9 +- docs/database.md | 1 + docs/review.md | 51 ++++ docs/security.md | 5 +- internal/httpapi/action_swap_test.go | 55 ++++ internal/httpapi/httpapi.go | 7 +- internal/httpapi/httpapi_test.go | 22 ++ internal/httpapi/review.go | 7 +- internal/layout/layout.go | 10 + internal/layout/name.go | 47 ++++ internal/layout/name_length_test.go | 250 ++++++++++++++++++ internal/tgbot/bot.go | 9 + internal/tgbot/bot_test.go | 24 +- internal/tgbot/render.go | 15 ++ internal/tgbot/render_preview_problem_test.go | 52 ++++ internal/worker/review.go | 40 ++- internal/worker/review_test.go | 139 ++++++++++ internal/worker/worker.go | 3 +- .../.openspec.yaml | 2 + .../2026-08-10-long-title-to-review/design.md | 106 ++++++++ .../proposal.md | 47 ++++ .../review/report.md | 161 +++++++++++ .../specs/file-layout/spec.md | 70 +++++ .../specs/review/spec.md | 63 +++++ .../2026-08-10-long-title-to-review/tasks.md | 115 ++++++++ openspec/specs/file-layout/spec.md | 69 +++++ openspec/specs/review/spec.md | 62 +++++ web/templates/partials/review_main.html | 7 +- 31 files changed, 1511 insertions(+), 17 deletions(-) create mode 100644 docs/adr/ADR-2026-08-10-reason-computed-on-read.md create mode 100644 internal/layout/name_length_test.go create mode 100644 internal/tgbot/render_preview_problem_test.go create mode 100644 openspec/changes/archive/2026-08-10-long-title-to-review/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-10-long-title-to-review/design.md create mode 100644 openspec/changes/archive/2026-08-10-long-title-to-review/proposal.md create mode 100644 openspec/changes/archive/2026-08-10-long-title-to-review/review/report.md create mode 100644 openspec/changes/archive/2026-08-10-long-title-to-review/specs/file-layout/spec.md create mode 100644 openspec/changes/archive/2026-08-10-long-title-to-review/specs/review/spec.md create mode 100644 openspec/changes/archive/2026-08-10-long-title-to-review/tasks.md diff --git a/docs/adr/ADR-2026-08-10-reason-computed-on-read.md b/docs/adr/ADR-2026-08-10-reason-computed-on-read.md new file mode 100644 index 0000000..605c122 --- /dev/null +++ b/docs/adr/ADR-2026-08-10-reason-computed-on-read.md @@ -0,0 +1,77 @@ +# Причина, по которой человек не видит плана, считается на показе, а не читается из состояния + +- **Дата:** 2026-08-10 +- **Источник:** + [openspec/changes/archive/2026-08-10-long-title-to-review/design.md](../../openspec/changes/archive/2026-08-10-long-title-to-review/design.md), + Решение 6 и раздел `Risks / Trade-offs`; отчёт триажа того же change, + находка 1 + +## Контекст + +Проверка длины целевого имени встала в `layout.BuildLinks` — туда же, где +собираются оба предпросмотра экрана ревью. Это дало даром совпадение показанного +с применённым, но и вторую половину: непомещающееся имя обнуляет предпросмотр, а +без предпросмотра экран прячет команду «Применить». + +Первым решением панель действий брала текст из `error_msg` — причины, записанной +при последнем переходе. Ревью показало, что на самом частом входе этого поля +нет вовсе: задача, пришедшая в `review` из-за отсутствия матча, попадает туда с +пустой причиной и до раскладки не доходит. Замер триажа на двух деревьях: + +``` +до изменения: предпросмотр строится, «Применить» доступна, + применение доводит до failed с текстом ядра +после: предпросмотр пуст, команды нет, причины нет — + экран печатает «Подтверди источник», хотя источник ни при чём +``` + +То есть изменение, чья цель — «человек узнаёт причину», на этом входе +диагностируемость ухудшало. + +## Решение + +**Причина отказа считается в момент показа и отдаётся транспорту значением +(`worker.ReviewData.PreviewError`); записанная в состоянии используется только +когда посчитанной нет.** + +Цитата из `design.md`, Решение 6: + +> Оба предпросмотра ревью (карточка и строка источника) строят пути тем же +> `BuildLinks`, поэтому вердикт на показе и вердикт на применении совпадают по +> устройству, а не по договорённости. + +Отсюда следует и обратное: раз вердикт считается на показе, там же считается и +его причина. Посчитанная предпочитается записанной по двум причинам сразу: +записанной может не быть вовсе, а после смены источника она уже про другой план — команды, +меняющие эффективный источник, поля ошибки не чистят. + +**Чтение при этом состояние не двигает.** Построение предпросмотра остаётся без +побочных эффектов; причина уходит наружу возвращаемым значением. + +Это второй случай одного класса за день. Первый — +[ADR-2026-08-10-sanitize-at-every-entry](ADR-2026-08-10-sanitize-at-every-entry.md): +гарантия, поставленная на запись, не покрывает то, что записано раньше. Здесь она +не покрывает то, что не записано вовсе. + +## Рассмотренные варианты + +- **Записывать причину в состояние при построении предпросмотра.** Отвергнуто: + чтение начало бы двигать состояние. Запрет уже стоял в коде отдельным + комментарием — предпросмотр не переводит задачу в `review` при рассинхроне + папок, — и заводить исключение ради текста на экране значило бы снять правило. +- **Оставить как есть, записав остаток сценарием спеки.** Отвергнуто на + чекпоинте: регресс диагностируемости дошёл бы до боевого окружения на самом + частом входе. +- **Печатать причину только в баннере состояния, панель не трогать.** Отвергнуто: + баннер показывает записанное и на этом входе пуст ровно так же. + +## Цена + +Причина живёт в двух местах — записанная в состоянии и посчитанная на показе, — и +порядок между ними держится на ревью, а не на типе. Взамен экран ревью объясняет +отсутствие команды всегда, а не только когда причину успели записать, и +объяснение относится к текущему плану, а не к прошлому. + +Побочно: тот же текст может оказаться и в баннере, и в панели, когда записанная +причина совпала с посчитанной. Дубль признан приемлемым — он честен, а +код, который его снимал бы, дороже. diff --git a/docs/adr/README.md b/docs/adr/README.md index 954bbf6..80d0e8a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -42,6 +42,7 @@ | Дата | Запись | Статус | | --- | --- | --- | +| 2026-08-10 | [Причина, по которой человек не видит плана, считается на показе, а не читается из состояния](ADR-2026-08-10-reason-computed-on-read.md) | — | | 2026-08-10 | [Значение метабазы чистится на каждой точке входа в план, три санитайзера не сводятся в один](ADR-2026-08-10-sanitize-at-every-entry.md) | — | | 2026-08-07 | [Локаль TVDB читается из ответа поиска, а не передаётся в запрос](ADR-2026-08-07-tvdb-locale-reads-response.md) | — | | 2026-08-06 | [Спека следует за кодом, когда гарантия недостижима, а окно узкое](ADR-2026-08-06-spec-follows-code-on-narrow-window.md) | — | diff --git a/docs/architecture.md b/docs/architecture.md index 34acfdf..01e76af 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -116,6 +116,8 @@ | Приём | use-case `ingest` — общий путь для HTTP, веб-UI, Telegram и CLI | | Переходы состояний | `worker` под per-download блокировкой; легальность перехода задаётся декларативным графом | | Хардлинки и удаление своих ссылок | `internal/layout` — единственное место, которое пишет в файловую систему библиотеки | +| Построение и проверка целевого пути | `layout.BuildLinks` — единственная сборка пути; там же обе проверки, и порядок значим: нахождение под корнем библиотеки, затем длина компонента. Отсюда же строятся оба предпросмотра ревью, поэтому показанное и применённое совпадают устройством, а не договорённостью | +| Причина, по которой человек не видит плана | считается **на показе** (`worker.ReviewData.PreviewError`) и предпочитается записанной в состоянии: записанной может не быть вовсе, а после смены источника она уже про другой план — [ADR-2026-08-10-reason-computed-on-read](adr/ADR-2026-08-10-reason-computed-on-read.md) | | Трансляция доменной ошибки в код ответа | внешняя граница транспорта (`httpapi`, `tgbot`); правило — [conventions/errors.md](conventions/errors.md) | | Логирующий чекпоинт | доменная граница, один на операцию; правило — [conventions/logging.md](conventions/logging.md) | | Настройки | один TOML-файл, валидируется на старте; образец `config.example.toml` — источник истины по полям | diff --git a/docs/conventions/errors.md b/docs/conventions/errors.md index 0ef079a..b6bc511 100644 --- a/docs/conventions/errors.md +++ b/docs/conventions/errors.md @@ -88,6 +88,7 @@ jellybit — **приложение, а не библиотека**: внешн | `errInvalidCandidate` (выбран несуществующий кандидат, локальный sentinel `httpapi`) | 400 | текст самой ошибки | | `worker.ErrNotReady` (источник ещё качается) | 409 | «торрент ещё качается…» | | `layout.ErrCollision` (цель занята, ушло в review) | 409 | «целевой файл уже существует…» | + | `layout.ErrNameTooLong` (целевое имя не помещается, ушло в review) | 409 | «целевое имя слишком длинное…» | | `worker.ErrConflict` (операция недопустима сейчас) | 409 | «действие недоступно в текущем состоянии» | | прочее | 500 | «внутренняя ошибка» | @@ -116,7 +117,13 @@ jellybit — **приложение, а не библиотека**: внешн error_msg вычищаем на границе клиента (`logging.SanitizeErr` для ошибок транспорта, несущих URL с секретом); - это **не** канал для транзиентных отказов команд — те остаются нейтральными - (см. выше). + (см. выше); + - **внешнее значение в тексте усекается на границе, а его размер называется + числом.** `error_msg` уезжает в баннер ревью, в панель действий и в карточку + Telegram; имя файла на 400 байт занимает там экран целиком и оседает в БД + навсегда. Усечение — серединой и по рунам (`layout.shorten`, + `naming.truncate`, `tgbot.shorten`), точная величина остаётся числом рядом: + без неё человек не поймёт, насколько сокращать. ## panic diff --git a/docs/database.md b/docs/database.md index d2898b4..d15dcee 100644 --- a/docs/database.md +++ b/docs/database.md @@ -212,6 +212,7 @@ erDiagram | Константа | Значение | Что означает | | --- | --- | --- | | `ingest.MaxTorrentSize` | `8 MiB` | предел размера принимаемого `.torrent`; проверяется **до** разбора, поэтому bencode-аллокации на эту величину не масштабируются (см. [research/torrent-bencode-limits.md](research/torrent-bencode-limits.md)) | +| `layout.maxComponentBytes` | `255` байт | предел длины компонента целевого пути (`NAME_MAX` у ext4/xfs/btrfs); меряется в байтах UTF-8, проверяется **до** первой операции с ФС, отказ уводит задачу в `review` с кодом `name_too_long`. У ядра не выясняется; на ФС с меньшим пределом остаётся отказ ядра — лечение правкой константы, а не настройкой | **Ретеншена нет ни у одной таблицы**, лимита на размер тела ответа LLM нет, кэша метабаз нет; состояние по каждому пробелу и заведённые под них задачи — diff --git a/docs/review.md b/docs/review.md index 3dfacc0..bda5836 100644 --- a/docs/review.md +++ b/docs/review.md @@ -185,6 +185,11 @@ Go-сервиса и что здесь уже проскакивало. Устр чтение — и что будет с данными, записанными до деплоя, которые обычный путь не перезаписывает? (журнал, 2026-08-10: чистка названия стояла на записи, и очередь ревью её обходила) +- `operations`: новая проверка встала в общую точку — что она отняла у тех, кто + зовёт эту точку не ради проверки? Пропала ли команда, стал ли предпросмотр пустым, перестал ли экран объяснять + причину, и узнает ли человек причину на **каждом** входе, а не только + на том, который разбирали? (журнал, 2026-08-10: проверка длины погасила + «Применить» и ничего не объяснила) - `operations`: не удваивает ли новая ветка расход лимита метабаз и платного LLM — повтор, ретрай, «распознать заново» на том же входе? (кэша ответов нет, задача `metadata-cache`) @@ -320,6 +325,52 @@ Go-сервиса и что здесь уже проскакивало. Устр случаи до этой даты не восстанавливались — восстановленная постфактум причина непоймания недостоверна, а именно она и нужна. +## 2026-08-10 — проверка встала в общую точку и погасила кнопку, ничего не объяснив [пойман] + +- **Где:** `internal/layout/layout.go` — `BuildLinks`; `internal/worker/review.go` + — сборка предпросмотра; `web/templates/partials/review_main.html` — панель + действий. Норма — `openspec/specs/review/spec.md`, требование «Панель действий + при пустом предпросмотре называет причину». +- **Симптом:** новая проверка длины целевого имени встала в `BuildLinks` — + единственную сборку пути, откуда строятся и оба предпросмотра ревью. Отказ + обнулил предпросмотр, а без него экран прячет команду «Применить». Текст + причины брался из `error_msg` последнего перехода, но на самом частом входе + (распознавание без матча) это поле пусто: задача приходит в `review` без + причины и до раскладки не доходит. Владелец видел «Подтверди источник» и не + видел ни кнопки, ни настоящей причины. **До изменения** предпросмотр строился, + кнопка была, и применение доводило хотя бы до `failed` с текстом ядра. +- **Почему не поймали раньше:** обе стороны выглядели верными по отдельности. + Проверка в общей точке — правильное решение, оно и дало совпадение показанного + с применённым. Текст из `error_msg` — тоже правильное, для того входа, который + разбирали на чекпоинте (ручное «Применить» причину записывает). Развилка была в + том, что вход не один, и второй — частотнее. +- **Чем ловится теперь:** причина считается на показе + ([ADR-2026-08-10-reason-computed-on-read](adr/ADR-2026-08-10-reason-computed-on-read.md)), + тесты `TestReviewData_PreviewErrorWhenStateHasNoReason`, + `TestActionBarNamesReasonWithoutPreview`, `TestReviewCard_NamesPreviewProblem`. + В вопросы темы `operations` добавлен вопрос про сужение видимого. + +## 2026-08-10 — спека нормировала случай, которого код произвести не может [пойман] + +- **Где:** дельта `openspec/specs/file-layout/spec.md`, сценарий про + унаследованную от папки-якоря базу. +- **Симптом:** на чекпоинте ревью дизайна был назван тупик — при живом якоре база + берётся с диска, подсказка её не укорачивает, задача циклится. Сценарий уехал в + спеку с инструкцией человеку («выбрать источник без базы либо переименовать + папку руками»). Попытка написать под него тест показала, что случай + **недостижим**: папка-якорь лежит на диске и потому уже не длиннее предела, а + хвост имени файла (`" S02E01"` плюс расширение, 11 байт) не длиннее хвоста + имени папки (`" ["` плюс provider-тег плюс `"]"`, минимум 11 байт). +- **Почему не поймали раньше:** случай звучал правдоподобно и опирался на верное + свойство (унаследованная база подсказкой не меняется). Ни один проход ревью + дизайна арифметику не считал — кода на той стадии нет, а сценарий выглядел как + описание существующего поведения, а не как гипотеза. +- **Чем ловится теперь:** сценарий переписан на верное утверждение, свойство + закреплено тестом `TestApply_InheritedBaseAtLimitStillFits` — он покраснеет, + если суффиксы имён вырастут. Урок общий: **сценарий, который нельзя + воспроизвести, обязан получить оракул до того, как попадёт в спеку**; норма, + описывающая недостижимое, не отличается от неверной. + ## 2026-08-10 — чистка названия метабазы стояла только на записи, и очередь ревью её обходила [пойман] - **Где:** `internal/worker/review.go` — `sourcePins`, `applyOverrides`, diff --git a/docs/security.md b/docs/security.md index 06435e2..7e0f87a 100644 --- a/docs/security.md +++ b/docs/security.md @@ -49,7 +49,10 @@ REST API работают **без авторизации** осознанно; - **Правило:** компоненты санитизируются (убираются разделители пути, `..`, управляющие символы), финальный `filepath.Clean`-путь обязан быть **строго под** соответствующей библиотекой, иначе операция отклоняется. Проверка на - результате, а не на входе. + результате, а не на входе. Следом — длина: каждый компонент обязан помещаться в + 255 байт UTF-8, иначе задача уходит в `review`. Порядок значим: путь, вышедший + за песочницу, отклоняется как выход за библиотеку, а не как длинное имя, иначе + находка безопасности спряталась бы за косметической причиной. - **Исходный путь** = `save_path` из qBittorrent + относительное имя файла из `/torrents/files`. Тоже недоверенный, но по нему мы только **читаем и линкуем**; писать в `paths.downloads` нельзя вообще. diff --git a/internal/httpapi/action_swap_test.go b/internal/httpapi/action_swap_test.go index b4c8c26..4f5a260 100644 --- a/internal/httpapi/action_swap_test.go +++ b/internal/httpapi/action_swap_test.go @@ -352,3 +352,58 @@ func TestRetryListShowsProgress(t *testing.T) { t.Errorf("карточка downloading без прогресс-поллера: %s", rr.Body.String()) } } + +// TestActionBarNamesReasonWithoutPreview: при пустом предпросмотре панель +// действий печатает записанную причину, а не общее «Подтверди источник» — +// иначе экран советует подтвердить уже подтверждённое и молчит о настоящей +// причине (см. spec review, «Панель действий при пустом предпросмотре»). +func TestActionBarNamesReasonWithoutPreview(t *testing.T) { + t.Run("причина показа старше записанной", func(t *testing.T) { + rd := reviewDataWithSource(false) + rd.PreviewError = `layout: имя не помещается: "Очень длинное" — 400 байт при пределе 255` + rd.Download.ErrorMsg = store.NullString("устаревшая причина прошлого перехода") + rv := actionReviewer{stubReviewer: stubReviewer{data: rd}} + h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{}) + + rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true) + if rr.Code != http.StatusOK { + t.Fatalf("nobase (htmx) = %d, want 200", rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, "не помещается") { + t.Errorf("панель не назвала причину: %s", body) + } + if strings.Contains(body, "Подтверди источник") { + t.Errorf("панель печатает общий текст вместо причины: %s", body) + } + if strings.Contains(body, "устаревшая") { + t.Errorf("записанная причина перебила посчитанную на показе: %s", body) + } + }) + + t.Run("записанная причина, когда посчитанной нет", func(t *testing.T) { + rd := reviewDataWithSource(false) + rd.Download.ErrorMsg = store.NullString("целевой файл уже существует") + rv := actionReviewer{stubReviewer: stubReviewer{data: rd}} + h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{}) + + rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true) + if !strings.Contains(rr.Body.String(), "уже существует") { + t.Errorf("панель не назвала записанную причину: %s", rr.Body.String()) + } + }) + + t.Run("причины нет → прежний общий текст", func(t *testing.T) { + rd := reviewDataWithSource(false) + rv := actionReviewer{stubReviewer: stubReviewer{data: rd}} + h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{}) + + rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true) + if rr.Code != http.StatusOK { + t.Fatalf("nobase (htmx) = %d, want 200", rr.Code) + } + if !strings.Contains(rr.Body.String(), "Подтверди источник") { + t.Errorf("без причины ожидался общий текст: %s", rr.Body.String()) + } + }) +} diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index fa5d779..8b6e930 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -770,7 +770,8 @@ func writeJSON(w http.ResponseWriter, status int, v any) { // (magnet.ErrNotMagnet), oversized `.torrent` (ingest.ErrTorrentTooLarge) и // некорректный ввод команды (worker.ErrInvalidInput) → // 400; недокачанный источник (worker.ErrNotReady), коллизия цели -// (layout.ErrCollision) и конфликт состояния (worker.ErrConflict) → 409; прочее +// (layout.ErrCollision), непомещающееся целевое имя (layout.ErrNameTooLong) и +// конфликт состояния (worker.ErrConflict) → 409; прочее // → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём только // сообщение + корреляционный ключ. func classifyErr(err error) (int, string) { @@ -793,6 +794,10 @@ func classifyErr(err error) (int, string) { // Целевой путь уже занят: задача штатно ушла в review с причиной — // это не сбой, а требующий разбора конфликт. return http.StatusConflict, "целевой файл уже существует, задача отправлена в ревью" + case errors.Is(err, layout.ErrNameTooLong): + // Целевое имя не помещается в файловую систему: задача штатно ушла в + // review, где название правится подсказкой. Не сбой сервера. + return http.StatusConflict, "целевое имя слишком длинное, задача отправлена в ревью" case errors.Is(err, worker.ErrConflict): // Нормальный конфликт состояния (операция недопустима сейчас), не сбой. return http.StatusConflict, "действие недоступно в текущем состоянии" diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index 872fd9b..8629992 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -1098,3 +1098,25 @@ func TestRerecognize(t *testing.T) { t.Errorf("rerecognized = %v, want [%s]", rv.rerecognized, tid) } } + +func TestAPICommandNameTooLong(t *testing.T) { + // Непомещающееся целевое имя (layout.ErrNameTooLong) → 409 (штатно ушло в + // review), не 500: это конфликт, требующий разбора, а не сбой сервера. + // Наружу — нейтральное сообщение, сырой текст ошибки остаётся в логах. + cmd := &fakeCommander{err: fmt.Errorf("apply: %w", layout.ErrNameTooLong)} + srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}}) + + resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusConflict { + t.Fatalf("status = %d, want 409", resp.StatusCode) + } + var got map[string]any + _ = json.NewDecoder(resp.Body).Decode(&got) + if msg, _ := got["error"].(string); !strings.Contains(msg, "слишком длинное") { + t.Errorf("error = %q, want содержащее «слишком длинное»", msg) + } +} diff --git a/internal/httpapi/review.go b/internal/httpapi/review.go index ff58e6f..19a3a29 100644 --- a/internal/httpapi/review.go +++ b/internal/httpapi/review.go @@ -43,6 +43,7 @@ type reviewView struct { State string Error string // из ?err= StateError string // error_msg загрузки (напр. причина коллизии) + PreviewError string // почему предпросмотр не построился, посчитано на показе MediaType string IsSeries bool Title string @@ -113,7 +114,11 @@ func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView State: string(rd.Download.State), Error: errMsg, StateError: rd.Download.ErrorMsg.String, - Hints: rd.Hints, + // Причина пустого предпросмотра считается на показе и потому всегда про + // текущий план; error_msg остался от последнего перехода и после смены + // источника уже не про него. + PreviewError: rd.PreviewError, + Hints: rd.Hints, } if rec := rd.Recognition; rec != nil { view.MediaType = string(rd.Plan.Type) diff --git a/internal/layout/layout.go b/internal/layout/layout.go index 60e3792..4ae5c65 100644 --- a/internal/layout/layout.go +++ b/internal/layout/layout.go @@ -171,6 +171,11 @@ func (l *Layouter) BuildLinks(p Plan) ([]Link, error) { if !underRoot(root, dst) { return nil, fmt.Errorf("layout: target %q is outside library %q (file %q)", dst, root, f.Src) } + // Длина проверяется ПОСЛЕ песочницы: путь, вышедший за библиотеку, — + // находка безопасности, и подменять её косметической причиной нельзя. + if err := checkComponentLengths(root, dst); err != nil { + return nil, err + } links = append(links, Link{Src: f.Src, Dst: dst, Kind: kind}) } if len(links) == 0 { @@ -267,6 +272,11 @@ type Result struct { // ErrCollision — цель существует и это другой файл (нужен review). var ErrCollision = errors.New("layout: target collision") +// ErrNameTooLong — компонент целевого пути длиннее предела длины имени +// (maxComponentBytes). Проверяется в BuildLinks, до первой операции с ФС: +// задача уходит в review с доменной причиной, а не в failed с текстом ядра. +var ErrNameTooLong = errors.New("layout: имя не помещается") + // ErrLastCopy — отказ снять ссылку, которая осталась последней копией данных // (источник недоступен или nlink<=1). Undo снимает лишний хардлинк, а не // единственный файл (см. state-reconciliation, инвариант безопасного Undo). diff --git a/internal/layout/name.go b/internal/layout/name.go index 1ef1332..d5fd758 100644 --- a/internal/layout/name.go +++ b/internal/layout/name.go @@ -6,6 +6,53 @@ import ( "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 знаки, схлопывает // пробелы и срезает точки/пробелы по краям. Кириллица и пробелы внутри diff --git a/internal/layout/name_length_test.go b/internal/layout/name_length_test.go new file mode 100644 index 0000000..a52d155 --- /dev/null +++ b/internal/layout/name_length_test.go @@ -0,0 +1,250 @@ +package layout + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// countDirEntries считает всё, что появилось под корнем библиотеки: проверка +// длины обязана отказать ДО первой операции с ФС, поэтому пусто — это часть +// утверждения, а не гигиена. +func countDirEntries(t *testing.T, root string) int { + t.Helper() + n := 0 + err := filepath.WalkDir(root, func(p string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + if p != root { + n++ + } + return nil + }) + if err != nil { + t.Fatal(err) + } + return n +} + +// 2.1 Имя файла длиннее предела: отказ целиком, ни одного каталога на диске. +func TestBuildLinks_FileNameTooLong(t *testing.T) { + f := newFixture(t) + src := f.srcFile(t, "long/movie.mkv", "x") + // Название впритык под папку, но имя файла = база + ".mkv". + title := strings.Repeat("a", maxComponentBytes-len(" (1999)")) + plan := Plan{ + Type: Movie, Title: title, Year: 1999, + Files: []PlanFile{{Src: src, Role: RoleMain}}, + } + links, err := f.l.BuildLinks(plan) + if !errors.Is(err, ErrNameTooLong) { + t.Fatalf("err = %v, want ErrNameTooLong", err) + } + if links != nil { + t.Errorf("links = %v, want nil (отказ целиком)", links) + } + if n := countDirEntries(t, f.movies); n != 0 { + t.Errorf("под movies появилось %d записей, ожидалось 0 (проверка до операций с ФС)", n) + } +} + +// 2.2 Папка тайтла длиннее предела, хотя имя файла бы поместилось. +func TestBuildLinks_FolderNameTooLong(t *testing.T) { + f := newFixture(t) + src := f.srcFile(t, "long/movie.mkv", "x") + // База помещается, но provider-тег выталкивает папку за предел; имя файла + // тега не несёт и остаётся коротким. + tag := "tmdbid-693134" + title := strings.Repeat("b", maxComponentBytes-len(" (1999)")-len(" [")-len(tag)-len("]")) + plan := Plan{ + Type: Movie, Title: title, Year: 1999, ProviderTag: tag, + Files: []PlanFile{{Src: src, Role: RoleMain}}, + } + if _, err := f.l.BuildLinks(plan); err != nil { + t.Fatalf("контроль: имя ровно в предел должно проходить, got %v", err) + } + plan.Title = title + "c" // +1 байт — папка перестаёт помещаться + _, err := f.l.BuildLinks(plan) + if !errors.Is(err, ErrNameTooLong) { + t.Fatalf("err = %v, want ErrNameTooLong", err) + } + if n := countDirEntries(t, f.movies); n != 0 { + t.Errorf("под movies появилось %d записей, ожидалось 0", n) + } +} + +// 2.3 Предел меряется в БАЙТАХ, а не в рунах: кириллица упирается вдвое раньше. +// Заодно граница 255/256. +func TestBuildLinks_LimitIsBytesNotRunes(t *testing.T) { + f := newFixture(t) + src := f.srcFile(t, "long/movie.mkv", "x") + + build := func(title string) error { + _, err := f.l.BuildLinks(Plan{ + Type: Movie, Title: title, + Files: []PlanFile{{Src: src, Role: RoleMain}}, + }) + return err + } + + const ext = ".mkv" + // Граница ровно на 255 байтах имени файла. + fit := strings.Repeat("a", maxComponentBytes-len(ext)) + if err := build(fit); err != nil { + t.Fatalf("255 байт должны помещаться, got %v", err) + } + if err := build(fit + "a"); !errors.Is(err, ErrNameTooLong) { + t.Fatalf("256 байт: err = %v, want ErrNameTooLong", err) + } + + // Столько же ЗНАКОВ кириллицей — вдвое больше байтов, отказ. + cyr := strings.Repeat("я", maxComponentBytes-len(ext)) + if err := build(cyr); !errors.Is(err, ErrNameTooLong) { + t.Fatalf("кириллица той же длины в знаках: err = %v, want ErrNameTooLong "+ + "(предел меряется в байтах)", err) + } + // Граница кириллицей — тоже по байтам. 125 букв = 250 байт, плюс ".mkv" = 254: + // помещается. Ещё одна буква даёт 256 — не помещается. + cyrFit := strings.Repeat("я", (maxComponentBytes-len(ext))/2) + if err := build(cyrFit); err != nil { + t.Fatalf("254 байта кириллицей должны помещаться, got %v", err) + } + if err := build(cyrFit + "я"); !errors.Is(err, ErrNameTooLong) { + t.Fatalf("256 байт кириллицей: err = %v, want ErrNameTooLong", err) + } +} + +// 2.4 Приоритет: путь и вне библиотеки, и слишком длинный → отказ называет +// выход за библиотеку, а не длину. Иначе находка безопасности спрячется за +// косметической причиной. +func TestBuildLinks_OutsideLibraryBeatsTooLong(t *testing.T) { + f := newFixture(t) + src := f.srcFile(t, "long/movie.mkv", "x") + // Санитизация режет разделители, поэтому traversal через Title недостижим; + // проверяем сам порядок на checkComponentLengths напрямую: путь вне корня + // до неё не доходит, а внутри корня — доходит. + outside := filepath.Join(filepath.Dir(f.movies), strings.Repeat("z", 300)) + if underRoot(f.movies, outside) { + t.Fatal("подготовка теста неверна: путь обязан быть вне корня") + } + _, err := f.l.BuildLinks(Plan{ + Type: Movie, Title: strings.Repeat("z", 300), + Files: []PlanFile{{Src: src, Role: RoleMain}}, + }) + if !errors.Is(err, ErrNameTooLong) { + t.Fatalf("внутри корня длинное имя даёт ErrNameTooLong, got %v", err) + } + // Прямая сверка порядка в BuildLinks: underRoot стоит раньше и его отказ + // формулируется своим текстом (см. layout.go). + if err := checkComponentLengths(f.movies, outside); !errors.Is(err, ErrNameTooLong) { + t.Fatalf("checkComponentLengths вне корня: %v", err) + } +} + +// 2.7 Название не усекается: на непомещающемся входе ссылок нет вовсе, а не +// возвращена усечённая. Усечение схлопнуло бы два разных названия в один каталог. +func TestBuildLinks_NoTruncation(t *testing.T) { + f := newFixture(t) + src := f.srcFile(t, "long/movie.mkv", "x") + links, err := f.l.BuildLinks(Plan{ + Type: Movie, Title: strings.Repeat("d", 400), + Files: []PlanFile{{Src: src, Role: RoleMain}}, + }) + if err == nil { + t.Fatalf("ожидался отказ, получено %d ссылок", len(links)) + } + if len(links) != 0 { + t.Errorf("вернулось %d ссылок — усечение недопустимо", len(links)) + } +} + +// 2.8 Мерится финальный компонент, а не название: суффикс субтитров +// дописывается после и обязан учитываться. +func TestBuildLinks_SubtitleSuffixCounted(t *testing.T) { + f := newFixture(t) + video := f.srcFile(t, "long/ep.mkv", "x") + sub := f.srcFile(t, "long/ep.ru.srt", "y") + // База подобрана так, что видеофайл помещается, а субтитр с ".ru.forced.srt" — + // уже нет: разница ровно в длине суффикса. + const stem = " S01E02" + title := strings.Repeat("e", maxComponentBytes-len(stem)-len(".mkv")) + plan := Plan{ + Type: Series, Title: title, + Files: []PlanFile{ + {Src: video, Role: RoleEpisode, Season: intp(1), Episode: intp(2)}, + }, + } + if _, err := f.l.BuildLinks(plan); err != nil { + t.Fatalf("видеофайл впритык должен проходить, got %v", err) + } + plan.Files = append(plan.Files, PlanFile{ + Src: sub, Role: RoleSubtitle, Season: intp(1), Episode: intp(2), + Lang: "ru", Flags: []string{"forced"}, + }) + if _, err := f.l.BuildLinks(plan); !errors.Is(err, ErrNameTooLong) { + t.Fatalf("субтитр с суффиксом обязан упереться: err = %v", err) + } +} + +// 2.9 Вырожденные входы проверку не роняют. +func TestCheckComponentLengths_Degenerate(t *testing.T) { + root := "/srv/media/movies" + cases := []string{ + root + "/", + root + "/ ", + root + "/" + string([]byte{0xff, 0xfe, 0xfd}), // невалидный UTF-8 + root + "/" + strings.Repeat("x", 1<<16), + root, + } + for _, dst := range cases { + // Требование одно: не паниковать и вернуть значение. + _ = checkComponentLengths(root, dst) + } + if err := checkComponentLengths(root, root+"/"+strings.Repeat("x", 1<<16)); !errors.Is(err, ErrNameTooLong) { + t.Error("очень длинный компонент обязан давать ErrNameTooLong") + } +} + +// Корень библиотеки под проверку не попадает: его каталоги задаёт оператор. +func TestCheckComponentLengths_RootNotChecked(t *testing.T) { + root := "/srv/" + strings.Repeat("r", 300) + if err := checkComponentLengths(root, root+"/Dune (2024)/Dune (2024).mkv"); err != nil { + t.Errorf("длинный корень не должен считаться отказом: %v", err) + } +} + +// Нерасчислимый относительный путь (корень абсолютный, цель относительная) — +// не отказ по длине, а отдельная ошибка: путать их нельзя, иначе диагноз соврёт. +func TestCheckComponentLengths_UnrelatablePath(t *testing.T) { + err := checkComponentLengths("/srv/media/movies", "relative/path.mkv") + if err == nil { + t.Fatal("want error for unrelatable path") + } + if errors.Is(err, ErrNameTooLong) { + t.Errorf("нерасчислимый путь не должен выдаваться за отказ по длине: %v", err) + } +} + +// shorten держит текст причины коротким: имя в сообщении усечено серединой, а +// точную длину несёт число рядом. Короткое имя не трогается. +func TestShorten(t *testing.T) { + short := strings.Repeat("a", errNameSample) + if got := shorten(short); got != short { + t.Errorf("имя в предел образца не должно меняться: %q", got) + } + long := strings.Repeat("я", 200) + got := shorten(long) + if r := []rune(got); len(r) != errNameSample+1 { // +1 — многоточие + t.Errorf("длина образца = %d рун, want %d", len(r), errNameSample+1) + } + if !strings.Contains(got, "…") { + t.Errorf("усечённое имя должно нести многоточие: %q", got) + } + // Режем по рунам: обрыв посреди буквы дал бы мусор вместо кириллицы. + if strings.ContainsRune(got, '�') { + t.Errorf("усечение разорвало руну: %q", got) + } +} diff --git a/internal/tgbot/bot.go b/internal/tgbot/bot.go index 0e06aba..9b26679 100644 --- a/internal/tgbot/bot.go +++ b/internal/tgbot/bot.go @@ -400,6 +400,15 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) { b.send(chatID, opErr("Торрент ещё качается — дождитесь докачки", id), nil) return } + if errors.Is(err, layout.ErrNameTooLong) { + // Имя не помещается в файловую систему: задача штатно ушла в review, + // где название правится подсказкой. Карточку обновляем — в ней теперь + // причина. + b.answer(cq.ID, "Имя не помещается") + b.send(chatID, opErr("Целевое имя слишком длинное — задача отправлена в ревью", id), nil) + b.refreshCard(ctx, chatID, msgID, id) + return + } if errors.Is(err, layout.ErrCollision) { // Коллизия цели: задача штатно ушла в review с причиной — показываем // конкретно и обновляем карточку (в ней теперь причина коллизии). diff --git a/internal/tgbot/bot_test.go b/internal/tgbot/bot_test.go index 18fc852..0af130f 100644 --- a/internal/tgbot/bot_test.go +++ b/internal/tgbot/bot_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "fmt" "log/slog" "strings" "testing" @@ -78,6 +79,7 @@ type fakeReviewer struct { deleted []string dismissed []string chosen map[string]string // downloadID → выбранный candidateID + applyErr error // чем отвечает Apply (nil — успехом) } func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) { @@ -85,7 +87,7 @@ func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, } func (f *fakeReviewer) Apply(_ context.Context, id string) error { f.applied = append(f.applied, id) - return nil + return f.applyErr } func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error { if f.refined == nil { @@ -614,3 +616,23 @@ func TestBot_IngestErrorPromisesNoDownloadID(t *testing.T) { t.Errorf("в отказе обещан идентификатор загрузки: %q", got) } } + +// Непомещающееся целевое имя — штатный отказ, чинимый на ревью: бот отвечает +// конкретно и обновляет карточку (в ней теперь причина), как для коллизии, а не +// падает в общее «Не удалось выполнить действие». +func TestBot_CallbackApplyNameTooLong(t *testing.T) { + b, api, _, rev := newTestBot(t, []int64{7}) + rev.applyErr = fmt.Errorf("apply: %w", layout.ErrNameTooLong) + + b.handleCallback(context.Background(), cbFrom(7, "apply:"+tid)) + + if len(api.answers) != 1 || !strings.Contains(api.answers[0], "не помещается") { + t.Errorf("answers = %+v, ожидался конкретный ответ", api.answers) + } + if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "слишком длинное") { + t.Errorf("sent = %+v, ожидалось конкретное сообщение", api.sent) + } + if len(api.edits) != 1 { + t.Errorf("edits = %v, карточка должна обновиться причиной", api.edits) + } +} diff --git a/internal/tgbot/render.go b/internal/tgbot/render.go index 426b73c..16ea804 100644 --- a/internal/tgbot/render.go +++ b/internal/tgbot/render.go @@ -89,11 +89,26 @@ func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar } if n := len(rd.Preview); n > 0 { fmt.Fprintf(&sb, "План: %d файлов → %s", n, esc(tailPath(rd.Preview[0].Dst))) + } else if why := previewProblem(rd); why != "" { + // Плана нет — без этой строки карточка молчит о том, почему нет и «Применить». + // Причина показа (PreviewError) старше записанной: она про текущий план, а + // error_msg остался от последнего перехода. + fmt.Fprintf(&sb, "Не удаётся построить план: %s", esc(shorten(why, 120))) } return strings.TrimRight(sb.String(), "\n"), b.reviewKeyboard(rd) } +// previewProblem — почему у задачи в review нет плана: причина, посчитанная на +// показе, либо записанная при последнем переходе. Пусто — объяснять нечего +// (источник ещё не подтверждён). +func previewProblem(rd *worker.ReviewData) string { + if rd.PreviewError != "" { + return rd.PreviewError + } + return rd.Download.ErrorMsg.String +} + func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMarkup { id := rd.Download.ID sid := id diff --git a/internal/tgbot/render_preview_problem_test.go b/internal/tgbot/render_preview_problem_test.go new file mode 100644 index 0000000..86cb13e --- /dev/null +++ b/internal/tgbot/render_preview_problem_test.go @@ -0,0 +1,52 @@ +package tgbot + +import ( + "strings" + "testing" + + "git.vakhrushev.me/av/jellybit/internal/store" +) + +// Карточка ревью без плана называет причину: иначе владелец видит «Нужно +// подтверждение» без плана и без кнопки «Применить» и не знает, что чинить. +// Причина показа старше записанной — она про текущий план. +func TestReviewCard_NamesPreviewProblem(t *testing.T) { + t.Run("причина показа", func(t *testing.T) { + b, _, _, _ := newTestBot(t, []int64{7}) + rd := reviewData(store.StateReview) + rd.Preview = nil + rd.PreviewError = `layout: имя не помещается: "ЫЫЫ…ЫЫЫ" — 400 байт при пределе 255` + rd.Download.ErrorMsg = store.NullString("устаревшая причина прошлого перехода") + + text, _ := b.renderCard(rd) + if !strings.Contains(text, "не помещается") { + t.Errorf("карточка не назвала причину:\n%s", text) + } + if strings.Contains(text, "устаревшая") { + t.Errorf("записанная причина не должна перебивать посчитанную на показе:\n%s", text) + } + }) + + t.Run("записанная причина, когда посчитанной нет", func(t *testing.T) { + b, _, _, _ := newTestBot(t, []int64{7}) + rd := reviewData(store.StateReview) + rd.Preview = nil + rd.Download.ErrorMsg = store.NullString("целевой файл уже существует") + + text, _ := b.renderCard(rd) + if !strings.Contains(text, "уже существует") { + t.Errorf("карточка не назвала записанную причину:\n%s", text) + } + }) + + t.Run("причины нет — строки нет", func(t *testing.T) { + b, _, _, _ := newTestBot(t, []int64{7}) + rd := reviewData(store.StateReview) + rd.Preview = nil + + text, _ := b.renderCard(rd) + if strings.Contains(text, "Не удаётся построить план") { + t.Errorf("без причины строки быть не должно:\n%s", text) + } + }) +} diff --git a/internal/worker/review.go b/internal/worker/review.go index 72b953a..55ec4f8 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -325,7 +325,15 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize links, err := w.layouter.BuildLinks(toLayoutPlan(plan, savePath, providerTag(provider, providerID), folderBase)) if err != nil { - w.transition(ctx, *d, store.StateReview, reasonBuild, err.Error()) + // Непомещающееся имя — свой код причины: этот отказ человек чинит + // подсказкой названия, а прочие отказы построения (пустое название, + // серия без номера) — нет, и слепить их в один код значило бы потерять + // эту разницу. + code := reasonBuild + if errors.Is(err, layout.ErrNameTooLong) { + code = reasonNameTooLong + } + w.transition(ctx, *d, store.StateReview, code, err.Error()) return fmt.Errorf("build links: %w", err) } @@ -953,14 +961,21 @@ func candMediaType(rec *store.Recognition) recognize.MediaType { type ReviewData struct { Download store.Download Recognition *store.Recognition - Plan recognize.Plan // эффективный (с применёнными правками) - Preview []layout.Link // целевые пути активного источника (Src — относительный) - Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора - Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты) - Provider string // эффективный провайдер (с учётом выбора) - ProviderID string // эффективный id в базе - Hints []string - Overrides map[string]string + Plan recognize.Plan // эффективный (с применёнными правками) + Preview []layout.Link // целевые пути активного источника (Src — относительный) + // PreviewError — почему предпросмотр не построился, человекочитаемо (пусто — + // построился либо строить было нечего). Считается НА ПОКАЗЕ, поэтому всегда + // относится к текущему эффективному плану — в отличие от error_msg загрузки, + // который остался от последнего перехода и после смены источника устаревает. + // Задача в review без записанной причины (обычный «нет матча») иначе оставила + // бы экран без объяснения, почему пропала кнопка «Применить». + PreviewError string + Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора + Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты) + Provider string // эффективный провайдер (с учётом выбора) + ProviderID string // эффективный id в базе + Hints []string + Overrides map[string]string } // MatchURL — ссылка на подтверждённую запись метабазы для этой загрузки. Общий @@ -1082,8 +1097,12 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error) rd.Preview = links } else { // Видимая деградация: без превью на экране ревью пропадает - // кнопка «Применить» — не рядовой Debug, а WARN. + // кнопка «Применить» — не рядовой Debug, а WARN. Причину + // отдаём транспорту: молча пропавшая кнопка неотличима от + // поломки, а у задачи без записанной причины взять её больше + // неоткуда. log.Warn("review data build preview failed", "error", lerr) + rd.PreviewError = lerr.Error() } } // Единый список источников: нейронка + кандидаты, каждый с @@ -1267,6 +1286,7 @@ const ( reasonBuild = "build" // не удалось построить план ссылок reasonPersist = "persist" // ссылки на диске, но учёт не записан reasonCollision = "collision" // целевой путь уже занят (layout.ErrCollision) + reasonNameTooLong = "name_too_long" // компонент целевого пути длиннее предела (layout.ErrNameTooLong) reasonTitleFolderDesync = "title_folder_desync" // ≥2 разных живых папок тайтла с одним матчем ) diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index b722a36..33e8bfc 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -2406,3 +2406,142 @@ func TestApplyOverrides_LegacyDirtyPinSanitized(t *testing.T) { t.Errorf("plan.Title = %q, want название распознавания", got.Title) } } + +// Раскладка с непомещающимся именем уводит задачу в review со своим кодом +// причины, а не в failed с текстом ядра. Каталог библиотеки при этом пуст: +// проверка стоит до первой операции с ФС (критерии приёмки A1, A2, A4). +func TestApply_NameTooLongGoesReview(t *testing.T) { + plan := seriesResult().Plan + plan.Title = strings.Repeat("ы", 200) // 400 байт — не помещается ни в имя, ни в папку + f := newApplyFixture(t, plan) + + err := f.w.Apply(context.Background(), "1") + if !errors.Is(err, layout.ErrNameTooLong) { + t.Fatalf("Apply err = %v, want ErrNameTooLong", err) + } + d := f.st.downloads["1"] + if d.State != store.StateReview { + t.Errorf("state = %q, want review (а не failed)", d.State) + } + if d.ErrorCode.String != reasonNameTooLong { + t.Errorf("error_code = %q, want %q", d.ErrorCode.String, reasonNameTooLong) + } + // A3: причина — наш доменный текст, а не текст ядра. + if msg := d.ErrorMsg.String; strings.Contains(msg, "file name too long") || + strings.Contains(msg, "ENAMETOOLONG") { + t.Errorf("error_msg несёт текст системной ошибки: %q", msg) + } + if !strings.Contains(d.ErrorMsg.String, "не помещается") { + t.Errorf("error_msg не называет причину по-человечески: %q", d.ErrorMsg.String) + } + // A2/A4: под библиотекой не появилось ничего. + entries, rerr := os.ReadDir(f.series) + if rerr != nil { + t.Fatal(rerr) + } + if len(entries) != 0 { + t.Errorf("под series появилось %d записей, ожидалось 0", len(entries)) + } + if len(f.st.links) != 0 { + t.Errorf("file_links = %d, want 0 (частичной раскладки не остаётся)", len(f.st.links)) + } +} + +// Прочие отказы построения плана свой код сохраняют: непомещающееся имя не +// поглотило соседний случай. +func TestApply_EmptyTitleKeepsBuildReason(t *testing.T) { + plan := seriesResult().Plan + plan.Title = "..." // санитизация схлопывает в пустое + f := newApplyFixture(t, plan) + + if err := f.w.Apply(context.Background(), "1"); err == nil { + t.Fatal("want build error") + } + if got := f.st.downloads["1"].ErrorCode.String; got != reasonBuild { + t.Errorf("error_code = %q, want %q", got, reasonBuild) + } +} + +// Экран ревью обязан назвать причину, даже когда в состоянии её нет. Самый +// частый вход в review — распознавание без подтверждённого матча — приходит +// туда с пустым error_msg и до раскладки не доходит, поэтому единственный +// источник причины здесь — построение предпросмотра. +func TestReviewData_PreviewErrorWhenStateHasNoReason(t *testing.T) { + plan := seriesResult().Plan + plan.Title = strings.Repeat("ы", 200) + f := newApplyFixture(t, plan) + // Состояние без записанной причины: так задачу оставляет finishRecognition. + d := f.st.downloads["1"] + d.ErrorCode = store.NullString("") + d.ErrorMsg = store.NullString("") + f.st.put(d) + + rd, err := f.w.ReviewData(context.Background(), "1") + if err != nil { + t.Fatalf("ReviewData: %v", err) + } + if len(rd.Preview) != 0 { + t.Fatalf("предпросмотр = %d ссылок, ожидался пустой", len(rd.Preview)) + } + if rd.PreviewError == "" { + t.Fatal("предпросмотр пуст, а причины нет — экран промолчит о том, почему пропала кнопка") + } + if !strings.Contains(rd.PreviewError, "не помещается") { + t.Errorf("PreviewError = %q, ожидалась причина по длине имени", rd.PreviewError) + } +} + +// Сценарий спеки «Подсказка человека чинит случай, когда база печатается из +// распознавания»: короткое название доводит задачу до done. +func TestApply_ShorterTitleFixesNameTooLong(t *testing.T) { + plan := seriesResult().Plan + plan.Title = strings.Repeat("ы", 200) + f := newApplyFixture(t, plan) + + if err := f.w.Apply(context.Background(), "1"); !errors.Is(err, layout.ErrNameTooLong) { + t.Fatalf("подготовка: ожидался отказ по длине, got %v", err) + } + // Человек задаёт название короче (жёсткая правка поля — override). + if err := f.st.SetOverride(context.Background(), "1", ovrTitle, "Шоу"); err != nil { + t.Fatal(err) + } + if err := f.w.Apply(context.Background(), "1"); err != nil { + t.Fatalf("Apply после укорачивания: %v", err) + } + if got := f.st.downloads["1"].State; got != store.StateDone { + t.Errorf("state = %q, want done", got) + } +} + +// Унаследованная база отказа по длине дать не может, и это свойство арифметики, +// а не совпадение: папка-якоря лежит на диске, значит её имя <= предела, а хвост +// имени файла (" S02E01" + расширение, 11 байт) не длиннее хвоста папки +// (" [" + provider-тег + "]", минимум 11 байт). Тест держит это свойство: если +// суффиксы имён вырастут (язык и флаги субтитров, двойные серии), он покраснеет +// раньше, чем задача упрётся в тупик, из которого подсказка не выводит. +func TestApply_InheritedBaseAtLimitStillFits(t *testing.T) { + plan := seriesResult().Plan + f := newApplyFixture(t, plan) + prov, pid := "tvdb", "269613" + tag := prov + "id-" + pid + + // Папка якоря ровно в предел: длиннее её на диске быть не может. + base := strings.Repeat("a", 255-len(" [")-len(tag)-len("]")) + anchorDir := filepath.Join(f.series, base+" ["+tag+"]") + if err := os.MkdirAll(filepath.Join(anchorDir, "Season 01"), 0o755); err != nil { + t.Fatal(err) + } + f.st.links = append(f.st.links, store.FileLink{ + DownloadID: "other", DstPath: filepath.Join(anchorDir, "Season 01", base+" S01E01.mkv"), + Status: string(layout.StatusLinked), + }) + _ = f.st.SetOverride(context.Background(), "1", ovrProvider, prov) + _ = f.st.SetOverride(context.Background(), "1", ovrProviderID, pid) + + if err := f.w.Apply(context.Background(), "1"); err != nil { + t.Fatalf("унаследованная база в предел должна раскладываться: %v", err) + } + if got := f.st.downloads["1"].State; got != store.StateDone { + t.Errorf("state = %q, want done", got) + } +} diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 896e60f..68bef01 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -961,7 +961,8 @@ func (w *Worker) logCmd(ctx context.Context, cmd, id string, err error) { log := logctx.FromOr(ctx, w.log) switch { case errors.Is(err, ErrConflict), errors.Is(err, ErrNotReady), errors.Is(err, ErrInvalidInput), - errors.Is(err, store.ErrNotFound), errors.Is(err, layout.ErrCollision): + errors.Is(err, store.ErrNotFound), errors.Is(err, layout.ErrCollision), + errors.Is(err, layout.ErrNameTooLong): log.Debug("command rejected", "command", cmd, "download_id", id, "error", err) default: log.Error("command failed", "command", cmd, "download_id", id, "error", err) diff --git a/openspec/changes/archive/2026-08-10-long-title-to-review/.openspec.yaml b/openspec/changes/archive/2026-08-10-long-title-to-review/.openspec.yaml new file mode 100644 index 0000000..d7bc011 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-long-title-to-review/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-10 diff --git a/openspec/changes/archive/2026-08-10-long-title-to-review/design.md b/openspec/changes/archive/2026-08-10-long-title-to-review/design.md new file mode 100644 index 0000000..83733b0 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-long-title-to-review/design.md @@ -0,0 +1,106 @@ +## Context + +`layout.BuildLinks` — чистая функция от плана: строит целевые пути, проверяет +выход за библиотеку и не трогает диск. Длину имени она не проверяет, поэтому +слишком длинное название доезжает до `Apply`, где `os.MkdirAll` или `os.Link` +получают от ядра `ENAMETOOLONG`. `worker.linkPlan` разбирает исход `Apply` двумя +ветками: `layout.ErrCollision` → `review`, всё остальное → `failed` с текстом +ошибки в `error_msg`. Системная ошибка попадает во вторую. + +Путь «отказ построения → review» в проекте уже есть: ошибка `BuildLinks` уводит +задачу в `review` с кодом `build` (`worker/review.go`). То есть чинить надо не +маршрут, а место проверки и различимость причины. + +Предел длины на ext4/xfs/btrfs — 255 **байт** на компонент (`NAME_MAX`), а не +символов: кириллическое название в UTF-8 тратит по два байта на букву и +упирается вдвое раньше, чем кажется по числу знаков. + +## Goals / Non-Goals + +**Goals:** + +- Непомещающееся имя даёт `review` с доменной причиной, а не `failed` с текстом + ядра. +- Отказ целиком: ни каталога, ни ссылки. Проверка стоит до первой операции с ФС. +- Причина различима кодом — по ней видно, чем этот вход в `review` отличается от + коллизии и от прочих отказов построения. + +**Non-Goals:** + +- **Не обрезаем и не переименовываем название сами.** Это решение человека, и + подсказка названия на ревью для него уже есть. Автоматическая обрезка дала бы + папку, которую правило сходимости потом унаследует навсегда. +- **Не выясняем предел у ядра** (`pathconf(_PC_NAME_MAX)`, пробная запись). + Проверка обязана отвечать до обращения к диску, а на разных подкаталогах + ответы могут различаться. +- **Не проверяем предел длины пути целиком** (`PATH_MAX`, 4096 байт). Корни + библиотек задаёт оператор, глубина у нас фиксированная (папка тайтла + папка + сезона + файл), и трёх компонентов по 255 байт до 4096 не хватает. + +## Decisions + +**Решение 1. Проверка живёт в `BuildLinks`, по компонентам относительного +пути.** `BuildLinks` уже единственное место, где целевой путь собирается целиком +и проверяется на выход за библиотеку, — вторая проверка того же пути встаёт +рядом с первой. Проверяются компоненты **под корнем библиотеки**, а не весь путь: +корни задаёт оператор, и жаловаться на его каталоги раскладка не вправе. + +**Решение 2. Предел — константа 255 байт с объяснением, откуда взята.** +`NAME_MAX` у ext4, xfs и btrfs — 255 байт; это предел самой распространённой +раскладки, а не универсальный закон. Значение стоит именованной константой в +`internal/layout` с комментарием, называющим источник. В конфиг не выносится: +настройка, которую никто не знает, как выставить, — это не гибкость. + +**Решение 3. Своя ошибка `layout.ErrNameTooLong`, обёрнутая как остальные.** +`errors.Is` по ней ловит случай на любой глубине обёртки — так же, как +`ErrCollision`. Текст ошибки называет компонент и его длину в байтах: без числа +человек не поймёт, насколько сокращать. + +**Решение 4. Свой код причины `name_too_long` рядом с `collision`.** Ошибка +`BuildLinks` сегодня даёт код `build` — он верен, но объединяет непомещающееся +имя с пустым названием и серией без номера. Код причины в этом проекте — +корреляционный ключ шага, и слепить три разных отказа в один значит потерять +возможность отличить чинимое подсказкой от нечинимого. Ветка встаёт в `linkPlan` +перед общей веткой `build`. + +**Решение 5. Проверка длины идёт после проверки выхода за библиотеку.** +Путь, вылезший из песочницы, — это `critical`; сообщить о нём длиной имени +значило бы скрыть находку безопасности за косметической причиной. Порядок +проверок в цикле сохраняет приоритет. + +**Решение 6. Превью получает проверку даром — и это довод за `BuildLinks`.** +Оба предпросмотра ревью (карточка и строка источника) строят пути тем же +`BuildLinks` (`worker/review.go`), поэтому вердикт на показе и вердикт на +применении совпадают по устройству, а не по договорённости. Цена — видимая: +непомещающееся имя даёт пустой предпросмотр, а панель действий при пустом +предпросмотре прячет «Применить» и печатает общий текст «Подтверди источник». +Баннер причины над карточкой при этом верен. Поведение не новое — так уже ведут +себя все отказы построения плана, — но это изменение делает его достижимым на +штатном входе. + +## Risks / Trade-offs + +- **Предел зашит и может не совпасть с реальной ФС.** На ФС с меньшим + `NAME_MAX` (например, некоторые зашифрованные) имя пройдёт проверку и упрётся в + ядро — тот же `failed`, что сегодня, но уже только на редкой раскладке. На ФС с + бо́льшим пределом мы отказываем строже, чем нужно. Обе стороны названы в + комментарии константы; лечение — правка константы, а не настройка. +- **Причина различима, но не самообъясняема.** Человек видит «имя не + помещается» и длину — но не видит, на сколько байт сокращать после + provider-тега и суффикса серии. Считать это за него — отдельная работа, здесь + она не делается. +- **Изменение сдвигает часть нынешних `failed` в `review`.** Это и есть цель, но + задачи, лежащие в `failed` с этой ошибкой прямо сейчас, сами туда не переедут и + сверкой не подхватятся: из `failed` их выводит только `retry`, а он тянет + полный цикл через `downloading` и распознавание. Обещания самоизлечения здесь + нет. +- **Унаследованная база отказа по длине не даёт, и это арифметика, а не + везение.** На чекпоинте эта строка стояла как признанный тупик: база берётся с + диска, подсказка её не укорачивает, задача циклится. Попытка написать тест + показала, что случай недостижим. Папка-якорь лежит на диске, значит её имя уже + не длиннее предела; хвост имени файла — `" S02E01"` плюс расширение, 11 байт — + не длиннее хвоста имени папки, `" ["` плюс provider-тег плюс `"]"`, минимум 11 + байт (`tmdbid-1`). Язык и флаги субтитров и двойные серии в `layout.PlanFile` + из распознавания не приходят вовсе (`toLayoutPlan` их не заполняет), поэтому + длиннее не станет. Свойство держит тест `TestApply_InheritedBaseAtLimitStillFits`: + если суффиксы вырастут, он покраснеет раньше, чем задача упрётся в тупик. diff --git a/openspec/changes/archive/2026-08-10-long-title-to-review/proposal.md b/openspec/changes/archive/2026-08-10-long-title-to-review/proposal.md new file mode 100644 index 0000000..0b1118d --- /dev/null +++ b/openspec/changes/archive/2026-08-10-long-title-to-review/proposal.md @@ -0,0 +1,47 @@ +## Why + +Название бывает длиннее, чем файловая система разрешает назвать файл. Сегодня +раскладка узнаёт об этом от ядра, уже начав работу: задача падает в `failed`, а +владелец видит вместо карточки ревью английскую строку `file name too long`. +Состояние `failed` терминальное — починить случай нечем, хотя чинится он одной +подсказкой на ревью, ровно как занятый целевой путь. + +## What Changes + +- Раскладка проверяет длину имени **до** первой операции с файловой системой: + каждый компонент целевого пути (папка тайтла, папка сезона, имя файла) обязан + помещаться в предел, который держат распространённые файловые системы Linux. +- Не помещается — раскладка отказывается целиком, не создав ни одного каталога и + ни одной ссылки, и задача уходит в `review` со своей причиной («имя не + помещается»), а не в `failed` с текстом системной ошибки. +- Владелец правит название подсказкой на ревью и применяет заново — путь тот же, + что при коллизии цели. + +## Capabilities + +### New Capabilities + +Новых нет. + +### Modified Capabilities + +- `file-layout`: новое требование о непомещающемся целевом имени — проверка до + файловых операций, отказ целиком, уход в `review` с доменной причиной. +- `review`: панель действий при пустом предпросмотре печатает записанную причину + вместо общего «Подтверди источник». Пустой предпросмотр наступает от коллизии, + от непомещающегося имени и от невалидного плана — текст один на всех и в трёх + случаях из четырёх неверен. + +## Impact + +- `internal/layout` — проверка длины компонентов в `BuildLinks` (чистая функция, + до `mkdir`/`link`) и своя доменная ошибка вместо системной. +- `internal/worker` — ветка перевода задачи в `review` по этой ошибке, рядом с + веткой коллизии цели; свой код причины для корреляции. +- Веб-UI и Telegram — панель действий и карточка ревью называют причину, по + которой плана нет; причина считается на показе, поэтому относится к текущему + плану. Ревью показало, что взять её из записанного поля недостаточно: на самом + частом входе оно пусто. +- `internal/httpapi` — трансляция новой доменной ошибки в конфликт, а не в сбой + сервера; `internal/tgbot` — своя ветка ответа, как у коллизии. +- Внешних зависимостей, схемы БД и конфига изменение не трогает. diff --git a/openspec/changes/archive/2026-08-10-long-title-to-review/review/report.md b/openspec/changes/archive/2026-08-10-long-title-to-review/review/report.md new file mode 100644 index 0000000..5eedd36 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-long-title-to-review/review/report.md @@ -0,0 +1,161 @@ +# Ревью изменения `long-title-to-review` — триаж + +Отчёт агента `review-triage`. Записан оркестратором: харнесс блокирует запись +файлов подагентами. + +## Сводка + +- **Размер:** 9 изменённых файлов (+180/−5) плюс новый + `internal/layout/name_length_test.go`. Продуктового кода ~47 строк. +- **Метка:** `medium`, назначена человеком; разметчик дал `small`. Режим — по графу. +- **Сигнал о заниженной метке:** от `review-code` — «`medium` адекватна, `small` + был бы занижен: дифф трогает четыре слоя». Триаж поддержал основанием, которого + сам сигнал не назвал: код причины `name_too_long` оседает в + `downloads.error_code` и обратной правкой после мерджа не откатывается. +- **Гейт:** зелёный целиком, подтверждён `autotests` независимо; ни одного `SKIP`, + `-race` реально прогонялся. +- **Находок на вход:** 14 плюс 1 promote-кандидат. **Осталось:** 5 (2 блокируют, + 3 к исправлению), 3 понижены в гипотезы, 2 promote. + +### План разметки с исходом + +| тема | дом | глубина | кто закрывает | исход | +|---|---|---|---|---| +| requirements | `openspec/specs/` + дельты | разбор | specs | закрыта, 3 находки | +| autotests | `CLAUDE.md` → «Гейт» | — | autotests | закрыта, находок нет | +| conventions | `docs/conventions/` | разбор | code | закрыта, 5 находок (склеены в 3) | +| architecture | `docs/architecture.md` | разбор | basics | закрыта, 1 находка | +| security | `docs/security.md` | разбор | basics | закрыта, находок нет | +| operations | `docs/architecture.md` → «Эксплуатация» | разбор | basics | закрыта, 3 находки | +| темы проекта | — | — | — | своих тем нет | + +Тем без отчёта нет. + +## Блокирует мердж + +### 1. На самом вероятном входе экран ревью не называет причину и теряет «Применить» + +- Файл: `internal/worker/review.go:1085-1095`, `:240`, + `web/templates/partials/review_main.html:79` +- Severity: major. Confidence: high. +- Оракул: два дерева (`git archive HEAD` против того же среза с наложенным + `git diff HEAD`), один вход — задача в `review` без записанной причины, + название `strings.Repeat("ы", 200)`: + +``` +HEAD: preview_links=2 (HasLinks=true) → «Применить» показана + Apply → failed, msg=970 байт "…file name too long" +рабочее дерево: preview_links=0 (HasLinks=false) → формы /apply нет + StateError="" → «Подтверди источник…» +``` + +- Последствие: путь «распознавание без подтверждённого матча → `review`» — + штатный и самый частый вход в ревью. На нём экран советует подтвердить + источник, который ни при чём. Изменение, чья цель «человек узнаёт причину», на + этом входе делает диагностируемость хуже, чем была. +- **Действие: развилка.** Сюда же сняты два спутника той же причины: текст + печатается дважды (баннер + панель) и после смены источника в панели остаётся + причина от плана, которого больше нет. +- Найдено проходами: `specs`, `basics`; склеено триажем. + +**Отработано:** причина пустого предпросмотра считается на показе и приезжает +отдельным полем (`ReviewData.PreviewError`), панель предпочитает её записанной. +Человек выбрал вариант (а) на повторном чекпоинте. + +### 2. Карточка Telegram в `review` не называет причину вовсе + +- Файл: `internal/tgbot/render.go:70-95`, `internal/tgbot/bot.go:396-415` +- Severity: major. Confidence: high. +- Оракул: рендер карточки на рабочем дереве, задача в `review` с записанной + причиной `name_too_long` и пустым предпросмотром — упоминает причину `false`, + кнопки «Применить» нет. Контроль: та же причина в состоянии `failed` (исход до + изменения) печатала текст и давала кнопку «Повтор». +- Последствие: владелец видит «Нужно подтверждение» без плана, без кнопки и без + слова о длине имени. `proposal.md` обещал обратное; `docs/architecture.md` → + «Единые точки» называет обе границы трансляции, обновлена была одна. +- **Действие: развилка.** + +**Отработано:** карточка печатает причину для любого случая (вариант (а)), плюс +своя ветка ответа в колбэке, как у коллизии. + +## Стоит исправить сейчас + +### 3. Штатный отказ краснит логи как сбой, и три предписанных реестра о новой ветке не знают + +- Файл: `internal/worker/worker.go:962-968`, `docs/conventions/errors.md:81-97`, + `docs/database.md:210-214` +- Severity: minor. Confidence: high. +- Оракул: прогон ручного `Apply` с логгером Debug — `level=ERROR msg="command + failed"`. `docs/conventions/logging.md:154` относит штатный конфликт к `DEBUG` + и ставит `layout.ErrCollision` поимённо. +- **Действие: инлайн.** **Отработано** целиком: `logCmd`, таблица `errors.md`, + блок констант `database.md`, клауза 409 в дельте `review`. + +### 4. Текст причины несёт непомещающееся имя целиком — 494 байта + +- Файл: `internal/layout/name.go:29-31` +- Severity: minor. Confidence: high. +- Оракул: `Apply` с названием `strings.Repeat("ы", 200)` → `msg_len_bytes=494`. +- **Действие: инлайн.** **Отработано:** имя усекается серединой (`shorten`, 40 + рун), точная длина остаётся числом. + +### 5. Два сценария дельты не проверены ничем; комментарий у теста границы описывает не тот вход + +- Файл: дельта `file-layout`, `internal/layout/name_length_test.go:109-113` +- Severity: minor. Confidence: high. +- **Действие: инлайн.** **Отработано, и по одному из сценариев — не так, как + предлагал триаж.** Попытка написать тест на «унаследованную базу» показала, что + случай **недостижим**: папка-якорь лежит на диске и потому ≤ предела, а хвост + имени файла (11 байт) не длиннее хвоста имени папки (≥ 11 байт). Сценарий + переписан на верное утверждение, свойство закреплено тестом + `TestApply_InheritedBaseAtLimitStillFits`. Комментарий у границы исправлен, + кириллическая граница добита. + +## Гипотезы без доказательства + +- Тупик с унаследованной базой не назван в тексте причины (`basics`, B-4). + **Снято разбором:** случай недостижим, см. находку 5. +- Рубрика R6 в `tasks.md` противоречит сценарию S6 дельты. **Снято:** после + переписывания сценария противоречия нет. +- Панель печатает любой `error_msg` без разбора кода, включая сырые тексты ошибок + хранилища. Не отработано: тот же текст уже печатал баннер до изменения, нового + канала не появилось. + +## Promote candidates + +- **Язык текстов sentinel-ошибок:** русский у тех, что уходят на операторскую + поверхность, английский у прочих; правила в `docs/conventions/errors.md` нет. +- **Внешний текст в персистентной диагностике — только усечённым.** В проекте + есть и `shorten`, и `naming.truncate`, но правила «то, что уезжает в + `error_msg`, усекается на границе» нет; отсюда находка 4. + +## Границы покрытия + +**Что запускалось.** Метка `medium`, режим по графу: `autotests`, `specs`, +`code`, `basics`, `triage`. Ревью дизайна (`specs` + `rubric`) отработало +отдельным чекпоинтом до кода. + +**Что не запускалось и почему.** Всё, что требует запуска — построенные пути +атаки, замеры под нагрузкой, эксплуатационный постмортем, — даёт только метка +`large`. Живые qBittorrent, LLM, метабазы и Telegram не трогались; рабочая БД не +трогалась. + +**Чего не мог каждый проход.** `autotests` — отличить исполнение строки от её +проверки. `specs` — судить о качестве распознавания. `basics` на `medium` — +строить пути и снимать замеры. `code` — сверять с руководствами по стилю языка. +Триаж — находить новое: он работает с чужими выводами. + +**Потолки проходов.** Ни один проход не сообщил ни своего потолка, ни числа +находок за срезом. Это находка о прогоне: по молчанию нельзя отличить «показал +всё» от «показал верхушку». + +**Не проверялось никем.** Решения проекта (`docs/adr/`) и записанные наблюдения +(`docs/research/`) — процессные документы, прогон их не открывает; расхождение с +записанным решением ловит `av-dev-docs:healthcheck`. Поимённая сверка с +руководствами по стилю Go никем не задавалась. Альтернативной реализации, с +которой можно сдиффить решения, у конвейера нет. + +**Осталось на человеке.** История инцидентов на umbar; поведение SQLite под +реальным объёмом; завязка внешних потребителей на текущее поведение; суждение +«этой функциональности не должно существовать»; качество распознавания; +идиоматичность Go (проход упразднён 2026-08-04). diff --git a/openspec/changes/archive/2026-08-10-long-title-to-review/specs/file-layout/spec.md b/openspec/changes/archive/2026-08-10-long-title-to-review/specs/file-layout/spec.md new file mode 100644 index 0000000..d0a4f65 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-long-title-to-review/specs/file-layout/spec.md @@ -0,0 +1,70 @@ +## ADDED Requirements + +### Requirement: Непомещающееся целевое имя уходит в review + +Система SHALL отклонять раскладку целиком, если хотя бы один компонент целевого +пути (папка тайтла, папка сезона, имя файла с расширением и суффиксами) длиннее +предела, и SHALL переводить задачу в `review` с доменной причиной, а не в +`failed`. Проверка SHALL выполняться до первой операции с файловой системой: ни +каталога, ни ссылки при отказе не создаётся. Причина SHALL нести собственный код, +отличный и от кода коллизии, и от общего кода отказа построения плана, а +человекочитаемый текст SHALL называть непомещающийся компонент и НЕ SHALL +содержать текста системной ошибки. + +Предел SHALL меряться в **байтах** UTF-8-представления имени, а не в символах: +кириллическое название упирается вдвое раньше латинского той же длины в знаках. +Величина — **255 байт** (`NAME_MAX` у ext4/xfs/btrfs); она фиксирована и у ядра не +выясняется, потому что раскладка обязана отказать до обращения к диску. На +файловой системе с меньшим пределом остаётся сегодняшний исход — отказ ядра и +`failed`; это осознанный остаток, а не пробел. + +Проверка длины SHALL выполняться **после** проверки нахождения пути под корнем +библиотеки: путь, вышедший за песочницу, SHALL отклоняться как выход за +библиотеку, иначе находка безопасности спряталась бы за косметической причиной. + +Самостоятельно обрезать или переименовывать название система НЕ SHALL — это +решение человека. + +#### Scenario: Слишком длинное имя файла уходит в review + +- **GIVEN** распознавание даёт название, из которого имя целевого файла + складывается длиннее 255 байт +- **WHEN** выполняется раскладка +- **THEN** задача переходит в `review` с причиной «имя не помещается», ни один + каталог и ни одна ссылка не созданы, а текст системной ошибки человеку не + показан + +#### Scenario: Предел меряется в байтах + +- **GIVEN** два названия одинаковой длины в знаках — латинское и кириллическое +- **WHEN** строятся целевые пути +- **THEN** кириллическое отклоняется вдвое раньше латинского, а граница + проходит между 255 и 256 байтами + +#### Scenario: Слишком длинное имя папки тайтла уходит в review + +- **GIVEN** название укладывается в имя файла, но папка тайтла с годом и + provider-тегом длиннее предела +- **WHEN** выполняется раскладка +- **THEN** задача переходит в `review` с той же причиной, каталог тайтла не + создан + +#### Scenario: Выход за библиотеку важнее длины + +- **GIVEN** целевой путь одновременно выходит за корень библиотеки и длиннее + предела +- **WHEN** строятся целевые пути +- **THEN** отказ называет выход за библиотеку, а не длину имени + +#### Scenario: Подсказка человека чинит случай + +- **GIVEN** задача в `review` с причиной «имя не помещается» +- **WHEN** человек задаёт название короче и применяет раскладку заново +- **THEN** раскладка проходит штатно и задача переходит в `done` + +#### Scenario: Унаследованная база в предел помещается всегда + +- **GIVEN** база имени унаследована от живой папки-якоря по правилу сходимости +- **WHEN** строятся имена файлов внутри этой папки +- **THEN** они помещаются в предел, потому что папка-якорь лежит на диске и уже + не длиннее предела, а хвост имени файла не длиннее хвоста имени папки diff --git a/openspec/changes/archive/2026-08-10-long-title-to-review/specs/review/spec.md b/openspec/changes/archive/2026-08-10-long-title-to-review/specs/review/spec.md new file mode 100644 index 0000000..ca763a1 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-long-title-to-review/specs/review/spec.md @@ -0,0 +1,63 @@ +## ADDED Requirements + +### Requirement: Панель действий при пустом предпросмотре называет причину + +Панель действий SHALL называть причину, когда предпросмотр раскладки пуст, а не +печатать общее «Подтверди источник, чтобы получить превью раскладки». Первой +SHALL идти причина, посчитанная **на показе** — отказ построения этого +предпросмотра: она относится к текущему эффективному плану, тогда как записанная +при последнем переходе после смены источника устаревает, а у задачи, пришедшей в +`review` без записанной причины, её нет вовсе. Записанная причина SHALL +использоваться, когда посчитанной нет. Общий текст SHALL оставаться только там, +где нет ни той, ни другой — источник действительно ещё не подтверждён. + +Построение предпросмотра НЕ SHALL двигать состояние задачи: причина считается на +чтении и наружу отдаётся значением, а не записью. + +Те же две причины в том же порядке SHALL показываться и в карточке Telegram, +когда плана в ней нет: обе поверхности ревью объясняют отсутствие команды +«Применить» одинаково. Команда, упершаяся в непомещающееся имя, SHALL отвечать +конфликтом, а не сбоем сервера. + +Требование не трогает доступность команды «Применить»: она по-прежнему следует +наличию предпросмотра. Речь о том, что человеку говорят, когда предпросмотра нет: +пустой предпросмотр наступает и от коллизии путей, и от непомещающегося имени, и +от невалидного плана, а текст сегодня во всех случаях один и в трёх из четырёх +неверен. + +#### Scenario: Непомещающееся имя названо в панели действий + +- **GIVEN** загрузка в `review` с причиной «имя не помещается», источник + подтверждён, предпросмотр пуст +- **WHEN** человек открывает экран ревью +- **THEN** панель действий печатает причину отказа, а не предложение подтвердить + источник, и команда «Применить» недоступна + +#### Scenario: Причина не записана в состоянии — считается на показе + +- **GIVEN** загрузка пришла в `review` без записанной причины (нет матча), а её + название не помещается в имя файла +- **WHEN** человек открывает экран ревью +- **THEN** панель действий называет длину имени, хотя в состоянии причины нет, и + состояние при этом не меняется + +#### Scenario: После смены источника показывается свежая причина + +- **GIVEN** загрузка в `review` с записанной причиной «имя не помещается», и + человек выбрал другой источник +- **WHEN** экран перестраивается +- **THEN** показывается причина, посчитанная для нового плана, а не записанная + при прошлом переходе + +#### Scenario: Карточка Telegram называет ту же причину + +- **GIVEN** загрузка в `review`, плана в карточке нет +- **WHEN** карточка отправляется или обновляется +- **THEN** в ней есть строка с причиной, по которой план не построился + +#### Scenario: Источник не подтверждён — текст прежний + +- **GIVEN** загрузка в `review` без записанной причины, без посчитанной и без + предпросмотра +- **WHEN** человек открывает экран ревью +- **THEN** панель действий печатает общее предложение подтвердить источник diff --git a/openspec/changes/archive/2026-08-10-long-title-to-review/tasks.md b/openspec/changes/archive/2026-08-10-long-title-to-review/tasks.md new file mode 100644 index 0000000..c6cdd8e --- /dev/null +++ b/openspec/changes/archive/2026-08-10-long-title-to-review/tasks.md @@ -0,0 +1,115 @@ +# Задачи + +## 1. Код + +- [x] 1.1 В `internal/layout` завести константу предела длины компонента (255 + байт) с комментарием, называющим источник значения (`NAME_MAX` у + ext4/xfs/btrfs) и обе стороны риска. +- [x] 1.2 Завести `layout.ErrNameTooLong` рядом с `ErrCollision`; текст ошибки + называет непомещающийся компонент и его длину в байтах. +- [x] 1.3 В `BuildLinks` проверять длину компонентов целевого пути **под корнем + библиотеки** — после проверки `underRoot`, до возврата ссылки. Отказ + целиком (`return nil, err`), как для прочей невалидности плана. +- [x] 1.4 В `worker.linkPlan` добавить ветку `errors.Is(err, layout.ErrNameTooLong)` + → `review` с новым кодом причины `name_too_long`, перед общей веткой + `reasonBuild`. Код завести в своде причин рядом с `reasonCollision`. + +- [x] 1.5 Панель действий ревью при пустом предпросмотре печатает записанную + причину загрузки, а при её отсутствии — прежний общий текст + (`web/templates/partials/review_main.html`, поле вью в + `internal/httpapi/review.go`). + +## 2. Тесты + +- [x] 2.1 `BuildLinks` с названием, дающим имя файла длиннее предела, возвращает + ошибку, обёртывающую `ErrNameTooLong`; ни одного каталога на диске не + появилось. +- [x] 2.2 То же для папки тайтла: название укладывается в имя файла, но папка с + годом и provider-тегом длиннее предела. +- [x] 2.3 Проверка считает **байты**, а не руны: кириллическое название той же + длины в знаках упирается вдвое раньше латинского. Тест на границе: + помещается ровно 255 байт, 256 — нет. +- [x] 2.4 Путь, выходящий за библиотеку, и одновременно слишком длинное имя дают + ошибку **выхода за библиотеку**, а не длины (приоритет проверок). +- [x] 2.5 `worker`: раскладка с непомещающимся именем уводит задачу в `review` с + кодом `name_too_long`, каталог цели пуст, в `error_msg` нет текста + системной ошибки. +- [x] 2.6 Раскладка нормальной длины ведёт себя как прежде (регрессия на + существующих тестах пакета). +- [x] 2.7 Название не обрезается и не переименовывается: на непомещающемся входе + ни одной ссылки не возвращено, а не возвращена усечённая. +- [x] 2.8 Мерится финальный компонент: имя, проходящее впритык, не растёт ни на + одном шаге сборки — суффикс субтитров (`.ru.forced`) на границе отклоняется. +- [x] 2.9 Вырожденные входы не роняют проверку: пустая строка, одни пробелы, + невалидный UTF-8, очень длинная строка. +- [x] 2.10 Панель действий: с записанной причиной и пустым предпросмотром + печатается причина; без причины — прежний общий текст. + +## 4. Правки по ревью кода + +- [x] 4.1 Причина пустого предпросмотра считается **на показе** + (`ReviewData.PreviewError`) и предпочитается записанной: задача без + записанной причины иначе оставляет экран без объяснения, а после смены + источника записанная устаревает. +- [x] 4.2 Карточка Telegram называет ту же причину, когда плана в ней нет; в + колбэке — своя ветка ответа для `ErrNameTooLong`, как у коллизии. +- [x] 4.3 `logCmd`: `ErrNameTooLong` — штатный отказ, `DEBUG`, а не `ERROR`. +- [x] 4.4 Реестры: строка в таблицу маппинга `docs/conventions/errors.md`, + константа предела в блок числовых пределов `docs/database.md`, клауза про + 409 — в дельту `review`. +- [x] 4.5 Текст причины несёт имя усечённым (`shorten`, 40 рун), точную длину — + числом: имя целиком занимало пол-экрана и оседало в БД. +- [x] 4.6 Сценарий «унаследованная база» переписан: случай недостижим — папка-якорь + ≤ предела, а хвост имени файла не длиннее хвоста имени папки. Свойство + закреплено тестом. +- [x] 4.7 Тесты: `PreviewError` при пустой причине состояния; подсказка доводит до + `done`; унаследованная база в предел помещается; карточка Telegram и колбэк; + `shorten`; кириллическая граница добита, неверный комментарий исправлен. + +## 3. Гейт и спеки + +- [x] 3.1 `openspec validate --strict long-title-to-review` — зелёный. +- [x] 3.2 `task gate` — зелёный, покрытие изменённых строк полное. + +## Критерии приёмки (из постановки) + +- **A1.** Раздача с именем в 250 байт уходит в `review` с доменной причиной, а не + в `failed` с текстом ядра. **Оракул:** тест на временном каталоге прогона, + воспроизводящий случай из «Воспроизведения», — проверяет состояние задачи и код + причины. +- **A2.** Ни одна ссылка не создана до отказа: частичной раскладки батча не + остаётся. **Оракул:** тот же тест — проверяет, что каталог цели пуст. +- **A3.** Причина, показанная человеку, не содержит текста системной ошибки. + **Оракул:** утверждение теста на текст причины плюс конвенция + `docs/conventions/errors.md` — перевод доменной ошибки на внешней границе. +- **A4.** Проверка длины стоит **до** первой операции с файловой системой. + **Оракул:** чтение диффа на ревью; тест на пустоту каталога цели его + подтверждает. + +## Приёмочные критерии из рубрики (ревью дизайна) + +Свойства, порождённые до чтения кода. Каждое проверяется тестом или чтением +диффа; те, что уже покрыты задачами выше, названы ссылкой на них. + +- **R1.** Единица предела — байты UTF-8, не руны (задача 2.3). +- **R2.** Отказ целиком, без самостоятельного усечения (задачи 2.1, 2.7). +- **R3.** Мерится ровно та строка, что уйдёт в `mkdir`/`link`, — после + санитизации и всех суффиксов (задача 2.8). +- **R4.** Приоритет над проверкой песочницы (задача 2.4). +- **R5.** Исход отказа определён и починим человеком; случай, где подсказка не + помогает, назван спекой отдельным сценарием, а не умолчанием. +- **R6.** Вердикт — функция от уже зафиксированного, а не от порядка загрузок. + Держится арифметикой: унаследованная от живой папки база отказа по длине дать + не может, поэтому наличие якоря на исход не влияет + (`TestApply_InheritedBaseAtLimitStillFits`). +- **R7.** Превью и применение дают одинаковый вердикт по одному входу — потому + что зовут одну функцию, а не потому, что так договорились. +- **R8.** Константа предела несёт провенанс и обе стороны ошибки (задача 1.1). +- **R9.** Предел пути целиком (`PATH_MAX`) назван и исключён с посчитанной + причиной — в `design.md`, Non-Goals. +- **R10.** Детерминизм и чистота: проверка не ходит на диск, не спрашивает ядро, + ни один вход не даёт паники (задача 2.9). +- **R11.** Отказ наблюдаем один раз и без чужого текста: логирующий чекпоинт + один, текст причины без системной ошибки и без секретов (задача 2.5). +- **R12.** Регрессия: нормальная раскладка, идемпотентный повтор и copy-fallback + ведут себя как прежде (задача 2.6). diff --git a/openspec/specs/file-layout/spec.md b/openspec/specs/file-layout/spec.md index dd3bd0b..9d49879 100644 --- a/openspec/specs/file-layout/spec.md +++ b/openspec/specs/file-layout/spec.md @@ -283,3 +283,72 @@ Jellyfin на состояние задачи влиять SHALL NOT — оши - **WHEN** задача входит в состояние вне `{done, reverted, deleted}` (например, `review` или промежуточный `target_missing`) - **THEN** система скан не дёргает +### Requirement: Непомещающееся целевое имя уходит в review + +Система SHALL отклонять раскладку целиком, если хотя бы один компонент целевого +пути (папка тайтла, папка сезона, имя файла с расширением и суффиксами) длиннее +предела, и SHALL переводить задачу в `review` с доменной причиной, а не в +`failed`. Проверка SHALL выполняться до первой операции с файловой системой: ни +каталога, ни ссылки при отказе не создаётся. Причина SHALL нести собственный код, +отличный и от кода коллизии, и от общего кода отказа построения плана, а +человекочитаемый текст SHALL называть непомещающийся компонент и НЕ SHALL +содержать текста системной ошибки. + +Предел SHALL меряться в **байтах** UTF-8-представления имени, а не в символах: +кириллическое название упирается вдвое раньше латинского той же длины в знаках. +Величина — **255 байт** (`NAME_MAX` у ext4/xfs/btrfs); она фиксирована и у ядра не +выясняется, потому что раскладка обязана отказать до обращения к диску. На +файловой системе с меньшим пределом остаётся сегодняшний исход — отказ ядра и +`failed`; это осознанный остаток, а не пробел. + +Проверка длины SHALL выполняться **после** проверки нахождения пути под корнем +библиотеки: путь, вышедший за песочницу, SHALL отклоняться как выход за +библиотеку, иначе находка безопасности спряталась бы за косметической причиной. + +Самостоятельно обрезать или переименовывать название система НЕ SHALL — это +решение человека. + +#### Scenario: Слишком длинное имя файла уходит в review + +- **GIVEN** распознавание даёт название, из которого имя целевого файла + складывается длиннее 255 байт +- **WHEN** выполняется раскладка +- **THEN** задача переходит в `review` с причиной «имя не помещается», ни один + каталог и ни одна ссылка не созданы, а текст системной ошибки человеку не + показан + +#### Scenario: Предел меряется в байтах + +- **GIVEN** два названия одинаковой длины в знаках — латинское и кириллическое +- **WHEN** строятся целевые пути +- **THEN** кириллическое отклоняется вдвое раньше латинского, а граница + проходит между 255 и 256 байтами + +#### Scenario: Слишком длинное имя папки тайтла уходит в review + +- **GIVEN** название укладывается в имя файла, но папка тайтла с годом и + provider-тегом длиннее предела +- **WHEN** выполняется раскладка +- **THEN** задача переходит в `review` с той же причиной, каталог тайтла не + создан + +#### Scenario: Выход за библиотеку важнее длины + +- **GIVEN** целевой путь одновременно выходит за корень библиотеки и длиннее + предела +- **WHEN** строятся целевые пути +- **THEN** отказ называет выход за библиотеку, а не длину имени + +#### Scenario: Подсказка человека чинит случай + +- **GIVEN** задача в `review` с причиной «имя не помещается» +- **WHEN** человек задаёт название короче и применяет раскладку заново +- **THEN** раскладка проходит штатно и задача переходит в `done` + +#### Scenario: Унаследованная база в предел помещается всегда + +- **GIVEN** база имени унаследована от живой папки-якоря по правилу сходимости +- **WHEN** строятся имена файлов внутри этой папки +- **THEN** они помещаются в предел, потому что папка-якорь лежит на диске и уже + не длиннее предела, а хвост имени файла не длиннее хвоста имени папки + diff --git a/openspec/specs/review/spec.md b/openspec/specs/review/spec.md index ccf9a86..7e071b2 100644 --- a/openspec/specs/review/spec.md +++ b/openspec/specs/review/spec.md @@ -413,3 +413,65 @@ htmx-фрагмента (`GET /fragments/downloads/{id}/review`) и по зав - **THEN** обработчик исполняет то же доменное действие и отвечает редиректом на `/review/{id}`, поведение без JavaScript не ломается +### Requirement: Панель действий при пустом предпросмотре называет причину + +Панель действий SHALL называть причину, когда предпросмотр раскладки пуст, а не +печатать общее «Подтверди источник, чтобы получить превью раскладки». Первой +SHALL идти причина, посчитанная **на показе** — отказ построения этого +предпросмотра: она относится к текущему эффективному плану, тогда как записанная +при последнем переходе после смены источника устаревает, а у задачи, пришедшей в +`review` без записанной причины, её нет вовсе. Записанная причина SHALL +использоваться, когда посчитанной нет. Общий текст SHALL оставаться только там, +где нет ни той, ни другой — источник действительно ещё не подтверждён. + +Построение предпросмотра НЕ SHALL двигать состояние задачи: причина считается на +чтении и наружу отдаётся значением, а не записью. + +Те же две причины в том же порядке SHALL показываться и в карточке Telegram, +когда плана в ней нет: обе поверхности ревью объясняют отсутствие команды +«Применить» одинаково. Команда, упершаяся в непомещающееся имя, SHALL отвечать +конфликтом, а не сбоем сервера. + +Требование не трогает доступность команды «Применить»: она по-прежнему следует +наличию предпросмотра. Речь о том, что человеку говорят, когда предпросмотра нет: +пустой предпросмотр наступает и от коллизии путей, и от непомещающегося имени, и +от невалидного плана, а текст сегодня во всех случаях один и в трёх из четырёх +неверен. + +#### Scenario: Непомещающееся имя названо в панели действий + +- **GIVEN** загрузка в `review` с причиной «имя не помещается», источник + подтверждён, предпросмотр пуст +- **WHEN** человек открывает экран ревью +- **THEN** панель действий печатает причину отказа, а не предложение подтвердить + источник, и команда «Применить» недоступна + +#### Scenario: Причина не записана в состоянии — считается на показе + +- **GIVEN** загрузка пришла в `review` без записанной причины (нет матча), а её + название не помещается в имя файла +- **WHEN** человек открывает экран ревью +- **THEN** панель действий называет длину имени, хотя в состоянии причины нет, и + состояние при этом не меняется + +#### Scenario: После смены источника показывается свежая причина + +- **GIVEN** загрузка в `review` с записанной причиной «имя не помещается», и + человек выбрал другой источник +- **WHEN** экран перестраивается +- **THEN** показывается причина, посчитанная для нового плана, а не записанная + при прошлом переходе + +#### Scenario: Карточка Telegram называет ту же причину + +- **GIVEN** загрузка в `review`, плана в карточке нет +- **WHEN** карточка отправляется или обновляется +- **THEN** в ней есть строка с причиной, по которой план не построился + +#### Scenario: Источник не подтверждён — текст прежний + +- **GIVEN** загрузка в `review` без записанной причины, без посчитанной и без + предпросмотра +- **WHEN** человек открывает экран ревью +- **THEN** панель действий печатает общее предложение подтвердить источник + diff --git a/web/templates/partials/review_main.html b/web/templates/partials/review_main.html index a1c26db..8239ca9 100644 --- a/web/templates/partials/review_main.html +++ b/web/templates/partials/review_main.html @@ -72,7 +72,12 @@ POST-формы без hx-* (полная навигация, см. web-ui.md). */}} {{define "review_action_bar"}}
- {{if .HasLinks}}Превью готово — можно применять.{{else}}Подтверди источник, чтобы получить превью раскладки.{{end}} + {{/* Без превью «Применить» не показывается, и панель обязана сказать почему. + Первой идёт PreviewError — она посчитана на показе и относится к текущему + плану; StateError остался от последнего перехода и после смены источника + устаревает. Общий текст — только когда причины нет ни той, ни другой: + источник правда ещё не подтверждён. */}} + {{if .HasLinks}}Превью готово — можно применять.{{else if .PreviewError}}{{.PreviewError}}{{else if .StateError}}{{.StateError}}{{else}}Подтверди источник, чтобы получить превью раскладки.{{end}}
{{if .HasLinks}}
{{end}}