Skip to main content

Context

Struct Context 

Source
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

Source

pub fn new(data_dir: impl Into<PathBuf>) -> Self

A context rooted at an explicit directory.

Source

pub fn find() -> Result<Self>

Walk up from the current directory to the nearest ancestor containing a Data/ directory.

Source

pub fn data_dir(&self) -> &Path

Source

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.

Source

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.

Source

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.

Source

pub fn rows<T: DeserializeOwned>(&self, file: &str) -> Result<Vec<T>, ApiError>

Read and parse a file the table needs.

Source

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?
examples/library/main.rs (line 91)
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    }

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = !

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, !>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.