Two follow-ups from live testing: - "recent" is BOTH a base toolbar key and a full registry key (where it means desc). ResolveSeriesSort's full-key passthrough ran first, so sort=recent&dir=asc silently resolved back to desc — the direction chevron flipped but Date Added could never sort ascending. "recent" now composes with the direction before the passthrough, unknown bases fall to the default entirely (a hostile dir can't ride an unknown sort), and the composition table is unit-pinned plus an HTTP-level row in the series filter test. - The header search "still broken" report was a stale-cache artifact: the fixed code shipped but main.js kept its ?v=7 stamp, so browsers with a cached copy never refetched (fresh-profile e2e passed while the user's browser didn't — heuristically cached module). Stamp bumped to ?v=8 in header + docs; the e2e suite re-verified against the rebuilt server.
342 lines
11 KiB
Go
342 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 = ""
|
|
}
|
|
// "recent" is BOTH a base toolbar key and a registry key (it means
|
|
// desc). Handle it before the full-key check or dir=asc is silently
|
|
// swallowed and the direction toggle can never flip Date Added.
|
|
if sort == "recent" {
|
|
if dir == "asc" {
|
|
return "recent_asc", "asc"
|
|
}
|
|
return "recent", "desc"
|
|
}
|
|
// 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 {
|
|
// Unknown base: the whole thing falls to the default — an
|
|
// unhandled sort must not sneak a direction past the registry.
|
|
return "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,
|
|
}
|
|
}
|