From ec45a080fcd9ce4c63e8cfe9f8a792e3ae2c6ecf Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 6 Jul 2026 01:08:36 +0900 Subject: [PATCH] feat(metadata): surface calibre custom columns from EPUB metadata (#4939) Parse calibre's embedded user metadata (custom columns) from the OPF in foliate-js, store it on BookMetadata.calibreColumns, render the columns in the book details view, and match column names and values in the library search so a value like a recommends tag can be found by typing it. Closes #4811 --- .../library-filter-calibre-columns.test.ts | 56 +++++++ .../foliate-calibre-user-metadata.test.ts | 154 ++++++++++++++++++ .../utils/book-calibre-columns.test.ts | 62 +++++++ .../src/app/library/utils/libraryUtils.ts | 13 +- .../components/metadata/BookDetailView.tsx | 21 +++ apps/readest-app/src/libs/document.ts | 14 ++ apps/readest-app/src/utils/book.ts | 27 ++- packages/foliate-js | 2 +- 8 files changed, 345 insertions(+), 4 deletions(-) create mode 100644 apps/readest-app/src/__tests__/app/library/library-filter-calibre-columns.test.ts create mode 100644 apps/readest-app/src/__tests__/foliate-calibre-user-metadata.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/book-calibre-columns.test.ts diff --git a/apps/readest-app/src/__tests__/app/library/library-filter-calibre-columns.test.ts b/apps/readest-app/src/__tests__/app/library/library-filter-calibre-columns.test.ts new file mode 100644 index 00000000..c47a3388 --- /dev/null +++ b/apps/readest-app/src/__tests__/app/library/library-filter-calibre-columns.test.ts @@ -0,0 +1,56 @@ +// Library search must match Calibre custom column values (readest#4811): +// the reporter categorizes children's books with a #recommends column +// ("TOD" = toddler) and wants to find them by typing the value. +import { describe, expect, it } from 'vitest'; + +import { createBookFilter } from '@/app/library/utils/libraryUtils'; +import { Book } from '@/types/book'; +import { BookMetadata } from '@/libs/document'; + +const bookWithColumns = (calibreColumns: BookMetadata['calibreColumns']): Book => ({ + hash: 'hash-1', + format: 'EPUB', + title: 'Goodnight (Moon)', + author: 'Margaret Wise Brown', + createdAt: 0, + updatedAt: 0, + metadata: { title: 'Goodnight (Moon)', author: '', language: 'en', calibreColumns }, +}); + +describe('createBookFilter with calibre custom columns', () => { + it('matches a custom column value', () => { + const book = bookWithColumns([ + { label: 'recommends', name: 'Recommends', datatype: 'text', value: ['TOD', 'Grandma'] }, + ]); + expect(createBookFilter('tod')(book)).toBe(true); + expect(createBookFilter('grandma')(book)).toBe(true); + }); + + it('matches a custom column display name', () => { + const book = bookWithColumns([ + { label: 'recommends', name: 'Recommends', datatype: 'text', value: ['TOD'] }, + ]); + expect(createBookFilter('recommends')(book)).toBe(true); + }); + + it('matches scalar column values', () => { + const book = bookWithColumns([ + { label: 'saga', name: 'My Saga', datatype: 'series', value: 'Cool Saga', extra: 2 }, + ]); + expect(createBookFilter('cool saga')(book)).toBe(true); + }); + + it('does not match values the book does not have', () => { + const book = bookWithColumns([ + { label: 'recommends', name: 'Recommends', datatype: 'text', value: ['TOD'] }, + ]); + expect(createBookFilter('teen')(book)).toBeFalsy(); + }); + + it('still works when calibreColumns is absent (regex and substring paths)', () => { + const book = bookWithColumns(undefined); + expect(createBookFilter('moon')(book)).toBe(true); + expect(createBookFilter('(moon')(book)).toBe(true); // invalid regex falls back to substring + expect(createBookFilter('tod')(book)).toBeFalsy(); + }); +}); diff --git a/apps/readest-app/src/__tests__/foliate-calibre-user-metadata.test.ts b/apps/readest-app/src/__tests__/foliate-calibre-user-metadata.test.ts new file mode 100644 index 00000000..5d7ab2cb --- /dev/null +++ b/apps/readest-app/src/__tests__/foliate-calibre-user-metadata.test.ts @@ -0,0 +1,154 @@ +// Feature test for surfacing Calibre custom columns (readest#4811). +// +// Calibre embeds its per-library custom columns ("user metadata") into the +// OPF when polishing / sending books, and the Readest calibre plugin embeds +// them on push. Two encodings exist (see calibre's opf2.py / opf3.py): +// +// OPF 2: one +// element per column +// OPF 3: a single element whose +// text is a JSON dict of all columns keyed by "#label" +// +// The per-column JSON carries the value in `#value#` (series index in +// `#extra#`); datetimes are wrapped as +// {"__class__": "datetime.datetime", "__value__": ""} and unset dates +// are 0101-01-01. Embedded files include EVERY column of the library, so +// empty values must be dropped at parse time. +import { describe, expect, it } from 'vitest'; + +import { parseEpubMetadataFromXML } from 'foliate-js/epub.js'; +import { BookMetadata } from '@/libs/document'; + +// foliate-js is plain JS; its inferred metadata type does not carry the +// dynamically attached calibreColumns field (same cast as tauriEpubBridge.ts). +const parse = parseEpubMetadataFromXML as unknown as (xml: string) => { metadata: BookMetadata }; + +const opf2 = (metas: string) => ` + + + Test Book + Test Author + 11111111-2222-3333-4444-555555555555 + en + ${metas} + + + +`; + +const opf3 = (metas: string) => ` + + + Test Book + urn:uuid:11111111-2222-3333-4444-555555555555 + en + 2024-01-01T00:00:00Z + ${metas} + + + +`; + +const RECOMMENDS_OPF2 = ``; + +describe('Calibre custom columns from OPF 2 legacy metas', () => { + it('parses per-column metas into metadata.calibreColumns', () => { + const xml = opf2(`${RECOMMENDS_OPF2} + `); + const { metadata } = parse(xml); + expect(metadata.calibreColumns).toEqual([ + { + label: 'recommends', + name: 'Recommends', + datatype: 'text', + value: ['TOD', 'Grandma'], + }, + { label: 'myrating', name: 'My Rating', datatype: 'rating', value: 8 }, + ]); + }); + + it('drops empty-valued columns (embedded files carry every library column)', () => { + const xml = opf2(`${RECOMMENDS_OPF2} + + + + `); + const { metadata } = parse(xml); + expect(metadata.calibreColumns?.map((c) => c.label)).toEqual(['recommends']); + }); + + it('ignores malformed column JSON and non-column calibre metas', () => { + const xml = opf2(` + + ${RECOMMENDS_OPF2}`); + const { metadata } = parse(xml); + expect(metadata.calibreColumns?.map((c) => c.label)).toEqual(['recommends']); + }); + + it('leaves calibreColumns undefined when no user metadata is embedded', () => { + const { metadata } = parse(opf2('')); + expect(metadata.calibreColumns).toBeUndefined(); + }); +}); + +describe('Calibre custom columns from the OPF 3 property meta', () => { + it('parses the single user_metadata dict, unwrapping datetime values', () => { + const dict = JSON.stringify({ + '#read': { name: 'Read', label: 'read', datatype: 'bool', '#value#': true, '#extra#': null }, + '#lastread': { + name: 'Last Read', + label: 'lastread', + datatype: 'datetime', + '#value#': { __class__: 'datetime.datetime', __value__: '2024-03-01T10:00:00+00:00' }, + '#extra#': null, + }, + '#saga': { + name: 'My Saga', + label: 'saga', + datatype: 'series', + '#value#': 'Cool Saga', + '#extra#': 2.0, + }, + '#solo': { + name: 'Solo Tag', + label: 'solo', + datatype: 'text', + is_multiple: ',', + '#value#': ['Only'], + '#extra#': null, + }, + '#undated': { + name: 'Undated', + label: 'undated', + datatype: 'datetime', + '#value#': { __class__: 'datetime.datetime', __value__: '0101-01-01T00:00:00+00:00' }, + '#extra#': null, + }, + '#unset': { name: 'Unset', label: 'unset', datatype: 'text', '#value#': null }, + }); + const xml = opf3(`${dict}`); + const { metadata } = parse(xml); + expect(metadata.calibreColumns).toEqual([ + { label: 'read', name: 'Read', datatype: 'bool', value: true }, + { + label: 'lastread', + name: 'Last Read', + datatype: 'datetime', + value: '2024-03-01T10:00:00+00:00', + }, + { label: 'saga', name: 'My Saga', datatype: 'series', value: 'Cool Saga', extra: 2 }, + // a single-element multi-value column must stay an array + { label: 'solo', name: 'Solo Tag', datatype: 'text', value: ['Only'] }, + ]); + }); + + it('prefers the OPF 3 dict over legacy per-column metas, like calibre does', () => { + const dict = JSON.stringify({ + '#new': { name: 'New', label: 'new', datatype: 'text', '#value#': 'from-opf3' }, + }); + const xml = opf3(`${dict} + `); + const { metadata } = parse(xml); + expect(metadata.calibreColumns?.map((c) => c.label)).toEqual(['new']); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/book-calibre-columns.test.ts b/apps/readest-app/src/__tests__/utils/book-calibre-columns.test.ts new file mode 100644 index 00000000..ea6d2398 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/book-calibre-columns.test.ts @@ -0,0 +1,62 @@ +// Display formatting for Calibre custom column values (readest#4811). +import { describe, expect, it } from 'vitest'; + +import { CalibreCustomColumn } from '@/libs/document'; +import { formatCalibreColumnValue } from '@/utils/book'; + +const column = (partial: Partial): CalibreCustomColumn => ({ + label: 'col', + name: 'Col', + datatype: 'text', + value: '', + ...partial, +}); + +describe('formatCalibreColumnValue', () => { + it('joins multi-value columns with a comma', () => { + expect(formatCalibreColumnValue(column({ value: ['TOD', 'Grandma'] }))).toBe('TOD, Grandma'); + }); + + it('passes plain text through', () => { + expect(formatCalibreColumnValue(column({ value: 'TOD' }))).toBe('TOD'); + }); + + it('renders ratings (0-10 half stars) as stars', () => { + expect(formatCalibreColumnValue(column({ datatype: 'rating', value: 8 }))).toBe('★★★★'); + expect(formatCalibreColumnValue(column({ datatype: 'rating', value: 7 }))).toBe('★★★½'); + }); + + it('renders series with its index like calibre does', () => { + expect( + formatCalibreColumnValue(column({ datatype: 'series', value: 'Cool Saga', extra: 2 })), + ).toBe('Cool Saga [2]'); + expect(formatCalibreColumnValue(column({ datatype: 'series', value: 'Cool Saga' }))).toBe( + 'Cool Saga', + ); + }); + + it('renders yes/no columns as check marks', () => { + expect(formatCalibreColumnValue(column({ datatype: 'bool', value: true }))).toBe('✓'); + expect(formatCalibreColumnValue(column({ datatype: 'bool', value: false }))).toBe('✗'); + }); + + it('renders datetime columns as a locale date', () => { + const formatted = formatCalibreColumnValue( + column({ datatype: 'datetime', value: '2024-03-01T10:00:00+00:00' }), + ); + expect(formatted).toContain('2024'); + expect(formatted).toContain('March'); + }); + + it('strips markup from comments columns', () => { + expect( + formatCalibreColumnValue( + column({ datatype: 'comments', value: '

Great read

' }), + ), + ).toBe('Great read'); + }); + + it('stringifies numbers', () => { + expect(formatCalibreColumnValue(column({ datatype: 'float', value: 2.5 }))).toBe('2.5'); + }); +}); diff --git a/apps/readest-app/src/app/library/utils/libraryUtils.ts b/apps/readest-app/src/app/library/utils/libraryUtils.ts index b3d47a8d..03446f56 100644 --- a/apps/readest-app/src/app/library/utils/libraryUtils.ts +++ b/apps/readest-app/src/app/library/utils/libraryUtils.ts @@ -148,6 +148,12 @@ export const expandBookshelfSelection = (ids: string[], items: (Book | BooksGrou return [...hashes]; }; +// Calibre custom column names and values, flattened for searching (#4811). +const getCalibreColumnsText = (item: Book) => + (item.metadata?.calibreColumns ?? []) + .map(({ name, value }) => `${name} ${Array.isArray(value) ? value.join(' ') : value}`) + .join(' '); + export const createBookFilter = (queryTerm: string | null) => (item: Book) => { if (!queryTerm) return true; if (item.deletedAt) return false; @@ -164,7 +170,9 @@ export const createBookFilter = (queryTerm: string | null) => (item: Book) => { authors.includes(lowerQuery) || item.format.toLowerCase().includes(lowerQuery) || (item.groupName && item.groupName.toLowerCase().includes(lowerQuery)) || - (item.metadata?.description && item.metadata.description.toLowerCase().includes(lowerQuery)) + (item.metadata?.description && + item.metadata.description.toLowerCase().includes(lowerQuery)) || + getCalibreColumnsText(item).toLowerCase().includes(lowerQuery) ); } const title = formatTitle(item.title); @@ -174,7 +182,8 @@ export const createBookFilter = (queryTerm: string | null) => (item: Book) => { searchTerm.test(authors) || searchTerm.test(item.format) || (item.groupName && searchTerm.test(item.groupName)) || - (item.metadata?.description && searchTerm.test(item.metadata?.description)) + (item.metadata?.description && searchTerm.test(item.metadata?.description)) || + searchTerm.test(getCalibreColumnsText(item)) ); }; diff --git a/apps/readest-app/src/components/metadata/BookDetailView.tsx b/apps/readest-app/src/components/metadata/BookDetailView.tsx index dd5e2c4b..25762110 100644 --- a/apps/readest-app/src/components/metadata/BookDetailView.tsx +++ b/apps/readest-app/src/components/metadata/BookDetailView.tsx @@ -19,6 +19,7 @@ import { useSettingsStore } from '@/store/settingsStore'; import { useEnv } from '@/context/EnvContext'; import { formatAuthors, + formatCalibreColumnValue, formatDate, formatBytes, formatLanguage, @@ -273,6 +274,26 @@ const BookDetailView: React.FC = ({ {metadata?.identifier || _('Unknown')}

+ {/* + Calibre custom columns embedded in the OPF (#4811). Column + names are user content, not translation keys. The identifier + cell above spans the full row on mobile, so alternate the + end-aligned style from a fresh even/odd count here. + */} + {metadata?.calibreColumns?.map((column, index) => ( +
+ {column.name} +

+ {formatCalibreColumnValue(column)} +

+
+ ))} {/* Only books imported in-place (or files opened directly via the OS, e.g. Android "Open with Readest") keep a `filePath`; books diff --git a/apps/readest-app/src/libs/document.ts b/apps/readest-app/src/libs/document.ts index f2df9d6e..2271d3f3 100644 --- a/apps/readest-app/src/libs/document.ts +++ b/apps/readest-app/src/libs/document.ts @@ -47,6 +47,18 @@ export interface SectionItem { createDocument: () => Promise; } +// A Calibre custom column embedded in the OPF as "user metadata"; parsed by +// foliate-js's getMetadata (see getCalibreUserMetadata in epub.js). `value` +// is an array for multi-value columns, `extra` is the series index for +// datatype 'series'. +export interface CalibreCustomColumn { + label: string; + name: string; + datatype: string; + value: string | number | boolean | string[]; + extra?: number; +} + export type BookMetadata = { // NOTE: the title and author fields should be formatted title: string | LanguageMap; @@ -73,6 +85,8 @@ export type BookMetadata = { coverImageFile?: string; coverImageUrl?: string; coverImageBlobUrl?: string; + + calibreColumns?: CalibreCustomColumn[]; }; export interface BookDoc { diff --git a/apps/readest-app/src/utils/book.ts b/apps/readest-app/src/utils/book.ts index 90473640..2e5b455b 100644 --- a/apps/readest-app/src/utils/book.ts +++ b/apps/readest-app/src/utils/book.ts @@ -1,4 +1,4 @@ -import { BookMetadata, EXTS } from '@/libs/document'; +import { BookMetadata, CalibreCustomColumn, EXTS } from '@/libs/document'; import { Book, BOOK_CONFIG_SCHEMA_VERSION, @@ -225,6 +225,31 @@ export const formatDate = (date: string | number | Date | null | undefined, isUT } }; +export const formatCalibreColumnValue = (column: CalibreCustomColumn): string => { + const { datatype, value, extra } = column; + if (Array.isArray(value)) return value.join(', '); + switch (datatype) { + case 'rating': { + // 0-10 in half stars, like calibre's own rendering + const rating = typeof value === 'number' ? value : 0; + return '★'.repeat(Math.floor(rating / 2)) + (rating % 2 ? '½' : ''); + } + case 'series': + return extra != null ? `${value} [${extra}]` : String(value); + case 'datetime': + return formatDate(String(value), true) || ''; + case 'comments': + return String(value) + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + case 'bool': + return value ? '✓' : '✗'; + default: + return String(value); + } +}; + export const formatLocaleDateTime = (date: number | Date) => { const userLang = getLocale(); return new Date(date).toLocaleString(userLang); diff --git a/packages/foliate-js b/packages/foliate-js index 6992cfef..8485e932 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit 6992cfef0c50a605ea56aba74533bcded58b5309 +Subproject commit 8485e93213100490329a83376288fc9b9a1aa361