forked from akai/readest
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:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+1
-1
Submodule packages/foliate-js updated: 6992cfef0c...8485e93213
Reference in New Issue
Block a user