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>
345 lines
10 KiB
TypeScript
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;
|
|
};
|