mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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];
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user