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
This commit is contained in:
Huang Xin
2026-07-06 01:08:36 +09:00
committed by GitHub
parent 0b180da6a6
commit ec45a080fc
8 changed files with 345 additions and 4 deletions
@@ -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();
});
});
@@ -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 <meta name="calibre:user_metadata:#label" content="{json}"/>
// element per column
// OPF 3: a single <meta property="calibre:user_metadata"> 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__": "<ISO>"} 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) => `<?xml version='1.0' encoding='utf-8'?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="uuid_id" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>Test Book</dc:title>
<dc:creator>Test Author</dc:creator>
<dc:identifier id="uuid_id" opf:scheme="uuid">11111111-2222-3333-4444-555555555555</dc:identifier>
<dc:language>en</dc:language>
${metas}
</metadata>
<manifest><item id="c" href="c.xhtml" media-type="application/xhtml+xml"/></manifest>
<spine><itemref idref="c"/></spine>
</package>`;
const opf3 = (metas: string) => `<?xml version='1.0' encoding='utf-8'?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="id" version="3.0" prefix="calibre: https://calibre-ebook.com">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>Test Book</dc:title>
<dc:identifier id="id">urn:uuid:11111111-2222-3333-4444-555555555555</dc:identifier>
<dc:language>en</dc:language>
<meta property="dcterms:modified">2024-01-01T00:00:00Z</meta>
${metas}
</metadata>
<manifest><item id="c" href="c.xhtml" media-type="application/xhtml+xml"/></manifest>
<spine><itemref idref="c"/></spine>
</package>`;
const RECOMMENDS_OPF2 = `<meta name="calibre:user_metadata:#recommends" content='{"table": "custom_column_5", "column": "value", "datatype": "text", "is_multiple": ",", "kind": "field", "name": "Recommends", "search_terms": ["#recommends"], "label": "recommends", "colnum": 5, "display": {"is_names": false}, "is_custom": true, "is_category": true, "#value#": ["TOD", "Grandma"], "#extra#": null}'/>`;
describe('Calibre custom columns from OPF 2 legacy metas', () => {
it('parses per-column metas into metadata.calibreColumns', () => {
const xml = opf2(`${RECOMMENDS_OPF2}
<meta name="calibre:user_metadata:#myrating" content='{"datatype": "rating", "name": "My Rating", "label": "myrating", "#value#": 8, "#extra#": null}'/>`);
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}
<meta name="calibre:user_metadata:#emptytext" content='{"datatype": "text", "name": "Empty Text", "label": "emptytext", "#value#": null}'/>
<meta name="calibre:user_metadata:#emptylist" content='{"datatype": "text", "name": "Empty List", "label": "emptylist", "is_multiple": ",", "#value#": []}'/>
<meta name="calibre:user_metadata:#emptystr" content='{"datatype": "comments", "name": "Empty Str", "label": "emptystr", "#value#": ""}'/>
<meta name="calibre:user_metadata:#zerorating" content='{"datatype": "rating", "name": "Zero Rating", "label": "zerorating", "#value#": 0}'/>`);
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(`<meta name="calibre:user_metadata:#broken" content='{not json'/>
<meta name="calibre:user_metadata:nothash" content='{"datatype": "text", "name": "No Hash", "#value#": "x"}'/>
${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(`<meta property="calibre:user_metadata">${dict}</meta>`);
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(`<meta property="calibre:user_metadata">${dict}</meta>
<meta name="calibre:user_metadata:#old" content='{"datatype": "text", "name": "Old", "label": "old", "#value#": "from-opf2"}'/>`);
const { metadata } = parse(xml);
expect(metadata.calibreColumns?.map((c) => c.label)).toEqual(['new']);
});
});
@@ -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>): 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: '<div><p>Great <b>read</b></p></div>' }),
),
).toBe('Great read');
});
it('stringifies numbers', () => {
expect(formatCalibreColumnValue(column({ datatype: 'float', value: 2.5 }))).toBe('2.5');
});
});
@@ -148,6 +148,12 @@ export const expandBookshelfSelection = (ids: string[], items: (Book | BooksGrou
return [...hashes]; 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) => { export const createBookFilter = (queryTerm: string | null) => (item: Book) => {
if (!queryTerm) return true; if (!queryTerm) return true;
if (item.deletedAt) return false; if (item.deletedAt) return false;
@@ -164,7 +170,9 @@ export const createBookFilter = (queryTerm: string | null) => (item: Book) => {
authors.includes(lowerQuery) || authors.includes(lowerQuery) ||
item.format.toLowerCase().includes(lowerQuery) || item.format.toLowerCase().includes(lowerQuery) ||
(item.groupName && item.groupName.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); const title = formatTitle(item.title);
@@ -174,7 +182,8 @@ export const createBookFilter = (queryTerm: string | null) => (item: Book) => {
searchTerm.test(authors) || searchTerm.test(authors) ||
searchTerm.test(item.format) || searchTerm.test(item.format) ||
(item.groupName && searchTerm.test(item.groupName)) || (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))
); );
}; };
@@ -19,6 +19,7 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useEnv } from '@/context/EnvContext'; import { useEnv } from '@/context/EnvContext';
import { import {
formatAuthors, formatAuthors,
formatCalibreColumnValue,
formatDate, formatDate,
formatBytes, formatBytes,
formatLanguage, formatLanguage,
@@ -273,6 +274,26 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
{metadata?.identifier || _('Unknown')} {metadata?.identifier || _('Unknown')}
</p> </p>
</div> </div>
{/*
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) => (
<div
key={column.label}
className={clsx(
'overflow-hidden',
index % 2 === 1 && 'pe-1 text-end sm:text-start',
)}
>
<span className='font-bold'>{column.name}</span>
<p className='text-neutral-content line-clamp-3 text-sm'>
{formatCalibreColumnValue(column)}
</p>
</div>
))}
{/* {/*
Only books imported in-place (or files opened directly via the Only books imported in-place (or files opened directly via the
OS, e.g. Android "Open with Readest") keep a `filePath`; books OS, e.g. Android "Open with Readest") keep a `filePath`; books
+14
View File
@@ -47,6 +47,18 @@ export interface SectionItem {
createDocument: () => Promise<Document>; createDocument: () => Promise<Document>;
} }
// 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 = { export type BookMetadata = {
// NOTE: the title and author fields should be formatted // NOTE: the title and author fields should be formatted
title: string | LanguageMap; title: string | LanguageMap;
@@ -73,6 +85,8 @@ export type BookMetadata = {
coverImageFile?: string; coverImageFile?: string;
coverImageUrl?: string; coverImageUrl?: string;
coverImageBlobUrl?: string; coverImageBlobUrl?: string;
calibreColumns?: CalibreCustomColumn[];
}; };
export interface BookDoc { export interface BookDoc {
+26 -1
View File
@@ -1,4 +1,4 @@
import { BookMetadata, EXTS } from '@/libs/document'; import { BookMetadata, CalibreCustomColumn, EXTS } from '@/libs/document';
import { import {
Book, Book,
BOOK_CONFIG_SCHEMA_VERSION, 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) => { export const formatLocaleDateTime = (date: number | Date) => {
const userLang = getLocale(); const userLang = getLocale();
return new Date(date).toLocaleString(userLang); return new Date(date).toLocaleString(userLang);