Files
bookorbit/server/src/modules/metadata/lib/opf-parser.ts
T
dependabot[bot] 3ba405729d chore(deps): bump the development-dependencies group with 21 updates (#617)
Bumps the development-dependencies group with 21 updates:

| Package | From | To |
| --- | --- | --- |
| [@commitlint/cli](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/cli) | `21.0.2` | `21.2.1` |
| [@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/config-conventional) | `21.0.2` | `21.2.0` |
| [@semantic-release/github](https://github.com/semantic-release/github) | `12.0.8` | `12.0.9` |
| [prettier](https://github.com/prettier/prettier) | `3.8.4` | `3.9.5` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.0.0` | `26.1.1` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.9` | `4.1.10` |
| [@vitest/eslint-plugin](https://github.com/vitest-dev/eslint-plugin-vitest) | `1.6.20` | `1.6.22` |
| [@vue/eslint-config-typescript](https://github.com/vuejs/eslint-config-typescript) | `14.8.0` | `14.9.0` |
| [eslint](https://github.com/eslint/eslint) | `10.5.0` | `10.6.0` |
| [eslint-plugin-oxlint](https://github.com/oxc-project/eslint-plugin-oxlint) | `1.70.0` | `1.73.0` |
| [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) | `1.70.0` | `1.73.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.16` | `8.1.4` |
| [vite-plugin-vue-devtools](https://github.com/vuejs/devtools/tree/HEAD/packages/vite) | `8.1.3` | `8.1.5` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.9` | `4.1.10` |
| [vue-tsc](https://github.com/vuejs/language-tools/tree/HEAD/packages/tsc) | `3.3.5` | `3.3.7` |
| [@nestjs/testing](https://github.com/nestjs/nest/tree/HEAD/packages/testing) | `11.1.27` | `11.1.28` |
| [@swc/core](https://github.com/swc-project/swc/tree/HEAD/packages/core) | `1.15.41` | `1.15.43` |
| [globals](https://github.com/sindresorhus/globals) | `17.6.0` | `17.7.0` |
| [ts-loader](https://github.com/TypeStrong/ts-loader) | `9.6.1` | `9.6.2` |
| [tsx](https://github.com/privatenumber/tsx) | `4.22.4` | `4.23.0` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.61.1` | `8.63.0` |

Updates `@commitlint/cli` from 21.0.2 to 21.2.1
- [Release notes](https://github.com/conventional-changelog/commitlint/releases)
- [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/cli/CHANGELOG.md)
- [Commits](https://github.com/conventional-changelog/commitlint/commits/v21.2.1/@commitlint/cli)

Updates `@commitlint/config-conventional` from 21.0.2 to 21.2.0
- [Release notes](https://github.com/conventional-changelog/commitlint/releases)
- [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/config-conventional/CHANGELOG.md)
- [Commits](https://github.com/conventional-changelog/commitlint/commits/v21.2.0/@commitlint/config-conventional)

Updates `@semantic-release/github` from 12.0.8 to 12.0.9
- [Release notes](https://github.com/semantic-release/github/releases)
- [Commits](https://github.com/semantic-release/github/compare/v12.0.8...v12.0.9)

Updates `prettier` from 3.8.4 to 3.9.5
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.4...3.9.5)

Updates `@types/node` from 26.0.0 to 26.1.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@vitest/coverage-v8` from 4.1.9 to 4.1.10
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/coverage-v8)

Updates `@vitest/eslint-plugin` from 1.6.20 to 1.6.22
- [Release notes](https://github.com/vitest-dev/eslint-plugin-vitest/releases)
- [Commits](https://github.com/vitest-dev/eslint-plugin-vitest/compare/v1.6.20...v1.6.22)

Updates `@vue/eslint-config-typescript` from 14.8.0 to 14.9.0
- [Release notes](https://github.com/vuejs/eslint-config-typescript/releases)
- [Commits](https://github.com/vuejs/eslint-config-typescript/compare/v14.8.0...v14.9.0)

Updates `eslint` from 10.5.0 to 10.6.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.5.0...v10.6.0)

Updates `eslint-plugin-oxlint` from 1.70.0 to 1.73.0
- [Release notes](https://github.com/oxc-project/eslint-plugin-oxlint/releases)
- [Commits](https://github.com/oxc-project/eslint-plugin-oxlint/compare/v1.70.0...v1.73.0)

Updates `oxlint` from 1.70.0 to 1.73.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxlint_v1.73.0/npm/oxlint)

Updates `vite` from 8.0.16 to 8.1.4
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.1.4/packages/vite)

Updates `vite-plugin-vue-devtools` from 8.1.3 to 8.1.5
- [Release notes](https://github.com/vuejs/devtools/releases)
- [Commits](https://github.com/vuejs/devtools/commits/v8.1.5/packages/vite)

Updates `vitest` from 4.1.9 to 4.1.10
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest)

Updates `vue-tsc` from 3.3.5 to 3.3.7
- [Release notes](https://github.com/vuejs/language-tools/releases)
- [Changelog](https://github.com/vuejs/language-tools/blob/master/CHANGELOG.md)
- [Commits](https://github.com/vuejs/language-tools/commits/v3.3.7/packages/tsc)

Updates `@nestjs/testing` from 11.1.27 to 11.1.28
- [Release notes](https://github.com/nestjs/nest/releases)
- [Commits](https://github.com/nestjs/nest/commits/v11.1.28/packages/testing)

Updates `@swc/core` from 1.15.41 to 1.15.43
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/commits/v1.15.43/packages/core)

Updates `globals` from 17.6.0 to 17.7.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.6.0...v17.7.0)

Updates `ts-loader` from 9.6.1 to 9.6.2
- [Release notes](https://github.com/TypeStrong/ts-loader/releases)
- [Changelog](https://github.com/TypeStrong/ts-loader/blob/main/CHANGELOG.md)
- [Commits](https://github.com/TypeStrong/ts-loader/compare/v9.6.1...v9.6.2)

Updates `tsx` from 4.22.4 to 4.23.0
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.22.4...v4.23.0)

Updates `typescript-eslint` from 8.61.1 to 8.63.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.63.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@commitlint/cli"
  dependency-version: 21.2.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: "@commitlint/config-conventional"
  dependency-version: 21.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: "@semantic-release/github"
  dependency-version: 12.0.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: prettier
  dependency-version: 3.9.5
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: "@types/node"
  dependency-version: 26.1.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@vitest/eslint-plugin"
  dependency-version: 1.6.22
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@vue/eslint-config-typescript"
  dependency-version: 14.9.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: eslint
  dependency-version: 10.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: eslint-plugin-oxlint
  dependency-version: 1.73.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: oxlint
  dependency-version: 1.73.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: vite
  dependency-version: 8.1.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: vite-plugin-vue-devtools
  dependency-version: 8.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: vitest
  dependency-version: 4.1.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: vue-tsc
  dependency-version: 3.3.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@nestjs/testing"
  dependency-version: 11.1.28
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@swc/core"
  dependency-version: 1.15.43
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: globals
  dependency-version: 17.7.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: ts-loader
  dependency-version: 9.6.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: tsx
  dependency-version: 4.23.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.63.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-09 21:53:14 -06:00

551 lines
23 KiB
TypeScript

import { XMLParser } from 'fast-xml-parser';
import { parsePublishedDateKey, parsePublishedYear } from '../../../common/utils/published-date.utils';
export interface ParsedOpf {
title: string | null;
subtitle: string | null;
description: string | null;
isbn10: string | null;
isbn13: string | null;
publisher: string | null;
publishedDate: string | null;
publishedYear: number | null;
language: string | null;
pageCount: number | null;
rating: number | null;
seriesName: string | null;
seriesIndex: number | null;
authors: { name: string; sortName: string | null }[];
genres: string[];
tags: string[];
googleBooksId: string | null;
goodreadsId: string | null;
amazonId: string | null;
hardcoverId: string | null;
hardcoverEditionId: string | null;
openLibraryId: string | null;
ranobedbId: string | null;
koboId: string | null;
lubimyczytacId: string | null;
aladinId: string | null;
itunesId: string | null;
customMetadata: Record<string, string>;
coverHref: string | null;
}
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
removeNSPrefix: true,
isArray: (name) => ['creator', 'identifier', 'subject', 'title', 'meta', 'item', 'reference'].includes(name),
textNodeName: '#text',
allowBooleanAttributes: true,
parseTagValue: false, // keep all values as strings to preserve ISBNs with leading zeroes
});
function toArray<T>(val: T | T[] | undefined): T[] {
if (val === undefined || val === null) return [];
return Array.isArray(val) ? val : [val];
}
function getText(node: unknown): string {
if (typeof node === 'string') return node.trim();
if (typeof node === 'object' && node !== null && '#text' in node) {
return String((node as Record<string, unknown>)['#text']).trim();
}
return '';
}
function parseIsbn(raw: string): {
isbn10: string | null;
isbn13: string | null;
} {
const digits = raw.replace(/[^\dX]/gi, '');
if (digits.length === 13) return { isbn10: null, isbn13: digits };
if (digits.length === 10) return { isbn10: digits, isbn13: null };
return { isbn10: null, isbn13: null };
}
function parseYear(raw: string): number | null {
return parsePublishedYear(raw) ?? null;
}
function parseNumber(raw: string | null): number | null {
if (!raw) return null;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : null;
}
function parseBookOrbitTags(raw: string | null): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.map((item) => String(item).trim()).filter(Boolean);
} catch {
return raw
.split(/[,;]/)
.map((item) => item.trim())
.filter(Boolean);
}
}
type ProviderKey =
'google' | 'amazon' | 'goodreads' | 'hardcover' | 'hardcoverEdition' | 'openlibrary' | 'ranobedb' | 'kobo' | 'lubimyczytac' | 'aladin' | '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',
hardcover_edition: 'hardcoverEdition',
'hardcover-edition': 'hardcoverEdition',
hardcoveredition: 'hardcoverEdition',
kobo: 'kobo',
itunes: 'itunes',
lubimyczytac: 'lubimyczytac',
ranobedb: 'ranobedb',
aladin: 'aladin',
};
const PROVIDER_PREFIXES: Record<ProviderKey, readonly string[]> = {
amazon: ['urn:amazon:', 'amazon:', 'asin:', 'mobi-asin:'],
google: ['urn:google:', 'google:'],
goodreads: ['urn:goodreads:', 'goodreads:'],
hardcover: ['urn:hardcover:', 'hardcover:'],
hardcoverEdition: ['urn:hardcover_edition:', 'hardcover_edition:', 'hardcover-edition:', 'hardcoveredition:'],
openlibrary: ['urn:openlibrary:', 'openlibrary:'],
ranobedb: ['urn:ranobedb:', 'ranobedb:'],
kobo: ['urn:kobo:', 'kobo:'],
lubimyczytac: ['urn:lubimyczytac:', 'lubimyczytac:'],
aladin: ['urn:aladin:', 'aladin:'],
itunes: ['urn:itunes:', 'itunes:'],
};
function normalizeProviderId(provider: ProviderKey, raw: string): string | null {
const value = raw.trim();
if (!value) return null;
const lower = value.toLowerCase();
for (const prefix of PROVIDER_PREFIXES[provider]) {
if (lower.startsWith(prefix)) {
return value.slice(prefix.length).trim() || null;
}
}
return value;
}
// 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 / extra tags when null/empty.
function parseCalibreUserMetadata(raw: string | null): { pageCount: number | null; subtitle: string | null; extraTags: string[] } {
const empty = { pageCount: null, subtitle: null, extraTags: [] };
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 coerceExtraTags = (v: unknown): string[] => {
if (v === undefined || v === null) return [];
if (Array.isArray(v)) return v.map((item) => String(item).trim()).filter(Boolean);
if (typeof v === 'string')
return v
.split(v.includes('|') ? '|' : ',')
.map((item) => item.trim())
.filter(Boolean);
return [];
};
const pageCount = coercePageCount(columnValue('#pagecount')) ?? coercePageCount(columnValue('#page_count'));
const subRaw = columnValue('#subtitle');
const subtitle = typeof subRaw === 'string' && subRaw.trim() ? subRaw.trim() : null;
const extraTags = coerceExtraTags(columnValue('#extra_tags'));
return { pageCount, subtitle, extraTags };
}
function normalizeCreatorRole(role: string | null | undefined): string {
if (!role) return '';
const trimmed = role.trim().toLowerCase();
if (!trimmed) return '';
const pathParts = trimmed.split('/');
const withoutPath = pathParts[pathParts.length - 1] ?? trimmed;
const colonParts = withoutPath.split(':');
return colonParts[colonParts.length - 1] ?? withoutPath;
}
// Build a map of id → array of refining meta nodes (EPUB 3).
function buildRefineMap(metas: unknown[]): Map<string, unknown[]> {
const map = new Map<string, unknown[]>();
for (const meta of metas) {
if (typeof meta !== 'object' || meta === null) continue;
const m = meta as Record<string, unknown>;
const refines = m['@_refines'];
if (typeof refines === 'string') {
const id = refines.startsWith('#') ? refines.slice(1) : refines;
if (!map.has(id)) map.set(id, []);
map.get(id)!.push(meta);
}
}
return map;
}
function getRefineValue(refines: Map<string, unknown[]>, id: string, property: string): string | null {
const nodes = refines.get(id) ?? [];
for (const node of nodes) {
const m = node as Record<string, unknown>;
if (m['@_property'] === property) return getText(m);
}
return null;
}
export function parseOpf(xml: string): ParsedOpf {
const root = parser.parse(xml) as Record<string, unknown>;
const pkg = (root['package'] ?? root['opf:package'] ?? {}) as Record<string, unknown>;
const metadata = (pkg['metadata'] ?? pkg['opf:metadata'] ?? {}) as Record<string, unknown>;
const rawMetas = toArray(metadata['meta']);
const refineMap = buildRefineMap(rawMetas);
// ── Calibre/EPUB2 named metas ──────────────────────────────────────────────
function namedMeta(name: string): string | null {
for (const m of rawMetas) {
if (typeof m !== 'object' || m === null) continue;
const mo = m as Record<string, unknown>;
if (mo['@_name'] === name) {
const content = mo['@_content'];
return (typeof content === 'string' ? content : '').trim() || null;
}
}
return null;
}
function propertyMeta(property: string): string | null {
for (const m of rawMetas) {
if (typeof m !== 'object' || m === null) continue;
const mo = m as Record<string, unknown>;
if (mo['@_property'] === property) {
return getText(m) || null;
}
}
return null;
}
const calibreUser = parseCalibreUserMetadata(propertyMeta('calibre:user_metadata'));
// ── Titles ─────────────────────────────────────────────────────────────────
let title: string | null = null;
let subtitle: string | null = null;
const rawTitles = toArray(metadata['title']);
if (rawTitles.length === 1) {
title = getText(rawTitles[0]);
} else if (rawTitles.length > 1) {
// EPUB 3: look for title-type refinements.
for (const t of rawTitles) {
const mo = (typeof t === 'object' && t !== null ? t : {}) as Record<string, unknown>;
const id = mo['@_id'] as string | undefined;
const type = id ? getRefineValue(refineMap, id, 'title-type') : null;
if (type === 'subtitle') subtitle = getText(t);
else title ??= getText(t);
}
title ??= getText(rawTitles[0]);
}
subtitle ??= namedMeta('bookorbit:subtitle');
subtitle ??= calibreUser.subtitle;
// ── Authors ────────────────────────────────────────────────────────────────
const authors: { name: string; sortName: string | null }[] = [];
const rawCreators = toArray(metadata['creator']);
for (const c of rawCreators) {
const mo = (typeof c === 'object' && c !== null ? c : {}) as Record<string, unknown>;
const name = getText(c);
if (!name) continue;
const id = mo['@_id'] as string | undefined;
// EPUB 3: role via refines
const role3 = id ? getRefineValue(refineMap, id, 'role') : null;
// EPUB 2: opf:role attribute
const role2 = (mo['@_opf:role'] ?? mo['@_role']) as string | undefined;
const role = normalizeCreatorRole(role3 ?? role2 ?? 'aut');
if (role !== 'aut' && role !== '') continue; // skip editors, illustrators, etc.
// EPUB 3: file-as via refines; EPUB 2: opf:file-as attribute
const sortName = (id ? getRefineValue(refineMap, id, 'file-as') : null) ?? ((mo['@_opf:file-as'] ?? mo['@_file-as'] ?? null) as string | null);
authors.push({ name, sortName: sortName?.trim() || null });
}
// ── Identifiers → ISBN + provider IDs ─────────────────────────────────────
let isbn10: string | null = null;
let isbn13: string | null = null;
// Two-pass collection: opf:scheme-based identifiers take priority over urn:-based ones
// regardless of document order. Collect both sets first, then resolve.
let schemeGoogleBooksId: string | null = null;
let schemeGoodreadsId: string | null = null;
let schemeAmazonId: string | null = null;
let schemeHardcoverId: string | null = null;
let schemeHardcoverEditionId: string | null = null;
let schemeOpenLibraryId: string | null = null;
let schemeRanobedbId: string | null = null;
let schemeKoboId: string | null = null;
let schemeLubimyczytacId: string | null = null;
let schemeAladinId: string | null = null;
let schemeItunesId: string | null = null;
let urnGoogleBooksId: string | null = null;
let urnGoodreadsId: string | null = null;
let urnAmazonId: string | null = null;
let urnHardcoverId: string | null = null;
let urnHardcoverEditionId: string | null = null;
let urnOpenLibraryId: string | null = null;
let urnRanobedbId: string | null = null;
let urnKoboId: string | null = null;
let urnLubimyczytacId: string | null = null;
let urnAladinId: string | null = null;
let urnItunesId: string | null = null;
// Calibre prefix:value identifiers have the lowest priority, 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();
const value = getText(ident);
if (!value) continue;
const lowerValue = value.toLowerCase();
const looksLikeBareIsbn = scheme === '' && /^[\d\s-]{9,17}$/.test(value) && /^\d{9}[\dX]$|^\d{13}$/.test(value.replace(/[\s-]/g, ''));
if (scheme === 'isbn' || lowerValue.includes('isbn') || looksLikeBareIsbn) {
const parsed = parseIsbn(value);
isbn10 ??= parsed.isbn10;
isbn13 ??= parsed.isbn13;
}
// opf:scheme-based provider identifiers (preferred format)
if (scheme === 'google') schemeGoogleBooksId ??= normalizeProviderId('google', value);
if (scheme === 'amazon') schemeAmazonId ??= normalizeProviderId('amazon', value);
if (scheme === 'goodreads') schemeGoodreadsId ??= normalizeProviderId('goodreads', value);
if (scheme === 'hardcover') schemeHardcoverId ??= normalizeProviderId('hardcover', value);
if (scheme === 'hardcover_edition' || scheme === 'hardcover-edition' || scheme === 'hardcoveredition') {
schemeHardcoverEditionId ??= normalizeProviderId('hardcoverEdition', value);
}
if (scheme === 'openlibrary') schemeOpenLibraryId ??= normalizeProviderId('openlibrary', value);
if (scheme === 'ranobedb') schemeRanobedbId ??= normalizeProviderId('ranobedb', value);
if (scheme === 'kobo') schemeKoboId ??= normalizeProviderId('kobo', value);
if (scheme === 'lubimyczytac') schemeLubimyczytacId ??= normalizeProviderId('lubimyczytac', value);
if (scheme === 'aladin') schemeAladinId ??= normalizeProviderId('aladin', value);
if (scheme === 'itunes') schemeItunesId ??= normalizeProviderId('itunes', value);
// urn:-prefixed provider identifiers (legacy / backward-compat)
if (lowerValue.startsWith('urn:goodreads:')) urnGoodreadsId ??= normalizeProviderId('goodreads', value);
if (lowerValue.startsWith('urn:amazon:')) urnAmazonId ??= normalizeProviderId('amazon', value);
if (lowerValue.startsWith('urn:hardcover:')) urnHardcoverId ??= normalizeProviderId('hardcover', value);
if (lowerValue.startsWith('urn:hardcover_edition:')) urnHardcoverEditionId ??= normalizeProviderId('hardcoverEdition', value);
if (lowerValue.startsWith('urn:hardcover-edition:')) urnHardcoverEditionId ??= normalizeProviderId('hardcoverEdition', value);
if (lowerValue.startsWith('urn:google:')) urnGoogleBooksId ??= normalizeProviderId('google', value);
if (lowerValue.startsWith('urn:openlibrary:')) urnOpenLibraryId ??= normalizeProviderId('openlibrary', value);
if (lowerValue.startsWith('urn:ranobedb:')) urnRanobedbId ??= normalizeProviderId('ranobedb', value);
if (lowerValue.startsWith('urn:kobo:')) urnKoboId ??= normalizeProviderId('kobo', value);
if (lowerValue.startsWith('urn:lubimyczytac:')) urnLubimyczytacId ??= normalizeProviderId('lubimyczytac', value);
if (lowerValue.startsWith('urn:aladin:')) urnAladinId ??= normalizeProviderId('aladin', value);
if (lowerValue.startsWith('urn:itunes:')) urnItunesId ??= normalizeProviderId('itunes', value);
// Calibre prefix:value only applies when there is no opf:scheme attribute and the value is not a urn.
if (scheme === '' && !lowerValue.startsWith('urn:')) {
const colon = value.indexOf(':');
if (colon > 0) {
const provider = PREFIX_TO_PROVIDER[value.slice(0, colon).toLowerCase()];
if (provider) {
const id = normalizeProviderId(provider, value);
if (id) prefixIds[provider] ??= id;
}
}
}
}
// Priority: opf:scheme, then urn:, then 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 hardcoverEditionId = schemeHardcoverEditionId ?? urnHardcoverEditionId ?? prefixIds.hardcoverEdition ?? 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 aladinId = schemeAladinId ?? urnAladinId ?? prefixIds.aladin ?? 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 tagsFromMeta = parseBookOrbitTags(propertyMeta('bookorbit:tags') ?? namedMeta('bookorbit:tags'));
const tags = [...new Set([...tagsFromMeta, ...calibreUser.extraTags])];
let pageCount = parseNumber(propertyMeta('bookorbit:page_count') ?? namedMeta('bookorbit:page_count'));
pageCount ??= calibreUser.pageCount;
const rating = parseNumber(propertyMeta('bookorbit:rating') ?? namedMeta('bookorbit:rating'));
const customMetadata = collectCustomMetadata(rawMetas);
// ── Series (Calibre EPUB2, then EPUB3) ────────────────────────────────────
let seriesName: string | null = namedMeta('calibre:series');
let seriesIndex: number | null = null;
const rawSeriesIdx = namedMeta('calibre:series_index');
if (rawSeriesIdx) {
const idx = parseFloat(rawSeriesIdx);
if (!isNaN(idx)) seriesIndex = idx;
}
if (!seriesName) {
// EPUB 3: belongs-to-collection
for (const m of rawMetas) {
if (typeof m !== 'object' || m === null) continue;
const mo = m as Record<string, unknown>;
if (mo['@_property'] === 'belongs-to-collection') {
seriesName = getText(m);
const id = mo['@_id'] as string | undefined;
if (id) {
const pos = getRefineValue(refineMap, id, 'group-position');
if (pos) seriesIndex = parseFloat(pos) || null;
}
break;
}
}
}
// ── Scalar fields ──────────────────────────────────────────────────────────
const descriptionNode = toArray(metadata['description'])[0];
const description = getText(descriptionNode) || null;
const publisher = getText(metadata['publisher']) || null;
const language = getText(metadata['language']) || null;
const rawDate = toArray(metadata['date'])[0];
const rawDateText = rawDate ? getText(rawDate) : null;
const publishedDate = rawDateText ? (parsePublishedDateKey(rawDateText) ?? null) : null;
const publishedYear = rawDateText ? parseYear(rawDateText) : null;
// ── Cover href (for sidecar OPFs linking to a sibling image file) ──────────
// Priority 1: EPUB2 <guide><reference type="cover" href="..."/>
// Priority 2: EPUB3 <manifest><item properties="cover-image" href="..."/>
// Priority 3: Calibre <meta name="cover" content="manifest-item-id"/> -> manifest lookup
let coverHref: string | null = null;
const guide = (pkg['guide'] ?? {}) as Record<string, unknown>;
for (const ref of toArray(guide['reference'])) {
const ro = (typeof ref === 'object' && ref !== null ? ref : {}) as Record<string, unknown>;
const type = ((ro['@_type'] as string | undefined) ?? '').toLowerCase().trim();
if (type === 'cover') {
const href = ((ro['@_href'] as string | undefined) ?? '').trim();
if (href) {
coverHref = href;
break;
}
}
}
const manifest = (pkg['manifest'] ?? {}) as Record<string, unknown>;
const manifestItems = toArray(manifest['item']);
if (!coverHref) {
for (const item of manifestItems) {
const io = (typeof item === 'object' && item !== null ? item : {}) as Record<string, unknown>;
const props = ((io['@_properties'] as string | undefined) ?? '').split(/\s+/);
if (props.includes('cover-image')) {
const href = ((io['@_href'] as string | undefined) ?? '').trim();
if (href) {
coverHref = href;
break;
}
}
}
}
if (!coverHref) {
const coverItemId = namedMeta('cover');
if (coverItemId) {
for (const item of manifestItems) {
const io = (typeof item === 'object' && item !== null ? item : {}) as Record<string, unknown>;
if ((io['@_id'] as string | undefined) === coverItemId) {
const href = ((io['@_href'] as string | undefined) ?? '').trim();
if (href) {
coverHref = href;
break;
}
}
}
}
}
return {
title: title || null,
subtitle: subtitle || null,
description,
isbn10,
isbn13,
publisher,
publishedDate,
publishedYear,
language: language || null,
pageCount,
rating,
seriesName: seriesName || null,
seriesIndex,
authors,
genres,
tags,
googleBooksId,
goodreadsId,
amazonId,
hardcoverId,
hardcoverEditionId,
openLibraryId,
ranobedbId,
koboId,
lubimyczytacId,
aladinId,
itunesId,
customMetadata,
coverHref,
};
}
function collectCustomMetadata(rawMetas: unknown[]): Record<string, string> {
const custom: Record<string, string> = {};
const prefix = 'bookorbit:custom:';
for (const meta of rawMetas) {
if (typeof meta !== 'object' || meta === null) continue;
const node = meta as Record<string, unknown>;
const name = typeof node['@_name'] === 'string' ? node['@_name'] : '';
const property = typeof node['@_property'] === 'string' ? node['@_property'] : '';
const keySource = name.startsWith(prefix) ? name : property.startsWith(prefix) ? property : '';
if (!keySource) continue;
const key = keySource.slice(prefix.length);
if (!/^[a-z0-9][a-z0-9_]{0,99}$/.test(key)) continue;
const content = typeof node['@_content'] === 'string' ? node['@_content'].trim() : getText(meta);
if (content) custom[key] = content;
}
return custom;
}