docs: remove the finished plan 002 (the spec is the record)

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:46:05 +02:00
1 parent 49c9efa704
commit a7ff9ae20d
1 file changed
-184
-184
View File
@@ -1,184 +0,0 @@
# 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 0: Wayback off by default
- [x] `src/web/template.ts`: drop `checked` from `#history-on`; amend spec 001 §4.9 and spec 002; DECISIONS.
Commit `feat(web): old covers from the Wayback Machine start off`.
### 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`.
- [x] 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).
- [x] Run, see them fail.
- [x] 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.
- [x] 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())`.
- [x] 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`).
- [x] 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.
- [x] 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.
- [x] 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).
- [x] 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`.
- [x] 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").
- [x] 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).
- [x] `draw()` sets `ui.download.disabled = selected === 0` and its label "Download N covers (ZIP)".
- [x] Click: `const b = session.bundle(); saveFile(zipStore(b.files), b.zipName, "application/zip")`;
then `onDownloaded` hook (Task 8 uses it for the mark).
- [x] 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.
- [x] 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`.
- [x] `AppOptions.history?: HistoryStore | null`. Without it the history elements stay hidden.
- [x] Refactor `run()`'s display part into `show(r: RunResult, foundAt: string)`; `run()` and `open()` use it.
- [x] 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.
- [x] `open(id)`: `fromSaved` → `session.restore` → fill inputs → `show`. Errors show in the stage line.
- [x] `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.
- [x] Clear history asks `confirm()`.
- [x] Commit `feat(web): history of searches on the page`.
### Task 9: Verify in a browser, docs, finish
- [x] 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`.
- [x] Userscript panel with the GM shim (scratch `cdp-us.ts`): download ZIP works from the shadow root.
- [x] Private-window-like failure: stub `indexedDB.open` to error → page still works.
- [x] Docs: ARCHITECTURE (modules, data flow), DECISIONS (ZIP writer, history storage), README (manual
path with download; history), TODO (anything deferred).
- [x] `git rm` this plan; merge to `main`; push.