mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(metadata): support calibre 9.x opf3 metadata formats
Calibre 9.x (opf3) writes provider identifiers as bare `prefix:value` text inside `<dc:identifier>` instead of using `opf:scheme` attributes. It also stores custom-column values like page count and subtitle in a `calibre:user_metadata` JSON blob. This adds parsing for these fallback formats to correctly extract metadata from Calibre-processed EPUBs. Closes #341
This commit is contained in:
@@ -448,6 +448,200 @@ describe('parseOpf', () => {
|
||||
expect(r.ranobedbId).toBeNull();
|
||||
expect(r.itunesId).toBeNull();
|
||||
});
|
||||
|
||||
describe('Calibre prefix:value identifiers (opf3 fallback)', () => {
|
||||
it('parses every known provider prefix from bare prefix:value text', () => {
|
||||
const xml = epub3Opf(`
|
||||
<dc:identifier>amazon:B0G3YRNY6Y</dc:identifier>
|
||||
<dc:identifier>goodreads:244564568</dc:identifier>
|
||||
<dc:identifier>google:ABCD1234</dc:identifier>
|
||||
<dc:identifier>openlibrary:OL99999999W</dc:identifier>
|
||||
<dc:identifier>hardcover:test-book-slug</dc:identifier>
|
||||
<dc:identifier>kobo:test-kobo-id</dc:identifier>
|
||||
<dc:identifier>itunes:987654321</dc:identifier>
|
||||
<dc:identifier>lubimyczytac:lub-99999</dc:identifier>
|
||||
<dc:identifier>ranobedb:ranobe-999</dc:identifier>
|
||||
`);
|
||||
const r = parseOpf(xml);
|
||||
expect(r.amazonId).toBe('B0G3YRNY6Y');
|
||||
expect(r.goodreadsId).toBe('244564568');
|
||||
expect(r.googleBooksId).toBe('ABCD1234');
|
||||
expect(r.openLibraryId).toBe('OL99999999W');
|
||||
expect(r.hardcoverId).toBe('test-book-slug');
|
||||
expect(r.koboId).toBe('test-kobo-id');
|
||||
expect(r.itunesId).toBe('987654321');
|
||||
expect(r.lubimyczytacId).toBe('lub-99999');
|
||||
expect(r.ranobedbId).toBe('ranobe-999');
|
||||
});
|
||||
|
||||
it('maps both asin: and mobi-asin: to amazonId', () => {
|
||||
expect(parseOpf(epub3Opf(`<dc:identifier>asin:B0G3YRNY6Y</dc:identifier>`)).amazonId).toBe('B0G3YRNY6Y');
|
||||
expect(parseOpf(epub3Opf(`<dc:identifier>mobi-asin:B0ABCDEFGH</dc:identifier>`)).amazonId).toBe('B0ABCDEFGH');
|
||||
});
|
||||
|
||||
it('lets opf:scheme win over prefix:value for the same provider', () => {
|
||||
const xml = epub2Opf(`
|
||||
<dc:identifier>amazon:PREFIX_ASIN</dc:identifier>
|
||||
<dc:identifier opf:scheme="AMAZON">SCHEME_ASIN</dc:identifier>
|
||||
`);
|
||||
expect(parseOpf(xml).amazonId).toBe('SCHEME_ASIN');
|
||||
});
|
||||
|
||||
it('lets urn: win over prefix:value for the same provider', () => {
|
||||
const xml = epub2Opf(`
|
||||
<dc:identifier>amazon:PREFIX_ASIN</dc:identifier>
|
||||
<dc:identifier>urn:amazon:URN_ASIN</dc:identifier>
|
||||
`);
|
||||
expect(parseOpf(xml).amazonId).toBe('URN_ASIN');
|
||||
});
|
||||
|
||||
it('uses prefix:value only as a fallback when scheme and urn are absent', () => {
|
||||
const xml = epub3Opf(`<dc:identifier>amazon:ONLY_PREFIX</dc:identifier>`);
|
||||
expect(parseOpf(xml).amazonId).toBe('ONLY_PREFIX');
|
||||
});
|
||||
|
||||
it('ignores unknown prefixes', () => {
|
||||
const xml = epub3Opf(`
|
||||
<dc:identifier>uuid:123e4567-e89b-12d3-a456-426614174000</dc:identifier>
|
||||
<dc:identifier>calibre:12345</dc:identifier>
|
||||
<dc:identifier>doi:10.1000/xyz123</dc:identifier>
|
||||
`);
|
||||
const r = parseOpf(xml);
|
||||
expect(r.amazonId).toBeNull();
|
||||
expect(r.googleBooksId).toBeNull();
|
||||
expect(r.goodreadsId).toBeNull();
|
||||
expect(r.openLibraryId).toBeNull();
|
||||
});
|
||||
|
||||
it('still parses isbn:VALUE as an ISBN, not a provider id', () => {
|
||||
const r = parseOpf(epub3Opf(`<dc:identifier>isbn:9780441013593</dc:identifier>`));
|
||||
expect(r.isbn13).toBe('9780441013593');
|
||||
expect(r.amazonId).toBeNull();
|
||||
expect(r.googleBooksId).toBeNull();
|
||||
});
|
||||
|
||||
it('splits on the first colon only, keeping colons in the id', () => {
|
||||
const r = parseOpf(epub3Opf(`<dc:identifier>hardcover:some:slug</dc:identifier>`));
|
||||
expect(r.hardcoverId).toBe('some:slug');
|
||||
});
|
||||
|
||||
it('matches the prefix case-insensitively while preserving the id case', () => {
|
||||
const r = parseOpf(epub3Opf(`<dc:identifier>AMAZON:B0G3YRNY6Y</dc:identifier>`));
|
||||
expect(r.amazonId).toBe('B0G3YRNY6Y');
|
||||
});
|
||||
|
||||
it('ignores a known prefix with an empty value', () => {
|
||||
const r = parseOpf(epub3Opf(`<dc:identifier>amazon:</dc:identifier>`));
|
||||
expect(r.amazonId).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a bare identifier with no colon', () => {
|
||||
const r = parseOpf(epub3Opf(`<dc:identifier>SOMEBAREID</dc:identifier>`));
|
||||
expect(r.amazonId).toBeNull();
|
||||
expect(r.googleBooksId).toBeNull();
|
||||
});
|
||||
|
||||
it('parses a full real-world Calibre 9.x opf3 identifier block', () => {
|
||||
const xml = epub3Opf(`
|
||||
<dc:identifier>urn:uuid:5f3c2b1a-0000-4444-8888-aaaabbbbcccc</dc:identifier>
|
||||
<dc:identifier>amazon:B0G3YRNY6Y</dc:identifier>
|
||||
<dc:identifier>goodreads:244564568</dc:identifier>
|
||||
<dc:identifier>google:ABCD1234</dc:identifier>
|
||||
`);
|
||||
const r = parseOpf(xml);
|
||||
expect(r.amazonId).toBe('B0G3YRNY6Y');
|
||||
expect(r.goodreadsId).toBe('244564568');
|
||||
expect(r.googleBooksId).toBe('ABCD1234');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Calibre user_metadata (pageCount & subtitle)', () => {
|
||||
it('parses page count from a #pagecount custom column', () => {
|
||||
const xml = epub3Opf(`<meta property="calibre:user_metadata">{"#pagecount":{"#value#":353}}</meta>`);
|
||||
expect(parseOpf(xml).pageCount).toBe(353);
|
||||
});
|
||||
|
||||
it('parses page count from a #page_count custom column', () => {
|
||||
const xml = epub3Opf(`<meta property="calibre:user_metadata">{"#page_count":{"#value#":400}}</meta>`);
|
||||
expect(parseOpf(xml).pageCount).toBe(400);
|
||||
});
|
||||
|
||||
it('parses a string numeric page count value', () => {
|
||||
const xml = epub3Opf(`<meta property="calibre:user_metadata">{"#page_count":{"#value#":"400"}}</meta>`);
|
||||
expect(parseOpf(xml).pageCount).toBe(400);
|
||||
});
|
||||
|
||||
it('parses subtitle from a #subtitle custom column', () => {
|
||||
const xml = epub3Opf(`<meta property="calibre:user_metadata">{"#subtitle":{"#value#":"A Subtitle"}}</meta>`);
|
||||
expect(parseOpf(xml).subtitle).toBe('A Subtitle');
|
||||
});
|
||||
|
||||
it('parses page count and subtitle together from one blob (issue sample)', () => {
|
||||
const xml = epub3Opf(`<meta property="calibre:user_metadata">{"#pagecount":{"#value#":353},"#subtitle":{"#value#":"TEST-SUBTITLE"}}</meta>`);
|
||||
const r = parseOpf(xml);
|
||||
expect(r.pageCount).toBe(353);
|
||||
expect(r.subtitle).toBe('TEST-SUBTITLE');
|
||||
});
|
||||
|
||||
it('prefers bookorbit:page_count over the Calibre user_metadata page count', () => {
|
||||
const xml = epub3Opf(`
|
||||
<meta property="bookorbit:page_count">100</meta>
|
||||
<meta property="calibre:user_metadata">{"#pagecount":{"#value#":353}}</meta>
|
||||
`);
|
||||
expect(parseOpf(xml).pageCount).toBe(100);
|
||||
});
|
||||
|
||||
it('prefers the EPUB3 title-type subtitle over the Calibre user_metadata subtitle', () => {
|
||||
const xml = epub3Opf(`
|
||||
<dc:title id="t1">Main Title</dc:title>
|
||||
<dc:title id="t2">Real Subtitle</dc:title>
|
||||
<meta refines="#t2" property="title-type">subtitle</meta>
|
||||
<meta property="calibre:user_metadata">{"#subtitle":{"#value#":"Calibre Subtitle"}}</meta>
|
||||
`);
|
||||
expect(parseOpf(xml).subtitle).toBe('Real Subtitle');
|
||||
});
|
||||
|
||||
it('ignores a malformed user_metadata JSON blob without throwing', () => {
|
||||
const xml = epub3Opf(`<meta property="calibre:user_metadata">{not valid json}</meta>`);
|
||||
const r = parseOpf(xml);
|
||||
expect(r.pageCount).toBeNull();
|
||||
expect(r.subtitle).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a zero or negative page count value', () => {
|
||||
expect(parseOpf(epub3Opf(`<meta property="calibre:user_metadata">{"#pagecount":{"#value#":0}}</meta>`)).pageCount).toBeNull();
|
||||
expect(parseOpf(epub3Opf(`<meta property="calibre:user_metadata">{"#pagecount":{"#value#":-5}}</meta>`)).pageCount).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a non-string subtitle value', () => {
|
||||
const xml = epub3Opf(`<meta property="calibre:user_metadata">{"#subtitle":{"#value#":42}}</meta>`);
|
||||
expect(parseOpf(xml).subtitle).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves fields null when the blob has only unrelated columns', () => {
|
||||
const xml = epub3Opf(`<meta property="calibre:user_metadata">{"#myrating":{"#value#":5}}</meta>`);
|
||||
const r = parseOpf(xml);
|
||||
expect(r.pageCount).toBeNull();
|
||||
expect(r.subtitle).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a blob that is a JSON array rather than an object', () => {
|
||||
const r = parseOpf(epub3Opf(`<meta property="calibre:user_metadata">[1,2,3]</meta>`));
|
||||
expect(r.pageCount).toBeNull();
|
||||
expect(r.subtitle).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a column whose value is not an object with a #value# key', () => {
|
||||
const r = parseOpf(epub3Opf(`<meta property="calibre:user_metadata">{"#pagecount":353,"#subtitle":{}}</meta>`));
|
||||
expect(r.pageCount).toBeNull();
|
||||
expect(r.subtitle).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a non-numeric page count value', () => {
|
||||
const r = parseOpf(epub3Opf(`<meta property="calibre:user_metadata">{"#pagecount":{"#value#":"lots"}}</meta>`));
|
||||
expect(r.pageCount).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('graceful handling of bad input', () => {
|
||||
|
||||
@@ -86,6 +86,55 @@ function parseBookOrbitTags(raw: string | null): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
type ProviderKey = 'google' | 'amazon' | 'goodreads' | 'hardcover' | 'openlibrary' | 'ranobedb' | 'kobo' | 'lubimyczytac' | 'itunes';
|
||||
|
||||
// Calibre 9.x (opf3) writes provider identifiers as bare `prefix:value` text inside <dc:identifier>.
|
||||
// Only these known prefixes are recognized, as a lowest-priority fallback after opf:scheme and urn:.
|
||||
const PREFIX_TO_PROVIDER: Record<string, ProviderKey> = {
|
||||
amazon: 'amazon',
|
||||
asin: 'amazon',
|
||||
'mobi-asin': 'amazon',
|
||||
goodreads: 'goodreads',
|
||||
google: 'google',
|
||||
openlibrary: 'openlibrary',
|
||||
hardcover: 'hardcover',
|
||||
kobo: 'kobo',
|
||||
itunes: 'itunes',
|
||||
lubimyczytac: 'lubimyczytac',
|
||||
ranobedb: 'ranobedb',
|
||||
};
|
||||
|
||||
// Calibre stores custom-column values in a `calibre:user_metadata` JSON blob keyed by column name,
|
||||
// each value carrying the actual value under `#value#`. Used only to fill page count / subtitle when null.
|
||||
function parseCalibreUserMetadata(raw: string | null): { pageCount: number | null; subtitle: string | null } {
|
||||
const empty = { pageCount: null, subtitle: null };
|
||||
if (!raw) return empty;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return empty;
|
||||
|
||||
const cols = parsed as Record<string, unknown>;
|
||||
const columnValue = (key: string): unknown => {
|
||||
const col = cols[key];
|
||||
return typeof col === 'object' && col !== null && '#value#' in col ? (col as Record<string, unknown>)['#value#'] : undefined;
|
||||
};
|
||||
const coercePageCount = (v: unknown): number | null => {
|
||||
if (v === undefined || v === null) return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) && n > 0 ? Math.round(n) : null;
|
||||
};
|
||||
|
||||
const pageCount = coercePageCount(columnValue('#pagecount')) ?? coercePageCount(columnValue('#page_count'));
|
||||
const subRaw = columnValue('#subtitle');
|
||||
const subtitle = typeof subRaw === 'string' && subRaw.trim() ? subRaw.trim() : null;
|
||||
return { pageCount, subtitle };
|
||||
}
|
||||
|
||||
function normalizeCreatorRole(role: string | null | undefined): string {
|
||||
if (!role) return '';
|
||||
const trimmed = role.trim().toLowerCase();
|
||||
@@ -153,6 +202,8 @@ export function parseOpf(xml: string): ParsedOpf {
|
||||
return null;
|
||||
}
|
||||
|
||||
const calibreUser = parseCalibreUserMetadata(propertyMeta('calibre:user_metadata'));
|
||||
|
||||
// ── Titles ─────────────────────────────────────────────────────────────────
|
||||
let title: string | null = null;
|
||||
let subtitle: string | null = null;
|
||||
@@ -172,6 +223,7 @@ export function parseOpf(xml: string): ParsedOpf {
|
||||
title ??= getText(rawTitles[0]);
|
||||
}
|
||||
subtitle ??= namedMeta('bookorbit:subtitle');
|
||||
subtitle ??= calibreUser.subtitle;
|
||||
|
||||
// ── Authors ────────────────────────────────────────────────────────────────
|
||||
const authors: { name: string; sortName: string | null }[] = [];
|
||||
@@ -222,6 +274,9 @@ export function parseOpf(xml: string): ParsedOpf {
|
||||
let urnLubimyczytacId: string | null = null;
|
||||
let urnItunesId: string | null = null;
|
||||
|
||||
// Calibre prefix:value identifiers — lowest priority, resolved after scheme and urn.
|
||||
const prefixIds: Partial<Record<ProviderKey, string>> = {};
|
||||
|
||||
for (const ident of toArray(metadata['identifier'])) {
|
||||
const mo = (typeof ident === 'object' && ident !== null ? ident : {}) as Record<string, unknown>;
|
||||
const scheme = ((mo['@_opf:scheme'] ?? mo['@_scheme'] ?? '') as string).toLowerCase().trim();
|
||||
@@ -256,25 +311,38 @@ export function parseOpf(xml: string): ParsedOpf {
|
||||
if (value.startsWith('urn:kobo:')) urnKoboId ??= value.slice('urn:kobo:'.length) || null;
|
||||
if (value.startsWith('urn:lubimyczytac:')) urnLubimyczytacId ??= value.slice('urn:lubimyczytac:'.length) || null;
|
||||
if (value.startsWith('urn:itunes:')) urnItunesId ??= value.slice('urn:itunes:'.length) || null;
|
||||
|
||||
// Calibre prefix:value — only when there is no opf:scheme attribute and the value is not a urn:
|
||||
if (scheme === '' && !value.startsWith('urn:')) {
|
||||
const colon = value.indexOf(':');
|
||||
if (colon > 0) {
|
||||
const provider = PREFIX_TO_PROVIDER[value.slice(0, colon).toLowerCase()];
|
||||
if (provider) {
|
||||
const id = value.slice(colon + 1).trim();
|
||||
if (id) prefixIds[provider] ??= id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// opf:scheme wins over urn: when both are present
|
||||
const googleBooksId = schemeGoogleBooksId ?? urnGoogleBooksId;
|
||||
const goodreadsId = schemeGoodreadsId ?? urnGoodreadsId;
|
||||
const amazonId = schemeAmazonId ?? urnAmazonId;
|
||||
const hardcoverId = schemeHardcoverId ?? urnHardcoverId;
|
||||
const openLibraryId = schemeOpenLibraryId ?? urnOpenLibraryId;
|
||||
const ranobedbId = schemeRanobedbId ?? urnRanobedbId;
|
||||
const koboId = schemeKoboId ?? urnKoboId;
|
||||
const lubimyczytacId = schemeLubimyczytacId ?? urnLubimyczytacId;
|
||||
const itunesId = schemeItunesId ?? urnItunesId;
|
||||
// Priority: opf:scheme → urn: → Calibre prefix:value
|
||||
const googleBooksId = schemeGoogleBooksId ?? urnGoogleBooksId ?? prefixIds.google ?? null;
|
||||
const goodreadsId = schemeGoodreadsId ?? urnGoodreadsId ?? prefixIds.goodreads ?? null;
|
||||
const amazonId = schemeAmazonId ?? urnAmazonId ?? prefixIds.amazon ?? null;
|
||||
const hardcoverId = schemeHardcoverId ?? urnHardcoverId ?? prefixIds.hardcover ?? null;
|
||||
const openLibraryId = schemeOpenLibraryId ?? urnOpenLibraryId ?? prefixIds.openlibrary ?? null;
|
||||
const ranobedbId = schemeRanobedbId ?? urnRanobedbId ?? prefixIds.ranobedb ?? null;
|
||||
const koboId = schemeKoboId ?? urnKoboId ?? prefixIds.kobo ?? null;
|
||||
const lubimyczytacId = schemeLubimyczytacId ?? urnLubimyczytacId ?? prefixIds.lubimyczytac ?? null;
|
||||
const itunesId = schemeItunesId ?? urnItunesId ?? prefixIds.itunes ?? null;
|
||||
|
||||
isbn10 ??= propertyMeta('bookorbit:isbn10') ?? namedMeta('bookorbit:isbn10');
|
||||
|
||||
// ── Genres and tags ────────────────────────────────────────────────────────
|
||||
const genres = toArray(metadata['subject']).map(getText).filter(Boolean);
|
||||
const tags = parseBookOrbitTags(propertyMeta('bookorbit:tags') ?? namedMeta('bookorbit:tags'));
|
||||
const pageCount = parseNumber(propertyMeta('bookorbit:page_count') ?? namedMeta('bookorbit:page_count'));
|
||||
let pageCount = parseNumber(propertyMeta('bookorbit:page_count') ?? namedMeta('bookorbit:page_count'));
|
||||
pageCount ??= calibreUser.pageCount;
|
||||
const rating = parseNumber(propertyMeta('bookorbit:rating') ?? namedMeta('bookorbit:rating'));
|
||||
|
||||
// ── Series (Calibre EPUB2, then EPUB3) ────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user