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:
131
apps/web/src/book/metadata.test.ts
Normal file
131
apps/web/src/book/metadata.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
171
apps/web/src/book/metadata.ts
Normal file
171
apps/web/src/book/metadata.ts
Normal 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";
|
||||
}
|
||||
Reference in New Issue
Block a user