Files
bookhoard/internal/services/series_service.go
T
John O'Keefe 96c1228805 feat(series): multi-field search, sort-direction toggle, apply placement
Toolbar feedback round: the series search box now matches whatever the
main search matches for a group — series NAME, any book's AUTHOR, and
any book's TITLE inside the series (WHERE EXISTS per group-row, so
counts stay true); the separate author input is gone from the toolbar
(the author= param stays live for the JSON API and deep links). The
sort control becomes a base-key select (Date added/Name/Author/Books/
Progress/Last read) plus a chevron direction toggle that flips and
resubmits; sort/dir compose through ResolveSeriesSort (full registry
keys keep working for back-compat, unknown base keys fall to recent).
The search/apply button moved next to the search box as requested, and
default URLs stay clean (sort/dir only emitted once the user chooses).
2026-10-27 19:05:23 -04:00

331 lines
11 KiB
Go

package services
import (
"bookhoard/internal/database"
"bookhoard/internal/utils"
"context"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type SeriesInfo struct {
Name string
LibraryID string
BookCount int64
TotalInSeries int
CoverPaths []string
LastEntryAt string
// Viewer-scoped progress aggregates (requesting user): books
// finished (percentage >= 0.999) and the series' most recent read.
ReadCount int64
LastReadAt string
}
// SeriesPageOptions carries the series page's sort/filter state. Zero
// values mean defaults (recent, no filters). Extensible by design:
// adding a sort = one entry in seriesSortKeys + a matching CASE arm in
// queries.sql (GetDistinctSeries) + one <option> in templates/
// series.templ; adding a filter = a param on this struct threaded into
// the query's WHERE/HAVING. Unknown values resolve to defaults, so a
// stale URL or hand-rolled API call can never reach the SQL with an
// unhandled key.
type SeriesPageOptions struct {
Sort string // base toolbar key or full registry key
Dir string // asc | desc ("" = the base key's default)
Query string // multi-field: series name, author, or book title
Author string // matches when ANY book's author matches
Status string // "" | unstarted | reading | finished
ViewerUserID pgtype.UUID
}
// seriesSortKeys is the registry of sort= values the series page
// understands.
var seriesSortKeys = map[string]bool{
"recent": true, // default: newest series first
"recent_asc": true,
"name_asc": true,
"name_desc": true,
"author_asc": true,
"author_desc": true,
"books_asc": true,
"books_desc": true,
"progress_asc": true,
"progress_desc": true,
"last_read_asc": true,
"last_read_desc": true,
}
// seriesStatusKeys is the registry of status= values. The three states
// are mutually exclusive and exhaustive: a series is finished when
// every owned book is, reading when any book sits between 0 and the
// finish threshold, unstarted when no book has progress > 0.
var seriesStatusKeys = map[string]bool{
"unstarted": true,
"reading": true,
"finished": true,
}
// seriesBaseSorts is the toolbar's base sort keys (the <select>) with
// the direction each one STARTS in (the asc/desc toggle's initial
// state). Composed with ResolveSeriesSort into the full registry keys
// the SQL cascade implements.
var seriesBaseSorts = map[string]string{
"recent": "desc",
"name": "asc",
"author": "asc",
"books": "desc",
"progress": "desc",
"last_read": "desc",
}
// ResolveSeriesSort maps a sort value (either a FULL registry key like
// "name_desc" — back-compat with old URLs and the JSON API — or a BASE
// toolbar key composed with a direction) to the registry key the SQL
// cascade implements, plus the EFFECTIVE direction (for the toggle's
// arrow). Unknown values resolve to the default.
func ResolveSeriesSort(sort, dir string) (string, string) {
sort = strings.TrimSpace(strings.ToLower(sort))
dir = strings.ToLower(strings.TrimSpace(dir))
if dir != "asc" && dir != "desc" {
dir = ""
}
// Full registry key straight from the URL wins as-is.
if seriesSortKeys[sort] {
return sort, seriesDirOf(sort)
}
base := sort
if base == "" {
base = "recent"
}
defaultDir, known := seriesBaseSorts[base]
if !known {
base, defaultDir = "recent", "desc"
}
if dir == "" {
dir = defaultDir
}
if base == "recent" && dir == "desc" {
return "recent", "desc"
}
return base + "_" + dir, dir
}
// seriesDirOf derives the display direction of a full registry key.
func seriesDirOf(key string) string {
switch {
case strings.HasSuffix(key, "_asc"):
return "asc"
case strings.HasSuffix(key, "_desc"):
return "desc"
case key == "recent":
return "desc"
default:
if d, ok := seriesBaseSorts[key]; ok {
return d
}
return "desc"
}
}
// SeriesSortBase strips the direction suffix from a full registry key
// ("name_desc" → "name") for the toolbar's base-key <select>.
func SeriesSortBase(key string) string {
key = strings.TrimSuffix(strings.TrimSuffix(key, "_asc"), "_desc")
if _, ok := seriesBaseSorts[key]; ok {
return key
}
return "recent"
}
// nargText makes an optional text param: empty string → SQL NULL so
// the query's IS NULL guard skips the filter.
func nargText(v string) pgtype.Text {
return pgtype.Text{String: v, Valid: v != ""}
}
type SeriesService struct {
db *database.Queries
}
func NewSeriesService(db *database.Queries) *SeriesService {
return &SeriesService{db: db}
}
// GetSeriesPage lists series grouped PER LIBRARY (same name in two
// libraries = two entries) filtered to the requester's visible libraries.
// Pass libraryID for a single-library page, or leave it invalid and pass
// visibleLibraryIDs for an across-my-libraries page. Both filters compose:
// the handler validates any explicit library_id against the visible set.
func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int, opts SeriesPageOptions) ([]SeriesInfo, int, error) {
sortKey, _ := ResolveSeriesSort(opts.Sort, opts.Dir)
sort := nargText(sortKey)
status := nargText("")
if seriesStatusKeys[opts.Status] {
status = nargText(opts.Status)
}
countParams := database.GetDistinctSeriesCountParams{
ViewerUserID: opts.ViewerUserID,
LibraryID: libraryID,
VisibleLibraryIds: visibleLibraryIDs,
SeriesQuery: nargText(opts.Query),
AuthorQuery: nargText(opts.Author),
Status: status,
}
totalCount, err := s.db.GetDistinctSeriesCount(ctx, countParams)
if err != nil {
return nil, 0, err
}
rows, err := s.db.GetDistinctSeries(ctx, database.GetDistinctSeriesParams{
ViewerUserID: opts.ViewerUserID,
LibraryID: libraryID,
VisibleLibraryIds: visibleLibraryIDs,
SeriesQuery: countParams.SeriesQuery,
AuthorQuery: countParams.AuthorQuery,
Status: status,
Sort: sort,
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
Offset: pgtype.Int4{Int32: int32(offset), Valid: true},
})
if err != nil {
return nil, 0, err
}
var series []SeriesInfo
for _, row := range rows {
seriesName := row.Series.String
coverPaths, _ := s.GetSeriesCovers(ctx, row.LibraryID, seriesName, 7)
totalInSeries := 0
if row.TotalInSeries != nil {
if v, ok := row.TotalInSeries.(int32); ok {
totalInSeries = int(v)
} else if v, ok := row.TotalInSeries.(int64); ok {
totalInSeries = int(v)
}
}
lastEntry := ""
if row.LastEntryAt != nil {
switch v := row.LastEntryAt.(type) {
case pgtype.Timestamptz:
if v.Valid {
lastEntry = v.Time.String()
}
case string:
lastEntry = v
}
}
lastRead := ""
if row.LastReadAt != nil {
switch v := row.LastReadAt.(type) {
case pgtype.Timestamptz:
if v.Valid {
lastRead = v.Time.String()
}
case string:
lastRead = v
}
}
if totalInSeries == 0 || totalInSeries < int(row.BookCount) {
totalInSeries = int(row.BookCount)
}
series = append(series, SeriesInfo{
Name: seriesName,
LibraryID: uuidFromPG(row.LibraryID),
BookCount: row.BookCount,
TotalInSeries: totalInSeries,
CoverPaths: coverPaths,
LastEntryAt: lastEntry,
ReadCount: row.FinishedCount,
LastReadAt: lastRead,
})
}
if series == nil {
series = []SeriesInfo{}
}
return series, int(totalCount), nil
}
func (s *SeriesService) GetSeriesCovers(ctx context.Context, libraryID pgtype.UUID, seriesName string, limit int) ([]string, error) {
covers, err := s.db.GetSeriesCovers(ctx, database.GetSeriesCoversParams{
LibraryID: libraryID,
Series: pgtype.Text{String: seriesName, Valid: true},
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
})
if err != nil {
return nil, err
}
paths := make([]string, 0, len(covers))
for _, c := range covers {
resolved := utils.ResolveMediaURL(c.LibraryID, c.CoverImagePath)
if resolved != "" {
paths = append(paths, resolved)
}
}
return paths, nil
}
func (s *SeriesService) GetSeriesBooks(ctx context.Context, libraryID pgtype.UUID, seriesName string) ([]database.MediaItems, error) {
return s.db.GetSeriesBooks(ctx, database.GetSeriesBooksParams{
Series: pgtype.Text{String: seriesName, Valid: true},
LibraryID: libraryID,
})
}
// VisibleLibraries exposes the per-user visibility set used by the series
// handlers to scope listings and validate explicit library_id params.
func (s *SeriesService) VisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]database.GetUserVisibleLibrariesRow, error) {
return s.db.GetUserVisibleLibraries(ctx, userID)
}
func uuidFromPG(id pgtype.UUID) string {
if !id.Valid {
return ""
}
u, err := uuid.FromBytes(id.Bytes[0:16])
if err != nil {
return ""
}
return u.String()
}
func continueSeriesRowToMediaItems(row database.GetContinueSeriesItemsRow) database.MediaItems {
return database.MediaItems{
ID: row.ID, LibraryID: row.LibraryID, Title: row.Title, Author: row.Author,
Isbn: row.Isbn, Description: row.Description, FilePath: row.FilePath,
FileSize: row.FileSize, MimeType: row.MimeType, CoverImagePath: row.CoverImagePath,
Series: row.Series, SeriesNumber: row.SeriesNumber, Tags: row.Tags, Asin: row.Asin,
DatePublished: row.DatePublished, Publisher: row.Publisher, Contributors: row.Contributors,
Language: row.Language, Edition: row.Edition, PageCount: row.PageCount,
Genre: row.Genre, CopyrightYear: row.CopyrightYear, GoodreadsID: row.GoodreadsID,
OpenlibraryID: row.OpenlibraryID, GoogleBooksID: row.GoogleBooksID,
AddedByAdminID: row.AddedByAdminID, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
FormatGroup: row.FormatGroup, FormatMimetype: row.FormatMimetype,
IsReflowable: row.IsReflowable, HasFixedLayout: row.HasFixedLayout,
TotalCharacters: row.TotalCharacters, ChapterCount: row.ChapterCount,
EntitlementID: row.EntitlementID, RevisionNumber: row.RevisionNumber,
KoboContentID: row.KoboContentID, KoboMetadata: row.KoboMetadata,
MangaType: row.MangaType, ReadingDirection: row.ReadingDirection,
SeriesCount: row.SeriesCount, Volume: row.Volume, Imprint: row.Imprint,
AgeRating: row.AgeRating, WebUrl: row.WebUrl, StoryArc: row.StoryArc,
IsBlackAndWhite: row.IsBlackAndWhite, MetadataNotes: row.MetadataNotes,
CommunityRating: row.CommunityRating, AlternateInfo: row.AlternateInfo,
ScanInformation: row.ScanInformation, Summary: row.Summary,
ChapterMetadata: row.ChapterMetadata, LibraryTypeName: row.LibraryTypeName,
TagsSearch: row.TagsSearch, ContributorsSearch: row.ContributorsSearch,
FileSha256: row.FileSha256, OpfIdentifier: row.OpfIdentifier,
OpfUuid: row.OpfUuid, HashConfidence: row.HashConfidence,
}
}