1use std::collections::BTreeMap;
12
13use clap::{CommandFactory, FromArgMatches, Parser, Subcommand};
14use serde::{Deserialize, Serialize};
15use serde_json::{Value, json};
16use table_editor::{
17 ApiError, App, Column, Context, Datalist, Front, MapSpec, NewRow, OptionsBy, Param, Schema,
18 Section, SelectOption, Server, ServerArgs, Speak, Table, TableLogic, ValidationError, View,
19 ViewArgs, ViewData, ViewLogic,
20};
21
22const BOOKS_FILE: &str = "Books.jsonl";
23const GENRES_FILE: &str = "Genres.jsonl";
24const BRANCHES_FILE: &str = "Branches.jsonl";
25
26const DEFAULT_PORT: u16 = 8791;
27
28#[derive(Debug, Serialize, Deserialize)]
34struct Book {
35 title: String,
36 author_first: String,
37 author_last: String,
38 genre: String,
39 subgenre: String,
40 publisher: String,
41 donor: String,
42 call_number: String,
43 pronunciation: String,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 edition: Option<u32>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 year: Option<u32>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 copies: Option<u32>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 rating: Option<f64>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 lent: Option<bool>,
54 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
55 shelved: BTreeMap<String, String>,
56 #[serde(default, skip_serializing_if = "String::is_empty")]
58 due: String,
59 #[serde(default, skip_serializing_if = "String::is_empty")]
61 link: String,
62 notes: String,
63}
64
65#[derive(Debug, Serialize, Deserialize)]
66struct Genre {
67 genre: String,
68 subgenre: String,
69}
70
71#[derive(Debug, Serialize, Deserialize)]
72struct Branch {
73 code: String,
74 name: String,
75 librarian_first: String,
76 librarian_last: String,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 staff: Option<u32>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 open: Option<bool>,
81 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
82 hours: BTreeMap<String, String>,
83}
84
85struct Books;
88
89impl Books {
90 fn genres(ctx: &Context) -> Result<Vec<Genre>, ApiError> {
91 ctx.optional_rows(GENRES_FILE)
92 }
93
94 fn branches(ctx: &Context) -> Result<Vec<Branch>, ApiError> {
95 ctx.optional_rows(BRANCHES_FILE)
96 }
97
98 fn shelf_mark(row: &Book) -> String {
99 let call = row.call_number.trim();
100 let author = row.author_last.trim();
101 match (call.is_empty(), author.is_empty()) {
102 (true, true) => String::new(),
103 (true, false) => author.to_string(),
104 (false, true) => call.to_string(),
105 (false, false) => format!("{call} {author}"),
106 }
107 }
108}
109
110impl TableLogic for Books {
111 type Row = Book;
112
113 fn name(&self) -> &'static str {
114 "books"
115 }
116
117 fn file(&self) -> &'static str {
118 BOOKS_FILE
119 }
120
121 fn title(&self) -> &'static str {
122 "Books"
123 }
124
125 fn schema(&self, ctx: &Context) -> Result<Schema, ApiError> {
126 let genres = Self::genres(ctx)?;
127 let branches = Self::branches(ctx)?;
128
129 let mut names: Vec<&str> = genres.iter().map(|g| g.genre.as_str()).collect();
130 names.sort_unstable();
131 names.dedup();
132
133 let mut by_genre = OptionsBy::new("genre");
134 for genre in &names {
135 let subgenres: Vec<&str> = genres
136 .iter()
137 .filter(|g| g.genre == *genre)
138 .map(|g| g.subgenre.as_str())
139 .collect();
140 by_genre.insert(*genre, subgenres);
141 }
142
143 let widest = genres.iter().map(|g| g.subgenre.len()).max().unwrap_or(0);
148 let width_ch = u16::try_from(widest.max(8)).unwrap_or(u16::MAX);
149
150 let branch_keys: Vec<SelectOption> = branches
152 .iter()
153 .map(|b| SelectOption::labelled(&b.code, &b.name))
154 .collect();
155
156 let mut publishers: Vec<String> = ctx
157 .optional_rows::<Book>(BOOKS_FILE)?
158 .iter()
159 .map(|b| b.publisher.trim().to_string())
160 .filter(|p| !p.is_empty())
161 .collect();
162 publishers.sort_unstable();
163 publishers.dedup();
164
165 Ok(Schema::new([
166 Column::string("title", "Title").width_ch(30),
167 Column::string("author_first", "First").width_ch(12),
168 Column::string("author_last", "Last").width_ch(14),
169 Column::select("genre", "Genre", names)
170 .allow_empty()
171 .cascades_to(["subgenre"]),
172 Column::select_by("subgenre", "Subgenre", by_genre)
173 .allow_empty()
174 .width_ch(width_ch),
175 Column::select(
176 "edition",
177 "Edition",
178 [
179 SelectOption::labelled("1", "First (1)"),
180 SelectOption::labelled("2", "Second (2)"),
181 SelectOption::labelled("3", "Third (3)"),
182 ],
183 )
184 .allow_empty()
185 .numeric_value(),
186 Column::number("year", "Year").int_only().width_ch(4),
189 Column::number("copies", "Copies").int_only().width_ch(3),
190 Column::number("rating", "Rating").width_ch(3),
191 Column::boolean("lent", "Lent"),
192 Column::string("publisher", "Publisher")
193 .width_ch(20)
194 .datalist("publishers"),
195 Column::string("donor", "Donated by")
196 .width_ch(20)
197 .datalist("reader-names"),
198 Column::spaced_string("call_number", "Call no.").width_ch(14),
199 Column::string("pronunciation", "Say")
200 .width_ch(16)
201 .speak(Speak::new(
202 "http://127.0.0.1:8765/say?text={value}",
203 "table-editor-speech-url",
204 )),
205 Column::map(
206 "shelved",
207 "Shelved",
208 MapSpec::new("Branch", "Copies")
209 .chips_show_key()
210 .key_options(branch_keys)
211 .value_options([
212 SelectOption::labelled("one", "One"),
213 SelectOption::labelled("several", "Several"),
214 SelectOption::labelled("many", "Many"),
215 ]),
216 ),
217 Column::string("due", "Due").width_ch(10),
218 Column::string("link", "Catalogue").width_ch(30),
219 Column::computed("shelf", "Shelf mark", "shelf").width_ch(18),
220 Column::text("notes", "Notes").wide(),
221 ])
222 .sortable()
223 .datalist("publishers", Datalist::fixed(publishers))
224 .datalist(
225 "reader-names",
226 Datalist::from_rows(["author_last", "author_first"], ", "),
227 )
228 .new_row(
229 NewRow::new()
230 .with("title", "")
231 .with("author_first", "")
232 .with("author_last", "")
233 .with("genre", "")
234 .with("subgenre", "")
235 .with("publisher", "")
236 .with("donor", "")
237 .with("call_number", "")
238 .with("pronunciation", "")
239 .with("due", "")
240 .with("link", "")
241 .with("notes", "")
242 .with("copies", 1)
243 .carry_forward(["genre", "subgenre", "publisher"]),
244 ))
245 }
246
247 fn validate(&self, rows: &[Book], ctx: &Context) -> Result<Vec<ValidationError>, ApiError> {
248 let genres = Self::genres(ctx)?;
249 let branches = Self::branches(ctx)?;
250 let mut errors = Vec::new();
251
252 for (idx, row) in rows.iter().enumerate() {
253 let line = idx + 1;
254 if row.title.trim().is_empty() {
255 errors.push(ValidationError::field(
256 line,
257 "title",
258 "a book needs a title",
259 ));
260 }
261
262 if !genres.is_empty() && !row.subgenre.is_empty() {
264 let known = genres
265 .iter()
266 .any(|g| g.genre == row.genre && g.subgenre == row.subgenre);
267 if !known {
268 errors.push(ValidationError::field(
269 line,
270 "subgenre",
271 format!("not a subgenre of {}", row.genre),
272 ));
273 }
274 }
275
276 if !branches.is_empty() {
277 for branch in row.shelved.keys() {
278 if !branches.iter().any(|b| b.code == *branch) {
279 errors.push(ValidationError::field(
280 line,
281 "shelved",
282 format!("no branch has the code {branch}"),
283 ));
284 }
285 }
286 }
287 }
288
289 Ok(errors)
290 }
291
292 fn derive(&self, rows: &[Book], _ctx: &Context) -> Result<Vec<Value>, ApiError> {
293 Ok(rows
294 .iter()
295 .map(|row| json!({ "shelf": Self::shelf_mark(row) }))
296 .collect())
297 }
298
299 fn siblings(&self, ctx: &Context) -> Result<Value, ApiError> {
300 Ok(json!({ "genres": Self::genres(ctx)? }))
301 }
302}
303
304struct Genres;
307
308impl TableLogic for Genres {
309 type Row = Genre;
310
311 fn name(&self) -> &'static str {
312 "genres"
313 }
314
315 fn file(&self) -> &'static str {
316 GENRES_FILE
317 }
318
319 fn title(&self) -> &'static str {
320 "Genres"
321 }
322
323 fn schema(&self, _ctx: &Context) -> Result<Schema, ApiError> {
324 Ok(Schema::new([
325 Column::string("genre", "Genre").width_ch(18),
326 Column::string("subgenre", "Subgenre").width_ch(22),
327 ])
328 .sortable()
329 .new_row(
330 NewRow::new()
331 .with("genre", "")
332 .with("subgenre", "")
333 .carry_forward(["genre"]),
334 ))
335 }
336
337 fn validate(&self, rows: &[Genre], _ctx: &Context) -> Result<Vec<ValidationError>, ApiError> {
338 let mut errors = Vec::new();
339 for (idx, row) in rows.iter().enumerate() {
340 if row.genre.trim().is_empty() {
341 errors.push(ValidationError::field(
342 idx + 1,
343 "genre",
344 "a genre is needed",
345 ));
346 }
347 if row.subgenre.trim().is_empty() {
348 errors.push(ValidationError::field(
349 idx + 1,
350 "subgenre",
351 "a subgenre is needed",
352 ));
353 }
354 }
355 Ok(errors)
356 }
357}
358
359struct Branches;
362
363impl TableLogic for Branches {
364 type Row = Branch;
365
366 fn name(&self) -> &'static str {
367 "branches"
368 }
369
370 fn file(&self) -> &'static str {
371 BRANCHES_FILE
372 }
373
374 fn title(&self) -> &'static str {
375 "Branches"
376 }
377
378 fn schema(&self, _ctx: &Context) -> Result<Schema, ApiError> {
379 Ok(Schema::new([
383 Column::string("code", "Code").width_ch(3),
384 Column::string("name", "Name").width_ch(22),
385 Column::string("librarian_first", "Librarian").width_ch(8),
386 Column::string("librarian_last", "Surname")
387 .width_ch(8)
388 .datalist("librarian-names"),
389 Column::number("staff", "Staff").int_only().width_ch(2),
390 Column::boolean("open", "Open"),
391 Column::map(
392 "hours",
393 "Hours",
394 MapSpec::new("Day", "Hours")
395 .key_options(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"])
396 .value_options([
397 SelectOption::new("09:00-17:00"),
398 SelectOption::new("12:00-20:00"),
399 ])
400 .allow_new_keys()
401 .allow_new_values(),
402 ),
403 ])
404 .datalist(
405 "librarian-names",
406 Datalist::from_rows(["librarian_last"], " "),
407 )
408 .new_row(
409 NewRow::new()
410 .with("code", "")
411 .with("name", "")
412 .with("librarian_first", "")
413 .with("librarian_last", ""),
414 ))
415 }
416
417 fn validate(&self, rows: &[Branch], _ctx: &Context) -> Result<Vec<ValidationError>, ApiError> {
418 let mut errors = Vec::new();
419 for (idx, row) in rows.iter().enumerate() {
420 if row.code.trim().is_empty() {
421 errors.push(ValidationError::field(
422 idx + 1,
423 "code",
424 "a branch needs a code",
425 ));
426 }
427 }
428 Ok(errors)
429 }
430}
431
432struct OnLoan;
443
444fn days_from_civil(date: &str) -> Option<i64> {
448 let mut parts = date.split('-');
449 let y: i64 = parts.next()?.parse().ok()?;
450 let m: i64 = parts.next()?.parse().ok()?;
451 let d: i64 = parts.next()?.parse().ok()?;
452 if parts.next().is_some() || !(1..=12).contains(&m) || !(1..=31).contains(&d) {
453 return None;
454 }
455
456 let y = if m <= 2 { y - 1 } else { y };
457 let era = if y >= 0 { y } else { y - 399 } / 400;
458 let yoe = y - era * 400;
459 let mp = (m + 9) % 12;
460 let doy = (153 * mp + 2) / 5 + d - 1;
461 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
462 Some(era * 146_097 + doe - 719_468)
463}
464
465fn today() -> i64 {
466 let seconds = std::time::SystemTime::now()
467 .duration_since(std::time::UNIX_EPOCH)
468 .map(|d| d.as_secs() as i64)
469 .unwrap_or(0);
470 seconds / 86_400
471}
472
473impl OnLoan {
474 fn branches(ctx: &Context) -> Result<Vec<Branch>, ApiError> {
475 ctx.optional_rows(BRANCHES_FILE)
476 }
477
478 fn columns() -> Vec<Column> {
481 vec![
482 Column::string("title", "Title").width_ch(30).href("link"),
483 Column::string("author", "Author").width_ch(18),
484 Column::string("due", "Due").width_ch(10),
485 Column::number("days", "Days").width_ch(4),
486 ]
487 }
488
489 fn row(book: &Book, days: i64) -> Loan {
490 Loan {
491 title: book.title.clone(),
492 author: format!("{} {}", book.author_first, book.author_last)
493 .trim()
494 .to_string(),
495 due: if book.due.is_empty() {
496 "—".to_string()
497 } else {
498 book.due.clone()
499 },
500 days: days.abs(),
501 link: book.link.clone(),
502 }
503 }
504
505 fn any<'a>(label: &str, values: impl IntoIterator<Item = &'a str>) -> Vec<SelectOption> {
508 std::iter::once(SelectOption::labelled("", label))
509 .chain(values.into_iter().map(SelectOption::from))
510 .collect()
511 }
512}
513
514#[derive(Serialize)]
517struct Loan {
518 title: String,
519 author: String,
520 due: String,
521 days: i64,
522 link: String,
523}
524
525impl ViewLogic for OnLoan {
526 fn name(&self) -> &'static str {
527 "on-loan"
528 }
529
530 fn title(&self) -> &'static str {
531 "On loan"
532 }
533
534 fn params(&self, ctx: &Context, asked: &ViewArgs) -> Result<Vec<Param>, ApiError> {
535 let branches = Self::branches(ctx)?;
536 let options: Vec<SelectOption> = branches
537 .iter()
538 .map(|b| SelectOption::labelled(&b.code, &b.name))
539 .collect();
540 let first = branches.first().map(|b| b.code.clone()).unwrap_or_default();
541
542 let genres: Vec<Genre> = ctx.optional_rows(GENRES_FILE)?;
548 let mut named: Vec<&str> = genres.iter().map(|g| g.genre.as_str()).collect();
549 named.sort_unstable();
550 named.dedup();
551
552 let chosen = asked.get_or("genre", "");
553 let mut subgenres: Vec<&str> = genres
554 .iter()
555 .filter(|g| g.genre == chosen)
556 .map(|g| g.subgenre.as_str())
557 .collect();
558 subgenres.sort_unstable();
559
560 Ok(vec![
561 Param::select("branch", "Branch", options).default(first),
562 Param::select("genre", "Genre", Self::any("Every genre", named)).default(""),
563 Param::select(
564 "subgenre",
565 "Subgenre",
566 Self::any("Every subgenre", subgenres),
567 )
568 .default(""),
569 Param::string("author", "Author"),
570 ])
571 }
572
573 fn render(&self, args: &ViewArgs, ctx: &Context) -> Result<ViewData, ApiError> {
574 let books: Vec<Book> = ctx.optional_rows(BOOKS_FILE)?;
575 let branches = Self::branches(ctx)?;
576 let code = args.get_or("branch", "");
577 let branch = branches.iter().find(|b| b.code == code);
578 let named = branch.map(|b| b.name.as_str()).unwrap_or(code);
579
580 let genre = args.get_or("genre", "");
581 let subgenre = args.get_or("subgenre", "");
582 let author = args.get_or("author", "").trim().to_lowercase();
583
584 let held: Vec<&Book> = books
587 .iter()
588 .filter(|b| b.shelved.contains_key(code))
589 .filter(|b| genre.is_empty() || b.genre == genre)
590 .filter(|b| subgenre.is_empty() || b.subgenre == subgenre)
591 .filter(|b| {
592 author.is_empty()
593 || format!("{} {}", b.author_first, b.author_last)
594 .to_lowercase()
595 .contains(&author)
596 })
597 .collect();
598
599 let now = today();
600 let mut out = Vec::new();
601 let mut overdue = Vec::new();
602 for book in held.iter().filter(|b| b.lent.unwrap_or(false)) {
603 let days = days_from_civil(&book.due).map(|due| now - due).unwrap_or(0);
604 if days > 0 {
605 overdue.push(OnLoan::row(book, days));
606 } else {
607 out.push(OnLoan::row(book, days));
608 }
609 }
610
611 let lent = out.len() + overdue.len();
612 Ok(ViewData::new()
613 .note(format!(
614 "{lent} of {} book(s) at {named} are out on loan. A title links to its catalogue entry.",
615 held.len()
616 ))
617 .section(
618 Section::new(OnLoan::columns())
619 .heading("Out")
620 .note("Days left before they are due.")
621 .rows(out)?,
622 )
623 .section(
624 Section::new(OnLoan::columns())
625 .heading("Overdue")
626 .note("Days past due. Chase these.")
627 .rows(overdue)?,
628 ))
629 }
630}
631
632struct Library {
635 books: Books,
636 genres: Genres,
637 branches: Branches,
638 on_loan: OnLoan,
639}
640
641impl App for Library {
642 fn name(&self) -> &str {
643 "Library"
644 }
645
646 fn subtitle(&self) -> Option<&str> {
647 Some("Example")
648 }
649
650 fn tables(&self) -> Vec<&dyn Table> {
651 vec![&self.books, &self.genres, &self.branches]
652 }
653
654 fn views(&self) -> Vec<&dyn View> {
655 vec![&self.on_loan]
656 }
657
658 fn front(&self) -> Front {
661 Front::View("on-loan")
662 }
663}
664
665#[derive(Parser)]
666#[command(name = "library", about = "An example consumer of the table editor")]
667struct Cli {
668 #[command(subcommand)]
669 command: Command,
670}
671
672#[derive(Subcommand)]
673enum Command {
674 Web(ServerArgs),
676}
677
678fn main() -> anyhow::Result<()> {
679 std::env::set_current_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/examples/library"))?;
681
682 let command = ServerArgs::augment_help(Cli::command(), "the front page", DEFAULT_PORT);
683 let cli = Cli::from_arg_matches(&command.get_matches())?;
684
685 match cli.command {
686 Command::Web(args) => Server::new(Library {
687 books: Books,
688 genres: Genres,
689 branches: Branches,
690 on_loan: OnLoan,
691 })
692 .child_env("LIBRARY_EXAMPLE_CHILD")
693 .default_port(DEFAULT_PORT)
694 .run(args),
695 }
696}