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: '' }),
+ ),
+ ).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