mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(metadata): preserve epub creators with multiple roles
Read every EPUB 3 role refinement so authors and narrators survive regardless of role order, including creators who hold both roles. Preserve EPUB 2 role precedence and contributor handling, and add regression coverage for ordering, normalization, and deduplication. Fixes #1344
This commit is contained in:
@@ -167,6 +167,97 @@ describe('parseOpf', () => {
|
||||
expect(r.authors[0].sortName).toBe('Pratchett, Terry');
|
||||
});
|
||||
|
||||
it.each(
|
||||
[
|
||||
['aut', 'ann', 'dto', 'ill', 'wpr'],
|
||||
['ann', 'aut', 'dto', 'ill', 'wpr'],
|
||||
['ann', 'dto', 'aut', 'ill', 'wpr'],
|
||||
['ann', 'dto', 'ill', 'aut', 'wpr'],
|
||||
['ann', 'dto', 'ill', 'wpr', 'aut'],
|
||||
].map((roles) => ({ roles })),
|
||||
)('includes a multi-role author with roles $roles', ({ roles }) => {
|
||||
const xml = epub3Opf(`
|
||||
<dc:title>Flatland</dc:title>
|
||||
<dc:creator id="author">Edwin A. Abbott</dc:creator>
|
||||
<meta property="file-as" refines="#author">Abbott, Edwin A.</meta>
|
||||
${roles.map((role) => `<meta property="role" refines="#author" scheme="marc:relators">${role}</meta>`).join('\n')}
|
||||
`);
|
||||
|
||||
const r = parseOpf(xml);
|
||||
expect(r.title).toBe('Flatland');
|
||||
expect(r.authors).toEqual([{ name: 'Edwin A. Abbott', sortName: 'Abbott, Edwin A.' }]);
|
||||
expect(r.narrators).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([' AUT ', 'http://id.loc.gov/vocabulary/relators/aut', 'marc:aut'])('normalizes a later author role %s', (role) => {
|
||||
const xml = epub3Opf(`
|
||||
<dc:creator id="author">Author Name</dc:creator>
|
||||
<meta refines="#author" property="role">ann</meta>
|
||||
<meta refines="#author" property="role">${role}</meta>
|
||||
`);
|
||||
|
||||
expect(parseOpf(xml).authors).toEqual([{ name: 'Author Name', sortName: null }]);
|
||||
});
|
||||
|
||||
it.each([{ roles: ['ann', 'ill'] }, { roles: ['', 'edt'] }, { roles: ['edt', ''] }])(
|
||||
'excludes a creator with no author role among $roles',
|
||||
({ roles }) => {
|
||||
const xml = epub3Opf(`
|
||||
<dc:creator id="person">Editor Name</dc:creator>
|
||||
${roles.map((role) => `<meta refines="#person" property="role">${role}</meta>`).join('\n')}
|
||||
`);
|
||||
|
||||
expect(parseOpf(xml).authors).toEqual([]);
|
||||
expect(parseOpf(xml).narrators).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps a creator whose role refinements are all empty as an author', () => {
|
||||
const xml = epub3Opf(`
|
||||
<dc:creator id="author">Author Name</dc:creator>
|
||||
<meta refines="#author" property="role"></meta>
|
||||
<meta refines="#author" property="role"> </meta>
|
||||
`);
|
||||
|
||||
expect(parseOpf(xml).authors).toEqual([{ name: 'Author Name', sortName: null }]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ attribute: 'edt', refinements: ['ann', 'aut'], isAuthor: true },
|
||||
{ attribute: 'aut', refinements: ['ann', 'ill'], isAuthor: false },
|
||||
{ attribute: 'edt', refinements: [], isAuthor: false },
|
||||
{ attribute: 'aut', refinements: [], isAuthor: true },
|
||||
{ attribute: 'edt', refinements: [''], isAuthor: true },
|
||||
])('preserves role precedence for attribute $attribute and refinements $refinements', ({ attribute, refinements, isAuthor }) => {
|
||||
const xml = epub3Opf(`
|
||||
<dc:creator xmlns:opf="http://www.idpf.org/2007/opf" id="author" opf:role="${attribute}" opf:file-as="Name, Author">Author Name</dc:creator>
|
||||
${refinements.map((role) => `<meta refines="#author" property="role">${role}</meta>`).join('\n')}
|
||||
`);
|
||||
|
||||
expect(parseOpf(xml).authors).toEqual(isAuthor ? [{ name: 'Author Name', sortName: 'Name, Author' }] : []);
|
||||
});
|
||||
|
||||
it('keeps roles scoped to their person and emits each creator once in document order', () => {
|
||||
const xml = epub3Opf(`
|
||||
<dc:creator id="editor">Editor Name</dc:creator>
|
||||
<dc:creator id="first">First Author</dc:creator>
|
||||
<dc:creator id="second">Second Author</dc:creator>
|
||||
<meta refines="#editor" property="role">edt</meta>
|
||||
<meta refines="#second" property="role">ill</meta>
|
||||
<meta refines="#first" property="role">ann</meta>
|
||||
<meta refines="#second" property="role">aut</meta>
|
||||
<meta refines="#first" property="role">aut</meta>
|
||||
<meta refines="#first" property="role">aut</meta>
|
||||
<meta refines="#first" property="file-as">Author, First</meta>
|
||||
<meta refines="#first" property="file-as">Unused Sort Name</meta>
|
||||
`);
|
||||
|
||||
expect(parseOpf(xml).authors).toEqual([
|
||||
{ name: 'First Author', sortName: 'Author, First' },
|
||||
{ name: 'Second Author', sortName: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts EPUB3 role values in full relator URI form', () => {
|
||||
const xml = epub3Opf(`
|
||||
<dc:creator id="cr1">Ursula Le Guin</dc:creator>
|
||||
@@ -229,6 +320,59 @@ describe('parseOpf', () => {
|
||||
expect(r.authors).toEqual([{ name: 'Neil Gaiman', sortName: null }]);
|
||||
});
|
||||
|
||||
it.each(['creator', 'contributor'])('recognizes a later narrator role on a %s', (element) => {
|
||||
const xml = epub3Opf(`
|
||||
<dc:${element} id="person">Narrator Name</dc:${element}>
|
||||
<meta refines="#person" property="role">ann</meta>
|
||||
<meta refines="#person" property="role">http://id.loc.gov/vocabulary/relators/nrt</meta>
|
||||
`);
|
||||
|
||||
const r = parseOpf(xml);
|
||||
expect(r.narrators).toEqual(['Narrator Name']);
|
||||
expect(r.authors).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([{ roles: ['aut', 'nrt'] }, { roles: ['nrt', 'aut'] }])('records a self-narrating creator in both roles for $roles', ({ roles }) => {
|
||||
const xml = epub3Opf(`
|
||||
<dc:creator id="person">Author Narrator</dc:creator>
|
||||
<meta refines="#person" property="file-as">Narrator, Author</meta>
|
||||
${roles.map((role) => `<meta refines="#person" property="role">${role}</meta>`).join('\n')}
|
||||
`);
|
||||
|
||||
const r = parseOpf(xml);
|
||||
expect(r.authors).toEqual([{ name: 'Author Narrator', sortName: 'Narrator, Author' }]);
|
||||
expect(r.narrators).toEqual(['Author Narrator']);
|
||||
});
|
||||
|
||||
it('does not skip authorship when a multi-role creator repeats a previously collected narrator', () => {
|
||||
const xml = epub3Opf(`
|
||||
<dc:creator id="narrator">Author Narrator</dc:creator>
|
||||
<dc:creator id="author">author narrator</dc:creator>
|
||||
<meta refines="#narrator" property="role">nrt</meta>
|
||||
<meta refines="#author" property="role">nrt</meta>
|
||||
<meta refines="#author" property="role">aut</meta>
|
||||
<meta refines="#author" property="role">narrator</meta>
|
||||
`);
|
||||
|
||||
const r = parseOpf(xml);
|
||||
expect(r.authors).toEqual([{ name: 'author narrator', sortName: null }]);
|
||||
expect(r.narrators).toEqual(['Author Narrator']);
|
||||
});
|
||||
|
||||
it.each([{ roles: [] }, { roles: [''] }, { roles: ['aut'] }, { roles: ['aut', 'nrt'] }])(
|
||||
'does not promote a contributor with roles $roles to an author',
|
||||
({ roles }) => {
|
||||
const xml = epub3Opf(`
|
||||
<dc:contributor id="person">Contributor Name</dc:contributor>
|
||||
${roles.map((role) => `<meta refines="#person" property="role">${role}</meta>`).join('\n')}
|
||||
`);
|
||||
|
||||
const r = parseOpf(xml);
|
||||
expect(r.authors).toEqual([]);
|
||||
expect(r.narrators).toEqual(roles.includes('nrt') ? ['Contributor Name'] : []);
|
||||
},
|
||||
);
|
||||
|
||||
it('accepts a narrator role in full relator URI form', () => {
|
||||
const xml = epub3Opf(`
|
||||
<dc:contributor id="ct1">Rosamund Pike</dc:contributor>
|
||||
|
||||
@@ -279,6 +279,15 @@ function getRefineValue(refines: Map<string, unknown[]>, id: string, property: s
|
||||
return null;
|
||||
}
|
||||
|
||||
function getRefineValues(refines: Map<string, unknown[]>, id: string, property: string): string[] {
|
||||
const values: string[] = [];
|
||||
for (const node of refines.get(id) ?? []) {
|
||||
const m = node as Record<string, unknown>;
|
||||
if (m['@_property'] === property) values.push(getText(m));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
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>;
|
||||
@@ -350,21 +359,21 @@ export function parseOpf(xml: string): ParsedOpf {
|
||||
|
||||
const id = mo['@_id'] as string | undefined;
|
||||
// EPUB 3: role via refines
|
||||
const role3 = id ? getRefineValue(refineMap, id, 'role') : null;
|
||||
const roles3 = id ? getRefineValues(refineMap, id, 'role') : [];
|
||||
// EPUB 2: opf:role attribute
|
||||
const role2 = (mo['@_opf:role'] ?? mo['@_role']) as string | undefined;
|
||||
const role = normalizeCreatorRole(role3 ?? role2 ?? defaults.role);
|
||||
const roles = (roles3.length > 0 ? roles3 : [role2 ?? defaults.role]).map(normalizeCreatorRole).filter(Boolean);
|
||||
|
||||
if (NARRATOR_ROLES.has(role)) {
|
||||
if (roles.some((role) => NARRATOR_ROLES.has(role))) {
|
||||
const key = name.toLowerCase();
|
||||
if (seenNarrators.has(key)) return;
|
||||
seenNarrators.add(key);
|
||||
narrators.push(name);
|
||||
return;
|
||||
if (!seenNarrators.has(key)) {
|
||||
seenNarrators.add(key);
|
||||
narrators.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (!defaults.allowAuthor) return;
|
||||
if (role !== 'aut' && role !== '') return; // skip editors, illustrators, etc.
|
||||
if (roles.length > 0 && !roles.includes('aut')) return; // 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);
|
||||
|
||||
Reference in New Issue
Block a user