Files
jellybit/internal/store/list.go
T
avandClaude Opus 4.8 c0b5ab7295 UI/UX списка и карточки загрузки: серверные фильтр/поиск/пагинация, матч-ссылка, имя раздачи (web-ui-list-detail)
- Список: серверные фильтр по группе состояний, поиск и пагинация (GET
  f/q/page/all, по 25), сортировка по времени добавления в qBittorrent
  (added_on) с фолбеком на created_at и tie-break по id.
- Заголовок загрузки = имя раздачи (display_name) → распознанное название →
  усечённый источник; сырой magnet вынесен в блок «Информация о торренте».
- Матч метабазы показан ссылкой на запись (страница загрузки и ревью);
  URL берётся у выбранного кандидата либо строится по provider+id и типу.
- Полировка вёрстки; клиентская JS-фильтрация убрана (всё серверное, без JS).
- Миграция 0005 (display_name, source_added_at); воркер однократно
  фиксирует source_added_at при поллинге/усыновлении; ER-схема обновлена.
- OpenSpec: дельты влиты в specs/{web-ui,ingest}, change заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 09:50:24 +03:00

114 lines
5.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"context"
"fmt"
"strings"
)
// StateGroup — группа состояний для фильтра списка загрузок. Единый источник
// истины «группа → состояния» для транспорта (чипы) и запроса (WHERE).
type StateGroup string
const (
GroupAll StateGroup = "all" // все, кроме deleted (если не IncludeDeleted)
GroupReview StateGroup = "review" // ждут человека
GroupActive StateGroup = "active" // в работе
GroupDone StateGroup = "done" // готово
GroupProblem StateGroup = "problem" // проблемы
)
// statesInGroup возвращает состояния группы фильтра. nil — группа не сужает
// выборку по состоянию (all/неизвестная): состояния перечисляются здесь и
// нигде больше, чтобы UI и запрос не разъезжались. Терминальные cancelled/
// reverted/deleted своей группы (чипа) не имеют — под all они видны (deleted —
// только при IncludeDeleted), точечно к ним не фильтруем (см. tasks 2.1).
func statesInGroup(g StateGroup) []State {
switch g {
case GroupReview:
return []State{StateReview, StateDeferred}
case GroupActive:
return []State{StateDownloading, StateCompleted, StateRecognizing, StateLinking}
case GroupDone:
return []State{StateDone}
case GroupProblem:
return []State{StateStuck, StateFailed, StateTargetMissing, StateOrphaned}
default:
return nil
}
}
// ListFilter — параметры страницы списка загрузок.
type ListFilter struct {
Group StateGroup // группа состояний ("" / all — без сужения)
Query string // поиск по названию/infohash/контексту (подстрока)
IncludeDeleted bool // показывать состояние deleted (только под all)
Limit int // размер страницы
Offset int // смещение страницы
}
// listWhere строит условие WHERE и его аргументы по фильтру.
func listWhere(f ListFilter) (string, []any) {
var conds []string
var args []any
if states := statesInGroup(f.Group); states != nil {
ph := make([]string, len(states))
for i, st := range states {
ph[i] = "?"
args = append(args, string(st))
}
conds = append(conds, "state IN ("+strings.Join(ph, ",")+")")
} else if !f.IncludeDeleted {
// Группа all: скрываем удалённые по умолчанию (шум в ленте).
conds = append(conds, "state <> ?")
args = append(args, string(StateDeleted))
}
if q := strings.TrimSpace(f.Query); q != "" {
// LIKE даёт регистронезависимость только для ASCII (встроенное
// case-folding SQLite), для кириллицы поиск чувствителен к регистру —
// ICU в чистом modernc-драйвере нет. Достаточно для инфохэша/латиницы;
// кириллические названия ищем как введены.
like := "%" + q + "%"
conds = append(conds, "(source_ref LIKE ? COLLATE NOCASE "+
"OR display_name LIKE ? COLLATE NOCASE "+
"OR context LIKE ? COLLATE NOCASE "+
"OR IFNULL(infohash,'') LIKE ? COLLATE NOCASE)")
args = append(args, like, like, like, like)
}
if len(conds) == 0 {
return "", args
}
return " WHERE " + strings.Join(conds, " AND "), args
}
// ListDownloadsPage возвращает страницу загрузок под фильтром и общее число
// строк, удовлетворяющих фильтру (для пагинации). Сортировка — по времени
// добавления в источник (source_added_at) с фолбеком на created_at и tie-break
// по id: без вторичного ключа при равных метках порядок неустойчив, и строки
// задваивались бы/пропадали между страницами. RecTitle подтягивается LEFT JOIN
// текущей попытки распознавания — для фолбека заголовка карточки.
func (s *Store) ListDownloadsPage(ctx context.Context, f ListFilter) ([]Download, int, error) {
where, args := listWhere(f)
var total int
if err := s.DB.GetContext(ctx, &total, `SELECT COUNT(*) FROM download`+where, args...); err != nil {
return nil, 0, fmt.Errorf("list downloads page count: %w", err)
}
q := `SELECT download.*, r.title AS rec_title
FROM download
LEFT JOIN recognition r ON r.download_id = download.id AND r.is_current = 1` + where +
` ORDER BY COALESCE(download.source_added_at, download.created_at) DESC, download.id DESC
LIMIT ? OFFSET ?`
pageArgs := append(append([]any{}, args...), f.Limit, f.Offset)
var out []Download
if err := s.DB.SelectContext(ctx, &out, q, pageArgs...); err != nil {
return nil, 0, fmt.Errorf("list downloads page: %w", err)
}
return out, total, nil
}