Skip to main content

library/
main.rs

1//! A worked consumer of the crate, for developing the browser bundle against
2//! and for seeing every column type on a page at once.
3//!
4//! Run it with `cargo run --example library -- web --api-only`, which serves
5//! the API in the foreground on port 8791, where the Vite dev server proxies
6//! it. Without `--api-only` it launches a browser on the embedded bundle.
7//!
8//! The tables it serves live in `examples/library/Data`, and the example moves
9//! into that directory itself, so it can be run from anywhere in the checkout.
10
11use 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// ── Rows ────────────────────────────────────────────────────────────────────
29
30/// A book. The fields that are always present are plain, and the ones a row may
31/// leave out are optional and skipped when empty, which is what lets a cleared
32/// cell be written as an absent field.
33#[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    /// When a lent book is due back, as `YYYY-MM-DD`.
57    #[serde(default, skip_serializing_if = "String::is_empty")]
58    due: String,
59    /// The catalogue entry a view links to.
60    #[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
85// ── Books ───────────────────────────────────────────────────────────────────
86
87struct 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        // Wide enough for the longest subgenre there is, computed here so the
144        // browser does not have to measure anything. It is the count of
145        // characters, nothing more: what the control puts around them is the
146        // bundle's business.
147        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        // A key that shows a branch's name and stores its code.
151        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            // A year is a whole number; a rating is not, which is what the
187            // absence of int_only means.
188            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            // A subgenre stands or falls with the genre it was chosen under.
263            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
304// ── Genres ──────────────────────────────────────────────────────────────────
305
306struct 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
359// ── Branches ────────────────────────────────────────────────────────────────
360
361struct 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        // Row order here is the order the branches are listed in, which is a
380        // choice the table makes, so this table is not sortable and rows are
381        // dragged into place instead.
382        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
432// ── The On loan view ────────────────────────────────────────────────────────
433
434/// What is out on loan from one branch, in two sections: the books still
435/// within their time, and the ones past it.
436///
437/// A view computes its rows rather than storing them, so nothing here is in a
438/// file: the counts, the days, and which section a book falls into are worked
439/// out per request from the tables the example already ships. The due dates
440/// are fixed in the data, so as real time passes more of them fall overdue,
441/// which is what an example of an overdue list should do.
442struct OnLoan;
443
444/// Days since 1970-01-01 for a `YYYY-MM-DD` date, or nothing for text that is
445/// not one. Howard Hinnant's civil-days algorithm, which needs no calendar
446/// library and no dependency.
447fn 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    /// The columns both sections show. They are the same in each, so the two
479    /// line up; a section that wanted a column of its own would say so here.
480    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    /// A list of options with "any of them" in front of it, whose value is
506    /// empty. Choosing it clears the parameter, which is an answer of its own.
507    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/// A row of either section. A view hands over its own type, so the shape of a
515/// row is written down once here rather than assembled field by field.
516#[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        // A genre and one of its subgenres. The second list follows the first
543        // one's answer, which is why `params` is told what was asked before
544        // anything is settled. A subgenre belonging to the genre chosen last
545        // time is no answer to the question being asked now, and falls back to
546        // every subgenre of the genre chosen this time.
547        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        // The books this branch holds that answer the rest of the question,
585        // whether they are out or not. A parameter left empty asks nothing.
586        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
632// ── The app ─────────────────────────────────────────────────────────────────
633
634struct 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    /// The reading happens on the view, so that is what a bare address opens;
659    /// the tables are where the writing happens.
660    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    /// Edit the tables in a browser.
675    Web(ServerArgs),
676}
677
678fn main() -> anyhow::Result<()> {
679    // The tables belong to the example, not to whoever ran it.
680    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}