mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
3ba405729d
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>
551 lines
23 KiB
TypeScript
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;
|
|
}
|