feat(api,web): support CBR — extraction RAR, scan et lecture d'albums
- api: cbr utilitaire (extraction RAR), scanner/métadonnées (+ tests), books, schéma - web: ReaderPage/locators (+ tests), mockData, types partagés - deps: pnpm-lock Refs: #17
This commit is contained in:
@ -70,13 +70,32 @@ export const mockBooks: BookDto[] = [
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
libraryId: 2,
|
||||
title: "Cabinet noir",
|
||||
author: "L. Rar",
|
||||
description: "Archive CBR lue avec le même parcours paginé que les comics CBZ.",
|
||||
isbn: null,
|
||||
language: "fr",
|
||||
publisher: "ReadaBook",
|
||||
publishedDate: "1937",
|
||||
format: "cbr",
|
||||
filePath: "/library/cbr/cabinet-noir.cbr",
|
||||
coverPath: null,
|
||||
fileSize: 14800000,
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
];
|
||||
|
||||
export const mockProgress: ProgressDto[] = [
|
||||
{ bookId: 1, locator: "mock:chapter-3", percent: 42, updatedAt: now },
|
||||
{ bookId: 2, locator: "pdf:page:12", percent: 18, updatedAt: now },
|
||||
{ bookId: 3, locator: "cbz:page:4", percent: 40, updatedAt: now }
|
||||
{ bookId: 3, locator: "cbz:page:4", percent: 40, updatedAt: now },
|
||||
{ bookId: 4, locator: "cbr:page:6", percent: 60, updatedAt: now }
|
||||
];
|
||||
|
||||
export const mockContinue: ContinueItem[] = mockProgress.map((progress) => ({
|
||||
|
||||
@ -28,7 +28,7 @@ export function ErrorRibbon({ message }: { message?: string }) {
|
||||
return <div className="error-ribbon">{message}</div>;
|
||||
}
|
||||
|
||||
export function FormatPill({ format }: { format: "epub" | "pdf" | "cbz" }) {
|
||||
export function FormatPill({ format }: { format: "epub" | "pdf" | "cbz" | "cbr" }) {
|
||||
return <span className={`format-pill format-${format}`}>{format.toUpperCase()}</span>;
|
||||
}
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import { ErrorRibbon, Meter } from "../components/ui";
|
||||
import { navigate } from "../router";
|
||||
import { CbzReader } from "../reader/CbzReader";
|
||||
import { EpubReader } from "../reader/EpubReader";
|
||||
import { parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
|
||||
import { parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
|
||||
import { PdfReader } from "../reader/PdfReader";
|
||||
import { useReaderProgress } from "../reader/useReaderProgress";
|
||||
|
||||
@ -36,7 +36,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
||||
}, [bookId]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextPage = parsePdfPageLocator(progress?.locator) ?? parseCbzPageLocator(progress?.locator);
|
||||
const nextPage = parsePdfPageLocator(progress?.locator) ?? parseCbzPageLocator(progress?.locator) ?? parseCbrPageLocator(progress?.locator);
|
||||
if (nextPage) setPage((current) => (current === nextPage ? current : nextPage));
|
||||
}, [progress]);
|
||||
|
||||
@ -49,12 +49,13 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
||||
[save]
|
||||
);
|
||||
const saveEpubLocator = useCallback((locator: string, percent: number) => void save(locator, percent), [save]);
|
||||
const saveCbzPage = useCallback(
|
||||
const saveComicPage = useCallback(
|
||||
(nextPage: number, pages: number) => {
|
||||
setPage(nextPage);
|
||||
void save(`cbz:page:${nextPage}`, pdfPagePercent(nextPage, pages));
|
||||
const prefix = book?.format === "cbr" ? "cbr" : "cbz";
|
||||
void save(`${prefix}:page:${nextPage}`, pdfPagePercent(nextPage, pages));
|
||||
},
|
||||
[save]
|
||||
[book?.format, save]
|
||||
);
|
||||
|
||||
return (
|
||||
@ -87,8 +88,8 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
||||
</div>
|
||||
) : book.format === "pdf" ? (
|
||||
<PdfReader url={fileUrl} page={page} onPageCommit={savePdfPage} />
|
||||
) : book.format === "cbz" ? (
|
||||
<CbzReader bookId={book.id} page={page} onPageCommit={saveCbzPage} />
|
||||
) : book.format === "cbz" || book.format === "cbr" ? (
|
||||
<CbzReader bookId={book.id} page={page} onPageCommit={saveComicPage} />
|
||||
) : (
|
||||
<EpubReader url={fileUrl} locator={progress?.locator} onLocatorChange={saveEpubLocator} />
|
||||
)}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "./locators";
|
||||
import { parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "./locators";
|
||||
|
||||
describe("reader locators", () => {
|
||||
it("parses valid PDF page locators", () => {
|
||||
@ -16,6 +16,11 @@ describe("reader locators", () => {
|
||||
expect(parseCbzPageLocator("pdf:page:7")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses CBR page locators", () => {
|
||||
expect(parseCbrPageLocator("cbr:page:9")).toBe(9);
|
||||
expect(parseCbrPageLocator("cbz:page:9")).toBeNull();
|
||||
});
|
||||
|
||||
it("bounds PDF page percentages", () => {
|
||||
expect(pdfPagePercent(2, 4)).toBe(50);
|
||||
expect(pdfPagePercent(8, 4)).toBe(100);
|
||||
|
||||
@ -8,6 +8,11 @@ export function parseCbzPageLocator(locator?: string | null): number | null {
|
||||
return parsePageSuffix(locator);
|
||||
}
|
||||
|
||||
export function parseCbrPageLocator(locator?: string | null): number | null {
|
||||
if (!locator?.startsWith("cbr:page:")) return null;
|
||||
return parsePageSuffix(locator);
|
||||
}
|
||||
|
||||
function parsePageSuffix(locator: string): number | null {
|
||||
const value = Number(locator.split(":").at(-1));
|
||||
return Number.isInteger(value) && value > 0 ? value : null;
|
||||
|
||||
@ -263,6 +263,11 @@ h2 {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.format-cbr {
|
||||
background: #6c4f2a;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.continue-grid,
|
||||
.library-list,
|
||||
.job-list {
|
||||
|
||||
Reference in New Issue
Block a user