From 88d814596d5ca77e400f37a2c80bffafa6ee68ca Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:01:16 +0000 Subject: [PATCH] fix(metadata): write ComicInfo Volume as an integer and order elements per schema 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 --- .../formats/cbx/comic-info-builder.test.ts | 108 +++++++++++++++++- .../formats/cbx/comic-info-builder.ts | 100 ++++++++++++++-- 2 files changed, 199 insertions(+), 9 deletions(-) diff --git a/server/src/modules/file-write/formats/cbx/comic-info-builder.test.ts b/server/src/modules/file-write/formats/cbx/comic-info-builder.test.ts index a30ae2455..5b772655a 100644 --- a/server/src/modules/file-write/formats/cbx/comic-info-builder.test.ts +++ b/server/src/modules/file-write/formats/cbx/comic-info-builder.test.ts @@ -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 }; 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 }; + 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 = `BoneBone`; + + expect(volumeOf('Bone', existing)).toBeUndefined(); + }); + }); + + it('emits elements in ComicInfo schema sequence order', () => { + const existing = `Old9Oldkeep`; + + 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 }; + + 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 = `12AB`; diff --git a/server/src/modules/file-write/formats/cbx/comic-info-builder.ts b/server/src/modules/file-write/formats/cbx/comic-info-builder.ts index f3ba715fd..6e30f004b 100644 --- a/server/src/modules/file-write/formats/cbx/comic-info-builder.ts +++ b/server/src/modules/file-write/formats/cbx/comic-info-builder.ts @@ -6,6 +6,55 @@ import { htmlToPlainText } from '../../../../common/utils/html-to-text.utils'; type ComicInfoObject = Record; +// 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); + // 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 `\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