mirror of
https://github.com/chmouel/liseur-desktop.git
synced 2026-10-19 07:43:24 +00:00
feat: Group multi-volume series into collapsible library stacks
Extracted series metadata from EPUB files and remote sync providers to fold multi-volume series into interactive stacks on the main shelf. Added a dedicated series view so readers could browse ordered volumes without cluttering the main library screen. Signed-off-by: Chmouel Boudjnah <chmouel@chmouel.com>
This commit is contained in:
parent
4dafa0b6a0
commit
0a00ccb27b
27 changed files with 805 additions and 46 deletions
|
|
@ -53,6 +53,12 @@ Library UX conventions carried over from Android:
|
|||
a paper-and-leather surface with a faint cover wash in light mode, stronger
|
||||
cover artwork in dark mode, spanning the same shelf width as the book row
|
||||
- Book card: cover + badges (download/server/finished) + title + author
|
||||
- Series with two or more volumes collapse into a stack in the same grid;
|
||||
stacks file alongside standalone books and open to their ordered,
|
||||
virtualized volume grid. A stack is keyed by its normalized name so local
|
||||
and server copies remain together. liseur-sync memberships are preserved
|
||||
in full: a book that belongs to multiple series appears in each relevant
|
||||
stack.
|
||||
|
||||
The settings panel belongs to the application shell rather than to the
|
||||
library, and paints over whichever screen is showing. Anything scoped to one
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { createSignal, For, onCleanup, Show, type JSX } from 'solid-js'
|
||||
import type { LibraryFilter, LibrarySortKey } from '@shared/domain/types'
|
||||
import { createEffect, createSignal, For, on, onCleanup, Show, type JSX } from 'solid-js'
|
||||
import type {
|
||||
LibraryEntry,
|
||||
LibraryFilter,
|
||||
LibrarySeriesEntry,
|
||||
LibrarySortKey,
|
||||
} from '@shared/domain/types'
|
||||
import { useLibraryStore } from './store'
|
||||
import { VirtualBookGrid } from './VirtualBookGrid'
|
||||
import { ContinueReading } from './ContinueReading'
|
||||
|
|
@ -29,10 +34,25 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }):
|
|||
const [statsOpen, setStatsOpen] = createSignal(false)
|
||||
const [sortMenuOpen, setSortMenuOpen] = createSignal(false)
|
||||
const [helpOpen, setHelpOpen] = createSignal(false)
|
||||
const [openSeries, setOpenSeries] = createSignal<LibrarySeriesEntry | null>(null)
|
||||
let searchInput: HTMLInputElement | undefined
|
||||
let gridEl: HTMLDivElement | undefined
|
||||
const vim = createVimSession<LibraryCommand>(LIBRARY_BINDINGS)
|
||||
|
||||
// Keep an open stack live when sync replaces one of its volumes. The store
|
||||
// updates entries incrementally, so this does not need another SQL query.
|
||||
createEffect(
|
||||
on(
|
||||
() => store.entries(),
|
||||
(entries) => {
|
||||
const current = openSeries()
|
||||
if (!current) return
|
||||
const fresh = entries.find((entry) => entry.kind === 'series' && entry.id === current.id)
|
||||
if (fresh?.kind === 'series') setOpenSeries(fresh)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// Chips a library has nothing to say with are left off rather than shown
|
||||
// inert: with no server every book is downloaded, and with nothing put
|
||||
// away the archive drawer is empty.
|
||||
|
|
@ -94,6 +114,12 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }):
|
|||
if (e.key === 'Escape' || (vimMode() && e.key === 'q')) setStatsOpen(false)
|
||||
return
|
||||
}
|
||||
if (openSeries() && e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setOpenSeries(null)
|
||||
setSelectedIndex(-1)
|
||||
return
|
||||
}
|
||||
if (sortMenuOpen() && e.key === 'Escape') {
|
||||
setSortMenuOpen(false)
|
||||
return
|
||||
|
|
@ -115,7 +141,7 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }):
|
|||
return
|
||||
}
|
||||
|
||||
const count = store.books().length
|
||||
const count = visibleEntries().length
|
||||
if (count === 0) return
|
||||
|
||||
switch (e.key) {
|
||||
|
|
@ -132,7 +158,7 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }):
|
|||
setSelectedIndex((i) => Math.max(0, i - gridColumns()))
|
||||
break
|
||||
case 'Enter':
|
||||
if (selectedIndex() >= 0) openBook(selectedIndex())
|
||||
if (selectedIndex() >= 0) openEntry(selectedIndex())
|
||||
break
|
||||
default:
|
||||
return
|
||||
|
|
@ -152,7 +178,7 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }):
|
|||
|
||||
/** Moves the selection, landing on the first book when nothing is picked. */
|
||||
const moveSelection = (delta: number) => {
|
||||
const count = store.books().length
|
||||
const count = visibleEntries().length
|
||||
if (count === 0) return
|
||||
setSelectedIndex((i) => (i < 0 ? 0 : Math.min(count - 1, Math.max(0, i + delta))))
|
||||
}
|
||||
|
|
@ -166,7 +192,7 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }):
|
|||
}
|
||||
|
||||
function runVimCommand(command: LibraryCommand, count: number | null): void {
|
||||
const books = store.books()
|
||||
const entries = visibleEntries()
|
||||
switch (command) {
|
||||
case 'left':
|
||||
times(count, () => moveSelection(-1))
|
||||
|
|
@ -186,17 +212,19 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }):
|
|||
case 'rowEnd':
|
||||
if (selectedIndex() >= 0) {
|
||||
setSelectedIndex((i) =>
|
||||
Math.min(books.length - 1, i - (i % gridColumns()) + gridColumns() - 1),
|
||||
Math.min(entries.length - 1, i - (i % gridColumns()) + gridColumns() - 1),
|
||||
)
|
||||
}
|
||||
break
|
||||
case 'first':
|
||||
if (books.length > 0) setSelectedIndex(0)
|
||||
if (entries.length > 0) setSelectedIndex(0)
|
||||
break
|
||||
case 'last':
|
||||
// `12G` is the twelfth book, as `12G` is the twelfth line.
|
||||
if (books.length > 0) {
|
||||
setSelectedIndex(count === null ? books.length - 1 : Math.min(books.length, count) - 1)
|
||||
if (entries.length > 0) {
|
||||
setSelectedIndex(
|
||||
count === null ? entries.length - 1 : Math.min(entries.length, count) - 1,
|
||||
)
|
||||
}
|
||||
break
|
||||
case 'halfPageDown':
|
||||
|
|
@ -206,7 +234,7 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }):
|
|||
moveSelection(-gridColumns() * Math.max(1, Math.floor(gridRows() / 2)))
|
||||
break
|
||||
case 'open':
|
||||
if (selectedIndex() >= 0) openBook(selectedIndex())
|
||||
if (selectedIndex() >= 0) openEntry(selectedIndex())
|
||||
break
|
||||
case 'continueReading': {
|
||||
const book = store.continueReadingBook()
|
||||
|
|
@ -248,16 +276,31 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }):
|
|||
setHelpOpen(true)
|
||||
break
|
||||
case 'closeOverlay':
|
||||
if (sortMenuOpen()) setSortMenuOpen(false)
|
||||
if (openSeries()) {
|
||||
setOpenSeries(null)
|
||||
setSelectedIndex(-1)
|
||||
} else if (sortMenuOpen()) setSortMenuOpen(false)
|
||||
else if (searchOpen()) closeSearch()
|
||||
else setSelectedIndex(-1)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const openBook = (index: number) => {
|
||||
const book = store.books()[index]
|
||||
if (!book) return
|
||||
const visibleEntries = (): LibraryEntry[] =>
|
||||
openSeries()
|
||||
? openSeries()!.books.map((book) => ({ kind: 'book', id: `book:${book.id}`, book }))
|
||||
: store.entries()
|
||||
|
||||
const openEntry = (index: number) => {
|
||||
const entry = visibleEntries()[index]
|
||||
if (!entry) return
|
||||
if (entry.kind === 'series') {
|
||||
setOpenSeries(entry)
|
||||
setSelectedIndex(-1)
|
||||
gridEl?.scrollTo({ top: 0 })
|
||||
return
|
||||
}
|
||||
const book = entry.book
|
||||
// Nothing to open until a book has a file; remote books fetch on open.
|
||||
if (!book.localPath && !book.remoteId) return
|
||||
props.onOpenBook(book.id)
|
||||
|
|
@ -451,21 +494,35 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }):
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={openSeries()}>
|
||||
{(series) => (
|
||||
<div class="series-header">
|
||||
<button type="button" class="series-back" onClick={() => setOpenSeries(null)}>
|
||||
← Library
|
||||
</button>
|
||||
<div>
|
||||
<h1>{series().name}</h1>
|
||||
<p>{series().books.length} books</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<main class="library-main">
|
||||
<Show when={!store.loading() && store.books().length === 0}>
|
||||
<Show when={!store.loading() && visibleEntries().length === 0}>
|
||||
<p class="empty-state">
|
||||
{store.searchText() ? `No books match “${store.searchText()}”.` : 'No books here yet.'}
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<VirtualBookGrid
|
||||
books={store.books}
|
||||
entries={visibleEntries}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={setSelectedIndex}
|
||||
onOpen={openBook}
|
||||
onOpen={(_entry, index) => openEntry(index)}
|
||||
gridRef={(el) => (gridEl = el)}
|
||||
header={
|
||||
<Show when={store.filter() === 'all' && !store.searchText()}>
|
||||
<Show when={!openSeries() && store.filter() === 'all' && !store.searchText()}>
|
||||
<ContinueReading
|
||||
book={store.continueReadingBook()}
|
||||
onOpen={() => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createSignal, createEffect, onCleanup, onMount, For, type JSX } from 'solid-js'
|
||||
import type { Book } from '@shared/domain/types'
|
||||
import type { LibraryEntry } from '@shared/domain/types'
|
||||
import { coverFor, requestRemoteCover } from './covers'
|
||||
import {
|
||||
computeColumns,
|
||||
|
|
@ -23,10 +23,10 @@ const GAP = GRID_GAP
|
|||
const ROW_HEIGHT = GRID_ROW_HEIGHT
|
||||
|
||||
interface Props {
|
||||
books: () => Book[]
|
||||
entries: () => LibraryEntry[]
|
||||
selectedIndex: () => number
|
||||
onSelect: (index: number) => void
|
||||
onOpen: (index: number) => void
|
||||
onOpen: (entry: LibraryEntry, index: number) => void
|
||||
gridRef?: (el: HTMLDivElement) => void
|
||||
/** Optional content that scrolls away above the grid (e.g. the
|
||||
continue-reading banner). Its height is measured, so the row math
|
||||
|
|
@ -62,7 +62,7 @@ export function VirtualBookGrid(props: Props): JSX.Element {
|
|||
|
||||
const range = () =>
|
||||
computeRange(
|
||||
props.books().length,
|
||||
props.entries().length,
|
||||
columns(),
|
||||
ROW_HEIGHT,
|
||||
scrollTop(),
|
||||
|
|
@ -93,7 +93,7 @@ export function VirtualBookGrid(props: Props): JSX.Element {
|
|||
}
|
||||
})
|
||||
|
||||
const visible = () => props.books().slice(range().start, range().end)
|
||||
const visible = () => props.entries().slice(range().start, range().end)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -105,7 +105,7 @@ export function VirtualBookGrid(props: Props): JSX.Element {
|
|||
onScroll={onScroll}
|
||||
role="grid"
|
||||
aria-label="Library"
|
||||
aria-rowcount={Math.ceil(props.books().length / columns())}
|
||||
aria-rowcount={Math.ceil(props.entries().length / columns())}
|
||||
>
|
||||
{/* Always in the tree so one observer registration at mount is
|
||||
enough: when the banner is gated off, the wrapper measures 0. */}
|
||||
|
|
@ -127,8 +127,11 @@ export function VirtualBookGrid(props: Props): JSX.Element {
|
|||
}}
|
||||
>
|
||||
<For each={visible()}>
|
||||
{(book, localIndex) => {
|
||||
{(entry, localIndex) => {
|
||||
const index = () => range().start + localIndex()
|
||||
const book = entry.kind === 'series' ? entry.cover : entry.book
|
||||
const title = entry.kind === 'series' ? entry.name : book.title
|
||||
const authors = entry.kind === 'series' ? entry.authors : book.authors
|
||||
// Books that live on a server arrive with no cover art. Ask
|
||||
// for it as the card is mounted, which virtualization already
|
||||
// means is roughly "as it comes into view".
|
||||
|
|
@ -137,16 +140,23 @@ export function VirtualBookGrid(props: Props): JSX.Element {
|
|||
<button
|
||||
type="button"
|
||||
class="book-card"
|
||||
classList={{ selected: props.selectedIndex() === index() }}
|
||||
classList={{
|
||||
selected: props.selectedIndex() === index(),
|
||||
'series-card': entry.kind === 'series',
|
||||
}}
|
||||
// One click opens. Selection still moves so the arrow keys
|
||||
// carry on from wherever the last click landed.
|
||||
onClick={() => {
|
||||
props.onSelect(index())
|
||||
props.onOpen(index())
|
||||
props.onOpen(entry, index())
|
||||
}}
|
||||
role="gridcell"
|
||||
aria-selected={props.selectedIndex() === index()}
|
||||
aria-label={`${book.title} by ${book.authors.join(', ')}`}
|
||||
aria-label={
|
||||
entry.kind === 'series'
|
||||
? `${entry.name}, ${entry.books.length} books`
|
||||
: `${book.title} by ${authors.join(', ')}`
|
||||
}
|
||||
>
|
||||
<div class="book-cover-wrap">
|
||||
<img
|
||||
|
|
@ -159,6 +169,9 @@ export function VirtualBookGrid(props: Props): JSX.Element {
|
|||
width={CARD_WIDTH}
|
||||
height={GRID_COVER_HEIGHT}
|
||||
/>
|
||||
{entry.kind === 'series' && (
|
||||
<span class="series-count">{entry.books.length}</span>
|
||||
)}
|
||||
{book.finished && <span class="badge badge-finished">Finished</span>}
|
||||
{book.downloaded && (
|
||||
<span class="badge badge-downloaded" title="Downloaded">
|
||||
|
|
@ -171,8 +184,10 @@ export function VirtualBookGrid(props: Props): JSX.Element {
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span class="book-title">{book.title}</span>
|
||||
<span class="book-author">{book.authors.join(', ')}</span>
|
||||
<span class="book-title">{title}</span>
|
||||
<span class="book-author">
|
||||
{entry.kind === 'series' ? `${entry.books.length} books` : authors.join(', ')}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { createSignal } from 'solid-js'
|
||||
import type {
|
||||
Book,
|
||||
LibraryEntry,
|
||||
LibraryFilter,
|
||||
LibraryQueryResult,
|
||||
LibrarySortKey,
|
||||
|
|
@ -29,6 +30,7 @@ const [sort, setSortSignal] = createSignal<LibrarySortKey>(DEFAULT_QUERY.sort)
|
|||
const [direction, setDirectionSignal] = createSignal<SortDirection>(DEFAULT_QUERY.direction)
|
||||
|
||||
const [books, setBooks] = createSignal<Book[]>([])
|
||||
const [entries, setEntries] = createSignal<LibraryEntry[]>([])
|
||||
const [totalCount, setTotalCount] = createSignal(0)
|
||||
const [archivedCount, setArchivedCount] = createSignal(0)
|
||||
const [hasServer, setHasServer] = createSignal(false)
|
||||
|
|
@ -45,6 +47,7 @@ export function useLibraryStore() {
|
|||
sort,
|
||||
direction,
|
||||
books,
|
||||
entries,
|
||||
totalCount,
|
||||
archivedCount,
|
||||
hasServer,
|
||||
|
|
@ -92,6 +95,7 @@ async function refresh(): Promise<void> {
|
|||
const result: LibraryQueryResult = await window.liseur.library.query(buildQuery())
|
||||
if (myGeneration !== generation) return // stale — a newer query is in flight
|
||||
setBooks(result.books)
|
||||
setEntries(result.entries)
|
||||
setTotalCount(result.totalCount)
|
||||
setArchivedCount(result.archivedCount)
|
||||
setLoading(false)
|
||||
|
|
@ -112,6 +116,27 @@ async function refreshContinueReading(): Promise<void> {
|
|||
|
||||
let bookAddedDebounce: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function replaceBookInEntries(current: LibraryEntry[], book: Book): LibraryEntry[] {
|
||||
return current.map((entry) => {
|
||||
if (entry.kind === 'book') {
|
||||
return entry.book.id === book.id ? { ...entry, book } : entry
|
||||
}
|
||||
const index = entry.books.findIndex((volume) => volume.id === book.id)
|
||||
if (index === -1) return entry
|
||||
const books = entry.books.slice()
|
||||
books[index] = book
|
||||
const cover =
|
||||
books.find((volume) => {
|
||||
const progression = volume.progress?.progression ?? 0
|
||||
return !volume.finished && progression > 0 && progression < 1
|
||||
}) ??
|
||||
books.find((volume) => !volume.finished) ??
|
||||
books[0] ??
|
||||
entry.cover
|
||||
return { ...entry, books, cover }
|
||||
})
|
||||
}
|
||||
|
||||
export function initLibrary(): void {
|
||||
void refresh()
|
||||
void refreshContinueReading()
|
||||
|
|
@ -127,6 +152,7 @@ export function initLibrary(): void {
|
|||
next[index] = book
|
||||
return next
|
||||
})
|
||||
setEntries((current) => replaceBookInEntries(current, book))
|
||||
// The banner holds its own copy of the book. Closing the reader shows
|
||||
// the library before the final position is saved (visual first), so the
|
||||
// save lands here — without this the banner kept showing the position
|
||||
|
|
|
|||
|
|
@ -305,6 +305,75 @@
|
|||
bottom: 28px;
|
||||
}
|
||||
|
||||
/* A stack keeps the familiar book silhouette, then earns its own small
|
||||
count marker. The detail still uses the normal virtualized grid. */
|
||||
.series-card .book-cover-wrap::before,
|
||||
.series-card .book-cover-wrap::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 5px -5px -5px 5px;
|
||||
z-index: -1;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.series-card .book-cover-wrap::after {
|
||||
inset: 9px -9px -9px 9px;
|
||||
z-index: -2;
|
||||
}
|
||||
|
||||
.series-count {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
min-width: 22px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.series-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.series-header h1,
|
||||
.series-header p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.series-header h1 {
|
||||
font-family: Georgia, serif;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.series-header p {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.series-back {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.series-back:hover {
|
||||
background: var(--bg-sunken);
|
||||
}
|
||||
|
||||
/* --- Settings overlay (M7) --- */
|
||||
|
||||
.settings-overlay {
|
||||
|
|
|
|||
21
src/shared/domain/series.ts
Normal file
21
src/shared/domain/series.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { SeriesMembership } from './types'
|
||||
|
||||
/** Parses the untrusted series payload used by sync and the library database. */
|
||||
export function parseSeriesMemberships(value: unknown): SeriesMembership[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.flatMap((membership) => {
|
||||
if (!membership || typeof membership !== 'object') return []
|
||||
const raw = membership as { id?: unknown; name?: unknown; position?: unknown }
|
||||
const name = typeof raw.name === 'string' ? raw.name.trim() : ''
|
||||
if (!name) return []
|
||||
const position =
|
||||
typeof raw.position === 'number' && Number.isFinite(raw.position) ? raw.position : undefined
|
||||
return [
|
||||
{
|
||||
...(typeof raw.id === 'string' && raw.id ? { id: raw.id } : {}),
|
||||
name,
|
||||
...(position !== undefined ? { position } : {}),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
|
@ -33,6 +33,15 @@ export interface ReadingProgress {
|
|||
updatedAt: number
|
||||
}
|
||||
|
||||
/** One series a book belongs to. A book may be in more than one. */
|
||||
export interface SeriesMembership {
|
||||
/** A server's stable id, retained for future series actions. */
|
||||
id?: string | undefined
|
||||
name: string
|
||||
/** The volume number when the source knows it. */
|
||||
position?: number | undefined
|
||||
}
|
||||
|
||||
export interface Book {
|
||||
id: BookId
|
||||
title: string
|
||||
|
|
@ -49,6 +58,29 @@ export interface Book {
|
|||
addedAt: number
|
||||
lastOpenedAt?: number
|
||||
progress?: ReadingProgress
|
||||
/** Effective series memberships from the file or catalog. */
|
||||
series?: SeriesMembership[]
|
||||
}
|
||||
|
||||
/** One card on the library shelf: either a book or a stack of volumes. */
|
||||
export type LibraryEntry = LibraryBookEntry | LibrarySeriesEntry
|
||||
|
||||
export interface LibraryBookEntry {
|
||||
kind: 'book'
|
||||
id: string
|
||||
book: Book
|
||||
}
|
||||
|
||||
export interface LibrarySeriesEntry {
|
||||
kind: 'series'
|
||||
/** Stable while the series name remains the same. */
|
||||
id: string
|
||||
name: string
|
||||
authors: string[]
|
||||
/** Ordered volumes, including server-only books. */
|
||||
books: Book[]
|
||||
/** The cover for the next unread volume, or the first volume. */
|
||||
cover: Book
|
||||
}
|
||||
|
||||
export type LibraryFilter = 'all' | 'downloaded' | 'unread' | 'archived'
|
||||
|
|
@ -66,6 +98,8 @@ export interface LibraryQuery {
|
|||
|
||||
export interface LibraryQueryResult {
|
||||
books: Book[]
|
||||
/** Grouped shelf entries, prepared by the worker so the renderer stays responsive. */
|
||||
entries: LibraryEntry[]
|
||||
totalCount: number
|
||||
/**
|
||||
* How many books are archived, whatever the filter asked for. The library
|
||||
|
|
|
|||
|
|
@ -275,4 +275,13 @@ export const MIGRATIONS: readonly Migration[] = [
|
|||
`)
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 10,
|
||||
name: 'book series memberships',
|
||||
up: (db) => {
|
||||
// A JSON array preserves liseur-sync's many memberships without
|
||||
// flattening a book into a single, arbitrary series.
|
||||
db.exec("ALTER TABLE books ADD COLUMN series TEXT NOT NULL DEFAULT '[]';")
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ export interface EpubMetadata {
|
|||
authors: string[]
|
||||
/** dc:identifier — ISBN or UUID when present; used for dedupe. */
|
||||
identifier?: string | undefined
|
||||
/** Calibre and EPUB 3 collection metadata, when the file supplies it. */
|
||||
series?: { name: string; position?: number | undefined } | undefined
|
||||
}
|
||||
|
||||
export interface EpubCover {
|
||||
|
|
@ -89,6 +91,17 @@ function parseOpf(xml: string, opfDir: string): Opf {
|
|||
const manifest = new Map<string, ManifestItem>()
|
||||
const spine: { idref: string; linear: boolean }[] = []
|
||||
let metaCoverId: string | undefined
|
||||
let calibreSeries: string | undefined
|
||||
let calibreSeriesPosition: number | undefined
|
||||
let epubSeries: { id?: string; name?: string; position?: number | undefined } | undefined
|
||||
const epubSeriesPositions = new Map<string, number>()
|
||||
let collectingSeriesMeta:
|
||||
| {
|
||||
property: 'belongs-to-collection' | 'group-position'
|
||||
id?: string | undefined
|
||||
refines?: string | undefined
|
||||
}
|
||||
| undefined
|
||||
|
||||
// Depth stack so we only pick up text of the element we're inside.
|
||||
const stack: string[] = []
|
||||
|
|
@ -112,6 +125,21 @@ function parseOpf(xml: string, opfDir: string): Opf {
|
|||
if (el.attributes['name'] === 'cover' && el.attributes['content']) {
|
||||
metaCoverId = el.attributes['content']
|
||||
}
|
||||
if (el.attributes['name'] === 'calibre:series' && el.attributes['content']) {
|
||||
calibreSeries = el.attributes['content'].trim() || undefined
|
||||
}
|
||||
if (el.attributes['name'] === 'calibre:series_index' && el.attributes['content']) {
|
||||
const position = Number(el.attributes['content'])
|
||||
calibreSeriesPosition = Number.isFinite(position) ? position : undefined
|
||||
}
|
||||
const property = el.attributes['property']
|
||||
if (property === 'belongs-to-collection') {
|
||||
collectingSeriesMeta = { property, id: el.attributes['id'] }
|
||||
collector = textCollector()
|
||||
} else if (property === 'group-position' && el.attributes['refines']) {
|
||||
collectingSeriesMeta = { property, refines: el.attributes['refines'].replace(/^#/, '') }
|
||||
collector = textCollector()
|
||||
}
|
||||
} else if (el.localName === 'item') {
|
||||
const id = el.attributes['id']
|
||||
const href = el.attributes['href']
|
||||
|
|
@ -132,7 +160,27 @@ function parseOpf(xml: string, opfDir: string): Opf {
|
|||
onText: (text) => collector?.onText(text),
|
||||
onEnd: (_name, localName) => {
|
||||
stack.pop()
|
||||
if (
|
||||
if (collector && collectingSeriesMeta && localName === 'meta') {
|
||||
const value = collector.value()
|
||||
const meta = collectingSeriesMeta
|
||||
collector = undefined
|
||||
collectingSeriesMeta = undefined
|
||||
if (meta.property === 'belongs-to-collection') {
|
||||
epubSeries = {
|
||||
...(meta.id ? { id: meta.id } : {}),
|
||||
name: value,
|
||||
...(meta.id && epubSeriesPositions.has(meta.id)
|
||||
? { position: epubSeriesPositions.get(meta.id) }
|
||||
: {}),
|
||||
}
|
||||
} else {
|
||||
const position = Number(value)
|
||||
if (Number.isFinite(position) && meta.refines) {
|
||||
epubSeriesPositions.set(meta.refines, position)
|
||||
if (epubSeries?.id === meta.refines) epubSeries.position = position
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
collector &&
|
||||
(localName === 'title' || localName === 'creator' || localName === 'identifier')
|
||||
) {
|
||||
|
|
@ -175,6 +223,18 @@ function parseOpf(xml: string, opfDir: string): Opf {
|
|||
title: titles[0],
|
||||
authors: creators,
|
||||
identifier,
|
||||
...(calibreSeries || epubSeries?.name
|
||||
? {
|
||||
series: {
|
||||
name: calibreSeries ?? epubSeries!.name!,
|
||||
...(calibreSeriesPosition !== undefined
|
||||
? { position: calibreSeriesPosition }
|
||||
: epubSeries?.position !== undefined
|
||||
? { position: epubSeries.position }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
cover,
|
||||
manifest,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import type { DatabaseSync } from 'node:sqlite'
|
||||
import type { Book, LibraryQuery, ReadingProgress } from '../../shared/domain/types'
|
||||
import type {
|
||||
Book,
|
||||
LibraryQuery,
|
||||
ReadingProgress,
|
||||
SeriesMembership,
|
||||
} from '../../shared/domain/types'
|
||||
import { parseSeriesMemberships } from '../../shared/domain/series'
|
||||
|
||||
/**
|
||||
* Book repository — all SQL touching the library tables lives here.
|
||||
|
|
@ -41,12 +47,13 @@ interface BookRow {
|
|||
locator: string | null
|
||||
progression: number | null
|
||||
progress_updated_at: number | null
|
||||
series: string
|
||||
}
|
||||
|
||||
const BOOK_SELECT = `
|
||||
SELECT b.id, b.title, b.authors, b.local_path, b.remote_id, b.server_id, b.cover_id,
|
||||
b.finished, b.archived, b.downloaded, b.added_at, b.last_opened_at,
|
||||
p.locator, p.progression, p.updated_at AS progress_updated_at
|
||||
p.locator, p.progression, p.updated_at AS progress_updated_at, b.series
|
||||
FROM books b
|
||||
LEFT JOIN reading_progress p ON p.book_id = b.id
|
||||
`
|
||||
|
|
@ -95,6 +102,13 @@ export function rowToBook(row: BookRow): Book {
|
|||
if (row.server_id !== null) book.serverId = row.server_id
|
||||
if (row.cover_id !== null) book.coverId = row.cover_id
|
||||
if (row.last_opened_at !== null) book.lastOpenedAt = row.last_opened_at
|
||||
let series: SeriesMembership[] = []
|
||||
try {
|
||||
series = parseSeriesMemberships(JSON.parse(row.series))
|
||||
} catch {
|
||||
// A malformed legacy row should not make the whole library unreadable.
|
||||
}
|
||||
if (series.length > 0) book.series = series
|
||||
if (row.locator !== null && row.progress_updated_at !== null) {
|
||||
const progress: ReadingProgress = {
|
||||
locator: JSON.parse(row.locator) as ReadingProgress['locator'],
|
||||
|
|
@ -114,9 +128,13 @@ export class BookRepository {
|
|||
const params: string[] = []
|
||||
const term = query.search.trim()
|
||||
if (term) {
|
||||
sql += ` AND (b.title LIKE ? ESCAPE '\\' OR b.authors LIKE ? ESCAPE '\\')`
|
||||
sql += ` AND (b.title LIKE ? ESCAPE '\\' OR b.authors LIKE ? ESCAPE '\\'
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM json_each(b.series) AS series_membership
|
||||
WHERE json_extract(series_membership.value, '$.name') LIKE ? ESCAPE '\\'
|
||||
))`
|
||||
const pattern = likePattern(term)
|
||||
params.push(pattern, pattern)
|
||||
params.push(pattern, pattern, pattern)
|
||||
}
|
||||
sql += ` ${orderBy(query)}`
|
||||
const rows = this.db.prepare(sql).all(...params) as unknown as BookRow[]
|
||||
|
|
@ -308,8 +326,8 @@ export class BookRepository {
|
|||
const insertBook = this.db.prepare(`
|
||||
INSERT INTO books (id, folder_id, title, authors, local_path, remote_id, cover_id,
|
||||
finished, archived, downloaded, added_at, last_opened_at,
|
||||
file_hash, epub_identifier, file_mtime, file_size)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
file_hash, epub_identifier, file_mtime, file_size, series)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
const insertProgress = this.db.prepare(`
|
||||
INSERT INTO reading_progress (book_id, locator, progression, updated_at)
|
||||
|
|
@ -336,6 +354,7 @@ export class BookRepository {
|
|||
extra?.epubIdentifier ?? null,
|
||||
extra?.fileMtime ?? null,
|
||||
extra?.fileSize ?? null,
|
||||
JSON.stringify(book.series ?? []),
|
||||
)
|
||||
if (book.progress) {
|
||||
insertProgress.run(
|
||||
|
|
|
|||
|
|
@ -169,6 +169,11 @@ export function generateFakeLibrary(size = FAKE_LIBRARY_SIZE, seed = FAKE_LIBRAR
|
|||
addedAt,
|
||||
}
|
||||
|
||||
// A stable pair makes the grouped-shelf behavior observable in the
|
||||
// production-build browser test without changing how real libraries are
|
||||
// seeded or requiring an extra test-only IPC operation.
|
||||
if (i < 2) book.series = [{ name: 'The Test Sequence', position: i + 1 }]
|
||||
|
||||
if (rng() < 0.5) {
|
||||
book.lastOpenedAt = now - Math.floor(rng() * 60) * day
|
||||
}
|
||||
|
|
|
|||
165
src/worker/library/grouping.ts
Normal file
165
src/worker/library/grouping.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import type {
|
||||
Book,
|
||||
LibraryEntry,
|
||||
LibraryQuery,
|
||||
LibrarySeriesEntry,
|
||||
} from '../../shared/domain/types'
|
||||
|
||||
/**
|
||||
* Folds the shelf exactly as Android does: a series belongs under its
|
||||
* normalized display name, not a server id, so a downloaded copy and its
|
||||
* catalog sibling do not become two stacks. A repeated title is the rare
|
||||
* trade-off for keeping the common local-plus-server case together.
|
||||
*/
|
||||
function sortKey(value: string): string {
|
||||
const folded = value
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.trim()
|
||||
.toLocaleLowerCase()
|
||||
for (const elision of ["l'", 'l’', "d'", 'd’']) {
|
||||
if (folded.startsWith(elision) && folded.slice(elision.length).trim()) {
|
||||
return folded.slice(elision.length).trimStart()
|
||||
}
|
||||
}
|
||||
const article = /^(?:the|a|an|le|la|les|un|une|des|du|de)\s+/.exec(folded)
|
||||
return article ? folded.slice(article[0].length) : folded
|
||||
}
|
||||
|
||||
function seriesKey(name: string): string {
|
||||
return sortKey(name)
|
||||
}
|
||||
|
||||
function membershipFor(book: Book, key: string) {
|
||||
return book.series?.find((membership) => seriesKey(membership.name) === key)
|
||||
}
|
||||
|
||||
function commonest(values: readonly string[]): string {
|
||||
const counts = new Map<string, number>()
|
||||
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1)
|
||||
return values.reduce((best, value) =>
|
||||
(counts.get(value) ?? 0) > (counts.get(best) ?? 0) ? value : best,
|
||||
)
|
||||
}
|
||||
|
||||
function volumeOrder(key: string, left: Book, right: Book): number {
|
||||
const leftPosition = membershipFor(left, key)?.position
|
||||
const rightPosition = membershipFor(right, key)?.position
|
||||
const leftKnown = leftPosition !== undefined
|
||||
const rightKnown = rightPosition !== undefined
|
||||
if (leftKnown !== rightKnown) return leftKnown ? -1 : 1
|
||||
if (leftPosition !== rightPosition) return (leftPosition ?? 0) - (rightPosition ?? 0)
|
||||
return sortKey(left.title).localeCompare(sortKey(right.title)) || left.id.localeCompare(right.id)
|
||||
}
|
||||
|
||||
function seriesEntries(books: readonly Book[]): LibrarySeriesEntry[] {
|
||||
const bySeries = new Map<string, Book[]>()
|
||||
for (const book of books) {
|
||||
for (const membership of book.series ?? []) {
|
||||
const key = seriesKey(membership.name)
|
||||
if (!key) continue
|
||||
const volumes = bySeries.get(key)
|
||||
if (volumes) volumes.push(book)
|
||||
else bySeries.set(key, [book])
|
||||
}
|
||||
}
|
||||
|
||||
return [...bySeries.entries()].flatMap(([key, volumes]) => {
|
||||
// A series of one stays a normal book card. Opening a page that contains
|
||||
// exactly the card it replaced adds friction without helping discovery.
|
||||
if (volumes.length < 2) return []
|
||||
const ordered = [...volumes].sort((left, right) => volumeOrder(key, left, right))
|
||||
const cover =
|
||||
ordered.find((book) => {
|
||||
const progression = book.progress?.progression ?? 0
|
||||
return !book.finished && progression > 0 && progression < 1
|
||||
}) ??
|
||||
ordered.find((book) => !book.finished) ??
|
||||
ordered[0]
|
||||
if (!cover) return []
|
||||
const names = volumes.flatMap((book) =>
|
||||
(book.series ?? [])
|
||||
.filter((membership) => seriesKey(membership.name) === key)
|
||||
.map((membership) => membership.name),
|
||||
)
|
||||
const authors = volumes.flatMap((book) => book.authors).filter(Boolean)
|
||||
return [
|
||||
{
|
||||
kind: 'series' as const,
|
||||
id: `series:${key}`,
|
||||
name: commonest(names),
|
||||
authors: authors.length > 0 ? [commonest(authors)] : [],
|
||||
books: ordered,
|
||||
cover,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function recentAt(book: Book): number {
|
||||
return Math.max(book.lastOpenedAt ?? 0, book.progress?.updatedAt ?? 0, book.addedAt)
|
||||
}
|
||||
|
||||
function compareEntries(query: LibraryQuery, left: LibraryEntry, right: LibraryEntry): number {
|
||||
const title = (entry: LibraryEntry) => (entry.kind === 'series' ? entry.name : entry.book.title)
|
||||
const authors = (entry: LibraryEntry) =>
|
||||
entry.kind === 'series' ? entry.authors : entry.book.authors
|
||||
const books = (entry: LibraryEntry) => (entry.kind === 'series' ? entry.books : [entry.book])
|
||||
const direction = query.direction === 'asc' ? 1 : -1
|
||||
let compared = 0
|
||||
switch (query.sort) {
|
||||
case 'recent':
|
||||
compared = Math.max(...books(left).map(recentAt)) - Math.max(...books(right).map(recentAt))
|
||||
break
|
||||
case 'added':
|
||||
compared =
|
||||
Math.max(...books(left).map((book) => book.addedAt)) -
|
||||
Math.max(...books(right).map((book) => book.addedAt))
|
||||
break
|
||||
case 'author': {
|
||||
const leftAuthor = authors(left)[0]
|
||||
const rightAuthor = authors(right)[0]
|
||||
if (!leftAuthor || !rightAuthor) compared = leftAuthor ? -1 : rightAuthor ? 1 : 0
|
||||
else compared = sortKey(leftAuthor).localeCompare(sortKey(rightAuthor))
|
||||
break
|
||||
}
|
||||
case 'title':
|
||||
compared = sortKey(title(left)).localeCompare(sortKey(title(right)))
|
||||
break
|
||||
}
|
||||
compared *= direction
|
||||
return (
|
||||
compared ||
|
||||
sortKey(title(left)).localeCompare(sortKey(title(right))) ||
|
||||
left.id.localeCompare(right.id)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces the cards for one query. `visible` is the search result; `books`
|
||||
* is the filter result before search, so a stack stays complete when one of
|
||||
* its volumes matches the query.
|
||||
*/
|
||||
export function groupedLibraryEntries(
|
||||
books: readonly Book[],
|
||||
visible: readonly Book[],
|
||||
query: LibraryQuery,
|
||||
): LibraryEntry[] {
|
||||
const stacks = seriesEntries(books)
|
||||
const visibleIds = new Set(visible.map((book) => book.id))
|
||||
const shownStacks = stacks.filter((stack) => stack.books.some((book) => visibleIds.has(book.id)))
|
||||
const stackedSeries = new Set(
|
||||
shownStacks.flatMap((stack) =>
|
||||
stack.books.flatMap((book) =>
|
||||
(book.series ?? []).map((membership) => seriesKey(membership.name)),
|
||||
),
|
||||
),
|
||||
)
|
||||
const singles: LibraryEntry[] = visible
|
||||
.filter(
|
||||
(book) =>
|
||||
!(book.series ?? []).some((membership) => stackedSeries.has(seriesKey(membership.name))),
|
||||
)
|
||||
.map((book) => ({ kind: 'book', id: `book:${book.id}`, book }))
|
||||
return [...shownStacks, ...singles].sort((left, right) => compareEntries(query, left, right))
|
||||
}
|
||||
|
|
@ -136,6 +136,7 @@ export class IngestionService {
|
|||
archived: false,
|
||||
downloaded: true, // a local file is by definition on disk
|
||||
addedAt: Date.now(),
|
||||
...(metadata.series ? { series: [metadata.series] } : {}),
|
||||
}
|
||||
if (coverId) book.coverId = coverId
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { DatabaseSync } from 'node:sqlite'
|
||||
import type { Book, LibraryQuery, LibraryQueryResult, Locator } from '../../shared/domain/types'
|
||||
import { BookRepository } from './book-repository'
|
||||
import { groupedLibraryEntries } from './grouping'
|
||||
|
||||
/**
|
||||
* Library query engine — worker side.
|
||||
|
|
@ -18,8 +19,12 @@ export class LibraryService {
|
|||
|
||||
query(query: LibraryQuery, requestId: number): LibraryQueryResult {
|
||||
const books = this.repository.query(query)
|
||||
const groupingBooks = query.search.trim()
|
||||
? this.repository.query({ ...query, search: '' })
|
||||
: books
|
||||
return {
|
||||
books,
|
||||
entries: groupedLibraryEntries(groupingBooks, books, query),
|
||||
totalCount: books.length,
|
||||
archivedCount: this.repository.countArchived(),
|
||||
requestId,
|
||||
|
|
|
|||
|
|
@ -22,10 +22,18 @@ const MAX_PAGES = 200
|
|||
interface KomgaBookDto {
|
||||
id: string
|
||||
name?: string
|
||||
seriesTitle?: string
|
||||
seriesId?: string
|
||||
oneshot?: boolean
|
||||
sizeBytes?: number
|
||||
lastModified?: string
|
||||
media?: { pagesCount?: number }
|
||||
metadata?: { title?: string; authors?: { name: string; role?: string }[] }
|
||||
metadata?: {
|
||||
title?: string
|
||||
authors?: { name: string; role?: string }[]
|
||||
numberSort?: number
|
||||
number?: string
|
||||
}
|
||||
readProgress?: { page?: number; completed?: boolean; readDate?: string; deviceId?: string }
|
||||
}
|
||||
|
||||
|
|
@ -40,6 +48,14 @@ function parseBook(dto: KomgaBookDto): RemoteBook {
|
|||
.map((a) => a.name)
|
||||
.filter(Boolean)
|
||||
const pages = dto.media?.pagesCount
|
||||
const seriesName = dto.oneshot ? undefined : dto.seriesTitle?.trim() || undefined
|
||||
const number = Number(dto.metadata?.number)
|
||||
const position =
|
||||
typeof dto.metadata?.numberSort === 'number' && Number.isFinite(dto.metadata.numberSort)
|
||||
? dto.metadata.numberSort
|
||||
: Number.isFinite(number)
|
||||
? number
|
||||
: undefined
|
||||
let progress: RemoteBook['progress']
|
||||
if (dto.readProgress && pages && dto.readProgress.page !== undefined) {
|
||||
progress = {
|
||||
|
|
@ -60,6 +76,17 @@ function parseBook(dto: KomgaBookDto): RemoteBook {
|
|||
downloadUrl: `/api/v1/books/${dto.id}/file`,
|
||||
coverUrl: `/api/v1/books/${dto.id}/thumbnail`,
|
||||
progress,
|
||||
...(seriesName
|
||||
? {
|
||||
series: [
|
||||
{
|
||||
...(dto.seriesId ? { id: dto.seriesId } : {}),
|
||||
name: seriesName,
|
||||
...(position !== undefined ? { position } : {}),
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { createHash } from 'node:crypto'
|
||||
import type { Locator } from '../../shared/domain/types'
|
||||
import { parseSeriesMemberships } from '../../shared/domain/series'
|
||||
import { Http, type HttpResponse, type HttpResult } from './http'
|
||||
import type {
|
||||
ProgressRecord,
|
||||
|
|
@ -72,6 +73,7 @@ interface CatalogBookPayload {
|
|||
contributors?: unknown
|
||||
size_bytes?: unknown
|
||||
cover_url?: unknown
|
||||
series?: unknown
|
||||
}
|
||||
|
||||
function catalogBook(payload: CatalogBookPayload): RemoteBook | null {
|
||||
|
|
@ -100,6 +102,7 @@ function catalogBook(payload: CatalogBookPayload): RemoteBook | null {
|
|||
: undefined
|
||||
const coverUrl =
|
||||
typeof payload.cover_url === 'string' && payload.cover_url ? payload.cover_url : undefined
|
||||
const series = parseSeriesMemberships(payload.series)
|
||||
return {
|
||||
remoteId,
|
||||
title,
|
||||
|
|
@ -107,6 +110,7 @@ function catalogBook(payload: CatalogBookPayload): RemoteBook | null {
|
|||
sizeBytes,
|
||||
downloadUrl: `/v1/books/${encodeURIComponent(remoteId)}/download`,
|
||||
...(coverUrl ? { coverUrl } : {}),
|
||||
...(series.length > 0 ? { series } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -162,11 +162,12 @@ export class SyncRepository {
|
|||
// reconstructable later).
|
||||
this.db
|
||||
.prepare(
|
||||
'UPDATE books SET title = ?, authors = ?, download_url = ?, cover_url = ? WHERE id = ?',
|
||||
'UPDATE books SET title = ?, authors = ?, series = ?, download_url = ?, cover_url = ? WHERE id = ?',
|
||||
)
|
||||
.run(
|
||||
remote.title,
|
||||
JSON.stringify(remote.authors),
|
||||
JSON.stringify(remote.series ?? []),
|
||||
remote.downloadUrl,
|
||||
remote.coverUrl ?? null,
|
||||
existing.id,
|
||||
|
|
@ -181,8 +182,8 @@ export class SyncRepository {
|
|||
.prepare(
|
||||
`INSERT INTO books (id, folder_id, title, authors, local_path, remote_id, cover_id,
|
||||
finished, archived, downloaded, added_at, server_id,
|
||||
download_url, cover_url)
|
||||
VALUES (?, NULL, ?, ?, NULL, ?, NULL, 0, 0, 0, ?, ?, ?, ?)`,
|
||||
download_url, cover_url, series)
|
||||
VALUES (?, NULL, ?, ?, NULL, ?, NULL, 0, 0, 0, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
|
|
@ -193,6 +194,7 @@ export class SyncRepository {
|
|||
serverId,
|
||||
remote.downloadUrl,
|
||||
remote.coverUrl ?? null,
|
||||
JSON.stringify(remote.series ?? []),
|
||||
)
|
||||
const book = new BookRepository(this.db).getById(id)!
|
||||
return { book, added: true }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Locator } from '../../shared/domain/types'
|
||||
import type { Locator, SeriesMembership } from '../../shared/domain/types'
|
||||
|
||||
/**
|
||||
* Remote catalog & sync capability model (M7).
|
||||
|
|
@ -42,6 +42,8 @@ export interface RemoteBook {
|
|||
coverUrl?: string | undefined
|
||||
/** Server-reported read progress when available (Komga). */
|
||||
progress?: ProgressRecord | undefined
|
||||
/** All effective series memberships the catalog reports. */
|
||||
series?: SeriesMembership[] | undefined
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,35 @@ test('launches and renders the library shell', async () => {
|
|||
await expect(page.locator('.book-card').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens a grouped series stack and returns to the shelf', async () => {
|
||||
await page.getByRole('button', { name: 'Search' }).click()
|
||||
await page.getByRole('searchbox', { name: 'Search books' }).fill('test sequence')
|
||||
const stack = page.locator('.series-card')
|
||||
await expect(stack).toHaveCount(1)
|
||||
await expect(stack).toHaveAccessibleName('The Test Sequence, 2 books')
|
||||
|
||||
await stack.click()
|
||||
await expect(page.locator('.series-header h1')).toHaveText('The Test Sequence')
|
||||
await expect(page.locator('.book-card')).toHaveCount(2)
|
||||
|
||||
// Statistics is above the series view, so Escape dismisses it first.
|
||||
await page.getByRole('button', { name: 'Reading statistics' }).click()
|
||||
await expect(page.locator('.stats-panel')).toBeVisible()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.locator('.stats-panel')).toHaveCount(0)
|
||||
await expect(page.locator('.series-header')).toBeVisible()
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.locator('.series-header')).toHaveCount(0)
|
||||
await expect(page.locator('.series-card')).toHaveCount(1)
|
||||
|
||||
// Tests share the shelf: restore the unfiltered view before the next one
|
||||
// asks it to scroll thousands of rows.
|
||||
await page.getByRole('searchbox', { name: 'Search books' }).fill('')
|
||||
await expect.poll(() => page.locator('.book-card').count()).toBeGreaterThan(2)
|
||||
await page.keyboard.press('Escape')
|
||||
})
|
||||
|
||||
test('the top bar carries the brand tile and the size of the shelf', async () => {
|
||||
// The reading-scene art, matching the Android app's top bar. It must
|
||||
// actually decode: a broken import still lays out as an <img>.
|
||||
|
|
|
|||
|
|
@ -139,6 +139,16 @@ test.describe('vim mode on the shelf', () => {
|
|||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(search).toHaveCount(0)
|
||||
|
||||
// q closes the open series view as well as the other shelf overlays.
|
||||
await page.keyboard.press('/')
|
||||
await page.keyboard.type('test sequence')
|
||||
const stack = page.locator('.series-card')
|
||||
await expect(stack).toHaveCount(1)
|
||||
await stack.click()
|
||||
await expect(page.locator('.series-header')).toBeVisible()
|
||||
await page.keyboard.press('q')
|
||||
await expect(page.locator('.series-header')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -86,6 +86,12 @@ export interface FixtureEpubOptions {
|
|||
noCover?: boolean
|
||||
/** Deflate the large entries to exercise the inflate path. */
|
||||
deflate?: boolean
|
||||
/** Calibre's widely used series metadata. */
|
||||
series?: { name: string; position?: number }
|
||||
/** EPUB 3 collection metadata used by non-Calibre publishers. */
|
||||
epubSeries?: { name: string; position?: number }
|
||||
/** Put group-position before belongs-to-collection to exercise OPF ordering. */
|
||||
epubSeriesPositionFirst?: boolean
|
||||
}
|
||||
|
||||
export function buildEpub(options: FixtureEpubOptions = {}): Buffer {
|
||||
|
|
@ -96,9 +102,20 @@ export function buildEpub(options: FixtureEpubOptions = {}): Buffer {
|
|||
epub2Cover = false,
|
||||
noCover = false,
|
||||
deflate = false,
|
||||
series,
|
||||
epubSeries,
|
||||
epubSeriesPositionFirst = false,
|
||||
} = options
|
||||
|
||||
const coverMeta = epub2Cover ? '<meta name="cover" content="cover-img"/>' : ''
|
||||
const seriesMeta = series
|
||||
? `<meta name="calibre:series" content="${series.name}"/>\n <meta name="calibre:series_index" content="${series.position ?? ''}"/>`
|
||||
: ''
|
||||
const epubSeriesMeta = epubSeries
|
||||
? epubSeriesPositionFirst
|
||||
? `<meta refines="#series" property="group-position">${epubSeries.position ?? ''}</meta>\n <meta id="series" property="belongs-to-collection">${epubSeries.name}</meta>`
|
||||
: `<meta id="series" property="belongs-to-collection">${epubSeries.name}</meta>\n <meta refines="#series" property="group-position">${epubSeries.position ?? ''}</meta>`
|
||||
: ''
|
||||
const coverProps = epub2Cover ? '' : ' properties="cover-image"'
|
||||
const coverItem = noCover
|
||||
? ''
|
||||
|
|
@ -112,6 +129,8 @@ export function buildEpub(options: FixtureEpubOptions = {}): Buffer {
|
|||
<dc:identifier id="bookid">${identifier}</dc:identifier>
|
||||
<dc:language>en</dc:language>
|
||||
${coverMeta}
|
||||
${seriesMeta}
|
||||
${epubSeriesMeta}
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,30 @@ describe('EpubFile', () => {
|
|||
expect(meta.cover?.entryPath).toBe('OEBPS/images/cover.png')
|
||||
})
|
||||
|
||||
it('reads Calibre series metadata', () => {
|
||||
const meta = new EpubFile(
|
||||
buildEpub({ series: { name: 'The Expanse', position: 2 } }),
|
||||
).metadata()
|
||||
expect(meta.series).toEqual({ name: 'The Expanse', position: 2 })
|
||||
})
|
||||
|
||||
it('reads EPUB 3 collection metadata', () => {
|
||||
const meta = new EpubFile(
|
||||
buildEpub({ epubSeries: { name: 'The Expanse', position: 2 } }),
|
||||
).metadata()
|
||||
expect(meta.series).toEqual({ name: 'The Expanse', position: 2 })
|
||||
})
|
||||
|
||||
it('reads EPUB 3 group position before the collection metadata', () => {
|
||||
const meta = new EpubFile(
|
||||
buildEpub({
|
||||
epubSeries: { name: 'The Expanse', position: 2 },
|
||||
epubSeriesPositionFirst: true,
|
||||
}),
|
||||
).metadata()
|
||||
expect(meta.series).toEqual({ name: 'The Expanse', position: 2 })
|
||||
})
|
||||
|
||||
it('reads deflated archives', () => {
|
||||
const epub = new EpubFile(buildEpub({ deflate: true, title: 'Deflated' }))
|
||||
expect(epub.metadata().title).toBe('Deflated')
|
||||
|
|
|
|||
106
tests/unit/grouping.test.ts
Normal file
106
tests/unit/grouping.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { Book, LibraryQuery } from '../../src/shared/domain/types'
|
||||
import { groupedLibraryEntries } from '../../src/worker/library/grouping'
|
||||
|
||||
const query: LibraryQuery = { filter: 'all', sort: 'title', direction: 'asc', search: '' }
|
||||
|
||||
function book(partial: Partial<Book> & { id: string; title: string }): Book {
|
||||
return {
|
||||
authors: ['Author'],
|
||||
finished: false,
|
||||
archived: false,
|
||||
downloaded: true,
|
||||
addedAt: 1,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
describe('groupedLibraryEntries', () => {
|
||||
it('folds two volumes into one stack ordered by their position', () => {
|
||||
const one = book({
|
||||
id: 'one',
|
||||
title: 'Leviathan Wakes',
|
||||
series: [{ name: 'The Expanse', position: 1 }],
|
||||
})
|
||||
const two = book({
|
||||
id: 'two',
|
||||
title: "Caliban's War",
|
||||
series: [{ name: 'The Expanse', position: 2 }],
|
||||
})
|
||||
const entries = groupedLibraryEntries([one, two], [one, two], query)
|
||||
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0]).toMatchObject({ kind: 'series', name: 'The Expanse' })
|
||||
expect(entries[0]?.kind === 'series' && entries[0].books.map((volume) => volume.id)).toEqual([
|
||||
'one',
|
||||
'two',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a complete stack when one volume matches a search', () => {
|
||||
const one = book({ id: 'one', title: 'Leviathan Wakes', series: [{ name: 'The Expanse' }] })
|
||||
const two = book({ id: 'two', title: "Caliban's War", series: [{ name: 'The Expanse' }] })
|
||||
const entries = groupedLibraryEntries([one, two], [one], { ...query, search: 'leviathan' })
|
||||
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0]?.kind === 'series' && entries[0].books).toEqual([two, one])
|
||||
})
|
||||
|
||||
it('retains every liseur-sync membership instead of choosing the first', () => {
|
||||
const shared = book({
|
||||
id: 'shared',
|
||||
title: 'Shared volume',
|
||||
series: [
|
||||
{ name: 'Main sequence', position: 1 },
|
||||
{ name: 'Companions', position: 1 },
|
||||
],
|
||||
})
|
||||
const main = book({
|
||||
id: 'main',
|
||||
title: 'Main two',
|
||||
series: [{ name: 'Main sequence', position: 2 }],
|
||||
})
|
||||
const companion = book({
|
||||
id: 'companion',
|
||||
title: 'Companion two',
|
||||
series: [{ name: 'Companions', position: 2 }],
|
||||
})
|
||||
const entries = groupedLibraryEntries(
|
||||
[shared, main, companion],
|
||||
[shared, main, companion],
|
||||
query,
|
||||
)
|
||||
|
||||
expect(entries.map((entry) => entry.kind === 'series' && entry.name)).toEqual([
|
||||
'Companions',
|
||||
'Main sequence',
|
||||
])
|
||||
})
|
||||
|
||||
it('groups case, accent, and leading-article variants together', () => {
|
||||
const one = book({ id: 'one', title: 'One', series: [{ name: "L'Étranger", position: 1 }] })
|
||||
const two = book({ id: 'two', title: 'Two', series: [{ name: 'etranger', position: 2 }] })
|
||||
|
||||
expect(groupedLibraryEntries([one, two], [one, two], query)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('sorts recent and added entries in the requested direction', () => {
|
||||
const old = book({ id: 'old', title: 'Old', addedAt: 1, lastOpenedAt: 10 })
|
||||
const recent = book({ id: 'recent', title: 'Recent', addedAt: 2, lastOpenedAt: 20 })
|
||||
|
||||
expect(
|
||||
groupedLibraryEntries([old, recent], [old, recent], {
|
||||
...query,
|
||||
sort: 'recent',
|
||||
direction: 'desc',
|
||||
}).map((entry) => entry.id),
|
||||
).toEqual(['book:recent', 'book:old'])
|
||||
expect(
|
||||
groupedLibraryEntries([old, recent], [old, recent], {
|
||||
...query,
|
||||
sort: 'added',
|
||||
direction: 'asc',
|
||||
}).map((entry) => entry.id),
|
||||
).toEqual(['book:old', 'book:recent'])
|
||||
})
|
||||
})
|
||||
|
|
@ -75,6 +75,23 @@ describe('IngestionService.ingestFile', () => {
|
|||
expect((outcome as { status: 'added'; book: Book }).book.title).toBe('fallback-name')
|
||||
})
|
||||
|
||||
it('persists the series from a Calibre EPUB', async () => {
|
||||
const path = writeEpub('volume.epub', { series: { name: 'The Expanse', position: 2 } })
|
||||
const outcome = await service().ingestFile(path)
|
||||
expect(outcome.status).toBe('added')
|
||||
expect((outcome as { status: 'added'; book: Book }).book.series).toEqual([
|
||||
{ name: 'The Expanse', position: 2 },
|
||||
])
|
||||
expect(
|
||||
new BookRepository(db).query({
|
||||
filter: 'all',
|
||||
sort: 'title',
|
||||
direction: 'asc',
|
||||
search: '',
|
||||
})[0]?.series,
|
||||
).toEqual([{ name: 'The Expanse', position: 2 }])
|
||||
})
|
||||
|
||||
it('skips the exact same file content (duplicate hash)', async () => {
|
||||
const a = writeEpub('a.epub')
|
||||
const b = join(booksDir, 'b.epub')
|
||||
|
|
|
|||
|
|
@ -97,6 +97,14 @@ describe('LibraryService.query', () => {
|
|||
expect(query({ search: 'LEA' }).books.map((b) => b.id)).toEqual(['c', 'a'])
|
||||
})
|
||||
|
||||
it('searches series names without matching JSON field names', () => {
|
||||
new BookRepository(db).insertBooks([
|
||||
book({ id: 'series', title: 'A Book', series: [{ name: 'The Expanse', position: 1 }] }),
|
||||
])
|
||||
expect(query({ search: 'expanse' }).books.map((b) => b.id)).toEqual(['series'])
|
||||
expect(query({ search: 'position' }).books).toEqual([])
|
||||
})
|
||||
|
||||
it('treats LIKE wildcards in search as literal characters', () => {
|
||||
expect(query({ search: '%' }).books).toEqual([])
|
||||
expect(query({ search: '_' }).books).toEqual([])
|
||||
|
|
|
|||
|
|
@ -63,15 +63,27 @@ export function mockKomga(
|
|||
{
|
||||
id: 'book-1',
|
||||
name: 'First Remote',
|
||||
seriesTitle: 'Remote sequence',
|
||||
seriesId: 'remote-sequence',
|
||||
sizeBytes: 1000,
|
||||
metadata: { title: 'First Remote', authors: [{ name: 'Remote Author', role: 'writer' }] },
|
||||
metadata: {
|
||||
title: 'First Remote',
|
||||
numberSort: 1,
|
||||
authors: [{ name: 'Remote Author', role: 'writer' }],
|
||||
},
|
||||
media: { pagesCount: 10 },
|
||||
},
|
||||
{
|
||||
id: 'book-2',
|
||||
name: 'Second Remote',
|
||||
seriesTitle: 'Remote sequence',
|
||||
seriesId: 'remote-sequence',
|
||||
sizeBytes: 2000,
|
||||
metadata: { title: 'Second Remote', authors: [{ name: 'Other Author' }] },
|
||||
metadata: {
|
||||
title: 'Second Remote',
|
||||
numberSort: 2,
|
||||
authors: [{ name: 'Other Author' }],
|
||||
},
|
||||
media: { pagesCount: 20 },
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ describe('SyncService against mock Komga', () => {
|
|||
const shell = repo.findByRemoteId(server.id, 'book-1')
|
||||
expect(shell).toBeDefined()
|
||||
expect(shell?.localPath).toBeUndefined()
|
||||
expect(shell?.series).toEqual([{ id: 'remote-sequence', name: 'Remote sequence', position: 1 }])
|
||||
|
||||
const downloaded = await service.downloadBook(shell!.id)
|
||||
expect(downloaded?.localPath).toContain('downloads')
|
||||
|
|
@ -1098,6 +1099,7 @@ describe('SyncService against mock Komga', () => {
|
|||
{ name: 'A Writer', role: 'author' },
|
||||
{ name: 'An Editor', role: 'editor' },
|
||||
],
|
||||
series: [{ id: 'expanse', name: 'The Expanse', position: 1, source: 'folder' }],
|
||||
size_bytes: 11,
|
||||
cover_url: '/v1/books/book-1/cover',
|
||||
},
|
||||
|
|
@ -1142,7 +1144,12 @@ describe('SyncService against mock Komga', () => {
|
|||
|
||||
const repo = new SyncRepository(db)
|
||||
const first = repo.findByRemoteId(server.id, 'book-1')
|
||||
expect(first).toMatchObject({ title: 'First book', authors: ['A Writer'], downloaded: false })
|
||||
expect(first).toMatchObject({
|
||||
title: 'First book',
|
||||
authors: ['A Writer'],
|
||||
series: [{ id: 'expanse', name: 'The Expanse', position: 1 }],
|
||||
downloaded: false,
|
||||
})
|
||||
expect(repo.findByRemoteId(server.id, 'book-2')).toBeDefined()
|
||||
expect(repo.findByRemoteId(server.id, 'book-3')).toBeDefined()
|
||||
expect(requests).toContain('/v1/folders?limit=200')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue