web-ui: добавлена страница группового удаления загрузок

- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа
  на обеих границах, потолок времени и остановка после трёх подряд отказов
  внешнего сервиса
- допуск полного удаления сведён в единую точку store.State.CanDelete() —
  worker, страница загрузки и Telegram больше не держат своих перечней
This commit is contained in:
av
2026-08-10 17:43:36 +03:00
parent a7c1efd8eb
commit 288be8ec34
35 changed files with 3185 additions and 31 deletions
+41 -3
View File
@@ -18,6 +18,7 @@ import (
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/go-chi/chi/v5"
@@ -52,6 +53,9 @@ type Reader interface {
// LayoutSizeByDownload — суммарный размер разложенных файлов по каждой из
// загрузок (фолбэк размера раздачи в карточке, когда торрента нет в снимке).
LayoutSizeByDownload(ctx context.Context, ids []string) (map[string]int64, error)
// ListDeletableDownloads — загрузки, разрешённые к полному удалению, без
// постраничной выдачи (страница группового удаления показывает их разом).
ListDeletableDownloads(ctx context.Context) ([]store.Download, error)
}
// Deps — зависимости транспорта.
@@ -113,6 +117,9 @@ func NewRouter(d Deps) (http.Handler, error) {
// Веб-UI.
r.Get("/", s.handleIndex)
r.Get("/download/{id}", s.handleDownload)
// Групповое удаление: выбор → подтверждение → исполнение. Отдельная
// страница, потому что живая перерисовка списка стёрла бы выбор человека.
r.Get("/delete", s.handleBulkDeletePage)
// Партиалы телеметрии без потребителя в новой разметке: оставлены гасителями
// вкладок, отрисованных прошлой версией (см. handleFragProgress).
@@ -124,6 +131,8 @@ func NewRouter(d Deps) (http.Handler, error) {
// Тело ревью для поллинга recognizing (htmx-своп до готового плана).
r.Get("/fragments/downloads/{id}/review", s.handleFragReview)
r.Post("/ui/downloads", s.handleUIAdd)
r.Post("/ui/delete/confirm", s.handleBulkDeleteConfirm)
r.Post("/ui/delete", s.handleBulkDelete)
r.Post("/ui/downloads/{id}/cancel", s.handleUICancel)
r.Post("/ui/downloads/{id}/retry", s.handleUIRetry)
@@ -692,12 +701,36 @@ func (s *server) toView(d store.Download, now time.Time) downloadView {
// несколько строк заголовка.
func downloadTitle(d store.Download) string {
if d.DisplayName != "" {
return d.DisplayName
return displaySafe(d.DisplayName)
}
if d.RecTitle.Valid && d.RecTitle.String != "" {
return d.RecTitle.String
return displaySafe(d.RecTitle.String)
}
return shorten(oneLine(d.SourceRef), 80)
return displaySafe(shorten(oneLine(d.SourceRef), 80))
}
// displaySafe готовит заголовок к показу: снимает управляющие символы
// направления письма (`unicode.Bidi_Control` — с ними строка читается не в том
// порядке, в каком хранится) и заменяет управляющие пробелом (перевод строки в
// заголовке склеил бы слова). Имя раздачи — недоверенный вход, а
// `html/template` экранирует разметку, но эти символы пропускает. Цена высока
// на экране подтверждения группового удаления: там поимённое чтение заголовков
// и есть предохранитель необратимой операции.
//
// Снимается ровно этот класс, не весь `unicode.Cf`: в `Cf` лежат и ZWJ/ZWNJ,
// без которых рассыпаются составные эмодзи и меняется написание персидских и
// индийских имён. Чистим на показе, а не на записи — хранение дословное, и
// поиск по списку идёт по сохранённому имени.
func displaySafe(s string) string {
return strings.Map(func(r rune) rune {
if unicode.Is(unicode.Bidi_Control, r) {
return -1
}
if r < 0x20 || r == 0x7f {
return ' '
}
return r
}, s)
}
// oneLine схлопывает переводы строк и лишние пробелы — сырой источник в
@@ -814,6 +847,11 @@ func classifyErr(err error) (int, string) {
return http.StatusBadRequest, errManualSource.Error()
case errors.Is(err, errInvalidCandidate):
return http.StatusBadRequest, errInvalidCandidate.Error()
case errors.Is(err, errBatchEmpty), errors.Is(err, errBatchTooLarge),
errors.Is(err, errBatchBadID):
// Отказы разбора пачки группового удаления — промах ввода, не сбой:
// текст sentinel'а показывается человеку как есть.
return http.StatusBadRequest, err.Error()
default:
return http.StatusInternalServerError, "внутренняя ошибка"
}