From e7af273a6d92db70d0a1f259e0cc6ecf754fed12 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 15 Oct 2026 11:28:13 +0200 Subject: [PATCH] docs: spec 002 (download covers, history of searches) and its plan Co-Authored-By: Claude Opus 5.5 --- docs/plans/002-download-and-history.md | 179 +++++++++++++++++++++++++ docs/specs/002-download-and-history.md | 142 ++++++++++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 docs/plans/002-download-and-history.md create mode 100644 docs/specs/002-download-and-history.md diff --git a/docs/plans/002-download-and-history.md b/docs/plans/002-download-and-history.md new file mode 100644 index 0000000..e99049c --- /dev/null +++ b/docs/plans/002-download-and-history.md @@ -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 `--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: ` plus `original: ` 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 & { sha256: string }` +- `interface SavedSearch { version: 1; seriesId: number; input: RunInput; rating: ContentRating; result: Omit & { 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; get(id: number): Promise; put(s: SavedSearch): Promise; remove(id: number): Promise; clear(): Promise }` +- `memoryHistory(): HistoryStore`; `saveAndPrune(store, s): Promise` (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": `