diff --git a/server/src/common/utils/series-index-format.utils.test.ts b/server/src/common/utils/series-index-format.utils.test.ts index f1debe16d..5787c1b7b 100644 --- a/server/src/common/utils/series-index-format.utils.test.ts +++ b/server/src/common/utils/series-index-format.utils.test.ts @@ -29,6 +29,7 @@ describe('formatSeriesIndex', () => { 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'); + expect(parseSeriesIndex('1.0')).toBe('1.0'); expect(parseSeriesIndex(5.1)).toBe('5.1'); expect(['', ' ', '-1', '1e2', '1.2.3', '123456789012345678901'].some(isValidSeriesIndex)).toBe(false); }); diff --git a/server/src/main.ts b/server/src/main.ts index 2d5ca3be3..65186ff59 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -107,7 +107,10 @@ async function bootstrap() { if (!shouldServeSpaFallback(request.url)) { return reply.status(404).send({ statusCode: 404, message: 'Not Found', path: request.url }); } - return reply.sendFile('index.html'); + // Nest's SWC type checker does not retain @fastify/static's reply augmentation + // through the adapter callback, even though the plugin decorates this reply at runtime. + const staticReply = reply as typeof reply & { sendFile(filename: string): typeof reply }; + return staticReply.sendFile('index.html'); }); }; diff --git a/server/src/modules/metadata/lib/opf-parser.test.ts b/server/src/modules/metadata/lib/opf-parser.test.ts index 9908d03d3..9feff0072 100644 --- a/server/src/modules/metadata/lib/opf-parser.test.ts +++ b/server/src/modules/metadata/lib/opf-parser.test.ts @@ -340,13 +340,28 @@ describe('parseOpf', () => { expect(r.seriesIndex).toBe('1'); }); - it('parses fractional series index', () => { + it.each([ + ['1.0', '1'], + ['2.0', '2'], + ['10.0', '10'], + ['0.0', '0'], + ['01.0', '01'], + ['1', '1'], + ['1.5', '1.5'], + ['5.10', '5.10'], + ['5.01', '5.01'], + ['1.00', '1.00'], + [' 1.0 ', '1'], + ['-1.0', null], + ['1e2', null], + ['1.0.0', null], + ])('maps Calibre series index %s to %s', (embedded, expected) => { const xml = epub2Opf(` - + `); - const r = parseOpf(xml); - expect(r.seriesIndex).toBe('5.10'); + + expect(parseOpf(xml).seriesIndex).toBe(expected); }); it('parses EPUB3 belongs-to-collection series', () => { @@ -360,6 +375,16 @@ describe('parseOpf', () => { expect(r.seriesIndex).toBe('1'); }); + it('preserves an exact EPUB3 group-position ending in .0', () => { + const xml = epub3Opf(` + Dune Chronicles + series + 1.0 + `); + + expect(parseOpf(xml).seriesIndex).toBe('1.0'); + }); + it('Calibre series takes precedence over EPUB3 belongs-to-collection', () => { const xml = epub3Opf(` diff --git a/server/src/modules/metadata/lib/opf-parser.ts b/server/src/modules/metadata/lib/opf-parser.ts index ea8e46d24..3a11bd293 100644 --- a/server/src/modules/metadata/lib/opf-parser.ts +++ b/server/src/modules/metadata/lib/opf-parser.ts @@ -81,6 +81,15 @@ function parseNumber(raw: string | null): number | null { return Number.isFinite(parsed) ? parsed : null; } +function parseCalibreSeriesIndex(raw: string): string | null { + const parsed = parseSeriesIndex(raw); + if (parsed === null) return null; + + // Calibre serializes whole-number series positions with a single `.0`; other + // fractional digits are exact user-facing labels and must remain untouched. + return /^\d+\.0$/.test(parsed) ? parsed.slice(0, -2) : parsed; +} + function parseBookOrbitTags(raw: string | null): string[] { if (!raw) return []; try { @@ -461,7 +470,7 @@ export function parseOpf(xml: string): ParsedOpf { let seriesIndex: string | null = null; const rawSeriesIdx = namedMeta('calibre:series_index'); if (rawSeriesIdx) { - seriesIndex = parseSeriesIndex(rawSeriesIdx); + seriesIndex = parseCalibreSeriesIndex(rawSeriesIdx); } if (!seriesName) {