forked from akai/readest
5e366018df
* 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>
139 lines
4.4 KiB
TypeScript
139 lines
4.4 KiB
TypeScript
import { describe, it, expect, beforeAll } from 'vitest';
|
|
import { readFileSync } from 'fs';
|
|
import { resolve, join } from 'path';
|
|
|
|
import { DocumentLoader } from '@/libs/document';
|
|
import type { BookDoc } from '@/libs/document';
|
|
import type { Collection } from '@/utils/book';
|
|
|
|
const vendorDir = join(process.cwd(), 'public/vendor');
|
|
|
|
const loadFixture = async (
|
|
filename: string,
|
|
mimeType: string,
|
|
expectedFormat: string,
|
|
): Promise<BookDoc> => {
|
|
const filePath = resolve(__dirname, '../fixtures/data', filename);
|
|
const buffer = readFileSync(filePath);
|
|
const file = new File([buffer], filename, { type: mimeType });
|
|
const loader = new DocumentLoader(file);
|
|
const result = await loader.open();
|
|
expect(result.format).toBe(expectedFormat);
|
|
return result.book;
|
|
};
|
|
|
|
const getSeries = (book: BookDoc): Collection | undefined => {
|
|
const belongsTo = book.metadata.belongsTo?.series;
|
|
if (!belongsTo) return undefined;
|
|
return Array.isArray(belongsTo) ? belongsTo[0] : belongsTo;
|
|
};
|
|
|
|
const makeCbzFixture = async ({
|
|
comicInfo,
|
|
comicInfoPath = 'ComicInfo.xml',
|
|
imageCount,
|
|
}: {
|
|
comicInfo?: string;
|
|
comicInfoPath?: string;
|
|
imageCount: number;
|
|
}): Promise<File> => {
|
|
const { BlobWriter, TextReader, ZipWriter } = await import('@zip.js/zip.js');
|
|
const writer = new ZipWriter(new BlobWriter('application/vnd.comicbook+zip'));
|
|
for (let i = 0; i < imageCount; i++) {
|
|
await writer.add(`${i}.png`, new TextReader(`image-${i}`));
|
|
}
|
|
if (comicInfo) {
|
|
await writer.add(comicInfoPath, new TextReader(comicInfo));
|
|
}
|
|
const blob = await writer.close();
|
|
return new File([blob], 'page-count.cbz', { type: 'application/vnd.comicbook+zip' });
|
|
};
|
|
|
|
describe('Calibre series metadata', () => {
|
|
describe('PDF (XMP calibre:series)', () => {
|
|
let book: BookDoc;
|
|
|
|
beforeAll(async () => {
|
|
await import('foliate-js/pdf.js');
|
|
const pdfjsLib = (globalThis as Record<string, unknown>)['pdfjsLib'] as {
|
|
GlobalWorkerOptions: { workerSrc: string };
|
|
};
|
|
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
|
`file://${join(vendorDir, 'pdfjs/pdf.worker.min.mjs')}`,
|
|
).href;
|
|
|
|
book = await loadFixture('sample-metadata.pdf', 'application/pdf', 'PDF');
|
|
}, 30_000);
|
|
|
|
it('extracts series name and position from XMP', () => {
|
|
const series = getSeries(book);
|
|
expect(series).toBeTruthy();
|
|
expect(series!.name).toBe('Metadata Series');
|
|
expect(series!.position).toBe('1.00');
|
|
});
|
|
|
|
it('preserves the title', () => {
|
|
expect(book.metadata.title).toBe('PDF Metadata');
|
|
});
|
|
});
|
|
|
|
describe('CBZ (ComicInfo.xml + ComicBookInfo)', () => {
|
|
let book: BookDoc;
|
|
|
|
beforeAll(async () => {
|
|
book = await loadFixture('sample-metadata.cbz', 'application/vnd.comicbook+zip', 'CBZ');
|
|
});
|
|
|
|
it('extracts series name and position', () => {
|
|
const series = getSeries(book);
|
|
expect(series).toBeTruthy();
|
|
expect(series!.name).toBe('Metadata Series');
|
|
expect(series!.position).toBe('2.0');
|
|
});
|
|
|
|
it('preserves the title', () => {
|
|
expect(book.metadata.title).toBe('CBZ Metadata');
|
|
});
|
|
|
|
it('extracts displayable ComicInfo.xml schema fields from nested archives', async () => {
|
|
const file = await makeCbzFixture({
|
|
imageCount: 3,
|
|
comicInfoPath: 'metadata/ComicInfo.xml',
|
|
comicInfo: `<?xml version="1.0" encoding="utf-8"?>
|
|
<ComicInfo xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
|
<Title>Example Title</Title>
|
|
<Series>Example Series</Series>
|
|
<Volume>1</Volume>
|
|
<Number>1</Number>
|
|
<Count>5</Count>
|
|
<Summary>Example Summary</Summary>
|
|
<Writer>Example Author</Writer>
|
|
<Genre>Example Genre</Genre>
|
|
<Year>2025</Year>
|
|
<Month>12</Month>
|
|
<Web>https://www.google.com/</Web>
|
|
<Manga>Yes</Manga>
|
|
<LanguageISO>en</LanguageISO>
|
|
<Translator>Example Translator</Translator>
|
|
<PageCount>20</PageCount>
|
|
</ComicInfo>`,
|
|
});
|
|
const loader = new DocumentLoader(file);
|
|
const result = await loader.open();
|
|
|
|
expect(result.book.metadata).toMatchObject({
|
|
title: 'Example Title',
|
|
author: 'Example Author',
|
|
language: 'en',
|
|
description: 'Example Summary',
|
|
publisher: undefined,
|
|
published: '2025-12',
|
|
identifier: 'https://www.google.com/',
|
|
subject: ['Example Genre'],
|
|
});
|
|
const series = getSeries(result.book);
|
|
expect(series).toMatchObject({ name: 'Example Series', position: '1', total: '5' });
|
|
});
|
|
});
|
|
});
|