pub struct Context { /* private fields */ }Expand description
The Data/ directory a request’s tables live in. Sibling reads go through
it too, so a table that cross-checks against another reads it from the same
place the editor writes it.
Each file is read from disk once per context. A table whose validate,
derive, and siblings all consult the same sibling therefore see one
version of it, however the file changes underneath them, and pay for one
read rather than three. A context is built per request, so a later request
reads the file again; parsing still happens per call, since the rows are
handed out by value and the row type differs from caller to caller.
What was read is remembered under the file name as it was spelled, not the
path it resolves to, so two spellings of one file would be read twice and
could disagree. A table’s file comes from crate::TableLogic::file,
which is one &'static str and a bare name, so a table and everything
cross-checking against it name the file the same way by construction.
Implementations§
Source§impl Context
impl Context
Sourcepub fn find() -> Result<Self>
pub fn find() -> Result<Self>
Walk up from the current directory to the nearest ancestor containing a
Data/ directory.
pub fn data_dir(&self) -> &Path
Sourcepub fn read(&self, file: &str) -> Result<String, ApiError>
pub fn read(&self, file: &str) -> Result<String, ApiError>
Read a file the table needs. A missing file is a 500: the table cannot be served without it.
Sourcepub fn read_optional(&self, file: &str) -> Result<Option<String>, ApiError>
pub fn read_optional(&self, file: &str) -> Result<Option<String>, ApiError>
Read a file the table can do without. A missing file is None; an
unreadable one is still a 500.
Sourcepub fn write(&self, file: &str, text: &str) -> Result<(), ApiError>
pub fn write(&self, file: &str, text: &str) -> Result<(), ApiError>
Replace a table file with new contents.
The text goes to a sibling temporary file first and is renamed over the target, so an interrupted write leaves the old table intact rather than a truncated one. The temporary file shares the directory, so the rename stays within one volume.
What was written becomes this context’s view of the file, so a read after a write sees the new text rather than whatever was read before.
Examples found in repository?
943 fn lend(fields: &Fields, args: &ViewArgs, ctx: &Context) -> Result<String, ApiError> {
944 let title = args.get_or("title", "");
945 let borrower = fields.text("borrower");
946 let days = fields.integer("days")?;
947 let from = fields.date("from")?;
948 let condition = fields.text("condition").to_lowercase();
949
950 if borrower.is_empty() {
951 return Err(ApiError::bad_request("a loan needs a borrower"));
952 }
953 if days < 1 {
954 return Err(ApiError::bad_request("a loan is at least one day long"));
955 }
956 let start = days_from_civil(from).ok_or_else(|| {
957 ApiError::bad_request(format!("{from} is not a date in the calendar"))
958 })?;
959 let due = civil_from_days(start + days);
960
961 let mut books: Vec<Book> = ctx.rows(BOOKS_FILE)?;
962 let book = books
963 .iter_mut()
964 .find(|b| b.title == title)
965 .ok_or_else(|| ApiError::bad_request(format!("no book is called \"{title}\"")))?;
966 if book.lent.unwrap_or(false) {
967 return Err(ApiError::bad_request(format!("\"{title}\" is already out")));
968 }
969 book.lent = Some(true);
970 book.due = due.clone();
971 book.notes = format!("Lent to {borrower} on {from}, {condition}.");
972
973 let problems = Books.validate(&books, ctx)?;
974 if let Some(first) = problems.first() {
975 return Err(ApiError::bad_request(format!(
976 "the loan would leave row {} in a state the table refuses: {}",
977 first.line, first.message
978 )));
979 }
980
981 let text = Books
982 .serialize(&books)
983 .map_err(|e| ApiError::server(format!("could not serialize {BOOKS_FILE}: {e}")))?;
984 ctx.write(BOOKS_FILE, &text)?;
985
986 Ok(format!("\"{title}\" is out to {borrower} until {due}."))
987 }Sourcepub fn rows<T: DeserializeOwned>(&self, file: &str) -> Result<Vec<T>, ApiError>
pub fn rows<T: DeserializeOwned>(&self, file: &str) -> Result<Vec<T>, ApiError>
Read and parse a file the table needs.
Examples found in repository?
943 fn lend(fields: &Fields, args: &ViewArgs, ctx: &Context) -> Result<String, ApiError> {
944 let title = args.get_or("title", "");
945 let borrower = fields.text("borrower");
946 let days = fields.integer("days")?;
947 let from = fields.date("from")?;
948 let condition = fields.text("condition").to_lowercase();
949
950 if borrower.is_empty() {
951 return Err(ApiError::bad_request("a loan needs a borrower"));
952 }
953 if days < 1 {
954 return Err(ApiError::bad_request("a loan is at least one day long"));
955 }
956 let start = days_from_civil(from).ok_or_else(|| {
957 ApiError::bad_request(format!("{from} is not a date in the calendar"))
958 })?;
959 let due = civil_from_days(start + days);
960
961 let mut books: Vec<Book> = ctx.rows(BOOKS_FILE)?;
962 let book = books
963 .iter_mut()
964 .find(|b| b.title == title)
965 .ok_or_else(|| ApiError::bad_request(format!("no book is called \"{title}\"")))?;
966 if book.lent.unwrap_or(false) {
967 return Err(ApiError::bad_request(format!("\"{title}\" is already out")));
968 }
969 book.lent = Some(true);
970 book.due = due.clone();
971 book.notes = format!("Lent to {borrower} on {from}, {condition}.");
972
973 let problems = Books.validate(&books, ctx)?;
974 if let Some(first) = problems.first() {
975 return Err(ApiError::bad_request(format!(
976 "the loan would leave row {} in a state the table refuses: {}",
977 first.line, first.message
978 )));
979 }
980
981 let text = Books
982 .serialize(&books)
983 .map_err(|e| ApiError::server(format!("could not serialize {BOOKS_FILE}: {e}")))?;
984 ctx.write(BOOKS_FILE, &text)?;
985
986 Ok(format!("\"{title}\" is out to {borrower} until {due}."))
987 }Sourcepub fn optional_rows<T: DeserializeOwned>(
&self,
file: &str,
) -> Result<Vec<T>, ApiError>
pub fn optional_rows<T: DeserializeOwned>( &self, file: &str, ) -> Result<Vec<T>, ApiError>
Read and parse a sibling table. A missing file yields no rows, so the cross-checks that consult it are skipped rather than failing; a present-but-unparseable file is a 500.
Examples found in repository?
91 fn genres(ctx: &Context) -> Result<Vec<Genre>, ApiError> {
92 ctx.optional_rows(GENRES_FILE)
93 }
94
95 fn branches(ctx: &Context) -> Result<Vec<Branch>, ApiError> {
96 ctx.optional_rows(BRANCHES_FILE)
97 }
98
99 fn shelf_mark(row: &Book) -> String {
100 let call = row.call_number.trim();
101 let author = row.author_last.trim();
102 match (call.is_empty(), author.is_empty()) {
103 (true, true) => String::new(),
104 (true, false) => author.to_string(),
105 (false, true) => call.to_string(),
106 (false, false) => format!("{call} {author}"),
107 }
108 }
109}
110
111impl TableLogic for Books {
112 type Row = Book;
113
114 fn name(&self) -> &'static str {
115 "books"
116 }
117
118 fn file(&self) -> &'static str {
119 BOOKS_FILE
120 }
121
122 fn title(&self) -> &'static str {
123 "Books"
124 }
125
126 fn schema(&self, ctx: &Context) -> Result<Schema, ApiError> {
127 let genres = Self::genres(ctx)?;
128 let branches = Self::branches(ctx)?;
129
130 let mut names: Vec<&str> = genres.iter().map(|g| g.genre.as_str()).collect();
131 names.sort_unstable();
132 names.dedup();
133
134 let mut by_genre = OptionsBy::new("genre");
135 for genre in &names {
136 let subgenres: Vec<&str> = genres
137 .iter()
138 .filter(|g| g.genre == *genre)
139 .map(|g| g.subgenre.as_str())
140 .collect();
141 by_genre.insert(*genre, subgenres);
142 }
143
144 // Wide enough for the longest subgenre there is, computed here so the
145 // browser does not have to measure anything. It is the count of
146 // characters, nothing more: what the control puts around them is the
147 // bundle's business.
148 let widest = genres.iter().map(|g| g.subgenre.len()).max().unwrap_or(0);
149 let width_ch = u16::try_from(widest.max(8)).unwrap_or(u16::MAX);
150
151 // A key that shows a branch's name and stores its code.
152 let branch_keys: Vec<SelectOption> = branches
153 .iter()
154 .map(|b| SelectOption::labelled(&b.code, &b.name))
155 .collect();
156
157 let mut publishers: Vec<String> = ctx
158 .optional_rows::<Book>(BOOKS_FILE)?
159 .iter()
160 .map(|b| b.publisher.trim().to_string())
161 .filter(|p| !p.is_empty())
162 .collect();
163 publishers.sort_unstable();
164 publishers.dedup();
165
166 Ok(Schema::new([
167 Column::string("title", "Title").width_ch(30),
168 Column::string("author_first", "First").width_ch(12),
169 Column::string("author_last", "Last").width_ch(14),
170 Column::select("genre", "Genre", names)
171 .allow_empty()
172 .cascades_to(["subgenre"]),
173 Column::select_by("subgenre", "Subgenre", by_genre)
174 .allow_empty()
175 .width_ch(width_ch),
176 Column::select(
177 "edition",
178 "Edition",
179 [
180 SelectOption::labelled("1", "First (1)"),
181 SelectOption::labelled("2", "Second (2)"),
182 SelectOption::labelled("3", "Third (3)"),
183 ],
184 )
185 .allow_empty()
186 .numeric_value(),
187 // A year is a whole number; a rating is not, which is what the
188 // absence of int_only means.
189 Column::number("year", "Year").int_only().width_ch(4),
190 Column::number("copies", "Copies").int_only().width_ch(3),
191 Column::number("rating", "Rating").width_ch(3),
192 Column::boolean("lent", "Lent"),
193 Column::string("publisher", "Publisher")
194 .width_ch(20)
195 .datalist("publishers"),
196 Column::string("donor", "Donated by")
197 .width_ch(20)
198 .datalist("reader-names"),
199 Column::spaced_string("call_number", "Call no.").width_ch(14),
200 Column::string("pronunciation", "Say")
201 .width_ch(16)
202 .speak(Speak::new(
203 "http://127.0.0.1:8765/say?text={value}",
204 "table-editor-speech-url",
205 )),
206 Column::map(
207 "shelved",
208 "Shelved",
209 MapSpec::new("Branch", "Copies")
210 .chips_show_key()
211 .key_options(branch_keys)
212 .value_options([
213 SelectOption::labelled("one", "One"),
214 SelectOption::labelled("several", "Several"),
215 SelectOption::labelled("many", "Many"),
216 ]),
217 ),
218 Column::string("due", "Due").width_ch(10),
219 Column::string("link", "Catalogue").width_ch(30),
220 Column::computed("shelf", "Shelf mark", "shelf").width_ch(18),
221 Column::text("notes", "Notes").wide(),
222 ])
223 .sortable()
224 .datalist("publishers", Datalist::fixed(publishers))
225 .datalist(
226 "reader-names",
227 Datalist::from_rows(["author_last", "author_first"], ", "),
228 )
229 .new_row(
230 NewRow::new()
231 .with("title", "")
232 .with("author_first", "")
233 .with("author_last", "")
234 .with("genre", "")
235 .with("subgenre", "")
236 .with("publisher", "")
237 .with("donor", "")
238 .with("call_number", "")
239 .with("pronunciation", "")
240 .with("due", "")
241 .with("link", "")
242 .with("notes", "")
243 .with("copies", 1)
244 .carry_forward(["genre", "subgenre", "publisher"]),
245 ))
246 }
247
248 fn validate(&self, rows: &[Book], ctx: &Context) -> Result<Vec<ValidationError>, ApiError> {
249 let genres = Self::genres(ctx)?;
250 let branches = Self::branches(ctx)?;
251 let mut errors = Vec::new();
252
253 for (idx, row) in rows.iter().enumerate() {
254 let line = idx + 1;
255 if row.title.trim().is_empty() {
256 errors.push(ValidationError::field(
257 line,
258 "title",
259 "a book needs a title",
260 ));
261 }
262
263 // A subgenre stands or falls with the genre it was chosen under.
264 if !genres.is_empty() && !row.subgenre.is_empty() {
265 let known = genres
266 .iter()
267 .any(|g| g.genre == row.genre && g.subgenre == row.subgenre);
268 if !known {
269 errors.push(ValidationError::field(
270 line,
271 "subgenre",
272 format!("not a subgenre of {}", row.genre),
273 ));
274 }
275 }
276
277 if !branches.is_empty() {
278 for branch in row.shelved.keys() {
279 if !branches.iter().any(|b| b.code == *branch) {
280 errors.push(ValidationError::field(
281 line,
282 "shelved",
283 format!("no branch has the code {branch}"),
284 ));
285 }
286 }
287 }
288 }
289
290 Ok(errors)
291 }
292
293 fn derive(&self, rows: &[Book], _ctx: &Context) -> Result<Vec<Value>, ApiError> {
294 Ok(rows
295 .iter()
296 .map(|row| json!({ "shelf": Self::shelf_mark(row) }))
297 .collect())
298 }
299
300 fn siblings(&self, ctx: &Context) -> Result<Value, ApiError> {
301 Ok(json!({ "genres": Self::genres(ctx)? }))
302 }
303}
304
305// ── Genres ──────────────────────────────────────────────────────────────────
306
307struct Genres;
308
309impl TableLogic for Genres {
310 type Row = Genre;
311
312 fn name(&self) -> &'static str {
313 "genres"
314 }
315
316 fn file(&self) -> &'static str {
317 GENRES_FILE
318 }
319
320 fn title(&self) -> &'static str {
321 "Genres"
322 }
323
324 fn schema(&self, _ctx: &Context) -> Result<Schema, ApiError> {
325 Ok(Schema::new([
326 Column::string("genre", "Genre").width_ch(18),
327 Column::string("subgenre", "Subgenre").width_ch(22),
328 ])
329 .sortable()
330 .new_row(
331 NewRow::new()
332 .with("genre", "")
333 .with("subgenre", "")
334 .carry_forward(["genre"]),
335 ))
336 }
337
338 fn validate(&self, rows: &[Genre], _ctx: &Context) -> Result<Vec<ValidationError>, ApiError> {
339 let mut errors = Vec::new();
340 for (idx, row) in rows.iter().enumerate() {
341 if row.genre.trim().is_empty() {
342 errors.push(ValidationError::field(
343 idx + 1,
344 "genre",
345 "a genre is needed",
346 ));
347 }
348 if row.subgenre.trim().is_empty() {
349 errors.push(ValidationError::field(
350 idx + 1,
351 "subgenre",
352 "a subgenre is needed",
353 ));
354 }
355 }
356 Ok(errors)
357 }
358}
359
360// ── Branches ────────────────────────────────────────────────────────────────
361
362struct Branches;
363
364impl TableLogic for Branches {
365 type Row = Branch;
366
367 fn name(&self) -> &'static str {
368 "branches"
369 }
370
371 fn file(&self) -> &'static str {
372 BRANCHES_FILE
373 }
374
375 fn title(&self) -> &'static str {
376 "Branches"
377 }
378
379 fn schema(&self, _ctx: &Context) -> Result<Schema, ApiError> {
380 // Row order here is the order the branches are listed in, which is a
381 // choice the table makes, so this table is not sortable and rows are
382 // dragged into place instead.
383 Ok(Schema::new([
384 Column::string("code", "Code").width_ch(3),
385 Column::string("name", "Name").width_ch(22),
386 Column::string("librarian_first", "Librarian").width_ch(8),
387 Column::string("librarian_last", "Surname")
388 .width_ch(8)
389 .datalist("librarian-names"),
390 Column::number("staff", "Staff").int_only().width_ch(2),
391 Column::boolean("open", "Open"),
392 Column::map(
393 "hours",
394 "Hours",
395 MapSpec::new("Day", "Hours")
396 .key_options(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"])
397 .value_options([
398 SelectOption::new("09:00-17:00"),
399 SelectOption::new("12:00-20:00"),
400 ])
401 .allow_new_keys()
402 .allow_new_values(),
403 ),
404 ])
405 .datalist(
406 "librarian-names",
407 Datalist::from_rows(["librarian_last"], " "),
408 )
409 .new_row(
410 NewRow::new()
411 .with("code", "")
412 .with("name", "")
413 .with("librarian_first", "")
414 .with("librarian_last", ""),
415 ))
416 }
417
418 fn validate(&self, rows: &[Branch], _ctx: &Context) -> Result<Vec<ValidationError>, ApiError> {
419 let mut errors = Vec::new();
420 for (idx, row) in rows.iter().enumerate() {
421 if row.code.trim().is_empty() {
422 errors.push(ValidationError::field(
423 idx + 1,
424 "code",
425 "a branch needs a code",
426 ));
427 }
428 }
429 Ok(errors)
430 }
431}
432
433// ── The On loan view ────────────────────────────────────────────────────────
434
435/// What is out on loan from one branch, in two sections: the books still
436/// within their time, and the ones past it.
437///
438/// A view computes its rows rather than storing them, so nothing here is in a
439/// file: the counts, the days, and which section a book falls into are worked
440/// out per request from the tables the example already ships. The due dates
441/// are fixed in the data, so as real time passes more of them fall overdue,
442/// which is what an example of an overdue list should do.
443struct OnLoan;
444
445/// Days since 1970-01-01 for a `YYYY-MM-DD` date, or nothing for text that is
446/// not one. Howard Hinnant's civil-days algorithm, which needs no calendar
447/// library and no dependency.
448fn days_from_civil(date: &str) -> Option<i64> {
449 let mut parts = date.split('-');
450 let y: i64 = parts.next()?.parse().ok()?;
451 let m: i64 = parts.next()?.parse().ok()?;
452 let d: i64 = parts.next()?.parse().ok()?;
453 if parts.next().is_some() || !(1..=12).contains(&m) || !(1..=31).contains(&d) {
454 return None;
455 }
456
457 let y = if m <= 2 { y - 1 } else { y };
458 let era = if y >= 0 { y } else { y - 399 } / 400;
459 let yoe = y - era * 400;
460 let mp = (m + 9) % 12;
461 let doy = (153 * mp + 2) / 5 + d - 1;
462 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
463 Some(era * 146_097 + doe - 719_468)
464}
465
466/// The `YYYY-MM-DD` date `days` days after 1970-01-01, which is the inverse of
467/// `days_from_civil` and the other half of the same algorithm.
468fn civil_from_days(days: i64) -> String {
469 let z = days + 719_468;
470 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
471 let doe = z - era * 146_097;
472 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
473 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
474 let mp = (5 * doy + 2) / 153;
475 let d = doy - (153 * mp + 2) / 5 + 1;
476 let m = if mp < 10 { mp + 3 } else { mp - 9 };
477 let y = yoe + era * 400 + i64::from(m <= 2);
478 format!("{y:04}-{m:02}-{d:02}")
479}
480
481fn today() -> i64 {
482 let seconds = std::time::SystemTime::now()
483 .duration_since(std::time::UNIX_EPOCH)
484 .map(|d| d.as_secs() as i64)
485 .unwrap_or(0);
486 seconds / 86_400
487}
488
489impl OnLoan {
490 fn branches(ctx: &Context) -> Result<Vec<Branch>, ApiError> {
491 ctx.optional_rows(BRANCHES_FILE)
492 }
493
494 /// The columns both sections show. They are the same in each, so the two
495 /// line up; a section that wanted a column of its own would say so here.
496 fn columns() -> Vec<Column> {
497 vec![
498 Column::string("title", "Title").width_ch(30).href("link"),
499 Column::string("author", "Author").width_ch(18),
500 Column::string("due", "Due").width_ch(10),
501 Column::number("days", "Days").width_ch(4),
502 ]
503 }
504
505 fn row(book: &Book, days: i64) -> Loan {
506 Loan {
507 title: book.title.clone(),
508 author: format!("{} {}", book.author_first, book.author_last)
509 .trim()
510 .to_string(),
511 due: if book.due.is_empty() {
512 "—".to_string()
513 } else {
514 book.due.clone()
515 },
516 days: days.abs(),
517 link: book.link.clone(),
518 }
519 }
520
521 /// A list of options with "any of them" in front of it, whose value is
522 /// empty. Choosing it clears the parameter, which is an answer of its own.
523 fn any<'a>(label: &str, values: impl IntoIterator<Item = &'a str>) -> Vec<SelectOption> {
524 std::iter::once(SelectOption::labelled("", label))
525 .chain(values.into_iter().map(SelectOption::from))
526 .collect()
527 }
528}
529
530/// A row of either section. A view hands over its own type, so the shape of a
531/// row is written down once here rather than assembled field by field.
532#[derive(Serialize)]
533struct Loan {
534 title: String,
535 author: String,
536 due: String,
537 days: i64,
538 link: String,
539}
540
541impl ViewLogic for OnLoan {
542 fn name(&self) -> &'static str {
543 "on-loan"
544 }
545
546 fn title(&self) -> &'static str {
547 "On loan"
548 }
549
550 fn params(&self, ctx: &Context, asked: &ViewArgs) -> Result<Vec<Param>, ApiError> {
551 let branches = Self::branches(ctx)?;
552 let options: Vec<SelectOption> = branches
553 .iter()
554 .map(|b| SelectOption::labelled(&b.code, &b.name))
555 .collect();
556 let first = branches.first().map(|b| b.code.clone()).unwrap_or_default();
557
558 // A genre and one of its subgenres. The second list follows the first
559 // one's answer, which is why `params` is told what was asked before
560 // anything is settled. A subgenre belonging to the genre chosen last
561 // time is no answer to the question being asked now, and falls back to
562 // every subgenre of the genre chosen this time.
563 let genres: Vec<Genre> = ctx.optional_rows(GENRES_FILE)?;
564 let mut named: Vec<&str> = genres.iter().map(|g| g.genre.as_str()).collect();
565 named.sort_unstable();
566 named.dedup();
567
568 let chosen = asked.get_or("genre", "");
569 let mut subgenres: Vec<&str> = genres
570 .iter()
571 .filter(|g| g.genre == chosen)
572 .map(|g| g.subgenre.as_str())
573 .collect();
574 subgenres.sort_unstable();
575
576 Ok(vec![
577 Param::select("branch", "Branch", options).default(first),
578 Param::select("genre", "Genre", Self::any("Every genre", named)).default(""),
579 Param::select(
580 "subgenre",
581 "Subgenre",
582 Self::any("Every subgenre", subgenres),
583 )
584 .default(""),
585 Param::string("author", "Author"),
586 ])
587 }
588
589 fn render(&self, args: &ViewArgs, ctx: &Context) -> Result<ViewData, ApiError> {
590 let books: Vec<Book> = ctx.optional_rows(BOOKS_FILE)?;
591 let branches = Self::branches(ctx)?;
592 let code = args.get_or("branch", "");
593 let branch = branches.iter().find(|b| b.code == code);
594 let named = branch.map(|b| b.name.as_str()).unwrap_or(code);
595
596 let genre = args.get_or("genre", "");
597 let subgenre = args.get_or("subgenre", "");
598 let author = args.get_or("author", "").trim().to_lowercase();
599
600 // The books this branch holds that answer the rest of the question,
601 // whether they are out or not. A parameter left empty asks nothing.
602 let held: Vec<&Book> = books
603 .iter()
604 .filter(|b| b.shelved.contains_key(code))
605 .filter(|b| genre.is_empty() || b.genre == genre)
606 .filter(|b| subgenre.is_empty() || b.subgenre == subgenre)
607 .filter(|b| {
608 author.is_empty()
609 || format!("{} {}", b.author_first, b.author_last)
610 .to_lowercase()
611 .contains(&author)
612 })
613 .collect();
614
615 let now = today();
616 let mut out = Vec::new();
617 let mut overdue = Vec::new();
618 for book in held.iter().filter(|b| b.lent.unwrap_or(false)) {
619 let days = days_from_civil(&book.due).map(|due| now - due).unwrap_or(0);
620 if days > 0 {
621 overdue.push(OnLoan::row(book, days));
622 } else {
623 out.push(OnLoan::row(book, days));
624 }
625 }
626
627 let lent = out.len() + overdue.len();
628 Ok(ViewData::new()
629 .note(format!(
630 "{lent} of {} book(s) at {named} are out on loan. A title links to its catalogue entry.",
631 held.len()
632 ))
633 .section(
634 Section::new(OnLoan::columns())
635 .heading("Out")
636 .note("Days left before they are due.")
637 .rows(out)?,
638 )
639 .section(
640 Section::new(OnLoan::columns())
641 .heading("Overdue")
642 .note("Days past due. Chase these.")
643 .rows(overdue)?,
644 ))
645 }
646}
647
648// ── The Branches view, which is a card per branch ────────────────────────────
649
650/// Every branch as a card, grouped by whether it is open.
651///
652/// A card is what a view answers with when the question is "how do these things
653/// stand" rather than "what are the values of these fields": there are no
654/// columns here, and each card says how one branch stands, what it is called,
655/// and two counts, with the whole card a link to the branch's own page.
656struct BranchCards;
657
658/// How many of the books a branch holds, and how many of those are out.
659fn shelf_counts(books: &[Book], code: &str) -> (usize, usize) {
660 let here: Vec<&Book> = books
661 .iter()
662 .filter(|b| b.shelved.contains_key(code))
663 .collect();
664 let out = here.iter().filter(|b| b.lent.unwrap_or(false)).count();
665 (here.len(), out)
666}
667
668fn librarian(branch: &Branch) -> String {
669 format!("{} {}", branch.librarian_first, branch.librarian_last)
670 .trim()
671 .to_string()
672}
673
674impl ViewLogic for BranchCards {
675 fn name(&self) -> &'static str {
676 "all-branches"
677 }
678
679 fn title(&self) -> &'static str {
680 "All branches"
681 }
682
683 fn render(&self, _args: &ViewArgs, ctx: &Context) -> Result<ViewData, ApiError> {
684 let branches: Vec<Branch> = ctx.optional_rows(BRANCHES_FILE)?;
685 let books: Vec<Book> = ctx.optional_rows(BOOKS_FILE)?;
686
687 let card = |branch: &Branch| {
688 let (here, out) = shelf_counts(&books, &branch.code);
689 let open = branch.open.unwrap_or(false);
690 let mut card = Card::new(branch.name.as_str())
691 .status(if open {
692 Status::new("Open", Tone::Good)
693 } else {
694 // A shut branch is a fact about it rather than a fault, so
695 // it is neutral—which is also what reads quieter.
696 Status::new("Shut", Tone::Neutral)
697 })
698 .identifier(branch.code.as_str())
699 .subtitle(format!(
700 "{}, {} staff",
701 librarian(branch),
702 branch.staff.unwrap_or(0)
703 ))
704 .row("Books here", here)
705 .row("Out on loan", out)
706 .link(ViewLink::new("branch").arg("branch", &branch.code));
707 if branch.hours.is_empty() {
708 card = card.sentence(
709 "No opening hours are recorded, so nothing here says when it can be visited.",
710 );
711 }
712 card
713 };
714
715 let group = |heading: &str, open: bool| {
716 CardGroup::new(heading).cards(
717 branches
718 .iter()
719 .filter(|b| b.open.unwrap_or(false) == open)
720 .map(&card),
721 )
722 };
723
724 Ok(ViewData::new()
725 .note("A card is a branch. Open one for what it holds and what is out.")
726 .group(group("Open", true))
727 .group(group("Shut", false)))
728 }
729}
730
731// ── The Branch view, which is one branch in detail ───────────────────────────
732
733/// One branch: what it has out, what is on its shelves, and when it is open.
734///
735/// It is reached from a card rather than from the switcher, so its one
736/// parameter is hidden: the page draws no control for it, and the card's link
737/// is what answers it. The parameter is a select all the same, so a link to a
738/// branch that has since gone falls back to the first one rather than to a page
739/// about nothing.
740struct BranchDetail;
741
742impl BranchDetail {
743 /// The books a branch holds, best rated first, which is the order the
744 /// numbered section is a ranking in.
745 fn by_rating<'a>(books: &'a [Book], code: &str, lent: bool) -> Vec<&'a Book> {
746 let mut held: Vec<&Book> = books
747 .iter()
748 .filter(|b| b.shelved.contains_key(code) && b.lent.unwrap_or(false) == lent)
749 .collect();
750 held.sort_by(|a, b| {
751 b.rating
752 .unwrap_or(0.0)
753 .total_cmp(&a.rating.unwrap_or(0.0))
754 .then_with(|| a.title.cmp(&b.title))
755 });
756 held
757 }
758
759 /// What a form asks before a book goes out. The four kinds of field are all
760 /// here, since this is the page the bundle's form controls are developed
761 /// against.
762 fn loan_form(title: &str) -> Form {
763 Form::new("lend-a-book")
764 .arg("title", title)
765 .field(Field::text("borrower", "Borrower"))
766 .field(Field::number("days", "Days out").default(21))
767 .field(Field::date("from", "Date lent").default(civil_from_days(today())))
768 .field(
769 Field::one_of(
770 "condition",
771 "Condition it left in",
772 ["As new", "Good", "Worn"],
773 )
774 .default("Good"),
775 )
776 }
777}
778
779impl ViewLogic for BranchDetail {
780 fn name(&self) -> &'static str {
781 "branch"
782 }
783
784 fn title(&self) -> &'static str {
785 "Branch"
786 }
787
788 /// This page is about one branch, and which one arrives through a card's
789 /// link. A switcher entry for it would open whichever branch the parameter
790 /// happens to default to, which is nobody's question.
791 fn in_switcher(&self) -> bool {
792 false
793 }
794
795 fn params(&self, ctx: &Context, _asked: &ViewArgs) -> Result<Vec<Param>, ApiError> {
796 let branches: Vec<Branch> = ctx.optional_rows(BRANCHES_FILE)?;
797 let codes: Vec<SelectOption> = branches
798 .iter()
799 .map(|b| SelectOption::labelled(&b.code, &b.name))
800 .collect();
801 let first = branches.first().map(|b| b.code.clone()).unwrap_or_default();
802 Ok(vec![
803 Param::select("branch", "Branch", codes)
804 .default(first)
805 .hidden(),
806 ])
807 }
808
809 fn render(&self, args: &ViewArgs, ctx: &Context) -> Result<ViewData, ApiError> {
810 let branches: Vec<Branch> = ctx.optional_rows(BRANCHES_FILE)?;
811 let books: Vec<Book> = ctx.optional_rows(BOOKS_FILE)?;
812 let code = args.get_or("branch", "");
813 let branch = branches
814 .iter()
815 .find(|b| b.code == code)
816 .ok_or_else(|| ApiError::new(404, format!("no branch has the code \"{code}\"")))?;
817
818 let (here, out) = shelf_counts(&books, code);
819 let now = today();
820
821 // What is out, with how it stands against its due date. A row's own
822 // link is the catalogue entry, and one book's link is a `javascript:`
823 // URL, which the page shows as text rather than following.
824 let mut lent = DetailSection::main("Out on loan");
825 for book in BranchDetail::by_rating(&books, code, true) {
826 let late = days_from_civil(&book.due).map(|due| now - due);
827 let mut row = DetailRow::new(book.title.as_str()).link(book.link.as_str());
828 if !book.due.is_empty() {
829 row = row.fact(format!("due {}", book.due));
830 }
831 row = match late {
832 Some(days) if days > 0 => row.fact(format!("{days} day(s) late")),
833 Some(days) => row.fact(format!("{} day(s) to go", -days)),
834 None => row.note("No due date is recorded."),
835 };
836 lent = lent.row(row.button(Button::disabled("Chase the borrower", "Not built yet")));
837 }
838 if out == 0 {
839 lent = lent.note("Everything this branch holds is on the shelf.");
840 }
841
842 // What can go out, ranked, with the one action this example offers.
843 let mut shelf = DetailSection::main("On the shelf")
844 .numbered()
845 .note("Best rated first.");
846 for book in BranchDetail::by_rating(&books, code, false) {
847 let mut row = DetailRow::new(book.title.as_str());
848 if let Some(rating) = book.rating {
849 row = row.fact(format!("rated {rating}"));
850 }
851 if let Some(year) = book.year {
852 row = row.fact(year);
853 }
854 if !book.notes.trim().is_empty() {
855 row = row.note(book.notes.as_str());
856 }
857 shelf = shelf.row(
858 row.button(Button::link("Catalogue", book.link.as_str()))
859 .button(Button::form(
860 "Lend it out",
861 BranchDetail::loan_form(&book.title),
862 )),
863 );
864 }
865 if here == out {
866 shelf = shelf.note("Nothing this branch holds is on the shelf today.");
867 }
868
869 let mut hours = DetailSection::side("When it is open").collapsed_on_phone();
870 for (day, open) in &branch.hours {
871 hours = hours.row(DetailRow::new(day.as_str()).fact(open.as_str()));
872 }
873 if branch.hours.is_empty() {
874 hours = hours.note("No hours are recorded for this branch.");
875 }
876
877 // A count per genre, which is a section of facts rather than of things
878 // to do, so it sits beside the page rather than in it.
879 let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
880 for book in books.iter().filter(|b| b.shelved.contains_key(code)) {
881 *counts.entry(book.genre.as_str()).or_default() += 1;
882 }
883 let mut genres = DetailSection::side("Genres here");
884 for (genre, count) in counts {
885 genres = genres.row(DetailRow::new(genre).fact(count));
886 }
887
888 Ok(ViewData::new().detail(
889 Detail::new(branch.name.as_str())
890 .status(if branch.open.unwrap_or(false) {
891 Status::new("Open", Tone::Good)
892 } else {
893 Status::new("Shut", Tone::Neutral)
894 })
895 .subtitle(format!(
896 "{}, {} staff, {here} book(s) here, {out} out",
897 librarian(branch),
898 branch.staff.unwrap_or(0)
899 ))
900 .back(ViewLink::new("all-branches"))
901 .section(lent)
902 .section(shelf)
903 .section(hours)
904 .section(genres),
905 ))
906 }