Files
readest/apps/readest-app/src/utils/book.ts
T
Huang Xin 5e366018df 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>
2026-05-23 15:59:29 +02:00

345 lines
10 KiB
TypeScript

import { BookMetadata, EXTS } from '@/libs/document';
import {
Book,
BOOK_CONFIG_SCHEMA_VERSION,
BookConfig,
BookProgress,
WritingMode,
} from '@/types/book';
import { SUPPORTED_LANGS } from '@/services/constants';
import { getLocale, getUserLang, makeSafeFilename } from './misc';
import { getStorageType } from './storage';
import { getDirFromLanguage } from './rtl';
import { code6392to6391, isValidLang, normalizedLangCode } from './lang';
import { md5 } from './md5';
export const getDir = (book: Book) => {
return `${book.hash}`;
};
export const getLibraryFilename = () => {
return 'library.json';
};
export const getLibraryBackupFilename = () => {
return 'library_backup.json';
};
export const getRemoteBookFilename = (book: Book) => {
// S3 storage: https://docs.aws.amazon.com/zh_cn/AmazonS3/latest/userguide/object-keys.html
if (getStorageType() === 'r2') {
return `${book.hash}/${makeSafeFilename(book.sourceTitle || book.title)}.${EXTS[book.format]}`;
} else if (getStorageType() === 's3') {
return `${book.hash}/${book.hash}.${EXTS[book.format]}`;
} else {
return '';
}
};
export const getLocalBookFilename = (book: Book) => {
return `${book.hash}/${makeSafeFilename(book.sourceTitle || book.title)}.${EXTS[book.format]}`;
};
export const getCoverFilename = (book: Book) => {
return `${book.hash}/cover.png`;
};
export const getConfigFilename = (book: Book) => {
return `${book.hash}/config.json`;
};
export const getBookNavFilename = (book: Book) => {
return `${book.hash}/nav.json`;
};
export const isBookFile = (filename: string) => {
return Object.values(EXTS).includes(filename.split('.').pop()!);
};
export const INIT_BOOK_CONFIG: BookConfig = {
schemaVersion: BOOK_CONFIG_SCHEMA_VERSION,
updatedAt: 0,
};
export interface LanguageMap {
[key: string]: string;
}
export interface Identifier {
scheme: string;
value: string;
}
export interface Contributor {
name: LanguageMap;
}
export interface Collection {
name: string;
position?: string;
total?: string;
}
const formatLanguageMap = (x: string | LanguageMap, defaultLang = false): string => {
const userLang = getUserLang();
if (!x) return '';
if (typeof x === 'string') return x;
const keys = Object.keys(x);
return defaultLang ? x[keys[0]!]! : x[userLang] || x[keys[0]!]!;
};
export const listFormater = (narrow = false, lang = '') => {
lang = lang ? lang : getUserLang();
if (narrow) {
return new Intl.ListFormat('en', { style: 'narrow', type: 'unit' });
} else {
return new Intl.ListFormat(lang, { style: 'long', type: 'conjunction' });
}
};
export const getBookLangCode = (lang: string | string[] | undefined) => {
try {
const bookLang = typeof lang === 'string' ? lang : lang?.[0];
return bookLang ? bookLang.split('-')[0]! : '';
} catch {
return '';
}
};
export const flattenContributors = (
contributors: string | string[] | Contributor | Contributor[],
) => {
if (!contributors) return '';
return Array.isArray(contributors)
? contributors
.map((contributor) =>
typeof contributor === 'string' ? contributor : formatLanguageMap(contributor?.name),
)
.join(', ')
: typeof contributors === 'string'
? contributors
: formatLanguageMap(contributors?.name);
};
// biome-ignore format: keep the language codes compact on a single line
const LASTNAME_AUTHOR_SORT_LANGS = [ 'ar', 'bo', 'de', 'en', 'es', 'fr', 'hi', 'it', 'nl', 'pl', 'pt', 'ru', 'th', 'tr', 'uk' ];
const formatAuthorName = (name: string, lastNameFirst: boolean) => {
if (!name) return '';
const parts = name.split(' ');
if (lastNameFirst && parts.length > 1) {
return `${parts[parts.length - 1]}, ${parts.slice(0, -1).join(' ')}`;
}
return name;
};
export const formatAuthors = (
contributors: string | string[] | Contributor | Contributor[],
bookLang?: string | string[],
sortAs?: boolean,
) => {
const langCode = getBookLangCode(bookLang) || 'en';
const lastNameFirst = !!sortAs && LASTNAME_AUTHOR_SORT_LANGS.includes(langCode);
return Array.isArray(contributors)
? listFormater(langCode === 'zh', langCode).format(
contributors.map((contributor) =>
typeof contributor === 'string'
? formatAuthorName(contributor, lastNameFirst)
: formatAuthorName(formatLanguageMap(contributor?.name), lastNameFirst),
),
)
: typeof contributors === 'string'
? formatAuthorName(contributors, lastNameFirst)
: formatAuthorName(formatLanguageMap(contributors?.name), lastNameFirst);
};
export const formatTitle = (title: string | LanguageMap) => {
return typeof title === 'string' ? title : formatLanguageMap(title);
};
export const formatDescription = (description?: string | LanguageMap) => {
if (!description) return '';
const text = typeof description === 'string' ? description : formatLanguageMap(description);
return text
.replace(/<\/?[^>]+(>|$)/g, '')
.replace(/&#\d+;/g, '')
.trim();
};
export const formatPublisher = (publisher: string | LanguageMap) => {
return typeof publisher === 'string' ? publisher : formatLanguageMap(publisher);
};
const langCodeToLangName = (langCode: string) => {
return SUPPORTED_LANGS[langCode] || langCode.toUpperCase();
};
export const formatLanguage = (lang: string | string[] | undefined): string => {
return Array.isArray(lang)
? lang.map(langCodeToLangName).join(', ')
: langCodeToLangName(lang || '');
};
// Should return valid ISO-639-1 language code, fallback to 'en' if not valid
export const getPrimaryLanguage = (lang: string | string[] | undefined) => {
const primaryLang = Array.isArray(lang) ? lang[0] : lang;
if (isValidLang(primaryLang)) {
const normalizedLang = normalizedLangCode(primaryLang);
return code6392to6391(normalizedLang) || normalizedLang;
}
return 'en';
};
export const formatDate = (date: string | number | Date | null | undefined, isUTC = false) => {
if (!date) return;
const userLang = getUserLang();
try {
return new Date(date).toLocaleDateString(userLang, {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: isUTC ? 'UTC' : undefined,
});
} catch {
return;
}
};
export const formatLocaleDateTime = (date: number | Date) => {
const userLang = getLocale();
return new Date(date).toLocaleString(userLang);
};
export const formatBytes = (bytes?: number | null, locale = 'en-US') => {
if (!bytes) return '';
const units = ['byte', 'kilobyte', 'megabyte', 'gigabyte', 'terabyte'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const value = bytes / Math.pow(1024, i);
const formatter = new Intl.NumberFormat(locale, {
style: 'unit',
unit: units[i],
unitDisplay: 'short',
maximumFractionDigits: 2,
});
return formatter.format(value);
};
export const getCurrentPage = (book: Book, progress: BookProgress) => {
const bookFormat = book.format;
const { section, pageinfo } = progress;
return bookFormat === 'PDF'
? section
? section.current + 1
: 0
: pageinfo
? pageinfo.current + 1
: 0;
};
export const getBookDirFromWritingMode = (writingMode: WritingMode) => {
switch (writingMode) {
case 'horizontal-tb':
return 'ltr';
case 'horizontal-rl':
case 'vertical-rl':
return 'rtl';
default:
return 'auto';
}
};
export const getBookDirFromLanguage = (language: string | string[] | undefined) => {
const lang = getPrimaryLanguage(language) || '';
return getDirFromLanguage(lang);
};
const getTitleForHash = (title: string | LanguageMap) => {
return typeof title === 'string' ? title : formatLanguageMap(title, true);
};
const getAuthorsList = (contributors: string | string[] | Contributor | Contributor[]) => {
if (!contributors) return [];
return Array.isArray(contributors)
? contributors
.map((contributor) =>
typeof contributor === 'string'
? contributor
: formatLanguageMap(contributor?.name, true),
)
.filter(Boolean)
: [
typeof contributors === 'string'
? contributors
: formatLanguageMap(contributors?.name, true),
];
};
const normalizeIdentifier = (identifier: string) => {
try {
if (identifier.includes('urn:')) {
// Slice after the last ':'
return identifier.match(/[^:]+$/)?.[0] || '';
} else if (identifier.includes(':')) {
// Slice after the first ':'
return identifier.match(/^[^:]+:(.+)$/)?.[1] || '';
}
} catch {
return identifier;
}
return identifier;
};
const getPreferredIdentifier = (identifiers: string[] | Identifier[]) => {
for (const scheme of ['uuid', 'calibre', 'isbn']) {
const found = identifiers.find((identifier) =>
typeof identifier === 'string'
? identifier.toLowerCase().includes(scheme)
: identifier.scheme.toLowerCase() === scheme,
);
if (found) {
return typeof found === 'string' ? normalizeIdentifier(found) : found.value;
}
}
return;
};
const getIdentifiersList = (
identifiers: undefined | string | string[] | Identifier | Identifier[],
) => {
if (!identifiers) return [];
if (Array.isArray(identifiers)) {
const preferred = getPreferredIdentifier(identifiers);
if (preferred) {
return [preferred];
}
}
return Array.isArray(identifiers)
? identifiers
.map((identifier) =>
typeof identifier === 'string' ? normalizeIdentifier(identifier) : identifier.value,
)
.filter(Boolean)
: typeof identifiers === 'string'
? [normalizeIdentifier(identifiers)]
: [identifiers.value];
};
export interface MetadataHashInfo {
title: string;
authors: string[];
identifiers: string[];
hashSource: string;
metaHash: string;
}
export const getMetadataHashInfo = (metadata: BookMetadata): MetadataHashInfo | undefined => {
if (!metadata) return;
try {
const title = getTitleForHash(metadata.title);
const authors = getAuthorsList(metadata.author);
const identifiers = getIdentifiersList(metadata.altIdentifier || metadata.identifier);
const hashSource = `${title}|${authors.join(',')}|${identifiers.join(',')}`;
const metaHash = md5(hashSource.normalize('NFC'));
return { title, authors, identifiers, hashSource, metaHash };
} catch (error) {
console.error('Error generating metadata hash:', error);
}
return;
};
export const getMetadataHash = (metadata: BookMetadata) => {
return getMetadataHashInfo(metadata)?.metaHash;
};