fix(scanner): handle legacy Book Dock series indexes

Normalize numeric JSON snapshots at the Book Dock boundary and reject malformed runtime values without aborting filing.

Closes #1241
This commit is contained in:
neon
2026-09-02 00:58:39 +00:00
parent 43a7c671d9
commit adab5c8783
6 changed files with 79 additions and 5 deletions
+3 -2
View File
@@ -48,8 +48,9 @@ export function compareSeriesIndices(a: SeriesIndex, b: SeriesIndex): number {
}
export function formatSeriesIndex(value: SeriesIndex | null): string | null {
if (value == null) return null;
const [whole, fraction] = value.split(".");
const parsed = parseSeriesIndex(value);
if (parsed == null) return null;
const [whole, fraction] = parsed.split(".");
const padded = whole.padStart(2, "0");
return fraction === undefined ? padded : `${padded}.${fraction}`;
}
@@ -92,5 +92,21 @@ describe('pattern-tokens.utils', () => {
expect(tokens['seriesIndex']).toBe('05.02');
});
it('normalizes legacy numeric series indexes and omits malformed runtime values', () => {
const legacy = buildPatternTokens({
metadata: { ...metadata, seriesIndex: 3 } as never,
originalStem: 'book',
format: 'epub',
});
const malformed = buildPatternTokens({
metadata: { ...metadata, seriesIndex: 'volume-three' } as never,
originalStem: 'book',
format: 'epub',
});
expect(legacy['seriesIndex']).toBe('03');
expect(malformed['seriesIndex']).toBeUndefined();
});
});
});
@@ -26,6 +26,14 @@ describe('formatSeriesIndex', () => {
expect(formatSeriesIndex('5.11')).toBe('05.11');
});
it('formats legacy numeric values and rejects malformed runtime values', () => {
expect(formatSeriesIndex(3 as never)).toBe('03');
expect(formatSeriesIndex(2.5 as never)).toBe('02.5');
expect(formatSeriesIndex(Number.NaN as never)).toBeNull();
expect(formatSeriesIndex('not-an-index' as never)).toBeNull();
expect(formatSeriesIndex({} as never)).toBeNull();
});
it('validates and parses exact labels without normalizing their digits', () => {
expect(['5.10', '01', '0.5'].every(isValidSeriesIndex)).toBe(true);
expect(parseSeriesIndex(' 5.10 ')).toBe('5.10');
@@ -2078,6 +2078,28 @@ describe('BookDockFinalizeService', () => {
).resolves.toBe('/library/original.epub');
});
it('resolveDestination files legacy BookDock metadata with a numeric series index', async () => {
const { service } = makeService();
const row = makeRow({
fileName: 'Day, Sylvia - Crossfire 03 - Entwined With You - Day, Sylvia.epub',
selectedMetadata: {
title: 'Entwined With You',
authors: ['Sylvia Day'],
seriesName: 'Crossfire',
seriesIndex: 3,
} as unknown as BookDockMetadata,
});
await expect(
(service as any).resolveDestination(
{ fileNamingPattern: '<{authors:first}|Unknown Author>/<{series}/><{seriesIndex}. >{title}' },
'/library',
row,
'epub',
),
).resolves.toBe('/library/Sylvia Day/Crossfire/03. Entwined With You.epub');
});
it('resolveDestination uses folder-mode global pattern for book_per_folder libraries', async () => {
const { service, appSettings } = makeService();
appSettings.getUploadPatternBookPerFolder.mockResolvedValue('{title}/');
@@ -98,6 +98,32 @@ describe('BookDock metadata normalization', () => {
expect(normalizeBookDockMetadata({ duration: 1200, durationSeconds: 2400 })).toEqual({ durationSeconds: 2400 });
});
it('normalizes legacy numeric series indexes while preserving exact string labels', () => {
expect(
normalizeBookDockMetadata({
seriesIndex: 3,
seriesMemberships: [
{ seriesName: 'Legacy', seriesIndex: 2.5 },
{ seriesName: 'Exact', seriesIndex: '5.10' },
{ seriesName: 'Invalid', seriesIndex: 'volume-three' },
],
}),
).toEqual({
seriesIndex: '3',
seriesMemberships: [
{ seriesName: 'Legacy', seriesIndex: '2.5' },
{ seriesName: 'Exact', seriesIndex: '5.10' },
{ seriesName: 'Invalid', seriesIndex: null },
],
});
});
it('normalizes malformed primary series indexes to null', () => {
expect(normalizeBookDockMetadata({ seriesIndex: Number.NaN })).toEqual({ seriesIndex: null });
expect(normalizeBookDockMetadata({ seriesIndex: '1.2.3' })).toEqual({ seriesIndex: null });
expect(normalizeBookDockMetadata({ seriesIndex: {} })).toEqual({ seriesIndex: null });
});
it('normalizes pipeline source names to the BookDock contract and drops unknown fields', () => {
expect(
normalizeBookDockMetadataSources({
@@ -1,4 +1,4 @@
import { MetadataProviderKey, type BookDockMetadata } from '@bookorbit/types';
import { MetadataProviderKey, parseSeriesIndex, type BookDockMetadata } from '@bookorbit/types';
const PASSTHROUGH_FIELDS = [
'title',
@@ -14,7 +14,6 @@ const PASSTHROUGH_FIELDS = [
'isbn10',
'isbn13',
'seriesName',
'seriesIndex',
'genres',
'coverUrl',
'abridged',
@@ -53,6 +52,8 @@ export function normalizeBookDockMetadata(value: unknown): BookDockMetadata | nu
if (value[field] !== undefined) target[field] = value[field];
}
if (value.seriesIndex !== undefined) target.seriesIndex = parseSeriesIndex(value.seriesIndex);
const durationSeconds = value.durationSeconds !== undefined ? value.durationSeconds : value.duration;
if (durationSeconds !== undefined) target.durationSeconds = durationSeconds;
@@ -110,7 +111,7 @@ function copySeriesMemberships(source: Record<string, unknown>, target: BookDock
target.seriesMemberships = source.seriesMemberships.flatMap((value) => {
if (!isRecord(value) || typeof value.seriesName !== 'string') return [];
const membership: NonNullable<BookDockMetadata['seriesMemberships']>[number] = { seriesName: value.seriesName };
if (value.seriesIndex === null || typeof value.seriesIndex === 'string') membership.seriesIndex = value.seriesIndex;
if (value.seriesIndex !== undefined) membership.seriesIndex = parseSeriesIndex(value.seriesIndex);
return [membership];
});
}