feat(api,web): modélisation série + volume et parcours de série

- Table series + colonnes seriesId/volumeNumber/volumeLabel sur les livres,
  extraction souple des numéros (T03, 003, etc.) via extract-series-volume
- Contrôleur et page de série, badge volume sur les cartes de livre,
  styles associés (fond/clarté série, ajustements globaux)
- Statuts de scan/enrichissement et provenance des métadonnées en base

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Git Agent
2026-08-24 09:11:10 +02:00
parent 34159e5e9b
commit 93bb40a8cd
24 changed files with 1481 additions and 76 deletions

View File

@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import type { BookDto } from "@readabook/shared";
import {
bookCardVolumeLabel,
bookDisplayTitle,
bookMetadataSourceSummary,
bookMetadataStateLabel,
bookSeriesInfo,
bookSeriesLabel,
bookVolumeLabel,
displayPublishedDate,
jobDigestSummary
} from "./metadata";
type BookFixture = BookDto & {
metadataStatus: "enriched" | "partial" | "none";
metadataProvenance: Record<string, string>;
scanStatus: "idle" | "running" | "succeeded" | "failed";
enrichmentStatus: "idle" | "running" | "succeeded" | "failed";
};
const baseBook: BookFixture = {
id: 1,
libraryId: 1,
title: "Livre test",
author: null,
description: null,
isbn: null,
isbn13: null,
language: null,
publisher: null,
publishedDate: null,
format: "epub",
filePath: "/books/test.epub",
coverPath: null,
metadataStatus: "none",
metadataProvenance: {},
scanStatus: "idle",
enrichmentStatus: "idle",
fileSize: 1,
fileMtime: "2026-08-23T00:00:00.000Z",
createdAt: "2026-08-23T00:00:00.000Z",
updatedAt: "2026-08-23T00:00:00.000Z"
};
describe("book metadata presentation", () => {
it("labels externally enriched books", () => {
const book: BookFixture = { ...baseBook, metadataStatus: "enriched", enrichmentStatus: "succeeded" };
expect(bookMetadataStateLabel(book)).toBe("enrichi");
expect(bookMetadataSourceSummary(book)).toBe("source locale + enrichissement externe");
});
it("labels locally discovered metadata as partial", () => {
const book: BookFixture = { ...baseBook, metadataStatus: "partial", author: "Ada", publishedDate: "1998", scanStatus: "succeeded" };
expect(bookMetadataStateLabel(book)).toBe("partiel");
expect(bookMetadataSourceSummary(book)).toBe("source locale uniquement");
});
it("labels books without exploitable metadata as missing", () => {
expect(bookMetadataStateLabel(baseBook)).toBe("non enrichi");
expect(bookMetadataSourceSummary(baseBook)).toBe("metadata indisponible");
});
it("accepts optional series fields when the backend exposes them", () => {
const book = { ...baseBook, title: "Nom fichier", series: "Cycle", volumeNumber: 2 } as BookDto & { series: string; volumeNumber: number };
expect(bookSeriesLabel(book)).toBe("Cycle · Volume 2");
expect(bookDisplayTitle(book)).toBe("Cycle");
expect(bookVolumeLabel(book)).toBe("Volume 2");
});
it("uses backend series objects and normalized backend volume labels", () => {
const book = {
...baseBook,
title: "Daredevil",
series: { id: 1, title: "Daredevil", normalizedTitle: "daredevil", description: null, publisher: null, createdAt: baseBook.createdAt, updatedAt: baseBook.updatedAt },
volumeNumber: 1,
volumeLabel: "001"
} as BookDto;
expect(bookDisplayTitle(book)).toBe("Daredevil");
expect(bookVolumeLabel(book)).toBe("#1");
expect(bookSeriesLabel(book)).toBe("Daredevil · #1");
});
it("uses compact and unambiguous volume labels on book cards", () => {
expect(bookCardVolumeLabel({ ...baseBook, title: "Solo Leveling T03" })).toBe("T. 3");
expect(bookCardVolumeLabel({ ...baseBook, title: "Archive Volume 12" })).toBe("T. 12");
expect(bookCardVolumeLabel({ ...baseBook, title: "Daredevil #6" })).toBe("#6");
});
it("hides book card volume labels when the number is absent or ambiguous", () => {
const ambiguousBook = { ...baseBook, title: "Nom fichier", series: "Cycle", volumeLabel: "Tome final" } as BookDto & { series: string; volumeLabel: string };
expect(bookCardVolumeLabel(ambiguousBook)).toBeNull();
expect(bookCardVolumeLabel({ ...baseBook, title: "Livre sans tome" })).toBeNull();
});
it("keeps admin job digest synthetic", () => {
expect(
jobDigestSummary({
id: 1,
type: "metadata-enrich",
status: "succeeded",
detail: null,
error: null,
createdAt: baseBook.createdAt,
updatedAt: baseBook.updatedAt
})
).toBe("enrichissement externe");
});
it("hides sentinel and absent publication dates", () => {
expect(displayPublishedDate("0101-01-01T00:00:00+00:00")).toBeNull();
expect(displayPublishedDate(null)).toBeNull();
expect(displayPublishedDate("")).toBeNull();
});
it("renders only the credible publication year", () => {
expect(displayPublishedDate("2007")).toBe("2007");
expect(displayPublishedDate("2007-07-21T00:00:00+00:00")).toBe("2007");
expect(displayPublishedDate("first published in 1998")).toBe("1998");
});
it("normalizes flexible series and volume suffixes from titles", () => {
expect(bookSeriesInfo({ ...baseBook, title: "Daredevil 001" })).toEqual({ title: "Daredevil", volumeLabel: "#1", volumeNumber: 1 });
expect(bookSeriesInfo({ ...baseBook, title: "Daredevil #6" })).toEqual({ title: "Daredevil", volumeLabel: "#6", volumeNumber: 6 });
expect(bookSeriesInfo({ ...baseBook, title: "Solo Leveling T03" })).toEqual({ title: "Solo Leveling", volumeLabel: "Tome 3", volumeNumber: 3 });
expect(bookSeriesInfo({ ...baseBook, title: "Eyeshield 21 T02" })).toEqual({ title: "Eyeshield 21", volumeLabel: "Tome 2", volumeNumber: 2 });
expect(bookSeriesInfo({ ...baseBook, title: "Archive Tome 3" })).toEqual({ title: "Archive", volumeLabel: "Tome 3", volumeNumber: 3 });
expect(bookSeriesInfo({ ...baseBook, title: "Archive Volume 3" })).toEqual({ title: "Archive", volumeLabel: "Volume 3", volumeNumber: 3 });
expect(bookSeriesInfo({ ...baseBook, title: "Archive Issue 6" })).toEqual({ title: "Archive", volumeLabel: "#6", volumeNumber: 6 });
});
});

View File

@ -0,0 +1,171 @@
import type { BookDto, JobDto } from "@readabook/shared";
export type BookMetadataState = "enriched" | "partial" | "missing";
const earliestCrediblePublishedYear = 1450;
export type BookSeriesInfo = {
title: string;
volumeLabel: string | null;
volumeNumber: number | null;
};
type ExtendedBookDto = BookDto & {
scanStatus?: "idle" | "running" | "succeeded" | "failed";
enrichmentStatus?: "idle" | "running" | "succeeded" | "failed";
series?: string | { title?: string | null } | null;
seriesTitle?: string | null;
collection?: string | null;
volumeLabel?: string | null;
seriesIndex?: string | number | null;
seriesNumber?: string | number | null;
volume?: string | number | null;
volumeNumber?: string | number | null;
issue?: string | number | null;
issueNumber?: string | number | null;
};
function hasValue(value: unknown): value is string | number {
if (typeof value === "number") return Number.isFinite(value);
return typeof value === "string" && value.trim().length > 0;
}
export function bookSeriesLabel(book: BookDto): string | null {
const series = bookSeriesInfo(book);
if (!series) return null;
return [series.title, series.volumeLabel].filter(Boolean).join(" · ");
}
function numericValue(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value !== "string") return null;
const match = value.trim().match(/\d+/);
if (!match) return null;
const parsed = Number(match[0]);
return Number.isFinite(parsed) ? parsed : null;
}
function normalizeVolumeLabel(value: unknown, fallbackKind: "tome" | "volume" | "issue" = "volume"): { label: string; number: number | null } | null {
if (!hasValue(value)) return null;
const raw = String(value).trim();
const number = numericValue(raw);
if (!number) return null;
if (/^(t|tome)\s*0*\d+$/i.test(raw)) return { label: `Tome ${number}`, number };
if (/^(vol\.?|volume)\s*0*\d+$/i.test(raw)) return { label: `Volume ${number}`, number };
if (/^(#|issue)\s*0*\d+$/i.test(raw)) return { label: `#${number}`, number };
if (/^0\d{2,}$/.test(raw)) return { label: `#${number}`, number };
if (fallbackKind === "tome") return { label: `Tome ${number}`, number };
if (fallbackKind === "issue") return { label: `#${number}`, number };
return { label: `Volume ${number}`, number };
}
function titleVolumeInfo(title: string): BookSeriesInfo | null {
const trimmed = title.trim();
const patterns: Array<{ pattern: RegExp; kind: "tome" | "volume" | "issue" }> = [
{ pattern: /^(.+?)\s+(T|Tome)\s*0*(\d+)$/i, kind: "tome" },
{ pattern: /^(.+?)\s+(Vol\.?|Volume)\s*0*(\d+)$/i, kind: "volume" },
{ pattern: /^(.+?)\s+(#|Issue)\s*0*(\d+)$/i, kind: "issue" },
{ pattern: /^(.+?)\s+0*(\d{3})$/i, kind: "issue" }
];
for (const { pattern, kind } of patterns) {
const match = trimmed.match(pattern);
if (!match) continue;
const titlePart = match[1]?.trim();
const number = Number(match[3] ?? match[2]);
if (!titlePart || !Number.isFinite(number)) continue;
const normalized = normalizeVolumeLabel(number, kind);
if (!normalized) continue;
return { title: titlePart, volumeLabel: normalized.label, volumeNumber: normalized.number };
}
return null;
}
export function bookSeriesInfo(book: BookDto): BookSeriesInfo | null {
const extended = book as ExtendedBookDto;
const seriesObjectTitle =
extended.series && typeof extended.series === "object" && hasValue(extended.series.title) ? extended.series.title : null;
const series = [seriesObjectTitle, extended.series, extended.seriesTitle, extended.collection].find(hasValue);
if (series) {
const explicitLabel = normalizeVolumeLabel(extended.volumeLabel);
const issue = normalizeVolumeLabel([extended.issueNumber, extended.issue, extended.seriesNumber].find(hasValue), "issue");
const volume = normalizeVolumeLabel([extended.volumeNumber, extended.volume, extended.seriesIndex].find(hasValue), "volume");
const position = explicitLabel ?? issue ?? volume;
return {
title: String(series),
volumeLabel: position?.label ?? null,
volumeNumber: position?.number ?? null
};
}
return titleVolumeInfo(book.title);
}
export function bookDisplayTitle(book: BookDto): string {
return bookSeriesInfo(book)?.title ?? book.title;
}
export function bookVolumeLabel(book: BookDto): string | null {
return bookSeriesInfo(book)?.volumeLabel ?? null;
}
export function bookCardVolumeLabel(book: BookDto): string | null {
const series = bookSeriesInfo(book);
if (!series?.volumeNumber) return null;
if (!series.volumeLabel) return null;
if (series.volumeLabel.startsWith("#")) return series.volumeLabel;
return `T. ${series.volumeNumber}`;
}
export function displayPublishedDate(value?: string | null): string | null {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed) return null;
const yearMatch = trimmed.match(/\b(\d{4})\b/);
if (!yearMatch) return null;
const year = Number(yearMatch[1]);
const nextYear = new Date().getFullYear() + 1;
if (!Number.isInteger(year) || year < earliestCrediblePublishedYear || year > nextYear) return null;
return String(year);
}
export function usefulMetadataCount(book: BookDto): number {
return [
book.author,
displayPublishedDate(book.publishedDate),
book.publisher,
book.description,
book.isbn13,
book.isbn,
book.coverPath,
bookSeriesLabel(book)
].filter(hasValue).length;
}
export function bookMetadataState(book: BookDto): BookMetadataState {
const statuses = book as ExtendedBookDto;
if (statuses.enrichmentStatus === "succeeded") return "enriched";
if (usefulMetadataCount(book) > 0 || statuses.scanStatus === "succeeded") return "partial";
return "missing";
}
export function bookMetadataStateLabel(book: BookDto): string {
const state = bookMetadataState(book);
if (state === "enriched") return "enrichi";
if (state === "partial") return "partiel";
return "non enrichi";
}
export function bookMetadataSourceSummary(book: BookDto): string {
const statuses = book as ExtendedBookDto;
if (statuses.enrichmentStatus === "running" || statuses.scanStatus === "running") return "mise a jour en cours";
if (statuses.enrichmentStatus === "succeeded") return "source locale + enrichissement externe";
if (usefulMetadataCount(book) > 0 || statuses.scanStatus === "succeeded") return "source locale uniquement";
return "metadata indisponible";
}
export function jobDigestSummary(job: JobDto): string {
const detail = job.detail?.trim();
if (detail) return detail;
if (job.type.toLowerCase().includes("enrich")) return "enrichissement externe";
if (job.type.toLowerCase().includes("scan")) return "source locale";
return "travail catalogue";
}