fix(books): list a book's formats from one ranked, read-along aware source

Every format chip, edition row and format menu now builds from shared helpers: content files
only, a read-along EPUB kept apart from a plain one, in the library's format priority, which
gains a read-along entry. Labels, colours and the chip each have one source, and cards, OPDS,
email, Kobo and the dashboard rank files the same way. Book detail file order is unchanged
because iOS builds its track queue from it.
This commit is contained in:
neon
2026-09-24 21:05:24 +00:00
parent 480904d250
commit 218f8d5dbd
70 changed files with 1415 additions and 905 deletions
+22 -1
View File
@@ -27,6 +27,21 @@ export function isComicFormat(format: string): boolean {
return COMIC_FORMATS.has(format.toLowerCase());
}
const BOOK_FORMAT_SET = new Set<string>(BOOK_FORMATS);
/** A format BookOrbit reads or plays. Covers, sidecars and anything else a book folder holds are not. */
export function isBookFormat(format: string | null | undefined): boolean {
return format != null && BOOK_FORMAT_SET.has(format.toLowerCase());
}
/**
* A readable or listenable edition of the book, as opposed to its cover or a sidecar such as an
* OPF or a text file. `primary` is the role the API reports for the book's primary file.
*/
export function isContentBookFile(file: { format: string | null; role: string }): boolean {
return (file.role === "content" || file.role === "primary") && isBookFormat(file.format);
}
/** What BookOrbit accepts as an ebook, and what an ebook tier may therefore ask for. */
export const EBOOK_FORMAT_LIST = ["epub", "kepub", "mobi", "azw3", "azw", "fb2", "pdf", "djvu"] as const;
@@ -133,7 +148,13 @@ export function getCoverMedia(files: readonly CoverMediaFile[]): CoverMedia {
}
export function getPrimaryBookFile<T extends BookMediaFile>(files: readonly T[]): T | null {
return files.find((file) => file.role === "primary") ?? files.find((file) => file.format != null) ?? files[0] ?? null;
return (
files.find((file) => file.role === "primary") ??
files.find((file) => isContentBookFile(file)) ??
files.find((file) => file.format != null) ??
files[0] ??
null
);
}
export function getBookMediaKind(format: string | null | undefined): BookMediaKind {
+61
View File
@@ -0,0 +1,61 @@
import { DEFAULT_FORMAT_PRIORITY } from "./library";
/**
* Format-priority entry that ranks EPUBs with media overlays apart from plain EPUBs. It is not a
* file format, so it stays out of `DEFAULT_FORMAT_PRIORITY`, which doubles as the list of book
* formats. A list without it ranks read-along EPUBs first within the `epub` slot, which is the same
* as placing it directly before `epub`.
*/
export const READ_ALONG_FORMAT_PRIORITY = "epub:readalong";
/**
* The key a file ranks, groups and is labelled under: its format, or `epub:readalong` for an EPUB
* with media overlays. Two EPUBs of one book stay apart only when exactly one of them reads along.
*/
export function bookFormatKey(format: string, readAlong: boolean): string {
const normalized = format.toLowerCase();
return normalized === "epub" && readAlong ? READ_ALONG_FORMAT_PRIORITY : normalized;
}
export function isReadAlongFormatKey(key: string): boolean {
return key === READ_ALONG_FORMAT_PRIORITY;
}
/** The file format a key stands for. */
export function formatOfKey(key: string): string {
return isReadAlongFormatKey(key) ? "epub" : key;
}
/** The list as the editor shows it: the read-along entry in its implicit slot when absent. */
export function withReadAlongFormatPriority(priority: readonly string[]): string[] {
if (priority.includes(READ_ALONG_FORMAT_PRIORITY)) return [...priority];
const epubIndex = priority.indexOf("epub");
if (epubIndex === -1) return [...priority, READ_ALONG_FORMAT_PRIORITY];
return [...priority.slice(0, epubIndex), READ_ALONG_FORMAT_PRIORITY, ...priority.slice(epubIndex)];
}
/**
* The list as it is saved: the read-along entry dropped when it sits in its implicit slot, so
* libraries that never move it keep the stored list, and the scan-settings hash, they had before.
*/
export function withoutImplicitReadAlongFormatPriority(priority: readonly string[]): string[] {
const index = priority.indexOf(READ_ALONG_FORMAT_PRIORITY);
if (index === -1 || priority[index + 1] !== "epub") return [...priority];
return priority.filter((_, i) => i !== index);
}
/**
* A library's stored priority completed for ranking: book formats it predates appended in default
* order, and the read-along entry placed. An empty or missing list means the default order.
*/
export function normalizeFormatPriority(stored: readonly string[] | null | undefined): string[] {
const base = stored && stored.length > 0 ? stored.map((entry) => entry.toLowerCase()) : [...DEFAULT_FORMAT_PRIORITY];
const missing = DEFAULT_FORMAT_PRIORITY.filter((format) => !base.includes(format));
return withReadAlongFormatPriority([...base, ...missing]);
}
/** Position of a key in a normalized priority; unknown keys rank last. */
export function formatKeyRank(key: string, normalizedPriority: readonly string[]): number {
const index = normalizedPriority.indexOf(key);
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
}
+1
View File
@@ -23,6 +23,7 @@ export * from "./media";
export * from "./collection";
export * from "./smart-scope";
export * from "./library";
export * from "./format-priority";
export * from "./maintenance";
export * from "./metadata-fetch";
export * from "./pattern-resolver";
-18
View File
@@ -28,24 +28,6 @@ export const DEFAULT_FORMAT_PRIORITY = [
"flac",
] as const;
export const FORMAT_LABELS: Record<string, string> = {
epub: "EPUB e-book",
kepub: "KEPUB e-book",
pdf: "PDF document",
cbz: "CBZ comic",
cbr: "CBR comic",
cb7: "CB7 comic",
mobi: "MOBI e-book",
azw3: "AZW3 e-book",
azw: "AZW e-book",
fb2: "FictionBook",
m4b: "M4B audiobook",
mp3: "MP3 audio",
m4a: "M4A audio",
opus: "Opus audio",
ogg: "OGG audio",
flac: "FLAC audio",
};
export type AccessLevel = "viewer" | "editor" | "owner";
/**