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