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:
@@ -15,8 +15,10 @@ import {
|
||||
buildRequestUrl,
|
||||
checkConnection,
|
||||
normalizeRootPath,
|
||||
WebDAVConnectResult,
|
||||
WebDAVRequestError,
|
||||
} from '@/services/webdav/WebDAVClient';
|
||||
import { type TranslationFunc } from '@/hooks/useTranslation';
|
||||
import { syncLibrary } from '@/services/webdav/WebDAVSync';
|
||||
import { buildWebDAVConnectSettings } from '@/services/webdav/webdavConnectSettings';
|
||||
import { getCoverFilename, getLocalBookFilename } from '@/utils/book';
|
||||
@@ -41,6 +43,54 @@ interface WebDAVFormProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a connection-probe failure into a user-facing string.
|
||||
*
|
||||
* Each branch must be a literal `_('...')` call so the i18next-scanner
|
||||
* picks the keys up — that's why this is a switch on `result.code`
|
||||
* rather than the previous `_(result.message || 'Connection error')`
|
||||
* pattern, which the scanner couldn't see into.
|
||||
*/
|
||||
const formatConnectError = (_: TranslationFunc, result: WebDAVConnectResult): string => {
|
||||
switch (result.code) {
|
||||
case 'SERVER_URL_REQUIRED':
|
||||
return _('Server URL is required');
|
||||
case 'AUTH_FAILED':
|
||||
return _('Authentication failed');
|
||||
case 'ROOT_NOT_FOUND':
|
||||
return _('Root directory not found');
|
||||
case 'UNEXPECTED_STATUS':
|
||||
return _('Unexpected server response (status {{status}})', {
|
||||
status: result.status ?? 0,
|
||||
});
|
||||
case 'NETWORK':
|
||||
default:
|
||||
return _('Network error');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Translate a sync-time error into a user-facing string. WebDAVRequestError
|
||||
* carries a `code` that lets us map to a specific message without ever
|
||||
* showing the raw English `e.message` to the user.
|
||||
*/
|
||||
const formatSyncError = (_: TranslationFunc, e: unknown): string => {
|
||||
if (e instanceof WebDAVRequestError) {
|
||||
switch (e.code) {
|
||||
case 'AUTH_FAILED':
|
||||
return _('WebDAV authentication failed. Reconnect in Settings.');
|
||||
case 'NOT_FOUND':
|
||||
return _('Remote resource not found');
|
||||
case 'NETWORK':
|
||||
return _('Network error');
|
||||
}
|
||||
if (typeof e.status === 'number') {
|
||||
return _('Sync failed (status {{status}})', { status: e.status });
|
||||
}
|
||||
}
|
||||
return _('Sync failed.');
|
||||
};
|
||||
|
||||
/**
|
||||
* WebDAV integration form. Two modes share the same panel:
|
||||
*
|
||||
@@ -98,7 +148,7 @@ const WebDAVForm: React.FC<WebDAVFormProps> = ({ onBack }) => {
|
||||
if (!result.success) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: `${_('Failed to connect')}: ${_(result.message || 'Connection error')}`,
|
||||
message: `${_('Failed to connect')}: ${formatConnectError(_, result)}`,
|
||||
});
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
@@ -234,7 +284,7 @@ const WebDAVForm: React.FC<WebDAVFormProps> = ({ onBack }) => {
|
||||
await persistWebdav({ deviceId });
|
||||
}
|
||||
|
||||
beginSync(_('Syncing 0 / {{total}}', { total: eligibleBooks.length }));
|
||||
beginSync(_('Syncing {{n}} / {{total}}', { n: 0, total: eligibleBooks.length }));
|
||||
|
||||
// Captured before the run begins so we can attribute startedAt
|
||||
// accurately even when the run fails in the catch block (the
|
||||
@@ -391,7 +441,7 @@ const WebDAVForm: React.FC<WebDAVFormProps> = ({ onBack }) => {
|
||||
onProgress: ({ book, index, total, action }) => {
|
||||
const actionStr = action === 'downloading' ? _('Downloading') : _('Uploading');
|
||||
updateProgress(
|
||||
_('{{action}} {{n}} / {{total}} — {{title}}', {
|
||||
_('{{action}} {{n}} / {{total}} · {{title}}', {
|
||||
action: actionStr,
|
||||
n: index + 1,
|
||||
total,
|
||||
@@ -411,7 +461,7 @@ const WebDAVForm: React.FC<WebDAVFormProps> = ({ onBack }) => {
|
||||
parts.push(_('downloaded {{n}} book(s)', { n: result.booksDownloaded }));
|
||||
}
|
||||
if (result.configsDownloaded > 0) {
|
||||
parts.push(_('pulled {{n}} progress entr(ies)', { n: result.configsDownloaded }));
|
||||
parts.push(_('pulled progress for {{n}} book(s)', { n: result.configsDownloaded }));
|
||||
}
|
||||
if (result.configsUploaded > 0) {
|
||||
parts.push(_('pushed {{n}} config(s)', { n: result.configsUploaded }));
|
||||
@@ -484,10 +534,7 @@ const WebDAVForm: React.FC<WebDAVFormProps> = ({ onBack }) => {
|
||||
};
|
||||
await appendSyncLogEntry(entry);
|
||||
} catch (e) {
|
||||
const message =
|
||||
e instanceof WebDAVRequestError && e.code === 'AUTH_FAILED'
|
||||
? _('WebDAV authentication failed. Reconnect in Settings.')
|
||||
: _('Sync failed: {{error}}', { error: (e as Error).message ?? String(e) });
|
||||
const message = formatSyncError(_, e);
|
||||
eventDispatcher.dispatch('toast', { type: 'error', message });
|
||||
// Persist a "failure" entry so the user can show what went wrong
|
||||
// without rummaging through the dev console. We don't have a
|
||||
@@ -542,10 +589,7 @@ const WebDAVForm: React.FC<WebDAVFormProps> = ({ onBack }) => {
|
||||
<SettingsSwitchRow
|
||||
label={_('Upload Book Files')}
|
||||
description={_(
|
||||
'This toggle only controls ' +
|
||||
'whether this device contributes the books. ' +
|
||||
'Reading progress and annotations are always synced both ways, and books ' +
|
||||
'already on the server are always downloaded.',
|
||||
'Only affects uploading book files. Reading progress and downloads always sync.',
|
||||
)}
|
||||
checked={stored.syncBooks ?? false}
|
||||
onChange={handleToggleSyncBooks}
|
||||
@@ -600,9 +644,9 @@ const WebDAVForm: React.FC<WebDAVFormProps> = ({ onBack }) => {
|
||||
runs with full counters and per-book failures. We render
|
||||
even when the log is empty so users can find where it
|
||||
lives before their first run. */}
|
||||
<SyncHistoryPanel entries={stored.syncLog ?? []} onClear={handleClearSyncLog} t={_} />
|
||||
<SyncHistoryPanel entries={stored.syncLog ?? []} onClear={handleClearSyncLog} />
|
||||
|
||||
<WebDAVBrowsePane settings={stored} t={_} onAppendSyncLogEntry={appendSyncLogEntry} />
|
||||
<WebDAVBrowsePane settings={stored} onAppendSyncLogEntry={appendSyncLogEntry} />
|
||||
|
||||
<div className='flex justify-end'>
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user