Skip to content

Commit 07a82a0

Browse files
phildenhoffclaude
andauthored
feat: page the library UI through the backend book query (#117)
The cover grid no longer loads every book at startup. The library store now keeps a paged query cache keyed by the serialized filter combination (text, author_id, series_id, hide_read, sort): each entry holds the filtered total plus a sparse map of 100-book pages, fetched on demand via clbQueryBooks as the virtualized grid scrolls (ensureBookRange, with in-flight de-dupe and generation stamps so stale fetches never land). Mutations stop refetching the whole library; they invalidate the cache so visible pages reload lazily. Search text debounces ~200ms before becoming a new cache key; sorting and hide-read now run in SQL via the query params. Deep links carry ids instead of names: the Authors page and book-detail author links pass author_id, and series links pass series_id, resolved through a new minimal clb_query_list_series command (the only way to map series names to the ids LibraryBookQuery filters on; it also lets the Series page drop its full-book-list dependency). A series filter fetches the whole series unpaged and sorts by series_index client-side, since the backend only sorts by title/author. clb_query_list_all_books stays for the Authors page (per-author book counts need every book-author link) and the book detail route (tag autocomplete needs the full tag vocabulary; no fetch-one-book command), both now loaded lazily on first use via useAllBooks. The pure cache logic lives in src/lib/book-page-cache.ts with vitest coverage for key serialization, page math, range-to-pages, sparse flattening, and generation invalidation. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 395642f commit 07a82a0

25 files changed

Lines changed: 1464 additions & 418 deletions

File tree

crates/libcalibre/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ pub use custom_columns::{CustomColumn, CustomColumnKind, CustomColumnSpec, Custo
1919
pub use error::CalibreError;
2020
pub use library::{
2121
Author as LibraryAuthor, AuthorAdd, AuthorUpdate, Book as LibraryBook, BookAdd, BookFileInfo,
22-
BookIdentifier, BookPage, BookQuery, BookSortOrder, BookUpdate, Library,
22+
BookIdentifier, BookPage, BookQuery, BookSortOrder, BookUpdate, Library, SeriesSummary,
2323
};
2424
pub use types::{AuthorId, BookFileId, BookId};
2525

crates/libcalibre/src/library.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,16 @@ pub struct BookPage {
173173
pub total: i64,
174174
}
175175

176+
/// One series in the library, with its linked-book count. Returned by
177+
/// [`Library::list_series`]; series ids are what [`BookQuery::series_id`]
178+
/// filters on.
179+
#[derive(Clone, Debug, PartialEq, Eq)]
180+
pub struct SeriesSummary {
181+
pub id: i32,
182+
pub name: String,
183+
pub book_count: i64,
184+
}
185+
176186
impl Library {
177187
pub fn new(db_path: ValidDbPath) -> Result<Self, CalibreError> {
178188
let conn = establish_connection(&db_path.database_path)
@@ -757,6 +767,12 @@ impl Library {
757767
Ok(BookPage { items, total })
758768
}
759769

770+
/// List every series in the library with its linked-book count, sorted
771+
/// by name. The returned ids feed [`BookQuery::series_id`].
772+
pub fn list_series(&mut self) -> Result<Vec<SeriesSummary>, CalibreError> {
773+
crate::queries::series::list_with_book_counts(&mut self.conn)
774+
}
775+
760776
pub fn search_books(&mut self, query: &str) -> Result<Vec<Book>, CalibreError> {
761777
let query = query.trim();
762778
if query.is_empty() {

crates/libcalibre/src/queries/series.rs

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,47 @@ use std::collections::HashMap;
22

33
use diesel::prelude::*;
44
use diesel::sql_query;
5-
use diesel::sql_types::{Integer, Text};
5+
use diesel::sql_types::{BigInt, Integer, Text};
66
use diesel::{QueryDsl, RunQueryDsl, SqliteConnection};
77

88
use crate::entities::series::{NewSeries, Series};
9+
use crate::library::SeriesSummary;
910
use crate::types::BookId;
1011
use crate::CalibreError;
1112

13+
pub(crate) fn list_with_book_counts(
14+
conn: &mut SqliteConnection,
15+
) -> Result<Vec<SeriesSummary>, CalibreError> {
16+
#[derive(QueryableByName)]
17+
struct SeriesCountRow {
18+
#[diesel(sql_type = Integer)]
19+
id: i32,
20+
#[diesel(sql_type = Text)]
21+
name: String,
22+
#[diesel(sql_type = BigInt)]
23+
book_count: i64,
24+
}
25+
26+
let rows: Vec<SeriesCountRow> = sql_query(
27+
"SELECT s.id AS id, s.name AS name, COUNT(bsl.book) AS book_count
28+
FROM series s
29+
LEFT JOIN books_series_link bsl ON bsl.series = s.id
30+
GROUP BY s.id, s.name
31+
ORDER BY s.name COLLATE NOCASE, s.id",
32+
)
33+
.load(conn)
34+
.map_err(CalibreError::from)?;
35+
36+
Ok(rows
37+
.into_iter()
38+
.map(|row| SeriesSummary {
39+
id: row.id,
40+
name: row.name,
41+
book_count: row.book_count,
42+
})
43+
.collect())
44+
}
45+
1246
pub(crate) fn find_by_name_case_insensitive(
1347
conn: &mut SqliteConnection,
1448
series_name: &str,

crates/libcalibre/tests/query_test.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,3 +530,39 @@ fn test_page_items_are_hydrated() {
530530
assert_eq!(item.tags, ["fantasy"]);
531531
assert!(item.is_read);
532532
}
533+
534+
// =============================================================================
535+
// Series listing
536+
// =============================================================================
537+
538+
#[test]
539+
fn test_list_series_returns_counts_sorted_by_name() {
540+
let (_temp, mut lib) = setup_with_library();
541+
542+
for n in 1u8..=2 {
543+
lib.add_book(BookAdd {
544+
series: Some("Earthsea".to_string()),
545+
series_index: Some(f32::from(n)),
546+
..book(&format!("Earthsea {n}"), &["Ursula K. Le Guin"])
547+
})
548+
.unwrap();
549+
}
550+
lib.add_book(BookAdd {
551+
series: Some("Culture".to_string()),
552+
series_index: Some(1.0),
553+
..book("Consider Phlebas", &["Iain M. Banks"])
554+
})
555+
.unwrap();
556+
lib.add_book(book("Standalone", &["Nobody"])).unwrap();
557+
558+
let series = lib.list_series().unwrap();
559+
let names: Vec<&str> = series.iter().map(|s| s.name.as_str()).collect();
560+
assert_eq!(names, ["Culture", "Earthsea"]);
561+
562+
let counts: Vec<i64> = series.iter().map(|s| s.book_count).collect();
563+
assert_eq!(counts, [1, 2]);
564+
565+
// The listed ids are the ones BookQuery::series_id filters on.
566+
let earthsea = series.iter().find(|s| s.name == "Earthsea").unwrap();
567+
assert_eq!(earthsea.id, series_id_by_name(&mut lib, "Earthsea"));
568+
}

src-tauri/src/libs/calibre/query.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,34 @@ pub fn clb_query_books(
116116
})
117117
}
118118

119+
/// One series in the library. `id` is what [`LibraryBookQuery::series_id`]
120+
/// filters on; the frontend otherwise only ever sees series names.
121+
#[derive(Serialize, Deserialize, specta::Type, Clone)]
122+
pub struct LibrarySeries {
123+
pub id: i32,
124+
pub name: String,
125+
pub book_count: u32,
126+
}
127+
128+
#[tauri::command]
129+
#[specta::specta]
130+
pub fn clb_query_list_series(
131+
state: tauri::State<CitadelState>,
132+
) -> Result<Vec<LibrarySeries>, String> {
133+
let summaries = state
134+
.with_library(|lib| lib.list_series())?
135+
.map_err(|e| e.to_string())?;
136+
137+
Ok(summaries
138+
.into_iter()
139+
.map(|series| LibrarySeries {
140+
id: series.id,
141+
name: series.name,
142+
book_count: u32::try_from(series.book_count).unwrap_or(u32::MAX),
143+
})
144+
.collect())
145+
}
146+
119147
#[tauri::command]
120148
#[specta::specta]
121149
pub fn clb_query_list_all_authors(

src-tauri/src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ fn run_tauri_backend() -> std::io::Result<()> {
3131
calibre::query::clb_query_is_file_importable,
3232
calibre::query::clb_query_importable_file_metadata,
3333
calibre::query::clb_query_list_all_filetypes,
34+
// Series query commands
35+
calibre::query::clb_query_list_series,
3436
// Book manipulation commands
3537
calibre::command::clb_cmd_create_book,
3638
calibre::command::clb_cmd_update_book,

src/BookView.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import type { LibraryBook } from "./bindings";
22

33
export interface BookView {
44
loading: boolean;
5-
bookList: LibraryBook[];
5+
/**
6+
* Indexed array of the filtered library: length is the total match
7+
* count, `undefined` entries are books whose page has not been fetched
8+
* yet (they render as placeholders).
9+
*/
10+
bookList: (LibraryBook | undefined)[];
611
onBookOpen: (bookId: LibraryBook["id"]) => void;
712
selectedBookId?: LibraryBook["id"] | null;
813
}

src/bindings.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ async clbQueryImportableFileMetadata(file: ImportableFile) : Promise<ImportableB
5757
async clbQueryListAllFiletypes() : Promise<([string, string])[]> {
5858
return await TAURI_INVOKE("clb_query_list_all_filetypes");
5959
},
60+
async clbQueryListSeries() : Promise<Result<LibrarySeries[], string>> {
61+
try {
62+
return { status: "ok", data: await TAURI_INVOKE("clb_query_list_series") };
63+
} catch (e) {
64+
if(e instanceof Error) throw e;
65+
else return { status: "error", error: e as any };
66+
}
67+
},
6068
async clbCmdCreateBook(md: ImportableBookMetadata) : Promise<Result<string, string>> {
6169
try {
6270
return { status: "ok", data: await TAURI_INVOKE("clb_cmd_create_book", { md }) };
@@ -313,6 +321,11 @@ text: string | null; author_id: string | null; series_id: number | null; hide_re
313321
* Page size. `None` returns all matches.
314322
*/
315323
limit: number | null; offset: number }
324+
/**
325+
* One series in the library. `id` is what [`LibraryBookQuery::series_id`]
326+
* filters on; the frontend otherwise only ever sees series names.
327+
*/
328+
export type LibrarySeries = { id: number; name: string; book_count: number }
316329
export type LocalFile = {
317330
/**
318331
* The absolute path to the file, including extension.

src/components/atoms/BookCard.module.css

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,17 @@
3333
outline-offset: 2px;
3434
box-shadow: 0 0 0 7px var(--ctd-focus-ring);
3535
}
36+
37+
/* Cover-shaped stand-in for a book whose page has not been fetched yet
38+
* (BookGrid placeholder cells). Sized like a typical cover (2:3, capped at
39+
* the shelf height) so rows hold steady while real books stream in. */
40+
.coverPlaceholder {
41+
display: block;
42+
width: 100%;
43+
max-width: 150px;
44+
aspect-ratio: 2 / 3;
45+
max-height: 230px;
46+
border-radius: 4px;
47+
background: var(--ctd-surface-muted);
48+
border: 1px solid var(--ctd-border);
49+
}

src/components/molecules/BookGrid.tsx

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
rowSlice,
2121
} from "@/lib/grid-virtual";
2222
import { BookCard } from "../atoms/BookCard";
23+
import cardClasses from "../atoms/BookCard.module.css";
2324

2425
/**
2526
* Shelf-grid layout constants. The per-row tracks mirror the old
@@ -37,6 +38,9 @@ const OVERSCAN_ROWS = 4;
3738
/** Scrolls the shelf row containing the given flat book index into view. */
3839
export type ScrollToBookIndex = (index: number) => void;
3940

41+
/** The inclusive flat-index range of books near the viewport. */
42+
export type VisibleRangeChange = (startIndex: number, endIndex: number) => void;
43+
4044
interface BookGridProps extends BookView {
4145
/** The page-owned scroll region the virtualizer windows against. */
4246
scrollElementRef: RefObject<HTMLElement>;
@@ -45,6 +49,11 @@ interface BookGridProps extends BookView {
4549
* reach rows that are not currently mounted (use-library-keymap).
4650
*/
4751
scrollToBookIndexRef?: MutableRefObject<ScrollToBookIndex | null>;
52+
/**
53+
* Fires when the rendered (viewport + overscan) flat-index range moves,
54+
* so the owner can page the covered books in (ensureBookRange).
55+
*/
56+
onVisibleRangeChange?: VisibleRangeChange;
4857
}
4958

5059
export const BookGrid = ({
@@ -54,6 +63,7 @@ export const BookGrid = ({
5463
selectedBookId,
5564
scrollElementRef,
5665
scrollToBookIndexRef,
66+
onVisibleRangeChange,
5767
}: BookGridProps) => {
5868
const actionsContext = useMemo(() => {
5969
return {
@@ -69,6 +79,7 @@ export const BookGrid = ({
6979
selectedBookId={selectedBookId}
7080
scrollElementRef={scrollElementRef}
7181
scrollToBookIndexRef={scrollToBookIndexRef}
82+
onVisibleRangeChange={onVisibleRangeChange}
7283
/>
7384
</bookActionsContext.Provider>
7485
);
@@ -80,12 +91,14 @@ const BookGridPure = ({
8091
selectedBookId,
8192
scrollElementRef,
8293
scrollToBookIndexRef,
94+
onVisibleRangeChange,
8395
}: {
8496
loading: boolean;
85-
bookList: LibraryBook[];
97+
bookList: (LibraryBook | undefined)[];
8698
selectedBookId?: LibraryBook["id"] | null;
8799
scrollElementRef: RefObject<HTMLElement>;
88100
scrollToBookIndexRef?: MutableRefObject<ScrollToBookIndex | null>;
101+
onVisibleRangeChange?: VisibleRangeChange;
89102
}) => {
90103
const actions = useContext(bookActionsContext);
91104

@@ -136,6 +149,20 @@ const BookGridPure = ({
136149
};
137150
}, [scrollToBookIndexRef, virtualizer, columns]);
138151

152+
// Report the rendered row window (includes overscan) as a flat-index
153+
// range so unfetched pages covering it can load.
154+
const virtualRows = virtualizer.getVirtualItems();
155+
const firstRow = virtualRows[0]?.index;
156+
const lastRow = virtualRows[virtualRows.length - 1]?.index;
157+
useEffect(() => {
158+
if (!onVisibleRangeChange) return;
159+
if (firstRow === undefined || lastRow === undefined) return;
160+
const start = rowSlice(firstRow, columns, books.length).start;
161+
const end = rowSlice(lastRow, columns, books.length).end - 1;
162+
if (end < start) return;
163+
onVisibleRangeChange(start, end);
164+
}, [onVisibleRangeChange, firstRow, lastRow, columns, books.length]);
165+
139166
return (
140167
<div
141168
ref={wrapperRef}
@@ -159,7 +186,7 @@ const BookGridPure = ({
159186
* bottom-aligns its book (see BookCard.module.css) so the bases
160187
* sit on one line.
161188
*/}
162-
{virtualizer.getVirtualItems().map((virtualRow) => {
189+
{virtualRows.map((virtualRow) => {
163190
const { start, end } = rowSlice(
164191
virtualRow.index,
165192
columns,
@@ -182,14 +209,19 @@ const BookGridPure = ({
182209
columnGap: COLUMN_GAP,
183210
}}
184211
>
185-
{books.slice(start, end).map((book) => (
186-
<BookCard
187-
key={book.id}
188-
book={book}
189-
actions={actions}
190-
selected={book.id === selectedBookId}
191-
/>
192-
))}
212+
{books.slice(start, end).map((book, offset) =>
213+
book !== undefined ? (
214+
<BookCard
215+
key={book.id}
216+
book={book}
217+
actions={actions}
218+
selected={book.id === selectedBookId}
219+
/>
220+
) : (
221+
// biome-ignore lint/suspicious/noArrayIndexKey: an unfetched slot has no id; its flat grid index IS its identity until the book arrives (and then the keyed BookCard replaces it).
222+
<BookCardPlaceholder key={`placeholder-${start + offset}`} />
223+
),
224+
)}
193225
</div>
194226
);
195227
})}
@@ -198,6 +230,17 @@ const BookGridPure = ({
198230
);
199231
};
200232

233+
/**
234+
* Stand-in cell for a book whose page has not been fetched yet: the same
235+
* shelf-cell layout as BookCard with a quiet cover-shaped block, so rows
236+
* keep their height and books pop in without the shelf reflowing.
237+
*/
238+
const BookCardPlaceholder = () => (
239+
<div className={cardClasses.cell} aria-hidden>
240+
<span className={cardClasses.coverPlaceholder} />
241+
</div>
242+
);
243+
201244
type BookAction = (bookId: LibraryBook["id"]) => void;
202245
interface BookActionsContext {
203246
onViewBook: BookAction;

0 commit comments

Comments
 (0)