mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(metadata): write ComicInfo Volume as an integer and order elements per schema
<Volume> is xs:int, but it received the ComicVine volume name, which is usually a title. Strict readers abort deserialization on the type error and discard every field, so metadata that is present in the archive becomes unreadable. Write the element only when the name parses as an integer, and emit elements in the schema's xs:sequence so the document validates. Closes #1064
This commit is contained in:
@@ -78,7 +78,7 @@ describe('buildComicInfoXml', () => {
|
||||
{
|
||||
seriesIndex: '1',
|
||||
comicIssueNumber: '12A',
|
||||
comicVolumeName: 'Year One',
|
||||
comicVolumeName: '2',
|
||||
comicPencillers: ['Penciller A', 'Penciller B'],
|
||||
comicInkers: ['Inker A'],
|
||||
comicColorists: ['Colorist A'],
|
||||
@@ -108,7 +108,7 @@ describe('buildComicInfoXml', () => {
|
||||
const parsed = parser.parse(xml) as { ComicInfo: Record<string, string> };
|
||||
|
||||
expect(parsed.ComicInfo.Number).toBe('12A');
|
||||
expect(parsed.ComicInfo.Volume).toBe('Year One');
|
||||
expect(parsed.ComicInfo.Volume).toBe('2');
|
||||
expect(parsed.ComicInfo.Penciller).toBe('Penciller A, Penciller B');
|
||||
expect(parsed.ComicInfo.Inker).toBe('Inker A');
|
||||
expect(parsed.ComicInfo.Colorist).toBe('Colorist A');
|
||||
@@ -120,6 +120,110 @@ describe('buildComicInfoXml', () => {
|
||||
expect(parsed.ComicInfo.StoryArc).toBe('Arc A');
|
||||
});
|
||||
|
||||
describe('Volume', () => {
|
||||
function volumeOf(comicVolumeName: string, existing: string | null = null): string | undefined {
|
||||
const xml = buildComicInfoXml(existing, { comicVolumeName }, new Set(['comicVolumeName']));
|
||||
const parsed = parser.parse(xml) as { ComicInfo: Record<string, string> };
|
||||
return parsed.ComicInfo.Volume;
|
||||
}
|
||||
|
||||
it('omits Volume when the ComicVine volume name is not an integer', () => {
|
||||
expect(volumeOf('Bone')).toBeUndefined();
|
||||
expect(volumeOf('Star Wars: Doctor Aphra')).toBeUndefined();
|
||||
expect(volumeOf('1.5')).toBeUndefined();
|
||||
expect(volumeOf('2019-2020')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('writes Volume when the volume name is an integer', () => {
|
||||
expect(volumeOf('2019')).toBe('2019');
|
||||
expect(volumeOf(' 3 ')).toBe('3');
|
||||
});
|
||||
|
||||
it('drops a non-integer Volume left behind by an earlier write', () => {
|
||||
const existing = `<?xml version="1.0"?><ComicInfo><Series>Bone</Series><Volume>Bone</Volume></ComicInfo>`;
|
||||
|
||||
expect(volumeOf('Bone', existing)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('emits elements in ComicInfo schema sequence order', () => {
|
||||
const existing = `<?xml version="1.0"?><ComicInfo><Summary>Old</Summary><Volume>9</Volume><Title>Old</Title><CustomThing>keep</CustomThing></ComicInfo>`;
|
||||
|
||||
const xml = buildComicInfoXml(
|
||||
existing,
|
||||
{
|
||||
title: 'The Promise',
|
||||
seriesName: 'Bone',
|
||||
seriesIndex: '39',
|
||||
comicVolumeName: '3',
|
||||
description: 'The Bones make their way home',
|
||||
subtitle: 'Anniversary Edition',
|
||||
publisher: 'Cartoon Books',
|
||||
publishedDate: '2000-06-15',
|
||||
authors: [{ name: 'Jeff Smith', sortName: null }],
|
||||
genres: ['Fantasy'],
|
||||
tags: ['Classic'],
|
||||
goodreadsId: '111',
|
||||
pageCount: 24,
|
||||
language: 'en',
|
||||
rating: 8,
|
||||
isbn13: '9781888963144',
|
||||
},
|
||||
new Set([
|
||||
'title',
|
||||
'seriesName',
|
||||
'seriesIndex',
|
||||
'comicVolumeName',
|
||||
'description',
|
||||
'subtitle',
|
||||
'publisher',
|
||||
'publishedDate',
|
||||
'authors',
|
||||
'genres',
|
||||
'tags',
|
||||
'goodreadsId',
|
||||
'pageCount',
|
||||
'language',
|
||||
'rating',
|
||||
'isbn13',
|
||||
]),
|
||||
);
|
||||
|
||||
const elements = [...xml.matchAll(/^\s*<(\w+)>/gm)].map((match) => match[1]);
|
||||
|
||||
expect(elements).toEqual([
|
||||
'Title',
|
||||
'Series',
|
||||
'Number',
|
||||
'Volume',
|
||||
'Summary',
|
||||
'Notes',
|
||||
'Year',
|
||||
'Month',
|
||||
'Day',
|
||||
'Writer',
|
||||
'Publisher',
|
||||
'Genre',
|
||||
'Tags',
|
||||
'Web',
|
||||
'PageCount',
|
||||
'LanguageISO',
|
||||
'CommunityRating',
|
||||
'GTIN',
|
||||
'CustomThing',
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits Month and Day when the published date carries no month or day', () => {
|
||||
const xml = buildComicInfoXml(null, { publishedDate: '1991' }, new Set(['publishedDate']));
|
||||
|
||||
const parsed = parser.parse(xml) as { ComicInfo: Record<string, string> };
|
||||
|
||||
expect(parsed.ComicInfo.Year).toBe('1991');
|
||||
expect(parsed.ComicInfo.Month).toBeUndefined();
|
||||
expect(parsed.ComicInfo.Day).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears selected comic-specific fields when payload values are empty', () => {
|
||||
const existing = `<?xml version="1.0"?><ComicInfo><Number>12</Number><Penciller>A</Penciller><Colorist>B</Colorist></ComicInfo>`;
|
||||
|
||||
|
||||
@@ -6,6 +6,55 @@ import { htmlToPlainText } from '../../../../common/utils/html-to-text.utils';
|
||||
|
||||
type ComicInfoObject = Record<string, unknown>;
|
||||
|
||||
// ComicInfo elements are an xs:sequence, so a document only validates when they appear in this
|
||||
// order. Listed per v2.1; v2.0 is the same order minus the elements it does not define.
|
||||
const COMIC_INFO_ELEMENT_ORDER = [
|
||||
'Title',
|
||||
'Series',
|
||||
'Number',
|
||||
'Count',
|
||||
'Volume',
|
||||
'AlternateSeries',
|
||||
'AlternateNumber',
|
||||
'AlternateCount',
|
||||
'Summary',
|
||||
'Notes',
|
||||
'Year',
|
||||
'Month',
|
||||
'Day',
|
||||
'Writer',
|
||||
'Penciller',
|
||||
'Inker',
|
||||
'Colorist',
|
||||
'Letterer',
|
||||
'CoverArtist',
|
||||
'Editor',
|
||||
'Translator',
|
||||
'Publisher',
|
||||
'Imprint',
|
||||
'Genre',
|
||||
'Tags',
|
||||
'Web',
|
||||
'PageCount',
|
||||
'LanguageISO',
|
||||
'Format',
|
||||
'BlackAndWhite',
|
||||
'Manga',
|
||||
'Characters',
|
||||
'Teams',
|
||||
'Locations',
|
||||
'ScanInformation',
|
||||
'StoryArc',
|
||||
'StoryArcNumber',
|
||||
'SeriesGroup',
|
||||
'AgeRating',
|
||||
'Pages',
|
||||
'CommunityRating',
|
||||
'MainCharacterOrTeam',
|
||||
'Review',
|
||||
'GTIN',
|
||||
] as const;
|
||||
|
||||
const PARSER = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
@@ -41,9 +90,11 @@ export function buildComicInfoXml(existingXml: string | null, payload: BookWrite
|
||||
setComicInfoField(info, fieldMask, 'publisher', 'Publisher', payload.publisher);
|
||||
setComicInfoField(info, fieldMask, 'seriesName', 'Series', payload.seriesName);
|
||||
setIssueNumberField(info, fieldMask, payload);
|
||||
setComicInfoField(info, fieldMask, 'comicVolumeName', 'Volume', payload.comicVolumeName);
|
||||
// <Volume> is xs:int, but comicVolumeName holds a ComicVine volume name, which is usually a title
|
||||
// ("Bone"). Emitting that makes strict readers discard the whole document, so keep only integers.
|
||||
setComicInfoField(info, fieldMask, 'comicVolumeName', 'Volume', parseComicInfoInteger(payload.comicVolumeName));
|
||||
setComicInfoPublicationDate(info, fieldMask, payload);
|
||||
setComicInfoField(info, fieldMask, 'pageCount', 'PageCount', payload.pageCount);
|
||||
setComicInfoField(info, fieldMask, 'pageCount', 'PageCount', parseComicInfoInteger(payload.pageCount));
|
||||
setComicInfoField(info, fieldMask, 'language', 'LanguageISO', payload.language);
|
||||
setComicInfoField(info, fieldMask, 'authors', 'Writer', payload.authors?.length ? payload.authors.map((a) => a.name).join(', ') : null);
|
||||
setComicInfoField(info, fieldMask, 'genres', 'Genre', payload.genres?.length ? payload.genres.join(', ') : null);
|
||||
@@ -86,10 +137,45 @@ export function buildComicInfoXml(existingXml: string | null, payload: BookWrite
|
||||
info['@_xmlns:xsd'] = 'http://www.w3.org/2001/XMLSchema';
|
||||
}
|
||||
|
||||
const xmlBody = BUILDER.build({ ComicInfo: info });
|
||||
const xmlBody = BUILDER.build({ ComicInfo: orderComicInfoElements(info) });
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n${xmlBody}`;
|
||||
}
|
||||
|
||||
function orderComicInfoElements(info: ComicInfoObject): ComicInfoObject {
|
||||
const ordered: ComicInfoObject = {};
|
||||
|
||||
for (const [key, value] of Object.entries(info)) {
|
||||
if (key.startsWith('@_')) ordered[key] = value;
|
||||
}
|
||||
for (const key of COMIC_INFO_ELEMENT_ORDER) {
|
||||
if (key in info) ordered[key] = info[key];
|
||||
}
|
||||
for (const [key, value] of Object.entries(info)) {
|
||||
if (!(key in ordered)) ordered[key] = value;
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function parseComicInfoInteger(value: unknown): number | null {
|
||||
if (typeof value === 'number') return Number.isSafeInteger(value) ? value : null;
|
||||
if (typeof value !== 'string') return null;
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!/^[+-]?\d+$/.test(trimmed)) return null;
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isSafeInteger(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function setNumericComicInfoElement(info: ComicInfoObject, comicInfoKey: string, value: number | null): void {
|
||||
if (value == null) {
|
||||
delete info[comicInfoKey];
|
||||
return;
|
||||
}
|
||||
info[comicInfoKey] = value;
|
||||
}
|
||||
|
||||
function formatRating(val: number): string {
|
||||
return Math.min(5.0, Math.max(0.0, val / 2.0)).toFixed(1);
|
||||
}
|
||||
@@ -98,13 +184,13 @@ function setComicInfoPublicationDate(info: ComicInfoObject, fieldMask: Set<BookW
|
||||
const shouldWrite = fieldMask.has('publishedDate') || fieldMask.has('publishedYear');
|
||||
if (!shouldWrite) return;
|
||||
if (payload.publishedDate) {
|
||||
info['Year'] = Number(payload.publishedDate.slice(0, 4));
|
||||
info['Month'] = Number(payload.publishedDate.slice(5, 7));
|
||||
info['Day'] = Number(payload.publishedDate.slice(8, 10));
|
||||
setNumericComicInfoElement(info, 'Year', parseComicInfoInteger(payload.publishedDate.slice(0, 4)));
|
||||
setNumericComicInfoElement(info, 'Month', parseComicInfoInteger(payload.publishedDate.slice(5, 7)));
|
||||
setNumericComicInfoElement(info, 'Day', parseComicInfoInteger(payload.publishedDate.slice(8, 10)));
|
||||
return;
|
||||
}
|
||||
if (payload.publishedYear != null) {
|
||||
info['Year'] = payload.publishedYear;
|
||||
setNumericComicInfoElement(info, 'Year', parseComicInfoInteger(payload.publishedYear));
|
||||
delete info['Month'];
|
||||
delete info['Day'];
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user