The /series page rendered its grid in one fixed order (newest first)
with no way to find a series beyond eyeballing cards. The page now has
a toolbar — series-name search, author filter, viewer-progress filter
and a 12-key sort select — wired as a plain GET form (the page's
existing model: the URL is the single source of truth, and the library
switcher now carries q/author/status/sort along on switch).
Extensibility is deliberate (user request): sorts live in a registry
(services.ResolveSeriesSort) mirrored by a CASE cascade in
GetDistinctSeries and a <select> in series.templ — a new sort is one
entry in each. The sort value is a BOUND PARAM compared inside the
cascade, never interpolated; unknown keys resolve to the default.
Filters: series-name matches in WHERE (the group key, so counts stay
true); author (BOOL_OR any-book match) and status (finished/reading/
unstarted vs the viewer's progress, same 0.999 threshold as the
filter_query status arms) in HAVING so COUNT(*) keeps reporting the
whole series. GetDistinctSeriesCount shares the JOIN + HAVING so
pagination totals match. The reading_progress join can't multiply rows
(the table is UNIQUE per media_item+user).
Cards gain the viewer's finished-count ("X of N read" + progress bar),
and the empty state distinguishes "no series at all" from "no matches —
clear filters". The JSON /api/series endpoint takes the same params and
gains read_count/last_read_at additively.
Integration test pins every sort key against a three-series fixture
with distinct created/author/count/progress answers, the filter matrix,
composition, unknown-sort fallback, card read-counts and the JSON
contract — all as the fresh regular user so the live reader's data is
untouched.
266 lines
8.8 KiB
Go
266 lines
8.8 KiB
Go
package services
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/utils"
|
|
"context"
|
|
|
|
"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 // registry key, resolved by ResolveSeriesSort
|
|
Query string // series-name substring
|
|
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,
|
|
}
|
|
|
|
// ResolveSeriesSort maps any unknown/empty sort to the default. It is
|
|
// the single source of truth backing both the SSR page and the JSON
|
|
// API — the SQL CASE cascade must contain an arm for every key here.
|
|
func ResolveSeriesSort(sort string) string {
|
|
if seriesSortKeys[sort] {
|
|
return sort
|
|
}
|
|
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) {
|
|
sort := nargText(ResolveSeriesSort(opts.Sort))
|
|
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,
|
|
}
|
|
}
|