mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
4baad41ed1
* 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>
237 lines
8.5 KiB
TypeScript
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 & 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 & entity in plain text', () => {
|
|
expect(summaryOf('Rock & Roll')).toBe('Rock & Roll');
|
|
});
|
|
|
|
it('decodes entity-encoded tags before stripping - prevents double-escaping bypass', () => {
|
|
// Old (buggy) order: strip tags first, then decode -> <b>text</b> survives
|
|
// New (correct) order: decode first -> <b>text</b>, then strip -> text
|
|
expect(summaryOf('<b>bold text</b>')).toBe('bold text');
|
|
});
|
|
|
|
it('removes entity-encoded script tags to prevent bypass', () => {
|
|
const result = summaryOf('<script src="evil.js">alert(1)</script> 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 & vast</p>')).toBe('Epic & vast');
|
|
});
|
|
|
|
it('handles " and ' entities', () => {
|
|
expect(summaryOf('She said "hello" and it'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');
|
|
});
|
|
});
|
|
});
|