From a7ff9ae20d7e97cfb5554d3493e1fe565cea7d93 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 15 Oct 2026 11:46:05 +0200 Subject: [PATCH] docs: remove the finished plan 002 (the spec is the record) Co-Authored-By: Claude Opus 5.5 --- docs/plans/002-download-and-history.md | 184 ------------------------- 1 file changed, 184 deletions(-) delete mode 100644 docs/plans/002-download-and-history.md diff --git a/docs/plans/002-download-and-history.md b/docs/plans/002-download-and-history.md deleted file mode 100644 index 9c0a575..0000000 --- a/docs/plans/002-download-and-history.md +++ /dev/null @@ -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 `--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: ` plus `original: ` 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 & { 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). - -- [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": `