fix(server): normalize Calibre indexes and restore image builds

Normalize integer-like Calibre OPF positions while preserving exact fractional labels. Make the static reply decoration explicit so Nest accepts production container builds.

Fixes #1212
This commit is contained in:
neon
2026-08-30 16:50:23 +00:00
parent cf84e37b2b
commit abc543044f
4 changed files with 44 additions and 6 deletions
@@ -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);
});
+4 -1
View File
@@ -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');
});
};
@@ -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(`
<meta name="calibre:series" content="Discworld"/>
<meta name="calibre:series_index" content="5.10"/>
<meta name="calibre:series_index" content="${embedded}"/>
`);
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(`
<meta id="series" property="belongs-to-collection">Dune Chronicles</meta>
<meta refines="#series" property="collection-type">series</meta>
<meta refines="#series" property="group-position">1.0</meta>
`);
expect(parseOpf(xml).seriesIndex).toBe('1.0');
});
it('Calibre series takes precedence over EPUB3 belongs-to-collection', () => {
const xml = epub3Opf(`
<meta name="calibre:series" content="Calibre Series"/>
+10 -1
View File
@@ -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) {