online Minecraft written book viewer

feat(serve): yeet categories

kokirigla.de ed63c51e d16ea67e

verified
+102 -416
+7 -7
Cargo.lock
··· 1011 1011 1012 1012 [[package]] 1013 1013 name = "nara" 1014 - version = "0.1.0" 1014 + version = "0.2.0" 1015 1015 dependencies = [ 1016 1016 "ahash", 1017 1017 "anyhow", ··· 1041 1041 1042 1042 [[package]] 1043 1043 name = "nara_core" 1044 - version = "0.1.0" 1044 + version = "0.2.0" 1045 1045 dependencies = [ 1046 1046 "hex", 1047 1047 "insta", ··· 1055 1055 1056 1056 [[package]] 1057 1057 name = "nara_io" 1058 - version = "0.1.0" 1058 + version = "0.2.0" 1059 1059 dependencies = [ 1060 1060 "crab_nbt", 1061 1061 "flate2", ··· 1066 1066 1067 1067 [[package]] 1068 1068 name = "nara_mcr" 1069 - version = "0.1.0" 1069 + version = "0.2.0" 1070 1070 dependencies = [ 1071 1071 "crab_nbt", 1072 1072 "flate2", ··· 1076 1076 1077 1077 [[package]] 1078 1078 name = "nara_slurper_1_12_core" 1079 - version = "0.1.0" 1079 + version = "0.2.0" 1080 1080 dependencies = [ 1081 1081 "nara_core", 1082 1082 "nara_mcr", ··· 1085 1085 1086 1086 [[package]] 1087 1087 name = "nara_slurper_1_12_infinity" 1088 - version = "0.1.0" 1088 + version = "0.2.0" 1089 1089 dependencies = [ 1090 1090 "nara_core", 1091 1091 "nara_io", ··· 1095 1095 1096 1096 [[package]] 1097 1097 name = "nara_slurper_1_12_world" 1098 - version = "0.1.0" 1098 + version = "0.2.0" 1099 1099 dependencies = [ 1100 1100 "crab_nbt", 1101 1101 "insta",
+1 -1
Cargo.toml
··· 3 3 members = ["nara_io", "nara_slurper_1_12_core", "nara_slurper_1_12_infinity", "nara_slurper_1_12_world", "nara_core", "nara_mcr"] 4 4 5 5 [workspace.package] 6 - version = "0.1.0" 6 + version = "0.2.0" 7 7 edition = "2024" 8 8 9 9 [workspace.dependencies]
-3
src/cli.rs
··· 70 70 71 71 #[derive(Args, Debug)] 72 72 pub struct ServeArgs { 73 - /// Whether to warn about duplicate books being found during initial indexing. 74 - #[arg(short = 'd', long, default_value_t = true, action = ArgAction::Set)] 75 - pub warn_duplicates: bool, 76 73 /// Whether to warn about empty/whitespace books being skipped during indexing. 77 74 #[arg(short = 'e', long, default_value_t = true, action = ArgAction::Set)] 78 75 pub warn_empty: bool,
+2 -79
src/library.rs
··· 8 8 use std::{cell::RefCell, num::NonZeroUsize}; 9 9 use strsim::jaro_winkler; 10 10 11 + 11 12 pub type BookId = usize; 12 13 13 14 /// In-memory index of all books with lookup and fuzzy search helpers. ··· 17 18 18 19 by_hash: AHashMap<BookHash, BookId>, 19 20 source_by_hash: AHashMap<BookHash, BookSource>, 20 - by_category: AHashMap<SmolStr, Vec<BookId>>, 21 21 by_author_lc: AHashMap<SmolStr, Vec<BookId>>, 22 - 23 - // per-book data, parallel to `books` 24 - category_by_id: Vec<SmolStr>, 25 22 26 23 // normalized blobs for scoring (same index as `books`) 27 24 norm_title: Vec<String>, ··· 38 35 title_threshold: f64, 39 36 40 37 cache_books_by_author: RefCell<LruCache<SmolStr, Vec<BookId>>>, 41 - cache_books_in_category: RefCell<LruCache<SmolStr, Vec<BookId>>>, 42 38 cache_fuzzy_title: RefCell<LruCache<FuzzyKey, Vec<(BookId, f64)>>>, 43 39 cache_fuzzy_author: RefCell<LruCache<FuzzyKey, Vec<(BookId, f64)>>>, 44 40 cache_fuzzy_contents: RefCell<LruCache<FuzzyKey, Vec<(BookId, f64)>>>, 45 41 cache_fuzzy_all: RefCell<LruCache<FuzzyKey, Vec<(BookId, f64)>>>, 46 42 47 - duplicate_books_filtered: u16, 48 43 empty_books_filtered: u16, 49 44 } 50 45 ··· 55 50 limit: usize, 56 51 } 57 52 58 - /// Assigns a category to a book. Currently always returns "Uncategorized". 59 - fn categorize(_book: &Book) -> SmolStr { 60 - SmolStr::new_static("Uncategorized") 61 - } 62 - 63 53 impl Library { 64 54 pub fn new( 65 55 content_threshold: f64, ··· 70 60 books: Vec::new(), 71 61 by_hash: AHashMap::new(), 72 62 source_by_hash: AHashMap::new(), 73 - by_category: AHashMap::new(), 74 63 by_author_lc: AHashMap::new(), 75 - category_by_id: Vec::new(), 76 64 norm_title: Vec::new(), 77 65 norm_author: Vec::new(), 78 66 norm_contents: Vec::new(), ··· 83 71 title_threshold, 84 72 author_threshold, 85 73 cache_books_by_author: RefCell::new(new_lru(CACHE_BY_AUTHOR_CAP)), 86 - cache_books_in_category: RefCell::new(new_lru(CACHE_BY_CATEGORY_CAP)), 87 74 cache_fuzzy_title: RefCell::new(new_lru(CACHE_FUZZY_CAP)), 88 75 cache_fuzzy_author: RefCell::new(new_lru(CACHE_FUZZY_CAP)), 89 76 cache_fuzzy_contents: RefCell::new(new_lru(CACHE_FUZZY_CAP)), 90 77 cache_fuzzy_all: RefCell::new(new_lru(CACHE_FUZZY_CAP)), 91 - duplicate_books_filtered: 0, 92 78 empty_books_filtered: 0, 93 79 } 94 80 } ··· 97 83 pub fn add_book( 98 84 &mut self, 99 85 book: Book, 100 - warn_duplicates: bool, 101 86 warn_empty: bool, 102 87 filter_empty_books: bool, 103 88 ) { ··· 116 101 117 102 let h = book.hash(); 118 103 if self.by_hash.contains_key(&h) { 119 - if warn_duplicates { 120 - let existing_book_source = 121 - self.source_by_hash.get(&h).expect("book to exist"); 122 - tracing::warn!( 123 - "Duplicate book with source {0:?}: {1} by {2} [already one with {3:?}]", 124 - book.metadata.source, 125 - book.content.title, 126 - book.content.author, 127 - existing_book_source 128 - ); 129 - } 130 - self.duplicate_books_filtered += 1; 131 104 return; 132 105 } 133 106 ··· 135 108 136 109 let source = book.metadata.source.clone(); 137 110 self.books.push(book); 138 - 139 - let category = categorize(&self.books[id]); 140 111 141 112 // indices... 142 113 self.by_hash.insert(h, id); 143 - self.by_category 144 - .entry(category.clone()) 145 - .or_default() 146 - .push(id); 147 114 self.source_by_hash.insert(h, source); 148 - self.category_by_id.push(category); 149 115 150 116 let author_lc = SmolStr::new(normalize(&self.books[id].content.author)); 151 117 if !author_lc.is_empty() { ··· 166 132 index_trigrams(&mut self.tri_contents, id, &self.norm_contents[id]); 167 133 168 134 self.cache_books_by_author.borrow_mut().clear(); 169 - self.cache_books_in_category.borrow_mut().clear(); 170 135 self.cache_fuzzy_title.borrow_mut().clear(); 171 136 self.cache_fuzzy_author.borrow_mut().clear(); 172 137 self.cache_fuzzy_contents.borrow_mut().clear(); ··· 203 168 ids.into_iter().map(|id| &self.books[id]) 204 169 } 205 170 206 - /// Lists books by category derived from location strings. 207 - #[inline] 208 - pub fn books_in_category<'a>( 209 - &'a self, 210 - category: &str, 211 - ) -> impl Iterator<Item = &'a Book> + 'a { 212 - let key = SmolStr::new(category); 213 - let ids = if key.is_empty() { 214 - Vec::new() 215 - } else if let Some(ids) = 216 - self.cache_books_in_category.borrow_mut().get(&key).cloned() 217 - { 218 - ids 219 - } else { 220 - let ids = self.by_category.get(&key).cloned().unwrap_or_default(); 221 - self.cache_books_in_category 222 - .borrow_mut() 223 - .put(key.clone(), ids.clone()); 224 - ids 225 - }; 226 - 227 - ids.into_iter().map(|id| &self.books[id]) 228 - } 229 - 230 171 /// Fuzzy search over normalized titles. 231 172 pub fn fuzzy_title(&self, query: &str, limit: usize) -> Vec<(&Book, f64)> { 232 173 let key = SmolStr::new(normalize(query)); ··· 427 368 self.books.iter() 428 369 } 429 370 430 - /// Returns all categories with their book counts. 431 - #[inline] 432 - pub fn categories(&self) -> Vec<(SmolStr, usize)> { 433 - self.by_category 434 - .iter() 435 - .map(|(k, v)| (k.clone(), v.len())) 436 - .collect() 437 - } 438 - 439 371 /// Returns the source for a book hash, if present. 440 372 #[inline] 441 373 pub fn source_for_hash(&self, hash: &BookHash) -> Option<&BookSource> { 442 374 self.source_by_hash.get(hash) 443 375 } 444 376 445 - /// Returns the category for a book hash, or an empty string if not found. 446 - #[inline] 447 - pub fn category_for_hash(&self, hash: &BookHash) -> &str { 448 - self.by_hash 449 - .get(hash) 450 - .map(|&id| self.category_by_id[id].as_str()) 451 - .unwrap_or("") 452 - } 453 377 } 454 378 455 379 /// Lowercases and normalizes a query string. ··· 468 392 } 469 393 470 394 const CACHE_BY_AUTHOR_CAP: usize = 1024; 471 - const CACHE_BY_CATEGORY_CAP: usize = 1024; 472 395 const CACHE_FUZZY_CAP: usize = 256; 473 396 474 397 /// Helper to build LRU caches with non-zero capacity. ··· 664 587 fn fuzzy_rank_contents( 665 588 query: &str, 666 589 norm_field: &[String], 667 - tri_index: &ahash::AHashMap<u32, Vec<BookId>>, 590 + tri_index: &AHashMap<u32, Vec<BookId>>, 668 591 limit: usize, 669 592 score_threshold: f64, 670 593 ) -> Vec<(BookId, f64)> {
+13 -2
src/main.rs
··· 1 - use std::time::Instant; 1 + use std::time::{Instant, SystemTime}; 2 2 3 3 use anyhow::Context; 4 4 use clap::Parser as _; ··· 88 88 } 89 89 90 90 fn build_library(args: &ServeArgs) -> anyhow::Result<Library> { 91 + let start = SystemTime::now(); 92 + tracing::info!("Opening book container..."); 91 93 let container = BookContainer::open_or_new(&args.container_path) 92 94 .context("Opening container")?; 95 + tracing::info!( 96 + "Container opened, took {} ms. Building library...", 97 + SystemTime::now().duration_since(start)?.as_nanos() / 1_000_000 98 + ); 99 + 100 + let start = SystemTime::now(); 93 101 let mut library = Library::new( 94 102 args.content_threshold, 95 103 args.title_threshold, ··· 98 106 for book in container.books() { 99 107 library.add_book( 100 108 book.clone(), 101 - args.warn_duplicates, 102 109 args.warn_empty, 103 110 args.filter_empty_books, 104 111 ); 105 112 } 113 + tracing::info!( 114 + "Built library in {}ms", 115 + SystemTime::now().duration_since(start)?.as_nanos() / 1_000_000 116 + ); 106 117 Ok(library) 107 118 }
+1 -57
src/web/api.rs
··· 24 24 .route("/health", get(health)) 25 25 .route("/books", get(list_books)) 26 26 .route("/books/by-author", get(books_by_author)) 27 - .route("/books/by-category", get(books_by_category)) 28 27 .route("/books/by-hash/{hash}", get(book_by_hash)) 29 - .route("/categories", get(list_categories)) 30 28 .route("/search", get(search_all)) 31 29 .route("/search/title", get(search_title)) 32 30 .route("/search/author", get(search_author)) ··· 37 35 #[derive(serde::Serialize)] 38 36 struct RootResponse { 39 37 message: &'static str, 40 - endpoints: [&'static str; 10], 38 + endpoints: [&'static str; 8], 41 39 } 42 40 43 41 async fn root() -> axum::Json<RootResponse> { ··· 47 45 "/api/health", 48 46 "/api/books?limit=25&offset=0", 49 47 "/api/books/by-author?author=Name&limit=25&offset=0", 50 - "/api/books/by-category?category=Category&limit=25&offset=0", 51 48 "/api/books/by-hash/:hash", 52 - "/api/categories", 53 49 "/api/search?query=term&limit=25&offset=0", 54 50 "/api/search/title?query=term&limit=25&offset=0", 55 51 "/api/search/author?query=term&limit=25&offset=0", ··· 140 136 Ok(Json(items)) 141 137 } 142 138 143 - #[derive(Deserialize)] 144 - struct CategoryQuery { 145 - category: String, 146 - limit: Option<usize>, 147 - offset: Option<usize>, 148 - } 149 - 150 - async fn books_by_category( 151 - State(state): State<AppState>, 152 - Query(query): Query<CategoryQuery>, 153 - ) -> Result<Json<Vec<BookDetail>>, ApiError> { 154 - let library = state.library.lock().expect("library mutex poisoned"); 155 - let category = query.category.trim(); 156 - if category.is_empty() { 157 - return Err(ApiError::bad_request("category is required")); 158 - } 159 - 160 - let limit = clamp_limit(query.limit); 161 - let offset = query.offset.unwrap_or(0); 162 - 163 - let items = library 164 - .books_in_category(category) 165 - .skip(offset) 166 - .take(limit) 167 - .map(|book| book_to_detail(book, &library)) 168 - .collect(); 169 - 170 - Ok(Json(items)) 171 - } 172 - 173 139 async fn book_by_hash( 174 140 State(state): State<AppState>, 175 141 Path(hash): Path<String>, ··· 181 147 }; 182 148 183 149 Ok(Json(book_to_detail(book, &library))) 184 - } 185 - 186 - #[derive(Serialize)] 187 - struct CategorySummary { 188 - name: String, 189 - count: usize, 190 - } 191 - 192 - async fn list_categories( 193 - State(state): State<AppState>, 194 - ) -> Json<Vec<CategorySummary>> { 195 - let library = state.library.lock().expect("library mutex poisoned"); 196 - let mut categories: Vec<CategorySummary> = library 197 - .categories() 198 - .into_iter() 199 - .map(|(name, count)| CategorySummary { 200 - name: name.to_string(), 201 - count, 202 - }) 203 - .collect(); 204 - categories.sort_by(|a, b| a.name.cmp(&b.name)); 205 - Json(categories) 206 150 } 207 151 208 152 #[derive(Deserialize)]
+63 -84
src/web/assets/stylesheet.css
··· 1 1 @font-face { 2 2 font-family: "Minecraft"; 3 - src: url("/assets/font/regular.woff2?v=__NARA_ASSET_VERSION__") format("woff2"); 3 + src: url("/assets/font/regular.woff2?v=__NARA_ASSET_VERSION__") 4 + format("woff2"); 4 5 font-weight: 400; 5 6 font-style: normal; 6 7 font-display: swap; ··· 16 17 17 18 @font-face { 18 19 font-family: "Minecraft"; 19 - src: url("/assets/font/italic.woff2?v=__NARA_ASSET_VERSION__") format("woff2"); 20 + src: url("/assets/font/italic.woff2?v=__NARA_ASSET_VERSION__") 21 + format("woff2"); 20 22 font-weight: 400; 21 23 font-style: italic; 22 24 font-display: swap; ··· 24 26 25 27 @font-face { 26 28 font-family: "Minecraft"; 27 - src: url("/assets/font/bold_italic.woff2?v=__NARA_ASSET_VERSION__") format("woff2"); 29 + src: url("/assets/font/bold_italic.woff2?v=__NARA_ASSET_VERSION__") 30 + format("woff2"); 28 31 font-weight: 700; 29 32 font-style: italic; 30 33 font-display: swap; 31 34 } 32 35 33 36 .font-minecraft { 34 - font-family: "Minecraft", system-ui, -apple-system, "Segoe UI", sans-serif; 37 + font-family: 38 + "Minecraft", 39 + system-ui, 40 + -apple-system, 41 + "Segoe UI", 42 + sans-serif; 35 43 font-synthesis: none; 36 44 } 37 45 ··· 44 52 } 45 53 46 54 .text-dark_blue { 47 - color: #0000AA; 55 + color: #0000aa; 48 56 } 49 57 50 58 .text-dark_green { 51 - color: #00AA00; 59 + color: #00aa00; 52 60 } 53 61 54 62 .text-dark_aqua { 55 - color: #00AAAA; 63 + color: #00aaaa; 56 64 } 57 65 58 66 .text-dark_red { 59 - color: #AA0000; 67 + color: #aa0000; 60 68 } 61 69 62 70 .text-dark_purple { 63 - color: #AA00AA; 71 + color: #aa00aa; 64 72 } 65 73 66 74 .text-gold { 67 - color: #FFAA00; 75 + color: #ffaa00; 68 76 } 69 77 70 78 .text-gray { 71 - color: #AAAAAA; 79 + color: #aaaaaa; 72 80 } 73 81 74 82 .text-dark_gray { ··· 76 84 } 77 85 78 86 .text-blue { 79 - color: #5555FF; 87 + color: #5555ff; 80 88 } 81 89 82 90 .text-green { 83 - color: #55FF55; 91 + color: #55ff55; 84 92 } 85 93 86 94 .text-aqua { 87 - color: #55FFFF; 95 + color: #55ffff; 88 96 } 89 97 90 98 .text-red { 91 - color: #FF5555; 99 + color: #ff5555; 92 100 } 93 101 94 102 .text-light_purple { 95 - color: #FF55FF; 103 + color: #ff55ff; 96 104 } 97 105 98 106 .text-yellow { 99 - color: #FFFF55; 107 + color: #ffff55; 100 108 } 101 109 102 110 .text-white { 103 - color: #FFFFFF; 111 + color: #ffffff; 104 112 } 105 113 106 114 :root { ··· 118 126 119 127 --accent: #4f9a3a; 120 128 --accent-strong: #336f26; 121 - --category-active-border: #275117; 122 - --category-active-surface: #78b65a; 123 - --category-active-ink: #0f2a09; 124 129 125 130 --border: #3b3b3b; 126 131 --button-border: #1f1f1f; ··· 168 173 margin: 0; 169 174 font-family: inherit; 170 175 color: var(--ink); 171 - background: 172 - linear-gradient(45deg, #7f7f7f 25%, #777777 25%, #777777 50%, #7f7f7f 50%, #7f7f7f 75%, #777777 75%, #777777 100%); 176 + background: linear-gradient( 177 + 45deg, 178 + #7f7f7f 25%, 179 + #777777 25%, 180 + #777777 50%, 181 + #7f7f7f 50%, 182 + #7f7f7f 75%, 183 + #777777 75%, 184 + #777777 100% 185 + ); 173 186 background-size: 1rem 1rem; 174 187 } 175 188 ··· 206 219 background: var(--tile-surface); 207 220 } 208 221 209 - :is(.book-badge, .book-icon, .category-link, .tag, .chip, .detail-link, button, .button, .link-reset, input[type="search"], select) { 222 + :is( 223 + .book-badge, 224 + .book-icon, 225 + .category-link, 226 + .tag, 227 + .chip, 228 + .detail-link, 229 + button, 230 + .button, 231 + .link-reset, 232 + input[type="search"], 233 + select 234 + ) { 210 235 border-radius: var(--radius-control); 211 236 } 212 237 ··· 338 363 339 364 .layout { 340 365 margin-top: 1.5rem; 341 - display: grid; 342 - grid-template-columns: 16.25rem 1fr; 343 - gap: 1.5rem; 344 - } 345 - 346 - .sidebar .panel { 347 - margin-bottom: 1.5rem; 348 - padding: 1.125rem; 349 - } 350 - 351 - .sidebar h2 { 352 - margin: 0 0 0.75rem; 353 - font-size: 1.125rem; 354 - } 355 - 356 - .all-link { 357 - display: inline-block; 358 - margin-bottom: 0.75rem; 359 - font-size: 0.8125rem; 360 - } 361 - 362 - .category-list { 363 - margin: 0; 364 - padding: 0; 365 - list-style: none; 366 - display: grid; 367 - gap: 0.5rem; 368 - } 369 - 370 - .category-link { 371 - display: flex; 372 - justify-content: space-between; 373 - padding: 0.5rem 0.625rem; 374 - border: var(--border-thick); 375 - color: var(--ink); 376 - background: #c3c3c3; 377 - } 378 - 379 - .category-link.active { 380 - border-color: var(--category-active-border); 381 - background: var(--category-active-surface); 382 - color: var(--category-active-ink); 383 - } 384 - 385 - .category-link .count { 386 - font-size: 0.75rem; 387 - color: var(--ink-muted); 388 366 } 389 367 390 368 .results-header { ··· 412 390 .book-tile { 413 391 text-align: center; 414 392 padding: 0.75rem 0.625rem; 415 - transition: border 0.15s ease, background 0.15s ease; 393 + transition: 394 + border 0.15s ease, 395 + background 0.15s ease; 416 396 } 417 397 418 398 .book-tile:hover { ··· 434 414 align-items: center; 435 415 justify-content: center; 436 416 box-shadow: var(--bevel-control); 437 - transition: transform 0.15s ease, box-shadow 0.15s ease; 417 + transition: 418 + transform 0.15s ease, 419 + box-shadow 0.15s ease; 438 420 } 439 421 440 422 .book-icon:hover { ··· 521 503 522 504 .pager { 523 505 margin-top: 1.125rem; 506 + margin-bottom: 1.125rem; 524 507 display: flex; 525 508 gap: 0.75rem; 526 509 } ··· 567 550 margin-top: 1.5rem; 568 551 margin-bottom: 1.5rem; 569 552 width: 100%; 570 - max-width: calc((var(--book-sprite-width) * 3) + (var(--book-grid-gap) * 2)); 553 + max-width: calc( 554 + (var(--book-sprite-width) * 3) + (var(--book-grid-gap) * 2) 555 + ); 571 556 margin-inline: auto; 572 557 } 573 558 ··· 659 644 mask-image: var(--mask, var(--book-mask)); 660 645 mask-repeat: no-repeat; 661 646 mask-size: 100% 100%; 662 - animation: glintMove 10s linear infinite, glintHue 10s linear infinite; 647 + animation: 648 + glintMove 10s linear infinite, 649 + glintHue 10s linear infinite; 663 650 } 664 651 665 652 @keyframes glintMove { ··· 683 670 } 684 671 685 672 @media (max-width: 61.25rem) { 686 - .layout { 687 - grid-template-columns: 1fr; 688 - } 689 - 690 - .sidebar .panel { 691 - position: static; 692 - } 693 - 694 673 .search { 695 674 grid-template-columns: 1fr; 696 675 } ··· 706 685 flex-direction: column; 707 686 align-items: flex-start; 708 687 } 709 - } 688 + }
+14 -146
src/web/pages.rs
··· 36 36 pub next_offset: usize, 37 37 pub pager_query: String, 38 38 pub books: Vec<BookCard>, 39 - pub categories: Vec<CategoryView>, 40 39 pub authors: Vec<AuthorView>, 41 40 pub git_hash: String, 42 41 } ··· 56 55 pub struct IndexQuery { 57 56 q: Option<String>, 58 57 scope: Option<String>, 59 - category: Option<String>, 60 58 author: Option<String>, 61 59 limit: Option<usize>, 62 60 offset: Option<usize>, ··· 71 69 pub struct QueryView { 72 70 pub q: String, 73 71 pub scope: String, 74 - pub category: String, 75 72 pub author: String, 76 - pub has_category: bool, 77 73 pub has_author: bool, 78 74 pub active: bool, 79 75 } 80 76 81 77 #[derive(Debug)] 82 - pub struct CategoryView { 83 - pub name: String, 84 - pub count: usize, 85 - pub href: String, 86 - pub active: bool, 87 - } 88 - 89 - #[derive(Debug)] 90 78 pub struct AuthorView { 91 79 pub name: String, 92 80 pub count: usize, ··· 107 95 pub author: String, 108 96 pub author_href: String, 109 97 pub source: BookSource, 110 - pub category: String, 111 - pub category_href: String, 112 98 pub pages: Vec<PageView>, 113 99 pub page_count: usize, 114 100 } ··· 130 116 131 117 impl HtmlSafe for HtmlText {} 132 118 133 - const DEFAULT_LIMIT: usize = 25; 134 - const MAX_LIMIT: usize = 50; 119 + const DEFAULT_LIMIT: usize = 100; 120 + const MAX_LIMIT: usize = 200; 135 121 const SEARCH_MAX_RESULTS: usize = 200; 136 122 137 123 #[derive(Debug)] 138 124 struct FilterParams { 139 125 query: String, 140 126 scope: &'static str, 141 - category: Option<String>, 142 127 author: Option<String>, 143 128 limit: usize, 144 129 offset: usize, 145 130 author_norm: Option<String>, 146 - category_norm: Option<String>, 147 131 } 148 132 149 133 pub async fn index( ··· 162 146 String, 163 147 usize, 164 148 String, 165 - ) = if !params.query.is_empty() 166 - || params.author.is_some() 167 - || params.category.is_some() 168 - { 149 + ) = if !params.query.is_empty() || params.author.is_some() { 169 150 let filtered = collect_candidates(&library, &params); 170 151 let total = filtered.len(); 171 152 let offset_used = params.offset.min(total); ··· 181 162 format!("Top {0} matches", total) 182 163 }; 183 164 let view_label = if params.query.is_empty() { 184 - filtered_view_label( 185 - params.author.as_deref(), 186 - params.category.as_deref(), 187 - ) 165 + filtered_view_label(params.author.as_deref()) 188 166 } else { 189 167 format!("Search results for \"{0}\"", params.query) 190 168 }; ··· 207 185 ) 208 186 }; 209 187 210 - let categories = build_categories(&library, params.category.as_deref()); 211 188 let authors = build_authors(&library); 212 189 let pager_query = build_pager_query( 213 190 &params.query, 214 191 params.scope, 215 - params.category.as_deref(), 216 192 params.author.as_deref(), 217 193 ); 218 - let has_category = params.category.is_some(); 219 194 let has_author = params.author.is_some(); 220 195 221 196 let active = !params.query.is_empty() 222 - || params.category.is_some() 223 197 || params.author.is_some() 224 198 || params.offset > 0; 225 199 ··· 236 210 let back_href = build_index_href( 237 211 &params.query, 238 212 params.scope, 239 - params.category.as_deref(), 240 213 params.author.as_deref(), 241 214 params.limit, 242 215 offset_used, ··· 253 226 query: QueryView { 254 227 q: params.query.clone(), 255 228 scope: params.scope.to_string(), 256 - category: params.category.clone().unwrap_or_default(), 257 229 author: params.author.clone().unwrap_or_default(), 258 - has_category, 259 230 has_author, 260 231 active, 261 232 }, ··· 272 243 next_offset, 273 244 pager_query, 274 245 books, 275 - categories, 276 246 authors, 277 247 git_hash, 278 248 } ··· 317 287 let back_href = build_index_href( 318 288 &params.query, 319 289 params.scope, 320 - params.category.as_deref(), 321 290 params.author.as_deref(), 322 291 params.limit, 323 292 params.offset, ··· 338 307 fn from_query(query: IndexQuery) -> Self { 339 308 let query_text = query.q.unwrap_or_default(); 340 309 let query_text = query_text.trim().to_string(); 341 - let category = cleaned_param(query.category); 342 310 let author = cleaned_param(query.author); 343 311 Self { 344 312 query: query_text, 345 313 scope: parse_scope(query.scope.as_deref()), 346 314 limit: clamp_limit(query.limit), 347 315 offset: query.offset.unwrap_or(0), 348 - category_norm: category.as_deref().map(normalize_query), 349 316 author_norm: author.as_deref().map(normalize_query), 350 - category, 351 317 author, 352 318 } 353 319 } ··· 358 324 params: &FilterParams, 359 325 ) -> Vec<&'a Book> { 360 326 if !params.query.is_empty() { 361 - // Search results are narrowed by optional author/category filters. 362 327 return search_books( 363 328 library, 364 329 params.scope, ··· 366 331 SEARCH_MAX_RESULTS, 367 332 ) 368 333 .into_iter() 369 - .filter(|book| { 370 - matches_author(book, params.author_norm.as_deref()) 371 - && matches_category( 372 - book, 373 - library, 374 - params.category_norm.as_deref(), 375 - ) 376 - }) 334 + .filter(|book| matches_author(book, params.author_norm.as_deref())) 377 335 .collect(); 378 336 } 379 337 380 - if let (Some(_author_name), Some(category_name)) = 381 - (params.author.as_deref(), params.category.as_deref()) 382 - { 383 - return library 384 - .books_in_category(category_name) 385 - .filter(|book| matches_author(book, params.author_norm.as_deref())) 386 - .collect(); 387 - } 388 338 if let Some(author_name) = params.author.as_deref() { 389 339 return library.books_by_author(author_name).collect(); 390 340 } 391 - if let Some(category_name) = params.category.as_deref() { 392 - return library.books_in_category(category_name).collect(); 393 - } 394 341 library.all_books().collect() 395 342 } 396 343 397 - fn filtered_view_label(author: Option<&str>, category: Option<&str>) -> String { 398 - match (author, category) { 399 - (Some(author), Some(category)) => { 400 - format!("Author: {0} · Category: {1}", author, category) 401 - } 402 - (Some(author), None) => format!("Author: {0}", author), 403 - (None, Some(category)) => format!("Category: {0}", category), 404 - (None, None) => String::from("Filtered books"), 344 + fn filtered_view_label(author: Option<&str>) -> String { 345 + match author { 346 + Some(author) => format!("Author: {0}", author), 347 + None => String::from("Filtered books"), 405 348 } 406 349 } 407 350 408 - fn build_categories( 409 - library: &Library, 410 - active: Option<&str>, 411 - ) -> Vec<CategoryView> { 412 - let mut categories: Vec<CategoryView> = library 413 - .categories() 414 - .into_iter() 415 - .map(|(name, count)| { 416 - let name_str = name.to_string(); 417 - CategoryView { 418 - active: active 419 - .map(|v| v.eq_ignore_ascii_case(&name_str)) 420 - .unwrap_or(false), 421 - href: format!( 422 - "/?category={0}", 423 - encode_query_component(&name_str) 424 - ), 425 - name: name_str, 426 - count, 427 - } 428 - }) 429 - .collect(); 430 - categories.sort_by(|a, b| { 431 - b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)) 432 - }); 433 - categories 434 - } 435 - 436 351 fn build_authors(library: &Library) -> Vec<AuthorView> { 437 352 let mut counts: ahash::AHashMap<SmolStr, (SmolStr, usize)> = 438 353 ahash::AHashMap::new(); ··· 473 388 (group, trimmed.to_lowercase()) 474 389 } 475 390 476 - fn build_pager_query( 477 - q: &str, 478 - scope: &str, 479 - category: Option<&str>, 480 - author: Option<&str>, 481 - ) -> String { 391 + fn build_pager_query(q: &str, scope: &str, author: Option<&str>) -> String { 482 392 let mut parts = Vec::new(); 483 393 if !q.is_empty() { 484 394 parts.push(format!("q={0}", encode_query_component(q))); ··· 486 396 parts.push(format!("scope={0}", encode_query_component(scope))); 487 397 } 488 398 } 489 - if let Some(category) = category { 490 - parts.push(format!("category={0}", encode_query_component(category))); 491 - } 492 399 if let Some(author) = author { 493 400 parts.push(format!("author={0}", encode_query_component(author))); 494 401 } ··· 502 409 fn build_index_href( 503 410 q: &str, 504 411 scope: &str, 505 - category: Option<&str>, 506 412 author: Option<&str>, 507 413 limit: usize, 508 414 offset: usize, ··· 514 420 parts.push(format!("scope={0}", encode_query_component(scope))); 515 421 } 516 422 } 517 - if let Some(category) = category { 518 - parts.push(format!("category={0}", encode_query_component(category))); 519 - } 520 423 if let Some(author) = author { 521 424 parts.push(format!("author={0}", encode_query_component(author))); 522 425 } ··· 547 450 } 548 451 } 549 452 550 - struct BookMeta { 551 - author_href: String, 552 - category: String, 553 - category_href: String, 554 - } 555 - 556 - fn book_meta(book: &Book, library: &Library) -> BookMeta { 557 - let category = library.category_for_hash(&book.hash()).to_string(); 558 - 559 - let category_href = 560 - format!("/?category={0}", encode_query_component(&category)); 561 - let author_href = 562 - format!("/?author={0}", encode_query_component(&book.content.author)); 563 - 564 - BookMeta { 565 - author_href, 566 - category, 567 - category_href, 568 - } 569 - } 570 - 571 453 fn book_card(book: &Book) -> BookCard { 572 454 let hash_hex = hex::encode(book.hash()); 573 455 let author_href = ··· 582 464 } 583 465 } 584 466 585 - fn book_detail(book: &Book, library: &Library) -> BookDetail { 586 - let meta = book_meta(book, library); 467 + fn book_detail(book: &Book, _library: &Library) -> BookDetail { 468 + let author_href = 469 + format!("/?author={0}", encode_query_component(&book.content.author)); 587 470 let pages = book 588 471 .content 589 472 .pages ··· 598 481 BookDetail { 599 482 title: book.content.title.clone(), 600 483 author: book.content.author.clone(), 601 - author_href: meta.author_href, 484 + author_href, 602 485 source: book.metadata.source.clone(), 603 - category: meta.category, 604 - category_href: meta.category_href, 605 486 pages, 606 487 page_count: book.content.pages.len(), 607 488 } ··· 700 581 return true; 701 582 }; 702 583 book.content.author.trim().eq_ignore_ascii_case(author_norm) 703 - } 704 - 705 - fn matches_category( 706 - book: &Book, 707 - library: &Library, 708 - category_norm: Option<&str>, 709 - ) -> bool { 710 - let Some(category_norm) = category_norm else { 711 - return true; 712 - }; 713 - library 714 - .category_for_hash(&book.hash()) 715 - .eq_ignore_ascii_case(category_norm) 716 584 } 717 585 718 586 fn parse_hash(hex_str: &str) -> Option<[u8; 20]> {
-3
templates/book.html
··· 37 37 by <a href="{{ book.author_href }}">{{ book.author }}</a> 38 38 </p> 39 39 <div class="meta-row"> 40 - <a class="tag-link" href="{{ book.category_href }}" 41 - ><span class="tag">{{ book.category }}</span></a 42 - > 43 40 <span class="tag subtle">{{ book.page_count }} pages</span> 44 41 </div> 45 42 </div>
+1 -34
templates/index.html
··· 53 53 {% endfor %} 54 54 </select> 55 55 </label> 56 - <label class="field"> 57 - <span>Category filter</span> 58 - <select name="category"> 59 - <option value="" {% if !query.has_category %}selected{% endif %}>All categories</option> 60 - {% for c in categories %} 61 - <option value="{{ c.name }}" {% if query.category==c.name %}selected{% endif %}>{{ c.name }} 62 - </option> 63 - {% endfor %} 64 - </select> 65 - </label> 66 56 <div class="actions"> 67 57 <button type="submit">Search</button> 68 58 {% if query.active %} ··· 72 62 </div> 73 63 </form> 74 64 75 - {% if query.has_category || query.has_author %} 65 + {% if query.has_author %} 76 66 <div class="active-filters"> 77 - {% if query.has_category %} 78 - <span class="chip">Category: {{ query.category }}</span> 79 - {% endif %} 80 - {% if query.has_author %} 81 67 <span class="chip">Author: {{ query.author }}</span> 82 - {% endif %} 83 68 </div> 84 69 {% endif %} 85 70 </header> 86 71 87 72 <section class="layout"> 88 - <aside class="sidebar"> 89 - <div class="panel"> 90 - <h2>Browse Categories</h2> 91 - <a class="all-link" href="/">All books</a> 92 - <ul class="category-list"> 93 - {% for c in categories %} 94 - <li> 95 - <a class="category-link {% if c.active %}active{% endif %}" href="{{ c.href }}"> 96 - <span>{{ c.name }}</span> 97 - <span class="count">{{ c.count }}</span> 98 - </a> 99 - </li> 100 - {% endfor %} 101 - </ul> 102 - </div> 103 - </aside> 104 - 105 73 <section class="results"> 106 74 <div class="results-header"> 107 75 <div> ··· 158 126 {% endif %} 159 127 </nav> 160 128 {% endif %} 161 - </section> 162 129 </section> 163 130 {% endblock %}