Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0081a6100c | |||
| cdd2e1d72c | |||
| 0029cff27c | |||
| f85641da61 | |||
| f66b63f6de | |||
| 0be578449b | |||
| 410509097a | |||
| 948ecae2cc | |||
| ea7bb97112 | |||
| 6fa9972bea | |||
| 77e3f710ec | |||
| faa80c41ea | |||
| 4aaf1d4f91 |
@@ -1,108 +1,49 @@
|
||||
---
|
||||
name: implement-tickets
|
||||
description: "Orchestrate a batch of tickets: plan the briefs, then hand each ticket to its own implementer subagent in its own worktree."
|
||||
description: "Run a planned wave of tickets: one implementer subagent per ticket in its own worktree, then land, merge and close what comes back."
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Implement tickets
|
||||
|
||||
You are the **orchestrator**. You write briefs, dispatch, land results, and talk
|
||||
to the tracker. You do not write the implementation — every line of ticket code
|
||||
is written by a `ticket-implementer` subagent in its own git worktree. Reach for
|
||||
the editor yourself only for a merge conflict resolution.
|
||||
You are the **orchestrator**. You dispatch, land results, and talk to the
|
||||
tracker. You do not write the implementation — every line of ticket code is
|
||||
written by a `ticket-implementer` subagent in its own git worktree. Reach for the
|
||||
editor yourself only for a merge conflict resolution.
|
||||
|
||||
Ticket source and tracker conventions: `docs/agents/issue-tracker.md`. `tea` usage: skill `gitea`.
|
||||
|
||||
## 1. Collect the tickets
|
||||
## 0. Load the plan
|
||||
|
||||
The user's argument is the selector: issue numbers, a label, a parent issue, or
|
||||
nothing. With nothing, take the open issues labelled `ready-for-agent`.
|
||||
Your argument is the batch slug. Read `.scratch/<batch-slug>/plan.md` — it gives
|
||||
the base branch, the waves, the contracts, and each ticket's brief path and
|
||||
status. Run the earliest wave that is not landed.
|
||||
|
||||
Fetch each with `tea issue <n> --comments`, and read the **whole** body —
|
||||
acceptance criteria and the `Blocked by` line are what the rest of this skill
|
||||
runs on. A ticket whose blockers are still open is out of this batch unless a
|
||||
blocker is also in it.
|
||||
The briefs are the requirements and they are already approved: read each one you
|
||||
are about to dispatch, but do not rewrite it, and do not fetch the tickets from
|
||||
the tracker to second-guess it. A brief that is wrong or thin is a `plan-tickets`
|
||||
problem — say so and stop, rather than patching it here.
|
||||
|
||||
## 2. Plan the batch
|
||||
No plan file, or no briefs for the next wave: run `plan-tickets` first. That
|
||||
skill owns wave membership, contracts, and every brief.
|
||||
|
||||
Explore enough of the codebase to write briefs a fresh context can act on: the
|
||||
files each ticket lands in, the patterns it must follow, the `AGENTS.md`
|
||||
invariants it touches.
|
||||
|
||||
Then decide three things:
|
||||
|
||||
- **Waves.** Blocking edges set the order; tickets with no open blocker inside
|
||||
the batch share a wave. Cap each wave at **3** concurrent tickets unless the
|
||||
user set another width.
|
||||
- **Contracts.** Two tickets in one wave that meet at a function signature, a
|
||||
JSON shape, a table column, or a token name: you decide the shape now and
|
||||
write the identical wording into both briefs. A contract left for the
|
||||
subagents to negotiate is a merge conflict you scheduled.
|
||||
- **Splits.** A ticket too big for one fresh context window goes into the wave
|
||||
as two briefs, or back to the user.
|
||||
|
||||
## 3. Get the plan approved
|
||||
|
||||
Present, and stop:
|
||||
|
||||
- the wave list, and for each ticket: number, title, one-line brief summary,
|
||||
the files or areas it will touch, its verification commands
|
||||
- every cross-ticket contract, verbatim as it will appear in the briefs
|
||||
- anything you had to assume
|
||||
|
||||
Wait for approval. Apply the user's edits to the plan, do not relitigate them.
|
||||
|
||||
## 4. Run a wave
|
||||
## 1. Dispatch the wave
|
||||
|
||||
Per ticket, before dispatch:
|
||||
|
||||
```bash
|
||||
git worktree add ../ticket-<n> -b ticket/<n>-<slug> <base> # base = the branch you are on
|
||||
git worktree add ../ticket-<n> -b ticket/<n>-<slug> <base> # base = the plan's base branch, checked out here
|
||||
cp .env ../ticket-<n>/ 2>/dev/null # gitignored, worktrees do not get it
|
||||
tea issue edit <n> --add-assignees <your gitea username> # tea login list has it
|
||||
```
|
||||
|
||||
Write the brief to `.scratch/<batch-slug>/t<n>-brief.md` using the template
|
||||
below, in the ubiquitous language of `CONTEXT.md` — a brief that says "scrape"
|
||||
where the domain says Poll hands the subagent the wrong model of the system.
|
||||
Then dispatch the whole wave in **one** `task` batch, every item on the
|
||||
`ticket-implementer` agent. Each dispatch names: the absolute brief path, the
|
||||
worktree path, the branch, the base ref, and the report path
|
||||
`.scratch/<batch-slug>/t<n>-report.md`.
|
||||
`.scratch/<batch-slug>/t<n>-report.md`. Mark each ticket `dispatched` in the plan
|
||||
file.
|
||||
|
||||
<brief-template>
|
||||
|
||||
# Ticket #<n> — <title>
|
||||
|
||||
**Read first.** `tea issue <n> --comments` for this ticket, then the issue it
|
||||
refers to — the parent or spec — the same way. The comments carry decisions the
|
||||
body never got updated with. This brief stays the requirements; those two reads
|
||||
are the intent behind them.
|
||||
|
||||
**Goal.** The end-to-end behaviour this ticket makes work, from the user's side.
|
||||
|
||||
**Acceptance criteria.** Verbatim from the ticket.
|
||||
|
||||
**Contract.** The exact shared signatures / shapes / names this ticket must
|
||||
implement or consume, and which sibling ticket is on the other end. Omit when
|
||||
the ticket touches nothing shared.
|
||||
|
||||
**Where it lands.** The files and packages, and the existing pattern to follow
|
||||
in each.
|
||||
|
||||
**Binding invariants.** The `AGENTS.md` rules this change can break — name them.
|
||||
|
||||
**TDD seams.** Where a test comes first — run the `tdd` skill at each one and
|
||||
follow its red → green loop. Or "none — verify after".
|
||||
|
||||
**Verify.** The exact commands, e.g. `cd backend && go test ./...`,
|
||||
`node --test userscript/test/logic.test.js`.
|
||||
|
||||
**Out of scope.** What not to touch, especially a sibling ticket's files.
|
||||
|
||||
</brief-template>
|
||||
|
||||
## 5. Land the wave
|
||||
## 2. Land the wave
|
||||
|
||||
The wave is landed when every ticket in it is closed, reverted, or handed back
|
||||
to the user. Per returned ticket:
|
||||
@@ -120,14 +61,17 @@ clash — both sides green apart, wrong together — goes back to whichever tick
|
||||
owns the contract, as a re-dispatch with the collision described.
|
||||
|
||||
Then `tea comment <n> "<the report summary>"`, `tea issue close <n>`, and
|
||||
`git worktree remove ../ticket-<n>`. Keep the report file.
|
||||
`git worktree remove ../ticket-<n>`. Keep the report file, and mark the ticket
|
||||
`landed` or `handed back` in the plan file.
|
||||
|
||||
Only once the whole wave is landed does the next wave start — its briefs may
|
||||
need what this one changed.
|
||||
## 3. Hand back or close the batch
|
||||
|
||||
## 6. Close the batch
|
||||
Waves left in the plan: stop and say which wave is next. Its briefs are written
|
||||
by `plan-tickets` against the base you just changed — that is why they were not
|
||||
written up front, and why you do not write them.
|
||||
|
||||
Run the full suite once on the merged base, and report: a line per ticket with
|
||||
its status, commits, and open concerns, plus anything still assigned or open on
|
||||
the tracker. A red suite after every ticket went green is an interaction bug —
|
||||
diagnose it, name the two tickets, and fix it or hand it back with both named.
|
||||
Last wave landed: run the full suite once on the merged base, and report a line
|
||||
per ticket with its status, commits, and open concerns, plus anything still
|
||||
assigned or open on the tracker. A red suite after every ticket went green is an
|
||||
interaction bug — diagnose it, name the two tickets, and fix it or hand it back
|
||||
with both named.
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
name: plan-tickets
|
||||
description: "Plan a batch of tickets and write the briefs for its next wave: read the tracker, decide waves and contracts, get the plan approved."
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Plan tickets
|
||||
|
||||
You produce the two artifacts the `implement-tickets` skill runs on: a **plan
|
||||
file** and one **brief** per ticket in the next wave. You write no ticket code
|
||||
and create no worktrees — that is the runner's half.
|
||||
|
||||
Ticket source and tracker conventions: `docs/agents/issue-tracker.md`. `tea` usage: skill `gitea`.
|
||||
|
||||
Called twice in a batch's life, at least: once to open it, then again after each
|
||||
wave lands, because a later wave's briefs may need what the last one changed. On
|
||||
a re-entry, read the existing `.scratch/<batch-slug>/plan.md` first and plan only
|
||||
the next unlanded wave — the waves and contracts already approved there stand
|
||||
unless the landed wave proved one wrong.
|
||||
|
||||
## 1. Collect the tickets
|
||||
|
||||
The user's argument is the selector: issue numbers, a label, a parent issue, or
|
||||
nothing. With nothing, take the open issues labelled `ready-for-agent`.
|
||||
|
||||
Fetch each with `tea issue <n> --comments`, and read the **whole** body —
|
||||
acceptance criteria and the `Blocked by` line are what the rest of this skill
|
||||
runs on. A ticket whose blockers are still open is out of this batch unless a
|
||||
blocker is also in it. Read the issue each ticket refers to, its parent or spec,
|
||||
the same way: nothing on the implementation path reads the tracker after you —
|
||||
only `cr-spec` does, at review time — so a decision that lives in a comment
|
||||
reaches the implementer only if you carry it into the brief.
|
||||
|
||||
## 2. Plan the batch
|
||||
|
||||
Explore enough of the codebase to write briefs a fresh context can act on: the
|
||||
files each ticket lands in, the patterns it must follow, the `AGENTS.md`
|
||||
invariants it touches.
|
||||
|
||||
Then decide three things:
|
||||
|
||||
- **Waves.** Blocking edges set the order; tickets with no open blocker inside
|
||||
the batch share a wave. Cap each wave at **3** concurrent tickets unless the
|
||||
user set another width.
|
||||
- **Contracts.** Two tickets in one wave that meet at a function signature, a
|
||||
JSON shape, a table column, or a token name: you decide the shape now and
|
||||
write the identical wording into both briefs. A contract left for the
|
||||
subagents to negotiate is a merge conflict you scheduled.
|
||||
- **Splits.** A ticket too big for one fresh context window goes into the wave
|
||||
as two briefs, or back to the user.
|
||||
|
||||
## 3. Write the artifacts
|
||||
|
||||
Pick a `<batch-slug>` — short, from what the batch is about — and write
|
||||
`.scratch/<batch-slug>/plan.md`. It is the handoff: the runner is a fresh context
|
||||
that reads this and nothing of your reasoning.
|
||||
|
||||
<plan-template>
|
||||
|
||||
# Batch <batch-slug>
|
||||
|
||||
**Base branch.** The branch every worktree forks from and merges back into.
|
||||
|
||||
**Waves.** A table: wave number, ticket number, title, brief path, and status —
|
||||
`planned` | `dispatched` | `landed` | `handed back`. Every ticket in the batch,
|
||||
including waves not briefed yet.
|
||||
|
||||
**Contracts.** Each cross-ticket contract verbatim, naming both ticket numbers.
|
||||
|
||||
**Assumptions.** What you had to assume, and what the user corrected at approval.
|
||||
|
||||
</plan-template>
|
||||
|
||||
Then write a brief per ticket in the next wave to
|
||||
`.scratch/<batch-slug>/t<n>-brief.md`, using the template below, in the
|
||||
ubiquitous language of `CONTEXT.md` — a brief that says "scrape" where the domain
|
||||
says Poll hands the subagent the wrong model of the system.
|
||||
|
||||
<brief-template>
|
||||
|
||||
# Ticket #<n> — <title>
|
||||
|
||||
**Decisions.** Whatever the ticket's comments or its parent spec settled that
|
||||
the body never got updated with, restated verbatim. The implementer reads this
|
||||
brief and the code, never the tracker — a decision missing here is lost to it,
|
||||
and a stale comment thread is not a source you want a fresh context guessing
|
||||
from. Omit only when the ticket has no comments.
|
||||
|
||||
**Goal.** The end-to-end behaviour this ticket makes work, from the user's side.
|
||||
|
||||
**Acceptance criteria.** Verbatim from the ticket.
|
||||
|
||||
**Contract.** The exact shared signatures / shapes / names this ticket must
|
||||
implement or consume, and which sibling ticket is on the other end. Omit when
|
||||
the ticket touches nothing shared.
|
||||
|
||||
**Where it lands.** The files and packages, and the existing pattern to follow
|
||||
in each.
|
||||
|
||||
**Binding invariants.** The `AGENTS.md` rules this change can break — name them.
|
||||
|
||||
**TDD seams.** Where a test comes first — run the `tdd` skill at each one and
|
||||
follow its red → green loop. Or "none — verify after".
|
||||
|
||||
**Verify.** The exact commands, e.g. `cd backend && go test ./...`,
|
||||
`node --test userscript/test/logic.test.js`.
|
||||
|
||||
**Out of scope.** What not to touch, especially a sibling ticket's files.
|
||||
|
||||
</brief-template>
|
||||
|
||||
## 4. Get the plan approved
|
||||
|
||||
Present, and stop:
|
||||
|
||||
- the wave list, and for each ticket in the next wave: number, title, one-line
|
||||
brief summary, the files or areas it will touch, its verification commands
|
||||
- every cross-ticket contract, verbatim as it appears in the briefs
|
||||
- anything you had to assume
|
||||
|
||||
Wait for approval. Apply the user's edits to the plan, do not relitigate them —
|
||||
into the files, not just the reply, or the runner never sees them.
|
||||
|
||||
Then hand off: name the batch slug and the wave, and stop. Running the wave is
|
||||
`implement-tickets`.
|
||||
@@ -24,34 +24,35 @@ Your branch is already checked out there. Never `git checkout`, `git switch`,
|
||||
|
||||
## Order of work
|
||||
|
||||
1. Read the brief file. It is the single source of requirements — use its exact
|
||||
values verbatim.
|
||||
2. Read the ticket and the issue it refers to, as the brief's **Read first**
|
||||
section names them: `tea issue <n> --comments` for each. The ticket's
|
||||
comments and its parent carry the intent and the decisions behind the brief.
|
||||
Read no other ticket and no other brief.
|
||||
3. Read `AGENTS.md` in the worktree, plus the nested `AGENTS.md` for the area
|
||||
1. Read the brief file. It is the only statement of requirements you get — use
|
||||
its exact values verbatim, and read nothing from the tracker. The
|
||||
orchestrator has already read the ticket, its comments and its parent spec,
|
||||
and folded every live decision into the brief; the threads themselves also
|
||||
hold reversed and rejected ones you cannot tell apart from here.
|
||||
2. Read `AGENTS.md` in the worktree, plus the nested `AGENTS.md` for the area
|
||||
you touch. Its invariants bind you: security rules, design system, comment
|
||||
policy.
|
||||
4. Ask before writing code if requirements, acceptance criteria, approach, or
|
||||
3. Ask before writing code if requirements, acceptance criteria, approach, or
|
||||
dependencies are unclear. Asking is free; guessing is not.
|
||||
5. Implement exactly what the brief specifies. At each TDD seam the brief names,
|
||||
4. Implement exactly what the brief specifies. At each TDD seam the brief names,
|
||||
run the `tdd` skill and follow its red → green loop.
|
||||
Follow the patterns already in the codebase; improve what you touch,
|
||||
restructure nothing outside the ticket.
|
||||
6. Verify. Focused tests while iterating, the brief's full verification commands
|
||||
5. Verify. Focused tests while iterating, the brief's full verification commands
|
||||
once at the end. Test output must be pristine.
|
||||
7. Commit to your branch. Reference the ticket number in the subject.
|
||||
8. Review (below), fix, re-verify, commit the fixes.
|
||||
9. Write the report file, then return the status contract.
|
||||
6. Commit to your branch. Reference the ticket number in the subject.
|
||||
7. Review (below), fix, re-verify, commit the fixes.
|
||||
8. Write the report file, then return the status contract.
|
||||
|
||||
## Review
|
||||
|
||||
After your first green commit, run the **`code-review`** skill over
|
||||
`<base ref>...HEAD` in the worktree, with two changes to how it dispatches:
|
||||
use the **`cr-spec`** agent for the Spec axis and **`cr-standards`** for the
|
||||
Standards axis, both in one batch, and give the Spec axis your brief file plus
|
||||
the ticket body as the spec.
|
||||
Standards axis, both in one batch, and hand the Spec axis your brief file as the
|
||||
spec plus the ticket number from the brief's title, telling it to read the
|
||||
ticket itself (`tea issue <n> --comments`). The tracker check belongs in that
|
||||
read-only context, not in yours.
|
||||
|
||||
Fix every Critical and Important finding, then re-run the tests that cover the
|
||||
amended code. Two fix rounds maximum: anything still open after that goes in the
|
||||
|
||||
@@ -99,7 +99,7 @@ Go backend:
|
||||
|
||||
- SQL always parameterized (`$N`). Only compile-time constants (`bookmarkColumns`) may be concatenated into query text — never a request value, not even a validated one.
|
||||
- `html/template` only for anything a browser parses, never `text/template`. Never wrap stored or fetched strings in `template.HTML`/`JS`/`URL`; that switches off the escaping every template depends on.
|
||||
- Any outbound fetch of a client-supplied URL passes `fetchableSeriesURL` (site + `https` + host check) first. `series_url` arrives in a PUT body, so without the gate the poller will probe arbitrary hosts from the server's own network position. New fetch path reuses the gate rather than re-deriving one.
|
||||
- Any outbound fetch of a client-supplied URL passes `FetchableSeriesURL` (site + `https` + host check) first. `series_url` arrives in a PUT body, so without the gate the poller will probe arbitrary hosts from the server's own network position. New fetch path reuses the gate rather than re-deriving one.
|
||||
- Cap every remote body with `io.LimitReader` (`maxBodyBytes`). An unbounded read is an OOM handed to whatever is on the other end.
|
||||
- Compare secrets with `hmac.Equal` / `subtle.ConstantTimeCompare`, never `==`. A credential is matched by the SHA-256 the `readers` table holds, which is already a fixed-width equality — a new secret comparison must not regress to `==`.
|
||||
- Errors: generic text to the client (`http.Error(w, "internal error", 500)`), detail to `log.Printf`. Never log `TOKEN_KEY`, a Reader's credential, `DISCORD_CLIENT_SECRET`, a session id, or a whole `Authorization` header.
|
||||
|
||||
+15
-3
@@ -95,7 +95,7 @@ _Avoid_: run, cycle, tick, batch, poll history
|
||||
**Forced Poll**:
|
||||
A Poll the owner asks for by hand instead of waiting for the Series's turn. It jumps its
|
||||
Lane's queue and ignores every waiting rule — the rest between Polls, a Sighting standing
|
||||
in for a check, a Series only finished Readers hold — but never overrules a Site that is
|
||||
in for a check, a finished Series — but never overrules a Site that is
|
||||
refusing us, the Lane's spacing between fetches, or a Series with no page to fetch. Asked
|
||||
for by marking the Series, never by commanding the poller, so it happens on the Lane's
|
||||
next pass rather than at the moment of asking.
|
||||
@@ -149,10 +149,22 @@ accent is permitted to signal.
|
||||
_Avoid_: unread, update available
|
||||
|
||||
**Lifecycle bucket**:
|
||||
Which of three mutually exclusive states a Bookmark sits in — reading, archived, or
|
||||
finished. A Bookmark is in exactly one. Orthogonal to being a favourite.
|
||||
Which of the two states a Bookmark sits in — reading or archived. A Bookmark is in exactly
|
||||
one. Orthogonal to being a favourite. Finished is not a bucket: it is a fact about the
|
||||
Series (see `series.finished_at`), owned by the owner and stamped once, and every Bookmark
|
||||
on a finished Series is archived.
|
||||
_Avoid_: state, status (as a domain word), list
|
||||
|
||||
**Finished Series**:
|
||||
A Series the owner has marked finished, stamped once in `series.finished_at`
|
||||
(epoch ms, zero means not finished). The owner is its only writer — no
|
||||
adapter, no Reader, no Poll can set it — and a Forced Poll reads a finished
|
||||
Series once for that pass and never clears the flag. It is a fact about the
|
||||
Series, not a Bookmark bucket: every Bookmark on a finished Series is
|
||||
archived, the Lane stops polling it (the due gate reads `finished_at = 0`),
|
||||
and Readers see a label and nothing more.
|
||||
_Avoid_: completed, done, dropped, shelved (that is Archived), ended
|
||||
|
||||
**Favourite**:
|
||||
A reader's manual pin on a Bookmark. Orthogonal to the Lifecycle bucket, and never a
|
||||
reason to reorder the list.
|
||||
|
||||
+2
-2
@@ -29,8 +29,8 @@ Not a public reading tracker or social app — a private, self-hosted sync layer
|
||||
|
||||
## Capabilities and Constraints
|
||||
|
||||
- Two libraries (manga, novels) with lifecycle tabs: All / Updated / Favourites / Archived / Finished. Search-filter by title (client-side, `filter.js`).
|
||||
- Card actions: continue (opens source site), toggle favourite, manual chapter override, archive, finish, remove — each move out of the list confirm-gated.
|
||||
- Two libraries (manga, novels) with lifecycle tabs: All / Updated / Favourites / Archived. Search-filter by title (client-side, `filter.js`).
|
||||
- Card actions: continue (opens source site), toggle favourite, manual chapter override, archive, remove — each move out of the list confirm-gated.
|
||||
- "Continue reading" horizontal strip for series with an unread chapter.
|
||||
- A Reader with no bookmarks at all sees a deliberate empty library offering both userscript install links, not an error and not a blank page.
|
||||
- Isolation is the load-bearing invariant: two Readers cannot see or change each other's bookmarks. A series both track is one shared row polled once, with independent progress on each side.
|
||||
|
||||
@@ -218,10 +218,10 @@ desktop for faster testing — install the same file unchanged.
|
||||
keeps checking it for new chapters, so it is worth coming back to. Archiving
|
||||
does not touch read progress, and reading an archived series leaves it
|
||||
archived.
|
||||
- **Finished**: series you have completed live in a **Finished** tab in the web
|
||||
UI only. It is set there and nowhere else — the API rejects the value — and
|
||||
finished series are hidden from every userscript tab and are no longer polled
|
||||
for new chapters.
|
||||
- **Finished**: the owner marks a Series finished from its detail page; the
|
||||
backend stops polling it, and every Reader sees a read-only label. It is a
|
||||
fact about the Series, not a Reader's bucket: old `Finished` bookmarks were
|
||||
folded into Archived in the cutover, so there is no Finished tab.
|
||||
- Bookmarks made on Asura appear when the panel is opened on Demonic, and vice
|
||||
versa — the backend is the shared store.
|
||||
|
||||
|
||||
+9
-5
@@ -200,12 +200,16 @@ stored** and clients must adopt that response rather than their own payload.
|
||||
|
||||
### Lifecycle buckets — `status` on each bookmark
|
||||
|
||||
`reading` | `archived` | `finished`, orthogonal to `favorite`. Archived and
|
||||
finished appear only in their own tab, never in All, Updated, Favourites or the
|
||||
recent strip. The poller keeps checking archived series and skips finished ones.
|
||||
`reading` | `archived`, orthogonal to `favorite`. Archived rows appear only
|
||||
in their own tab, never in All, Updated, Favourites or the recent strip. The
|
||||
poller keeps checking archived series; a finished Series (issue #157) is a
|
||||
`series.finished_at` fact the Lane gate reads, with every bookmark on it
|
||||
archived.
|
||||
|
||||
- `finished` is settable only from the web UI; `PUT /bookmarks/{key}` rejects
|
||||
it with 400.
|
||||
- `PUT /bookmarks/{key}` accepts only the two values; anything else —
|
||||
`finished` included — is a plain 400, and the web UI's own status control
|
||||
validates the same way. The 0016 migration is the only writer of the flag
|
||||
today; the undo is writing 0.
|
||||
- **An empty incoming status means "keep the stored one"**, and it is resolved
|
||||
on the `VALUES` side of `Store.Upsert`, not in the conflict clause:
|
||||
`excluded.*` is the post-evaluation row, so a default applied there would
|
||||
|
||||
+48
-1
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -298,7 +299,7 @@ func TestFlatWireFieldSet(t *testing.T) {
|
||||
"series_url": true, "cover": true, "last_chapter": true,
|
||||
"last_chapter_num": true, "last_chapter_url": true, "favorite": true,
|
||||
"latest_chapter": true, "latest_chapter_num": true, "updated_at": true,
|
||||
"status": true, "kind": true,
|
||||
"status": true, "kind": true, "finished": true,
|
||||
}
|
||||
checkFlat := func(t *testing.T, payload []byte) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
@@ -362,6 +363,52 @@ func TestFlatWireFieldSet(t *testing.T) {
|
||||
checkFlat(t, body2)
|
||||
}
|
||||
|
||||
// finished is derived on the wire and read-only: a client PUT echoing a cached
|
||||
// value, forward progress or not, must not change the Series' retired state,
|
||||
// so GET still reports the truth after the echo (issues #157, #160).
|
||||
func TestFinishedWireRoundTrip(t *testing.T) {
|
||||
s, url := newTestStoreURL(t)
|
||||
srv := newRouter(s, testConfig())
|
||||
key := "asura:done"
|
||||
|
||||
putBookmark(t, srv, key, store.Bookmark{
|
||||
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/comics/done",
|
||||
LastChapterNum: 10,
|
||||
})
|
||||
|
||||
// The store writer for the flag is the admin surface's own and lands in
|
||||
// the same wave (#158), so seed the fact with SQL, like store_test.go.
|
||||
db, err := sql.Open("pgx", url)
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(
|
||||
`UPDATE series SET finished_at = 1000 WHERE site = 'asura' AND series_id = 'done'`); err != nil {
|
||||
t.Fatalf("seed finished: %v", err)
|
||||
}
|
||||
|
||||
got := getBookmarks(t, srv)
|
||||
if len(got) != 1 || !got[0].Finished {
|
||||
t.Fatalf("GET = %+v, want one bookmark carrying finished: true", got)
|
||||
}
|
||||
|
||||
// A stale cache echoing the flag cannot un-finish (or finish) the Series.
|
||||
for _, sent := range []bool{false, true} {
|
||||
echoed := putBookmark(t, srv, key, store.Bookmark{
|
||||
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/comics/done",
|
||||
LastChapterNum: 11, Finished: sent,
|
||||
})
|
||||
if !echoed.Finished {
|
||||
t.Fatalf("PUT echoing Finished: %v reported finished = false, want true", sent)
|
||||
}
|
||||
got = getBookmarks(t, srv)
|
||||
if !got[0].Finished {
|
||||
t.Fatalf("GET after echoing Finished: %v = false, want the Series state preserved", sent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A PUT naming an existing series must ignore client-supplied title, cover and
|
||||
// URL — the security boundary from ADR-0003, where a hostile site's scraped
|
||||
// values could otherwise land on a shared row — while progress still lands.
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestPublicCoverServesStoredBytesUnauthenticated(t *testing.T) {
|
||||
|
||||
// The wire URL is what a client actually requests, so the path under test
|
||||
// is taken from it rather than rebuilt by hand.
|
||||
wire := st.CoverWireURL(store.CoverAddress(sourceURL))
|
||||
wire := st.CoverWireURL(store.CoverAddressForBytes([]byte("\x00webp-bytes")))
|
||||
path, ok := strings.CutPrefix(wire, testCoverBaseURL)
|
||||
if !ok {
|
||||
t.Fatalf("wire URL %q is not on the public origin %q", wire, testCoverBaseURL)
|
||||
@@ -57,7 +57,7 @@ func TestPublicCoverServesStoredBytesUnauthenticated(t *testing.T) {
|
||||
func TestPublicCoverRejectsUnknownAddress(t *testing.T) {
|
||||
srv, _ := newWebTestServer(t, testConfig())
|
||||
cases := map[string]string{
|
||||
"unknown": "/covers/" + store.CoverAddress("https://cdn.example/never-stored.jpg"),
|
||||
"unknown": "/covers/" + store.CoverAddressForBytes([]byte("never-stored")),
|
||||
"malformed": "/covers/not-an-address",
|
||||
"traversal": "/covers/../../etc/passwd",
|
||||
"empty": "/covers/",
|
||||
@@ -86,7 +86,7 @@ func TestPublicCoverNeverEchoesNonImage(t *testing.T) {
|
||||
}
|
||||
// A legitimate row, then the content type flipped behind the store's back:
|
||||
// the bytes exist at the address, so only the type is hostile.
|
||||
address := store.CoverAddress(sourceURL)
|
||||
address := store.CoverAddressForBytes([]byte("<script>"))
|
||||
if err := st.SetSeriesCover("asura", "solo", sourceURL, []byte("<script>"), "image/png"); err != nil {
|
||||
t.Fatalf("seed row: %v", err)
|
||||
}
|
||||
@@ -127,7 +127,7 @@ func TestListRendersAcquiredCover(t *testing.T) {
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
want := `src="` + testCoverBaseURL + "/covers/" + store.CoverAddress(sourceURL) + `"`
|
||||
want := `src="` + testCoverBaseURL + "/covers/" + store.CoverAddressForBytes([]byte("\xff\xd8jpeg")) + `"`
|
||||
if !strings.Contains(rr.Body.String(), want) {
|
||||
t.Fatalf("rendered list does not contain %s", want)
|
||||
}
|
||||
|
||||
@@ -73,14 +73,11 @@ func (h *Handler) Put(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// An empty status is "no opinion" and Upsert keeps the stored bucket.
|
||||
// Finishing a series is a web-UI decision, so the JSON API refuses it
|
||||
// rather than trusting every client to leave it alone.
|
||||
// Anything else outside the two lifecycle buckets is a client bug, not
|
||||
// something to silently coerce — finished included, which is no longer a
|
||||
// bucket at all (issue #157).
|
||||
switch b.Status {
|
||||
case "", store.StatusReading, store.StatusArchived:
|
||||
case store.StatusFinished:
|
||||
http.Error(w, "status "+store.StatusFinished+" can only be set from the web UI",
|
||||
http.StatusBadRequest)
|
||||
return
|
||||
default:
|
||||
http.Error(w, "invalid status", http.StatusBadRequest)
|
||||
return
|
||||
|
||||
@@ -123,10 +123,10 @@ func TestAcquireFillsChapterAndCoverFromOneFetch(t *testing.T) {
|
||||
if got.LatestChapterNum == nil || *got.LatestChapterNum != 181 {
|
||||
t.Fatalf("LatestChapterNum = %v, want 181", got.LatestChapterNum)
|
||||
}
|
||||
if want := testCoverBaseURL + "/covers/" + store.CoverAddress(acquireCoverURL); got.Cover != want {
|
||||
if want := testCoverBaseURL + "/covers/" + store.CoverAddressForBytes([]byte("cover-bytes")); got.Cover != want {
|
||||
t.Fatalf("Cover = %q, want the absolute address %q", got.Cover, want)
|
||||
}
|
||||
body, contentType, ok, err := s.CoverByAddress(store.CoverAddress(acquireCoverURL))
|
||||
body, contentType, ok, err := s.CoverByAddress(store.CoverAddressForBytes([]byte("cover-bytes")))
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CoverByAddress = %v, %v", ok, err)
|
||||
}
|
||||
@@ -182,7 +182,7 @@ func TestAcquireSkipsAnExistingSeries(t *testing.T) {
|
||||
t.Fatalf("cover fetches = %d, want 1", got)
|
||||
}
|
||||
got := readBookmark(t, s, acquireKey)
|
||||
if want := testCoverBaseURL + "/covers/" + store.CoverAddress(acquireCoverURL); got.Cover != want {
|
||||
if want := testCoverBaseURL + "/covers/" + store.CoverAddressForBytes([]byte("cover-bytes")); got.Cover != want {
|
||||
t.Fatalf("Cover = %q, want the acquired one %q", got.Cover, want)
|
||||
}
|
||||
}
|
||||
@@ -343,10 +343,10 @@ func TestAcquireKaganeCoverThroughBrowser(t *testing.T) {
|
||||
t.Fatalf("browser cover fetched URL %q, want %q", got, kaganeCoverSrc)
|
||||
}
|
||||
got := readBookmark(t, s, kaganeKey)
|
||||
if want := testCoverBaseURL + "/covers/" + store.CoverAddress(kaganeCoverSrc); got.Cover != want {
|
||||
if want := testCoverBaseURL + "/covers/" + store.CoverAddressForBytes([]byte("cover-bytes")); got.Cover != want {
|
||||
t.Fatalf("Cover = %q, want the content-addressed URL %q", got.Cover, want)
|
||||
}
|
||||
body, contentType, ok, err := s.CoverByAddress(store.CoverAddress(kaganeCoverSrc))
|
||||
body, contentType, ok, err := s.CoverByAddress(store.CoverAddressForBytes([]byte("cover-bytes")))
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CoverByAddress = %v, %v", ok, err)
|
||||
}
|
||||
@@ -381,7 +381,7 @@ func TestAcquireNovelfullCoverOverPlainTLS(t *testing.T) {
|
||||
t.Fatalf("cover fetched from %q, want %q", got, novelfullCoverURL)
|
||||
}
|
||||
got := readBookmark(t, s, novelfullKey)
|
||||
if want := testCoverBaseURL + "/covers/" + store.CoverAddress(novelfullCoverURL); got.Cover != want {
|
||||
if want := testCoverBaseURL + "/covers/" + store.CoverAddressForBytes([]byte("cover-bytes")); got.Cover != want {
|
||||
t.Fatalf("Cover = %q, want %q", got.Cover, want)
|
||||
}
|
||||
}
|
||||
@@ -451,7 +451,7 @@ func TestAcquireNovelfullCoverWithoutBrowser(t *testing.T) {
|
||||
t.Fatalf("cover fetches = %d, want 1", got)
|
||||
}
|
||||
got := readBookmark(t, s, novelfullKey)
|
||||
if want := testCoverBaseURL + "/covers/" + store.CoverAddress(novelfullCoverURL); got.Cover != want {
|
||||
if want := testCoverBaseURL + "/covers/" + store.CoverAddressForBytes([]byte("cover-bytes")); got.Cover != want {
|
||||
t.Fatalf("Cover = %q, want %q", got.Cover, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ func (f *BrowserFetcher) Get(ctx context.Context, seriesURL string) (string, int
|
||||
// request must be made from inside the page so it carries the clearance
|
||||
// cookie, and the API is the only place the list exists. Refusing any other
|
||||
// address is the per-Site half of the SSRF gate, kept deliberately behind
|
||||
// fetchableSeriesURL (see browserRead.Read).
|
||||
// FetchableSeriesURL (see browserRead.Read).
|
||||
func kaganeRead(seriesURL string, out *string) (chromedp.Action, bool) {
|
||||
apiURL, ok := kaganeAPIURL(seriesURL)
|
||||
if !ok {
|
||||
@@ -326,7 +326,7 @@ func (f *BrowserFetcher) run(ctx context.Context, target string, read chromedp.A
|
||||
|
||||
// kaganeAPIURL maps a stored series_url to the JSON endpoint carrying its
|
||||
// chapter list. Returning false for anything else is a second line of defence
|
||||
// behind fetchableSeriesURL: a headless browser is a strong SSRF primitive and
|
||||
// behind FetchableSeriesURL: a headless browser is a strong SSRF primitive and
|
||||
// series_url is client-supplied, so the host is pinned here too.
|
||||
func kaganeAPIURL(seriesURL string) (string, bool) {
|
||||
u, err := url.Parse(seriesURL)
|
||||
|
||||
@@ -174,7 +174,7 @@ func (f *TLSCoverFetcher) Fetch(ctx context.Context, sourceURL string) ([]byte,
|
||||
return body, contentType, nil
|
||||
}
|
||||
|
||||
// This gate deliberately differs from fetchableSeriesURL: cover hosts are
|
||||
// This gate deliberately differs from FetchableSeriesURL: cover hosts are
|
||||
// site-independent CDNs, so a Site host allowlist would reject valid covers.
|
||||
func (f *TLSCoverFetcher) validateURL(ctx context.Context, u *url.URL) error {
|
||||
if u == nil || u.Scheme != "https" || u.Host == "" || u.User != nil {
|
||||
|
||||
@@ -98,6 +98,23 @@ func (p *Poller) fillBlankCover(ctx context.Context, sr store.Series, cover stri
|
||||
}()
|
||||
}
|
||||
|
||||
// replaceCover is the Forced Poll's Cover path: the owner asked to accept the
|
||||
// page as it now stands, so where fillBlankCover leaves a non-blank Cover
|
||||
// alone (ADR-0007) this writes through whatever the page's Cover URL answers
|
||||
// with, whether one exists or not. The accepted consequence (issue #135):
|
||||
// refreshing the Cover and re-reading the chapters are one act — there is no
|
||||
// Cover-only refetch.
|
||||
func (p *Poller) replaceCover(ctx context.Context, sr store.Series, cover string) {
|
||||
if cover == "" {
|
||||
return
|
||||
}
|
||||
p.coverWG.Add(1)
|
||||
go func() {
|
||||
defer p.coverWG.Done()
|
||||
p.storeCover(ctx, sr, cover)
|
||||
}()
|
||||
}
|
||||
|
||||
// prefetchCover heals Series that already carry a third-party source URL but
|
||||
// no stored address — the state left by client-supplied covers before
|
||||
// acquisition moved server-side. Every Site takes the same path; fetchCoverBytes
|
||||
@@ -122,17 +139,42 @@ func (p *Poller) prefetchCover(ctx context.Context, sr store.Series) {
|
||||
p.storeCover(ctx, sr, sr.Cover)
|
||||
}
|
||||
|
||||
// storeCover fetches bytes for sourceURL and points the Series at them. Every
|
||||
// failure is logged against the Series and swallowed so the chapter poll
|
||||
// cannot see it.
|
||||
// storeCover fetches bytes for sourceURL and points the Series at them: a
|
||||
// fill-only write for an ordinary pass, a write-through for a forced one
|
||||
// (issue #135). Every failure is logged against the Series and swallowed so
|
||||
// the chapter poll cannot see it.
|
||||
func (p *Poller) storeCover(ctx context.Context, sr store.Series, sourceURL string) {
|
||||
bytes, contentType, err := fetchCoverBytes(ctx, sourceURL, p.CoverFetch, p.CoverBytesFetch)
|
||||
if err != nil {
|
||||
log.Printf("latest poll %q: fetch cover %s: %v", sr.Key(), sourceURL, err)
|
||||
return
|
||||
}
|
||||
if err := p.Store.SetSeriesCover(sr.Site, sr.SeriesID, sourceURL, bytes, contentType); err != nil {
|
||||
if !sr.Forced {
|
||||
if err := p.Store.SetSeriesCover(sr.Site, sr.SeriesID, sourceURL, bytes, contentType); err != nil {
|
||||
log.Printf("latest poll %q: persist cover: %v", sr.Key(), err)
|
||||
}
|
||||
return
|
||||
}
|
||||
// The forced write replaces whether or not a Cover exists, and the row
|
||||
// then tells the three outcomes apart: a blank filled, identical artwork
|
||||
// re-served — an honest no-op — or a replacement whose previous address
|
||||
// is stranded and reclaimed below. A failed reclaim is logged and the
|
||||
// stranded bytes stay served until a later call reclaims them.
|
||||
previous, current, err := p.Store.ReplaceSeriesCover(sr.Site, sr.SeriesID, sourceURL, bytes, contentType)
|
||||
if err != nil {
|
||||
log.Printf("latest poll %q: persist cover: %v", sr.Key(), err)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case previous == "":
|
||||
log.Printf("latest poll %q: cover filled at %s", sr.Key(), current)
|
||||
case previous == current:
|
||||
log.Printf("latest poll %q: cover unchanged, the site re-serves the same bytes", sr.Key())
|
||||
default:
|
||||
if err := p.Store.ReclaimCover(previous); err != nil {
|
||||
log.Printf("latest poll %q: reclaim cover %s: %v", sr.Key(), previous, err)
|
||||
}
|
||||
log.Printf("latest poll %q: cover replaced %s -> %s", sr.Key(), previous, current)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,7 +663,12 @@ func (p *Poller) checkOne(ctx context.Context, sr store.Series) (outcome readOut
|
||||
p.healCover(ctx, sr)
|
||||
// Cover fill is independent of the chapter signal: a page that lost its
|
||||
// chapter list may keep its og:image, and a blank Series heals either way.
|
||||
p.fillBlankCover(ctx, sr, facts.Cover)
|
||||
// A forced pass writes the Cover through the replace path instead.
|
||||
if sr.Forced {
|
||||
p.replaceCover(ctx, sr, facts.Cover)
|
||||
} else {
|
||||
p.fillBlankCover(ctx, sr, facts.Cover)
|
||||
}
|
||||
if !facts.HasLatest {
|
||||
// Most likely a challenge page or a layout change. Either way the row is
|
||||
// already stamped, so this waits out a rest instead of hot-looping.
|
||||
@@ -710,7 +757,7 @@ func (p *Poller) waitCovers() {
|
||||
p.coverWG.Wait()
|
||||
}
|
||||
|
||||
// fetchableSeriesURL reports whether site is a Site the registry knows and
|
||||
// FetchableSeriesURL reports whether site is a Site the registry knows and
|
||||
// seriesURL is safe to hand to a fetcher: an https URL whose host matches the
|
||||
// Site's pinned hostname exactly. series_url comes from client-supplied PUT
|
||||
// bodies, so this is a defence against the poller being used to probe
|
||||
@@ -719,7 +766,11 @@ func (p *Poller) waitCovers() {
|
||||
// browser Site guards a control that executes JavaScript and carries cookies,
|
||||
// a parser Site guards a wasted request — but the rule is one rule, from the
|
||||
// registry.
|
||||
func fetchableSeriesURL(site, seriesURL string) bool {
|
||||
//
|
||||
// The owner's series URL repair (issue #151) is a second caller: the web
|
||||
// layer validates with this same gate before storing a repair, so there is
|
||||
// never a second copy of it.
|
||||
func FetchableSeriesURL(site, seriesURL string) bool {
|
||||
s, known := sites[site]
|
||||
if !known {
|
||||
return false
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -214,9 +216,9 @@ func TestRunOncePrefetchesPublicCover(t *testing.T) {
|
||||
if got := covers.callCount(); got != 1 {
|
||||
t.Fatalf("cover fetch calls = %d, want 1", got)
|
||||
}
|
||||
body, contentType, found, err := s.GetCover(coverURL)
|
||||
body, contentType, found, err := s.CoverByAddress(store.CoverAddressForBytes([]byte("cover-bytes")))
|
||||
if err != nil || !found {
|
||||
t.Fatalf("GetCover: %v found=%v", err, found)
|
||||
t.Fatalf("CoverByAddress: %v found=%v", err, found)
|
||||
}
|
||||
if string(body) != "cover-bytes" || contentType != "image/jpeg" {
|
||||
t.Fatalf("stored cover = (%q, %q), want (cover-bytes, image/jpeg)", body, contentType)
|
||||
@@ -588,8 +590,8 @@ func TestFetchableSeriesURL(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := fetchableSeriesURL(tt.site, tt.seriesURL); got != tt.want {
|
||||
t.Errorf("fetchableSeriesURL(%q, %q) = %v, want %v",
|
||||
if got := FetchableSeriesURL(tt.site, tt.seriesURL); got != tt.want {
|
||||
t.Errorf("FetchableSeriesURL(%q, %q) = %v, want %v",
|
||||
tt.site, tt.seriesURL, got, tt.want)
|
||||
}
|
||||
})
|
||||
@@ -663,7 +665,7 @@ func TestNovelfullUsesTLSWhenNoBrowserFetcher(t *testing.T) {
|
||||
if err != nil || !found {
|
||||
t.Fatalf("Get: %v found=%v", err, found)
|
||||
}
|
||||
if want := testCoverBaseURL + "/covers/" + store.CoverAddress(novelfullCoverURL); got.Cover != want {
|
||||
if want := testCoverBaseURL + "/covers/" + store.CoverAddressForBytes([]byte("cover-bytes")); got.Cover != want {
|
||||
t.Fatalf("Cover = %q, want %q", got.Cover, want)
|
||||
}
|
||||
if got.LatestChapterNum == nil || *got.LatestChapterNum != 2334 {
|
||||
@@ -811,7 +813,7 @@ func TestRunOncePrefetchesKaganeCover(t *testing.T) {
|
||||
p.runOnce(context.Background())
|
||||
p.waitCovers()
|
||||
|
||||
body, contentType, ok, err := s.CoverByAddress(store.CoverAddress(coverURL))
|
||||
body, contentType, ok, err := s.CoverByAddress(store.CoverAddressForBytes([]byte("cover-bytes")))
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CoverByAddress: %v found=%v", err, ok)
|
||||
}
|
||||
@@ -821,7 +823,7 @@ func TestRunOncePrefetchesKaganeCover(t *testing.T) {
|
||||
if got := covers.callCount(); got != 1 {
|
||||
t.Fatalf("cover fetch calls = %d, want 1", got)
|
||||
}
|
||||
if got := readBookmark(t, s, key); got.Cover != testCoverBaseURL+"/covers/"+store.CoverAddress(coverURL) {
|
||||
if got := readBookmark(t, s, key); got.Cover != testCoverBaseURL+"/covers/"+store.CoverAddressForBytes([]byte("cover-bytes")) {
|
||||
t.Fatalf("wire Cover = %q, want content-addressed URL", got.Cover)
|
||||
}
|
||||
}
|
||||
@@ -914,7 +916,7 @@ func TestRunOnceRejectsInvalidKaganeCover(t *testing.T) {
|
||||
p.runOnce(context.Background())
|
||||
p.waitCovers()
|
||||
|
||||
if _, _, found, err := s.CoverByAddress(store.CoverAddress(coverURL)); err != nil || found {
|
||||
if _, _, found, err := s.CoverByAddress(store.CoverAddressForBytes([]byte("not an image"))); err != nil || found {
|
||||
t.Fatalf("invalid cover persisted = %v, err %v; want missing", found, err)
|
||||
}
|
||||
}
|
||||
@@ -940,7 +942,7 @@ func TestRunOnceWithoutCoverFetcherStillPollsKagane(t *testing.T) {
|
||||
p.runOnce(context.Background())
|
||||
p.waitCovers()
|
||||
|
||||
if _, _, found, err := s.CoverByAddress(store.CoverAddress(coverURL)); err != nil || found {
|
||||
if _, _, found, err := s.CoverByAddress(store.CoverAddressForBytes([]byte("cover-bytes"))); err != nil || found {
|
||||
t.Fatalf("cover after nil CoverFetch = found %v, err %v; want missing", found, err)
|
||||
}
|
||||
}
|
||||
@@ -1019,8 +1021,8 @@ func TestFetchableSeriesURLPinsNovelHosts(t *testing.T) {
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := fetchableSeriesURL(tc.site, tc.url); got != tc.want {
|
||||
t.Fatalf("fetchableSeriesURL(%q, %q) = %v, want %v", tc.site, tc.url, got, tc.want)
|
||||
if got := FetchableSeriesURL(tc.site, tc.url); got != tc.want {
|
||||
t.Fatalf("FetchableSeriesURL(%q, %q) = %v, want %v", tc.site, tc.url, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1038,7 +1040,6 @@ func TestRunOnceFillsBlankCoverFromSeriesPage(t *testing.T) {
|
||||
seriesURL string
|
||||
kind string
|
||||
body string
|
||||
wantCover string
|
||||
browser bool
|
||||
}{
|
||||
{
|
||||
@@ -1047,14 +1048,12 @@ func TestRunOnceFillsBlankCoverFromSeriesPage(t *testing.T) {
|
||||
seriesID: "chronicles-of-the-demon-faction-f886a8af",
|
||||
seriesURL: "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af",
|
||||
kind: store.KindManga, body: asuraSeriesFixture + asuraCoverFixture,
|
||||
wantCover: "https://cdn.asurascans.com/asura-images/covers/chronicles-of-the-demon-faction.d4dcb8.webp",
|
||||
},
|
||||
{
|
||||
name: "lightnovelworld novel",
|
||||
key: "lightnovelworld:all-jobs-and-classes-i-just-wanted-one-skill-not-them-all", site: "lightnovelworld",
|
||||
seriesID: "all-jobs-and-classes-i-just-wanted-one-skill-not-them-all", seriesURL: "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/",
|
||||
kind: store.KindNovel, body: lnwSeriesFixture + lnwCoverFixture,
|
||||
wantCover: "https://i1.wp.com/lightnovelworld.net/wp-content/uploads/2025/10/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all.jpg",
|
||||
},
|
||||
{
|
||||
name: "kagane manga",
|
||||
@@ -1062,7 +1061,6 @@ func TestRunOnceFillsBlankCoverFromSeriesPage(t *testing.T) {
|
||||
seriesID: "019fe11a-8670-7cf3-8343-0b02057d3787",
|
||||
seriesURL: "https://kagane.to/series/019fe11a-8670-7cf3-8343-0b02057d3787",
|
||||
kind: store.KindManga, body: kaganeAPIFixtureWithCover, browser: true,
|
||||
wantCover: "https://kagane.to/api/v2/image/019fe11a-84c3-7fc3-a84b-88787374b617/compressed",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
@@ -1086,7 +1084,7 @@ func TestRunOnceFillsBlankCoverFromSeriesPage(t *testing.T) {
|
||||
p.waitCovers()
|
||||
|
||||
got := readBookmark(t, s, tc.key)
|
||||
wantWire := testCoverBaseURL + "/covers/" + store.CoverAddress(tc.wantCover)
|
||||
wantWire := testCoverBaseURL + "/covers/" + store.CoverAddressForBytes([]byte("cover-bytes"))
|
||||
if got.Cover != wantWire {
|
||||
t.Fatalf("Cover = %q, want %q", got.Cover, wantWire)
|
||||
}
|
||||
@@ -1143,7 +1141,7 @@ func TestRunOnceDoesNotReplaceExistingCover(t *testing.T) {
|
||||
t.Fatalf("cover fetch calls = %d, want 0", got)
|
||||
}
|
||||
got := readBookmark(t, s, key)
|
||||
if want := testCoverBaseURL + "/covers/" + store.CoverAddress(first); got.Cover != want {
|
||||
if want := testCoverBaseURL + "/covers/" + store.CoverAddressForBytes([]byte("first")); got.Cover != want {
|
||||
t.Fatalf("Cover = %q, want the first one %q", got.Cover, want)
|
||||
}
|
||||
}
|
||||
@@ -1190,7 +1188,7 @@ func TestRunOnceRetriesFailedBlankCoverOnNextPoll(t *testing.T) {
|
||||
t.Fatalf("cover fetch calls after retry = %d, want 2", got)
|
||||
}
|
||||
got := readBookmark(t, s, key)
|
||||
if want := testCoverBaseURL + "/covers/" + store.CoverAddress(coverURL); got.Cover != want {
|
||||
if want := testCoverBaseURL + "/covers/" + store.CoverAddressForBytes([]byte("cover-bytes")); got.Cover != want {
|
||||
t.Fatalf("Cover after retry = %q, want %q", got.Cover, want)
|
||||
}
|
||||
}
|
||||
@@ -2337,3 +2335,363 @@ func TestForcedSeriesWakesSleepingBrowser(t *testing.T) {
|
||||
t.Fatalf("browser fetches with a forced series = %d, want 2 (the lane wakes)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A forced pass accepts the page as it now stands, so it writes the Cover
|
||||
// through the replace path; an ordinary pass still only fills a blank one
|
||||
// (issue #135).
|
||||
func TestRunOnceForcedPassReplacesExistingCover(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
const (
|
||||
key = "asura:chronicles-of-the-demon-faction-f886a8af"
|
||||
seriesID = "chronicles-of-the-demon-faction-f886a8af"
|
||||
seriesURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
|
||||
first = "https://cdn.example/covers/first.jpg"
|
||||
)
|
||||
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
||||
Key: key, Site: "asura", SeriesID: seriesID, SeriesURL: seriesURL, UpdatedAt: 1000,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := s.SetSeriesCover("asura", seriesID, first, []byte("first"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("seed cover: %v", err)
|
||||
}
|
||||
at := time.UnixMilli(5_000_000)
|
||||
public := &fakeBytesCoverFetcher{body: []byte("second"), contentType: "image/jpeg"}
|
||||
|
||||
t.Run("unforced pass leaves the cover alone", func(t *testing.T) {
|
||||
p := &Poller{
|
||||
Store: s, Fetch: &fakeFetcher{body: asuraSeriesFixture + asuraCoverFixture, status: 200},
|
||||
CoverBytesFetch: public,
|
||||
Now: func() time.Time { return at },
|
||||
}
|
||||
p.runOnce(context.Background())
|
||||
p.waitCovers()
|
||||
if got := public.callCount(); got != 0 {
|
||||
t.Fatalf("cover fetch calls = %d, want 0", got)
|
||||
}
|
||||
got := readBookmark(t, s, key)
|
||||
if want := testCoverBaseURL + "/covers/" + store.CoverAddressForBytes([]byte("first")); got.Cover != want {
|
||||
t.Fatalf("Cover = %q, want the first one %q", got.Cover, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("forced pass replaces the cover", func(t *testing.T) {
|
||||
if err := s.ForceSeriesPoll("asura", seriesID, at.Add(time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||
}
|
||||
p := &Poller{
|
||||
Store: s, Fetch: &fakeFetcher{body: asuraSeriesFixture + asuraCoverFixture, status: 200},
|
||||
CoverBytesFetch: public,
|
||||
Now: func() time.Time { return at },
|
||||
}
|
||||
p.runOnce(context.Background())
|
||||
p.waitCovers()
|
||||
if got := public.callCount(); got != 1 {
|
||||
t.Fatalf("cover fetch calls = %d, want 1", got)
|
||||
}
|
||||
got := readBookmark(t, s, key)
|
||||
if want := testCoverBaseURL + "/covers/" + store.CoverAddressForBytes([]byte("second")); got.Cover != want {
|
||||
t.Fatalf("Cover = %q, want the second one %q", got.Cover, want)
|
||||
}
|
||||
body, _, ok, err := s.CoverByAddress(store.CoverAddressForBytes([]byte("second")))
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CoverByAddress: %v found=%v", err, ok)
|
||||
}
|
||||
if string(body) != "second" {
|
||||
t.Fatalf("stored cover = %q, want second", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Identical artwork re-served is an honest no-op the caller can tell apart
|
||||
// from a replacement: the address comes from the bytes, so the row cannot
|
||||
// change in substance, and the replace call site reports the three outcomes
|
||||
// distinctly (issue #135).
|
||||
func TestRunOnceForcedPassIdenticalBytesLogsNoOp(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
const (
|
||||
key = "asura:chronicles-of-the-demon-faction-f886a8af"
|
||||
seriesID = "chronicles-of-the-demon-faction-f886a8af"
|
||||
seriesURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
|
||||
first = "https://cdn.example/covers/first.jpg"
|
||||
)
|
||||
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
||||
Key: key, Site: "asura", SeriesID: seriesID, SeriesURL: seriesURL, UpdatedAt: 1000,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := s.SetSeriesCover("asura", seriesID, first, []byte("cover-bytes"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("seed cover: %v", err)
|
||||
}
|
||||
at := time.UnixMilli(5_000_000)
|
||||
if err := s.ForceSeriesPoll("asura", seriesID, at.Add(time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||
}
|
||||
var logs strings.Builder
|
||||
prev := log.Writer()
|
||||
log.SetOutput(&logs)
|
||||
t.Cleanup(func() { log.SetOutput(prev) })
|
||||
|
||||
p := &Poller{
|
||||
Store: s, Fetch: &fakeFetcher{body: asuraSeriesFixture + asuraCoverFixture, status: 200},
|
||||
CoverBytesFetch: &fakeBytesCoverFetcher{body: []byte("cover-bytes"), contentType: "image/jpeg"},
|
||||
Now: func() time.Time { return at },
|
||||
}
|
||||
p.runOnce(context.Background())
|
||||
p.waitCovers()
|
||||
|
||||
got := readBookmark(t, s, key)
|
||||
if want := testCoverBaseURL + "/covers/" + store.CoverAddressForBytes([]byte("cover-bytes")); got.Cover != want {
|
||||
t.Fatalf("Cover = %q, want unchanged %q", got.Cover, want)
|
||||
}
|
||||
if body, _, ok, err := s.CoverByAddress(store.CoverAddressForBytes([]byte("cover-bytes"))); err != nil || !ok || string(body) != "cover-bytes" {
|
||||
t.Fatalf("stored cover after no-op: found=%v err=%v", ok, err)
|
||||
}
|
||||
logged := logs.String()
|
||||
if !strings.Contains(logged, "cover unchanged") {
|
||||
t.Fatalf("no-op not reported as unchanged; log:\n%s", logged)
|
||||
}
|
||||
if strings.Contains(logged, "cover replaced") {
|
||||
t.Fatalf("no-op reported as a replacement; log:\n%s", logged)
|
||||
}
|
||||
}
|
||||
|
||||
// A Series whose sharded Cover file was unlinked out from under it is
|
||||
// repaired by one forced pass: same bytes mean the same address and the file
|
||||
// re-linked (issue #135, story 32).
|
||||
func TestRunOnceForcedPassRelinksUnlinkedCoverFile(t *testing.T) {
|
||||
coverDir := t.TempDir()
|
||||
url := pgtest.URL(t)
|
||||
s, err := store.Open(url, testOwner, coverDir, testCoverBaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
const (
|
||||
key = "asura:chronicles-of-the-demon-faction-f886a8af"
|
||||
seriesID = "chronicles-of-the-demon-faction-f886a8af"
|
||||
seriesURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
|
||||
first = "https://cdn.example/covers/first.jpg"
|
||||
)
|
||||
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
||||
Key: key, Site: "asura", SeriesID: seriesID, SeriesURL: seriesURL, UpdatedAt: 1000,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := s.SetSeriesCover("asura", seriesID, first, []byte("cover-bytes"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("seed cover: %v", err)
|
||||
}
|
||||
address := store.CoverAddressForBytes([]byte("cover-bytes"))
|
||||
coverPath := filepath.Join(coverDir, filepath.FromSlash(address[:2]+"/"+address[2:4]+"/"+address))
|
||||
if err := os.Remove(coverPath); err != nil {
|
||||
t.Fatalf("unlink cover file: %v", err)
|
||||
}
|
||||
if _, _, ok, err := s.CoverByAddress(address); err != nil || ok {
|
||||
t.Fatalf("CoverByAddress after unlink = found %v err %v; want missing (file gone)", ok, err)
|
||||
}
|
||||
|
||||
at := time.UnixMilli(5_000_000)
|
||||
if err := s.ForceSeriesPoll("asura", seriesID, at.Add(time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||
}
|
||||
p := &Poller{
|
||||
Store: s, Fetch: &fakeFetcher{body: asuraSeriesFixture + asuraCoverFixture, status: 200},
|
||||
CoverBytesFetch: &fakeBytesCoverFetcher{body: []byte("cover-bytes"), contentType: "image/jpeg"},
|
||||
Now: func() time.Time { return at },
|
||||
}
|
||||
p.runOnce(context.Background())
|
||||
p.waitCovers()
|
||||
|
||||
body, contentType, ok, err := s.CoverByAddress(address)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CoverByAddress after forced pass: found=%v err=%v; want the file re-linked", ok, err)
|
||||
}
|
||||
if string(body) != "cover-bytes" || contentType != "image/jpeg" {
|
||||
t.Fatalf("re-linked cover = (%q, %q), want (cover-bytes, image/jpeg)", body, contentType)
|
||||
}
|
||||
}
|
||||
|
||||
// A forced pass degrades exactly like an ordinary one when the cover sidecar
|
||||
// is unreachable: the fetch is skipped and logged, the chapter poll is
|
||||
// untouched, and the stored Cover is not moved (issue #135, story 6).
|
||||
func TestRunOnceForcedPassWithoutCoverFetcherStillPolls(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
const (
|
||||
key = "kagane:019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"
|
||||
seriesID = "019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"
|
||||
)
|
||||
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
||||
Key: key, Site: "kagane", SeriesID: seriesID,
|
||||
SeriesURL: "https://kagane.to/series/" + seriesID, UpdatedAt: 1000,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := s.SetSeriesCover("kagane", seriesID, "https://kagane.to/api/v2/image/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b/compressed", []byte("existing"), "image/webp"); err != nil {
|
||||
t.Fatalf("seed cover: %v", err)
|
||||
}
|
||||
at := time.UnixMilli(5_000_000)
|
||||
if err := s.ForceSeriesPoll("kagane", seriesID, at.Add(time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||
}
|
||||
var logs strings.Builder
|
||||
prev := log.Writer()
|
||||
log.SetOutput(&logs)
|
||||
t.Cleanup(func() { log.SetOutput(prev) })
|
||||
|
||||
p := &Poller{
|
||||
Store: s, BrowserFetch: &fakeFetcher{body: kaganeAPIFixtureWithCover, status: 200},
|
||||
Now: func() time.Time { return at },
|
||||
}
|
||||
p.runOnce(context.Background())
|
||||
p.waitCovers()
|
||||
|
||||
got := readBookmark(t, s, key)
|
||||
if got.LatestChapterNum == nil || *got.LatestChapterNum != 41 {
|
||||
t.Fatalf("LatestChapterNum = %v, want 41", got.LatestChapterNum)
|
||||
}
|
||||
if want := testCoverBaseURL + "/covers/" + store.CoverAddressForBytes([]byte("existing")); got.Cover != want {
|
||||
t.Fatalf("Cover = %q, want the existing one %q untouched", got.Cover, want)
|
||||
}
|
||||
if checked := readLatestCheckedAt(t, s, key); checked != at.UnixMilli() {
|
||||
t.Fatalf("latest_checked_at = %d, want %d", checked, at.UnixMilli())
|
||||
}
|
||||
if !strings.Contains(logs.String(), "fetch cover") {
|
||||
t.Fatalf("missing skipped-cover log; log:\n%s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A forced replacement strands the previous address, and the poller reclaims
|
||||
// it from the stranded branch: the old sharded file and covers row are both
|
||||
// gone once the pass lands while the new bytes read back (issue #154). The
|
||||
// identical-bytes no-op that follows reclaims nothing: previous == current
|
||||
// there, and a reclamation would delete the Cover the pass just wrote.
|
||||
func TestRunOnceForcedPassReclaimsSupersededCover(t *testing.T) {
|
||||
coverDir := t.TempDir()
|
||||
url := pgtest.URL(t)
|
||||
s, err := store.Open(url, testOwner, coverDir, testCoverBaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
const (
|
||||
key = "asura:chronicles-of-the-demon-faction-f886a8af"
|
||||
seriesID = "chronicles-of-the-demon-faction-f886a8af"
|
||||
seriesURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
|
||||
first = "https://cdn.example/covers/first.jpg"
|
||||
)
|
||||
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
||||
Key: key, Site: "asura", SeriesID: seriesID, SeriesURL: seriesURL, UpdatedAt: 1000,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := s.SetSeriesCover("asura", seriesID, first, []byte("first"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("seed cover: %v", err)
|
||||
}
|
||||
stale := store.CoverAddressForBytes([]byte("first"))
|
||||
stalePath := filepath.Join(coverDir, filepath.FromSlash(stale[:2]+"/"+stale[2:4]+"/"+stale))
|
||||
|
||||
at := time.UnixMilli(5_000_000)
|
||||
if err := s.ForceSeriesPoll("asura", seriesID, at.Add(time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||
}
|
||||
p := &Poller{
|
||||
Store: s, Fetch: &fakeFetcher{body: asuraSeriesFixture + asuraCoverFixture, status: 200},
|
||||
CoverBytesFetch: &fakeBytesCoverFetcher{body: []byte("second"), contentType: "image/jpeg"},
|
||||
Now: func() time.Time { return at },
|
||||
}
|
||||
p.runOnce(context.Background())
|
||||
p.waitCovers()
|
||||
|
||||
// The stranded address is gone on disk and in SQL; the new one reads back.
|
||||
if _, err := os.Stat(stalePath); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("stale sharded file after replacement = %v, want fs.ErrNotExist", err)
|
||||
}
|
||||
if _, _, ok, err := s.CoverByAddress(stale); err != nil || ok {
|
||||
t.Fatalf("stale bytes after replacement = found %v err %v, want reclaimed", ok, err)
|
||||
}
|
||||
if body, _, ok, err := s.CoverByAddress(store.CoverAddressForBytes([]byte("second"))); err != nil || !ok || string(body) != "second" {
|
||||
t.Fatalf("new bytes after replacement = found %v err %v, want served", ok, err)
|
||||
}
|
||||
|
||||
// The identical-bytes pass is an honest no-op and reclaims nothing.
|
||||
if err := s.ForceSeriesPoll("asura", seriesID, at.Add(2*time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||
}
|
||||
p.runOnce(context.Background())
|
||||
p.waitCovers()
|
||||
current := store.CoverAddressForBytes([]byte("second"))
|
||||
currentPath := filepath.Join(coverDir, filepath.FromSlash(current[:2]+"/"+current[2:4]+"/"+current))
|
||||
if _, err := os.Stat(currentPath); err != nil {
|
||||
t.Fatalf("live sharded file after no-op pass = %v, want present", err)
|
||||
}
|
||||
if body, _, ok, err := s.CoverByAddress(current); err != nil || !ok || string(body) != "second" {
|
||||
t.Fatalf("bytes after no-op pass = found %v err %v, want still served", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A reclamation that fails must not fail the Poll: the failure is logged
|
||||
// against the Series and the replacement still lands, so the stranded bytes
|
||||
// stay reachable for a retry and the owner's act succeeded (issue #154).
|
||||
func TestRunOnceForcedPassReclaimFailureDoesNotFailPoll(t *testing.T) {
|
||||
coverDir := t.TempDir()
|
||||
url := pgtest.URL(t)
|
||||
s, err := store.Open(url, testOwner, coverDir, testCoverBaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
const (
|
||||
key = "asura:chronicles-of-the-fallen-f886a8af"
|
||||
seriesID = "chronicles-of-the-fallen-f886a8af"
|
||||
seriesURL = "https://asurascans.com/comics/chronicles-of-the-fallen-f886a8af"
|
||||
first = "https://cdn.example/covers/first.jpg"
|
||||
)
|
||||
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
||||
Key: key, Site: "asura", SeriesID: seriesID, SeriesURL: seriesURL, UpdatedAt: 1000,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := s.SetSeriesCover("asura", seriesID, first, []byte("first"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("seed cover: %v", err)
|
||||
}
|
||||
// Make the stranded file unremovable: a non-empty directory in its place.
|
||||
stale := store.CoverAddressForBytes([]byte("first"))
|
||||
stalePath := filepath.Join(coverDir, filepath.FromSlash(stale[:2]+"/"+stale[2:4]+"/"+stale))
|
||||
if err := os.Remove(stalePath); err != nil {
|
||||
t.Fatalf("clear file: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(stalePath, 0o755); err != nil {
|
||||
t.Fatalf("replace file with dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(stalePath, "blob"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("fill dir: %v", err)
|
||||
}
|
||||
|
||||
at := time.UnixMilli(5_000_000)
|
||||
if err := s.ForceSeriesPoll("asura", seriesID, at.Add(time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||
}
|
||||
var logs strings.Builder
|
||||
prev := log.Writer()
|
||||
log.SetOutput(&logs)
|
||||
t.Cleanup(func() { log.SetOutput(prev) })
|
||||
p := &Poller{
|
||||
Store: s, Fetch: &fakeFetcher{body: asuraSeriesFixture + asuraCoverFixture, status: 200},
|
||||
CoverBytesFetch: &fakeBytesCoverFetcher{body: []byte("second"), contentType: "image/jpeg"},
|
||||
Now: func() time.Time { return at },
|
||||
}
|
||||
p.runOnce(context.Background())
|
||||
p.waitCovers()
|
||||
|
||||
logged := logs.String()
|
||||
if !strings.Contains(logged, "reclaim cover") {
|
||||
t.Fatalf("failed reclamation not logged; log:\n%s", logged)
|
||||
}
|
||||
if !strings.Contains(logged, "cover replaced") {
|
||||
t.Fatalf("replacement not reported after a failed reclaim; log:\n%s", logged)
|
||||
}
|
||||
got := readBookmark(t, s, key)
|
||||
if want := testCoverBaseURL + "/covers/" + store.CoverAddressForBytes([]byte("second")); got.Cover != want {
|
||||
t.Fatalf("Cover = %q, want the replacement %q", got.Cover, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ var (
|
||||
// its own network position to whatever URL a token-holder writes, including
|
||||
// link-local/internal addresses or non-https schemes.
|
||||
func readSeriesPage(ctx context.Context, site, seriesURL string, browser, tls Fetcher) (seriesRead, error) {
|
||||
if !fetchableSeriesURL(site, seriesURL) {
|
||||
if !FetchableSeriesURL(site, seriesURL) {
|
||||
return seriesRead{}, fmt.Errorf("%w: site=%q url=%q", errNotFetchable, site, seriesURL)
|
||||
}
|
||||
f := fetcherFor(site, browser, tls)
|
||||
|
||||
@@ -46,7 +46,7 @@ type site struct {
|
||||
type browserRead struct {
|
||||
// Read builds the tab read for seriesURL, refusing (false) an address
|
||||
// this Site will not open in a browser — the per-Site half of the SSRF
|
||||
// gate, kept deliberately behind fetchableSeriesURL: a headless browser
|
||||
// gate, kept deliberately behind FetchableSeriesURL: a headless browser
|
||||
// executes JavaScript and carries cookies, and series_url is
|
||||
// client-supplied.
|
||||
Read func(seriesURL string, out *string) (chromedp.Action, bool)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -108,10 +109,7 @@ func TestSmokeAcquireKaganeCover(t *testing.T) {
|
||||
if ws == "" {
|
||||
t.Skip("SMOKE_BROWSER_WS_URL unset")
|
||||
}
|
||||
const (
|
||||
seriesID = "019fe11a-8670-7cf3-8343-0b02057d3787"
|
||||
coverURL = "https://kagane.to/api/v2/image/019fe11a-84c3-7fc3-a84b-88787374b617/compressed"
|
||||
)
|
||||
const seriesID = "019fe11a-8670-7cf3-8343-0b02057d3787"
|
||||
s, _ := newTestStore(t)
|
||||
bf, err := NewBrowserFetcher(ws)
|
||||
if err != nil {
|
||||
@@ -140,12 +138,13 @@ func TestSmokeAcquireKaganeCover(t *testing.T) {
|
||||
if err != nil || !found {
|
||||
t.Fatalf("Get: %v found=%v", err, found)
|
||||
}
|
||||
if want := testCoverBaseURL + "/covers/" + store.CoverAddress(coverURL); got.Cover != want {
|
||||
t.Fatalf("Cover = %q, want %q — the acquire path did not store the browser-fetched bytes", got.Cover, want)
|
||||
addr, ok := strings.CutPrefix(got.Cover, testCoverBaseURL+"/covers/")
|
||||
if !ok {
|
||||
t.Fatalf("Cover = %q, want an address on %q — the acquire path did not store the browser-fetched bytes", got.Cover, testCoverBaseURL+"/covers/")
|
||||
}
|
||||
body, contentType, ok, err := s.CoverByAddress(store.CoverAddress(coverURL))
|
||||
body, contentType, ok, err := s.CoverByAddress(addr)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CoverByAddress: %v found=%v", err, ok)
|
||||
t.Fatalf("CoverByAddress(%q): %v found=%v", addr, err, ok)
|
||||
}
|
||||
if len(body) < 1000 {
|
||||
t.Fatalf("stored cover is %d bytes, want a real image", len(body))
|
||||
@@ -153,5 +152,10 @@ func TestSmokeAcquireKaganeCover(t *testing.T) {
|
||||
if contentType != "image/webp" {
|
||||
t.Fatalf("content type = %q, want image/webp", contentType)
|
||||
}
|
||||
// The address the row carries is the bytes' own SHA-256: a re-art behind
|
||||
// the same URL would be a different address, which is the whole point.
|
||||
if want := testCoverBaseURL + "/covers/" + store.CoverAddressForBytes(body); got.Cover != want {
|
||||
t.Fatalf("Cover = %q, want %q", got.Cover, want)
|
||||
}
|
||||
t.Logf("stored %d bytes of %s", len(body), contentType)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestSmokeLnwCommentBoundary(t *testing.T) {
|
||||
if seriesURL == "" {
|
||||
t.Skip("SMOKE_LNW_SERIES_URL unset")
|
||||
}
|
||||
if !fetchableSeriesURL("lightnovelworld", seriesURL) {
|
||||
if !FetchableSeriesURL("lightnovelworld", seriesURL) {
|
||||
t.Fatalf("%q is not a fetchable lightnovelworld series URL", seriesURL)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Series filter names (issue #140), ordered permanent-then-fixable — the
|
||||
// repairs nothing will ever undo first, the ones a Poll can make right after.
|
||||
// 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.
|
||||
// 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 is not part of
|
||||
// that ordering: a finished Series is a deliberate state, not a repair, so
|
||||
// it sits last, 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"
|
||||
@@ -21,9 +23,10 @@ const (
|
||||
SeriesFilterStale = "stale"
|
||||
SeriesFilterNoCover = "no_cover"
|
||||
SeriesFilterReaderReport = "reader_report"
|
||||
SeriesFilterFinished = "finished"
|
||||
)
|
||||
|
||||
// SeriesFilter is one named hygiene predicate over the whole library. Site
|
||||
// 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 "stale" filter compares against, supplied by the
|
||||
// caller's clock — the store has no clock; Page is 1-based.
|
||||
@@ -41,7 +44,8 @@ type SeriesFilter struct {
|
||||
// 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.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.force_poll_at,
|
||||
s.latest_corrected_at, s.finished_at`
|
||||
|
||||
// raisedByReaderAnswer answers "did a Reader's report set this number" without
|
||||
// naming which Reader. Kept apart from adminSeriesColumns so the column list —
|
||||
@@ -71,9 +75,17 @@ type AdminSeries struct {
|
||||
// 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
|
||||
ReaderCount int
|
||||
RaisedByReader bool // a Reader's report set LatestChapterNum
|
||||
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
|
||||
// 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
|
||||
}
|
||||
|
||||
// SeriesPage is one page of the owner's filtered Series list plus the count
|
||||
@@ -106,10 +118,19 @@ func (a AdminSeries) Key() string { return a.Site + ":" + a.SeriesID }
|
||||
// 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, and Reader-report.
|
||||
// (non-zero versus zero check stamp), stale, no cover, finished (the
|
||||
// retirement stamp, read directly), and Reader-report.
|
||||
// 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 four): the clock-driven one —
|
||||
// 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; the three outcome-driven ones — no-URL, no-readers, Reader-report —
|
||||
// read stored facts that simply stop arriving, so a finished Series needing a
|
||||
// genuine repair still shows up under them.
|
||||
//
|
||||
// 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
|
||||
@@ -126,16 +147,18 @@ func adminFilter(f SeriesFilter) (where, having string, args []any, err error) {
|
||||
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`)
|
||||
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`)
|
||||
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))
|
||||
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 = ''`)
|
||||
clauses = append(clauses, `s.cover_address = '' AND s.finished_at = 0`)
|
||||
case SeriesFilterReaderReport:
|
||||
clauses = append(clauses, `s.latest_raised_by IS NOT NULL`)
|
||||
case SeriesFilterFinished:
|
||||
clauses = append(clauses, `s.finished_at > 0`)
|
||||
case SeriesFilterNoReaders:
|
||||
having = `HAVING COUNT(b.reader_id) = 0`
|
||||
default:
|
||||
@@ -150,9 +173,9 @@ func adminFilter(f SeriesFilter) (where, having string, args []any, err error) {
|
||||
// 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. ReaderCount is a plain count of every Bookmark on
|
||||
// the Series, which knowingly disagrees with the two Lane queries for as long
|
||||
// as the finished lifecycle bucket exists (#140).
|
||||
// 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
|
||||
@@ -189,7 +212,7 @@ func (s *Store) SeriesPage(f SeriesFilter) (SeriesPage, error) {
|
||||
`+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_raised_by
|
||||
s.force_poll_at, s.latest_corrected_at, s.finished_at, s.latest_raised_by
|
||||
`+having+`
|
||||
ORDER BY s.latest_checked_at, s.site, s.series_id
|
||||
LIMIT $`+strconv.Itoa(base+1)+` OFFSET $`+strconv.Itoa(base+2), args...)
|
||||
@@ -264,7 +287,7 @@ func scanAdminSeries(scan func(...any) error) (AdminSeries, int, error) {
|
||||
if err := scan(
|
||||
&a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress,
|
||||
&a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt,
|
||||
&a.ForcePollAt,
|
||||
&a.ForcePollAt, &a.LatestCorrectedAt, &a.FinishedAt,
|
||||
&a.RaisedByReader, &a.ReaderCount, &total,
|
||||
); err != nil {
|
||||
return AdminSeries{}, 0, err
|
||||
|
||||
@@ -128,6 +128,85 @@ func TestAdminSeriesFilters(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A finished Series is the owner's deliberate state, not a problem a Poll
|
||||
// will fix: the four clock-driven hygiene predicates exclude it (their
|
||||
// stamps stop advancing at the last Poll, so without the guard a retired row
|
||||
// is reported forever), the three outcome-driven ones still include it, and
|
||||
// the finished filter returns exactly the retired rows.
|
||||
func TestAdminFinishedSeriesFilters(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
// Each fin-* row is shaped to trip exactly one predicate if its guard
|
||||
// fails: checked-but-old for stale, a zero stamp for never-checked, a
|
||||
// stamp with no chapter for no-chapter, an empty cover for no-cover, and
|
||||
// the unguarded three shaped to trip their own. A healthy, unfinished
|
||||
// neighbour keeps the exclusion checks honest: a filter that regressed to
|
||||
// matching nothing would pass a bare "no finished rows" assertion.
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-stale", url: "u", cover: "c", checkedAt: 2000, latestNum: new(4.0), bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-neverchecked", url: "u", cover: "c", checkedAt: 0, latestNum: new(4.0), bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-nochapter", url: "u", cover: "c", checkedAt: 9000, bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-nocover", url: "u", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-nourl", url: "", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-orphan", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 0})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-report", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1, raisedBy: true})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:healthy", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1})
|
||||
finished := []string{
|
||||
"asura:fin-stale", "asura:fin-neverchecked", "asura:fin-nochapter",
|
||||
"asura:fin-nocover", "asura:fin-nourl", "asura:fin-orphan", "asura:fin-report",
|
||||
}
|
||||
for _, key := range finished {
|
||||
site, id, _ := strings.Cut(key, ":")
|
||||
if err := s.SetSeriesFinished(site, id, 1000); err != nil {
|
||||
t.Fatalf("finish %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
f SeriesFilter
|
||||
want []string
|
||||
}{
|
||||
{"stale excludes finished", SeriesFilter{Name: SeriesFilterStale, Cutoff: 5000}, nil},
|
||||
{"never checked excludes finished", SeriesFilter{Name: SeriesFilterNeverChecked}, nil},
|
||||
{"no chapter excludes finished", SeriesFilter{Name: SeriesFilterNoChapter}, nil},
|
||||
{"no cover excludes finished", SeriesFilter{Name: SeriesFilterNoCover}, nil},
|
||||
{"no url includes finished", SeriesFilter{Name: SeriesFilterNoURL}, []string{"asura:fin-nourl"}},
|
||||
{"no readers includes finished", SeriesFilter{Name: SeriesFilterNoReaders}, []string{"asura:fin-orphan"}},
|
||||
{"reader report includes finished", SeriesFilter{Name: SeriesFilterReaderReport}, []string{"asura:fin-report"}},
|
||||
{"finished returns the retired rows", SeriesFilter{Name: SeriesFilterFinished}, finished},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := pageKeys(t, s, tc.f)
|
||||
want := map[string]bool{}
|
||||
for _, k := range tc.want {
|
||||
want[k] = true
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%+v returned %v, want exactly %v", tc.f, got, want)
|
||||
}
|
||||
for k := range want {
|
||||
if !got[k] {
|
||||
t.Fatalf("%+v dropped %q (got %v)", tc.f, k, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The aggregate's finished total counts every retired row — the same
|
||||
// predicate the Overview's finished figure is summed from.
|
||||
shapes, err := s.SeriesShapes(SeriesFilter{Name: SeriesFilterFinished})
|
||||
if err != nil {
|
||||
t.Fatalf("SeriesShapes(finished): %v", err)
|
||||
}
|
||||
sum := 0
|
||||
for _, sh := range shapes {
|
||||
sum += sh.Total
|
||||
}
|
||||
if sum != len(finished) {
|
||||
t.Fatalf("finished aggregate = %d, want %d", sum, len(finished))
|
||||
}
|
||||
}
|
||||
|
||||
// "Never read a chapter" and "never checked" are disjoint by construction:
|
||||
// the first requires a non-zero check stamp, the second a zero one. Over a
|
||||
// mix that should satisfy both, no row may be counted twice.
|
||||
@@ -394,3 +473,57 @@ func TestAdminSeriesCarriesForcePollAt(t *testing.T) {
|
||||
t.Fatalf("row = %+v, want ForcePollAt 5000", page.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
// SetSeriesFinished is the owner's finish stamp write: finishing writes the
|
||||
// given ms, un-finishing writes zero — the one undo, the same shape as the
|
||||
// correction stamp. Touching a missing series is not an error: the row may
|
||||
// have been orphaned, and the caller's read decides what exists.
|
||||
func TestSetSeriesFinishedStampsAndClears(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:x", url: "u", checkedAt: 9000, bookmarks: 1})
|
||||
|
||||
if err := s.SetSeriesFinished("asura", "x", 42); err != nil {
|
||||
t.Fatalf("SetSeriesFinished: %v", err)
|
||||
}
|
||||
var got int64
|
||||
if err := s.db.QueryRow(
|
||||
`SELECT finished_at FROM series WHERE site = 'asura' AND series_id = 'x'`).Scan(&got); err != nil {
|
||||
t.Fatalf("read finished_at: %v", err)
|
||||
}
|
||||
if got != 42 {
|
||||
t.Fatalf("finished_at = %d, want 42", got)
|
||||
}
|
||||
|
||||
if err := s.SetSeriesFinished("asura", "x", 0); err != nil {
|
||||
t.Fatalf("SetSeriesFinished un-finish: %v", err)
|
||||
}
|
||||
if err := s.db.QueryRow(
|
||||
`SELECT finished_at FROM series WHERE site = 'asura' AND series_id = 'x'`).Scan(&got); err != nil {
|
||||
t.Fatalf("read finished_at after un-finish: %v", err)
|
||||
}
|
||||
if got != 0 {
|
||||
t.Fatalf("finished_at = %d, want 0 (un-finish writes zero)", got)
|
||||
}
|
||||
|
||||
if err := s.SetSeriesFinished("asura", "ghost", 42); err != nil {
|
||||
t.Fatalf("SetSeriesFinished missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The admin projection carries the finish stamp so the web layer can render
|
||||
// the finished state without a second read.
|
||||
func TestAdminSeriesCarriesFinishedAt(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:x", url: "u", checkedAt: 1000, bookmarks: 1})
|
||||
if err := s.SetSeriesFinished("asura", "x", 5000); err != nil {
|
||||
t.Fatalf("SetSeriesFinished: %v", err)
|
||||
}
|
||||
|
||||
page, err := s.SeriesPage(SeriesFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("SeriesPage: %v", err)
|
||||
}
|
||||
if len(page.Rows) != 1 || page.Rows[0].FinishedAt != 5000 {
|
||||
t.Fatalf("row = %+v, want FinishedAt 5000", page.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
-- The Cover splits into two facts. `cover` keeps the third-party address the
|
||||
-- bytes come from, which is what the acquisition path refetches and dedupes
|
||||
-- on; `cover_address` is the content address of the bytes once they are
|
||||
-- actually stored, and is what the wire's absolute URL is built from.
|
||||
-- bytes come from, which is what the refetch path dedupes on; `cover_address`
|
||||
-- is the content address of the bytes once they are actually stored, and is
|
||||
-- what the wire's absolute URL is built from.
|
||||
--
|
||||
-- Empty `cover_address` therefore means "no Cover yet" rather than "a Cover
|
||||
-- that 404s", which is the distinction the API and the UI both depend on.
|
||||
--
|
||||
-- The content address was originally the hex SHA-256 of the source URL
|
||||
-- (ADR-0007). Since ADR-0014 it is the hex SHA-256 of the bytes themselves,
|
||||
-- so a re-art behind the same URL is a new address. Rows written before
|
||||
-- ADR-0014 keep their URL-derived addresses; they are never rehashed and heal
|
||||
-- into byte addressing on their first forced replacement. Both derivations
|
||||
-- share the 64-hex-digit shape, so the serving guard is unchanged.
|
||||
ALTER TABLE series ADD COLUMN cover_address text NOT NULL DEFAULT '';
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- latest_corrected_at is the "the current Latest Chapter is the owner's" stamp
|
||||
-- (#149). Written by the Correction; zeroed by every machine write of the
|
||||
-- value. Zero means never corrected.
|
||||
ALTER TABLE series ADD COLUMN latest_corrected_at bigint NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- finished_at is "the owner marked this Series finished" (#157): epoch ms,
|
||||
-- zero means not finished, and it doubles as the undo (write zero). The poll
|
||||
-- gate reads it — a Series is polled only while finished_at = 0 — never a
|
||||
-- bookmark's status.
|
||||
ALTER TABLE series ADD COLUMN finished_at bigint NOT NULL DEFAULT 0;
|
||||
|
||||
-- Column first, seed second, flip third — the order is load-bearing: a seed
|
||||
-- that ran after the flip would read the buckets it just destroyed, declare
|
||||
-- nothing finished, and silently resume polling on Series nobody chose to
|
||||
-- resume. The seed mirrors the pre-cutover due gate exactly: a Series stays
|
||||
-- polled while any bookmark is outside the finished bucket, so a Series whose
|
||||
-- every bookmark sits in it is stamped, one click from being read again
|
||||
-- afterwards. The stamp is the only memory of the bucket the flip is about to
|
||||
-- erase.
|
||||
UPDATE series s SET finished_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint
|
||||
WHERE EXISTS (SELECT 1 FROM bookmarks b
|
||||
WHERE b.site = s.site AND b.series_id = s.series_id)
|
||||
AND NOT EXISTS (SELECT 1 FROM bookmarks b
|
||||
WHERE b.site = s.site AND b.series_id = s.series_id
|
||||
AND b.status <> 'finished');
|
||||
|
||||
-- The Lifecycle bucket is gone; a finished bookmark is an archived one. The
|
||||
-- flip must come after the seed, which still reads the bucket.
|
||||
UPDATE bookmarks SET status = 'archived' WHERE status = 'finished';
|
||||
+264
-70
@@ -16,6 +16,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
@@ -46,13 +47,16 @@ type Bookmark struct {
|
||||
LatestChapter string `json:"latest_chapter"`
|
||||
LatestChapterNum *float64 `json:"latest_chapter_num"` // nil until first captured
|
||||
UpdatedAt int64 `json:"updated_at"` // unix ms; see Upsert
|
||||
// Status is the lifecycle bucket: reading, archived, or finished.
|
||||
// Archived series stay polled for new chapters; finished ones do not.
|
||||
// Status is the lifecycle bucket: reading or archived.
|
||||
// Archived series stay polled for new chapters.
|
||||
// Empty on the way in means "no opinion" — see Upsert.
|
||||
Status string `json:"status"`
|
||||
// Kind is the library bucket: manga or novel. Empty on the way in means
|
||||
// "no opinion" — see Upsert.
|
||||
Kind string `json:"kind"`
|
||||
// Finished is the owner's retirement of the Series, derived: the flag is a
|
||||
// Series fact and a client cannot write it — see Upsert.
|
||||
Finished bool `json:"finished"`
|
||||
}
|
||||
|
||||
// Series is one distinct work, shared by every bookmark that tracks it. It is
|
||||
@@ -60,10 +64,11 @@ type Bookmark struct {
|
||||
// exists once no matter how many bookmarks point at it (ADR-0003).
|
||||
//
|
||||
// Title, SeriesURL and Cover are written once, at creation: a PUT naming an
|
||||
// existing Series has them ignored, and only the backend's own Poll may change
|
||||
// them. Kind and the latest-chapter fields are last-write-wins like the
|
||||
// bookmark's own fields. Never serialized: the wire format is the flat
|
||||
// Bookmark (ADR-0004).
|
||||
// existing Series has them ignored, and only the backend's own Poll may
|
||||
// change them. The one exception is SeriesURL, which the owner's
|
||||
// SetSeriesURL may repair (issue #151). Kind and the latest-chapter fields
|
||||
// are last-write-wins like the bookmark's own fields. Never serialized: the
|
||||
// wire format is the flat Bookmark (ADR-0004).
|
||||
type Series struct {
|
||||
Site string
|
||||
SeriesID string
|
||||
@@ -212,10 +217,11 @@ const (
|
||||
)
|
||||
|
||||
// Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal.
|
||||
// Finished is not a bucket: it is a fact about the Series (series.finished_at),
|
||||
// never about a Reader's bookmark.
|
||||
const (
|
||||
StatusReading = "reading"
|
||||
StatusArchived = "archived"
|
||||
StatusFinished = "finished"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
@@ -227,7 +233,8 @@ var migrations embed.FS
|
||||
// order, so the flat Bookmark reads back whole despite the split (ADR-0004).
|
||||
const bookmarkColumns = `b.site, b.series_id, s.title, s.series_url, s.cover_address,
|
||||
b.last_chapter, b.last_chapter_num, b.last_chapter_url,
|
||||
b.favorite, s.latest_chapter, s.latest_chapter_num, b.updated_at, b.status, s.kind`
|
||||
b.favorite, s.latest_chapter, s.latest_chapter_num, b.updated_at, b.status, s.kind,
|
||||
s.finished_at > 0`
|
||||
|
||||
// seriesColumns is the series row in scanSeries order, used by the poller's
|
||||
// due query. latest_checked_at lives only on series — see MarkLatestChecked
|
||||
@@ -606,6 +613,7 @@ func (s *Store) scanBookmark(scan func(...any) error) (Bookmark, error) {
|
||||
&b.Site, &b.SeriesID, &b.Title, &b.SeriesURL, &coverAddress,
|
||||
&b.LastChapter, &b.LastChapterNum, &b.LastChapterURL,
|
||||
&b.Favorite, &b.LatestChapter, &latestChapterNum, &b.UpdatedAt, &b.Status, &b.Kind,
|
||||
&b.Finished,
|
||||
); err != nil {
|
||||
return Bookmark{}, err
|
||||
}
|
||||
@@ -617,9 +625,9 @@ func (s *Store) scanBookmark(scan func(...any) error) (Bookmark, error) {
|
||||
// bookmark is keyed (reader_id, site, series_id) (issue #22).
|
||||
b.Key = b.Site + ":" + b.SeriesID
|
||||
// An unrecognised bucket (a hand-edited row) would leave the row in no list
|
||||
// at all, so anything outside the three known buckets reads as the default
|
||||
// at all, so anything outside the two known buckets reads as the default
|
||||
// rather than being passed through.
|
||||
if b.Status != StatusReading && b.Status != StatusArchived && b.Status != StatusFinished {
|
||||
if b.Status != StatusReading && b.Status != StatusArchived {
|
||||
b.Status = StatusReading
|
||||
}
|
||||
return b, nil
|
||||
@@ -704,67 +712,112 @@ func (s *Store) getCoverByAddress(address string) ([]byte, string, bool, error)
|
||||
return body, contentType, true, nil
|
||||
}
|
||||
|
||||
func (s *Store) putCover(sourceURL string, body []byte, contentType string) error {
|
||||
func (s *Store) putCover(sourceURL string, body []byte, contentType string) (string, error) {
|
||||
stored, ok := CoverContentType(contentType)
|
||||
if !ok {
|
||||
return fmt.Errorf("put cover %q: unsupported content type %q", sourceURL, contentType)
|
||||
return "", fmt.Errorf("put cover %q: unsupported content type %q", sourceURL, contentType)
|
||||
}
|
||||
contentType = stored
|
||||
address := coverSourceAddress(sourceURL)
|
||||
address := CoverAddressForBytes(body)
|
||||
relativePath := coverRelativePath(address)
|
||||
coverPath := filepath.Join(s.coverDir, filepath.FromSlash(relativePath))
|
||||
if err := os.MkdirAll(filepath.Dir(coverPath), 0o755); err != nil {
|
||||
return fmt.Errorf("create cover shard: %w", err)
|
||||
return "", fmt.Errorf("create cover shard: %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(coverPath), ".cover-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create cover temp file: %w", err)
|
||||
return "", fmt.Errorf("create cover temp file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if _, err := tmp.Write(body); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write cover temp file: %w", err)
|
||||
return "", fmt.Errorf("write cover temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("sync cover temp file: %w", err)
|
||||
return "", fmt.Errorf("sync cover temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close cover temp file: %w", err)
|
||||
return "", fmt.Errorf("close cover temp file: %w", err)
|
||||
}
|
||||
if err := os.Link(tmpName, coverPath); err != nil && !errors.Is(err, fs.ErrExist) {
|
||||
return fmt.Errorf("install cover file: %w", err)
|
||||
return "", fmt.Errorf("install cover file: %w", err)
|
||||
}
|
||||
if _, err := s.db.Exec(`
|
||||
INSERT INTO covers (address, path, content_type)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (address) DO NOTHING`, address, relativePath, contentType); err != nil {
|
||||
return fmt.Errorf("record cover %q: %w", address, err)
|
||||
return "", fmt.Errorf("record cover %q: %w", address, err)
|
||||
}
|
||||
return address, nil
|
||||
}
|
||||
|
||||
// ReclaimCover permanently removes a Cover nothing references: the sharded
|
||||
// file first, the covers row last. A blank address is a no-op, and so is any
|
||||
// address a Series row still points at — byte-identical artwork is one row by
|
||||
// construction (ADR-0014), so reclaiming one Series' stranded bytes must not
|
||||
// blank another's. The file goes first because the covers row is the handle:
|
||||
// an interrupted run stays findable in SQL — covers rows unreferenced by any
|
||||
// series cover_address — and re-running finishes the job, whereas deleting
|
||||
// the row first would leave a file nothing names. A concurrent Forced Poll
|
||||
// repointing a live Series at this address between the guard and the unlink
|
||||
// is the repairable case: the missing file reads as ok=false and the next
|
||||
// pass re-installs it. Failures are returned, never logged here — the caller
|
||||
// logs and carries on — and a failed unlink leaves the row in place for a
|
||||
// retry. A whole-table sweep, if ever wanted, is one SQL query over covers,
|
||||
// not a tree walk and not this function.
|
||||
func (s *Store) ReclaimCover(address string) error {
|
||||
if address == "" {
|
||||
return nil
|
||||
}
|
||||
var referenced int
|
||||
err := s.db.QueryRow(`SELECT 1 FROM series WHERE cover_address = $1 LIMIT 1`, address).Scan(&referenced)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("guard reclaim of cover %q: %w", address, err)
|
||||
}
|
||||
coverPath := filepath.Join(s.coverDir, filepath.FromSlash(coverRelativePath(address)))
|
||||
if err := os.Remove(coverPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("remove cover file %q: %w", address, err)
|
||||
}
|
||||
if _, err := s.db.Exec(`DELETE FROM covers WHERE address = $1`, address); err != nil {
|
||||
return fmt.Errorf("delete cover row %q: %w", address, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCover returns the immutable object addressed by its source URL. Missing
|
||||
// GetCover returns the immutable object a source URL's own hash names. Rows
|
||||
// written before byte addressing (ADR-0014) are the only ones that ever reach
|
||||
// it; it hashes the URL, so a byte-addressed Cover is invisible to it. Missing
|
||||
// files are reported with ok=false so callers can retry acquisition later.
|
||||
func (s *Store) GetCover(sourceURL string) ([]byte, string, bool, error) {
|
||||
return s.getCover(sourceURL)
|
||||
}
|
||||
|
||||
// PutCover persists bytes under the source URL's content address. A later
|
||||
// write for the same URL cannot replace the immutable object.
|
||||
// PutCover persists bytes under their own content address (ADR-0014). A later
|
||||
// write of the same bytes cannot replace the immutable object.
|
||||
func (s *Store) PutCover(sourceURL string, body []byte, contentType string) error {
|
||||
return s.putCover(sourceURL, body, contentType)
|
||||
_, err := s.putCover(sourceURL, body, contentType)
|
||||
return err
|
||||
}
|
||||
|
||||
// CoverAddress is the content address bytes fetched from sourceURL are stored
|
||||
// under. It is a pure function of the URL, so the acquisition path can name a
|
||||
// Cover before it has the bytes.
|
||||
func CoverAddress(sourceURL string) string { return coverSourceAddress(sourceURL) }
|
||||
// CoverAddressForBytes is the content address body is stored under: the hex
|
||||
// SHA-256 of the bytes, so identical artwork is one address and a re-art a
|
||||
// new one. Legacy rows were addressed from their source URL instead and are
|
||||
// never rehashed — both derivations coexist (ADR-0014).
|
||||
func CoverAddressForBytes(body []byte) string {
|
||||
sum := sha256.Sum256(body)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// coverAddressRe is the shape of a stored address: the hex SHA-256 of a source
|
||||
// URL. Request paths reach CoverByAddress, so the shape is checked before the
|
||||
// value is ever turned into a filesystem path.
|
||||
// coverAddressRe is the shape of a stored address: 64 lowercase hex digits —
|
||||
// the hex SHA-256 of the cover bytes, or of the source URL for legacy rows
|
||||
// (ADR-0014). Request paths reach CoverByAddress, so the shape is checked
|
||||
// before the value is ever turned into a filesystem path; byte-derived
|
||||
// addresses keep the same shape, so the guard is unchanged.
|
||||
var coverAddressRe = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
|
||||
// CoverByAddress returns the immutable object at one content address. An
|
||||
@@ -788,24 +841,68 @@ func (s *Store) CoverWireURL(address string) string {
|
||||
return s.coverBaseURL + "/covers/" + address
|
||||
}
|
||||
|
||||
// SetSeriesCover stores the bytes and points the Series at them, but only
|
||||
// while the Series has no Cover: acquisition at creation and the poll both
|
||||
// call this, and whichever arrives second must not overwrite the first. The
|
||||
// bytes themselves are content-addressed and immutable, so storing them twice
|
||||
// is free.
|
||||
// SetSeriesCover stores the bytes and points the Series at their address, but
|
||||
// only while the Series has no Cover: acquisition at creation and the poll
|
||||
// both call this, and whichever arrives second must not overwrite the first.
|
||||
// The bytes themselves are content-addressed and immutable, so storing them
|
||||
// twice is free. See ReplaceSeriesCover for the write that may move a Cover
|
||||
// once one exists (ADR-0014).
|
||||
func (s *Store) SetSeriesCover(site, seriesID, sourceURL string, body []byte, contentType string) error {
|
||||
if err := s.putCover(sourceURL, body, contentType); err != nil {
|
||||
address, err := s.putCover(sourceURL, body, contentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.Exec(`
|
||||
UPDATE series SET cover = $3, cover_address = $4
|
||||
WHERE site = $1 AND series_id = $2 AND cover_address = ''`,
|
||||
site, seriesID, sourceURL, coverSourceAddress(sourceURL)); err != nil {
|
||||
site, seriesID, sourceURL, address); err != nil {
|
||||
return fmt.Errorf("set cover for %q: %w", site+":"+seriesID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReplaceSeriesCover stores the bytes and points the Series at their address
|
||||
// whether or not one already exists, writing the current source URL alongside
|
||||
// — the Forced Poll's installer and the only write that may move a Cover once
|
||||
// one exists (ADR-0014). previous is the address the row held before the write
|
||||
// ("" if it had none) and current the address of the bytes just stored; both
|
||||
// are read and written in one transaction, so a concurrent replacement reports
|
||||
// the exact displacement. previous == current means the Site served identical
|
||||
// artwork, an honest no-op; otherwise previous is stranded — the row no
|
||||
// longer points at it, and reclaiming its bytes is the caller's separate act
|
||||
// (the poller's replace path calls ReclaimCover on it). This write itself
|
||||
// removes nothing.
|
||||
func (s *Store) ReplaceSeriesCover(site, seriesID, sourceURL string, body []byte, contentType string) (previous, current string, err error) {
|
||||
current, err = s.putCover(sourceURL, body, contentType)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("begin replace cover for %q: %w", site+":"+seriesID, err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
err = tx.QueryRow(`
|
||||
SELECT cover_address FROM series
|
||||
WHERE site = $1 AND series_id = $2 FOR UPDATE`,
|
||||
site, seriesID).Scan(&previous)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
previous = ""
|
||||
} else if err != nil {
|
||||
return "", "", fmt.Errorf("read cover for %q: %w", site+":"+seriesID, err)
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
UPDATE series SET cover = $3, cover_address = $4
|
||||
WHERE site = $1 AND series_id = $2`,
|
||||
site, seriesID, sourceURL, current); err != nil {
|
||||
return "", "", fmt.Errorf("replace cover for %q: %w", site+":"+seriesID, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", "", fmt.Errorf("commit cover replace for %q: %w", site+":"+seriesID, err)
|
||||
}
|
||||
return previous, current, nil
|
||||
}
|
||||
|
||||
// List returns every bookmark of one reader, newest activity first.
|
||||
// Series-owned fields are joined in, so each Bookmark reads back whole and
|
||||
// flat (ADR-0004).
|
||||
@@ -904,6 +1001,11 @@ func (s *Store) Upsert(readerID int64, b Bookmark) (Bookmark, error) {
|
||||
// xmax is zero only on a row this statement inserted, which is how a
|
||||
// Series nobody had bookmarked before is told apart from one that already
|
||||
// existed — DO UPDATE returns a row either way.
|
||||
// latest_corrected_at is the one clause conditional on the value moving
|
||||
// (#149): after a Correction a Reader's cached row holds the corrected
|
||||
// number and resends it on the next Progress PUT, so unconditional
|
||||
// zeroing would erase the fact while the value is still the owner's. The
|
||||
// stamp survives a same-number PUT and dies the moment the number moves.
|
||||
var created bool
|
||||
if err := tx.QueryRow(`
|
||||
INSERT INTO series (site, series_id, title, series_url, kind,
|
||||
@@ -914,7 +1016,10 @@ func (s *Store) Upsert(readerID int64, b Bookmark) (Bookmark, error) {
|
||||
ON CONFLICT (site, series_id) DO UPDATE SET
|
||||
kind=excluded.kind,
|
||||
latest_chapter=excluded.latest_chapter,
|
||||
latest_chapter_num=excluded.latest_chapter_num
|
||||
latest_chapter_num=excluded.latest_chapter_num,
|
||||
latest_corrected_at = CASE
|
||||
WHEN series.latest_chapter_num IS DISTINCT FROM excluded.latest_chapter_num
|
||||
THEN 0 ELSE series.latest_corrected_at END
|
||||
RETURNING xmax = 0`,
|
||||
b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Kind,
|
||||
b.LatestChapter, latestNum).Scan(&created); err != nil {
|
||||
@@ -984,6 +1089,37 @@ func (s *Store) Delete(readerID int64, key string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// pgForeignKeyViolation is the SQLSTATE the driver surfaces when a Bookmark
|
||||
// row refuses a Series delete (bookmarks_series_fk). pgconn exports no named
|
||||
// constant for it, so the store names it here.
|
||||
const pgForeignKeyViolation = "23503"
|
||||
|
||||
// ErrSeriesHasBookmarks is RemoveSeries' refusal: a Reader still holds the
|
||||
// Series, so the owner's removal must not reach past that record. The
|
||||
// delete is the check — no NOT EXISTS pre-check that can race the insert —
|
||||
// and the driver's foreign-key violation is translated here so no driver
|
||||
// type escapes the store (issue #155).
|
||||
var ErrSeriesHasBookmarks = errors.New("series has bookmarks")
|
||||
|
||||
// RemoveSeries deletes one Series row by (site, series_id). It is refused
|
||||
// while any Bookmark references the row; deleting an absent key is not an
|
||||
// error, matching Delete. The caller owns the stranded Cover: read the row's
|
||||
// cover_address before the delete and call ReclaimCover after it — the
|
||||
// helper's guard cannot pass while the series row still points at the
|
||||
// address, so the order is the sequence, not a preference.
|
||||
func (s *Store) RemoveSeries(site, seriesID string) error {
|
||||
if _, err := s.db.Exec(
|
||||
`DELETE FROM series WHERE site = $1 AND series_id = $2`,
|
||||
site, seriesID); err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == pgForeignKeyViolation {
|
||||
return ErrSeriesHasBookmarks
|
||||
}
|
||||
return fmt.Errorf("remove series %s:%s: %w", site, seriesID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordLanePass appends one pass and prunes every older row in the same
|
||||
// transaction. retainBefore is supplied by the poller's clock.
|
||||
func (s *Store) RecordLanePass(p LanePass, retainBefore int64) error {
|
||||
@@ -1172,10 +1308,12 @@ func (s *Store) LaneGates(site string) (pausedUntil, refuseUntil int64, err erro
|
||||
//
|
||||
// A forced Series (force_poll_at newer than latest_checked_at, issue #146)
|
||||
// overrides exactly three gates: the rest cutoff, the Sighting-deferral
|
||||
// clause and the finished-only bucket. It never overrides an empty
|
||||
// series_url or the Bookmarks join — nothing to fetch, and no consumer for
|
||||
// the result — so those stay unconditional. Forced rows sort to the front of
|
||||
// the queue; the reader-count-then-age ordering among the rest is ADR-0003.
|
||||
// clause and a finished Series. It never overrides an empty series_url or the
|
||||
// Bookmarks join — nothing to fetch, and no consumer for the result — so
|
||||
// those stay unconditional, and it never clears the finish: nothing here
|
||||
// writes finished_at, and pending force clears itself when the pass stamps
|
||||
// the check timestamp. Forced rows sort to the front of the queue; the
|
||||
// reader-count-then-age ordering among the rest is ADR-0003.
|
||||
//
|
||||
// The reader_count ordering is the point of the split (ADR-0003): a series
|
||||
// shared by several readers is fetched once per due cycle, and the popular
|
||||
@@ -1183,14 +1321,15 @@ func (s *Store) LaneGates(site string) (pausedUntil, refuseUntil int64, err erro
|
||||
// reader count, oldest-first keeps the poll fair when the backlog outgrows
|
||||
// throughput: the most neglected series is always next, so a large collection
|
||||
// refreshes uniformly slower rather than leaving a tail that never refreshes
|
||||
// at all. The userscript sorts its own queue the same way (L453).
|
||||
// at all. The userscript sorts its own queue the same way.
|
||||
//
|
||||
// Series with no series_url are skipped — there is nothing to fetch, which is
|
||||
// the same filter the userscript applies at L452. Series whose only bookmarks
|
||||
// are finished are skipped too: nothing more is coming, so fetching them only
|
||||
// burns requests. Archived bookmarks still count — knowing what a shelved
|
||||
// series is up to is the whole reason for archiving instead of deleting.
|
||||
// A series with no bookmarks at all never appears: the join excludes it.
|
||||
// the same filter the userscript applies before refreshing. A finished Series is
|
||||
// skipped unless forced: nothing more is coming, so fetching it only burns
|
||||
// requests (issue #157). Archived bookmarks still count — knowing what a
|
||||
// shelved series is up to is the whole reason for archiving instead of
|
||||
// deleting. A series with no bookmarks at all never appears: the join
|
||||
// excludes it.
|
||||
//
|
||||
// ceilingMs is the Sighting deferral ceiling (issue #103): a Series whose last
|
||||
// real Poll is older than it appears however recently it was sighted. That is
|
||||
@@ -1199,10 +1338,10 @@ func (s *Store) LaneGates(site string) (pausedUntil, refuseUntil int64, err erro
|
||||
// decided here, from two facts the query already computes, so a Lane gains no
|
||||
// query per round: a Sighting younger than cutoffMs holds the Series back, but
|
||||
// only while COUNT(*) is 1. A Series a second Reader bookmarks is Polled on
|
||||
// schedule, so a wrong value the whole guild can see is corrected by a check
|
||||
// that was never postponed; on a solitary Series the only person a wrong value
|
||||
// reaches is the Reader who reported it. Whether the reporting Reader is
|
||||
// allowed to defer at all was settled when the Sighting was recorded — see
|
||||
// the schedule, so a wrong value the guild can see is corrected by a check
|
||||
// that was never postponed; on a solitary Series the only person a wrong
|
||||
// value reaches is the Reader who reported it. Whether the reporting Reader
|
||||
// is allowed to defer at all was settled when the Sighting was recorded — see
|
||||
// RecordSighting.
|
||||
func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Series, error) {
|
||||
rows, err := s.db.Query(`SELECT `+seriesColumns+`,
|
||||
@@ -1214,12 +1353,11 @@ func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Ser
|
||||
AND s.series_url <> ''
|
||||
AND (s.latest_checked_at <= $2::bigint
|
||||
OR s.force_poll_at > s.latest_checked_at)
|
||||
AND (s.finished_at = 0 OR s.force_poll_at > s.latest_checked_at)
|
||||
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover,
|
||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
|
||||
s.force_poll_at
|
||||
HAVING (COUNT(*) FILTER (WHERE b.status <> 'finished') > 0
|
||||
OR s.force_poll_at > s.latest_checked_at)
|
||||
AND (COUNT(*) > 1
|
||||
s.force_poll_at, s.finished_at
|
||||
HAVING (COUNT(*) > 1
|
||||
OR s.latest_sighted_at <= $2::bigint
|
||||
OR s.latest_checked_at <= $3::bigint
|
||||
OR s.force_poll_at > s.latest_checked_at)
|
||||
@@ -1241,14 +1379,18 @@ func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Ser
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// EligibleSeriesCount returns how many of a Site's Series still have at least
|
||||
// one bookmark outside the finished bucket. It is the denominator of the
|
||||
// Lane's pace (issue #100): the effective gap is the smaller of the registry
|
||||
// gap and one hour divided by this count, so Series that will never be Polled
|
||||
// do not make the Lane faster than it needs to be, and counting every eligible
|
||||
// Series rather than only those currently due keeps the pace steady — the
|
||||
// single worst moment to be fastest is startup, when everything is due at
|
||||
// once.
|
||||
// EligibleSeriesCount returns how many of a Site's Series are not finished —
|
||||
// the flag, never a Reader vote. It is the denominator of the Lane's pace
|
||||
// (issue #100): the effective gap is the smaller of the registry gap and one
|
||||
// hour divided by this count, so Series that will never be Polled do not make
|
||||
// the Lane faster than it needs to be, and counting every eligible Series
|
||||
// rather than only those currently due keeps the pace steady — the single
|
||||
// worst moment to be fastest is startup, when everything is due at once.
|
||||
//
|
||||
// The deliberate asymmetry with DueForLatestCheck's WHERE: a forced Series
|
||||
// is due but never admitted here, because a forced pass must not speed up
|
||||
// every other fetch on the Site — one impassioned press is not a reason to
|
||||
// hammer the Site (issue #157).
|
||||
func (s *Store) EligibleSeriesCount(site string) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRow(`SELECT COUNT(*) FROM (
|
||||
@@ -1256,8 +1398,8 @@ func (s *Store) EligibleSeriesCount(site string) (int, error) {
|
||||
FROM series s
|
||||
JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
|
||||
WHERE s.site = $1
|
||||
AND s.finished_at = 0
|
||||
GROUP BY s.site, s.series_id
|
||||
HAVING COUNT(*) FILTER (WHERE b.status <> 'finished') > 0
|
||||
) e`, site).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count eligible series %s: %w", site, err)
|
||||
@@ -1303,6 +1445,21 @@ func (s *Store) ForceSeriesPoll(site, seriesID string, at int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSeriesFinished stamps or clears the owner's finish. at is unix ms to
|
||||
// finish, zero to un-finish. A finished Series drops out of the Lane's reads
|
||||
// (issue #157), and nothing else writes this column: it is the only writer
|
||||
// outside migration 0016, so a machine write can never retire a Series
|
||||
// silently. Touching a missing series is not an error: the row may have been
|
||||
// orphaned, and the caller's read decides what exists.
|
||||
func (s *Store) SetSeriesFinished(site, seriesID string, at int64) error {
|
||||
if _, err := s.db.Exec(
|
||||
`UPDATE series SET finished_at = $1 WHERE site = $2 AND series_id = $3`,
|
||||
at, site, seriesID); err != nil {
|
||||
return fmt.Errorf("set series finished %s:%s: %w", site, seriesID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LatestCheckedAt reads the column MarkLatestChecked writes. It exists for
|
||||
// tests outside this package (the poller's own tests assert on rest
|
||||
// bookkeeping) — see MarkLatestChecked for why the field stays off the
|
||||
@@ -1320,10 +1477,13 @@ func (s *Store) LatestCheckedAt(site, seriesID string) (int64, error) {
|
||||
// SetLatestChapter records the newest chapter the poll found on a series page.
|
||||
// The poller walks Series rather than Bookmarks, so this is a series-level
|
||||
// write: the row is shared, and updating it once refreshes every bookmark that
|
||||
// joins to it. Touching a missing series is not an error.
|
||||
// joins to it. Touching a missing series is not an error. The correction stamp
|
||||
// is zeroed unconditionally: checkOne only calls this when the number differs,
|
||||
// so a second copy of the condition would drift (#149).
|
||||
func (s *Store) SetLatestChapter(site, seriesID, label string, num float64) error {
|
||||
if _, err := s.db.Exec(
|
||||
`UPDATE series SET latest_chapter = $3, latest_chapter_num = $4
|
||||
`UPDATE series SET latest_chapter = $3, latest_chapter_num = $4,
|
||||
latest_corrected_at = 0
|
||||
WHERE site = $1 AND series_id = $2`,
|
||||
site, seriesID, label, num); err != nil {
|
||||
return fmt.Errorf("set latest chapter %s:%s: %w", site, seriesID, err)
|
||||
@@ -1331,8 +1491,42 @@ func (s *Store) SetLatestChapter(site, seriesID, label string, num float64) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordSighting notes that a Reader's browser reported this Series' Latest
|
||||
// Chapter, which is the half of a Sighting the client body cannot express
|
||||
// SetSeriesURL stores the owner's repair for a Series' source address
|
||||
// (issue #151): the one write that lifts the write-once rule documented on
|
||||
// Series.SeriesURL. It is a store, not a verification — the caller has
|
||||
// already passed the poller's fetch gate. The handler 404s on an unknown row
|
||||
// before calling; the write itself is a plain single-column UPDATE like
|
||||
// MarkLatestChecked.
|
||||
func (s *Store) SetSeriesURL(site, seriesID, seriesURL string) error {
|
||||
if _, err := s.db.Exec(
|
||||
`UPDATE series SET series_url = $3 WHERE site = $1 AND series_id = $2`,
|
||||
site, seriesID, seriesURL); err != nil {
|
||||
return fmt.Errorf("set series url %s:%s: %w", site, seriesID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CorrectLatestChapter makes the Latest Chapter the owner's: one UPDATE
|
||||
// carrying the number, the derived label and the correction stamp. The label
|
||||
// shape is the poller's and the userscript's ("Chapter " + the number as
|
||||
// printed), so chapterLeadIn strips it and the UI renders "Ch N" with no
|
||||
// special case. latest_checked_at is not touched: a Correction is not a check.
|
||||
// A raising Reader is cleared without judgement: the number is the owner's
|
||||
// now, and no Sighting counter moves (spec #135).
|
||||
func (s *Store) CorrectLatestChapter(site, seriesID string, num float64, at int64) error {
|
||||
if _, err := s.db.Exec(
|
||||
`UPDATE series SET
|
||||
latest_chapter = $3,
|
||||
latest_chapter_num = $4,
|
||||
latest_corrected_at = $5,
|
||||
latest_raised_by = NULL
|
||||
WHERE site = $1 AND series_id = $2`,
|
||||
site, seriesID, "Chapter "+strconv.FormatFloat(num, 'f', -1, 64), num, at); err != nil {
|
||||
return fmt.Errorf("correct latest chapter %s:%s: %w", site, seriesID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// (issue #103). It must be called *before* the Upsert that stores the reported
|
||||
// value: the raise test compares against what is still on the row, and after
|
||||
// the Upsert there is nothing left to compare with. A Series that does not
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -277,6 +279,27 @@ func seedForCheck(t *testing.T, s *Store, key, seriesURL string, checkedAt int64
|
||||
}
|
||||
}
|
||||
|
||||
// seedFinished inserts a bookmark (and with it its series) and stamps the
|
||||
// series finished — the series-level fact the due gate reads (issue #157).
|
||||
func seedFinished(t *testing.T, s *Store, key, seriesURL string) {
|
||||
t.Helper()
|
||||
site, seriesID, ok := strings.Cut(key, ":")
|
||||
if !ok {
|
||||
t.Fatalf("key %q: no ':' separator", key)
|
||||
}
|
||||
if _, err := s.Upsert(s.OwnerID(), Bookmark{
|
||||
Key: key, Site: site, SeriesID: seriesID, SeriesURL: seriesURL,
|
||||
UpdatedAt: 1000,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed %q: %v", key, err)
|
||||
}
|
||||
if _, err := s.db.Exec(
|
||||
`UPDATE series SET finished_at = 1000 WHERE site = $1 AND series_id = $2`,
|
||||
site, seriesID); err != nil {
|
||||
t.Fatalf("seed finish %q: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// noCeiling is a Sighting deferral ceiling no Series can reach, for the tests
|
||||
// that predate the ceiling and are about rest, ordering or buckets instead.
|
||||
const noCeiling = int64(-1)
|
||||
@@ -484,24 +507,15 @@ func TestUpsertStatusChangeKeepsUpdatedAt(t *testing.T) {
|
||||
t.Fatalf("UpdatedAt = %d, want it frozen at %d", stored.UpdatedAt, first.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// Archiving is the reason to keep polling — the point is to come back to a
|
||||
// series that has moved on. A finished series has nothing left to publish.
|
||||
// series that has moved on. A finished series has nothing left to publish,
|
||||
// whichever Reader marked it (issue #157).
|
||||
func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
for _, tc := range []struct{ key, status string }{
|
||||
{"asura:reading", StatusReading},
|
||||
{"asura:archived", StatusArchived},
|
||||
{"asura:finished", StatusFinished},
|
||||
} {
|
||||
if _, err := store.Upsert(store.OwnerID(), Bookmark{
|
||||
Key: tc.key, Site: "asura", SeriesID: strings.TrimPrefix(tc.key, "asura:"),
|
||||
SeriesURL: "https://asurascans.com/comics/" + tc.key,
|
||||
Status: tc.status, UpdatedAt: time.Now().UnixMilli(),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed %s: %v", tc.key, err)
|
||||
}
|
||||
for _, key := range []string{"asura:reading", "asura:archived"} {
|
||||
seedForCheck(t, store, key, "https://asurascans.com/comics/"+key, 0)
|
||||
}
|
||||
seedFinished(t, store, "asura:finished", "https://asurascans.com/comics/asura:finished")
|
||||
|
||||
due, err := store.DueForLatestCheck("asura", time.Now().UnixMilli(), noCeiling)
|
||||
if err != nil {
|
||||
@@ -520,27 +534,26 @@ func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
|
||||
}
|
||||
|
||||
// The gap's denominator counts every Series the Lane will ever Poll: a
|
||||
// finished Series must not make the Lane faster than it needs to be, and
|
||||
// another Site's Series must not leak into this Site's count.
|
||||
// finished Series must not make the Lane faster than it needs to be, another
|
||||
// Site's Series must not leak into this Site's count, and a forced Series is
|
||||
// not admitted either — it is due once, not a reason to tighten the pace for
|
||||
// every other fetch on the Site (issue #157).
|
||||
func TestEligibleSeriesCount(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedForCheck(t, store, "asura:reading", "https://asurascans.com/comics/reading", 0)
|
||||
seedForCheck(t, store, "asura:archived", "https://asurascans.com/comics/archived", 0)
|
||||
if _, err := store.Upsert(store.OwnerID(), Bookmark{
|
||||
Key: "asura:finished", Site: "asura", SeriesID: "finished",
|
||||
SeriesURL: "https://asurascans.com/comics/finished",
|
||||
Status: StatusFinished, UpdatedAt: 1000,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed finished: %v", err)
|
||||
}
|
||||
seedFinished(t, store, "asura:finished", "https://asurascans.com/comics/asura:finished")
|
||||
seedForCheck(t, store, "demonic:z", "https://demonicscans.org/manga/z", 0)
|
||||
if err := store.ForceSeriesPoll("asura", "archived", 5000); err != nil {
|
||||
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||
}
|
||||
|
||||
n, err := store.EligibleSeriesCount("asura")
|
||||
if err != nil {
|
||||
t.Fatalf("EligibleSeriesCount: %v", err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("eligible = %d, want 2 (finished excluded, demonic excluded)", n)
|
||||
t.Fatalf("eligible = %d, want 2 (finished and forced excluded, demonic excluded)", n)
|
||||
}
|
||||
n, err = store.EligibleSeriesCount("demonic")
|
||||
if err != nil {
|
||||
@@ -753,6 +766,133 @@ func TestMigration0008DropsLegacyCoverRows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 0016's three statements are order-dependent: the finished_at seed must see
|
||||
// the pre-flip 'finished' buckets, and the flip must come after it. A seed
|
||||
// that ran after the flip would read bookmarks that no longer say
|
||||
// 'finished', declare nothing finished, and silently resume polling series
|
||||
// somebody marked done. This seeds a pre-0016 database the way every
|
||||
// deployment looked — finished a bookmark bucket, no finished_at column —
|
||||
// runs the migration, and asserts on what it preserved.
|
||||
func TestMigration0016SeedsFinishedAtBeforeFlippingBucket(t *testing.T) {
|
||||
url := pgtest.URL(t)
|
||||
db, err := sql.Open("pgx", url)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := migrate(db, 15); err != nil {
|
||||
t.Fatalf("migrate to 0015: %v", err)
|
||||
}
|
||||
if err := seedOwner(db, testOwner); err != nil {
|
||||
t.Fatalf("seed owner: %v", err)
|
||||
}
|
||||
// A second reader, so "mixed" can carry two bookmarks on one series.
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO readers (discord_id, token_sha256) VALUES ('second', '\x01'::bytea)`); err != nil {
|
||||
t.Fatalf("seed second reader: %v", err)
|
||||
}
|
||||
|
||||
owner := func() int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
if err := db.QueryRow(
|
||||
`SELECT id FROM readers WHERE discord_id = $1`, testOwner.DiscordID).Scan(&id); err != nil {
|
||||
t.Fatalf("owner id: %v", err)
|
||||
}
|
||||
return id
|
||||
}()
|
||||
seedBookmark := func(readerID int64, site, seriesID, status string) {
|
||||
t.Helper()
|
||||
// The bookmark FK demands the series row; Upsert would create it on
|
||||
// the fly, raw SQL has to spell it out.
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO series (site, series_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
site, seriesID); err != nil {
|
||||
t.Fatalf("seed series %s:%s: %v", site, seriesID, err)
|
||||
}
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO bookmarks (reader_id, site, series_id, status, updated_at)
|
||||
VALUES ($1, $2, $3, $4, 1000)`, readerID, site, seriesID, status); err != nil {
|
||||
t.Fatalf("seed %s:%s/%s: %v", site, seriesID, status, err)
|
||||
}
|
||||
}
|
||||
// done: the one-bookmark series every finished series looked like.
|
||||
seedBookmark(owner, "asura", "done", "finished")
|
||||
// shared: every reader finished — the multi-reader equivalent.
|
||||
seedBookmark(owner, "asura", "shared", "finished")
|
||||
// mixed: a second reader still reading keeps the series alive.
|
||||
seedBookmark(owner, "asura", "mixed", "finished")
|
||||
seedBookmark(owner+1, "asura", "mixed", "archived")
|
||||
// live: nobody finished it.
|
||||
seedBookmark(owner, "asura", "live", "reading")
|
||||
// orphan: no bookmarks at all, nothing to decide from.
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO series (site, series_id) VALUES ('asura', 'orphan')`); err != nil {
|
||||
t.Fatalf("seed orphan: %v", err)
|
||||
}
|
||||
|
||||
if err := migrate(db, 0); err != nil {
|
||||
t.Fatalf("migrate 0016: %v", err)
|
||||
}
|
||||
|
||||
finishedAt := func(site, seriesID string) int64 {
|
||||
t.Helper()
|
||||
var at int64
|
||||
if err := db.QueryRow(
|
||||
`SELECT finished_at FROM series WHERE site = $1 AND series_id = $2`,
|
||||
site, seriesID).Scan(&at); err != nil {
|
||||
t.Fatalf("finished_at %s:%s: %v", site, seriesID, err)
|
||||
}
|
||||
return at
|
||||
}
|
||||
status := func(readerID int64, site, seriesID string) string {
|
||||
t.Helper()
|
||||
var s string
|
||||
if err := db.QueryRow(
|
||||
`SELECT status FROM bookmarks WHERE reader_id = $1
|
||||
AND site = $2 AND series_id = $3`, readerID, site, seriesID).Scan(&s); err != nil {
|
||||
t.Fatalf("status %s:%s: %v", site, seriesID, err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// The seed ran before the flip: had the flip gone first, done's bookmarks
|
||||
// would read archived and nothing would be stamped. The stamp is a real
|
||||
// timestamp, not a sentinel.
|
||||
before := time.Now().Add(-time.Minute).UnixMilli()
|
||||
after := time.Now().Add(time.Minute).UnixMilli()
|
||||
for _, sr := range []struct{ site, seriesID string }{
|
||||
{"asura", "done"}, {"asura", "shared"},
|
||||
} {
|
||||
if at := finishedAt(sr.site, sr.seriesID); at < before || at > after {
|
||||
t.Fatalf("%s:%s finished_at = %d, want now-ish", sr.site, sr.seriesID, at)
|
||||
}
|
||||
// The flip followed the seed: every finished bookmark is archived.
|
||||
if s := status(owner, sr.site, sr.seriesID); s != StatusArchived {
|
||||
t.Fatalf("%s:%s status = %q, want archived after the flip", sr.site, sr.seriesID, s)
|
||||
}
|
||||
}
|
||||
// The seed ignores a series any bookmark keeps alive.
|
||||
if at := finishedAt("asura", "mixed"); at != 0 {
|
||||
t.Fatalf("mixed finished_at = %d, want 0", at)
|
||||
}
|
||||
// The flip is a bucket rewrite, not a series-wide one: the finished
|
||||
// bookmark becomes archived and the sibling rows keep their buckets.
|
||||
if s := status(owner, "asura", "mixed"); s != StatusArchived {
|
||||
t.Fatalf("mixed finished bookmark = %q, want archived", s)
|
||||
}
|
||||
if s := status(owner+1, "asura", "mixed"); s != StatusArchived {
|
||||
t.Fatalf("mixed archived bookmark = %q, want untouched archived", s)
|
||||
}
|
||||
if s := status(owner, "asura", "live"); s != StatusReading {
|
||||
t.Fatalf("live status = %q, want untouched reading", s)
|
||||
}
|
||||
if at := finishedAt("asura", "orphan"); at != 0 {
|
||||
t.Fatalf("orphan finished_at = %d, want 0", at)
|
||||
}
|
||||
}
|
||||
|
||||
// readSeries reads the series row directly, for asserting on what Upsert
|
||||
// actually stored rather than what the joined Bookmark reports.
|
||||
func readSeries(t *testing.T, s *Store, site, seriesID string) Series {
|
||||
@@ -794,6 +934,37 @@ func TestUpsertCreatesSeriesFromClient(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The wire's finished flag is derived, never stored: the Upsert's explicit
|
||||
// column list does not name finished_at — the same omission that protects
|
||||
// cover — so a client echoing a cached flag, true or false, cannot change the
|
||||
// Series' retired state (issues #157, #160).
|
||||
func TestUpsertCannotWriteSeriesFinished(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedFinished(t, store, "asura:done", "https://asurascans.com/comics/asura:done")
|
||||
|
||||
for _, sent := range []bool{true, false} {
|
||||
stored, err := store.Upsert(store.OwnerID(), Bookmark{
|
||||
Key: "asura:done", Site: "asura", SeriesID: "done",
|
||||
Finished: sent, UpdatedAt: 2000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert(Finished: %v): %v", sent, err)
|
||||
}
|
||||
if !stored.Finished {
|
||||
t.Fatalf("stored.Finished = false after echoing %v, want true", sent)
|
||||
}
|
||||
}
|
||||
var finishedAt int64
|
||||
if err := store.db.QueryRow(
|
||||
`SELECT finished_at FROM series WHERE site = $1 AND series_id = $2`,
|
||||
"asura", "done").Scan(&finishedAt); err != nil {
|
||||
t.Fatalf("read finished_at: %v", err)
|
||||
}
|
||||
if finishedAt != 1000 {
|
||||
t.Fatalf("series.finished_at = %v, want the seeded 1000 untouched", finishedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// The hook is what starts creation-time acquisition, so it must fire exactly
|
||||
// once per Series — on the PUT that created it, and on no later one, whichever
|
||||
// Reader sends it.
|
||||
@@ -850,7 +1021,7 @@ func TestSetSeriesCoverDoesNotOverwrite(t *testing.T) {
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Get = %v, %v", ok, err)
|
||||
}
|
||||
if want := "https://bookmarks.test/covers/" + CoverAddress(first); got.Cover != want {
|
||||
if want := "https://bookmarks.test/covers/" + CoverAddressForBytes([]byte("first")); got.Cover != want {
|
||||
t.Fatalf("Cover = %q, want the first one %q", got.Cover, want)
|
||||
}
|
||||
}
|
||||
@@ -869,7 +1040,7 @@ func TestCoverByAddress(t *testing.T) {
|
||||
t.Fatalf("SetSeriesCover: %v", err)
|
||||
}
|
||||
|
||||
body, contentType, ok, err := store.CoverByAddress(CoverAddress(source))
|
||||
body, contentType, ok, err := store.CoverByAddress(CoverAddressForBytes([]byte("bytes")))
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CoverByAddress = %v, %v", ok, err)
|
||||
}
|
||||
@@ -877,8 +1048,8 @@ func TestCoverByAddress(t *testing.T) {
|
||||
t.Fatalf("CoverByAddress = %q, %q, want the stored bytes", body, contentType)
|
||||
}
|
||||
|
||||
for _, address := range []string{"", "../../etc/passwd", "ZZ" + CoverAddress(source)[2:],
|
||||
CoverAddress("never stored")} {
|
||||
for _, address := range []string{"", "../../etc/passwd", "ZZ" + CoverAddressForBytes([]byte("bytes"))[2:],
|
||||
CoverAddressForBytes([]byte("never stored"))} {
|
||||
_, _, ok, err := store.CoverByAddress(address)
|
||||
if err != nil || ok {
|
||||
t.Fatalf("CoverByAddress(%q) = %v, %v, want a clean miss", address, ok, err)
|
||||
@@ -913,7 +1084,7 @@ func TestUpsertExistingSeriesIgnoresClientTitleCoverURL(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
wantCover := "https://bookmarks.test/covers/" + CoverAddress(acquired)
|
||||
wantCover := "https://bookmarks.test/covers/" + CoverAddressForBytes([]byte("bytes"))
|
||||
if got.Title != "Solo Leveling" || got.SeriesURL != "https://asurascans.com/comics/solo" ||
|
||||
got.Cover != wantCover {
|
||||
t.Fatalf("stored = %+v, want original title/url/cover kept", got)
|
||||
@@ -981,7 +1152,7 @@ func TestDeleteKeepsSeriesRow(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("re-upsert: %v", err)
|
||||
}
|
||||
wantCover := "https://bookmarks.test/covers/" + CoverAddress(acquired)
|
||||
wantCover := "https://bookmarks.test/covers/" + CoverAddressForBytes([]byte("bytes"))
|
||||
if stored.Title != "Solo Leveling" || stored.Cover != wantCover {
|
||||
t.Fatalf("re-bookmark = %+v, want title/cover from the surviving series row", stored)
|
||||
}
|
||||
@@ -1498,9 +1669,9 @@ func TestCoverPersistsAcrossReopen(t *testing.T) {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
defer second.Close()
|
||||
got, contentType, ok, err := second.GetCover(sourceURL)
|
||||
got, contentType, ok, err := second.CoverByAddress(CoverAddressForBytes(body))
|
||||
if err != nil {
|
||||
t.Fatalf("GetCover: %v", err)
|
||||
t.Fatalf("CoverByAddress: %v", err)
|
||||
}
|
||||
if !ok || !bytes.Equal(got, body) || contentType != "image/webp" {
|
||||
t.Fatalf("stored cover = (%q, %q, %v), want (%q, image/webp, true)", got, contentType, ok, body)
|
||||
@@ -1539,7 +1710,7 @@ func TestCoverIsContentAddressedOnFilesystem(t *testing.T) {
|
||||
}
|
||||
defer first.Close()
|
||||
|
||||
addressBytes := sha256.Sum256([]byte(sourceURL))
|
||||
addressBytes := sha256.Sum256(body)
|
||||
address := hex.EncodeToString(addressBytes[:])
|
||||
wantPath := filepath.Join(address[:2], address[2:4], address)
|
||||
|
||||
@@ -1570,9 +1741,9 @@ func TestCoverStoreAcceptsAnySourceURL(t *testing.T) {
|
||||
if err := s.PutCover(sourceURL, want, "image/jpeg"); err != nil {
|
||||
t.Fatalf("PutCover: %v", err)
|
||||
}
|
||||
got, contentType, ok, err := s.GetCover(sourceURL)
|
||||
got, contentType, ok, err := s.CoverByAddress(CoverAddressForBytes(want))
|
||||
if err != nil {
|
||||
t.Fatalf("GetCover: %v", err)
|
||||
t.Fatalf("CoverByAddress: %v", err)
|
||||
}
|
||||
if !ok || !bytes.Equal(got, want) || contentType != "image/jpeg" {
|
||||
t.Fatalf("GetCover = (%q, %q, %v), want (%q, image/jpeg, true)", got, contentType, ok, want)
|
||||
@@ -1580,7 +1751,7 @@ func TestCoverStoreAcceptsAnySourceURL(t *testing.T) {
|
||||
if err := s.PutCover("https://cdn.example/not-image", []byte("html"), "text/html"); err == nil {
|
||||
t.Fatal("PutCover accepted a non-image")
|
||||
}
|
||||
if _, _, ok, err := s.GetCover("https://cdn.example/not-image"); err != nil || ok {
|
||||
if _, _, ok, err := s.CoverByAddress(CoverAddressForBytes([]byte("html"))); err != nil || ok {
|
||||
t.Fatalf("rejected cover = found %v, err %v; want missing", ok, err)
|
||||
}
|
||||
}
|
||||
@@ -1807,19 +1978,12 @@ func TestDueForLatestCheckForcedOverridesSightingDeferral(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The finished-only bucket excludes a series whose only bookmarks are
|
||||
// finished; a forced request overrides it — the owner asked, so the Lane
|
||||
// looks.
|
||||
func TestDueForLatestCheckForcedOverridesFinishedBucket(t *testing.T) {
|
||||
// The finished flag excludes a series; a forced request overrides it — the
|
||||
// owner asked, so the Lane looks.
|
||||
func TestDueForLatestCheckForcedOverridesFinishedFlag(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedForCheck(t, s, "asura:reading", "https://asurascans.com/comics/reading", 0)
|
||||
if _, err := s.Upsert(s.OwnerID(), Bookmark{
|
||||
Key: "asura:finished", Site: "asura", SeriesID: "finished",
|
||||
SeriesURL: "https://asurascans.com/comics/finished",
|
||||
Status: StatusFinished, UpdatedAt: 1000,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed finished: %v", err)
|
||||
}
|
||||
seedFinished(t, s, "asura:finished", "https://asurascans.com/comics/asura:finished")
|
||||
|
||||
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||
if err != nil {
|
||||
@@ -1899,3 +2063,551 @@ func TestDueForLatestCheckForcedDoesNotOverrideURLOrJoin(t *testing.T) {
|
||||
t.Fatalf("due = %v, want neither the URL-less nor the orphan series", due)
|
||||
}
|
||||
}
|
||||
|
||||
// A Correction writes the number, the derived label and the stamp, clears the
|
||||
// raising Reader, and never touches either Sighting counter or the check
|
||||
// stamp — a Correction is not a check and never judges a Reader (#149).
|
||||
func TestCorrectLatestChapterStampsClearsAndDoesNotTouchCheckOrMarks(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
other := secondReader(t, s)
|
||||
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 4321_000)
|
||||
// A Reader raised the number, and carries a mark for it.
|
||||
if err := s.RecordSighting(other, "asura", "solo", num2(3), 1000); err != nil {
|
||||
t.Fatalf("RecordSighting: %v", err)
|
||||
}
|
||||
if _, err := s.db.Exec(`
|
||||
UPDATE readers SET sighting_agreements = 5, sighting_disagreements = 2
|
||||
WHERE id = $1`, other); err != nil {
|
||||
t.Fatalf("mark reader: %v", err)
|
||||
}
|
||||
|
||||
if err := s.CorrectLatestChapter("asura", "solo", 12.5, 9000); err != nil {
|
||||
t.Fatalf("CorrectLatestChapter: %v", err)
|
||||
}
|
||||
|
||||
var chapter string
|
||||
var num float64
|
||||
var stamp, checkedAt int64
|
||||
var raisedBy any
|
||||
if err := s.db.QueryRow(`
|
||||
SELECT latest_chapter, latest_chapter_num, latest_corrected_at,
|
||||
latest_checked_at, latest_raised_by
|
||||
FROM series WHERE site = 'asura' AND series_id = 'solo'`).
|
||||
Scan(&chapter, &num, &stamp, &checkedAt, &raisedBy); err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if chapter != "Chapter 12.5" {
|
||||
t.Errorf("latest_chapter = %q, want the derived label %q", chapter, "Chapter 12.5")
|
||||
}
|
||||
if num != 12.5 {
|
||||
t.Errorf("latest_chapter_num = %v, want 12.5", num)
|
||||
}
|
||||
if stamp != 9000 {
|
||||
t.Errorf("latest_corrected_at = %d, want 9000", stamp)
|
||||
}
|
||||
if checkedAt != 4321_000 {
|
||||
t.Errorf("latest_checked_at = %d, want the untouched 4321000", checkedAt)
|
||||
}
|
||||
if raisedBy != nil {
|
||||
t.Errorf("latest_raised_by = %v, want the attribution cleared", raisedBy)
|
||||
}
|
||||
|
||||
readers, err := s.Readers()
|
||||
if err != nil {
|
||||
t.Fatalf("Readers: %v", err)
|
||||
}
|
||||
for _, r := range readers {
|
||||
if r.ID == other && (r.Agreements != 5 || r.Disagreements != 2) {
|
||||
t.Errorf("raising reader's marks = %+v, want agreements 5, disagreements 2 unchanged", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The stamp follows the number (spec #135): an Upsert resending the corrected
|
||||
// value — a Reader's cached row after a correction — keeps it, and an Upsert
|
||||
// that actually moves the number kills it. Unconditional zeroing would erase
|
||||
// the fact while the value is still the owner's; that is the whole point of
|
||||
// the clause.
|
||||
func TestUpsertCorrectionStampFollowsTheNumber(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
base := Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo", Kind: KindManga,
|
||||
SeriesURL: "https://asurascans.com/comics/solo", UpdatedAt: 1000,
|
||||
}
|
||||
if _, err := s.Upsert(s.OwnerID(), base); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := s.CorrectLatestChapter("asura", "solo", 5, 9000); err != nil {
|
||||
t.Fatalf("CorrectLatestChapter: %v", err)
|
||||
}
|
||||
|
||||
// Same number back: the value is still the owner's.
|
||||
same := base
|
||||
same.LatestChapterNum = num2(5)
|
||||
if _, err := s.Upsert(s.OwnerID(), same); err != nil {
|
||||
t.Fatalf("same-number upsert: %v", err)
|
||||
}
|
||||
if got := s.latestCorrectedAt(t, "asura", "solo"); got != 9000 {
|
||||
t.Fatalf("stamp after same-number Upsert = %d, want 9000 kept", got)
|
||||
}
|
||||
|
||||
// A different number: a machine (or a Reader) wrote the value.
|
||||
moved := base
|
||||
moved.LatestChapterNum = num2(7)
|
||||
if _, err := s.Upsert(s.OwnerID(), moved); err != nil {
|
||||
t.Fatalf("moved upsert: %v", err)
|
||||
}
|
||||
if got := s.latestCorrectedAt(t, "asura", "solo"); got != 0 {
|
||||
t.Fatalf("stamp after moved Upsert = %d, want zeroed", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The poller's chapter setter zeroes the stamp unconditionally: checkOne only
|
||||
// calls it when the number differs, so the condition lives upstream and a
|
||||
// second copy here would drift (#149).
|
||||
func TestSetLatestChapterZeroesCorrectionStamp(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
if err := s.CorrectLatestChapter("asura", "solo", 5, 9000); err != nil {
|
||||
t.Fatalf("CorrectLatestChapter: %v", err)
|
||||
}
|
||||
if err := s.SetLatestChapter("asura", "solo", "Chapter 6", 6); err != nil {
|
||||
t.Fatalf("SetLatestChapter: %v", err)
|
||||
}
|
||||
if got := s.latestCorrectedAt(t, "asura", "solo"); got != 0 {
|
||||
t.Fatalf("stamp after a machine write = %d, want zeroed", got)
|
||||
}
|
||||
}
|
||||
|
||||
// latestCorrectedAt reads the stamp column for the assertion above.
|
||||
func (s *Store) latestCorrectedAt(t *testing.T, site, seriesID string) int64 {
|
||||
t.Helper()
|
||||
var stamp int64
|
||||
if err := s.db.QueryRow(
|
||||
`SELECT latest_corrected_at FROM series WHERE site = $1 AND series_id = $2`,
|
||||
site, seriesID).Scan(&stamp); err != nil {
|
||||
t.Fatalf("read stamp: %v", err)
|
||||
}
|
||||
return stamp
|
||||
}
|
||||
|
||||
// num2 boxes a chapter number for the Bookmark fields that take a pointer.
|
||||
func num2(f float64) *float64 { return &f }
|
||||
|
||||
// --- Cover addressing (ADR-0014): the address is the bytes' SHA-256 ---
|
||||
|
||||
// The address is what makes a re-art visible at all, so the same bytes must
|
||||
// always name the same address and different bytes different ones — and the
|
||||
// address must keep the 64-hex-digit shape CoverByAddress's guard still checks
|
||||
// before any request-supplied value becomes a filesystem path.
|
||||
func TestCoverAddressForBytesIsDeterministicAndDistinct(t *testing.T) {
|
||||
first := CoverAddressForBytes([]byte("art"))
|
||||
again := CoverAddressForBytes([]byte("art"))
|
||||
other := CoverAddressForBytes([]byte("artwork"))
|
||||
if first != again {
|
||||
t.Fatalf("same bytes gave %q then %q, want one address", first, again)
|
||||
}
|
||||
if first == other {
|
||||
t.Fatalf("different bytes gave the same address %q", first)
|
||||
}
|
||||
if !coverAddressRe.MatchString(first) {
|
||||
t.Fatalf("address %q is not the 64-hex-digit shape the serving guard checks", first)
|
||||
}
|
||||
}
|
||||
|
||||
// ReplaceSeriesCover is the forced-replacement installer: it moves a Cover
|
||||
// whether or not one exists, writes the source URL alongside it, and reports
|
||||
// the three outcomes the Forced Poll has to tell apart.
|
||||
func TestReplaceSeriesCover(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
if _, err := store.Upsert(store.OwnerID(), Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 1000,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
// A blank Cover: previous is "", and the row points at the new bytes.
|
||||
previous, current, err := store.ReplaceSeriesCover("asura", "solo",
|
||||
"https://cdn.asurascans.com/covers/solo.webp", []byte("first-art"), "image/webp")
|
||||
if err != nil {
|
||||
t.Fatalf("ReplaceSeriesCover on a blank: %v", err)
|
||||
}
|
||||
if previous != "" {
|
||||
t.Fatalf("previous on a blank = %q, want empty", previous)
|
||||
}
|
||||
if want := CoverAddressForBytes([]byte("first-art")); current != want {
|
||||
t.Fatalf("current = %q, want %q", current, want)
|
||||
}
|
||||
if sr := readSeries(t, store, "asura", "solo"); sr.CoverAddress != current ||
|
||||
sr.Cover != "https://cdn.asurascans.com/covers/solo.webp" {
|
||||
t.Fatalf("series after blank fill = %+v, want the new address and source URL", sr)
|
||||
}
|
||||
|
||||
// A re-art: previous is the stranded address, current the new one.
|
||||
previous, current, err = store.ReplaceSeriesCover("asura", "solo",
|
||||
"https://cdn.asurascans.com/covers/solo-rebrand.webp", []byte("second-art"), "image/jpeg")
|
||||
if err != nil {
|
||||
t.Fatalf("ReplaceSeriesCover over a filled Cover: %v", err)
|
||||
}
|
||||
if want := CoverAddressForBytes([]byte("first-art")); previous != want {
|
||||
t.Fatalf("previous = %q, want the replaced address %q", previous, want)
|
||||
}
|
||||
if want := CoverAddressForBytes([]byte("second-art")); current != want {
|
||||
t.Fatalf("current = %q, want %q", current, want)
|
||||
}
|
||||
if sr := readSeries(t, store, "asura", "solo"); sr.CoverAddress != current ||
|
||||
sr.Cover != "https://cdn.asurascans.com/covers/solo-rebrand.webp" {
|
||||
t.Fatalf("series after replacement = %+v, want the new address and source URL", sr)
|
||||
}
|
||||
// The replaced bytes stay served under their old address: ReplaceSeriesCover
|
||||
// itself reclaims nothing, reclamation is the caller's separate act (#154).
|
||||
if _, _, ok, err := store.CoverByAddress(CoverAddressForBytes([]byte("first-art"))); err != nil || !ok {
|
||||
t.Fatalf("superseded bytes = found %v, err %v, want still served", ok, err)
|
||||
}
|
||||
|
||||
// The Site is serving the same artwork again: previous == current is the
|
||||
// honest no-op the caller reports as "unchanged".
|
||||
previous, current, err = store.ReplaceSeriesCover("asura", "solo",
|
||||
"https://cdn.asurascans.com/covers/solo-rebrand.webp", []byte("second-art"), "image/jpeg")
|
||||
if err != nil {
|
||||
t.Fatalf("ReplaceSeriesCover over identical bytes: %v", err)
|
||||
}
|
||||
if previous != current {
|
||||
t.Fatalf("identical bytes: previous = %q, current = %q, want one address", previous, current)
|
||||
}
|
||||
if want := CoverAddressForBytes([]byte("second-art")); current != want {
|
||||
t.Fatalf("current = %q, want %q", current, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Rows written before byte addressing hold the hash of their source URL and
|
||||
// are never rehashed: GetCover — the poller's heal path — keeps resolving
|
||||
// them through coverSourceAddress.
|
||||
func TestGetCoverResolvesLegacyURLDerivedAddress(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
source := "https://cdn.example/legacy.jpg"
|
||||
legacy := coverSourceAddress(source)
|
||||
relativePath := coverRelativePath(legacy)
|
||||
coverPath := filepath.Join(store.coverDir, filepath.FromSlash(relativePath))
|
||||
if err := os.MkdirAll(filepath.Dir(coverPath), 0o755); err != nil {
|
||||
t.Fatalf("create shard dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(coverPath, []byte("legacy-bytes"), 0o644); err != nil {
|
||||
t.Fatalf("write legacy file: %v", err)
|
||||
}
|
||||
if _, err := store.db.Exec(
|
||||
`INSERT INTO covers (address, path, content_type) VALUES ($1, $2, $3)`,
|
||||
legacy, relativePath, "image/jpeg"); err != nil {
|
||||
t.Fatalf("plant legacy row: %v", err)
|
||||
}
|
||||
|
||||
body, contentType, ok, err := store.GetCover(source)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetCover on a legacy row = %v, %v, want found", ok, err)
|
||||
}
|
||||
if string(body) != "legacy-bytes" || contentType != "image/jpeg" {
|
||||
t.Fatalf("legacy cover = (%q, %q), want the planted bytes", body, contentType)
|
||||
}
|
||||
}
|
||||
|
||||
// SetSeriesURL is the one write that lifts the write-once rule of
|
||||
// Series.SeriesURL (issue #151): a client PUT naming an existing Series still
|
||||
// has its new URL dropped, yet the owner's repair lands where the Upsert
|
||||
// would have ignored it.
|
||||
func TestSetSeriesURLWritesWhereUpsertIgnores(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
base := Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/comics/solo",
|
||||
UpdatedAt: 1000,
|
||||
}
|
||||
if _, err := store.Upsert(store.OwnerID(), base); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
// A client PUT naming the existing Series is refused: the row is shared,
|
||||
// so the stored URL stands.
|
||||
base.SeriesURL = "https://evil.example/solo"
|
||||
if got, err := store.Upsert(store.OwnerID(), base); err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
} else if got.SeriesURL != "https://asurascans.com/comics/solo" {
|
||||
t.Fatalf("Upsert stored %q, want the original URL untouched", got.SeriesURL)
|
||||
}
|
||||
|
||||
// The owner's repair writes where the Upsert would have ignored it.
|
||||
repair := "https://asurascans.com/comics/solo-renumbered"
|
||||
if err := store.SetSeriesURL("asura", "solo", repair); err != nil {
|
||||
t.Fatalf("SetSeriesURL: %v", err)
|
||||
}
|
||||
sr := readSeries(t, store, "asura", "solo")
|
||||
if sr.SeriesURL != repair {
|
||||
t.Fatalf("stored URL = %q, want %q", sr.SeriesURL, repair)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cover byte reclamation (issue #154): one guarded helper, file first ---
|
||||
|
||||
// coverShardPath is the on-disk location of one address's bytes, built the
|
||||
// same way getCoverByAddress reads them.
|
||||
func coverShardPath(t *testing.T, s *Store, address string) string {
|
||||
t.Helper()
|
||||
return filepath.Join(s.coverDir, filepath.FromSlash(coverRelativePath(address)))
|
||||
}
|
||||
|
||||
// ReclaimCover removes a Cover nothing references: the row alone is not the
|
||||
// point — the sharded file must be gone too, because the file is the reclaimed
|
||||
// disk space.
|
||||
func TestReclaimCoverRemovesUnreferencedBytes(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/old.jpg", []byte("old-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("put cover: %v", err)
|
||||
}
|
||||
old := CoverAddressForBytes([]byte("old-art"))
|
||||
if _, _, err := store.ReplaceSeriesCover("asura", "solo", "https://cdn.example/covers/new.jpg", []byte("new-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("replace cover: %v", err)
|
||||
}
|
||||
|
||||
if err := store.ReclaimCover(old); err != nil {
|
||||
t.Fatalf("ReclaimCover: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(coverShardPath(t, store, old)); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("sharded path after reclaim = %v, want fs.ErrNotExist", err)
|
||||
}
|
||||
if _, _, ok, err := store.CoverByAddress(old); err != nil || ok {
|
||||
t.Fatalf("covers row after reclaim = found %v err %v, want gone", ok, err)
|
||||
}
|
||||
// The live Cover survives the reclamation of the stranded one.
|
||||
if body, _, ok, err := store.CoverByAddress(CoverAddressForBytes([]byte("new-art"))); err != nil || !ok || string(body) != "new-art" {
|
||||
t.Fatalf("new bytes after reclaim = found %v err %v, want still served", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The guard is the whole design: byte-identical artwork is one covers row by
|
||||
// construction (ADR-0014), so a second Series pointing at the address must
|
||||
// keep the bytes — reclaiming one Series' stranded artwork may not blank
|
||||
// another's.
|
||||
func TestReclaimCoverSparesReferencedAddress(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
const src = "https://cdn.example/covers/shared.jpg"
|
||||
addr := CoverAddressForBytes([]byte("shared-art"))
|
||||
if err := store.SetSeriesCover("asura", "solo", src, []byte("shared-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("put cover: %v", err)
|
||||
}
|
||||
|
||||
// One Series pointing at the address is enough for the guard.
|
||||
if err := store.ReclaimCover(addr); err != nil {
|
||||
t.Fatalf("ReclaimCover on a referenced address: %v", err)
|
||||
}
|
||||
if body, _, ok, err := store.CoverByAddress(addr); err != nil || !ok || string(body) != "shared-art" {
|
||||
t.Fatalf("bytes after no-op = found %v err %v, want still served", ok, err)
|
||||
}
|
||||
if _, err := os.Stat(coverShardPath(t, store, addr)); err != nil {
|
||||
t.Fatalf("sharded file after no-op: %v, want present", err)
|
||||
}
|
||||
|
||||
// A second Series serving identical bytes shares the row by construction.
|
||||
seedForCheck(t, store, "asura:second", "https://asurascans.com/comics/second", 0)
|
||||
if err := store.SetSeriesCover("asura", "second", src, []byte("shared-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("share cover: %v", err)
|
||||
}
|
||||
if err := store.ReclaimCover(addr); err != nil {
|
||||
t.Fatalf("ReclaimCover on a shared address: %v", err)
|
||||
}
|
||||
if body, _, ok, err := store.CoverByAddress(addr); err != nil || !ok || string(body) != "shared-art" {
|
||||
t.Fatalf("shared bytes after no-op = found %v err %v, want still served", ok, err)
|
||||
}
|
||||
if _, err := os.Stat(coverShardPath(t, store, addr)); err != nil {
|
||||
t.Fatalf("sharded file after shared no-op: %v, want present", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A blank address is the wire value for "no Cover" (ADR-0007), never a
|
||||
// reclaimable one.
|
||||
func TestReclaimCoverBlankAddressIsNoOp(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
if err := store.ReclaimCover(""); err != nil {
|
||||
t.Fatalf("ReclaimCover(\"\") = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An interrupted reclamation is the state the file-first order exists for:
|
||||
// the row is the handle, so the unreferenced-covers query finds the torn
|
||||
// Cover and re-running ReclaimCover finishes the job — a missing file is
|
||||
// "already gone", which counts as success.
|
||||
func TestReclaimCoverInterruptedRunIsFindableAndFinishes(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/torn.jpg", []byte("torn-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("put cover: %v", err)
|
||||
}
|
||||
torn := CoverAddressForBytes([]byte("torn-art"))
|
||||
if _, _, err := store.ReplaceSeriesCover("asura", "solo", "https://cdn.example/covers/new.jpg", []byte("new-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("replace cover: %v", err)
|
||||
}
|
||||
if err := os.Remove(coverShardPath(t, store, torn)); err != nil {
|
||||
t.Fatalf("unlink mid-reclamation: %v", err)
|
||||
}
|
||||
|
||||
var found string
|
||||
err := store.db.QueryRow(`
|
||||
SELECT address FROM covers c
|
||||
WHERE NOT EXISTS (SELECT 1 FROM series s WHERE s.cover_address = c.address)
|
||||
LIMIT 1`).Scan(&found)
|
||||
if err != nil || found != torn {
|
||||
t.Fatalf("unreferenced-covers query = (%q, %v), want the torn row %q", found, err, torn)
|
||||
}
|
||||
|
||||
if err := store.ReclaimCover(torn); err != nil {
|
||||
t.Fatalf("re-run over a missing file: %v", err)
|
||||
}
|
||||
if _, _, ok, err := store.CoverByAddress(torn); err != nil || ok {
|
||||
t.Fatalf("row after re-run = found %v err %v, want gone", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed file removal is the one state that is not self-cleaning: the
|
||||
// covers row must survive so a retry can finish the job, and the store
|
||||
// returns the error rather than logging — each caller logs and carries on,
|
||||
// so the failure has no user-facing surface.
|
||||
func TestReclaimCoverFailedUnlinkKeepsRow(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/stuck.jpg", []byte("stuck-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("put cover: %v", err)
|
||||
}
|
||||
stuck := CoverAddressForBytes([]byte("stuck-art"))
|
||||
if _, _, err := store.ReplaceSeriesCover("asura", "solo", "https://cdn.example/covers/other.jpg", []byte("other-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("replace cover: %v", err)
|
||||
}
|
||||
// Make the unlink fail: the sharded path becomes a non-empty directory,
|
||||
// which os.Remove refuses.
|
||||
shard := coverShardPath(t, store, stuck)
|
||||
if err := os.Remove(shard); err != nil {
|
||||
t.Fatalf("clear file: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(shard, 0o755); err != nil {
|
||||
t.Fatalf("replace file with dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(shard, "blob"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("fill dir: %v", err)
|
||||
}
|
||||
|
||||
if err := store.ReclaimCover(stuck); err == nil {
|
||||
t.Fatal("ReclaimCover over an unremovable file = nil, want the error")
|
||||
}
|
||||
var one int
|
||||
if err := store.db.QueryRow(`SELECT 1 FROM covers WHERE address = $1`, stuck).Scan(&one); err != nil {
|
||||
t.Fatal("covers row after failed unlink is gone; want it left for a retry")
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveSeries is the orphan removal (#155): one Series, one delete, refused
|
||||
// by the database while any Bookmark points at it. The store translates the
|
||||
// foreign-key violation into its own sentinel so no driver type escapes, and
|
||||
// the caller reaps the stranded Cover through ReclaimCover.
|
||||
func TestRemoveSeriesRemovesOrphanAndReclaimsCover(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
if err := store.Delete(store.OwnerID(), "asura:solo"); err != nil {
|
||||
t.Fatalf("orphan the series: %v", err)
|
||||
}
|
||||
if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/old.jpg", []byte("old-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("put cover: %v", err)
|
||||
}
|
||||
addr := CoverAddressForBytes([]byte("old-art"))
|
||||
|
||||
if err := store.RemoveSeries("asura", "solo"); err != nil {
|
||||
t.Fatalf("RemoveSeries: %v", err)
|
||||
}
|
||||
// The caller's sequence: the row is deleted first, then the address is
|
||||
// reclaimed — the guard cannot pass while the row still points at it.
|
||||
if err := store.ReclaimCover(addr); err != nil {
|
||||
t.Fatalf("ReclaimCover: %v", err)
|
||||
}
|
||||
var one int
|
||||
if err := store.db.QueryRow(`SELECT 1 FROM series WHERE site = $1 AND series_id = $2`, "asura", "solo").Scan(&one); err != sql.ErrNoRows {
|
||||
t.Fatalf("series row after remove = %v, want sql.ErrNoRows", err)
|
||||
}
|
||||
if _, _, ok, err := store.CoverByAddress(addr); err != nil || ok {
|
||||
t.Fatalf("covers row after remove = found %v err %v, want gone", ok, err)
|
||||
}
|
||||
if _, err := os.Stat(coverShardPath(t, store, addr)); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("sharded file after remove = %v, want fs.ErrNotExist", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The refusal is the whole point of the sentinel: a Series a Reader still
|
||||
// holds is not removed, its row is untouched and its Cover keeps serving.
|
||||
func TestRemoveSeriesRefusedWhileBookmarked(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/kept.jpg", []byte("kept-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("put cover: %v", err)
|
||||
}
|
||||
addr := CoverAddressForBytes([]byte("kept-art"))
|
||||
|
||||
if err := store.RemoveSeries("asura", "solo"); !errors.Is(err, ErrSeriesHasBookmarks) {
|
||||
t.Fatalf("RemoveSeries on a bookmarked series = %v, want ErrSeriesHasBookmarks", err)
|
||||
}
|
||||
var held int
|
||||
if err := store.db.QueryRow(`SELECT 1 FROM series WHERE site = $1 AND series_id = $2`, "asura", "solo").Scan(&held); err != nil {
|
||||
t.Fatal("series row after refusal is gone; want it untouched")
|
||||
}
|
||||
if body, _, ok, err := store.CoverByAddress(addr); err != nil || !ok || string(body) != "kept-art" {
|
||||
t.Fatalf("cover after refusal = found %v err %v, want still served", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A Series sharing its Cover address with a second Series is removed while
|
||||
// the artwork stays readable through CoverByAddress: the series row stops
|
||||
// referencing the address first, so ReclaimCover's guard passes for this
|
||||
// caller without touching the shared bytes (ADR-0014).
|
||||
func TestRemoveSeriesSparesSharedCover(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
seedForCheck(t, store, "asura:second", "https://asurascans.com/comics/second", 0)
|
||||
if err := store.Delete(store.OwnerID(), "asura:solo"); err != nil {
|
||||
t.Fatalf("orphan solo: %v", err)
|
||||
}
|
||||
if err := store.Delete(store.OwnerID(), "asura:second"); err != nil {
|
||||
t.Fatalf("orphan second: %v", err)
|
||||
}
|
||||
const src = "https://cdn.example/covers/shared.jpg"
|
||||
if err := store.SetSeriesCover("asura", "solo", src, []byte("shared-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("put cover on solo: %v", err)
|
||||
}
|
||||
if err := store.SetSeriesCover("asura", "second", src, []byte("shared-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("put cover on second: %v", err)
|
||||
}
|
||||
addr := CoverAddressForBytes([]byte("shared-art"))
|
||||
|
||||
if err := store.RemoveSeries("asura", "solo"); err != nil {
|
||||
t.Fatalf("RemoveSeries: %v", err)
|
||||
}
|
||||
// The guard spares the shared bytes even though this caller reclaims.
|
||||
if err := store.ReclaimCover(addr); err != nil {
|
||||
t.Fatalf("ReclaimCover over a shared address: %v", err)
|
||||
}
|
||||
if body, _, ok, err := store.CoverByAddress(addr); err != nil || !ok || string(body) != "shared-art" {
|
||||
t.Fatalf("shared bytes after remove = found %v err %v, want still served", ok, err)
|
||||
}
|
||||
if _, err := os.Stat(coverShardPath(t, store, addr)); err != nil {
|
||||
t.Fatalf("sharded file after remove: %v, want present", err)
|
||||
}
|
||||
var one int
|
||||
if err := store.db.QueryRow(`SELECT 1 FROM series WHERE site = $1 AND series_id = $2`, "asura", "second").Scan(&one); err != nil {
|
||||
t.Fatal("the second series row vanished with the first")
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting an absent key removes nothing and is not an error, matching the
|
||||
// Delete precedent — the handler's own lookups turn the absent case into the
|
||||
// 404 before the store ever sees it.
|
||||
func TestRemoveSeriesMissingKeyIsCleanNoOp(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
if err := store.RemoveSeries("asura", "ghost"); err != nil {
|
||||
t.Fatalf("RemoveSeries on a missing key = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,11 @@ func (h *Handler) adminRoutes() []adminRoute {
|
||||
{"GET /admin/series", h.adminSeries},
|
||||
{"GET /admin/series/{key}", h.adminSeriesDetail},
|
||||
{"POST /admin/series/{key}/poll", h.adminSeriesPoll},
|
||||
{"POST /admin/series/{key}/finish", h.adminSeriesFinish},
|
||||
{"POST /admin/series/{key}/unfinish", h.adminSeriesUnfinish},
|
||||
{"POST /admin/series/{key}/latest", h.adminSeriesCorrectLatest},
|
||||
{"POST /admin/series/{key}/series-url", h.adminSeriesSetURL},
|
||||
{"POST /admin/series/{key}/remove", h.adminSeriesRemove},
|
||||
{"POST /admin/lanes/{site}/pause", h.adminLanePause},
|
||||
{"POST /admin/lanes/{site}/resume", h.adminLaneResume},
|
||||
{"GET /ui/admin/lanes", h.uiLanes},
|
||||
|
||||
@@ -24,8 +24,9 @@ type overviewView struct {
|
||||
// its own filter, and the verdict wants the inclusive number.
|
||||
Unchecked int
|
||||
// Hygiene is the seven problem filters in the Series list's own render
|
||||
// order; Library is the library split plus the roster. Every figure is a
|
||||
// door into the list that counts it, except a zero.
|
||||
// order plus the finished figure riding last (informational); Library is
|
||||
// the library split plus the roster. Every figure is a door into the list
|
||||
// that counts it, except a zero.
|
||||
Hygiene []fig
|
||||
Library []fig
|
||||
// Sites is the per-Site library shape table, one row per Site with any
|
||||
@@ -59,7 +60,7 @@ type siteRow struct {
|
||||
}
|
||||
|
||||
// overviewView assembles the landing page from the store's read model: one
|
||||
// SeriesShapes pass per filter summed in Go (the shipped surface offers eight
|
||||
// SeriesShapes pass per filter summed in Go (the shipped surface offers nine
|
||||
// grouped passes, not a stats query — #140), the pass log's latest pass per
|
||||
// Site, and the roster. A failure in any read is a 500 with a logged reason,
|
||||
// never a page of silent zeroes.
|
||||
@@ -93,8 +94,10 @@ func (h *Handler) overviewView() (overviewView, error) {
|
||||
view.Unchecked = totals[store.SeriesFilterStale] + totals[store.SeriesFilterNeverChecked]
|
||||
view.Verdict, view.HasCounts = overviewVerdict(passes, now)
|
||||
|
||||
// The seven problem filters, in seriesFilterOrder's permanent-then-fixable
|
||||
// order; the All filter's count belongs to the Library block, not to a
|
||||
// The hygiene figures, in seriesFilterOrder's tail: the seven problem
|
||||
// filters in permanent-then-fixable order, then the finished figure last —
|
||||
// informational, not a problem, and last because seriesFilterOrder appends
|
||||
// it there. The All filter's count belongs to the Library block, not to a
|
||||
// "hygiene" figure.
|
||||
hygiene := make([]fig, 0, len(seriesFilterOrder)-1)
|
||||
for _, name := range seriesFilterOrder[1:] {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -18,10 +20,10 @@ import (
|
||||
// the wrong page. The store does not export it (#140).
|
||||
const seriesPageSize = 50
|
||||
|
||||
// seriesFilterLabels names every hygiene filter for the Series list select,
|
||||
// keyed by the wire constant the URL carries. The render order is
|
||||
// seriesFilterOrder; the labels are read by later admin tickets too, so the
|
||||
// map and the constants cannot drift apart.
|
||||
// seriesFilterLabels names every Series filter for the list select, keyed by
|
||||
// the wire constant the URL carries. The render order is seriesFilterOrder;
|
||||
// the labels are read by later admin tickets too, so the map and the
|
||||
// constants cannot drift apart.
|
||||
var seriesFilterLabels = map[string]string{
|
||||
store.SeriesFilterAll: "All series",
|
||||
store.SeriesFilterNoURL: "No series URL",
|
||||
@@ -31,10 +33,13 @@ var seriesFilterLabels = map[string]string{
|
||||
store.SeriesFilterStale: "Not checked in 12h",
|
||||
store.SeriesFilterNoCover: "No cover",
|
||||
store.SeriesFilterReaderReport: "Latest from a Reader",
|
||||
store.SeriesFilterFinished: "Finished",
|
||||
}
|
||||
|
||||
// seriesFilterOrder is the select's render order: All first, then the
|
||||
// permanent repairs, then the fixable ones (issue #140).
|
||||
// permanent repairs, then the fixable ones (issue #140). Finished rides the
|
||||
// tail, last — deliberate, not a repair — and the Overview's stats block
|
||||
// renders the same tail, which is what sits the finished figure last there.
|
||||
var seriesFilterOrder = []string{
|
||||
store.SeriesFilterAll,
|
||||
store.SeriesFilterNoURL,
|
||||
@@ -44,10 +49,10 @@ var seriesFilterOrder = []string{
|
||||
store.SeriesFilterStale,
|
||||
store.SeriesFilterNoCover,
|
||||
store.SeriesFilterReaderReport,
|
||||
store.SeriesFilterFinished,
|
||||
}
|
||||
|
||||
// seriesListView is the Series list page's data. The template renders strings
|
||||
// and flags, and every judgement about what a value means is made here.
|
||||
type seriesListView struct {
|
||||
Filters []seriesFilterOption
|
||||
Sites []string
|
||||
@@ -56,6 +61,9 @@ type seriesListView struct {
|
||||
FilterLabel string
|
||||
Rows []seriesRowView
|
||||
Total int
|
||||
// OOB marks the out-of-band copy of the heading the removal answer
|
||||
// carries; on the page itself it is false (issue #155).
|
||||
OOB bool
|
||||
// KindBoth / KindManga / KindNovel are the Library segment links, and
|
||||
// PrevHref / NextHref the pager's, all carrying the active filter, Site
|
||||
// and Kind so narrowing never drops state.
|
||||
@@ -100,6 +108,17 @@ type seriesRowView struct {
|
||||
CanPoll bool
|
||||
Pending bool
|
||||
Requested string // "requested 3m ago", rendered only while pending
|
||||
// CanRemove is the Remove control's visibility: only a Series no Reader
|
||||
// holds can be removed, so the owner is never offered a button that the
|
||||
// database will always refuse (issue #155). RemovalRefused marks the one
|
||||
// raced answer: the row stays and says a fresh Bookmark caught the press.
|
||||
CanRemove bool
|
||||
RemovalRefused bool
|
||||
// Finished is the row's display of the owner's finish stamp: the list row
|
||||
// shows the state and never offers the control — that lives on the detail
|
||||
// page, where a press that retires a Series from the Lane is on purpose
|
||||
// and confirm-gated (issue #158).
|
||||
Finished bool
|
||||
}
|
||||
|
||||
// adminSeries renders the filterable, bookmarkable Series list: filter, Site,
|
||||
@@ -172,6 +191,359 @@ func (h *Handler) adminSeriesPoll(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now()))
|
||||
}
|
||||
|
||||
// adminSeriesFinish is the owner's Finish control: it stamps the Series'
|
||||
// finished_at and answers with the freshly rendered meta fragment, so the
|
||||
// "finished <age> ago" line describes the state after the press. The Lane's
|
||||
// next pass reads the stamp and stops polling the Series (issue #157). The
|
||||
// owner gate is the route's, not this handler's; the body is capped like the
|
||||
// API path caps its bodies; the key is validated here — a malformed key is a
|
||||
// 400 and an unknown one a 404.
|
||||
func (h *Handler) adminSeriesFinish(w http.ResponseWriter, r *http.Request) {
|
||||
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
|
||||
if !ok || site == "" || seriesID == "" {
|
||||
http.Error(w, "bad series key", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil {
|
||||
log.Printf("series finish %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
} else if !found {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := h.store.SetSeriesFinished(site, seriesID, time.Now().UnixMilli()); err != nil {
|
||||
log.Printf("series finish %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Re-read after the stamp: the answer must describe the state after the
|
||||
// press, so the line reads "finished just now". The control's one caller
|
||||
// is the detail page, which swaps the meta fragment in place.
|
||||
a, found, err := h.adminSeriesByKey(site, seriesID)
|
||||
if err != nil {
|
||||
log.Printf("series finish %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a))
|
||||
}
|
||||
|
||||
// adminSeriesUnfinish is the reversal of the Finish control: it clears the
|
||||
// stamp (writes zero) and answers with the freshly rendered meta fragment, so
|
||||
// the Series is back in the Lane's queue from its next pass. Reversal, so it
|
||||
// fires instantly with no confirm row (issue #158).
|
||||
func (h *Handler) adminSeriesUnfinish(w http.ResponseWriter, r *http.Request) {
|
||||
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
|
||||
if !ok || site == "" || seriesID == "" {
|
||||
http.Error(w, "bad series key", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil {
|
||||
log.Printf("series unfinish %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
} else if !found {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := h.store.SetSeriesFinished(site, seriesID, 0); err != nil {
|
||||
log.Printf("series unfinish %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Re-read after the write: the answer must describe the state after the
|
||||
// press, so the fragment no longer carries the finished line.
|
||||
a, found, err := h.adminSeriesByKey(site, seriesID)
|
||||
if err != nil {
|
||||
log.Printf("series unfinish %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a))
|
||||
}
|
||||
|
||||
// adminSeriesCorrectLatest is the Latest Chapter correction: the owner types
|
||||
// one number and the Series' Latest Chapter becomes it, stamped as a
|
||||
// Correction. The number must be a finite float greater than zero — a
|
||||
// non-numeric, zero or negative value answers 400 and never reaches the
|
||||
// store, because a bad value would become every Reader's problem. The press
|
||||
// answers with the freshly rendered meta fragment, so the figures describe
|
||||
// the state after the press. The owner gate is the route's, not this
|
||||
// handler's; the body is capped like the API path caps its bodies.
|
||||
func (h *Handler) adminSeriesCorrectLatest(w http.ResponseWriter, r *http.Request) {
|
||||
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
|
||||
if !ok || site == "" || seriesID == "" {
|
||||
http.Error(w, "bad series key", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
num, err := strconv.ParseFloat(r.PostFormValue("chapter"), 64)
|
||||
if err != nil || math.IsNaN(num) || math.IsInf(num, 0) || num <= 0 {
|
||||
http.Error(w, "chapter must be a finite number greater than zero", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil {
|
||||
log.Printf("series correction %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
} else if !found {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := h.store.CorrectLatestChapter(site, seriesID, num, time.Now().UnixMilli()); err != nil {
|
||||
log.Printf("series correction %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Re-read after the write: the answer must describe the state after the
|
||||
// press, so the marker reads "corrected just now".
|
||||
a, found, err := h.adminSeriesByKey(site, seriesID)
|
||||
if err != nil {
|
||||
log.Printf("series correction %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a))
|
||||
}
|
||||
|
||||
// adminSeriesSetURL is the series URL repair: the owner types one address
|
||||
// and the Series' Poll fetches it from then on, verified by the same gate
|
||||
// the poller uses before it fetches anything — a URL failing
|
||||
// latest.FetchableSeriesURL answers 400 and never reaches the store. The
|
||||
// repair is a store, not a verification: it performs no outbound fetch, and
|
||||
// the owner presses Check now afterwards. This lifts the write-once rule of
|
||||
// Series.SeriesURL for the owner only — a Reader's PUT is still ignored. The
|
||||
// owner gate is the route's, not this handler's; the body is capped like the
|
||||
// API path caps its bodies; the key is validated here — a malformed key is a
|
||||
// 400 and an unknown one a 404.
|
||||
func (h *Handler) adminSeriesSetURL(w http.ResponseWriter, r *http.Request) {
|
||||
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
|
||||
if !ok || site == "" || seriesID == "" {
|
||||
http.Error(w, "bad series key", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
seriesURL := r.PostFormValue("series_url")
|
||||
if !latest.FetchableSeriesURL(site, seriesURL) {
|
||||
http.Error(w, "series URL must be an https address on this site's host", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil {
|
||||
log.Printf("series url %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
} else if !found {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := h.store.SetSeriesURL(site, seriesID, seriesURL); err != nil {
|
||||
log.Printf("series url %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Re-read after the write: the answer must describe the state after the
|
||||
// press, like the correction's answer does.
|
||||
a, found, err := h.adminSeriesByKey(site, seriesID)
|
||||
if err != nil {
|
||||
log.Printf("series url %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a))
|
||||
}
|
||||
|
||||
// seriesListHeadView is the list heading's data. The template renders it
|
||||
// inline at the top of the Series list and out of band in the removal answer
|
||||
// (OOB true, like the chrome partials' OOB flag): the count and the filter
|
||||
// label are one fact (issue #155).
|
||||
type seriesListHeadView struct {
|
||||
Total int
|
||||
FilterLabel string
|
||||
OOB bool
|
||||
}
|
||||
|
||||
// adminSeriesRemove is the orphan removal: one Series, one delete, refused by
|
||||
// the database while any Bookmark exists (translated by the store, never a
|
||||
// driver error on the page). The owner gate is the route's, not this
|
||||
// handler's; the body is capped like the API path caps its bodies; the key is
|
||||
// validated here — a malformed key is a 400 and an unknown one a 404.
|
||||
//
|
||||
// The Cover is read from the row before the delete and reclaimed after it:
|
||||
// ReclaimCover's guard cannot pass while a series row still points at the
|
||||
// address, so the order is the sequence, not a preference. A reclamation
|
||||
// failure is not a removal failure — the row is gone and the covers row
|
||||
// survives for a retry; the handler logs and answers success, because the
|
||||
// failure has no user-facing surface.
|
||||
//
|
||||
// Two callers, one handler, branched on HX-Target like adminSeriesPoll. The
|
||||
// detail page's remove answers with a navigation — to the No-Readers list on
|
||||
// success, back to the detail page when a fresh Bookmark raced the press,
|
||||
// where the new count is visible. The list row's answers with the removed
|
||||
// row's fragment and the heading re-rendered with the fresh count out of
|
||||
// band; HX-Reswap deletes the row through the same button that swaps the
|
||||
// refusal back in, and the count query runs over the press's own filter
|
||||
// state, so the heading describes the list the owner is looking at.
|
||||
func (h *Handler) adminSeriesRemove(w http.ResponseWriter, r *http.Request) {
|
||||
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
|
||||
if !ok || site == "" || seriesID == "" {
|
||||
http.Error(w, "bad series key", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// The row's Cover address is read before the delete because the delete is
|
||||
// what makes it reclaimable.
|
||||
a, found, err := h.adminSeriesByKey(site, seriesID)
|
||||
if err != nil {
|
||||
log.Printf("series remove %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
cover := a.CoverAddress
|
||||
if err := h.store.RemoveSeries(site, seriesID); err != nil {
|
||||
if errors.Is(err, store.ErrSeriesHasBookmarks) {
|
||||
// A Bookmark landed between the owner's read and the press: the
|
||||
// row stays, answered at its new count with the fact spelled
|
||||
// out — never a 500, and never a deleted row.
|
||||
if r.Header.Get("HX-Target") == "detail-meta" {
|
||||
seriesRemoveNavigation(w, r, "/admin/series/"+site+":"+seriesID)
|
||||
return
|
||||
}
|
||||
fresh, found, err := h.adminSeriesByKey(site, seriesID)
|
||||
if err != nil {
|
||||
log.Printf("series remove %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
// A second press removed it while this one was refused; the
|
||||
// row has nothing left to say.
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
band := 0
|
||||
if r.PostFormValue("band") == "1" {
|
||||
band = 1
|
||||
}
|
||||
row := seriesRow(fresh, band, time.Now())
|
||||
row.RemovalRefused = true
|
||||
h.render(w, http.StatusOK, "series-row", row)
|
||||
return
|
||||
}
|
||||
log.Printf("series remove %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := h.store.ReclaimCover(cover); err != nil {
|
||||
log.Printf("series remove %s: reclaim cover: %v", site+":"+seriesID, err)
|
||||
}
|
||||
if r.Header.Get("HX-Target") == "detail-meta" {
|
||||
seriesRemoveNavigation(w, r, "/admin/series?filter="+store.SeriesFilterNoReaders)
|
||||
return
|
||||
}
|
||||
// The list answer: the removed row's fragment, plus the heading
|
||||
// re-rendered with the fresh count. HX-Reswap deletes the row through the
|
||||
// same button that swaps the refusal back in. The count query failing
|
||||
// does not undo the removal — log it and answer the row alone.
|
||||
w.Header().Set("HX-Reswap", "delete")
|
||||
band := 0
|
||||
if r.PostFormValue("band") == "1" {
|
||||
band = 1
|
||||
}
|
||||
h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now()))
|
||||
if head, err := h.seriesListHeadView(r); err != nil {
|
||||
log.Printf("series remove %s: %v", site+":"+seriesID, err)
|
||||
} else {
|
||||
h.render(w, http.StatusOK, "series-list-head", head)
|
||||
}
|
||||
}
|
||||
|
||||
// seriesListHeadView is the list heading with the count as it stands after a
|
||||
// removal: the same filter, Site and Kind the press's row carried (the list
|
||||
// row's button hx-includes the filterbar), so the figure describes the list
|
||||
// the owner is looking at — the All filter and an unknown one stay the
|
||||
// absent case. The count is the store's window total, one query.
|
||||
func (h *Handler) seriesListHeadView(r *http.Request) (seriesListHeadView, error) {
|
||||
filter := r.PostFormValue("filter")
|
||||
if _, ok := seriesFilterLabels[filter]; !ok {
|
||||
filter = store.SeriesFilterAll
|
||||
}
|
||||
site := r.PostFormValue("site")
|
||||
kind := r.PostFormValue("kind")
|
||||
if kind != store.KindManga && kind != store.KindNovel {
|
||||
kind = ""
|
||||
}
|
||||
data, err := h.store.SeriesPage(store.SeriesFilter{
|
||||
Site: site,
|
||||
Kind: kind,
|
||||
Name: filter,
|
||||
Cutoff: time.Now().Add(-ownerWindow).UnixMilli(),
|
||||
Page: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return seriesListHeadView{}, err
|
||||
}
|
||||
return seriesListHeadView{
|
||||
Total: data.Total,
|
||||
FilterLabel: seriesFilterLabels[filter],
|
||||
OOB: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// seriesRemoveNavigation answers a removal from the detail page. htmx gets a
|
||||
// full navigation (HX-Redirect): a bare 303 would be followed by the request
|
||||
// and the landing page swapped into the press's target, so the header is the
|
||||
// redirect htmx can see; plain clients get the 303 the ticket names.
|
||||
func seriesRemoveNavigation(w http.ResponseWriter, r *http.Request, to string) {
|
||||
if r.Header.Get("HX-Request") != "" {
|
||||
w.Header().Set("HX-Redirect", to)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, to, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// seriesListView assembles one Series list view from the request's query
|
||||
// string. An unknown filter value is the absent All case, never an error: the
|
||||
// select's options are not the only way this URL can be reached.
|
||||
@@ -243,9 +615,9 @@ func (h *Handler) seriesListView(r *http.Request) (seriesListView, error) {
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// seriesFilterOptions renders every hygiene filter with its library-wide
|
||||
// count, one SeriesShapes pass per filter summed in Go — the shipped surface
|
||||
// offers eight grouped passes, not a single stats query (#140). The counts
|
||||
// seriesFilterOptions renders every filter with its library-wide count, one
|
||||
// SeriesShapes pass per filter summed in Go — the shipped surface offers nine
|
||||
// grouped passes, not a single stats query (#140). The counts
|
||||
// are library-wide because the select sits next to the Site narrowing and
|
||||
// must not shift as the owner narrows the list itself. Cutoff travels with
|
||||
// the stale filter, or its count would always be zero.
|
||||
@@ -296,8 +668,10 @@ func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView {
|
||||
Readers: a.ReaderCount,
|
||||
Band: i%2 == 1,
|
||||
CanPoll: canPoll,
|
||||
CanRemove: a.ReaderCount == 0,
|
||||
Pending: pending,
|
||||
Requested: requested,
|
||||
Finished: a.FinishedAt > 0,
|
||||
}
|
||||
if a.LatestChapterNum != nil {
|
||||
row.Ch = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64)
|
||||
|
||||
@@ -24,14 +24,26 @@ type seriesDetailView struct {
|
||||
Cover string
|
||||
Chapter string // Latest Chapter number, or "—" before the first capture
|
||||
Checked string // how long ago the poller last checked, or "never"
|
||||
// URL is the stored source address, prefilled into the repair input —
|
||||
// the one stored string this page renders back into a form (issue #151).
|
||||
URL string
|
||||
Readers int
|
||||
// Corrected is the correction marker's text, "" while no Correction
|
||||
// stands: "corrected <age> ago" — the copy that says the value is the
|
||||
// owner's, and it dies with the stamp (a machine write of the number).
|
||||
Corrected string
|
||||
|
||||
// Provenance is the actor class behind the current Chapter: "correction",
|
||||
// "sighting" or "machine read"; "" while the Series was never read, when
|
||||
// the line is not rendered. Derived from the same anonymous stamps the
|
||||
// marks above read — no Reader identity crosses here.
|
||||
Provenance string
|
||||
|
||||
// Marks, one per hygiene fact, rendered only while it holds.
|
||||
Unpollable bool // no SeriesURL to fetch
|
||||
NoCover bool
|
||||
Orphan bool // no Reader holds the Series
|
||||
SightingRaised bool // a Reader's Sighting set the Latest Chapter
|
||||
|
||||
// Poll is the Check now control and the pending marker (issue #146): the
|
||||
// same derivation and visibility as the list row. CanPoll is false on a
|
||||
// Series with no page to fetch and on an orphan; Pending is derived —
|
||||
@@ -40,6 +52,18 @@ type seriesDetailView struct {
|
||||
CanPoll bool
|
||||
Pending bool
|
||||
Requested string
|
||||
// CanRemove is the Remove control's visibility (issue #155): only a
|
||||
// Series no Reader holds can be removed, so the owner is never offered a
|
||||
// button the database will always refuse.
|
||||
CanRemove bool
|
||||
// Finished is the owner's finish stamp rendered for the control: while it
|
||||
// stands, the page offers the instant Un-finish, not the confirm-gated
|
||||
// Finish (issue #158).
|
||||
Finished bool
|
||||
// FinishedSince is the "finished <age> ago" line, "" while no finish
|
||||
// stands. It rides the meta fragment both presses swap, so the answer
|
||||
// itself shows how long the Series has been finished.
|
||||
FinishedSince string
|
||||
}
|
||||
|
||||
// adminSeriesDetail renders one Series' page, keyed by the composite
|
||||
@@ -103,12 +127,14 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
|
||||
Kind: a.Kind,
|
||||
Title: a.Title,
|
||||
Cover: h.store.CoverWireURL(a.CoverAddress),
|
||||
URL: a.SeriesURL,
|
||||
Readers: a.ReaderCount,
|
||||
Unpollable: a.SeriesURL == "",
|
||||
NoCover: a.CoverAddress == "",
|
||||
Orphan: a.ReaderCount == 0,
|
||||
SightingRaised: a.RaisedByReader,
|
||||
CanPoll: canPoll,
|
||||
CanRemove: a.ReaderCount == 0,
|
||||
Pending: pending,
|
||||
Requested: requested,
|
||||
}
|
||||
@@ -122,5 +148,46 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
|
||||
} else {
|
||||
v.Checked = since(time.Now(), time.UnixMilli(a.LatestCheckedAt))
|
||||
}
|
||||
v.Corrected = correctedAge(time.Now(), a.LatestCorrectedAt)
|
||||
v.Finished = a.FinishedAt != 0
|
||||
v.FinishedSince = finishedAge(time.Now(), a.FinishedAt)
|
||||
|
||||
// Provenance: the actor class behind the current number, evaluated in the
|
||||
// order the classes outrank one another — the owner's stamp, which a
|
||||
// Correction leaves standing and a machine write clears (issue #149); a
|
||||
// raising Reader, which a Correction drops; then any check stamp at all.
|
||||
// An Acquisition reads as a machine read because it stamps
|
||||
// latest_checked_at exactly as a Poll does, so the two are
|
||||
// indistinguishable the moment it finishes; telling them apart would need
|
||||
// the column this project declines to add (spec #135), and the one
|
||||
// actionable case — acquired once, never read again — is already the
|
||||
// unchecked filter.
|
||||
if a.LatestCorrectedAt != 0 {
|
||||
v.Provenance = "correction"
|
||||
} else if a.RaisedByReader {
|
||||
v.Provenance = "sighting"
|
||||
} else if a.LatestCheckedAt != 0 {
|
||||
v.Provenance = "machine read"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// correctedAge is the correction marker's text: "corrected <age> ago" while
|
||||
// the stamp is set, "" when zero — zero means never corrected, and the marker
|
||||
// must not read as history once a machine wrote the number.
|
||||
func correctedAge(now time.Time, at int64) string {
|
||||
if at == 0 {
|
||||
return ""
|
||||
}
|
||||
return "corrected " + since(now, time.UnixMilli(at))
|
||||
}
|
||||
|
||||
// finishedAge is the finish marker's text: "finished <age> ago" while the
|
||||
// stamp is set, "" when zero — zero means never finished, and the reversal
|
||||
// (un-finish) must not read as history after a press (issue #158).
|
||||
func finishedAge(now time.Time, at int64) string {
|
||||
if at == 0 {
|
||||
return ""
|
||||
}
|
||||
return "finished " + since(now, time.UnixMilli(at))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
)
|
||||
|
||||
// seriesDetailView derives the provenance line from the three stamps the
|
||||
// admin projection already carries: the correction stamp outranks a raising
|
||||
// Reader, which outranks a check stamp, and a Series with none of the three
|
||||
// renders no line at all — it was never read, and no actor class is true of
|
||||
// it. Acquisition stamps latest_checked_at exactly as a Poll does, so an
|
||||
// acquired value lands in the same "machine read" class (#152).
|
||||
func TestSeriesDetailViewProvenance(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
a store.AdminSeries
|
||||
want string
|
||||
}{
|
||||
{"correction stamp", store.AdminSeries{LatestCorrectedAt: 1}, "correction"},
|
||||
{"raising reader only", store.AdminSeries{RaisedByReader: true}, "sighting"},
|
||||
{"check stamp only", store.AdminSeries{LatestCheckedAt: 1}, "machine read"},
|
||||
{"correction outranks sighting", store.AdminSeries{LatestCorrectedAt: 1, RaisedByReader: true}, "correction"},
|
||||
{"none of the three", store.AdminSeries{}, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := (&Handler{}).seriesDetailView(tc.a).Provenance; got != tc.want {
|
||||
t.Fatalf("Provenance = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -653,6 +653,29 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* The finish confirm is the one calm row on the admin sheet: a reversible
|
||||
move wears the recessed ash, never the remove wash above, and its
|
||||
affirmative takes the sheet's patina accent (issue #158). */
|
||||
.admin-sheet .confirm-row.calm {
|
||||
background: var(--ash);
|
||||
}
|
||||
|
||||
.admin-sheet .confirm-row.calm span {
|
||||
color: var(--paper-dim);
|
||||
}
|
||||
|
||||
.admin-sheet .confirm-row.calm .go {
|
||||
background: var(--patina);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.admin-sheet .tbl.series .row-msg {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 6px;
|
||||
color: var(--danger-soft);
|
||||
font: 400 13px/1.4 var(--font-body);
|
||||
}
|
||||
|
||||
.admin-sheet .detail-back {
|
||||
display: inline-block;
|
||||
margin: 18px 0 0;
|
||||
@@ -720,6 +743,27 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.admin-sheet .dform input {
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--field-line);
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
font: 500 14px var(--font-mono);
|
||||
outline: none;
|
||||
}
|
||||
/* Focus follows the chapter form's idiom — paper, not heat: a red border on
|
||||
a valid number field reads as "invalid". */
|
||||
.admin-sheet .dform input:focus {
|
||||
border-color: var(--paper);
|
||||
}
|
||||
.admin-sheet .dform .hint {
|
||||
margin: 0;
|
||||
color: var(--mute-2);
|
||||
font: 500 12px/1.4 var(--font-mono);
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
.admin-sheet .pausebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -61,7 +61,7 @@ function setActiveTab(el) {
|
||||
document.dispatchEvent(new Event("bmgr:refilter"));
|
||||
}
|
||||
|
||||
// The chapter-edit form and the archive/finish/remove confirm rows are the
|
||||
// The chapter-edit form and the archive/remove confirm rows are the
|
||||
// per-card disclosure panels; only one makes sense open at a time. The button
|
||||
// that owns an open panel carries .open, which is how the strip shows which
|
||||
// cell the panel belongs to.
|
||||
@@ -97,10 +97,10 @@ function toggleChapterForm(key) {
|
||||
if (form && !form.hidden) form.querySelector("input").focus();
|
||||
}
|
||||
|
||||
// kind is "archive" | "finish" | "remove" — the panel id and the owning action
|
||||
// kind is "archive" | "remove" — the panel id and the owning action
|
||||
// cell share it.
|
||||
function toggleConfirmRow(key, kind) {
|
||||
var cls = { archive: ".box", finish: ".finish", remove: ".remove" }[kind];
|
||||
var cls = { archive: ".box", remove: ".remove" }[kind];
|
||||
var row = togglePanel(key, "confirm-" + kind + "-" + key, ".actions " + cls);
|
||||
// Focus the answer rather than trusting aria-live on a container that merely
|
||||
// unhides: it makes the announcement deterministic, keeps tab order inside
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
|
||||
--ink: #100f0e; /* page */
|
||||
--ash: #161413; /* recessed panel (chapter form) */
|
||||
--dim: #0d0c0b; /* archived / finished rows sink */
|
||||
--dim: #0d0c0b; /* archived rows sink */
|
||||
--rule: #221f1d; /* hairline between sheets */
|
||||
--rule-soft: #1a1817; /* measure edges */
|
||||
--field-line: #2c2926;
|
||||
@@ -550,6 +550,7 @@ button { cursor: pointer; }
|
||||
.new-chapter { color: var(--ember); }
|
||||
.state { display: flex; align-items: center; gap: 4px; color: var(--mute); }
|
||||
.state svg { width: 10px; height: 10px; }
|
||||
.state.finished { color: var(--moss); }
|
||||
.is-dim .meta { color: var(--mute-2); }
|
||||
.is-dim .site-asura, .is-dim .site-demonic,
|
||||
.is-dim .site-comix, .is-dim .site-kagane,
|
||||
@@ -612,13 +613,13 @@ button { cursor: pointer; }
|
||||
.actions > *:last-child { border-right: none; }
|
||||
.actions svg { width: 17px; height: 17px; }
|
||||
.actions > *:hover { color: var(--paper); }
|
||||
/* Per-action accent on hover and press: gold favourite, slate archive, moss
|
||||
finished, clay chapter. Remove keeps --danger, play keeps paper/ember. */
|
||||
/* Per-action accent on hover and press: gold favourite, slate archive, clay
|
||||
chapter. Remove keeps --danger, play keeps paper/ember. */
|
||||
.actions .fav:hover, .actions .fav:active, .actions .fav:focus-visible { color: var(--brass); }
|
||||
.actions .pencil:hover, .actions .pencil:active, .actions .pencil:focus-visible { color: var(--clay); }
|
||||
.actions .box:hover, .actions .box:active, .actions .box:focus-visible { color: var(--slate); }
|
||||
.actions .finish:hover, .actions .finish:active, .actions .finish:focus-visible { color: var(--moss); }
|
||||
.actions .play { color: var(--paper); }
|
||||
|
||||
.is-new .actions .play { color: var(--ember); }
|
||||
.actions .play:hover { background: var(--hover); }
|
||||
.actions .on { color: var(--brass); }
|
||||
@@ -632,8 +633,8 @@ button { cursor: pointer; }
|
||||
.actions .remove.open { background: var(--danger-wash); color: var(--danger); }
|
||||
.is-dim .actions > * { color: var(--mute-2); }
|
||||
|
||||
/* Three clusters by consequence: navigate (play) | organize (favourite,
|
||||
chapter) | lifecycle (archive/restore, finish, remove). The lifecycle cells
|
||||
/* Two clusters by consequence: navigate (play) | organize (favourite,
|
||||
chapter) | lifecycle (archive/restore, remove). The lifecycle cells
|
||||
sit on a recessed ground so the thumb reads "this one moves the series"
|
||||
before it reads which icon it landed on. */
|
||||
.actions > .lifecycle { background: var(--ash); }
|
||||
@@ -712,7 +713,7 @@ button { cursor: pointer; }
|
||||
color: var(--danger-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Archive and finish are reversible, so their confirm asks in grey — only the
|
||||
/* Archive is reversible, so its confirm asks in grey — only the
|
||||
irreversible remove gets the danger wash. */
|
||||
.confirm-row.calm { background: var(--ash); }
|
||||
.confirm-row.calm span { color: var(--paper-dim); }
|
||||
@@ -917,9 +918,7 @@ button { cursor: pointer; }
|
||||
/* The cell border follows the icon on hover, so the accent reads as a state
|
||||
rather than a stray colour. */
|
||||
.actions .fav:hover, .actions .pencil:hover,
|
||||
.actions .box:hover, .actions .finish:hover { border-color: currentColor; }
|
||||
|
||||
/* Panels line up with the body text, i.e. past the cover and its gap. */
|
||||
.actions .box:hover { border-color: currentColor; }
|
||||
.chapter-form, .confirm-row, .error-inline {
|
||||
margin-left: calc(var(--cover-w) + var(--row-gap));
|
||||
}
|
||||
|
||||
@@ -71,10 +71,6 @@
|
||||
{{if eq .Tab "archived"}}aria-current="page"{{end}}
|
||||
hx-get="{{.ListURL "archived"}}" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="{{.PageURL "archived"}}" hx-on::after-request="setActiveTab(this)">Archived</a>
|
||||
<a href="{{.PageURL "finished"}}" class="{{if eq .Tab "finished"}}active{{end}}"
|
||||
{{if eq .Tab "finished"}}aria-current="page"{{end}}
|
||||
hx-get="{{.ListURL "finished"}}" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="{{.PageURL "finished"}}" hx-on::after-request="setActiveTab(this)">Finished</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{{define "card"}}
|
||||
{{/* One sheet per series. is-new turns the title crimson over an ember rule;
|
||||
is-dim sinks archived and finished rows into italic grey. */}}
|
||||
is-dim sinks archived rows into italic grey. */}}
|
||||
<article class="card{{if eq .Status "reading"}}{{if .HasNewChapter}} is-new{{end}}{{else}} is-dim{{end}}"
|
||||
id="card-{{.Key}}" data-title="{{.Title}}">
|
||||
<div class="row">
|
||||
@@ -32,9 +32,10 @@
|
||||
{{if eq .Status "archived"}}
|
||||
<span class="sep">/</span>
|
||||
<span class="state">archived</span>
|
||||
{{else if eq .Status "finished"}}
|
||||
{{end}}
|
||||
{{if .Finished}}
|
||||
<span class="sep">/</span>
|
||||
<span class="state"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-check"/></svg>finished</span>
|
||||
<span class="state finished">finished</span>
|
||||
{{end}}
|
||||
</p>
|
||||
</div>
|
||||
@@ -56,7 +57,7 @@
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg>
|
||||
</button>
|
||||
{{/* Restore is a reversal, so it fires straight away; every move *out* of
|
||||
the list (archive, finish, remove) goes through a confirm row. */}}
|
||||
the list (archive, remove) goes through a confirm row. */}}
|
||||
{{if eq .Status "reading"}}
|
||||
<button class="lifecycle box" title="Archive" aria-label="Archive"
|
||||
aria-expanded="false" aria-controls="confirm-archive-{{.Key}}"
|
||||
@@ -71,13 +72,6 @@
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg>
|
||||
</button>
|
||||
{{end}}
|
||||
{{if ne .Status "finished"}}
|
||||
<button class="lifecycle finish" title="Mark finished" aria-label="Mark finished"
|
||||
aria-expanded="false" aria-controls="confirm-finish-{{.Key}}"
|
||||
onclick="toggleConfirmRow('{{.Key}}', 'finish')">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-check"/></svg>
|
||||
</button>
|
||||
{{end}}
|
||||
<button class="lifecycle remove" title="Remove" aria-label="Remove"
|
||||
aria-expanded="false" aria-controls="confirm-remove-{{.Key}}"
|
||||
onclick="toggleConfirmRow('{{.Key}}', 'remove')">
|
||||
@@ -116,18 +110,6 @@
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if ne .Status "finished"}}
|
||||
<div class="confirm-row calm" id="confirm-finish-{{.Key}}" role="group" aria-live="polite" hidden>
|
||||
<span>Mark finished?</span>
|
||||
<div>
|
||||
<button class="go"
|
||||
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"finished"}'
|
||||
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
|
||||
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">Finish</button>
|
||||
<button type="button" onclick="toggleConfirmRow('{{.Key}}', 'finish')">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="confirm-row" id="confirm-remove-{{.Key}}" role="group" aria-live="polite" hidden>
|
||||
<span>Remove “{{.Title}}”? Chapter progress is lost.</span>
|
||||
<div>
|
||||
|
||||
@@ -29,21 +29,17 @@
|
||||
|
||||
{{/* The action key. The icon strip on a card is unlabelled, so one permanent
|
||||
line under the tabs names every glyph. It follows the tab rather than the
|
||||
row: the archived and finished buckets swap Archive for Restore, and a
|
||||
finished series has no Done to offer. */}}
|
||||
row: the archived bucket swaps Archive for Restore. */}}
|
||||
{{define "keyrow"}}
|
||||
<div class="keyrow" id="keyrow" aria-label="Action key"{{if .OOB}} hx-swap-oob="true"{{end}}>
|
||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-play"/></svg><span>Read</span></span>
|
||||
<span class="pair brass"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-star"/></svg><span>Fav</span></span>
|
||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg><span>Chapter</span></span>
|
||||
{{if or (eq .Tab "archived") (eq .Tab "finished")}}
|
||||
{{if eq .Tab "archived"}}
|
||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg><span>Restore</span></span>
|
||||
{{else}}
|
||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-box"/></svg><span>Archive</span></span>
|
||||
{{end}}
|
||||
{{if ne .Tab "finished"}}
|
||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-check"/></svg><span>Done</span></span>
|
||||
{{end}}
|
||||
<span class="pair trash"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-trash"/></svg><span>Delete</span></span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
<symbol id="i-star-on" viewBox="0 0 24 24"><path d="M12 3.6l2.6 5.6 6 .8-4.4 4.2 1.1 6-5.3-2.9-5.3 2.9 1.1-6-4.4-4.2 6-.8z" fill="currentColor" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-pencil" viewBox="0 0 24 24"><path d="M4 20h4L19 9l-4-4L4 16z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-box" viewBox="0 0 24 24"><path d="M3 7h18v4H3zM5 11v9h14v-9M10 15h4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24"><path d="M4 12.5l5.2 5.5L20 6.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M4 7h16M9.5 7V4h5v3M6.5 7l1 13h9l1-13M10.5 11v6M13.5 11v6" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
|
||||
<symbol id="i-undo" viewBox="0 0 24 24"><path d="M4 9h9.5a5 5 0 010 10H8M4 9l4.2-4.2M4 9l4.2 4.2" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M15.3 15.3L20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
<div class="empty"><strong>Nothing new.</strong><p>Every series is caught up to its latest chapter.</p></div>
|
||||
{{else if eq .Tab "archived"}}
|
||||
<div class="empty"><strong>Nothing archived.</strong><p>Shelve a series to park it here — it keeps getting checked for new chapters.</p></div>
|
||||
{{else if eq .Tab "finished"}}
|
||||
<div class="empty"><strong>Nothing finished yet.</strong><p>Mark a series finished and it moves out of your reading list.</p></div>
|
||||
{{else if .EmptyLibrary}}
|
||||
{{/* Nothing in either library, so the links are the only thing this page can
|
||||
usefully say. Both scripts: the two libraries are separate installs. */}}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{{/* Per-Series page: one address per Series, keyed "<site>:<series_id>" so the
|
||||
list row is one hop from it. Everything here is a Series-level fact plus
|
||||
the anonymous Reader count. The Check now control lands in its own .dform
|
||||
below the (empty) .detail-grid; the pending marker rides the meta line
|
||||
with the other marks. */}}
|
||||
the anonymous Reader count. Check now lands in its own .dform below the
|
||||
.detail-grid; the correction form is the grid's first column and the URL
|
||||
repair the second (issue #151). The pending and corrected markers ride the
|
||||
meta line with the other marks. */}}
|
||||
{{define "series-detail"}}
|
||||
<a class="ghost detail-back" href="/admin/series">← Series</a>
|
||||
<h1 class="detail-title">{{.Title}}</h1>
|
||||
@@ -10,26 +11,69 @@
|
||||
{{if .Cover}}<div class="cover"><img src="{{.Cover}}" alt="" loading="lazy"></div>
|
||||
{{else}}<div class="cover"></div>{{end}}
|
||||
{{template "series-detail-meta" .}}
|
||||
<div class="detail-grid"></div>
|
||||
<div class="detail-grid">
|
||||
<form class="dform" hx-post="/admin/series/{{.Key}}/latest" hx-target="#detail-meta" hx-swap="outerHTML">
|
||||
<h3>Correct latest chapter</h3>
|
||||
<p class="hint">The next successful Poll overwrites this value.</p>
|
||||
<div class="field">
|
||||
<input type="number" name="chapter" step="any" placeholder="{{.Chapter}}" required>
|
||||
<button type="submit" class="ghost">Set</button>
|
||||
</div>
|
||||
</form>
|
||||
<form class="dform" hx-post="/admin/series/{{.Key}}/series-url" hx-target="#detail-meta" hx-swap="outerHTML">
|
||||
<h3>Repair series URL</h3>
|
||||
<p class="hint">The Poll fetches this address — storing is not verifying it. A Site-wide host change is a SQL migration, not two hundred forms.</p>
|
||||
<div class="field">
|
||||
<input type="url" name="series_url" value="{{.URL}}" required>
|
||||
<button type="submit" class="ghost">Set</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{if .CanPoll}}
|
||||
<div class="dform">
|
||||
<div class="field"><a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="#detail-meta" hx-swap="outerHTML" href="#">Check now</a></div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .CanRemove}}
|
||||
<div class="dform">
|
||||
<div class="field"><button class="ghost danger" hx-post="/admin/series/{{.Key}}/remove" hx-target="#detail-meta" hx-confirm="Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row.">Remove</button></div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Finished}}
|
||||
<div class="dform">
|
||||
<div class="field"><button type="button" class="ghost" hx-post="/admin/series/{{.Key}}/unfinish" hx-target="#detail-meta" hx-swap="outerHTML">Un-finish</button></div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="dform">
|
||||
<div class="field"><button type="button" class="ghost" onclick="document.getElementById('confirm-finish').hidden = false">Finish</button></div>
|
||||
<div class="confirm-row calm" id="confirm-finish" role="group" aria-live="polite" hidden>
|
||||
<span>Mark this Series finished?</span>
|
||||
<div>
|
||||
<button type="button" class="go" hx-post="/admin/series/{{.Key}}/finish" hx-target="#detail-meta" hx-swap="outerHTML">Finish</button>
|
||||
<button type="button" onclick="document.getElementById('confirm-finish').hidden = true">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{/* series-detail-meta is the meta line, and the answer a Check now press on
|
||||
the detail page swaps into its place: the same marks, re-rendered after
|
||||
the stamp so the pending marker shows. */}}
|
||||
{{/* series-detail-meta is the meta line, and the answer a Check now or
|
||||
correction press on the detail page swaps into its place: the same marks,
|
||||
re-rendered after the stamp so the pending and corrected markers — and
|
||||
the provenance line beside the number — describe the value they sit
|
||||
next to. */}}
|
||||
{{define "series-detail-meta"}}
|
||||
<div class="detail-meta" id="detail-meta">
|
||||
<span>ch {{.Chapter}}</span>
|
||||
{{if .Provenance}}<span>{{.Provenance}}</span>{{end}}
|
||||
<span>checked {{.Checked}}</span>
|
||||
<span>{{.Readers}} readers</span>
|
||||
{{if .Corrected}}<span class="mark">{{.Corrected}}</span>{{end}}
|
||||
{{if .Pending}}<span class="mark">{{.Requested}}</span>{{end}}
|
||||
{{if .FinishedSince}}<span class="mark">{{.FinishedSince}}</span>{{end}}
|
||||
{{if .Unpollable}}<span class="mark">unpollable</span>{{end}}
|
||||
{{if .NoCover}}<span class="mark">no cover</span>{{end}}
|
||||
{{if .Orphan}}<span class="mark">orphan</span>{{end}}
|
||||
{{if .SightingRaised}}<span class="mark">sighting-raised</span>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -4,7 +4,7 @@
|
||||
that can be bookmarked: the two selects submit the GET form, and the
|
||||
Library segment links and the pager preserve the filter and Site. */}}
|
||||
{{define "series-list"}}
|
||||
<form class="filterbar" method="get" action="/admin/series">
|
||||
<form class="filterbar" id="filterbar" method="get" action="/admin/series">
|
||||
<input type="hidden" name="kind" value="{{.Kind}}">
|
||||
<label class="fsel"><span>Show</span><select name="filter" onchange="this.form.submit()">
|
||||
{{range .Filters}}<option value="{{.Name}}"{{if .Selected}} selected{{end}}>{{.Label}} ({{.Count}})</option>{{end}}
|
||||
@@ -19,7 +19,7 @@
|
||||
<a href="{{.KindNovel}}"{{if eq .Kind "novel"}} class="active"{{end}}>novels</a>
|
||||
</span>
|
||||
</form>
|
||||
<div class="listhead">{{.Total}} series <span class="lbl">· <em>{{.FilterLabel}}</em></span></div>
|
||||
{{template "series-list-head" .}}
|
||||
{{if .Rows}}
|
||||
<div class="tbl series">
|
||||
<div class="thead"><span>Site</span><span class="c-ch">Ch</span><span>Checked</span><span class="c-rd">Readers</span><span>Notes</span><span></span></div>
|
||||
@@ -47,7 +47,17 @@
|
||||
<span class="c-ch">{{.Ch}}</span>
|
||||
<span>{{.Age}}</span>
|
||||
<span class="c-rd">{{.Readers}}</span>
|
||||
<span class="c-note">{{range .Notes}}<span class="mark">{{.}}</span>{{end}}{{if .More}}<span class="mark mark-faint">+{{.More}}</span>{{end}}</span>
|
||||
<span class="c-act">{{if .CanPoll}}<a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="closest .trow" hx-swap="outerHTML" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' href="#">Check now</a>{{end}}</span>
|
||||
<span class="c-note">{{range .Notes}}<span class="mark">{{.}}</span>{{end}}{{if .More}}<span class="mark mark-faint">+{{.More}}</span>{{end}}{{if .Finished}}<span class="mark mark-faint">finished</span>{{end}}</span>
|
||||
<span class="c-act">{{if .CanPoll}}<a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="closest .trow" hx-swap="outerHTML" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' href="#">Check now</a>{{end}}{{if .CanRemove}}<button class="ghost danger" hx-post="/admin/series/{{.Key}}/remove" hx-target="closest .trow" hx-swap="outerHTML" hx-include="#filterbar" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' hx-confirm="Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row.">Remove</button>{{end}}</span>
|
||||
{{if .RemovalRefused}}<span class="row-msg">a Reader has bookmarked this Series again</span>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* series-list-head is the list's heading — the count and the label are one
|
||||
fact. The removal answer renders it out of band (the OOB flag, like the
|
||||
chrome partials) so the heading never lies past the row that made it,
|
||||
and inline here it is the page's own heading. The id is the OOB swap's
|
||||
hook; hx-swap-oob sits on the element the answer carries. */}}
|
||||
{{define "series-list-head"}}
|
||||
<div class="listhead" id="series-listhead"{{if .OOB}} hx-swap-oob="true"{{end}}>{{.Total}} series <span class="lbl">· <em>{{.FilterLabel}}</em></span></div>
|
||||
{{end}}
|
||||
|
||||
+10
-11
@@ -293,10 +293,11 @@ func libOf(q string) string {
|
||||
// buildListView loads one reader's list once and derives both the tab-filtered
|
||||
// items and the recent strip from it.
|
||||
//
|
||||
// Archived and finished series appear in their own tab and nowhere else — not
|
||||
// in All, not in Updated, not in Favourites, and not in the recent strip. An
|
||||
// archived favourite therefore shows only under Archived: Favourites means
|
||||
// "favourites I am currently reading".
|
||||
// Archived series appear in their own tab and nowhere else — not in All, not
|
||||
// in Updated, not in Favourites, and not in the recent strip. An archived
|
||||
// favourite therefore shows only under Archived: Favourites means "favourites
|
||||
// I am currently reading". There is no Finished tab: finished is a fact about
|
||||
// the Series, not a bookmark bucket (issue #157).
|
||||
func (h *Handler) buildListView(readerID int64, lib, tab string) (listView, error) {
|
||||
all, err := h.store.List(readerID) // already ordered updated_at DESC
|
||||
if err != nil {
|
||||
@@ -327,8 +328,6 @@ func (h *Handler) buildListView(readerID int64, lib, tab string) (listView, erro
|
||||
items = withNew
|
||||
case "archived":
|
||||
items = filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusArchived })
|
||||
case "finished":
|
||||
items = filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusFinished })
|
||||
default:
|
||||
tab = "all"
|
||||
items = reading
|
||||
@@ -392,7 +391,7 @@ func currentLib(r *http.Request) string {
|
||||
// writeChromeOOB appends the regions that live outside #list — the recent
|
||||
// strip, the Updated badge and the action key — as out-of-band swaps, so a
|
||||
// mutation cannot leave them describing the library as it was before the tap.
|
||||
// The key is in here because it is tab-shaped too: archived and finished swap
|
||||
// The key is in here because it is tab-shaped too: the archived bucket swaps
|
||||
// Archive for Restore.
|
||||
func (h *Handler) writeChromeOOB(w http.ResponseWriter, view listView) {
|
||||
view.OOB = true
|
||||
@@ -492,9 +491,9 @@ func (h *Handler) uiFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
h.saveAndRenderCard(w, r, b)
|
||||
}
|
||||
|
||||
// uiStatus moves a bookmark between lifecycle buckets. This is the only place
|
||||
// a series can be marked finished — the JSON API refuses that value, so the
|
||||
// userscript cannot set it even by accident.
|
||||
// uiStatus moves a bookmark between the two lifecycle buckets. Finished is not
|
||||
// one of them: it is a fact about the Series, decided from the admin surface,
|
||||
// so the web UI's per-bookmark control cannot set it (issue #157).
|
||||
//
|
||||
// last_chapter_num is untouched, so Upsert keeps the stored updated_at and the
|
||||
// list does not reorder.
|
||||
@@ -508,7 +507,7 @@ func (h *Handler) uiStatus(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
switch s := r.PostFormValue("status"); s {
|
||||
case store.StatusReading, store.StatusArchived, store.StatusFinished:
|
||||
case store.StatusReading, store.StatusArchived:
|
||||
b.Status = s
|
||||
default:
|
||||
http.Error(w, "invalid status", http.StatusBadRequest)
|
||||
|
||||
@@ -78,7 +78,7 @@ func TestPutStatusValidation(t *testing.T) {
|
||||
{"empty is no opinion", "", http.StatusOK},
|
||||
{"reading", "reading", http.StatusOK},
|
||||
{"archived", "archived", http.StatusOK},
|
||||
{"finished is web-only", "finished", http.StatusBadRequest},
|
||||
{"finished is no longer a bucket", "finished", http.StatusBadRequest},
|
||||
{"garbage", "dropped", http.StatusBadRequest},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
|
||||
+826
-29
@@ -1523,8 +1523,8 @@ func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
|
||||
body := rr.Body.String()
|
||||
|
||||
want := `hx-target="[id='card-asura:solo']"`
|
||||
if strings.Count(body, want) != 5 {
|
||||
t.Fatalf("body has %d occurrences of %s, want 5 (favorite, archive, finish, delete buttons, chapter form)",
|
||||
if strings.Count(body, want) != 4 {
|
||||
t.Fatalf("body has %d occurrences of %s, want 4 (favorite, archive, delete buttons, chapter form)",
|
||||
strings.Count(body, want), want)
|
||||
}
|
||||
if strings.Contains(body, `hx-target="#card-asura:solo"`) {
|
||||
@@ -1748,8 +1748,6 @@ func seedStatusRows(t *testing.T, st *store.Store) {
|
||||
{Key: "asura:archived", Site: "asura", SeriesID: "archived", Title: "ArchivedOne",
|
||||
Status: store.StatusArchived, LastChapterNum: 5, Favorite: true,
|
||||
LatestChapter: "99", LatestChapterNum: floatPtr(99)},
|
||||
{Key: "asura:finished", Site: "asura", SeriesID: "finished", Title: "FinishedOne",
|
||||
Status: store.StatusFinished, LastChapterNum: 200, Favorite: true},
|
||||
}
|
||||
for _, b := range rows {
|
||||
b.UpdatedAt = time.Now().UnixMilli()
|
||||
@@ -1758,7 +1756,6 @@ func seedStatusRows(t *testing.T, st *store.Store) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTabsShowOnlyTheirBucket(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
@@ -1768,11 +1765,10 @@ func TestTabsShowOnlyTheirBucket(t *testing.T) {
|
||||
tab string
|
||||
want, dontWant []string
|
||||
}{
|
||||
{"all", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
|
||||
{"new", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
|
||||
{"fav", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
|
||||
{"archived", []string{"ArchivedOne"}, []string{"ReadingOne", "FinishedOne"}},
|
||||
{"finished", []string{"FinishedOne"}, []string{"ReadingOne", "ArchivedOne"}},
|
||||
{"all", []string{"ReadingOne"}, []string{"ArchivedOne"}},
|
||||
{"new", []string{"ReadingOne"}, []string{"ArchivedOne"}},
|
||||
{"fav", []string{"ReadingOne"}, []string{"ArchivedOne"}},
|
||||
{"archived", []string{"ArchivedOne"}, []string{"ReadingOne"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.tab, func(t *testing.T) {
|
||||
@@ -1835,12 +1831,12 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
|
||||
if !strings.Contains(strip, "ReadingOne") {
|
||||
t.Fatal("strip dropped the series with an unread chapter")
|
||||
}
|
||||
for _, unwanted := range []string{"CaughtUpOne", "ArchivedOne", "FinishedOne"} {
|
||||
for _, unwanted := range []string{"CaughtUpOne", "ArchivedOne"} {
|
||||
if strings.Contains(strip, unwanted) {
|
||||
t.Fatalf("strip included %s", unwanted)
|
||||
}
|
||||
}
|
||||
for _, tab := range []string{"new", "fav", "archived", "finished"} {
|
||||
for _, tab := range []string{"new", "fav", "archived"} {
|
||||
if strings.Contains(stripOf(t, srv, st, tab), "ReadingOne") {
|
||||
t.Fatalf("tab %s rendered the strip", tab)
|
||||
}
|
||||
@@ -1903,7 +1899,7 @@ func TestUIStatusSetsBucket(t *testing.T) {
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, st)
|
||||
|
||||
for _, want := range []string{store.StatusArchived, store.StatusFinished, store.StatusReading} {
|
||||
for _, want := range []string{store.StatusArchived, store.StatusReading} {
|
||||
if rr := postStatus(t, srv, st, "asura:reading", want); rr.Code != http.StatusOK {
|
||||
t.Fatalf("set %s: status = %d, body %s", want, rr.Code, rr.Body.String())
|
||||
}
|
||||
@@ -1971,12 +1967,11 @@ func TestCardShowsStatusControls(t *testing.T) {
|
||||
tab string
|
||||
want, dontWant []string
|
||||
}{
|
||||
// A series being read can be shelved or completed, not restored.
|
||||
{"all", []string{`hx-vals='{"status":"archived"}'`, `hx-vals='{"status":"finished"}'`}, nil},
|
||||
// An archived one can come back or be completed.
|
||||
{"archived", []string{`hx-vals='{"status":"reading"}'`, `hx-vals='{"status":"finished"}'`}, nil},
|
||||
// A finished one can only come back.
|
||||
{"finished", []string{`hx-vals='{"status":"reading"}'`}, []string{`hx-vals='{"status":"finished"}'`}},
|
||||
// A series being read can be shelved, not restored, and there is no
|
||||
// finish control any more (issue #157).
|
||||
{"all", []string{`hx-vals='{"status":"archived"}'`}, []string{`hx-vals='{"status":"finished"}'`}},
|
||||
// An archived one can come back.
|
||||
{"archived", []string{`hx-vals='{"status":"reading"}'`}, []string{`hx-vals='{"status":"finished"}'`}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.tab, func(t *testing.T) {
|
||||
@@ -2010,11 +2005,14 @@ func TestAppRendersNewTabs(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
|
||||
for _, want := range []string{`href="/?tab=archived"`, `href="/?tab=finished"`} {
|
||||
for _, want := range []string{`href="/?tab=archived"`} {
|
||||
if !strings.Contains(rr.Body.String(), want) {
|
||||
t.Fatalf("app page missing %s", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(rr.Body.String(), `href="/?tab=finished"`) {
|
||||
t.Fatal("app page still offers a Finished tab")
|
||||
}
|
||||
}
|
||||
|
||||
// A mutation has to bring the chrome with it: the strip and the badge live
|
||||
@@ -2131,7 +2129,6 @@ func TestNovelPageOmitsUpdatedTab(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"/?lib=novel&tab=fav",
|
||||
"/?lib=novel&tab=archived",
|
||||
"/?lib=novel&tab=finished",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("novel page missing tab link %s", want)
|
||||
@@ -2310,6 +2307,42 @@ func TestSeriesListFilterWiring(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The finished filter is a first-class option: the select carries it with
|
||||
// its label and library-wide count, and entering it lists exactly the
|
||||
// retired rows — picking it up from order-plus-label like every other
|
||||
// filter, with no per-filter branch in the handler.
|
||||
func TestSeriesListFinishedFilterOption(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:healthy", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(10), bookmarks: 1})
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:retired", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||
if err := st.SetSeriesFinished("asura", "retired", time.Now().UnixMilli()); err != nil {
|
||||
t.Fatalf("finish asura:retired: %v", err)
|
||||
}
|
||||
srv := newRouter(st, testConfig())
|
||||
|
||||
body := adminSeriesPage(t, srv, st, "?filter=finished")
|
||||
if !strings.Contains(body, "Title of asura:retired") {
|
||||
t.Errorf("finished list misses its row:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "Title of asura:healthy") {
|
||||
t.Errorf("finished list renders an unfinished row:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "1 series") || !strings.Contains(body, "Finished") {
|
||||
t.Errorf("finished heading lacks the count and label:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "Finished (1)") {
|
||||
t.Errorf("the finished option lacks its count:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `<option value="finished" selected>`) {
|
||||
t.Errorf("the finished option is not selected:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// ?filter=stale&site=kagane&kind=manga narrows on all three at once: only the
|
||||
// kagane manga stale row renders, and every link the page emits carries the
|
||||
// filter and Site so the narrowing survives in the URL.
|
||||
@@ -2557,18 +2590,21 @@ func TestSeriesListRowShape(t *testing.T) {
|
||||
if !strings.Contains(body, `class="c-site site-asura"`) {
|
||||
t.Errorf("the site cell lacks its site class:\n%s", body)
|
||||
}
|
||||
// The action cell carries the Check now control on pollable rows and is
|
||||
// empty on the orphan (no URL, no Readers — a button that can never do
|
||||
// anything is not offered), and no Remove control or confirm row renders
|
||||
// in this batch.
|
||||
if !strings.Contains(body, `<span class="c-act"></span>`) {
|
||||
t.Errorf("the orphan's action cell is not present and empty:\n%s", body)
|
||||
// The action cell carries the Check now control on pollable rows and the
|
||||
// Remove control on the orphan (#155) — a Series no Reader holds can be
|
||||
// removed, so the orphan's cell is never empty. No confirm row renders
|
||||
// in this batch: the confirm gate is htmx's own, not a toggled cell.
|
||||
if !strings.Contains(body, `<span class="c-act">`) {
|
||||
t.Errorf("the action cell is not present:\n%s", body)
|
||||
}
|
||||
if got := strings.Count(body, "Check now"); got != 2 {
|
||||
t.Errorf("Check now control count = %d, want 2 (only the two pollable rows):\n%s", got, body)
|
||||
}
|
||||
if strings.Contains(body, "Remove") || strings.Contains(body, "confirm-row") {
|
||||
t.Errorf("a Remove control or confirm row renders in this batch:\n%s", body)
|
||||
if got := strings.Count(body, ">Remove<"); got != 1 {
|
||||
t.Errorf("Remove control count = %d, want 1 (only the orphan):\n%s", got, body)
|
||||
}
|
||||
if strings.Contains(body, "confirm-row") {
|
||||
t.Errorf("a confirm row renders in this batch:\n%s", body)
|
||||
}
|
||||
// No ember: the new-chapter signal stays off the admin surface. Scoped to
|
||||
// the page content — the shell's brand mark legitimately wears the ember
|
||||
@@ -2722,6 +2758,60 @@ func TestAdminSeriesDetailRendersMarks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The provenance line beside the chapter names the actor class behind the
|
||||
// value — "machine read" for a checked Series, "correction" for the owner's
|
||||
// stamp, "sighting" for a Reader-raised one — and appears nowhere in the
|
||||
// rendered Series list: an actor class is context for the Series the owner is
|
||||
// already looking at, never a population to sweep (#152).
|
||||
func TestAdminSeriesDetailProvenanceLine(t *testing.T) {
|
||||
router, st := newWebTestServer(t, testConfig())
|
||||
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:machine", Site: "asura", SeriesID: "machine",
|
||||
Title: "Machine", SeriesURL: "https://asurascans.com/series/machine",
|
||||
Kind: "manga", LatestChapter: "45", LatestChapterNum: floatPtr(45),
|
||||
})
|
||||
if err := st.MarkLatestChecked("asura", "machine", time.Now().Add(-time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("MarkLatestChecked: %v", err)
|
||||
}
|
||||
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:hand", Site: "asura", SeriesID: "hand",
|
||||
Title: "Hand", SeriesURL: "https://asurascans.com/series/hand",
|
||||
Kind: "manga", LatestChapter: "12", LatestChapterNum: floatPtr(12),
|
||||
})
|
||||
if err := st.CorrectLatestChapter("asura", "hand", 13, time.Now().UnixMilli()); err != nil {
|
||||
t.Fatalf("CorrectLatestChapter: %v", err)
|
||||
}
|
||||
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "demonic:raised", Site: "demonic", SeriesID: "raised",
|
||||
Title: "Raised", SeriesURL: "https://demonicscans.org/series/raised",
|
||||
Kind: "manga",
|
||||
})
|
||||
if err := st.RecordSighting(st.OwnerID(), "demonic", "raised", floatPtr(7), time.Now().UnixMilli()); err != nil {
|
||||
t.Fatalf("RecordSighting: %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct{ key, want string }{
|
||||
{"asura:machine", "machine read"},
|
||||
{"asura:hand", "correction"},
|
||||
{"demonic:raised", "sighting"},
|
||||
} {
|
||||
body := seriesDetailPage(t, router, st, tc.key)
|
||||
if !strings.Contains(body, "<span>"+tc.want+"</span>") {
|
||||
t.Errorf("detail %s lacks the %q provenance line:\n%s", tc.key, tc.want, body)
|
||||
}
|
||||
}
|
||||
|
||||
listBody := adminSeriesPage(t, router, st, "")
|
||||
for _, word := range []string{"machine read", "correction", "sighting"} {
|
||||
if strings.Contains(listBody, "<span>"+word+"</span>") {
|
||||
t.Errorf("Series list carries a %q provenance line:\n%s", word, listBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A well-formed key naming no row is a 404, and so is a key with no ":",
|
||||
// an empty Site or an empty SeriesID — the detail page never answers 500 for
|
||||
// an address nobody can reach.
|
||||
@@ -2937,6 +3027,67 @@ func TestOverviewZeroIsAnUnlinkedDigit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The finished figure rides last in the Hygiene block: seriesFilterOrder
|
||||
// appends it at the tail, the block renders order[1:], and the count comes
|
||||
// from the same aggregate the select is numbered from. Asserting on the body
|
||||
// order catches a render that merely shows the figure somewhere.
|
||||
func TestOverviewFinishedFigureRidesLast(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:healthy", url: "https://asurascans.com/comics/healthy", cover: "aaa", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(10), bookmarks: 1})
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:retired", url: "https://asurascans.com/comics/retired", cover: "bbb", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||
if err := st.SetSeriesFinished("asura", "retired", time.Now().UnixMilli()); err != nil {
|
||||
t.Fatalf("finish asura:retired: %v", err)
|
||||
}
|
||||
srv := newRouter(st, testConfig())
|
||||
body := overviewBody(t, srv, st)
|
||||
|
||||
stat := regexp.MustCompile(`<div class="stat"><span class="lbl">(.*?)</span>(?:<a class="fig"[^>]*>(\d+)</a>|<span class="fig zero">(\d+)</span>)</div>`)
|
||||
var labels []string
|
||||
for _, m := range stat.FindAllStringSubmatch(body, -1) {
|
||||
switch m[1] {
|
||||
case "Series", "Manga", "Novels", "Readers":
|
||||
continue // the Library block, not the hygiene figures
|
||||
}
|
||||
labels = append(labels, m[1])
|
||||
}
|
||||
if len(labels) != 8 {
|
||||
t.Fatalf("hygiene block renders %d figures, want 8 (seven problems + finished):\n%s", len(labels), body)
|
||||
}
|
||||
if labels[len(labels)-1] != "Finished" {
|
||||
t.Errorf("finished figure does not ride last; hygiene order = %v:\n%s", labels, body)
|
||||
}
|
||||
if !strings.Contains(body, `href="/admin/series?filter=finished">1</a>`) {
|
||||
t.Errorf("the finished figure lacks its count and door:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A measured zero finished count stays on the page as a muted digit, never a
|
||||
// link: following it would land on an empty list, exactly like every other
|
||||
// zero figure.
|
||||
func TestOverviewFinishedZeroIsAnUnlinkedDigit(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:one", url: "https://asurascans.com/comics/one", cover: "aaa", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||
srv := newRouter(st, testConfig())
|
||||
body := overviewBody(t, srv, st)
|
||||
|
||||
if !strings.Contains(body, `Finished</span><span class="fig zero">0</span>`) {
|
||||
t.Errorf("a zero finished figure does not render as a muted digit:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, `href="/admin/series?filter=finished"`) {
|
||||
t.Errorf("a zero finished figure is still a link:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// The waiting figure sums Due over the latest pass per Site — older passes
|
||||
// for the same Site must not double-count, so the verdict reads the same
|
||||
// latest-per-Site projection the Lanes page reads.
|
||||
@@ -3238,3 +3389,649 @@ func TestSeriesPollCapsBody(t *testing.T) {
|
||||
t.Errorf("an oversized body still stamped the request:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// The correction route validates at the boundary: a non-numeric, zero,
|
||||
// negative or non-finite chapter answers 400 and never reaches the store, and
|
||||
// a finite number greater than zero stores the number, the derived label and
|
||||
// the stamp. The answer is the freshly rendered meta fragment, so the figures
|
||||
// describe the state after the press (#149).
|
||||
func TestCorrectLatestChapterRoute(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{
|
||||
key: "asura:solo", url: "u", checkedAt: 9000, bookmarks: 1, latestNum: floatPtr(3),
|
||||
})
|
||||
router := newRouter(st, testConfig())
|
||||
cookie := sessionCookie(t, st)
|
||||
|
||||
for _, body := range []string{
|
||||
"chapter=abc", "chapter=", "chapter=0", "chapter=-1", "chapter=NaN", "chapter=Inf",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/latest", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("POST latest with body %q: status = %d, want 400", body, rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing reached the store: the seeded number stands, unstamped.
|
||||
var num float64
|
||||
var stamp int64
|
||||
if err := db.QueryRow(`
|
||||
SELECT latest_chapter_num, latest_corrected_at
|
||||
FROM series WHERE site = 'asura' AND series_id = 'solo'`).
|
||||
Scan(&num, &stamp); err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if num != 3 || stamp != 0 {
|
||||
t.Fatalf("after 400s the row is num %v, stamp %d; want 3, 0", num, stamp)
|
||||
}
|
||||
|
||||
// A good press stores the number, the derived label and the stamp, and
|
||||
// answers with the meta fragment describing the state after the press.
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/latest", strings.NewReader("chapter=12.5"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("POST latest status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, `id="detail-meta"`) {
|
||||
t.Errorf("correction answer is not the meta fragment:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `<span class="mark">corrected `) {
|
||||
t.Errorf("correction answer lacks the fresh corrected marker:\n%s", body)
|
||||
}
|
||||
var label string
|
||||
if err := db.QueryRow(`
|
||||
SELECT latest_chapter, latest_chapter_num, latest_corrected_at
|
||||
FROM series WHERE site = 'asura' AND series_id = 'solo'`).
|
||||
Scan(&label, &num, &stamp); err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if label != "Chapter 12.5" || num != 12.5 {
|
||||
t.Errorf("stored = %q, %v; want the derived label and 12.5", label, num)
|
||||
}
|
||||
if stamp == 0 {
|
||||
t.Error("stamp = 0, want the correction stamp written")
|
||||
}
|
||||
}
|
||||
|
||||
// The detail page offers the one-input correction with the plain copy, and
|
||||
// the corrected marker rides the meta line while the stamp is set — then
|
||||
// disappears the moment a machine writes the number (#149).
|
||||
func TestAdminSeriesDetailCorrectionMarker(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:solo", url: "u", checkedAt: 9000, bookmarks: 1})
|
||||
router := newRouter(st, testConfig())
|
||||
cookie := sessionCookie(t, st)
|
||||
|
||||
body := seriesDetailPage(t, router, st, "asura:solo")
|
||||
for _, want := range []string{
|
||||
`name="chapter"`,
|
||||
`hx-post="/admin/series/asura:solo/latest"`,
|
||||
"The next successful Poll overwrites this value.",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("detail page lacks %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "corrected ") {
|
||||
t.Errorf("uncorrected detail already carries the marker:\n%s", body)
|
||||
}
|
||||
|
||||
// The press lands the marker on the meta line.
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/latest", strings.NewReader("chapter=7"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("POST latest status = %d, want 200", rr.Code)
|
||||
}
|
||||
body = seriesDetailPage(t, router, st, "asura:solo")
|
||||
if !strings.Contains(body, `<span class="mark">corrected `) {
|
||||
t.Errorf("detail page lacks the corrected marker after the press:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "ch 7") {
|
||||
t.Errorf("detail page does not show the corrected number:\n%s", body)
|
||||
}
|
||||
|
||||
// A machine write (the poller's setter) kills the marker.
|
||||
if err := st.SetLatestChapter("asura", "solo", "Chapter 8", 8); err != nil {
|
||||
t.Fatalf("SetLatestChapter: %v", err)
|
||||
}
|
||||
body = seriesDetailPage(t, router, st, "asura:solo")
|
||||
if strings.Contains(body, "corrected ") {
|
||||
t.Errorf("marker survives a machine write:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "ch 8") {
|
||||
t.Errorf("detail page does not show the machine-written number:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// The finish control lives on the Series detail page only: a neighbouring
|
||||
// Finish in the fifty-row grid is not a harmless read the way Check now is,
|
||||
// so the list row renders the state as a mark and offers no control to set
|
||||
// it. On the detail page the press is confirm-gated — the opener does not
|
||||
// post and the only finish POST sits inside the confirm row, targeting the
|
||||
// meta fragment it will swap (#158).
|
||||
func TestAdminSeriesDetailFinishControl(t *testing.T) {
|
||||
router, st := newWebTestServer(t, testConfig())
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Title: "Solo", Kind: store.KindManga,
|
||||
})
|
||||
|
||||
body := seriesDetailPage(t, router, st, "asura:solo")
|
||||
for _, want := range []string{
|
||||
`id="confirm-finish"`,
|
||||
"Mark this Series finished?",
|
||||
`hx-post="/admin/series/asura:solo/finish"`,
|
||||
`hx-target="#detail-meta"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("detail page lacks %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
// The press travels through the confirm row: the page's only finish POST
|
||||
// sits after the row's id (inside the row), and the opener that reveals
|
||||
// it posts nothing.
|
||||
if opener := strings.Index(body, `id="confirm-finish"`); opener < 0 ||
|
||||
strings.Index(body, `hx-post="/admin/series/asura:solo/finish"`) < opener {
|
||||
t.Errorf("finish POST is not inside the confirm row:\n%s", body)
|
||||
}
|
||||
if strings.Count(body, `hx-post="/admin/series/asura:solo/finish"`) != 1 {
|
||||
t.Errorf("finish POST count = %d, want exactly one (the affirmative):\n%s",
|
||||
strings.Count(body, `hx-post="/admin/series/asura:solo/finish"`), body)
|
||||
}
|
||||
|
||||
// The list row displays nothing to set: no finish control, no finished
|
||||
// mark on an unfinished Series.
|
||||
list := adminSeriesPage(t, router, st, "")
|
||||
for _, banned := range []string{
|
||||
`/admin/series/asura:solo/finish`,
|
||||
`/admin/series/asura:solo/unfinish`,
|
||||
`mark-faint">finished`,
|
||||
} {
|
||||
if strings.Contains(list, banned) {
|
||||
t.Errorf("list row carries %q:\n%s", banned, list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The finish route is the owner's one writer: a finish press stamps
|
||||
// finished_at and answers with the swapped detail-meta fragment carrying the
|
||||
// "finished <age> ago" mark, and an un-finish press writes zero and answers
|
||||
// with a fragment that has no finished line. A malformed key is a 400 and an
|
||||
// unknown one a 404, like the other Series mutations (#158).
|
||||
func TestSeriesFinishRoute(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:solo", url: "u", checkedAt: 9000, bookmarks: 1})
|
||||
router := newRouter(st, testConfig())
|
||||
cookie := sessionCookie(t, st)
|
||||
|
||||
for path, want := range map[string]int{
|
||||
"/admin/series/solo/finish": http.StatusBadRequest,
|
||||
"/admin/series/ghost:x/finish": http.StatusNotFound,
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(""))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != want {
|
||||
t.Errorf("POST %s: status = %d, want %d", path, rr.Code, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A finish press stamps and answers with the swapped meta fragment whose
|
||||
// mark reads "finished just now".
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/finish", strings.NewReader(""))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("POST finish status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, `id="detail-meta"`) {
|
||||
t.Errorf("finish answer is not the meta fragment:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `<span class="mark">finished `) {
|
||||
t.Errorf("finish answer lacks the fresh finished mark:\n%s", body)
|
||||
}
|
||||
var at int64
|
||||
if err := db.QueryRow(`SELECT finished_at FROM series WHERE site = 'asura' AND series_id = 'solo'`).Scan(&at); err != nil {
|
||||
t.Fatalf("read finished_at: %v", err)
|
||||
}
|
||||
if at == 0 {
|
||||
t.Error("finished_at = 0, want the finish stamp written")
|
||||
}
|
||||
|
||||
// The page after the press offers the instant reversal, no confirm.
|
||||
body = seriesDetailPage(t, router, st, "asura:solo")
|
||||
if !strings.Contains(body, `hx-post="/admin/series/asura:solo/unfinish"`) {
|
||||
t.Errorf("finished detail page lacks the un-finish control:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "Mark this Series finished?") {
|
||||
t.Errorf("finished detail page still carries the confirm row:\n%s", body)
|
||||
}
|
||||
|
||||
// An un-finish press fires instantly and writes zero.
|
||||
req = httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/unfinish", strings.NewReader(""))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("POST unfinish status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
body = rr.Body.String()
|
||||
if !strings.Contains(body, `id="detail-meta"`) {
|
||||
t.Errorf("unfinish answer is not the meta fragment:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, `<span class="mark">finished `) {
|
||||
t.Errorf("unfinish answer still carries the finished mark:\n%s", body)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT finished_at FROM series WHERE site = 'asura' AND series_id = 'solo'`).Scan(&at); err != nil {
|
||||
t.Fatalf("read finished_at after un-finish: %v", err)
|
||||
}
|
||||
if at != 0 {
|
||||
t.Errorf("finished_at = %d after un-finish, want 0", at)
|
||||
}
|
||||
}
|
||||
|
||||
// A finished Series shows its state in the list row as a faint mark, and the
|
||||
// row offers no control to set or clear it — the reversal lives on the
|
||||
// detail page, its press, and the mark is the whole of the row's share
|
||||
// (#158).
|
||||
func TestAdminSeriesListShowsFinished(t *testing.T) {
|
||||
router, st := newWebTestServer(t, testConfig())
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Title: "Solo", Kind: store.KindManga,
|
||||
})
|
||||
if err := st.SetSeriesFinished("asura", "solo", time.Now().Add(-2*time.Minute).UnixMilli()); err != nil {
|
||||
t.Fatalf("SetSeriesFinished: %v", err)
|
||||
}
|
||||
|
||||
body := adminSeriesPage(t, router, st, "")
|
||||
if !strings.Contains(body, `<span class="mark mark-faint">finished</span>`) {
|
||||
t.Errorf("list row lacks the finished mark:\n%s", body)
|
||||
}
|
||||
for _, banned := range []string{"solo/finish", "solo/unfinish"} {
|
||||
if strings.Contains(body, banned) {
|
||||
t.Errorf("list row offers %q:\n%s", banned, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The series URL repair validates with the poller's own fetch gate and
|
||||
// answers 400 before anything reaches the store; a URL that passes the gate
|
||||
// is stored where an Upsert would have ignored it. The request performs no
|
||||
// outbound fetch — no fetcher is ever constructed on this path (the web
|
||||
// router has no fetcher seam at all, and the handler only calls the store),
|
||||
// so "storing is not verifying" is enforced by construction (#151).
|
||||
func TestSeriesURLRepairRoute(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{
|
||||
key: "asura:solo", url: "https://asurascans.com/comics/solo", checkedAt: 9000, bookmarks: 1,
|
||||
})
|
||||
router := newRouter(st, testConfig())
|
||||
cookie := sessionCookie(t, st)
|
||||
|
||||
// A URL the gate refuses — foreign host, http scheme, host of another
|
||||
// Site — answers 400 and never reaches the store.
|
||||
for _, body := range []string{
|
||||
"series_url=https://evil.example/solo",
|
||||
"series_url=http://asurascans.com/stories/solo",
|
||||
"series_url=https://kagane.to/series/solo",
|
||||
"series_url=",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/series-url", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("POST series-url with body %q: status = %d, want 400", body, rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
capReq := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/series-url",
|
||||
strings.NewReader("series_url=https://asurascans.com/stories/"+strings.Repeat("a", 1<<17)))
|
||||
capReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
capReq.AddCookie(cookie)
|
||||
capRR := httptest.NewRecorder()
|
||||
router.ServeHTTP(capRR, capReq)
|
||||
if capRR.Code != http.StatusBadRequest {
|
||||
t.Errorf("POST series-url with an oversized body: status = %d, want 400", capRR.Code)
|
||||
}
|
||||
var stored string
|
||||
if err := db.QueryRow(`SELECT series_url FROM series WHERE site = 'asura' AND series_id = 'solo'`).
|
||||
Scan(&stored); err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if stored != "https://asurascans.com/comics/solo" {
|
||||
t.Fatalf("after 400s the stored URL = %q, want the seeded one untouched", stored)
|
||||
}
|
||||
|
||||
// A URL that passes the gate lands, and the press answers with the meta
|
||||
// fragment just like the other detail-page actions.
|
||||
repair := "https://asurascans.com/stories/solo-renumbered"
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/series-url",
|
||||
strings.NewReader("series_url="+repair))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("POST series-url status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
if body := rr.Body.String(); !strings.Contains(body, `id="detail-meta"`) {
|
||||
t.Errorf("repair answer is not the meta fragment:\n%s", body)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT series_url FROM series WHERE site = 'asura' AND series_id = 'solo'`).
|
||||
Scan(&stored); err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if stored != repair {
|
||||
t.Fatalf("stored URL = %q, want %q", stored, repair)
|
||||
}
|
||||
}
|
||||
|
||||
// The detail page offers the repair input prefilled with the stored address,
|
||||
// states the honest limit — a Site-wide host change is a SQL migration, not a
|
||||
// per-Series form — and a stored string renders back into the input escaped
|
||||
// (issue #151).
|
||||
func TestAdminSeriesDetailRepairForm(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{
|
||||
key: "asura:solo", url: "https://asurascans.com/stories/solo", bookmarks: 1,
|
||||
})
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{
|
||||
key: "asura:evil", url: `https://asurascans.com/x"><script>alert(1)</script>`, bookmarks: 1,
|
||||
})
|
||||
router := newRouter(st, testConfig())
|
||||
|
||||
body := seriesDetailPage(t, router, st, "asura:solo")
|
||||
for _, want := range []string{
|
||||
`name="series_url"`,
|
||||
`hx-post="/admin/series/asura:solo/series-url"`,
|
||||
`value="https://asurascans.com/stories/solo"`,
|
||||
"A Site-wide host change", "SQL migration",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("detail page lacks %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
|
||||
// The stored value that is markup stays markup in the input's value
|
||||
// attribute, never executable HTML.
|
||||
body = seriesDetailPage(t, router, st, "asura:evil")
|
||||
if strings.Contains(body, `<script>alert(1)</script>`) {
|
||||
t.Errorf("repair input renders stored URL unescaped:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `value="https://asurascans.com/x"><script>alert(1)</script>"`) {
|
||||
t.Errorf("repair input does not carry the escaped stored URL:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// The Remove control is offered only to the owner, and only on a Series no
|
||||
// Reader holds: on the orphan's list row and on the orphan's detail page,
|
||||
// nowhere else (#155).
|
||||
func TestSeriesRemoveRendersOnlyOnOrphans(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:ok", url: "u", checkedAt: 9000, bookmarks: 1})
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:orphan", url: "u", checkedAt: 9000, bookmarks: 0})
|
||||
router := newRouter(st, testConfig())
|
||||
|
||||
body := adminSeriesPage(t, router, st, "")
|
||||
if got := strings.Count(body, ">Remove<"); got != 1 {
|
||||
t.Errorf("list offers Remove %d times, want 1 (only the orphan):\n%s", got, body)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
key string
|
||||
want bool
|
||||
}{
|
||||
{"asura:ok", false},
|
||||
{"asura:orphan", true},
|
||||
} {
|
||||
body := seriesDetailPage(t, router, st, tc.key)
|
||||
if got := strings.Contains(body, ">Remove<"); got != tc.want {
|
||||
t.Errorf("%s detail offers Remove = %v, want %v", tc.key, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A removal from the list answers with the removed row's fragment and the
|
||||
// heading re-rendered out of band with the fresh count: the row and the
|
||||
// count are one fact. The row's press carries the list's filter state, so
|
||||
// the count describes the list the owner is looking at, and the HX-Reswap
|
||||
// header deletes the row through the same button that swaps the refusal in.
|
||||
func TestRemoveFromListAnswersRowAndFreshHeading(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:a", url: "u", checkedAt: 9000, bookmarks: 0})
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:b", url: "u", checkedAt: 9000, bookmarks: 0})
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:held", url: "u", checkedAt: 9000, bookmarks: 1})
|
||||
router := newRouter(st, testConfig())
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:a/remove",
|
||||
strings.NewReader("filter=no_readers&band=0"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("removal status = %d, want 200", rr.Code)
|
||||
}
|
||||
if got := rr.Header().Get("HX-Reswap"); got != "delete" {
|
||||
t.Errorf("response does not ask htmx to delete the row (HX-Reswap = %q)", got)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, "Title of asura:a") {
|
||||
t.Errorf("answer does not carry the removed row's fragment:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `hx-swap-oob="true"`) ||
|
||||
!strings.Contains(body, "1 series") || !strings.Contains(body, "No Readers") {
|
||||
t.Errorf("answer does not re-render the heading out of band with the fresh count:\n%s", body)
|
||||
}
|
||||
|
||||
// Gone from the store, gone from the list, and the heading lies no longer.
|
||||
var one int
|
||||
if err := db.QueryRow(`SELECT 1 FROM series WHERE site = 'asura' AND series_id = 'a'`).Scan(&one); err != sql.ErrNoRows {
|
||||
t.Fatalf("series row after removal = %v, want sql.ErrNoRows", err)
|
||||
}
|
||||
body = adminSeriesPage(t, router, st, "?filter=no_readers")
|
||||
if strings.Contains(body, "Title of asura:a") || !strings.Contains(body, "1 series") {
|
||||
t.Errorf("list after removal is not the fresh view:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A removal from the detail page navigates to the No-Readers list: htmx gets
|
||||
// a full navigation (HX-Redirect — a 303 would be followed by the request
|
||||
// and the list page swapped into the press's target), plain clients the 303
|
||||
// the ticket names, to the wire filter the orphan list actually is.
|
||||
func TestRemoveFromDetailRedirectsToNoReadersList(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:gone", url: "u", checkedAt: 9000, bookmarks: 0})
|
||||
// A second orphan for the htmx dialect's request, whose row must still
|
||||
// exist after the first request removed its own.
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:gone2", url: "u", checkedAt: 9000, bookmarks: 0})
|
||||
router := newRouter(st, testConfig())
|
||||
target := "/admin/series?filter=" + store.SeriesFilterNoReaders
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:gone/remove", nil)
|
||||
req.Header.Set("HX-Target", "detail-meta")
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("detail removal status = %d, want 303", rr.Code)
|
||||
}
|
||||
if got := rr.Header().Get("Location"); got != target {
|
||||
t.Errorf("Location = %q, want %q", got, target)
|
||||
}
|
||||
var one int
|
||||
if err := db.QueryRow(`SELECT 1 FROM series WHERE site = 'asura' AND series_id = 'gone'`).Scan(&one); err != sql.ErrNoRows {
|
||||
t.Fatalf("series row after detail removal = %v, want sql.ErrNoRows", err)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/admin/series/asura:gone2/remove", nil)
|
||||
req.Header.Set("HX-Request", "true")
|
||||
req.Header.Set("HX-Target", "detail-meta")
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if got := rr.Header().Get("HX-Redirect"); got != target {
|
||||
t.Errorf("HX-Redirect = %q, want %q", got, target)
|
||||
}
|
||||
}
|
||||
|
||||
// A removal that races a fresh Bookmark is a refusal, not an error: the row
|
||||
// is rendered again at its new count with the fact spelled out, never a 500,
|
||||
// and it must not vanish from the list — the delete never happened. The
|
||||
// detail-surface refusal navigates back to the detail page, where the same
|
||||
// fresh count is visible.
|
||||
func TestRemoveRacedBookmarkIsRefusedNotError(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:raced", url: "u", checkedAt: 9000, bookmarks: 1})
|
||||
router := newRouter(st, testConfig())
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:raced/remove",
|
||||
strings.NewReader("filter=no_readers&band=0"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("refusal status = %d, want 200 (never a 500)", rr.Code)
|
||||
}
|
||||
if got := rr.Header().Get("HX-Reswap"); got != "" {
|
||||
t.Errorf("refusal carries HX-Reswap = %q, want none (the row must stay)", got)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, "a Reader has bookmarked this Series again") {
|
||||
t.Errorf("refusal does not say what happened:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `class="c-rd">1</span>`) {
|
||||
t.Errorf("refusal does not render the fresh count:\n%s", body)
|
||||
}
|
||||
var one int
|
||||
if err := db.QueryRow(`SELECT 1 FROM series WHERE site = 'asura' AND series_id = 'raced'`).Scan(&one); err != nil {
|
||||
t.Fatalf("series row after refusal = %v, want present", err)
|
||||
}
|
||||
list := adminSeriesPage(t, router, st, "")
|
||||
if !strings.Contains(list, "Title of asura:raced") {
|
||||
t.Fatal("row vanished from the list after a refused removal")
|
||||
}
|
||||
if strings.Contains(list, ">Remove<") {
|
||||
t.Errorf("a held series still offers Remove:\n%s", list)
|
||||
}
|
||||
|
||||
// The detail-surface refusal navigates back to the detail page.
|
||||
req = httptest.NewRequest(http.MethodPost, "/admin/series/asura:raced/remove", nil)
|
||||
req.Header.Set("HX-Target", "detail-meta")
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("detail refusal status = %d, want 303", rr.Code)
|
||||
}
|
||||
if got := rr.Header().Get("Location"); got != "/admin/series/asura:raced" {
|
||||
t.Errorf("refusal Location = %q, want the detail page", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The remove route trusts the same way the poll route does: a malformed key
|
||||
// is a 400 and an unknown Series a 404, and an oversized body is a 400 that
|
||||
// removes nothing.
|
||||
func TestRemoveRejectsBadKeysAndCapsBody(t *testing.T) {
|
||||
router, st := newWebTestServer(t, testConfig())
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:x", Site: "asura", SeriesID: "x",
|
||||
Title: "Title of asura:x", SeriesURL: "u",
|
||||
})
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
want int
|
||||
}{
|
||||
{"/admin/series/nocolon/remove", http.StatusBadRequest},
|
||||
{"/admin/series/:x/remove", http.StatusBadRequest},
|
||||
{"/admin/series/asura:/remove", http.StatusBadRequest},
|
||||
{"/admin/series/asura:ghost/remove", http.StatusNotFound},
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, tc.path, nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != tc.want {
|
||||
t.Errorf("POST %s status = %d, want %d", tc.path, rr.Code, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
big := strings.Repeat("a", 1<<17) // 128 KiB, over the 64 KiB cap
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/remove", strings.NewReader(big))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("oversized body status = %d, want 400", rr.Code)
|
||||
}
|
||||
list := adminSeriesPage(t, router, st, "")
|
||||
if !strings.Contains(list, "Title of asura:x") {
|
||||
t.Errorf("an oversized body still removed the row:\n%s", list)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# ADR-0014: Cover addresses derived from the bytes, not the source URL
|
||||
|
||||
Date: 2026-08-22
|
||||
Status: accepted
|
||||
|
||||
## Decision
|
||||
|
||||
A Cover's content address is the hex SHA-256 of its **bytes**, not of the
|
||||
source URL it was fetched from. `CoverAddressForBytes(body)` names the address
|
||||
`putCover` stores under, `SetSeriesCover` and `ReplaceSeriesCover` point the
|
||||
Series row at it, and the wire URL is built from it exactly as before — same
|
||||
route, same 64-hex-digit shape, same immutability, only the input to the hash
|
||||
changes. Rows written before this ADR keep their URL-derived addresses
|
||||
forever: they are never rehashed on read, and they heal into byte addressing
|
||||
only when a Forced Poll replaces them.
|
||||
|
||||
`ReplaceSeriesCover(site, seriesID, sourceURL, body, contentType)`
|
||||
`(previous, current, error)` is the one write that may move a Cover once one
|
||||
exists. It stores the bytes, then in one transaction locks the Series row,
|
||||
reads the old `cover_address`, writes the new one and the source URL, and
|
||||
reports both addresses: `previous == ""` means there was no Cover,
|
||||
`previous == current` means the Site served identical artwork, and any other
|
||||
pair names the stranded address.
|
||||
|
||||
## Why a future reader will find this surprising
|
||||
|
||||
The address is what makes a re-art visible at all. URL addressing collapses
|
||||
every image behind a stable URL into one address, so a Series whose Cover
|
||||
changes (a big-budget CPI blitz on a light novel is the standing example)
|
||||
keeps serving its original cover bytes: the poll refetches the same URL,
|
||||
hashes it, and the store records the same address, everyone happy except the
|
||||
Reader. Nothing in the system can detect the change, because the address is a
|
||||
pure function of the fetch target, and identical bytes written 1,000 times
|
||||
are one blob on disk. Storing bytes we already know how to store is only a
|
||||
few lines of work. **Rejecting that work is the surprising part, and the
|
||||
answer is the Forced Poll wave**: for a corrupt/blank cover the poll's
|
||||
fill-if-blank installer already worked, but for a *wrong but non-blank* cover
|
||||
there was no write that would move it at all — only a manual truth in
|
||||
`series.cover_address`, which is exactly the thing that must never be set by
|
||||
hand. Byte addressing gives the replacement write a **new address to write**,
|
||||
and with it a legitimate, transaction-safe mover.
|
||||
|
||||
## Considered options
|
||||
|
||||
**Keep URL addressing and add a generic "clear the cover" write.**
|
||||
Rejected: clearing is a two-phase action (blank it, wait for the poll to
|
||||
re-fill, hope the bytes changed in between) that cannot report what the
|
||||
write did, and it makes the Series render cover-less in between. The
|
||||
replacement write is atomic, reports its displacement, and has one effect:
|
||||
the Series now points at bytes that actually came from its source URL.
|
||||
|
||||
**Address by URL, but salt it so a re-art is a new address.**
|
||||
Rejected: the salt would have to live somewhere addressable (a stored per-
|
||||
Series nonce), turning the address from a content fact into a mutable fact —
|
||||
two rows could then hold identical bytes under different addresses and the
|
||||
invariant "same bytes object" is gone.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `store.CoverAddress` (URL-hash) is deleted; `CoverAddressForBytes` is
|
||||
public so tests and the forced-poll wave can predict addresses from the
|
||||
bytes fakes serve.
|
||||
- Legacy URL-addressed rows are read-only facts: `GetCover(sourceURL)` keeps
|
||||
resolving them (the poll heal path), and they are re-addressed only by a
|
||||
forced replacement. Until one happens, they are invisible to byte-derived
|
||||
lookups — the reverse direction was always true, so this side has no
|
||||
migration and no lookup fan-out.
|
||||
- A replaced Cover's old bytes stay on disk under their address (the `covers`
|
||||
row is untouched — only the Series row moves). Nothing reclaims them
|
||||
today; a later sweep is a small query over `covers` addresses not
|
||||
referenced by any `series` row.
|
||||
- `SetSeriesCover` keeps its `cover_address = ''` guard untouched: the
|
||||
acquisition-at-creation and poll fill paths still may not overwrite a
|
||||
non-blank Cover. The two installers are now deliberately different
|
||||
functions instead of one function with a conditional.
|
||||
- The address is still a filesystem path (≤64 hex chars, no separators), so
|
||||
`coverAddressRe` and the sharding stay exactly as they are.
|
||||
|
||||
## Cost of reversing
|
||||
|
||||
The URL-hash side of the current rows is uncomputable from the rows alone: a
|
||||
rollback would need every stored blob's source URL, a join to a table that
|
||||
does not store it, or a refetch of every Series. Keeping both derivations
|
||||
resolvable is cheaper than either, so the two derivations are documented in
|
||||
the 0009 migration comment: no component may assume which derivation a
|
||||
stored address came from, because the 64-hex shape hides it.
|
||||
@@ -0,0 +1,109 @@
|
||||
# ADR-0015: Finished is a fact about the Series, not a bookmark bucket
|
||||
|
||||
Date: 2026-08-22
|
||||
Status: accepted
|
||||
|
||||
## Decision
|
||||
|
||||
"Finished" moves from the per-Reader `bookmarks.status` bucket to a
|
||||
Series-owned flag: `series.finished_at`, unix ms, zero while the Series is
|
||||
still running. The Lane's gate reads the flag — a Series is polled only while
|
||||
`finished_at = 0` — and `bookmarks.status` keeps exactly two values,
|
||||
`reading` and `archived`.
|
||||
|
||||
The cutover is one-way, done by migration 0016 in three load-bearing
|
||||
statements:
|
||||
|
||||
1. `ALTER TABLE series ADD COLUMN finished_at bigint NOT NULL DEFAULT 0`.
|
||||
2. Seed it from the bookmarks: a Series is stamped finished when no bookmark
|
||||
on it is outside the `finished` bucket. This mirrors the pre-cutover due
|
||||
gate exactly — the old query skipped a Series only while
|
||||
`COUNT(*) FILTER (WHERE status <> 'finished') = 0` — so no Series changes
|
||||
polling state at the cutover.
|
||||
3. Rewrite every `finished` bookmark to `archived`. The bucket is gone; the
|
||||
seed ran first because it is the only statement that can still read it.
|
||||
|
||||
The JSON API rejects a `finished` status with the same plain 400 as any
|
||||
unknown value, and the web UI no longer offers a Finished tab, a finish
|
||||
button, or a finished state badge.
|
||||
|
||||
## Why a future reader will find this surprising
|
||||
|
||||
The bucket looked Reader-shaped but described a Series fact. A Series is
|
||||
finished once, and every Reader reading it is then on a finished Series —
|
||||
yet the bucket carried three copies of the answer, one per Reader, free to
|
||||
disagree. The disagreement is not theoretical: a second Reader who merely
|
||||
kept the Series (or never read it) kept it in `reading`, so the poll gate
|
||||
kept fetching a Series the first Reader had closed out, forever. Worse, the
|
||||
disagreement was never resolvable — nothing in the system could say "this
|
||||
Series is finished" without rewriting every bookmark, which silently edits
|
||||
another Reader's progress state.
|
||||
|
||||
The flag is also the only memory of the bucket after the flip. `finished`
|
||||
bookmarks become `archived` because a two-value status needs no third
|
||||
value, and an archived row must keep meaning "shelved, but the Series is
|
||||
being watched" — which is what the row says. The migration's seed is what
|
||||
keeps the legacy meaning: a Series every Reader finished is stamped, so the
|
||||
Lane stops polling it just as it would have pre-cutover; a Series any
|
||||
Reader still reads is left alone, exactly as the old gate left it. A
|
||||
Series whose every Reader only shelved (archived) continues to be polled,
|
||||
because an archived bookmark is *supposed* to be polled — the cutover
|
||||
changes the answer, it does not invent it. And because the flag is a series
|
||||
fact, the cutover also repairs the disagreement case: the moment one Reader
|
||||
has the Series open, it reads as finished to everyone.
|
||||
|
||||
The migration is the only writer of the flag today; the undo is writing 0,
|
||||
which returns the Series to the poll. An owner-facing "mark finished" write
|
||||
is deliberately not part of this change — the gate is what this ticket
|
||||
rewrites, and the write can land on top of it without touching anything
|
||||
here.
|
||||
|
||||
The userscript merge ranks `archived > reading` now. `finished` is not a
|
||||
value the wire can carry, so the merge cannot un-finish a row — it cannot
|
||||
even name the state it is protecting.
|
||||
|
||||
## Considered options
|
||||
|
||||
**Keep the bucket and add the flag alongside it, both live.**
|
||||
Rejected: two sources of truth for one fact, with the Lane forced to
|
||||
resolve "any Reader finished?" on every due query and every Reader write
|
||||
still able to resurrect a finished Series. The whole point of the change is
|
||||
that the finished state survives Readers.
|
||||
|
||||
**Stamp a Series finished when every bookmark is finished *or* archived.**
|
||||
Rejected: it flips polling state at the cutover. Shelved-only Series were
|
||||
polled before; making them finished stops the checks the reader knowingly
|
||||
asked to keep.
|
||||
|
||||
**Finish as "no bookmark is reading", leaving the buckets untouched.**
|
||||
Rejected for the same reason plus one: `archived` is a Reader's own state
|
||||
and the flip is what makes the flag the *only* source of finished. Keeping
|
||||
the `finished` value in the table would force every status validation,
|
||||
merge and UI branch to keep handling a value no write can produce.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `bookmarks.status` is validated to `reading | archived`, empty meaning
|
||||
"keep the stored value"; the API's 400 for `finished` is now the generic
|
||||
invalid-status rejection rather than a special case, and the web UI's own
|
||||
status control rejects it the same way.
|
||||
- The Lane due query and the eligible count read `finished_at`; a Forced
|
||||
Poll (issue #146) still overrides the flag — the owner asked, so the Lane
|
||||
looks — and the pending force clears itself when the pass stamps the
|
||||
check timestamp, never by touching `finished_at`.
|
||||
- The web UI has no Finished tab; finished Series render in Archived with
|
||||
their archived badge, dimmed like any shelved row.
|
||||
- Migration 0016 stamps `finished_at` with the migration's own clock
|
||||
(`now()` ms), which is also the undo: write 0 and the Series returns to
|
||||
the poll.
|
||||
|
||||
## Cost of reversing
|
||||
|
||||
The finished buckets are destroyed by the flip; reversing means re-deriving
|
||||
per-Reader finished state from a Series fact that now encodes the
|
||||
majority-agreement snapshot plus whatever the owner reset since. The stamp
|
||||
differentiates "finished at the cutover and untouched" from "finished
|
||||
later", but not which Reader's choice each Series carried, and any Series
|
||||
the owner has since restored is gone from the derivation entirely. The ADR
|
||||
is a statement of intent to ship the one-way cutover and live with its
|
||||
consequences; the per-Reader history is not kept anywhere in the schema.
|
||||
+14
-14
@@ -2,7 +2,7 @@
|
||||
|
||||
Source of truth: the Claude Design project **BookmarkManager Web UI**
|
||||
(`969ac210-fe02-4c01-ae1b-9a271dcc779a`, `index.html` + siblings
|
||||
`archived.html`/`fav.html`/`finished.html`/`new.html`/`login.html`/`mobile.html`,
|
||||
`archived.html`/`fav.html`/`new.html`/`login.html`/`mobile.html`,
|
||||
`style.css`, `filter.js`). This file records the rules that got implemented so
|
||||
a future agent can extend the UI without re-reading the design.
|
||||
|
||||
@@ -48,7 +48,7 @@ Defined once in `backend/internal/web/static/style.css` `:root`, mirrored in the
|
||||
| --- | --- | --- | --- |
|
||||
| `--ink` | `#100f0e` | `#f7f4ef` | page |
|
||||
| `--ash` | `#161413` | `#efeae3` | recessed panel (chapter form) |
|
||||
| `--dim` | `#0d0c0b` | `#f1ede7` | archived / finished row background |
|
||||
| `--dim` | `#0d0c0b` | `#f1ede7` | archived row background |
|
||||
| `--rule` | `#221f1d` | `#e0dad2` | hairline between sheets, button borders |
|
||||
| `--rule-soft` | `#1a1817` | `#e8e3dc` | the measure's own side edges |
|
||||
| `--field-line` | `#2c2926` | `#d4cdc4` | input borders, ghost-button underline |
|
||||
@@ -70,7 +70,7 @@ Defined once in `backend/internal/web/static/style.css` `:root`, mirrored in the
|
||||
| `--danger-soft` | `#e2aaa1` | `#7c2c22` | text on danger wash |
|
||||
| `--brass` | `#b8912f` | `#8a681c` | favourite — a cooler second metal |
|
||||
| `--slate` | `#7fa0c0` | `#3f6689` | archive accent |
|
||||
| `--moss` | `#7fae86` | `#3d6c46` | finished accent |
|
||||
| `--moss` | `#7fae86` | `#3d6c46` | finished Series label |
|
||||
| `--clay` | `#b5906f` | `#7c5533` | set-chapter accent |
|
||||
| `--trash` | `#977671` | `#8c6558` | remove, at rest — icons need 3:1, not 4.5:1 |
|
||||
| `--patina` | `#5fb3a6` | `#1f6f66` | admin page only — a Poll Lane needing attention, a Reader whose reports are blocked |
|
||||
@@ -164,9 +164,9 @@ rather than scaling the artwork down.
|
||||
|
||||
**Action key** (`.keyrow`): one permanent line under the tabs naming what
|
||||
every icon in `.actions` does — Read / Fav / Chapter / Archive / Done /
|
||||
Delete — so the icon strip on a card is never a guess. The key follows the tab,
|
||||
not the row: Archive becomes Restore under Archived and Finished, and Finished
|
||||
drops Done. On a phone each pair stacks icon-over-word
|
||||
Delete — so the icon strip on a card is never a guess. The key follows the
|
||||
tab, not the row: under Archived, Archive becomes Restore. On a phone each
|
||||
pair stacks icon-over-word
|
||||
(`flex-direction: column`) so the word gets the full cell width; ≥720px it lays
|
||||
out icon-beside-word at the same wording. `.pair.brass` and `.pair.trash`
|
||||
carry their icon's resting accent so the key itself teaches the colour
|
||||
@@ -179,7 +179,7 @@ article.card[.is-new|.is-dim]#card-<key>[data-title]
|
||||
.row
|
||||
a.cover[tabindex="-1" aria-hidden] img | span.monogram, + span.foot-rule[.brass]
|
||||
.body .title-line (h3.title + svg.fav-mark) , p.meta
|
||||
.actions play, favourite, chapter | lifecycle: archive/restore, finish, remove
|
||||
.actions play, favourite, chapter | lifecycle: archive/restore, remove
|
||||
form.chapter-form[hidden] .hint + .field(input + Save) + .hint (latest known)
|
||||
.confirm-row[.calm][hidden] × one per lifecycle action, span + (go/danger-solid, Cancel)
|
||||
p.error-inline[hidden]
|
||||
@@ -188,7 +188,7 @@ article.card[.is-new|.is-dim]#card-<key>[data-title]
|
||||
Rules that are easy to break:
|
||||
|
||||
- `.is-new` only when `Status == reading && HasNewChapter`; `.is-dim` for
|
||||
`archived` and `finished`. Both are set on the `<article>` — every heat and
|
||||
`archived`. Both are set on the `<article>` — every heat and
|
||||
dim rule is a descendant selector off those two classes, so a new sub-element
|
||||
inherits the state for free.
|
||||
- `.actions` is `flex: 1 0 100%` inside `.row`, which is what makes it a
|
||||
@@ -196,19 +196,19 @@ Rules that are easy to break:
|
||||
the row at ≥720px. Cells are 46px tall on phone (thumb target) and divided by
|
||||
`border-right: 1px var(--rule)`, last child none.
|
||||
- Three clusters by consequence, in this order: navigate (`.play`) | organize
|
||||
(`.fav`, `.pencil`) | lifecycle (`.box`/`.restore`, `.finish`, `.remove`,
|
||||
(`.fav`, `.pencil`) | lifecycle (`.box`/`.restore`, `.remove`,
|
||||
each carrying the `.lifecycle` class). Lifecycle cells sit on a recessed
|
||||
`--ash` ground so the thumb reads "this one moves the series" before it
|
||||
reads which icon it landed on; ≥720px they separate by a 10px gap instead of
|
||||
the phone's inset hairline.
|
||||
- Every lifecycle button that moves a series out of the list is
|
||||
**confirm-gated**: it opens its own `.confirm-row` (`archive`, `finish`,
|
||||
`remove` — `toggleConfirmRow(key, kind)` in `filter.js`). Archive and finish
|
||||
ask in `.calm` grey since they're reversible; remove alone gets the
|
||||
**confirm-gated**: it opens its own `.confirm-row` (`archive`,
|
||||
`remove` — `toggleConfirmRow(key, kind)` in `filter.js`). Archive asks in
|
||||
`.calm` grey since it's reversible; remove alone gets the
|
||||
`--danger-wash` treatment and names the series in its question. Restore
|
||||
fires instantly — no confirm — because it's the reversal.
|
||||
- Per-action hover/press accent: `.fav` → `--brass`, `.pencil` → `--clay`,
|
||||
`.box` → `--slate`, `.finish` → `--moss`. `.play` stays paper/ember (ember
|
||||
`.box` → `--slate`. `.play` stays paper/ember (ember
|
||||
only when `.is-new`). `.remove` stays `--trash` at rest, `--danger` on
|
||||
hover. Desktop cell borders follow the same accent on hover
|
||||
(`border-color: currentColor`); the two coloured *resting* states
|
||||
@@ -286,7 +286,7 @@ No transforms on hover, no scale, no easing curves beyond `ease-out`/`linear`.
|
||||
never reuse `--ember` or `--danger` for anything but their one meaning.
|
||||
3. If it is per-series, hang it off `.is-new` / `.is-dim` rather than adding a
|
||||
third state class.
|
||||
4. If it removes a series from the current view (archive/finish/remove-shaped),
|
||||
4. If it removes a series from the current view (archive/remove-shaped),
|
||||
it is confirm-gated via its own `.confirm-row` — no exceptions, restore is
|
||||
the only instant action because it's the one that's reversible by nature.
|
||||
5. Icon → `templates/icons.html`; nothing inlines SVG paths. Brand mark stays
|
||||
|
||||
@@ -471,9 +471,10 @@
|
||||
}
|
||||
|
||||
// Only an explicit archive/restore has an opinion about the bucket. Every
|
||||
// other write omits `status`, so the server keeps the stored one — otherwise
|
||||
// a cached value would resend "finished" (which the API rejects with 400) or
|
||||
// silently un-archive a series archived on another device.
|
||||
// other write omits `status`, so the server keeps the stored one —
|
||||
// otherwise a cached value would resend a stale status (the API rejects
|
||||
// unknown values with 400) or silently un-archive a series archived on
|
||||
// another device.
|
||||
async function apiPut(key, obj, { sendStatus = false } = {}) {
|
||||
const body = Object.assign({}, obj);
|
||||
if (!sendStatus) delete body.status;
|
||||
@@ -550,6 +551,12 @@
|
||||
return b.kind || "manga";
|
||||
}
|
||||
|
||||
// finished is a fact about the Series, decided from the owner's side and
|
||||
// derived on the wire (issue #157); the panel only labels it.
|
||||
function finishedLabel(b) {
|
||||
return b && b.finished ? "Finished" : "";
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Retry queue
|
||||
//
|
||||
@@ -888,8 +895,8 @@
|
||||
}
|
||||
|
||||
// Archive parks a series: it leaves All and Favourites but the server keeps
|
||||
// polling it for new chapters. "finished" is deliberately not reachable from
|
||||
// here — the API rejects that value, it is a web-UI decision.
|
||||
// polling it for new chapters. finished is not a bucket the script can
|
||||
// reach — it is a fact about the Series now (issue #157).
|
||||
async function toggleArchive(key) {
|
||||
const existing = state.byKey[key];
|
||||
if (!existing) return;
|
||||
@@ -966,7 +973,6 @@
|
||||
const now = Date.now();
|
||||
const due = state.list
|
||||
.filter((b) => b.site === site && b.series_url)
|
||||
.filter((b) => statusOf(b) !== "finished")
|
||||
.filter((b) => now - (checked[b.key] || 0) >= LATEST_CHECK_THROTTLE_MS)
|
||||
.sort((a, b) => (checked[a.key] || 0) - (checked[b.key] || 0))
|
||||
.slice(0, LATEST_CHECK_BATCH);
|
||||
@@ -1335,8 +1341,8 @@
|
||||
}
|
||||
|
||||
// Tabs narrow what is drawn; state.list always holds every bookmark.
|
||||
// Archived rows are hidden from All and Favourites, and finished ones —
|
||||
// which only the web UI can set — are hidden from every tab.
|
||||
// Archived rows are hidden from All and Favourites; a Series that is
|
||||
// finished is the web UI's business and arrives archived (issue #157).
|
||||
for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) {
|
||||
root.getElementById(id).classList.toggle("active", activeTab === tab);
|
||||
}
|
||||
@@ -1399,6 +1405,7 @@
|
||||
el("div", { class: "meta" }, [
|
||||
el("a", { class: "go-t t", href: cont, text: b.title || b.series_id }),
|
||||
el("div", { class: "c" + (behind ? " behind" : ""), text: sub }),
|
||||
b.finished && el("span", { class: "finished", text: finishedLabel(b) }),
|
||||
el("div", { class: "actions" }, [
|
||||
el("button", {
|
||||
class: "btn small star" + (b.favorite ? " on" : ""),
|
||||
@@ -1669,7 +1676,7 @@
|
||||
--paper: #f2ece5; --paper-hot: #f0d3cb; --paper-dim: #ddd5cb;
|
||||
--mute: #8d857c; --mute-2: #5a5450; --faint: #3a3733; --faint-2: #57504b;
|
||||
--ember: #e0452c; --ember-wash: #1a1211; --ember-ink: #150907;
|
||||
--ember-soft: #eda798; --brass: #b8912f; --trash: #6b5450;
|
||||
--ember-soft: #eda798; --brass: #b8912f; --trash: #6b5450; --moss: #7fae86;
|
||||
--font-display: Georgia, "Times New Roman", serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
--font-body: system-ui, -apple-system, sans-serif;
|
||||
@@ -1788,6 +1795,12 @@
|
||||
font: 400 17px/1.2 var(--font-display); color: var(--paper-dim);
|
||||
max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
/* finished: a Series fact, not a state class (issue #157) — same meta
|
||||
typography as .c, moss instead of mute. */
|
||||
.finished {
|
||||
margin: 0; color: var(--moss);
|
||||
font: 500 10px/1.4 var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
|
||||
}
|
||||
.item.hot .t { color: var(--paper-hot); border-bottom: 1px solid var(--ember); padding-bottom: 3px; }
|
||||
.item.dim .t { font-style: italic; color: var(--mute); }
|
||||
.c {
|
||||
@@ -1838,7 +1851,7 @@
|
||||
// Exposes pure logic only — see userscript/test/logic.test.js.
|
||||
// ============================================================
|
||||
if (typeof window === "undefined" && typeof module === "object" && module.exports) {
|
||||
module.exports = { stripBuildHash, comixSeriesId, asura, demonic, comix, kagane, anchorsFromHTML, statusOf, kindOf };
|
||||
module.exports = { stripBuildHash, comixSeriesId, asura, demonic, comix, kagane, anchorsFromHTML, statusOf, kindOf, finishedLabel };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -268,8 +268,9 @@
|
||||
// Duplicate case (spec user story 2): one row must survive, and it is
|
||||
// the one already under the repaired key — with the stale row's
|
||||
// progress carried across when it is ahead, favourite OR'd, and the
|
||||
// stronger lifecycle bucket kept (finished > archived > reading, so a
|
||||
// merge can never silently un-archive or un-finish a row).
|
||||
// stronger lifecycle bucket kept (archived > reading, so a merge can
|
||||
// never silently un-archive a row). finished is not a bucket the
|
||||
// script can see (issue #157).
|
||||
const merged = Object.assign({}, existing);
|
||||
if (
|
||||
existing.last_chapter_num == null ||
|
||||
@@ -280,7 +281,7 @@
|
||||
merged.last_chapter_url = stale.last_chapter_url;
|
||||
}
|
||||
merged.favorite = !!(existing.favorite || stale.favorite);
|
||||
const rank = (s) => ({ finished: 2, archived: 1, reading: 0 }[s || "reading"] || 0);
|
||||
const rank = (s) => ({ archived: 1, reading: 0 }[s || "reading"] || 0);
|
||||
merged.status = rank(stale.status) > rank(existing.status) ? stale.status : existing.status;
|
||||
merged.updated_at = Math.max(existing.updated_at || 0, stale.updated_at || 0);
|
||||
listOut = list.filter((b) => b.key !== oldKey).map((b) => (b.key === newKey ? merged : b));
|
||||
@@ -405,9 +406,10 @@
|
||||
}
|
||||
|
||||
// Only an explicit archive/restore has an opinion about the bucket. Every
|
||||
// other write omits `status`, so the server keeps the stored one — otherwise
|
||||
// a cached value would resend "finished" (which the API rejects with 400) or
|
||||
// silently un-archive a series archived on another device.
|
||||
// other write omits `status`, so the server keeps the stored one —
|
||||
// otherwise a cached value would resend a stale status (the API rejects
|
||||
// unknown values with 400) or silently un-archive a series archived on
|
||||
// another device.
|
||||
async function apiPut(key, obj, { sendStatus = false } = {}) {
|
||||
const body = Object.assign({}, obj);
|
||||
if (!sendStatus) delete body.status;
|
||||
@@ -484,6 +486,12 @@
|
||||
return b.kind || "manga";
|
||||
}
|
||||
|
||||
// finished is a fact about the Series, decided from the owner's side and
|
||||
// derived on the wire (issue #157); the panel only labels it.
|
||||
function finishedLabel(b) {
|
||||
return b && b.finished ? "Finished" : "";
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Retry queue
|
||||
//
|
||||
@@ -792,8 +800,8 @@
|
||||
}
|
||||
|
||||
// Archive parks a series: it leaves All and Favourites but the server keeps
|
||||
// polling it for new chapters. "finished" is deliberately not reachable from
|
||||
// here — the API rejects that value, it is a web-UI decision.
|
||||
// polling it for new chapters. finished is not a bucket the script can
|
||||
// reach — it is a fact about the Series now (issue #157).
|
||||
async function toggleArchive(key) {
|
||||
const existing = state.byKey[key];
|
||||
if (!existing) return;
|
||||
@@ -878,7 +886,6 @@
|
||||
const now = Date.now();
|
||||
const due = state.list
|
||||
.filter((b) => b.site === site && b.series_url)
|
||||
.filter((b) => statusOf(b) !== "finished")
|
||||
.filter((b) => now - (checked[b.key] || 0) >= LATEST_CHECK_THROTTLE_MS)
|
||||
.sort((a, b) => (checked[a.key] || 0) - (checked[b.key] || 0))
|
||||
.slice(0, LATEST_CHECK_BATCH);
|
||||
@@ -1241,8 +1248,8 @@
|
||||
}
|
||||
|
||||
// Tabs narrow what is drawn; state.list always holds every bookmark.
|
||||
// Archived rows are hidden from All and Favourites, and finished ones —
|
||||
// which only the web UI can set — are hidden from every tab.
|
||||
// Archived rows are hidden from All and Favourites; a Series that is
|
||||
// finished is the web UI's business and arrives archived (issue #157).
|
||||
for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) {
|
||||
root.getElementById(id).classList.toggle("active", activeTab === tab);
|
||||
}
|
||||
@@ -1305,6 +1312,7 @@
|
||||
el("div", { class: "meta" }, [
|
||||
el("a", { class: "go-t t", href: cont, text: b.title || b.series_id }),
|
||||
el("div", { class: "c" + (behind ? " behind" : ""), text: sub }),
|
||||
b.finished && el("span", { class: "finished", text: finishedLabel(b) }),
|
||||
el("div", { class: "actions" }, [
|
||||
el("button", {
|
||||
class: "btn small star" + (b.favorite ? " on" : ""),
|
||||
@@ -1593,7 +1601,7 @@
|
||||
--paper: #f2ece5; --paper-hot: #f0d3cb; --paper-dim: #ddd5cb;
|
||||
--mute: #8d857c; --mute-2: #5a5450; --faint: #3a3733; --faint-2: #57504b;
|
||||
--ember: #e0452c; --ember-wash: #1a1211; --ember-ink: #150907;
|
||||
--ember-soft: #eda798; --brass: #b8912f; --trash: #6b5450;
|
||||
--ember-soft: #eda798; --brass: #b8912f; --trash: #6b5450; --moss: #7fae86;
|
||||
--font-display: Georgia, "Times New Roman", serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
--font-body: system-ui, -apple-system, sans-serif;
|
||||
@@ -1719,6 +1727,12 @@
|
||||
font: 500 10px/1.4 var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
|
||||
}
|
||||
.c.behind { color: var(--ember); }
|
||||
/* finished: a Series fact, not a state class (issue #157) — same meta
|
||||
typography as .c, moss instead of mute. */
|
||||
.finished {
|
||||
margin: 0; color: var(--moss);
|
||||
font: 500 10px/1.4 var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
|
||||
}
|
||||
.actions { display: flex; gap: 0; flex-wrap: wrap; margin-top: 2px; }
|
||||
.btn {
|
||||
background: none; color: var(--mute); border: 1px solid var(--rule);
|
||||
@@ -1762,7 +1776,7 @@
|
||||
// Exposes pure logic only — see userscript/test/novel-logic.test.js.
|
||||
// ============================================================
|
||||
if (typeof window === "undefined" && typeof module === "object" && module.exports) {
|
||||
module.exports = { novelfull, lightnovelworld, anchorsFromHTML, statusOf, kindOf, maxChapter, escapeRe, computeLatestChapter, repairLnwStaleRow };
|
||||
module.exports = { novelfull, lightnovelworld, anchorsFromHTML, statusOf, kindOf, maxChapter, escapeRe, computeLatestChapter, repairLnwStaleRow, finishedLabel };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -58,6 +58,7 @@ const {
|
||||
anchorsFromHTML,
|
||||
statusOf,
|
||||
kindOf,
|
||||
finishedLabel,
|
||||
} = require("../manga-bookmark.user.js");
|
||||
|
||||
// detect() reads only these four properties off location.
|
||||
@@ -395,7 +396,18 @@ test("statusOf defaults a missing status to reading", () => {
|
||||
assert.equal(statusOf({}), "reading");
|
||||
assert.equal(statusOf({ status: "" }), "reading");
|
||||
assert.equal(statusOf({ status: "archived" }), "archived");
|
||||
assert.equal(statusOf({ status: "finished" }), "finished");
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// finishedLabel — finished is a fact about the Series (issue #157), which is
|
||||
// the server's business; the panel only names it. The label is text through
|
||||
// the el() helper, never markup.
|
||||
// ============================================================
|
||||
|
||||
test("finishedLabel names a finished series and nothing else", () => {
|
||||
assert.equal(finishedLabel({ finished: true }), "Finished");
|
||||
assert.equal(finishedLabel({ finished: false }), "");
|
||||
assert.equal(finishedLabel({}), "");
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -49,6 +49,7 @@ const {
|
||||
kindOf,
|
||||
maxChapter,
|
||||
repairLnwStaleRow,
|
||||
finishedLabel,
|
||||
} = require("../novel-bookmark.user.js");
|
||||
|
||||
function loc(href) {
|
||||
@@ -563,6 +564,23 @@ test("repairLnwStaleRow does not regress progress when the repaired-key row is a
|
||||
assert.equal(out.list[0].last_chapter_num, 100);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Merge rank — exactly two values: archived beats reading, and nothing else
|
||||
// can ever win (issue #157 retired the finished bucket).
|
||||
// ============================================================
|
||||
|
||||
test("repairLnwStaleRow merge keeps archived over reading and lets no third value win", () => {
|
||||
const canonical = (over) => staleRow(Object.assign({ key: "lightnovelworld:immortality-simulator" }, over));
|
||||
// archived beats reading whether it arrives as the stale row or the repaired one
|
||||
let out = repairLnwStaleRow([canonical({ status: "archived" }), staleRow({ status: "reading" })], [], {}, lnwPage());
|
||||
assert.equal(out.list[0].status, "archived");
|
||||
out = repairLnwStaleRow([staleRow({ status: "archived" }), canonical({ status: "reading" })], [], {}, lnwPage());
|
||||
assert.equal(out.list[0].status, "archived");
|
||||
// "finished" is not a bucket anymore: a row carrying it reads as the
|
||||
// default, so it can never win (issue #157).
|
||||
out = repairLnwStaleRow([staleRow({ status: "finished" }), canonical({ status: "reading" })], [], {}, lnwPage());
|
||||
assert.equal(out.list[0].status, "reading");
|
||||
});
|
||||
// ============================================================
|
||||
// kindOf
|
||||
// ============================================================
|
||||
@@ -575,6 +593,18 @@ test("kindOf passes through novel", () => {
|
||||
assert.equal(kindOf({ kind: "novel" }), "novel");
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// finishedLabel — finished is a fact about the Series (issue #157), which is
|
||||
// the server's business; the panel only names it. The label is text through
|
||||
// the el() helper, never markup.
|
||||
// ============================================================
|
||||
|
||||
test("finishedLabel names a finished series and nothing else", () => {
|
||||
assert.equal(finishedLabel({ finished: true }), "Finished");
|
||||
assert.equal(finishedLabel({ finished: false }), "");
|
||||
assert.equal(finishedLabel({}), "");
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Source guard
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user