web-ui: добавлена страница группового удаления загрузок
- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа на обеих границах, потолок времени и остановка после трёх подряд отказов внешнего сервиса - допуск полного удаления сведён в единую точку store.State.CanDelete() — worker, страница загрузки и Telegram больше не держат своих перечней
This commit is contained in:
@@ -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, "внутренняя ошибка"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user