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.
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.
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?
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 }