Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a83161b1c | |||
| cddd16bcdc | |||
| c1616b3162 | |||
| e1ba7fdabb | |||
| 2ac1f0c507 | |||
| e93e79c1bb | |||
| 8e4fa6448e | |||
| faa80c41ea | |||
| 4aaf1d4f91 | |||
| 889f0f3f38 | |||
| 0a245a0dde | |||
| 249f11e1fe | |||
| f5d3fe58ec | |||
| 766aa8f00d | |||
| 550b258c59 | |||
| 3ac865cd08 |
+1
-20
@@ -1,24 +1,5 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "CMD=$(python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('tool_input',d).get('command',''))\" 2>/dev/null || true); case \"$CMD\" in *grep*|*rg\\ *|*ripgrep*|*find\\ *|*fd\\ *|*ack\\ *|*ag\\ *) [ -f graphify-out/graph.json ] && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"MANDATORY: graphify-out/graph.json exists. You MUST run `graphify query \\\"<question>\\\"` before grepping raw files. Only grep after graphify has oriented you, or to modify/debug specific lines.\"}}' || true ;; esac"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Read|Glob",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "HIT=$(python3 -c \"import json,sys;d=json.load(sys.stdin);t=d.get('tool_input',d);exts=('.py','.js','.ts','.tsx','.jsx','.astro','.vue','.svelte','.go','.rs','.java','.rb','.c','.h','.cpp','.hpp','.cc','.cs','.kt','.swift','.php','.scala','.lua','.sh','.md','.rst','.txt','.mdx');vals=[str(t.get('file_path') or ''),str(t.get('pattern') or ''),str(t.get('path') or '')];j=' '.join(vals).lower().replace(chr(92),'/');tails=[('.'+x.rsplit('.',1)[-1]) for v in vals if v for x in [v.lower().replace(chr(92),'/').rsplit('/',1)[-1]] if '.' in x];sys.stdout.write('1' if 'graphify-out/' not in j and any(tl in exts for tl in tails) else '')\" 2>/dev/null || true); if [ \"$HIT\" = 1 ] && [ -f graphify-out/graph.json ]; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"MANDATORY: graphify-out/graph.json exists. You MUST run graphify before reading source files. Use: `graphify query \\\"<question>\\\"` (scoped subgraph), `graphify explain \\\"<concept>\\\"`, or `graphify path \\\"<A>\\\" \\\"<B>\\\"`. Only read raw files after graphify has oriented you, or to modify/debug specific lines. This rule applies to subagents too \u2014 include it in every subagent prompt involving code exploration.\"}}'; fi || true"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
"PreToolUse": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: gitea
|
||||
description: Use for every forge operation in this repo — read, create, comment on, label, close, or search an issue; create, review, merge, or check out a PR; and whenever `gh`, `issue://`, or `pr://` fails or a ticket number is ambiguous. This repo's forge is self-hosted Gitea driven by `tea`, not GitHub.
|
||||
---
|
||||
|
||||
# Gitea, not GitHub
|
||||
|
||||
`origin` is the self-hosted Gitea instance `gitea.violetcrown.my.id`, repo
|
||||
`sulthan/mangaBookmark`. Everything past plain git goes through
|
||||
[`tea`](https://gitea.com/gitea/tea) (0.14.2 on this machine).
|
||||
|
||||
**`gh` is not installed**, so the harness's `issue://<n>` and `pr://<n>` URIs
|
||||
error out (`GitHub CLI (gh) is not installed`, measured 2026-08-17). There is no
|
||||
fallback to add — read tickets with `tea`.
|
||||
|
||||
`tea` infers the repo from `origin`; auth lives in `tea login`, never a
|
||||
`GH_TOKEN`. Your Gitea username comes from `tea login list` — `tea` has no `@me`.
|
||||
|
||||
Flags are the environment's job: run `tea <command> --help` rather than trusting
|
||||
a remembered flag. This file carries only what `--help` will not tell you.
|
||||
|
||||
## Commands
|
||||
|
||||
| Job | Command |
|
||||
|---|---|
|
||||
| Read | `tea issue <n> --comments` / `tea pr <n> --comments` — `--comments` is not optional |
|
||||
| List | `tea issue list --state open\|closed\|all -o json --fields index,title,body,labels,state,author` |
|
||||
| Search | `tea issue list -k "<keyword>" -L "<label>" -A "<author>"` (`-K all` also searches PRs) |
|
||||
| Create | `tea issue create -t "..." -d "..."` (`-L`, `-a` optional) |
|
||||
| Comment | `tea comment <n> "..."` |
|
||||
| Label | `tea issue edit <n> --add-labels "..."` / `--remove-labels "..."` |
|
||||
| Close | `tea issue close <n>` / `tea pr close <n>` |
|
||||
| PR | `tea pr create --head <branch> --base main -t "..." -d "..."`, `tea pr checkout <n>`, `tea pr review <n>`, `tea pr merge <n>` |
|
||||
|
||||
## Traps
|
||||
|
||||
- **A bare read is a truncated read.** Without `--comments`, `tea issue <n>` and
|
||||
`tea pr <n>` print the opening body and drop every comment silently — no
|
||||
prompt, no marker, no hint that more exists (measured 2026-08-17: issue #123
|
||||
prints 40 lines bare, 132 with `--comments`). The comments are where the
|
||||
decisions live and the body is usually the stalest part of the ticket, so
|
||||
**every read that exists to understand an issue or PR passes `--comments`**,
|
||||
and understanding means body plus all comments plus whatever ticket they point
|
||||
at. Comment count is `tea issue list --fields index,comments`, so a read that
|
||||
shows fewer than that is incomplete. A PR's review comments are a second
|
||||
stream: `tea pr review-comments <n>`.
|
||||
- **One index space for issues and PRs.** A bare `#42` may be either: try
|
||||
`tea pr 42`, fall back to `tea issue 42`. Say which one you found.
|
||||
- **Output is rendered boxes**, not plain text. Anything you parse needs
|
||||
`-o json`, plus `--fields` to keep the payload small. `tea pr create` prints
|
||||
the PR URL on its last line.
|
||||
- **`close` takes no `--comment`.** Comment with `tea comment <n>`, then close.
|
||||
- **Gitea will not auto-create a label.** `tea labels list` first; missing one
|
||||
gets `tea labels create --name "..." --color "#rrggbb"` before the `edit`.
|
||||
- **Multi-line bodies go through a heredoc**, never inline escapes:
|
||||
```bash
|
||||
tea issue create -t "Title" -d "$(cat <<'EOF'
|
||||
body line one
|
||||
|
||||
- acceptance criterion
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
- **No sub-issue and no dependency command.** Gitea's API has issue
|
||||
dependencies, `tea` does not expose them, so parentage and blocking live as
|
||||
body lines — the shapes are in `docs/agents/issue-tracker.md`.
|
||||
|
||||
## Conventions this repo layers on top
|
||||
|
||||
Ticket bodies, wayfinding issues, and the PR-as-request-surface flag:
|
||||
`docs/agents/issue-tracker.md`. Triage label strings: `docs/agents/triage-labels.md`.
|
||||
A label named there still has to exist in the tracker before `--add-labels`.
|
||||
|
||||
Finish a forge action by stating the number you touched and its state after —
|
||||
"commented and closed #71" — so the write is checkable without a second query.
|
||||
@@ -1,109 +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 `tea` usage: `docs/agents/issue-tracker.md`. Codebase
|
||||
questions: `graphify query "<question>"` before grepping.
|
||||
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:
|
||||
@@ -121,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`.
|
||||
+12
-2
@@ -55,13 +55,17 @@ DISCORD_CLIENT_SECRET=
|
||||
# The guild whose membership gates sign-in (Developer Mode -> right-click the
|
||||
# server -> Copy Server ID).
|
||||
DISCORD_GUILD_ID=
|
||||
# Optional: human-readable name for that guild, shown on the login screen so a
|
||||
# stranger knows which community owns this library and who to ask for an
|
||||
# invite. When unset the page falls back to a generic "private community"
|
||||
# label.
|
||||
# DISCORD_GUILD_NAME=
|
||||
# Exact callback URL, e.g. https://bookmark.example.com/auth/discord/callback.
|
||||
# Discord matches it verbatim, so it must equal the registered redirect.
|
||||
DISCORD_REDIRECT_URI=
|
||||
# Optional: a role snowflake members must hold on top of guild membership.
|
||||
# Empty (the default) means membership alone suffices.
|
||||
# DISCORD_REQUIRED_ROLE=
|
||||
|
||||
# Subdomain Traefik routes to the browser UI (required by the prod override).
|
||||
# Left commented on purpose: an example value here would be a silent
|
||||
# wrong-hostname fallback, and Traefik would publish the UI router on a domain
|
||||
@@ -100,7 +104,13 @@ DISCORD_REDIRECT_URI=
|
||||
# every kagane poll. Left unset here on purpose — a wrong default would poll a
|
||||
# stranger's address, and "no browser" is a safe, self-announcing state.
|
||||
# BROWSER_WS_URL=ws://100.x.y.z:9222
|
||||
|
||||
#
|
||||
# Discord webhook for owner notices (outbound alerting when a poll Lane
|
||||
# stalls). Unset means the whole path is off — a local stack needs no webhook,
|
||||
# exactly as the browser URL behaves. The address is a secret in the class of
|
||||
# TOKEN_KEY: never commit it, never paste it anywhere public.
|
||||
# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
||||
#
|
||||
# Zone the backend stamps its log lines in. Cosmetic only. Nothing else in
|
||||
# the service has a zone: bookmark timestamps are unix ms, and the two real
|
||||
# time columns are timestamptz. Defaults to Asia/Jakarta; set to UTC for the
|
||||
|
||||
@@ -5,11 +5,6 @@
|
||||
backend/server
|
||||
backend/backend
|
||||
.playwright-mcp/
|
||||
# graphify map is committed; only regenerable/local parts are ignored
|
||||
graphify-out/cost.json
|
||||
graphify-out/cache/
|
||||
graphify-out/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/
|
||||
graphify-out/.rebuild.lock
|
||||
plans/
|
||||
.scratch/
|
||||
docs/superpowers/
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
---
|
||||
target: backend/internal/web (admin surface)
|
||||
total_score: 22
|
||||
max_score: 40
|
||||
na_heuristics:
|
||||
p0_count: 1
|
||||
p1_count: 2
|
||||
timestamp: 2026-08-27T07-53-05Z
|
||||
slug: backend-internal-web-templates-admin-html
|
||||
---
|
||||
Method: dual-agent (A: AssessDesign · B: AssessEvidence)
|
||||
|
||||
## Design Health Score
|
||||
|
||||
| # | Heuristic | Score | Key Issue |
|
||||
|---|-----------|-------|-----------|
|
||||
| 1 | Visibility of System Status | 2 | Domain status is excellent; *interaction* status is absent — no `hx-indicator`/`hx-disabled-elt` in any admin template (only `card.html:52,71,85,108,119` has them). |
|
||||
| 2 | Match System / Real World | 3 | `Due`/`Checked`/`Gap` (`lanes.html:24`) are unlabeled integers and a raw Go duration (`admin_lanes.go:229-231`); `Never chk` (`overview.html:13`). |
|
||||
| 3 | User Control and Freedom | 2 | Only Finish has Cancel + reversal (`series-detail.html:51-57`). Clear marks, Revoke, both Removes and both `Set` writes are one-way, and the old chapter survives only as a `placeholder` (`series-detail.html:19`). |
|
||||
| 4 | Consistency and Standards | 2 | "Needs attention" is `--patina` on the Series list (`admin.css:285-287`) and `--danger` on Lanes/Sites (`admin.css:189-191`, `:409-413`); sibling controls are `<a href="#">` vs `<button>` (`series-list.html:51`); three duration formatters. |
|
||||
| 5 | Error Prevention | 2 | Server-side gating is strong (`admin_lanes.go:82-90`, `admin_series.go:315-319`, `readers.html:36-42`), but `.ghost::after` inflates each target by 12px horizontally (`style.css:250`) against 12px/18px sibling gaps (`admin.css:303-305`, `:118-124`) — the overlap resolves to the destructive later sibling. |
|
||||
| 6 | Recognition Rather Than Recall | 2 | `.thead { display:none }` below 899px (`admin.css:826-828`) and the Sites figures carry `Label: ""` from Go (`admin_overview.go:170-173`), so the labels exist at no width on a phone. |
|
||||
| 7 | Flexibility and Efficiency | 3 | Bookmarkable state, figures-as-doors, OOB count refresh (`admin_series.go:503-515`); but the 30s `outerHTML` swap has no `hx-sync` (`lanes.html:12-13`) and there is no title search over 50-per-page (`admin_series.go:21`). |
|
||||
| 8 | Aesthetic and Minimalist Design | 3 | Verified restrained on screenshot; the charge is density on the landing page — 11 hygiene figures + 4 library figures + a 6-column table before any content (`overview.html:9-13`). |
|
||||
| 9 | Error Recovery | 1 | Every admin failure is a bare `http.Error`; htmx does not swap non-2xx; `admin.html:15` never loads `filter.js`, whose handlers are `.card`-scoped anyway (`filter.js:133-136`, `:172-187`). No error slot exists in any admin template. |
|
||||
| 10 | Help and Documentation | 2 | Good in-context prose (`readers.html:10-15`, `series-detail.html:17`, `:26`), but nothing explains `Due`, `Gap`, or the six outcome words, and the chips are documented dead ends (`admin_lanes.go:242-246`). |
|
||||
| **Total** | | **22/40** | **Acceptable — significant improvements needed** |
|
||||
|
||||
Adjudication note: Assessment A scored H4 at 1 and H8 at 2; both were raised one step after the parent verified the rendered pages (main flows are consistent and genuinely uncluttered on screenshot — the divergences are detail-level).
|
||||
|
||||
## Design Specificity Verdict
|
||||
|
||||
**Authored at the level of language and judgement; category-interchangeable at the level of composition and interaction.**
|
||||
|
||||
**LLM assessment.** The *writing* could belong to no other product. `laneState` emits sentences — `paused · resumes in 4h20m`, `refusing · backs off until 23:40`, `browser asleep`, `nothing eligible` — where a generic console prints `SKIPPED` (`admin_lanes.go:273-321`). `overviewVerdict` returns `no Lane has reported yet` with `HasCounts=false` so the page omits the counts clause rather than printing confident zeroes (`admin_overview.go:205-217`, `overview.html:7`). `door()` writes count and href as one atom so a figure cannot link to a list with a different number (`admin_overview.go:190-195`). `--patina` is declared as the admin's only accent with a stated reason and spent on the wordmark's `em` so the brand says which surface you are on (`style.css:91-94`, `admin.css:15-21`). `CanPoll`/`CanRemove` are visibility, not disablement (`admin_series.go:668-675`).
|
||||
|
||||
The *layout and interaction* would ship unedited as a Kubernetes admin: topbar + 4-tab underline navrow + micro-label + `repeat(auto-fit, minmax(232px,1fr))` stats grid + wide table (`admin.html:19-46`, `admin.css:30-63`, `:313-318`, `:236-241`). The uppercase-mono micro-label idiom appears at seven sizes between 10px and 13px with tracking from `.04em` to `.2em` (`admin.css:69-79`, `:202-207`, `:328-333`, `:531-536`, `:595-604`, `:732-738`) — a style, not a system: nothing tells the reader which size means what. And the destructive interactions are htmx's default `hx-confirm`, which is conspicuously *not* the pattern the Reader-facing library authored for itself (`card.html:113-121`).
|
||||
|
||||
**Deterministic scan.** CLI detector over all six admin templates: **exit 0, `[]`, zero findings.** Nothing to dismiss as a Go-template false positive.
|
||||
|
||||
**Browser evidence.** All five admin pages were rendered from the repo's own templates via a throwaway Go module with realistic fixtures covering every state the Go code can produce, then measured in Playwright at 390×844 and 1280×900. **No horizontal overflow, no clipping, no overlap on any page at either viewport** (overview `scrollW 375 < 390`; readers `390 == 390`; all `.tbl` grids collapse to flex-wrap under 899px, lanes additionally under 1019px). Injected detector fired 9 `text-overflow` (brand `em` 41px; `span.mark.bad` 33px; `span.ok` 47px; `a.ghost.act` 22px ×5), 22–48 `ai-color-palette` "cyan neon" per page, one `cream-palette`, and `overused-font` at 16–27%. **All are false positives**: the overflows are inline boxes spilling into their own cell's padding inside the border box (`1014 < c-ctrl left 1038`), the "cyan" is every `--patina` usage, the cream is the designed light-branch `--ink` (`style.css:125`), and the font is the deliberate `--font-display` on brand and headings only. The one real signal inside the noise is *frequency*: 22–48 patina elements per page means the admin's single accent is doing a great many different jobs.
|
||||
|
||||
No user-visible overlay is left in a browser — the live server was injected, read, and stopped.
|
||||
|
||||
## Overall Impression
|
||||
|
||||
The thinking behind this surface is better than the surface. Judgement is computed in Go and the template only prints, so the page structurally cannot lie about its own numbers — that is rarer than it sounds and it is why the copy can be as confident as it is. What has not been spent is the *feedback and repair* budget: the owner comes here to fix one wrong row, and the surface will not tell them whether the fix landed, will not label the columns on the device they are holding, and hands the two irreversible actions to a browser dialog while spending a hand-built confirm row on the reversible one.
|
||||
|
||||
**Single biggest opportunity:** make the admin surface as accountable for its own actions as it already is for the poller's. Load the error path, add busy state, and move destruction onto the confirm-row pattern the codebase already owns.
|
||||
|
||||
## What's Working
|
||||
|
||||
1. **Judgement is an atom.** `laneState` returns `(phrase, good, attention)` together (`admin_lanes.go:273-321`); `door()` binds count to href (`admin_overview.go:190-195`); `overviewVerdict` documents that it deliberately answers a *different* question from `laneState` so page and notification cannot contradict each other (`admin_overview.go:197-204`). This eliminates a whole bug class: a colour that disagrees with the sentence beside it.
|
||||
2. **Zeros and unknowns are honest, and the design pays for it.** A measured zero loses its link and drops to `--mute` (`admin_overview.go:190-195`, `admin.css:341-343`); a virgin pass log suppresses the counts clause entirely; a failed outcome sum degrades per row to `none observed` instead of blanking the table (`admin_lanes.go:172-180`). Health consoles usually fail here — a page of confident zeroes reading as health.
|
||||
3. **Removal from the list is a complete interaction.** `HX-Reswap: delete`, OOB heading count re-rendered over the press's own filter state, and if a Bookmark raced the press the row swaps *back in* with the reason in-row (`admin_series.go:474-515`, `series-list.html:52`, `:62`). Success, contention and refusal land in the same slot at the same scale. Every other action should look like this.
|
||||
|
||||
## Priority Issues
|
||||
|
||||
**[P0] An htmx failure on the admin surface is completely invisible.**
|
||||
- *Why it matters:* every admin error path is `http.Error` (`admin.go:98`, `:109`, `:136`, `:155`; `admin_lanes.go:118-128`; `admin_series.go:307-322`); htmx does not swap non-2xx; `admin.html:15` loads only `htmx.min.js`, and `filter.js`'s `htmx:responseError`/`htmx:sendError` handlers are both unloaded here *and* `.card`-scoped (`filter.js:133-136`, `:172-187`). Verified: no admin template contains `error-inline`, `hx-indicator` or `hx-disabled-elt`. A rejected chapter correction or a dropped LAN connection produces **zero visual change**. The owner's natural response is to press again, and with no `hx-disabled-elt` the second press is a second write. This is the corrective-task path; it breaks trust exactly where trust is the product.
|
||||
- *Fix:* load `filter.js` from the admin shell (or extract its error module) and generalise `showError`'s `.closest(".card")` to a configurable slot; add `<p class="error-inline" role="status" hidden>` to each `.dform`, `#readers` and the `.trow` — `.row-msg` (`admin.css:672-677`, `series-list.html:52`) already proves the row can carry a message; add `hx-disabled-elt="this"` to every writing control. The `http.Error` bodies are already short human sentences worth showing verbatim.
|
||||
- *Suggested command:* `/impeccable harden`
|
||||
|
||||
**[P1] Below 899px the tables lose their only labels, and the Sites table never had any.**
|
||||
- *Why it matters:* `.thead { display:none }` (`admin.css:826-828`; `:793-795` for lanes at 1019px) with cells flattening to equal-weight left-aligned text. The Sites figures are `door("", …)` ×4 (`admin_overview.go:170-173`), so those labels exist at **no width** on a phone. Measured and screenshot-confirmed: a Lanes row on a phone reads `3 2 4m30s ran 2m ago` — four bare values. The Series grid survives (title owns its line, site is a coloured word, ages self-label as `3h ago`); Lanes degrades; Sites does not survive, and it is on the landing page. The primary device is a phone.
|
||||
- *Fix:* give collapsed cells their labels instead of hiding the header — populate `fig.Label` for site rows (the field exists, `admin_overview.go:43-47`) and render `.trow > *[data-lbl]::before { content: attr(data-lbl) " " }` inside the 899px block in the existing 11px `--mute-2` idiom; same for `Due`/`Checked`/`Gap`; add `.tbl.sites .c-site { width: 100% }` so the site name owns its line as `.c-title` already does.
|
||||
- *Suggested command:* `/impeccable adapt`
|
||||
|
||||
**[P1] Destructive actions are gated by native browser dialogs while the reversible one gets the bespoke confirm.**
|
||||
- *Why it matters:* `hx-confirm` on Clear marks (`readers.html:31`), Revoke sessions (`:39`), list Remove (`series-list.html:51`) and detail Remove (`series-detail.html:40`) — versus a hand-built `.confirm-row calm` with Cancel and `aria-live` for **Finish**, which is reversible via Un-finish (`series-detail.html:51-57`, `admin_series.go:257-294`). Inverted effort. A native dialog cannot carry `--danger`, so "sign this Reader out of every device" and "clear some counters" look identical at the decision moment, and it is OS chrome inside a sheet that has removed every corner and shadow. The full `--danger-wash` + `.danger-solid` pattern already exists and is already used by the Reader-facing card for *its* irreversible remove (`style.css:688-717`, `card.html:113-121`). AGENTS.md states the law directly: any move that pulls a series out of the list must be confirm-gated via its own `.confirm-row`.
|
||||
- *Fix:* move Revoke and both Removes onto `.confirm-row` with `--danger-wash`/`.danger-solid`, Clear marks onto `.calm` (it restores a privilege, `admin.go:142-147`). The confirm copy transfers verbatim. Note `admin.css:307-311` already styles `.tbl .trow > .confirm-row` at `grid-column: 1/-1` — **that rule is dead today**; the CSS is waiting for the markup.
|
||||
- *Suggested command:* `/impeccable harden`
|
||||
|
||||
**[P2] `--danger` is used to mean "system unhealthy", which the token law forbids, and "needs attention" is spoken in two colours.**
|
||||
- *Why it matters:* `style.css:91-94` states `--patina` is the admin page's only accent and that neither ember nor danger may say "system unhealthy". But `admin.css:409-413` and `:189-191` paint `.c-state.bad`, `.c-skip .bad` and `.trow.attention .c-site` in `--danger` — screenshot-confirmed: `refusing · backs off until 14:20` and `not checking` render oxblood, and the attention site names go red — while the identical semantic on the Series list is verdigris (`admin.css:285-287`). Colour is the owner's fastest read, and cross-page it does not resolve. Separately `.c-state`'s base colour is `--patina` with no `.ok` rule, so `no pass yet` — an *unknown* — renders in the healthy accent.
|
||||
- *Fix:* route all attention through `--patina`; reserve `--danger` for Remove/Revoke and their wash. If a second severity tier is genuinely wanted for `refusing`/`not checking`, declare a token in both `:root` branches rather than borrowing destruction's colour. Give `.c-state.ok` an explicit rule and make base `.c-state` neutral so an unlabeled state is not a claim.
|
||||
- *Suggested command:* `/impeccable colorize`
|
||||
|
||||
**[P2] Phone hit targets: the destructive sibling wins ambiguous taps, and the pager is untappable.**
|
||||
- *Why it matters:* `.ghost::after { inset: -15px -12px }` (`style.css:250`) inflates each ghost by 24px of combined horizontal overhang against a 12px gap in `.c-act` (`admin.css:303-305`) and 18px in `.reader-actions` (`:118-124`) — the pseudo-elements overlap and the later sibling paints on top. The later sibling is `Remove` and `Revoke sessions`. Measured at 390px: Check now 76×16, Remove 50×16, Clear marks 92×16, Revoke 126×16, pager `next ›` 49×11, pause `<select>` ~31px tall, `.fig` links 9–34×20–24, navrow links 41–42px high. `.pg.disabled` sits at `--faint` = **1.62:1** — "no next page" reads as "the control is missing". The scene is one thumb, at night.
|
||||
- *Fix:* raise the `.c-act`/`.reader-actions` gap past 26px, or put the destructive control on its own line at phone width; give `.pg`, `.segrow a`, `.fig` and the pause select real 44px boxes inside the 899px block; add `line-height` to `.navrow a`.
|
||||
- *Suggested command:* `/impeccable adapt`
|
||||
|
||||
## Persona Red Flags
|
||||
|
||||
**Alex (power user / single operator — no colleague, no runbook).** The failure chips are permanently non-navigable *by design* (`admin_lanes.go:242-246`), so `refused 14` is a dead end: fourteen refusals and no path to a single affected Series. Nothing on the surface defines `Due`, `Gap`, or the six outcome words. No title search over a 50-row page (`admin_series.go:21`), no bulk action, no keyboard path to anything but tab order.
|
||||
|
||||
**Sam (accessibility / low-vision).** `aria-live="polite"` wraps the self-refreshing Lanes block (`admin.html:38`), which replaces itself every 30s — a screen reader re-announces the whole table twice a minute — while `#readers` and `#detail-meta`, the fragments that change *because the owner acted*, have no live region at all. The politeness is on the wrong element. Revoke's only success signal is the button's absence. The micro-label idiom bottoms out at 10px uppercase mono with `.2em` tracking in `--mute-2` (`admin.css:708-720`, `:732-738`) — `#877f76` is 4.86:1 on `--ink`, and `style.css:67-68` says that budget was set for 10px mono specifically, i.e. it is at the edge, not above it. On banded rows `--danger` measures 4.46:1 and `--mute-2` 4.49:1 against `--hover` (`admin.css:495-497`) — both just under AA.
|
||||
|
||||
**Casey (thumb-only mobile).** Fails the hit-target cluster above, and specifically the **30s refresh racing the pause select**: `lanes.html:12-13` swaps `#lanes` outerHTML every 30s with no `hx-sync` while the duration is a native `<select>` inside that fragment (`lanes.html:47-49`). Open the picker, scroll to `24h`, straddle a refresh boundary — the select is rebuilt at its `selected` default of `6h`, silently. The owner then gets six hours and a perfectly accurate confirmation phrase saying so.
|
||||
|
||||
**Project-specific — the owner on a phone, at night, mid-chapter, fixing one wrong row.** The surface's true primary scene, and it fails in sequence: (1) *finding* the row — filter/site/kind but no title search, so they page through 50-row pages using the 11px zero-padding pager whose disabled state is invisible at 1.62:1; (2) *reaching* the fix — the correction form exists only on the detail page (`series-detail.html:15-23`), one hop past a list row whose action slot already carries two controls; (3) *knowing it worked* — the P0 case exactly. And the one visual anchor confirming "yes, this is the right series" is squashed: `style.css:489` sets `height: calc(var(--cover-w) * 4/3)` = 124px, which `admin.css:708-711` never overrides while setting `width: 160px` and `aspect-ratio: 3/4` — both dimensions definite, so `aspect-ratio` is ignored and portrait artwork renders as a **160×124 landscape crop**. Confirmed on the rendered page.
|
||||
|
||||
## Minor Observations
|
||||
|
||||
- **"1 readers."** `series-detail.html:72` prints `{{.Readers}} readers` over an `int` (`admin_series_detail.go:30`) — screenshot-confirmed on the detail page. `readers.html:20` pluralizes `session{{if ne .Sessions 1}}s{{end}}` correctly four files away.
|
||||
- **First-run Overview has no empty state.** No `{{else}}` on any of the three blocks; a fresh install renders the verdict line, 11 zeros, 4 zeros, then a `.tbl.sites` **header row with nothing under it**. `lanes.html:56-58` and `series-list.html:34` both guard properly; the one page a new owner sees first does not.
|
||||
- **~60 lines of dead CSS.** `.lanelist`/`.lane-site`/`.lane-fact`/`.lane-mark`/`.lane-browser` (`admin.css:100-116`, `:141-143`, `:160-191`) — `lanes.html` uses `.tbl.lanes`. `.tbl .trow > .confirm-row` (`:307-311`) has no markup. `.c-state.ok` is emitted but unstyled. `.act` is applied at `series-list.html:51` and `series-detail.html:35` and **defined nowhere**.
|
||||
- **`admin.css:5-9`** is a `prefers-color-scheme: light` block redefining `--measure-wide` to the identical `1080px`. Delete it; it teaches the next reader a lie about there being a width to keep in sync.
|
||||
- **The no-cover placeholder has typography but no content.** `.cover` gets a centred 10px uppercase `--mute-2` label idiom (`admin.css:708-720`); `series-detail.html:12` renders an empty hatched box. `NoCover` already exists (`admin_series_detail.go:142`) — print it, or drop the styling.
|
||||
- **`Check now` is an `<a href="#">` carrying `hx-post`** (`series-list.html:51`, `series-detail.html:35`) while `Remove` beside it is a real `<button>`. Long-press, middle-click and no-JS keyboard activation all navigate to `#`.
|
||||
- **Three duration formatters**, one of them the stdlib's: `since()` prints raw Go durations (`admin_lanes.go:344-350`) so `Gap` reads `1m30s`, while `checkedAge` (`admin_series.go:748-765`) and `humanDuration` read like English.
|
||||
- **The Overview's 11 hygiene labels are the Series page's 11 Show options** (`admin_series.go:27-40`) in two shapes; the select already carries the counts *and* attaches narrowing, which is arguably the better of the two presentations.
|
||||
- **`.stat` rows have no separators** (`admin.css:320-326`). On the rendered desktop page a 4-column row reads `NO SERIES URL 3 NEVER READ A CHAPTER 0` — figure and next label collide into one phrase with only a 16px gap to separate them.
|
||||
- **`readers.html` has no empty state.** Unreachable in practice (the owner is always in the roster), but both siblings guard.
|
||||
|
||||
## Questions to Consider
|
||||
|
||||
1. **Why does the Overview exist?** Fifteen figures and a table, where 11 figures duplicate a select that already shows the same counts *with narrowing attached*. Strip it to the verdict line, the four Library figures and the Sites table — what is lost?
|
||||
2. **If a chip can never be a door, should it still be a count?** `refused 14` invites a tap that will never exist (`admin_lanes.go:242-246`). Is the honest answer `refused · 14 attempts`, or that Lanes needs the per-Series drill-down the taxonomy currently cannot provide?
|
||||
3. **The surface built a Cancel for Finish and handed Revoke to the browser.** If the reasoning is that Revoke's `hx-confirm` sentence is already unambiguous, why does the Reader-facing library still spend a `.confirm-row` on *its* remove? Which surface has the wrong standard?
|
||||
4. **What is `--danger` for?** The token says destruction; the law says only `--patina` may say "system unhealthy"; the CSS paints `refusing` in danger. Either a third severity token gets declared in both branches, or Lanes comes back under the law. Which — and how did the surface run this long with both readings?
|
||||
5. **Is 30 seconds a design decision or a default?** It polls tab-hidden, clobbers a half-operated select, and on a dimmed phone nobody sees it. Would `refreshed 40s ago · tap to refresh` be cheaper *and* more honest?
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
target: backend/internal/web
|
||||
total_score: 27
|
||||
max_score: 40
|
||||
na_heuristics:
|
||||
p0_count: 1
|
||||
p1_count: 2
|
||||
timestamp: 2026-08-27T12-24-48Z
|
||||
slug: backend-internal-web
|
||||
---
|
||||
Method: dual-agent (A: AssessmentA/designer · B: AssessmentB/task — B crashed before yielding; its detector artifacts were recovered from `history://AssessmentB` and `/tmp/impeccable-critique`, and the browser overlay pass was re-run in the parent)
|
||||
|
||||
## Design Health Score
|
||||
|
||||
| # | Heuristic | Score | Key Issue |
|
||||
|---|-----------|-------|-----------|
|
||||
| 1 | Visibility of System Status | 3 | Busy hairline, per-card error slots, 15s htmx timeout, OOB chrome refresh all good — but a *successful* mutation announces nothing, and `hx-delete` swaps the card for an empty body in total silence (`card.html:124-134`, `web.go:569-573`). |
|
||||
| 2 | Match System / Real World | 3 | Reader copy is human ("Chapter you're on", `card.html:95`); the owner's view leaks implementation vocabulary — "Sighting counters", "deferral blocked", "Lanes", "band" (`readers.html:10-14`, `lanes.html:15`). |
|
||||
| 3 | User Control and Freedom | 2 | Esc closes panels and restores focus (`filter.js:120-150`); removal is irreversible with no undo (`web.go:558-573`), and `readers.html:53` Cancel restores neither `aria-expanded` nor focus. |
|
||||
| 4 | Consistency and Standards | 2 | Three confirm implementations: focus-managed JS (`card.html:70,83`), bare inline `onclick` (`readers.html:31,34`; `series-detail.html:47,70`), native `confirm()` (`setup.html:29`). |
|
||||
| 5 | Error Prevention | 3 | `max="9999"` guard, no-op submit is a no-write (`web.go:527-531`), restore fires instantly *because* reversible. Docked for `onchange="this.form.submit()"` (`series-list.html:9,12`) — WCAG 3.2.2 On-Input. |
|
||||
| 6 | Recognition Rather Than Recall | 3 | The permanent action key answers the unlabelled icon strip (`chrome.html:35-45`) but teaches a second vocabulary: "Delete" vs `aria-label="Remove"`, "Read" vs "Continue reading", "Fav" vs "Toggle favourite". |
|
||||
| 7 | Flexibility and Efficiency | 2 | Esc is the only accelerator. No skip link, no `/`-to-search, no bulk action, no sort; 200 series get one substring filter. |
|
||||
| 8 | Aesthetic and Minimalist Design | 3 | Containerless sheets, one `--measure`, three faces each with one job — but **563px of chrome measured** before the first card at 390×844, and the inert action key renders as a row of buttons. |
|
||||
| 9 | Error Recovery | 3 | `filter.js:180-250` is the best-designed code here: 401 → "Session expired — nothing was saved" + login link, echoes the server's 400 reason, no auto-dismiss with the rationale written down. Docked: the echoed reasons are engineer strings ("missing key", "invalid status", `web.go:446,456,513`). |
|
||||
| 10 | Help and Documentation | 3 | Setup panel is honest documentation incl. the Violentmonkey-on-mobile caveat (`setup.html:17-20`); empty states teach. Docked: Lanes/Overview ship an ops vocabulary with zero explanation. |
|
||||
| **Total** | | **27/40** | **Acceptable — significant improvements needed** |
|
||||
|
||||
## Design Specificity Verdict
|
||||
|
||||
**LLM assessment — split: the reader library is authored; `/admin` is a generic ops console wearing Cinder tokens.**
|
||||
|
||||
Library-side evidence that could not be lifted into another product: `--danger` exists as a separate token *specifically* so a remove confirm is never misread as an unread chapter across a dark room (`style.css:77-79`); the busy indicator is a grey hairline that deliberately refuses the accent (`style.css:840-852`); source tokens are named for the actual scraped sites (`style.css:110-117`); `--hatch` exists because `og:image` is often absent and the slot must stay honest rather than fake artwork (`style.css:119-122`); the monogram sits *under* the `<img>` unconditionally so a stored-then-404'd cover degrades to a letter (`card.html:9-19`); `overflow-wrap: anywhere` is justified by a measured 789px-wide `og:title`; `step="any"` by a real series read to 1200.25; the Updated tab exists only for manga because novels have no poller (`app.html:64-70`).
|
||||
|
||||
Admin-side: Overview is a stat grid plus a site table (`overview.html:7-15`); Series is a filter bar with two auto-submitting selects, a paged table and `‹ prev / next ›` (`series-list.html:7-70`). Strip `--patina` and it is any 2014 Rails admin. It also contradicts PRODUCT.md's own principle that the owner's panel is "one list with one button, not an admin console."
|
||||
|
||||
**Deterministic scan.** `node detect.mjs --json backend/internal/web/templates` → **exit 0, `[]`, zero findings** — and that is a degraded scan, not a clean bill: the static-HTML engine's parser deps (`htmlparser2`, `css-select`, `css-tree`, `domutils`) are absent from the skill install, Go templates are fragments rather than full pages, and `<link href="/static/style.css">` is absolute so no CSS ever cascades from a template directory. Assessment B worked around all three — rendered the real templates through `html/template` in a throwaway Go program outside the repo, copied `static/` beside them, `npm install`ed the four parsers into a `/tmp` engine copy — and got **64 findings** over the rendered pages: 29 `undersized-ui-text`, 12 `cramped-padding`, 9 `low-contrast`, 7 `overused-font`, 7 `cream-palette`. Re-run against a dark-only stylesheet (light + `min-width` blocks stripped, because the engine discards `@media` conditions and merges every block last-wins): **103 findings** — 71 `undersized-ui-text`, 25 `low-contrast`, 7 `overused-font`.
|
||||
|
||||
Agreements and false positives: the detector's `low-contrast` hits land on exactly the pair Assessment A computed by hand — `--ember #e0452c` on `--ember-wash #221311` at **4.3:1** — independent confirmation of the P1 below. `overused-font` (Instrument Serif at 34% of text) and `cream-palette` are false positives: both are the committed brief. Most `cramped-padding` is an artifact of the engine flattening `@media` conditions. `text-occlusion` on "Rotate credential" is a false positive from the collapsed `<details>`.
|
||||
|
||||
**Visual overlays.** Injection succeeded. Overlays were rendered live in the browser tab at 390×844, dark, over the real stylesheet, and the in-page detector reported **19 anti-patterns**: 2× `low-contrast` (`#e0452c` on `#221311`, 4.3:1), 11× `undersized-ui-text` (10px "Manga", "Novels", "Userscripts", "Read", "Fav", "Chapter", "Archive", "Delete", "Continue reading", "Chapter you're on", "Latest known: …"), 1× `overused-font`, 1× `text-occlusion` (FP). Two measurements taken in the same browser:
|
||||
|
||||
- **First card top = 563px** on 390×844 — topbar 102 + search 44 + tabs 46 + keyrow 50 + setup 57 + recent strip 250. Exactly one list row on first paint, on the primary device. Assessment A predicted ~560 from the stylesheet alone.
|
||||
- **Desktop overflows with no content.** `empty.html` at 1280×800: `zoom: 1.2` on `:root` × `min-height: 100dvh` on `.sheet` = 960px in an 800px viewport, so an empty library scrolls 20%. `.login-card` divides `--zoom` back out (`style.css:885-886`); `.sheet` does not (`style.css:234-237`).
|
||||
|
||||
## Overall Impression
|
||||
|
||||
The reader library is one of the more disciplined self-hosted UIs I've reviewed: one heat colour that means exactly one thing, a token file whose comments record contrast arithmetic and the bugs each rule was written against, and error handling designed by someone who has been interrupted mid-tap on a phone. What it does not have is a non-visual user. Every one of the five primary actions swaps its own DOM node out from under the focused element with no announcement and no focus move — the interface's most careful work is all visual, and its least careful work is everything a screen reader depends on. Second biggest opportunity: the first screen. 563px of learn-once chrome ahead of the one row the reader opened the app to see.
|
||||
|
||||
## What's Working
|
||||
|
||||
1. **The ember law is enforced by omission, not convention** (`style.css:77-94`, `840-852`). Splitting `--danger` from `--ember`, giving the busy bar a grey hairline instead of the obvious accent, and picking `--patina` from the far side of the wheel for admin means that at 2am one colour on screen has one meaning. Most systems declare that rule and leak it inside a month.
|
||||
2. **`filter.js:180-250`.** Routes failures to the nearest meaningful slot, distinguishes "session expired — *nothing was saved*" on a write from the same 401 on a navigation, echoes a 400 reason only when it looks like a reason rather than an error page, and refuses an auto-dismiss timer with the reason written in the code.
|
||||
3. **The cover fallback is correct in the way that only comes from being burned** (`card.html:9-19`). Monogram always beneath the image plus `onerror="this.remove()"` handles the case everyone forgets — a cover stored successfully and later 404ing — where the naive `{{else}}` leaves a broken-image glyph in a 93px slot.
|
||||
|
||||
## Priority Issues
|
||||
|
||||
**[P0] htmx swaps destroy focus and announce nothing, on all five primary actions.**
|
||||
Favourite (`card.html:56-58`), restore (`:75-78`), archive (`:115-117`), chapter save (`:89-92`) and remove (`:124-127`) all target the card with `hx-swap="outerHTML"`. The `<article>` is in no live region and nothing moves focus. Remove is worst: `web.go:569-573` answers with an empty body, so the focused button and its container both vanish and focus falls to `<body>` in silence — indistinguishable from a crash. That the fix is known is proven three files over: `series-detail.html:91` puts `aria-live="polite"` on `#detail-meta`, so the owner's form announces itself while the reader's card does not.
|
||||
**Fix:** one persistent visually-hidden `<div role="status" id="sr-announce">` in `app.html` and `admin.html`; emit the result text into it out-of-band from each mutation handler ("Archived Berserk"). For removal, move focus to the next `.card`'s play cell (or `#list` when it was the last row) in an `htmx:afterSwap` handler beside the existing hook at `filter.js:48`.
|
||||
**Suggested command:** `/impeccable harden`
|
||||
|
||||
**[P1] The ember state indicator fails WCAG AA at 4.3:1 — the one pair the whole design rests on.**
|
||||
Hand-computed by A and independently confirmed by the in-browser detector: `--ember #e0452c` on `--ember-wash #221311` = **4.33:1** for `.new-chapter` (11px mono, `style.css:609` on `.card.is-new` ground `:534`), the active `.tab-new` (`:392`), and the `.count` badge (`:394-401`). `--ember` on `--ink` passes at 4.61 — this is the case that was checked against the page but never against its own tinted background. Light branch source labels fail too: `--lightnovelworld` 4.25, `--demonic` 4.48, `--novelfull` 4.49, all dropping further on `--ember-wash`.
|
||||
**Fix:** dark `--ember` → ≈`#e85a41` (4.98 on `--ember-wash`, 5.31 on `--ink`), or darken `--ember-wash` to `#1c0f0d`. Light: `--lightnovelworld: #47705f`, `--novelfull: #6f6244`, `--demonic: #7b5d4a`. Verify against `--ember-wash`, not only `--ink`.
|
||||
**Suggested command:** `/impeccable audit`
|
||||
|
||||
**[P1] Control boundaries are invisible: `--field-line` is 1.32:1 against the page.**
|
||||
`--field-line #2c2926` on `--ink` = 1.32:1 (light branch 1.44:1), and it is the *only* boundary for the chapter input (`style.css:731`), every `.ghost` — Log out, Admin, install links, Rotate (`:273`), the library switch (`:623,633`), Clear search (`:867`), and **Cancel inside the remove confirm** (`:767`). WCAG 1.4.11 wants 3:1 for a control's identifying boundary. At the highest-stakes moment the design gives destruction a filled `--danger` slab and safety a 12px `--mute` label in a box nobody can see.
|
||||
**Fix:** raise `--field-line` to ≈`#4a4540` (3.06:1 on `--ink`) for interactive borders; leave `--rule`/`--rule-soft` decorative but stop using `--field-line` as if it were. Give Cancel real button weight in the confirm row — it is the recommended action.
|
||||
**Suggested command:** `/impeccable audit`
|
||||
|
||||
**[P2] The admin surface renders "healthy" and "broken" identically.**
|
||||
`.mark.bad` (`admin.css:396-400`), `.mark` (`:362-366`), `.mark.mark-strong` (`:376-383`), `.c-state.ok` *and* `.c-state.bad` (`:425-430`), `.c-skip .ok` *and* `.c-skip .bad` (`:431-449`) all resolve to the same `--patina` / `--patina-wash` / `--patina-line` triple, so `lanes.html:18` paints "unreachable" and "reachable" in one colour separated by a 7px dot, and `overview.html:15` chips are pixel-identical. The page whose purpose is "is anything wrong at a glance" now has to be read chip by chip. The one-accent law was meant to keep ember and danger off admin, not to collapse ok into bad.
|
||||
**Fix:** keep `--patina` for good, give `.bad` the existing `--slate` family (`--slate-soft` on `--slate-wash` = 6.06:1) plus a glyph or word so the distinction is not colour-alone.
|
||||
**Suggested command:** `/impeccable colorize`
|
||||
|
||||
**[P2] Two unlabelled write inputs and two auto-submitting selects on the owner's most consequential page.**
|
||||
`series-detail.html:19` (`type="number" name="chapter" placeholder="{{.Chapter}}"`) and `:29` (`type="url" name="series_url"`) have no `<label>`, `aria-label` or `aria-labelledby`; the placeholder is the *current value*, so a screen reader hears a bare number as the field's name — WCAG 3.3.2/4.1.2 on a form that writes a value every Reader sees. `series-list.html:9,12` carry `onchange="this.form.submit()"`, making the filter unusable from a keyboard (first arrow-key navigates). Three lines away the same file gets it right (`<label class="fsel">`), as does `lanes.html:49`.
|
||||
**Fix:** real `<label for>` on both, current value moved from `placeholder` into a hint line; replace auto-submit with a visible Apply button.
|
||||
**Suggested command:** `/impeccable clarify`
|
||||
|
||||
**[P2] Desktop overflows with an empty library (browser-verified).**
|
||||
`style.css:955` sets `zoom: 1.2` on `:root` at ≥1280px; `.sheet` keeps `min-height: 100dvh` (`:234-237`) without dividing `--zoom` back out, the way `.login-card` correctly does (`:885-886`). Measured live: `empty.html` at 1280×800 → sheet 960px, scrollbar on a page with no content. The `--zoom` token comment at `:129-131` names this exact hazard.
|
||||
**Fix:** `min-height: calc(100dvh / var(--zoom))` on `.sheet`, matching `.login-card`.
|
||||
**Suggested command:** `/impeccable adapt`
|
||||
|
||||
## Persona Red Flags
|
||||
|
||||
**Sam (screen reader, keyboard-only, 200% zoom, low vision)** — the hardest-hit persona:
|
||||
- Focus destroyed and nothing announced on all five card actions; remove is total silence (`card.html:56,75,89,115,124`; `web.go:569-573`).
|
||||
- Favourite never exposes state: static `aria-label="Toggle favourite"` overrides the dynamic `title`, no `aria-pressed` (`card.html:53-55`).
|
||||
- The pencil is a disclosure with no disclosure semantics — no `aria-expanded`/`aria-controls` in markup, while archive and remove three lines away have both (`card.html:61-62` vs `:68-69,81-82`).
|
||||
- Broken heading order: `<main id="list">` has no heading, so every card `<h3>` nests under the sibling `<h2>Continue reading</h2>` — by heading navigation all 40 series appear to live inside the 5-item strip (`chrome.html:11`, `app.html:92`, `card.html:25`). Two `<h1>`s on series detail (`admin.html:25` + `series-detail.html:9`).
|
||||
- `aria-label` on a bare `<div class="keyrow">` with no role is dropped: the legend announces as five orphan words (`chrome.html:35`).
|
||||
- Search results never announced: filtering toggles `card.hidden` with no live region and no count; `#no-match` is unhidden without `role="status"` (`filter.js:12-32`, `list.html:7`).
|
||||
- `.play` and `.remove` have no `:focus-visible` rule while `.fav`, `.pencil`, `.box`, `.restore` do — tabbing a card, three cells light and two do not (`style.css:677-689`). Two inputs drop the focus ring for a 1px border change (`:355,735`). Exactly one `:focus-visible` outline exists in the codebase and it is scoped to `.admin-sheet .ghost` (`admin.css:734-738`).
|
||||
- No skip link past ten chrome controls (`app.html:26-92`). `.tabs`/`.recent-strip` scroll horizontally with `scrollbar-width: none` and no fade, so at 200% zoom "Archived" is off-screen with no cue (`style.css:360-369`).
|
||||
- Login refusal is never announced: `role="alert"` on markup present at parse time does not fire, and the page reloads on failure (`login.html:24-25`, `discord.go:184-185`).
|
||||
- Correct and worth keeping: `alt=""` on the decorative cover inside an `aria-hidden` duplicate link, with the title as a real `<h3>` beside it (`card.html:7-19`). Measured hit targets are honest — tab 44px, card action cell 46px.
|
||||
|
||||
**Alex (impatient power user)**:
|
||||
- One list row at 563px on first paint; the chrome that costs it is all learn-once content. The recent strip hides itself when empty (`chrome.html:10`); the five-item legend never does (`:35`).
|
||||
- Esc is the only keyboard accelerator in the product (`filter.js:120-150`). No `/`, no `j/k`, no Enter-to-continue.
|
||||
- No bulk actions, no sort: archiving ten dead bookmarks is 30 taps.
|
||||
- His fast path evaporates on the first keystroke — the strip hides while filtering (`filter.js:22-24`) and is server-suppressed on every tab but All (`web.go:344-350`).
|
||||
- Removal is irreversible and thumb-adjacent as the fifth cell in a five-cell strip (`web.go:558-573`).
|
||||
|
||||
**Jordan (confused first-timer)**:
|
||||
- The same install action appears twice on the first screen, framed differently (`list.html:22-26` under `setup.html:11-14`).
|
||||
- A five-item action legend renders for a list with zero rows (`chrome.html:35`), and it *looks* like a toolbar — icon-plus-label cells in a bordered row, visually indistinguishable from the card action strip it explains, yet inert. Confirmed in the screenshot at both 390px and 1280px.
|
||||
- "USERSCRIPTS" renders as a bare mono label with no caret or affordance; that it is a `<details>` summary is invisible (`setup.html:8`, screenshot).
|
||||
- Five unlabelled icons per row explained by a legend using different words ("Delete" vs "Remove").
|
||||
- Cancel is nearly invisible beside a filled Remove (`style.css:764-778`).
|
||||
- "Archive this?" never says what archiving does; the copy that explains it ("it keeps getting checked for new chapters") only appears *after* he archives something (`card.html:112`, `list.html:16`).
|
||||
- Worst-case sign-in is an unstyled plaintext `internal error` (`discord.go:196,204`).
|
||||
|
||||
## Minor Observations
|
||||
|
||||
- `--faint-2 #57504b` monogram on `--hatch` ≈ 2.19:1 — decorative by declaration (`aria-hidden`), but it is the only identity a coverless series gets (`card.html:19`).
|
||||
- 2.7MB PNG on the login page (`static/login-art.png`, `login.html:20`) — the first bytes a phone on mobile data receives, on a one-button page.
|
||||
- Server error strings surface verbatim and capitalised to readers: "Missing key.", "Not found.", "Invalid status." (`filter.js:216-221` ← `web.go:446,456,513`).
|
||||
- Five verbs for three concepts: Delete / Remove / Un-finish / Shelve / Archive.
|
||||
- `.keyrow` is not conditioned on library: novels have no Updated tab but get the same five-key legend.
|
||||
- `readers.html:59` uses `<p class="empty">` where every other empty state is `<div class="empty"><strong>…</strong><p>…</p></div>`.
|
||||
- `series-detail.html:54` cancels via `document.querySelector('.dform .danger')` — page-global, unique only by luck.
|
||||
- Worth preserving: `prefers-reduced-motion` naming pseudo-elements because `*` does not match them (`style.css:1031-1035`); `hx-sync` on the self-refreshing Lanes fragment (`lanes.html:12`); `.ghost::after` hit-target expansion (`style.css:282`); the coarse-pointer block keyed to input method rather than viewport (`admin.css:983-1027`); the confirm row focusing *Cancel* on the irreversible action and the affirmative on the reversible one (`filter.js:106-114`).
|
||||
|
||||
## Questions to Consider
|
||||
|
||||
1. The action key exists because the icon strip cannot be read — so why is the strip still unlabelled? A permanent inert legend that looks like a toolbar costs more vertical space and more confusion than labelled cells or one overflow control would.
|
||||
2. PRODUCT.md says the owner's panel is "one list with one button, not an admin console." It is now four nav tabs, a paged table and a per-series detail page. Did the principle change, or did the surface grow while nobody held it to the principle?
|
||||
3. The ember law was enforced so literally on admin that "reachable" and "unreachable" now render identically. When a design law starts destroying the distinction the surface exists to make, is it still a law or a habit?
|
||||
4. Removal is irreversible, thumb-adjacent and silent to a screen reader, gated only by a confirm. Why is the confirm the whole safety story rather than an optimistic remove with a 10-second undo in the existing `#notice` slot? The confirm interrupts every removal; undo interrupts none and still catches the mistake.
|
||||
5. The strip disappears when nothing is new, and the list is already ordered by reading recency. If the strip only renders when Updated is also non-empty, is it 250px of duplicated answer — and should the first screen simply *be* the Updated bucket?
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
target: backend/internal/web
|
||||
total_score: 26
|
||||
max_score: 40
|
||||
na_heuristics:
|
||||
p0_count: 0
|
||||
p1_count: 2
|
||||
timestamp: 2026-08-27T14-36-34Z
|
||||
slug: backend-internal-web
|
||||
---
|
||||
Method: dual-agent (A: AssessDesign-2 [designer] · B: AssessDetector [scout])
|
||||
|
||||
Note: Assessment A completed its analysis but hung in a yield loop after writing its report; recovered from its on-disk artifact and cancelled. Two of its claims failed parent verification and are corrected below.
|
||||
|
||||
## Design Health Score
|
||||
|
||||
Mode: **Operate** (all 10 heuristics apply; none n/a).
|
||||
|
||||
| # | Heuristic | Score | Key Issue |
|
||||
|---|-----------|-------|-----------|
|
||||
| 1 | Visibility of System Status | 3 | Busy hairline is grey, not ember (`style.css:865-877`), chrome refreshed out-of-band on every mutation (`web.go:397`) — but the horizontal Recent strip hides its own overflow (`style.css:471-476`) |
|
||||
| 2 | Match System / Real World | 3 | Reader copy is native ("Chapter you're on" `card.html:96`); admin leaks ops jargon — "Sighting … deferral blocked" `readers.html:10-12`, "Outcomes · status" `lanes.html:26` |
|
||||
| 3 | User Control and Freedom | 2 | Esc closes panels and returns focus (`filter.js:163-170`), Restore is one-tap undo (`card.html:75`) — but Remove and Archive have no post-action undo, only a pre-action gate |
|
||||
| 4 | Consistency and Standards | 3 | Action order play∣fav∣chapter∣lifecycle is invariant (`card.html:48-86`); admin is a second system — patina accent (`admin.css:10-14`) and `--measure-wide: 1080px` (`admin.css:2`) vs `--measure: 760px` (`style.css:124`) |
|
||||
| 5 | Error Prevention | 3 | `max=9999` + `step=any` (`card.html:99-102`), no-op chapter save skipped (`web.go:547-550`), every move out of the list confirm-gated (`card.html:111,123`); no inline validation on the chapter field |
|
||||
| 6 | Recognition Rather Than Recall | 3 | Permanent keyrow (`chrome.html:35-45`) decodes the icon strip — but it is hidden while filtering (`filter.js:22-24`), exactly when cards are being scanned |
|
||||
| 7 | Flexibility and Efficiency | 2 | Instant client-side filter (`filter.js:1-13`), bookmarkable tabs (`app.html:61`); no keyboard shortcut, no bulk archive, no swipe — curation is per-card taps |
|
||||
| 8 | Aesthetic and Minimalist Design | 3 | Hairlines not cards, single column, Instrument Serif titles (`style.css:590`); phone chrome stacks ~220px (search `style.css:347` + tabs + keyrow `style.css:420` + strip `style.css:453`) before the first card |
|
||||
| 9 | Error Recovery | 3 | Per-slot inline errors with verbatim server reason (`filter.js:200-224,260-263`), 401 → "Log in again" (`filter.js:273`), no self-destruct timer (`filter.js:241-245`); tab-switch failures land in the easy-to-miss global `#notice` (`style.css:830`) |
|
||||
| 10 | Help and Documentation | 1 | Help is only empty states (`list.html:20-26`, `setup.html:9-17`); a guild outsider gets one sentence (`login.html:28`) and an opaque refusal (`discord.go:185`) with no guild name and no next step |
|
||||
| **Total** | | **26/40** | **Competent, with one weak flank (help) and mobile ergonomics debt** |
|
||||
|
||||
## Design Specificity Verdict
|
||||
|
||||
**Authored for this product. Not category-interchangeable.**
|
||||
|
||||
**LLM assessment**: The visual language is a committed editorial position, not a framework default. Specific choices that no generic CRUD would carry: a single 760px hairline column with `border-bottom: 1px solid var(--rule)` instead of cards (`style.css:124,543`); the ember law enforced in code and in comments — "neither ember … may say 'system unhealthy'" (`style.css:236-241`), busy bar deliberately grey (`style.css:865`); heat expressed typographically (hot title `style.css:592`, 2px ember underline `style.css:525`, ember wash `style.css:548`) with no pulse or badge bounce; the hatch + monogram cover fallback (`style.css:121`, `card.html:18-19`) built for scraped covers that 404; manga and novels as two separate libraries with the Updated tab existing only for manga because only manga has a poller-fed "what's out" (`app.html:64-69`, `web.go:317`); the Recent strip capped at 5 and suppressed on Updated to avoid duplicating the list (`web.go:31,346`).
|
||||
|
||||
What a generic app would keep unchanged: the underline tab row (`style.css:374-403`), the client-side title filter (`filter.js:4-13`), the admin table with select filters and pagination (`series-list.html:9-33`), the single OAuth button (`login.html:22-26`). That is a fair split — the IA is generic, the surface is not.
|
||||
|
||||
The one real breach of the committed system is admin: `--measure-wide: 1080px` (`admin.css:2`) plus a patina accent buys horizontal sprawl (7-column lane grid, `admin.css:482`) that collapses back to stacked rows at 899px anyway (`admin.css:924`). Width did not save the tables; it created a second visual system to learn.
|
||||
|
||||
**Deterministic scan**: `detect.mjs --json backend/internal/web/templates` → **exit 0, zero findings** (`[]`), verified across three runs. Pipeline liveness confirmed against a control snippet, which correctly returned exit 2 with `overused-font`. The scan covers templates only — CSS lives outside the scan root, so `ai-color-palette`, `cream-palette` and line-length rules never ran. Mechanical sweep instead:
|
||||
|
||||
- **Zero** hardcoded colours outside the token blocks in either stylesheet. All 98 hex definitions sit in the dark `:root` (`style.css:55-122`) or the light branch (`style.css:138-191`). One non-token literal exists: `rgba(0,0,0,.5)` in a login-art drop-shadow (`style.css:946`). Every `#…` match in `admin.css` is an issue number in a comment.
|
||||
- **Ember/danger segregation holds.** 32 `--ember` lines and 26 `--danger` lines in `style.css`, no overlap; `admin.css` uses `--ember` zero times.
|
||||
- **0** `z-index`, **0** `position: fixed`, **2** `!important` (both justified: `[hidden]` override `style.css:217`, reduced-motion kill `style.css:1060`).
|
||||
- **0** injection sinks. `filter.js` writes through `textContent` only; no `template.HTML/JS/URL` anywhere in the package; the single raw write is escaped (`web.go:425`).
|
||||
- **Accessibility is authored, not audited in**: 36 real `<button>`s, **0** clickable `<div>`s, **0** hrefless `<a>`s, ~77 `aria-*` attributes, all 4 `<img>` intentionally `alt=""` beside a text name, **0** icon-only controls without an accessible name, 19 `:focus-visible` rules, `lang` + viewport + `color-scheme` on all three shells, one `prefers-reduced-motion` block.
|
||||
|
||||
Detector and review agree on the important thing: there are no mechanical anti-patterns here. Every finding below is a design judgment, not a lint.
|
||||
|
||||
**Visual overlays**: none. Browser visualization was skipped — the UI is only reachable through the Go/Docker stack, which was out of scope, so no local URL existed to point automation at. No overlay exists in your browser.
|
||||
|
||||
## Overall Impression
|
||||
|
||||
This is the rare self-hosted tool with an actual design system that the code obeys. Tokens are complete in both colour branches, the ember law is enforced in comments *and* in the busy-state colour choice, destruction is gated and coloured correctly, and the accessibility work is native rather than retrofitted. The problems are not taste problems — they are **mobile ergonomics and dead ends**.
|
||||
|
||||
Biggest single opportunity: **the phone.** Mobile is stated as a hard functional constraint, but two of the three horizontal-scroll surfaces (Recent strip, tab row) hide their scrollbars and offer no fade, snap, or peek, so their off-screen content is simply invisible; and ~220px of chrome loads above the first card. The product's answer to "what do I read next" is the strip — and on a 360px viewport its newest item can sit off-screen with no hint that it exists.
|
||||
|
||||
## What's Working
|
||||
|
||||
1. **The ember law is enforced where it is easiest to break.** The busy indicator is grey (`style.css:865-877`), not ember; error is `--danger`, not ember; the code comment states the rule (`style.css:236-241`) and the token contrast is annotated with a measured ratio ("verified 5.31:1 on `--ember-wash`", `style.css:73`). Design systems fail at exactly this junction. This one holds.
|
||||
2. **Out-of-band chrome truthfulness.** Every mutation re-renders the strip, keyrow and Updated count out-of-band (`web.go:397`, `chrome.html:10,35,75`) and rebroadcasts a refilter event (`filter.js:104`), so the badge can never describe the pre-tap library. The comment at `web.go:474-476` explicitly buys correctness with one extra read.
|
||||
3. **Confirm gates focus the safe button.** `filter.js:155-157` deliberately puts focus on Cancel, not Remove — "pre-armed Enter is the opposite of what a confirm gate is for." Paired with the danger wash and the title quoted into the prompt (`card.html:124`), destruction is weighted correctly.
|
||||
|
||||
## Priority Issues
|
||||
|
||||
### [P1] Both horizontal-scroll surfaces hide their own overflow
|
||||
- **Why it matters**: `.recent-strip` (`style.css:468-476`) and `.tabs` (`style.css:374-383`) both set `overflow-x: auto` with `scrollbar-width: none` and a killed webkit scrollbar, and neither has a fade mask, scroll-snap, or a deliberately half-peeked item. At 93px covers plus 14px gaps (`style.css:127,470`), four cards fill a 360-390px viewport and the fifth — the newest — is invisible. Same for the fourth tab and its count pill. The strip is the product's answer to "what next"; an affordance nobody discovers is a feature that does not exist.
|
||||
- **Fix**: Add `scroll-snap-type: x proximity` on `.recent-strip` with `scroll-snap-align: start` per `.recent-card`, plus a right-edge `mask-image` gradient on both `.recent` and `.tabs`. Alternatively size the strip padding so one card is always half-visible. No JS.
|
||||
- **Suggested command**: `/impeccable adapt`
|
||||
|
||||
### [P1] A guild outsider hits a dead end with no next step
|
||||
- **Why it matters**: The entire funnel is one page. `login.html:16,28` says "Private library" and "Guild membership is required"; a non-member's refusal is "not a member of this community" (`discord.go:185`) with no guild name, no invite path, no "ask a member." Heuristic 10 scores 1 almost entirely on this. It is also the only screen a stranger ever sees.
|
||||
- **Fix**: Add one help line under `login.html:28` naming the guild (deployment config, safe to display) and stating how to get in. Keep the existing distinction between a Discord outage and a membership refusal (`web.go:171` vs `185`) — that part is already right.
|
||||
- **Suggested command**: `/impeccable clarify`
|
||||
|
||||
### [P2] Removal ends in silence for sighted users
|
||||
- **Why it matters**: **Correction to Assessment A** — the screen-reader path is *not* missing: `web.go:592` already announces "Removed <title>" out-of-band and `refreshChrome` updates the counts. What is missing is the visual half. The card is swapped out to an empty body (`card.html:127`, `web.go:578-596`), focus hops to the next play link (`filter.js:82-88`), and nothing else happens. Peak-end: the peak is the danger confirm, the end is absence. Users hesitate to curate, and libraries bloat.
|
||||
- **Fix**: On delete success also write a transient `#notice` (`style.css:830`) carrying the same "Removed <title>" text. An Undo would need a re-create POST; the notice alone closes the loop for one line of Go.
|
||||
- **Suggested command**: `/impeccable polish`
|
||||
|
||||
### [P2] Archive is confirm-gated despite being one-tap reversible
|
||||
- **Why it matters**: Archive is the most common curation move and is undone by a single Restore tap (`card.html:75`), which itself fires instantly and without a gate. Its confirm row already wears the calm ash treatment (`style.css:806`) — the design admits the action is cheap while still charging two taps for it. Every gate spent on a cheap action devalues the gate on Remove.
|
||||
- **Fix**: This one is a product decision, not a defect: the AGENTS.md law says every move out of the list is confirm-gated. If you want to relax it, the honest form is instant archive plus a persistent "Archived — Undo" notice, and the law should be amended in the same change. If you keep the law, keep the gate.
|
||||
- **Suggested command**: `/impeccable shape`
|
||||
|
||||
### [P2] Adjacent lifecycle cells differ only by hue at rest
|
||||
- **Why it matters**: **Partial correction to Assessment A** — the mis-tap claim was overstated. Each action is a full-cell `<button>` (`style.css:674-680`, `card.html:53-86`), so the target is 68×46, not the 17px glyph; the lifecycle cluster already sits recessed on `--ash` with an inset hairline before it (`style.css:724-726`). What remains real: Archive and Remove are neighbours in that cluster, and at rest they are separated only by `--slate` mute vs `--trash` (`style.css:684,708`) — the slate/danger wash distinction appears on hover, which a phone does not have. A slip lands on the destructive gate.
|
||||
- **Fix**: Give `.actions .remove` a resting treatment that reads without hover — a left hairline in `--danger-soft`, or move Remove to the far edge with a wider divider. Do not add a gap; the recessed ground is doing that job already.
|
||||
- **Suggested command**: `/impeccable polish`
|
||||
|
||||
## Persona Red Flags
|
||||
|
||||
**Midnight phone reader (primary persona: 2am, Bromite, one thumb, dimmed OLED)**
|
||||
- The `.is-new` wash is `#1c0f0d` on an `#100f0e` page (`style.css:74,55`) — roughly 5% brighter. At low screen brightness the wash effectively disappears, leaving `--paper-hot` title colour (`style.css:64`) as the sole "new" signal. The ember foot-rule and 1px cover outline (`style.css:519,551`) survive better; the wash is the weakest link in the product's most important state.
|
||||
- Two competing continue affordances: the cover link (`card.html:7`, `aria-hidden`) and the play cell (`card.html:49`). The cover is the bigger, more obvious target and carries no focus ring; the play cell is the labelled one.
|
||||
- The chapter override field opens the Android numeric keypad over the "Latest known" hint it is meant to be compared against (`card.html:99-106`), and its focus ring is paper-white (`style.css:760`) — a bright flash in a dark room, against the stated no-glare requirement.
|
||||
|
||||
**First-time guild member (invited, app is new, library empty)**
|
||||
- `setup.html:7` is a `<details>` closed by default, so on a first run the install CTAs are hidden behind a tap on a surface the user has no reason to trust yet. The rich empty state (`list.html:17-27`) only fires when *both* libraries are empty; otherwise they get the terse line at `list.html:29`.
|
||||
- The library switch (`app.html:30-34`) defaults to manga with no explanation that Novels is a separate library. A first novel bookmark lands in a tab they are not looking at, which reads as "the save failed."
|
||||
- Getting to a first bookmark requires: notice the panel → open it → choose install vs download → complete the mobile Violentmonkey save flow, which is documented in exactly one paragraph (`setup.html:15-17`).
|
||||
|
||||
**Owner triaging from a phone (secondary but real — the owner is also a night reader)**
|
||||
- Lanes is a 7-column grid (`admin.css:482`) that collapses by hiding the header and printing `data-label::before` pseudo-labels (`admin.css:915,985`). It works, but a phone row becomes four logical lines with three numeric cells, each needing its label read.
|
||||
- The pause control (`lanes.html:44-55`) puts a select plus button inline at row end; at 360px it wraps and renders at 10px mono (`admin.css:317`).
|
||||
- `admin.css` has no `prefers-color-scheme` block of its own and no reduced-motion block; both are inherited via `style.css` tokens and its global `*` rule. That works today and breaks silently the day admin is loaded without `style.css`.
|
||||
|
||||
## Minor Observations
|
||||
|
||||
- Dark and light branches are genuinely co-maintained — the light branch retunes ember to `#c23a22` (`style.css:154`) rather than inverting it, and there is no pure-white page surface (`#f7f4ef` light / `#100f0e` dark). The only `#fff` is `--ember-ink` on the solid ember button (`style.css:156`), which is isolated.
|
||||
- `.ghost::after { inset: -15px -12px }` (`style.css:295`) reaching 44px without shifting layout is the most mobile-aware line in the codebase. The action cells deserve the same care.
|
||||
- `zoom: 1.2` above 1280px (`style.css:980`) preserves hairline proportions where a font-size scale would not; the `--zoom` token (`style.css:131`) handles the viewport-unit consequence. Unusual, correct, well commented.
|
||||
- The reduced-motion block explicitly kills `::before` animations because `*` misses pseudo-elements (`style.css:1056-1060`). Rare and right.
|
||||
- `.tabs a:hover` is the only `border-radius` in the reader sheet (`style.css:397`). Tabs are not cards; leave it.
|
||||
- Credential rotation uses a native blocking `hx-confirm` (`setup.html:29`) while every other destructive action uses the custom confirm row. Defensible (rotation is rarer and more severe) but it is a third confirmation vocabulary.
|
||||
- The Recent strip is hidden by JS while a filter is active (`filter.js:22-24`) — correct, since it would show non-matching series. Side effect: the keyrow is the only chrome left, and per-card icon meaning falls back on the keyrow at exactly the moment the user is scanning cards.
|
||||
- `login.html:10` preloads the serif but not DM Sans, unlike `app.html:16,19` — minor FOIT on the first paint a new user ever sees.
|
||||
- `color-mix(in oklch, …)` (`style.css:515,549,863`) degrades to transparent rather than breaking. Fine on Chromium/Bromite.
|
||||
- `.is-dim` archived cards get `grayscale(1) opacity(.85)` (`style.css:546,568`), which also flattens the hatch fallback on missing covers. Intentional, but archived + no cover is close to unreadable.
|
||||
|
||||
## Questions to Consider
|
||||
|
||||
1. **If the action strip is right, why does the keyrow exist?** The keyrow (`chrome.html:35`) is a permanent legend for an icon set that cannot be read. Label the cells directly on phone — the keyrow already knows how to stack icon over word (`style.css:430`) — and you delete the keyrow and reclaim ~28px of feed.
|
||||
2. **Who is the Updated tab for, if the strip already answers that question?** Updated (`app.html:65`) and the Recent strip (`chrome.html:9`, `web.go:346`) render the same `HasNewChapter` predicate — one as a list, one as covers. Pick one and give the phone back ~90px.
|
||||
3. **Does brass favourite compete with ember new?** `--brass #b8912f` (`style.css:83`) and `--ember #e85a41` (`style.css:73`) are both warm. Night readers scan for ember; a gold fav-mark plus brass foot-rule (`card.html:27`, `style.css:527`) puts a second warm accent in the same scan. Would a cool metal separate reward from heat?
|
||||
4. **If the single measured column is law, why does admin get 1080px?** The tables collapse at 899px anyway (`admin.css:924`), so the extra 320px bought sprawl, not capability. What would admin look like inside `--measure` with row drill-downs instead of 7-column grids?
|
||||
5. **A new chapter arriving while the page is open is invisible.** The poller updates the database; only admin Lanes auto-refreshes (`lanes.html:13`). A reader must switch tabs to learn anything changed. Is a 60s `hx-trigger` poll on the chrome fragment worth the requests, given the product's whole value proposition is "you find out without visiting the site"?
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
---
|
||||
target: Reader library web surface (app.html)
|
||||
total_score: 32
|
||||
max_score: 40
|
||||
na_heuristics:
|
||||
p0_count: 0
|
||||
p1_count: 2
|
||||
timestamp: 2026-08-27T15-38-17Z
|
||||
slug: backend-internal-web-templates-app-html
|
||||
---
|
||||
Method: dual-agent (A: CritiqueDesignReview · B: CritiqueDetectorEvidence)
|
||||
|
||||
Target: the Reader library surface — `backend/internal/web/templates/{app,chrome,list,card,setup,icons}.html`, `static/style.css`, `static/filter.js`. Mode: **Operate**.
|
||||
|
||||
## Design Health Score
|
||||
|
||||
| # | Heuristic | Score | Key Issue |
|
||||
|---|-----------|-------|-----------|
|
||||
| 1 | Visibility of System Status | 3 | `card.htmx-request::before` (style.css:880-889) animates a silent bar for up to the full 15s htmx timeout (app.html:12) with no "saving" text and no `aria-busy`. |
|
||||
| 2 | Match System / Real World | 4 | Solid. Copy is situational, not generic: "Chapter you're on" vs "Latest known" (card.html:96-106); four distinct empty states (list.html:11-29). |
|
||||
| 3 | User Control and Freedom | 3 | Confirm+Cancel and Esc-to-close cover accidental opens (filter.js:152-200); a committed Remove has no undo. |
|
||||
| 4 | Consistency and Standards | 3 | `:focus-visible` rings are defined for every `.actions` cell, the chapter input and confirm buttons (style.css:703-826) but for **no** navigation control — `.tabs a`, `.libswitch a`, `.ghost`, `.login-card button`, `.empty .clear-search` fall back to the UA ring. |
|
||||
| 5 | Error Prevention | 4 | `step="any"` chapter guard, confirm-gate on every move-out-of-list, `hx-disabled-elt` on every mutating control, Cancel focused on Remove (filter.js:159-164). |
|
||||
| 6 | Recognition Rather Than Recall | 3 | The `.keyrow` legend (chrome.html:34-46) exists precisely because the 5-icon strip is unlabelled — and it scrolls away, at 10px, in exactly the long-list case it was built for. |
|
||||
| 7 | Flexibility and Efficiency | 2 | No bulk actions, no sort, no shortcut beyond Esc. A 300-series library is triaged one card, two taps at a time. |
|
||||
| 8 | Aesthetic and Minimalist Design | 3 | Hairline-only, one-measure, disciplined — but the minimalism is paid for in legibility: 9 distinct functional labels compute to **10px** at 390px (Assessment B), below any UI-text floor. |
|
||||
| 9 | Error Recovery | 4 | `reasonFrom()` surfaces the server's literal 400 text (filter.js:270-292); distinct 401/400/network/timeout copy; auto-`scrollIntoView` to the message. |
|
||||
| 10 | Help and Documentation | 3 | In-context `<details class="setup">` covers install and rotation; nothing in the library view explains what dim/italic, ember, or brass mean without first finding the keyrow. |
|
||||
| **Total** | | **32/40** | **Good — solid foundation, weak areas are specific and cheap** |
|
||||
|
||||
## Design Specificity Verdict
|
||||
|
||||
**Authored, not assembled.** This could not ship unchanged on another product, and that is rare.
|
||||
|
||||
**LLM assessment (Assessment A, unanchored):** the specificity is load-bearing, not decorative. `--ember` is a contract, not an accent — every "new chapter" surface shares that one token (`.is-new .title` 604-610, `.tabs .tab-new` 409-412, `.foot-rule` 529-536, `.libswitch a.active` 663-667) and destruction is deliberately split onto a duller `--danger` family so a remove-confirm is never mistaken across the room for an unread chapter (style.css:77-83). The `:root` block carries per-pair contrast measurements, and the light branch **re-derives** every hue instead of reusing dark values (137-195) — evidence the "both branches touched together" law is honoured, not asserted. Three purpose-chosen faces, self-hosted because Bromite blocks `fonts.googleapis.com` (style.css:1-5). One `--measure: 760px` column, no cards, no shadows, and in 1075 lines exactly one stray corner (`.tabs a:hover` 2px radius, line 402). The gaps are not taste failures; they are unfinished passes — sticky positioning, focus rings on nav, and a legibility floor.
|
||||
|
||||
**Deterministic scan:** `detect.mjs --json backend/internal/web/templates` → **0 findings, exit 0**, and Assessment B validated that zero three ways (synthetic bad HTML fired 2 rules; `--no-config` reproduced the zero; no `config.json` exists). Note the scan's blind spot: the detector did **not** resolve the linked `/static/style.css`, so a clean markup scan says nothing about the tokens. The runtime pass is where everything was found.
|
||||
|
||||
**Visual overlays:** injection **succeeded** — but against a standalone render, not the live app. Assessment B found `web.go:125` parses templates with a plain `template.ParseFS` and no FuncMap, so it rendered the repo's real templates + real CSS from a throwaway program in `/tmp/bmrender`, served it, and injected `detect.js`. Overlay confirmed present; screenshots at `/tmp/bmrender/shots/` (390×844 light, dark, dark+overlay). **20 runtime findings, none of which the static scan could see:** `undersized-ui-text` ×18, `wide-tracking` ×1, `overused-font` ×1. Every card action measured **66.8 × 46 px** — clears 44px on both axes. No horizontal overflow at 390px (an earlier 499px reading was the overlay's own label boxes). Both servers stopped, no Docker started, no repo file changed.
|
||||
|
||||
False positives I accept from B: 9 of the 18 `undersized-ui-text` hits are the same `.hint` rule counted once per row inside `hidden` chapter forms — one defect, not nine. `overused-font` on Instrument Serif is the rule's share-of-text heuristic misreporting a deliberate display serif. `wide-tracking` anchors to the mono meta line, not body copy. **The remaining 9 are real and unanimous with A's typography read:** `Manga`, `Novels`, `Userscripts`, `Read`, `Fav`, `Chapter`, `Archive`, `Delete`, `Continue reading` all render at 10px.
|
||||
|
||||
## Overall Impression
|
||||
|
||||
This is the best-argued small codebase I have critiqued in a while — the CSS comments read like design review notes, and the taste is consistent. What works is the discipline: one heat signal, one column, hairlines, situational copy, and a confirm model that distinguishes reversible from final and even routes focus differently for each.
|
||||
|
||||
What does not work is that the design's own mitigations are undermined by two omissions it never got around to. The card strip needs the legend to be readable; the legend is 10px and scrolls away. The tab row is the primary navigation; it is the one row with no focus ring. Both are 20-line fixes.
|
||||
|
||||
**Single biggest opportunity: make the chrome persistent and lift the 10px floor.** Those two changes convert a well-reasoned desktop-shaped design into one that actually works one-handed at night on a phone — which PRODUCT.md says is the entire point.
|
||||
|
||||
## What's Working
|
||||
|
||||
1. **Ember law is real.** One reserved token for "unread chapter", nothing else borrows it, destruction gets its own duller family. This is why a dense icon-heavy list stays scannable to a tired reader: any warm colour means *something to read*. Most design systems claim this; this one enforces it in a comment and then actually holds the line for 1075 lines.
|
||||
2. **Confirm-row focus routing.** `toggleConfirmRow` focuses the affirmative button for the reversible action (Archive) and **Cancel** for the irreversible one (Remove) — filter.js:159-164, paired with `.confirm-row.calm` grey vs the danger wash (style.css:786-826). A one-line detail that measurably lowers accidental destruction. Nobody notices this; everybody benefits from it.
|
||||
3. **State coverage is exhaustive and honest.** Four distinct empty states by real cause, verbatim server 400 text surfaced inline instead of "something went wrong", a chapter form that disambiguates your progress from the published latest, and a cover slot with hatch + monogram + `onerror="this.remove()"` covering both "never acquired" and "404s after storage" (card.html:9-19). Verified in B's render: the broken-cover row hit `ERR_NAME_NOT_RESOLVED` and the monogram fallback executed.
|
||||
|
||||
## Priority Issues
|
||||
|
||||
**[P1] Nothing is sticky, on a mobile-first surface built for hundreds of series**
|
||||
- **Why it matters:** `position: sticky` appears **nowhere** in style.css (verified). Casey, one-handed, three screens into a 300-item library, must scroll to the top to switch tabs, search, or re-read the icon legend — the exact moment search and legend earn their existence. This also silently defeats the keyrow, which is the whole mitigation for the unlabelled 5-icon strip.
|
||||
- **Fix:** `.chrome` (searchbar + tabs) → `position: sticky; top: 0; background: var(--ink);` with `padding-top: env(safe-area-inset-top)` and a `z-index` above cards; keep `.recent`/`.keyrow` non-sticky if header height is tight, or sticky the keyrow instead and accept a shorter tab row.
|
||||
- **Suggested command:** `/impeccable adapt`
|
||||
|
||||
**[P1] Navigation controls have no `:focus-visible`; nine functional labels render at 10px**
|
||||
- **Why it matters:** two separate legibility/a11y regressions in the same layer. (a) Every `.actions` cell, the chapter input and confirm buttons define 2px token-coloured rings (style.css:703-826); `.tabs a`, `.libswitch a`, `.ghost`, `.login-card button` and `.empty .clear-search` define none, so keyboard focus on the primary navigation falls back to a UA ring — likely bright blue, against a palette explicitly tuned for low glare. `.search` goes further and zeroes its outline (372), delegating to a 1px `border-bottom-color` change — materially weaker than every other control in the file. (b) B measured 10px computed on `.libswitch a`, `.keyrow .pair span`, `.recent h2`, `.setup summary`, `.hint`. 10px mono, at night, on a phone, is not a minimalism choice a night-reading brief supports.
|
||||
- **Fix:** add `:focus-visible { outline: 2px solid var(--paper); outline-offset: 2px; }` to the four nav selectors, matching the established pattern; give `.search` a real ring instead of the hairline delegation. Raise the 10px steps to 11-12px (the `min-width: 720px` block already does exactly this for `.keyrow .pair span` at line 1024 — the mobile branch is the one that needs it more, not less).
|
||||
- **Suggested command:** `/impeccable audit` then `/impeccable typeset`
|
||||
|
||||
**[P2] Five icon-only actions per card, at rest, in both list states**
|
||||
- **Why it matters:** `.actions` renders Play, Fav, Chapter, Archive/Restore, Remove (card.html:48-87) — one over the ≤4 working-memory ceiling, on every card. At rest all five are `var(--mute)` grey; per-action colour is a *response* to interaction, not a resting cue. The design already knows this, which is why the keyrow exists — and the keyrow is 10px and scrolls away (P1). Fix P1 and this drops to P3; leave P1 and this is five monochrome glyphs versus a distracted thumb.
|
||||
- **Fix:** either resolve via P1 (sticky + legible legend) and keep five, or move Chapter-override behind the same disclosure affordance as Archive/Remove so the resting strip is Play, Fav, Archive, Remove.
|
||||
- **Suggested command:** `/impeccable layout`
|
||||
|
||||
**[P2] Up to 15 seconds of silent busy state**
|
||||
- **Why it matters:** `.card.htmx-request::before` animates a bar; there is no interim text, no `aria-busy`, and no live-region message until the outcome lands. app.html:8-12 documents the 15s cap and its own reasoning is "a phone that walks into a dead zone" — so the slow path is anticipated, and it is the one path with no feedback. To a distracted user this reads as "did my tap register?" for an uncomfortably long stretch; to a screen reader user there is literally nothing between the tap and the result.
|
||||
- **Fix:** after ~2s of `htmx-request`, reveal a "Saving…" line in the card's existing `.error-inline` slot (or a sibling) and set `aria-busy="true"` on the article.
|
||||
- **Suggested command:** `/impeccable harden`
|
||||
|
||||
**[P3] Long titles clamp in the strip but not in the list**
|
||||
- **Why it matters:** `.recent-title` clamps to 2 lines (style.css:512-517); `.title` gets only `overflow-wrap: anywhere` (592-601) despite the adjacent comment documenting a real 90-character scraped title. Horizontal overflow is defended; a card growing four lines tall and breaking the list's rhythm is not. Titles are scraped `og:title` — the length is not under your control.
|
||||
- **Fix:** apply the same `-webkit-line-clamp: 2` to `.title`, or decide the list shows titles in full and say why the two components diverge.
|
||||
- **Suggested command:** `/impeccable polish`
|
||||
|
||||
## Persona Red Flags
|
||||
|
||||
**Casey (distracted, one-handed, mobile — the stated primary user)**
|
||||
- No sticky chrome: every tab switch or search after scrolling is a full reach to the top of the viewport.
|
||||
- 10px `Manga`/`Novels` library switch and 10px keyrow labels — the two things you glance at, set at the smallest size on the page.
|
||||
- Five same-weight grey glyphs per card demand deliberate recognition, not a colour-coded glance.
|
||||
- *Working:* `.actions { flex: 1 0 100% }` puts the strip full-width at the bottom of each card's content — correct thumb zone. Every cell measured 66.8 × 46 px.
|
||||
|
||||
**Sam (screen reader / keyboard / low vision)**
|
||||
- `.tabs a` and `.libswitch a` have no custom focus ring; `.search:focus-visible { outline: none }` delegates to a 1px hairline recolour on the one control most in need of being findable.
|
||||
- No `aria-busy`/live cue during the in-flight window — announcements exist before nothing and after everything.
|
||||
- *Working, despite appearances:* cover `alt=""` + `aria-hidden` monogram is correctly decorative because the sibling `<h3 class="title">` carries the name; every icon button has both `aria-label` and `title`; mutations announce through `#sr-announce`.
|
||||
|
||||
**Riley (edge-case stress tester)**
|
||||
- Chapter `max="9999"` is a client-only cosmetic guard — server validation (`web.go:564-569`) rejects negative/NaN/Inf but no upper bound, so a direct POST stores an arbitrary magnitude.
|
||||
- `closeCardPanels` (filter.js:118-128) enforces one open panel *per card*, not per page: a confirm-remove can sit open on card 3 while a chapter form is open on card 40.
|
||||
- 300 series: no pagination, no virtualization, no bulk action, no sticky nav — sequential two-tap cleanup only.
|
||||
- *Working:* 0-series is handled with four distinct states including a true first-run with install links; missing covers are handled from both directions.
|
||||
|
||||
## Minor Observations
|
||||
|
||||
- `.keyrow .full { display: inline; }` (style.css:1025) targets a `.full` class that exists nowhere in `chrome.html` — dead rule, delete it.
|
||||
- `.tabs a:hover { border-radius: 2px 2px 0 0; }` (402) is the only corner rounding in the sheet, an unexplained exception to the stated no-corners law.
|
||||
- `.libswitch a` under `@media (pointer: coarse)` computes ~42px tall (1066-1068) against the file's own claim at 1061-1063 that "every control is already 44".
|
||||
- Restore fires instantly while Archive is confirm-gated — defensible as "reversible needs no confirm", but Restore *is* the reversal of Archive, so the asymmetry deserves a sentence in the CSS comment where the rule is stated.
|
||||
- The detector cannot see `style.css` through a `<link href="/static/...">`. Any future detector run on this repo must target rendered HTML in a browser, not the templates directory, or it will report clean and mean nothing.
|
||||
|
||||
## Questions to Consider
|
||||
|
||||
1. If a permanent legend is required to make the card strip legible, is 5 icon-only actions the right density for a phone — or is Chapter-override one disclosure away from a 4-icon strip that needs no legend?
|
||||
2. Why does neither the tab row nor the search box stay reachable once a Reader is three screens into the library the Updated badge exists to triage?
|
||||
3. Ember means "new chapter" everywhere except the wordmark and the login screen (style.css:238-241). Is a law with a carved-out exception still a law, or does the brand need its own accent instead of borrowing the one reserved signal?
|
||||
4. The 10px steps read as confidence — restraint pushed to its limit. On a surface whose brief names night reading and glare as an explicit personal requirement, is that restraint or is it the one place taste overrode the brief?
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md
|
||||
|
||||
Guidance for OpenCode (and Claude Code) working in this repo.
|
||||
Repo-wide guidance for coding agents.
|
||||
|
||||
## What this is
|
||||
|
||||
@@ -13,15 +13,18 @@ One backend, one `bookmarks` table: a `kind` column (`manga`|`novel`) splits the
|
||||
|
||||
## Hard constraints (drive design — don't violate)
|
||||
|
||||
Nothing below is derivable from reading the code — it is why the code looks the
|
||||
way it does, plus dated measurements against services we don't control.
|
||||
|
||||
Userscript targets **Violentmonkey**, so `GM_*` APIs available, but stay GM-free where plain web APIs suffice — keeps portability across engines:
|
||||
- **Avoid `GM_*` unless needed.** Prefer page `localStorage` over `GM_setValue`/`GM_getValue`, on-page UI over `GM_registerMenuCommand`, plain `fetch()` over `GM_xmlhttpRequest` for cross-origin.
|
||||
- Cross-origin `fetch()` work **only** against CORS-enabled backend. Manga sites `https://`, so backend **must be HTTPS** (else mixed-content block).
|
||||
- Every site is its **own origin with its own `localStorage`** — a shared remote store is the only way to unify bookmarks. Cloud sync required, not optional.
|
||||
- Userscript run in **isolated world**, so embedded API token safe from site's JS.
|
||||
- Cloudflare's block on manga sites is **per-zone configuration plus request fingerprint, not IP reputation — and not reliably reproducible.** Verified 2026-07-26: plain `curl` from both CGNAT dev machine *and* deployed VPS got clean 200s with real HTML on both asurascans.com and demonicscans.org (homepage, series, chapter pages) — no interactive Turnstile challenge from either IP at test time. Contradicts earlier untested assumption CGNAT dev IP blocked; wasn't, at least this date. Treat "does curl work right now" as live, time-varying fact to re-check, not fixed property of machine — a Site can turn its protection on overnight, which is exactly what comix.to did on 2026-08-12. An earlier version of this line blamed "Cloudflare's bot scoring"; that was wrong. The 1-99 bot score is Enterprise Bot Management only and does not exist for a free-plan zone, and no per-IP request rate is documented as an input to challenge issuance — `docs/research/cloudflare-bot-scoring-and-poll-cadence.md`. Backend fetcher still needs graceful-degrade path for when challenged, and adapters should be **verified against live pages** (Playwright MCP, on-device devtools, direct probe) before finalizing, not assumed from single earlier test.
|
||||
- **kagane.to, comix.to and novelfull.com are the exception to the above** — all three sit behind a Cloudflare JavaScript challenge no TLS fingerprint clears, so the backend polls them over CDP (`BROWSER_WS_URL`). When that's unset, kagane and comix are skipped entirely (a plain fetch would only retrieve a challenge page) while novelfull pages are still attempted over plain TLS — its challenge is a live time-varying fact and its cover bytes never need the browser. comix turned hostile on 2026-08-12 (#98): its cover host `static.comix.to` is gated too, so its cover bytes go through the browser as well, and its page is read as an in-tab `fetch()` of the series URL rather than a rendered DOM — comix is an SPA, and rendering costs ~65 requests for the same server-rendered HTML one fetch returns. The three other sites poll fine over plain TLS.
|
||||
- Cloudflare's block on manga sites is **per-zone configuration plus request fingerprint, not IP reputation — and not reliably reproducible.** Verified 2026-07-26: plain `curl` from both CGNAT dev machine *and* deployed VPS got clean 200s with real HTML on both asurascans.com and demonicscans.org (homepage, series, chapter pages) — no interactive Turnstile challenge from either IP at test time. Contradicts earlier untested assumption CGNAT dev IP blocked; wasn't, at least this date. Treat "does curl work right now" as live, time-varying fact to re-check, not fixed property of machine — a Site can turn its protection on overnight, which is exactly what comix.to did on 2026-08-12. An earlier version of this line blamed "Cloudflare's bot scoring"; that was wrong. The 1-99 bot score is Enterprise Bot Management only and does not exist for a free-plan zone, and no per-IP request rate is documented as an input to challenge issuance. Backend fetcher still needs graceful-degrade path for when challenged, and adapters should be **verified against live pages** (Playwright MCP, on-device devtools, direct probe) before finalizing, not assumed from single earlier test.
|
||||
- **kagane.to, comix.to and novelfull.com are the exception to the above** — all three sit behind a Cloudflare JavaScript challenge no TLS fingerprint clears, so the backend polls them over CDP (`BROWSER_WS_URL`). When that's unset, kagane and comix are skipped entirely (a plain fetch would only retrieve a challenge page) while novelfull pages are still attempted over plain TLS — its challenge is a live time-varying fact and its cover bytes never need the browser. comix turned hostile on 2026-08-12: its cover host `static.comix.to` is gated too, so its cover bytes go through the browser as well, and its page is read as an in-tab `fetch()` of the series URL rather than a rendered DOM — comix is an SPA, and rendering costs ~65 requests for the same server-rendered HTML one fetch returns. The three other sites poll fine over plain TLS.
|
||||
- **The CDP browser must look like a real browser, and stock headless images don't.** Measured 2026-08-08 against kagane.to, all from the same IP: `chromedp/headless-shell:stable` never cleared the challenge in 90s (`navigator.webdriver` true, empty plugin list, Chromium-branded client hints — suppressing `webdriver` alone changed nothing); `zenika/alpine-chrome` ships Chrome 124, refused outright; real Chrome with the default `--headless=new` UA never cleared, because the UA says `HeadlessChrome`; real Chrome with a stock UA **and** a non-UTC clock zone cleared in ~4s. Hence `chrome/` — a Debian image with `google-chrome-stable`, a version-derived UA, and `TZ`/`BROWSER_TZ`. Chrome reads the zone *name* through ICU from `/etc/localtime`'s symlink target, ignoring the file's contents, so mounting the host's `/etc/localtime` does **not** work; `/etc/timezone` is mounted instead.
|
||||
- **The browser is not in the API stack and must not be put back.** It's its own compose unit (`chrome/docker-compose.yml`) on a second machine, reached over the tailnet — it held 471 MiB on a 1974 MiB swapless VPS, and a residential egress avoids the cloud-hosting-IP signature Bot Fight Mode documentedly challenges (ADR-0006; not a better "score" — free-plan zones have no score). Consequences that constrain code: `BROWSER_WS_URL` must be a tailnet **IP** (a MagicDNS name 500s at `/json/version`, same trap as the old Docker service name); the CDP port binds to the tailnet address only, since CDP authenticates nothing and that host has a real LAN; and the browser is on-demand (ADR-0005), so an unreachable or asleep one must degrade exactly as an unset `BROWSER_WS_URL` — plain-TLS libraries unaffected, kagane/comix logged and skipped, stored covers still served. Never add `chromedp.NoModifyURL`: discovery per fetch is what makes a restarted Chrome invisible.
|
||||
- **The browser is not in the API stack and must not be put back.** It's its own compose unit (`chrome/docker-compose.yml`) on a second machine, reached over the tailnet — it held 471 MiB on a 1974 MiB swapless VPS, and a residential egress avoids the cloud-hosting-IP signature Bot Fight Mode documentedly challenges (not a better "score" — free-plan zones have no score). Consequences that constrain code: `BROWSER_WS_URL` must be a tailnet **IP** (a MagicDNS name 500s at `/json/version`, same trap as the old Docker service name); the CDP port binds to the tailnet address only, since CDP authenticates nothing and that host has a real LAN; and the browser is on-demand, so an unreachable or asleep one must degrade exactly as an unset `BROWSER_WS_URL` — plain-TLS libraries unaffected, kagane/comix logged and skipped, stored covers still served. Never add `chromedp.NoModifyURL`: discovery per fetch is what makes a restarted Chrome invisible.
|
||||
- **UTC is the tell, not a country mismatch.** A UTC clock is the datacenter default, and the challenge refuses it; any real zone clears. Measured 2026-08-08, identical container, one Indonesian egress IP: UTC never cleared in 60s (twice), while `Asia/Jakarta` **and** `America/New_York` both cleared in 4s. An earlier note here claimed the zone had to match the egress IP's country — that was wrong, inferred from the host clock (`Asia/Bangkok`) rather than the measured egress. A second earlier claim, that Cloudflare "scores" a UTC clock, was also wrong: the measurement is real but the mechanism is not documented anywhere — Cloudflare publishes no timezone signal, and free-plan zones carry no score at all. `BROWSER_TZ` therefore needs a plausible zone, not a geolocated one.
|
||||
- **A challenged page needs the tab kept open.** The interstitial takes seconds to solve and only then writes clearance into the browser's shared cookie jar. Navigate-read-close never clears anything; `BrowserFetcher.run` holds one tab and re-reads until the payload arrives.
|
||||
|
||||
@@ -36,7 +39,7 @@ Two Violentmonkey userscripts (isolated world, per-site adapters, localStorage c
|
||||
on-demand Chrome, separate machine (chrome/)
|
||||
```
|
||||
|
||||
Two deployable units on two machines: the API stack (`docker-compose.yml` + `docker-compose.prod.yml`, on the VPS) and the browser (`chrome/docker-compose.yml`, on the home machine). They share nothing but `BROWSER_WS_URL` and update independently. Backend-specific architecture (packages, endpoints, poller, config env vars) lives in `backend/AGENTS.md`. Userscript-specific structure (adapters, retry queue, UI, live URL shapes) lives in `userscript/AGENTS.md`. Deploy order `DEPLOY.md` (§7 for the browser), redeploy `REDEPLOY.md` (§8 for the browser).
|
||||
Two deployable units on two machines: the API stack (`docker-compose.yml` + `docker-compose.prod.yml`, on the VPS) and the browser (`chrome/docker-compose.yml`, on the home machine). They share nothing but `BROWSER_WS_URL` and update independently. Backend-specific detail lives in `backend/AGENTS.md`, userscript-specific detail in `userscript/AGENTS.md`.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -47,6 +50,15 @@ Backend (`cd backend`):
|
||||
|
||||
Local stack: `docker compose up` (bookmark-api + postgres only; `postgres-data` named volume, `restart: unless-stopped`). No browser — without `BROWSER_WS_URL` the poller logs and skips kagane and comix. To run one: `cd chrome && BROWSER_BIND_ADDR=172.17.0.1 docker compose up -d --build`, then `BROWSER_WS_URL=ws://172.17.0.1:9222` in the root `.env` (bridge gateway, so the API container can name it by IP).
|
||||
|
||||
**Clean up Docker after testing.** Storage on the dev machine is scarce, so
|
||||
anything you started for a test you also tear down before calling the work
|
||||
done: `docker compose down -v` for a stack you brought up, `docker rm -f -v`
|
||||
for a container you ran by hand (`-v`, or the image's anonymous data volume
|
||||
survives). Then check `docker volume ls` and `docker system df` for leftovers
|
||||
and reclaim them — a dangling volume nobody notices is the leak that fills the
|
||||
disk. Never remove the `postgres-data` volume of a stack the user is actually
|
||||
running, and never blanket-`docker system prune` their images or build cache.
|
||||
|
||||
Live CDP proof (needs that browser and network, skipped otherwise):
|
||||
`SMOKE_BROWSER_WS_URL=ws://<ip>:<port> go test -run 'TestSmokeKagane|TestSmokeComix' ./internal/latest`
|
||||
— fetches a real kagane and comix cover and chapter list. A red run means the challenge is
|
||||
@@ -64,28 +76,22 @@ Smoke test: `curl` endpoints with `Authorization: Bearer <token>`; confirm `OPTI
|
||||
|
||||
## Forge: Gitea, not GitHub
|
||||
|
||||
`origin` is self-hosted Gitea instance (`gitea.violetcrown.my.id`), so **`gh` don't work here — use `tea` (Gitea CLI) for anything past plain git.** Common ones:
|
||||
|
||||
- Open PR: `tea pr create --head <branch> --base main --title "..." --description "..."`
|
||||
- List / view / check out: `tea pr list`, `tea pr <n>`, `tea pr checkout <n>`
|
||||
- Issues: `tea issue create`, `tea issue list`
|
||||
- Auth lives in `tea login`, not `GH_TOKEN` env var.
|
||||
|
||||
`tea` print output as rendered boxes rather than plain text; PR URL lands on last line.
|
||||
`origin` is self-hosted Gitea (`gitea.violetcrown.my.id`, repo `sulthan/mangaBookmark`), so **`gh` don't work here and the `issue://`/`pr://` URIs error out — drive the forge with `tea`.** How to run it — commands, traps, JSON output: skill `gitea`. Tracker conventions (ticket bodies, wayfinding, PR-as-request-surface flag): `docs/agents/issue-tracker.md`. Triage label strings: `docs/agents/triage-labels.md`.
|
||||
|
||||
## Design system
|
||||
|
||||
Web UI + userscript panel follow **Cinder**, rules in `docs/design-system.md`
|
||||
— source of truth Claude Design project `BookmarkManager Web UI`
|
||||
(`969ac210-fe02-4c01-ae1b-9a271dcc779a`). Read it before touching
|
||||
`backend/internal/web/static/style.css`, `backend/internal/web/templates/*`, or userscript
|
||||
`TEMPLATE`/`CSS`. Core law: **ember means new chapter only** — no other
|
||||
state (busy, error, destruction) may use `--ember`; destruction gets
|
||||
`--danger`. No cards/corners/shadows, one `--measure: 760px` column, tokens
|
||||
only (never hardcode hex outside `:root`), both colour branches touched
|
||||
together. Any move that pulls series out of list (archive/finish/remove)
|
||||
must be confirm-gated via its own `.confirm-row`; only restore fires
|
||||
instantly.
|
||||
Web UI + userscript panel follow **Cinder**. Tokens are the `:root` block in
|
||||
`backend/internal/web/static/style.css`; that file, `backend/internal/web/templates/*`,
|
||||
and the userscript `TEMPLATE`/`CSS` are the only places it is expressed.
|
||||
Source of truth for the visual language is the Claude Design project
|
||||
`BookmarkManager Web UI` (`969ac210-fe02-4c01-ae1b-9a271dcc779a`).
|
||||
|
||||
Core law: **ember means new chapter only** — no other state (busy, error,
|
||||
destruction) may use `--ember`; destruction gets `--danger`. No
|
||||
cards/corners/shadows, one `--measure: 760px` column, tokens only (never
|
||||
hardcode hex outside `:root`), both colour branches touched together. Any move
|
||||
that pulls a series out of the list (archive/finish/remove) must be
|
||||
confirm-gated via its own `.confirm-row`; only restore fires instantly.
|
||||
|
||||
## Security invariants
|
||||
|
||||
@@ -102,7 +108,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.
|
||||
@@ -127,13 +133,19 @@ Review gate: auth, CORS, session, crypto, and the fetch gate are security-critic
|
||||
## Comments
|
||||
|
||||
Comment only if code alone can't carry info. Cost per read — must earn spot.
|
||||
Wrong comment worse than none: it misleads readers and measurably degrades
|
||||
LLM performance on the file. Missing comment costs little. Bias to fewer.
|
||||
|
||||
Write for:
|
||||
- Why not what. Tradeoffs, non-obvious decisions.
|
||||
- Load-bearing detail looking incidental — say so if "simplify" breaks it.
|
||||
Docstring on public/exported surface — exception, near-always worth it.
|
||||
Contract only: what it takes, returns, throws, mutates; units; pre/post
|
||||
conditions. Not a restatement of the body. Skip on private/obvious.
|
||||
|
||||
Inline — write for:
|
||||
- Why not what. Tradeoffs, non-obvious decisions, rejected alternatives.
|
||||
- heavy detail looking incidental — say so if "simplify" breaks it.
|
||||
- Non-local consequence, invisible from function alone.
|
||||
- Wire format / encoding / interface contract — save callers re-deriving.
|
||||
- Gotcha/workaround, with ref if exists.
|
||||
- Wire format / encoding / ordering / invariant — save callers re-deriving.
|
||||
- Gotcha/workaround, with ref (issue, RFC, vendor bug) if exists.
|
||||
- Domain/business rule not derivable from code.
|
||||
|
||||
Skip:
|
||||
@@ -142,34 +154,49 @@ Skip:
|
||||
- Banners, dividers, `// helpers`.
|
||||
- Change narration (`// fix bug`, `// as requested`, `// new impl`) — git's job.
|
||||
- Commented-out code — delete.
|
||||
- TODO without concrete action.
|
||||
- TODO without concrete action + owner.
|
||||
- Narrating the plan you just reasoned through. Plan in prose or in your head;
|
||||
ship the code, not the transcript.
|
||||
- Anything restating a name that could be fixed by renaming instead.
|
||||
|
||||
Style: one dense comment over function beats one per line inside. Tight, no worked example unless bug subtle. Wrong comment worse than none — update/delete on change. Default fewer — sparse+high-signal beats comprehensive.
|
||||
Staleness filter: if the comment describes something likely to change
|
||||
independently of this line, it will rot and start lying. Either anchor it to
|
||||
something stable, assert it in a test, or leave it out.
|
||||
|
||||
Test: "competent reader get this from code in few sec?" Yes → skip. Needs detour through another file/spec/git-blame → write it.
|
||||
Style: one dense comment over a function beats one per line inside. Tight; no
|
||||
worked example unless the bug is subtle. On edit, update or delete stale
|
||||
comments in the code you touch — silence beats a lie.
|
||||
|
||||
## Agent skills
|
||||
Test: "competent reader get this from code in a few sec?" Yes → skip.
|
||||
Needs detour through another file/spec/git-blame/external doc → write it.
|
||||
|
||||
`AGENTS.md` is the single source of truth for agent guidance; every `CLAUDE.md` in this repo is a symlink to the `AGENTS.md` beside it. Edit `AGENTS.md`.
|
||||
## Writing an AGENTS.md
|
||||
|
||||
### Issue tracker
|
||||
`AGENTS.md` is the single source of truth for agent guidance; every `CLAUDE.md`
|
||||
in this repo is a symlink to the `AGENTS.md` beside it. Edit `AGENTS.md`.
|
||||
|
||||
Issues live as Gitea issues on `gitea.violetcrown.my.id` (`sulthan/mangaBookmark`), driven by the `tea` CLI — not `gh`. See `docs/agents/issue-tracker.md`.
|
||||
**Cite code, never docs, issues, or plans.** A spec, ADR, plan file, or Gitea
|
||||
issue records what was true when it was written and then goes stale silently;
|
||||
an agent that follows the pointer reads a decision that may already have been
|
||||
reversed. Code is the only source true at read time — cite a package, file,
|
||||
symbol, env var, or route. The sole non-code exception is a sibling
|
||||
`AGENTS.md`. If a doc holds a fact an agent needs, restate the fact here rather
|
||||
than linking to it.
|
||||
|
||||
### Triage labels
|
||||
**State a fact in prose only if the code cannot answer it.** Split by
|
||||
derivability:
|
||||
|
||||
Default five-role vocabulary, label strings unchanged (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). See `docs/agents/triage-labels.md`.
|
||||
- *Structure* — packages, routes, env vars, columns, struct fields. Rots fast,
|
||||
cheap to re-read. **Name the symbol, write nothing else.**
|
||||
- *Mechanism* — what a function does, how a flow proceeds. **Name the symbol
|
||||
plus at most one line of orientation.**
|
||||
- *Rationale* — why it is this way, what a "simplify" would break, what was
|
||||
tried and rejected. Not in the code and cannot be re-derived. **Write it out.**
|
||||
- *Measurement* — an observation against something we don't control. **Write it
|
||||
out with the date**; a dated fact is honest, an undated one pretends to be
|
||||
permanent.
|
||||
|
||||
### Domain docs
|
||||
|
||||
Single-context: one root `CONTEXT.md` plus `docs/adr/`, both created lazily. See `docs/agents/domain.md`.
|
||||
|
||||
## graphify
|
||||
|
||||
Project has knowledge graph at graphify-out/ with god nodes, community structure, cross-file relationships.
|
||||
|
||||
Rules:
|
||||
- For codebase questions and exploration, always first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. Return scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
||||
- If graphify-out/wiki/index.md exists, use for broad navigation instead of raw source browsing.
|
||||
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain don't surface enough context.
|
||||
- After modifying code, run `graphify update .` to keep graph current (AST-only, no API cost).
|
||||
Restating mechanism in prose is how these files rot: the code changes, the
|
||||
paragraph doesn't, and the next agent trusts the paragraph. A pointer degrades
|
||||
more honestly — and every symbol you name must actually exist, since a dead
|
||||
pointer is a bug, not a stale sentence.
|
||||
|
||||
+67
-5
@@ -43,6 +43,13 @@ Readers: Progress, Favourite, Lifecycle bucket. Facts about the Series itself be
|
||||
to the Series, not here.
|
||||
_Avoid_: entry, item, record, subscription
|
||||
|
||||
**Orphan Series**:
|
||||
A Series no Reader bookmarks. Removing a Bookmark never removes the Series, so the row
|
||||
outlives every relationship to it: nothing reads it, no Poll visits it, and it still owns
|
||||
a Cover. A state of the Series, not a Lifecycle bucket — it says how many Readers hold it,
|
||||
never anything about a Reader.
|
||||
_Avoid_: dangling, unused, dead series, stale
|
||||
|
||||
**Library**:
|
||||
One of the two halves of the collection — manga or novel — selected by a Bookmark's
|
||||
`kind`. The web UI and the userscripts each address exactly one Library at a time.
|
||||
@@ -76,6 +83,41 @@ asked. Every Site has exactly one and no Lane can slow, block or borrow from ano
|
||||
a Reader never has one and never influences one.
|
||||
_Avoid_: worker, queue, scheduler, batch, wave
|
||||
|
||||
**Lane Pass**:
|
||||
One sweep of a Poll Lane over the Series due on its Site: what it found waiting, how many it
|
||||
read, and whether it declined to work at all. A fact about the Lane rather than about any
|
||||
Series — a pass that read nothing is still a pass, and one that declined carries the reason it
|
||||
declined, since a Lane resting and a Lane stuck look identical from a count alone. Its record
|
||||
outlives the process that made it: "the poller has done nothing for six hours" is only
|
||||
answerable by something written down.
|
||||
_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 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.
|
||||
It also takes whatever Cover the Site publishes today: asking for one is asking to accept the
|
||||
page as it now stands, so it is the only read after Acquisition that can replace a Cover.
|
||||
_Avoid_: manual poll, refresh, retry, force refresh
|
||||
|
||||
**Paused Lane**:
|
||||
A Poll Lane the owner has stopped for a bounded time. It makes no Polls until the pause
|
||||
expires, so its Series stay due and unstamped exactly as they do when a Site cannot be
|
||||
reached. Every pause carries an expiry — a Lane cannot be stopped indefinitely — and it
|
||||
outlives a restart, being a fact about the Site rather than about the running process.
|
||||
_Avoid_: disabled, off, stopped, suspended, kill switch (that is the deploy-time switch)
|
||||
|
||||
**Stall**:
|
||||
A Poll Lane that owed Polls, made none, and has nothing to say for it. Distinct from the
|
||||
two conditions it resembles: a Site that refuses is exercising the pace it is entitled to,
|
||||
and a Lane the owner paused was told to stop — a Stall is neither asked for nor explained.
|
||||
It is the one fault no Reader surface can show: every Bookmark still opens, Progress still
|
||||
syncs, and Latest Chapter is quietly wrong for as long as it lasts.
|
||||
_Avoid_: outage, downtime, failure, backlog, lag
|
||||
|
||||
**Sighting**:
|
||||
What a Reader's browser happened to see of a Series's Latest Chapter while that Reader
|
||||
was on the page. It reports the same fact as a Poll but carries none of its authority:
|
||||
@@ -88,21 +130,41 @@ _Avoid_: client report, user poll, observation, claim
|
||||
The single read of a Series page made the moment the Series first exists, giving it
|
||||
both its Latest Chapter and its Cover without waiting for the Lane's pace. Distinct
|
||||
from a Poll in the two ways that matter: a Reader is present — it is triggered by
|
||||
their first Bookmark of that Series — and it is the only read that establishes a
|
||||
Cover rather than refreshing facts. It happens once in a Series's life; every later
|
||||
read of the same page is a Poll.
|
||||
their first Bookmark of that Series — and it establishes a Cover rather than refreshing
|
||||
facts, which no Poll does unless the owner forces one. It happens once in a Series's
|
||||
life; every later read of the same page is a Poll.
|
||||
_Avoid_: initial poll, first fetch, prefetch, warm-up
|
||||
|
||||
**Correction**:
|
||||
A Latest Chapter the owner sets by hand, on a Series no Poll can read. It reports the
|
||||
same fact as a Poll and carries even less authority than a Sighting: the next Poll
|
||||
overwrites it, so does any Reader's Sighting, and it is never a floor or a pin. It
|
||||
exists only because the Site page is unreadable — where a Poll can read the page, the
|
||||
Poll is the answer and a Correction is not wanted.
|
||||
_Avoid_: override, pin, manual value, fix
|
||||
|
||||
**New Chapter**:
|
||||
The state where Latest Chapter is ahead of Progress. The single condition the ember
|
||||
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.
|
||||
|
||||
@@ -136,6 +136,9 @@ gated by membership in one configured guild.
|
||||
DISCORD_REDIRECT_URI=https://bookmark.violetcrown.my.id/auth/discord/callback
|
||||
# Optional: only members holding this role may sign in.
|
||||
# DISCORD_REQUIRED_ROLE=<role snowflake>
|
||||
# Optional: display name for that guild — shown on the login screen so a
|
||||
# stranger knows which Discord to ask for an invite.
|
||||
# DISCORD_GUILD_NAME=Your Guild Name
|
||||
```
|
||||
|
||||
The guild id is in Discord's client with Developer Mode on: right-click the
|
||||
|
||||
+5
-5
@@ -8,7 +8,7 @@ web
|
||||
|
||||
## Users
|
||||
|
||||
Members of one private Discord guild, each with their own library. Accounts exist and are created by signing in — there is no signup form, no invite code and no approval step: any member of the configured guild becomes a Reader on their first Discord login. The person running the deployment is the owner, seeded at startup, and the only Reader with an administrative capability (revoking another Reader's sessions).
|
||||
Members of one private Discord guild, each with their own library. Accounts exist and are created by signing in — there is no signup form, no invite code and no approval step: any member of the configured guild becomes a Reader on their first Discord login. The person running the deployment is the owner, seeded at startup, and the only Reader with an administrative capability (`/admin` — Overview, Lanes, Readers, Series).
|
||||
|
||||
Reading happens on **asurascans.com**, **demonicscans.org**, **comix.to** and **kagane.to** for manga and **novelfull.com** and **lightnovelworld.net** for novels, primarily via Bromite on mobile, with checks and corrections from a desktop browser. The web UI is the cross-device view into progress the userscripts capture while reading.
|
||||
|
||||
@@ -29,12 +29,12 @@ 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.
|
||||
- The owner can revoke a specific Reader's sessions; nothing else in the UI differs by Reader.
|
||||
- The owner alone can reach `/admin` (Overview, Lanes, Readers, Series) for library/lane/series maintenance and Reader session/Sighting controls; otherwise nothing else in the UI differs by Reader.
|
||||
- htmx-driven partial updates, no client-side framework or build step — templates are Go `html/template`, `go:embed`-ed.
|
||||
- Mobile-first is a hard functional constraint (primary device is a phone), not just a starting breakpoint.
|
||||
|
||||
@@ -54,7 +54,7 @@ Not a public reading tracker or social app — a private, self-hosted sync layer
|
||||
- Mobile is the primary target; desktop is an enhancement, not the design center.
|
||||
- Progress data integrity over visual flourish: `updated_at`/list-ordering behavior is a correctness constraint the UI must respect, not decorate over.
|
||||
- A leak between Readers fails silently and looks like working software — isolation is asserted from both directions, never inferred from counting one Reader's rows.
|
||||
- No roles, no org chrome: the owner's Readers panel is one list with one button (revoke someone's sessions), not an admin console, and otherwise every Reader's view is the same.
|
||||
- No roles, no org chrome beyond the owner gate: `/admin` is the one owner-only console (Overview, Lanes, Readers, Series) for library-wide hygiene, lane health/pauses and series/Reader maintenance — Readers offers revoke sessions and clear Sighting marks, both confirm-gated — and otherwise every Reader's view is the same.
|
||||
- Prefer native platform affordances (system dark/light, native touch targets) over custom widgetry — this is a lean self-hosted tool, not a product to demo.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
@@ -48,6 +48,7 @@ covers are stored, so the library renders in full with the browser switched off.
|
||||
| `DISCORD_CLIENT_ID` | *(required)* | Discord application credentials for the browser sign-in (ADR-0002). |
|
||||
| `DISCORD_CLIENT_SECRET` | *(required)* | As above. Never logged, never echoed in an error. |
|
||||
| `DISCORD_GUILD_ID` | *(required)* | The one guild whose membership gates sign-in, checked at login only. Membership *is* registration: any member becomes a Reader on first login. |
|
||||
| `DISCORD_GUILD_NAME` | empty | Human-readable name for `DISCORD_GUILD_ID`, shown on the login screen so a stranger knows which community owns this library and who to ask for an invite. When unset the page shows a generic "private community" label. |
|
||||
| `DISCORD_REDIRECT_URI` | *(required)* | Exact callback URL; Discord matches it verbatim against the registered redirect. |
|
||||
| `DISCORD_REQUIRED_ROLE` | empty | Role snowflake a member must additionally hold. Empty means guild membership alone suffices. |
|
||||
| `DISCORD_API_BASE` | `https://discord.com/api/v10` | Test seam — tests point it at a local stub so the real token exchange runs. |
|
||||
@@ -218,10 +219,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.
|
||||
|
||||
|
||||
+278
-270
@@ -1,274 +1,282 @@
|
||||
Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGENTS.md` for the project-wide architecture diagram, hard constraints, and design system.
|
||||
Scope: `backend/`.
|
||||
|
||||
- **Backend** (`backend/`): stdlib `net/http` (handful routes, no framework) + Postgres over `jackc/pgx/v5` (pure Go, `CGO_ENABLED=0` -> static binary -> distroless/scratch image). Reverse proxy terminates TLS; Go service listens plain `:8080`.
|
||||
Single binary, split into packages under `backend/internal/`: `store`
|
||||
(Bookmark type, Postgres persistence, migration runner), `latest` (background
|
||||
poller, site parsers, TLS fetcher), `session` (cookie signing, login
|
||||
rate limiter), `httpmw` (Auth/Gzip/CORS middleware), `api` (JSON
|
||||
bookmark handlers), `userscript` (userscript-serving handler), `web`
|
||||
(browser UI handler + `templates/` + `static/`, `go:embed`-ed).
|
||||
`backend/main.go` is the composition root — the only place that wires
|
||||
packages together into `newRouter`. Root-level `*_test.go` hold
|
||||
integration tests that exercise the full router; unit tests for a
|
||||
package live beside it under `internal/`.
|
||||
- **Schema is migration-owned.** `internal/store/migrations/*.sql` is
|
||||
`go:embed`-ed and applied on every start by `store.migrate`: one numbered
|
||||
file per change, one transaction each, versions recorded in
|
||||
`schema_migrations`. Files are **append-only** — editing an applied one
|
||||
changes nothing on a database that already ran it. No column probing, no
|
||||
data-fixup migrations: both were SQLite-era machinery and are gone.
|
||||
- **Tests need Docker.** `internal/pgtest` starts one `postgres:17-alpine`
|
||||
container per test binary (`TestMain` -> `pgtest.Main`) and hands each test
|
||||
its own database (`pgtest.URL(t)`). A package whose tests touch the store
|
||||
must have that `TestMain`.
|
||||
- **Reader-owned store, four tables.** `readers` is keyed by Discord user ID
|
||||
and carries the SHA-256 of the Reader's userscript credential plus a
|
||||
`token_epoch` (issue #24). Credentials are derived, never stored: `token.Token(TOKEN_KEY, discord_id, epoch)` (HMAC, `internal/token`), and only its SHA-256 sits in `readers.token_sha256`, so install URLs can be rebuilt after any restart while a database leak yields nothing but hashes. The seed creates the **owner** row at startup; its epoch-0 hash is refreshed on every start **only while the row has never been rotated**, so a restart can never resurrect a rotated-away credential. Every other row is created by that Reader's own first login (`Store.EnsureReader`, idempotent on `discord_id`, and it never rewrites an existing row's hash). Rotation is `Store.RotateToken` (epoch bump + hash rewrite in one transaction), driven by the web UI.
|
||||
`series` keyed `(site, series_id)`
|
||||
(`asura`|`demonic`|`comix`|`kagane`|`novelfull`|`lightnovelworld`) owns the
|
||||
shared facts — title, cover, canonical URL, `kind` (`manga`|`novel`),
|
||||
Latest Chapter, `latest_checked_at`, and the Sighting pair
|
||||
`latest_sighted_at`/`latest_raised_by` (issue #103) — and `bookmarks` holds only what
|
||||
differs between readers: progress, favourite, lifecycle bucket,
|
||||
`updated_at`. A bookmark is keyed `(reader_id, site, series_id)` — no
|
||||
surrogate id; the wire `key` is derived as `site:series_id` on read — and
|
||||
every store read/write is scoped to the reader it names. Auth resolves the
|
||||
acting Reader from the presented credential (`httpmw.Auth`) and nothing
|
||||
else — there is no unauthenticated-by-Reader route and no global token; the
|
||||
reader id travels in the request context. Sync **last-write-wins**; the wire format
|
||||
stays flat (ADR-0004). `Store.Upsert` decomposes one flat body across two
|
||||
tables and enforces the ownership rule: client `title`/`series_url`/`cover`
|
||||
are written only when the series row is new (ADR-0003).
|
||||
- **Endpoints:** `GET /bookmarks`, `PUT /bookmarks/{key}` (upsert; see `updated_at` rule below), `DELETE /bookmarks/{key}`, `GET /healthz` (no auth).
|
||||
- **Web UI:** same binary serve the browser UI on a second
|
||||
hostname — `GET /` (list, or login page when no session),
|
||||
`GET /auth/discord` + `GET /auth/discord/callback` (Discord OAuth,
|
||||
ADR-0002), `POST /logout`, `GET /static/*`, htmx fragment endpoints
|
||||
under `/ui/*`. Templates + assets `go:embed`-ed under
|
||||
`backend/internal/web/`, so `backend/Dockerfile` must copy the whole
|
||||
`internal/` tree, not just `*.go`. Sessions are rows in the `sessions`
|
||||
table: the cookie carries only an opaque id, looked up (and expiry-
|
||||
checked) on every request, and deleting the row revokes the session.
|
||||
Guild membership *is* registration (issue #27): `discordCallback` gates on
|
||||
membership (and `DISCORD_REQUIRED_ROLE` when set) and then calls
|
||||
`Store.EnsureReader`, so a refusal creates nothing and a returning Reader
|
||||
reuses their row. The owner is the only Reader with administrative reach:
|
||||
`POST /readers/{id}/revoke` (404 for anyone else) drops that Reader's
|
||||
sessions, and the `readers` panel renders only on the owner's page.
|
||||
A Reader with no bookmarks at all sees `listView.Fresh`, whose empty state
|
||||
offers both install links instead of describing a filter.
|
||||
UI mutations read-modify-write
|
||||
through `Store.Get` + `Store.Upsert` so `updated_at` rule stays one
|
||||
place. See `docs/superpowers/specs/2026-07-25-web-ui-design.md`.
|
||||
**Design-tool caveat:** templates link `/static/style.css` root-absolutely
|
||||
(correct — served from `/`), but impeccable detector resolves
|
||||
stylesheet href with `path.resolve(fileDir, href)`, drops directory
|
||||
on leading `/` and silently skip file. Relative href don't help
|
||||
either: template's directory isn't its served path. So
|
||||
`detect.mjs backend/internal/web/templates` reports **false clean** —
|
||||
always pass `backend/internal/web/static` too. One finding there,
|
||||
`overused-font` on "Instrument Serif", deliberate identity choice, not debt.
|
||||
- **Every action that moves series out of list is confirm-gated.**
|
||||
Archive, finish, remove each open own `.confirm-row` disclosure
|
||||
(`toggleConfirmRow(key, kind)` in `filter.js`, `kind` ∈
|
||||
`archive|finish|remove`); restore fire instantly since it's the reversal.
|
||||
Remove's row wear ember wash, two reversible ones wear `.calm` grey.
|
||||
`--ember` stay reserved for new-chapter signal: busy bar and inline
|
||||
error use `--mute`.
|
||||
- **Latest-chapter poller:** one goroutine per Site (a Poll Lane, issue #100),
|
||||
each re-checking that Site's bookmarked series' newest published chapter from
|
||||
backend's own network access, so `latest_chapter` stays fresh when the user
|
||||
isn't browsing. Second, parallel signal — the userscript keeps its own
|
||||
`maybeCaptureLatestOnSeriesPage`/`backgroundRefreshLatest` schedule, and its
|
||||
`reportLatestChapter` PUTs every read, unchanged numbers included, because an
|
||||
unchanged read is exactly the Sighting worth deferring a Poll on (#103).
|
||||
Two independent clocks: per-series rest (`series.latest_checked_at`,
|
||||
enforced by `Store.DueForLatestCheck`'s WHERE clause — `now - Rest`) and
|
||||
per-Lane gap (the Lane sleeping between fetches, `effectiveGap`). Both live
|
||||
in the Site registry (`internal/latest/sites.go`), not config: the five env
|
||||
knobs that used to size a shared pace are gone.
|
||||
The poller walks **Series, not Bookmarks** — a series referenced by several
|
||||
bookmarks is fetched once per cycle, and the due queue orders
|
||||
`reader_count DESC, latest_checked_at ASC` (ADR-0003). Series row stamped
|
||||
*before* fetch so broken series wait out the rest instead of retrying
|
||||
every tick; found chapter written straight to the series row via
|
||||
`Store.SetLatestChapter`, so a bookmark's `updated_at` — and the list
|
||||
order — is never touched.
|
||||
**Sightings** (issue #103, ADR-0011) let a Reader's own page read defer a
|
||||
Poll: `Store.RecordSighting` — called by the PUT handler *before* the Upsert,
|
||||
because the raise test needs the row as it stands — stamps
|
||||
`series.latest_sighted_at` and, when the report raises the stored number,
|
||||
names its Reader in `series.latest_raised_by`. The due query's HAVING clause
|
||||
is where deferral lives: a Series is skipped only while it has exactly one
|
||||
Bookmark, was sighted within one Rest, and is under the ceiling
|
||||
(`sightingCeilingRests`, six of that Site's rests) since its last Poll. So a
|
||||
shared Series is never deferred, and no Series goes six hours unpolled
|
||||
whatever arrives. `checkOne` judges the named Reader off the comparison it
|
||||
already makes: a lower number is a contradiction (logged with the Reader and
|
||||
both numbers), the same number an agreement, a higher number the Site
|
||||
publishing and neither — that last one clears the attribution instead, since
|
||||
the value the Poll then stores is its own and a later retraction is not the
|
||||
Reader's fault. Three contradictions
|
||||
(`store.SightingDisagreementLimit`) stop that Reader deferring — their
|
||||
reports still write the Latest Chapter — and twenty consecutive agreements
|
||||
(`store.SightingAgreementsToClear`) forgive them, as does the owner's
|
||||
clear-marks control. Deferral is recomputed from live facts every round, so
|
||||
nothing needs invalidating when a Series gains a second Bookmark; the one
|
||||
input read earlier is the Reader's marks, checked when the Sighting is
|
||||
recorded, so crossing the threshold or being cleared takes effect from that
|
||||
Reader's next Sighting and the standing already bought lasts out its rest.
|
||||
Refusals and browser loss are Lane-local: two `errChallengeHeld` in one pass
|
||||
stop that Site for `refuseBackoff` (15m) while other Lanes continue; an
|
||||
`errBrowserInterrupted` (remote Chrome restart) sets a shared Poller flag
|
||||
that makes the other browser Lanes skip their passes for the same 15m, so a
|
||||
restarting Chrome doesn't stamp one Series per Lane per pass — after the
|
||||
window the flag decays and they probe again. Browser Lanes wake Chrome only
|
||||
when 5+ Series are due or one has waited 15m (ADR-0005 on-demand browser),
|
||||
and cover work (both healing a stored source URL and filling a blank from
|
||||
the series page) runs in the background so a slow CDN can't consume a
|
||||
Lane's gap.
|
||||
A refusal is only ever the challenge *page*: `isInterstitial` matches the
|
||||
orchestration path `/cdn-cgi/challenge-platform/h/`, never the bare prefix.
|
||||
Cloudflare injects `/cdn-cgi/challenge-platform/scripts/jsd/main.js` into
|
||||
ordinary 200 pages once a zone turns JS detections on, which demonic did on
|
||||
2026-08-16 — the prefix match then read every real demonic page as a refusal
|
||||
and parked that Lane in 15m backoff while plain TLS was returning the full
|
||||
series page.
|
||||
Fetches use `bogdanfinn/tls-client` with Chrome profile as defence in depth
|
||||
against fingerprint-based blocking; any failure log and skip. kagane, comix
|
||||
and novelfull sit behind Cloudflare JavaScript challenges the TLS client
|
||||
can't clear, so they are fetched over CDP via `BROWSER_WS_URL`; kagane and
|
||||
comix are simply not polled when that's unset, while novelfull falls back to
|
||||
a plain-TLS attempt — its challenge is a live time-varying fact, and its
|
||||
cover bytes never need the browser. comix's browser read is an in-tab
|
||||
`fetch()` of the Series URL, not a DOM render: it is an SPA, so rendering
|
||||
costs ~65 requests for the same server-rendered HTML one fetch returns
|
||||
(measured 2026-08-12, issue #98). See
|
||||
`docs/superpowers/specs/2026-07-26-server-latest-chapter-polling-design.md`.
|
||||
The poller's series write is a single-column UPDATE
|
||||
(`Store.SetLatestChapter`), not a read-modify-write of the whole bookmark:
|
||||
it cannot revert read progress or move `updated_at`, so the old
|
||||
stale-re-read race is gone with the Get+Upsert flow.
|
||||
- **Covers are acquired at creation, then served from our own origin
|
||||
(ADR-0007):** the first Bookmark of a Series fires `Store.OnSeriesCreated`,
|
||||
which `latest.Acquirer` turns into one series-page fetch yielding both the
|
||||
Latest Chapter and the cover URL; the bytes then go through
|
||||
`latest.CoverBytesFetcher` into `Store.SetSeriesCover`. It runs in a
|
||||
goroutine — the Reader's PUT must neither block on a Site nor fail with one
|
||||
— and every failure is logged and dropped, leaving the Bookmark intact. The
|
||||
wire's `cover` is the absolute `PUBLIC_BASE_URL + /covers/{sha256}` once
|
||||
bytes exist and `""` before, never an address that 404s. `GET /covers/{addr}`
|
||||
is public and uncredentialed: the userscript renders it on a Site's origin,
|
||||
where no cookie or token of ours travels. A client-sent `cover` is decoded
|
||||
and discarded, permanently (ADR-0004 compatibility).
|
||||
Browser-backed Sites join the same pipeline (issue #62, extended to comix by
|
||||
#98): kagane and comix pages *and* cover bytes go through the browser sidecar
|
||||
(nothing falls back to a plain fetch, which would only retrieve a challenge
|
||||
page), while novelfull needs the browser only for its HTML — the cover URL
|
||||
comes out of the browser-fetched page and the bytes go over plain TLS. With
|
||||
no browser configured, kagane and comix Covers are simply absent; novelfull
|
||||
still gets one — at creation and on the poll — when its page body happens to
|
||||
answer a plain request (the challenge is a live time-varying fact). comix
|
||||
cover bytes must arrive by direct navigation, not an in-page fetch: its
|
||||
Series page sets `cross-origin-embedder-policy: require-corp`, which fails a
|
||||
page-context fetch of `static.comix.to`. The old kagane-only
|
||||
serving path (`/img/kagane/{id}`, template rewrite, `CoverFetcher`) is gone
|
||||
(issue #63): the one public route serves every Site.
|
||||
- **`updated_at` drives list order, so moves only on real reading progress:** server apply its timestamp when row new or `last_chapter_num` changes, else keep stored value — favouriting series or recording newly published chapter must not reorder list. `PUT` therefore returns row **as stored**, clients must adopt that response rather than own payload. See `plans/2026-07-25-bookmark-list-favorites-design.md` §4.
|
||||
- **Lifecycle buckets:** `status` on each bookmark is `reading` | `archived` |
|
||||
`finished`, orthogonal to `favorite`. Archived and finished appear only in
|
||||
own tab — not in All, Updated, Favourites, or recent strip. Poller keeps
|
||||
checking archived series and skip finished ones. `finished` settable
|
||||
only from web UI; `PUT /bookmarks/{key}` reject it with 400.
|
||||
**Empty incoming status means "keep stored one"** — resolved on the
|
||||
`VALUES` side of `Store.Upsert`, not conflict clause, since
|
||||
`excluded.*` is post-evaluation row and default applied there would
|
||||
wipe bucket on every PUT from client that predates column. See
|
||||
`docs/superpowers/specs/2026-07-27-status-buckets-design.md`.
|
||||
- **Config via env:** `TOKEN_KEY` (derives every Reader's userscript credential;
|
||||
required), `OWNER_DISCORD_ID` (seeds the owner Reader — the administrator and
|
||||
the owner of every pre-registration bookmark; required),
|
||||
`ALLOWED_ORIGINS` (comma list),
|
||||
`DATABASE_URL` (Postgres connection URL, required — no default),
|
||||
`COVER_DIR` (required filesystem volume for content-addressed Cover bytes),
|
||||
`PUBLIC_BASE_URL` (required origin this deployment answers on, trailing
|
||||
slash trimmed; every Cover URL on the wire is built from it, absolute
|
||||
because the userscript renders on a Site's origin — ADR-0007),
|
||||
`PORT` (default `8080`), `DISCORD_CLIENT_ID`/`_CLIENT_SECRET`/`_GUILD_ID`/
|
||||
`_REDIRECT_URI` (required; Discord OAuth for the browser UI),
|
||||
`DISCORD_REQUIRED_ROLE` (optional role gate, empty by default),
|
||||
`DISCORD_API_BASE` (default `https://discord.com/api/v10`),
|
||||
`LATEST_CHAPTER_POLL_ENABLED` (background latest-chapter poller kill
|
||||
switch, default on). Pace is per Site in the registry (issue #100): every
|
||||
Site rests an hour and gaps ten seconds, a Site with more eligible Series
|
||||
than 360 tightens its own gap toward the 1s floor, and browser Lanes wake
|
||||
Chrome only on demand (ADR-0005). The `_COOLDOWN`/`_BROWSER_COOLDOWN`/
|
||||
`_INTERVAL`/`_BATCH`/`_STAGGER` knobs that used to size a shared pace are
|
||||
gone. The 1h rest for browser Sites is safe on documented grounds: a
|
||||
challenged page costs seconds of a serialized single-tab browser, free-plan
|
||||
zones have no bot score and no published per-IP rate input, and
|
||||
`cf_clearance` expires in 30 minutes so every cadence at or above 1h
|
||||
re-solves anyway —
|
||||
`docs/research/cloudflare-bot-scoring-and-poll-cadence.md`.
|
||||
`USERSCRIPT_PATH` and `NOVEL_USERSCRIPT_PATH` (files served at
|
||||
`/u/{token}/manga-bookmark.user.js` and `/u/{token}/novel-bookmark.user.js`,
|
||||
defaults `/userscript/manga-bookmark.user.js` and
|
||||
`/userscript/novel-bookmark.user.js`, both supplied by bindmount; the
|
||||
`__API_TOKEN__` placeholder inside them is substituted with the requesting
|
||||
Reader's credential at serve time).
|
||||
`BROWSER_WS_URL` (CDP endpoint of the browser, which runs on a **separate
|
||||
machine** and is reached over the tailnet — ADR-0006, `chrome/docker-compose.yml`.
|
||||
Used by the poller for kagane, comix and novelfull page fetches and by the
|
||||
cover pipeline for kagane's and comix's image bytes (the browser is the only
|
||||
route that clears the challenge those two serve their covers behind); unset —
|
||||
the default — disables browser polling and leaves kagane and comix Covers
|
||||
blank until stored bytes
|
||||
exist. Must be a tailnet IP, never a hostname: Chrome's DevTools handler 500s
|
||||
`/json/version` for any Host that isn't an IP or `localhost`).
|
||||
- **No per-Site cover path (issue #63):** every Cover — all six Sites — is
|
||||
served by the one public `GET /covers/{addr}` route from content-addressed
|
||||
bytes. There is no proxy, no per-Site rewrite, no second place that decides
|
||||
a Cover's renderable address: the wire `cover` is it. The only place a Site
|
||||
name still appears in cover code is the extraction module (`latest`), where
|
||||
kagane's and comix's image URLs are claimed by `browserOnlyCoverURL` — kagane
|
||||
answers a plain fetch with a challenge and
|
||||
Each entry names the code that holds the truth — read that for *what it does*.
|
||||
The prose here is only what code cannot tell you: rationale, rejected
|
||||
alternatives, dated measurements, and invariants a plausible refactor would
|
||||
silently break.
|
||||
|
||||
### Layout
|
||||
|
||||
`backend/main.go` → `newRouter` is the composition root, the only place
|
||||
packages are wired. Packages under `backend/internal/`: `store`, `latest`,
|
||||
`session`, `httpmw`, `api`, `userscript`, `web`, `token`, `pgtest`. Root-level
|
||||
`*_test.go` exercise the full router; unit tests live beside their package.
|
||||
|
||||
Not visible from any single file: stdlib `net/http` with no framework,
|
||||
Postgres over `jackc/pgx/v5`, `CGO_ENABLED=0` static binary into a distroless
|
||||
image, TLS terminated by the reverse proxy so the service listens plain `:8080`.
|
||||
|
||||
### Schema — `internal/store/migrations/*.sql`, run by `store.migrate`
|
||||
|
||||
- Migration files are **append-only**. Editing an applied one changes nothing
|
||||
on a database that already recorded its version in `schema_migrations`, so
|
||||
the fix silently applies to new deployments only.
|
||||
- No column probing, no data-fixup migrations. Both were SQLite-era machinery
|
||||
and were removed deliberately — don't reintroduce either.
|
||||
|
||||
### Tests need Docker — `internal/pgtest`
|
||||
|
||||
`pgtest.Main` from `TestMain` starts one `postgres:17-alpine` per test binary;
|
||||
`pgtest.URL` hands each test its own database. A package whose tests touch the
|
||||
store must have that `TestMain` or it has no database at all.
|
||||
|
||||
### Reader-owned store — `internal/store`, `internal/token`
|
||||
- The Reader-owned tables are `readers`, `bookmarks`, `series`, and `sessions`; auxiliary `covers`, `poll_lanes`, and `poll_passes` are also defined in the migrations.
|
||||
|
||||
- **Credentials are derived, never stored.** `token.Token(TOKEN_KEY, discord_id, epoch)`
|
||||
is an HMAC; only its SHA-256 reaches `readers.token_sha256`. So install URLs
|
||||
can be rebuilt after any restart, and a database leak yields nothing usable.
|
||||
- **The owner's epoch-0 hash is refreshed at startup only while the row has
|
||||
never been rotated.** Drop that condition and a restart resurrects a
|
||||
rotated-away credential.
|
||||
- `Store.EnsureReader` never rewrites an existing row's hash — a returning
|
||||
Reader's login must not invalidate their installed scripts.
|
||||
- **Every read and write is scoped to the acting Reader**, resolved from the
|
||||
presented credential by `httpmw.Auth` and carried in the request context.
|
||||
There is no unauthenticated-by-Reader route and no global token.
|
||||
- **`series` holds what readers share, `bookmarks` only what differs.** A
|
||||
bookmark key is `(reader_id, site, series_id)` with no surrogate id; the wire
|
||||
`key` is derived as `site:series_id` on read.
|
||||
- `Store.Upsert` splits one flat body across both tables and enforces the
|
||||
ownership rule: client `title`/`series_url`/`cover` are written **only when
|
||||
the series row is new**, so one reader cannot retitle a shared series.
|
||||
- Sync is last-write-wins and the wire format stays flat — clients depend on
|
||||
both; neither is an implementation detail to tidy up.
|
||||
|
||||
### Web UI — `internal/web`
|
||||
|
||||
Routes, templates and assets are all in that package; `AdminPatterns()` and
|
||||
`adminRoutes()` enumerate the privileged ones.
|
||||
|
||||
- **`backend/Dockerfile` must copy the whole `internal/` tree**, not just
|
||||
`*.go`: templates and static assets are `go:embed`-ed from
|
||||
`internal/web/`.
|
||||
- **Guild membership *is* registration.** `discordCallback` gates on membership
|
||||
(plus `DISCORD_REQUIRED_ROLE` when set) and only then calls
|
||||
`Store.EnsureReader`, so a refusal creates nothing.
|
||||
- Sessions are rows, not signatures: the cookie carries an opaque id and
|
||||
expiry is checked on lookup, which is what makes deleting the row an instant
|
||||
revocation.
|
||||
- UI mutations go through `Store.Get` + `Store.Upsert` so the `updated_at` rule
|
||||
below stays in exactly one place.
|
||||
- `listView.Fresh` exists because a Reader with no bookmarks at all needs
|
||||
install links, not an empty-filter message.
|
||||
- **Design-tool caveat:** `detect.mjs backend/internal/web/templates` reports a
|
||||
**false clean**. Templates link `/static/style.css` root-absolutely (correct —
|
||||
it is served from `/`), but the detector resolves hrefs with
|
||||
`path.resolve(fileDir, href)`, which drops the directory on a leading `/` and
|
||||
skips the file silently; a relative href doesn't help either, since a
|
||||
template's directory isn't its served path. Always pass
|
||||
`backend/internal/web/static` too. The one finding there, `overused-font` on
|
||||
"Instrument Serif", is a deliberate identity choice, not debt.
|
||||
|
||||
### Confirm gating — `internal/web/static/filter.js`, `toggleConfirmRow(key, kind)`
|
||||
|
||||
Every action that pulls a series out of the list (`archive|finish|remove`) opens
|
||||
its own `.confirm-row`; restore fires instantly because it is the reversal.
|
||||
Remove wears the ember wash, the two reversible ones wear `.calm` grey.
|
||||
**`--ember` is reserved for the new-chapter signal** — the busy bar and inline
|
||||
errors must use `--mute`, or the one colour that means "something to read"
|
||||
stops meaning it.
|
||||
|
||||
### Latest-chapter poller — `internal/latest`, Site registry in `sites.go`
|
||||
|
||||
One goroutine per Site (a Poll Lane) re-checks that Site's bookmarked series
|
||||
from the backend's own network position, so `latest_chapter` stays fresh while
|
||||
nobody is browsing. The userscript's `reportLatestChapter` is a second,
|
||||
parallel signal — it PUTs every read, unchanged numbers included, because an
|
||||
unchanged read is exactly the Sighting worth deferring a Poll on.
|
||||
|
||||
- **Pace lives in the Site registry, not config.** Two clocks: per-series rest
|
||||
(`series.latest_checked_at`, enforced in `Store.DueForLatestCheck`'s WHERE)
|
||||
and per-Lane gap (`effectiveGap`). The five env knobs that used to size one
|
||||
shared pace are gone; don't add them back.
|
||||
- **The poller walks Series, not Bookmarks** — a series several readers hold is
|
||||
fetched once per cycle, and the due queue orders `reader_count DESC,
|
||||
latest_checked_at ASC` so the widely-read ones win contention.
|
||||
- **The series row is stamped *before* the fetch**, so a permanently broken
|
||||
series waits out its rest instead of being retried every tick.
|
||||
- `Store.SetLatestChapter` is a single-column UPDATE, deliberately not a
|
||||
read-modify-write of the bookmark: it therefore cannot revert read progress
|
||||
or move `updated_at`. The old stale-re-read race died with the Get+Upsert
|
||||
flow — don't restore one here.
|
||||
|
||||
**Sightings** (`Store.RecordSighting`, the due query's HAVING clause,
|
||||
`latest.checkOne`) let a Reader's own page read defer a Poll.
|
||||
|
||||
- Recorded by the PUT handler **before** the Upsert, because the raise test
|
||||
needs the row as it stands.
|
||||
- A Series is deferred only while it has exactly one Bookmark, was sighted
|
||||
within one Rest, and is under `sightingCeilingRests` since its last Poll — so
|
||||
a shared Series is never deferred and nothing goes six hours unpolled
|
||||
whatever arrives.
|
||||
- A *higher* report clears the attribution rather than crediting it: the value
|
||||
the Poll then stores is its own, so a later retraction isn't the Reader's
|
||||
fault.
|
||||
- `store.SightingDisagreementLimit` contradictions stop a Reader deferring —
|
||||
their reports still write the Latest Chapter — and
|
||||
`store.SightingAgreementsToClear` agreements forgive them, as does the
|
||||
owner's clear-marks control.
|
||||
- Deferral is recomputed from live facts each round, so nothing needs
|
||||
invalidating when a Series gains a second Bookmark. The one input read
|
||||
earlier is the Reader's marks, so crossing or clearing a threshold takes
|
||||
effect from their next Sighting and the standing already bought lasts out its
|
||||
rest.
|
||||
|
||||
**Refusals and browser loss are Lane-local.** Two `errChallengeHeld` in a pass
|
||||
stop that Site for `RefuseBackoff` while other Lanes continue. An
|
||||
`errBrowserInterrupted` (remote Chrome restarted) sets a shared Poller flag so
|
||||
the *other* browser Lanes skip their passes for the same window — otherwise a
|
||||
restarting Chrome stamps one Series per Lane per pass, burning rests on
|
||||
failures. The flag decays and they probe again.
|
||||
|
||||
- **`isInterstitial` matches the orchestration path
|
||||
`/cdn-cgi/challenge-platform/h/`, never the bare prefix.** Cloudflare injects
|
||||
`/cdn-cgi/challenge-platform/scripts/jsd/main.js` into ordinary 200 pages
|
||||
once a zone turns JS detections on, which demonic did on 2026-08-16: the
|
||||
prefix match read every real demonic page as a refusal and parked the Lane in
|
||||
backoff while plain TLS was returning full series pages.
|
||||
- Fetches use `bogdanfinn/tls-client` with a Chrome profile as defence in depth
|
||||
against fingerprint blocking; any failure logs and skips.
|
||||
- kagane, comix and novelfull sit behind Cloudflare JS challenges the TLS
|
||||
client can't clear, so they go over CDP (`BROWSER_WS_URL`). kagane and comix
|
||||
are simply not polled when it's unset — a plain fetch would only retrieve a
|
||||
challenge page — while novelfull still attempts plain TLS, because its
|
||||
challenge is a live time-varying fact and its cover bytes never need a browser.
|
||||
- **comix's browser read is an in-tab `fetch()` of the Series URL, not a DOM
|
||||
render.** It is an SPA: rendering cost ~65 requests for the same
|
||||
server-rendered HTML one fetch returns (measured 2026-08-12).
|
||||
- Browser Lanes wake Chrome only when 5+ Series are due or one has waited 15m,
|
||||
and cover work runs in the background so a slow CDN can't eat a Lane's gap.
|
||||
|
||||
### Owner notices — `internal/notify`, `latest.Fault`, `latest.Notifier`, `latest.FaultsFrom`
|
||||
|
||||
The poller's outbound owner-notice path (issue #171): one condition today
|
||||
(the stall), judged from the durable pass log alone so the poller and any
|
||||
future reader of the same judgement cannot disagree. The webhook address is a
|
||||
secret in the class of TOKEN_KEY — never logged, never rendered, never
|
||||
carried in an error. The `owner_notices` suppression table (one row per
|
||||
condition + site) is the only state; every threshold is derived, not stored.
|
||||
|
||||
### Covers — `Store.OnSeriesCreated`, `latest.Acquirer`, `latest.CoverBytesFetcher`, `Store.SetSeriesCover`
|
||||
|
||||
Acquired once when the first Bookmark of a Series is created, then served from
|
||||
our own origin by the public `GET /covers/{addr}`.
|
||||
|
||||
- Acquisition runs in a goroutine: the Reader's PUT must neither block on a
|
||||
Site nor fail with one. Every failure is logged and dropped, leaving the
|
||||
Bookmark intact.
|
||||
- The wire `cover` is the absolute `PUBLIC_BASE_URL + /covers/{sha256}` once
|
||||
bytes exist and `""` before — **never an address that 404s**. Absolute
|
||||
because the userscript renders it on a Site's origin.
|
||||
- `GET /covers/{addr}` is public and uncredentialed by design: no cookie or
|
||||
token of ours may travel to a Site's origin.
|
||||
- A client-sent `cover` is decoded and discarded, permanently — wire
|
||||
compatibility, not an oversight.
|
||||
- **One route serves all six Sites.** No proxy, no per-Site rewrite, no second
|
||||
place that decides a renderable address: the wire `cover` is it. Templates
|
||||
render `.Cover` and nothing else. The old kagane-only serving path
|
||||
(`/img/kagane/{id}` plus a template rewrite) is gone; don't reintroduce a
|
||||
per-Site route because one Site's CDN misbehaves.
|
||||
- The only Site names left in cover code are in `browserOnlyCoverURL`
|
||||
(`internal/latest`): kagane answers a plain fetch with a challenge *and*
|
||||
`cross-origin-resource-policy: same-origin`, and `static.comix.to` answers
|
||||
one with the same Cloudflare challenge its pages serve;
|
||||
every other Site's CDN answers plain TLS. Templates render `.Cover` — the
|
||||
wire value — never anything else.
|
||||
- **Web UI also owns:** session-gated `GET /install/{manga,novel}-bookmark.user.js`
|
||||
(renders the bindmounted script with the acting Reader's derived credential
|
||||
substituted in — the credential never appears in page markup, the address
|
||||
bar, or a redirect; `?download=1` adds `Content-Disposition: attachment` for
|
||||
mobile Violentmonkey, which ignores a `.user.js` navigation) and
|
||||
`POST /rotate-token` (atomic epoch bump + hash
|
||||
rewrite; invalidates every installed copy, so the panel warns to reinstall
|
||||
on all devices).
|
||||
- **Owner-only admin page (`internal/web/admin.go`, issue #102):** `GET /admin`
|
||||
carries the Reader roster (sessions, Sighting counters, `POST
|
||||
/readers/{id}/revoke` and `POST /readers/{id}/clear-marks`) and Poll Lane
|
||||
status (`GET /ui/admin/lanes`, self-refreshing every 30s). Every route that
|
||||
reaches past the acting Reader is listed in `adminRoutes()` and wrapped in
|
||||
`requireOwner` at registration — add a route there, not a check inside a
|
||||
handler; `web.AdminPatterns()` is what the gate test walks. A non-owner gets
|
||||
404, never 403. Lane figures come from the running poller through the
|
||||
`web.LaneReporter` seam (`latest.Poller.LaneStatus`), never from a table: a
|
||||
nil reporter or a Lane that has not finished a pass renders "no data yet"
|
||||
rather than zeroes. `main.newRouter` takes the reporter as an interface and
|
||||
converts a nil `*Poller` to a nil interface — a typed nil would make the page
|
||||
claim a poller exists.
|
||||
The one owner comparison left outside `requireOwner` is in `index`
|
||||
with the same challenge its pages serve. Every other Site's CDN answers plain
|
||||
TLS.
|
||||
- **comix cover bytes must arrive by direct navigation, not an in-page fetch:**
|
||||
its Series page sets `cross-origin-embedder-policy: require-corp`, which
|
||||
fails a page-context fetch of `static.comix.to`.
|
||||
- With no browser configured, kagane and comix Covers are simply absent;
|
||||
novelfull still gets one whenever its page answers a plain request.
|
||||
|
||||
### `updated_at` drives list order — `Store.Upsert`
|
||||
|
||||
The server applies its own timestamp only when the row is new or
|
||||
`last_chapter_num` changes, else it keeps the stored value. **Favouriting a
|
||||
series, or a newly published chapter arriving, must not reorder the list** —
|
||||
only real reading progress moves a row. Consequently `PUT` returns the row **as
|
||||
stored** and clients must adopt that response rather than their own payload.
|
||||
|
||||
### Lifecycle buckets — `status` on each bookmark
|
||||
|
||||
`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.
|
||||
|
||||
- `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
|
||||
wipe the bucket on every PUT from a client predating the column.
|
||||
|
||||
### Config — `Config` / `loadConfig` / `loadLatestPoll` in `backend/main.go`
|
||||
|
||||
That function is the complete list of env vars, their defaults, and which are
|
||||
required. What it can't tell you:
|
||||
|
||||
- `PUBLIC_BASE_URL` must be an absolute origin because every Cover URL on the
|
||||
wire is built from it and the userscript renders on a Site's origin.
|
||||
- `BROWSER_WS_URL` **must be a tailnet IP, never a hostname** — Chrome's
|
||||
DevTools handler 500s `/json/version` for any Host that isn't an IP or
|
||||
`localhost`. Unset (the default) disables browser polling.
|
||||
- `USERSCRIPT_PATH` / `NOVEL_USERSCRIPT_PATH` are bindmounted files; the
|
||||
`__API_TOKEN__` placeholder inside them is substituted with the requesting
|
||||
Reader's credential at serve time.
|
||||
- Pace is per Site in the registry, not env. The
|
||||
`_COOLDOWN`/`_BROWSER_COOLDOWN`/`_INTERVAL`/`_BATCH`/`_STAGGER` knobs are
|
||||
gone on purpose.
|
||||
- The 1h rest for browser Sites is safe on documented grounds: a challenged
|
||||
page costs seconds of a serialized single-tab browser, free-plan zones carry
|
||||
no bot score and no published per-IP rate input, and `cf_clearance` expires
|
||||
in 30 minutes, so every cadence at or above 1h re-solves anyway.
|
||||
|
||||
### Userscript install & rotation — `internal/userscript`, `internal/token`
|
||||
|
||||
Session-gated `GET /install/{manga,novel}-bookmark.user.js` renders the
|
||||
bindmounted script with the acting Reader's derived credential substituted in,
|
||||
so the credential never appears in page markup, the address bar, or a redirect.
|
||||
`?download=1` adds `Content-Disposition: attachment` for mobile Violentmonkey,
|
||||
which ignores a `.user.js` navigation. `POST /rotate-token` is an atomic epoch
|
||||
bump plus hash rewrite and invalidates every installed copy — the panel must
|
||||
keep warning to reinstall on all devices.
|
||||
|
||||
### Owner-only admin — `internal/web/admin.go`
|
||||
|
||||
- **Every route reaching past the acting Reader is listed in `adminRoutes()`
|
||||
and wrapped in `requireOwner` at registration** — add it there, not as a
|
||||
check inside a handler; `web.AdminPatterns()` is what the gate test walks. A
|
||||
non-owner gets 404, never 403.
|
||||
- The one owner comparison left outside the gate is in `index`
|
||||
(`view.Owner = readerID == h.store.OwnerID()`): it gates a link, not an
|
||||
endpoint, so it is a rendering decision a registration-time wrapper cannot
|
||||
express — do not "unify" it into the gate.
|
||||
A Lane pass that returns before computing its figures (refusal backoff,
|
||||
sidecar down) carries the previous pass's due count and gap forward rather
|
||||
than recording zeroes; a Lane that has never reached a pace renders no gap at
|
||||
all. `Checked` next to `Due` is what separates a stopped Lane from a quiet
|
||||
one, so neither figure may be dropped from the row.
|
||||
Due-without-Checked is *not* by itself a stall: a browser Lane under both
|
||||
wake thresholds sets `LaneState.Asleep` at the on-demand gate and renders
|
||||
"browser asleep" instead of "not checking", and never counts toward
|
||||
`Attention`. That is the commonest healthy state for kagane, comix and
|
||||
novelfull — one due Series, nothing checked — so spending the stall mark on
|
||||
it would train the owner to ignore the mark that matters.
|
||||
express. Do not "unify" it into the gate.
|
||||
- The Lanes page reads the pass log, never a running poller: `lanesView()` in
|
||||
`admin_lanes.go` projects `store.LatestLanePasses()` and
|
||||
`store.LanePassOutcomes()` (ADR-0012), so a restart answers the instant the
|
||||
database is up. Browser configuration is a config fact and reachability is
|
||||
derived from recent browser-Site passes inside `latest.RefuseBackoff` — no
|
||||
reporter interface exists to fake.
|
||||
- A pass that returns before computing figures (refusal backoff, sidecar down)
|
||||
carries the previous pass's numbers forward rather than recording zeroes.
|
||||
- **`Checked` next to `Due` is what separates a stopped Lane from a quiet one**,
|
||||
so neither may be dropped from the row.
|
||||
- Due-without-Checked is **not** by itself a stall: a browser Lane under both
|
||||
wake thresholds records its pass with the `SkipAsleep` skip and renders
|
||||
"browser asleep", and that never counts toward `Attention`. It is the
|
||||
commonest healthy state for kagane, comix and novelfull, so spending the
|
||||
stall mark on it would train the owner to ignore the mark that matters.
|
||||
|
||||
+53
-6
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -48,7 +49,7 @@ func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) }
|
||||
|
||||
func newTestServer(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
return newRouter(newTestStore(t), testConfig(), nil)
|
||||
return newRouter(newTestStore(t), testConfig())
|
||||
}
|
||||
|
||||
func newTestStore(t *testing.T) *store.Store {
|
||||
@@ -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.
|
||||
@@ -599,7 +646,7 @@ func TestLoadConfigDiscord(t *testing.T) {
|
||||
// cooldown and the poller would re-fetch that series on every single tick.
|
||||
func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
srv := newRouter(s, testConfig(), nil)
|
||||
srv := newRouter(s, testConfig())
|
||||
|
||||
seedForCheck(t, s, "asura:x", "https://asurascans.com/comics/x", 777)
|
||||
|
||||
@@ -627,7 +674,7 @@ func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
|
||||
// series stops being due the moment the PUT lands.
|
||||
func TestPutRecordsASighting(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
srv := newRouter(s, testConfig(), nil)
|
||||
srv := newRouter(s, testConfig())
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
hour := time.Hour.Milliseconds()
|
||||
@@ -677,7 +724,7 @@ func TestUserscriptServedWithWebUIDisabled(t *testing.T) {
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/u/"+ownerCredential()+"/manga-bookmark.user.js", nil)
|
||||
newRouter(s, cfg, nil).ServeHTTP(rr, req)
|
||||
newRouter(s, cfg).ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
@@ -699,7 +746,7 @@ func TestNovelUserscriptServed(t *testing.T) {
|
||||
|
||||
cfg := testConfig()
|
||||
cfg.NovelUserscriptPath = novelPath
|
||||
srv := newRouter(s, cfg, nil)
|
||||
srv := newRouter(s, cfg)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func TestPublicCoverNeverEchoesNonImage(t *testing.T) {
|
||||
if _, err := db.Exec(`UPDATE covers SET content_type = 'text/html' WHERE address = $1`, address); err != nil {
|
||||
t.Fatalf("poison row: %v", err)
|
||||
}
|
||||
rr := getCover(t, newRouter(st, testConfig(), nil), "/covers/"+address, nil)
|
||||
rr := getCover(t, newRouter(st, testConfig()), "/covers/"+address, nil)
|
||||
if rr.Code == http.StatusOK {
|
||||
t.Fatalf("status = 200, want a refusal for a non-image row (body %q)", rr.Body.String())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -135,6 +135,33 @@ func TestAcquireFillsChapterAndCoverFromOneFetch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A pause governs the Lane only: a Reader's first bookmark of a Series on a
|
||||
// paused Site still reads the page, because acquisition is the creation-time
|
||||
// fetch, not the poll queue (issue #147).
|
||||
func TestAcquireIgnoresLanePause(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
if err := s.PauseLane("asura", time.Now().Add(6*time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("PauseLane: %v", err)
|
||||
}
|
||||
page := &fakeFetcher{body: asuraSeriesAndCoverFixture, status: 200}
|
||||
covers := &fakeBytesCoverFetcher{body: []byte("cover-bytes"), contentType: "image/jpeg"}
|
||||
acq := newAcquirer(s, page, covers)
|
||||
|
||||
bookmarkNewSeries(t, s, acquireSeriesURL)
|
||||
acq.Wait()
|
||||
|
||||
if got := page.callCount(); got != 1 {
|
||||
t.Fatalf("series page fetches on a paused Site = %d, want 1", got)
|
||||
}
|
||||
if got := covers.callCount(); got != 1 {
|
||||
t.Fatalf("cover fetches = %d, want 1", got)
|
||||
}
|
||||
got := readBookmark(t, s, acquireKey)
|
||||
if got.LatestChapterNum == nil || *got.LatestChapterNum != 181 {
|
||||
t.Fatalf("LatestChapterNum = %v, want 181", got.LatestChapterNum)
|
||||
}
|
||||
}
|
||||
|
||||
// A Series that already exists is not re-acquired: no fetch, and the Cover it
|
||||
// already has is left alone.
|
||||
func TestAcquireSkipsAnExistingSeries(t *testing.T) {
|
||||
@@ -155,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)
|
||||
}
|
||||
}
|
||||
@@ -316,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)
|
||||
}
|
||||
@@ -354,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)
|
||||
}
|
||||
}
|
||||
@@ -424,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)
|
||||
|
||||
@@ -47,6 +47,15 @@ func fetchCoverBytes(ctx context.Context, cover string, browser BrowserCoverFetc
|
||||
// inject it to exercise hostile DNS results without touching the live network.
|
||||
type CoverResolver func(context.Context, string) ([]netip.Addr, error)
|
||||
|
||||
// maxCoverBytes caps one cover, separately from the series-page maxBodyBytes:
|
||||
// a cover is a bounded binary asset, not a text page, and 4 MiB rejected 12%
|
||||
// of asurascans covers measured 2026-08-17 (p90 4.52 MB, max 8.57 MB — two of
|
||||
// the three over-cap files were JPEGs, not the animated GIF of issue #71).
|
||||
// 10 MiB is ~18% headroom over that worst case and matches the GitHub and
|
||||
// Discord image limits; see docs/research/gif-maximum-byte-size.md. GIF itself
|
||||
// has no maximum size, so this number is policy, not format.
|
||||
const maxCoverBytes = 10 << 20
|
||||
|
||||
// TLSCoverFetcher retrieves image bytes with the standard HTTPS client. Unlike
|
||||
// TLSFetcher, it does not need a browser fingerprint: cover hosts are public
|
||||
// CDNs and the response is accepted only after the destination gate passes.
|
||||
@@ -152,20 +161,20 @@ func (f *TLSCoverFetcher) Fetch(ctx context.Context, sourceURL string) ([]byte,
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("fetch cover: unsupported content type %q", raw)
|
||||
}
|
||||
if resp.ContentLength > maxBodyBytes {
|
||||
return nil, "", fmt.Errorf("fetch cover: response exceeds %d bytes", maxBodyBytes)
|
||||
if resp.ContentLength > maxCoverBytes {
|
||||
return nil, "", fmt.Errorf("fetch cover: response exceeds %d bytes", maxCoverBytes)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes+1))
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxCoverBytes+1))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("read cover: %w", err)
|
||||
}
|
||||
if len(body) > maxBodyBytes {
|
||||
return nil, "", fmt.Errorf("fetch cover: response exceeds %d bytes", maxBodyBytes)
|
||||
if len(body) > maxCoverBytes {
|
||||
return nil, "", fmt.Errorf("fetch cover: response exceeds %d bytes", maxCoverBytes)
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -171,7 +171,7 @@ func TestCoverFetcherRejectsOversizedBody(t *testing.T) {
|
||||
var calls int
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
response := coverResponse(http.StatusOK, "image/webp", "", bytes.Repeat([]byte("x"), maxBodyBytes+1))
|
||||
response := coverResponse(http.StatusOK, "image/webp", "", bytes.Repeat([]byte("x"), maxCoverBytes+1))
|
||||
response.ContentLength = -1
|
||||
return response, nil
|
||||
})}
|
||||
@@ -187,6 +187,29 @@ func TestCoverFetcherRejectsOversizedBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Covers between the series-page cap and the cover cap must be accepted: the
|
||||
// 4 MiB page cap rejected 12% of asurascans covers (issue #71).
|
||||
func TestCoverFetcherAcceptsCoverOverPageCap(t *testing.T) {
|
||||
body := bytes.Repeat([]byte("x"), maxBodyBytes+1)
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return coverResponse(http.StatusOK, "image/gif", "", body), nil
|
||||
})}
|
||||
fetcher := newCoverFetcher(client, func(context.Context, string) ([]netip.Addr, error) {
|
||||
return []netip.Addr{netip.MustParseAddr("198.51.100.10")}, nil
|
||||
})
|
||||
|
||||
got, contentType, err := fetcher.Fetch(context.Background(), "https://cdn.example/big.gif")
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch rejected a %d-byte cover: %v", len(body), err)
|
||||
}
|
||||
if len(got) != len(body) {
|
||||
t.Fatalf("body = %d bytes, want %d", len(got), len(body))
|
||||
}
|
||||
if contentType != "image/gif" {
|
||||
t.Fatalf("content type = %q, want image/gif", contentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverFetcherRejectsNonImage(t *testing.T) {
|
||||
var calls int
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package latest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
)
|
||||
|
||||
// ConditionStall is the owner-notice machine word for a Lane that owed Polls,
|
||||
// made none, and has nothing to say for it. The word is the message's footer
|
||||
// and its suppression key; it is wire-stable. #172 declares the other three
|
||||
// words (no-browser-route, sidecar-down, adapter-broken); this ticket
|
||||
// declares only the stall.
|
||||
const ConditionStall = "stall"
|
||||
// ConditionNoBrowserRoute is the owner-notice machine word for a Site whose
|
||||
// challenge refuses for longer than the owner window with no browser route
|
||||
// to clear it — the 403-with-interstitial the reader maps to
|
||||
// errChallengeHeld (read.go), which plain TLS cannot clear. The word is the
|
||||
// message's footer and its suppression key; it is wire-stable.
|
||||
const ConditionNoBrowserRoute = "no-browser-route"
|
||||
|
||||
// ConditionSidecarDown is the owner-notice machine word for a browser
|
||||
// sidecar no Lane has reached for longer than the owner window: every
|
||||
// browser-backed Lane's latest pass is a sidecar skip. The word is the
|
||||
// message's footer and its suppression key; it is wire-stable, and its
|
||||
// suppression row holds the empty Site (AC4).
|
||||
const ConditionSidecarDown = "sidecar-down"
|
||||
|
||||
// ConditionAdapterBroken is the owner-notice machine word for a Site whose
|
||||
// adapter stopped finding chapters: more than half of its Series hold an
|
||||
// old no-chapter failure row. The word is the message's footer and its
|
||||
// suppression key; it is wire-stable.
|
||||
const ConditionAdapterBroken = "adapter-broken"
|
||||
|
||||
// OwnerWindow is the class-level staleness boundary every owner-notice
|
||||
// condition measures against — the same twelve hours the Lanes page's
|
||||
// "not checked in 12h" filter uses (internal/web/admin.go). Declared here
|
||||
// once so #172's three conditions and the admin filters share one figure.
|
||||
const OwnerWindow = 12 * time.Hour
|
||||
|
||||
// Fault is one condition the owner is told about, judged from durable rows
|
||||
// alone. Site is "" for a fault that is not one Site's.
|
||||
type Fault struct {
|
||||
Condition string // one of the Condition* words
|
||||
Site string
|
||||
Since int64 // unix ms the episode began; the message's age
|
||||
}
|
||||
|
||||
// FaultInput is everything the judgement reads. A struct so #172's three
|
||||
// conditions can add inputs without changing either caller.
|
||||
type FaultInput struct {
|
||||
Passes []store.LanePass
|
||||
// RefusingSince is, per Site, the unix ms when that Site's current
|
||||
// unbroken run of refusing passes began, or absent when its latest pass
|
||||
// did not refuse. Its zero value is an empty map, which contributes no
|
||||
// fault.
|
||||
RefusingSince map[string]int64
|
||||
|
||||
// SidecarOK is, per browser-backed Site, the unix ms of that Site's most
|
||||
// recent pass that actually reached the sidecar. Zero when the pass log
|
||||
// holds none — an asleep Lane never reached it and never counts as
|
||||
// evidence either way. Its zero value is an empty map.
|
||||
SidecarOK map[string]int64
|
||||
|
||||
// NoChapterShare is, per Site, the share of that Site's Series holding a
|
||||
// no-chapter failure row older than the owner window. Its zero value is
|
||||
// an empty map, which contributes no fault.
|
||||
NoChapterShare map[string]float64
|
||||
}
|
||||
|
||||
// FaultsFrom judges the owner-notice conditions from durable rows alone, so
|
||||
// the poller and the landing page cannot disagree about what a fault is.
|
||||
//
|
||||
// A Lane stalls when its latest pass shows due > 0, none checked, no skip
|
||||
// value and no refusal — exactly the row the mid-loop browser loss writes
|
||||
// (see the comment at the outcomeUnreachable return in runLanePass), so a
|
||||
// Lane that owed Polls, made none, and has nothing to say for it is a fault.
|
||||
// The three #172 conditions share the same OwnerWindow boundary and the same
|
||||
// fail-open shape: an input's absence contributes no fault, never a false
|
||||
// one.
|
||||
//
|
||||
// - no-browser-route: a Site whose refusing run began before the window
|
||||
// and that has no browser route to clear the challenge (AC2). Both
|
||||
// refusal shapes count — the gate's skip='refusing' rows and the loop's
|
||||
// refused>0 rows (the twice-refused break writes skip="") — so the run
|
||||
// stays unbroken across them, and one healthy pass ends it.
|
||||
// - sidecar-down: every browser-backed Site's latest pass is a sidecar
|
||||
// skip — SkipSidecarDown or SkipNoFetcher, the two early returns that
|
||||
// record a skip value — and each Site's most recent sidecar-reaching
|
||||
// pass is older than the window (AC4). The skip clause is what keeps
|
||||
// SkipAsleep out: a Lane under both wake thresholds is the commonest
|
||||
// healthy state, never reached the sidecar, and would otherwise age into
|
||||
// a false alarm. Emitted once, with Site "" (AC4's one row per episode).
|
||||
// - adapter-broken: more than half of one Site's Series hold a no-chapter
|
||||
// failure row older than the window (AC6). Strictly above half: the
|
||||
// filter is the tool, and one Site change makes hundreds of rows, so a
|
||||
// single failing Series never fires (AC7). The episode's age is the
|
||||
// window — the old rows prove the episode is at least that old.
|
||||
func FaultsFrom(in FaultInput, now time.Time) []Fault {
|
||||
var faults []Fault
|
||||
for _, p := range in.Passes {
|
||||
if p.Due > 0 && p.Checked == 0 && p.Skip == "" && p.Refused == 0 {
|
||||
faults = append(faults, Fault{Condition: ConditionStall, Site: p.Site, Since: p.RanAt})
|
||||
}
|
||||
}
|
||||
cutoff := now.Add(-OwnerWindow).UnixMilli()
|
||||
for site, since := range in.RefusingSince {
|
||||
if since < cutoff && !isBrowserSite(site) {
|
||||
faults = append(faults, Fault{Condition: ConditionNoBrowserRoute, Site: site, Since: since})
|
||||
}
|
||||
}
|
||||
if since, down := sidecarDownSince(in.Passes, in.SidecarOK, cutoff); down {
|
||||
faults = append(faults, Fault{Condition: ConditionSidecarDown, Site: "", Since: since})
|
||||
}
|
||||
for site, share := range in.NoChapterShare {
|
||||
if share > 0.5 {
|
||||
faults = append(faults, Fault{Condition: ConditionAdapterBroken, Site: site, Since: now.Add(-OwnerWindow).UnixMilli()})
|
||||
}
|
||||
}
|
||||
return faults
|
||||
}
|
||||
|
||||
// sidecarDownSince reports whether no browser Lane has reached the sidecar
|
||||
// for longer than the owner window and, when it has, the last moment any
|
||||
// Lane reached it. The skip clause — every browser-backed Site's latest pass
|
||||
// must be SkipSidecarDown or SkipNoFetcher — keeps SkipAsleep out (see
|
||||
// FaultsFrom). A Site with no pass row at all is not judged down either: a
|
||||
// fresh database is not a dead sidecar.
|
||||
func sidecarDownSince(passes []store.LanePass, ok map[string]int64, cutoff int64) (since int64, down bool) {
|
||||
latest := make(map[string]store.LanePass, len(passes))
|
||||
for _, p := range passes {
|
||||
latest[p.Site] = p
|
||||
}
|
||||
for _, site := range browserBackedSites() {
|
||||
p, found := latest[site]
|
||||
if !found || (p.Skip != SkipSidecarDown && p.Skip != SkipNoFetcher) {
|
||||
return 0, false
|
||||
}
|
||||
reached := ok[site]
|
||||
if reached > 0 && reached >= cutoff {
|
||||
return 0, false
|
||||
}
|
||||
if reached > since {
|
||||
since = reached
|
||||
}
|
||||
}
|
||||
// No Lane's retained pass log shows a sidecar reach: the honest age is
|
||||
// the window itself — "at least twelve hours" — not the epoch, which
|
||||
// humanAge would render as tens of thousands of days.
|
||||
if since == 0 {
|
||||
since = cutoff
|
||||
}
|
||||
return since, true
|
||||
}
|
||||
|
||||
// Notifier delivers one owner notice. The poller neither retries nor queues:
|
||||
// an error is logged and the suppression row left unwritten, so the next pass
|
||||
// tries again while the condition holds.
|
||||
type Notifier interface {
|
||||
Notify(ctx context.Context, f Fault, sentence, href string) error
|
||||
}
|
||||
|
||||
// ownerNoticeConditions is every condition this package judges, for the
|
||||
// clear loop in recordPass: a condition absent from a pass's fault list
|
||||
// forgets its episode, so the next occurrence sends again. #172 extends the
|
||||
// list when it adds its conditions.
|
||||
var ownerNoticeConditions = []string{ConditionStall, ConditionNoBrowserRoute, ConditionSidecarDown, ConditionAdapterBroken}
|
||||
|
||||
// noticeFor renders one fault's message: the description sentence — condition,
|
||||
// age, repair — and the deep link the embed's title points at. Each condition
|
||||
// provides its own wording; the stall is the only one today (issue #171).
|
||||
func noticeFor(f Fault, row store.LanePass, now time.Time) (sentence, href string) {
|
||||
switch f.Condition {
|
||||
case ConditionStall:
|
||||
return fmt.Sprintf(
|
||||
"%s owed %d Polls and made none — %s; check the Lane's browser sidecar and the Site's challenge state",
|
||||
f.Site, row.Due, humanAge(now.Sub(time.UnixMilli(f.Since)))), "/admin/lanes"
|
||||
case ConditionNoBrowserRoute:
|
||||
return fmt.Sprintf(
|
||||
"%s has refused for %s with no browser route — the challenge does not clear on plain TLS; redeploy or add a browser route",
|
||||
f.Site, humanAge(now.Sub(time.UnixMilli(f.Since)))), "/admin/lanes"
|
||||
case ConditionSidecarDown:
|
||||
return fmt.Sprintf(
|
||||
"no browser Lane has reached the sidecar for %s — the browser sidecar is down; start or repair the browser machine",
|
||||
humanAge(now.Sub(time.UnixMilli(f.Since)))), "/admin/lanes"
|
||||
case ConditionAdapterBroken:
|
||||
return fmt.Sprintf(
|
||||
"more than half of %s's Series have failed no-chapter reads for at least %s — the Site's layout changed and the adapter is broken",
|
||||
f.Site, humanAge(now.Sub(time.UnixMilli(f.Since)))), "/admin/lanes"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// humanAge renders a duration the way an owner reads it in a message: minutes
|
||||
// under an hour, then hours, then days; a stall that was just born reads
|
||||
// "just now".
|
||||
func humanAge(d time.Duration) string {
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh", int(d.Hours()))
|
||||
default:
|
||||
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/url"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -47,20 +46,26 @@ type Poller struct {
|
||||
// CoverBytesFetch is optional; it handles plain-TLS sources through the
|
||||
// same failure-isolated prefetch path.
|
||||
CoverBytesFetch CoverBytesFetcher
|
||||
Now func() time.Time // injected so tests can freeze it
|
||||
// Notify delivers owner notices. Nil disables the whole path (issue #171):
|
||||
// the poller is not the place a missing webhook becomes an error.
|
||||
Notify Notifier
|
||||
Now func() time.Time // injected so tests can freeze it
|
||||
// eligibleCount reports how many of a Site's Series are eligible for
|
||||
// polling, defaulting to Store.EligibleSeriesCount. Injected so tests can
|
||||
// fail the count alone: the eligible query shares the due query's tables,
|
||||
// so no real store failure can reach this path without breaking the due
|
||||
// query first (issue #141).
|
||||
eligibleCount func(site string) (int, error)
|
||||
|
||||
// refuseUntil gates a Site's Lane after it refused twice in one run: no
|
||||
// Series of that Site is attempted again before this time (issue #100).
|
||||
// The stamp is durable — the pass gate reads it from the store, so a
|
||||
// restart does not forget the refusal; nothing of it lives in memory.
|
||||
// browserDownAt is when a browser Lane last lost the sidecar; the other
|
||||
// browser Lanes skip their passes for the next refuseBackoff, so a
|
||||
// browser Lanes skip their passes for the next RefuseBackoff, so a
|
||||
// restarting Chrome does not stamp one Series per pass per Lane (story 20).
|
||||
mu sync.Mutex
|
||||
refuseUntil map[string]time.Time
|
||||
browserDownAt time.Time
|
||||
// laneStates is the owner's page snapshot of each Lane's last pass
|
||||
// (issue #102), keyed by Site. Guarded by mu; a Site appears only after
|
||||
// its first pass, so a restart renders "no data yet" rather than zeroes.
|
||||
laneStates map[string]LaneState
|
||||
// coverWG tracks in-flight cover work. Covers heal in the background so a
|
||||
// slow cover host cannot delay the next Series-page Poll; tests join it
|
||||
// before asserting on cover fetches.
|
||||
@@ -96,6 +101,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
|
||||
@@ -120,17 +142,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,14 +214,7 @@ func fetcherFor(site string, browser, tls Fetcher) Fetcher {
|
||||
// laneNames returns every registry Site in the deterministic order both Run
|
||||
// and runOnce iterate: sorted, so lane behaviour and its tests agree on who
|
||||
// runs first.
|
||||
func laneNames() []string {
|
||||
names := make([]string, 0, len(sites))
|
||||
for name := range sites {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
func laneNames() []string { return SiteNames() }
|
||||
|
||||
func (p *Poller) Run(ctx context.Context) {
|
||||
names := laneNames()
|
||||
@@ -216,28 +256,143 @@ func (p *Poller) runOnce(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// One skip value per way a Lane Pass can return before its loop (issue #141);
|
||||
// empty means the pass reached the loop. The values are wire strings — stored
|
||||
// in poll_passes and read by the Lanes page — so they are stable, not prose.
|
||||
const (
|
||||
// Exported so the web layer renders a skip's reason without retyping the
|
||||
// wire string (issue #145); the values are storage and page-stable.
|
||||
SkipPaused = "paused" // the pause row was read at the top
|
||||
SkipRefusing = "refusing" // refusal backoff
|
||||
SkipSidecarDown = "sidecar-down" // a sibling browser Lane lost Chrome
|
||||
SkipNoFetcher = "no-fetcher" // browser Site, no browser configured, no fallback
|
||||
SkipDueQuery = "due-query" // the due query failed
|
||||
SkipAsleep = "asleep" // under both browser wake thresholds
|
||||
SkipEligibleCount = "eligible-count" // the eligible count failed
|
||||
SkipNothingEligible = "nothing-eligible" // nothing eligible; sleeps a full rest
|
||||
)
|
||||
|
||||
// readOutcome classifies one Series read for the pass row's outcome counts
|
||||
// (issue #141). The classification the read already makes is counted, never a
|
||||
// second taxonomy: refused is the Site holding a challenge, unreachable the
|
||||
// browser interrupting, noChapter a 200 with real HTML but no chapter links,
|
||||
// unfetchable the host pin or a missing fetcher, notFound a 4xx other than
|
||||
// the 403 refusal — the Site answered with a client status — and errors
|
||||
// everything else.
|
||||
type readOutcome int
|
||||
|
||||
const (
|
||||
outcomeSuccess readOutcome = iota
|
||||
outcomeRefused
|
||||
outcomeUnreachable
|
||||
outcomeNoChapter
|
||||
outcomeUnfetchable
|
||||
outcomeError
|
||||
outcomeNotFound
|
||||
)
|
||||
// word returns the wire spelling this outcome stores in poll_failures — the
|
||||
// same strings the pass log's columns use (C1, issue #164). Success, refusal
|
||||
// and browser loss return "" so recordFailure's "no statement" case is one
|
||||
// return: a challenge or a lost sidecar is no evidence about any particular
|
||||
// Series (ADR-0016).
|
||||
func (o readOutcome) word() string {
|
||||
switch o {
|
||||
case outcomeNotFound:
|
||||
return "not_found"
|
||||
case outcomeNoChapter:
|
||||
return "no_chapter"
|
||||
case outcomeUnfetchable:
|
||||
return "unfetchable"
|
||||
case outcomeError:
|
||||
return "errors"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// outcomeCounts are the six named outcome counts of one pass. A success
|
||||
// count is derived, never stored: checked minus the five named failures,
|
||||
// with unreachable excluded because the sidecar-loss path returns before the
|
||||
// checked counter increments (issue #141).
|
||||
type outcomeCounts struct {
|
||||
refused, unreachable, noChapter, unfetchable, errors, notFound int
|
||||
}
|
||||
|
||||
func (c *outcomeCounts) add(o readOutcome) {
|
||||
switch o {
|
||||
case outcomeRefused:
|
||||
c.refused++
|
||||
case outcomeUnreachable:
|
||||
c.unreachable++
|
||||
case outcomeNoChapter:
|
||||
c.noChapter++
|
||||
case outcomeUnfetchable:
|
||||
c.unfetchable++
|
||||
case outcomeNotFound:
|
||||
c.notFound++
|
||||
case outcomeError:
|
||||
c.errors++
|
||||
}
|
||||
}
|
||||
|
||||
// passRecord is what one pass's durable row will be: the skip value and
|
||||
// outcome counts filled in along the pass's return path. recordPass assembles
|
||||
// the row, so every exit records exactly once.
|
||||
type passRecord struct {
|
||||
site string
|
||||
ranAt int64
|
||||
skip string
|
||||
counts outcomeCounts
|
||||
}
|
||||
|
||||
// lanePassRetention is how far back a Lane's pass log is kept. It is not the
|
||||
// display window: retention is how far back a question can reach, and the
|
||||
// window is what the owner is shown (issue #139).
|
||||
const lanePassRetention = 14 * 24 * time.Hour
|
||||
|
||||
// runLanePass processes one pass of one Site's Lane: select the due Series,
|
||||
// pace through them, and report how long the Lane should wait before its next
|
||||
// pass. paced spaces consecutive fetches by the Site's effective gap — the
|
||||
// production Lane's rate limit; the deterministic test entry runs back to back.
|
||||
func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.Duration {
|
||||
now := p.Now()
|
||||
// Snapshot this pass for the owner's page (issue #102). Recorded on every
|
||||
// return path, with the figures filled in where the pass computes them.
|
||||
st := LaneState{Site: name, LastRun: now, Browser: isBrowserSite(name)}
|
||||
defer func() { p.recordLaneState(st) }()
|
||||
if until := p.refusalBackoff(name); now.Before(until) {
|
||||
// Durable pass log (issue #141): one row per exit. The figures are filled
|
||||
// in as the pass measures them; a pass that returns before measuring
|
||||
// carries the previous pass's forward inside recordPass.
|
||||
fig := passFigures{}
|
||||
rec := passRecord{site: name, ranAt: now.UnixMilli()}
|
||||
defer func() { p.recordPass(ctx, rec, fig) }()
|
||||
|
||||
// One Lane row read at the top of a pass, serving two gates (issue #139).
|
||||
// Both stamps outlive our process, so the gates read the durable row
|
||||
// rather than memory: a refusal is the Site's mood and a pause the
|
||||
// owner's order, and neither is lost to a restart.
|
||||
pausedUntil, refuseUntil, err := p.Store.LaneGates(name)
|
||||
if err != nil {
|
||||
// Fail open: a store that cannot answer the gate cannot record the
|
||||
// pass either, and one Lane must not stall on its own gate read.
|
||||
log.Printf("latest poll %s: lane gates: %v", name, err)
|
||||
}
|
||||
if pausedUntil > now.UnixMilli() {
|
||||
// Paused ahead of the refusal check: no Series is touched, so the
|
||||
// queue stays intact for when the pause lifts (issue #141, #147).
|
||||
rec.skip = SkipPaused
|
||||
log.Printf("latest poll %s: paused until %s, skipping pass", name, time.UnixMilli(pausedUntil).Format(time.RFC3339))
|
||||
return time.Duration(pausedUntil-now.UnixMilli()) * time.Millisecond
|
||||
}
|
||||
if refuseUntil > now.UnixMilli() {
|
||||
// Cooling down after a refusal: do not attempt this Site at all.
|
||||
return until.Sub(now)
|
||||
rec.skip = SkipRefusing
|
||||
return time.Duration(refuseUntil-now.UnixMilli()) * time.Millisecond
|
||||
}
|
||||
if isBrowserSite(name) {
|
||||
if downFor, down := p.browserDownFor(now); down && downFor < refuseBackoff {
|
||||
if downFor, down := p.browserDownFor(now); down && downFor < RefuseBackoff {
|
||||
// A sibling browser Lane lost the sidecar within the backoff
|
||||
// window: skip this pass, so a restarting Chrome does not stamp
|
||||
// this Site's Series one pass at a time. After refuseBackoff the
|
||||
// this Site's Series one pass at a time. After RefuseBackoff the
|
||||
// flag decays and the Lane probes again (issue #100, story 20).
|
||||
rec.skip = SkipSidecarDown
|
||||
log.Printf("latest poll %s: browser lane skipping pass (sidecar down %s ago)", name, downFor)
|
||||
return refuseBackoff - downFor
|
||||
return RefuseBackoff - downFor
|
||||
}
|
||||
}
|
||||
s := sites[name]
|
||||
@@ -246,24 +401,29 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
||||
// No fetcher at all right now (browser absent, no fallback): every
|
||||
// Series stays unstamped and due, so a browser that appears after a
|
||||
// restart finds its full queue waiting (issue #100).
|
||||
st.Gap = defaultGap
|
||||
rec.skip = SkipNoFetcher
|
||||
fig.Gap = defaultGap
|
||||
return defaultGap
|
||||
}
|
||||
|
||||
due, err := p.Store.DueForLatestCheck(name, now.Add(-s.Rest).UnixMilli(),
|
||||
now.Add(-sightingCeilingRests*s.Rest).UnixMilli())
|
||||
if err != nil {
|
||||
rec.skip = SkipDueQuery
|
||||
log.Printf("latest poll %s: due query: %v", name, err)
|
||||
st.Gap = defaultGap
|
||||
fig.Gap = defaultGap
|
||||
return defaultGap
|
||||
}
|
||||
st.Due = len(due)
|
||||
if s.Browser != nil && f == p.BrowserFetch && !browserWakeDue(due, now, s.Rest) {
|
||||
fig.Due = len(due)
|
||||
if s.Browser != nil && f == p.BrowserFetch && !browserWakeDue(due, now, s.Rest) && !anyForced(due) {
|
||||
// Below both thresholds Chrome stays asleep (ADR-0005 on-demand
|
||||
// browser): waking it for a single Poll would cost a challenge solve
|
||||
// per request. The Lane still paces at the default gap, which is what
|
||||
// the owner's page must show rather than a zero.
|
||||
st.Gap, st.Asleep = defaultGap, true
|
||||
// per request. A forced Series is the one exception — a human asking
|
||||
// is not the machine waking itself (issue #146). The Lane still paces
|
||||
// at the default gap, which is what the owner's page must show rather
|
||||
// than a zero.
|
||||
rec.skip = SkipAsleep
|
||||
fig.Gap = defaultGap
|
||||
return defaultGap
|
||||
}
|
||||
if s.Browser != nil {
|
||||
@@ -276,20 +436,22 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
||||
}
|
||||
}
|
||||
|
||||
eligible, err := p.Store.EligibleSeriesCount(name)
|
||||
eligible, err := p.countEligible(name)
|
||||
if err != nil {
|
||||
rec.skip = SkipEligibleCount
|
||||
log.Printf("latest poll %s: eligible count: %v", name, err)
|
||||
st.Gap = defaultGap
|
||||
fig.Gap = defaultGap
|
||||
return defaultGap
|
||||
}
|
||||
gap, clamped := effectiveGap(s, eligible)
|
||||
st.Gap, st.Clamped = gap, clamped
|
||||
fig.Gap, fig.Clamped = gap, clamped
|
||||
if clamped {
|
||||
log.Printf("latest poll %s: gap clamped to %s floor (eligible series=%d)", name, minGap, eligible)
|
||||
}
|
||||
if eligible == 0 {
|
||||
// Nothing to poll for the foreseeable future; sleep a full rest instead
|
||||
// of re-querying every gap.
|
||||
rec.skip = SkipNothingEligible
|
||||
return s.Rest
|
||||
}
|
||||
|
||||
@@ -300,7 +462,7 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
||||
}
|
||||
if refusals >= 2 {
|
||||
// This Site refused twice in a row: the remaining Series are left
|
||||
// unstamped and due, and the Lane waits refuseBackoff before
|
||||
// unstamped and due, and the Lane waits RefuseBackoff before
|
||||
// trying it again.
|
||||
break
|
||||
}
|
||||
@@ -314,46 +476,205 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := p.checkOne(ctx, sr); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, errChallengeHeld):
|
||||
refusals++
|
||||
case errors.Is(err, errBrowserInterrupted):
|
||||
p.setBrowserDown(now)
|
||||
log.Printf("latest poll %s: browser unreachable, browser lanes skipping passes for %s", name, refuseBackoff)
|
||||
return gap
|
||||
default:
|
||||
refusals = 0
|
||||
}
|
||||
outcome := p.checkOne(ctx, sr)
|
||||
p.recordFailure(sr, outcome)
|
||||
if outcome == outcomeUnreachable {
|
||||
// The mid-loop browser loss writes an empty skip on purpose: the
|
||||
// pass returns before the checked counter increments, so its row
|
||||
// is stall-shaped (due > 0, checked 0, skip ''), and a stall is
|
||||
// the exact signal this exit produces. A tenth skip value would
|
||||
// make it legible but is deliberately not invented here.
|
||||
rec.counts.add(outcome)
|
||||
p.setBrowserDown(now)
|
||||
log.Printf("latest poll %s: browser unreachable, browser lanes skipping passes for %s", name, RefuseBackoff)
|
||||
return gap
|
||||
}
|
||||
if outcome == outcomeRefused {
|
||||
refusals++
|
||||
} else {
|
||||
refusals = 0
|
||||
}
|
||||
st.Checked++
|
||||
rec.counts.add(outcome)
|
||||
fig.Checked++
|
||||
}
|
||||
if st.Checked > 0 {
|
||||
log.Printf("latest poll %s: due=%d checked=%d", name, len(due), st.Checked)
|
||||
if fig.Checked > 0 {
|
||||
log.Printf("latest poll %s: due=%d checked=%d", name, len(due), fig.Checked)
|
||||
}
|
||||
if refusals >= 2 {
|
||||
p.setRefusalBackoff(name, now.Add(refuseBackoff))
|
||||
log.Printf("latest poll %s: refused twice this run, waiting %s", name, refuseBackoff)
|
||||
return refuseBackoff
|
||||
// The refusal outlives the process: the durable stamp gates a restart,
|
||||
// so a Site that just told us to back off is not re-probed.
|
||||
if err := p.Store.SetLaneRefusal(name, now.Add(RefuseBackoff).UnixMilli()); err != nil {
|
||||
log.Printf("latest poll %s: persist refusal: %v", name, err)
|
||||
}
|
||||
log.Printf("latest poll %s: refused twice this run, waiting %s", name, RefuseBackoff)
|
||||
return RefuseBackoff
|
||||
}
|
||||
return gap
|
||||
}
|
||||
|
||||
func (p *Poller) refusalBackoff(name string) time.Time {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.refuseUntil[name]
|
||||
// passFigures are the numbers one pass measured for its durable row (issue
|
||||
// #141): due and checked as the pass saw them, the pace it chose, and whether
|
||||
// the gap sat on the floor. A pass that returned before measuring keeps the
|
||||
// previous pass's figures via carry-forward in recordPass; the in-memory
|
||||
// snapshot those once mirrored into is gone — the page reads the durable row
|
||||
// now (issue #145).
|
||||
type passFigures struct {
|
||||
Due, Checked int
|
||||
Gap time.Duration
|
||||
Clamped bool
|
||||
}
|
||||
|
||||
func (p *Poller) setRefusalBackoff(name string, until time.Time) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.refuseUntil == nil {
|
||||
p.refuseUntil = make(map[string]time.Time)
|
||||
// recordPass writes the durable row for one pass (issue #141). Called deferred
|
||||
// from runLanePass so every return path records exactly one row. A pass that
|
||||
// never computed its own figures — its gap is zero — carries the previous
|
||||
// pass's due, gap, clamped and checked forward rather than stating zeroes it
|
||||
// did not measure; the skip column says why it declined, so the zeroes that
|
||||
// remain (due-query, no-fetcher) read as explanations rather than
|
||||
// measurements. Once the row is durable, the owner-notice judgement runs
|
||||
// beside it (issue #171).
|
||||
func (p *Poller) recordPass(ctx context.Context, rec passRecord, fig passFigures) {
|
||||
row := store.LanePass{
|
||||
Site: rec.site,
|
||||
RanAt: rec.ranAt,
|
||||
Skip: rec.skip,
|
||||
Due: fig.Due,
|
||||
Checked: fig.Checked,
|
||||
GapMS: fig.Gap.Milliseconds(),
|
||||
Clamped: fig.Clamped,
|
||||
Refused: rec.counts.refused,
|
||||
Unreachable: rec.counts.unreachable,
|
||||
NoChapter: rec.counts.noChapter,
|
||||
Unfetchable: rec.counts.unfetchable,
|
||||
NotFound: rec.counts.notFound,
|
||||
Errors: rec.counts.errors,
|
||||
}
|
||||
p.refuseUntil[name] = until
|
||||
if row.GapMS == 0 {
|
||||
// The pass never computed a gap, so it has no figures of its own:
|
||||
// carry the previous pass's, in one latest-per-Site read — the
|
||||
// recorder needs one Site, not six (issue #139).
|
||||
if prev, ok, err := p.Store.LatestLanePass(rec.site); err != nil {
|
||||
log.Printf("latest poll %s: previous pass: %v", rec.site, err)
|
||||
} else if ok {
|
||||
row.Due, row.Checked = prev.Due, prev.Checked
|
||||
row.GapMS, row.Clamped = prev.GapMS, prev.Clamped
|
||||
}
|
||||
}
|
||||
if err := p.Store.RecordLanePass(row, rec.ranAt-lanePassRetention.Milliseconds()); err != nil {
|
||||
log.Printf("latest poll %s: record lane pass: %v", rec.site, err)
|
||||
return
|
||||
}
|
||||
p.ownerNotices(ctx, row)
|
||||
}
|
||||
|
||||
// ownerNotices judges the owner-notice conditions for the pass just recorded
|
||||
// and fires (issue #171). It sits in recordPass because that deferred call is
|
||||
// the one place every return path passes through: three of the four
|
||||
// conditions occur on early returns and the success path can never see them.
|
||||
// The judgement reads the whole pass log plus three derived reads — the
|
||||
// other Lanes' latest passes, each Site's refusing-run start, its last
|
||||
// sidecar-reaching pass, and its no-chapter share — so one pass judges every
|
||||
// condition (issue #172). Per fault: NoticeSent → send → MarkNoticeSent, so
|
||||
// a fault lasting a month sends one message, not one per pass; a condition
|
||||
// absent from this pass's fault list forgets its episode, so the next
|
||||
// occurrence sends again. Everything here is best-effort: a failed send, a
|
||||
// failed store read and a failed notice write are all logged and never
|
||||
// change the pass's outcome counts or its return value. The clear runs even
|
||||
// when Notify is nil, so a deployment that turns the webhook off does not
|
||||
// leave stale rows that suppress the first real notice after it is turned
|
||||
// back on.
|
||||
func (p *Poller) ownerNotices(ctx context.Context, row store.LanePass) {
|
||||
now := p.Now()
|
||||
in := FaultInput{Passes: []store.LanePass{row}}
|
||||
// Each read fails independently: a failure logs and contributes no fault,
|
||||
// never a false one.
|
||||
if passes, err := p.Store.LatestLanePasses(); err != nil {
|
||||
log.Printf("latest poll %s: latest lane passes: %v", row.Site, err)
|
||||
} else {
|
||||
in.Passes = passes
|
||||
}
|
||||
if since, err := p.Store.RefusingSince(now.UnixMilli()); err != nil {
|
||||
log.Printf("latest poll %s: refusing since: %v", row.Site, err)
|
||||
} else {
|
||||
in.RefusingSince = since
|
||||
}
|
||||
if ok, err := p.Store.SidecarOK(browserBackedSites()); err != nil {
|
||||
log.Printf("latest poll %s: sidecar ok: %v", row.Site, err)
|
||||
} else {
|
||||
in.SidecarOK = ok
|
||||
}
|
||||
if share, err := p.Store.NoChapterShare(now.Add(-OwnerWindow).UnixMilli()); err != nil {
|
||||
log.Printf("latest poll %s: no-chapter share: %v", row.Site, err)
|
||||
} else {
|
||||
in.NoChapterShare = share
|
||||
}
|
||||
faults := FaultsFrom(in, now)
|
||||
bySite := make(map[string]store.LanePass, len(in.Passes))
|
||||
for _, pass := range in.Passes {
|
||||
bySite[pass.Site] = pass
|
||||
}
|
||||
for _, f := range faults {
|
||||
if p.Notify == nil {
|
||||
continue
|
||||
}
|
||||
sent, err := p.Store.NoticeSent(f.Condition, f.Site)
|
||||
if err != nil {
|
||||
log.Printf("latest poll %s: notice sent: %v", row.Site, err)
|
||||
continue
|
||||
}
|
||||
if sent {
|
||||
continue
|
||||
}
|
||||
// The fault's own Site's pass renders its sentence — a stall judged
|
||||
// from another Lane's pass must not quote this pass's figures.
|
||||
pass, ok := bySite[f.Site]
|
||||
if !ok {
|
||||
pass = row
|
||||
}
|
||||
sentence, href := noticeFor(f, pass, now)
|
||||
if err := p.Notify.Notify(ctx, f, sentence, href); err != nil {
|
||||
// The stamp stays unset: no queue, no backoff — the condition is
|
||||
// durable, so the next pass tries again while it holds.
|
||||
log.Printf("latest poll %s: owner notice %s: %v", row.Site, f.Condition, err)
|
||||
continue
|
||||
}
|
||||
if err := p.Store.MarkNoticeSent(f.Condition, f.Site, now.UnixMilli()); err != nil {
|
||||
log.Printf("latest poll %s: mark notice sent: %v", row.Site, err)
|
||||
}
|
||||
}
|
||||
for _, cond := range ownerNoticeConditions {
|
||||
if !hasFault(faults, cond, row.Site) {
|
||||
if err := p.Store.ClearNotice(cond, row.Site); err != nil {
|
||||
log.Printf("latest poll %s: clear owner notice: %v", row.Site, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// sidecar-down suppresses under the empty Site — one row across all
|
||||
// browser Lanes (AC4) — so clear that row when it is absent from this
|
||||
// pass's fault list, and a lifted sidecar fires again when it returns.
|
||||
if !hasFault(faults, ConditionSidecarDown, "") {
|
||||
if err := p.Store.ClearNotice(ConditionSidecarDown, ""); err != nil {
|
||||
log.Printf("latest poll %s: clear owner notice: %v", row.Site, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hasFault reports whether faults hold the given condition for the site.
|
||||
func hasFault(faults []Fault, condition, site string) bool {
|
||||
for _, f := range faults {
|
||||
if f.Condition == condition && f.Site == site {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// countEligible routes the eligible count through the test seam when one is
|
||||
// set, else the store.
|
||||
func (p *Poller) countEligible(site string) (int, error) {
|
||||
if p.eligibleCount != nil {
|
||||
return p.eligibleCount(site)
|
||||
}
|
||||
return p.Store.EligibleSeriesCount(site)
|
||||
}
|
||||
|
||||
// setBrowserDown records when a browser Lane lost the sidecar. It is Poller
|
||||
@@ -366,7 +687,7 @@ func (p *Poller) setBrowserDown(now time.Time) {
|
||||
|
||||
// browserDownFor reports how long the sidecar has been down and that it is
|
||||
// down at all — the zero time means never down, which must not read as a
|
||||
// zero-duration loss. The window decays: once refuseBackoff passes without a
|
||||
// zero-duration loss. The window decays: once RefuseBackoff passes without a
|
||||
// fresh loss, Lanes probe again.
|
||||
func (p *Poller) browserDownFor(now time.Time) (time.Duration, bool) {
|
||||
p.mu.Lock()
|
||||
@@ -395,6 +716,19 @@ func browserWakeDue(due []store.Series, now time.Time, rest time.Duration) bool
|
||||
return maxSeriesWait(due, now, rest) >= browserWakeAge
|
||||
}
|
||||
|
||||
// anyForced reports whether the due list holds a forced Series: one whose
|
||||
// owner check-now request (issue #146) has not been answered yet. A human
|
||||
// asking wakes a sleeping Chrome even below the wake thresholds; the request
|
||||
// itself still ages visibly if the home machine is off.
|
||||
func anyForced(due []store.Series) bool {
|
||||
for _, sr := range due {
|
||||
if sr.Forced {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// maxSeriesWait returns how long the most-overdue of the due Series has been
|
||||
// waiting past its due moment (0 when due is empty).
|
||||
func maxSeriesWait(due []store.Series, now time.Time, rest time.Duration) time.Duration {
|
||||
@@ -406,16 +740,40 @@ func maxSeriesWait(due []store.Series, now time.Time, rest time.Duration) time.D
|
||||
}
|
||||
return oldest
|
||||
}
|
||||
// recordFailure keeps one Series' failure row in step with its read
|
||||
// (ADR-0016): a failure word is upserted, a successful read deletes the row,
|
||||
// and refused or unreachable issue no statement at all. Called for every
|
||||
// outcome from the pass loop, so the four failure words and the success path
|
||||
// share one write point, and a forced Poll that reads the page clears through
|
||||
// the ordinary success path — no branch of its own. Log a store failure and
|
||||
// carry on: this is best-effort, and no single bad Series may stall a Lane.
|
||||
func (p *Poller) recordFailure(sr store.Series, outcome readOutcome) {
|
||||
if outcome == outcomeSuccess {
|
||||
if err := p.Store.ClearSeriesFailure(sr.Site, sr.SeriesID); err != nil {
|
||||
log.Printf("latest poll %q: clear failure: %v", sr.Key(), err)
|
||||
}
|
||||
return
|
||||
}
|
||||
word := outcome.word()
|
||||
if word == "" {
|
||||
return
|
||||
}
|
||||
if err := p.Store.RecordSeriesFailure(sr.Site, sr.SeriesID, word, p.Now().UnixMilli()); err != nil {
|
||||
log.Printf("latest poll %q: record failure: %v", sr.Key(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// checkOne re-checks one series. Every failure path here is "log and move on":
|
||||
// the poller is a best-effort enhancement, and no single bad series may stall a
|
||||
// Lane or take down the process. The returned error is the page read's
|
||||
// classified outcome so the Lane can tell a refusal from a loss of the
|
||||
// browser; non-classified failures still return nil-equivalent behaviour.
|
||||
func (p *Poller) checkOne(ctx context.Context, sr store.Series) error {
|
||||
// Lane or take down the process. The returned outcome classifies the read for
|
||||
// the pass row (issue #141), so the Lane can count a refusal, a lost browser,
|
||||
// a chapter-less page, an unfetchable address, a missing page or a transport
|
||||
// error without re-deriving the taxonomy.
|
||||
func (p *Poller) checkOne(ctx context.Context, sr store.Series) (outcome readOutcome) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("latest poll %q: recovered from panic: %v", sr.Key(), r)
|
||||
outcome = outcomeError
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -427,7 +785,7 @@ func (p *Poller) checkOne(ctx context.Context, sr store.Series) error {
|
||||
// the stamp means "attempted", and an untried Series stays due.
|
||||
if err := p.Store.MarkLatestChecked(sr.Site, sr.SeriesID, p.Now().UnixMilli()); err != nil {
|
||||
log.Printf("latest poll %q: mark checked: %v", sr.Key(), err)
|
||||
return nil
|
||||
return outcomeError
|
||||
}
|
||||
|
||||
facts, err := readSeriesPage(ctx, sr.Site, sr.SeriesURL, p.BrowserFetch, p.Fetch)
|
||||
@@ -438,28 +796,61 @@ func (p *Poller) checkOne(ctx context.Context, sr store.Series) error {
|
||||
// passes the gate is retried at rest pace rather than
|
||||
// hot-looping.
|
||||
log.Printf("latest poll %q: not fetchable: site=%q url=%q", sr.Key(), sr.Site, sr.SeriesURL)
|
||||
return err
|
||||
return outcomeUnfetchable
|
||||
case errors.Is(err, errNoFetcher):
|
||||
log.Printf("latest poll %q: no fetcher for site %q", sr.Key(), sr.Site)
|
||||
return err
|
||||
return outcomeUnfetchable
|
||||
}
|
||||
// A legacy cover heals independently of the page read: its source may
|
||||
// answer — a CDN — while the origin does not, so a fetch failure does
|
||||
// not skip the heal, matching the order the shared read replaced.
|
||||
p.healCover(ctx, sr)
|
||||
log.Printf("latest poll %q: %v", sr.Key(), err)
|
||||
return err
|
||||
if errors.Is(err, errChallengeHeld) {
|
||||
return outcomeRefused
|
||||
}
|
||||
if errors.Is(err, errBrowserInterrupted) {
|
||||
return outcomeUnreachable
|
||||
}
|
||||
if errors.Is(err, errNotFound) {
|
||||
return outcomeNotFound
|
||||
}
|
||||
return outcomeError
|
||||
}
|
||||
// A legacy cover source is healed independently of the page read.
|
||||
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)
|
||||
}
|
||||
|
||||
// Learned from the successful read: the write sits after the error switch
|
||||
// (a refused, unreachable or errored read reaches nothing) and before the
|
||||
// returns below — a completed page whose chapter number did not change
|
||||
// still has to write. The transition is zero-versus-nonzero, not the
|
||||
// stamp's value: a Series still completed keeps its original stamp, so the
|
||||
// age #170 prints is "since the Site first said so"; one that stopped
|
||||
// being completed is zeroed.
|
||||
stamp := int64(0)
|
||||
if facts.SiteCompleted {
|
||||
stamp = p.Now().UnixMilli()
|
||||
}
|
||||
if (sr.SiteCompletedAt == 0) != (stamp == 0) {
|
||||
if err := p.Store.SetSiteCompletedAt(sr.Site, sr.SeriesID, stamp); err != nil {
|
||||
// Best-effort, like every poller write: never change the outcome
|
||||
// word the pass counts.
|
||||
log.Printf("latest poll %q: set site completed: %v", sr.Key(), err)
|
||||
}
|
||||
}
|
||||
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.
|
||||
log.Printf("latest poll %q: no chapter links in %d bytes", sr.Key(), facts.BodyLen)
|
||||
return nil
|
||||
return outcomeNoChapter
|
||||
}
|
||||
|
||||
// The Poll is the oracle for whatever Sighting last raised this Series
|
||||
@@ -472,7 +863,7 @@ func (p *Poller) checkOne(ctx context.Context, sr store.Series) error {
|
||||
// against the due-query snapshot; a concurrent write in between only costs
|
||||
// one redundant UPDATE of the same absolute value, never a wrong one.
|
||||
if sr.LatestChapterNum != nil && *sr.LatestChapterNum == facts.Latest.Num {
|
||||
return nil
|
||||
return outcomeSuccess
|
||||
}
|
||||
|
||||
// Series-level write: the row is shared, so one update refreshes every
|
||||
@@ -481,10 +872,10 @@ func (p *Poller) checkOne(ctx context.Context, sr store.Series) error {
|
||||
// the list.
|
||||
if err := p.Store.SetLatestChapter(sr.Site, sr.SeriesID, facts.Latest.Label, facts.Latest.Num); err != nil {
|
||||
log.Printf("latest poll %q: set latest chapter: %v", sr.Key(), err)
|
||||
return nil
|
||||
return outcomeError
|
||||
}
|
||||
log.Printf("latest poll %q: latest is now %s", sr.Key(), facts.Latest.Label)
|
||||
return nil
|
||||
return outcomeSuccess
|
||||
}
|
||||
|
||||
// judgeSighting settles the Sighting the Series' stored Latest Chapter is owed
|
||||
@@ -543,7 +934,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
|
||||
@@ -552,7 +943,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
|
||||
|
||||
+2179
-105
File diff suppressed because it is too large
Load Diff
@@ -6,15 +6,19 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// seriesRead carries the two facts the poll and the acquirer both extract
|
||||
// from a series page. Persistence, stamps and scheduling stay with the
|
||||
// callers, so the policies that keep the two flows distinct (stamp order,
|
||||
// rests) are not swallowed by the module.
|
||||
// seriesRead carries the facts the poll and the acquirer both extract from a
|
||||
// series page. Persistence, stamps and scheduling stay with the callers, so
|
||||
// the policies that keep the two flows distinct (stamp order, rests) are not
|
||||
// swallowed by the module.
|
||||
type seriesRead struct {
|
||||
Latest latestChapter
|
||||
HasLatest bool
|
||||
Cover string
|
||||
HasCover bool
|
||||
// SiteCompleted is whether the Site's own completed value was on the page.
|
||||
// A challenge body and a redesign both read false — an absent hint, never
|
||||
// a claim (issue #168).
|
||||
SiteCompleted bool
|
||||
// BodyLen is the fetched body's length, surfaced because the no-chapter
|
||||
// log uses it to tell a markup change from a body the size cap cut short.
|
||||
BodyLen int
|
||||
@@ -25,15 +29,18 @@ type seriesRead struct {
|
||||
// errChallengeHeld (browser.go) is the outcome of a Site that answered with
|
||||
// its interstitial — status 403 (cf-mitigated) or a challenge page body — and
|
||||
// is how a Lane tells a refusal from an ordinary failure (issue #100).
|
||||
// errNotFound marks any other 4xx: the page is gone — a fact the owner can act
|
||||
// on — as distinct from a Site or database that is merely unwell (issue #164).
|
||||
var (
|
||||
errNotFetchable = errors.New("series url not fetchable")
|
||||
errNoFetcher = errors.New("no fetcher for site")
|
||||
errNotFound = errors.New("series page not found")
|
||||
)
|
||||
|
||||
// readSeriesPage performs the series-page read the poll and the acquirer have
|
||||
// in common: gate the address, choose the route, fetch the page, extract the
|
||||
// Latest Chapter and the Cover address. It persists nothing and stamps
|
||||
// nothing.
|
||||
// Latest Chapter, the Cover address and the Site's completed value. It
|
||||
// persists nothing and stamps nothing.
|
||||
//
|
||||
// series_url arrives in a client-supplied PUT body (PUT /bookmarks/{key}
|
||||
// accepts any string), so the gate is not an optimisation against burning a
|
||||
@@ -41,7 +48,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)
|
||||
@@ -59,6 +66,9 @@ func readSeriesPage(ctx context.Context, site, seriesURL string, browser, tls Fe
|
||||
return seriesRead{}, fmt.Errorf("%w: fetch %s: status %d", errChallengeHeld, seriesURL, status)
|
||||
}
|
||||
if status != 200 {
|
||||
if status >= 400 && status < 500 {
|
||||
return seriesRead{}, fmt.Errorf("%w: fetch %s: status %d", errNotFound, seriesURL, status)
|
||||
}
|
||||
return seriesRead{}, fmt.Errorf("fetch %s: status %d", seriesURL, status)
|
||||
}
|
||||
if isInterstitial(body) {
|
||||
@@ -69,5 +79,12 @@ func readSeriesPage(ctx context.Context, site, seriesURL string, browser, tls Fe
|
||||
}
|
||||
latest, hasLatest := latestChapterFrom(site, seriesURL, body)
|
||||
cover, hasCover := coverFrom(site, seriesURL, body)
|
||||
return seriesRead{Latest: latest, HasLatest: hasLatest, Cover: cover, HasCover: hasCover, BodyLen: len(body)}, nil
|
||||
return seriesRead{
|
||||
Latest: latest,
|
||||
HasLatest: hasLatest,
|
||||
Cover: cover,
|
||||
HasCover: hasCover,
|
||||
SiteCompleted: siteCompletedFrom(site, seriesURL, body),
|
||||
BodyLen: len(body),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ type latestChapter struct {
|
||||
|
||||
// site answers the fixed questions every series-page read asks of its Site
|
||||
// (ADR-0009): the host its addresses must carry, how to find the Latest
|
||||
// Chapter and the Cover address in a body, and — for a Site behind a
|
||||
// JavaScript challenge — how to read its payload from a cleared tab. One
|
||||
// entry describes everything about one Site, and nowhere else gets to compare
|
||||
// the site string.
|
||||
// Chapter and the Cover address in a body, whether the Site calls the work
|
||||
// completed, and — for a Site behind a JavaScript challenge — how to read its
|
||||
// payload from a cleared tab. One entry describes everything about one Site,
|
||||
// and nowhere else gets to compare the site string.
|
||||
type site struct {
|
||||
// Host is the exact hostname a series_url for this Site must carry.
|
||||
Host string
|
||||
@@ -33,6 +33,10 @@ type site struct {
|
||||
LatestChapter func(seriesURL, body string) (latestChapter, bool)
|
||||
// Cover finds the Cover address in a fetched body.
|
||||
Cover func(seriesURL, body string) (string, bool)
|
||||
// Completed reports whether this body carries the Site's own completed
|
||||
// value. False for a body that carries any other value, and false for a
|
||||
// failed extraction — never an error and never a third state.
|
||||
Completed func(seriesURL, body string) bool
|
||||
// Rest is how long a Series of this Site rests between Polls.
|
||||
Rest time.Duration
|
||||
// Gap is the Lane's strictest pace: at least one second must pass between
|
||||
@@ -46,7 +50,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)
|
||||
@@ -299,34 +303,42 @@ func kaganeCoverURL(body string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func comixCoverURL(seriesURL, body string) string {
|
||||
// comixDetailQuery returns the ["manga","detail","<id>"] query entry of
|
||||
// comix's initial-data JSON, or nil. The cover and completed reads share the
|
||||
// scoped lookup so a "recommended" strip entry can never contribute either
|
||||
// answer.
|
||||
func comixDetailQuery(seriesURL, body string) json.RawMessage {
|
||||
id, ok := comixSeriesID(seriesURL)
|
||||
if !ok {
|
||||
return ""
|
||||
return nil
|
||||
}
|
||||
data := comixInitialDataRe.FindStringSubmatch(body)
|
||||
if data == nil {
|
||||
return ""
|
||||
return nil
|
||||
}
|
||||
var state struct {
|
||||
Queries map[string]json.RawMessage `json:"queries"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data[1]), &state); err != nil {
|
||||
return nil
|
||||
}
|
||||
return state.Queries[`["manga","detail","`+id+`"]`]
|
||||
}
|
||||
|
||||
func comixCoverURL(seriesURL, body string) string {
|
||||
detail := comixDetailQuery(seriesURL, body)
|
||||
if len(detail) == 0 {
|
||||
return ""
|
||||
}
|
||||
raw := state.Queries[`["manga","detail","`+id+`"]`]
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var detail struct {
|
||||
var entry struct {
|
||||
Poster struct {
|
||||
Medium string `json:"medium"`
|
||||
} `json:"poster"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &detail); err != nil {
|
||||
if err := json.Unmarshal(detail, &entry); err != nil {
|
||||
return ""
|
||||
}
|
||||
return publishedCoverURL(detail.Poster.Medium)
|
||||
return publishedCoverURL(entry.Poster.Medium)
|
||||
}
|
||||
|
||||
// ogImageCover reads the og:image metadata shared by asura, demonic and
|
||||
@@ -360,6 +372,87 @@ func coverFrom(site, seriesURL, body string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// asuraStatusRe matches the status value inside the escaped astro-island
|
||||
// props blob, in both the " form the served document carries and the "
|
||||
// form a decoded copy would. "completed" is the only true value: "dropped"
|
||||
// is scanlation editorial (the work itself continues elsewhere) and hiatus is
|
||||
// its own value.
|
||||
var asuraStatusRe = regexp.MustCompile(`(?:"|")status(?:"|"):\[0,(?:"|")completed(?:"|")\]`)
|
||||
|
||||
func asuraCompleted(_, body string) bool {
|
||||
return asuraStatusRe.MatchString(body)
|
||||
}
|
||||
|
||||
// demonicStatusRe matches the info block's status pair: a Status label <li>
|
||||
// immediately followed by the value <li>. The site's whole status vocabulary
|
||||
// is {Ongoing, Completed} (its advanced-search status filter), so the literal
|
||||
// Completed value is the entire signal.
|
||||
var demonicStatusRe = regexp.MustCompile(`<li[^>]*>\s*Status\s*</li>\s*<li[^>]*>\s*Completed\s*</li>`)
|
||||
|
||||
func demonicCompleted(_, body string) bool {
|
||||
return demonicStatusRe.MatchString(body)
|
||||
}
|
||||
|
||||
// comixCompleted reads "status" from the scoped detail entry only;
|
||||
// "finished" is the completed value, and on_hiatus and discontinued are
|
||||
// distinct values.
|
||||
func comixCompleted(seriesURL, body string) bool {
|
||||
detail := comixDetailQuery(seriesURL, body)
|
||||
if len(detail) == 0 {
|
||||
return false
|
||||
}
|
||||
var entry struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(detail, &entry); err != nil {
|
||||
return false
|
||||
}
|
||||
return entry.Status == "finished"
|
||||
}
|
||||
|
||||
// kaganeCompleted reads publication_status only: upload_status is the
|
||||
// release's state, and the two provably diverge ('Cause Calypso Can,
|
||||
// 2026-08-19: publication Ongoing, upload Hiatus), so a Completed upload
|
||||
// must never read as a Completed work.
|
||||
func kaganeCompleted(_, body string) bool {
|
||||
var series struct {
|
||||
PublicationStatus string `json:"publication_status"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &series); err != nil {
|
||||
return false
|
||||
}
|
||||
return series.PublicationStatus == "Completed"
|
||||
}
|
||||
|
||||
// novelfullStatusRe matches the info panel's status link. The page's whole
|
||||
// status vocabulary is {Ongoing, Completed} (the "OnGoing" spelling aliases
|
||||
// "Ongoing" on the taxonomy), so the Completed href is the signal.
|
||||
var novelfullStatusRe = regexp.MustCompile(`href="/status/Completed"`)
|
||||
|
||||
func novelfullCompleted(_, body string) bool {
|
||||
return novelfullStatusRe.MatchString(body)
|
||||
}
|
||||
|
||||
// lnwCompleted matches creativeWorkStatus in the head's JSON-LD block. The
|
||||
// whole body is scanned and the comment marker is not required, unlike
|
||||
// lnwLatestChapter: the status block sits ahead of the visitor-writable
|
||||
// thread, and hiatus maps to a distinct PotentialActionStatus value.
|
||||
var lnwStatusRe = regexp.MustCompile(`"creativeWorkStatus"\s*:\s*"https://schema\.org/CompletedActionStatus"`)
|
||||
|
||||
func lnwCompleted(_, body string) bool {
|
||||
return lnwStatusRe.MatchString(body)
|
||||
}
|
||||
|
||||
// siteCompletedFrom reports whether the Site calls this work completed, via
|
||||
// the Site's registry entry. False for an unknown site, a challenge body and
|
||||
// a redesign alike: an absent hint, never a claim.
|
||||
func siteCompletedFrom(site, seriesURL, body string) bool {
|
||||
if fn := sites[site].Completed; fn != nil {
|
||||
return fn(seriesURL, body)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// metaContent returns the content of the first <meta> whose attrName is
|
||||
// attrValue. It keeps scanning after an empty match so a later published cover
|
||||
// is not hidden by an empty tag.
|
||||
@@ -401,9 +494,10 @@ const (
|
||||
// express (docs/research/cloudflare-bot-scoring-and-poll-cadence.md);
|
||||
// below it the Lane is outrunning its own plan and says so loudly.
|
||||
minGap = time.Second
|
||||
// refuseBackoff is how long a Lane waits after its Site refused twice in
|
||||
// one run before attempting it again.
|
||||
refuseBackoff = 15 * time.Minute
|
||||
// RefuseBackoff is how long a Lane waits after its Site refused twice in
|
||||
// one run before attempting it again. Exported so the web layer can derive
|
||||
// browser reachability from the pass log over the same window (issue #145).
|
||||
RefuseBackoff = 15 * time.Minute
|
||||
// browserWakeCount and browserWakeAge gate a browser Lane's run: five or
|
||||
// more due Series, or any one of them waiting this long, or Chrome stays
|
||||
// asleep (ADR-0005 on-demand browser).
|
||||
@@ -448,6 +542,7 @@ var sites = map[string]site{
|
||||
Host: "asurascans.com",
|
||||
LatestChapter: asuraLatestChapter,
|
||||
Cover: ogImageCover,
|
||||
Completed: asuraCompleted,
|
||||
Rest: defaultRest,
|
||||
Gap: defaultGap,
|
||||
},
|
||||
@@ -455,6 +550,7 @@ var sites = map[string]site{
|
||||
Host: "demonicscans.org",
|
||||
LatestChapter: demonicLatestChapter,
|
||||
Cover: ogImageCover,
|
||||
Completed: demonicCompleted,
|
||||
Rest: defaultRest,
|
||||
Gap: defaultGap,
|
||||
},
|
||||
@@ -462,6 +558,7 @@ var sites = map[string]site{
|
||||
Host: "comix.to",
|
||||
LatestChapter: comixLatestChapter,
|
||||
Cover: comixCoverEntry,
|
||||
Completed: comixCompleted,
|
||||
Rest: defaultRest,
|
||||
Gap: defaultGap,
|
||||
Browser: &browserRead{
|
||||
@@ -478,6 +575,7 @@ var sites = map[string]site{
|
||||
Host: "kagane.to",
|
||||
LatestChapter: kaganeLatestChapter,
|
||||
Cover: kaganeCoverEntry,
|
||||
Completed: kaganeCompleted,
|
||||
Rest: defaultRest,
|
||||
Gap: defaultGap,
|
||||
Browser: &browserRead{
|
||||
@@ -492,6 +590,7 @@ var sites = map[string]site{
|
||||
Host: "novelfull.com",
|
||||
LatestChapter: novelfullLatestChapter,
|
||||
Cover: novelfullCoverEntry,
|
||||
Completed: novelfullCompleted,
|
||||
Rest: defaultRest,
|
||||
Gap: defaultGap,
|
||||
Browser: &browserRead{
|
||||
@@ -506,11 +605,30 @@ var sites = map[string]site{
|
||||
Host: "lightnovelworld.net",
|
||||
LatestChapter: lnwLatestChapter,
|
||||
Cover: ogImageCover,
|
||||
Completed: lnwCompleted,
|
||||
Rest: defaultRest,
|
||||
Gap: defaultGap,
|
||||
},
|
||||
}
|
||||
|
||||
// SiteNames returns every registry Site, sorted. The admin Series list's Site
|
||||
// select needs the full registry, not just the Sites that have rows, and
|
||||
// laneNames() is the poller's copy of the same list — both read this.
|
||||
func SiteNames() []string {
|
||||
names := make([]string, 0, len(sites))
|
||||
for name := range sites {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// BrowserBackedSites is derived from the registry: the Sites whose pages are
|
||||
// read through the browser sidecar. Sorted so callers that range it (the
|
||||
// browser fetcher's dispatch) see a stable order instead of map-iteration
|
||||
// noise. Exported so the web layer shares the same set the poller does.
|
||||
func BrowserBackedSites() []string { return browserBackedSites() }
|
||||
|
||||
// browserBackedSites is derived from the registry: the Sites whose pages are
|
||||
// read through the browser sidecar. Sorted so callers that range it (the
|
||||
// browser fetcher's dispatch) see a stable order instead of map-iteration
|
||||
|
||||
@@ -188,6 +188,104 @@ const lnwSeriesFixture = `
|
||||
</div>
|
||||
`
|
||||
|
||||
// Completed-marker fixtures, trimmed from the live pages fetched 2026-08-22
|
||||
// for the verification step below. Selector-removed rows delete the marker
|
||||
// from the consts and must answer false.
|
||||
|
||||
// Trimmed from https://asurascans.com/comics/solo-leveling (301 to
|
||||
// /comics/solo-leveling-b60d532c) fetched 2026-08-22 by a curl probe from
|
||||
// this machine. The astro-island props are HTML-escaped in the served
|
||||
// document, so the quotes arrive as ".
|
||||
const asuraCompletedFixture = `
|
||||
"bookmarkCount":[0,39128],"status":[0,"completed"],"type":[0,"manhwa"]
|
||||
`
|
||||
|
||||
// Trimmed from https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af
|
||||
// fetched 2026-08-22 by a curl probe.
|
||||
const asuraOngoingFixture = `
|
||||
"bookmarkCount":[0,54027],"status":[0,"ongoing"],"type":[0,"manhwa"]
|
||||
`
|
||||
|
||||
// Trimmed from https://demonicscans.org/manga/Solo-Leveling fetched
|
||||
// 2026-08-22 by a curl probe. The info block is sloppy: bare <li>s inside a
|
||||
// <div>, label and value as a sibling pair.
|
||||
const demonicCompletedFixture = `
|
||||
<div class="flex flex-row">
|
||||
<li style="width:150px;color:#b2b2b2;">Status</li>
|
||||
<li>Completed</li>
|
||||
</div>
|
||||
`
|
||||
|
||||
// Trimmed from https://demonicscans.org/manga/Catastrophic-Necromancer
|
||||
// fetched 2026-08-22 by a curl probe. Same block, ongoing value.
|
||||
const demonicOngoingFixture = `
|
||||
<div class="flex flex-row">
|
||||
<li style="width:150px;color:#b2b2b2;">Status</li>
|
||||
<li>Ongoing</li>
|
||||
</div>
|
||||
`
|
||||
|
||||
// Trimmed from https://comix.to/title/q77m-countach fetched 2026-08-22 by a
|
||||
// cleared Chrome tab (the CDP sidecar: the Series URL is fetched in-tab, the
|
||||
// same server-rendered payload the poll reads). The served page escapes the
|
||||
// query map keys as \u0022; the fixture carries the decoded form, which
|
||||
// parses to the same key. The recommended strip on this very page carries a
|
||||
// finished entry; only the ["manga","detail","q77m"] entry counts.
|
||||
const comixCompletedFixture = `<script type="application/json" id="initial-data">{"queries":{"[\"manga\",\"recommended\",\"q77m\",1]":{"items":[{"hid":"pz65","title":"Wangan Midnight: C1 Runner","status":"finished"}]},"[\"manga\",\"detail\",\"q77m\"]":{"id":13211,"hid":"q77m","title":"Countach","status":"finished","originalLanguage":"ja"}}}</script>`
|
||||
|
||||
// Trimmed from https://comix.to/title/n8we-dungeons-and-crayons fetched
|
||||
// 2026-08-22 the same way (keys in their decoded form, as above). The
|
||||
// recommended strip includes a finished entry; the target detail is
|
||||
// releasing, and only the detail read counts.
|
||||
const comixOngoingFixture = `<script type="application/json" id="initial-data">{"queries":{"[\"manga\",\"recommended\",\"n8we\",1]":{"items":[{"hid":"g2rk","title":"On the Way to Meet Mom","status":"finished"}]},"[\"manga\",\"detail\",\"n8we\"]":{"id":1429,"hid":"n8we","title":"Dungeons and Crayons","status":"releasing","originalLanguage":"ko"}}}</script>`
|
||||
|
||||
// Trimmed from GET https://kagane.to/api/v2/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b
|
||||
// fetched 2026-08-22 in a cleared Chrome tab (plain TLS serves the Cloudflare
|
||||
// challenge).
|
||||
const kaganeOngoingFixture = `{"series_id":"019f84bc-9ba0-7ed9-86f5-8b905ec7c28b","title":"Infinite Decryption: The Strongest Level 0","publication_status":"Ongoing","upload_status":"Ongoing"}`
|
||||
|
||||
// Trimmed from GET https://kagane.to/api/v2/series/019c29c3-5abd-70e6-9efc-1f1f22d839f6
|
||||
// fetched 2026-08-22 the same way.
|
||||
const kaganeCompletedFixture = `{"series_id":"019c29c3-5abd-70e6-9efc-1f1f22d839f6","title":"Real Account 1-17","publication_status":"Completed","upload_status":"Completed"}`
|
||||
|
||||
// Composed, not a single live trim: upload Completed alongside a
|
||||
// non-Completed publication status is the research note's inferred inverse
|
||||
// case and did not turn up in the ~1900-series live scan of 2026-08-22 (both
|
||||
// field vocabularies are live-verified; the note's live divergence is 'Cause
|
||||
// Calypso Can, publication Ongoing + upload Hiatus). The predicate must read
|
||||
// publication_status only.
|
||||
const kaganeDivergentFixture = `{"series_id":"019f84bc-9ba0-7ed9-86f5-8b905ec7c28b","title":"Infinite Decryption: The Strongest Level 0","publication_status":"Ongoing","upload_status":"Completed"}`
|
||||
|
||||
// Trimmed from https://novelfull.com/reverend-insanity.html fetched
|
||||
// 2026-08-22 in a cleared Chrome tab after the plain-TLS probe was served the
|
||||
// challenge (time-varying; plain curl answered 403 the same day).
|
||||
const novelfullCompletedFixture = `<div><h3>Status:</h3><a href="/status/Completed">Completed</a></div>`
|
||||
|
||||
// Trimmed from https://novelfull.com/a-cunning-pervert-in-the-cultivation-world.html
|
||||
// fetched 2026-08-22 the same way.
|
||||
const novelfullOngoingFixture = `<div><h3>Status:</h3><a href="/status/Ongoing">Ongoing</a></div>`
|
||||
|
||||
// Trimmed from the JSON-LD block in the head of
|
||||
// https://lightnovelworld.net/novel/a-will-eternal/ fetched 2026-08-22. The
|
||||
// block sits ahead of the wpdiscuz thread, so the predicate reads the whole
|
||||
// body and needs no comment marker, unlike lnwLatestChapter.
|
||||
const lnwCompletedFixture = `<script type="application/ld+json">{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Book",
|
||||
"name": "A Will Eternal",
|
||||
"creativeWorkStatus": "https://schema.org/CompletedActionStatus"
|
||||
}</script>`
|
||||
|
||||
// Trimmed from
|
||||
// https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/
|
||||
// fetched 2026-08-22. Same block, ongoing value.
|
||||
const lnwOngoingFixture = `<script type="application/ld+json">{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Book",
|
||||
"name": "All Jobs and Classes I Just Wanted One Skill Not Them All",
|
||||
"creativeWorkStatus": "https://schema.org/ActiveActionStatus"
|
||||
}</script>`
|
||||
|
||||
// Trimmed from https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af
|
||||
// (redirected to ...-00dcbf97) on 2026-08-10.
|
||||
const asuraCoverFixture = `<meta property="og:image" content="https://cdn.asurascans.com/asura-images/covers/chronicles-of-the-demon-faction.d4dcb8.webp">`
|
||||
@@ -473,3 +571,178 @@ func TestLatestChapterFrom(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteCompletedFrom(t *testing.T) {
|
||||
const asuraURL = "https://asurascans.com/comics/solo-leveling-b60d532c"
|
||||
const demonicURL = "https://demonicscans.org/manga/Solo-Leveling"
|
||||
const comixURL = "https://comix.to/title/q77m-countach"
|
||||
const kaganeURL = "https://kagane.to/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"
|
||||
const novelfullURL = "https://novelfull.com/reverend-insanity.html"
|
||||
const lnwURL = "https://lightnovelworld.net/novel/a-will-eternal/"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
site string
|
||||
seriesURL string
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "asura completed via escaped props status",
|
||||
site: "asura", seriesURL: asuraURL, body: asuraCompletedFixture,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "asura ongoing value is not completed",
|
||||
site: "asura",
|
||||
seriesURL: "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af",
|
||||
body: asuraOngoingFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "asura with the status key removed",
|
||||
site: "asura", seriesURL: asuraURL,
|
||||
body: strings.ReplaceAll(asuraCompletedFixture, `"status":[0,"completed"]`, ""),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "asura challenge page",
|
||||
site: "asura", seriesURL: asuraURL, body: challengeFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "demonic completed info-block pair",
|
||||
site: "demonic", seriesURL: demonicURL, body: demonicCompletedFixture,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "demonic ongoing value is not completed",
|
||||
site: "demonic",
|
||||
seriesURL: "https://demonicscans.org/manga/Catastrophic-Necromancer",
|
||||
body: demonicOngoingFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "demonic with the value li removed",
|
||||
site: "demonic", seriesURL: demonicURL,
|
||||
body: strings.ReplaceAll(demonicCompletedFixture, "<li>Completed</li>", ""),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "comix recommended finished does not count",
|
||||
site: "comix",
|
||||
seriesURL: "https://comix.to/title/n8we-dungeons-and-crayons",
|
||||
body: comixOngoingFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "comix detail finished wins over a finished recommended strip",
|
||||
site: "comix",
|
||||
seriesURL: comixURL,
|
||||
body: comixCompletedFixture,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "comix with the detail status removed",
|
||||
site: "comix", seriesURL: comixURL,
|
||||
body: strings.ReplaceAll(comixCompletedFixture, `"status":"finished",`, ""),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "comix challenge page",
|
||||
site: "comix", seriesURL: comixURL, body: challengeFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "kagane publication Completed",
|
||||
site: "kagane",
|
||||
seriesURL: "https://kagane.to/series/019c29c3-5abd-70e6-9efc-1f1f22d839f6",
|
||||
body: kaganeCompletedFixture,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "kagane upload Completed does not count",
|
||||
site: "kagane", seriesURL: kaganeURL, body: kaganeDivergentFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "kagane ongoing values are not completed",
|
||||
site: "kagane", seriesURL: kaganeURL, body: kaganeOngoingFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "kagane with publication_status removed",
|
||||
site: "kagane", seriesURL: kaganeURL,
|
||||
body: strings.ReplaceAll(kaganeCompletedFixture, `"publication_status":"Completed",`, ""),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "kagane challenge page",
|
||||
site: "kagane", seriesURL: kaganeURL, body: challengeFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "novelfull status link Completed",
|
||||
site: "novelfull",
|
||||
seriesURL: novelfullURL,
|
||||
body: novelfullCompletedFixture,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "novelfull ongoing link is not completed",
|
||||
site: "novelfull",
|
||||
seriesURL: "https://novelfull.com/a-cunning-pervert-in-the-cultivation-world.html",
|
||||
body: novelfullOngoingFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "novelfull with the status link removed",
|
||||
site: "novelfull", seriesURL: novelfullURL,
|
||||
body: strings.ReplaceAll(novelfullCompletedFixture, `<a href="/status/Completed">Completed</a>`, ""),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "novelfull challenge page",
|
||||
site: "novelfull", seriesURL: novelfullURL, body: challengeFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "lightnovelworld JSON-LD CompletedActionStatus",
|
||||
site: "lightnovelworld",
|
||||
seriesURL: lnwURL,
|
||||
body: lnwCompletedFixture,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "lightnovelworld ActiveActionStatus is not completed",
|
||||
site: "lightnovelworld",
|
||||
seriesURL: "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/",
|
||||
body: lnwOngoingFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "lightnovelworld with creativeWorkStatus removed",
|
||||
site: "lightnovelworld", seriesURL: lnwURL,
|
||||
body: strings.ReplaceAll(lnwCompletedFixture, `"creativeWorkStatus": "https://schema.org/CompletedActionStatus"`, ""),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "lightnovelworld challenge page",
|
||||
site: "lightnovelworld", seriesURL: lnwURL, body: challengeFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unknown site",
|
||||
site: "mangadex", seriesURL: "https://mangadex.org/title/x", body: asuraCompletedFixture,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := siteCompletedFrom(tt.site, tt.seriesURL, tt.body); got != tt.want {
|
||||
t.Errorf("siteCompletedFrom(%q, %q, body) = %v, want %v", tt.site, tt.seriesURL, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
package latest
|
||||
|
||||
import "time"
|
||||
|
||||
// LaneState is the administrative page's view of one Poll Lane (issue #102):
|
||||
// what the Lane's last pass saw. Due, Gap and Checked are filled in as the
|
||||
// pass computes them; a pass that returned before reaching a figure (refusal
|
||||
// backoff, sidecar down) carries the previous pass's figures forward rather
|
||||
// than overwriting them with zeroes the page would state as fact.
|
||||
type LaneState struct {
|
||||
Site string
|
||||
Due int
|
||||
LastRun time.Time
|
||||
Gap time.Duration
|
||||
// Checked is how many Series this pass actually read. A Lane with Series
|
||||
// due and nothing checked has stopped working; one with nothing due is
|
||||
// merely quiet, and the page must not draw the two the same (story 13).
|
||||
Checked int
|
||||
Clamped bool
|
||||
Refusing bool
|
||||
Browser bool
|
||||
// Asleep marks a browser Lane whose last pass declined to wake Chrome
|
||||
// because it was under both wake thresholds (ADR-0005). Due without
|
||||
// Checked then means "waiting for the group to gather", not "stopped", and
|
||||
// the page must not draw it as a stall.
|
||||
Asleep bool
|
||||
}
|
||||
|
||||
// Status is the owner's page snapshot of the whole poller (issue #102).
|
||||
type Status struct {
|
||||
Lanes []LaneState
|
||||
BrowserConfigured bool
|
||||
BrowserReachable bool
|
||||
}
|
||||
|
||||
// LaneStatus returns a copy of the poller's Lane state for the owner's page.
|
||||
// Only Sites that have completed a pass appear — a restart therefore renders
|
||||
// "no data yet" instead of confident zeroes — in the same order Run iterates.
|
||||
// Refusing is derived at snapshot time from the refusal backoff, not stored,
|
||||
// so a Lane that cooled down between passes reports false without a new pass.
|
||||
// BrowserReachable mirrors the Lanes' own gate: the sidecar is down only
|
||||
// within the refuseBackoff window since its last loss.
|
||||
func (p *Poller) LaneStatus() Status {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
lanes := make([]LaneState, 0, len(p.laneStates))
|
||||
now := p.Now()
|
||||
for _, name := range laneNames() {
|
||||
st, ok := p.laneStates[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
st.Refusing = now.Before(p.refuseUntil[name])
|
||||
lanes = append(lanes, st)
|
||||
}
|
||||
configured := p.BrowserFetch != nil
|
||||
reachable := configured
|
||||
if reachable && !p.browserDownAt.IsZero() && now.Sub(p.browserDownAt) < refuseBackoff {
|
||||
reachable = false
|
||||
}
|
||||
return Status{Lanes: lanes, BrowserConfigured: configured, BrowserReachable: reachable}
|
||||
}
|
||||
|
||||
// recordLaneState stores one Lane's last pass for LaneStatus. Called deferred
|
||||
// from runLanePass so every return path records, even a pass that refused.
|
||||
// A pass that never reached the pace (Gap zero) keeps the last pass's figures:
|
||||
// the Lane's due count and gap did not become zero because this pass declined
|
||||
// to look, and the row's own marks say why it declined.
|
||||
func (p *Poller) recordLaneState(st LaneState) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.laneStates == nil {
|
||||
p.laneStates = make(map[string]LaneState)
|
||||
}
|
||||
if prev, ok := p.laneStates[st.Site]; ok && st.Gap == 0 {
|
||||
st.Due, st.Gap, st.Clamped, st.Checked = prev.Due, prev.Gap, prev.Clamped, prev.Checked
|
||||
}
|
||||
p.laneStates[st.Site] = st
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Package notify posts owner-notice embeds to a Discord webhook. It is
|
||||
// deliberately small and stdlib-only: one POST of a JSON body needs no
|
||||
// Discord library, and the path imports nothing of this repo's session or
|
||||
// OAuth packages — that independence is why a webhook was chosen over a bot
|
||||
// (issue #171, AC9).
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/latest"
|
||||
)
|
||||
|
||||
// dangerColor is the dark design branch's --danger, #cf5c4d = 13589581. The
|
||||
// integer is unreadable, so a future edit will otherwise "fix" it — do not:
|
||||
// --ember means new chapter only, and a fault wearing ember would tell the
|
||||
// owner a stall is a release. This is the one colour a fault wears.
|
||||
const dangerColor = 13589581
|
||||
|
||||
// Client posts owner notices to one Discord webhook. The webhook address is a
|
||||
// secret in the class of TOKEN_KEY: it is never logged, never rendered, and
|
||||
// never carried in a returned error, which the poller logs.
|
||||
type Client struct {
|
||||
webhookURL string
|
||||
baseURL string // the deployment's public origin; embed URLs resolve against it
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New returns a Client posting to webhookURL. baseURL is the deployment's
|
||||
// public origin (Config.PublicBaseURL); the embed's deep-linked title is
|
||||
// built from it.
|
||||
func New(webhookURL, baseURL string) *Client {
|
||||
return &Client{
|
||||
webhookURL: webhookURL,
|
||||
baseURL: strings.TrimSuffix(baseURL, "/"),
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Notify posts one owner notice as a Discord embed: the danger colour, the
|
||||
// subject as a deep-linked title, the sentence as the description, the pass
|
||||
// time as the timestamp and the machine word in the footer. The send is
|
||||
// wrapped in a deadline so a hanging Discord cannot hold a poller Lane. On
|
||||
// failure the error carries no part of the webhook address (the poller logs
|
||||
// it), and the caller leaves the suppression row unwritten so the next pass
|
||||
// retries while the condition holds.
|
||||
func (c *Client) Notify(ctx context.Context, f latest.Fault, sentence, href string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
body, err := json.Marshal(c.payload(f, sentence, href))
|
||||
if err != nil {
|
||||
return fmt.Errorf("owner notice: marshal: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.webhookURL, strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return errors.New("owner notice: build request")
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
// The transport error embeds the webhook address; the address is a
|
||||
// secret in the class of TOKEN_KEY and the poller logs this error.
|
||||
return errors.New("owner notice: send failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Cap the read: a Discord error page is enough, and draining the body
|
||||
// lets the connection be reused.
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return fmt.Errorf("owner notice: webhook status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// payload is the webhook body: one embed and nothing else. No fields, no
|
||||
// thumbnail, no author block — the wire shape is what Discord reads.
|
||||
func (c *Client) payload(f latest.Fault, sentence, href string) webhookPayload {
|
||||
return webhookPayload{Embeds: []embed{{
|
||||
Color: dangerColor,
|
||||
Title: subject(f),
|
||||
URL: c.baseURL + href,
|
||||
Description: sentence,
|
||||
Timestamp: time.UnixMilli(f.Since).UTC().Format(time.RFC3339),
|
||||
Footer: embedFooter{Text: f.Condition},
|
||||
}}}
|
||||
}
|
||||
|
||||
// subject renders the embed's title: the Site the fault is about, with the
|
||||
// machine word so the title needs no per-condition wording here — #172's
|
||||
// conditions pass different sentences, not a different builder. For a fault
|
||||
// that is not one Site's, the machine word stands alone.
|
||||
func subject(f latest.Fault) string {
|
||||
if f.Site == "" {
|
||||
return f.Condition
|
||||
}
|
||||
return f.Site + ": " + f.Condition
|
||||
}
|
||||
|
||||
type webhookPayload struct {
|
||||
Embeds []embed `json:"embeds"`
|
||||
}
|
||||
|
||||
type embed struct {
|
||||
Color int `json:"color"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Footer embedFooter `json:"footer"`
|
||||
}
|
||||
|
||||
type embedFooter struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package notify_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/latest"
|
||||
"bookmarkmanager/backend/internal/notify"
|
||||
)
|
||||
|
||||
// TestNotifyEmbedShape pins the wire shape Discord actually reads: one embed
|
||||
// in the danger colour with the subject as a deep-linked title built from the
|
||||
// base URL, the sentence as the description, the pass time as an RFC3339
|
||||
// timestamp, the machine word in the footer, and no fields grid (nor
|
||||
// thumbnail, nor author block) at all. The webhook is a local server, so no
|
||||
// test can reach Discord.
|
||||
func TestNotifyEmbedShape(t *testing.T) {
|
||||
var body map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Errorf("decode request body: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
passTime := time.UnixMilli(5_000_000).UTC()
|
||||
c := notify.New(srv.URL, "https://bookmarks.test/")
|
||||
err := c.Notify(context.Background(),
|
||||
latest.Fault{Condition: latest.ConditionStall, Site: "comix", Since: passTime.UnixMilli()},
|
||||
"sentence", "/admin/lanes")
|
||||
if err != nil {
|
||||
t.Fatalf("Notify: %v", err)
|
||||
}
|
||||
|
||||
embeds, ok := body["embeds"].([]any)
|
||||
if !ok || len(embeds) != 1 {
|
||||
t.Fatalf("embeds = %#v, want exactly one embed", body["embeds"])
|
||||
}
|
||||
embed, ok := embeds[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("embed = %#v, want an object", embeds[0])
|
||||
}
|
||||
if color, ok := embed["color"].(float64); !ok || int(color) != 13589581 {
|
||||
t.Fatalf("color = %#v, want 13589581 (--danger #cf5c4d)", embed["color"])
|
||||
}
|
||||
if url := embed["url"]; url != "https://bookmarks.test/admin/lanes" {
|
||||
t.Fatalf("url = %#v, want the title deep-linked off the base URL", url)
|
||||
}
|
||||
if desc := embed["description"]; desc != "sentence" {
|
||||
t.Fatalf("description = %#v, want the sentence", desc)
|
||||
}
|
||||
footer, ok := embed["footer"].(map[string]any)
|
||||
if !ok || footer["text"] != latest.ConditionStall {
|
||||
t.Fatalf("footer = %#v, want the machine word in the footer", embed["footer"])
|
||||
}
|
||||
ts, ok := embed["timestamp"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("timestamp = %#v, want an RFC3339 string", embed["timestamp"])
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, ts)
|
||||
if err != nil || !parsed.Equal(passTime) {
|
||||
t.Fatalf("timestamp = %q, want %s (the pass time, RFC3339)", ts, passTime.Format(time.RFC3339))
|
||||
}
|
||||
for _, banned := range []string{"fields", "thumbnail", "author"} {
|
||||
if _, ok := embed[banned]; ok {
|
||||
t.Fatalf("embed has %q, want it absent (no field grid, no thumbnail, no author block)", banned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A non-2xx answer is an error the caller logs, and the error never carries
|
||||
// the webhook address — a secret in the class of TOKEN_KEY, and the poller
|
||||
// logs every notify error.
|
||||
func TestNotifyNon2xxIsErrorWithoutTheAddress(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := notify.New(srv.URL, "https://bookmarks.test")
|
||||
err := c.Notify(context.Background(),
|
||||
latest.Fault{Condition: latest.ConditionStall, Site: "comix", Since: 5_000_000},
|
||||
"sentence", "/admin/lanes")
|
||||
if err == nil {
|
||||
t.Fatal("Notify = nil, want an error for a 500")
|
||||
}
|
||||
if strings.Contains(err.Error(), srv.URL) {
|
||||
t.Fatalf("error %q leaks the webhook address", err)
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,9 @@ func Main(m *testing.M) int {
|
||||
fmt.Println("pgtest:", err)
|
||||
return 1
|
||||
}
|
||||
defer exec.Command("docker", "rm", "-f", id).Run()
|
||||
// -v: the postgres image declares a VOLUME, so an explicit rm without it
|
||||
// leaves the anonymous data volume behind on every test run.
|
||||
defer exec.Command("docker", "rm", "-f", "-v", id).Run()
|
||||
|
||||
adminURL = url
|
||||
return m.Run()
|
||||
@@ -85,7 +87,7 @@ func start() (id, url string, err error) {
|
||||
|
||||
port, err := exec.Command("docker", "port", id, "5432/tcp").Output()
|
||||
if err != nil {
|
||||
exec.Command("docker", "rm", "-f", id).Run()
|
||||
exec.Command("docker", "rm", "-f", "-v", id).Run()
|
||||
return "", "", fmt.Errorf("docker port: %w", err)
|
||||
}
|
||||
// "0.0.0.0:32768" (and possibly a second, IPv6 line); the port is all we want.
|
||||
@@ -94,7 +96,7 @@ func start() (id, url string, err error) {
|
||||
first[strings.LastIndex(first, ":")+1:])
|
||||
|
||||
if err := waitReady(url); err != nil {
|
||||
exec.Command("docker", "rm", "-f", id).Run()
|
||||
exec.Command("docker", "rm", "-f", "-v", id).Run()
|
||||
return "", "", err
|
||||
}
|
||||
return id, url, nil
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Series filter names (issue #140): the seven repair filters are ordered
|
||||
// permanent-then-fixable — the repairs nothing will ever undo first, the
|
||||
// ones a Poll can make right after. SeriesFilterFinished and
|
||||
// SeriesFilterSiteCompleted are not part of that ordering: a finished
|
||||
// Series is a deliberate state and a Site-completed one is the Site's own
|
||||
// marker, not repairs, so the pair rides the tail, informational. A name is
|
||||
// the repair a row needs, not the SQL that finds it; the values are the
|
||||
// wire form the Series list URL carries (#142). "all" is the absent and
|
||||
// unknown case: every Series.
|
||||
const (
|
||||
SeriesFilterAll = "all"
|
||||
SeriesFilterNoURL = "no_series_url"
|
||||
SeriesFilterNoChapter = "never_read_a_chapter"
|
||||
SeriesFilterNoReaders = "no_readers"
|
||||
SeriesFilterNeverChecked = "never_checked"
|
||||
SeriesFilterStale = "stale"
|
||||
SeriesFilterNoCover = "no_cover"
|
||||
SeriesFilterReaderReport = "reader_report"
|
||||
SeriesFilterFinished = "finished"
|
||||
SeriesFilterSiteCompleted = "site_completed"
|
||||
SeriesFilterFailing = "failing"
|
||||
SeriesFilterUnverified = "unverified"
|
||||
)
|
||||
|
||||
// SeriesFilter is one named filter predicate over the whole library. Site
|
||||
// and Kind narrow the row read; Name picks the predicate; Cutoff is the
|
||||
// staleness boundary the age-based filters — "stale" and the failing pair —
|
||||
// compare against, supplied by the caller's clock — the store has no clock;
|
||||
// Page is 1-based.
|
||||
type SeriesFilter struct {
|
||||
Site string // "" = every Site
|
||||
Kind string // "" = both library buckets' series
|
||||
Name string // one of the SeriesFilter* constants; "" = SeriesFilterAll
|
||||
Cutoff int64 // unix ms; the age-based filters read it, the store never does
|
||||
Page int // 1-based page of the row read; default 1
|
||||
}
|
||||
|
||||
// adminSeriesColumns is the owner's library-wide Series projection in
|
||||
// scanAdminSeries order. It is the privacy boundary: a Series' row carries
|
||||
// the Reader id that raised its Latest Chapter (latest_raised_by), and that id
|
||||
// must never leave the store package — so the projection does not select it,
|
||||
// and only the anonymous boolean in raisedByReaderAnswer crosses it.
|
||||
const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_address,
|
||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.force_poll_at,
|
||||
s.latest_corrected_at, s.finished_at, s.site_completed_at`
|
||||
|
||||
// raisedByReaderAnswer answers "did a Reader's report set this number" without
|
||||
// naming which Reader. Kept apart from adminSeriesColumns so the column list —
|
||||
// the shape scanAdminSeries is fed — stays free of the Sighting-raiser
|
||||
// identity, and the owner learns which rows to act on and nothing about the
|
||||
// Reader behind them.
|
||||
const raisedByReaderAnswer = `(s.latest_raised_by IS NOT NULL) AS raised_by_reader`
|
||||
|
||||
// failureAnswer is the poll-failures row's answer (issue #165), kept apart
|
||||
// from adminSeriesColumns like raisedByReaderAnswer: outcome and failing_since
|
||||
// are not Series columns, and the COALESCE keeps the row scannable when the
|
||||
// LEFT JOIN finds no failure row.
|
||||
const failureAnswer = `COALESCE(f.outcome, ''), COALESCE(f.failing_since, 0)`
|
||||
|
||||
// seriesPageSize is the row read's page length. The tie-break in the query's
|
||||
// ORDER BY is what makes this a stable page boundary — see SeriesPage.
|
||||
const seriesPageSize = 50
|
||||
|
||||
// AdminSeries is one Series as the owner's library-wide view sees it: a
|
||||
// Series-level fact plus an anonymous Reader count. ReaderCount being zero is
|
||||
// the orphan marker. RaisedByReader is the only trace of the Sighting
|
||||
// mechanism here; the Reader id behind it never reaches this type.
|
||||
type AdminSeries struct {
|
||||
Site string
|
||||
SeriesID string
|
||||
Title string
|
||||
SeriesURL string
|
||||
CoverAddress string // "" = no Cover yet
|
||||
Kind string
|
||||
LatestChapter string
|
||||
LatestChapterNum *float64 // nil until first captured
|
||||
LatestCheckedAt int64
|
||||
// ForcePollAt is the owner's "check now" request stamp (issue #146), zero
|
||||
// meaning never asked. Pending is derived, never stored: a request is
|
||||
// pending while ForcePollAt is newer than LatestCheckedAt.
|
||||
ForcePollAt int64
|
||||
// LatestCorrectedAt is the correction stamp (issue #149): non-zero means
|
||||
// the Latest Chapter is the owner's, zero means never corrected. The
|
||||
// provenance line (#152) derives from it, so the zero-means-never meaning
|
||||
// is load-bearing.
|
||||
LatestCorrectedAt int64
|
||||
ReaderCount int
|
||||
RaisedByReader bool // a Reader's report set LatestChapterNum
|
||||
// FailureOutcome is the outcome word of the Series' standing failure, ""
|
||||
// when no failure row stands. FailingSince is when the run of failures
|
||||
// began, zero with no row. Both come from the LEFT JOIN, not the Series
|
||||
// row (issue #165: the row's existence is the state).
|
||||
FailureOutcome string
|
||||
FailingSince int64
|
||||
// FinishedAt is the owner's finish stamp: unix ms, zero while the Series is
|
||||
// not finished — the same shape as the Correction stamp, and its own undo.
|
||||
FinishedAt int64
|
||||
// SiteCompletedAt is when the last successful Poll read saw the Site's own
|
||||
// completed value, zero meaning it did not (issue #168). The Site's marker,
|
||||
// never a Lifecycle decision: the owner's Finish is the only retirement.
|
||||
SiteCompletedAt int64
|
||||
}
|
||||
|
||||
// SeriesPage is one page of the owner's filtered Series list plus the count
|
||||
// of every Series matching the same filter — a window number, not the page's
|
||||
// len, so the landing page's figure and the list heading come from one query.
|
||||
type SeriesPage struct {
|
||||
Rows []AdminSeries
|
||||
Total int
|
||||
}
|
||||
|
||||
// SiteSeriesShape is one Site's share of the Series matching a filter: how
|
||||
// many, and the manga/novel split. One grouped pass, then library-wide totals
|
||||
// are summed in Go over the rows — the landing page's per-Site table reads
|
||||
// this and never pays for the rows the list discards.
|
||||
type SiteSeriesShape struct {
|
||||
Site string
|
||||
Total int
|
||||
Manga int
|
||||
Novel int
|
||||
}
|
||||
|
||||
// Key returns the canonical identity in bookmark-key form ("<site>:<series_id>").
|
||||
func (a AdminSeries) Key() string { return a.Site + ":" + a.SeriesID }
|
||||
|
||||
// adminFilter maps a filter's named predicate to its compile-time WHERE and
|
||||
// HAVING clauses and their bound parameters — the name never reaches query
|
||||
// text, and Site and Kind bind as parameters. Shared by the row read and the
|
||||
// per-Site aggregate so the two cannot disagree on what a filter means.
|
||||
//
|
||||
// The WHERE set is: no URL (an empty URL only — the host-failing-the-fetch-
|
||||
// gate case is invisible to SQL, needs the Site registry in Go, and belongs to
|
||||
// a later repair), never-read-a-chapter and never-checked as disjoint halves
|
||||
// (non-zero versus zero check stamp), stale, no cover, finished (the
|
||||
// retirement stamp, read directly), site completed (the Site's marker, read
|
||||
// directly), Reader-report, and the failing pair — failing (the failure row
|
||||
// exists, a chapter exists, and failing_since is past the cutoff) and
|
||||
// unverified (the Reader-attributed subset of failing).
|
||||
// failing's chapter IS NOT NULL is the exact complement of never-read-a-
|
||||
// chapter's IS NULL half, so the two are disjoint by construction.
|
||||
// no_readers is the one HAVING predicate: it is the orphan test, an aggregate
|
||||
// over the LEFT JOIN, where a bare WHERE has no row to test.
|
||||
//
|
||||
// The clock-versus-outcome split decides which predicates exclude finished
|
||||
// Series (`s.finished_at = 0` in each of the five): the clock-driven ones —
|
||||
// never-checked, stale, no-chapter, no-cover — keep ticking after the last
|
||||
// Poll, so they would report a retired row as a problem no Poll is coming to
|
||||
// fix; site-completed's stamp is the Site's, and it keeps standing after the
|
||||
// owner retires the row, so a finished Series would be reported as work
|
||||
// nobody is going to do. The outcome-driven ones — no-URL, no-readers,
|
||||
// Reader-report, failing, unverified — read stored facts that simply stop
|
||||
// arriving, so a finished Series needing a genuine repair still shows up
|
||||
// under them. The failing pair carries no finished guard for exactly that
|
||||
// contrast: a failure row is a stored outcome, not a ticking clock.
|
||||
//
|
||||
// stale is the checked-but-old half of the stamp partition — because the
|
||||
// verdict line wants "not checked in twelve hours" as one figure, and a never
|
||||
// checked Series is already counted on its own "waiting"/never-checked
|
||||
// filter, folding it in would double-report it. The landing page computes the
|
||||
// inclusive number as stale + never-checked.
|
||||
func adminFilter(f SeriesFilter) (where, having string, args []any, err error) {
|
||||
var clauses []string
|
||||
if f.Kind != "" {
|
||||
args = append(args, f.Kind)
|
||||
clauses = append(clauses, "s.kind = $"+strconv.Itoa(len(args)))
|
||||
}
|
||||
switch f.Name {
|
||||
case "", SeriesFilterAll:
|
||||
case SeriesFilterNoURL:
|
||||
clauses = append(clauses, `s.series_url = ''`)
|
||||
case SeriesFilterNoChapter:
|
||||
clauses = append(clauses, `s.latest_checked_at <> 0 AND s.latest_chapter_num IS NULL AND s.finished_at = 0`)
|
||||
case SeriesFilterNeverChecked:
|
||||
clauses = append(clauses, `s.latest_checked_at = 0 AND s.finished_at = 0`)
|
||||
case SeriesFilterStale:
|
||||
clauses = append(clauses, `s.latest_checked_at > 0 AND s.latest_checked_at < $`+strconv.Itoa(len(args)+1)+` AND s.finished_at = 0`)
|
||||
args = append(args, f.Cutoff)
|
||||
case SeriesFilterNoCover:
|
||||
clauses = append(clauses, `s.cover_address = '' AND s.finished_at = 0`)
|
||||
case SeriesFilterReaderReport:
|
||||
clauses = append(clauses, `s.latest_raised_by IS NOT NULL`)
|
||||
case SeriesFilterFailing:
|
||||
clauses = append(clauses, `f.site IS NOT NULL AND s.latest_chapter_num IS NOT NULL AND f.failing_since < $`+strconv.Itoa(len(args)+1))
|
||||
args = append(args, f.Cutoff)
|
||||
case SeriesFilterUnverified:
|
||||
clauses = append(clauses, `f.site IS NOT NULL AND s.latest_chapter_num IS NOT NULL AND f.failing_since < $`+strconv.Itoa(len(args)+1)+` AND s.latest_raised_by IS NOT NULL`)
|
||||
args = append(args, f.Cutoff)
|
||||
case SeriesFilterFinished:
|
||||
clauses = append(clauses, `s.finished_at > 0`)
|
||||
case SeriesFilterSiteCompleted:
|
||||
clauses = append(clauses, `s.site_completed_at > 0 AND s.finished_at = 0`)
|
||||
case SeriesFilterNoReaders:
|
||||
having = `HAVING COUNT(b.reader_id) = 0`
|
||||
default:
|
||||
return "", "", nil, fmt.Errorf("unknown series filter %q", f.Name)
|
||||
}
|
||||
if len(clauses) > 0 {
|
||||
where = "WHERE " + strings.Join(clauses, " AND ")
|
||||
}
|
||||
return where, having, args, nil
|
||||
}
|
||||
|
||||
// SeriesPage returns one page of the Series matching the filter, least
|
||||
// recently checked first. The LEFT JOIN to Bookmarks is what surfaces the
|
||||
// orphans that hygiene has to find — an inner join would hide them, exactly
|
||||
// as the Lane's join does. Every bookmark keeps its Series polled now that
|
||||
// finished is a Series flag, so this plain ReaderCount agrees with the Lane
|
||||
// queries (issue #157).
|
||||
//
|
||||
// The tie-break is mandatory, not decorative: every unpollable Series shares a
|
||||
// zero check stamp, so ordering on that column alone gives no stable page
|
||||
// boundary and rows would repeat or vanish across pages. (site, series_id) is
|
||||
// the primary key, hence total. The filtered total is a window count in the
|
||||
// same query — window functions run after grouping and before the limit, so
|
||||
// one where-clause cannot disagree with a second copy of itself.
|
||||
func (s *Store) SeriesPage(f SeriesFilter) (SeriesPage, error) {
|
||||
where, having, args, err := adminFilter(f)
|
||||
if err != nil {
|
||||
return SeriesPage{}, err
|
||||
}
|
||||
if f.Page < 1 {
|
||||
f.Page = 1
|
||||
}
|
||||
// Site narrowing is the row read's own; the aggregate must see every Site.
|
||||
if f.Site != "" {
|
||||
args = append(args, f.Site)
|
||||
clause := "s.site = $" + strconv.Itoa(len(args))
|
||||
if where == "" {
|
||||
where = "WHERE " + clause
|
||||
} else {
|
||||
where += " AND " + clause
|
||||
}
|
||||
}
|
||||
base := len(args)
|
||||
args = append(args, seriesPageSize, seriesPageSize*(f.Page-1))
|
||||
rows, err := s.db.Query(`
|
||||
SELECT `+adminSeriesColumns+`, `+raisedByReaderAnswer+`, `+failureAnswer+`,
|
||||
COUNT(b.reader_id) AS reader_count,
|
||||
COUNT(*) OVER () AS filtered_total
|
||||
FROM series s
|
||||
LEFT JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
|
||||
LEFT JOIN poll_failures f ON f.site = s.site AND f.series_id = s.series_id
|
||||
`+where+`
|
||||
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover_address,
|
||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
|
||||
s.force_poll_at, s.latest_corrected_at, s.finished_at, s.site_completed_at,
|
||||
s.latest_raised_by,
|
||||
f.outcome, f.failing_since
|
||||
`+having+`
|
||||
ORDER BY s.latest_checked_at, s.site, s.series_id
|
||||
LIMIT $`+strconv.Itoa(base+1)+` OFFSET $`+strconv.Itoa(base+2), args...)
|
||||
if err != nil {
|
||||
return SeriesPage{}, fmt.Errorf("query series page: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := SeriesPage{}
|
||||
for rows.Next() {
|
||||
a, total, err := scanAdminSeries(rows.Scan)
|
||||
if err != nil {
|
||||
return SeriesPage{}, fmt.Errorf("scan series page: %w", err)
|
||||
}
|
||||
out.Rows = append(out.Rows, a)
|
||||
out.Total = total
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SeriesShapes returns each Site's share of the Series matching the filter,
|
||||
// one grouped pass. Site and Page are row-read concerns and are ignored; the
|
||||
// Landing page reads this per Site and sums the totals in Go for the
|
||||
// library-wide figure.
|
||||
func (s *Store) SeriesShapes(f SeriesFilter) ([]SiteSeriesShape, error) {
|
||||
where, having, args, err := adminFilter(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.Query(`
|
||||
SELECT site,
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE kind = 'manga') AS manga,
|
||||
COUNT(*) FILTER (WHERE kind = 'novel') AS novel
|
||||
FROM (
|
||||
SELECT s.site, s.kind
|
||||
FROM series s
|
||||
LEFT JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
|
||||
LEFT JOIN poll_failures f ON f.site = s.site AND f.series_id = s.series_id
|
||||
`+where+`
|
||||
GROUP BY s.site, s.series_id, s.kind
|
||||
`+having+`
|
||||
) shape
|
||||
GROUP BY site
|
||||
ORDER BY site`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query series shapes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []SiteSeriesShape{}
|
||||
for rows.Next() {
|
||||
var sh SiteSeriesShape
|
||||
if err := rows.Scan(&sh.Site, &sh.Total, &sh.Manga, &sh.Novel); err != nil {
|
||||
return nil, fmt.Errorf("scan series shape: %w", err)
|
||||
}
|
||||
out = append(out, sh)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// scanAdminSeries reads one row in adminSeriesColumns + raisedByReaderAnswer +
|
||||
// failureAnswer order, plus the query's reader_count and filtered_total
|
||||
// columns, and returns the window total alongside the row. latest_chapter_num
|
||||
// is NULL until first captured — the "never read a chapter" state. The
|
||||
// Sighting-raiser column is never among the scanned columns.
|
||||
func scanAdminSeries(scan func(...any) error) (AdminSeries, int, error) {
|
||||
var (
|
||||
a AdminSeries
|
||||
latestChapterNum sql.NullFloat64
|
||||
total int
|
||||
)
|
||||
if err := scan(
|
||||
&a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress,
|
||||
&a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt,
|
||||
&a.ForcePollAt, &a.LatestCorrectedAt, &a.FinishedAt, &a.SiteCompletedAt,
|
||||
&a.RaisedByReader, &a.FailureOutcome, &a.FailingSince, &a.ReaderCount, &total,
|
||||
); err != nil {
|
||||
return AdminSeries{}, 0, err
|
||||
}
|
||||
if latestChapterNum.Valid {
|
||||
a.LatestChapterNum = &latestChapterNum.Float64
|
||||
}
|
||||
return a, total, nil
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// seriesSeed describes one Series (and optionally its bookmarks) to stand up
|
||||
// for an admin filter test. Direct SQL, because the filters separate rows the
|
||||
// Upsert path could not produce together: an orphan has no bookmark, and a
|
||||
// Reader-raised Latest Chapter needs a Sighting the store does not create.
|
||||
type seriesSeed struct {
|
||||
key string
|
||||
kind string
|
||||
url string
|
||||
cover string // cover_address
|
||||
checkedAt int64
|
||||
latestNum *float64
|
||||
bookmarks int // readers that hold it; 0 = orphan
|
||||
raisedBy bool // a Reader's report is attributed as the raiser
|
||||
siteCompletedAt int64 // the Site's own marker (issue #168), 0 = not set
|
||||
}
|
||||
|
||||
// seedAdminSeries inserts one series row and its bookmarks (owner first, then
|
||||
// fresh readers), with the exact admin-relevant facts a test needs.
|
||||
func seedAdminSeries(t *testing.T, s *Store, seed seriesSeed) {
|
||||
t.Helper()
|
||||
site, seriesID, ok := strings.Cut(seed.key, ":")
|
||||
if !ok {
|
||||
t.Fatalf("key %q: no ':' separator", seed.key)
|
||||
}
|
||||
if seed.kind == "" {
|
||||
seed.kind = "manga"
|
||||
}
|
||||
var latestChapter any = ""
|
||||
if seed.latestNum != nil {
|
||||
latestChapter = "Chapter " + strconv.FormatFloat(*seed.latestNum, 'f', -1, 64)
|
||||
}
|
||||
if _, err := s.db.Exec(`
|
||||
INSERT INTO series (site, series_id, title, kind, series_url, cover_address,
|
||||
latest_checked_at, latest_chapter, latest_chapter_num, site_completed_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
site, seriesID, "Title of "+seed.key, seed.kind, seed.url, seed.cover,
|
||||
seed.checkedAt, latestChapter, seed.latestNum, seed.siteCompletedAt); err != nil {
|
||||
t.Fatalf("seed series %q: %v", seed.key, err)
|
||||
}
|
||||
for i := range seed.bookmarks {
|
||||
var readerID int64 = s.OwnerID()
|
||||
if i > 0 {
|
||||
readerID = secondReader(t, s)
|
||||
}
|
||||
if _, err := s.db.Exec(`
|
||||
INSERT INTO bookmarks (reader_id, site, series_id,
|
||||
last_chapter, last_chapter_num, last_chapter_url,
|
||||
favorite, status, updated_at)
|
||||
VALUES ($1, $2, $3, '', 0, '', false, 'reading', $4)`,
|
||||
readerID, site, seriesID, seed.checkedAt); err != nil {
|
||||
t.Fatalf("seed bookmark %q: %v", seed.key, err)
|
||||
}
|
||||
}
|
||||
if seed.raisedBy {
|
||||
if _, err := s.db.Exec(
|
||||
`UPDATE series SET latest_raised_by = $1 WHERE site = $2 AND series_id = $3`,
|
||||
s.OwnerID(), site, seriesID); err != nil {
|
||||
t.Fatalf("seed raised-by %q: %v", seed.key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pageKeys(t *testing.T, s *Store, f SeriesFilter) map[string]bool {
|
||||
t.Helper()
|
||||
page, err := s.SeriesPage(f)
|
||||
if err != nil {
|
||||
t.Fatalf("SeriesPage(%+v): %v", f, err)
|
||||
}
|
||||
keys := map[string]bool{}
|
||||
for _, a := range page.Rows {
|
||||
keys[a.Key()] = true
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// Each filter must return the rows it names and no others, over one shared
|
||||
// seeded mix where every healthy neighbour is present to be wrongly returned.
|
||||
// The stale cutoff is 5000: a Series checked at 9000 is current, at 2000 stale.
|
||||
func TestAdminSeriesFilters(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:healthy", url: "https://asurascans.com/comics/healthy", cover: "aaa", checkedAt: 9000, latestNum: new(10.0), bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:nourl", url: "", cover: "bbb", checkedAt: 9000, latestNum: new(5.0), bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:nochapter", url: "https://asurascans.com/comics/nochapter", cover: "ccc", checkedAt: 9000, bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:neverchecked", url: "https://asurascans.com/comics/neverchecked", cover: "ddd", checkedAt: 0, bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:orphan", url: "https://asurascans.com/comics/orphan", cover: "eee", checkedAt: 9000, latestNum: new(7.0), bookmarks: 0})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:stale", url: "https://asurascans.com/comics/stale", cover: "fff", checkedAt: 2000, latestNum: new(4.0), bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:nocover", url: "https://asurascans.com/comics/nocover", checkedAt: 9000, latestNum: new(9.0), bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:report", url: "https://asurascans.com/comics/report", cover: "ggg", checkedAt: 9000, latestNum: new(8.0), bookmarks: 1, raisedBy: true})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
f SeriesFilter
|
||||
want []string
|
||||
}{
|
||||
{"all", SeriesFilter{}, []string{"asura:healthy", "asura:nourl", "asura:nochapter", "asura:neverchecked", "asura:orphan", "asura:stale", "asura:nocover", "asura:report"}},
|
||||
{"no series url", SeriesFilter{Name: SeriesFilterNoURL}, []string{"asura:nourl"}},
|
||||
{"never read a chapter", SeriesFilter{Name: SeriesFilterNoChapter}, []string{"asura:nochapter"}},
|
||||
{"never checked", SeriesFilter{Name: SeriesFilterNeverChecked}, []string{"asura:neverchecked"}},
|
||||
{"no readers", SeriesFilter{Name: SeriesFilterNoReaders}, []string{"asura:orphan"}},
|
||||
{"stale", SeriesFilter{Name: SeriesFilterStale, Cutoff: 5000}, []string{"asura:stale"}},
|
||||
{"no cover", SeriesFilter{Name: SeriesFilterNoCover}, []string{"asura:nocover"}},
|
||||
{"reader report", SeriesFilter{Name: SeriesFilterReaderReport}, []string{"asura:report"}},
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestAdminNeverChapterAndNeverCheckedAreDisjoint(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:nochapter", url: "u", checkedAt: 9000, bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:neverchecked", url: "u", checkedAt: 0, bookmarks: 1})
|
||||
// A zero-stamp, no-chapter row is never-checked only: if never-read-a-
|
||||
// chapter ever lost its non-zero-stamp guard, it would claim this row too
|
||||
// and the two counts would double-report it.
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:both", url: "u", checkedAt: 0, bookmarks: 1})
|
||||
noChapter := pageKeys(t, s, SeriesFilter{Name: SeriesFilterNoChapter})
|
||||
neverChecked := pageKeys(t, s, SeriesFilter{Name: SeriesFilterNeverChecked})
|
||||
for k := range noChapter {
|
||||
if neverChecked[k] {
|
||||
t.Fatalf("row %q matches both never-read-a-chapter and never-checked", k)
|
||||
}
|
||||
}
|
||||
if !noChapter["asura:nochapter"] || !neverChecked["asura:neverchecked"] {
|
||||
t.Fatalf("disjoint split lost its own rows: no-chapter=%v never-checked=%v", noChapter, neverChecked)
|
||||
}
|
||||
}
|
||||
|
||||
// Several rows share a zero check stamp, so ordering on latest_checked_at
|
||||
// alone gives no stable page boundary. The (site, series_id) tie-break must
|
||||
// make page 2 a strict continuation of page 1: no repeat, no vanishing row.
|
||||
func TestAdminSeriesPageTieBreakIsStable(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
const total = 53 // > one page, < two (page size 50)
|
||||
for i := range total {
|
||||
id := "tie" + strconv.Itoa(i)
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:" + id, url: "u", checkedAt: 0, bookmarks: 1})
|
||||
}
|
||||
// A second Site's zero-stamp row is part of the same all-filter list, and
|
||||
// must land on a valid page boundary rather than duplicating or dropping
|
||||
// one of asura's rows: the tie-break is global (site, series_id).
|
||||
seedAdminSeries(t, s, seriesSeed{key: "demonic:z", url: "u", checkedAt: 0, bookmarks: 1})
|
||||
wantTotal := total + 1
|
||||
|
||||
p1, err := s.SeriesPage(SeriesFilter{Name: SeriesFilterAll})
|
||||
if err != nil {
|
||||
t.Fatalf("SeriesPage page 1: %v", err)
|
||||
}
|
||||
p2, err := s.SeriesPage(SeriesFilter{Name: SeriesFilterAll, Page: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("SeriesPage page 2: %v", err)
|
||||
}
|
||||
if len(p1.Rows) != seriesPageSize {
|
||||
t.Fatalf("page 1 has %d rows, want %d", len(p1.Rows), seriesPageSize)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, a := range append(append([]AdminSeries{}, p1.Rows...), p2.Rows...) {
|
||||
if seen[a.Key()] {
|
||||
t.Fatalf("row %q repeats across pages", a.Key())
|
||||
}
|
||||
seen[a.Key()] = true
|
||||
}
|
||||
if len(seen) != wantTotal {
|
||||
t.Fatalf("%d distinct rows across pages, want %d (a row vanished)", len(seen), wantTotal)
|
||||
}
|
||||
if p1.Total != wantTotal {
|
||||
t.Fatalf("page total = %d, want %d (the window count must span pages)", p1.Total, wantTotal)
|
||||
}
|
||||
// A page beyond the end is empty, not an error (the list re-reads page 1).
|
||||
// The window count runs over the rows present in the result set, so an
|
||||
// overflow page has no rows and therefore no total — the caller must not
|
||||
// render it, which is exactly why the list re-reads page 1.
|
||||
pFinal, err := s.SeriesPage(SeriesFilter{Name: SeriesFilterAll, Page: 99})
|
||||
if err != nil {
|
||||
t.Fatalf("SeriesPage beyond end: %v", err)
|
||||
}
|
||||
if len(pFinal.Rows) != 0 {
|
||||
t.Fatalf("page beyond end = %d rows, want 0", len(pFinal.Rows))
|
||||
}
|
||||
}
|
||||
|
||||
// The filtered total is the window number over the same filter the rows use,
|
||||
// and the per-Site aggregate sums to the same figure — so the landing page's
|
||||
// count and the list's heading can never disagree, whoever computes them.
|
||||
func TestAdminTotalAgreesWithRowsAndShapes(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:a", url: "u", cover: "a", checkedAt: 9000, bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:b", url: "u", checkedAt: 9000, bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:c", url: "u", checkedAt: 9000, bookmarks: 1, latestNum: new(2.0), raisedBy: true})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "demonic:d", url: "u", kind: "novel", checkedAt: 9000, bookmarks: 1})
|
||||
|
||||
filters := []SeriesFilter{
|
||||
{},
|
||||
{Name: SeriesFilterNoCover},
|
||||
{Name: SeriesFilterReaderReport},
|
||||
{Name: SeriesFilterNoChapter},
|
||||
}
|
||||
for _, f := range filters {
|
||||
page, err := s.SeriesPage(f)
|
||||
if err != nil {
|
||||
t.Fatalf("SeriesPage(%+v): %v", f, err)
|
||||
}
|
||||
want := len(page.Rows)
|
||||
if f.Page == 0 && want == seriesPageSize {
|
||||
t.Fatalf("seed produced a full page; bump the seed or drop page size in the test")
|
||||
}
|
||||
if page.Total != want {
|
||||
t.Fatalf("%+v total = %d, want %d (window count disagrees with row count)", f, page.Total, want)
|
||||
}
|
||||
shapes, err := s.SeriesShapes(f)
|
||||
if err != nil {
|
||||
t.Fatalf("SeriesShapes(%+v): %v", f, err)
|
||||
}
|
||||
sum := 0
|
||||
for _, sh := range shapes {
|
||||
sum += sh.Total
|
||||
}
|
||||
if sum != want {
|
||||
t.Fatalf("%+v aggregate sum = %d, want %d (aggregate disagrees with row query)", f, sum, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The default filter's aggregate carries the library shape: per-Site
|
||||
// totals and the manga/novel split, summed in Go for library wide.
|
||||
shapes, err := s.SeriesShapes(SeriesFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("SeriesShapes default: %v", err)
|
||||
}
|
||||
if len(shapes) != 2 || shapes[0].Site != "asura" || shapes[1].Site != "demonic" {
|
||||
t.Fatalf("shapes = %+v, want asura then demonic", shapes)
|
||||
}
|
||||
if shapes[0].Total != 3 || shapes[0].Manga != 3 || shapes[0].Novel != 0 {
|
||||
t.Fatalf("asura shape = %+v, want 3 manga, 0 novel", shapes[0])
|
||||
}
|
||||
if shapes[1].Total != 1 || shapes[1].Manga != 0 || shapes[1].Novel != 1 {
|
||||
t.Fatalf("demonic shape = %+v, want 1 novel", shapes[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Site and Library narrowing stack on a named filter without changing what
|
||||
// the filter means.
|
||||
func TestAdminFilterSiteAndKindNarrow(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:aa", url: "u", checkedAt: 9000, bookmarks: 1, latestNum: new(1.0)})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:ab", url: "", checkedAt: 9000, bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "demonic:aa", url: "u", kind: "novel", checkedAt: 9000, bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "demonic:ab", url: "", kind: "novel", checkedAt: 9000, bookmarks: 1})
|
||||
|
||||
got := pageKeys(t, s, SeriesFilter{Name: SeriesFilterNoURL, Site: "asura"})
|
||||
if len(got) != 1 || !got["asura:ab"] {
|
||||
t.Fatalf("site+nourl = %v, want only asura:ab", got)
|
||||
}
|
||||
got = pageKeys(t, s, SeriesFilter{Name: SeriesFilterNoURL, Kind: "novel"})
|
||||
if len(got) != 1 || !got["demonic:ab"] {
|
||||
t.Fatalf("kind+nourl = %v, want only demonic:ab", got)
|
||||
}
|
||||
got = pageKeys(t, s, SeriesFilter{Name: SeriesFilterAll, Site: "demonic", Kind: "novel"})
|
||||
if len(got) != 2 || !got["demonic:aa"] || !got["demonic:ab"] {
|
||||
t.Fatalf("site+kind+all = %v, want both demonic rows", got)
|
||||
}
|
||||
|
||||
// The aggregate ignores the Site narrowing (it is per-Site by shape), but
|
||||
// honours the Library narrowing: asura's missing-URL row is manga, so the
|
||||
// novel no-URL list is demonic alone.
|
||||
shapes, err := s.SeriesShapes(SeriesFilter{Name: SeriesFilterNoURL, Kind: "novel"})
|
||||
if err != nil {
|
||||
t.Fatalf("SeriesShapes: %v", err)
|
||||
}
|
||||
if len(shapes) != 1 || shapes[0].Site != "demonic" ||
|
||||
shapes[0].Total != 1 || shapes[0].Novel != 1 {
|
||||
t.Fatalf("novel no-URL aggregate = %+v, want demonic {Total:1 Novel:1}", shapes)
|
||||
}
|
||||
}
|
||||
|
||||
// The projection is the privacy boundary: a Series whose Latest Chapter was
|
||||
// raised by a Reader's report reads back with the anonymous boolean set, not
|
||||
// with the Reader's id, and no Reader id travels in any returned row.
|
||||
func TestAdminSeriesReportsAnonymously(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:raised", url: "u", checkedAt: 9000, latestNum: new(9.0), bookmarks: 1, raisedBy: true})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:polled", url: "u", checkedAt: 9000, latestNum: new(8.0), bookmarks: 1})
|
||||
|
||||
page, err := s.SeriesPage(SeriesFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("SeriesPage: %v", err)
|
||||
}
|
||||
byKey := map[string]AdminSeries{}
|
||||
for _, a := range page.Rows {
|
||||
byKey[a.Key()] = a
|
||||
}
|
||||
if !byKey["asura:raised"].RaisedByReader {
|
||||
t.Fatal("Reader-raised Series read back RaisedByReader=false")
|
||||
}
|
||||
if byKey["asura:polled"].RaisedByReader {
|
||||
t.Fatal("Poll-raised Series read back RaisedByReader=true")
|
||||
}
|
||||
}
|
||||
|
||||
// The privacy test that cannot rot into a template-only guarantee: assert the
|
||||
// admin column constant does not carry the Sighting-raiser column and that the
|
||||
// admin row type has no field for it, modelled on the guard on the Bookmark
|
||||
// column list.
|
||||
func TestAdminProjectionHidesSightingRaiser(t *testing.T) {
|
||||
if strings.Contains(adminSeriesColumns, "latest_raised_by") {
|
||||
t.Fatal("admin column list carries latest_raised_by: the Sighting-raiser id would reach the owner")
|
||||
}
|
||||
if _, ok := reflect.TypeOf(AdminSeries{}).FieldByName("LatestRaisedBy"); ok {
|
||||
t.Fatal("AdminSeries carries a field for the Sighting-raiser id")
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown filter name is rejected rather than silently meaning "all" —
|
||||
// otherwise a mistyped URL would present an empty page as the whole library.
|
||||
func TestAdminFilterUnknownNameRejected(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:a", url: "u", checkedAt: 9000, bookmarks: 1})
|
||||
for name, call := range map[string]func() error{
|
||||
"page": func() error { _, err := s.SeriesPage(SeriesFilter{Name: "bogus"}); return err },
|
||||
"shape": func() error { _, err := s.SeriesShapes(SeriesFilter{Name: "bogus"}); return err },
|
||||
} {
|
||||
if err := call(); err == nil || !strings.Contains(err.Error(), "unknown series filter") {
|
||||
t.Fatalf("%s with bogus filter = %v, want unknown-filter error", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ForceSeriesPoll is the idempotent stamp write: a second press overwrites
|
||||
// the request time, and touching a missing series is not an error.
|
||||
func TestForceSeriesPollStampsIdempotently(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:x", url: "u", checkedAt: 9000, bookmarks: 1})
|
||||
|
||||
if err := s.ForceSeriesPoll("asura", "x", 42); err != nil {
|
||||
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||
}
|
||||
if err := s.ForceSeriesPoll("asura", "x", 99); err != nil {
|
||||
t.Fatalf("ForceSeriesPoll re-stamp: %v", err)
|
||||
}
|
||||
// Touching a missing series is not an error: the row may have been
|
||||
// orphaned, and the caller's read decides what exists.
|
||||
if err := s.ForceSeriesPoll("asura", "ghost", 99); err != nil {
|
||||
t.Fatalf("ForceSeriesPoll missing: %v", err)
|
||||
}
|
||||
|
||||
var got int64
|
||||
if err := s.db.QueryRow(
|
||||
`SELECT force_poll_at FROM series WHERE site = 'asura' AND series_id = 'x'`).Scan(&got); err != nil {
|
||||
t.Fatalf("read force_poll_at: %v", err)
|
||||
}
|
||||
if got != 99 {
|
||||
t.Fatalf("force_poll_at = %d, want 99 (the later press wins)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The admin projection carries the force stamp so the web layer can derive
|
||||
// the pending flag without a second read.
|
||||
func TestAdminSeriesCarriesForcePollAt(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:x", url: "u", checkedAt: 1000, bookmarks: 1})
|
||||
if err := s.ForceSeriesPoll("asura", "x", 5000); err != nil {
|
||||
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||
}
|
||||
|
||||
page, err := s.SeriesPage(SeriesFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("SeriesPage: %v", err)
|
||||
}
|
||||
if len(page.Rows) != 1 || page.Rows[0].ForcePollAt != 5000 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// The failing pair reads the failure row's age, not the Series row (issue
|
||||
// #165): failing requires the joined row, a Latest Chapter, and failing_since
|
||||
// past the cutoff — the same constant stale reads; unverified is the
|
||||
// Reader-attributed subset of the same test. A failure inside the window is
|
||||
// in neither — one bad fetch is not a fault to correct — and a Series that
|
||||
// never captured a chapter is never failing, the exact complement of
|
||||
// never-read-a-chapter's IS NULL half, so the two filters are disjoint by
|
||||
// construction. A finished failing Series still appears: this pair reads
|
||||
// stored outcomes, which simply stop arriving, and carries no finished guard.
|
||||
func TestAdminFailingAndUnverifiedFilters(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
const cutoff = 5000
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:failing", url: "u", cover: "c", checkedAt: 9000, latestNum: new(10.0), bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:recent", url: "u", cover: "c", checkedAt: 9000, latestNum: new(9.0), bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:nochapter", url: "u", cover: "c", checkedAt: 9000, bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:reader", url: "u", cover: "c", checkedAt: 9000, latestNum: new(8.0), bookmarks: 1, raisedBy: true})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:polled", url: "u", cover: "c", checkedAt: 9000, latestNum: new(7.0), bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-failing", url: "u", cover: "c", checkedAt: 9000, latestNum: new(6.0), bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:healthy", url: "u", cover: "c", checkedAt: 9000, latestNum: new(5.0), bookmarks: 1})
|
||||
|
||||
// Failure rows seed the run's start stamp; the cutoff decides the age.
|
||||
for _, f := range []struct {
|
||||
key, word string
|
||||
since int64
|
||||
}{
|
||||
{"asura:failing", "not_found", 2000},
|
||||
{"asura:recent", "not_found", 9000},
|
||||
{"asura:nochapter", "not_found", 2000},
|
||||
{"asura:reader", "not_found", 2000},
|
||||
{"asura:polled", "errors", 2000},
|
||||
{"asura:fin-failing", "not_found", 2000},
|
||||
} {
|
||||
site, id, _ := strings.Cut(f.key, ":")
|
||||
if err := s.RecordSeriesFailure(site, id, f.word, f.since); err != nil {
|
||||
t.Fatalf("seed failure %s: %v", f.key, err)
|
||||
}
|
||||
}
|
||||
if err := s.SetSeriesFinished("asura", "fin-failing", 1000); err != nil {
|
||||
t.Fatalf("finish asura:fin-failing: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
f SeriesFilter
|
||||
want []string
|
||||
}{
|
||||
{"failing", SeriesFilter{Name: SeriesFilterFailing, Cutoff: cutoff}, []string{"asura:failing", "asura:reader", "asura:polled", "asura:fin-failing"}},
|
||||
{"unverified", SeriesFilter{Name: SeriesFilterUnverified, Cutoff: cutoff}, []string{"asura:reader"}},
|
||||
{"never read a chapter", SeriesFilter{Name: SeriesFilterNoChapter}, []string{"asura:nochapter"}},
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Disjointness: the failing pair and never-read-a-chapter are disjoint by
|
||||
// the chapter column — IS NOT NULL here, IS NULL there — so no row may be
|
||||
// counted under both.
|
||||
failing := pageKeys(t, s, SeriesFilter{Name: SeriesFilterFailing, Cutoff: cutoff})
|
||||
noChapter := pageKeys(t, s, SeriesFilter{Name: SeriesFilterNoChapter})
|
||||
for k := range failing {
|
||||
if noChapter[k] {
|
||||
t.Fatalf("row %q matches both failing and never-read-a-chapter", k)
|
||||
}
|
||||
}
|
||||
if failing["asura:nochapter"] {
|
||||
t.Fatal("a Series with no chapter ever captured appears in failing")
|
||||
}
|
||||
if !noChapter["asura:nochapter"] {
|
||||
t.Fatal("the no-chapter Series vanished from never-read-a-chapter")
|
||||
}
|
||||
|
||||
// The aggregates count the same rows the lists do: the landing page's
|
||||
// figures and the select's options come from these two passes.
|
||||
for _, name := range []string{SeriesFilterFailing, SeriesFilterUnverified} {
|
||||
shapes, err := s.SeriesShapes(SeriesFilter{Name: name, Cutoff: cutoff})
|
||||
if err != nil {
|
||||
t.Fatalf("SeriesShapes(%s): %v", name, err)
|
||||
}
|
||||
sum := 0
|
||||
for _, sh := range shapes {
|
||||
sum += sh.Total
|
||||
}
|
||||
want := len(pageKeys(t, s, SeriesFilter{Name: name, Cutoff: cutoff}))
|
||||
if sum != want {
|
||||
t.Fatalf("%s aggregate sum = %d, want %d (aggregate disagrees with row query)", name, sum, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The projection carries the failure facts from the LEFT JOIN, not the
|
||||
// Series row: a failing Series reads back its outcome word and the stamp its
|
||||
// run began at; a Series with no failure row reads empty and zero. Nothing
|
||||
// new is projected from the Series row itself.
|
||||
func TestAdminFailureProjection(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:broken", url: "u", cover: "c", checkedAt: 9000, latestNum: new(9.0), bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:fine", url: "u", cover: "c", checkedAt: 9000, latestNum: new(8.0), bookmarks: 1})
|
||||
if err := s.RecordSeriesFailure("asura", "broken", "not_found", 2000); err != nil {
|
||||
t.Fatalf("seed failure row: %v", err)
|
||||
}
|
||||
|
||||
page, err := s.SeriesPage(SeriesFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("SeriesPage: %v", err)
|
||||
}
|
||||
byKey := map[string]AdminSeries{}
|
||||
for _, a := range page.Rows {
|
||||
byKey[a.Key()] = a
|
||||
}
|
||||
if byKey["asura:broken"].FailureOutcome != "not_found" || byKey["asura:broken"].FailingSince != 2000 {
|
||||
t.Fatalf("broken row = %+v, want FailureOutcome not_found, FailingSince 2000", byKey["asura:broken"])
|
||||
}
|
||||
if byKey["asura:fine"].FailureOutcome != "" || byKey["asura:fine"].FailingSince != 0 {
|
||||
t.Fatalf("fine row = %+v, want empty outcome and zero stamp", byKey["asura:fine"])
|
||||
}
|
||||
}
|
||||
|
||||
// The site-completed filter (issue #170) lists the Site's own marker as work
|
||||
// to work through, carrying the same finished guard the clock-driven
|
||||
// predicates gained: the Site's stamp keeps standing after the owner retires
|
||||
// the row, so a finished Series would be reported as work nobody is going to
|
||||
// do. A zero stamp never appears. Un-finishing puts the Series back in the
|
||||
// Poll query — the finished_at gate is #157's own, asserted through
|
||||
// DueForLatestCheck rather than re-derived here.
|
||||
func TestAdminSiteCompletedFilter(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:done", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1, siteCompletedAt: 5000})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:never", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:retired", url: "u", cover: "c", checkedAt: 0, latestNum: new(4.0), bookmarks: 1, siteCompletedAt: 5000})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:healthy", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1})
|
||||
if err := s.SetSeriesFinished("asura", "retired", 1000); err != nil {
|
||||
t.Fatalf("finish asura:retired: %v", err)
|
||||
}
|
||||
|
||||
got := pageKeys(t, s, SeriesFilter{Name: SeriesFilterSiteCompleted})
|
||||
if len(got) != 1 || !got["asura:done"] {
|
||||
t.Fatalf("site-completed returned %v, want only asura:done", got)
|
||||
}
|
||||
|
||||
// The projection carries the stamp so the detail page can age it.
|
||||
page, err := s.SeriesPage(SeriesFilter{Name: SeriesFilterSiteCompleted})
|
||||
if err != nil {
|
||||
t.Fatalf("SeriesPage(site_completed): %v", err)
|
||||
}
|
||||
if len(page.Rows) != 1 || page.Rows[0].SiteCompletedAt != 5000 {
|
||||
t.Fatalf("row = %+v, want SiteCompletedAt 5000", page.Rows)
|
||||
}
|
||||
|
||||
// The aggregate counts the same row: the landing figure and the select's
|
||||
// option come from this pass, so they cannot disagree with the list.
|
||||
shapes, err := s.SeriesShapes(SeriesFilter{Name: SeriesFilterSiteCompleted})
|
||||
if err != nil {
|
||||
t.Fatalf("SeriesShapes(site_completed): %v", err)
|
||||
}
|
||||
sum := 0
|
||||
for _, sh := range shapes {
|
||||
sum += sh.Total
|
||||
}
|
||||
if sum != 1 {
|
||||
t.Fatalf("site-completed aggregate = %d, want 1", sum)
|
||||
}
|
||||
|
||||
// Un-finishing puts the Series back in the Poll query: the due read's
|
||||
// finished_at gate (issue #157) admits it again — asserted through the
|
||||
// Lane's own read, not re-derived here.
|
||||
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
for _, sr := range due {
|
||||
if sr.Key() == "asura:retired" {
|
||||
t.Fatalf("a finished Series is still due for a Poll:\n%+v", due)
|
||||
}
|
||||
}
|
||||
if err := s.SetSeriesFinished("asura", "retired", 0); err != nil {
|
||||
t.Fatalf("un-finish asura:retired: %v", err)
|
||||
}
|
||||
due, err = s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck after un-finish: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, sr := range due {
|
||||
if sr.Key() == "asura:retired" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("un-finished Series is not due for a Poll:\n%+v", due)
|
||||
}
|
||||
}
|
||||
@@ -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,6 @@
|
||||
-- One durable state row per Poll Lane. Zero means no pause or refusal is set.
|
||||
CREATE TABLE poll_lanes (
|
||||
site text NOT NULL PRIMARY KEY,
|
||||
paused_until bigint NOT NULL DEFAULT 0,
|
||||
refuse_until bigint NOT NULL DEFAULT 0
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Append-only Lane Pass log. Timestamps are unix milliseconds from the poller's clock.
|
||||
CREATE TABLE poll_passes (
|
||||
site text NOT NULL,
|
||||
ran_at bigint NOT NULL,
|
||||
skip text NOT NULL,
|
||||
due integer NOT NULL,
|
||||
checked integer NOT NULL,
|
||||
gap_ms bigint NOT NULL,
|
||||
clamped boolean NOT NULL,
|
||||
refused integer NOT NULL,
|
||||
unreachable integer NOT NULL,
|
||||
no_chapter integer NOT NULL,
|
||||
unfetchable integer NOT NULL,
|
||||
errors integer NOT NULL,
|
||||
PRIMARY KEY (site, ran_at)
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Admin read-model foundation (#140). The Series list's default order is
|
||||
-- least-recently-checked first, so the table — which has only its primary key
|
||||
-- today — gets an index that can serve it. A grouped query over a join may
|
||||
-- ignore the index, so this is a judgement, not a measurement: re-time on real
|
||||
-- data before adding a second.
|
||||
CREATE INDEX series_latest_checked_at_idx ON series (latest_checked_at);
|
||||
|
||||
-- force_poll_at is the "ask for one Series to be checked now" stamp (#146).
|
||||
-- Zero means never forced; nothing reads the column before that ticket wires
|
||||
-- it, so it lands here unused.
|
||||
ALTER TABLE series ADD COLUMN force_poll_at bigint NOT NULL DEFAULT 0;
|
||||
@@ -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';
|
||||
@@ -0,0 +1,4 @@
|
||||
-- A 4xx other than the 403 refusal is the page being gone, not the Site being
|
||||
-- unwell; the pass log counts it separately so the Lanes page can say "not
|
||||
-- found" (#164). DEFAULT 0 keeps pre-existing rows readable.
|
||||
ALTER TABLE poll_passes ADD COLUMN not_found integer NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- One row per Series that is failing right now (ADR-0016): the row's
|
||||
-- existence is the failure state, failing_since ages the run of failures,
|
||||
-- and a correct read deletes the row. Keyed by the same (site, series_id)
|
||||
-- composite the rest of the system uses, with the cascade so deleting a
|
||||
-- Series takes its failure row and orphan removal stays a single statement.
|
||||
CREATE TABLE poll_failures (
|
||||
site text NOT NULL,
|
||||
series_id text NOT NULL,
|
||||
outcome text NOT NULL,
|
||||
failing_since bigint NOT NULL,
|
||||
PRIMARY KEY (site, series_id),
|
||||
FOREIGN KEY (site, series_id) REFERENCES series (site, series_id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- When the last successful Poll read saw the Site's own completed value
|
||||
-- (#168): epoch-ms, zero meaning it did not. DEFAULT 0 keeps pre-existing
|
||||
-- rows readable.
|
||||
ALTER TABLE series ADD COLUMN site_completed_at bigint NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Owner notices (issue #171): one row per episode, remembered only as "the
|
||||
-- owner was told". The row's presence is the whole state — the poller checks
|
||||
-- it before sending, writes it after a successful send, and clears it when
|
||||
-- the condition no longer holds. site is '' for a fault that is not one
|
||||
-- Site's; the composite primary key is what makes the row a lock against a
|
||||
-- second message for the same episode.
|
||||
CREATE TABLE owner_notices (
|
||||
condition text NOT NULL,
|
||||
site text NOT NULL,
|
||||
notified_at bigint NOT NULL,
|
||||
PRIMARY KEY (condition, site)
|
||||
);
|
||||
+713
-76
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+44
-118
@@ -4,71 +4,31 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/latest"
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
)
|
||||
|
||||
// LaneReporter is the administrative page's whole window onto the running
|
||||
// poller: one snapshot of Poll Lane state, copied out of memory on request.
|
||||
// The Poller satisfies it in production and a fake with fixed values satisfies
|
||||
// it in tests, so the page's tests need neither a poller nor a Site.
|
||||
type LaneReporter interface {
|
||||
LaneStatus() latest.Status
|
||||
}
|
||||
// ownerWindow is the staleness boundary the Series list's "not checked in
|
||||
// 12h" filter compares against. It reads latest.OwnerWindow — the one place
|
||||
// the class-level twelve hours lives, shared with the owner-notice
|
||||
// conditions (issue #171).
|
||||
const ownerWindow = latest.OwnerWindow
|
||||
|
||||
// adminView is what the administrative page and the roster fragment receive.
|
||||
// adminView is the shared shell data for an administrative page and the roster
|
||||
// fragment returned after a Reader action.
|
||||
type adminView struct {
|
||||
Page string
|
||||
Readers []store.ReaderSummary
|
||||
// OwnerID travels with the roster so it can tell the owner's own row from
|
||||
// the Readers they may act on.
|
||||
OwnerID int64
|
||||
Lanes lanesView
|
||||
}
|
||||
|
||||
// lanesView is the Lane status block: one row per Site that has run, plus the
|
||||
// browser fact, which is shared by the three browser Sites rather than held
|
||||
// once per Site.
|
||||
type lanesView struct {
|
||||
Rows []laneRow
|
||||
// PollerOff means no poller is running at all (disabled by config, or its
|
||||
// client could not be built). The browser line must not answer "not
|
||||
// configured" then: the sidecar is not the reason nothing is polled.
|
||||
PollerOff bool
|
||||
BrowserConfigured bool
|
||||
BrowserReachable bool
|
||||
}
|
||||
|
||||
// laneRow is one Lane formatted for reading rather than for arithmetic: the
|
||||
// template renders strings and flags, and every judgement about what they mean
|
||||
// is made here.
|
||||
type laneRow struct {
|
||||
Site string
|
||||
Due int
|
||||
Ran string
|
||||
// Checked is how many Series the last pass read. Due without Checked is a
|
||||
// Lane that has stopped working; the two figures side by side are what
|
||||
// separate that from a Lane with nothing to do.
|
||||
Checked int
|
||||
// Gap is empty when no pass has reached the pace yet, so the row omits the
|
||||
// figure instead of stating a zero.
|
||||
Gap string
|
||||
Clamped bool
|
||||
Refusing bool
|
||||
// BrowserLost marks a Lane whose pages can only be read through the
|
||||
// sidecar while the sidecar is unreachable — including the case where none
|
||||
// is configured, which stops those Series just as completely.
|
||||
BrowserLost bool
|
||||
// Stalled marks a Lane with Series waiting that its last pass did not read
|
||||
// — the difference between a stopped Lane and a quiet one (story 13). A
|
||||
// browser Lane holding Chrome asleep under the wake thresholds is neither,
|
||||
// so it carries Asleep instead and never Stalled.
|
||||
Stalled bool
|
||||
Asleep bool
|
||||
// Attention is the one flag the template colours on, so an unhealthy Lane
|
||||
// is found at a glance rather than read for.
|
||||
Attention bool
|
||||
OwnerID int64
|
||||
Lanes lanesView
|
||||
SeriesList seriesListView
|
||||
// Detail is the per-Series page data; zero on every other page.
|
||||
Detail seriesDetailView
|
||||
// Overview is the landing page data; zero on every other page.
|
||||
Overview overviewView
|
||||
}
|
||||
|
||||
// adminRoute pairs a route pattern with its handler so the route list and the
|
||||
@@ -84,6 +44,18 @@ type adminRoute struct {
|
||||
func (h *Handler) adminRoutes() []adminRoute {
|
||||
return []adminRoute{
|
||||
{"GET /admin", h.admin},
|
||||
{"GET /admin/lanes", h.adminLanes},
|
||||
{"GET /admin/readers", h.adminReaders},
|
||||
{"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},
|
||||
{"POST /readers/{id}/revoke", h.revokeReaderSessions},
|
||||
{"POST /readers/{id}/clear-marks", h.clearReaderMarks},
|
||||
@@ -116,78 +88,32 @@ func (h *Handler) requireOwner(next http.HandlerFunc) http.HandlerFunc {
|
||||
})
|
||||
}
|
||||
|
||||
// admin renders the owner's page: the Reader roster and Poll Lane status.
|
||||
// admin renders the Overview landing page: a verdict line, a stats block
|
||||
// where every figure is a door into the list it counts, and the per-Site
|
||||
// library shape table — all read from the database, never from a poller.
|
||||
func (h *Handler) admin(w http.ResponseWriter, r *http.Request) {
|
||||
readers, err := h.store.Readers()
|
||||
view, err := h.overviewView()
|
||||
if err != nil {
|
||||
log.Printf("admin: %v", err)
|
||||
log.Printf("admin overview: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.render(w, http.StatusOK, "admin", adminView{
|
||||
Readers: readers,
|
||||
OwnerID: h.store.OwnerID(),
|
||||
Lanes: h.lanesView(),
|
||||
})
|
||||
h.renderAdmin(w, adminView{Page: "overview", Overview: view})
|
||||
}
|
||||
|
||||
// uiLanes answers the status block's own refresh. Only the block refreshes on a
|
||||
// timer; the roster re-renders after an action, as it always has.
|
||||
func (h *Handler) uiLanes(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, http.StatusOK, "lanes", h.lanesView())
|
||||
// adminReaders renders the Reader roster on its own bookmarkable page.
|
||||
func (h *Handler) adminReaders(w http.ResponseWriter, r *http.Request) {
|
||||
readers, err := h.store.Readers()
|
||||
if err != nil {
|
||||
log.Printf("admin readers: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.renderAdmin(w, adminView{Page: "readers", Readers: readers, OwnerID: h.store.OwnerID()})
|
||||
}
|
||||
|
||||
// lanesView copies the poller's snapshot into display form. A nil reporter (no
|
||||
// poller running) and a poller no Lane has reported to yet are the same thing
|
||||
// to the page: no data, which it must say rather than draw as confident zeroes
|
||||
// — an empty page a few seconds after a restart must not read as a stopped one.
|
||||
func (h *Handler) lanesView() lanesView {
|
||||
if h.lanes == nil {
|
||||
return lanesView{PollerOff: true}
|
||||
}
|
||||
snap := h.lanes.LaneStatus()
|
||||
v := lanesView{
|
||||
Rows: make([]laneRow, 0, len(snap.Lanes)),
|
||||
BrowserConfigured: snap.BrowserConfigured,
|
||||
BrowserReachable: snap.BrowserReachable,
|
||||
}
|
||||
now := time.Now()
|
||||
for _, l := range snap.Lanes {
|
||||
lost := l.Browser && !snap.BrowserReachable
|
||||
// Series waiting and none read is the shape of a Lane that has stopped
|
||||
// working, as distinct from one that is quiet for want of work — or one
|
||||
// deliberately leaving Chrome asleep until its group gathers.
|
||||
stalled := l.Due > 0 && l.Checked == 0 && !l.Asleep
|
||||
gap := ""
|
||||
if l.Gap > 0 {
|
||||
gap = l.Gap.Truncate(time.Second).String()
|
||||
}
|
||||
v.Rows = append(v.Rows, laneRow{
|
||||
Site: l.Site,
|
||||
Due: l.Due,
|
||||
Ran: since(now, l.LastRun),
|
||||
Checked: l.Checked,
|
||||
Gap: gap,
|
||||
Clamped: l.Clamped,
|
||||
Refusing: l.Refusing,
|
||||
BrowserLost: lost,
|
||||
Stalled: stalled,
|
||||
Asleep: l.Asleep,
|
||||
Attention: l.Clamped || l.Refusing || lost || stalled,
|
||||
})
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// since formats how long ago a Lane last ran, at second resolution: the block
|
||||
// refreshes every thirty seconds, so anything finer is noise the owner would
|
||||
// have to ignore.
|
||||
func since(now, then time.Time) string {
|
||||
d := now.Sub(then).Truncate(time.Second)
|
||||
if d < time.Second {
|
||||
return "just now"
|
||||
}
|
||||
return d.String() + " ago"
|
||||
func (h *Handler) renderAdmin(w http.ResponseWriter, view adminView) {
|
||||
h.render(w, http.StatusOK, "admin", view)
|
||||
}
|
||||
|
||||
// revokeReaderSessions logs one Reader out of every browser they are signed in
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/latest"
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
)
|
||||
|
||||
// lanesView is the Lane status block: one row per Site's latest durable pass,
|
||||
// plus the browser fact derived from that same log. No poller is consulted —
|
||||
// the page answers from the database, so it is complete thirty seconds after
|
||||
// a deploy (issue #145).
|
||||
type lanesView struct {
|
||||
Rows []laneRow
|
||||
// PollerOff means latest-chapter polling is switched off in this
|
||||
// deployment (LATEST_CHAPTER_POLL_ENABLED). It is a config fact, not a
|
||||
// poller answering "absent": the browser line must not blame the sidecar
|
||||
// when nothing polls.
|
||||
PollerOff bool
|
||||
BrowserConfigured bool
|
||||
BrowserReachable bool
|
||||
}
|
||||
|
||||
// laneRow is one Lane formatted for reading rather than for arithmetic: the
|
||||
// template renders strings and flags, and every judgement about what they
|
||||
// mean is made here.
|
||||
type laneRow struct {
|
||||
Site string
|
||||
Due int
|
||||
Checked int
|
||||
// Gap is the last pass's pace, or "—" when no pass has reached one yet —
|
||||
// a refused Lane still reports the pace its last real pass chose, so a
|
||||
// zero here would be a figure the row never measured.
|
||||
Gap string
|
||||
Ran string
|
||||
// Chips are the named outcome counts over the owner's window, in the
|
||||
// taxonomy's fixed order. Empty writes "none observed".
|
||||
Chips []chip
|
||||
HasChips bool
|
||||
// StatePhrase is the reason this Lane declined to work: a skipped pass's
|
||||
// own sentence, or the one true stall. Empty means the pass reached its
|
||||
// loop and read normally. StateGood marks a healthy way to do nothing
|
||||
// (paused, browser asleep, nothing eligible) rather than a fault.
|
||||
StatePhrase string
|
||||
StateGood bool
|
||||
// Attention is the one flag the template colours on, so a Lane that
|
||||
// needs the owner is found at a glance rather than read for.
|
||||
Attention bool
|
||||
// Paused is the live pause state — the poll_lanes stamp the pass row
|
||||
// joins on, still in the future — not the pass's skip: the control must
|
||||
// offer Resume from the moment the owner presses Pause, with no pass
|
||||
// having run to record it (issue #147).
|
||||
Paused bool
|
||||
// FailingHref is the one navigation the row offers: the Site's name
|
||||
// links to that Site's failing Series. The chips beside it stay
|
||||
// unlinked; the withdrawn promise lives on outcomeChips (issue #167).
|
||||
FailingHref string
|
||||
}
|
||||
|
||||
// chip is one named outcome count over the owner's window.
|
||||
type chip struct {
|
||||
Name string
|
||||
Count int
|
||||
}
|
||||
|
||||
// adminLanes renders the page that hosts the live Lane fragment.
|
||||
func (h *Handler) adminLanes(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderAdmin(w, adminView{Page: "lanes", Lanes: h.lanesView()})
|
||||
}
|
||||
|
||||
// uiLanes answers the status block's own refresh. Only the block refreshes on
|
||||
// a timer; the roster re-renders after an action, as it always has.
|
||||
func (h *Handler) uiLanes(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, http.StatusOK, "lanes", h.lanesView())
|
||||
}
|
||||
|
||||
// pauseDurations are the offered pause lengths, by their wire value. A fixed
|
||||
// allow-list rather than time.ParseDuration: the unoffered value must be
|
||||
// refused, and a permissive parser turns the offered set into "anything Go
|
||||
// can read" (issue #147).
|
||||
var pauseDurations = map[string]time.Duration{
|
||||
"1h": time.Hour,
|
||||
"6h": 6 * time.Hour,
|
||||
"24h": 24 * time.Hour,
|
||||
}
|
||||
|
||||
// laneSite reads the Site a lane route names, answering the request itself
|
||||
// when it is not a registry Site. The path value is client-supplied, so it
|
||||
// is checked against the registry before it reaches the store.
|
||||
func laneSite(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
site := r.PathValue("site")
|
||||
if !slices.Contains(latest.SiteNames(), site) {
|
||||
http.Error(w, "unknown site", http.StatusBadRequest)
|
||||
return "", false
|
||||
}
|
||||
return site, true
|
||||
}
|
||||
|
||||
// adminLanePause writes a bounded pause for one Site and answers with the
|
||||
// freshly rendered Lanes block, so the figures describe the state after the
|
||||
// press. The pause is a fact about the Site — the Lane's next pass reads it
|
||||
// from the durable row, never from this process — so it survives a restart.
|
||||
// The owner gate is the route's, not this handler's; the body is capped like
|
||||
// the API path caps its bodies; the Site and the duration are validated
|
||||
// here, before the store sees them (issue #147).
|
||||
func (h *Handler) adminLanePause(w http.ResponseWriter, r *http.Request) {
|
||||
site, ok := laneSite(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
d, ok := pauseDurations[r.PostFormValue("duration")]
|
||||
if !ok {
|
||||
http.Error(w, "unknown pause duration", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.store.PauseLane(site, time.Now().Add(d).UnixMilli()); err != nil {
|
||||
log.Printf("pause lane %s: %v", site, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.render(w, http.StatusOK, "lanes", h.lanesView())
|
||||
}
|
||||
|
||||
// adminLaneResume zeroes one Site's pause and answers with the freshly
|
||||
// rendered Lanes block. Resume is the reversal of a bounded pause, so it
|
||||
// fires instantly with no confirm row (issue #147).
|
||||
func (h *Handler) adminLaneResume(w http.ResponseWriter, r *http.Request) {
|
||||
site, ok := laneSite(w, r)
|
||||
if !ok {
|
||||
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 err := h.store.ResumeLane(site); err != nil {
|
||||
log.Printf("resume lane %s: %v", site, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.render(w, http.StatusOK, "lanes", h.lanesView())
|
||||
}
|
||||
|
||||
// lanesView builds the Lane status block from the durable pass log. Both
|
||||
// reads are the store's latest-per-Site projection, so the page's seam is a
|
||||
// seeded row rather than a fake poller; errors degrade to the empty state and
|
||||
// are logged, never shown to the owner in detail.
|
||||
func (h *Handler) lanesView() lanesView {
|
||||
v := lanesView{
|
||||
PollerOff: !h.pollerEnabled,
|
||||
BrowserConfigured: h.browserConfigured,
|
||||
}
|
||||
passes, err := h.store.LatestLanePasses()
|
||||
if err != nil {
|
||||
log.Printf("admin lanes: latest passes: %v", err)
|
||||
// No evidence of a lost sidecar reads as reachable, per the same rule
|
||||
// browserReachable applies: a store failure must not condemn the
|
||||
// browser. The empty table already says no Lane has recorded a pass.
|
||||
v.BrowserReachable = true
|
||||
return v
|
||||
}
|
||||
now := time.Now()
|
||||
outcomes, err := h.store.LanePassOutcomes(now.Add(-ownerWindow).UnixMilli())
|
||||
if err != nil {
|
||||
// The rows are complete without the chips, so a failed outcome sum
|
||||
// must not blank the table into "no data yet" — that is the confident
|
||||
// wrong statement the page exists to avoid. Every row renders "none
|
||||
// observed" instead, which is honest.
|
||||
log.Printf("admin lanes: outcomes: %v", err)
|
||||
outcomes = nil
|
||||
}
|
||||
bySite := make(map[string]store.SiteOutcomes, len(outcomes))
|
||||
for _, o := range outcomes {
|
||||
bySite[o.Site] = o
|
||||
}
|
||||
v.Rows = make([]laneRow, 0, len(passes))
|
||||
v.BrowserReachable = browserReachable(passes, now)
|
||||
for _, p := range passes {
|
||||
v.Rows = append(v.Rows, buildLaneRow(p, bySite[p.Site], now))
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// browserReachable derives the sidecar's reachability from the pass log: a
|
||||
// browser Site is down when its latest pass inside the refusal backoff is a
|
||||
// sidecar loss, a missing fetcher, or an interrupted read. Only browser Sites
|
||||
// ever produce those signals, so no Site registry leaks into the web layer.
|
||||
// A configured browser with no such evidence reads as reachable; an unset
|
||||
// BROWSER_WS_URL degrades identically to a browser that is down.
|
||||
func browserReachable(passes []store.LanePass, now time.Time) bool {
|
||||
backoff := latest.RefuseBackoff
|
||||
for _, p := range passes {
|
||||
ran := time.UnixMilli(p.RanAt)
|
||||
if now.Sub(ran) >= backoff || ran.After(now) {
|
||||
continue
|
||||
}
|
||||
if p.Skip == latest.SkipSidecarDown || p.Skip == latest.SkipNoFetcher || p.Unreachable > 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// buildLaneRow turns one Site's latest pass and window outcome sums into the
|
||||
// row the template prints. The skip column is the authority on why a pass did
|
||||
// nothing; the outcomes render named and unlinked, because the pass row holds
|
||||
// counts and never identities.
|
||||
func buildLaneRow(p store.LanePass, o store.SiteOutcomes, now time.Time) laneRow {
|
||||
row := laneRow{
|
||||
Site: p.Site,
|
||||
Due: p.Due,
|
||||
Checked: p.Checked,
|
||||
Gap: "—",
|
||||
Ran: since(now, time.UnixMilli(p.RanAt)),
|
||||
}
|
||||
if p.GapMS > 0 {
|
||||
row.Gap = (time.Duration(p.GapMS) * time.Millisecond).Truncate(time.Second).String()
|
||||
}
|
||||
row.FailingHref = seriesListHref(store.SeriesFilterFailing, p.Site, "", 0)
|
||||
row.Chips = outcomeChips(o)
|
||||
row.HasChips = len(row.Chips) > 0
|
||||
row.StatePhrase, row.StateGood, row.Attention = laneState(p, now)
|
||||
row.Paused = time.UnixMilli(p.PausedUntil).After(now)
|
||||
return row
|
||||
}
|
||||
|
||||
// outcomeChips lists a Site's nonzero window sums in the taxonomy's fixed
|
||||
// order, so the chips never reorder as the window changes. None observed is
|
||||
// written by the template, not drawn as a confident zero count. The names are
|
||||
// spelled through outcomeWord, the one vocabulary the failure surface shares
|
||||
// with the Series list's fact line (issue #167). The counts stay unlinked
|
||||
// permanently — a withdrawn promise: two of the six words write no per-Series
|
||||
// state, and the other four count attempts inside the owner window while the
|
||||
// failing filter lists Series failing now, so neither set contains the other
|
||||
// and no chip can be a door to its list.
|
||||
func outcomeChips(o store.SiteOutcomes) []chip {
|
||||
fixed := []struct {
|
||||
name string
|
||||
count int
|
||||
}{
|
||||
{outcomeWord("refused"), o.Refused},
|
||||
{outcomeWord("unreachable"), o.Unreachable},
|
||||
{outcomeWord("no_chapter"), o.NoChapter},
|
||||
{outcomeWord("unfetchable"), o.Unfetchable},
|
||||
{outcomeWord("not_found"), o.NotFound},
|
||||
{outcomeWord("errors"), o.Errors},
|
||||
}
|
||||
var out []chip
|
||||
for _, f := range fixed {
|
||||
if f.count > 0 {
|
||||
out = append(out, chip{Name: f.name, Count: f.count})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// laneState renders the reason a Lane's last pass did nothing, in one sentence
|
||||
// per skip value with the one true stall kept apart from every Lane that
|
||||
// declined and said why. Good states — a pause, a sleeping browser, nothing
|
||||
// eligible — carry no Attention: the mark must stay spendable on the faults
|
||||
// that actually need the owner.
|
||||
func laneState(p store.LanePass, now time.Time) (phrase string, good, attention bool) {
|
||||
// The pause phrase reads the live poll_lanes stamp the pass row joins
|
||||
// on, not the pass's skip: the owner's press must render as paused on
|
||||
// the very answer it gets, with no pass having run to record it. The
|
||||
// pause is a fact about the Site, and the join delivers it (issue #147).
|
||||
if pausedUntil := time.UnixMilli(p.PausedUntil); pausedUntil.After(now) {
|
||||
phrase = "paused · resumes in " + humanDuration(pausedUntil.Sub(now))
|
||||
good = true
|
||||
return phrase, good, attention
|
||||
}
|
||||
switch p.Skip {
|
||||
case latest.SkipPaused:
|
||||
// A paused pass whose stamp has since lapsed: the Lane still
|
||||
// declined with a reason, so it is never the one true stall.
|
||||
phrase = "paused · resumes in " + humanDuration(time.UnixMilli(p.PausedUntil).Sub(now))
|
||||
good = true
|
||||
case latest.SkipRefusing:
|
||||
phrase = "refusing"
|
||||
if until := time.UnixMilli(p.RefuseUntil); until.After(now) {
|
||||
phrase += " · backs off until " + until.Format("15:04")
|
||||
}
|
||||
attention = true
|
||||
case latest.SkipSidecarDown, latest.SkipNoFetcher:
|
||||
// Known false positive shipped per spec: a sibling Lane's Chrome loss
|
||||
// stamps this Site too, and the enum deliberately has no tenth value
|
||||
// to separate it (issue #141). Render it as written.
|
||||
phrase = "no browser"
|
||||
attention = true
|
||||
case latest.SkipAsleep:
|
||||
phrase = "browser asleep"
|
||||
good = true
|
||||
case latest.SkipDueQuery:
|
||||
phrase = "due query failed"
|
||||
attention = true
|
||||
case latest.SkipEligibleCount:
|
||||
phrase = "eligible count failed"
|
||||
attention = true
|
||||
case latest.SkipNothingEligible:
|
||||
phrase = "nothing eligible"
|
||||
good = true
|
||||
}
|
||||
if phrase == "" && p.Due > 0 && p.Checked == 0 {
|
||||
// The one true stall: the pass reached its loop, Series were waiting,
|
||||
// and none were read. Every skip above is a Lane that said why.
|
||||
phrase = "not checking"
|
||||
attention = true
|
||||
}
|
||||
return phrase, good, attention
|
||||
}
|
||||
|
||||
// humanDuration renders a positive duration compactly for a "resumes in" clue
|
||||
// at the pause and refusal scales — minutes under an hour, then h and h+m.
|
||||
func humanDuration(d time.Duration) string {
|
||||
d = d.Round(time.Minute)
|
||||
if d <= 0 {
|
||||
return "soon"
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%dm", int(d/time.Minute))
|
||||
}
|
||||
h := int(d / time.Hour)
|
||||
if m := int(d%time.Hour) / int(time.Minute); m == 0 {
|
||||
return fmt.Sprintf("%dh", h)
|
||||
} else {
|
||||
return fmt.Sprintf("%dh%dm", h, m)
|
||||
}
|
||||
}
|
||||
|
||||
// since formats how long ago a Lane last ran, at second resolution: the block
|
||||
// refreshes every thirty seconds, so anything finer is noise the owner would
|
||||
// have to ignore.
|
||||
func since(now, then time.Time) string {
|
||||
d := now.Sub(then).Truncate(time.Second)
|
||||
if d < time.Second {
|
||||
return "just now"
|
||||
}
|
||||
return d.String() + " ago"
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/latest"
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
)
|
||||
|
||||
// overviewView is the Overview landing page's data: one verdict line, the
|
||||
// hygiene and library stats blocks, and the per-Site library shape table.
|
||||
// Every judgement — the verdict state, which figures link, what a Lane's
|
||||
// state means — is made here; the template only prints.
|
||||
type overviewView struct {
|
||||
// Verdict is the attention phrase that leads the page.
|
||||
Verdict string
|
||||
// HasCounts is false on a virgin pass log: the waiting figure would be a
|
||||
// confident zero, and "nothing has happened" must not render as health.
|
||||
HasCounts bool
|
||||
// Waiting is the sum of Due over the latest pass per Site.
|
||||
Waiting int
|
||||
// Unchecked is the number of Series not checked in the window, computed
|
||||
// as stale + never_checked: a never-checked Series is already counted on
|
||||
// its own filter, and the verdict wants the inclusive number.
|
||||
Unchecked int
|
||||
// Hygiene is the problem filters in the Series list's own render order
|
||||
// plus the informational tail — finished, then site completed — riding
|
||||
// last; 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
|
||||
// Series, in the store's Site order.
|
||||
Sites []siteRow
|
||||
}
|
||||
|
||||
// fig is one stats figure: its label, the list it counts, and the count
|
||||
// itself. Href empty means the count is zero: a measured zero is a real
|
||||
// figure that stays on the page, but it is not a door, because following it
|
||||
// lands on an empty list.
|
||||
type fig struct {
|
||||
Label string
|
||||
Href string
|
||||
Count int
|
||||
}
|
||||
|
||||
// siteRow is one Site's share of the library: the Series total and the three
|
||||
// hygiene counts the per-Site table carries, each a door to the list narrowed
|
||||
// to that Site, plus the Lane state phrase derived from its latest pass. The
|
||||
// table is library shape only — the Poll outcome sums live on the Lanes page.
|
||||
type siteRow struct {
|
||||
Site string
|
||||
SiteHref string
|
||||
Figs []fig
|
||||
// State is the Lane's own sentence; "" means the last pass read normally.
|
||||
// StateGood / StateBad pick the ok / bad second class.
|
||||
State string
|
||||
StateGood bool
|
||||
StateBad bool
|
||||
}
|
||||
|
||||
// overviewView assembles the landing page from the store's read model: one
|
||||
// SeriesShapes pass per filter summed in Go (the shipped surface offers ten
|
||||
// grouped passes, not a stats query — #140), the pass log's latest pass per
|
||||
// Site, and the roster. A failure in the SeriesShapes, pass or roster reads
|
||||
// is a 500 with a logged reason, never a page of silent zeroes. The three
|
||||
// fault-input reads (RefusingSince, SidecarOK, NoChapterShare) fail open:
|
||||
// a failing read logs and contributes no fault, so the landing page still
|
||||
// renders — the same fail-open the poller uses for owner notices.
|
||||
func (h *Handler) overviewView() (overviewView, error) {
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-ownerWindow).UnixMilli()
|
||||
|
||||
shapes := make(map[string][]store.SiteSeriesShape, len(seriesFilterOrder))
|
||||
totals := make(map[string]int, len(seriesFilterOrder))
|
||||
for _, name := range seriesFilterOrder {
|
||||
rows, err := h.store.SeriesShapes(store.SeriesFilter{Name: name, Cutoff: cutoff})
|
||||
if err != nil {
|
||||
return overviewView{}, err
|
||||
}
|
||||
shapes[name] = rows
|
||||
for _, sh := range rows {
|
||||
totals[name] += sh.Total
|
||||
}
|
||||
}
|
||||
|
||||
passes, err := h.store.LatestLanePasses()
|
||||
if err != nil {
|
||||
return overviewView{}, err
|
||||
}
|
||||
readers, err := h.store.Readers()
|
||||
if err != nil {
|
||||
return overviewView{}, err
|
||||
}
|
||||
|
||||
view := overviewView{Waiting: waiting(passes)}
|
||||
view.Unchecked = totals[store.SeriesFilterStale] + totals[store.SeriesFilterNeverChecked]
|
||||
var refusingSince map[string]int64
|
||||
if m, err := h.store.RefusingSince(now.UnixMilli()); err != nil {
|
||||
log.Printf("admin overview: refusing since: %v", err)
|
||||
} else {
|
||||
refusingSince = m
|
||||
}
|
||||
var sidecarOK map[string]int64
|
||||
if m, err := h.store.SidecarOK(latest.BrowserBackedSites()); err != nil {
|
||||
log.Printf("admin overview: sidecar ok: %v", err)
|
||||
} else {
|
||||
sidecarOK = m
|
||||
}
|
||||
var noChapterShare map[string]float64
|
||||
if m, err := h.store.NoChapterShare(cutoff); err != nil {
|
||||
log.Printf("admin overview: no-chapter share: %v", err)
|
||||
} else {
|
||||
noChapterShare = m
|
||||
}
|
||||
faults := latest.FaultsFrom(latest.FaultInput{
|
||||
Passes: passes,
|
||||
RefusingSince: refusingSince,
|
||||
SidecarOK: sidecarOK,
|
||||
NoChapterShare: noChapterShare,
|
||||
}, now)
|
||||
view.Verdict, view.HasCounts = overviewVerdict(passes, faults)
|
||||
|
||||
// The hygiene figures, in seriesFilterOrder's tail: the problem filters
|
||||
// in permanent-then-fixable order, then the informational tail — finished
|
||||
// and site completed — riding last because seriesFilterOrder appends them
|
||||
// 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:] {
|
||||
hygiene = append(hygiene, door(seriesFilterLabels[name], totals[name], seriesListHref(name, "", "", 0)))
|
||||
}
|
||||
view.Hygiene = hygiene
|
||||
|
||||
var manga, novel int
|
||||
for _, sh := range shapes[store.SeriesFilterAll] {
|
||||
manga += sh.Manga
|
||||
novel += sh.Novel
|
||||
}
|
||||
view.Library = []fig{
|
||||
door("Series", totals[store.SeriesFilterAll], seriesListHref("", "", "", 0)),
|
||||
door("Manga", manga, seriesListHref("", "", store.KindManga, 0)),
|
||||
door("Novels", novel, seriesListHref("", "", store.KindNovel, 0)),
|
||||
door("Readers", len(readers), "/admin/readers"),
|
||||
}
|
||||
|
||||
// One row per Site with any Series, from the All shapes; the hygiene
|
||||
// counts come from the same per-Site projection so the table cannot
|
||||
// disagree with the library-wide figures above it.
|
||||
siteCounts := make(map[string]map[string]int, len(shapes))
|
||||
for name, rows := range shapes {
|
||||
m := make(map[string]int, len(rows))
|
||||
for _, sh := range rows {
|
||||
m[sh.Site] = sh.Total
|
||||
}
|
||||
siteCounts[name] = m
|
||||
}
|
||||
passBySite := make(map[string]store.LanePass, len(passes))
|
||||
for _, p := range passes {
|
||||
passBySite[p.Site] = p
|
||||
}
|
||||
view.Sites = make([]siteRow, 0, len(shapes[store.SeriesFilterAll]))
|
||||
for _, sh := range shapes[store.SeriesFilterAll] {
|
||||
row := siteRow{
|
||||
Site: sh.Site,
|
||||
SiteHref: seriesListHref("", sh.Site, "", 0),
|
||||
// The labels are the thead's words, carried per cell because the
|
||||
// phone layout drops the thead: four bare counts in a row are
|
||||
// unreadable without them (they render as the cell's prefix).
|
||||
Figs: []fig{
|
||||
door("series", sh.Total, seriesListHref("", sh.Site, "", 0)),
|
||||
door("no cover", siteCounts[store.SeriesFilterNoCover][sh.Site], seriesListHref(store.SeriesFilterNoCover, sh.Site, "", 0)),
|
||||
door("never chk", siteCounts[store.SeriesFilterNeverChecked][sh.Site], seriesListHref(store.SeriesFilterNeverChecked, sh.Site, "", 0)),
|
||||
door("stale", siteCounts[store.SeriesFilterStale][sh.Site], seriesListHref(store.SeriesFilterStale, sh.Site, "", 0)),
|
||||
},
|
||||
}
|
||||
if p, ok := passBySite[sh.Site]; ok {
|
||||
row.State, row.StateGood, row.StateBad = laneState(p, now)
|
||||
} else {
|
||||
row.State = "no pass yet"
|
||||
}
|
||||
view.Sites = append(view.Sites, row)
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// door is one figure with its door: the list that counts it. A measured zero
|
||||
// is still a real figure, but the door closes — following it would land on an
|
||||
// empty list. The count is written once so the figure and what it links to
|
||||
// cannot drift apart.
|
||||
func door(label string, count int, href string) fig {
|
||||
if count == 0 {
|
||||
href = ""
|
||||
}
|
||||
return fig{Label: label, Href: href, Count: count}
|
||||
}
|
||||
|
||||
// overviewVerdict decides the landing page's one line: no passes at all is
|
||||
// "no Lane has reported yet" — never confident zeroes; otherwise the count of
|
||||
// faults from the shared FaultsFrom judgement, so the page and the push
|
||||
// cannot disagree. Zero faults is "all lanes healthy". A sidecar-down fault
|
||||
// carries Site == "" and is still one fault. The Lanes page's per-row
|
||||
// laneState is a different question (is this Lane's last pass healthy) from
|
||||
// the notice class, and its known false positives live there deliberately, so
|
||||
// the two now differ.
|
||||
func overviewVerdict(passes []store.LanePass, faults []latest.Fault) (phrase string, counts bool) {
|
||||
if len(passes) == 0 {
|
||||
return "no Lane has reported yet", false
|
||||
}
|
||||
n := len(faults)
|
||||
if n == 0 {
|
||||
return "all lanes healthy", true
|
||||
}
|
||||
if n == 1 {
|
||||
return "1 lane needs a look", true
|
||||
}
|
||||
return fmt.Sprintf("%d lanes need a look", n), true
|
||||
}
|
||||
|
||||
// waiting sums Due over the latest pass per Site: how many Series the Lanes
|
||||
// found waiting, from the durable log rather than a running poller.
|
||||
func waiting(passes []store.LanePass) int {
|
||||
n := 0
|
||||
for _, p := range passes {
|
||||
n += p.Due
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The three administrative tables are grids on a mouse and label-value pairs
|
||||
// on a phone, where the media query drops the .thead and prints each cell's
|
||||
// data-label as its prefix instead. Without the attribute the collapsed row
|
||||
// renders as bare figures — "1200.25 3d 4", "12 40 6h" — which no reader can
|
||||
// decode, and nothing in Go or in a desktop render says so. Every cell whose
|
||||
// only column heading was the .thead is asserted here.
|
||||
func TestAdminTablesLabelCollapsedCells(t *testing.T) {
|
||||
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFS: %v", err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
data any
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
"series-row",
|
||||
seriesRowView{Key: "asura:x", Title: "Chronicles", Site: "asura", Ch: "1200.25", Age: "3d", Readers: 4},
|
||||
[]string{`data-label="ch"`, `data-label="checked"`, `data-label="readers"`},
|
||||
},
|
||||
{
|
||||
"lanes",
|
||||
lanesView{Rows: []laneRow{{Site: "asura", Due: 12, Checked: 40, Gap: "6h", Ran: "4m ago"}}},
|
||||
[]string{`data-label="due"`, `data-label="checked"`, `data-label="gap"`},
|
||||
},
|
||||
{
|
||||
// The Site table's figures are a range over one slice, so their
|
||||
// labels are the ones overviewView puts on the figs.
|
||||
"overview",
|
||||
overviewView{Sites: []siteRow{{Site: "asura", Figs: []fig{
|
||||
{Label: "series", Href: "/admin/series", Count: 143},
|
||||
{Label: "stale", Count: 0},
|
||||
}}}},
|
||||
[]string{`data-label="series"`, `data-label="stale"`},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var out strings.Builder
|
||||
if err := tmpl.ExecuteTemplate(&out, tc.name, tc.data); err != nil {
|
||||
t.Fatalf("ExecuteTemplate: %v", err)
|
||||
}
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Errorf("missing %s: the collapsed phone row renders this cell as a bare figure", want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/latest"
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
)
|
||||
|
||||
// seriesPageSize matches the store's row-read page length: the pager's range
|
||||
// must agree with the LIMIT the store applies or the "of N" figure describes
|
||||
// the wrong page. The store does not export it (#140).
|
||||
const seriesPageSize = 50
|
||||
|
||||
// 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",
|
||||
store.SeriesFilterNoChapter: "Never read a chapter",
|
||||
store.SeriesFilterNoReaders: "No Readers",
|
||||
store.SeriesFilterNeverChecked: "Never checked",
|
||||
store.SeriesFilterStale: "Not checked in 12h",
|
||||
store.SeriesFilterNoCover: "No cover",
|
||||
store.SeriesFilterReaderReport: "Latest from a Reader",
|
||||
store.SeriesFilterFailing: "Failing over 12h",
|
||||
store.SeriesFilterUnverified: "Unverified Reader number",
|
||||
store.SeriesFilterFinished: "Finished",
|
||||
store.SeriesFilterSiteCompleted: "Site says completed",
|
||||
}
|
||||
|
||||
// seriesFilterOrder is the select's render order: All first, then the
|
||||
// permanent repairs, then the fixable ones (issue #140). Finished and the
|
||||
// site-completed hint ride the tail — deliberate, not repairs — and the
|
||||
// Overview's stats block renders the same tail, which is what sits the
|
||||
// finished and site-completed figures last there.
|
||||
var seriesFilterOrder = []string{
|
||||
store.SeriesFilterAll,
|
||||
store.SeriesFilterNoURL,
|
||||
store.SeriesFilterNoChapter,
|
||||
store.SeriesFilterNoReaders,
|
||||
store.SeriesFilterNeverChecked,
|
||||
store.SeriesFilterStale,
|
||||
store.SeriesFilterNoCover,
|
||||
store.SeriesFilterReaderReport,
|
||||
store.SeriesFilterFailing,
|
||||
store.SeriesFilterUnverified,
|
||||
store.SeriesFilterFinished,
|
||||
store.SeriesFilterSiteCompleted,
|
||||
}
|
||||
|
||||
// seriesListView is the Series list page's data. The template renders strings
|
||||
type seriesListView struct {
|
||||
Filters []seriesFilterOption
|
||||
Sites []string
|
||||
Site string // "" = every Site
|
||||
Kind string // "" = both libraries
|
||||
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.
|
||||
KindBoth string
|
||||
KindManga string
|
||||
KindNovel string
|
||||
PrevHref string
|
||||
NextHref string
|
||||
Range string
|
||||
}
|
||||
|
||||
// seriesFilterOption is one entry of the Show select: its wire value, its
|
||||
// rendered label with the library-wide count, and whether it is the active
|
||||
// filter.
|
||||
type seriesFilterOption struct {
|
||||
Name string
|
||||
Label string
|
||||
Count int
|
||||
Selected bool
|
||||
}
|
||||
|
||||
// seriesRowView is one Series row formatted for the template. Band carries
|
||||
// the alternating row tint by class rather than nth-of-type, so the confirm
|
||||
// rows later tickets add are row siblings without breaking the alternation.
|
||||
// Attention tints the title patina: a row with any hygiene chip needs one.
|
||||
//
|
||||
// CanPoll is the Check now control's visibility: absent on a Series with no
|
||||
// page to fetch and on an orphan, so the owner is never offered a button that
|
||||
// can never do anything. Pending is derived — the request stamp is newer than
|
||||
// the check stamp — and Requested is its ageing label.
|
||||
type seriesRowView struct {
|
||||
Key string
|
||||
Title string
|
||||
Site string
|
||||
Ch string // chapter number; "—" until first captured
|
||||
Age string // checked age; "never" until first check
|
||||
Readers int
|
||||
Notes []string // chips, capped at two
|
||||
More int // chips past the cap, rendered as a +N tail
|
||||
Band bool
|
||||
Attention bool
|
||||
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
|
||||
// Failure names the standing failure and how long it has stood — "not
|
||||
// found · 3d ago", "" while no failure row stands. Its own field, never a
|
||||
// Notes chip: the chips cap at two plus a tail, so the one fact that
|
||||
// names the failure would be the most likely to be truncated away.
|
||||
Failure string
|
||||
}
|
||||
|
||||
// adminSeries renders the filterable, bookmarkable Series list: filter, Site,
|
||||
// Library and page all live in the query string, so the list's state is an
|
||||
// address rather than a click path.
|
||||
func (h *Handler) adminSeries(w http.ResponseWriter, r *http.Request) {
|
||||
view, err := h.seriesListView(r)
|
||||
if err != nil {
|
||||
log.Printf("admin series: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.renderAdmin(w, adminView{Page: "series", SeriesList: view})
|
||||
}
|
||||
|
||||
// adminSeriesPoll is the Check now action: it stamps the Series' force_poll_at
|
||||
// and answers with the freshly rendered row, so the figures describe the
|
||||
// state after the press. The control never commands the poller — the request
|
||||
// is a fact about the Series, and the Lane's next pass reads it through
|
||||
// DueForLatestCheck (ADR-0013). 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) adminSeriesPoll(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 poll %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.ForceSeriesPoll(site, seriesID, time.Now().UnixMilli()); err != nil {
|
||||
log.Printf("series poll %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. The detail page's control swaps its meta in place and the list
|
||||
// row's swaps the row; htmx names an id target in HX-Target, so the
|
||||
// response matches the surface it came from. The row's band parity travels
|
||||
// with the press (hx-vals), so the swap keeps the zebra alternation.
|
||||
a, found, err := h.adminSeriesByKey(site, seriesID)
|
||||
if err != nil {
|
||||
log.Printf("series poll %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("HX-Target") == "detail-meta" {
|
||||
h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a))
|
||||
return
|
||||
}
|
||||
band := 0
|
||||
if r.PostFormValue("band") == "1" {
|
||||
band = 1
|
||||
}
|
||||
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 || num > maxChapterNum {
|
||||
http.Error(w, "chapter must be a finite number between 0 and 10000", 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()))
|
||||
// The heading is an out-of-band append to a response whose status line has
|
||||
// already gone out with the row, so it is executed straight onto w —
|
||||
// h.render would send a second WriteHeader.
|
||||
if head, err := h.seriesListHeadView(r); err != nil {
|
||||
log.Printf("series remove %s: %v", site+":"+seriesID, err)
|
||||
} else if err := h.tmpl.ExecuteTemplate(w, "series-list-head", head); err != nil {
|
||||
log.Printf("series remove %s: render series-list-head oob: %v", site+":"+seriesID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (h *Handler) seriesListView(r *http.Request) (seriesListView, error) {
|
||||
q := r.URL.Query()
|
||||
filter := q.Get("filter")
|
||||
if _, ok := seriesFilterLabels[filter]; !ok {
|
||||
filter = store.SeriesFilterAll
|
||||
}
|
||||
site := q.Get("site")
|
||||
kind := q.Get("kind")
|
||||
if kind != store.KindManga && kind != store.KindNovel {
|
||||
kind = ""
|
||||
}
|
||||
page := 1
|
||||
if p, err := strconv.Atoi(q.Get("page")); err == nil && p > 1 {
|
||||
page = p
|
||||
}
|
||||
|
||||
sf := store.SeriesFilter{
|
||||
Site: site,
|
||||
Kind: kind,
|
||||
Name: filter,
|
||||
Cutoff: time.Now().Add(-ownerWindow).UnixMilli(),
|
||||
Page: page,
|
||||
}
|
||||
data, err := h.store.SeriesPage(sf)
|
||||
if err != nil {
|
||||
return seriesListView{}, err
|
||||
}
|
||||
// A page past the end is not an empty list: the store's window count runs
|
||||
// over the rows the result set carries, so an overflow page reports zero
|
||||
// rows and zero total, and the list re-reads at page 1 to know the truth.
|
||||
if len(data.Rows) == 0 && page > 1 {
|
||||
page = 1
|
||||
sf.Page = 1
|
||||
data, err = h.store.SeriesPage(sf)
|
||||
if err != nil {
|
||||
return seriesListView{}, err
|
||||
}
|
||||
}
|
||||
|
||||
view := seriesListView{
|
||||
Site: site,
|
||||
Kind: kind,
|
||||
FilterLabel: seriesFilterLabels[filter],
|
||||
Rows: make([]seriesRowView, 0, len(data.Rows)),
|
||||
Total: data.Total,
|
||||
Sites: latest.SiteNames(),
|
||||
}
|
||||
now := time.Now()
|
||||
for i, a := range data.Rows {
|
||||
view.Rows = append(view.Rows, seriesRow(a, i, now))
|
||||
}
|
||||
view.Filters, err = h.seriesFilterOptions(filter, sf.Cutoff)
|
||||
if err != nil {
|
||||
return seriesListView{}, err
|
||||
}
|
||||
view.KindBoth = seriesListHref(filter, site, "", 0)
|
||||
view.KindManga = seriesListHref(filter, site, store.KindManga, 0)
|
||||
view.KindNovel = seriesListHref(filter, site, store.KindNovel, 0)
|
||||
if page > 1 {
|
||||
view.PrevHref = seriesListHref(filter, site, kind, page-1)
|
||||
}
|
||||
if last := (data.Total + seriesPageSize - 1) / seriesPageSize; page < last {
|
||||
view.NextHref = seriesListHref(filter, site, kind, page+1)
|
||||
}
|
||||
view.Range = pagerRange(data.Total, len(data.Rows), page)
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (h *Handler) seriesFilterOptions(selected string, cutoff int64) ([]seriesFilterOption, error) {
|
||||
out := make([]seriesFilterOption, 0, len(seriesFilterOrder))
|
||||
for _, name := range seriesFilterOrder {
|
||||
shapes, err := h.store.SeriesShapes(store.SeriesFilter{Name: name, Cutoff: cutoff})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count := 0
|
||||
for _, sh := range shapes {
|
||||
count += sh.Total
|
||||
}
|
||||
out = append(out, seriesFilterOption{
|
||||
Name: name,
|
||||
Label: seriesFilterLabels[name],
|
||||
Count: count,
|
||||
Selected: name == selected,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// seriesRow shapes one store row for the template, capping its chips at two
|
||||
// plus a +N tail; attention marks a row that carries any.
|
||||
// pollState derives the Check now control and the pending marker (issue
|
||||
// #146), shared by the list row and the detail page: CanPoll is false on a
|
||||
// Series with no page to fetch and on an orphan, so the owner is never
|
||||
// offered a button that can never do anything. Pending is derived — the
|
||||
// request stamp is newer than the check stamp — and requested is its ageing
|
||||
// label, which never expires.
|
||||
func pollState(a store.AdminSeries, now time.Time) (canPoll, pending bool, requested string) {
|
||||
canPoll = a.SeriesURL != "" && a.ReaderCount > 0
|
||||
if a.ForcePollAt > a.LatestCheckedAt {
|
||||
pending = true
|
||||
requested = requestedAge(now, a.ForcePollAt)
|
||||
}
|
||||
return canPoll, pending, requested
|
||||
}
|
||||
|
||||
func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView {
|
||||
canPoll, pending, requested := pollState(a, now)
|
||||
row := seriesRowView{
|
||||
Key: a.Key(),
|
||||
Site: a.Site,
|
||||
Title: a.Title,
|
||||
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)
|
||||
} else {
|
||||
row.Ch = "—"
|
||||
}
|
||||
row.Age = checkedAge(now, a.LatestCheckedAt)
|
||||
if a.FailureOutcome != "" {
|
||||
row.Failure = outcomeWord(a.FailureOutcome) + " · " + checkedAge(now, a.FailingSince)
|
||||
}
|
||||
notes := seriesNotes(a, now)
|
||||
if n := len(notes); n > 2 {
|
||||
row.Notes, row.More = notes[:2], n-2
|
||||
} else {
|
||||
row.Notes = notes
|
||||
}
|
||||
row.Attention = len(notes) > 0
|
||||
return row
|
||||
}
|
||||
|
||||
// outcomeWord spells the wire failure word as the Lanes chips spell it — a
|
||||
// space, not the underscore: not_found reads "not found", no_chapter "no
|
||||
// chapter". The remaining words are their own spelling, so an unknown word
|
||||
// degrades to itself rather than vanishing from the page.
|
||||
func outcomeWord(wire string) string {
|
||||
switch wire {
|
||||
case "not_found":
|
||||
return "not found"
|
||||
case "no_chapter":
|
||||
return "no chapter"
|
||||
}
|
||||
return wire
|
||||
}
|
||||
|
||||
// seriesNotes are a row's hygiene chips in the design's order: no URL, no
|
||||
// cover, orphan, stale, reader sighting.
|
||||
func seriesNotes(a store.AdminSeries, now time.Time) []string {
|
||||
notes := []string{}
|
||||
if a.SeriesURL == "" {
|
||||
notes = append(notes, "no URL")
|
||||
}
|
||||
if a.CoverAddress == "" {
|
||||
notes = append(notes, "no cover")
|
||||
}
|
||||
if a.ReaderCount == 0 {
|
||||
notes = append(notes, "orphan")
|
||||
}
|
||||
if a.LatestCheckedAt > 0 && a.LatestCheckedAt < now.Add(-ownerWindow).UnixMilli() {
|
||||
notes = append(notes, "stale")
|
||||
}
|
||||
if a.RaisedByReader {
|
||||
notes = append(notes, "reader sighting")
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
// checkedAge formats how long ago a Series was last checked, at the
|
||||
// granularity the list reads at — minutes, hours, days. Zero means never.
|
||||
func checkedAge(now time.Time, ts int64) string {
|
||||
if ts == 0 {
|
||||
return "never"
|
||||
}
|
||||
d := now.Sub(time.UnixMilli(ts))
|
||||
switch {
|
||||
case d < time.Hour:
|
||||
m := int(d / time.Minute)
|
||||
if m < 1 {
|
||||
m = 1
|
||||
}
|
||||
return fmt.Sprintf("%dm ago", m)
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh ago", int(d/time.Hour))
|
||||
default:
|
||||
return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
|
||||
}
|
||||
}
|
||||
|
||||
// requestedAge is the pending marker's text: how long ago the owner asked,
|
||||
// and nothing about when the request will run — the page does not know when a
|
||||
// sleeping browser will wake (issue #146). An unanswered request ages forever;
|
||||
// there is no expiry.
|
||||
func requestedAge(now time.Time, ts int64) string {
|
||||
return "requested " + checkedAge(now, ts)
|
||||
}
|
||||
|
||||
// pagerRange is the pager's "1–50 of 120" line. The template renders the
|
||||
// pager only over rows (the empty state replaces it), so it is never asked
|
||||
// to describe an empty list.
|
||||
func pagerRange(total, rows, page int) string {
|
||||
from := (page-1)*seriesPageSize + 1
|
||||
return fmt.Sprintf("%d–%d of %d", from, from+rows-1, total)
|
||||
}
|
||||
|
||||
// seriesListHref is one Series list address carrying the filter, Site, Kind
|
||||
// and page. The All filter and page 1 are the absent cases and stay out of
|
||||
// the URL, so the default address is the shortest one.
|
||||
func seriesListHref(filter, site, kind string, page int) string {
|
||||
q := url.Values{}
|
||||
if filter != "" && filter != store.SeriesFilterAll {
|
||||
q.Set("filter", filter)
|
||||
}
|
||||
if site != "" {
|
||||
q.Set("site", site)
|
||||
}
|
||||
if kind != "" {
|
||||
q.Set("kind", kind)
|
||||
}
|
||||
if page > 1 {
|
||||
q.Set("page", strconv.Itoa(page))
|
||||
}
|
||||
if len(q) == 0 {
|
||||
return "/admin/series"
|
||||
}
|
||||
return "/admin/series?" + q.Encode()
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
)
|
||||
|
||||
// seriesDetailView is one Series' page as the owner sees it: strings and
|
||||
// flags, every judgement made here, the template left to print. ReaderCount
|
||||
// is the only figure that crosses the privacy boundary — the owner learns how
|
||||
// many Readers hold the Series, never which Reader reads what.
|
||||
type seriesDetailView struct {
|
||||
Key string // "<site>:<series_id>", the page's address and the Series' identity
|
||||
Site string
|
||||
Kind string
|
||||
// Title, Cover and Chapter come from the shared Series row; the Cover is
|
||||
// the wire URL of the stored bytes, "" before any exist.
|
||||
Title string
|
||||
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 —
|
||||
// the request stamp is newer than the check stamp — and Requested is its
|
||||
// ageing label.
|
||||
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
|
||||
// Unverified is the sentence beside the Latest Chapter correction
|
||||
// control while a Reader's number stands behind a failure past the
|
||||
// owner window: the value is unconfirmed and the owner should not trust
|
||||
// it. It never names the Reader. "" otherwise.
|
||||
Unverified string
|
||||
// SiteCompleted is the hint's line, "" while the Site has said nothing or
|
||||
// the owner has finished the Series: "the site says this work is
|
||||
// completed (since 3d ago)". A hint, never a control (issue #170).
|
||||
SiteCompleted string
|
||||
}
|
||||
|
||||
// adminSeriesDetail renders one Series' page, keyed by the composite
|
||||
// "<site>:<series_id>" the list row already shows. The row is read through
|
||||
// the list's own SeriesPage read narrowed to the key's Site: the admin
|
||||
// projection is the privacy boundary, and a dedicated single-row read would
|
||||
// be a second definition of it.
|
||||
func (h *Handler) adminSeriesDetail(w http.ResponseWriter, r *http.Request) {
|
||||
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
|
||||
if !ok || site == "" || seriesID == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
a, found, err := h.adminSeriesByKey(site, seriesID)
|
||||
if err != nil {
|
||||
log.Printf("series detail %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.renderAdmin(w, adminView{Page: "series-detail", Detail: h.seriesDetailView(a)})
|
||||
}
|
||||
|
||||
// adminSeriesByKey reads one Series through the list's own SeriesPage read
|
||||
// narrowed to the key's Site: the admin projection is the privacy boundary,
|
||||
// and a dedicated single-row read would be a second definition of it. Absence
|
||||
// is reported with found=false, never an error.
|
||||
// ponytail: a page scan per keyed read, one query per page of the Site's rows
|
||||
// up to the window total; a keyed read alongside SeriesPage when the library
|
||||
// outgrows the page size.
|
||||
func (h *Handler) adminSeriesByKey(site, seriesID string) (store.AdminSeries, bool, error) {
|
||||
seen := 0
|
||||
for page := 1; ; page++ {
|
||||
p, err := h.store.SeriesPage(store.SeriesFilter{Site: site, Page: page})
|
||||
if err != nil {
|
||||
return store.AdminSeries{}, false, err
|
||||
}
|
||||
seen += len(p.Rows)
|
||||
for i := range p.Rows {
|
||||
if p.Rows[i].SeriesID == seriesID {
|
||||
return p.Rows[i], true, nil
|
||||
}
|
||||
}
|
||||
if seen >= p.Total {
|
||||
break
|
||||
}
|
||||
}
|
||||
return store.AdminSeries{}, false, nil
|
||||
}
|
||||
|
||||
// seriesDetailView shapes one AdminSeries row for display: every judgement in
|
||||
// Go, the template left to print strings and flags.
|
||||
func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
|
||||
canPoll, pending, requested := pollState(a, time.Now())
|
||||
v := seriesDetailView{
|
||||
Key: a.Key(),
|
||||
Site: a.Site,
|
||||
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,
|
||||
}
|
||||
if a.LatestChapterNum == nil {
|
||||
v.Chapter = "—"
|
||||
} else {
|
||||
v.Chapter = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64)
|
||||
}
|
||||
if a.LatestCheckedAt == 0 {
|
||||
v.Checked = "never"
|
||||
} 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)
|
||||
// Unverified: a Reader's number standing behind a failure that has outlived
|
||||
// the owner window is a number nobody has re-checked since — say so next to
|
||||
// the correction control, without naming the Reader. A machine read or a
|
||||
// failure still inside the window carries no sentence; the failure itself
|
||||
// has not yet outlived the twelve hours' worth of trust.
|
||||
if a.RaisedByReader && a.FailureOutcome != "" && a.FailingSince < time.Now().Add(-ownerWindow).UnixMilli() {
|
||||
v.Unverified = "Unverified Reader number: the page has been failing for over 12h, so this Reader-reported chapter is unconfirmed."
|
||||
}
|
||||
if a.SiteCompletedAt != 0 && a.FinishedAt == 0 {
|
||||
v.SiteCompleted = "the site says this work is completed (since " + checkedAge(time.Now(), a.SiteCompletedAt) + ")"
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
)
|
||||
|
||||
// The card carries two guarantees a browser can break that Go cannot see, so
|
||||
// they are asserted on the rendered markup:
|
||||
//
|
||||
// - the monogram is unconditional. A cover that 404s or a request the phone
|
||||
// drops leaves an <img> with no bytes, and the letter underneath it is the
|
||||
// only thing between that and the browser's broken-image glyph in a 93px
|
||||
// slot. Rendering it only when Cover is empty covers the wrong failure.
|
||||
// - the chapter field does not refuse its own value. It is pre-filled from
|
||||
// LastChapterNum, which the API accepts as any float, so a step that
|
||||
// quantises the field makes a series read to 1200.25 unsavable without
|
||||
// first editing a number the reader did not want to change.
|
||||
func TestCardRenderKeepsCoverFallbackAndAcceptsFractionalChapter(t *testing.T) {
|
||||
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFS: %v", err)
|
||||
}
|
||||
b := store.Bookmark{
|
||||
Key: "asura:x", Site: "asura", Title: "Chronicles", Status: store.StatusReading,
|
||||
Cover: "https://bookmarks.test/covers/abc", LastChapter: "Chapter 1200.25",
|
||||
LastChapterNum: 1200.25,
|
||||
}
|
||||
var out strings.Builder
|
||||
if err := tmpl.ExecuteTemplate(&out, "card", b); err != nil {
|
||||
t.Fatalf("ExecuteTemplate: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
|
||||
if !strings.Contains(got, `class="monogram"`) {
|
||||
t.Error("a card with a cover rendered no monogram: a failed image has no fallback")
|
||||
}
|
||||
if !strings.Contains(got, `step="any"`) {
|
||||
t.Error(`chapter input is not step="any": a fractional pre-filled value is unsavable`)
|
||||
}
|
||||
if !strings.Contains(got, `value="1200.25"`) {
|
||||
t.Errorf("chapter input is not pre-filled with the stored progress:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Every failing request must land somewhere visible. A card's own writes report
|
||||
// into its .error-inline; a tab switch and a credential rotation have no card,
|
||||
// and #notice is the only slot filter.js can fall back to — without it in the
|
||||
// shell they fail silently. The tab's own active-state move is guarded on the
|
||||
// response, or a failed switch underlines a bucket the list is not showing.
|
||||
func TestAppShellCarriesFailureNoticeAndGuardsTabState(t *testing.T) {
|
||||
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFS: %v", err)
|
||||
}
|
||||
var out strings.Builder
|
||||
view := listView{Lib: store.KindManga, Tab: "all"}
|
||||
if err := tmpl.ExecuteTemplate(&out, "app", view); err != nil {
|
||||
t.Fatalf("ExecuteTemplate: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
|
||||
if !strings.Contains(got, `id="notice"`) {
|
||||
t.Error("no #notice in the shell: a failure with no card to sit in reports nowhere")
|
||||
}
|
||||
if strings.Contains(got, `hx-on::after-request="setActiveTab(this)"`) {
|
||||
t.Error("a tab moves its active state unconditionally: a failed switch underlines the wrong bucket")
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,11 @@ type DiscordConfig struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
GuildID string
|
||||
// GuildName is a human-readable name for the guild that gates access.
|
||||
// It is never fetched from Discord; it is an optional display string the
|
||||
// operator sets (DISCORD_GUILD_NAME) so the login screen can name the
|
||||
// community without inventing one.
|
||||
GuildName string
|
||||
// RequiredRole, when non-empty, is a role ID a member must hold on top of
|
||||
// guild membership. Empty by default: membership alone suffices.
|
||||
RequiredRole string
|
||||
@@ -173,16 +178,21 @@ func (h *Handler) discordCallback(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
// The refusal is the same for a non-member and a member without the
|
||||
// required role, and it names neither the guild nor its id: an outsider
|
||||
// cannot tell whether the guild exists, let alone which one gates.
|
||||
// required role. When DISCORD_GUILD_NAME is set the message names the
|
||||
// community so a stranger knows which Discord to ask about; otherwise it
|
||||
// degrades to a generic community label and still gives the next step. It
|
||||
// never names the numeric guild id, which would not be actionable.
|
||||
//
|
||||
// It also returns before EnsureReader, so a refused sign-in leaves no
|
||||
// Reader row behind — the gate is the only thing standing between guild
|
||||
// membership and a library.
|
||||
if !isMember || (h.discord.RequiredRole != "" && !slices.Contains(member.Roles, h.discord.RequiredRole)) {
|
||||
h.limiter.Fail(ip, time.Now())
|
||||
h.renderLogin(w, http.StatusForbidden,
|
||||
"This Discord account is not a member of this community.")
|
||||
msg := "This Discord account is not a member of this community. Ask a member for an invite and try again."
|
||||
if h.discord.GuildName != "" {
|
||||
msg = fmt.Sprintf("This Discord account is not a member of the %s Discord. Ask a member for an invite and try again.", h.discord.GuildName)
|
||||
}
|
||||
h.renderLogin(w, http.StatusForbidden, msg)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
// Title search runs entirely in the browser: the full list is already in the
|
||||
// DOM, so filtering it needs no request.
|
||||
(function () {
|
||||
function applyFilter() {
|
||||
function applyFilter(e) {
|
||||
var box = document.getElementById("search");
|
||||
if (!box) return;
|
||||
var query = box.value.trim();
|
||||
@@ -30,6 +30,21 @@
|
||||
none.hidden = !(needle !== "" && cards.length > 0 && visible === 0);
|
||||
if (!none.hidden) none.querySelector(".no-match-q").textContent = query;
|
||||
}
|
||||
|
||||
var isCardSwap = e && e.detail && e.detail.target && e.detail.target.classList && e.detail.target.classList.contains("card");
|
||||
if (isCardSwap) return;
|
||||
var announce = document.getElementById("sr-announce");
|
||||
if (announce) {
|
||||
if (needle === "") {
|
||||
announce.textContent = "";
|
||||
} else if (cards.length === 0) {
|
||||
announce.textContent = "";
|
||||
} else if (visible === 0) {
|
||||
announce.textContent = 'No titles match "' + query + '"';
|
||||
} else {
|
||||
announce.textContent = visible + ' ' + (visible === 1 ? 'title matches' : 'titles match') + ' "' + query + '"';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("input", function (e) {
|
||||
@@ -47,6 +62,54 @@
|
||||
// htmx replaces the list on a tab switch, so re-apply to the new cards.
|
||||
document.body.addEventListener("htmx:afterSwap", applyFilter);
|
||||
document.addEventListener("bmgr:refilter", applyFilter);
|
||||
// Focus handoff on remove: the card and its focused button vanish with the
|
||||
// swap, so focus would fall to <body> in silence. Remember the next card's
|
||||
// play link before the DELETE and hand focus to it after, or to the list
|
||||
// when it was the last row.
|
||||
var pendingRemove = null;
|
||||
document.body.addEventListener("htmx:beforeRequest", function (e) {
|
||||
var elt = e.detail.elt;
|
||||
if (!elt || !elt.getAttribute("hx-delete")) return;
|
||||
var card = elt.closest(".card");
|
||||
if (!card) return;
|
||||
var nxt = card.nextElementSibling;
|
||||
while (nxt && (!nxt.classList.contains("card") || nxt.hidden)) nxt = nxt.nextElementSibling;
|
||||
var play = nxt && nxt.querySelector(".play");
|
||||
pendingRemove = { card: card, play: play };
|
||||
});
|
||||
document.body.addEventListener("htmx:afterSwap", function (e) {
|
||||
if (!pendingRemove) return;
|
||||
if (e.detail.target !== pendingRemove.card) return;
|
||||
if (pendingRemove.play && document.body.contains(pendingRemove.play)) {
|
||||
pendingRemove.play.focus();
|
||||
} else {
|
||||
var list = document.getElementById("list");
|
||||
if (list) list.focus();
|
||||
}
|
||||
pendingRemove = null;
|
||||
var notice = document.getElementById("notice");
|
||||
if (notice && !notice.hidden) {
|
||||
notice.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
|
||||
});
|
||||
}
|
||||
});
|
||||
document.body.addEventListener("htmx:responseError", function () { pendingRemove = null; });
|
||||
document.body.addEventListener("htmx:sendError", function () { pendingRemove = null; });
|
||||
// htmx sets no aria-busy, so the in-flight window — up to the 15s timeout on
|
||||
// a phone in a dead zone — is silent to a screen reader; the busy bar and the
|
||||
// "Saving…" word are the visual half of the same signal. A successful swap
|
||||
// replaces the card and takes the attribute with it; afterRequest is for the
|
||||
// failures, where the card stays.
|
||||
document.body.addEventListener("htmx:beforeRequest", function (e) {
|
||||
var card = e.detail.elt && e.detail.elt.closest(".card");
|
||||
if (card) card.setAttribute("aria-busy", "true");
|
||||
});
|
||||
document.body.addEventListener("htmx:afterRequest", function (e) {
|
||||
var card = e.detail.elt && e.detail.elt.closest(".card");
|
||||
if (card) card.removeAttribute("aria-busy");
|
||||
});
|
||||
})();
|
||||
|
||||
function setActiveTab(el) {
|
||||
@@ -61,7 +124,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 +160,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
|
||||
@@ -121,19 +184,72 @@ document.addEventListener("keydown", function (e) {
|
||||
if (e.key !== "Escape" || !e.target.closest) return;
|
||||
var card = e.target.closest(".card");
|
||||
var owner = card && card.querySelector(".actions .open");
|
||||
if (!owner) return;
|
||||
closeCardPanels(card.id.replace(/^card-/, ""));
|
||||
owner.focus();
|
||||
if (owner) {
|
||||
closeCardPanels(card.id.replace(/^card-/, ""));
|
||||
owner.focus();
|
||||
return;
|
||||
}
|
||||
// Admin confirm rows: any open .confirm-row that contains the focused element
|
||||
var row = e.target.closest(".confirm-row");
|
||||
if (row && !row.hidden) {
|
||||
row.hidden = true;
|
||||
var opener = row.id && document.querySelector('[aria-controls="' + row.id + '"]');
|
||||
if (opener) {
|
||||
opener.setAttribute("aria-expanded", "false");
|
||||
opener.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Also handle when focus is outside row but row is open on page (e.g., opener focused)
|
||||
var openRow = document.querySelector(".admin-sheet .confirm-row:not([hidden])");
|
||||
if (openRow) {
|
||||
openRow.hidden = true;
|
||||
var op = openRow.id && document.querySelector('[aria-controls="' + openRow.id + '"]');
|
||||
if (op) {
|
||||
op.setAttribute("aria-expanded", "false");
|
||||
op.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// A failed favourite/chapter/delete request leaves the card in place (htmx
|
||||
// does not swap on a non-2xx response) but otherwise gives no sign anything
|
||||
// went wrong. Surface it inline instead of leaving the tap looking ignored.
|
||||
(function () {
|
||||
function showError(elt, message, linkHref, linkText) {
|
||||
function slotFor(elt) {
|
||||
var card = elt.closest(".card");
|
||||
var slot = card && card.querySelector(".error-inline");
|
||||
if (!slot) return;
|
||||
if (card) {
|
||||
var s = card.querySelector(".error-inline");
|
||||
if (s) return s;
|
||||
}
|
||||
// Admin: per-form inline slot
|
||||
var dform = elt.closest(".dform");
|
||||
if (dform) {
|
||||
var ds = dform.querySelector(".error-inline");
|
||||
if (ds) return ds;
|
||||
}
|
||||
// Admin: per-reader roster
|
||||
var readers = elt.closest("#readers");
|
||||
if (readers) {
|
||||
var rs = readers.querySelector(".error-inline");
|
||||
if (rs) return rs;
|
||||
}
|
||||
// Admin: series row — reuse row-msg if present, otherwise fall back
|
||||
var trow = elt.closest(".trow");
|
||||
if (trow) {
|
||||
var ts = trow.querySelector(".error-inline") || trow.querySelector(".row-msg");
|
||||
if (ts) return ts;
|
||||
}
|
||||
return document.getElementById("notice");
|
||||
}
|
||||
|
||||
function inCard(slot) {
|
||||
return slot.classList.contains("error-inline");
|
||||
}
|
||||
|
||||
function show(slot, message, linkHref, linkText) {
|
||||
if (slot.id === "notice") slot.setAttribute("role", "status");
|
||||
slot.textContent = message;
|
||||
if (linkHref) {
|
||||
var a = document.createElement("a");
|
||||
@@ -155,9 +271,10 @@ document.addEventListener("keydown", function (e) {
|
||||
}
|
||||
|
||||
document.body.addEventListener("htmx:beforeRequest", function (e) {
|
||||
var card = e.detail.elt.closest(".card");
|
||||
var slot = card && card.querySelector(".error-inline");
|
||||
var slot = slotFor(e.detail.elt);
|
||||
if (slot) slot.hidden = true;
|
||||
var n = document.getElementById("notice");
|
||||
if (n && !n.hidden && n.textContent.indexOf("Removed ") === 0) n.hidden = true;
|
||||
});
|
||||
|
||||
// The handlers answer a bad value with http.Error, i.e. a short plain-text
|
||||
@@ -170,19 +287,31 @@ document.addEventListener("keydown", function (e) {
|
||||
}
|
||||
|
||||
document.body.addEventListener("htmx:responseError", function (e) {
|
||||
var slot = slotFor(e.detail.elt);
|
||||
if (!slot) return;
|
||||
var xhr = e.detail.xhr;
|
||||
// "Saved" is only true of a card's own write; a failed tab switch saved
|
||||
// nothing because it was never a write.
|
||||
if (xhr.status === 401) {
|
||||
showError(e.detail.elt, "Session expired — nothing was saved.", "/login", "Log in again");
|
||||
show(slot, inCard(slot) ? "Session expired — nothing was saved." : "Session expired.",
|
||||
"/login", "Log in again");
|
||||
return;
|
||||
}
|
||||
if (xhr.status === 400) {
|
||||
var reason = reasonFrom(xhr);
|
||||
showError(e.detail.elt, reason ? reason + "." : "That value wasn't accepted — check it and try again.");
|
||||
show(slot, reason ? reason + "." : "That value wasn't accepted — check it and try again.");
|
||||
return;
|
||||
}
|
||||
showError(e.detail.elt, "Couldn't save — try again.");
|
||||
show(slot, inCard(slot) ? "Couldn't save — try again." : "That didn't go through — try again.");
|
||||
});
|
||||
document.body.addEventListener("htmx:sendError", function (e) {
|
||||
showError(e.detail.elt, "No connection — try again.");
|
||||
var slot = slotFor(e.detail.elt);
|
||||
if (slot) show(slot, "No connection — try again.");
|
||||
});
|
||||
// htmx-config caps every request at 15s; without this the give-up is as
|
||||
// silent as the hang it replaced.
|
||||
document.body.addEventListener("htmx:timeout", function (e) {
|
||||
var slot = slotFor(e.detail.elt);
|
||||
if (slot) show(slot, "Timed out — try again.");
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -54,10 +54,10 @@
|
||||
|
||||
--ink: #100f0e; /* page */
|
||||
--ash: #161413; /* recessed panel (chapter form) */
|
||||
--dim: #0d0c0b; /* archived / finished rows sink */
|
||||
--rule: #221f1d; /* hairline between sheets */
|
||||
--rule-soft: #1a1817; /* measure edges */
|
||||
--field-line: #2c2926;
|
||||
--dim: #0d0c0b; /* archived rows sink */
|
||||
--rule: #221f1d; /* decorative hairlines — not interactive — hairline between sheets */
|
||||
--rule-soft: #1a1817; /* decorative hairlines — not interactive — measure edges */
|
||||
--field-line: #4a4540; /* ≈■ #4a4540 — interactive border, distinct from decorative --rule/--rule-soft */
|
||||
--hover: #1a1816;
|
||||
|
||||
--paper: #f2ece5; /* highest-contrast text, primary button */
|
||||
@@ -70,8 +70,8 @@
|
||||
--faint: #3a3733; /* meta separators */
|
||||
--faint-2: #57504b; /* cover monogram */
|
||||
|
||||
--ember: #e0452c; /* the only heat */
|
||||
--ember-wash: #1a1211; /* ember-tinted surface */
|
||||
--ember: #e85a41; /* the only heat — verified against --ember-wash, not --ink — ≈■ #e85a41 — verified 5.31:1 on --ember-wash #1c0f0d, not --ink */
|
||||
--ember-wash: #1c0f0d; /* ■ #1c0f0d — verified against this wash for is-new card/Recent/Updated/libswitch */
|
||||
--ember-ink: #150907; /* text on solid ember */
|
||||
--ember-soft: #eda798; /* text on ember wash */
|
||||
/* Destruction is hot but not ember: a duller oxblood, so a remove confirm is
|
||||
@@ -80,6 +80,7 @@
|
||||
--danger-wash: #211311;
|
||||
--danger-ink: #150808;
|
||||
--danger-soft: #e2aaa1;
|
||||
--danger-line: #4a2e2b; /* resting hairline for Remove — muted danger, not alarm */
|
||||
--brass: #b8912f; /* favourite — a second, cooler metal */
|
||||
/* One accent per action, so a press says which lane it belongs to. All three
|
||||
are held at the same weight as --brass: muted, no ember competition. */
|
||||
@@ -92,7 +93,17 @@
|
||||
blue. Neither ember (new chapter) nor danger (destruction) may say
|
||||
"system unhealthy". */
|
||||
--patina: #5fb3a6;
|
||||
|
||||
--patina-wash: #12201e;
|
||||
--patina-soft: #9ad6cd;
|
||||
--patina-line: #1e3a37;
|
||||
--brass-wash: #231e0f;
|
||||
--brass-soft: #e0c495;
|
||||
--slate-wash: #14202a;
|
||||
--slate-soft: #a8bdd2;
|
||||
--clay-wash: #241a13;
|
||||
--clay-soft: #d9b79d;
|
||||
--moss-wash: #13211a;
|
||||
--moss-soft: #a9ceb1;
|
||||
/* Desktop cell borders for the two coloured action states. */
|
||||
--play-hot-line: #3a1d18;
|
||||
--fav-line: #332b14;
|
||||
@@ -116,6 +127,9 @@
|
||||
desktop, so both live here rather than as magic numbers. */
|
||||
--cover-w: 93px;
|
||||
--row-gap: 14px;
|
||||
/* The desktop scale-up lives in a token because `zoom` multiplies viewport
|
||||
units too: anything sized in vh has to divide it back out. */
|
||||
--zoom: 1;
|
||||
}
|
||||
|
||||
/* Light mode: same rules, cooler paper. Hues are re-tuned, not reused — the
|
||||
@@ -125,8 +139,8 @@
|
||||
--ink: #f7f4ef;
|
||||
--ash: #efeae3;
|
||||
--dim: #f1ede7;
|
||||
--rule: #e0dad2;
|
||||
--rule-soft: #e8e3dc;
|
||||
--rule: #e0dad2; /* decorative hairlines — not interactive */
|
||||
--rule-soft: #e8e3dc; /* decorative hairlines — not interactive */
|
||||
--field-line: #d4cdc4;
|
||||
--hover: #efeae3;
|
||||
|
||||
@@ -138,30 +152,42 @@
|
||||
--faint: #c9c2ba;
|
||||
--faint-2: #a8a098;
|
||||
|
||||
--ember: #c23a22;
|
||||
--ember-wash: #fbeee9;
|
||||
--ember: #c23a22; /* verified against --ember-wash, not --ink */
|
||||
--ember-wash: #fbeee9; /* verified against --ember-wash, not --ink — wash ground */
|
||||
--ember-ink: #fff;
|
||||
--ember-soft: #8d2c17;
|
||||
--ember-soft: #7c2314; /* from #8d2c17 — crimson not brown, 4.9:1 on #fbeee9 */
|
||||
--danger: #97362a;
|
||||
--danger-wash: #fbe9e5;
|
||||
--danger-ink: #fff;
|
||||
--danger-soft: #7c2c22;
|
||||
--danger-line: #d8b8b0; /* resting hairline for Remove — light counterpart to #4a2e2b */
|
||||
--brass: #8a681c;
|
||||
--slate: #3f6689;
|
||||
--moss: #3d6c46;
|
||||
--clay: #7c5533;
|
||||
--trash: #8c6558;
|
||||
--patina: #1f6f66;
|
||||
--patina-wash: #def3f0;
|
||||
--patina-soft: #144a43;
|
||||
--patina-line: #b8ddd8;
|
||||
--brass-wash: #fdf6e3;
|
||||
--brass-soft: #5a4812;
|
||||
--slate-wash: #e8eef5;
|
||||
--slate-soft: #24445f;
|
||||
--clay-wash: #fdf0e6;
|
||||
--clay-soft: #5a3520;
|
||||
--moss-wash: #e7f3e8;
|
||||
--moss-soft: #23482a;
|
||||
|
||||
--play-hot-line: #f0cfc6;
|
||||
--fav-line: #e3d3a4;
|
||||
|
||||
--asura: #4f6b80;
|
||||
--demonic: #8a6a55;
|
||||
--comix: #5f7250;
|
||||
--demonic: #7a5c4a; /* from #8a6a55 — 5.1:1 on #f7f4ef, ~4.7:1 on #efeae3 */
|
||||
--comix: #5a6c4b; /* from #5f7250 — 5.2:1 on #f7f4ef, 4.77:1 on #efeae3 — was 4.38:1 on ash, fails AA */
|
||||
--kagane: #6f5f7d;
|
||||
--novelfull: #7d6f4f;
|
||||
--lightnovelworld: #4f7d70;
|
||||
--novelfull: #6f5f3f; /* from #7d6f4f — same bar */
|
||||
--lightnovelworld: #3d6b5e; /* from #4f7d70 — lifts 3.90→4.8:1 on ash */
|
||||
|
||||
--hatch: repeating-linear-gradient(135deg, #e6e0d8 0 5px, #efeae3 5px 10px);
|
||||
--hatch-dim: repeating-linear-gradient(135deg, #ebe6de 0 5px, #f2eee8 5px 10px);
|
||||
@@ -178,6 +204,11 @@ body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
/* Every shell sets viewport-fit=cover, which puts the sheet's own hairline
|
||||
borders and its 20px gutter under a landscape cutout on a notched phone.
|
||||
Zero on every other device, so it costs nothing to honour here. */
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
@@ -186,6 +217,19 @@ button { cursor: pointer; }
|
||||
|
||||
/* Every hideable thing here is a flex container, and display beats hidden. */
|
||||
[hidden] { display: none !important; }
|
||||
/* Visually hidden but still announced — the live region for mutation and
|
||||
filter results. Display:none would silence it. */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Ends flush with the right edge of the card (30% wide × 233% travel), so the
|
||||
card itself never needs overflow: hidden to contain it. */
|
||||
@@ -203,6 +247,9 @@ button { cursor: pointer; }
|
||||
width: 100%;
|
||||
max-width: var(--measure);
|
||||
min-height: 100vh;
|
||||
/* The URL bar is inside 100vh on mobile Chromium, so the flush bottom
|
||||
border sits under it. dvh is the visible box; vh above is the fallback. */
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--rule-soft);
|
||||
@@ -248,6 +295,15 @@ button { cursor: pointer; }
|
||||
.ghost:hover { color: var(--paper); border-bottom-color: var(--paper); }
|
||||
/* The label is 15px tall by design; the thumb gets 44 without moving it. */
|
||||
.ghost::after { content: ""; position: absolute; inset: -15px -12px; }
|
||||
/* Nav and standalone controls share one paper ring. The per-action cells in
|
||||
the card strip carry their own accent rings instead; everything here would
|
||||
otherwise fall back to the UA default, which is a bright blue on a palette
|
||||
tuned for a dark room. */
|
||||
.ghost:focus-visible,
|
||||
.tabs a:focus-visible,
|
||||
.libswitch a:focus-visible,
|
||||
.empty .clear-search:focus-visible,
|
||||
.login-card button:focus-visible { outline: 2px solid var(--paper); outline-offset: 2px; }
|
||||
|
||||
/* ---- userscript setup: collapsed by default, one hairline, no card ---- */
|
||||
.setup {
|
||||
@@ -261,7 +317,7 @@ button { cursor: pointer; }
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
font: 500 10px/1 var(--font-mono);
|
||||
font: 500 11px/1 var(--font-mono);
|
||||
letter-spacing: .2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mute-2);
|
||||
@@ -296,71 +352,22 @@ button { cursor: pointer; }
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
/* ---- admin page: two sections on the same measured sheet, no cards ----
|
||||
The reading page is a list of series; this is a list of facts. Both are
|
||||
sheets of hairline-separated rows, so the roster keeps the shape it had as
|
||||
a fold-out and the Lane block copies it. */
|
||||
.readers, .lanes { margin: 0 20px; padding: 12px 0 16px; border-bottom: 1px solid var(--rule); }
|
||||
.readers h2, .lanes h2 {
|
||||
margin: 0;
|
||||
padding: 8px 0;
|
||||
font: 500 10px/1 var(--font-mono);
|
||||
letter-spacing: .2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mute-2);
|
||||
}
|
||||
.readerlist, .lanelist { margin: 0; padding: 0; list-style: none; }
|
||||
.readerlist li, .lanelist li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
min-height: 44px;
|
||||
border-top: 1px solid var(--rule);
|
||||
}
|
||||
.reader-actions { display: flex; gap: 18px; margin-left: auto; }
|
||||
.readerlist form { margin: 0; }
|
||||
.reader-id {
|
||||
font: 500 13px/1.4 var(--font-mono);
|
||||
letter-spacing: .04em;
|
||||
color: var(--paper);
|
||||
}
|
||||
.reader-sessions {
|
||||
font: 500 10px/1 var(--font-mono);
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mute);
|
||||
}
|
||||
.reader-sightings, .lane-fact {
|
||||
font: 500 10px/1 var(--font-mono);
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mute-2);
|
||||
}
|
||||
/* Two states the owner is meant to find rather than read for: a Reader whose
|
||||
reports no longer defer a Poll, and a Lane that is not keeping its promise.
|
||||
Both wear --patina — never ember, which means one thing, and never danger,
|
||||
which is destruction. */
|
||||
.reader-blocked, .lane-mark {
|
||||
font: 500 10px/1 var(--font-mono);
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--patina);
|
||||
}
|
||||
.lane-site {
|
||||
font: 400 19px/1.2 var(--font-display);
|
||||
color: var(--paper-dim);
|
||||
}
|
||||
/* The whole row leans patina when the Lane needs attention, so the scan is one
|
||||
pass down the left edge rather than a read of every mark. */
|
||||
.lanelist li.attention .lane-site { color: var(--patina); }
|
||||
.lane-browser { padding: 12px 0 0; }
|
||||
/* Revocation cuts someone off, so it wears --danger. Ember stays reserved for
|
||||
the new-chapter signal. */
|
||||
.ghost.danger { color: var(--danger); }
|
||||
.ghost.danger:hover { color: var(--danger); border-bottom-color: var(--danger); }
|
||||
|
||||
.chrome { display: flex; flex-direction: column; }
|
||||
/* Sticky: the buckets and the search box are how a large library is triaged,
|
||||
and a phone three screens in otherwise has to scroll back to the top to
|
||||
reach either. The keyrow below is deliberately left to scroll away — it
|
||||
teaches the icon strip once, and 150px of permanent chrome on an 844px
|
||||
phone costs more than re-scrolling for a reminder does. */
|
||||
.chrome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
/* Cards are position: relative, so without this they paint over it. */
|
||||
z-index: 2;
|
||||
background: var(--ink);
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.searchbar {
|
||||
display: flex;
|
||||
@@ -371,8 +378,9 @@ button { cursor: pointer; }
|
||||
border-bottom: 1px solid var(--rule);
|
||||
color: var(--mute-2);
|
||||
}
|
||||
/* The input drops its own outline, so the bar it sits in carries the focus
|
||||
ring — same move the two other inputs make with their border. */
|
||||
/* The bar carries a resting recolour on focus-within, and the input carries
|
||||
its own ring: the 1px border change alone is a weaker indicator than every
|
||||
other control on the sheet gets, on the control most worth finding. */
|
||||
.searchbar:focus-within { border-bottom-color: var(--paper); color: var(--paper); }
|
||||
.searchbar svg { width: 15px; height: 15px; flex: none; }
|
||||
.search {
|
||||
@@ -383,8 +391,8 @@ button { cursor: pointer; }
|
||||
background: transparent;
|
||||
color: var(--paper);
|
||||
font: 400 15px var(--font-body);
|
||||
outline: none;
|
||||
}
|
||||
.search:focus-visible { outline: 2px solid var(--paper); outline-offset: 3px; }
|
||||
.search::placeholder { color: var(--mute-2); }
|
||||
|
||||
/* Tabs are set in the display serif and underlined, not chipped. */
|
||||
@@ -396,6 +404,9 @@ button { cursor: pointer; }
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
/* Right-edge fade — the pills and 4th tab crop invisibly without it. */
|
||||
-webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
|
||||
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
|
||||
}
|
||||
.tabs::-webkit-scrollbar { display: none; }
|
||||
.tabs a {
|
||||
@@ -411,21 +422,25 @@ button { cursor: pointer; }
|
||||
font: 400 17px var(--font-display);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tabs a:hover { color: var(--paper-dim); }
|
||||
.tabs a:hover { color: var(--paper-dim); background: var(--hover); border-radius: 2px 2px 0 0; }
|
||||
.tabs a.active {
|
||||
color: var(--paper);
|
||||
border-bottom: 2px solid var(--paper);
|
||||
margin-bottom: -1px;
|
||||
background: var(--hover);
|
||||
}
|
||||
/* Updated is the one tab that carries heat. */
|
||||
.tabs .tab-new { color: var(--ember); }
|
||||
.tabs .tab-new.active { border-bottom-color: var(--ember); }
|
||||
.tabs .tab-new.active { border-bottom-color: var(--ember); background: var(--ember-wash); color: var(--ember); }
|
||||
.tabs .tab-new:hover { background: var(--ember-wash); color: var(--ember); }
|
||||
.count {
|
||||
font: 600 10px var(--font-mono);
|
||||
letter-spacing: 0;
|
||||
padding: 2px 5px;
|
||||
border: 1px solid currentColor;
|
||||
background: var(--ink);
|
||||
}
|
||||
.tab-new .count { background: var(--ember-wash); }
|
||||
|
||||
/* ---- action key: one permanent line under the tabs, so the icon strip below
|
||||
never has to be guessed at. Lean on a phone (28px, edge to edge), a step
|
||||
@@ -449,12 +464,18 @@ button { cursor: pointer; }
|
||||
}
|
||||
.keyrow .pair svg { width: 14px; height: 14px; flex: none; }
|
||||
.keyrow .pair span {
|
||||
font: 500 10px/1 var(--font-mono);
|
||||
font: 500 11px/1 var(--font-mono);
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.keyrow .pair.brass svg { color: var(--brass); }
|
||||
.keyrow .pair.slate svg { color: var(--slate); }
|
||||
.keyrow .pair.clay svg { color: var(--clay); }
|
||||
.keyrow .pair.trash svg { color: var(--trash); }
|
||||
.keyrow .pair.brass span { color: var(--brass); }
|
||||
.keyrow .pair.slate span { color: var(--slate); }
|
||||
.keyrow .pair.clay span { color: var(--clay); }
|
||||
.keyrow .pair.trash span { color: var(--trash); }
|
||||
|
||||
/* ---- continue reading ---- */
|
||||
.recent {
|
||||
@@ -467,7 +488,7 @@ button { cursor: pointer; }
|
||||
.recent h2 {
|
||||
margin: 0;
|
||||
padding: 0 20px;
|
||||
font: 500 10px/1 var(--font-mono);
|
||||
font: 500 11px/1 var(--font-mono);
|
||||
letter-spacing: .2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mute-2);
|
||||
@@ -479,6 +500,10 @@ button { cursor: pointer; }
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
padding: 0 20px 4px;
|
||||
scroll-snap-type: x proximity;
|
||||
/* Static right-edge fade — newest card at 93px+GAP is otherwise invisible at 360px. */
|
||||
-webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
|
||||
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
|
||||
}
|
||||
.recent-strip::-webkit-scrollbar { display: none; }
|
||||
.recent-card {
|
||||
@@ -487,6 +512,7 @@ button { cursor: pointer; }
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
.recent-cover {
|
||||
position: relative;
|
||||
@@ -497,6 +523,11 @@ button { cursor: pointer; }
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Cover and monogram share one grid cell rather than stacking: the monogram is
|
||||
the fallback for a cover that fails to load, not only for one that was never
|
||||
acquired, and an image that fails to decode otherwise renders the browser's
|
||||
broken-image glyph over the hatch. */
|
||||
.recent-cover > img, .recent-cover > .monogram { grid-area: 1 / 1; }
|
||||
.recent-cover img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.recent-title {
|
||||
font: 400 16px/1.25 var(--font-display);
|
||||
@@ -505,6 +536,7 @@ button { cursor: pointer; }
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.recent-chapter {
|
||||
font: 500 11px/1 var(--font-mono);
|
||||
@@ -513,6 +545,7 @@ button { cursor: pointer; }
|
||||
}
|
||||
.recent-card.is-new .recent-title { color: var(--paper-hot); }
|
||||
.recent-card.is-new .recent-chapter { color: var(--ember); }
|
||||
.recent-card.is-new .recent-cover { outline: 1px solid color-mix(in oklch, var(--ember) 26%, transparent); outline-offset: -1px; }
|
||||
|
||||
/* The rule at the cover foot is the only cover ornament: ember for a new
|
||||
chapter, brass for a favourite. */
|
||||
@@ -544,6 +577,11 @@ button { cursor: pointer; }
|
||||
animation: sheetIn .18s ease-out;
|
||||
}
|
||||
.card.is-dim { background: var(--dim); }
|
||||
.card.is-new {
|
||||
background: var(--ember-wash);
|
||||
border-bottom-color: color-mix(in oklch, var(--ember) 14%, var(--rule));
|
||||
}
|
||||
.card.is-new .cover { outline: 1px solid color-mix(in oklch, var(--ember) 22%, transparent); outline-offset: -1px; }
|
||||
|
||||
.row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--row-gap); }
|
||||
|
||||
@@ -557,9 +595,12 @@ button { cursor: pointer; }
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cover > img, .cover > .monogram { grid-area: 1 / 1; }
|
||||
.cover img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.cover .monogram { font-size: 32px; }
|
||||
.is-dim .cover { background: var(--hatch-dim); filter: grayscale(1); opacity: .85; }
|
||||
.is-dim .cover { background: var(--hatch-dim); }
|
||||
.is-dim .cover img { filter: grayscale(1); opacity: .85; }
|
||||
.is-dim .cover .monogram { color: var(--mute-2); }
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
@@ -577,6 +618,17 @@ button { cursor: pointer; }
|
||||
min-width: 0;
|
||||
font: 400 21px/1.2 var(--font-display);
|
||||
color: var(--paper-dim);
|
||||
/* Titles come from a third party's og:title. One unbroken 90-character
|
||||
token measured 789px of document on a 390px viewport — the whole sheet
|
||||
scrolled sideways — so every slot that prints one breaks mid-word. */
|
||||
overflow-wrap: anywhere;
|
||||
/* Three lines, so one scraped 90-character title cannot set the rhythm of
|
||||
the whole list. Deeper than the strip's two: this is where the title is
|
||||
the primary identifier, not a thumbnail caption. */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Heat: crimson title over an ember hairline sized to the text, not the row. */
|
||||
.is-new .title {
|
||||
@@ -585,6 +637,9 @@ button { cursor: pointer; }
|
||||
color: var(--paper-hot);
|
||||
border-bottom: 1px solid var(--ember);
|
||||
padding-bottom: 3px;
|
||||
/* -webkit-box fills its line box, which would stretch the ember hairline to
|
||||
the full row; the underline is sized to the text. */
|
||||
width: fit-content;
|
||||
}
|
||||
.is-dim .title { font-style: italic; color: var(--mute); }
|
||||
.is-dim .fav-mark { color: var(--mute-2); }
|
||||
@@ -613,6 +668,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,
|
||||
@@ -627,7 +683,7 @@ button { cursor: pointer; }
|
||||
}
|
||||
.libswitch a {
|
||||
padding: 7px 13px;
|
||||
font: 500 10px/1 var(--font-mono);
|
||||
font: 500 11px/1 var(--font-mono);
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mute);
|
||||
@@ -643,9 +699,6 @@ button { cursor: pointer; }
|
||||
box-shadow: inset 0 -2px 0 var(--ember);
|
||||
}
|
||||
.topbar form { margin-left: 18px; }
|
||||
/* The admin page's topbar has no switch to fill the middle, so its back link
|
||||
keeps company with Log out at the right edge instead of floating centre. */
|
||||
.topbar .back { margin-left: auto; }
|
||||
/* At phone width brand + switch + Log out do not fit on one line, so the
|
||||
switch takes its own row under the wordmark rather than pushing Log out
|
||||
off-screen. */
|
||||
@@ -655,9 +708,6 @@ button { cursor: pointer; }
|
||||
.libswitch { order: 3; margin-left: 0; }
|
||||
.libswitch a { flex: 1; text-align: center; padding: 8px 14px; }
|
||||
.topbar form { margin-left: 12px; }
|
||||
/* The admin page has no switch to take the second row, so its brand claims
|
||||
the first outright and the back link keeps Log out company below. */
|
||||
.topbar:has(.back) .brand { flex: 1 1 100%; }
|
||||
}
|
||||
|
||||
/* ---- action strip: full-width on a phone, hairline-divided cells ---- */
|
||||
@@ -681,28 +731,39 @@ 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. */
|
||||
.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); }
|
||||
/* 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); background: var(--brass-wash); }
|
||||
.actions .fav:focus-visible { outline: 2px solid var(--brass); outline-offset: -2px; }
|
||||
.actions .pencil:hover, .actions .pencil:active, .actions .pencil:focus-visible { color: var(--clay); background: var(--clay-wash); }
|
||||
.actions .pencil:focus-visible { outline: 2px solid var(--clay); outline-offset: -2px; }
|
||||
.actions .box:hover, .actions .box:active, .actions .box:focus-visible { color: var(--slate); background: var(--slate-wash); }
|
||||
.actions .box:focus-visible { outline: 2px solid var(--slate); outline-offset: -2px; }
|
||||
.actions .play { color: var(--paper); }
|
||||
|
||||
.is-new .actions .play { color: var(--ember); }
|
||||
.actions .play:hover { background: var(--hover); }
|
||||
.actions .on { color: var(--brass); }
|
||||
.is-new .actions .play:hover { background: var(--ember-wash); color: var(--ember); }
|
||||
.actions .play:focus-visible { background: var(--hover); outline: 2px solid var(--paper); outline-offset: -2px; }
|
||||
.is-new .actions .play:focus-visible { background: var(--ember-wash); color: var(--ember); outline-color: var(--ember); }
|
||||
.actions .on { color: var(--brass); background: var(--brass-wash); }
|
||||
.actions .restore { color: var(--paper); }
|
||||
.actions .remove { color: var(--trash); }
|
||||
.actions .remove:hover { color: var(--danger); }
|
||||
.actions .restore:hover, .actions .restore:active, .actions .restore:focus-visible { color: var(--slate); background: var(--slate-wash); }
|
||||
.actions .restore:focus-visible { outline: 2px solid var(--slate); outline-offset: -2px; }
|
||||
.actions .remove { color: var(--trash); box-shadow: inset 1px 0 0 var(--danger-line); }
|
||||
.actions .remove:hover, .actions .remove:active, .actions .remove:focus-visible { color: var(--danger); background: var(--danger-wash); }
|
||||
.actions .remove:focus-visible { outline: 2px solid var(--danger); outline-offset: -2px; }
|
||||
.actions .open { background: var(--hover); color: var(--paper); }
|
||||
/* Lifecycle cells already sit on --ash, which is what --hover resolves to, so
|
||||
an open one needs the next step up to stay legible as the panel's owner. */
|
||||
.actions .lifecycle.open { background: var(--rule); }
|
||||
.actions .lifecycle.open.box, .actions .lifecycle.open.box:hover { background: var(--slate-wash); color: var(--slate); }
|
||||
.actions .remove.open { background: var(--danger-wash); color: var(--danger); }
|
||||
.is-dim .actions > * { color: var(--mute-2); }
|
||||
.is-dim .actions > *:hover { background: transparent; color: var(--mute); }
|
||||
|
||||
/* 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); }
|
||||
@@ -722,7 +783,7 @@ button { cursor: pointer; }
|
||||
}
|
||||
.chapter-form .hint {
|
||||
margin: 0;
|
||||
font: 500 10px/1 var(--font-mono);
|
||||
font: 500 11px/1 var(--font-mono);
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mute-2);
|
||||
@@ -739,9 +800,10 @@ button { cursor: pointer; }
|
||||
font: 500 17px var(--font-mono);
|
||||
outline: none;
|
||||
}
|
||||
/* Focus follows the .searchbar idiom — paper, not heat: a red border on a
|
||||
valid number field reads as "invalid". */
|
||||
.chapter-form input:focus { border-color: var(--paper); }
|
||||
/* Clay carries the chapter action -- paper flashes on a dimmed OLED and ember
|
||||
reads as "invalid" on a valid number. */
|
||||
.chapter-form input:focus { border-color: var(--clay); }
|
||||
.chapter-form input:focus-visible { border-color: var(--clay); outline: 2px solid var(--clay); outline-offset: 2px; }
|
||||
.chapter-form input::-webkit-outer-spin-button,
|
||||
.chapter-form input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
.chapter-form button {
|
||||
@@ -752,6 +814,7 @@ button { cursor: pointer; }
|
||||
color: var(--ink);
|
||||
font: 400 17px var(--font-display);
|
||||
}
|
||||
.chapter-form button:focus-visible { outline: 2px solid var(--clay); outline-offset: 2px; }
|
||||
|
||||
.confirm-row {
|
||||
display: flex;
|
||||
@@ -764,24 +827,26 @@ button { cursor: pointer; }
|
||||
}
|
||||
/* The remove question names the series, so it has to be able to take the row
|
||||
to itself and push the buttons onto their own line. */
|
||||
.confirm-row span { flex: 1 1 16ch; font: 400 17px/1.3 var(--font-display); color: var(--danger-soft); }
|
||||
.confirm-row span { flex: 1 1 16ch; font: 400 17px/1.3 var(--font-display); color: var(--danger-soft); overflow-wrap: anywhere; }
|
||||
.confirm-row div { display: flex; flex: none; gap: 12px; margin-left: auto; }
|
||||
.confirm-row button {
|
||||
height: 46px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--field-line);
|
||||
background: transparent;
|
||||
color: var(--mute);
|
||||
background: var(--ash);
|
||||
color: var(--paper-dim);
|
||||
font: 500 12px var(--font-body);
|
||||
}
|
||||
.confirm-row button:hover { color: var(--paper); }
|
||||
.confirm-row button:hover { color: var(--paper); border-color: var(--paper); }
|
||||
.confirm-row button:focus-visible { color: var(--paper); border-color: var(--paper); outline: 2px solid var(--paper); outline-offset: 2px; }
|
||||
.confirm-row .danger-solid {
|
||||
border: none;
|
||||
background: var(--danger);
|
||||
color: var(--danger-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Archive and finish are reversible, so their confirm asks in grey — only the
|
||||
.confirm-row .danger-solid:focus-visible { outline-color: var(--danger); }
|
||||
/* 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); }
|
||||
@@ -791,16 +856,31 @@ button { cursor: pointer; }
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.confirm-row .go:hover { color: var(--ink); }
|
||||
.confirm-row .go:hover, .confirm-row .go:focus-visible { color: var(--ink); }
|
||||
|
||||
/* The same notice in two positions: .error-inline belongs to one card and
|
||||
reports a write that failed, .notice sits under the chrome and reports the
|
||||
requests that have no card to belong to — a tab switch, a rotation. */
|
||||
.error-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin: 0;
|
||||
padding: 11px 13px;
|
||||
background: var(--danger-wash);
|
||||
border-left: 2px solid var(--danger);
|
||||
font: 400 16px var(--font-display);
|
||||
color: var(--danger-soft);
|
||||
}
|
||||
.notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin: 0;
|
||||
padding: 11px 20px;
|
||||
background: var(--ash);
|
||||
border-left: 2px solid var(--mute);
|
||||
border-left: 2px solid var(--patina);
|
||||
border-bottom: 1px solid var(--rule);
|
||||
font: 400 16px var(--font-display);
|
||||
color: var(--paper-dim);
|
||||
}
|
||||
@@ -810,13 +890,22 @@ button { cursor: pointer; }
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--mute);
|
||||
background: var(--danger);
|
||||
}
|
||||
.notice::before {
|
||||
content: "";
|
||||
flex: none;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--patina);
|
||||
animation: mutePulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.error-inline .error-link {
|
||||
.error-inline .error-link, .notice .error-link {
|
||||
border-bottom: 1px solid var(--field-line);
|
||||
color: var(--paper);
|
||||
}
|
||||
.error-inline .error-link { border-bottom-color: color-mix(in oklch, var(--danger) 26%, transparent); color: var(--danger-soft); }
|
||||
|
||||
/* Busy: a grey hairline slides across the top of the sheet — deliberately not
|
||||
ember, which on a list screen only ever means "new chapter". */
|
||||
@@ -831,6 +920,22 @@ button { cursor: pointer; }
|
||||
background: var(--mute);
|
||||
animation: barSlide 1.15s linear infinite;
|
||||
}
|
||||
/* Past about two seconds the bar alone stops reading as "working" and starts
|
||||
reading as "ignored" — and htmx's own timeout is 15s. Delayed rather than
|
||||
immediate: every response on a live connection lands well inside it. */
|
||||
@keyframes busyWord { to { opacity: 1 } }
|
||||
.card.htmx-request::after {
|
||||
content: "Saving…";
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 20px;
|
||||
font: 500 11px/1 var(--font-mono);
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mute);
|
||||
opacity: 0;
|
||||
animation: busyWord 0s 2s forwards;
|
||||
}
|
||||
|
||||
/* ---- empty ---- */
|
||||
.empty {
|
||||
@@ -839,7 +944,7 @@ button { cursor: pointer; }
|
||||
gap: 7px;
|
||||
padding: 40px 20px 48px;
|
||||
}
|
||||
.empty strong { font: 400 20px var(--font-display); color: var(--paper); }
|
||||
.empty strong { font: 400 20px var(--font-display); color: var(--paper); overflow-wrap: anywhere; }
|
||||
.empty .clear-search {
|
||||
align-self: flex-start;
|
||||
margin-top: 4px;
|
||||
@@ -863,21 +968,26 @@ button { cursor: pointer; }
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
min-height: 100vh;
|
||||
min-height: calc(100vh / var(--zoom));
|
||||
min-height: calc(100dvh / var(--zoom));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 52px 28px 40px;
|
||||
}
|
||||
.login-card .eyebrow {
|
||||
font: 500 10px/1 var(--font-mono);
|
||||
font: 500 11px/1 var(--font-mono);
|
||||
letter-spacing: .2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mute-2);
|
||||
}
|
||||
.login-card h1 {
|
||||
margin: 14px 0 0;
|
||||
font: 400 44px/1 var(--font-display);
|
||||
/* The wordmark is the widest unbreakable run in the app; at 44px it is
|
||||
wider than a 360px phone, and the page has no other content to scroll
|
||||
to, so the overflow reads as a broken page. Fluid to the measured fit
|
||||
(30px clears a 320px viewport), locked at the design size from 520px up. */
|
||||
font: 400 clamp(30px, 8.5vw, 44px)/1 var(--font-display);
|
||||
letter-spacing: -.01em;
|
||||
color: var(--paper);
|
||||
}
|
||||
@@ -928,7 +1038,7 @@ button { cursor: pointer; }
|
||||
keeps every proportion — hairlines, cover ratios, hit targets —
|
||||
intact. ---- */
|
||||
@media (min-width: 1280px) {
|
||||
:root { zoom: 1.2; }
|
||||
:root { --zoom: 1.2; zoom: var(--zoom); }
|
||||
}
|
||||
|
||||
/* ---- desktop: same measure, actions fold up beside the row ---- */
|
||||
@@ -948,6 +1058,7 @@ button { cursor: pointer; }
|
||||
}
|
||||
.tabs { order: 1; flex: none; gap: 20px; padding: 0; border-bottom: none; }
|
||||
.tabs a { padding: 10px 0 14px; font-size: 18px; }
|
||||
.tabs, .recent-strip { -webkit-mask-image: none; mask-image: none; }
|
||||
.searchbar { order: 2; flex: 1; margin: 0; border-bottom: none; }
|
||||
.recent h2, .recent-strip { padding-left: 32px; padding-right: 32px; }
|
||||
.keyrow {
|
||||
@@ -959,8 +1070,7 @@ button { cursor: pointer; }
|
||||
}
|
||||
.keyrow .pair { flex-direction: row; gap: 7px; }
|
||||
.keyrow .pair svg { width: 14px; height: 14px; }
|
||||
.keyrow .pair span { font-size: 11px; letter-spacing: .1em; }
|
||||
.keyrow .full { display: inline; }
|
||||
.keyrow .pair span { letter-spacing: .1em; }
|
||||
.recent-card, .recent-cover { width: var(--cover-w); }
|
||||
.recent-cover { height: calc(var(--cover-w) * 4 / 3); }
|
||||
.recent-title { font-size: 17px; }
|
||||
@@ -981,17 +1091,29 @@ button { cursor: pointer; }
|
||||
than by the hairline the phone layout uses. */
|
||||
.actions > .play + *,
|
||||
.actions > *:not(.lifecycle) + .lifecycle { margin-left: 10px; box-shadow: none; }
|
||||
.actions .remove { box-shadow: none; border-left-color: var(--danger-line); }
|
||||
.is-new .actions .play { border-color: var(--play-hot-line); }
|
||||
.actions .on { border-color: var(--fav-line); }
|
||||
/* 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));
|
||||
}
|
||||
/* Not the panel indent: the notice is chrome, so it keeps the sheet gutter
|
||||
every other full-width row uses. */
|
||||
.notice { padding-left: 32px; padding-right: 32px; }
|
||||
}
|
||||
|
||||
/* Every other control on this sheet is already 44 or borrows a .ghost::after
|
||||
to get there; the library switch is the one sized by its own padding in both
|
||||
layouts, and padding plus an 11px line lands at 43. min-height states the
|
||||
target instead of arithmetic on the font. Gated on the pointer rather than
|
||||
the width — a touch laptop lands on the desktop layout with the same
|
||||
thumb. */
|
||||
@media (pointer: coarse) {
|
||||
.libswitch a { min-height: 44px; display: grid; place-items: center; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -999,4 +1121,7 @@ button { cursor: pointer; }
|
||||
busy bar and error dot are both ::before. Their static form still reads:
|
||||
the bar stays drawn and .actions stays dimmed. */
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||||
/* The busy word is revealed by a zero-duration delayed animation, which the
|
||||
rule above cancels — show it from the start instead. */
|
||||
.card.htmx-request::after { opacity: 1; }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
{{/* The owner's administrative page: everything that reaches past one Reader,
|
||||
at its own address so it can be bookmarked rather than hunted for inside
|
||||
the reading page. Owner-only at route registration (requireOwner), which
|
||||
is why nothing in here re-tests who is asking. */}}
|
||||
{{/* Every owner-only address shares this shell; page content stays behind its
|
||||
bookmarkable route so the active tab survives a reload. */}}
|
||||
{{define "admin"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
@@ -9,30 +7,53 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
{{/* Same request timeout as the library shell: the Lanes fragment refreshes
|
||||
itself every 30s, so an untimed hung request stacks. */}}
|
||||
<meta name="htmx-config" content='{"timeout":15000}'>
|
||||
<title>BookmarkManager — Admin</title>
|
||||
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/admin.css">
|
||||
<link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/static/fonts/dm-sans-var-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
<script src="/static/filter.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sheet">
|
||||
<div class="sheet admin-sheet">
|
||||
<header class="topbar">
|
||||
<h1 class="brand">{{template "mark" .}}<span>Bookmark<em>Manager</em></span></h1>
|
||||
{{/* Back to the library, no switch: this page belongs to neither library,
|
||||
and the ember-lit switch says which library you are reading. */}}
|
||||
<a class="ghost back" href="/">Library</a>
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit" class="ghost">Log out</button>
|
||||
</form>
|
||||
<span class="topbar-actions">
|
||||
<a class="ghost" href="/">Library</a>
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit" class="ghost">Log out</button>
|
||||
</form>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{{/* The live region wraps the swapped block rather than being it: the
|
||||
refresh replaces the section wholesale, and a region recreated on every
|
||||
update is never announced. */}}
|
||||
<div aria-live="polite">{{template "lanes" .Lanes}}</div>
|
||||
<nav class="navrow" aria-label="Admin pages">
|
||||
<a href="/admin" class="{{if eq .Page "overview"}}active{{end}}" {{if eq .Page "overview"}}aria-current="page"{{end}}>Overview</a>
|
||||
<a href="/admin/lanes" class="{{if eq .Page "lanes"}}active{{end}}" {{if eq .Page "lanes"}}aria-current="page"{{end}}>Lanes</a>
|
||||
<a href="/admin/readers" class="{{if eq .Page "readers"}}active{{end}}" {{if eq .Page "readers"}}aria-current="page"{{end}}>Readers</a>
|
||||
<a href="/admin/series" class="{{if or (eq .Page "series") (eq .Page "series-detail")}}active{{end}}" {{if or (eq .Page "series") (eq .Page "series-detail")}}aria-current="page"{{end}}>Series</a>
|
||||
</nav>
|
||||
|
||||
{{template "readers" .}}
|
||||
{{/* Where a failure with no specific slot gets reported — same slot the
|
||||
reader library uses under its chrome. filter.js fills it. */}}
|
||||
<p class="notice" id="notice" role="status" hidden></p>
|
||||
<div id="sr-announce" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
|
||||
<main class="page admin-page">
|
||||
{{if eq .Page "lanes"}}
|
||||
{{template "lanes" .Lanes}}
|
||||
{{else if eq .Page "readers"}}
|
||||
{{template "readers" .}}
|
||||
{{else if eq .Page "series"}}
|
||||
{{template "series-list" .SeriesList}}
|
||||
{{else if eq .Page "series-detail"}}
|
||||
{{template "series-detail" .Detail}}
|
||||
{{else}}{{template "overview" .Overview}}{{end}}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
{{/* htmx defaults to no timeout, so a phone that walks into a dead zone
|
||||
leaves the XHR open forever and the card it came from keeps its busy bar
|
||||
and its disabled actions until a reload. 15s is well past any response
|
||||
this deployment produces. */}}
|
||||
<meta name="htmx-config" content='{"timeout":15000}'>
|
||||
<title>BookmarkManager</title>
|
||||
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
@@ -53,38 +58,39 @@
|
||||
<a href="{{.PageURL "all"}}" class="{{if eq .Tab "all"}}active{{end}}"
|
||||
{{if eq .Tab "all"}}aria-current="page"{{end}}
|
||||
hx-get="{{.ListURL "all"}}" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="{{.PageURL "all"}}" hx-on::after-request="setActiveTab(this)">All</a>
|
||||
hx-push-url="{{.PageURL "all"}}" hx-on::after-request="if (event.detail.successful) setActiveTab(this)">All</a>
|
||||
{{/* The one bucket novels do not have: without a poller-fed "what is out
|
||||
that I have not read", the tab would only ever restate All. */}}
|
||||
{{if eq .Lib "manga"}}
|
||||
<a href="{{.PageURL "new"}}" class="tab-new {{if eq .Tab "new"}}active{{end}}"
|
||||
{{if eq .Tab "new"}}aria-current="page"{{end}}
|
||||
hx-get="{{.ListURL "new"}}" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="{{.PageURL "new"}}" hx-on::after-request="setActiveTab(this)">Updated
|
||||
hx-push-url="{{.PageURL "new"}}" hx-on::after-request="if (event.detail.successful) setActiveTab(this)">Updated
|
||||
{{template "newcount" .}}</a>
|
||||
{{end}}
|
||||
<a href="{{.PageURL "fav"}}" class="{{if eq .Tab "fav"}}active{{end}}"
|
||||
{{if eq .Tab "fav"}}aria-current="page"{{end}}
|
||||
hx-get="{{.ListURL "fav"}}" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="{{.PageURL "fav"}}" hx-on::after-request="setActiveTab(this)">Favourites</a>
|
||||
hx-push-url="{{.PageURL "fav"}}" hx-on::after-request="if (event.detail.successful) setActiveTab(this)">Favourites</a>
|
||||
<a href="{{.PageURL "archived"}}" class="{{if eq .Tab "archived"}}active{{end}}"
|
||||
{{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>
|
||||
hx-push-url="{{.PageURL "archived"}}" hx-on::after-request="if (event.detail.successful) setActiveTab(this)">Archived</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{{/* Where a failure with no card to sit in gets reported: a tab switch, a
|
||||
rotation, an expired session. filter.js fills it and unhides it. */}}
|
||||
<p class="notice" id="notice" role="status" hidden></p>
|
||||
<div id="sr-announce" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
|
||||
{{template "setup" .}}
|
||||
|
||||
{{template "keyrow" .}}
|
||||
|
||||
{{template "recent" .}}
|
||||
|
||||
<main id="list" class="list">
|
||||
<main id="list" class="list" tabindex="-1">
|
||||
{{template "list" .}}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
{{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">
|
||||
<a class="cover" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer"
|
||||
tabindex="-1" aria-hidden="true">
|
||||
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
|
||||
{{/* aria-hidden on the cover link is not enough — Chromium still exposes
|
||||
the letter because the link is programmatically focusable — so the
|
||||
monogram carries its own, same as the recent strip's. */}}
|
||||
{{else}}<span class="monogram" aria-hidden="true">{{.Initial}}</span>{{end}}
|
||||
monogram carries its own, same as the recent strip's.
|
||||
|
||||
The monogram is always in the DOM under the image, not only when no
|
||||
cover was acquired: a stored cover that 404s or a phone that drops
|
||||
the request otherwise leaves the browser's broken-image glyph in a
|
||||
93px slot. onerror removes the image and the letter is already
|
||||
there. */}}
|
||||
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy" onerror="this.remove()">{{end}}
|
||||
<span class="monogram" aria-hidden="true">{{.Initial}}</span>
|
||||
{{if and (eq .Status "reading") .HasNewChapter}}<span class="foot-rule"></span>
|
||||
{{else if .Favorite}}<span class="foot-rule brass"></span>{{end}}
|
||||
</a>
|
||||
@@ -32,9 +38,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>
|
||||
@@ -45,18 +52,19 @@
|
||||
</a>
|
||||
<button class="fav{{if .Favorite}} on{{end}}"
|
||||
title="{{if .Favorite}}Remove from favourites{{else}}Add to favourites{{end}}"
|
||||
aria-label="Toggle favourite"
|
||||
aria-label="{{if .Favorite}}Remove from favourites{{else}}Add to favourites{{end}}"
|
||||
aria-pressed="{{if .Favorite}}true{{else}}false{{end}}"
|
||||
hx-post="/ui/bookmarks/{{.Key}}/favorite"
|
||||
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
|
||||
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-star{{if .Favorite}}-on{{end}}"/></svg>
|
||||
</button>
|
||||
<button class="pencil" title="Set chapter" aria-label="Set chapter"
|
||||
<button class="pencil" title="Set chapter" aria-label="Set chapter" aria-expanded="false" aria-controls="chapter-form-{{.Key}}"
|
||||
onclick="toggleChapterForm('{{.Key}}')">
|
||||
<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 +79,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')">
|
||||
@@ -94,8 +95,11 @@
|
||||
label names the field and the latest sits after it as context. */}}
|
||||
<label class="hint" for="chapter-{{.Key}}">Chapter you're on</label>
|
||||
<div class="field">
|
||||
{{/* max is a fat-finger guard, not a real ceiling — no series is near it. */}}
|
||||
<input id="chapter-{{.Key}}" name="chapter" type="number" step="0.1" min="0" max="9999"
|
||||
{{/* max is a fat-finger guard, not a real ceiling — no series is near it.
|
||||
step is "any" because the server accepts any float and the field is
|
||||
pre-filled from it: under step=0.1 a series read to 1200.25 opened a
|
||||
form that refused its own value. */}}
|
||||
<input id="chapter-{{.Key}}" name="chapter" type="number" step="any" min="0" max="9999"
|
||||
value="{{.LastChapterNum}}" required>
|
||||
<button type="submit">Save</button>
|
||||
</div>
|
||||
@@ -116,18 +120,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>
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
<a class="recent-card {{if .HasNewChapter}}is-new{{end}}" href="{{.ContinueURL}}"
|
||||
target="_blank" rel="noopener noreferrer">
|
||||
<span class="recent-cover">
|
||||
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
|
||||
{{else}}<span class="monogram" aria-hidden="true">{{.Initial}}</span>{{end}}
|
||||
{{/* Monogram always present under the image; see card.html. */}}
|
||||
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy" onerror="this.remove()">{{end}}
|
||||
<span class="monogram" aria-hidden="true">{{.Initial}}</span>
|
||||
{{if .HasNewChapter}}<span class="foot-rule"></span>
|
||||
{{else if .Favorite}}<span class="foot-rule brass"></span>{{end}}
|
||||
</span>
|
||||
@@ -29,20 +30,16 @@
|
||||
|
||||
{{/* 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")}}
|
||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg><span>Restore</span></span>
|
||||
<span class="pair clay"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg><span>Chapter</span></span>
|
||||
{{if eq .Tab "archived"}}
|
||||
<span class="pair slate"><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>
|
||||
<span class="pair slate"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-box"/></svg><span>Archive</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,42 +1,63 @@
|
||||
{{/* Poll Lane status: one row per Site, refreshing itself so a run can be
|
||||
watched rather than sampled by reloading. The refresh is one attribute on
|
||||
the fragment root and the endpoint answers with this same fragment, so the
|
||||
swap replaces the element that asked for it.
|
||||
{{/* Poll Lane status: one row per Site's latest durable pass, refreshing
|
||||
itself so a run can be watched rather than sampled by reloading. The
|
||||
refresh is one attribute on the fragment root and the endpoint answers
|
||||
with this same fragment, so the swap replaces the element that asked.
|
||||
|
||||
Every figure here is read out of the running poller, never out of a table:
|
||||
a Site absent from Rows has not completed a pass since the last restart,
|
||||
which the empty state must say — zeroes would read as a stopped Lane. */}}
|
||||
Every figure is read from poll_passes, never from a running poller: a
|
||||
restart answers from the database the moment it is up (issue #145). The
|
||||
browser fact is a deployment-config fact plus a reachability derived from
|
||||
the pass log; the cause chips and the state phrase are decided in Go,
|
||||
this template only prints them. */}}
|
||||
{{define "lanes"}}
|
||||
<section class="lanes" id="lanes"
|
||||
hx-get="/ui/admin/lanes" hx-trigger="every 30s" hx-swap="outerHTML">
|
||||
<h2>Poll Lanes</h2>
|
||||
hx-get="/ui/admin/lanes" hx-trigger="every 30s" hx-swap="outerHTML" hx-sync="this:replace">
|
||||
<div class="sechead">
|
||||
<h2 class="sec">Poll Lanes</h2>
|
||||
<p class="statusline">
|
||||
{{if .PollerOff}}Polling: <span class="mark-faint">off</span>
|
||||
{{else}}Browser: {{if not .BrowserConfigured}}<span class="mark-faint">not configured</span>{{else if .BrowserReachable}}<span class="mark mark-strong">reachable</span>{{else}}<span class="mark bad">unreachable</span>{{end}}{{end}}
|
||||
</p>
|
||||
</div>
|
||||
<p class="hint">Healthy idle (paused, browser asleep, nothing eligible) needs no action — attention needs the owner.</p>
|
||||
{{if .Rows}}
|
||||
<ul class="lanelist">
|
||||
<div class="tbl lanes">
|
||||
<div class="thead">
|
||||
<span>Site</span><span>Due</span><span>Checked</span><span>Gap</span>
|
||||
<span>Last pass</span><span title="Window outcomes for this Site (last 12h)">Outcomes · status</span><span></span>
|
||||
</div>
|
||||
{{range .Rows}}
|
||||
<li{{if .Attention}} class="attention"{{end}}>
|
||||
<span class="lane-site">{{.Site}}</span>
|
||||
<span class="lane-fact">{{.Due}} due</span>
|
||||
<span class="lane-fact">{{.Checked}} checked</span>
|
||||
<span class="lane-fact">ran {{.Ran}}</span>
|
||||
{{if .Gap}}<span class="lane-fact">gap {{.Gap}}</span>{{end}}
|
||||
{{if .Clamped}}<span class="lane-mark">gap at floor</span>{{end}}
|
||||
{{if .Refusing}}<span class="lane-mark">refusing</span>{{end}}
|
||||
{{if .BrowserLost}}<span class="lane-mark">no browser</span>{{end}}
|
||||
{{if .Stalled}}<span class="lane-mark">not checking</span>{{end}}
|
||||
{{if .Asleep}}<span class="lane-mark">browser asleep</span>{{end}}
|
||||
</li>
|
||||
<div class="trow{{if .Attention}} attention{{end}}">
|
||||
<a class="c-site" href="{{.FailingHref}}">{{.Site}}</a>
|
||||
{{/* data-label is the column word for the phone layout, which drops the
|
||||
thead: three bare numbers in a row say nothing. */}}
|
||||
<span data-label="due">{{.Due}}</span>
|
||||
<span data-label="checked">{{.Checked}}</span>
|
||||
<span data-label="gap">{{.Gap}}</span>
|
||||
<span>ran {{.Ran}}</span>
|
||||
<span class="c-skip">{{if .HasChips}}{{range $i, $c := .Chips}}{{if $i}}<span class="mark-faint"> · </span>{{end}}<span class="mark">{{$c.Name}} {{$c.Count}}</span>{{end}}{{else}}<span class="mark-faint">none observed</span>{{end}}{{if .StatePhrase}} · <span class="{{if .StateGood}}ok{{else}}bad{{end}}" aria-label="{{if .StateGood}}healthy: {{end}}{{.StatePhrase}}">{{.StatePhrase}}</span>{{end}}</span>
|
||||
{{/* The pause control lives in the one slot the design leaves for it:
|
||||
a running Lane offers the three durations and Pause; a paused Lane
|
||||
offers Resume in the same place. Pause is not destruction — it
|
||||
takes nothing away and reverses in one press — so neither wears a
|
||||
confirm row or the danger accent. The form wraps the select so the
|
||||
offered duration travels with the press. */}}
|
||||
<span class="c-ctrl">{{if .Paused}}<span class="pausebar">
|
||||
<form hx-post="/admin/lanes/{{.Site}}/resume" hx-target="#lanes" hx-swap="outerHTML" hx-indicator="#lanes" hx-disabled-elt="find button" hx-sync="closest #lanes:replace">
|
||||
<button type="submit" class="ghost">Resume</button>
|
||||
</form>
|
||||
</span>{{else}}<span class="pausebar">
|
||||
<form hx-post="/admin/lanes/{{.Site}}/pause" hx-target="#lanes" hx-swap="outerHTML" hx-indicator="#lanes" hx-disabled-elt="find button, select" hx-sync="closest #lanes:replace">
|
||||
<select name="duration" aria-label="Pause duration">
|
||||
<option>1h</option><option selected>6h</option><option>24h</option>
|
||||
</select>
|
||||
<button type="submit" class="ghost">Pause</button>
|
||||
</form>
|
||||
</span>{{end}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="setup-copy">No data yet — no Lane has completed a pass since the
|
||||
backend started.</p>
|
||||
<p class="empty">No data yet — no Lane has recorded a pass.</p>
|
||||
{{end}}
|
||||
<p class="setup-copy lane-browser">
|
||||
{{if .PollerOff}}Polling is switched off in this deployment: no Lane runs,
|
||||
and Latest Chapter comes from the userscripts alone.
|
||||
{{else}}Browser sidecar:
|
||||
{{if not .BrowserConfigured}}not configured — comix, kagane and novelfull
|
||||
pages are not fetched through it{{else if .BrowserReachable}}reachable
|
||||
{{else}}unreachable{{end}}.{{end}}</p>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{{/* The client filter only hides cards, so without this the list area goes
|
||||
blank on a query that matches nothing. filter.js fills in the query and
|
||||
unhides it; it lives inside #list so a tab swap re-creates it. */}}
|
||||
<div class="empty" id="no-match" hidden>
|
||||
<div class="empty" id="no-match" role="status" hidden>
|
||||
<strong>No titles match “<span class="no-match-q"></span>”.</strong>
|
||||
<button type="button" class="clear-search">Clear search</button>
|
||||
</div>
|
||||
@@ -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. */}}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/static/fonts/dm-sans-var-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
</head>
|
||||
<body>
|
||||
<main class="login-card">
|
||||
@@ -25,7 +26,7 @@
|
||||
<p class="error" role="alert">{{.Error}}</p>
|
||||
<button type="submit">Continue with Discord</button>
|
||||
</form>
|
||||
<p class="login-note">Guild membership is required to sign in.</p>
|
||||
<p class="login-note">{{if .GuildName}}This library is for members of the {{.GuildName}} Discord. Ask a member for an invite to sign in.{{else}}This library is private to its Discord community. Ask a member for an invite to sign in.{{end}}</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{{/* The Overview landing page: one verdict line leading, then a stats block
|
||||
where every figure is a door into the list that counts it, and the
|
||||
per-Site library shape table. Every judgement — the verdict state, which
|
||||
figures link, what a Lane's state means — is made in Go; this template
|
||||
only prints. */}}
|
||||
{{define "overview"}}
|
||||
<p class="verdict"><span class="attn">{{.Verdict}}</span> {{if .HasCounts}}<span class="counts">· <b>{{.Waiting}}</b> series waiting · <b>{{.Unchecked}}</b> unchecked over 12h{{if ne .Verdict "all lanes healthy"}} · <a href="/admin/lanes">review Lanes</a>{{end}}</span>{{end}}</p>
|
||||
<h2 class="sec">Hygiene</h2>
|
||||
<div class="stats">{{range .Hygiene}}<div class="stat"><span class="lbl">{{.Label}}</span>{{if .Href}}<a class="fig" href="{{.Href}}">{{.Count}}</a>{{else}}<span class="fig zero">{{.Count}}</span>{{end}}</div>{{end}}</div>
|
||||
<h2 class="sec">Library</h2>
|
||||
<div class="stats">{{range .Library}}<div class="stat"><span class="lbl">{{.Label}}</span>{{if .Href}}<a class="fig" href="{{.Href}}">{{.Count}}</a>{{else}}<span class="fig zero">{{.Count}}</span>{{end}}</div>{{end}}</div>
|
||||
{{/* data-label carries each cell's column word for the phone layout, which
|
||||
drops the thead: the CSS prints it as the cell's prefix. */}}
|
||||
<h2 class="sec">Sites · last 12h</h2>
|
||||
{{if .Sites}}<div class="tbl sites"><div class="thead"><span>Site</span><span>Series</span><span>No cover</span><span>Never checked</span><span title="Stale: not checked in 12h window">Stale</span><span title="Lane status — same vocabulary as Poll Lanes">Status</span></div>{{range .Sites}}<div class="trow"><a class="c-site site-{{.Site}}" href="{{.SiteHref}}">{{.Site}}</a>{{range .Figs}}{{if .Href}}<a class="fig" data-label="{{.Label}}" href="{{.Href}}">{{.Count}}</a>{{else}}<span class="fig zero" data-label="{{.Label}}">{{.Count}}</span>{{end}}{{end}}<span class="c-state{{if .StateGood}} ok{{end}}{{if .StateBad}} bad{{end}}"{{if .State}} aria-label="{{if .StateGood}}healthy: {{end}}{{.State}}"{{end}}>{{.State}}</span></div>{{end}}</div>{{else}}<p class="empty">No sites yet — bookmarked series will appear here once the userscript records a chapter.</p>{{end}}
|
||||
{{end}}
|
||||
@@ -13,9 +13,10 @@
|
||||
often a later Poll confirmed or contradicted what that Reader's browser
|
||||
reported; enough contradictions stop their reports deferring a Poll, and
|
||||
clearing the marks gives that back.</p>
|
||||
{{if .Readers}}
|
||||
<ul class="readerlist">
|
||||
{{range .Readers}}
|
||||
<li>
|
||||
<li id="reader-{{.ID}}">
|
||||
<span class="reader-id">{{.DiscordID}}</span>
|
||||
<span class="reader-sessions">{{.Sessions}} session{{if ne .Sessions 1}}s{{end}}</span>
|
||||
<span class="reader-sightings">{{.Agreements}} confirmed / {{.Disagreements}} contradicted</span>
|
||||
@@ -23,26 +24,42 @@
|
||||
numbers and a threshold. */}}
|
||||
{{if .Blocked}}<span class="reader-blocked">deferral blocked</span>{{end}}
|
||||
<span class="reader-actions">
|
||||
{{/* Clearing restores a privilege, so it is a plain ghost button —
|
||||
the destruction accent belongs to revocation alone. It is offered
|
||||
on every row, including one reading zero: the remedy must be
|
||||
findable before the counters climb, not after. */}}
|
||||
<form hx-post="/readers/{{.ID}}/clear-marks" hx-target="#readers" hx-swap="outerHTML"
|
||||
hx-confirm="Clearing wipes this Reader's whole Sighting record, confirmations included. Clear?">
|
||||
<button type="submit" class="ghost">Clear marks</button>
|
||||
</form>
|
||||
{{/* The owner's own row never offers Revoke: it is the one row where the
|
||||
button would sign the tapping browser out, and the endpoint refuses
|
||||
it anyway. Logout is the deliberate way to do that. */}}
|
||||
{{/* Clearing restores a privilege, so its confirm wears the calm wash,
|
||||
not destruction. Revocation destroys sessions, so it wears danger.
|
||||
Both are confirm-gated — the opener never posts. */}}
|
||||
<button type="button" class="ghost" aria-expanded="false" aria-controls="confirm-clear-{{.ID}}"
|
||||
onclick="document.getElementById('confirm-clear-{{.ID}}').hidden = false; this.setAttribute('aria-expanded','true')">Clear marks</button>
|
||||
{{if and .Sessions (ne .ID $.OwnerID)}}
|
||||
<form hx-post="/readers/{{.ID}}/revoke" hx-target="#readers" hx-swap="outerHTML"
|
||||
hx-confirm="Revoking signs this Reader out on every device immediately. Revoke?">
|
||||
<button type="submit" class="ghost danger">Revoke sessions</button>
|
||||
</form>
|
||||
<button type="button" class="ghost danger" aria-expanded="false" aria-controls="confirm-revoke-{{.ID}}"
|
||||
onclick="document.getElementById('confirm-revoke-{{.ID}}').hidden = false; this.setAttribute('aria-expanded','true')">Revoke sessions</button>
|
||||
{{end}}
|
||||
</span>
|
||||
<div class="confirm-row calm" id="confirm-clear-{{.ID}}" role="group" aria-live="polite" hidden>
|
||||
<span>Clear this Reader's Sighting record?</span>
|
||||
<div>
|
||||
<button type="button" class="go"
|
||||
hx-post="/readers/{{.ID}}/clear-marks" hx-target="#readers" hx-swap="outerHTML"
|
||||
hx-indicator="#readers" hx-disabled-elt="this">Clear</button>
|
||||
<button type="button" onclick="document.getElementById('confirm-clear-{{.ID}}').hidden = true; var b=document.querySelector('[aria-controls=\'confirm-clear-{{.ID}}\']'); if(b){b.setAttribute('aria-expanded','false'); b.focus();}">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{{if and .Sessions (ne .ID $.OwnerID)}}
|
||||
<div class="confirm-row" id="confirm-revoke-{{.ID}}" role="group" aria-live="polite" hidden>
|
||||
<span>Revoke this Reader's sessions?</span>
|
||||
<div>
|
||||
<button type="button" class="danger-solid"
|
||||
hx-post="/readers/{{.ID}}/revoke" hx-target="#readers" hx-swap="outerHTML"
|
||||
hx-indicator="#readers" hx-disabled-elt="this">Revoke</button>
|
||||
<button type="button" onclick="document.getElementById('confirm-revoke-{{.ID}}').hidden = true; var b=document.querySelector('[aria-controls=\'confirm-revoke-{{.ID}}\']'); if(b){b.setAttribute('aria-expanded','false'); b.focus();}">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="empty">No readers yet — the roster appears after the first Discord sign-in.</p>
|
||||
{{end}}
|
||||
<p class="error-inline" role="status" hidden></p>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
{{/* 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. 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>
|
||||
<p class="detail-key">{{.Key}} · {{.Site}} · {{.Kind}}</p>
|
||||
{{if .Cover}}<div class="cover"><img src="{{.Cover}}" alt="" loading="lazy"></div>
|
||||
{{else}}<div class="cover" aria-hidden="true"><span>no cover</span></div>{{end}}
|
||||
{{template "series-detail-meta" .}}
|
||||
<div class="detail-grid">
|
||||
<form class="dform" hx-post="/admin/series/{{.Key}}/latest" hx-target="#detail-meta" hx-swap="outerHTML" hx-indicator="closest .dform" hx-disabled-elt="find button, input">
|
||||
<h3>Correct latest chapter</h3>
|
||||
<div class="field">
|
||||
<label class="sr-only" for="chapter-{{.Key}}">Latest chapter number</label>
|
||||
<input id="chapter-{{.Key}}" type="number" name="chapter" step="any" placeholder="{{.Chapter}}" required aria-describedby="hint-latest-{{.Key}}">
|
||||
<button type="submit" class="ghost">Save chapter</button>
|
||||
</div>
|
||||
<p class="hint" id="hint-latest-{{.Key}}">The next successful Poll overwrites this value. Use this only when the Poll is failing or the page is wrong.</p>
|
||||
{{if .Unverified}}<p class="hint">{{.Unverified}}</p>{{end}}
|
||||
<p class="error-inline" role="status" hidden></p>
|
||||
</form>
|
||||
<form class="dform" hx-post="/admin/series/{{.Key}}/series-url" hx-target="#detail-meta" hx-swap="outerHTML" hx-indicator="closest .dform" hx-disabled-elt="find button, input">
|
||||
<h3>Repair series URL</h3>
|
||||
<div class="field">
|
||||
<label class="sr-only" for="url-{{.Key}}">Series page URL</label>
|
||||
<input id="url-{{.Key}}" type="url" name="series_url" value="{{.URL}}" required aria-describedby="hint-url-{{.Key}}">
|
||||
<button type="submit" class="ghost">Save URL</button>
|
||||
</div>
|
||||
<p class="hint" id="hint-url-{{.Key}}">The Poll fetches this address. Saving does not verify it — A Site-wide host change is a SQL migration, not per-series edits.</p>
|
||||
<p class="error-inline" role="status" hidden></p>
|
||||
</form>
|
||||
</div>
|
||||
{{if .CanPoll}}
|
||||
<div class="dform">
|
||||
<div class="field"><button type="button" class="ghost"
|
||||
hx-post="/admin/series/{{.Key}}/poll" hx-target="#detail-meta" hx-swap="outerHTML"
|
||||
hx-indicator="#detail-meta" hx-disabled-elt="this">Check now</button></div>
|
||||
<p class="error-inline" role="status" hidden></p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .CanRemove}}
|
||||
<div class="dform">
|
||||
<div class="field"><button type="button" class="ghost danger"
|
||||
aria-expanded="false" aria-controls="confirm-remove"
|
||||
onclick="document.getElementById('confirm-remove').hidden = false; this.setAttribute('aria-expanded','true')">Remove</button></div>
|
||||
<div class="confirm-row" id="confirm-remove" role="group" aria-live="polite" hidden>
|
||||
<span>Remove “{{.Title}}”? Stored cover is lost.</span>
|
||||
<div>
|
||||
<button type="button" class="danger-solid"
|
||||
hx-post="/admin/series/{{.Key}}/remove" hx-target="#detail-meta"
|
||||
hx-indicator="#detail-meta" hx-disabled-elt="this">Remove</button>
|
||||
<button type="button" onclick="document.getElementById('confirm-remove').hidden = true; document.querySelector('.dform .danger').setAttribute('aria-expanded','false')">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="error-inline" role="status" hidden></p>
|
||||
</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"
|
||||
hx-indicator="#detail-meta" hx-disabled-elt="this">Un-finish</button></div>
|
||||
<p class="error-inline" role="status" hidden></p>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="dform">
|
||||
<div class="field"><button type="button" class="ghost" aria-expanded="false" aria-controls="confirm-finish"
|
||||
onclick="document.getElementById('confirm-finish').hidden = false; this.setAttribute('aria-expanded','true')">Finish</button></div>
|
||||
{{if .SiteCompleted}}<p class="hint">{{.SiteCompleted}}</p>{{end}}
|
||||
<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"
|
||||
hx-indicator="#detail-meta" hx-disabled-elt="this">Finish</button>
|
||||
<button type="button" onclick="document.getElementById('confirm-finish').hidden = true; document.querySelector('[aria-controls=confirm-finish]').setAttribute('aria-expanded','false')">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="error-inline" role="status" hidden></p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{/* 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" aria-live="polite">
|
||||
<span>ch {{.Chapter}}</span>
|
||||
{{if .Provenance}}<span>{{.Provenance}}</span>{{end}}
|
||||
<span>checked {{.Checked}}</span>
|
||||
<span>{{.Readers}} reader{{if ne .Readers 1}}s{{end}}</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}}
|
||||
@@ -0,0 +1,81 @@
|
||||
{{/* The Series list: every Series across every Reader's library, filtered by
|
||||
one hygiene rule and narrowed by Site and Library. Filter, Site, Library
|
||||
and page all live in the query string, so the list's state is an address
|
||||
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" id="filterbar" method="get" action="/admin/series">
|
||||
<input type="hidden" name="kind" value="{{.Kind}}">
|
||||
<label class="fsel"><span>Filter</span><select name="filter" onchange="this.form.submit()" aria-label="Filter series — changes apply immediately">
|
||||
{{range .Filters}}<option value="{{.Name}}"{{if .Selected}} selected{{end}}>{{.Label}} ({{.Count}})</option>{{end}}
|
||||
</select></label>
|
||||
<label class="fsel"><span>Site</span><select name="site" onchange="this.form.submit()" aria-label="Filter by site — changes apply immediately">
|
||||
<option value=""{{if not .Site}} selected{{end}}>All sites</option>
|
||||
{{range .Sites}}<option value="{{.}}"{{if eq $.Site .}} selected{{end}}>{{.}}</option>{{end}}
|
||||
</select></label>
|
||||
<noscript><button type="submit" class="ghost">Apply</button></noscript>
|
||||
<span class="segrow">
|
||||
<a href="{{.KindBoth}}"{{if not .Kind}} class="active"{{end}}>both</a>
|
||||
<a href="{{.KindManga}}"{{if eq .Kind "manga"}} class="active"{{end}}>manga</a>
|
||||
<a href="{{.KindNovel}}"{{if eq .Kind "novel"}} class="active"{{end}}>novels</a>
|
||||
</span>
|
||||
</form>
|
||||
{{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>
|
||||
{{range .Rows}}{{template "series-row" .}}{{end}}
|
||||
</div>
|
||||
<div class="pager">
|
||||
{{if .PrevHref}}<a class="pg" href="{{.PrevHref}}">‹ prev</a>{{else}}<span class="pg disabled">‹ prev</span>{{end}}
|
||||
<span>{{.Range}}</span>
|
||||
{{if .NextHref}}<a class="pg" href="{{.NextHref}}">next ›</a>{{else}}<span class="pg disabled">next ›</span>{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty"><strong>No series</strong><p>Nothing matches <em>{{.FilterLabel}}</em>.</p></div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{/* series-row is one Series list row, and the answer a Check now press swaps
|
||||
into the row's place (hx-target="closest .trow"): it must render the
|
||||
pending marker the press created. The control is absent on a Series with
|
||||
no page to fetch and on an orphan, so the owner is never offered a button
|
||||
that can never do anything. */}}
|
||||
{{define "series-row"}}
|
||||
<div class="trow{{if .Attention}} attention{{end}}{{if .Band}} band{{end}}" id="row-{{.Key}}">
|
||||
<span class="c-title"><a href="/admin/series/{{.Key}}">{{.Title}}</a>{{if .Pending}}<span class="mark">{{.Requested}}</span>{{end}}</span>
|
||||
<span class="c-site site-{{.Site}}">{{.Site}}</span>
|
||||
{{/* data-label is the column word for the phone layout, which drops the
|
||||
thead: "1200.25 3d 4" says nothing without it. */}}
|
||||
<span class="c-ch" data-label="ch">{{.Ch}}</span>
|
||||
<span data-label="checked">{{.Age}}</span>
|
||||
<span class="c-rd" data-label="readers">{{.Readers}}</span>
|
||||
<span class="c-note">{{if .Failure}}<span class="mark">{{.Failure}}</span>{{end}}{{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}}<button type="button" class="ghost"
|
||||
hx-post="/admin/series/{{.Key}}/poll" hx-target="closest .trow" hx-swap="outerHTML"
|
||||
hx-indicator="closest .trow" hx-disabled-elt="this"
|
||||
hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}'>Check now</button>{{end}}{{if .CanRemove}}<button type="button" class="ghost danger"
|
||||
aria-expanded="false" aria-controls="confirm-remove-{{.Key}}"
|
||||
onclick="document.getElementById('confirm-remove-{{.Key}}').hidden = false; this.setAttribute('aria-expanded','true')">Remove</button>{{end}}</span>
|
||||
{{if .RemovalRefused}}<span class="row-msg">a Reader has bookmarked this Series again</span>{{end}}
|
||||
{{if .CanRemove}}<div class="confirm-row" id="confirm-remove-{{.Key}}" role="group" aria-live="polite" hidden>
|
||||
<span>Remove “{{.Title}}”? Progress is lost.</span>
|
||||
<div>
|
||||
<button type="button" class="danger-solid"
|
||||
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-indicator="closest .trow" hx-disabled-elt="this">Remove</button>
|
||||
<button type="button" onclick="document.getElementById('confirm-remove-{{.Key}}').hidden = true; document.querySelector('#row-{{.Key}} .c-act .danger').setAttribute('aria-expanded','false')">Cancel</button>
|
||||
</div>
|
||||
</div>{{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}}
|
||||
+85
-31
@@ -3,6 +3,7 @@ package web
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
@@ -29,6 +30,14 @@ var staticFS embed.FS
|
||||
// RecentCount is how many series the "Continue reading" strip shows.
|
||||
const RecentCount = 5
|
||||
|
||||
// maxChapterNum bounds a chapter number a Reader or the owner types. The
|
||||
// number reaches the store as a float64, so without a ceiling a hand-rolled
|
||||
// POST stores 1e308 and every later reader of that row — the poller's
|
||||
// HasNewChapter comparison, the display string — inherits it. Matches the
|
||||
// `max` on the card's chapter input; no real series is within three orders of
|
||||
// magnitude of it.
|
||||
const maxChapterNum = 9999
|
||||
|
||||
// Handler serves the browser UI: full pages at / and htmx fragments at /ui/.
|
||||
// It is a separate handler from api.Handler because the two speak different
|
||||
// representations (HTML versus JSON) to different clients under different auth.
|
||||
@@ -50,9 +59,14 @@ type Handler struct {
|
||||
// httpClient is the plain stdlib client that talks to Discord. It is not
|
||||
// an injected interface: tests point APIBase at a stub server instead.
|
||||
httpClient *http.Client
|
||||
// lanes is the Poll Lane snapshot source the administrative page reads.
|
||||
// Nil is a running deployment with no poller, not a bug.
|
||||
lanes LaneReporter
|
||||
// pollerEnabled reports whether latest-chapter polling is switched on in
|
||||
// this deployment (LATEST_CHAPTER_POLL_ENABLED) and browserConfigured
|
||||
// whether a browser sidecar is configured (BROWSER_WS_URL set). Both are
|
||||
// deployment facts resolved by the composition root; the Lanes page (issue
|
||||
// #145) reports them from config and derives reachability from the pass
|
||||
// log rather than from whether a poller goroutine happened to start.
|
||||
pollerEnabled bool
|
||||
browserConfigured bool
|
||||
}
|
||||
|
||||
// listView is what every list-rendering template receives.
|
||||
@@ -104,15 +118,18 @@ func (v listView) ListURL(tab string) string {
|
||||
|
||||
// loginView is what the login template receives.
|
||||
type loginView struct {
|
||||
Error string
|
||||
Error string
|
||||
GuildName string
|
||||
}
|
||||
|
||||
// New parses every template up front so a broken one kills the process at
|
||||
// startup rather than the first request that touches it.
|
||||
//
|
||||
// lanes is the administrative page's window onto the running Poller; nil means
|
||||
// nothing is polling, which the page reports rather than hides.
|
||||
func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string, lanes LaneReporter) (*Handler, error) {
|
||||
// pollerEnabled and browserConfigured are deployment facts the composition
|
||||
// root resolves from LATEST_CHAPTER_POLL_ENABLED and BROWSER_WS_URL: the Lanes
|
||||
// page (issue #145) reports them and derives browser reachability from the
|
||||
// pass log, so no running poller is wired through here at all.
|
||||
func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string, pollerEnabled, browserConfigured bool) (*Handler, error) {
|
||||
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -127,7 +144,8 @@ func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, nove
|
||||
states: newOAuthStates(),
|
||||
limiter: session.NewLoginLimiter(),
|
||||
httpClient: &http.Client{Timeout: discordTimeout},
|
||||
lanes: lanes,
|
||||
pollerEnabled: pollerEnabled,
|
||||
browserConfigured: browserConfigured,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -235,7 +253,7 @@ func (h *Handler) render(w http.ResponseWriter, status int, name string, data an
|
||||
func (h *Handler) index(w http.ResponseWriter, r *http.Request) {
|
||||
readerID, ok := h.sessionReader(r)
|
||||
if !ok {
|
||||
h.render(w, http.StatusOK, "login", loginView{})
|
||||
h.render(w, http.StatusOK, "login", loginView{GuildName: h.discord.GuildName})
|
||||
return
|
||||
}
|
||||
view, err := h.buildListView(readerID, libOf(r.URL.Query().Get("lib")), r.URL.Query().Get("tab"))
|
||||
@@ -285,10 +303,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 {
|
||||
@@ -319,8 +338,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
|
||||
@@ -384,7 +401,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
|
||||
@@ -411,12 +428,20 @@ func (h *Handler) refreshChrome(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
h.writeChromeOOB(w, view)
|
||||
}
|
||||
// writeAnnounceOOB appends a visually-hidden live region update out-of-band,
|
||||
// so a successful mutation announces itself without moving focus.
|
||||
func (h *Handler) writeAnnounceOOB(w http.ResponseWriter, msg string) {
|
||||
fmt.Fprintf(w, `<div id="sr-announce" class="sr-only" role="status" aria-live="polite" aria-atomic="true" hx-swap-oob="true">%s</div>`, template.HTMLEscapeString(msg))
|
||||
}
|
||||
func (h *Handler) writeNoticeOOB(w http.ResponseWriter, msg string) {
|
||||
fmt.Fprintf(w, `<p id="notice" class="notice" hx-swap-oob="true">%s</p>`, template.HTMLEscapeString(msg))
|
||||
}
|
||||
|
||||
// renderLogin renders the login page with an error message, for refused or
|
||||
// failed sign-ins. Every message is author-written text — nothing Discord
|
||||
// supplied is ever interpolated into a page.
|
||||
func (h *Handler) renderLogin(w http.ResponseWriter, status int, msg string) {
|
||||
h.render(w, status, "login", loginView{Error: msg})
|
||||
h.render(w, status, "login", loginView{Error: msg, GuildName: h.discord.GuildName})
|
||||
}
|
||||
|
||||
// logout revokes the session row and clears the cookie in one step: the next
|
||||
@@ -481,12 +506,19 @@ func (h *Handler) uiFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
b.Favorite = !b.Favorite
|
||||
b.UpdatedAt = time.Now().UnixMilli()
|
||||
msg := ""
|
||||
if b.Favorite {
|
||||
msg = fmt.Sprintf("Added %s to favourites", b.Title)
|
||||
} else {
|
||||
msg = fmt.Sprintf("Removed %s from favourites", b.Title)
|
||||
}
|
||||
h.saveAndRenderCard(w, r, b)
|
||||
h.writeAnnounceOOB(w, msg)
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -500,14 +532,21 @@ 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)
|
||||
return
|
||||
}
|
||||
b.UpdatedAt = time.Now().UnixMilli()
|
||||
msg := ""
|
||||
if b.Status == store.StatusArchived {
|
||||
msg = fmt.Sprintf("Archived %s", b.Title)
|
||||
} else {
|
||||
msg = fmt.Sprintf("Restored %s", b.Title)
|
||||
}
|
||||
h.saveAndRenderCard(w, r, b)
|
||||
h.writeAnnounceOOB(w, msg)
|
||||
}
|
||||
|
||||
// uiChapter forces the read chapter to a value the user typed.
|
||||
@@ -532,8 +571,8 @@ func (h *Handler) uiChapter(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
raw := strings.TrimSpace(r.PostFormValue("chapter"))
|
||||
num, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil || num < 0 || math.IsNaN(num) || math.IsInf(num, 0) {
|
||||
http.Error(w, "chapter must be a non-negative number", http.StatusBadRequest)
|
||||
if err != nil || num < 0 || num > maxChapterNum || math.IsNaN(num) || math.IsInf(num, 0) {
|
||||
http.Error(w, "chapter must be a non-negative number below 10000", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -543,27 +582,42 @@ func (h *Handler) uiChapter(w http.ResponseWriter, r *http.Request) {
|
||||
b.LastChapterNum = num
|
||||
}
|
||||
b.UpdatedAt = time.Now().UnixMilli()
|
||||
msg := fmt.Sprintf("Updated %s to chapter %s", b.Title, raw)
|
||||
h.saveAndRenderCard(w, r, b)
|
||||
h.writeAnnounceOOB(w, msg)
|
||||
}
|
||||
|
||||
// uiDelete removes the row and answers with an empty body, which htmx swaps in
|
||||
// place of the card — removing it from the page.
|
||||
func (h *Handler) uiDelete(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.PathValue("key")
|
||||
if key == "" {
|
||||
http.Error(w, "missing key", http.StatusBadRequest)
|
||||
b, ok := h.loadForMutation(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.store.Delete(readerOf(r), key); err != nil {
|
||||
log.Printf("ui delete %q: %v", key, err)
|
||||
if err := h.store.Delete(readerOf(r), b.Key); err != nil {
|
||||
log.Printf("ui delete %q: %v", b.Key, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
// The empty body is what removes the card; the chrome still has to be told
|
||||
// the library got smaller.
|
||||
h.refreshChrome(w, r)
|
||||
msg := fmt.Sprintf("Removed %s", b.Title)
|
||||
h.writeAnnounceOOB(w, msg)
|
||||
h.writeNoticeOOB(w, msg)
|
||||
view, err := h.buildListView(readerOf(r), currentLib(r), currentTab(r))
|
||||
if err != nil {
|
||||
log.Printf("ui chrome: %v", err)
|
||||
return
|
||||
}
|
||||
h.writeChromeOOB(w, view)
|
||||
if len(view.Items) == 0 {
|
||||
fmt.Fprint(w, `<main id="list" class="list" tabindex="-1" hx-swap-oob="true">`)
|
||||
if err := h.tmpl.ExecuteTemplate(w, "list", view); err != nil {
|
||||
log.Printf("render list oob: %v", err)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `</main>`)
|
||||
}
|
||||
}
|
||||
|
||||
// installUserscript renders the bindmounted script with the acting Reader's
|
||||
|
||||
+50
-17
@@ -14,6 +14,7 @@ import (
|
||||
"bookmarkmanager/backend/internal/api"
|
||||
"bookmarkmanager/backend/internal/httpmw"
|
||||
"bookmarkmanager/backend/internal/latest"
|
||||
"bookmarkmanager/backend/internal/notify"
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
"bookmarkmanager/backend/internal/token"
|
||||
"bookmarkmanager/backend/internal/userscript"
|
||||
@@ -54,6 +55,17 @@ type Config struct {
|
||||
// /u/{token}/novel-bookmark.user.js. Same bindmount, second script: the
|
||||
// two libraries are separate installs.
|
||||
NovelUserscriptPath string
|
||||
// BrowserWSURL is the CDP websocket the poller's browser Sites read
|
||||
// through. Set means a browser sidecar is configured in this deployment —
|
||||
// the Lanes page reports the fact and derives reachability from the pass
|
||||
// log rather than asking the poller (issue #145).
|
||||
BrowserWSURL string
|
||||
// DiscordWebhookURL is the webhook owner notices post to (issue #171).
|
||||
// Unset means the whole path is off — a local stack needs no webhook,
|
||||
// exactly as the browser URL behaves. The address is a secret in the
|
||||
// class of TOKEN_KEY: never logged, and it must not reach any line that
|
||||
// prints configuration.
|
||||
DiscordWebhookURL string
|
||||
// LatestPoll configures the background latest-chapter fetcher.
|
||||
LatestPoll LatestPoll
|
||||
}
|
||||
@@ -108,12 +120,15 @@ func loadConfig() Config {
|
||||
OwnerDiscordID: os.Getenv("OWNER_DISCORD_ID"),
|
||||
UserscriptPath: envOr("USERSCRIPT_PATH", "/userscript/manga-bookmark.user.js"),
|
||||
NovelUserscriptPath: envOr("NOVEL_USERSCRIPT_PATH", "/userscript/novel-bookmark.user.js"),
|
||||
BrowserWSURL: os.Getenv("BROWSER_WS_URL"),
|
||||
DiscordWebhookURL: os.Getenv("DISCORD_WEBHOOK_URL"),
|
||||
LatestPoll: loadLatestPoll(),
|
||||
}
|
||||
c.Discord = web.DiscordConfig{
|
||||
ClientID: os.Getenv("DISCORD_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("DISCORD_CLIENT_SECRET"),
|
||||
GuildID: os.Getenv("DISCORD_GUILD_ID"),
|
||||
GuildName: os.Getenv("DISCORD_GUILD_NAME"),
|
||||
RequiredRole: os.Getenv("DISCORD_REQUIRED_ROLE"),
|
||||
APIBase: envOr("DISCORD_API_BASE", "https://discord.com/api/v10"),
|
||||
RedirectURI: os.Getenv("DISCORD_REDIRECT_URI"),
|
||||
@@ -130,9 +145,10 @@ func loadConfig() Config {
|
||||
// preflight OPTIONS short-circuits before auth; /bookmarks* is auth-protected,
|
||||
// /healthz is public.
|
||||
//
|
||||
// lanes may be nil — polling disabled, or its client could not be built. The
|
||||
// admin page reports that rather than pretending Lanes exist.
|
||||
func newRouter(s *store.Store, cfg Config, lanes web.LaneReporter) http.Handler {
|
||||
// The web layer learns the deployment's poller and browser config from cfg —
|
||||
// nothing of the running poller is wired through here; the Lanes page reads
|
||||
// the database (issue #145).
|
||||
func newRouter(s *store.Store, cfg Config) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
h := &api.Handler{Store: s}
|
||||
mux.HandleFunc("GET /healthz", api.Healthz)
|
||||
@@ -161,9 +177,12 @@ func newRouter(s *store.Store, cfg Config, lanes web.LaneReporter) http.Handler
|
||||
mux.Handle("/bookmarks/", auth)
|
||||
|
||||
// The browser UI is always registered; signing in is Discord OAuth, so
|
||||
// there is no password to forget and no gate to leave unset.
|
||||
// there is no password to forget and no gate to leave unset. The poller
|
||||
// and browser facts are config, not the poller's: the Lanes page reads
|
||||
// the pass log and reports the deployment as configured.
|
||||
wh, err := web.New(s, cfg.Discord, []byte(cfg.TokenKey),
|
||||
cfg.UserscriptPath, cfg.NovelUserscriptPath, lanes)
|
||||
cfg.UserscriptPath, cfg.NovelUserscriptPath,
|
||||
cfg.LatestPoll.Enabled, strings.TrimSpace(cfg.BrowserWSURL) != "")
|
||||
if err != nil {
|
||||
log.Fatalf("web handler: %v", err)
|
||||
}
|
||||
@@ -241,7 +260,7 @@ func main() {
|
||||
var browser latest.Fetcher
|
||||
pollCtx, stopPoll := context.WithCancel(context.Background())
|
||||
defer stopPoll()
|
||||
if ws := strings.TrimSpace(os.Getenv("BROWSER_WS_URL")); ws != "" {
|
||||
if ws := strings.TrimSpace(cfg.BrowserWSURL); ws != "" {
|
||||
bf, err := latest.NewBrowserFetcher(ws)
|
||||
if err != nil {
|
||||
log.Printf("browser fetcher disabled: %v", err)
|
||||
@@ -278,17 +297,26 @@ func main() {
|
||||
}
|
||||
s.OnSeriesCreated = acq.Acquire
|
||||
}
|
||||
// A nil *Poller must not become a non-nil interface holding a nil pointer:
|
||||
// the admin page tests the reporter for nil to decide whether anything is
|
||||
// polling at all.
|
||||
var lanes web.LaneReporter
|
||||
if poller := startLatestPoller(pollCtx, s, cfg.LatestPoll, browser); poller != nil {
|
||||
lanes = poller
|
||||
// Owner notices (issue #171): a configured webhook makes the poller tell
|
||||
// the owner about stalled Lanes. Unset means the whole path is off — a
|
||||
// local stack needs no webhook, exactly as the browser URL behaves. Only
|
||||
// the presence is logged; the address itself is a secret in the class of
|
||||
// TOKEN_KEY.
|
||||
var notifier latest.Notifier
|
||||
if u := strings.TrimSpace(cfg.DiscordWebhookURL); u != "" {
|
||||
notifier = notify.New(u, cfg.PublicBaseURL)
|
||||
log.Println("owner notices: enabled")
|
||||
} else {
|
||||
log.Println("owner notices: disabled (DISCORD_WEBHOOK_URL unset)")
|
||||
}
|
||||
// The poller's only connection to the web layer is the database now: it is
|
||||
// started for its own sake, and the Lanes page reads the pass rows it
|
||||
// records (issue #145).
|
||||
startLatestPoller(pollCtx, s, cfg.LatestPoll, browser, notifier)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: newRouter(s, cfg, lanes),
|
||||
Handler: newRouter(s, cfg),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
@@ -317,7 +345,9 @@ func main() {
|
||||
|
||||
// newLatestPoller wires the fetcher seams into the poller. Pace is registry
|
||||
// property, not config (issue #100), so there are no knobs to pass through.
|
||||
func newLatestPoller(s *store.Store, cfg LatestPoll, fetch, browser latest.Fetcher) *latest.Poller {
|
||||
// notifier is nil when no webhook is configured: a missing webhook is a
|
||||
// silent off switch, not an error (issue #171).
|
||||
func newLatestPoller(s *store.Store, cfg LatestPoll, fetch, browser latest.Fetcher, notifier latest.Notifier) *latest.Poller {
|
||||
var covers latest.BrowserCoverFetcher
|
||||
if f, ok := browser.(latest.BrowserCoverFetcher); ok {
|
||||
covers = f
|
||||
@@ -328,6 +358,7 @@ func newLatestPoller(s *store.Store, cfg LatestPoll, fetch, browser latest.Fetch
|
||||
BrowserFetch: browser,
|
||||
CoverFetch: covers,
|
||||
CoverBytesFetch: latest.NewCoverFetcher(),
|
||||
Notify: notifier,
|
||||
Now: time.Now,
|
||||
}
|
||||
}
|
||||
@@ -336,8 +367,10 @@ func newLatestPoller(s *store.Store, cfg LatestPoll, fetch, browser latest.Fetch
|
||||
// HTTP client cannot be built. Any problem here is logged and skipped: this
|
||||
// feature going missing degrades the service to userscript-only latest-chapter
|
||||
// tracking, which is exactly how it behaved before. It returns the running
|
||||
// Poller, or nil when there is none — the admin page's Lane status reads it.
|
||||
func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll, browser latest.Fetcher) *latest.Poller {
|
||||
// Poller, or nil when there is none; the caller starts it for its own sake —
|
||||
// the Lanes page reads the pass log, so no return value is wired anywhere.
|
||||
// notifier is nil when DISCORD_WEBHOOK_URL is unset (issue #171).
|
||||
func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll, browser latest.Fetcher, notifier latest.Notifier) *latest.Poller {
|
||||
if !cfg.Enabled {
|
||||
log.Println("latest-chapter poller: disabled by config")
|
||||
return nil
|
||||
@@ -350,7 +383,7 @@ func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll, brow
|
||||
// Nil browser: sites behind a JavaScript challenge are simply not polled,
|
||||
// and their latest_chapter comes from the userscript alone — which is how
|
||||
// the service behaved before the sidecar existed.
|
||||
p := newLatestPoller(s, cfg, f, browser)
|
||||
p := newLatestPoller(s, cfg, f, browser, notifier)
|
||||
|
||||
go p.Run(ctx)
|
||||
return p
|
||||
|
||||
+24
-2
@@ -28,6 +28,20 @@ func TestLoadConfigReadsCoverDirectory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigReadsDiscordWebhook(t *testing.T) {
|
||||
// The address is read, never defaulted: unset stays empty (the whole
|
||||
// path is off), set flows into the Config for the poller's notifier.
|
||||
const url = "https://discord.com/api/webhooks/000000/secret"
|
||||
t.Setenv("DISCORD_WEBHOOK_URL", url)
|
||||
if got := loadConfig().DiscordWebhookURL; got != url {
|
||||
t.Fatalf("DiscordWebhookURL = %q, want %q", got, url)
|
||||
}
|
||||
t.Setenv("DISCORD_WEBHOOK_URL", "")
|
||||
if got := loadConfig().DiscordWebhookURL; got != "" {
|
||||
t.Fatalf("DiscordWebhookURL = %q, want empty when unset", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLatestPollEnabledParsing(t *testing.T) {
|
||||
tests := []struct {
|
||||
raw string
|
||||
@@ -51,7 +65,8 @@ func TestLoadLatestPollEnabledParsing(t *testing.T) {
|
||||
// nothing here sizes a cooldown any more.
|
||||
func TestNewLatestPollerWiresFetchers(t *testing.T) {
|
||||
tls := &latest.TLSFetcher{}
|
||||
p := newLatestPoller(nil, LatestPoll{Enabled: true}, tls, nil)
|
||||
notifier := &stubNotifier{}
|
||||
p := newLatestPoller(nil, LatestPoll{Enabled: true}, tls, nil, notifier)
|
||||
if p.Fetch != tls {
|
||||
t.Fatalf("Fetch not wired")
|
||||
}
|
||||
@@ -67,8 +82,15 @@ func TestNewLatestPollerWiresFetchers(t *testing.T) {
|
||||
if p.Now == nil {
|
||||
t.Fatalf("Now = nil, want the live clock")
|
||||
}
|
||||
if p.Notify != notifier {
|
||||
t.Fatalf("Notify = %v, want the configured notifier", p.Notify)
|
||||
}
|
||||
}
|
||||
|
||||
// stubNotifier satisfies latest.Notifier so newLatestPoller's wiring can be
|
||||
// asserted; it is never called.
|
||||
type stubNotifier struct{ latest.Notifier }
|
||||
|
||||
func TestPutStatusValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -78,7 +100,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 {
|
||||
|
||||
@@ -49,7 +49,7 @@ func withBody(req *http.Request, body string) *http.Request {
|
||||
// A refused credential is refused however plausible it looks: only a hash the
|
||||
// readers table holds authenticates anything.
|
||||
func TestUnknownCredentialRejected(t *testing.T) {
|
||||
srv := newRouter(newTestStore(t), testConfig(), nil)
|
||||
srv := newRouter(newTestStore(t), testConfig())
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", readerCredential("never-registered")))
|
||||
@@ -69,7 +69,7 @@ func TestUnknownCredentialRejected(t *testing.T) {
|
||||
func TestPerReaderIsolation(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
registerReader(t, s, "other-reader")
|
||||
srv := newRouter(s, testConfig(), nil)
|
||||
srv := newRouter(s, testConfig())
|
||||
|
||||
ownerKey := "asura:solo"
|
||||
putBookmark(t, srv, ownerKey, store.Bookmark{
|
||||
@@ -267,7 +267,7 @@ func TestRotateCredentialViaWebUI(t *testing.T) {
|
||||
}
|
||||
cfg := testConfig()
|
||||
cfg.UserscriptPath = path
|
||||
srv := newRouter(s, cfg, nil)
|
||||
srv := newRouter(s, cfg)
|
||||
|
||||
oldCred := ownerCredential()
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
+2848
-122
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -45,11 +45,13 @@ services:
|
||||
# in; distroless already carries tzdata, so the name just resolves.
|
||||
TZ: ${API_TZ:-Asia/Jakarta}
|
||||
# Discord OAuth for the browser UI (ADR-0002). The first four are
|
||||
# required; DISCORD_REQUIRED_ROLE is optional and empty by default.
|
||||
# Guild membership is the whole gate: any member becomes a Reader.
|
||||
# required; DISCORD_REQUIRED_ROLE and DISCORD_GUILD_NAME are optional and
|
||||
# empty by default. Guild membership is the whole gate: any member
|
||||
# becomes a Reader.
|
||||
DISCORD_CLIENT_ID: ${DISCORD_CLIENT_ID:?set DISCORD_CLIENT_ID in .env}
|
||||
DISCORD_CLIENT_SECRET: ${DISCORD_CLIENT_SECRET:?set DISCORD_CLIENT_SECRET in .env}
|
||||
DISCORD_GUILD_ID: ${DISCORD_GUILD_ID:?set DISCORD_GUILD_ID in .env}
|
||||
DISCORD_GUILD_NAME: ${DISCORD_GUILD_NAME:-}
|
||||
DISCORD_REQUIRED_ROLE: ${DISCORD_REQUIRED_ROLE:-}
|
||||
DISCORD_API_BASE: ${DISCORD_API_BASE:-https://discord.com/api/v10}
|
||||
DISCORD_REDIRECT_URI: ${DISCORD_REDIRECT_URI:?set DISCORD_REDIRECT_URI in .env}
|
||||
@@ -76,6 +78,12 @@ services:
|
||||
# independent of chromedp's own dial logic. The same trap that used to
|
||||
# force a pinned Docker IP now forbids the tailnet name.
|
||||
BROWSER_WS_URL: ${BROWSER_WS_URL:-}
|
||||
#
|
||||
# Owner-notice webhook (issue #171). Empty default, never a
|
||||
# required-guard: unset means the whole path is off, so a local stack
|
||||
# runs exactly as it does today. An env var not listed here never
|
||||
# reaches the container.
|
||||
DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL:-}
|
||||
depends_on:
|
||||
# The migration runner is the first thing the binary does, so a Postgres
|
||||
# that is still initialising means a crash-loop until it is not.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# ADR-0012: Persisted lane state
|
||||
|
||||
Date: 2026-08-21
|
||||
Status: accepted
|
||||
|
||||
Supersedes the in-memory lane snapshot carried by `latest`'s `LaneState`/`Status`
|
||||
and the `web.LaneReporter` seam (ADR-0010 wrote the durable rows this page now
|
||||
reads).
|
||||
|
||||
## Decision
|
||||
|
||||
The admin Lanes page stops reading the poller's in-memory Lane state and
|
||||
becomes a read of `poll_passes`/`poll_lanes` in Postgres. There is no
|
||||
`LaneReporter` interface: `web/admin_lanes.go` walks `store.LatestLanePasses()`
|
||||
into one row per Site and adds the window's outcome sums from
|
||||
`store.LanePassOutcomes()`. The `latest` package's `LaneState`/`Status` snapshot
|
||||
and its `web.LaneReporter` seam are deleted.
|
||||
|
||||
The browser is a deployment configuration fact plus a reachability derived
|
||||
from the pass log: `BROWSER_WS_URL` set means "configured", and the browser is
|
||||
"reachable" unless a recent browser-Site pass inside `latest.RefuseBackoff` is
|
||||
a sidecar loss, a missing fetcher, or an interrupted read. A skip reason is
|
||||
the whole difference between a Lane resting and a Lane stuck: a skipped pass
|
||||
prints its sentence, and only an empty skip with Series due and none read
|
||||
draws the true-stall fault. Sleep skips never count toward `Attention`.
|
||||
|
||||
## Why
|
||||
|
||||
The old page lived on a poller snapshot. Because that state was in memory, a
|
||||
deploy erased it: the page read zeroes until a fresh pass ran, and browser
|
||||
reachability came through a reporter interface only a live poller could
|
||||
serve. Making the page answer from the database means a restart is complete
|
||||
the instant the store is up, the browser fact survives a poller restart, and
|
||||
a Lane that has not yet gathered figures shows a placeholder rather than a
|
||||
confident zero.
|
||||
|
||||
## Constraints
|
||||
|
||||
The poller still owns the writes: each pass exit records one row (ADR-0010),
|
||||
and a pass that returns before gathering figures carries the previous pass's
|
||||
numbers forward instead of recording zeroes. A skip is a stable wire string;
|
||||
`asleep` never counts toward `Attention`. When polling is switched off
|
||||
(`LATEST_CHAPTER_POLL_ENABLED` unset) the page must say so, and the browser
|
||||
statusline appears only when polling is switched on.
|
||||
@@ -0,0 +1,60 @@
|
||||
# ADR-0013: Commands through the database
|
||||
|
||||
Date: 2026-08-22
|
||||
Status: accepted
|
||||
|
||||
## Decision
|
||||
|
||||
Owner interventions are **facts about rows, never commands to the poller**.
|
||||
*Check now* (`POST /admin/series/{key}/poll`) writes one stamp —
|
||||
`series.force_poll_at`, unix ms, zero meaning never asked (the column landed
|
||||
in migration 0014) — and the poller's next pass reads it through
|
||||
`Store.DueForLatestCheck`. The control never signals the running process, so
|
||||
a request survives a restart, and the whole surface is testable with no
|
||||
poller running at all.
|
||||
|
||||
**Pending is derived, never stored**: a Series is pending while
|
||||
`force_poll_at > latest_checked_at`. It self-clears with no second write and
|
||||
no sweeper because the check stamp is written *before* the fetch (the same
|
||||
"attempted" discipline as ADR-0010) — the first attempt ends the pending
|
||||
state whatever the attempt returns. There is no expiry: a request the Lane
|
||||
never reaches keeps ageing in the UI, and an old pending marker is itself the
|
||||
evidence that a Lane is stuck. Writing again re-stamps the request time; the
|
||||
write is idempotent.
|
||||
|
||||
**Queue-jump rules.** A forced Series overrides exactly three gates in the
|
||||
due query: the rest cutoff, the Sighting-deferral clause and the finished-only
|
||||
bucket, and it sorts to the front of the queue
|
||||
(`ORDER BY forced DESC, reader_count DESC, latest_checked_at ASC`). It never
|
||||
overrides an empty `series_url` (nothing to fetch), the Bookmarks join (a
|
||||
Series no Reader holds has no consumer for the result), the Lane's refusal
|
||||
backoff, the sidecar-down skip, or the Lane's gap — the last three are
|
||||
poller-side gates the query cannot see and must not. The one pass-level gate
|
||||
a forced Series does open is the browser wake threshold: a human asking wakes
|
||||
a sleeping Chrome, where the thresholds exist to stop the machine waking
|
||||
itself for one unattended check. If the home machine is off, nothing happens
|
||||
and the request ages visibly, which is correct.
|
||||
|
||||
Rejected: zeroing the check stamp as the force signal. It would corrupt the
|
||||
never-checked and stale counts the landing page exists to show, and make a
|
||||
pending marker impossible.
|
||||
|
||||
## Why
|
||||
|
||||
A stuck-looking Series previously waited for its turn in the Lane's hour, and
|
||||
there was no way to ask for one check sooner. A direct poller command would
|
||||
have been lost on every restart and untestable without a running poller; a
|
||||
row the poller already reads is neither. Deriving pending from the two stamps
|
||||
keeps the flag honest across restarts and makes the mechanism two column
|
||||
writes and three query clauses instead of a state machine.
|
||||
|
||||
## Constraints
|
||||
|
||||
- The finished-status clause the force flag overrides is today's Lifecycle
|
||||
test; a later spec in this series deletes it wholesale rather than amending
|
||||
it, so the clause stays as it stands.
|
||||
- The control is unconfirmed (it takes nothing away) and renders no
|
||||
`.confirm-row`; it is hidden on a Series with no `series_url` and on an
|
||||
orphan — the same pair the due query refuses to override.
|
||||
- The answer to a press is the freshly rendered row, so the figures describe
|
||||
the state after the press.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,147 @@
|
||||
# ADR-0016: The failure row is the state
|
||||
|
||||
Date: 2026-08-22
|
||||
Status: accepted
|
||||
|
||||
## Decision
|
||||
|
||||
A Series that used to work and has stopped is recorded in one table,
|
||||
`poll_failures`, keyed by the same `(site, series_id)` composite the rest of
|
||||
the system uses. One row per failing Series, carrying the outcome word and a
|
||||
`failing_since` stamp. The row's **existence** is the failure state: there is
|
||||
no success sentinel, no counter, no history, and nothing added to the Series
|
||||
row. A correct read deletes the row, and a repeated identical failure writes
|
||||
nothing — the stamp ages the run of failures, not the current word.
|
||||
|
||||
The write is one upsert, from the poll pass loop:
|
||||
|
||||
```sql
|
||||
INSERT INTO poll_failures (site, series_id, outcome, failing_since)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (site, series_id) DO UPDATE SET outcome = EXCLUDED.outcome
|
||||
WHERE poll_failures.outcome <> EXCLUDED.outcome
|
||||
```
|
||||
|
||||
`failing_since` is simply absent from the `SET` arm, so it is never
|
||||
overwritten, and the `WHERE` makes an unchanged word write nothing at all —
|
||||
one statement, no read-then-write race. The clear is a plain `DELETE`; a row
|
||||
that is not there is not an error, because the poller clears on every
|
||||
successful read and most clears find nothing. The composite foreign key
|
||||
cascades: deleting a Series takes its failure row, so orphan removal stays a
|
||||
single statement.
|
||||
|
||||
## Why a future reader will find this surprising
|
||||
|
||||
The failure state lives in a table of its own, not on the Series row and not
|
||||
in the pass log. A flag on `series` would be the familiar shape, and the
|
||||
pass log already records outcomes per pass — why a third place?
|
||||
|
||||
Because the failure must outlive the pass that observed it and mean
|
||||
something the pass row cannot say. The pass log is a per-Lane stream: it
|
||||
records that a Site returned N `errors` and M `not_found` on a given run,
|
||||
but "this exact Series has been failing for three months" is not a question
|
||||
any single pass row answers — it is a question across rows, and the Lanes
|
||||
page is a live view of the last 14 days, not a Series index. A Series-level
|
||||
fact needs Series-level storage, and a flag on the Series row is the wrong
|
||||
shape too: the failure is *transient by definition* (a correct read ends it)
|
||||
and *repeatable* (the same Series can fail again next year), so the honest
|
||||
record of "failing since" is a stamp that moves, not a column that flips.
|
||||
A row that is created and deleted by the read's outcome is that stamp, and
|
||||
nothing else: the moment a read succeeds the row is gone, so "is this Series
|
||||
failing?" is answered by one indexed existence check — no success sentinel
|
||||
to keep consistent with the failure, no counter to reset, no history to
|
||||
prune. The state cannot drift out of step with the reads that maintain it,
|
||||
because the reads *are* the maintenance.
|
||||
|
||||
The two no-write outcomes are the second surprise. `refused` (the Site is
|
||||
holding a challenge) and `unreachable` (the browser sidecar was lost
|
||||
mid-loop) issue **no statement at all** — neither an upsert nor a delete.
|
||||
The direction matters, and both directions are wrong to touch:
|
||||
|
||||
- **Writing would condemn a whole library for one Site's bad day.** A
|
||||
refusal is the Site's mood, not a fact about any particular Series: when
|
||||
Cloudflare turns a zone's JS detection on, every Series on that Site reads
|
||||
refused in the same pass. Writing those rows would stamp every Series in
|
||||
the library as failing on the evidence of one Site's configuration, and
|
||||
#166's worklist would present a Site-wide outage as thousands of broken
|
||||
Series.
|
||||
- **Deleting would claim a recovery nothing read.** A lost sidecar tells us
|
||||
nothing about the page — the read never happened. Deleting the row would
|
||||
report the Series healthy, and worse, it would reset `failing_since`: the
|
||||
age that makes a three-month failure findable would start over on a
|
||||
browser restart, erasing evidence no page read contradicted.
|
||||
|
||||
So a refusal or an unreachable pass leaves the table exactly as it found
|
||||
it — the pass neither adds rows that would condemn nor deletes rows that
|
||||
would claim recovery. The four outcomes that are real evidence about the
|
||||
page (`not_found`, `no_chapter`, `unfetchable`, `errors`) write; the two
|
||||
that are not write nothing; success deletes. There is no third case.
|
||||
|
||||
## Considered options
|
||||
|
||||
**A `failing_since` column on the Series row, alongside the failure word.**
|
||||
Rejected: it is two more columns on a table every due query and every
|
||||
bookmark join already touches, for a state that is transient and repeatable.
|
||||
The column would need its own "clear on success" writer anyway — the same
|
||||
maintenance the row has — while permanently widening the hottest table in
|
||||
the system for a value that is usually absent. And the worklist's join
|
||||
would have to distinguish "never failed" from "currently healthy", which
|
||||
is exactly the sentinel problem the row avoids: an empty column means both.
|
||||
|
||||
**A counter or last-outcome timestamp instead of (or beside) the row.**
|
||||
Rejected: nothing in the system consumes a failure *count* or a
|
||||
last-outcome time — the worklist needs "failing and since when". A counter
|
||||
invites "three failures = something" thresholds that the ticket explicitly
|
||||
keeps out of scope, and a last-outcome timestamp conflates "the word
|
||||
changed" with "the run restarted", which the age is deliberately defined
|
||||
against. The stamp is the only number that means anything, and the row
|
||||
carries exactly that.
|
||||
|
||||
**Write into `poll_passes` and derive the failure state from the pass
|
||||
stream.**
|
||||
Rejected: a pass row is per-Lane and per-run; deriving "this Series is
|
||||
failing since" means scanning 14 days of outcome counts per Series and
|
||||
guessing at continuity across retention boundaries. The pass log answers
|
||||
"what did this Site's lane do recently"; the failure table answers "which
|
||||
Series are broken right now". Two questions, two tables — the pass log is
|
||||
already pruned on a fixed cutoff, which would silently reset every
|
||||
failure's age the moment the evidence aged out.
|
||||
|
||||
**Refused/unreachable write nothing, but the delete happens anyway (or the
|
||||
upsert happens, with no delete).**
|
||||
Rejected in both directions, above: writing condemns a library for one
|
||||
Site's mood; deleting claims a recovery nothing read and resets the age
|
||||
that makes a long failure findable. The asymmetry is the point — the two
|
||||
outcomes are evidence about the *environment*, never about a page.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The poller writes from the pass loop, one call after `checkOne`, and
|
||||
clears through the ordinary success path: a Forced Poll that reads the
|
||||
page deletes the row with no forced branch of its own, and a Forced Poll
|
||||
*request* — which only stamps the request — clears nothing.
|
||||
- `poll_failures` is a poll-write table like `poll_lanes` and
|
||||
`poll_passes`: the store's two methods live next to the Lane-pass
|
||||
writers, and neither runs on a request path. A store failure is logged
|
||||
and the poll continues — a failure row is best-effort, and no single bad
|
||||
Series may stall a Lane.
|
||||
- The due query does not join the table. A failing Series is polled at the
|
||||
same pace as any other, because the moment the query started pacing by
|
||||
failure state, the age would stop meaning what the worklist reads it as.
|
||||
- Orphan removal stays a single statement: the composite foreign key's
|
||||
`ON DELETE CASCADE` is what takes the failure row with the Series.
|
||||
- The table starts empty at deploy, so nothing is findable for the first
|
||||
twelve hours after deploy and a pre-existing breakage reads as new.
|
||||
Accepted; the alternative is inventing history.
|
||||
|
||||
## Cost of reversing
|
||||
|
||||
The failure state is derived, not stored: a rollback drops the table and
|
||||
every in-progress failure age with it, leaving the pass log's outcome
|
||||
counts as the only trace — which is exactly the "log line nobody reads"
|
||||
the ticket set out to replace. Recreating the table later starts the ages
|
||||
over, so a reversal that is later reversed loses the evidence of the
|
||||
intervening failures. The failure state itself, however, is the one thing
|
||||
that is *not* lost by reversing: it is re-derived from the next pass, in
|
||||
the same direction the original design derives it — the row is
|
||||
recreated by the next failing read and deleted by the next good one.
|
||||
+56
-22
@@ -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 |
|
||||
@@ -128,8 +128,11 @@ Recurring specs (copy these rather than inventing sizes):
|
||||
- Tab: `400 17px display` (`18px` ≥720px), active gets `border-bottom: 2px` in
|
||||
`--paper` (`--ember` for Updated) plus `margin-bottom: -1px` so it lands on
|
||||
the row's own hairline.
|
||||
- Meta / label / badge / action key: `500 10–11px mono`, `letter-spacing:
|
||||
- Meta / label / badge / action key: `500 11px mono`, `letter-spacing:
|
||||
.04em`–`.2em`, `text-transform: uppercase`. Eyebrows use the widest tracking.
|
||||
**11px is the floor** — nothing in this UI sets mono below it. The brief names
|
||||
night reading and glare as the usage scene, and a 10px small-caps label at
|
||||
arm's length on a phone is where that scene stops being served.
|
||||
- Empty-state heading: `400 20px display`; body `400 14px/1.6 sans`, `max-width: 44ch`.
|
||||
- Primary button: `--paper` fill, `--ink` text, `400 17–19px display`, no border radius.
|
||||
- Ghost button: mono small-caps, transparent, `border-bottom: 1px --field-line`.
|
||||
@@ -140,11 +143,20 @@ Recurring specs (copy these rather than inventing sizes):
|
||||
.sheet
|
||||
.topbar .brand (mark + wordmark) + .ghost (log out)
|
||||
.chrome .searchbar + nav.tabs (column on phone, row ≥720px via order:)
|
||||
sticky at top: 0, z-index 2, on an --ink ground
|
||||
.keyrow one-line action key: Read / Fav / Chapter / Archive / Done / Delete
|
||||
.recent h2 eyebrow + .recent-strip > a.recent-card
|
||||
main#list article.card … | .empty
|
||||
```
|
||||
|
||||
**Sticky chrome.** Search and the tab row are the two controls a 300-item
|
||||
library needs mid-scroll, so `.chrome` alone sticks (`padding-top:
|
||||
env(safe-area-inset-top)` for the notch cutout). Everything above it —
|
||||
`.topbar`, `.keyrow`, `.recent` — scrolls away on purpose: another 150px of
|
||||
permanent chrome on an 844px phone costs more than re-scrolling for an icon
|
||||
reminder. If header height ever grows, unstick `.recent`/`.keyrow` further
|
||||
rather than adding to the sticky region, and keep `.chrome` above the cards.
|
||||
|
||||
The owner's admin page (`admin.html`) is the same sheet with two sections in
|
||||
place of the list — `.lanes` (Poll Lane rows) and `.readers` (the roster) —
|
||||
and no library switch: it belongs to neither library, so its topbar carries a
|
||||
@@ -164,9 +176,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 +191,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 +200,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 +208,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
|
||||
@@ -225,9 +237,16 @@ Rules that are easy to break:
|
||||
- `[hidden] { display: none !important; }` is load-bearing — every disclosure
|
||||
panel is a flex container, and `display` beats `hidden`.
|
||||
- Busy state is `.card.htmx-request::before`, a 1px grey bar sliding across the
|
||||
top hairline (`barSlide`), plus the action strip at `opacity: .5`. Never a
|
||||
spinner, and deliberately `--mute` not `--ember` — on a list screen ember
|
||||
means "new chapter" and nothing else, so a system state can't borrow it.
|
||||
top hairline (`barSlide`), the action strip at `opacity: .5`, and past 2s the
|
||||
word `Saving…` in `::after` (`busyWord`, §6). `filter.js` sets `aria-busy` on
|
||||
the card over the same window because htmx sets none, so the wait is not
|
||||
silent to a screen reader. Never a spinner, and deliberately `--mute` not
|
||||
`--ember` — on a list screen ember means "new chapter" and nothing else, so a
|
||||
system state can't borrow it.
|
||||
- Titles clamp at 3 lines (`.recent-title` at 2 — there the title is a
|
||||
reminder, in the list it is the identifier). `.is-new .title` needs
|
||||
`width: fit-content`, or `-webkit-box` stretches the ember underline to the
|
||||
full row and the rule stops being sized to the text.
|
||||
- `.open` on the pencil / lifecycle cell marks which panel is showing;
|
||||
`filter.js` `togglePanel()`/`toggleConfirmRow()` own that class alongside
|
||||
`hidden`. An open lifecycle cell needs the next surface step up from
|
||||
@@ -251,7 +270,7 @@ is a 1px bar, not a rotating ring); toasts are `--ash` with a 2px left rule,
|
||||
|
||||
## 6. Motion
|
||||
|
||||
Three animations, all ≤ 1.15s and all disabled under
|
||||
Four animations, all ≤ 1.15s and all disabled under
|
||||
`prefers-reduced-motion: reduce` (pseudo-elements need naming explicitly in
|
||||
that query — `*` does not match `::before`/`::after`, so the busy bar and
|
||||
error dot are listed by name and fall back to their static drawn form):
|
||||
@@ -259,16 +278,29 @@ error dot are listed by name and fall back to their static drawn form):
|
||||
- `sheetIn` — 180ms fade + 4px rise, on a row and on each disclosure panel.
|
||||
- `barSlide` — the sliding hairline, for any busy state.
|
||||
- `mutePulse` — the 5px dot on `.error-inline`.
|
||||
- `busyWord` — `0s 2s forwards`, a delay rather than a motion: it reveals the
|
||||
`Saving…` word only once a request has outlived a plausible response. The
|
||||
reduced-motion block cancels the animation and so would pin it at
|
||||
`opacity: 0`; that branch re-declares `opacity: 1` to show it from the start.
|
||||
Any future state revealed this way needs the same two-line pair.
|
||||
|
||||
No transforms on hover, no scale, no easing curves beyond `ease-out`/`linear`.
|
||||
|
||||
## 7. Accessibility floor (not negotiable)
|
||||
|
||||
- Touch targets on the phone layout are 44–46px; the 44px desktop cells are
|
||||
pointer-only (≥720px).
|
||||
- Every icon-only control keeps `title` + `aria-label`; the SVG inside is
|
||||
`aria-hidden`. Lifecycle buttons also carry `aria-expanded` +
|
||||
`aria-controls` pointing at their `.confirm-row`.
|
||||
- **Every focusable control carries a visible ring**: `outline: 2px solid
|
||||
var(--paper)` with `2px` offset on `:focus-visible`, since the UA default is
|
||||
a bright blue tuned for neither branch of this palette. `.searchbar` recolours
|
||||
its border on `:focus-within` as a resting cue, but that 1px change is not the
|
||||
ring — the `.search` input declares its own. A new control that suppresses
|
||||
`outline` must replace it, not drop it.
|
||||
- Touch targets are 44–46px under `(pointer: coarse)` — including inline text
|
||||
controls like `.libswitch a`, where padding plus an 11px line lands short of
|
||||
44 and needs `min-height` + `place-items: center`. The 44px desktop cells are
|
||||
pointer-only (≥720px).
|
||||
- The cover link is `tabindex="-1" aria-hidden="true"` because the title link
|
||||
and the play cell already reach the same URL — do not make it a third tab stop.
|
||||
- Tabs keep `role="tab"` / `role="tablist"`; the active one is marked by class,
|
||||
@@ -286,13 +318,15 @@ 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
|
||||
the one exception (`chrome.html`'s `mark` template), since it takes
|
||||
page-level custom properties the sprite can't carry per-instance.
|
||||
6. Phone first (44px targets, single column), then the ≥720px block.
|
||||
6. Phone first (44px targets under `(pointer: coarse)`, single column), then the
|
||||
≥720px block. Mono no smaller than 11px, and a `:focus-visible` ring on
|
||||
anything focusable — both are §7 floors, not preferences.
|
||||
7. Verify: `cd backend && go test ./...`, then run the binary and screenshot
|
||||
both widths and both colour schemes (Playwright: `emulateMedia`,
|
||||
`setViewportSize`; disable the browser cache — `/static/*` is served with
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
# GIF — maximum byte size of a file
|
||||
|
||||
Research note for Gitea issue #71 (backend `maxBodyBytes` = 4 MiB rejects the
|
||||
8,571,192-byte animated cover GIF at
|
||||
`https://cdn.asurascans.com/asura-images/covers/a-dragonslayers-peerless-regression.gif`).
|
||||
|
||||
All facts fetched live on **2026-08-17**: the GIF89a spec at
|
||||
`https://www.w3.org/Graphics/GIF/spec-gif89a.txt`, Go stdlib `image/gif`
|
||||
sources at `/usr/local/go/src/image/gif/reader.go` (Go 1.26.5), Chromium
|
||||
`blink/renderer/platform/image-decoders/` sources via
|
||||
`chromium.googlesource.com`, Firefox `image/decoders/nsGIFDecoder2.cpp` via
|
||||
`hg.mozilla.org`, and cover bytes probed with plain `curl` (desktop Chrome UA;
|
||||
`HEAD`/ranged `GET`). **No Cloudflare challenge was encountered on any CDN
|
||||
probe** — every request returned real headers, consistent with the AGENTS.md
|
||||
note of 2026-07-26 that plain `curl` works against both scan sites from the
|
||||
dev machine and the VPS.
|
||||
|
||||
Every claim carries the URL it came from, or a reproducible command.
|
||||
Interpretation rather than observation is marked `[INFERENCE]`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary answer table
|
||||
|
||||
| Question | Answer | Evidence |
|
||||
|---|---|---|
|
||||
| Does the GIF89a spec define a maximum file size? | **No.** There is no file-size field anywhere in the format; the only numeric ceilings are per-field (16-bit screen/image dimensions, 255-byte sub-blocks, 12-bit LZW codes). | §2 |
|
||||
| Maximum logical screen | 65535 × 65535 pixels (unsigned 16-bit width/height). | §2.1 |
|
||||
| Number of frames / image descriptors | Unbounded — "An unlimited number of images may be present per Data Stream." | §2.2 |
|
||||
| Formal max byte size of any single GIF | None. Single-frame worst case ≈ **6.44 GB** (12-bit LZW, max canvas); animated GIFs are **unbounded** because frames are unbounded. | §3 |
|
||||
| Does the backend's decoder (Go `image/gif`) bound size? | **No.** It reads 16-bit dimensions and allocates `width×height` bytes per frame; a 65535² frame forces a ~4 GiB allocation. No total-size or dimension guard. | §4.1 |
|
||||
| Do browsers bound on-disk GIF size? | Chromium and Firefox: no on-wire size cap in their GIF readers; Chromium caps *decoded* memory at min(4 B × pixels, platform budget). | §4.3, §4.4 |
|
||||
| Real cover sizes (asurascans, n=25) | min 190,410 B · median 1,275,082 B · p90 4,524,788 B · max 8,571,192 B · **3/25 > 4 MiB** (two JPEGs and the animated GIF) | §5 |
|
||||
| Real cover sizes (demonicscans/readermc, n=78) | min 13,298 B · median 63,061 B · max 801,200 B · 0/78 > 4 MiB | §5 |
|
||||
| Comparable service caps | GitHub: 10 MB for images/GIFs. Discord API: default 10 MiB per file. Wikimedia: 100 MiB upload / 5 GiB host. | §6 |
|
||||
| Recommended cover cap for #71 | **10 MiB** (separate from the 4 MiB series-page cap). Covers 100% of the 103 observed covers; matches GitHub/Discord calibration; ≤ 20 MiB worst-case transient per concurrent fetch+serve on a 1974 MiB swapless VPS. | §7 |
|
||||
|
||||
---
|
||||
|
||||
## 2. What the GIF89a specification actually bounds
|
||||
|
||||
Source: `https://www.w3.org/Graphics/GIF/spec-gif89a.txt` (fetched 2026-08-17).
|
||||
|
||||
### 2.1 Fixed-width fields — the only hard ceilings
|
||||
|
||||
The format is a stream of fixed-width blocks; the numeric fields that *do*
|
||||
have a ceiling are all 16-bit unsigned, little-endian ("multi-byte numeric
|
||||
fields are ordered Least Significant Byte first", §4 of the spec):
|
||||
|
||||
- **Logical Screen Width / Height** — "Unsigned" 2-byte fields (§18, Logical
|
||||
Screen Descriptor) → maximum **65535 × 65535** pixels.
|
||||
- **Image Left / Top Position, Image Width / Height** — "Unsigned" 2-byte
|
||||
fields (§20, Image Descriptor). Each image "must fit within the boundaries
|
||||
of the Logical Screen" (§20a), so an image cannot exceed the 65535² canvas
|
||||
even though its own fields would allow it.
|
||||
- **Data sub-blocks** — "A data sub-block may contain from 0 to 255 data
|
||||
bytes" (§15); each sub-block is preceded by a 1-byte size field and the
|
||||
stream is terminated by a 0x00 Block Terminator (§16). This bounds a
|
||||
*chunk*, not the stream.
|
||||
- **Global/Local Color Tables** — optional, "3 x 2^(Size of Global Color
|
||||
Table+1)" bytes with a 3-bit size field → at most 3 × 2⁸ = **768 bytes**
|
||||
each (§19, §21).
|
||||
- **LZW codes** — "The output codes are of variable length, starting at
|
||||
<code size>+1 bits per code, **up to 12 bits per code**. This defines a
|
||||
maximum code value of 4095 (0xFFF)" (Appendix F, COMPRESSION, rule 4).
|
||||
- **Trailer** — a single byte, fixed value 0x3B, "indicating the end of the
|
||||
GIF Data Stream" (§27).
|
||||
|
||||
### 2.2 What is unbounded
|
||||
|
||||
- **Number of images (frames).** §20a, verbatim: "This block is REQUIRED for
|
||||
an image. Exactly one Image Descriptor must be present per image in the
|
||||
Data Stream. **An unlimited number of images may be present per Data
|
||||
Stream.**"
|
||||
- **The Data Stream itself.** The grammar in Appendix B is
|
||||
`<GIF Data Stream> ::= Header <Logical Screen> <Data>* Trailer`, and the
|
||||
spec states "the entity Data … may be repeated any number of times,
|
||||
including 0 times." There is **no field anywhere that carries a file size,
|
||||
byte count, frame count, or total-length value**. §13 (Block Sizes) only
|
||||
defines sizes *within* blocks.
|
||||
|
||||
### 2.3 Verdict
|
||||
|
||||
**The GIF89a specification defines no maximum file size.** The only hard
|
||||
bounds are per-field: 65535×65535 pixels per screen/image, 255 bytes per
|
||||
sub-block, 12 bits per LZW code, and one trailer byte. A compliant decoder
|
||||
must process whatever stream the blocks describe. Any byte ceiling a
|
||||
particular GIF actually hits is therefore *implicit* — 16-bit dimensions,
|
||||
LZW code width, decoder memory, or an external policy — never something the
|
||||
format itself enforces. `[INFERENCE]` This is why real-world GIFs cap out at
|
||||
"a few GB at most" and every service that wants a bound has to impose one
|
||||
itself (see §6; Wikimedia explicitly documents that a 4 GiB host limit was a
|
||||
storage-representation artifact of 32-bit integers, `phab:T191805`, not a
|
||||
format limit).
|
||||
|
||||
---
|
||||
|
||||
## 3. Theoretical worst case
|
||||
|
||||
### 3.1 Single frame, maximal canvas, 8-bit pixels
|
||||
|
||||
| Quantity | Value | Derivation |
|
||||
|---|---|---|
|
||||
| Max pixels | 4,294,836,225 | 65535 × 65535 |
|
||||
| Raw 8-bit palette-index raster | 4,294,836,225 B ≈ **4.29 GB / 4.00 GiB** | 1 byte per pixel (Table Based Image Data, §22; Go's `image.Paletted` uses exactly 1 byte/pixel) |
|
||||
| LZW worst case | ≈ **6.44 GB / 6.00 GiB** | codes ≤ 12 bits each (Appendix F), at most ~1 code per pixel for incompressible data → ≤ 12 bits/px = 1.5 B/px → 4,294,836,225 × 1.5 B |
|
||||
| Sub-block overhead | ≈ +25.3 MB | every ≤255-byte chunk carries a 1-byte size field (§15): ⌈6,442,254,338 / 255⌉ ≈ 25,263,743 size bytes, + 1 block terminator |
|
||||
| Fixed overhead | ≈ +1.6 KB | header 6 B (§17) + logical screen descriptor 7 B (§18) + global color table ≤ 768 B (§19) + image descriptor 10 B (§20) + local color table ≤ 768 B (§21) + LZW minimum code size 1 B (§22) |
|
||||
|
||||
So a **single maximal-frame GIF cannot exceed ≈ 6.47 GB on the wire**
|
||||
(12-bit LZW bound), and LZW being lossless means the real byte count depends
|
||||
entirely on image content — the same canvas can be a few KB (flat color) or
|
||||
~6 GB (noise).
|
||||
|
||||
Two caveats, both marked `[INFERENCE]`:
|
||||
|
||||
- The "1.5 B/px" figure assumes ~one emitted code per pixel. An encoder is
|
||||
permitted to emit a Clear code at any point (Appendix F: "The Clear code
|
||||
can appear at any point in the image data stream"), so a
|
||||
pathological-but-compliant encoder emitting clear+pixel per pixel reaches
|
||||
~24 bits/px ≈ 12.9 GB for the max canvas. Real encoders do not do this;
|
||||
12-bit/px is the practical bound.
|
||||
- The spec's deferred-clear note (cover sheet) explicitly allows an encoder
|
||||
to keep using a full table at 12-bit codes without clearing, so the 12-bit
|
||||
cap holds for the whole stream, it cannot "grow" past 12 bits.
|
||||
|
||||
### 3.2 Animated GIFs: unbounded
|
||||
|
||||
Every frame is one Image Descriptor, each bounded by the 65535² canvas, but
|
||||
the *count* of frames is unbounded (§2.2). Total bytes = sum over frames —
|
||||
therefore **there is no finite maximum byte size for an animated GIF** in
|
||||
the format. The only thing that stops a real one is decoder memory, a
|
||||
service cap, or disk space. `[INFERENCE]` This is the category the issue #71
|
||||
cover falls into: it is an animated GIF (NETSCAPE2.0 loop extension found at
|
||||
offset 0x310 of the file, verified 2026-08-17 by a ranged GET), and its
|
||||
8,571,192 bytes are ~2.04× the current 4 MiB backend cap.
|
||||
|
||||
---
|
||||
|
||||
## 4. Decoder-side real limits
|
||||
|
||||
### 4.1 Go `image/gif` (the backend's decoder path, stdlib)
|
||||
|
||||
Source: `/usr/local/go/src/image/gif/reader.go`, Go 1.26.5.
|
||||
|
||||
- Dimensions are read as little-endian uint16 — `left/top/width/height :=
|
||||
int(d.tmp[N]) + int(d.tmp[N+1])<<8` (reader.go:490-493) — so the format
|
||||
ceiling 65535 applies, and nothing smaller is enforced.
|
||||
- The only geometric check is that each frame fits inside the logical
|
||||
screen: `if left+width > d.width || top+height > d.height` →
|
||||
`errors.New("gif: frame bounds larger than image bounds")` (reader.go:512-513).
|
||||
- **There is no file-size, byte-count, frame-count, or pixel-count guard.**
|
||||
Each frame allocates `image.NewPaletted(...)` (reader.go:515) — a
|
||||
`[]byte` of width×height — so decoding one legal 65535² frame attempts a
|
||||
**~4.29 GB allocation**. `DecodeAll` (reader.go:603-605) additionally
|
||||
retains every frame's `Pix` slice for the lifetime of the returned `*GIF`.
|
||||
- `[INFERENCE]` On the 1974 MiB swapless VPS (root AGENTS.md), decoding such
|
||||
a file would OOM rather than error cleanly; nothing in stdlib protects
|
||||
the process. This matters for §7: the backend stores cover bytes without
|
||||
decoding them (see §5.3), so the fetch path never triggers this — but any
|
||||
future "validate/re-encode server-side" scheme would.
|
||||
- Grep for `MaxInt|limit|too large|bounds` in reader.go: the only hits are
|
||||
the frame-bounds check above and the `tmp [1024]byte` scratch buffer
|
||||
(reader.go:109); no size caps exist.
|
||||
|
||||
### 4.2 giflib / libgif
|
||||
|
||||
**Not verified from source.** On 2026-08-17 the giflib sources were not
|
||||
reachable from this network: `github.com/giflib/giflib` returns 404 (repo
|
||||
gone/moved), `gitlab.com/giflib/giflib/-/raw/...` answers a Cloudflare
|
||||
"Just a moment…" challenge, and the SourceForge project download path
|
||||
404s. No limit claim about giflib is made here. `[INFERENCE]` giflib is
|
||||
widely known to be allocation-driven with no dimension cap, but that is not
|
||||
checked against source and is not needed for issue #71 (the backend uses Go
|
||||
stdlib, not giflib).
|
||||
|
||||
### 4.3 Chromium (browser behaviour, first-party source)
|
||||
|
||||
- `third_party/blink/renderer/platform/image-decoders/gif/gif_image_reader.cc`
|
||||
(via `chromium.googlesource.com/chromium/src/+/main/...`, fetched
|
||||
2026-08-17): **no GIF byte-size or dimension cap found** — grep for
|
||||
`max|limit|too large|dimension|65535|overflow` matches only license text.
|
||||
- The base `ImageDecoder` caps *decoded memory*, not transfer size:
|
||||
`CalculateMaxDecodedBytes` computes `min(4 * num_pixels, platform_max_decoded_bytes)`
|
||||
(8 bytes/pixel for high-bit-depth), and the header comment says "Ignoring
|
||||
this limit can cause excessive memory use or even crashes on low-memory
|
||||
devices"
|
||||
(`image_decoder.cc:94-117`, `image_decoder.h:545-549`). The GIF reader
|
||||
itself is untouched by this — it is a decoded-buffer budget.
|
||||
- Practical consequence `[INFERENCE]`: a browser will happily download and
|
||||
store a multi-GB GIF from its own cache perspective; Chromium only limits
|
||||
what it *decodes* into pixels.
|
||||
|
||||
### 4.4 Firefox
|
||||
|
||||
`image/decoders/nsGIFDecoder2.cpp` (via `hg.mozilla.org/mozilla-central/
|
||||
raw-file/tip/...`, fetched 2026-08-17): **no dimension or size limit**; the
|
||||
only guards are on LZW code width (`MAX_BITS` = 12, "maximum codeword size
|
||||
of 12 bits") and the decode stack. Nothing bounds the on-disk byte size.
|
||||
|
||||
### 4.5 Summary
|
||||
|
||||
No mainstream decoder enforces a byte-size ceiling; they stop at the 16-bit
|
||||
dimension ceiling (Go, by construction) or at decoded-memory budgets
|
||||
(Chromium) or nowhere (Firefox). A GIF's byte size is policed only by
|
||||
*storage* policies — which is what §6 calibrates and §7 sets.
|
||||
|
||||
---
|
||||
|
||||
## 5. Practical distribution — what real manga covers weigh
|
||||
|
||||
Probed **2026-08-17** with `curl -sI` (HEAD) and ranged GETs, desktop Chrome
|
||||
UA. No Cloudflare block on any request. Sample = covers *as the backend
|
||||
would fetch them* (the `og:image`/page-listed cover URL), not thumbnails we
|
||||
chose by hand.
|
||||
|
||||
### 5.1 Exact commands
|
||||
|
||||
```sh
|
||||
# asurascans.com — harvest cover URLs from the homepage, then HEAD each
|
||||
curl -s -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/126.0" https://asurascans.com/ -o home.html
|
||||
grep -oE 'https://cdn\.asurascans\.com/asura-images/covers/[^"&\\< ]+\.(webp|gif|jpg|jpeg|png)' home.html \
|
||||
| sort -u | grep -v '\-400\.' | head -25 > sample.txt # one full-res cover per series, no -400 thumbs
|
||||
while read -r u; do curl -s -A "…Chrome/126.0" -I "$u" | tr -d '\r' \
|
||||
| grep -iE '^content-length:'; done < sample.txt
|
||||
|
||||
# demonicscans.org — covers live on readermc.org (ADR-0007), URLs contain spaces/UTF-8
|
||||
curl -s -A "…Chrome/126.0" https://demonicscans.org/ -o demonic.html
|
||||
grep -oE 'src="https://readermc\.org/images/thumbnails/[^"]+"' demonic.html | tr -d 'src="' > demonic.txt
|
||||
# …plus og:image from 5 manga pages (Catastrophic-Necromancer, Magic-Emperor, …)
|
||||
# each URL percent-encoded per path segment (urllib.parse.quote, safe=':/') before HEAD
|
||||
```
|
||||
|
||||
### 5.2 asurascans — 25 full-res covers (mixed formats)
|
||||
|
||||
Homepage fetched 200 (664,700 B). All 25 returned `200` with a real
|
||||
`Content-Length`. Distribution:
|
||||
|
||||
| Statistic | Bytes |
|
||||
|---|---|
|
||||
| n | 25 |
|
||||
| min | 190,410 |
|
||||
| median | 1,275,082 |
|
||||
| p90 | 4,524,788 |
|
||||
| max | 8,571,192 |
|
||||
| mean | 1,943,651 |
|
||||
| **> 4 MiB (4,194,304)** | **3 (12%)** — `a-dragonslayers-peerless-regression.gif` 8,571,192 (the issue #71 cover, animated: NETSCAPE2.0 at 0x310, 550×733, 256 colors); `bad-born-blood.3008f6.webp` 4,524,788 `image/jpeg`; `ending-maker.cfbf53.webp` 4,619,303 `image/jpeg` |
|
||||
|
||||
Notes: the CDN serves `Content-Type` by stored bytes, not by URL extension
|
||||
(the `.webp` URLs return `image/png`, `image/jpeg`, or `image/webp` — the
|
||||
sample spans all four of `png/jpeg/webp/gif`). Two of the three over-cap
|
||||
files are **not GIFs**, so the current 4 MiB cap already silently drops 12%
|
||||
of asura covers of any format. p90 itself (4.52 MB) exceeds the cap.
|
||||
|
||||
### 5.3 demonicscans — 78 covers on readermc.org
|
||||
|
||||
78 unique cover URLs (73 from the homepage's `/images/thumbnails/` plus 5
|
||||
`og:image` values from manga pages — demonicscans publishes the thumbnail
|
||||
file as the full cover, so that is exactly what the backend would fetch).
|
||||
**78/78 returned 200 with a real Content-Length** (spaces and UTF-8 in the
|
||||
filenames were percent-encoded per path segment; the homepage's raw HTML
|
||||
carries `’`-style mojibake for curly quotes, which was repaired by
|
||||
latin-1→utf-8 re-encoding before probing).
|
||||
|
||||
| Statistic | Bytes |
|
||||
|---|---|
|
||||
| n | 78 |
|
||||
| min | 13,298 |
|
||||
| median | 63,061 |
|
||||
| p90 | 206,994 |
|
||||
| max | 801,200 |
|
||||
| mean | 110,038 |
|
||||
| > 4 MiB | 0 |
|
||||
|
||||
### 5.4 Reading
|
||||
|
||||
`[INFERENCE]` asurascans covers are the heavy tail (median 1.3 MB, top
|
||||
decile > 4 MiB, occasional ~5–9 MB), demonicscans covers are tiny (all
|
||||
< 0.8 MB). A cover cap must be chosen against the *asura* distribution —
|
||||
the 8.57 MB animated GIF is not a freak one-off outlier; the 90th
|
||||
percentile already crosses 4 MiB and two JPEGs sit between 4.5–4.7 MB.
|
||||
|
||||
---
|
||||
|
||||
## 6. Comparable documented byte caps (first-party docs only)
|
||||
|
||||
| Service | Cap | Source (fetched 2026-08-17) |
|
||||
|---|---|---|
|
||||
| GitHub (issues/PR comments) | **10 MB for images and gifs**; 25 MB other files; 10/100 MB video | `https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/attaching-files` — "The maximum file size is: 10MB for images and gifs … 25MB for all other files" |
|
||||
| Discord (API uploads) | default **10 MiB per file**, higher with Nitro / boost tier | `https://discord.com/developers/docs/reference#uploading-files` — "The file upload size limit applies to each file in a request. The default limit is `10 MiB` for all users" (help-center article `support.discord.com/hc/en-us/articles/115002935588` exists but answered 403 from this network on the probe date, so its figures were not verified here) |
|
||||
| Wikimedia Commons | **100 MiB** upload limit; hosting up to **5 GiB**; GIF thumbnails limited to **100 megapixels**; prior 4 GiB host cap was a 32-bit storage artifact (phab:T191805) | `https://commons.wikimedia.org/wiki/Commons:Maximum_file_size` |
|
||||
| MDN | nothing — MDN documents no byte-size limit for images; browsers impose none (see §4.3–4.4) | `[INFERENCE]` from absence in the platform docs read in §4 |
|
||||
|
||||
Calibration takeaway: two major platforms independently land on **~10 MB**
|
||||
as the ceiling for an uploadable image/GIF (GitHub exactly 10 MB, Discord
|
||||
exactly 10 MiB), with Wikimedia the outlier at 100 MiB/5 GiB because it is a
|
||||
media *archive*. A 10 MiB cover cap is therefore squarely inside industry
|
||||
normal.
|
||||
|
||||
---
|
||||
|
||||
## 7. Recommendation for issue #71
|
||||
|
||||
**Raise the cover cap to 10 MiB (10,485,760 B) — as a separate constant, not
|
||||
by moving the shared one.**
|
||||
|
||||
Why:
|
||||
|
||||
- **Fits the measured reality.** The largest observed cover is 8,571,192 B
|
||||
(the issue's animated GIF) = 82% of 10 MiB; 10 MiB covers **100% of the
|
||||
103 sampled covers** and the *entire* asura distribution, including its
|
||||
heavy tail. 4 MiB rejects 12% of asura covers (two of them plain JPEGs).
|
||||
- **Matches industry calibration** (§6): GitHub 10 MB images/GIFs, Discord
|
||||
10 MiB default. A 10 MiB cap is a number every engineer recognizes, and
|
||||
it leaves ~18% headroom over the current worst observed file.
|
||||
- **Costs little on the target hardware.** The backend buffers cover bytes
|
||||
whole during fetch (`backend/internal/latest/cover.go`: `ContentLength >
|
||||
maxBodyBytes` rejection at :155, then `io.ReadAll(io.LimitReader(…,
|
||||
maxBodyBytes+1))` at :158) and loads the full body per `GET /covers/…`
|
||||
(`backend/internal/api/handlers.go`, `Cover` → `w.Write(body)`). Worst
|
||||
case per concurrent fetch **+** serve is therefore 2 × cap = 20 MiB; even
|
||||
ten of each concurrently is ~200 MiB of a 1974 MiB swapless VPS (~10%),
|
||||
and the browser unit (471 MiB, root AGENTS.md) is no longer on that box.
|
||||
The 4 MiB series-page cap is *not* the issue — measured pages run
|
||||
100 KB–1.2 MB (`backend/internal/latest/fetch.go` comment) — so keep it.
|
||||
- **The cap is a separate knob.** Today one `const maxBodyBytes = 4 << 20`
|
||||
(`backend/internal/latest/fetch.go:17`) gates *both* series pages and
|
||||
covers (`cover.go` references it). Raising it wholesale would loosen the
|
||||
page-side memory guard for no benefit; a cover-specific constant (e.g.
|
||||
`maxCoverBytes = 10 << 20`) keeps the two policies independent. The fetch
|
||||
already double-checks `ContentLength` and the post-`LimitReader` length,
|
||||
so a larger constant changes nothing else.
|
||||
|
||||
Alternatives and their costs:
|
||||
|
||||
| Option | Cost |
|
||||
|---|---|
|
||||
| Keep 4 MiB | 12% of asura covers (incl. non-GIF JPEGs) never stored — current bug, silent missing covers. |
|
||||
| 16 MiB cap | 2× headroom over the observed max for future GIFs; +60% worst-case transient memory vs 10 MiB; diverges from the GitHub/Discord 10 MB calibration. |
|
||||
| Server-side re-encode / downscale covers | Requires decoding → Go `image/gif` allocates width×height per frame with **no guard** (§4.1); a legal 65535² GIF forces a ~4.29 GB allocation on a 1974 MiB swapless box — OOM, not an error. Also mutates bytes, which the store treats as immutable/content-addressed (ADR-0007). Highest risk, no upside at this scale. |
|
||||
| No cap | Unbounded transient memory and disk; rejected outright. |
|
||||
|
||||
Decision is the user's; on the evidence, **10 MiB for covers, 4 MiB for
|
||||
pages** is the defensible middle.
|
||||
@@ -1,219 +0,0 @@
|
||||
{
|
||||
"0": "HTMX Library Internals",
|
||||
"1": "Cover Fetch Test Helpers",
|
||||
"2": "Manga Userscript Adapters",
|
||||
"3": "Novel Userscript Adapters",
|
||||
"4": "Series Acquisition Tests",
|
||||
"5": "Bookmarks API Tests",
|
||||
"6": "Storage Choice ADR",
|
||||
"7": "Cover & Acquire Internals",
|
||||
"8": "System Architecture Concepts",
|
||||
"9": "Session Middleware",
|
||||
"10": "Go Test Helpers",
|
||||
"11": "Store Tests",
|
||||
"12": "Bookmarks API Handler",
|
||||
"13": "displayChapter",
|
||||
"14": "Go Error Handling",
|
||||
"16": "Cloudflare bot scoring and poll cadence — what is actually documented",
|
||||
"17": "Go Code Style Guide",
|
||||
"18": "Agent Skills",
|
||||
"19": "Store",
|
||||
"20": "I/O Performance Patterns",
|
||||
"21": "CPU Optimization",
|
||||
"22": "Caching Patterns",
|
||||
"23": "Browser Entrypoint",
|
||||
"24": "Memory Allocation & GC",
|
||||
"25": "Cover Fetcher Tests",
|
||||
"26": "Open",
|
||||
"27": "Find Skills Guide",
|
||||
"28": "Allocation Patterns",
|
||||
"29": "Observability & Alerting",
|
||||
"30": "AGENTS.md",
|
||||
"32": "Open",
|
||||
"33": "Go Testing Guide",
|
||||
"34": "Session Store",
|
||||
"35": "Web UI Filter Logic",
|
||||
"36": "Userscript Test Harness",
|
||||
"37": "Product & Security Context",
|
||||
"38": "novel-logic.test.js",
|
||||
"39": "UI Critique 2026-07-26A",
|
||||
"40": "UI Critique 2026-07-26B",
|
||||
"41": "Handler",
|
||||
"43": "Issue Tracker & Triage",
|
||||
"44": "Ticket Workflow",
|
||||
"45": "Go Perf Alert Rules",
|
||||
"46": "Userscript Display Logic",
|
||||
"47": "Go Perf Skill Docs",
|
||||
"48": "Login Page Art",
|
||||
"49": "BookmarkManager Logo",
|
||||
"50": "Skills CLI",
|
||||
"51": "Skills Leaderboard",
|
||||
"52": "Complex Condition Extraction",
|
||||
"53": "Sentinel Errors",
|
||||
"54": "errors.As Patterns",
|
||||
"55": "errors.Is Patterns",
|
||||
"56": "errors.Join Patterns",
|
||||
"57": "Error Wrapping",
|
||||
"58": "Single Error Handling",
|
||||
"59": "SIMD Optimizations",
|
||||
"60": "GOGC Tuning",
|
||||
"61": "GOMEMLIMIT",
|
||||
"62": "Bottleneck Decision Tree",
|
||||
"63": "pprof Profiling",
|
||||
"64": "Test Timeout Helper",
|
||||
"65": "httptest Patterns",
|
||||
"66": "testify Suite Pattern",
|
||||
"67": "go:embed Fixtures",
|
||||
"68": "clockwork Time Mocking",
|
||||
"69": "testify Mocking",
|
||||
"70": "t.ArtifactDir Helper",
|
||||
"71": "Subtests Pitfall",
|
||||
"72": "golang-benchmark Skill",
|
||||
"73": "golang-concurrency Skill",
|
||||
"74": "golang-ci Skill",
|
||||
"75": "golang-database Skill",
|
||||
"76": "golang-lint Skill",
|
||||
"77": "testify Skill",
|
||||
"78": "Build Tag Integration Tests",
|
||||
"79": "Test Naming Convention",
|
||||
"80": "UI Critique A Finding",
|
||||
"81": "UI Critique B Finding",
|
||||
"82": "P0 Overflow Bug",
|
||||
"83": "P1 hx-indicator Gap",
|
||||
"84": "golang-benchmark Skill (ext)",
|
||||
"85": "golang-concurrency Skill (ext)",
|
||||
"86": "golang-ci Skill (ext)",
|
||||
"87": "golang-data-structures Skill (ext)",
|
||||
"88": "golang-database Skill (ext)",
|
||||
"89": "golang-design-patterns Skill (ext)",
|
||||
"90": "golang-documentation Skill (ext)",
|
||||
"91": "golang-gopls Skill (ext)",
|
||||
"92": "golang-lint Skill (ext)",
|
||||
"93": "golang-naming Skill (ext)",
|
||||
"94": "golang-observability Skill (ext)",
|
||||
"95": "golang-refactoring Skill (ext)",
|
||||
"96": "golang-safety Skill (ext)",
|
||||
"97": "golang-samber-oops Skill (ext)",
|
||||
"98": "golang-samber-slog Skill (ext)",
|
||||
"99": "golang-structs-interfaces Skill (ext)",
|
||||
"100": "golang-troubleshooting Skill (ext)",
|
||||
"101": "promql-cli Skill",
|
||||
"102": "Backend Module",
|
||||
"103": "bookmark-api Service",
|
||||
"104": "AGENTS.md",
|
||||
"105": "reviewer.md",
|
||||
"106": "Redeploy runbook",
|
||||
"107": "1. Backend",
|
||||
"108": "Deployment",
|
||||
"109": "Cinder — BookmarkManager design system",
|
||||
"110": "Implement tickets",
|
||||
"111": "SQLite → Postgres cutover runbook",
|
||||
"112": "Testing the userscript",
|
||||
"113": "ADR-0007: The backend hosts every Site's Cover bytes",
|
||||
"114": "Issue tracker: Gitea (`tea` CLI)",
|
||||
"115": "ADR-0006: The browser runs on the home machine, over the tailnet",
|
||||
"116": "ADR-0008: A Series identity is discovered from the Site's links, never derived from an address",
|
||||
"117": "Domain Docs",
|
||||
"118": "ticket-implementer.md",
|
||||
"119": "implementer.md",
|
||||
"120": "Series is a shared entity, and only the Poll may update it",
|
||||
"121": "Postgres replaces SQLite as the primary datastore",
|
||||
"122": "Identity comes from Discord OAuth; we store no passwords and send no email",
|
||||
"123": "The wire format stays flat and deliberately does not mirror the schema",
|
||||
"124": "ADR-0005: On-demand browser sidecar",
|
||||
"126": "Bookmark Manager",
|
||||
"127": "triage-labels.md",
|
||||
"128": "Cross-Ticket Contract",
|
||||
"129": "Implement Tickets Skill",
|
||||
"130": "Orchestrator Role",
|
||||
"131": "resolving-merge-conflicts Skill",
|
||||
"132": "tdd Skill",
|
||||
"133": "Ticket Wave Batching",
|
||||
"134": "Four-Object Browser Stub",
|
||||
"135": "Module Export Hook",
|
||||
"136": "logic.test.js Test Harness",
|
||||
"137": "manga-bookmark.user.js",
|
||||
"138": "stripBuildHash",
|
||||
"139": "Testing the Userscript Skill",
|
||||
"140": "cr-spec Agent",
|
||||
"141": "cr-standards Agent",
|
||||
"142": "Escalate Rather Than Guess",
|
||||
"143": "Status Contract",
|
||||
"144": "Ticket Implementer Agent",
|
||||
"145": "Worktree Isolation",
|
||||
"146": "Escalate Rather Than Guess (opencode)",
|
||||
"147": "Implementer Subagent (opencode)",
|
||||
"148": "Subagent-Driven Development",
|
||||
"149": "Code Quality Review",
|
||||
"150": "Reviewer Subagent (opencode)",
|
||||
"151": "Finding Severity Rubric",
|
||||
"152": "Spec Compliance Review",
|
||||
"154": "ADR-0011: Sightings — a Reader report defers a Poll where being wrong hurts only them",
|
||||
"156": "ResponseWriter",
|
||||
"161": "Why Use samber/oops",
|
||||
"162": "singleflight Cache Stampede Prevention",
|
||||
"163": "Struct Field Alignment",
|
||||
"164": "testing/synctest Deterministic Goroutine Testing",
|
||||
"177": "Backend CLAUDE.md Guidance",
|
||||
"178": "Graphify Knowledge Graph (graphify-out/)",
|
||||
"179": "CLAUDE.md (Symlink to AGENTS.md)",
|
||||
"192": "ADR-0001 (Drop modernc.org/sqlite)",
|
||||
"193": "ADR-0003 (Split Shared Series Facts)",
|
||||
"194": "SQLite-to-Postgres Cutover Runbook",
|
||||
"195": "Import SQL Generation Rules",
|
||||
"196": "Throwaway Import Generator",
|
||||
"206": "Real scaling limit is the poller outbound fetch budget",
|
||||
"207": "PostgreSQL (jackc/pgx/v5)",
|
||||
"208": "SQLite (modernc.org/sqlite)",
|
||||
"209": "Postgres chosen for future supportability, not concurrency",
|
||||
"210": "Per-Reader bearer token for userscripts",
|
||||
"211": "Discord OAuth2 (authorization code grant)",
|
||||
"212": "ADR-0002: Discord OAuth, no passwords, no email",
|
||||
"213": "Discord snowflake is the sole identity (lock-in)",
|
||||
"214": "Bookmark (per-Reader state: Progress, Favourite, Lifecycle)",
|
||||
"215": "Deduplicate polling per Series (reader_count DESC queue)",
|
||||
"216": "ADR-0003: Series is shared, only the Poll updates it",
|
||||
"217": "Only the Poll writes Series fields (security boundary)",
|
||||
"218": "Series (shared entity keyed site+series_id)",
|
||||
"219": "ADR-0004: Wire format stays flat, does not mirror schema",
|
||||
"220": "Flat wire shape is a contract, not an implementation detail",
|
||||
"221": "Installed userscripts must keep working (14-day grace window)",
|
||||
"222": "CDP (Chrome DevTools Protocol) endpoint",
|
||||
"223": "headless-shell service (socat-fronted CDP)",
|
||||
"224": "Start Chrome on first CDP connection, reap after 300s idle",
|
||||
"225": "BROWSER_WS_URL configuration seam",
|
||||
"226": "ADR-0006: Browser runs on the home machine over the tailnet",
|
||||
"227": "Browser moved home: VPS memory pressure, no requests served",
|
||||
"228": "Tailnet (Tailscale network)",
|
||||
"229": "Content-addressed filesystem storage (SHA-256 of source URL)",
|
||||
"230": "Cover (Series image bytes)",
|
||||
"231": "Deny-class destination control for outbound fetch",
|
||||
"232": "ADR-0007: Backend hosts every Site's Cover bytes",
|
||||
"233": "kagane CORP same-origin cover restriction",
|
||||
"234": "Backend acquires, stores, serves every Cover (uniformity)",
|
||||
"235": "a[aria-label='All Chapter'] anchor pointer",
|
||||
"236": "Series identity is discovered from the Site's links",
|
||||
"237": "ADR-0008: Series identity discovered, never derived",
|
||||
"238": "Chapter slug vs series slug divergence (~7% measured)",
|
||||
"239": "Scan truncated at first wpd-threads marker",
|
||||
"240": "Surface ADR conflicts explicitly rather than silently overriding",
|
||||
"241": "Domain docs: single-context layout guidance",
|
||||
"242": "/domain-modeling skill (lazy CONTEXT.md creation)",
|
||||
"243": "CONTEXT.md glossary (ubiquitous language)",
|
||||
"244": "Gitea (tea CLI, gitea.violetcrown.my.id)",
|
||||
"245": "wayfinder map/ticket mechanism",
|
||||
"246": "Triage labels: canonical roles to tracker labels",
|
||||
"247": "Canonical triage role labels (needs-triage ... wontfix)",
|
||||
"252": "a[aria-label='All Chapter'] priority pointer",
|
||||
"253": "Research: lightnovelworld chapter slug vs series slug",
|
||||
"254": "Gitea issue #77 (chapter vs series slug)",
|
||||
"255": "Slug divergence measurements (3/41 diverge, 1 split)",
|
||||
"256": "Unscoped chapter regex is SAFE, truncated at wpd-threads",
|
||||
"257": "BookmarkManager",
|
||||
"258": "Bromite (Primary Device)",
|
||||
"259": "Dark-First Design Constraint",
|
||||
"260": "Discord Guild Membership",
|
||||
"261": "Reader Isolation Invariant",
|
||||
"273": "AGENTS.md",
|
||||
"279": "Userscript CLAUDE guidance"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
.
|
||||
@@ -1,556 +0,0 @@
|
||||
# Graph Report - mangaBookmark (2026-08-16)
|
||||
|
||||
## Corpus Check
|
||||
- 119 files · ~291,777 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 1739 nodes · 3610 edges · 217 communities (67 shown, 150 thin omitted)
|
||||
- Extraction: 90% EXTRACTED · 10% INFERRED · 0% AMBIGUOUS · INFERRED: 363 edges (avg confidence: 0.78)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Graph Freshness
|
||||
- Built from commit: `20fff588`
|
||||
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
|
||||
- Run `graphify update .` after code changes (no API cost).
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- [[_COMMUNITY_HTMX Library Internals|HTMX Library Internals]]
|
||||
- [[_COMMUNITY_Cover Fetch Test Helpers|Cover Fetch Test Helpers]]
|
||||
- [[_COMMUNITY_Manga Userscript Adapters|Manga Userscript Adapters]]
|
||||
- [[_COMMUNITY_Novel Userscript Adapters|Novel Userscript Adapters]]
|
||||
- [[_COMMUNITY_Series Acquisition Tests|Series Acquisition Tests]]
|
||||
- [[_COMMUNITY_Bookmarks API Tests|Bookmarks API Tests]]
|
||||
- [[_COMMUNITY_Storage Choice ADR|Storage Choice ADR]]
|
||||
- [[_COMMUNITY_Cover & Acquire Internals|Cover & Acquire Internals]]
|
||||
- [[_COMMUNITY_System Architecture Concepts|System Architecture Concepts]]
|
||||
- [[_COMMUNITY_Session Middleware|Session Middleware]]
|
||||
- [[_COMMUNITY_Go Test Helpers|Go Test Helpers]]
|
||||
- [[_COMMUNITY_Store Tests|Store Tests]]
|
||||
- [[_COMMUNITY_Bookmarks API Handler|Bookmarks API Handler]]
|
||||
- [[_COMMUNITY_displayChapter|displayChapter]]
|
||||
- [[_COMMUNITY_Go Error Handling|Go Error Handling]]
|
||||
- [[_COMMUNITY_Cloudflare bot scoring and poll cadence — what is actually documented|Cloudflare bot scoring and poll cadence — what is actually documented]]
|
||||
- [[_COMMUNITY_Go Code Style Guide|Go Code Style Guide]]
|
||||
- [[_COMMUNITY_Agent Skills|Agent Skills]]
|
||||
- [[_COMMUNITY_Store|Store]]
|
||||
- [[_COMMUNITY_IO Performance Patterns|I/O Performance Patterns]]
|
||||
- [[_COMMUNITY_CPU Optimization|CPU Optimization]]
|
||||
- [[_COMMUNITY_Caching Patterns|Caching Patterns]]
|
||||
- [[_COMMUNITY_Browser Entrypoint|Browser Entrypoint]]
|
||||
- [[_COMMUNITY_Memory Allocation & GC|Memory Allocation & GC]]
|
||||
- [[_COMMUNITY_Cover Fetcher Tests|Cover Fetcher Tests]]
|
||||
- [[_COMMUNITY_Open|Open]]
|
||||
- [[_COMMUNITY_Find Skills Guide|Find Skills Guide]]
|
||||
- [[_COMMUNITY_Allocation Patterns|Allocation Patterns]]
|
||||
- [[_COMMUNITY_Observability & Alerting|Observability & Alerting]]
|
||||
- [[_COMMUNITY_AGENTS|AGENTS.md]]
|
||||
- [[_COMMUNITY_Go Testing Guide|Go Testing Guide]]
|
||||
- [[_COMMUNITY_Session Store|Session Store]]
|
||||
- [[_COMMUNITY_Web UI Filter Logic|Web UI Filter Logic]]
|
||||
- [[_COMMUNITY_Userscript Test Harness|Userscript Test Harness]]
|
||||
- [[_COMMUNITY_Product & Security Context|Product & Security Context]]
|
||||
- [[_COMMUNITY_novel-logic.test.js|novel-logic.test.js]]
|
||||
- [[_COMMUNITY_UI Critique 2026-07-26A|UI Critique 2026-07-26A]]
|
||||
- [[_COMMUNITY_UI Critique 2026-07-26B|UI Critique 2026-07-26B]]
|
||||
- [[_COMMUNITY_Handler|Handler]]
|
||||
- [[_COMMUNITY_Issue Tracker & Triage|Issue Tracker & Triage]]
|
||||
- [[_COMMUNITY_Ticket Workflow|Ticket Workflow]]
|
||||
- [[_COMMUNITY_Go Perf Alert Rules|Go Perf Alert Rules]]
|
||||
- [[_COMMUNITY_Userscript Display Logic|Userscript Display Logic]]
|
||||
- [[_COMMUNITY_Go Perf Skill Docs|Go Perf Skill Docs]]
|
||||
- [[_COMMUNITY_Login Page Art|Login Page Art]]
|
||||
- [[_COMMUNITY_BookmarkManager Logo|BookmarkManager Logo]]
|
||||
- [[_COMMUNITY_Skills CLI|Skills CLI]]
|
||||
- [[_COMMUNITY_Skills Leaderboard|Skills Leaderboard]]
|
||||
- [[_COMMUNITY_Complex Condition Extraction|Complex Condition Extraction]]
|
||||
- [[_COMMUNITY_Sentinel Errors|Sentinel Errors]]
|
||||
- [[_COMMUNITY_errors.As Patterns|errors.As Patterns]]
|
||||
- [[_COMMUNITY_errors.Is Patterns|errors.Is Patterns]]
|
||||
- [[_COMMUNITY_errors.Join Patterns|errors.Join Patterns]]
|
||||
- [[_COMMUNITY_Error Wrapping|Error Wrapping]]
|
||||
- [[_COMMUNITY_Single Error Handling|Single Error Handling]]
|
||||
- [[_COMMUNITY_SIMD Optimizations|SIMD Optimizations]]
|
||||
- [[_COMMUNITY_GOGC Tuning|GOGC Tuning]]
|
||||
- [[_COMMUNITY_GOMEMLIMIT|GOMEMLIMIT]]
|
||||
- [[_COMMUNITY_Bottleneck Decision Tree|Bottleneck Decision Tree]]
|
||||
- [[_COMMUNITY_pprof Profiling|pprof Profiling]]
|
||||
- [[_COMMUNITY_Test Timeout Helper|Test Timeout Helper]]
|
||||
- [[_COMMUNITY_httptest Patterns|httptest Patterns]]
|
||||
- [[_COMMUNITY_testify Suite Pattern|testify Suite Pattern]]
|
||||
- [[_COMMUNITY_goembed Fixtures|go:embed Fixtures]]
|
||||
- [[_COMMUNITY_clockwork Time Mocking|clockwork Time Mocking]]
|
||||
- [[_COMMUNITY_testify Mocking|testify Mocking]]
|
||||
- [[_COMMUNITY_t.ArtifactDir Helper|t.ArtifactDir Helper]]
|
||||
- [[_COMMUNITY_Subtests Pitfall|Subtests Pitfall]]
|
||||
- [[_COMMUNITY_golang-benchmark Skill|golang-benchmark Skill]]
|
||||
- [[_COMMUNITY_golang-concurrency Skill|golang-concurrency Skill]]
|
||||
- [[_COMMUNITY_golang-ci Skill|golang-ci Skill]]
|
||||
- [[_COMMUNITY_golang-database Skill|golang-database Skill]]
|
||||
- [[_COMMUNITY_golang-lint Skill|golang-lint Skill]]
|
||||
- [[_COMMUNITY_testify Skill|testify Skill]]
|
||||
- [[_COMMUNITY_Build Tag Integration Tests|Build Tag Integration Tests]]
|
||||
- [[_COMMUNITY_Test Naming Convention|Test Naming Convention]]
|
||||
- [[_COMMUNITY_UI Critique A Finding|UI Critique A Finding]]
|
||||
- [[_COMMUNITY_UI Critique B Finding|UI Critique B Finding]]
|
||||
- [[_COMMUNITY_P0 Overflow Bug|P0 Overflow Bug]]
|
||||
- [[_COMMUNITY_P1 hx-indicator Gap|P1 hx-indicator Gap]]
|
||||
- [[_COMMUNITY_golang-benchmark Skill (ext)|golang-benchmark Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-concurrency Skill (ext)|golang-concurrency Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-ci Skill (ext)|golang-ci Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-data-structures Skill (ext)|golang-data-structures Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-database Skill (ext)|golang-database Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-design-patterns Skill (ext)|golang-design-patterns Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-documentation Skill (ext)|golang-documentation Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-gopls Skill (ext)|golang-gopls Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-lint Skill (ext)|golang-lint Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-naming Skill (ext)|golang-naming Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-observability Skill (ext)|golang-observability Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-refactoring Skill (ext)|golang-refactoring Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-safety Skill (ext)|golang-safety Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-samber-oops Skill (ext)|golang-samber-oops Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-samber-slog Skill (ext)|golang-samber-slog Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-structs-interfaces Skill (ext)|golang-structs-interfaces Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-troubleshooting Skill (ext)|golang-troubleshooting Skill (ext)]]
|
||||
- [[_COMMUNITY_promql-cli Skill|promql-cli Skill]]
|
||||
- [[_COMMUNITY_Backend Module|Backend Module]]
|
||||
- [[_COMMUNITY_bookmark-api Service|bookmark-api Service]]
|
||||
- [[_COMMUNITY_AGENTS|AGENTS.md]]
|
||||
- [[_COMMUNITY_reviewer|reviewer.md]]
|
||||
- [[_COMMUNITY_Redeploy runbook|Redeploy runbook]]
|
||||
- [[_COMMUNITY_1. Backend|1. Backend]]
|
||||
- [[_COMMUNITY_Deployment|Deployment]]
|
||||
- [[_COMMUNITY_Cinder — BookmarkManager design system|Cinder — BookmarkManager design system]]
|
||||
- [[_COMMUNITY_Implement tickets|Implement tickets]]
|
||||
- [[_COMMUNITY_SQLite → Postgres cutover runbook|SQLite → Postgres cutover runbook]]
|
||||
- [[_COMMUNITY_Testing the userscript|Testing the userscript]]
|
||||
- [[_COMMUNITY_ADR-0007 The backend hosts every Site's Cover bytes|ADR-0007: The backend hosts every Site's Cover bytes]]
|
||||
- [[_COMMUNITY_Issue tracker Gitea (`tea` CLI)|Issue tracker: Gitea (`tea` CLI)]]
|
||||
- [[_COMMUNITY_ADR-0006 The browser runs on the home machine, over the tailnet|ADR-0006: The browser runs on the home machine, over the tailnet]]
|
||||
- [[_COMMUNITY_ADR-0008 A Series identity is discovered from the Site's links, never derived from an address|ADR-0008: A Series identity is discovered from the Site's links, never derived from an address]]
|
||||
- [[_COMMUNITY_Domain Docs|Domain Docs]]
|
||||
- [[_COMMUNITY_ticket-implementer|ticket-implementer.md]]
|
||||
- [[_COMMUNITY_implementer|implementer.md]]
|
||||
- [[_COMMUNITY_Series is a shared entity, and only the Poll may update it|Series is a shared entity, and only the Poll may update it]]
|
||||
- [[_COMMUNITY_Postgres replaces SQLite as the primary datastore|Postgres replaces SQLite as the primary datastore]]
|
||||
- [[_COMMUNITY_Identity comes from Discord OAuth; we store no passwords and send no email|Identity comes from Discord OAuth; we store no passwords and send no email]]
|
||||
- [[_COMMUNITY_The wire format stays flat and deliberately does not mirror the schema|The wire format stays flat and deliberately does not mirror the schema]]
|
||||
- [[_COMMUNITY_ADR-0005 On-demand browser sidecar|ADR-0005: On-demand browser sidecar]]
|
||||
- [[_COMMUNITY_Bookmark Manager|Bookmark Manager]]
|
||||
- [[_COMMUNITY_triage-labels|triage-labels.md]]
|
||||
- [[_COMMUNITY_Cross-Ticket Contract|Cross-Ticket Contract]]
|
||||
- [[_COMMUNITY_Implement Tickets Skill|Implement Tickets Skill]]
|
||||
- [[_COMMUNITY_Orchestrator Role|Orchestrator Role]]
|
||||
- [[_COMMUNITY_resolving-merge-conflicts Skill|resolving-merge-conflicts Skill]]
|
||||
- [[_COMMUNITY_tdd Skill|tdd Skill]]
|
||||
- [[_COMMUNITY_Ticket Wave Batching|Ticket Wave Batching]]
|
||||
- [[_COMMUNITY_Four-Object Browser Stub|Four-Object Browser Stub]]
|
||||
- [[_COMMUNITY_Module Export Hook|Module Export Hook]]
|
||||
- [[_COMMUNITY_logic.test.js Test Harness|logic.test.js Test Harness]]
|
||||
- [[_COMMUNITY_manga-bookmark.user.js|manga-bookmark.user.js]]
|
||||
- [[_COMMUNITY_stripBuildHash|stripBuildHash]]
|
||||
- [[_COMMUNITY_Testing the Userscript Skill|Testing the Userscript Skill]]
|
||||
- [[_COMMUNITY_cr-spec Agent|cr-spec Agent]]
|
||||
- [[_COMMUNITY_cr-standards Agent|cr-standards Agent]]
|
||||
- [[_COMMUNITY_Escalate Rather Than Guess|Escalate Rather Than Guess]]
|
||||
- [[_COMMUNITY_Status Contract|Status Contract]]
|
||||
- [[_COMMUNITY_Ticket Implementer Agent|Ticket Implementer Agent]]
|
||||
- [[_COMMUNITY_Worktree Isolation|Worktree Isolation]]
|
||||
- [[_COMMUNITY_Escalate Rather Than Guess (opencode)|Escalate Rather Than Guess (opencode)]]
|
||||
- [[_COMMUNITY_Implementer Subagent (opencode)|Implementer Subagent (opencode)]]
|
||||
- [[_COMMUNITY_Subagent-Driven Development|Subagent-Driven Development]]
|
||||
- [[_COMMUNITY_Code Quality Review|Code Quality Review]]
|
||||
- [[_COMMUNITY_Reviewer Subagent (opencode)|Reviewer Subagent (opencode)]]
|
||||
- [[_COMMUNITY_Finding Severity Rubric|Finding Severity Rubric]]
|
||||
- [[_COMMUNITY_Spec Compliance Review|Spec Compliance Review]]
|
||||
- [[_COMMUNITY_ADR-0011 Sightings — a Reader report defers a Poll where being wrong hurts only them|ADR-0011: Sightings — a Reader report defers a Poll where being wrong hurts only them]]
|
||||
- [[_COMMUNITY_ResponseWriter|ResponseWriter]]
|
||||
- [[_COMMUNITY_Why Use samberoops|Why Use samber/oops]]
|
||||
- [[_COMMUNITY_singleflight Cache Stampede Prevention|singleflight Cache Stampede Prevention]]
|
||||
- [[_COMMUNITY_Struct Field Alignment|Struct Field Alignment]]
|
||||
- [[_COMMUNITY_testingsynctest Deterministic Goroutine Testing|testing/synctest Deterministic Goroutine Testing]]
|
||||
- [[_COMMUNITY_Backend CLAUDE.md Guidance|Backend CLAUDE.md Guidance]]
|
||||
- [[_COMMUNITY_Graphify Knowledge Graph (graphify-out)|Graphify Knowledge Graph (graphify-out/)]]
|
||||
- [[_COMMUNITY_CLAUDE.md (Symlink to AGENTS.md)|CLAUDE.md (Symlink to AGENTS.md)]]
|
||||
- [[_COMMUNITY_ADR-0001 (Drop modernc.orgsqlite)|ADR-0001 (Drop modernc.org/sqlite)]]
|
||||
- [[_COMMUNITY_ADR-0003 (Split Shared Series Facts)|ADR-0003 (Split Shared Series Facts)]]
|
||||
- [[_COMMUNITY_SQLite-to-Postgres Cutover Runbook|SQLite-to-Postgres Cutover Runbook]]
|
||||
- [[_COMMUNITY_Import SQL Generation Rules|Import SQL Generation Rules]]
|
||||
- [[_COMMUNITY_Throwaway Import Generator|Throwaway Import Generator]]
|
||||
- [[_COMMUNITY_Real scaling limit is the poller outbound fetch budget|Real scaling limit is the poller outbound fetch budget]]
|
||||
- [[_COMMUNITY_PostgreSQL (jackcpgxv5)|PostgreSQL (jackc/pgx/v5)]]
|
||||
- [[_COMMUNITY_SQLite (modernc.orgsqlite)|SQLite (modernc.org/sqlite)]]
|
||||
- [[_COMMUNITY_Postgres chosen for future supportability, not concurrency|Postgres chosen for future supportability, not concurrency]]
|
||||
- [[_COMMUNITY_Per-Reader bearer token for userscripts|Per-Reader bearer token for userscripts]]
|
||||
- [[_COMMUNITY_Discord OAuth2 (authorization code grant)|Discord OAuth2 (authorization code grant)]]
|
||||
- [[_COMMUNITY_ADR-0002 Discord OAuth, no passwords, no email|ADR-0002: Discord OAuth, no passwords, no email]]
|
||||
- [[_COMMUNITY_Discord snowflake is the sole identity (lock-in)|Discord snowflake is the sole identity (lock-in)]]
|
||||
- [[_COMMUNITY_Bookmark (per-Reader state Progress, Favourite, Lifecycle)|Bookmark (per-Reader state: Progress, Favourite, Lifecycle)]]
|
||||
- [[_COMMUNITY_Deduplicate polling per Series (reader_count DESC queue)|Deduplicate polling per Series (reader_count DESC queue)]]
|
||||
- [[_COMMUNITY_ADR-0003 Series is shared, only the Poll updates it|ADR-0003: Series is shared, only the Poll updates it]]
|
||||
- [[_COMMUNITY_Only the Poll writes Series fields (security boundary)|Only the Poll writes Series fields (security boundary)]]
|
||||
- [[_COMMUNITY_Series (shared entity keyed site+series_id)|Series (shared entity keyed site+series_id)]]
|
||||
- [[_COMMUNITY_ADR-0004 Wire format stays flat, does not mirror schema|ADR-0004: Wire format stays flat, does not mirror schema]]
|
||||
- [[_COMMUNITY_Flat wire shape is a contract, not an implementation detail|Flat wire shape is a contract, not an implementation detail]]
|
||||
- [[_COMMUNITY_Installed userscripts must keep working (14-day grace window)|Installed userscripts must keep working (14-day grace window)]]
|
||||
- [[_COMMUNITY_CDP (Chrome DevTools Protocol) endpoint|CDP (Chrome DevTools Protocol) endpoint]]
|
||||
- [[_COMMUNITY_headless-shell service (socat-fronted CDP)|headless-shell service (socat-fronted CDP)]]
|
||||
- [[_COMMUNITY_Start Chrome on first CDP connection, reap after 300s idle|Start Chrome on first CDP connection, reap after 300s idle]]
|
||||
- [[_COMMUNITY_BROWSER_WS_URL configuration seam|BROWSER_WS_URL configuration seam]]
|
||||
- [[_COMMUNITY_ADR-0006 Browser runs on the home machine over the tailnet|ADR-0006: Browser runs on the home machine over the tailnet]]
|
||||
- [[_COMMUNITY_Browser moved home VPS memory pressure, no requests served|Browser moved home: VPS memory pressure, no requests served]]
|
||||
- [[_COMMUNITY_Tailnet (Tailscale network)|Tailnet (Tailscale network)]]
|
||||
- [[_COMMUNITY_Content-addressed filesystem storage (SHA-256 of source URL)|Content-addressed filesystem storage (SHA-256 of source URL)]]
|
||||
- [[_COMMUNITY_Cover (Series image bytes)|Cover (Series image bytes)]]
|
||||
- [[_COMMUNITY_Deny-class destination control for outbound fetch|Deny-class destination control for outbound fetch]]
|
||||
- [[_COMMUNITY_ADR-0007 Backend hosts every Site's Cover bytes|ADR-0007: Backend hosts every Site's Cover bytes]]
|
||||
- [[_COMMUNITY_kagane CORP same-origin cover restriction|kagane CORP same-origin cover restriction]]
|
||||
- [[_COMMUNITY_Backend acquires, stores, serves every Cover (uniformity)|Backend acquires, stores, serves every Cover (uniformity)]]
|
||||
- [[_COMMUNITY_aaria-label='All Chapter' anchor pointer|a[aria-label='All Chapter'] anchor pointer]]
|
||||
- [[_COMMUNITY_Series identity is discovered from the Site's links|Series identity is discovered from the Site's links]]
|
||||
- [[_COMMUNITY_ADR-0008 Series identity discovered, never derived|ADR-0008: Series identity discovered, never derived]]
|
||||
- [[_COMMUNITY_Chapter slug vs series slug divergence (~7% measured)|Chapter slug vs series slug divergence (~7% measured)]]
|
||||
- [[_COMMUNITY_Scan truncated at first wpd-threads marker|Scan truncated at first wpd-threads marker]]
|
||||
- [[_COMMUNITY_Surface ADR conflicts explicitly rather than silently overriding|Surface ADR conflicts explicitly rather than silently overriding]]
|
||||
- [[_COMMUNITY_Domain docs single-context layout guidance|Domain docs: single-context layout guidance]]
|
||||
- [[_COMMUNITY_domain-modeling skill (lazy CONTEXT.md creation)|/domain-modeling skill (lazy CONTEXT.md creation)]]
|
||||
- [[_COMMUNITY_CONTEXT.md glossary (ubiquitous language)|CONTEXT.md glossary (ubiquitous language)]]
|
||||
- [[_COMMUNITY_Gitea (tea CLI, gitea.violetcrown.my.id)|Gitea (tea CLI, gitea.violetcrown.my.id)]]
|
||||
- [[_COMMUNITY_wayfinder mapticket mechanism|wayfinder map/ticket mechanism]]
|
||||
- [[_COMMUNITY_Triage labels canonical roles to tracker labels|Triage labels: canonical roles to tracker labels]]
|
||||
- [[_COMMUNITY_Canonical triage role labels (needs-triage ... wontfix)|Canonical triage role labels (needs-triage ... wontfix)]]
|
||||
- [[_COMMUNITY_aaria-label='All Chapter' priority pointer|a[aria-label='All Chapter'] priority pointer]]
|
||||
- [[_COMMUNITY_Research lightnovelworld chapter slug vs series slug|Research: lightnovelworld chapter slug vs series slug]]
|
||||
- [[_COMMUNITY_Gitea issue 77 (chapter vs series slug)|Gitea issue #77 (chapter vs series slug)]]
|
||||
- [[_COMMUNITY_Slug divergence measurements (341 diverge, 1 split)|Slug divergence measurements (3/41 diverge, 1 split)]]
|
||||
- [[_COMMUNITY_Unscoped chapter regex is SAFE, truncated at wpd-threads|Unscoped chapter regex is SAFE, truncated at wpd-threads]]
|
||||
- [[_COMMUNITY_BookmarkManager|BookmarkManager]]
|
||||
- [[_COMMUNITY_Bromite (Primary Device)|Bromite (Primary Device)]]
|
||||
- [[_COMMUNITY_Dark-First Design Constraint|Dark-First Design Constraint]]
|
||||
- [[_COMMUNITY_Discord Guild Membership|Discord Guild Membership]]
|
||||
- [[_COMMUNITY_Reader Isolation Invariant|Reader Isolation Invariant]]
|
||||
- [[_COMMUNITY_AGENTS|AGENTS.md]]
|
||||
- [[_COMMUNITY_Userscript CLAUDE guidance|Userscript CLAUDE guidance]]
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `newTestStore()` - 62 edges
|
||||
2. `testConfig()` - 55 edges
|
||||
3. `newWebTestServer()` - 49 edges
|
||||
4. `newTestStore()` - 44 edges
|
||||
5. `e()` - 33 edges
|
||||
6. `Store` - 31 edges
|
||||
7. `Open()` - 31 edges
|
||||
8. `newTestPoller()` - 30 edges
|
||||
9. `Handler` - 29 edges
|
||||
10. `ne()` - 28 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `el()` --indirect_call--> `c()` [INFERRED]
|
||||
userscript/manga-bookmark.user.js → backend/internal/web/static/htmx.min.js
|
||||
- `el()` --indirect_call--> `c()` [INFERRED]
|
||||
userscript/novel-bookmark.user.js → backend/internal/web/static/htmx.min.js
|
||||
- `latestChapterFromAnchors()` --indirect_call--> `re()` [INFERRED]
|
||||
userscript/manga-bookmark.user.js → backend/internal/web/static/htmx.min.js
|
||||
- `el()` --indirect_call--> `k()` [INFERRED]
|
||||
userscript/manga-bookmark.user.js → backend/internal/web/static/htmx.min.js
|
||||
- `el()` --indirect_call--> `k()` [INFERRED]
|
||||
userscript/novel-bookmark.user.js → backend/internal/web/static/htmx.min.js
|
||||
|
||||
## Import Cycles
|
||||
- None detected.
|
||||
|
||||
## Hyperedges (group relationships)
|
||||
- **Batch Ticket Implementation Pipeline** — _claude_skills_implement_tickets_skill_implement_tickets, _omp_agents_ticket_implementer_ticket_implementer, _omp_agents_ticket_implementer_cr_spec, _omp_agents_ticket_implementer_cr_standards [INFERRED 0.85]
|
||||
- **Subagent-Driven Development Pipeline** — _opencode_agent_implementer_implementer, _opencode_agent_reviewer_reviewer, _opencode_agent_implementer_subagent_driven_development [INFERRED 0.85]
|
||||
- **Backend owns the truth (single-writer ownership of shared facts)** — docs_adr_0003_series_shared_and_poll_owned_poll_owned_writes, docs_adr_0004_wire_format_does_not_mirror_the_schema_flat_wire_contract, docs_adr_0007_backend_hosts_cover_bytes_server_side_covers [INFERRED 0.85]
|
||||
- **Headless browser infrastructure (sidecar, on-demand, home deployment)** — docs_adr_0005_on_demand_browser_headless_shell, docs_adr_0005_on_demand_browser_cdp, docs_adr_0005_on_demand_browser_on_demand_start, docs_adr_0006_browser_on_the_home_machine_home_machine_rationale [INFERRED 0.85]
|
||||
- **lightnovelworld series-identity investigation and fix** — docs_research_lightnovelworld_chapter_vs_series_slug_issue_77, docs_research_lightnovelworld_chapter_vs_series_slug_unscoped_regex, docs_adr_0008_series_identity_is_discovered_not_derived_discovered_identity [INFERRED 0.85]
|
||||
|
||||
## Communities (217 total, 150 thin omitted)
|
||||
|
||||
### Community 0 - "HTMX Library Internals"
|
||||
Cohesion: 0.08
|
||||
Nodes (101): A(), ae(), an(), at(), B(), be(), bn(), bt() (+93 more)
|
||||
|
||||
### Community 1 - "Cover Fetch Test Helpers"
|
||||
Cohesion: 0.09
|
||||
Nodes (88): floatPtr(), testConfig(), getCover(), Cookie, Handler, ResponseRecorder, T, TestListRendersAcquiredCover() (+80 more)
|
||||
|
||||
### Community 2 - "Manga Userscript Adapters"
|
||||
Cohesion: 0.06
|
||||
Nodes (77): adapterFor(), anchorsFromDocument(), anchorsFromHTML(), apiDelete(), apiGet(), apiPut(), applyFabPos(), armDwell() (+69 more)
|
||||
|
||||
### Community 3 - "Novel Userscript Adapters"
|
||||
Cohesion: 0.06
|
||||
Nodes (78): adapterFor(), anchorsFromDocument(), anchorsFromHTML(), apiDelete(), apiGet(), apiPut(), applyFabPos(), applyLnwStaleRowRepair() (+70 more)
|
||||
|
||||
### Community 4 - "Series Acquisition Tests"
|
||||
Cohesion: 0.07
|
||||
Nodes (103): bookmarkNewKaganeSeries(), bookmarkNewNovelfullSeries(), bookmarkNewSeries(), Context, Store, T, newAcquirer(), readBookmark() (+95 more)
|
||||
|
||||
### Community 5 - "Bookmarks API Tests"
|
||||
Cohesion: 0.11
|
||||
Nodes (54): auth(), getBookmarks(), Handler, Request, Store, T, newTestServer(), newTestStore() (+46 more)
|
||||
|
||||
### Community 7 - "Cover & Acquire Internals"
|
||||
Cohesion: 0.08
|
||||
Nodes (31): fakeLanes, Context, Store, WaitGroup, isInterstitial(), fetchCoverBytes(), browserWakeDue(), fetcherFor() (+23 more)
|
||||
|
||||
### Community 8 - "System Architecture Concepts"
|
||||
Cohesion: 0.15
|
||||
Nodes (15): Confirm Row (Archive/Finish/Remove), card.html — Series Card Template, htmx /ui/* Mutation Endpoints, chrome.html — Out-of-Band Regions, Action Key, Brand Mark SVG, Continue Reading Strip, icons.html — Icon Sprite Template (+7 more)
|
||||
|
||||
### Community 9 - "Session Middleware"
|
||||
Cohesion: 0.08
|
||||
Nodes (35): ClearCookie(), ClientIP(), Duration, Mutex, Request, ResponseWriter, Time, isHTTPS() (+27 more)
|
||||
|
||||
### Community 10 - "Go Test Helpers"
|
||||
Cohesion: 0.05
|
||||
Nodes (39): Test Helpers, Test Timeout, Basic Handler Test, HTTP Handler Testing, Query Parameters and Headers, Docker Compose Fixture, Integration Testing, SQL Schema Fixture (+31 more)
|
||||
|
||||
### Community 11 - "Store Tests"
|
||||
Cohesion: 0.07
|
||||
Nodes (81): M, TestMain(), M, TestMain(), M, Main(), start(), URL() (+73 more)
|
||||
|
||||
### Community 12 - "Bookmarks API Handler"
|
||||
Cohesion: 0.08
|
||||
Nodes (32): Handler, Request, ResponseWriter, Store, Healthz(), writeJSON(), Auth(), compressible() (+24 more)
|
||||
|
||||
### Community 13 - "displayChapter"
|
||||
Cohesion: 0.09
|
||||
Nodes (36): Addr, Config, NewBrowserFetcher(), defaultCoverResolver(), Client, Context, NewCoverFetcher(), NewCoverFetcherWithResolver() (+28 more)
|
||||
|
||||
### Community 14 - "Go Error Handling"
|
||||
Cohesion: 0.06
|
||||
Nodes (33): Creating Errors, Custom Error Types, Custom types that wrap other errors, Decision table: which error strategy to use, Error Creation, Error String Conventions, Errors as Values, `errors.New` — static error messages (+25 more)
|
||||
|
||||
### Community 16 - "Cloudflare bot scoring and poll cadence — what is actually documented"
|
||||
Cohesion: 0.06
|
||||
Nodes (33): 1.1 The score itself, 1.2 The detection engines (Enterprise Bot Management), 1.3 Rate limiting is a separate product, 1. What a bot score is and what feeds it, 2.1 What each plan gets, 2.2 Bot Fight Mode specifics (the Free-plan product), 2.3 Does the free tier "score" continuously?, 2. The free-plan reality (+25 more)
|
||||
|
||||
### Community 17 - "Go Code Style Guide"
|
||||
Cohesion: 0.08
|
||||
Nodes (23): Code Style Details, Extract Complex Conditions, Value vs Pointer Arguments, Code Organization Within Files, Complex Conditions & Init Scope, Composite Literals, Control Flow, Cross-References (+15 more)
|
||||
|
||||
### Community 19 - "Store"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): ADR-0010: Poll Lanes — one independent Poll stream per Site, Constraints preserved, Decision, Tradeoffs and rejections, Why
|
||||
|
||||
### Community 20 - "I/O Performance Patterns"
|
||||
Cohesion: 0.11
|
||||
Nodes (18): Avoid io.ReadAll for large payloads, Batch Operations, Buffered I/O, Cgo Overhead, Channel: batch processing from a stream, Concurrent Multi-Stage Pipelines, Connection pooling, Database: batch inserts over row-by-row (+10 more)
|
||||
|
||||
### Community 21 - "CPU Optimization"
|
||||
Cohesion: 0.13
|
||||
Nodes (15): Cache Locality, Contiguous 2D allocation, CPU Optimization, False Sharing, Function Inlining, Handling CPU-specific instruction sets, Instruction-Level Parallelism, Monotonic Time (+7 more)
|
||||
|
||||
### Community 22 - "Caching Patterns"
|
||||
Cohesion: 0.13
|
||||
Nodes (14): Algorithmic Complexity, Avoid iterator chains, Caching Patterns, Compiled Pattern Caching, Early returns and short-circuit loops, LRU caches, Map lookups over slice scanning, Precomputed lookup tables (+6 more)
|
||||
|
||||
### Community 23 - "Browser Entrypoint"
|
||||
Cohesion: 0.35
|
||||
Nodes (14): browser_alive(), connection(), connection_signal(), finish_connection(), has_connections(), lock(), reaper(), entrypoint.sh script (+6 more)
|
||||
|
||||
### Community 24 - "Memory Allocation & GC"
|
||||
Cohesion: 0.13
|
||||
Nodes (15): Allocation Rate Reduction, Ballast pattern (pre-Go 1.19), Garbage Collector Tuning, GC pacing, GC Profiling and Diagnostics, GODEBUG=gctrace=1, GOGC (default: 100), GOMAXPROCS in Containers (+7 more)
|
||||
|
||||
### Community 25 - "Cover Fetcher Tests"
|
||||
Cohesion: 0.33
|
||||
Nodes (12): coverResponse(), Request, T, TestCoverFetcherCanonicalisesJpgAlias(), TestCoverFetcherFetchesPublicHTTPSImage(), TestCoverFetcherRefusesUnsafeDestinationsBeforeRequest(), TestCoverFetcherRejectsNonImage(), TestCoverFetcherRejectsOversizedBody() (+4 more)
|
||||
|
||||
### Community 26 - "Open"
|
||||
Cohesion: 0.06
|
||||
Nodes (57): awaitPromise(), browserConnectionLost(), classifyBrowserError(), comixRead(), comixSeriesPageURL(), Action, Context, Mutex (+49 more)
|
||||
|
||||
### Community 27 - "Find Skills Guide"
|
||||
Cohesion: 0.14
|
||||
Nodes (13): Common Skill Categories, Find Skills, How to Help Users Find Skills, Step 1: Understand What They Need, Step 2: Check the Leaderboard First, Step 3: Search for Skills, Step 4: Verify Quality Before Recommending, Step 5: Present Options to the User (+5 more)
|
||||
|
||||
### Community 28 - "Allocation Patterns"
|
||||
Cohesion: 0.14
|
||||
Nodes (14): Allocation Patterns, Backing Array Leaks, Direct indexing vs append, Eliminate redundant map lookups, Interface boxing, Map never shrinks, Map size hints, Memory Optimization (+6 more)
|
||||
|
||||
### Community 29 - "Observability & Alerting"
|
||||
Cohesion: 0.22
|
||||
Nodes (9): Alerting rules (examples), CPU saturation, GC pressure, Goroutine leaks, Grafana Dashboards, Memory leaks, Prometheus Metrics for Go, PromQL Queries for Performance Diagnosis (+1 more)
|
||||
|
||||
### Community 30 - "AGENTS.md"
|
||||
Cohesion: 0.40
|
||||
Nodes (5): Map of pointers for large, frequently updated structs, Memory Layout, Pointer receivers for large structs, Struct field alignment, Zero-size field at end of struct
|
||||
|
||||
### Community 33 - "Go Testing Guide"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): CI Regression Detection, Common Mistakes, Core Philosophy, Cross-References, Decision Tree: Where Is Time Spent?, Deep Dives, Go Performance Optimization, Iterative Optimization Methodology (+2 more)
|
||||
|
||||
### Community 34 - "Session Store"
|
||||
Cohesion: 0.28
|
||||
Nodes (4): Duration, Store, Time, Session
|
||||
|
||||
### Community 35 - "Web UI Filter Logic"
|
||||
Cohesion: 0.31
|
||||
Nodes (5): closeCardPanels(), setActiveTab(), toggleChapterForm(), toggleConfirmRow(), togglePanel()
|
||||
|
||||
### Community 37 - "Product & Security Context"
|
||||
Cohesion: 0.17
|
||||
Nodes (11): Accessibility & Inclusion, Brand Commitments, Capabilities and Constraints, Evidence on Hand, Operating Context, Platform, Positioning, Product (+3 more)
|
||||
|
||||
### Community 38 - "novel-logic.test.js"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): ADR-0009: A Site answers fixed questions; an unusual Site owns its own fetch, Consequences, Considered options, Decision, Why
|
||||
|
||||
### Community 39 - "UI Critique 2026-07-26A"
|
||||
Cohesion: 0.29
|
||||
Nodes (6): Design Health Score, Design Specificity Verdict, Minor Observations, Persona Red Flags, Priority Issues, Questions to Consider
|
||||
|
||||
### Community 40 - "UI Critique 2026-07-26B"
|
||||
Cohesion: 0.29
|
||||
Nodes (6): Design Health Score, Design Specificity Verdict, Minor Observations, Persona Red Flags, Priority Issues, Questions to Consider
|
||||
|
||||
### Community 41 - "Handler"
|
||||
Cohesion: 0.05
|
||||
Nodes (25): coverRelativePath(), coverSourceAddress(), displayChapter(), Store, scanSeries(), currentLib(), currentTab(), filterBookmarks() (+17 more)
|
||||
|
||||
### Community 45 - "Go Perf Alert Rules"
|
||||
Cohesion: 0.50
|
||||
Nodes (4): Prometheus Alerting Rules (Go Performance), GoroutineLeak Alert, HighGCPauseTime Alert, MemoryNearLimit Alert
|
||||
|
||||
### Community 46 - "Userscript Display Logic"
|
||||
Cohesion: 0.07
|
||||
Nodes (27): 1. Summary answer table, 2. The two reference pages, 3. Chapter page → series URL: every in-page pointer, in priority order, 4.1 Sample method, 4.2 Divergence results, 4.3 Is there a derivable rule? **No.**, 4.4 The split case — a slug can change *mid-series*, 4. The reverse direction, and how common divergence is (+19 more)
|
||||
|
||||
### Community 47 - "Go Perf Skill Docs"
|
||||
Cohesion: 0.22
|
||||
Nodes (5): Continuous Profiling, Production Observability for Performance, Pyroscope pull mode (via Grafana Alloy), Pyroscope push mode, Real-Time Visualization (Development)
|
||||
|
||||
### Community 48 - "Login Page Art"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): Fantasy Sword, Fiery Volcanic Scene, Login Art: Sword in Volcanic Rock
|
||||
|
||||
### Community 49 - "BookmarkManager Logo"
|
||||
Cohesion: 1.00
|
||||
Nodes (3): Mirrored Double Bookmark Mark, Ember Flame Accent, BookmarkManager Logo
|
||||
|
||||
### Community 103 - "bookmark-api Service"
|
||||
Cohesion: 0.32
|
||||
Nodes (8): Browser Sidecar Service, CDP Endpoint (Tailnet-Bound :9222), Persistent Chrome Profile Volume, chrome/docker-compose.yml — Browser Deployable Unit, bookmark-api Prod Override, CDP Never on Shared Proxy Network, docker-compose.prod.yml — Production Override, Traefik Reverse Proxy Labels
|
||||
|
||||
### Community 104 - "AGENTS.md"
|
||||
Cohesion: 0.12
|
||||
Nodes (14): Agent skills, Architecture, Commands, Comments, Design system, Domain docs, Forge: Gitea, not GitHub, graphify (+6 more)
|
||||
|
||||
### Community 105 - "reviewer.md"
|
||||
Cohesion: 0.12
|
||||
Nodes (15): Assessment, Calibration, Critical (Must Fix), Do Not Trust the Report, Important (Should Fix), Inputs, Issues, Method (+7 more)
|
||||
|
||||
### Community 106 - "Redeploy runbook"
|
||||
Cohesion: 0.12
|
||||
Nodes (15): 0. Preflight, 1. Back up the database, 2. Pull the new code, 3. Rebuild and restart, 4. Verify the deploy, 5. Smoke-test the full loop, 6. Rollback, 7. The whole thing, as one block (+7 more)
|
||||
|
||||
### Community 107 - "1. Backend"
|
||||
Cohesion: 0.13
|
||||
Nodes (14): 1. Backend, 2. Userscript, Adapter reference (verified live 2026-07-24), Config (env), Deploy behind your reverse proxy, Desktop iteration (optional), Develop / test, Endpoints (+6 more)
|
||||
|
||||
### Community 108 - "Deployment"
|
||||
Cohesion: 0.14
|
||||
Nodes (13): 0. Prerequisites, 1. Configure `.env`, 1b. Web UI, 2. Build + start, 3. Verify over HTTPS, 4. Configure the userscript, 5. Install on Bromite, 6. Smoke-test the full loop (+5 more)
|
||||
|
||||
### Community 109 - "Cinder — BookmarkManager design system"
|
||||
Cohesion: 0.20
|
||||
Nodes (9): 1. The one idea, 2. Tokens, 3. Type, 4. Components (web UI), 5. Components (userscript panel), 6. Motion, 7. Accessibility floor (not negotiable), 8. Adding something new — checklist (+1 more)
|
||||
|
||||
### Community 110 - "Implement tickets"
|
||||
Cohesion: 0.22
|
||||
Nodes (8): 1. Collect the tickets, 2. Plan the batch, 3. Get the plan approved, 4. Run a wave, 5. Land the wave, 6. Close the batch, Implement tickets, Ticket #<n> — <title>
|
||||
|
||||
### Community 111 - "SQLite → Postgres cutover runbook"
|
||||
Cohesion: 0.22
|
||||
Nodes (8): 0. The generator is throwaway, 1. Stop the old API and take a fresh export, 2. Bring up Postgres with the schema and the owner Reader, 3. Generate the import SQL, 4. Review it by eye, 5. Apply it, 6. Afterwards, SQLite → Postgres cutover runbook
|
||||
|
||||
### Community 112 - "Testing the userscript"
|
||||
Cohesion: 0.29
|
||||
Nodes (6): Adding a test, Commands, Gotchas, How the harness works, Testing the userscript, What is NOT testable here
|
||||
|
||||
### Community 113 - "ADR-0007: The backend hosts every Site's Cover bytes"
|
||||
Cohesion: 0.29
|
||||
Nodes (6): ADR-0007: The backend hosts every Site's Cover bytes, Consequences, Considered options, Decision, Two deliberate relaxations, Why a future reader will find this surprising
|
||||
|
||||
### Community 114 - "Issue tracker: Gitea (`tea` CLI)"
|
||||
Cohesion: 0.29
|
||||
Nodes (6): Conventions, Issue tracker: Gitea (`tea` CLI), Pull requests as a triage surface, Wayfinding operations, When a skill says "fetch the relevant ticket", When a skill says "publish to the issue tracker"
|
||||
|
||||
### Community 115 - "ADR-0006: The browser runs on the home machine, over the tailnet"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): ADR-0006: The browser runs on the home machine, over the tailnet, Consequences, Constraints, Decision, Why
|
||||
|
||||
### Community 116 - "ADR-0008: A Series identity is discovered from the Site's links, never derived from an address"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): ADR-0008: A Series identity is discovered from the Site's links, never derived from an address, Consequences, Considered options, Decision, Why
|
||||
|
||||
### Community 117 - "Domain Docs"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): Before exploring, read these, Domain Docs, File structure, Flag ADR conflicts, Use the glossary's vocabulary
|
||||
|
||||
### Community 118 - "ticket-implementer.md"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): Escalate rather than guess, Order of work, Report, Review, The worktree is your whole world
|
||||
|
||||
### Community 119 - "implementer.md"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): Before You Begin, Report Format, Self-Review Before Reporting, When You're in Over Your Head, Your Job
|
||||
|
||||
### Community 120 - "Series is a shared entity, and only the Poll may update it"
|
||||
Cohesion: 0.40
|
||||
Nodes (4): Consequences, Only the Poll writes Series fields, Series is a shared entity, and only the Poll may update it, Why
|
||||
|
||||
### Community 121 - "Postgres replaces SQLite as the primary datastore"
|
||||
Cohesion: 0.50
|
||||
Nodes (3): Consequences, Considered options, Postgres replaces SQLite as the primary datastore
|
||||
|
||||
### Community 122 - "Identity comes from Discord OAuth; we store no passwords and send no email"
|
||||
Cohesion: 0.50
|
||||
Nodes (3): Consequences, Considered options, Identity comes from Discord OAuth; we store no passwords and send no email
|
||||
|
||||
### Community 123 - "The wire format stays flat and deliberately does not mirror the schema"
|
||||
Cohesion: 0.50
|
||||
Nodes (3): Consequence, The wire format stays flat and deliberately does not mirror the schema, Why a future reader will find this surprising
|
||||
|
||||
### Community 124 - "ADR-0005: On-demand browser sidecar"
|
||||
Cohesion: 0.50
|
||||
Nodes (3): ADR-0005: On-demand browser sidecar, Constraints, Decision
|
||||
|
||||
### Community 154 - "ADR-0011: Sightings — a Reader report defers a Poll where being wrong hurts only them"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): ADR-0011: Sightings — a Reader report defers a Poll where being wrong hurts only them, Constraints preserved, Decision, Tradeoffs and rejections, Why
|
||||
|
||||
### Community 156 - "ResponseWriter"
|
||||
Cohesion: 0.18
|
||||
Nodes (13): AdminPatterns(), HandlerFunc, Request, ResponseWriter, Time, Handler, readerPathID(), since() (+5 more)
|
||||
|
||||
### Community 273 - "AGENTS.md"
|
||||
Cohesion: 0.50
|
||||
Nodes (3): Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trust), Second script: `novel-bookmark.user.js`, Userscript structure (single IIFE, `manga-bookmark.user.js`)
|
||||
|
||||
## Knowledge Gaps
|
||||
- **524 isolated node(s):** `bookmarkmanager/backend`, `ctxKey`, `loginView`, `ctxKey`, `test` (+519 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **150 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `Open()` connect `Store Tests` to `Cover Fetch Test Helpers`, `Series Acquisition Tests`, `Bookmarks API Tests`, `Handler`, `displayChapter`?**
|
||||
_High betweenness centrality (0.057) - this node is a cross-community bridge._
|
||||
- **Why does `New()` connect `Series Acquisition Tests` to `Bookmarks API Tests`, `Cover & Acquire Internals`, `Handler`, `Session Middleware`, `Store Tests`, `displayChapter`, `ResponseWriter`?**
|
||||
_High betweenness centrality (0.051) - this node is a cross-community bridge._
|
||||
- **Why does `newRouter()` connect `Bookmarks API Tests` to `Cover Fetch Test Helpers`, `Series Acquisition Tests`, `Bookmarks API Handler`, `displayChapter`, `ResponseWriter`?**
|
||||
_High betweenness centrality (0.031) - this node is a cross-community bridge._
|
||||
- **Are the 25 inferred relationships involving `newTestStore()` (e.g. with `TestAcquireDoesNotBlockTheWrite()` and `TestAcquireFailureLeavesTheBookmarkIntact()`) actually correct?**
|
||||
_`newTestStore()` has 25 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 48 inferred relationships involving `testConfig()` (e.g. with `TestListRendersAcquiredCover()` and `TestPublicCoverNeverEchoesNonImage()`) actually correct?**
|
||||
_`testConfig()` has 48 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 8 inferred relationships involving `newWebTestServer()` (e.g. with `TestListRendersAcquiredCover()` and `TestPublicCoverRejectsUnknownAddress()`) actually correct?**
|
||||
_`newWebTestServer()` has 8 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **What connects `bookmarkmanager/backend`, `ctxKey`, `loginView` to the rest of the system?**
|
||||
_556 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
File diff suppressed because one or more lines are too long
-56488
File diff suppressed because it is too large
Load Diff
@@ -1,677 +0,0 @@
|
||||
{
|
||||
".agents/skills/golang-code-style/evals/evals.json": {
|
||||
"mtime": 1784884678.6627614,
|
||||
"ast_hash": "bec0e12446e7af3cd05de9b6d42badd8",
|
||||
"semantic_hash": "bec0e12446e7af3cd05de9b6d42badd8"
|
||||
},
|
||||
".agents/skills/golang-error-handling/evals/evals.json": {
|
||||
"mtime": 1784884678.6655047,
|
||||
"ast_hash": "275d710b774fba1e1d0bc098d3646c6d",
|
||||
"semantic_hash": "275d710b774fba1e1d0bc098d3646c6d"
|
||||
},
|
||||
".agents/skills/golang-performance/evals/evals.json": {
|
||||
"mtime": 1784884678.6688662,
|
||||
"ast_hash": "4f06df87f90aa0e4f6deaa318b47689f",
|
||||
"semantic_hash": "4f06df87f90aa0e4f6deaa318b47689f"
|
||||
},
|
||||
".agents/skills/golang-testing/evals/evals.json": {
|
||||
"mtime": 1784884678.6721346,
|
||||
"ast_hash": "60a821bbfd20c6fe8bba996b8b540dd4",
|
||||
"semantic_hash": "60a821bbfd20c6fe8bba996b8b540dd4"
|
||||
},
|
||||
"backend/go.mod": {
|
||||
"mtime": 1786216141.668644,
|
||||
"ast_hash": "dac242903b0e98c3e4395159d609e08e",
|
||||
"semantic_hash": "dac242903b0e98c3e4395159d609e08e"
|
||||
},
|
||||
"backend/main.go": {
|
||||
"mtime": 1786886056.023822,
|
||||
"ast_hash": "d5a50b03438d7c120079d40cc578462b",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"skills-lock.json": {
|
||||
"mtime": 1784884678.6842625,
|
||||
"ast_hash": "4a94ac85bad6bce330d085bcc0ae3ffd",
|
||||
"semantic_hash": "4a94ac85bad6bce330d085bcc0ae3ffd"
|
||||
},
|
||||
"userscript/manga-bookmark.user.js": {
|
||||
"mtime": 1786886056.0389624,
|
||||
"ast_hash": "4e79684942234b26f4a1fb03fccd6d35",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
".agents/skills/find-skills/SKILL.md": {
|
||||
"mtime": 1784884338.760326,
|
||||
"ast_hash": "62b297abdee9aea84c577ab2e04e1974",
|
||||
"semantic_hash": "62b297abdee9aea84c577ab2e04e1974"
|
||||
},
|
||||
".agents/skills/golang-code-style/SKILL.md": {
|
||||
"mtime": 1784884678.6623824,
|
||||
"ast_hash": "d6a01e6f64550a5c8d59dac2e948000e",
|
||||
"semantic_hash": "d6a01e6f64550a5c8d59dac2e948000e"
|
||||
},
|
||||
".agents/skills/golang-code-style/references/details.md": {
|
||||
"mtime": 1784884678.6627865,
|
||||
"ast_hash": "19891e396a986f1b24bf34b7a2854fcb",
|
||||
"semantic_hash": "19891e396a986f1b24bf34b7a2854fcb"
|
||||
},
|
||||
".agents/skills/golang-error-handling/SKILL.md": {
|
||||
"mtime": 1784884678.665052,
|
||||
"ast_hash": "8b7970f472adb240e5bc4bde863d43a6",
|
||||
"semantic_hash": "8b7970f472adb240e5bc4bde863d43a6"
|
||||
},
|
||||
".agents/skills/golang-error-handling/references/error-creation.md": {
|
||||
"mtime": 1784884678.665524,
|
||||
"ast_hash": "248dbf75492c68faef2334b8d83bd080",
|
||||
"semantic_hash": "248dbf75492c68faef2334b8d83bd080"
|
||||
},
|
||||
".agents/skills/golang-error-handling/references/error-handling.md": {
|
||||
"mtime": 1784884678.6655412,
|
||||
"ast_hash": "c2424ee3999b05963b199e0100727aa3",
|
||||
"semantic_hash": "c2424ee3999b05963b199e0100727aa3"
|
||||
},
|
||||
".agents/skills/golang-error-handling/references/error-wrapping.md": {
|
||||
"mtime": 1784884678.6655717,
|
||||
"ast_hash": "4a15d9266a1ea0c8bb1951e6b9c0f586",
|
||||
"semantic_hash": "4a15d9266a1ea0c8bb1951e6b9c0f586"
|
||||
},
|
||||
".agents/skills/golang-performance/SKILL.md": {
|
||||
"mtime": 1784884678.667833,
|
||||
"ast_hash": "35a15fd129c5bedaada3fd4df0b6ba8c",
|
||||
"semantic_hash": "35a15fd129c5bedaada3fd4df0b6ba8c"
|
||||
},
|
||||
".agents/skills/golang-performance/assets/prometheus-alerts.yml": {
|
||||
"mtime": 1784884678.668527,
|
||||
"ast_hash": "fa9357ffa87c4f894fc21afa9707c4db",
|
||||
"semantic_hash": "fa9357ffa87c4f894fc21afa9707c4db"
|
||||
},
|
||||
".agents/skills/golang-performance/references/caching.md": {
|
||||
"mtime": 1784884678.6690567,
|
||||
"ast_hash": "807c42a82994e5548dbf7eb6e30c71e0",
|
||||
"semantic_hash": "807c42a82994e5548dbf7eb6e30c71e0"
|
||||
},
|
||||
".agents/skills/golang-performance/references/cpu.md": {
|
||||
"mtime": 1784884678.669087,
|
||||
"ast_hash": "6d52e532ef51a35cc4134694c944517d",
|
||||
"semantic_hash": "6d52e532ef51a35cc4134694c944517d"
|
||||
},
|
||||
".agents/skills/golang-performance/references/io-networking.md": {
|
||||
"mtime": 1784884678.6691036,
|
||||
"ast_hash": "95c5dd51f728fd69c945a92ff021d766",
|
||||
"semantic_hash": "95c5dd51f728fd69c945a92ff021d766"
|
||||
},
|
||||
".agents/skills/golang-performance/references/memory.md": {
|
||||
"mtime": 1784884678.6691158,
|
||||
"ast_hash": "3b2108df06b4cfb3980fa80bbd9ebcff",
|
||||
"semantic_hash": "3b2108df06b4cfb3980fa80bbd9ebcff"
|
||||
},
|
||||
".agents/skills/golang-performance/references/observability.md": {
|
||||
"mtime": 1784884678.6691446,
|
||||
"ast_hash": "0aa8a498e8d55ccdd4990ad187bae828",
|
||||
"semantic_hash": "0aa8a498e8d55ccdd4990ad187bae828"
|
||||
},
|
||||
".agents/skills/golang-performance/references/runtime.md": {
|
||||
"mtime": 1784884678.669426,
|
||||
"ast_hash": "26386c33b3a3794aaef0556713bf8f3a",
|
||||
"semantic_hash": "26386c33b3a3794aaef0556713bf8f3a"
|
||||
},
|
||||
".agents/skills/golang-testing/SKILL.md": {
|
||||
"mtime": 1784884678.6716368,
|
||||
"ast_hash": "0a9b9793bba2a239db94e980272a393e",
|
||||
"semantic_hash": "0a9b9793bba2a239db94e980272a393e"
|
||||
},
|
||||
".agents/skills/golang-testing/references/helpers.md": {
|
||||
"mtime": 1784884678.6722167,
|
||||
"ast_hash": "0aecb7cfbeb9b374bf61c52e324d9d3f",
|
||||
"semantic_hash": "0aecb7cfbeb9b374bf61c52e324d9d3f"
|
||||
},
|
||||
".agents/skills/golang-testing/references/http-testing.md": {
|
||||
"mtime": 1784884678.67225,
|
||||
"ast_hash": "9111110c28a7fbbffc3537aad786b390",
|
||||
"semantic_hash": "9111110c28a7fbbffc3537aad786b390"
|
||||
},
|
||||
".agents/skills/golang-testing/references/integration-testing.md": {
|
||||
"mtime": 1784884678.67225,
|
||||
"ast_hash": "fcf9861bc36ae56e3fb7a1c18aa0e77f",
|
||||
"semantic_hash": "fcf9861bc36ae56e3fb7a1c18aa0e77f"
|
||||
},
|
||||
".agents/skills/golang-testing/references/mocking.md": {
|
||||
"mtime": 1784884678.6722653,
|
||||
"ast_hash": "3a08979e4603aae5c32a58d5b6c39765",
|
||||
"semantic_hash": "3a08979e4603aae5c32a58d5b6c39765"
|
||||
},
|
||||
"CLAUDE.md": {
|
||||
"mtime": 1786857336.6414917,
|
||||
"ast_hash": "c79e49f912d7852f9832565a5f9a1c39",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"DEPLOY.md": {
|
||||
"mtime": 1786861012.026504,
|
||||
"ast_hash": "b7c2f813aded562ee9291c9baed795ad",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"README.md": {
|
||||
"mtime": 1786861012.026504,
|
||||
"ast_hash": "81af12a0a2d43e791efd030b5f4d6cbc",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docker-compose.prod.yml": {
|
||||
"mtime": 1786292465.8305523,
|
||||
"ast_hash": "0751998a532297b8ac507a01ec48dc31",
|
||||
"semantic_hash": "0751998a532297b8ac507a01ec48dc31"
|
||||
},
|
||||
"docker-compose.yml": {
|
||||
"mtime": 1786861012.026504,
|
||||
"ast_hash": "d3b53a8f42a8e0acb4fc4306ea42c093",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
".claude/settings.json": {
|
||||
"mtime": 1784951973.1869545,
|
||||
"ast_hash": "e51077b6a7f1f67afc748f1a32a1557d",
|
||||
"semantic_hash": "e51077b6a7f1f67afc748f1a32a1557d"
|
||||
},
|
||||
"backend/web_test.go": {
|
||||
"mtime": 1786891437.925184,
|
||||
"ast_hash": "74aed7f5a5b196d2b860cd6a34266ec6",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/main_test.go": {
|
||||
"mtime": 1786863966.7091317,
|
||||
"ast_hash": "0a5d4dbdc770b40c329ccccc899b59f4",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
".claude/settings.local.json": {
|
||||
"mtime": 1785697645.350201,
|
||||
"ast_hash": "9a1ac6369f968e8df4be9dcff0948f70",
|
||||
"semantic_hash": "9a1ac6369f968e8df4be9dcff0948f70"
|
||||
},
|
||||
"PRODUCT.md": {
|
||||
"mtime": 1786216141.660644,
|
||||
"ast_hash": "c52072d1978286060087fa0686f9c7f9",
|
||||
"semantic_hash": "c52072d1978286060087fa0686f9c7f9"
|
||||
},
|
||||
"backend/.impeccable/critique/2026-07-26T15-50-42Z__backend-templates-app-html.md": {
|
||||
"mtime": 1785128576.2412457,
|
||||
"ast_hash": "d08627c27f22d453125db6c1ab4b71ec",
|
||||
"semantic_hash": "d08627c27f22d453125db6c1ab4b71ec"
|
||||
},
|
||||
"backend/.impeccable/critique/2026-07-26T17-08-41Z__backend-templates-app-html.md": {
|
||||
"mtime": 1785128576.2452524,
|
||||
"ast_hash": "e69a8340a371579ca3ea689660f7d7bd",
|
||||
"semantic_hash": "e69a8340a371579ca3ea689660f7d7bd"
|
||||
},
|
||||
"AGENTS.md": {
|
||||
"mtime": 1786857336.6414917,
|
||||
"ast_hash": "c79e49f912d7852f9832565a5f9a1c39",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"userscript/test/logic.test.js": {
|
||||
"mtime": 1786499529.779988,
|
||||
"ast_hash": "80d512bfe6fa8b847f3ba6169c321a74",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
".claude/skills/testing-the-userscript/SKILL.md": {
|
||||
"mtime": 1786363889.5489495,
|
||||
"ast_hash": "8f3c0132eb4787a2c8736eb99f7689af",
|
||||
"semantic_hash": "8f3c0132eb4787a2c8736eb99f7689af"
|
||||
},
|
||||
"REDEPLOY.md": {
|
||||
"mtime": 1786857336.6414917,
|
||||
"ast_hash": "e5e910f0244a040e2ccdc669b17eb4db",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/design-system.md": {
|
||||
"mtime": 1786886056.0313923,
|
||||
"ast_hash": "3bd39932997c8e245508874194db4e49",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/api_test.go": {
|
||||
"mtime": 1786886056.016252,
|
||||
"ast_hash": "b64372df5429fb14e48b37b3bab10402",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/cover_test.go": {
|
||||
"mtime": 1786886056.016252,
|
||||
"ast_hash": "fd6b3f6ef46bd17b0241d104b6e5bdf6",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/api/handlers.go": {
|
||||
"mtime": 1786886056.016252,
|
||||
"ast_hash": "ae4f3b961450f06a06e0632eb273a5b2",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/httpmw/middleware.go": {
|
||||
"mtime": 1786216141.672644,
|
||||
"ast_hash": "385b36f58488b7e6d93eb6d6034e9ee3",
|
||||
"semantic_hash": "385b36f58488b7e6d93eb6d6034e9ee3"
|
||||
},
|
||||
"backend/internal/latest/browser.go": {
|
||||
"mtime": 1786889854.5578685,
|
||||
"ast_hash": "b65f3693db77c69ef0a6247fd75118bc",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/browser_test.go": {
|
||||
"mtime": 1786889854.5578685,
|
||||
"ast_hash": "118aba3d794b9faf893c45e58298e11a",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/fetch.go": {
|
||||
"mtime": 1786499529.7688599,
|
||||
"ast_hash": "3e20ad86aa46783e9aa95c2b746551ee",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/poller.go": {
|
||||
"mtime": 1786891405.415882,
|
||||
"ast_hash": "54f6eec9d2090c7713f502c8553063a2",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/poller_test.go": {
|
||||
"mtime": 1786891462.5665002,
|
||||
"ast_hash": "512806a9c6e15036a75f01bf5577c20b",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/sites.go": {
|
||||
"mtime": 1786886056.0200372,
|
||||
"ast_hash": "5786000dd0dfd5b4b3bd2b87b1fc100b",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/sites_test.go": {
|
||||
"mtime": 1786857336.6414917,
|
||||
"ast_hash": "0db2028a6073f342fee61ad15c2e5f0f",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/smoke_image_test.go": {
|
||||
"mtime": 1786363889.5602942,
|
||||
"ast_hash": "db068cb59575f8c82669acbaf84bcaed",
|
||||
"semantic_hash": "db068cb59575f8c82669acbaf84bcaed"
|
||||
},
|
||||
"backend/internal/pgtest/pgtest.go": {
|
||||
"mtime": 1786216141.6766438,
|
||||
"ast_hash": "f60372d41516e66f7aaeb272da227d6e",
|
||||
"semantic_hash": "f60372d41516e66f7aaeb272da227d6e"
|
||||
},
|
||||
"backend/internal/session/session.go": {
|
||||
"mtime": 1786216141.6766438,
|
||||
"ast_hash": "9952474ffb22c825d6f075b866ee26f4",
|
||||
"semantic_hash": "9952474ffb22c825d6f075b866ee26f4"
|
||||
},
|
||||
"backend/internal/session/session_test.go": {
|
||||
"mtime": 1786216141.680644,
|
||||
"ast_hash": "37ffd00964e7a67350c68ed50c6503c5",
|
||||
"semantic_hash": "37ffd00964e7a67350c68ed50c6503c5"
|
||||
},
|
||||
"backend/internal/store/migrations/0001_bookmarks.sql": {
|
||||
"mtime": 1786216141.680644,
|
||||
"ast_hash": "f87ccfb2c25c43f93021177ced0bfae4",
|
||||
"semantic_hash": "f87ccfb2c25c43f93021177ced0bfae4"
|
||||
},
|
||||
"backend/internal/store/migrations/0002_series.sql": {
|
||||
"mtime": 1786216141.6820722,
|
||||
"ast_hash": "5dc98771e0c0f6e8416b434c280b0efb",
|
||||
"semantic_hash": "5dc98771e0c0f6e8416b434c280b0efb"
|
||||
},
|
||||
"backend/internal/store/migrations/0003_reader.sql": {
|
||||
"mtime": 1786216141.6820722,
|
||||
"ast_hash": "444a97799f38f6222f87d4e9fb2d6258",
|
||||
"semantic_hash": "444a97799f38f6222f87d4e9fb2d6258"
|
||||
},
|
||||
"backend/internal/store/migrations/0004_owner_bookmarks.sql": {
|
||||
"mtime": 1786216141.684644,
|
||||
"ast_hash": "e4fa900cc223865d3ecd4c60c5707a65",
|
||||
"semantic_hash": "e4fa900cc223865d3ecd4c60c5707a65"
|
||||
},
|
||||
"backend/internal/store/migrations/0005_sessions.sql": {
|
||||
"mtime": 1786216141.684644,
|
||||
"ast_hash": "5158887ebc57cf8c16a7b821b61cd760",
|
||||
"semantic_hash": "5158887ebc57cf8c16a7b821b61cd760"
|
||||
},
|
||||
"backend/internal/store/migrations/0006_reader_token_epoch.sql": {
|
||||
"mtime": 1786216141.684644,
|
||||
"ast_hash": "3093cc1c3aae0cd9643d04105d045402",
|
||||
"semantic_hash": "3093cc1c3aae0cd9643d04105d045402"
|
||||
},
|
||||
"backend/internal/store/sessions.go": {
|
||||
"mtime": 1786216141.684644,
|
||||
"ast_hash": "eee3510cc6172ef4b1da820474c26b01",
|
||||
"semantic_hash": "eee3510cc6172ef4b1da820474c26b01"
|
||||
},
|
||||
"backend/internal/store/sessions_test.go": {
|
||||
"mtime": 1786216141.684644,
|
||||
"ast_hash": "0b6764a0ee20f5cb7748eecd31a1d220",
|
||||
"semantic_hash": "0b6764a0ee20f5cb7748eecd31a1d220"
|
||||
},
|
||||
"backend/internal/store/store.go": {
|
||||
"mtime": 1786886056.0200372,
|
||||
"ast_hash": "34f55617b7409f03f02e925bec92fac0",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/store/store_test.go": {
|
||||
"mtime": 1786886056.0200372,
|
||||
"ast_hash": "16f69d97eb63bcec7409294466642411",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/userscript/userscript.go": {
|
||||
"mtime": 1786216141.692644,
|
||||
"ast_hash": "aa13a71b1c9eefe4930fd31f27722328",
|
||||
"semantic_hash": "aa13a71b1c9eefe4930fd31f27722328"
|
||||
},
|
||||
"backend/internal/userscript/userscript_test.go": {
|
||||
"mtime": 1786216141.692644,
|
||||
"ast_hash": "6c050968d7b8b67956da1a3136d2c3c7",
|
||||
"semantic_hash": "6c050968d7b8b67956da1a3136d2c3c7"
|
||||
},
|
||||
"backend/internal/web/discord.go": {
|
||||
"mtime": 1786216141.692644,
|
||||
"ast_hash": "69e4959c65fa67d7491aedf6a71bb575",
|
||||
"semantic_hash": "69e4959c65fa67d7491aedf6a71bb575"
|
||||
},
|
||||
"backend/internal/web/oauth_test.go": {
|
||||
"mtime": 1786216141.692644,
|
||||
"ast_hash": "a3bddeb70dd8d7eb14139da808723ea8",
|
||||
"semantic_hash": "a3bddeb70dd8d7eb14139da808723ea8"
|
||||
},
|
||||
"backend/internal/web/static/filter.js": {
|
||||
"mtime": 1786022513.9473197,
|
||||
"ast_hash": "b4ee3306201bfd88b148b96801972617",
|
||||
"semantic_hash": "b4ee3306201bfd88b148b96801972617"
|
||||
},
|
||||
"backend/internal/web/static/htmx.min.js": {
|
||||
"mtime": 1785873769.5512016,
|
||||
"ast_hash": "19a573773be4ca22570ca2f8543120c5",
|
||||
"semantic_hash": "19a573773be4ca22570ca2f8543120c5"
|
||||
},
|
||||
"backend/internal/web/web.go": {
|
||||
"mtime": 1786886056.023822,
|
||||
"ast_hash": "b051f2de214c4b253ee09fbbdb8ebb60",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/reader_credential_test.go": {
|
||||
"mtime": 1786886056.023822,
|
||||
"ast_hash": "0c93de387af595901c43b6bdb3bed8cc",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"chrome/entrypoint.sh": {
|
||||
"mtime": 1786363889.5678573,
|
||||
"ast_hash": "8008a187690764436540fab47ba0cfcc",
|
||||
"semantic_hash": "8008a187690764436540fab47ba0cfcc"
|
||||
},
|
||||
"userscript/novel-bookmark.user.js": {
|
||||
"mtime": 1786886056.0389624,
|
||||
"ast_hash": "36c81462919719f0e053ca5d633a243a",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"userscript/test/novel-logic.test.js": {
|
||||
"mtime": 1786499529.779988,
|
||||
"ast_hash": "b25a7377af210dd0aff8284501fd0252",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
".opencode/agent/implementer.md": {
|
||||
"mtime": 1785873769.5402634,
|
||||
"ast_hash": "000de469c18d68352027e10c8ce8acfb",
|
||||
"semantic_hash": "000de469c18d68352027e10c8ce8acfb"
|
||||
},
|
||||
".opencode/agent/reviewer.md": {
|
||||
"mtime": 1785873769.5416775,
|
||||
"ast_hash": "e44a2f6f624db044e19508bc5ab05592",
|
||||
"semantic_hash": "e44a2f6f624db044e19508bc5ab05592"
|
||||
},
|
||||
"CONTEXT.md": {
|
||||
"mtime": 1786861012.022683,
|
||||
"ast_hash": "4aafbce0b046e6e34734fb414df818ce",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"CUTOVER.md": {
|
||||
"mtime": 1786216141.660644,
|
||||
"ast_hash": "6c6f3e4c4c2f57867894280bce728c50",
|
||||
"semantic_hash": "6c6f3e4c4c2f57867894280bce728c50"
|
||||
},
|
||||
"backend/AGENTS.md": {
|
||||
"mtime": 1786891513.6486945,
|
||||
"ast_hash": "b4233ebc5ff905b701ded9427277ad8c",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/CLAUDE.md": {
|
||||
"mtime": 1786891513.6486945,
|
||||
"ast_hash": "b4233ebc5ff905b701ded9427277ad8c",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/web/templates/app.html": {
|
||||
"mtime": 1786886056.023822,
|
||||
"ast_hash": "99d3e1139042d0eb61627155dddb3843",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/web/templates/card.html": {
|
||||
"mtime": 1786363889.5678573,
|
||||
"ast_hash": "ab83ae0dbb34fd40c146a7cc1263173e",
|
||||
"semantic_hash": "ab83ae0dbb34fd40c146a7cc1263173e"
|
||||
},
|
||||
"backend/internal/web/templates/chrome.html": {
|
||||
"mtime": 1786363889.5678573,
|
||||
"ast_hash": "d80b27cf3bd9d131075c485dd169dfcc",
|
||||
"semantic_hash": "d80b27cf3bd9d131075c485dd169dfcc"
|
||||
},
|
||||
"backend/internal/web/templates/icons.html": {
|
||||
"mtime": 1785873769.553139,
|
||||
"ast_hash": "8e10c507c32934a92463b4bca9e34fe6",
|
||||
"semantic_hash": "8e10c507c32934a92463b4bca9e34fe6"
|
||||
},
|
||||
"backend/internal/web/templates/list.html": {
|
||||
"mtime": 1786216141.696644,
|
||||
"ast_hash": "365548aace8c06559a1f66db0ae47256",
|
||||
"semantic_hash": "365548aace8c06559a1f66db0ae47256"
|
||||
},
|
||||
"backend/internal/web/templates/login.html": {
|
||||
"mtime": 1786216141.696644,
|
||||
"ast_hash": "bcc3101498a66cf8b79f9d97c6c9cd6b",
|
||||
"semantic_hash": "bcc3101498a66cf8b79f9d97c6c9cd6b"
|
||||
},
|
||||
"backend/internal/web/templates/readers.html": {
|
||||
"mtime": 1786886056.023822,
|
||||
"ast_hash": "2b1cbb24d6185e48561966db1af04ba4",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/web/templates/setup.html": {
|
||||
"mtime": 1786216141.696644,
|
||||
"ast_hash": "72e93c0b827414063596f7338987d879",
|
||||
"semantic_hash": "72e93c0b827414063596f7338987d879"
|
||||
},
|
||||
"docs/adr/0001-postgresql-over-sqlite.md": {
|
||||
"mtime": 1786216141.704644,
|
||||
"ast_hash": "abfb08754cee58be67311377904f8ca4",
|
||||
"semantic_hash": "abfb08754cee58be67311377904f8ca4"
|
||||
},
|
||||
"docs/adr/0002-discord-oauth-no-passwords-no-email.md": {
|
||||
"mtime": 1786216141.7071996,
|
||||
"ast_hash": "852a04d86659385085da6ffc8b489933",
|
||||
"semantic_hash": "852a04d86659385085da6ffc8b489933"
|
||||
},
|
||||
"docs/adr/0003-series-shared-and-poll-owned.md": {
|
||||
"mtime": 1786501942.7367291,
|
||||
"ast_hash": "6138b113340693e0cc667d1ecbb75f72",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/adr/0004-wire-format-does-not-mirror-the-schema.md": {
|
||||
"mtime": 1786216141.7071996,
|
||||
"ast_hash": "a6ea2770dec2156f78a65b35ba06902a",
|
||||
"semantic_hash": "a6ea2770dec2156f78a65b35ba06902a"
|
||||
},
|
||||
"docs/agents/domain.md": {
|
||||
"mtime": 1786216141.7071996,
|
||||
"ast_hash": "6f99318ac6cb9825b613bfde55d76091",
|
||||
"semantic_hash": "6f99318ac6cb9825b613bfde55d76091"
|
||||
},
|
||||
"docs/agents/issue-tracker.md": {
|
||||
"mtime": 1786216141.7087462,
|
||||
"ast_hash": "1342e66ccb84a84fd579fb6dc0b8243a",
|
||||
"semantic_hash": "1342e66ccb84a84fd579fb6dc0b8243a"
|
||||
},
|
||||
"docs/agents/triage-labels.md": {
|
||||
"mtime": 1786216141.7087462,
|
||||
"ast_hash": "69114d07ed792d6bb1d13758ba5435e1",
|
||||
"semantic_hash": "69114d07ed792d6bb1d13758ba5435e1"
|
||||
},
|
||||
"userscript/AGENTS.md": {
|
||||
"mtime": 1786499529.7762787,
|
||||
"ast_hash": "e276ffe9a6e7b55fd3235466a1995c22",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"userscript/CLAUDE.md": {
|
||||
"mtime": 1786499529.7762787,
|
||||
"ast_hash": "e276ffe9a6e7b55fd3235466a1995c22",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/web/static/login-art.png": {
|
||||
"mtime": 1786022513.9585779,
|
||||
"ast_hash": "05d7863cba344a946256719a0c9ef959",
|
||||
"semantic_hash": "05d7863cba344a946256719a0c9ef959"
|
||||
},
|
||||
"backend/internal/web/static/logo.svg": {
|
||||
"mtime": 1786022513.9585779,
|
||||
"ast_hash": "d0d34d0f08a25b53176cc55989b7babe",
|
||||
"semantic_hash": "d0d34d0f08a25b53176cc55989b7babe"
|
||||
},
|
||||
"backend/internal/store/migrations/0007_covers.sql": {
|
||||
"mtime": 1786262323.6964688,
|
||||
"ast_hash": "6033ce0701be1236ed175362363bd96c",
|
||||
"semantic_hash": "6033ce0701be1236ed175362363bd96c"
|
||||
},
|
||||
"docs/adr/0005-on-demand-browser.md": {
|
||||
"mtime": 1786292465.8305523,
|
||||
"ast_hash": "8cf2fb8c0a66c2c7d82ae433b99ca42b",
|
||||
"semantic_hash": "8cf2fb8c0a66c2c7d82ae433b99ca42b"
|
||||
},
|
||||
"chrome/docker-compose.yml": {
|
||||
"mtime": 1786292465.8305523,
|
||||
"ast_hash": "5605599395a3f085904e78a2bfec1e58",
|
||||
"semantic_hash": "5605599395a3f085904e78a2bfec1e58"
|
||||
},
|
||||
"docs/adr/0006-browser-on-the-home-machine.md": {
|
||||
"mtime": 1786501942.7367291,
|
||||
"ast_hash": "bfcaf39b6c7e96610a7caa00eeb36533",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/adr/0007-backend-hosts-cover-bytes.md": {
|
||||
"mtime": 1786292465.8305523,
|
||||
"ast_hash": "b19e38045b3dcda7dd59634ed9227a68",
|
||||
"semantic_hash": "b19e38045b3dcda7dd59634ed9227a68"
|
||||
},
|
||||
"backend/internal/store/migrations/0008_filesystem_covers.sql": {
|
||||
"mtime": 1786363889.5602942,
|
||||
"ast_hash": "46cf7822d4f667e3cab36b547abe5e97",
|
||||
"semantic_hash": "46cf7822d4f667e3cab36b547abe5e97"
|
||||
},
|
||||
"backend/internal/latest/cover.go": {
|
||||
"mtime": 1786857336.6414917,
|
||||
"ast_hash": "d5f2248c3d11de74bf5a3977651b17c2",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/cover_fetch_test.go": {
|
||||
"mtime": 1786363889.5565126,
|
||||
"ast_hash": "60d9eb7c59a3751baf4f31c7655217e7",
|
||||
"semantic_hash": "60d9eb7c59a3751baf4f31c7655217e7"
|
||||
},
|
||||
"backend/internal/latest/acquire.go": {
|
||||
"mtime": 1786861012.026504,
|
||||
"ast_hash": "d605e3c94a62fc7787efbc139696b9d2",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/acquire_test.go": {
|
||||
"mtime": 1786363889.5565126,
|
||||
"ast_hash": "7bd9f41814f6bf59d8998dfb81cf990a",
|
||||
"semantic_hash": "7bd9f41814f6bf59d8998dfb81cf990a"
|
||||
},
|
||||
"backend/internal/store/migrations/0009_series_cover_address.sql": {
|
||||
"mtime": 1786363889.5602942,
|
||||
"ast_hash": "4c1f6328b2e1a95828fad6d88d474c2d",
|
||||
"semantic_hash": "4c1f6328b2e1a95828fad6d88d474c2d"
|
||||
},
|
||||
".claude/skills/implement-tickets/SKILL.md": {
|
||||
"mtime": 1786417841.7117643,
|
||||
"ast_hash": "3060e32e19cc571d91871a98f18afe53",
|
||||
"semantic_hash": "3060e32e19cc571d91871a98f18afe53"
|
||||
},
|
||||
".omp/agents/ticket-implementer.md": {
|
||||
"mtime": 1786417841.7163916,
|
||||
"ast_hash": "0150a46c0d21c572b71b3d87d21ac925",
|
||||
"semantic_hash": "0150a46c0d21c572b71b3d87d21ac925"
|
||||
},
|
||||
"docs/adr/0008-series-identity-is-discovered-not-derived.md": {
|
||||
"mtime": 1786417841.7163916,
|
||||
"ast_hash": "3ce6d64ef6a8a39f27c257b39065389e",
|
||||
"semantic_hash": "3ce6d64ef6a8a39f27c257b39065389e"
|
||||
},
|
||||
"docs/research/lightnovelworld-chapter-vs-series-slug.md": {
|
||||
"mtime": 1786417841.7163916,
|
||||
"ast_hash": "74a4e538875f0a7e8ca3d5dc48c0bb53",
|
||||
"semantic_hash": "74a4e538875f0a7e8ca3d5dc48c0bb53"
|
||||
},
|
||||
"backend/internal/latest/smoke_lnw_test.go": {
|
||||
"mtime": 1786499529.7725692,
|
||||
"ast_hash": "2d65da8a081759172918fdf159760f45",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/adr/0009-a-site-answers-questions-its-own-way.md": {
|
||||
"mtime": 1786499529.7725692,
|
||||
"ast_hash": "8039012a5b6de2359ff1a47079f51b66",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/read.go": {
|
||||
"mtime": 1786861012.026504,
|
||||
"ast_hash": "9f039cc3ad74f803d7621f7ef4157bf2",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/research/cloudflare-bot-scoring-and-poll-cadence.md": {
|
||||
"mtime": 1786501942.7367291,
|
||||
"ast_hash": "1aa17575ab20f2f36583602999a6a60f",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/smoke_comix_test.go": {
|
||||
"mtime": 1786857336.6414917,
|
||||
"ast_hash": "111fdbb75fc68ac2ab1013bc916063cf",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/adr/0010-poll-lanes-per-site-pace.md": {
|
||||
"mtime": 1786861012.026504,
|
||||
"ast_hash": "dc19d75f034ca920d93b9c71e6ca28e6",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/status.go": {
|
||||
"mtime": 1786891396.3216066,
|
||||
"ast_hash": "5893c77b117a5bcec3a4583aa51e7f42",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/store/migrations/0010_reader_sightings.sql": {
|
||||
"mtime": 1786886056.0200372,
|
||||
"ast_hash": "a72c08c63fad530df3c793d16edfa385",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/web/admin.go": {
|
||||
"mtime": 1786891411.0618205,
|
||||
"ast_hash": "e02249496b57096cf1929f9cd0e35c3e",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/web/templates/admin.html": {
|
||||
"mtime": 1786886056.023822,
|
||||
"ast_hash": "f04ea1cb01369d53053eac489e796faa",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/web/templates/lanes.html": {
|
||||
"mtime": 1786891416.6118941,
|
||||
"ast_hash": "0e96f66560bac29713a71ec31139d25e",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/sighting_test.go": {
|
||||
"mtime": 1786886056.0200372,
|
||||
"ast_hash": "3b1372bbeeb538b73f86f57da210b4cc",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/store/migrations/0011_series_sightings.sql": {
|
||||
"mtime": 1786886056.0200372,
|
||||
"ast_hash": "d0ade928a6e951eb179e4d0255cca526",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/adr/0011-sighting-deferral-trust-model.md": {
|
||||
"mtime": 1786886056.0313923,
|
||||
"ast_hash": "0b6ca704f62185c9d629320f2d8475b5",
|
||||
"semantic_hash": ""
|
||||
}
|
||||
}
|
||||
+94
-84
@@ -1,93 +1,103 @@
|
||||
Guidance for OpenCode (and Claude Code) working under `userscript/`. See root `AGENTS.md` for the project-wide architecture diagram, hard constraints, and design system.
|
||||
Scope: `userscript/`.
|
||||
|
||||
### Userscript structure (single IIFE, `manga-bookmark.user.js`)
|
||||
Each entry names the code that holds the truth. The prose is only what the code
|
||||
cannot tell you: rationale, invariants a refactor would break, and dated
|
||||
observations about sites we don't control.
|
||||
|
||||
1. **Site adapters** — one per host, `detect(location, document)` return page `type` + IDs. Identify type/IDs from **URL regex** (most stable); pull `title` from **`og:title`** (or the page heading where a site ships no og: tags), not CSS classes. **No adapter reads a cover**: the backend acquires, stores and serves every Cover from its own origin (ADR-0007), the wire's `cover` is already an address on our origin, and `apiPut` strips any `cover` off an outgoing body.
|
||||
2. **API client** — `apiGet/apiPut/apiDelete` with bearer header; `localStorage` key `bmgr:manga:cache` for instant render + offline fallback.
|
||||
3. **Progress logic** — auto-upsert `last_chapter` only when `chapterNum >= stored last_chapter_num` (re-reading old chapters must not regress progress; unparseable -> set current). Manual panel override forces any value.
|
||||
4. **Retry queue** — every write go through `pushBookmark`/`pushDelete`, so
|
||||
failed mutation park in `localStorage` (`bmgr:manga:queue`) and replayed on
|
||||
next navigation, reconnect, or `refresh()`. Entries are markers
|
||||
(`{key, op, sendStatus, attempts}`), never payloads — body read from
|
||||
cache at send time, so one entry per key give ordering and coalescing for
|
||||
free. `sendStatus` is **sticky**: while archive pending, later writes to
|
||||
that key keep carrying bucket, which stop successful
|
||||
in-between write from silently un-archiving series. `refresh()` drains
|
||||
before it fetches and overlays anything still pending, so list never
|
||||
flaps. 400 drops entry, 401 abort pass and keep queue, and
|
||||
transient failures retry to cap of 10. Latest-chapter writes deliberately
|
||||
stay out of queue. See
|
||||
`docs/superpowers/specs/2026-07-27-offline-retry-queue-design.md`.
|
||||
5. **UI** — rendered inside **Shadow DOM** root to isolate from site CSS
|
||||
(critical on mobile). Three tabs (All / Favourites / Archived) and row of
|
||||
link chips to web UI and both manga sites; `WEB_BASE` sits in CONFIG
|
||||
block next to `API_BASE`. FAB is `7 × 44` edge tab whose *hit* area
|
||||
widened to `28 × 72` by invisible `#hit` child; `#fab` must keep
|
||||
`touch-action: none` and must **not** regain `overflow: hidden`. Since
|
||||
`touch-action` resolved at gesture start, strip can't be both
|
||||
browser-scrolled and script-dragged, so `makeDraggable` splits by intent: swipe
|
||||
from `#hit` scrolls via `window.scrollBy`, hold of `ARM_MS` arms
|
||||
reposition drag, visible sliver drags with no hold. See
|
||||
`docs/superpowers/specs/2026-07-28-edge-tab-hitbox-design.md`.
|
||||
6. **SPA navigation** — Asura is Astro, client-routed on comic/chapter pages: patch `history.pushState`/`replaceState` + listen `popstate`, re-run `detect()` on URL change so auto-update fire without reload. Demonic uses classic reloads (initial `document-idle` run suffice).
|
||||
### Structure — single IIFE, `manga-bookmark.user.js`
|
||||
|
||||
### Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trust)
|
||||
Six parts, in file order: site adapters, API client, progress logic, retry
|
||||
queue, UI, SPA navigation.
|
||||
|
||||
- **asurascans.com**: series `/comics/<slug>` (slug carries trailing
|
||||
site-wide build-hash suffix, e.g. `-059befe1`, that **rotates on every
|
||||
redeploy**), chapter `/comics/<slug>/chapter/<n>`. `seriesId` must strip
|
||||
hash (`/-[0-9a-f]{8}$/`, `stripBuildHash` in userscript,
|
||||
`asuraBuildHash` in backend); URLs keep full slug — stale-hash
|
||||
URLs 302 to current ones. Astro-rendered; chapter links present in raw
|
||||
server HTML.
|
||||
- **demonicscans.org**: series `/manga/<slug>` (slug may URL-encode punctuation, e.g. `%2527` for `'`), chapter `/title/<slug>/chapter/<n>/<page>` (older `chaptered.php?manga=<id>&chapter=<n>` form still exists as redirect, what series-page chapter-list anchors link through).
|
||||
Encodings (incl. triple-encoded punctuation like `%25252D`) identical
|
||||
on /manga/ and /title/ pages, so decode-once seriesIds match — verified
|
||||
2026-07-28.
|
||||
- **comix.to**: series `/title/<id>-<slug>`, chapter
|
||||
`/title/<id>-<slug>/<uploadId>-chapter-<n>`. Only the leading `<id>` is
|
||||
identity — the slug re-renders when a series is renamed (`comixSeriesId`).
|
||||
An SPA that **never rewrites `og:title`**: the server-rendered head keeps
|
||||
whatever document loaded first, so on a cold load `og:title` is the homepage's
|
||||
"Comix — Read Comics online for free" and after an in-page hop it is the
|
||||
*previous* series' name. `document.title` is the one thing client routing does
|
||||
update, so titles come from there, with the chapter page's `" · Ch.<n>"` tail
|
||||
stripped. It publishes no `og:image` either, which is one of the reasons cover
|
||||
**Site adapters** — one per host, `detect(location, document)` returning page
|
||||
`type` + IDs.
|
||||
|
||||
- Identify type and IDs from **URL regex**, which is the most stable surface a
|
||||
site exposes; take `title` from **`og:title`** (or the page heading where a
|
||||
site ships no og: tags), never CSS classes.
|
||||
- **No adapter reads a cover.** The backend acquires, stores and serves every
|
||||
Cover from its own origin, the wire `cover` is already an address there, and
|
||||
`apiPut` strips any `cover` off an outgoing body.
|
||||
|
||||
**Progress logic** — auto-upsert `last_chapter` only when
|
||||
`chapterNum >= stored last_chapter_num`; unparseable sets the current value.
|
||||
Re-reading an old chapter must not regress progress. A manual panel override
|
||||
forces any value.
|
||||
|
||||
**Retry queue** — every write goes through `pushBookmark`/`pushDelete`.
|
||||
|
||||
- Entries are markers (`{key, op, sendStatus, attempts}`), **never payloads**:
|
||||
the body is read from cache at send time, so one entry per key gives ordering
|
||||
and coalescing for free.
|
||||
- `sendStatus` is **sticky** — while an archive is pending, later writes to that
|
||||
key keep carrying the bucket. Without it a successful in-between write
|
||||
silently un-archives the series.
|
||||
- `refresh()` drains before it fetches and overlays anything still pending, so
|
||||
the list never flaps.
|
||||
- 400 drops the entry, 401 aborts the pass and keeps the queue, transient
|
||||
failures retry to a cap. Latest-chapter writes deliberately stay out of the
|
||||
queue.
|
||||
|
||||
**UI** — rendered inside a **Shadow DOM** root to isolate it from site CSS,
|
||||
which is critical on mobile.
|
||||
|
||||
- The FAB's *hit* area is widened by an invisible `#hit` child. `#fab` must keep
|
||||
`touch-action: none` and must **not** regain `overflow: hidden`.
|
||||
- `touch-action` is resolved at gesture start, so the strip cannot be both
|
||||
browser-scrolled and script-dragged. `makeDraggable` therefore splits by
|
||||
intent: a swipe from `#hit` scrolls via `window.scrollBy`, a hold of `ARM_MS`
|
||||
arms a reposition drag, and the visible sliver drags with no hold.
|
||||
|
||||
**SPA navigation** — Asura is Astro and client-routes on comic/chapter pages, so
|
||||
`history.pushState`/`replaceState` are patched and `popstate` listened to, and
|
||||
`detect()` re-runs on URL change. Demonic uses classic reloads, where the
|
||||
initial `document-idle` run suffices.
|
||||
|
||||
### Live URL shapes
|
||||
|
||||
Encoded in the adapters; the notes below are the parts a reader of the regex
|
||||
would get wrong. **Verified 2026-07-26 unless dated otherwise — sites drift, so
|
||||
re-check against a live page before trusting any of it.**
|
||||
|
||||
- **asurascans.com** — the series slug carries a site-wide build-hash suffix
|
||||
(e.g. `-059befe1`) that **rotates on every redeploy**, so `seriesId` must
|
||||
strip it (`stripBuildHash` here, `asuraBuildHash` in the backend) while URLs
|
||||
keep the full slug — stale-hash URLs 302 to current ones.
|
||||
- **demonicscans.org** — slugs may URL-encode punctuation, and the older
|
||||
`chaptered.php?manga=<id>&chapter=<n>` form still exists as a redirect, which
|
||||
is what series-page chapter-list anchors link through. Encodings (including
|
||||
triple-encoded punctuation like `%25252D`) are identical on `/manga/` and
|
||||
`/title/` pages, so decode-once seriesIds match (verified 2026-07-28).
|
||||
- **comix.to** — only the leading `<id>` is identity; the slug re-renders when a
|
||||
series is renamed (`comixSeriesId`). It is an SPA that **never rewrites
|
||||
`og:title`**: the server-rendered head keeps whatever document loaded first,
|
||||
so on a cold load `og:title` is the homepage's name and after an in-page hop
|
||||
it is the *previous* series'. `document.title` is the one thing client routing
|
||||
updates, hence titles come from there with the chapter page's `" · Ch.<n>"`
|
||||
tail stripped. It publishes no `og:image` either, one of the reasons cover
|
||||
acquisition moved to the backend.
|
||||
- **kagane.to**: series `/series/<uuid>`, reader
|
||||
`/series/<uuid>/reader/<bookUuid>`. Reader URLs carry no chapter number, so
|
||||
the number comes out of `og:title`. Two shapes exist: `"<Series> - Chapter
|
||||
<n>[ - Episode <n>]"` and, for volume-numbered series, `"<Series> - Volume <v>
|
||||
Chapter <n>"` with no episode name — both must yield a bare series title, or
|
||||
the volume tail lands in the bookmark's title.
|
||||
Its covers are challenge- and CORP-protected, so nothing outside kagane.to can
|
||||
load one directly; the panel renders the backend's own cover address like every
|
||||
other Site. Behind a Cloudflare JS challenge, so the backend polls it
|
||||
through the headless browser.
|
||||
- **novelfull.com** (novel script): series `/<slug>.html`, chapter
|
||||
`/<slug>/chapter-<n>[-<title-slug>].html`. No `og:*` tags at all — title from
|
||||
`h3.title` (series) or `a.truyen-title` (chapter); the script reads no cover.
|
||||
Behind a Cloudflare JS challenge no TLS fingerprint
|
||||
clears, so the backend polls it through the headless browser.
|
||||
- **lightnovelworld.net** (novel script): series `/novel/<slug>/`, chapter
|
||||
`/<slug>-chapter-<n>/` — flat, at the site root. The chapter path's slug is a
|
||||
Chapter Slug, not an identity: the Series address is read off the page's
|
||||
- **kagane.to** — reader URLs carry no chapter number, so the number comes out
|
||||
of `og:title`. Two shapes exist, `"<Series> - Chapter <n>[ - Episode <n>]"`
|
||||
and `"<Series> - Volume <v> Chapter <n>"`; both must yield a bare series
|
||||
title, or the volume tail lands in the bookmark's title. Its covers are
|
||||
challenge- and CORP-protected, so nothing outside kagane.to can load one —
|
||||
the panel renders the backend's cover address like every other Site.
|
||||
- **novelfull.com** (novel script) — no `og:*` tags at all, so the title comes
|
||||
from `h3.title` (series) or `a.truyen-title` (chapter).
|
||||
- **lightnovelworld.net** (novel script) — chapter paths are flat at the site
|
||||
root and their slug is a **Chapter Slug, not an identity**: a Series may
|
||||
publish under several. The Series address is read off the page's
|
||||
`a[aria-label='All Chapter']` (fallback: the BreadcrumbList's second crumb),
|
||||
and a Series may publish under several Chapter Slugs. A chapter page with no
|
||||
pointer resolves to `other`, so no Bookmark is offered. `h1.entry-title` is
|
||||
the clean title on a series page and `<Title> Chapter <n>` on a chapter page.
|
||||
Its series page lists every chapter with an
|
||||
absolute href, so the backend polls it with the plain TLS client.
|
||||
The client performs no latest-chapter scan for this Site: the Poll's
|
||||
one-hour cooldown dominates the client's four-hour throttle, so a scan
|
||||
would add no freshness, and the page's wpdiscuz thread is a public write
|
||||
surface a scan would have to truncate at. `computeLatestChapter` yields
|
||||
null here and `backgroundRefreshLatest` skips the Site before any fetch.
|
||||
and a chapter page with no pointer resolves to `other` so no Bookmark is
|
||||
offered. The client runs **no latest-chapter scan** for this Site —
|
||||
`computeLatestChapter` yields null and `backgroundRefreshLatest` skips it
|
||||
before any fetch — because the backend Poll's one-hour cooldown dominates the
|
||||
client's four-hour throttle, so a scan would add no freshness while having to
|
||||
truncate at the page's wpdiscuz thread, a public write surface.
|
||||
|
||||
### Second script: `novel-bookmark.user.js`
|
||||
### Second script — `novel-bookmark.user.js`
|
||||
|
||||
A copy of the manga script with two adapters, `LIBRARY = "novel"` and
|
||||
`STORE_PREFIX = "bmgr:novel:"`. No migration loop (this script has no previous
|
||||
installation to carry keys over from). Installed alongside the manga script;
|
||||
both write to the same backend with the same `LIBRARY` column discriminating
|
||||
them.
|
||||
`STORE_PREFIX = "bmgr:novel:"`. No migration loop, because this script has no
|
||||
previous installation to carry keys over from. Installed alongside the manga
|
||||
script; both write to the same backend, discriminated by `LIBRARY`.
|
||||
|
||||
@@ -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