docs: spec 002 (download covers, history of searches) and its plan

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-15 11:28:13 +02:00
1 parent 6c9bc62186
commit e7af273a6d
2 files changed
+321

No files matched your search

+179
View File
@@ -0,0 +1,179 @@
# Download and history — implementation plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (native; the user's standing
> choice). Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** "Download selected (ZIP)" on the page and in the panel, and a history of searches on the page that
restores instantly from IndexedDB.
**Architecture:** Pure pieces in `src/core` (ZIP writer, download bundle, saved-search model, link expiry,
pruning) with unit tests; thin browser adapters in `src/web` (IndexedDB store, Blob download, rendering),
checked in headless Chrome. The userscript only gains the shared download button.
**Tech stack:** TypeScript on Bun, `bun test`, no new dependencies.
**Spec:** [`docs/specs/002-download-and-history.md`](../specs/002-download-and-history.md) (and 001 for context).
## Global constraints
- `src/core/` uses no DOM, `Bun.*`, `node:*` or `GM_*` (tests may use Bun).
- No new runtime dependencies. Use `bun`/`bunx` only.
- Tests never touch the network. Fixtures are not hand-written (none are needed here).
- Nothing is uploaded or sent without a click; downloading is local and needs no network.
- History limits: 30 entries, 300 MB of image bytes; the newest entry is always kept.
- ZIP: store-only, UTF-8 flag, refuse > 4 GiB (0xFFFFFFFF) total.
- File names `NN_role_lang[_YYYY]_WxH.ext`; ZIP name `<slug>-<seriesId>-covers.zip`.
- Docs duties in the same commit as the code (ARCHITECTURE, DECISIONS, README, TODO).
## Review focus
1. **Many covers** (100+): file numbers pad to the width of the count (`001_…`), names stay unique.
→ Task 2 test "pads to the count's width".
2. **A title with no ASCII letters** (Korean/Japanese MangaBaka title): the slug falls back to `series`.
→ Task 2 test "slug falls back".
3. **Restoring an entry whose litterbox link has expired**: the link is gone, the card is otherwise intact,
and "2a" shows the source URL. → Task 4 test "drops expired links".
4. **Storage refused** (private window / quota): saving fails without breaking the page.
→ Task 7: `put` rejects; app catches and shows "couldn't be saved" (browser check in Task 9).
5. **Searching a series again after edits**: unticked covers stay unticked, notes kept.
→ Task 4 test "carries edits by hash".
---
### Task 1: ZIP writer (`src/core/zip.ts`)
**Files:** Create `src/core/zip.ts`, `test/zip.test.ts`.
**Produces:** `interface ZipEntry { name: string; bytes: Uint8Array; modified: Date }`,
`crc32(bytes: Uint8Array): number`, `zipStore(entries: readonly ZipEntry[]): Uint8Array`, `class ZipError`.
- [ ] Failing tests:
- `crc32(new TextEncoder().encode("123456789")) === 0xcbf43926`; `crc32(new Uint8Array()) === 0`.
- Two entries (one UTF-8 name `03_poster_zh-Hant_480x623.jpg`, one `covers.txt`): write to a temp file,
`unzip -t` exits 0, `unzip -p file name` returns the original bytes; general-purpose flag bit 11 set
(bytes 6–7 of the local header).
- `zipStore` throws `ZipError` when total size would exceed 0xFFFFFFFF (pass an entry whose `bytes` is a
fake object with `length` 2**32, checked before any copy).
- [ ] Run, see them fail.
- [ ] Implement: table-driven CRC-32 (poly 0xEDB88320); per entry a local header (sig 0x04034b50, version 20,
flags 0x0800, method 0, DOS time/date from UTC fields, crc, sizes, name length), name, data; then central
directory entries (sig 0x02014b50, offsets) and the end record (sig 0x06054b50). Little-endian via DataView.
- [ ] Run, see them pass. Commit `feat(core): store-only ZIP writer`.
### Task 2: Download bundle (`src/core/download.ts`) and `Session.bundle`
**Files:** Create `src/core/download.ts`, `test/download.test.ts`; modify `src/core/session.ts`.
**Consumes:** `ZipEntry`, `zipStore`; `ReviewCard`; `coverFormUrl`.
**Produces:**
- `coverFileName(position: number, total: number, card: ReviewCard, language: string): string`
- `slug(title: string): string`
- `interface BundleInput { seriesId: number; mbTitle: string; mbLink: string; sourceName: string; series: SourceSeries; cards: readonly ReviewCard[]; mainSha256: string | null; now: Date }`
- `downloadBundle(input: BundleInput): { zipName: string; files: ZipEntry[] }` — `files` = covers then `covers.txt`.
- `Session.input: RunInput | null` (set by `run`, by `restore`), `Session.bundle(now = new Date())`.
- [ ] Failing tests: names (`01_poster_en_480x623.jpg`; historical `seenAt` 2019… → `_2019_`; `zh-Hant`
kept; 100 cards → `001_`); `slug("Tower of God")` = `tower-of-god`, `slug("나 혼자만 레벨업")` = `series`;
zip name `tower-of-god-9999-covers.zip`; `covers.txt` contains the header lines, each file name,
`type: volume · language: en · rating: safe`, `MAIN COVER` only for the main one, the note, and
`source: <fetchedFrom>` plus `original: <candidate.url>` when it differs; `Session.bundle()` includes only
selected cards in shelf order and marks the main cover (via `defaultChoice()` kind `new`).
- [ ] Run, fail. Implement. Run, pass. Commit `feat(core): download bundle of the selected covers`.
### Task 3: Link expiry and hosting on cards
**Files:** modify `src/core/hosting/host.ts`, `src/core/session.ts`; test `test/hosting.test.ts`, `test/session.test.ts`.
**Produces:**
- `linkExpiry(host: HostId, options: HostOptions, now: number): number | null` — litterbox 1/12/24/72 h,
server 30 days, catbox and direct `null`.
- `ReviewCard.hosting?: { by: HostId; until: number | null; key: string | null }` (key = rehost cache key).
- `Session.rehost` sets `hosting` on each hosted card.
- `Session.restore(result: RunResult, cards: ReviewCard[], makeDefault: boolean, input: RunInput | null)`:
sets state, resets conflicts, puts each card's `hosting.key → hostedUrl` back in the rehost cache.
- [ ] Failing tests: `linkExpiry` per host; after `rehost("litterbox", {litterboxTime: "1h"})` with the fake
transport, a card has `hosting.by === "litterbox"` and `until` ≈ now + 1 h; after `restore` a second
litterbox rehost of the same card makes no upload call.
- [ ] Run, fail. Implement. Run, pass. Commit `feat(core): remember where and until when a cover is hosted`.
### Task 4: Saved searches (`src/core/saved.ts`)
**Files:** Create `src/core/saved.ts`, `test/saved.test.ts`.
**Produces:**
- `interface Marks { downloaded?: string; rehosted?: { at: string; host: HostId }; filed?: { at: string; submissionId: number } }`
- `type SavedCard = Omit<ReviewCard, "cover"> & { sha256: string }`
- `interface SavedSearch { version: 1; seriesId: number; input: RunInput; rating: ContentRating; result: Omit<RunResult, "source"> & { sourceId: string }; cards: SavedCard[]; makeDefault: boolean; marks: Marks; foundAt: string; savedAt: string; bytes: number }`
- `interface SearchSummary { seriesId; title; sourceName; language; fresh; present; marks; foundAt; savedAt; bytes; thumb: { bytes: Uint8Array; contentType: string } | null }`
- `toSaved(a: { result; cards; makeDefault; input; rating; marks; foundAt: string; now: Date }): SavedSearch`
- `summarize(s: SavedSearch): SearchSummary` (thumb = suggested default, else first fresh, else first present)
- `fromSaved(s, sources, now: number): { result: RunResult; cards: ReviewCard[]; makeDefault; input; rating; marks; foundAt }` — throws `SavedSearchError` for an unknown source; drops links whose `hosting.until <= now`.
- `carryEdits(previous: readonly SavedCard[] | readonly ReviewCard[], next: ReviewCard[], now: number): void` — by sha256: selected, type, note, contentRating, and unexpired hostedUrl/hosting.
- `pruneIds(summaries, limits = HISTORY_LIMITS): number[]`; `HISTORY_LIMITS = { count: 30, bytes: 300 * 2**20 }`.
- `interface HistoryStore { list(): Promise<SearchSummary[]>; get(id: number): Promise<SavedSearch | null>; put(s: SavedSearch): Promise<void>; remove(id: number): Promise<void>; clear(): Promise<void> }`
- `memoryHistory(): HistoryStore`; `saveAndPrune(store, s): Promise<number[]>` (removed ids).
- [ ] Failing tests: round trip keeps edits, main-cover flag, input, bytes identity of covers; unknown
source → `SavedSearchError`; drops expired litterbox, keeps catbox; carries edits by hash (unticked stays
unticked, note kept; new cover keeps defaults; expired link not carried); prune by count (31 → oldest out)
and by bytes (newest kept even when alone over the cap); `memoryHistory` lists newest first; `saveAndPrune`.
- [ ] Run, fail. Implement. Run, pass. Commit `feat(core): saved searches for the history`.
### Task 5: View helpers
**Files:** modify `src/web/view-model.ts`, `test/view-model.test.ts`.
**Produces:** `relativeTime(iso: string, now: Date): string` ("just now", "5 min ago", "3 h ago",
"yesterday", "4 days ago", else `YYYY-MM-DD`); `markText(m: Marks): string[]` (["downloaded",
"rehosted on litterbox", "filed #123"]); `summaryLine(s: SearchSummary): string` ("14 new · 3 on MangaBaka").
- [ ] Failing tests, implement, pass, commit `feat(web): history view helpers`.
### Task 6: Download button (page and panel)
**Files:** modify `src/web/template.ts` (block "2a · Add by hand": `<button id="download">`), `src/web/app.ts`,
`src/web/styles.css` if needed; create `src/web/save-file.ts` (`saveFile(bytes, name, type, root)`:
Blob URL, `<a download>` appended to `root`'s body or shadow root, click, revoke after 30 s).
- [ ] `draw()` sets `ui.download.disabled = selected === 0` and its label "Download N covers (ZIP)".
- [ ] Click: `const b = session.bundle(); saveFile(zipStore(b.files), b.zipName, "application/zip")`;
then `onDownloaded` hook (Task 8 uses it for the mark).
- [ ] Typecheck, tests, build. Browser check (Task 9). Commit `feat(web): download the selected covers as a ZIP`.
### Task 7: IndexedDB history store (`src/web/history-db.ts`)
**Produces:** `openHistory(indexedDB: IDBFactory): HistoryStore` — db `mbseed` v1, stores `summaries` and
`searches` keyed by `seriesId`; `put` writes `summarize(s)` and `s` in one `readwrite` transaction; `list`
sorts by `savedAt` desc; every request wrapped so errors reject the promise. If `indexedDB` is missing or
`open` fails, `openHistory` resolves to `null` and the page runs without history.
- [ ] Implement (thin; covered by the browser check). Commit with Task 8.
### Task 8: History UI (impeccable `craft`, Obi Shelf)
**Files:** modify `src/web/template.ts` (`<section id="recent" class="mbseed-recent" hidden>` with
`<ol id="recent-list">` and `<button id="recent-clear">` under the run status; `<div id="recent-shelf">`
in `.mbseed-empty`; `<p id="found-line">` with `<button id="search-again">` in the review header),
`src/web/app.ts`, `src/web/main.ts`, `src/web/styles.css`; create `src/web/recent.ts`.
- [ ] `AppOptions.history?: HistoryStore | null`. Without it the history elements stay hidden.
- [ ] Refactor `run()`'s display part into `show(r: RunResult, foundAt: string)`; `run()` and `open()` use it.
- [ ] After a run: `prev = await history.get(id)`; if found `carryEdits(prev.cards, session.cards, now)` and
keep `prev.marks`; save. After edits: debounced save (500 ms). Rehost → `marks.rehosted`; download →
`marks.downloaded`; filed → `marks.filed`; each saves.
- [ ] `open(id)`: `fromSaved` → `session.restore` → fill inputs → `show`. Errors show in the stage line.
- [ ] `recent.ts`: `renderRecent(list, shelf, summaries, now, { open, remove })` — rows with thumbnail (Blob
URL, revoked on re-render), title, relative time, summary line, marks, remove button; shelf of up to 6.
- [ ] Clear history asks `confirm()`.
- [ ] Commit `feat(web): history of searches on the page`.
### Task 9: Verify in a browser, docs, finish
- [ ] Headless Chrome (scratch CDP script): run ToG without Wayback; untick one, edit a note; reload; History
shows the entry; open it → state restored; Search again → edits carried; download ZIP (page) → `unzip -t`.
- [ ] Userscript panel with the GM shim (scratch `cdp-us.ts`): download ZIP works from the shadow root.
- [ ] Private-window-like failure: stub `indexedDB.open` to error → page still works.
- [ ] Docs: ARCHITECTURE (modules, data flow), DECISIONS (ZIP writer, history storage), README (manual
path with download; history), TODO (anything deferred).
- [ ] `git rm` this plan; merge to `main`; push.
+142
View File
@@ -0,0 +1,142 @@
---
status: approved
---
# 002 — Download covers, and a history of searches
Two additions from the first user review (2026-10-15). Design approved by the user in chat on 2026-10-15,
with these answers: history restores instantly (images kept in the browser); download everywhere, history on
the page only; one ZIP. Builds on [001](001-webtoons-covers.md).
## 1. Goals and non-goals
**Goals**
- Contributors without API access (spec 001 §9: fewer than 25 approved submissions) can **download the
selected covers** and upload them by hand in MangaBaka's cover form, without rehosting first.
- The page keeps a **history** of searched series: reopen one instantly as it was left (covers, ticks,
types, notes, ratings, main cover, rehosted links), see what was done with it, search it again.
**Non-goals**
- History in the userscript panel. The panel opens on the series being viewed; its storage would be
mangabaka.org's origin or GM storage, a poor place for megabytes of images.
- Syncing history between browsers or devices, or keeping it on our server.
- Per-cover download buttons (the ZIP of the selection covers it; untick what you don't want).
## 2. Download selected (ZIP)
On the page and in the panel, in the block renamed **"2a · Add by hand"** (it now holds the paste text, the
download and the form link): a **Download selected (ZIP)** button. It needs no rehosting and no network:
the bytes are already in memory from inspection (spec 001 §4.4).
- **Contents:** the selected covers, in shelf order, with their **original bytes** (no recompression), plus
`covers.txt`.
- **File names:** `NN_role_lang[_YYYY]_WxH.ext`, e.g. `01_poster_en_480x623.jpg`,
`03_poster_en_2019_480x623.jpg`. `NN` is the position (two digits, more when needed), `role` the
candidate role (`poster`, `square`, `banner`, `foreground`, `other`), `lang` the series language, `YYYY`
the year of the earliest archive capture (omitted for covers only seen live), `WxH` the pixel size.
Names are ASCII; the language code is kept as is (`zh-Hant`).
- **`covers.txt`:** a header (MangaBaka title and link, source title and link, the cover form link, the
time it was made), then one paragraph per file: file name, `type · language · rating`, `MAIN COVER` when
it is the chosen main cover, the note, and the source URL (the URL the bytes came from; the original
URL too when it differs).
- **ZIP name:** `<slug of the MangaBaka title>-<seriesId>-covers.zip`, e.g. `tower-of-god-9999-covers.zip`.
- **Format:** a store-only ZIP (method 0, no compression: JPEG/PNG/WebP don't shrink), UTF-8 name flag set,
CRC-32 per entry, written by our own `src/core/zip.ts` (no dependency). ZIP64 is not needed: a selection is
tens of MB at most; over 4 GiB it refuses.
- **Delivery:** a Blob URL and an `<a download>` click, on the page and in the panel's shadow root.
`GM_download` is the fallback only if the Blob link turns out not to work on mangabaka.org.
- Disabled when nothing is selected. Downloading marks the history entry *downloaded* (page).
## 3. History (page only)
### 3.1 What is kept
One entry per MangaBaka series (key: `seriesId`). A new search of the same series replaces its entry.
An entry holds:
- the inputs: both links, Wayback on/off and granularity, the rating chosen for new covers;
- the run result (spec 001 `RunResult`) as data: the source is stored by `Source.id` and looked up in the
registry on restore (an entry whose source no longer exists can't be opened and says so);
- the review state: per cover `selected`, `type`, `note`, `contentRating`, `hostedUrl`, plus when that link
expires and which host made it; the main-cover checkbox;
- status marks with times: *downloaded*, *rehosted* (host), *filed for review* (submission id);
- `savedAt`, `foundAt` (when the search ran) and the total image bytes (for pruning).
Saved automatically when a search finishes, after each edit (debounced, ~500 ms), after rehosting, after a
download and after filing.
### 3.2 Storage
IndexedDB on our page's origin, database `mbseed`, two object stores keyed by `seriesId`:
`summaries` (small: title, source, counts, marks, times, one thumbnail's bytes) and `searches` (the full
entry with every image's bytes). Both are written in one transaction; the list reads only `summaries`.
Pruning after each save: keep the 30 most recently saved series, and drop the oldest beyond 300 MB of image
bytes in total (the newest entry is always kept). A failed write (quota, private window, blocked storage)
never breaks the page: the history line says it couldn't be saved.
### 3.3 Restoring
Opening an entry shows the review exactly as it was, without network, with a line
"Found <relative time> · Search again". The inputs are filled in too.
- **Expired links are dropped:** litterbox links past their chosen lifetime and our server's past 30 days
are cleared on restore, so "2a" never hands out a dead link. catbox and `direct` links don't expire.
- **Rehost cache:** restored litterbox/catbox links go back into the session's rehost cache, so rehosting
again doesn't upload the same image twice.
- **Search again** runs the search with the stored inputs. When it finishes, the edits of covers that are
still there (same SHA-256) carry over: selection, type, note, rating, unexpired link. New covers get the
defaults. Then the entry is replaced.
- **Staleness is safe:** filing with a token always fetches a fresh MangaBaka snapshot first (spec 001 §4.9),
and MangaBaka may have changed since the search; the "Found …" line says how old it is.
### 3.4 Interface
- **Left column**, under the run plates: **History** — newest first; each row has the thumbnail, the
MangaBaka title, the relative time, "N new · M on MangaBaka", and the marks. Click (or Enter) opens it.
Each row has a remove button; the list ends with "Clear history" (asks to confirm).
- **Start screen** (before any search): the most recent entries (up to 6) as a small shelf of covers under
the intro, each opening its entry.
- Hidden entirely when there is no history yet, and in the userscript panel.
## 4. Modules
| Module | What |
|---|---|
| `src/core/zip.ts` | `crc32(bytes)`, `zipStore(files: {name, bytes, modified}[]): Uint8Array`. Pure. |
| `src/core/download.ts` | `downloadBundle(session-like input) → {zipName, files}`: names, `covers.txt`. Pure. |
| `src/core/saved.ts` | `SavedSearch` type; `saveSearch(session, inputs, marks, now)`; `restoreSearch(saved, sources, now)` → result + cards with expired links dropped; `carryEdits(oldCards, newCards, now)`; `pruneIds(summaries)`. Pure. |
| `src/core/hosting/host.ts` | `linkExpiry(host, options, now)`: when a hosted link stops working (null = never). |
| `src/core/session.ts` | `ReviewCard` gains `hostedBy` and `hostedUntil` (set by `rehost`); `restore(result, cards, makeDefault)`; `hostedCache` refilled on restore. |
| `src/web/history-db.ts` | `HistoryStore` interface and its IndexedDB implementation (thin; checked in a browser). |
| `src/web/recent.ts` | Rendering of the History list and the start-screen shelf. |
| `src/web/app.ts` | Download button; history wiring when `AppOptions.history` is given (the page passes it, the userscript doesn't). |
| `src/userscript/main.ts` | Nothing new beyond the shared template; check the download in the panel. |
`src/core` stays free of DOM, Bun, node and GM APIs: IndexedDB, Blob and `<a download>` live in `src/web`.
## 5. Testing
Unit tests, no network, no DOM:
- `zip.ts`: CRC-32 against known values; a written archive passes `unzip -t` and `unzip -p` returns the
original bytes (the test spawns `unzip` on a temp file); UTF-8 flag; refuses over 4 GiB (by size check).
- `download.ts`: names (order, two-digit padding, year from `seenAt`, no year when live only), `covers.txt`
content including the main-cover mark, zip name slug.
- `saved.ts`: save → restore round trip keeps every edit and the bytes; unknown source refused; expired
litterbox/server links dropped and catbox kept; `carryEdits` by hash; pruning by count and by bytes,
newest kept.
- `linkExpiry` per host; `Session.rehost` sets `hostedBy`/`hostedUntil`.
- `HistoryStore` is used through an in-memory fake in app-level tests where needed.
In a real browser (headless Chrome through the DevTools protocol, as for 001): search, edit, reload, open the
entry from History, check the restored state; Search again carries the edits; download the ZIP on the page
and in the userscript panel (GM shim) and check it with `unzip -t`.
## 6. Risks
- **Browser storage limits:** IndexedDB quota differs per browser; Firefox asks above a threshold in some
setups. Pruning at 300 MB keeps it moderate; a refused write is reported, not fatal.
- **`hostedUntil` for old entries:** links rehosted before this change have no expiry recorded; there are none
(history starts with this change).
- **Download in the panel:** mangabaka.org's CSP could block the Blob link; then `GM_download` (needs a
`@grant`).