a533e4f769
The Site's own completed marker (issue #168) joins the owner's surface as an informational hint, never a decision: - store: SeriesFilterSiteCompleted ('site_completed') sits beside Finished in the filter vocabulary; its predicate carries the same finished_at guard as the clock-driven filters, because the Site's stamp keeps standing after the owner retires the row. The projection and SeriesPage GROUP BY carry site_completed_at so the detail page can age it. - web: the filter joins the Series list select as 'Site says completed' and rides the tail of seriesFilterOrder, so the landing stats figure comes for free and sits after the finished figure. - detail: one hint line beside the Finish control - 'the site says this work is completed (since 3d ago)' - rendered only while the stamp stands and no Finish stands; the Finish control itself is untouched. - tests: filter's finished exclusion and un-finish return to the Poll query (store); figure at zero and nonzero, hint present/absent, and the Finish control's markup byte-identical with a hint present (web).
343 lines
15 KiB
Go
343 lines
15 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Series filter names (issue #140): the seven repair filters are ordered
|
|
// permanent-then-fixable — the repairs nothing will ever undo first, the
|
|
// ones a Poll can make right after. SeriesFilterFinished and
|
|
// SeriesFilterSiteCompleted are not part of that ordering: a finished
|
|
// Series is a deliberate state and a Site-completed one is the Site's own
|
|
// marker, not repairs, so the pair rides the tail, informational. A name is
|
|
// the repair a row needs, not the SQL that finds it; the values are the
|
|
// wire form the Series list URL carries (#142). "all" is the absent and
|
|
// unknown case: every Series.
|
|
const (
|
|
SeriesFilterAll = "all"
|
|
SeriesFilterNoURL = "no_series_url"
|
|
SeriesFilterNoChapter = "never_read_a_chapter"
|
|
SeriesFilterNoReaders = "no_readers"
|
|
SeriesFilterNeverChecked = "never_checked"
|
|
SeriesFilterStale = "stale"
|
|
SeriesFilterNoCover = "no_cover"
|
|
SeriesFilterReaderReport = "reader_report"
|
|
SeriesFilterFinished = "finished"
|
|
SeriesFilterSiteCompleted = "site_completed"
|
|
SeriesFilterFailing = "failing"
|
|
SeriesFilterUnverified = "unverified"
|
|
)
|
|
|
|
// SeriesFilter is one named filter predicate over the whole library. Site
|
|
// and Kind narrow the row read; Name picks the predicate; Cutoff is the
|
|
// staleness boundary the age-based filters — "stale" and the failing pair —
|
|
// compare against, supplied by the caller's clock — the store has no clock;
|
|
// Page is 1-based.
|
|
type SeriesFilter struct {
|
|
Site string // "" = every Site
|
|
Kind string // "" = both library buckets' series
|
|
Name string // one of the SeriesFilter* constants; "" = SeriesFilterAll
|
|
Cutoff int64 // unix ms; the age-based filters read it, the store never does
|
|
Page int // 1-based page of the row read; default 1
|
|
}
|
|
|
|
// adminSeriesColumns is the owner's library-wide Series projection in
|
|
// scanAdminSeries order. It is the privacy boundary: a Series' row carries
|
|
// the Reader id that raised its Latest Chapter (latest_raised_by), and that id
|
|
// must never leave the store package — so the projection does not select it,
|
|
// and only the anonymous boolean in raisedByReaderAnswer crosses it.
|
|
const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_address,
|
|
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.force_poll_at,
|
|
s.latest_corrected_at, s.finished_at, s.site_completed_at`
|
|
|
|
// raisedByReaderAnswer answers "did a Reader's report set this number" without
|
|
// naming which Reader. Kept apart from adminSeriesColumns so the column list —
|
|
// the shape scanAdminSeries is fed — stays free of the Sighting-raiser
|
|
// identity, and the owner learns which rows to act on and nothing about the
|
|
// Reader behind them.
|
|
const raisedByReaderAnswer = `(s.latest_raised_by IS NOT NULL) AS raised_by_reader`
|
|
|
|
// failureAnswer is the poll-failures row's answer (issue #165), kept apart
|
|
// from adminSeriesColumns like raisedByReaderAnswer: outcome and failing_since
|
|
// are not Series columns, and the COALESCE keeps the row scannable when the
|
|
// LEFT JOIN finds no failure row.
|
|
const failureAnswer = `COALESCE(f.outcome, ''), COALESCE(f.failing_since, 0)`
|
|
|
|
// seriesPageSize is the row read's page length. The tie-break in the query's
|
|
// ORDER BY is what makes this a stable page boundary — see SeriesPage.
|
|
const seriesPageSize = 50
|
|
|
|
// AdminSeries is one Series as the owner's library-wide view sees it: a
|
|
// Series-level fact plus an anonymous Reader count. ReaderCount being zero is
|
|
// the orphan marker. RaisedByReader is the only trace of the Sighting
|
|
// mechanism here; the Reader id behind it never reaches this type.
|
|
type AdminSeries struct {
|
|
Site string
|
|
SeriesID string
|
|
Title string
|
|
SeriesURL string
|
|
CoverAddress string // "" = no Cover yet
|
|
Kind string
|
|
LatestChapter string
|
|
LatestChapterNum *float64 // nil until first captured
|
|
LatestCheckedAt int64
|
|
// ForcePollAt is the owner's "check now" request stamp (issue #146), zero
|
|
// meaning never asked. Pending is derived, never stored: a request is
|
|
// pending while ForcePollAt is newer than LatestCheckedAt.
|
|
ForcePollAt int64
|
|
// LatestCorrectedAt is the correction stamp (issue #149): non-zero means
|
|
// the Latest Chapter is the owner's, zero means never corrected. The
|
|
// provenance line (#152) derives from it, so the zero-means-never meaning
|
|
// is load-bearing.
|
|
LatestCorrectedAt int64
|
|
ReaderCount int
|
|
RaisedByReader bool // a Reader's report set LatestChapterNum
|
|
// FailureOutcome is the outcome word of the Series' standing failure, ""
|
|
// when no failure row stands. FailingSince is when the run of failures
|
|
// began, zero with no row. Both come from the LEFT JOIN, not the Series
|
|
// row (issue #165: the row's existence is the state).
|
|
FailureOutcome string
|
|
FailingSince int64
|
|
// FinishedAt is the owner's finish stamp: unix ms, zero while the Series is
|
|
// not finished — the same shape as the Correction stamp, and its own undo.
|
|
FinishedAt int64
|
|
// SiteCompletedAt is when the last successful Poll read saw the Site's own
|
|
// completed value, zero meaning it did not (issue #168). The Site's marker,
|
|
// never a Lifecycle decision: the owner's Finish is the only retirement.
|
|
SiteCompletedAt int64
|
|
}
|
|
|
|
// SeriesPage is one page of the owner's filtered Series list plus the count
|
|
// of every Series matching the same filter — a window number, not the page's
|
|
// len, so the landing page's figure and the list heading come from one query.
|
|
type SeriesPage struct {
|
|
Rows []AdminSeries
|
|
Total int
|
|
}
|
|
|
|
// SiteSeriesShape is one Site's share of the Series matching a filter: how
|
|
// many, and the manga/novel split. One grouped pass, then library-wide totals
|
|
// are summed in Go over the rows — the landing page's per-Site table reads
|
|
// this and never pays for the rows the list discards.
|
|
type SiteSeriesShape struct {
|
|
Site string
|
|
Total int
|
|
Manga int
|
|
Novel int
|
|
}
|
|
|
|
// Key returns the canonical identity in bookmark-key form ("<site>:<series_id>").
|
|
func (a AdminSeries) Key() string { return a.Site + ":" + a.SeriesID }
|
|
|
|
// adminFilter maps a filter's named predicate to its compile-time WHERE and
|
|
// HAVING clauses and their bound parameters — the name never reaches query
|
|
// text, and Site and Kind bind as parameters. Shared by the row read and the
|
|
// per-Site aggregate so the two cannot disagree on what a filter means.
|
|
//
|
|
// The WHERE set is: no URL (an empty URL only — the host-failing-the-fetch-
|
|
// gate case is invisible to SQL, needs the Site registry in Go, and belongs to
|
|
// a later repair), never-read-a-chapter and never-checked as disjoint halves
|
|
// (non-zero versus zero check stamp), stale, no cover, finished (the
|
|
// retirement stamp, read directly), site completed (the Site's marker, read
|
|
// directly), Reader-report, and the failing pair — failing (the failure row
|
|
// exists, a chapter exists, and failing_since is past the cutoff) and
|
|
// unverified (the Reader-attributed subset of failing).
|
|
// failing's chapter IS NOT NULL is the exact complement of never-read-a-
|
|
// chapter's IS NULL half, so the two are disjoint by construction.
|
|
// no_readers is the one HAVING predicate: it is the orphan test, an aggregate
|
|
// over the LEFT JOIN, where a bare WHERE has no row to test.
|
|
//
|
|
// The clock-versus-outcome split decides which predicates exclude finished
|
|
// Series (`s.finished_at = 0` in each of the five): the clock-driven ones —
|
|
// never-checked, stale, no-chapter, no-cover — keep ticking after the last
|
|
// Poll, so they would report a retired row as a problem no Poll is coming to
|
|
// fix; site-completed's stamp is the Site's, and it keeps standing after the
|
|
// owner retires the row, so a finished Series would be reported as work
|
|
// nobody is going to do. The outcome-driven ones — no-URL, no-readers,
|
|
// Reader-report, failing, unverified — read stored facts that simply stop
|
|
// arriving, so a finished Series needing a genuine repair still shows up
|
|
// under them. The failing pair carries no finished guard for exactly that
|
|
// contrast: a failure row is a stored outcome, not a ticking clock.
|
|
//
|
|
// stale is the checked-but-old half of the stamp partition — because the
|
|
// verdict line wants "not checked in twelve hours" as one figure, and a never
|
|
// checked Series is already counted on its own "waiting"/never-checked
|
|
// filter, folding it in would double-report it. The landing page computes the
|
|
// inclusive number as stale + never-checked.
|
|
func adminFilter(f SeriesFilter) (where, having string, args []any, err error) {
|
|
var clauses []string
|
|
if f.Kind != "" {
|
|
args = append(args, f.Kind)
|
|
clauses = append(clauses, "s.kind = $"+strconv.Itoa(len(args)))
|
|
}
|
|
switch f.Name {
|
|
case "", SeriesFilterAll:
|
|
case SeriesFilterNoURL:
|
|
clauses = append(clauses, `s.series_url = ''`)
|
|
case SeriesFilterNoChapter:
|
|
clauses = append(clauses, `s.latest_checked_at <> 0 AND s.latest_chapter_num IS NULL AND s.finished_at = 0`)
|
|
case SeriesFilterNeverChecked:
|
|
clauses = append(clauses, `s.latest_checked_at = 0 AND s.finished_at = 0`)
|
|
case SeriesFilterStale:
|
|
clauses = append(clauses, `s.latest_checked_at > 0 AND s.latest_checked_at < $`+strconv.Itoa(len(args)+1)+` AND s.finished_at = 0`)
|
|
args = append(args, f.Cutoff)
|
|
case SeriesFilterNoCover:
|
|
clauses = append(clauses, `s.cover_address = '' AND s.finished_at = 0`)
|
|
case SeriesFilterReaderReport:
|
|
clauses = append(clauses, `s.latest_raised_by IS NOT NULL`)
|
|
case SeriesFilterFailing:
|
|
clauses = append(clauses, `f.site IS NOT NULL AND s.latest_chapter_num IS NOT NULL AND f.failing_since < $`+strconv.Itoa(len(args)+1))
|
|
args = append(args, f.Cutoff)
|
|
case SeriesFilterUnverified:
|
|
clauses = append(clauses, `f.site IS NOT NULL AND s.latest_chapter_num IS NOT NULL AND f.failing_since < $`+strconv.Itoa(len(args)+1)+` AND s.latest_raised_by IS NOT NULL`)
|
|
args = append(args, f.Cutoff)
|
|
case SeriesFilterFinished:
|
|
clauses = append(clauses, `s.finished_at > 0`)
|
|
case SeriesFilterSiteCompleted:
|
|
clauses = append(clauses, `s.site_completed_at > 0 AND s.finished_at = 0`)
|
|
case SeriesFilterNoReaders:
|
|
having = `HAVING COUNT(b.reader_id) = 0`
|
|
default:
|
|
return "", "", nil, fmt.Errorf("unknown series filter %q", f.Name)
|
|
}
|
|
if len(clauses) > 0 {
|
|
where = "WHERE " + strings.Join(clauses, " AND ")
|
|
}
|
|
return where, having, args, nil
|
|
}
|
|
|
|
// SeriesPage returns one page of the Series matching the filter, least
|
|
// recently checked first. The LEFT JOIN to Bookmarks is what surfaces the
|
|
// orphans that hygiene has to find — an inner join would hide them, exactly
|
|
// as the Lane's join does. Every bookmark keeps its Series polled now that
|
|
// finished is a Series flag, so this plain ReaderCount agrees with the Lane
|
|
// queries (issue #157).
|
|
//
|
|
// The tie-break is mandatory, not decorative: every unpollable Series shares a
|
|
// zero check stamp, so ordering on that column alone gives no stable page
|
|
// boundary and rows would repeat or vanish across pages. (site, series_id) is
|
|
// the primary key, hence total. The filtered total is a window count in the
|
|
// same query — window functions run after grouping and before the limit, so
|
|
// one where-clause cannot disagree with a second copy of itself.
|
|
func (s *Store) SeriesPage(f SeriesFilter) (SeriesPage, error) {
|
|
where, having, args, err := adminFilter(f)
|
|
if err != nil {
|
|
return SeriesPage{}, err
|
|
}
|
|
if f.Page < 1 {
|
|
f.Page = 1
|
|
}
|
|
// Site narrowing is the row read's own; the aggregate must see every Site.
|
|
if f.Site != "" {
|
|
args = append(args, f.Site)
|
|
clause := "s.site = $" + strconv.Itoa(len(args))
|
|
if where == "" {
|
|
where = "WHERE " + clause
|
|
} else {
|
|
where += " AND " + clause
|
|
}
|
|
}
|
|
base := len(args)
|
|
args = append(args, seriesPageSize, seriesPageSize*(f.Page-1))
|
|
rows, err := s.db.Query(`
|
|
SELECT `+adminSeriesColumns+`, `+raisedByReaderAnswer+`, `+failureAnswer+`,
|
|
COUNT(b.reader_id) AS reader_count,
|
|
COUNT(*) OVER () AS filtered_total
|
|
FROM series s
|
|
LEFT JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
|
|
LEFT JOIN poll_failures f ON f.site = s.site AND f.series_id = s.series_id
|
|
`+where+`
|
|
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover_address,
|
|
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
|
|
s.force_poll_at, s.latest_corrected_at, s.finished_at, s.site_completed_at,
|
|
s.latest_raised_by,
|
|
f.outcome, f.failing_since
|
|
`+having+`
|
|
ORDER BY s.latest_checked_at, s.site, s.series_id
|
|
LIMIT $`+strconv.Itoa(base+1)+` OFFSET $`+strconv.Itoa(base+2), args...)
|
|
if err != nil {
|
|
return SeriesPage{}, fmt.Errorf("query series page: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := SeriesPage{}
|
|
for rows.Next() {
|
|
a, total, err := scanAdminSeries(rows.Scan)
|
|
if err != nil {
|
|
return SeriesPage{}, fmt.Errorf("scan series page: %w", err)
|
|
}
|
|
out.Rows = append(out.Rows, a)
|
|
out.Total = total
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// SeriesShapes returns each Site's share of the Series matching the filter,
|
|
// one grouped pass. Site and Page are row-read concerns and are ignored; the
|
|
// Landing page reads this per Site and sums the totals in Go for the
|
|
// library-wide figure.
|
|
func (s *Store) SeriesShapes(f SeriesFilter) ([]SiteSeriesShape, error) {
|
|
where, having, args, err := adminFilter(f)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.db.Query(`
|
|
SELECT site,
|
|
COUNT(*) AS total,
|
|
COUNT(*) FILTER (WHERE kind = 'manga') AS manga,
|
|
COUNT(*) FILTER (WHERE kind = 'novel') AS novel
|
|
FROM (
|
|
SELECT s.site, s.kind
|
|
FROM series s
|
|
LEFT JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
|
|
LEFT JOIN poll_failures f ON f.site = s.site AND f.series_id = s.series_id
|
|
`+where+`
|
|
GROUP BY s.site, s.series_id, s.kind
|
|
`+having+`
|
|
) shape
|
|
GROUP BY site
|
|
ORDER BY site`, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query series shapes: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []SiteSeriesShape{}
|
|
for rows.Next() {
|
|
var sh SiteSeriesShape
|
|
if err := rows.Scan(&sh.Site, &sh.Total, &sh.Manga, &sh.Novel); err != nil {
|
|
return nil, fmt.Errorf("scan series shape: %w", err)
|
|
}
|
|
out = append(out, sh)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// scanAdminSeries reads one row in adminSeriesColumns + raisedByReaderAnswer +
|
|
// failureAnswer order, plus the query's reader_count and filtered_total
|
|
// columns, and returns the window total alongside the row. latest_chapter_num
|
|
// is NULL until first captured — the "never read a chapter" state. The
|
|
// Sighting-raiser column is never among the scanned columns.
|
|
func scanAdminSeries(scan func(...any) error) (AdminSeries, int, error) {
|
|
var (
|
|
a AdminSeries
|
|
latestChapterNum sql.NullFloat64
|
|
total int
|
|
)
|
|
if err := scan(
|
|
&a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress,
|
|
&a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt,
|
|
&a.ForcePollAt, &a.LatestCorrectedAt, &a.FinishedAt, &a.SiteCompletedAt,
|
|
&a.RaisedByReader, &a.FailureOutcome, &a.FailingSince, &a.ReaderCount, &total,
|
|
); err != nil {
|
|
return AdminSeries{}, 0, err
|
|
}
|
|
if latestChapterNum.Valid {
|
|
a.LatestChapterNum = &latestChapterNum.Float64
|
|
}
|
|
return a, total, nil
|
|
}
|