feat(metadata): support parsing series info from calibre exported EPUBs, closes #3259 (#3267)

This commit is contained in:
Huang Xin
2026-02-11 22:39:48 +08:00
committed by GitHub
parent 950bbd0821
commit 411f9e236d
37 changed files with 37 additions and 31 deletions
@@ -1024,7 +1024,6 @@
"Unable to start RSVP": "تعذر بدء RSVP",
"RSVP not supported for PDF": "RSVP غير مدعوم لملفات PDF",
"Select Chapter": "اختر الفصل",
"{{number}} WPM": "{{number}} كلمة في الدقيقة",
"Context": "السياق",
"Ready": "جاهز",
"Chapter Progress": "تقدم الفصل",
@@ -976,7 +976,6 @@
"Unable to start RSVP": "RSVP शुरू করতে অসমর্থ",
"RSVP not supported for PDF": "PDF-এর জন্য RSVP সমর্থিত নয়",
"Select Chapter": "অধ্যায় নির্বাচন করুন",
"{{number}} WPM": "{{number}} WPM",
"Context": "প্রসঙ্গ",
"Ready": "প্রস্তুত",
"Chapter Progress": "অধ্যায় অগ্রগতি",
@@ -964,7 +964,6 @@
"Unable to start RSVP": "RSVP འགོ་འཛུགས་མ་ཐུབ།",
"RSVP not supported for PDF": "PDF ལ་ RSVP རྒྱབ་སྐྱོར་མེད།",
"Select Chapter": "ལེའུ་འདེམས་པ།",
"{{number}} WPM": "{{number}} WPM",
"Context": "བརྗོད་དོན།",
"Ready": "གྲ་སྒྲིག་ཡོད།",
"Chapter Progress": "ལེའུའི་འཕེལ་རིམ།",
@@ -976,7 +976,6 @@
"Unable to start RSVP": "RSVP kann nicht gestartet werden",
"RSVP not supported for PDF": "RSVP wird für PDF nicht unterstützt",
"Select Chapter": "Kapitel auswählen",
"{{number}} WPM": "{{number}} WPM",
"Context": "Kontext",
"Ready": "Bereit",
"Chapter Progress": "Kapitelfortschritt",
@@ -976,7 +976,6 @@
"Unable to start RSVP": "Αδυναμία έναρξης RSVP",
"RSVP not supported for PDF": "Το RSVP δεν υποστηρίζεται για PDF",
"Select Chapter": "Επιλογή Κεφαλαίου",
"{{number}} WPM": "{{number}} WPM",
"Context": "Πλαίσιο",
"Ready": "Έτοιμο",
"Chapter Progress": "Πρόοδος Κεφαλαίου",
@@ -988,7 +988,6 @@
"Unable to start RSVP": "No se puede iniciar RSVP",
"RSVP not supported for PDF": "RSVP no es compatible con PDF",
"Select Chapter": "Seleccionar capítulo",
"{{number}} WPM": "{{number}} PPM",
"Context": "Contexto",
"Ready": "Listo",
"Chapter Progress": "Progreso del capítulo",
@@ -976,7 +976,6 @@
"Unable to start RSVP": "قادر به شروع RSVP نیست",
"RSVP not supported for PDF": "RSVP برای PDF پشتیبانی نمی‌شود",
"Select Chapter": "انتخاب فصل",
"{{number}} WPM": "{{number}} کلمه در دقیقه",
"Context": "زمینه",
"Ready": "آماده",
"Chapter Progress": "پیشرفت فصل",
@@ -988,7 +988,6 @@
"Unable to start RSVP": "Impossible de démarrer RSVP",
"RSVP not supported for PDF": "RSVP non pris en charge pour PDF",
"Select Chapter": "Sélectionner le chapitre",
"{{number}} WPM": "{{number}} MPM",
"Context": "Contexte",
"Ready": "Prêt",
"Chapter Progress": "Progression du chapitre",
@@ -487,7 +487,6 @@
"Select Chapter": "בחר פרק",
"Speed Reading": "קריאה מהירה",
"Close Speed Reading": "סגור קריאה מהירה",
"{{number}} WPM": "{{number}} מילים לדקה",
"Context": "הקשר",
"Ready": "מוכן",
"Chapter Progress": "התקדמות הפרק",
@@ -976,7 +976,6 @@
"Unable to start RSVP": "RSVP शुरू करने में असमर्थ",
"RSVP not supported for PDF": "PDF के लिए RSVP समर्थित नहीं है",
"Select Chapter": "अध्याय चुनें",
"{{number}} WPM": "{{number}} WPM",
"Context": "संदर्भ",
"Ready": "तैयार",
"Chapter Progress": "अध्याय प्रगति",
@@ -964,7 +964,6 @@
"Unable to start RSVP": "Tidak dapat memulai RSVP",
"RSVP not supported for PDF": "RSVP tidak didukung untuk PDF",
"Select Chapter": "Pilih Bab",
"{{number}} WPM": "{{number}} WPM",
"Context": "Konteks",
"Ready": "Siap",
"Chapter Progress": "Kemajuan Bab",
@@ -988,7 +988,6 @@
"Unable to start RSVP": "Impossibile avviare RSVP",
"RSVP not supported for PDF": "RSVP non supportato per PDF",
"Select Chapter": "Seleziona capitolo",
"{{number}} WPM": "{{number}} PPM",
"Context": "Contesto",
"Ready": "Pronto",
"Chapter Progress": "Progresso capitolo",
@@ -964,7 +964,6 @@
"Unable to start RSVP": "RSVPを起動できません",
"RSVP not supported for PDF": "PDFはRSVPに対応していません",
"Select Chapter": "章を選択",
"{{number}} WPM": "{{number}} WPM",
"Context": "コンテキスト",
"Ready": "準備完了",
"Chapter Progress": "章の進捗",
@@ -964,7 +964,6 @@
"Unable to start RSVP": "RSVP를 시작할 수 없습니다",
"RSVP not supported for PDF": "PDF는 RSVP를 지원하지 않습니다",
"Select Chapter": "챕터 선택",
"{{number}} WPM": "{{number}} WPM",
"Context": "문맥",
"Ready": "준비됨",
"Chapter Progress": "챕터 진행 상황",
@@ -964,7 +964,6 @@
"Unable to start RSVP": "Tidak dapat memulakan RSVP",
"RSVP not supported for PDF": "RSVP tidak disokong untuk PDF",
"Select Chapter": "Pilih Bab",
"{{number}} WPM": "{{number}} WPM",
"Context": "Konteks",
"Ready": "Sedia",
"Chapter Progress": "Kemajuan Bab",
@@ -976,7 +976,6 @@
"Unable to start RSVP": "Kan RSVP niet starten",
"RSVP not supported for PDF": "RSVP niet ondersteund voor PDF",
"Select Chapter": "Hoofdstuk selecteren",
"{{number}} WPM": "{{number}} WPM",
"Context": "Context",
"Ready": "Klaar",
"Chapter Progress": "Hoofdstukvoortgang",
@@ -1000,7 +1000,6 @@
"Unable to start RSVP": "Nie można uruchomić RSVP",
"RSVP not supported for PDF": "RSVP nie jest obsługiwane dla PDF",
"Select Chapter": "Wybierz rozdział",
"{{number}} WPM": "{{number}} WPM",
"Context": "Kontekst",
"Ready": "Gotowy",
"Chapter Progress": "Postęp rozdziału",
@@ -988,7 +988,6 @@
"Unable to start RSVP": "Não foi possível iniciar o RSVP",
"RSVP not supported for PDF": "RSVP não suportado para PDF",
"Select Chapter": "Selecionar capítulo",
"{{number}} WPM": "{{number}} PPM",
"Context": "Contexto",
"Ready": "Pronto",
"Chapter Progress": "Progresso do capítulo",
@@ -1000,7 +1000,6 @@
"Unable to start RSVP": "Не удалось запустить RSVP",
"RSVP not supported for PDF": "RSVP не поддерживается для PDF",
"Select Chapter": "Выбрать главу",
"{{number}} WPM": "{{number}} WPM",
"Context": "Контекст",
"Ready": "Готово",
"Chapter Progress": "Прогресс главы",
@@ -976,7 +976,6 @@
"Unable to start RSVP": "RSVP ආරම්භ කිරීමට නොහැක",
"RSVP not supported for PDF": "PDF සඳහා RSVP සහාය නොදක්වයි",
"Select Chapter": "පරිච්ඡේදය තෝරන්න",
"{{number}} WPM": "{{number}} WPM",
"Context": "සන්දර්භය",
"Ready": "සූදානම්",
"Chapter Progress": "පරිච්ඡේදයේ ප්‍රගතිය",
@@ -976,7 +976,6 @@
"Unable to start RSVP": "Kunde inte starta RSVP",
"RSVP not supported for PDF": "RSVP stöds inte för PDF",
"Select Chapter": "Välj kapitel",
"{{number}} WPM": "{{number}} ord/min",
"Context": "Sammanhang",
"Ready": "Klar",
"Chapter Progress": "Kapitelframsteg",
@@ -976,7 +976,6 @@
"Unable to start RSVP": "RSVP-ஐத் தொடங்க முடியவில்லை",
"RSVP not supported for PDF": "PDF-க்கு RSVP ஆதரவு இல்லை",
"Select Chapter": "அத்தியாயத்தைத் தேர்ந்தெடு",
"{{number}} WPM": "{{number}} WPM",
"Context": "சூழல்",
"Ready": "தயார்",
"Chapter Progress": "அத்தியாய முன்னேற்றம்",
@@ -964,7 +964,6 @@
"Unable to start RSVP": "ไม่สามารถเริ่ม RSVP ได้",
"RSVP not supported for PDF": "ไม่รองรับ RSVP สำหรับ PDF",
"Select Chapter": "เลือกบท",
"{{number}} WPM": "{{number}} WPM",
"Context": "บริบท",
"Ready": "พร้อม",
"Chapter Progress": "ความคืบหน้าของบท",
@@ -976,7 +976,6 @@
"Unable to start RSVP": "RSVP başlatılamadı",
"RSVP not supported for PDF": "PDF için RSVP desteklenmiyor",
"Select Chapter": "Bölüm Seç",
"{{number}} WPM": "{{number}} Kelime/Dakika",
"Context": "Bağlam",
"Ready": "Hazır",
"Chapter Progress": "Bölüm İlerlemesi",
@@ -1000,7 +1000,6 @@
"Unable to start RSVP": "Не вдалося запустити RSVP",
"RSVP not supported for PDF": "RSVP не підтримується для PDF",
"Select Chapter": "Вибрати розділ",
"{{number}} WPM": "{{number}} WPM",
"Context": "Контекст",
"Ready": "Готово",
"Chapter Progress": "Прогрес розділу",
@@ -964,7 +964,6 @@
"Unable to start RSVP": "Không thể bắt đầu RSVP",
"RSVP not supported for PDF": "RSVP không được hỗ trợ cho PDF",
"Select Chapter": "Chọn chương",
"{{number}} WPM": "{{number}} WPM",
"Context": "Ngữ cảnh",
"Ready": "Sẵn sàng",
"Chapter Progress": "Tiến độ chương",
@@ -964,7 +964,6 @@
"Unable to start RSVP": "无法启动 RSVP",
"RSVP not supported for PDF": "PDF 不支持 RSVP",
"Select Chapter": "选择章节",
"{{number}} WPM": "{{number}} WPM",
"Context": "上下文",
"Ready": "准备就绪",
"Chapter Progress": "章节进度",
@@ -964,7 +964,6 @@
"Unable to start RSVP": "無法啟動 RSVP",
"RSVP not supported for PDF": "PDF 不支援 RSVP",
"Select Chapter": "選擇章節",
"{{number}} WPM": "{{number}} WPM",
"Context": "上下文",
"Ready": "準備就緒",
"Chapter Progress": "章節進度",
@@ -55,6 +55,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
{ label: _('Title'), value: LibrarySortByType.Title },
{ label: _('Author'), value: LibrarySortByType.Author },
{ label: _('Format'), value: LibrarySortByType.Format },
{ label: _('Series'), value: LibrarySortByType.Series },
{ label: _('Date Read'), value: LibrarySortByType.Updated },
{ label: _('Date Added'), value: LibrarySortByType.Created },
{ label: _('Date Published'), value: LibrarySortByType.Published },
@@ -105,6 +105,8 @@ export const createBookSorter = (sortBy: string, uiLanguage: string) => (a: Book
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
case LibrarySortByType.Format:
return a.format.localeCompare(b.format, uiLanguage || navigator.language);
case LibrarySortByType.Series:
return (a.metadata?.seriesIndex || 0) - (b.metadata?.seriesIndex || 0);
case LibrarySortByType.Published:
const aPublished = a.metadata?.published || '0001-01-01';
const bPublished = b.metadata?.published || '0001-01-01';
@@ -250,7 +250,7 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
<span className='font-bold'>{_('Series')}</span>
<p className='text-neutral-content text-sm'>{metadata?.series || _('Unknown')}</p>
</div>
<div className='overflow-hidden'>
<div className='overflow-hidden pe-1 text-end'>
<span className='font-bold'>{_('Series Index')}</span>
<p className='text-neutral-content text-sm'>
{metadata?.seriesIndex || _('Unknown')}
+5 -1
View File
@@ -1,5 +1,5 @@
import { BookFormat } from '@/types/book';
import { Contributor, Identifier, LanguageMap } from '@/utils/book';
import { Collection, Contributor, Identifier, LanguageMap } from '@/utils/book';
import * as epubcfi from 'foliate-js/epubcfi.js';
export const CFI = epubcfi;
@@ -47,6 +47,10 @@ export type BookMetadata = {
subject?: string | string[] | Contributor;
identifier?: string;
altIdentifier?: string | string[] | Identifier;
belongsTo?: {
collection?: Array<Collection> | Collection;
series?: Array<Collection> | Collection;
};
subtitle?: string;
series?: string;
@@ -408,12 +408,22 @@ export abstract class BaseAppService implements AppService {
sourceTitle: formatTitle(loadedBook.metadata.title),
primaryLanguage,
author: formatAuthors(loadedBook.metadata.author, primaryLanguage),
metadata: loadedBook.metadata,
createdAt: existingBook ? existingBook.createdAt : Date.now(),
uploadedAt: existingBook ? existingBook.uploadedAt : null,
deletedAt: transient ? Date.now() : null,
downloadedAt: Date.now(),
updatedAt: Date.now(),
};
// update series info from metadata
if (book.metadata?.belongsTo?.series) {
const belongsTo = book.metadata.belongsTo.series;
const series = Array.isArray(belongsTo) ? belongsTo[0] : belongsTo;
if (series) {
book.metadata.series = formatTitle(series.name);
book.metadata.seriesIndex = parseFloat(series.position || '0');
}
}
// update book metadata when reimporting the same book
if (existingBook) {
existingBook.format = book.format;
@@ -421,6 +431,7 @@ export abstract class BaseAppService implements AppService {
existingBook.sourceTitle = existingBook.sourceTitle ?? book.sourceTitle;
existingBook.author = existingBook.author ?? book.author;
existingBook.primaryLanguage = existingBook.primaryLanguage ?? book.primaryLanguage;
existingBook.metadata = book.metadata;
existingBook.downloadedAt = Date.now();
}
+10
View File
@@ -180,6 +180,16 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
const primaryLanguage = getPrimaryLanguage(bookDoc.metadata.language);
book.primaryLanguage = book.primaryLanguage ?? primaryLanguage;
book.metadata = book.metadata ?? bookDoc.metadata;
// Update series info from metadata
if (bookDoc.metadata.belongsTo?.series) {
const belongsTo = bookDoc.metadata.belongsTo.series;
const series = Array.isArray(belongsTo) ? belongsTo[0] : belongsTo;
if (series) {
book.metadata.series = formatTitle(series.name);
book.metadata.seriesIndex = parseFloat(series.position || '0');
}
}
// TODO: uncomment this when we can ensure metaHash is correctly generated for all books
// book.metaHash = book.metaHash ?? getMetadataHash(bookDoc.metadata);
book.metaHash = getMetadataHash(bookDoc.metadata);
+1
View File
@@ -13,6 +13,7 @@ export const LibrarySortByType = {
Author: 'author',
Updated: 'updated',
Created: 'created',
Series: 'series',
Size: 'size',
Format: 'format',
Published: 'published',
+5
View File
@@ -56,6 +56,11 @@ export interface Contributor {
name: LanguageMap;
}
export interface Collection {
name: string;
position?: string;
}
const formatLanguageMap = (x: string | LanguageMap, defaultLang = false): string => {
const userLang = getUserLang();
if (!x) return '';