From d203438902b8e088777e5a4f517a37c8063bc37c Mon Sep 17 00:00:00 2001 From: Squeaks72 Date: Tue, 22 Sep 2026 11:18:47 -0500 Subject: [PATCH 1/3] Keep Discover's source filter through a search A term submitted with a source chosen now asks only that source (&source= on search-all) instead of fanning out to every one and answering with everything, and the picker stays on screen so the filter is visible and clearable rather than silently narrowing the results. `source` only narrows the route's existing surfaceable() set, so an adult source without adult=1, one above the account's age limit, or an unknown id asks nobody and answers the empty shape. The search query's key now includes the chosen source; discoverSearch.test.ts and discoverAdult.test.ts pin the new key and the picker being mounted in both modes, and searchAll.int.test.ts covers the narrowed fan-out. Documented in openapi.yaml, docs/api.md and USAGE.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- bff/openapi.yaml | 10 +++++++ bff/src/routes/sources.ts | 16 ++++++++-- bff/test/searchAll.int.test.ts | 30 +++++++++++++++++++ docs/USAGE.md | 3 ++ docs/api.md | 5 ++++ web/app/discover/page.tsx | 52 +++++++++++++++++++++------------ web/test/discoverAdult.test.ts | 16 ++++++---- web/test/discoverSearch.test.ts | 23 +++++++++++---- 8 files changed, 123 insertions(+), 32 deletions(-) diff --git a/bff/openapi.yaml b/bff/openapi.yaml index 9e103bf13..c14a3c148 100644 --- a/bff/openapi.yaml +++ b/bff/openapi.yaml @@ -1988,6 +1988,16 @@ paths: schema: type: string description: "`1` to include adult sources in the fan-out. Absent means they are not asked." + - name: source + in: query + required: false + schema: + type: string + description: >- + Ask only this source id (what Discover sends while it is filtered to one source). Narrows the + fan-out within the sources the caller may be shown, never past them: an id outside that set + (an adult source without `adult=1`, one above the account's age limit, an unknown id) asks + nobody and answers the empty shape. Absent means every source. - name: groupBy in: query required: false diff --git a/bff/src/routes/sources.ts b/bff/src/routes/sources.ts index 721f7f059..3003c79c4 100644 --- a/bff/src/routes/sources.ts +++ b/bff/src/routes/sources.ts @@ -1781,7 +1781,7 @@ export default async function sourceRoutes(app: FastifyInstance) { * polls the same URL with a short `wait` until it is 0. */ app.get('/api/sources/search-all', async (req) => { - const { q: rawQ, groupBy, wait } = req.query as { q?: string; groupBy?: string; wait?: string }; + const { q: rawQ, groupBy, wait, source } = req.query as { q?: string; groupBy?: string; wait?: string; source?: string }; const term = (rawQ || '').trim(); if (!term) return { content: [], sources: [], pending: 0, asked: 0 }; // Absent means the full first-answer wait, so a caller written before `wait` existed gets the most @@ -1795,7 +1795,19 @@ export default async function sourceRoutes(app: FastifyInstance) { // "Show 18+" chip. The chip belongs here and not only in the shaping below, because a source left in // `ask` is a source this request STARTS -- an outbound query to an adult site on behalf of someone who // asked not to see one, and its results would then also land in the shared entry under this term. - const ask = surfaceable(req); + const all = surfaceable(req); + // `source` narrows the fan-out to one source, for a search made while Discover is filtered to it. The + // filter was display-only before, and did not survive a search at all: submitting a term asked every + // source and answered with everything, so choosing a source and then searching within it was not + // possible. Narrowing here rather than filtering the answer also makes it one outbound request instead + // of a dozen, which is the difference between an instant answer and the slowest source's timeout. + // + // It only ever narrows `surfaceable`, never widens it: an id outside that set -- an adult source with + // the reveal off, one an age cap puts out of reach, or one that does not exist -- leaves `ask` empty, + // nobody is asked, and the answer is the ordinary nothing-found shape. The entry is still keyed by the + // term alone, so a narrowed search and a full one share whatever the sources have already answered. + const only = typeof source === 'string' ? source : ''; + const ask = only ? all.filter((s) => s.id === only) : all; const health = new Map((await healthAll().catch(() => [])).map((h) => [h.source_id, h] as const)); const ans = await searchAll(term, ask, { waitMs, health }); const byId = new Map(ask.map((s) => [s.id, s] as const)); diff --git a/bff/test/searchAll.int.test.ts b/bff/test/searchAll.int.test.ts index 571075dc0..f9ac258e1 100644 --- a/bff/test/searchAll.int.test.ts +++ b/bff/test/searchAll.int.test.ts @@ -263,6 +263,36 @@ test('an empty term answers the same shape without asking anyone', { skip }, asy assert.deepEqual(r.json(), { content: [], sources: [], pending: 0, asked: 0 }); }); +test('source= narrows the fan-out to that one source, and cannot reach past the caller\'s own set', { skip }, async () => { + // Discover filtered to one source keeps that filter through a search. Narrowing is done on the ASK, not on + // the answer, so the other sources are not even asked: one outbound request rather than a dozen, and the + // answer comes as soon as that source does. Reintroduce by ignoring `source` in the route: the other + // sources' counters move and `sources` lists all of them. + const before = { ...calls }; + const r = await app.inject({ method: 'GET', url: `/api/sources/search-all?q=Narrowed%20Term&source=${FAST}`, headers: tok(ids.plain) }); + assert.equal(r.statusCode, 200); + const j = r.json(); + assert.deepEqual(j.sources.map((x: any) => x.id), [FAST], 'the answer covers more than the chosen source'); + assert.equal(j.asked, 1); + assert.ok(j.content.length > 0 && j.content.every((g: any) => g.providers.every((p: any) => p.source === FAST)), + 'a card came from a source that was not chosen'); + assert.equal(calls[FAST] - before[FAST], 1, 'PREMISE: the chosen source was not asked'); + const others = ALL.filter((id) => id !== FAST && calls[id] !== before[id]); + assert.deepEqual(others, [], `sources outside the filter were asked: ${others.join(', ')}`); + + // The filter only ever narrows `surfaceable`: naming an adult source without the reveal, or naming one an + // age cap puts out of reach even with it, asks nobody and answers the ordinary empty shape. + const adultBefore = calls[ADULT]; + for (const [url, who] of [ + [`/api/sources/search-all?q=Narrowed%20Adult&source=${ADULT}`, ids.plain], + [`/api/sources/search-all?q=Narrowed%20Adult&source=${ADULT}&adult=1`, ids.capped], + ] as const) { + const a = (await app.inject({ method: 'GET', url, headers: tok(who) })).json(); + assert.deepEqual([a.content, a.sources, a.asked], [[], [], 0], `${url} reached a source outside the caller's set`); + } + assert.equal(calls[ADULT], adultBefore, 'naming the adult source asked it'); +}); + test('after the TTL the sources are asked again', { skip }, async () => { // Reintroduce by dropping the TTL check at the top of searchAll: the entry answers forever and the // counter does not move. diff --git a/docs/USAGE.md b/docs/USAGE.md index 7ee1b5816..58dff2a99 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -617,6 +617,9 @@ again. about six seconds); source rows show *Searching…*, empty, failed, disabled or cooling-down states while the rest arrive. Repeating the same search continues the in-flight work and a recent term opens from the five-minute cache. Results are still filtered for the signed-in account, including its age limit. + With one source chosen in the chip, a search asks **only that source** and the chip stays on screen while + the results are up, so you can see the search is narrowed and clear it with its × (which searches every + source again). Switching the toggle to *Newest* or *Popular* goes back to browsing. - **Add:** tap a card and pick which source to add it from — each with its favicon, the first marked *most used* (skipped when only one has it). The dialog then opens with *From {source} · Change*. Choose **Chapters to fetch now** (All, First N, Latest N, or **Nothing yet — pick chapters later**), toggle diff --git a/docs/api.md b/docs/api.md index 1e86a3990..31dbf4336 100644 --- a/docs/api.md +++ b/docs/api.md @@ -324,6 +324,11 @@ slot count) at a time, one lane for solver-fronted sources and one for the rest; budget is recorded as slow (never as a failure), one that throws is recorded against its health, and one that is disabled or in a cooldown is skipped rather than asked — exactly as the newest listing treats them. +`&source=` asks only that one source, which is what Discover sends while it is filtered to a source. It +narrows the set described above and never widens it: an adult source without `adult=1`, one above your age +limit, or an id that does not exist asks nobody and answers the empty shape. The entry is still keyed by the +term alone, so a narrowed search reads whatever a full one already heard, and the other way round. + `GET /api/sources/detail` is cached for ten minutes per source and series (it was ninety seconds), and concurrent requests for the same pair — the add dialog's pre-warm and the pick that follows it — collapse into one outbound fetch. A failed lookup is never cached, so an immediate retry asks the source again. diff --git a/web/app/discover/page.tsx b/web/app/discover/page.tsx index f97d15dc9..a0295d9be 100644 --- a/web/app/discover/page.tsx +++ b/web/app/discover/page.tsx @@ -211,12 +211,15 @@ export default function DiscoverPage() { * same term again inside five minutes is instant, and leaving search mode stops the polling by itself. */ const searchQ = useQuery({ - queryKey: ['search-all', term], + // `selected` is part of the key: narrowing to a source is a different question, and must not be + // answered from the unfiltered search's cache. + queryKey: ['search-all', term, selected], queryFn: ({ signal, queryKey, client }) => { // ⚠️ Only the first request may wait the long wait. A poll that also waited six seconds would hold // its answer until the server's grace expired, so the wall would fill in six seconds late every time. const first = (client.getQueryState(queryKey)?.dataUpdateCount ?? 0) === 0; - return api(`/api/sources/search-all?q=${encodeURIComponent(term)}&wait=${first ? SEARCH_FIRST_WAIT_MS : SEARCH_POLL_WAIT_MS}`, { signal }); + const only = selected ? `&source=${encodeURIComponent(selected)}` : ''; + return api(`/api/sources/search-all?q=${encodeURIComponent(term)}&wait=${first ? SEARCH_FIRST_WAIT_MS : SEARCH_POLL_WAIT_MS}${only}`, { signal }); }, enabled: mode === 'search' && !!term, // A failed search is shown as one; retrying it would be another fan-out to every source. @@ -227,11 +230,18 @@ export default function DiscoverPage() { }); // The grouped hits as wall rows, under today's mapping: the first provider's ids are the card's, the badge // counts every provider. Derived, so a poll's answer replaces the rows without anything being cleared. - const searchHits = useMemo(() => (searchQ.data?.content ?? []).map((g) => ({ - source: g.providers[0]?.source ?? '', sourceId: g.providers[0]?.sourceId ?? g.title, - title: g.title, coverUrl: g.coverUrl, updatedAt: g.updatedAt, - inLibrary: g.inLibrary, providerCount: g.providers.length, - })), [searchQ.data]); + const searchHits = useMemo(() => (searchQ.data?.content ?? []).flatMap((g) => { + // With a source chosen the server has already asked only that one, so this is belt-and-braces: keep + // the card only if that source is among its providers, and let that provider be the card's own, so + // tapping it opens the source being browsed rather than whichever the fold happened to rank first. + const pick = selected ? g.providers.find((p) => p.source === selected) : g.providers[0]; + if (!pick) return []; + return [{ + source: pick.source ?? '', sourceId: pick.sourceId ?? g.title, + title: g.title, coverUrl: g.coverUrl, updatedAt: g.updatedAt, + inLibrary: g.inLibrary, providerCount: g.providers.length, + }]; + }), [searchQ.data, selected]); const groupsRef = useRef>({}); // What each search stored, keyed the way the wall's own fold is, so open() offers the providers of a hit // the same way it offers the providers of a folded card. Written from the answer, never from state. @@ -449,18 +459,22 @@ export default function DiscoverPage() { - {mode === 'newest' && ( - { setListMode(m); setSelected(null); setPage(1); }} - /> - )} + {/* + In both modes, not just while browsing. The picker is the only place the chosen source is visible or + clearable, so hiding it during a search -- while the search itself is narrowed to that source -- + would leave the results silently filtered with nothing on screen to say why. Choosing a list tab is + a browse action, so from a search it returns to browsing that list. + */} + { setListMode(m); setSelected(null); setPage(1); if (mode === 'search') backToNewest(); }} + /> {/* One mounted child per budgeted source. Renders nothing; owns one request. The key carries the listing mode, so switching Newest/Popular REMOUNTS these and they fetch the diff --git a/web/test/discoverAdult.test.ts b/web/test/discoverAdult.test.ts index 67b7797cd..ca43b88b6 100644 --- a/web/test/discoverAdult.test.ts +++ b/web/test/discoverAdult.test.ts @@ -38,16 +38,22 @@ test('the reveal chip stays on screen while the reveal is on', () => { }); test('the chip is anchored where a search cannot unmount it', () => { - // Reintroduce by moving `` into the SourcePicker chip row: it is mounted only for - // `mode === 'newest'` (the line below it), so the chip would vanish as soon as anyone searched. + // Reintroduce by moving `` into the SourcePicker chip row: the header is the one place + // that is the same in both modes, and the picker's row is a browse control whose mounting has changed + // before. SourcePicker used to be newest-only, which is why this anchor exists; it now stays mounted + // while searching too, because a search made with a source chosen is narrowed to that source and the + // picker is where that filter is shown and cleared. Reintroduce the old gating by wrapping it in + // `{mode === 'newest' && (...)}` again: "SourcePicker is gated on the mode" fails, and a narrowed search + // loses the only thing on screen that says it is narrowed. const page = code(read('app/discover/page.tsx')); const header = page.slice(page.indexOf('')); assert.ok(header.includes(' { diff --git a/web/test/discoverSearch.test.ts b/web/test/discoverSearch.test.ts index 18127672e..9720f8da9 100644 --- a/web/test/discoverSearch.test.ts +++ b/web/test/discoverSearch.test.ts @@ -42,10 +42,16 @@ const PAGE = 'app/discover/page.tsx'; test('the search is a query keyed on the submitted term, not on the field', () => { // `q` is whatever is in the field; `term` is what was submitted. Keyed on `q`, every keystroke would be a // fan-out to every source, and keyed on a constant, a new term would paint under the old term's answer - // until its own landed. Reintroduce by writing `queryKey: ['search-all', q]`: "keyed on the field" fails; - // by bringing back `setSearchHits`: "the imperative search is back" fails. + // until its own landed. The chosen source is part of the key too: a search narrowed to one source is a + // different question (it sends `&source=`), and answering it from the unfiltered search's cache, or the + // other way round, would show one under the other. Reintroduce by writing `queryKey: ['search-all', q]`: + // "keyed on the field" fails; by dropping `selected` from the key: the same assertion fails; by dropping + // `&source=` from the request: "the chosen source is not sent" fails; by bringing back `setSearchHits`: + // "the imperative search is back" fails. const src = code(read(PAGE)); - assert.match(src, /queryKey: \['search-all', term\]/, 'the search is not keyed on the submitted term (or is keyed on the field)'); + assert.match(src, /queryKey: \['search-all', term, selected\]/, 'the search is not keyed on the submitted term and the chosen source (or is keyed on the field)'); + assert.match(src, /const only = selected \? `&source=\$\{encodeURIComponent\(selected\)\}` : '';/, 'the chosen source is not sent'); + assert.match(src, /SEARCH_POLL_WAIT_MS\}\$\{only\}`, \{ signal \}\)/, 'the chosen source is not sent'); assert.match(src, /const \[term, setTerm\] = useState\(''\)/, 'there is no separate submitted term'); assert.match(src, /enabled: mode === 'search' && !!term/, 'the search runs outside search mode or with an empty term'); assert.doesNotMatch(src, /setSearchHits|setSearching|useState\(\[\]\)/, 'the imperative search is back'); @@ -127,12 +133,17 @@ test('the wall pins still hold, and the hits are derived from the answer', () => // wall.test.ts pins `return foldByTitle(out, nameOf, rankOf);` and `wall.groups[key] ?? // groupsRef.current[key]` byte for byte; both survive the rebuild, and the hits and the groups are now // DERIVED from the answer (a memo and an effect on `searchQ.data`) rather than set by a handler, so a - // poll's answer replaces the rows without anything being cleared first. Reintroduce by filling groupsRef - // inside the queryFn: "groups are not filled from the answer" fails. + // poll's answer replaces the rows without anything being cleared first. With a source chosen, a hit keeps + // only if that source is among its providers, and that provider is the card's own, so tapping it opens the + // source being browsed rather than whichever one the fold ranked first. Reintroduce by filling groupsRef + // inside the queryFn: "groups are not filled from the answer" fails; by going back to `providers[0]` + // regardless of the filter: "a hit's provider ignores the chosen source" fails. const src = read(PAGE); assert.match(src, /return foldByTitle\(out, nameOf, rankOf\);/, 'the wall is not folded'); assert.match(src, /wall\.groups\[key\] \?\? groupsRef\.current\[key\]/, "open() does not read the wall's groups"); - assert.match(code(src), /const searchHits = useMemo\(\(\) => \(searchQ\.data\?\.content \?\? \[\]\)\.map\(/, 'the hits are not derived from the answer'); + assert.match(code(src), /const searchHits = useMemo\(\(\) => \(searchQ\.data\?\.content \?\? \[\]\)\.flatMap\(/, 'the hits are not derived from the answer'); + assert.match(code(src), /const pick = selected \? g\.providers\.find\(\(p\) => p\.source === selected\) : g\.providers\[0\];/, "a hit's provider ignores the chosen source"); + assert.match(code(src), /\}\), \[searchQ\.data, selected\]\);/, 'the hits are not re-derived when the chosen source changes'); assert.match(code(src), /useEffect\(\(\) => \{\s*groupsRef\.current = \{\};\s*\(searchQ\.data\?\.content \?\? \[\]\)\.forEach\(\(g\) => \{ groupsRef\.current\[normTitle\(g\.title\)\] = g\.providers; \}\);\s*\}, \[searchQ\.data\]\);/, 'groups are not replaced from the latest answer'); }); From 888a26bb8e42fbff9d8b61306d814ef1ff3d88ba Mon Sep 17 00:00:00 2001 From: Squeaks72 Date: Thu, 24 Sep 2026 06:27:13 -0500 Subject: [PATCH 2/3] fix: chapter list past chapter 1000; page it 100 at a time, opening at Continue Chapter list - The series page and the reader asked the books route for one page of 1000, so One Piece's chapters 1001+ never appeared in the list or in the reader's prev/next/jump list. lib/seriesBooks.ts fetchAllBooks() walks every page (tested). The smart-offline planner had the same cap server-side. - The list now renders 100 rows a page (lib/chapterPages.ts, tested) with a pager above and below (first/prev/range picker/next/last). It opens on the page holding the "Continue" chapter; a tap on the pager pins a page, and a new series, sort, or filter follows Continue again. A chapter chip in Sources & translations turns to the page holding that row before it scrolls to it. Co-Authored-By: Claude Opus 5.5 (1M context) --- bff/src/routes/catalog.ts | 4 ++- docs/USAGE.md | 5 +++ web/app/reader/page.tsx | 3 +- web/app/series/page.tsx | 55 ++++++++++++++++++++++++++++++--- web/components/SourcesSheet.tsx | 9 +++++- web/lib/chapterPages.ts | 27 ++++++++++++++++ web/lib/seriesBooks.ts | 26 ++++++++++++++++ web/public/locales/ar.json | 8 +++-- web/public/locales/de.json | 8 +++-- web/public/locales/es.json | 8 +++-- web/public/locales/fr.json | 8 +++-- web/public/locales/ja.json | 8 +++-- web/public/locales/pt-BR.json | 8 +++-- web/public/locales/ru.json | 8 +++-- web/public/locales/zh.json | 8 +++-- web/test/chapterPages.test.ts | 26 ++++++++++++++++ web/test/seriesBooks.test.ts | 42 +++++++++++++++++++++++++ 17 files changed, 238 insertions(+), 23 deletions(-) create mode 100644 web/lib/chapterPages.ts create mode 100644 web/lib/seriesBooks.ts create mode 100644 web/test/chapterPages.test.ts create mode 100644 web/test/seriesBooks.test.ts diff --git a/bff/src/routes/catalog.ts b/bff/src/routes/catalog.ts index 9ebf72806..1dd4c3a6f 100644 --- a/bff/src/routes/catalog.ts +++ b/bff/src/routes/catalog.ts @@ -708,8 +708,10 @@ export default async function catalogRoutes(app: FastifyInstance) { const planShown = await browsableIds(favAll, vc(req)); const favIds = favAll.filter((id) => planShown.has(id)); const out: { bookId: string; seriesId: string }[] = []; + // Every chapter, not the first 1000: past chapter 1000 of One Piece the unread ones were all beyond + // the page and the plan came back empty. for (const sid of favIds) { - const raw = await komga.seriesBooks(vc(req), sid, 0, 1000, 'metadata.numberSort,asc').catch(() => null); + const raw = await komga.seriesBooks(vc(req), sid, 0, 100000, 'metadata.numberSort,asc').catch(() => null); if (!raw) continue; const books = await booksForUser(req, raw.content); // A pruned chapter has no pages to download; planning it would queue a manifest that answers 410. diff --git a/docs/USAGE.md b/docs/USAGE.md index 7ee1b5816..3dac7d230 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -119,6 +119,11 @@ genres, description, and the **chapter list**. - **Mark all read** does what it says to every chapter of the series; **Filter** narrows the list to one translation group or hides the grey rows; **Select** picks chapters one by one for the actions described in *Selecting chapters* below. +- A long series shows its chapters **100 at a time**, with a pager above and below the list (first, + previous, a picker named by the rows each page holds — *901–1000* — next, last). The list opens on the + page holding the chapter *Continue* would open, so a reader on chapter 956 lands among the 900s. Picking a + page keeps you there; a new series, sort order or filter goes back to following *Continue*. Every chapter + is listed — before this, the list and the reader's chapter list stopped at chapter 1000. - The line under the title — *MangaDex · Example Scans +2 · 4 not here yet ›* — is the series' source, who translates it and how many chapters the sources have that this server does not. Tap it for **Sources & translations**, described below. diff --git a/web/app/reader/page.tsx b/web/app/reader/page.tsx index bf7822bbc..ce1270e13 100644 --- a/web/app/reader/page.tsx +++ b/web/app/reader/page.tsx @@ -6,6 +6,7 @@ import { useSearchParams, useRouter } from 'next/navigation'; import { AnimatePresence, motion } from 'framer-motion'; import { useQuery } from '@tanstack/react-query'; import { api, img } from '@/lib/api'; +import { fetchAllBooks } from '@/lib/seriesBooks'; import { chapterOutcome } from '@/lib/readerState'; import { openableChapters } from '@/lib/chapterRows'; import { buildFlow, startIndex, renderWindow } from '@/lib/readerFlow'; @@ -195,7 +196,7 @@ function ReaderInner() { } // chapter list for prev/next/jump try { - const list = await api>(`/api/series/${first.seriesId}/books?size=1000&sort=metadata.numberSort,asc`); + const list = await fetchAllBooks(first.seriesId); // ⚠️ A chapter the server's cleanup deleted is still a row in that list -- it has to be, it carries // everyone's progress -- and the first cut of "Chapter deleted" only handled the failure screen, so // next/prev walked straight onto the tombstone and showed it in the middle of a series that was diff --git a/web/app/series/page.tsx b/web/app/series/page.tsx index 8d8a2ca84..6ba9a368c 100644 --- a/web/app/series/page.tsx +++ b/web/app/series/page.tsx @@ -19,6 +19,8 @@ import { t as tr } from '@/lib/i18n'; import { FindMissingDialog } from '@/components/FindMissingDialog'; import { normGroup } from '@/lib/scanlators'; import { GHOST_CAP, mergeRows, whyLabel, runLabel, chunkNumbers, MARK_CHUNK } from '@/lib/chapterRows'; +import { CHAPTER_PAGE, clampPage, pageCount, pageOf, pageSlice } from '@/lib/chapterPages'; +import { fetchAllBooks } from '@/lib/seriesBooks'; import { ALL_GROUPS, copySourceId, groupsOfRow, matchesGroup } from '@/lib/groupFilter'; import { SourcesSheet, useSeriesGroups, useCheckNow } from '@/components/SourcesSheet'; import { SourcesExplainer } from '@/components/SourcesExplainer'; @@ -756,6 +758,27 @@ function readShowGhosts(): boolean { try { return localStorage.getItem(SHOW_GHOSTS_KEY) !== 'off'; } catch { return true; } } +/** + * Prev / a range picker / Next for the chapter list. The picker names each page by the rows it holds + * ("901–1000"), which is what a reader hunting for a chapter number actually scans for. + */ +function ChapterPager({ page, pages, total, onPage }: { page: number; pages: number; total: number; onPage: (p: number) => void }) { + const range = (p: number) => `${p * CHAPTER_PAGE + 1}–${Math.min(total, (p + 1) * CHAPTER_PAGE)}`; + return ( + + ); +} + function SeriesInner() { const id = useSearchParams().get('id') || ''; const router = useRouter(); @@ -810,7 +833,7 @@ function SeriesInner() { const { data: series } = useQuery({ queryKey: ['series', id], queryFn: () => api(`/api/series/${id}`), enabled: !!id }); const { data: books } = useQuery({ queryKey: ['series-books', id], - queryFn: () => api>(`/api/series/${id}/books?size=1000&sort=metadata.numberSort,asc`), + queryFn: () => fetchAllBooks(id), enabled: !!id, }); // What the sources list that the library lacks, as of the updater's last visit. A courtesy, never a @@ -971,6 +994,27 @@ function SeriesInner() { return c.find((b) => !b.readProgress?.completed && openable(b)) || c.find(openable) || c[0]; }, [books, downloaded]); + // The list a page at a time (lib/chapterPages.ts). `null` = follow "Continue": the page holding + // `resumeBook`, so a reader on chapter 956 lands among the 900s. Any tap on the pager pins a page; a new + // series, sort or filter drops the pin and follows Continue again. + const [chapterPage, setChapterPage] = useState(null); + useEffect(() => { setChapterPage(null); }, [id, asc, group, showGhosts]); + const autoPage = useMemo(() => (resumeBook ? pageOf(rows, (r) => r.kind === 'book' && r.book.id === resumeBook.id) : 0), [rows, resumeBook]); + const shownPage = clampPage(chapterPage ?? autoPage, rows.length); + const pages = pageCount(rows.length); + const pageRows = useMemo(() => pageSlice(rows, shownPage), [rows, shownPage]); + // A chip in Sources & translations jumps to a chapter's row, which may be on another page: turn to it + // first. A number with no row (folded into a run, or filtered out) leaves the page as it is. + const showChapter = (n: number) => { + const i = rows.findIndex((r) => (r.kind === 'book' && r.book.number === n) || (r.kind === 'ghost' && r.ghost.number === n)); + if (i >= 0) setChapterPage(Math.floor(i / CHAPTER_PAGE)); + }; + const chaptersTop = useRef(null); + const goPage = (p: number, scroll: boolean) => { + setChapterPage(clampPage(p, rows.length)); + if (scroll) chaptersTop.current?.scrollIntoView({ block: 'start', behavior: 'smooth' }); + }; + const inProgress = books?.content.some((b) => b.readProgress && !b.readProgress.completed); const back = () => (typeof window !== 'undefined' && window.history.length > 1 ? router.back() : router.push('/')); @@ -1437,7 +1481,7 @@ function SeriesInner() { // How many of the Filter sheet's two choices are off their default; the chip wears the number. const activeFilters = (group !== ALL_GROUPS ? 1 : 0) + (showGhosts ? 0 : 1); const Chapters = ( -
+
{/* The heading on its own line and ONE row of four short, text-only chips under it. Measured at 390 px: with icons and the two long chips this was five chips on two rows plus two sentences; the four fit one row in English, and `flex-wrap` (never nowrap) is the safety valve for German @@ -1469,8 +1513,9 @@ function SeriesInner() { {tr('{n} of {m} chapters match', { n: filteredBooks.length + filteredGhosts.length, m: allBooks.length + visibleGhosts.length })}

)} + {pages > 1 && goPage(p, false)} />}
- {rows.map((r) => { + {pageRows.map((r) => { if (r.kind === 'book') { const b = r.book; return ( @@ -1532,6 +1577,7 @@ function SeriesInner() { })} {!books && Array.from({ length: 8 }).map((_, i) =>
)}
+ {pages > 1 && goPage(p, true)} />}
); @@ -1636,7 +1682,8 @@ function SeriesInner() { onSaved={() => { for (const k of [['series', id], ['series-books', id], ['home'], ['library']]) qc.invalidateQueries({ queryKey: k }); }} onClose={() => setSourcesOpen(false)} onExplain={() => { setSourcesOpen(false); setExplaining(true); }} - onFindMissing={() => { setSourcesOpen(false); setFindingMissing(true); }} /> + onFindMissing={() => { setSourcesOpen(false); setFindingMissing(true); }} + onShowChapter={showChapter} /> )} {explaining && { setExplaining(false); setSourcesOpen(true); }} />} {filterOpen && ( diff --git a/web/components/SourcesSheet.tsx b/web/components/SourcesSheet.tsx index d6b1b771b..b3160b2e3 100644 --- a/web/components/SourcesSheet.tsx +++ b/web/components/SourcesSheet.tsx @@ -252,7 +252,7 @@ function GroupRow({ g, blocked, serverBlocked, haveNumbers, seriesStatus, contro * turn "follows the defaults" into a per-series copy of them on the first tap -- a copy that then stops * following when the defaults change. Blank patience means the same thing for the same reason. */ -export function SourcesSheet({ id, series, groups, admin, error, isLoading, haveNumbers, checkedAt, onSaved, onClose, onExplain, onFindMissing }: { +export function SourcesSheet({ id, series, groups, admin, error, isLoading, haveNumbers, checkedAt, onSaved, onClose, onExplain, onFindMissing, onShowChapter }: { id: string; series: Series | undefined; groups: GroupStat[]; @@ -270,6 +270,12 @@ export function SourcesSheet({ id, series, groups, admin, error, isLoading, have onExplain: () => void; /** Open Find missing chapters. The page closes this sheet first: a Modal (z-50) opened under a Sheet (z-60) is unreachable. */ onFindMissing: () => void; + /** + * Put chapter `n`'s row on screen before the jump scrolls to it. The chapter list shows 100 rows a page + * (lib/chapterPages.ts), so the row a chip names may be on a page that is not rendered at all, and + * `getElementById` finds nothing. + */ + onShowChapter?: (n: number) => void; }) { const toast = useToast(); const qc = useQueryClient(); @@ -377,6 +383,7 @@ export function SourcesSheet({ id, series, groups, admin, error, isLoading, have // anyone can see. The frame after the close, the sheet is gone and the row is there. const jump = (n: number) => { onClose(); + onShowChapter?.(n); requestAnimationFrame(() => document.getElementById(`ch-${n}`)?.scrollIntoView({ block: 'center', behavior: 'smooth' })); }; diff --git a/web/lib/chapterPages.ts b/web/lib/chapterPages.ts new file mode 100644 index 000000000..17f086b98 --- /dev/null +++ b/web/lib/chapterPages.ts @@ -0,0 +1,27 @@ +// The series page's chapter list, a page at a time. Long series (One Piece: 1,193 chapters) rendered every +// row at once; now the list shows CHAPTER_PAGE rows and a pager, and opens on the page holding the chapter +// "Continue" would open -- on chapter 956 nobody wants to start at chapter 1. +// +// Paged over the merged rows (chapters, ghosts, run rows), not the books, so ghosts sit on the page between +// the chapters they fall between, exactly as in the unpaged list. + +export const CHAPTER_PAGE = 100; + +export function pageCount(total: number, size = CHAPTER_PAGE): number { + return Math.max(1, Math.ceil(total / size)); +} + +/** The page holding the first row `isTarget` accepts; page 0 when none does. */ +export function pageOf(rows: readonly T[], isTarget: (r: T) => boolean, size = CHAPTER_PAGE): number { + const i = rows.findIndex(isTarget); + return i < 0 ? 0 : Math.floor(i / size); +} + +export function clampPage(page: number, total: number, size = CHAPTER_PAGE): number { + return Math.min(Math.max(0, page), pageCount(total, size) - 1); +} + +export function pageSlice(rows: readonly T[], page: number, size = CHAPTER_PAGE): T[] { + const p = clampPage(page, rows.length, size); + return rows.slice(p * size, p * size + size); +} diff --git a/web/lib/seriesBooks.ts b/web/lib/seriesBooks.ts new file mode 100644 index 000000000..d35d88518 --- /dev/null +++ b/web/lib/seriesBooks.ts @@ -0,0 +1,26 @@ +// Every chapter of a series, however many there are. The books route pages, and the series page and the +// reader used to ask for one page of 1000 -- so One Piece stopped at chapter 1000 in the list, and the +// reader's prev/next/jump list ended there too, although chapters 1001+ were on disk. +// Reintroduce by going back to one `?size=1000` request: "walks past the first 1000" in seriesBooks.test.ts +// gets 1000 chapters of 1193. +import { api } from './api'; +import type { Book, Page } from './types'; + +export const BATCH = 1000; +/** Backstop against a route that never reports a short page: 50 × 1000 is more chapters than exist. */ +const MAX_BATCHES = 50; + +/** `get` is the fetcher, `api` in the app; a test hands it a fake so no network is involved. */ +export async function fetchAllBooks(seriesId: string, get: (url: string) => Promise> = api): Promise> { + const url = (p: number) => `/api/series/${seriesId}/books?page=${p}&size=${BATCH}&sort=metadata.numberSort,asc`; + const first = await get(url(0)); + const content = [...(first.content ?? [])]; + const total = first.totalElements ?? content.length; + for (let p = 1; content.length < total && p < MAX_BATCHES; p++) { + const next = await get(url(p)); + // An empty page ends it even short of `total`: the route may hide rows from this viewer after counting. + if (!next.content?.length) break; + content.push(...next.content); + } + return { ...first, content, size: content.length, totalPages: 1, first: true, last: true }; +} diff --git a/web/public/locales/ar.json b/web/public/locales/ar.json index f05546ae6..64348abff 100644 --- a/web/public/locales/ar.json +++ b/web/public/locales/ar.json @@ -2,7 +2,7 @@ "_meta": { "language": "ar", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1153 }, "1 check found something": "فحص واحد وجد شيئًا", "1 member": "عضو واحد", @@ -1152,5 +1152,9 @@ "The engine said: {reason}": "قال المحرّك: {reason}", "The extension engine refused that address: {reason}": "رفض محرّك الإضافات هذا العنوان: {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "يشغّل تثبيت Docker القياسي محرّكًا في الحاوية {name}. إذا عطّلته بإفراغ SUWAYOMI_URL، فأعِد ذلك السطر إلى ملف .env ثم شغّل {command}.", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "نصيحة: أخفِ أولًا اللغات التي لا تقرؤها — لا يمكن تفعيل أكثر من {n} مصدرًا في وقت واحد." + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "نصيحة: أخفِ أولًا اللغات التي لا تقرؤها — لا يمكن تفعيل أكثر من {n} مصدرًا في وقت واحد.", + "Chapter pages": "صفحات الفصول", + "First page": "الصفحة الأولى", + "Last page": "الصفحة الأخيرة", + "of {n}": "من {n}" } diff --git a/web/public/locales/de.json b/web/public/locales/de.json index 465e0bc63..52d143cf6 100644 --- a/web/public/locales/de.json +++ b/web/public/locales/de.json @@ -2,7 +2,7 @@ "_meta": { "language": "de", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1153 }, "1 check found something": "1 Prüfung hat etwas gefunden", "1 member": "1 Mitglied", @@ -1152,5 +1152,9 @@ "The engine said: {reason}": "Die Engine meldete: {reason}", "The extension engine refused that address: {reason}": "Die Erweiterungs-Engine hat diese Adresse abgelehnt: {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "Die Standard-Docker-Installation betreibt eine im Container {name}. Wenn du sie ausgeschaltet hast, indem du SUWAYOMI_URL geleert hast, stelle diese Zeile in .env wieder her und führe {command} aus.", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Tipp: Blende zuerst die Sprachen aus, die du nicht liest — es können nur {n} Quellen gleichzeitig aktiv sein." + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Tipp: Blende zuerst die Sprachen aus, die du nicht liest — es können nur {n} Quellen gleichzeitig aktiv sein.", + "Chapter pages": "Kapitelseiten", + "First page": "Erste Seite", + "Last page": "Letzte Seite", + "of {n}": "von {n}" } diff --git a/web/public/locales/es.json b/web/public/locales/es.json index 7e5c0e5aa..99aae8997 100644 --- a/web/public/locales/es.json +++ b/web/public/locales/es.json @@ -2,7 +2,7 @@ "_meta": { "language": "es", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1153 }, "1 check found something": "1 comprobación encontró algo", "1 member": "1 miembro", @@ -1152,5 +1152,9 @@ "The engine said: {reason}": "El motor dijo: {reason}", "The extension engine refused that address: {reason}": "El motor de extensiones rechazó esa dirección: {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "La instalación estándar de Docker ejecuta uno en el contenedor {name}. Si lo desactivaste dejando vacío SUWAYOMI_URL, vuelve a poner esa línea en .env y ejecuta {command}.", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Consejo: oculta primero los idiomas que no lees — solo se pueden activar {n} fuentes a la vez." + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Consejo: oculta primero los idiomas que no lees — solo se pueden activar {n} fuentes a la vez.", + "Chapter pages": "Páginas de capítulos", + "First page": "Primera página", + "Last page": "Última página", + "of {n}": "de {n}" } diff --git a/web/public/locales/fr.json b/web/public/locales/fr.json index 1ff5b5177..111d01871 100644 --- a/web/public/locales/fr.json +++ b/web/public/locales/fr.json @@ -2,7 +2,7 @@ "_meta": { "language": "fr", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1153 }, "1 check found something": "1 vérification a trouvé quelque chose", "1 member": "1 membre", @@ -1152,5 +1152,9 @@ "The engine said: {reason}": "Le moteur a répondu : {reason}", "The extension engine refused that address: {reason}": "Le moteur d’extensions a refusé cette adresse : {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "L’installation Docker standard en fait tourner un dans le conteneur {name}. Si vous l’avez désactivé en vidant SUWAYOMI_URL, remettez cette ligne dans .env et lancez {command}.", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Astuce : masquez d’abord les langues que vous ne lisez pas — seules {n} sources peuvent être activées à la fois." + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Astuce : masquez d’abord les langues que vous ne lisez pas — seules {n} sources peuvent être activées à la fois.", + "Chapter pages": "Pages de chapitres", + "First page": "Première page", + "Last page": "Dernière page", + "of {n}": "sur {n}" } diff --git a/web/public/locales/ja.json b/web/public/locales/ja.json index a39bbd916..d18ac2e91 100644 --- a/web/public/locales/ja.json +++ b/web/public/locales/ja.json @@ -2,7 +2,7 @@ "_meta": { "language": "ja", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1153 }, "1 check found something": "1 件のチェックで問題を検出", "1 member": "メンバー 1 人", @@ -1152,5 +1152,9 @@ "The engine said: {reason}": "エンジンの応答: {reason}", "The extension engine refused that address: {reason}": "拡張機能エンジンがこのアドレスを拒否しました: {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "標準の Docker インストールでは {name} コンテナーでエンジンが動作します。SUWAYOMI_URL を空にして無効にした場合は、.env にその行を戻して {command} を実行してください。", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "ヒント: まず読まない言語を隠しておきましょう — 同時に有効にできるソースは {n} 件までです。" + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "ヒント: まず読まない言語を隠しておきましょう — 同時に有効にできるソースは {n} 件までです。", + "Chapter pages": "章のページ", + "First page": "最初のページ", + "Last page": "最後のページ", + "of {n}": "/ {n}" } diff --git a/web/public/locales/pt-BR.json b/web/public/locales/pt-BR.json index fb5206222..c5b8ae1cf 100644 --- a/web/public/locales/pt-BR.json +++ b/web/public/locales/pt-BR.json @@ -2,7 +2,7 @@ "_meta": { "language": "pt-BR", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1153 }, "1 check found something": "1 verificação encontrou algo", "1 member": "1 membro", @@ -1152,5 +1152,9 @@ "The engine said: {reason}": "O motor respondeu: {reason}", "The extension engine refused that address: {reason}": "O motor de extensões recusou esse endereço: {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "A instalação padrão do Docker roda um no contêiner {name}. Se você o desativou deixando SUWAYOMI_URL vazio, coloque essa linha de volta no .env e execute {command}.", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Dica: oculte primeiro os idiomas que você não lê — só {n} fontes podem ficar ativadas ao mesmo tempo." + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Dica: oculte primeiro os idiomas que você não lê — só {n} fontes podem ficar ativadas ao mesmo tempo.", + "Chapter pages": "Páginas de capítulos", + "First page": "Primeira página", + "Last page": "Última página", + "of {n}": "de {n}" } diff --git a/web/public/locales/ru.json b/web/public/locales/ru.json index 07c131555..81ddcc1ae 100644 --- a/web/public/locales/ru.json +++ b/web/public/locales/ru.json @@ -2,7 +2,7 @@ "_meta": { "language": "ru", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1153 }, "1 check found something": "1 проверка нашла проблему", "1 member": "1 участник", @@ -1152,5 +1152,9 @@ "The engine said: {reason}": "Ответ движка: {reason}", "The extension engine refused that address: {reason}": "Движок расширений отклонил этот адрес: {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "Стандартная установка Docker запускает его в контейнере {name}. Если вы отключили его, очистив SUWAYOMI_URL, верните эту строку в .env и выполните {command}.", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Совет: сначала скройте языки, которые вы не читаете, — одновременно можно включить не больше {n} источников." + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Совет: сначала скройте языки, которые вы не читаете, — одновременно можно включить не больше {n} источников.", + "Chapter pages": "Страницы глав", + "First page": "Первая страница", + "Last page": "Последняя страница", + "of {n}": "из {n}" } diff --git a/web/public/locales/zh.json b/web/public/locales/zh.json index e65db2136..19df9e0a7 100644 --- a/web/public/locales/zh.json +++ b/web/public/locales/zh.json @@ -2,7 +2,7 @@ "_meta": { "language": "zh", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1153 }, "1 check found something": "1 项检查发现问题", "1 member": "1 位成员", @@ -1152,5 +1152,9 @@ "The engine said: {reason}": "引擎返回:{reason}", "The extension engine refused that address: {reason}": "扩展引擎拒绝了该地址:{reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "标准 Docker 安装会在 {name} 容器中运行一个扩展引擎。如果你是通过清空 SUWAYOMI_URL 关闭的,请把那一行加回 .env,然后运行 {command}。", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "提示:先隐藏你不读的语言 — 同时最多只能启用 {n} 个来源。" + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "提示:先隐藏你不读的语言 — 同时最多只能启用 {n} 个来源。", + "Chapter pages": "章节分页", + "First page": "第一页", + "Last page": "最后一页", + "of {n}": "共 {n}" } diff --git a/web/test/chapterPages.test.ts b/web/test/chapterPages.test.ts new file mode 100644 index 000000000..0fee6f887 --- /dev/null +++ b/web/test/chapterPages.test.ts @@ -0,0 +1,26 @@ +// The chapter list pages over its merged rows and opens on the page holding "Continue". +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { pageCount, pageOf, pageSlice, clampPage } from '../lib/chapterPages'; + +const rows = Array.from({ length: 1193 }, (_, i) => i + 1); // One Piece, chapters 1..1193 + +test('page count rounds up and is never zero', () => { + assert.equal(pageCount(1193, 100), 12); + assert.equal(pageCount(100, 100), 1); + assert.equal(pageCount(0, 100), 1); +}); + +test('opens on the page holding the target', () => { + assert.equal(pageOf(rows, (n) => n === 956, 100), 9); + assert.equal(pageOf(rows, (n) => n === 1, 100), 0); + assert.equal(pageOf(rows, (n) => n === 1193, 100), 11); + assert.equal(pageOf(rows, () => false, 100), 0); +}); + +test('slices past chapter 1000 and clamps a stale page', () => { + assert.deepEqual(pageSlice(rows, 11, 100), rows.slice(1100)); + assert.deepEqual(pageSlice(rows, 99, 100), rows.slice(1100)); // filter shrank the list under the pager + assert.equal(clampPage(-1, 1193, 100), 0); + assert.equal(pageSlice([], 0, 100).length, 0); +}); diff --git a/web/test/seriesBooks.test.ts b/web/test/seriesBooks.test.ts new file mode 100644 index 000000000..ec5cf692a --- /dev/null +++ b/web/test/seriesBooks.test.ts @@ -0,0 +1,42 @@ +// The series page and the reader load EVERY chapter, not the route's first page of 1000. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { fetchAllBooks, BATCH } from '../lib/seriesBooks'; +import type { Book, Page } from '../lib/types'; + +/** A books route over `n` chapters that pages like the real one, and `hidden` rows it counts but never sends. */ +function route(n: number, hidden = 0) { + const all = Array.from({ length: n - hidden }, (_, i) => ({ id: `b${i + 1}`, number: i + 1 }) as unknown as Book); + const urls: string[] = []; + const get = async (url: string): Promise> => { + urls.push(url); + const q = new URL(url, 'http://x').searchParams; + const page = Number(q.get('page')), size = Number(q.get('size')); + const content = all.slice(page * size, page * size + size); + return { content, totalElements: n, totalPages: Math.ceil(n / size), number: page, size, first: page === 0, last: (page + 1) * size >= n } as Page; + }; + return { get, urls }; +} + +test('walks past the first 1000 (One Piece, 1193 chapters)', async () => { + const r = route(1193); + const res = await fetchAllBooks('s1', r.get); + assert.equal(res.content.length, 1193); + assert.equal(res.content.at(-1)!.number, 1193); + assert.equal(r.urls.length, 2); + assert.ok(r.urls.every((u) => u.includes(`size=${BATCH}`) && u.includes('sort=metadata.numberSort,asc'))); + assert.equal(res.last, true); +}); + +test('one request when a single page holds the series', async () => { + const r = route(40); + assert.equal((await fetchAllBooks('s1', r.get)).content.length, 40); + assert.equal(r.urls.length, 1); +}); + +test('an empty page ends the walk even when the count promised more', async () => { + const r = route(1500, 600); // counted 1500, sends 900 + const res = await fetchAllBooks('s1', r.get); + assert.equal(res.content.length, 900); + assert.equal(r.urls.length, 2); +}); From 790acea696de419fc48581e87bf320c8d1094f8c Mon Sep 17 00:00:00 2001 From: AngeloSha Date: Fri, 25 Sep 2026 10:18:43 +0200 Subject: [PATCH 3/3] Review fixes: pages named by chapter, a list that stays put, one row per book On top of @Squeaks72's paging, which fixes a real bug on main -- Continue opened chapter 1 for anyone past chapter 1000: - The picker named pages by row position. Ghost, run and "show all" rows are rows but not chapters, and newest first labelled the page holding 1193-1094 "1-100". Pages are now named by the first and last chapter numbers they show, in the list's own order, and the count beside it is chapters. - The page followed Continue live, so expanding an older-chapters run, "Show all" or "Mark all read" flipped the list away from what the reader was doing. It is decided once per series, sort and filter, once the chapters and the sources' listing (whose ghost rows would shift it) have both arrived. - A scan between the two requests could hand the same book back at the 1000 boundary: two rows, one React key. Kept once. Co-Authored-By: Claude Opus 5.5 --- web/app/series/page.tsx | 27 ++++++++++++++++----------- web/lib/chapterPages.ts | 26 ++++++++++++++++++++++++++ web/lib/seriesBooks.ts | 5 ++++- web/test/chapterPages.test.ts | 33 ++++++++++++++++++++++++++++++++- web/test/seriesBooks.test.ts | 14 ++++++++++++++ 5 files changed, 92 insertions(+), 13 deletions(-) diff --git a/web/app/series/page.tsx b/web/app/series/page.tsx index 6ba9a368c..f28c1bdea 100644 --- a/web/app/series/page.tsx +++ b/web/app/series/page.tsx @@ -18,8 +18,8 @@ import { IcChevronLeft, IcHeart, IcStar, IcPlay, IcDownload, IcCloudDownload, Ic import { t as tr } from '@/lib/i18n'; import { FindMissingDialog } from '@/components/FindMissingDialog'; import { normGroup } from '@/lib/scanlators'; -import { GHOST_CAP, mergeRows, whyLabel, runLabel, chunkNumbers, MARK_CHUNK } from '@/lib/chapterRows'; -import { CHAPTER_PAGE, clampPage, pageCount, pageOf, pageSlice } from '@/lib/chapterPages'; +import { GHOST_CAP, mergeRows, whyLabel, runLabel, chunkNumbers, MARK_CHUNK, type Row } from '@/lib/chapterRows'; +import { CHAPTER_PAGE, clampPage, pageCount, pageLabel, pageOf, pageSlice } from '@/lib/chapterPages'; import { fetchAllBooks } from '@/lib/seriesBooks'; import { ALL_GROUPS, copySourceId, groupsOfRow, matchesGroup } from '@/lib/groupFilter'; import { SourcesSheet, useSeriesGroups, useCheckNow } from '@/components/SourcesSheet'; @@ -759,11 +759,12 @@ function readShowGhosts(): boolean { } /** - * Prev / a range picker / Next for the chapter list. The picker names each page by the rows it holds - * ("901–1000"), which is what a reader hunting for a chapter number actually scans for. + * Prev / a range picker / Next for the chapter list. The picker names each page by the first and last chapter + * numbers it shows ("901–1000", or "1193–1094" newest first), which is what a reader hunting for a chapter + * number actually scans for; `total` is chapters, not rows. */ -function ChapterPager({ page, pages, total, onPage }: { page: number; pages: number; total: number; onPage: (p: number) => void }) { - const range = (p: number) => `${p * CHAPTER_PAGE + 1}–${Math.min(total, (p + 1) * CHAPTER_PAGE)}`; +function ChapterPager({ page, pages, rows, asc, total, onPage }: { page: number; pages: number; rows: readonly Row[]; asc: boolean; total: number; onPage: (p: number) => void }) { + const range = (p: number) => pageLabel(rows, p, asc); return (