Files
bookorbit/server/src/modules/file-write/formats/cbx/comic-info-builder.test.ts
T
Gideon VanRiette 4baad41ed1 fix(metadata): extract and write back comicvine id for comics (#842)
* fix(metadata): extract and write back comicvine id for comics

ComicVine references were never read from a comic's embedded
ComicInfo.xml on import, even though every other provider id
(Goodreads, Amazon, Hardcover, Google Books, OpenLibrary, Kobo)
already round-trips through the same <Web>/Notes fields. This
affected Book Dock import, direct upload, and library scanning
alike, since they share the same extractor. Write-back had the
same gap: a comicvineId resolved via search/auto-fill was never
persisted back into the file.

Closes #840

* fix(metadata): keep comicvine id off the contested Web slot and source-check notes

ComicInfo has a single Web element and buildComicInfoXml writes the first
provider id in COMIC_INFO_PROVIDER_ID_KEYS order into it. The other six
providers are also in COMIC_INFO_MANAGED_NOTES_KEYS, so losing that slot costs
them nothing; comicvineId had no second channel, so a write-back for a book
that also carried a goodreads/amazon/hardcover/google/openlibrary/kobo id
dropped the id from the file entirely and clobbered any ComicVine URL that was
already there. Add comicvineId to the managed notes keys, which also makes the
existing managed-note read precedence reachable from our own writes.

ComicTagger emits "[Issue ID N]" for whichever talker produced the tag, naming
the source separately as "using info from <source>". Matching the bare marker
attributed Metron and other non-ComicVine issue ids to ComicVine and wrote them
back out as fabricated comicvine.gamespot.com URLs. Require the Comic Vine
source in the same sentence.

Also anchor the Web URL scan to a path segment so a segment ending in 4000-,
such as "14000-777", is no longer read as an issue id.

---------

Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
2026-07-30 18:31:27 -06:00

237 lines
8.5 KiB
TypeScript

import { XMLParser } from 'fast-xml-parser';
import { buildComicInfoXml } from './comic-info-builder';
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
trimValues: true,
parseTagValue: false,
});
describe('buildComicInfoXml', () => {
it('maps payload fields to ComicInfo schema and normalizes values', () => {
const xml = buildComicInfoXml(
null,
{
title: 'Dune',
description: '<p>Epic &amp; vast</p>',
publisher: 'Ace',
seriesName: 'Dune Saga',
seriesIndex: 1,
publishedYear: 1965,
pageCount: 412,
language: 'en',
authors: [{ name: 'Frank Herbert', sortName: null }],
genres: ['Sci-Fi'],
tags: ['Classic'],
rating: 8,
isbn13: '9780441172719',
goodreadsId: '44767458-dune',
hardcoverEditionId: '8941973',
ranobedbId: 'ranobe-1',
subtitle: 'Anniversary Edition',
isbn10: '0441172717',
},
new Set([
'title',
'description',
'publisher',
'seriesName',
'seriesIndex',
'publishedYear',
'pageCount',
'language',
'authors',
'genres',
'tags',
'rating',
'isbn13',
'goodreadsId',
'hardcoverEditionId',
'ranobedbId',
'subtitle',
'isbn10',
]),
);
const parsed = parser.parse(xml) as {
ComicInfo: Record<string, string>;
};
expect(parsed.ComicInfo.Title).toBe('Dune');
expect(parsed.ComicInfo.Summary).toBe('Epic & vast');
expect(parsed.ComicInfo.Number).toBe('1');
expect(parsed.ComicInfo.CommunityRating).toBe('4.0');
expect(parsed.ComicInfo.Web).toBe('https://www.goodreads.com/book/show/44767458-dune');
expect(parsed.ComicInfo.Notes).toContain('[bookorbit:subtitle] Anniversary Edition');
expect(parsed.ComicInfo.Notes).toContain('[bookorbit:isbn10] 0441172717');
expect(parsed.ComicInfo.Notes).toContain('[bookorbit:hardcoverEditionId] 8941973');
expect(parsed.ComicInfo.Notes).toContain('[bookorbit:ranobedbId] ranobe-1');
expect(parsed.ComicInfo['@_xmlns:xsi']).toBeDefined();
expect(parsed.ComicInfo['@_xmlns:xsd']).toBeDefined();
});
it('writes comic-specific ComicInfo fields and prefers comic issue number', () => {
const xml = buildComicInfoXml(
null,
{
seriesIndex: 1,
comicIssueNumber: '12A',
comicVolumeName: 'Year One',
comicPencillers: ['Penciller A', 'Penciller B'],
comicInkers: ['Inker A'],
comicColorists: ['Colorist A'],
comicLetterers: ['Letterer A'],
comicCoverArtists: ['Cover Artist A'],
comicCharacters: ['Character A'],
comicTeams: ['Team A'],
comicLocations: ['Location A'],
comicStoryArcs: ['Arc A'],
},
new Set([
'seriesIndex',
'comicIssueNumber',
'comicVolumeName',
'comicPencillers',
'comicInkers',
'comicColorists',
'comicLetterers',
'comicCoverArtists',
'comicCharacters',
'comicTeams',
'comicLocations',
'comicStoryArcs',
]),
);
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.Penciller).toBe('Penciller A, Penciller B');
expect(parsed.ComicInfo.Inker).toBe('Inker A');
expect(parsed.ComicInfo.Colorist).toBe('Colorist A');
expect(parsed.ComicInfo.Letterer).toBe('Letterer A');
expect(parsed.ComicInfo.CoverArtist).toBe('Cover Artist A');
expect(parsed.ComicInfo.Characters).toBe('Character A');
expect(parsed.ComicInfo.Teams).toBe('Team A');
expect(parsed.ComicInfo.Locations).toBe('Location A');
expect(parsed.ComicInfo.StoryArc).toBe('Arc A');
});
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>`;
const xml = buildComicInfoXml(
existing,
{ comicIssueNumber: ' ', comicPencillers: [], comicColorists: [] },
new Set(['comicIssueNumber', 'comicPencillers', 'comicColorists']),
);
const parsed = parser.parse(xml) as { ComicInfo: Record<string, string> };
expect(parsed.ComicInfo.Number).toBeUndefined();
expect(parsed.ComicInfo.Penciller).toBeUndefined();
expect(parsed.ComicInfo.Colorist).toBeUndefined();
});
it('keeps existing non-bookorbit note lines while replacing managed lines', () => {
const existing = `<?xml version="1.0"?><ComicInfo><Notes>Manual note\n[bookorbit:subtitle] old\n[bookorbit:goodreadsId] 1</Notes></ComicInfo>`;
const xml = buildComicInfoXml(
existing,
{
subtitle: 'new subtitle',
goodreadsId: '2',
},
new Set(['subtitle', 'goodreadsId']),
);
const parsed = parser.parse(xml) as { ComicInfo: Record<string, string> };
const notes = parsed.ComicInfo.Notes;
expect(notes).toContain('Manual note');
expect(notes).toContain('[bookorbit:subtitle] new subtitle');
expect(notes).toContain('[bookorbit:goodreadsId] 2');
expect(notes).not.toContain('[bookorbit:subtitle] old');
});
it('writes comicvineId into Web using the canonical 4000-<id> issue URL', () => {
const xml = buildComicInfoXml(null, { comicvineId: '140529' }, new Set(['comicvineId']));
const parsed = parser.parse(xml) as { ComicInfo: Record<string, string> };
expect(parsed.ComicInfo.Web).toBe('https://comicvine.gamespot.com/-/4000-140529/');
});
it('keeps comicvineId in Notes when another provider id owns the single Web slot', () => {
const xml = buildComicInfoXml(null, { goodreadsId: '111', comicvineId: '140529' }, new Set(['goodreadsId', 'comicvineId']));
const parsed = parser.parse(xml) as { ComicInfo: Record<string, string> };
expect(parsed.ComicInfo.Web).toBe('https://www.goodreads.com/book/show/111');
expect(parsed.ComicInfo.Notes).toContain('[bookorbit:comicvineId] 140529');
});
it('does not overwrite provider-derived Web/Notes when provider fields are excluded from field mask', () => {
const existing = `<?xml version="1.0"?><ComicInfo><Web>https://www.goodreads.com/book/show/111</Web><Notes>[bookorbit:goodreadsId] 111</Notes></ComicInfo>`;
const xml = buildComicInfoXml(
existing,
{
title: 'Only Title Change',
goodreadsId: '222',
},
new Set(['title']),
);
const parsed = parser.parse(xml) as { ComicInfo: Record<string, string> };
expect(parsed.ComicInfo.Web).toBe('https://www.goodreads.com/book/show/111');
expect(parsed.ComicInfo.Notes).toContain('[bookorbit:goodreadsId] 111');
expect(parsed.ComicInfo.Notes).not.toContain('[bookorbit:goodreadsId] 222');
});
describe('stripHtml (via description field)', () => {
function summaryOf(description: string): string {
const xml = buildComicInfoXml(null, { description }, new Set(['description']));
const parsed = parser.parse(xml) as { ComicInfo: Record<string, string> };
return parsed.ComicInfo.Summary ?? '';
}
it('strips plain HTML tags', () => {
expect(summaryOf('<p>Hello <b>world</b></p>')).toBe('Hello world');
});
it('decodes &amp; entity in plain text', () => {
expect(summaryOf('Rock &amp; Roll')).toBe('Rock & Roll');
});
it('decodes entity-encoded tags before stripping - prevents double-escaping bypass', () => {
// Old (buggy) order: strip tags first, then decode -> &lt;b&gt;text&lt;/b&gt; survives
// New (correct) order: decode first -> <b>text</b>, then strip -> text
expect(summaryOf('&lt;b&gt;bold text&lt;/b&gt;')).toBe('bold text');
});
it('removes entity-encoded script tags to prevent bypass', () => {
const result = summaryOf('&lt;script src="evil.js"&gt;alert(1)&lt;/script&gt; safe');
expect(result).not.toContain('<script');
expect(result).not.toContain('</script');
expect(result).toContain('safe');
});
it('handles mixed HTML and entities correctly', () => {
expect(summaryOf('<p>Epic &amp; vast</p>')).toBe('Epic & vast');
});
it('handles &quot; and &#39; entities', () => {
expect(summaryOf('She said &quot;hello&quot; and it&#39;s fine')).toBe(`She said "hello" and it's fine`);
});
it('collapses extra whitespace from removed tags', () => {
expect(summaryOf('<p> lots of space </p>')).toBe('lots of space');
});
});
});