fix(cbz): ComicInfo metadata + CBZ page count + WebDAV i18n (#4282)

* fix(cbz,i18n): ComicInfo metadata + CBZ page count + WebDAV i18n

Closes #4253 (ComicInfo.xml not read) and #4255 (CBZ shows "1 page left").

CBZ / ComicInfo (foliate-js submodule + Readest derivation):
- comic-book.js: find ComicInfo.xml in subdirectories too, parse
  description / subject / identifier / published / series fields
  beyond the prior name+position pair. Series Count populates the
  canonical `belongsTo.series.total`; no top-level duplication.
- bookService.ts / readerStore.ts: derive `metadata.seriesTotal`
  from `belongsTo.series.total` in parallel to the existing
  series / seriesIndex derivation.
- ProgressBar / FooterBar / DesktopFooterBar: drop the hard-coded
  `pagesLeft = 1` for fixed-layout books and compute it from
  `section.total - section.current`. FooterBar uses
  `FIXED_LAYOUT_FORMATS.has(bookFormat)` so CBZ picks `section`
  (correct image count) instead of `pageinfo` (locations).
- ProgressBar: switch the remaining-pages text to "in book" for
  fixed-layout titles (no chapter structure) and keep
  "in chapter" for reflowable books.

WebDAV refactor for translation coverage:
- WebDAVBrowsePane / SyncHistoryPanel called `t(...)` (passed as a
  prop) instead of `_(...)`. The i18next-scanner only looks for
  `_`, so ~53 strings were unreachable and shipped in English to
  every locale. Switched both components to call
  `useTranslation()` themselves; helpers that aren't React FCs
  take `_: TranslationFunc` so the scanner sees the literal calls.
- WebDAVClient.checkConnection now returns a `code` discriminator
  (`SERVER_URL_REQUIRED` / `AUTH_FAILED` / `ROOT_NOT_FOUND` /
  `UNEXPECTED_STATUS` / `NETWORK`); raw English `message` is
  reserved for the dev console. New `formatConnectError` and
  `formatSyncError` helpers in WebDAVForm translate via a switch
  where each branch is a literal `_('...')`. Same treatment for
  the sync-failure path that previously surfaced raw e.message.
- "Syncing 0 / {{total}}" is now parameterized as
  "Syncing {{n}} / {{total}}" with n=0 at startup so the digit
  formats naturally and the template can be reused mid-sync.
- "Cleanup · {{count}} book(s)" hard-coded options used unsupported
  ternary; rewrote as plural-aware key.

i18n scanner fix (i18next-scanner.config.cjs):
- vinyl-fs walked into directories whose names end in source-file
  extensions (Next.js route folder `runtime-config.js/`, Playwright
  screenshot folder `*.test.tsx/`) and crashed with EISDIR.
  Resolved by expanding globs via `fs.globSync` and filtering to
  files only before handing to the scanner.

TypeScript-syntax sites that broke esprima during extraction:
- WebDAVBrowsePane / WebDAVForm: `(e as Error).message` and
  `failed[0]!.title` inside `_(..., options)` arguments. Replaced
  with `e instanceof Error ? e.message : String(e)` and
  `failed[0]?.title ?? ''` — also runtime-safer.

User-facing em-dash cleanup:
- Removed em-dashes from translation keys across SyncHistoryPanel /
  WebDAVForm / WebDAVBrowsePane / SyncPassphraseSection / send/page /
  replicaCryptoMiddleware / AIPanel. Tagline in `layout.tsx` kept.

Locale translations:
- ~2400 translations applied across all 33 locales for the keys
  that were either newly extractable, freshly worded, or
  pre-existing but untranslated. Zero `__STRING_NOT_TRANSLATED__`
  remain after the run.

Misc:
- next.config.mjs: drop `eslint.ignoreDuringBuilds: true` so build
  runs the same lint as CI.
- Collection type: add `total?: string` for ComicInfo series count.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(test): fix vitest invocation, run with 4 workers

`pnpm test:pr:web` was chaining `pnpm test -- --watch=false`, which
pnpm expanded into:

    dotenv -e .env -e .env.test.local -- vitest -- --watch=false

The second `--` made vitest treat `--watch=false` as a positional
file pattern, not a flag. Vitest then fell back to defaults (in CI's
non-TTY env that still meant a one-shot run, so the suite passed),
but the worker pool was effectively serialized for big chunks of the
243-file run — wall ~90 s on a 4-vCPU runner where the parallel-sum
of phases was ~236 s (≈2.6× effective parallelism).

Replace the chained pnpm invocation with a direct call to
`vitest run --maxWorkers=4`, matching the 4 vCPUs the GH Actions
ubuntu-latest runner provides.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-23 21:59:29 +08:00
committed by GitHub
parent b78daed562
commit 5e366018df
55 changed files with 3551 additions and 339 deletions
@@ -1,7 +1,7 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { Trans } from 'react-i18next';
import { Insets } from '@/types/misc';
import type { Insets } from '@/types/misc';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
@@ -55,26 +55,47 @@ const ProgressBar: React.FC<ProgressBarProps> = ({
const { page: current = 0, pages: total = 0 } = view?.renderer || {};
const pagesLeft = bookData?.isFixedLayout
? 1
? pageInfo
? Math.max(pageInfo.total - pageInfo.current, 1)
: 0
: Math.min(Math.max(total - current, 1), pageInfo ? pageInfo.total - pageInfo.current : total);
const showPagesLeft = total > 0 || bookData?.isFixedLayout;
const showPagesLeft = pagesLeft > 0 && (total > 0 || !!bookData?.isFixedLayout);
// Fixed-layout formats (CBZ, PDF) have no chapter structure — every page is
// its own section — so the remaining count is the whole book, not a chapter.
const remainingInBook = !!bookData?.isFixedLayout;
const timeLeftStr = showPagesLeft
? _('{{time}} min left in chapter', {
time: formatNumber(
Math.round((pagesLeft * SIZE_PER_LOC) / SIZE_PER_TIME_UNIT),
localize,
lang,
),
})
? remainingInBook
? _('{{time}} min left in book', {
time: formatNumber(
Math.round((pagesLeft * SIZE_PER_LOC) / SIZE_PER_TIME_UNIT),
localize,
lang,
),
})
: _('{{time}} min left in chapter', {
time: formatNumber(
Math.round((pagesLeft * SIZE_PER_LOC) / SIZE_PER_TIME_UNIT),
localize,
lang,
),
})
: '';
const pagesLeftStr = showPagesLeft
? localize
? _('{{number}} pages left in chapter', {
number: formatNumber(pagesLeft, localize, lang),
})
: _('{{count}} pages left in chapter', {
count: pagesLeft,
})
? remainingInBook
? _('{{number}} pages left in book', {
number: formatNumber(pagesLeft, localize, lang),
})
: _('{{number}} pages left in chapter', {
number: formatNumber(pagesLeft, localize, lang),
})
: remainingInBook
? _('{{count}} pages left in book', {
count: pagesLeft,
})
: _('{{count}} pages left in chapter', {
count: pagesLeft,
})
: '';
const [progressBarMode, setProgressBarMode] = useState<string>(viewSettings.progressInfoMode);
@@ -225,12 +246,27 @@ const ProgressBar: React.FC<ProgressBarProps> = ({
) : viewSettings.showRemainingPages && showPagesLeft ? (
<span className='text-start'>
{localize ? (
<Trans
i18nKey='{{number}} pages left in chapter'
values={{ number: formatNumber(pagesLeft, localize, lang) }}
>
<span className='pages-left-number'>{'{{number}}'}</span>
<span className='pages-left-label'>{' pages left in chapter'}</span>
remainingInBook ? (
<Trans
i18nKey='{{number}} pages left in book'
values={{ number: formatNumber(pagesLeft, localize, lang) }}
>
<span className='pages-left-number'>{'{{number}}'}</span>
<span className='pages-left-label'>{' pages left in book'}</span>
</Trans>
) : (
<Trans
i18nKey='{{number}} pages left in chapter'
values={{ number: formatNumber(pagesLeft, localize, lang) }}
>
<span className='pages-left-number'>{'{{number}}'}</span>
<span className='pages-left-label'>{' pages left in chapter'}</span>
</Trans>
)
) : remainingInBook ? (
<Trans i18nKey='{{count}} pages left in book' count={pagesLeft}>
<span className='pages-left-number'>{'{{count}}'}</span>
<span className='pages-left-label'>{' pages left in book'}</span>
</Trans>
) : (
<Trans i18nKey='{{count}} pages left in chapter' count={pagesLeft}>
@@ -8,7 +8,7 @@ import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useBookDataStore } from '@/store/bookDataStore';
import { formatProgress } from '@/utils/progress';
import { FooterBarChildProps } from './types';
import type { FooterBarChildProps } from './types';
import { getNavigationIcon } from './utils';
import Button from '@/components/Button';
@@ -5,10 +5,11 @@ import { useSpatialNavigation } from '@/app/reader/hooks/useSpatialNavigation';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { FIXED_LAYOUT_FORMATS } from '@/types/book';
import { useTranslation } from '@/hooks/useTranslation';
import { useDeviceControlStore } from '@/store/deviceStore';
import { eventDispatcher } from '@/utils/event';
import { FooterBarProps, NavigationHandlers, FooterBarChildProps } from './types';
import type { FooterBarProps, NavigationHandlers, FooterBarChildProps } from './types';
import { debounce } from '@/utils/debounce';
import { RSVPControl } from '../rsvp';
import MobileFooterBar from './MobileFooterBar';
@@ -46,7 +47,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
const pointerInDoc = docs.some(({ doc }) => doc?.body?.style.cursor === 'pointer');
const progressInfo = useMemo(
() => (bookFormat === 'PDF' ? section : pageinfo),
() => (FIXED_LAYOUT_FORMATS.has(bookFormat) ? section : pageinfo),
[bookFormat, section, pageinfo],
);
@@ -1,9 +1,9 @@
import { PageInfo } from '@/types/book';
import type { BookFormat, PageInfo } from '@/types/book';
import { Insets } from '@/types/misc';
export interface FooterBarProps {
bookKey: string;
bookFormat: string;
bookFormat: BookFormat;
section?: PageInfo;
pageinfo?: PageInfo;
isHoveredAnim: boolean;