1use std::collections::BTreeMap;
18use std::fmt;
19
20use serde::Serialize;
21
22use crate::error::ApiError;
23use crate::schema::{Column, SelectOption};
24
25fn is_false(value: &bool) -> bool {
26 !*value
27}
28
29#[derive(Debug, Clone, Serialize)]
38pub struct Section {
39 #[serde(skip_serializing_if = "Option::is_none")]
40 heading: Option<String>,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 note: Option<String>,
43 columns: Vec<Column>,
44 rows: Vec<serde_json::Value>,
45}
46
47impl Section {
48 pub fn new(columns: impl IntoIterator<Item = Column>) -> Self {
49 Self {
50 heading: None,
51 note: None,
52 columns: columns.into_iter().collect(),
53 rows: Vec::new(),
54 }
55 }
56
57 pub fn heading(mut self, heading: impl Into<String>) -> Self {
58 self.heading = Some(heading.into());
59 self
60 }
61
62 pub fn note(mut self, note: impl Into<String>) -> Self {
63 self.note = Some(note.into());
64 self
65 }
66
67 pub fn rows<T: Serialize>(
74 mut self,
75 rows: impl IntoIterator<Item = T>,
76 ) -> Result<Self, ApiError> {
77 self.rows = rows
78 .into_iter()
79 .map(|row| serde_json::to_value(row))
80 .collect::<Result<Vec<_>, _>>()
81 .map_err(|e| {
82 let what = self.heading.as_deref().unwrap_or("a section");
83 ApiError::server(format!("could not serialize the rows of {what}: {e}"))
84 })?;
85 Ok(self)
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
98#[serde(rename_all = "lowercase")]
99pub enum Tone {
100 Good,
102 Warning,
104 Bad,
106 Neutral,
109 Info,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
115pub struct Status {
116 word: String,
117 tone: Tone,
118}
119
120impl Status {
121 pub fn new(word: impl Into<String>, tone: Tone) -> Self {
122 Self {
123 word: word.into(),
124 tone,
125 }
126 }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
135pub struct ViewLink {
136 view: String,
137 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
138 args: BTreeMap<String, String>,
139}
140
141impl ViewLink {
142 pub fn new(view: impl Into<String>) -> Self {
143 Self {
144 view: view.into(),
145 args: BTreeMap::new(),
146 }
147 }
148
149 pub fn arg(mut self, key: impl Into<String>, value: impl fmt::Display) -> Self {
151 self.args.insert(key.into(), value.to_string());
152 self
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
160pub struct CardRow {
161 label: String,
162 value: String,
163}
164
165#[derive(Debug, Clone, Serialize)]
172pub struct Card {
173 #[serde(skip_serializing_if = "Vec::is_empty")]
174 statuses: Vec<Status>,
175 #[serde(skip_serializing_if = "Option::is_none")]
176 identifier: Option<String>,
177 title: String,
178 #[serde(skip_serializing_if = "Option::is_none")]
179 subtitle: Option<String>,
180 #[serde(skip_serializing_if = "Vec::is_empty")]
181 rows: Vec<CardRow>,
182 #[serde(skip_serializing_if = "Option::is_none")]
183 sentence: Option<String>,
184 #[serde(skip_serializing_if = "Option::is_none")]
185 link: Option<ViewLink>,
186}
187
188impl Card {
189 pub fn new(title: impl Into<String>) -> Self {
190 Self {
191 statuses: Vec::new(),
192 identifier: None,
193 title: title.into(),
194 subtitle: None,
195 rows: Vec::new(),
196 sentence: None,
197 link: None,
198 }
199 }
200
201 pub fn status(mut self, status: Status) -> Self {
205 self.statuses.push(status);
206 self
207 }
208
209 pub fn identifier(mut self, identifier: impl Into<String>) -> Self {
211 self.identifier = Some(identifier.into());
212 self
213 }
214
215 pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
217 self.subtitle = Some(subtitle.into());
218 self
219 }
220
221 pub fn row(mut self, label: impl Into<String>, value: impl fmt::Display) -> Self {
225 self.rows.push(CardRow {
226 label: label.into(),
227 value: value.to_string(),
228 });
229 self
230 }
231
232 pub fn sentence(mut self, sentence: impl Into<String>) -> Self {
235 self.sentence = Some(sentence.into());
236 self
237 }
238
239 pub fn link(mut self, link: ViewLink) -> Self {
241 self.link = Some(link);
242 self
243 }
244}
245
246#[derive(Debug, Clone, Serialize)]
248pub struct CardGroup {
249 heading: String,
250 cards: Vec<Card>,
251}
252
253impl CardGroup {
254 pub fn new(heading: impl Into<String>) -> Self {
255 Self {
256 heading: heading.into(),
257 cards: Vec::new(),
258 }
259 }
260
261 pub fn card(mut self, card: Card) -> Self {
262 self.cards.push(card);
263 self
264 }
265
266 pub fn cards(mut self, cards: impl IntoIterator<Item = Card>) -> Self {
267 self.cards.extend(cards);
268 self
269 }
270
271 pub fn is_empty(&self) -> bool {
272 self.cards.is_empty()
273 }
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
283#[serde(rename_all = "lowercase")]
284pub(crate) enum DetailColumn {
285 Main,
286 Side,
287}
288
289#[derive(Debug, Clone, Serialize)]
292pub struct Detail {
293 title: String,
294 #[serde(skip_serializing_if = "Vec::is_empty")]
295 statuses: Vec<Status>,
296 #[serde(skip_serializing_if = "Option::is_none")]
297 subtitle: Option<String>,
298 #[serde(skip_serializing_if = "Option::is_none")]
299 back: Option<ViewLink>,
300 sections: Vec<DetailSection>,
301}
302
303impl Detail {
304 pub fn new(title: impl Into<String>) -> Self {
305 Self {
306 title: title.into(),
307 statuses: Vec::new(),
308 subtitle: None,
309 back: None,
310 sections: Vec::new(),
311 }
312 }
313
314 pub fn status(mut self, status: Status) -> Self {
317 self.statuses.push(status);
318 self
319 }
320
321 pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
322 self.subtitle = Some(subtitle.into());
323 self
324 }
325
326 pub fn back(mut self, link: ViewLink) -> Self {
329 self.back = Some(link);
330 self
331 }
332
333 pub fn section(mut self, section: DetailSection) -> Self {
334 self.sections.push(section);
335 self
336 }
337
338 pub(crate) fn actions(&self) -> impl Iterator<Item = (&str, &BTreeMap<String, String>)> {
345 self.sections
346 .iter()
347 .flat_map(|section| section.rows.iter())
348 .flat_map(|row| row.buttons.iter())
349 .filter_map(Button::offer)
350 }
351}
352
353#[derive(Debug, Clone, Serialize)]
355pub struct DetailSection {
356 heading: String,
357 column: DetailColumn,
358 #[serde(skip_serializing_if = "Option::is_none")]
359 note: Option<String>,
360 #[serde(skip_serializing_if = "is_false")]
361 numbered: bool,
362 #[serde(skip_serializing_if = "is_false")]
363 collapsed_on_phone: bool,
364 rows: Vec<DetailRow>,
365}
366
367impl DetailSection {
368 pub fn main(heading: impl Into<String>) -> Self {
371 Self::in_column(heading, DetailColumn::Main)
372 }
373
374 pub fn side(heading: impl Into<String>) -> Self {
378 Self::in_column(heading, DetailColumn::Side)
379 }
380
381 fn in_column(heading: impl Into<String>, column: DetailColumn) -> Self {
382 Self {
383 heading: heading.into(),
384 column,
385 note: None,
386 numbered: false,
387 collapsed_on_phone: false,
388 rows: Vec::new(),
389 }
390 }
391
392 pub fn note(mut self, note: impl Into<String>) -> Self {
395 self.note = Some(note.into());
396 self
397 }
398
399 pub fn numbered(mut self) -> Self {
402 self.numbered = true;
403 self
404 }
405
406 pub fn collapsed_on_phone(mut self) -> Self {
410 self.collapsed_on_phone = true;
411 self
412 }
413
414 pub fn row(mut self, row: DetailRow) -> Self {
415 self.rows.push(row);
416 self
417 }
418
419 pub fn rows(mut self, rows: impl IntoIterator<Item = DetailRow>) -> Self {
420 self.rows.extend(rows);
421 self
422 }
423}
424
425#[derive(Debug, Clone, Serialize)]
428pub struct DetailRow {
429 title: String,
430 #[serde(skip_serializing_if = "Option::is_none")]
431 link: Option<String>,
432 #[serde(skip_serializing_if = "Vec::is_empty")]
433 facts: Vec<String>,
434 #[serde(skip_serializing_if = "Vec::is_empty")]
435 notes: Vec<String>,
436 #[serde(skip_serializing_if = "Vec::is_empty")]
437 buttons: Vec<Button>,
438}
439
440impl DetailRow {
441 pub fn new(title: impl Into<String>) -> Self {
442 Self {
443 title: title.into(),
444 link: None,
445 facts: Vec::new(),
446 notes: Vec::new(),
447 buttons: Vec::new(),
448 }
449 }
450
451 pub fn link(mut self, url: impl Into<String>) -> Self {
455 self.link = Some(url.into());
456 self
457 }
458
459 pub fn fact(mut self, fact: impl fmt::Display) -> Self {
462 self.facts.push(fact.to_string());
463 self
464 }
465
466 pub fn note(mut self, note: impl Into<String>) -> Self {
469 self.notes.push(note.into());
470 self
471 }
472
473 pub fn button(mut self, button: Button) -> Self {
474 self.buttons.push(button);
475 self
476 }
477}
478
479#[derive(Debug, Clone, Serialize)]
484pub struct Button {
485 label: String,
486 #[serde(flatten)]
487 kind: ButtonKind,
488}
489
490#[derive(Debug, Clone, Serialize)]
491#[serde(tag = "type", rename_all = "lowercase")]
492enum ButtonKind {
493 Link { url: String },
494 Form(Form),
495 Disabled { reason: String },
496}
497
498impl Button {
499 pub fn link(label: impl Into<String>, url: impl Into<String>) -> Self {
502 Self {
503 label: label.into(),
504 kind: ButtonKind::Link { url: url.into() },
505 }
506 }
507
508 pub fn form(label: impl Into<String>, form: Form) -> Self {
510 Self {
511 label: label.into(),
512 kind: ButtonKind::Form(form),
513 }
514 }
515
516 pub fn disabled(label: impl Into<String>, reason: impl Into<String>) -> Self {
519 Self {
520 label: label.into(),
521 kind: ButtonKind::Disabled {
522 reason: reason.into(),
523 },
524 }
525 }
526
527 fn offer(&self) -> Option<(&str, &BTreeMap<String, String>)> {
530 match &self.kind {
531 ButtonKind::Form(form) => Some((form.action(), form.args())),
532 _ => None,
533 }
534 }
535}
536
537#[derive(Debug, Clone, Serialize)]
545pub struct Form {
546 action: String,
547 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
548 args: BTreeMap<String, String>,
549 fields: Vec<Field>,
550}
551
552impl Form {
553 pub fn new(action: impl Into<String>) -> Self {
554 Self {
555 action: action.into(),
556 args: BTreeMap::new(),
557 fields: Vec::new(),
558 }
559 }
560
561 pub fn arg(mut self, key: impl Into<String>, value: impl fmt::Display) -> Self {
571 self.args.insert(key.into(), value.to_string());
572 self
573 }
574
575 pub fn field(mut self, field: Field) -> Self {
576 self.fields.push(field);
577 self
578 }
579
580 pub fn fields(mut self, fields: impl IntoIterator<Item = Field>) -> Self {
581 self.fields.extend(fields);
582 self
583 }
584
585 pub(crate) fn action(&self) -> &str {
586 &self.action
587 }
588
589 pub(crate) fn args(&self) -> &BTreeMap<String, String> {
590 &self.args
591 }
592}
593
594#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
598#[serde(rename_all = "kebab-case")]
599pub(crate) enum FieldKind {
600 Text,
601 Number,
602 Date,
603 OneOf,
604}
605
606#[derive(Debug, Clone, Serialize)]
608pub struct Field {
609 key: String,
610 label: String,
611 #[serde(rename = "type")]
612 kind: FieldKind,
613 #[serde(skip_serializing_if = "Vec::is_empty")]
614 options: Vec<SelectOption>,
615 #[serde(skip_serializing_if = "Option::is_none")]
616 default: Option<String>,
617}
618
619impl Field {
620 pub fn text(key: impl Into<String>, label: impl Into<String>) -> Self {
621 Self::of_kind(key, label, FieldKind::Text)
622 }
623
624 pub fn number(key: impl Into<String>, label: impl Into<String>) -> Self {
625 Self::of_kind(key, label, FieldKind::Number)
626 }
627
628 pub fn date(key: impl Into<String>, label: impl Into<String>) -> Self {
630 Self::of_kind(key, label, FieldKind::Date)
631 }
632
633 pub fn one_of(
637 key: impl Into<String>,
638 label: impl Into<String>,
639 options: impl IntoIterator<Item = impl Into<SelectOption>>,
640 ) -> Self {
641 let mut field = Self::of_kind(key, label, FieldKind::OneOf);
642 field.options = options.into_iter().map(Into::into).collect();
643 field
644 }
645
646 fn of_kind(key: impl Into<String>, label: impl Into<String>, kind: FieldKind) -> Self {
647 Self {
648 key: key.into(),
649 label: label.into(),
650 kind,
651 options: Vec::new(),
652 default: None,
653 }
654 }
655
656 pub fn default(mut self, value: impl fmt::Display) -> Self {
659 self.default = Some(value.to_string());
660 self
661 }
662}
663
664#[cfg(test)]
665mod tests {
666 use serde::Serialize;
667 use serde_json::json;
668
669 use super::*;
670
671 #[test]
672 fn a_section_omits_what_it_was_not_given() {
673 let bare = Section::new([Column::string("title", "Title")]);
674 assert_eq!(
675 serde_json::to_value(&bare).unwrap(),
676 json!({ "columns": [{ "field": "title", "label": "Title", "type": "string" }],
677 "rows": [] })
678 );
679
680 let full = Section::new([Column::string("title", "Title")])
681 .heading("Out")
682 .note("Due back this week.")
683 .rows(vec![json!({ "title": "A Field Guide to Moss" })])
684 .unwrap();
685 assert_eq!(
686 serde_json::to_value(&full).unwrap(),
687 json!({ "heading": "Out", "note": "Due back this week.",
688 "columns": [{ "field": "title", "label": "Title", "type": "string" }],
689 "rows": [{ "title": "A Field Guide to Moss" }] })
690 );
691 }
692
693 #[test]
694 fn a_section_takes_a_repositorys_own_type_for_its_rows() {
695 #[derive(Serialize)]
696 struct Loan {
697 title: &'static str,
698 days: u32,
699 }
700
701 let section = Section::new([Column::string("title", "Title")])
702 .rows([Loan {
703 title: "Nine Doors",
704 days: 25,
705 }])
706 .unwrap();
707 assert_eq!(
708 serde_json::to_value(§ion).unwrap()["rows"],
709 json!([{ "title": "Nine Doors", "days": 25 }])
710 );
711 }
712
713 #[test]
714 fn a_row_that_cannot_be_serialized_names_the_section_it_was_in() {
715 struct Awkward;
716 impl Serialize for Awkward {
717 fn serialize<S: serde::Serializer>(&self, _: S) -> Result<S::Ok, S::Error> {
718 Err(serde::ser::Error::custom("no"))
719 }
720 }
721
722 let failure = Section::new([Column::string("title", "Title")])
723 .heading("Out")
724 .rows([Awkward])
725 .unwrap_err();
726 assert_eq!(failure.status, 500);
727 assert!(failure.message.contains("Out"), "{}", failure.message);
728 }
729
730 #[test]
731 fn a_tone_is_one_of_five_words() {
732 let words: Vec<serde_json::Value> = [
733 Tone::Good,
734 Tone::Warning,
735 Tone::Bad,
736 Tone::Neutral,
737 Tone::Info,
738 ]
739 .iter()
740 .map(|tone| serde_json::to_value(tone).unwrap())
741 .collect();
742 assert_eq!(
743 words,
744 vec!["good", "warning", "bad", "neutral", "info"]
745 .into_iter()
746 .map(serde_json::Value::from)
747 .collect::<Vec<_>>()
748 );
749 }
750
751 #[test]
752 fn a_bare_card_is_a_title_and_nothing_else() {
753 assert_eq!(
754 serde_json::to_value(Card::new("Three Minutes to Midnight")).unwrap(),
755 json!({ "title": "Three Minutes to Midnight" })
756 );
757 }
758
759 #[test]
760 fn a_card_serializes_to_the_documented_shape() {
761 let card = Card::new("Three Minutes to Midnight")
762 .status(Status::new("Submitted", Tone::Warning))
763 .identifier("midnight")
764 .subtitle("4,200 words, science fiction, v3")
765 .row("Clarkesworld", "6m 3w 2d")
766 .row("Open markets", 7)
767 .sentence("Tied up at Clarkesworld, which reads one story at a time.")
768 .link(ViewLink::new("story").arg("codename", "midnight"));
769
770 assert_eq!(
771 serde_json::to_value(&card).unwrap(),
772 json!({
773 "statuses": [{ "word": "Submitted", "tone": "warning" }],
774 "identifier": "midnight",
775 "title": "Three Minutes to Midnight",
776 "subtitle": "4,200 words, science fiction, v3",
777 "rows": [{ "label": "Clarkesworld", "value": "6m 3w 2d" },
778 { "label": "Open markets", "value": "7" }],
779 "sentence": "Tied up at Clarkesworld, which reads one story at a time.",
780 "link": { "view": "story", "args": { "codename": "midnight" } }
781 })
782 );
783 }
784
785 #[test]
786 fn a_card_can_stand_two_ways_at_once() {
787 let card = Card::new("Locus of Control")
788 .status(Status::new("Revisions requested", Tone::Good))
789 .status(Status::new("Submitted", Tone::Warning));
790 assert_eq!(
791 serde_json::to_value(&card).unwrap()["statuses"],
792 json!([{ "word": "Revisions requested", "tone": "good" },
793 { "word": "Submitted", "tone": "warning" }])
794 );
795 }
796
797 #[test]
798 fn a_view_link_with_no_arguments_carries_none() {
799 assert_eq!(
800 serde_json::to_value(ViewLink::new("stories")).unwrap(),
801 json!({ "view": "stories" })
802 );
803 }
804
805 #[test]
806 fn a_group_counts_what_is_in_it() {
807 let empty = CardGroup::new("Idle");
808 assert!(empty.is_empty());
809 assert_eq!(
810 serde_json::to_value(&empty).unwrap(),
811 json!({ "heading": "Idle", "cards": [] })
812 );
813
814 let filled = CardGroup::new("Idle").cards([Card::new("Teare"), Card::new("Nine Doors")]);
815 assert!(!filled.is_empty());
816 assert_eq!(
817 serde_json::to_value(&filled).unwrap()["cards"]
818 .as_array()
819 .unwrap()
820 .len(),
821 2
822 );
823 }
824
825 #[test]
826 fn a_detail_page_serializes_to_the_documented_shape() {
827 let detail = Detail::new("Three Minutes to Midnight")
828 .status(Status::new("Submitted", Tone::Warning))
829 .subtitle("4,200 words, science fiction, v3")
830 .back(ViewLink::new("stories"))
831 .section(
832 DetailSection::main("Send next").numbered().row(
833 DetailRow::new("Clarkesworld")
834 .link("https://example.invalid/guidelines")
835 .fact("$0.12 a word")
836 .fact("rank 1")
837 .note("Reads one story at a time")
838 .button(Button::link(
839 "Guidelines",
840 "https://example.invalid/guidelines",
841 ))
842 .button(Button::disabled("Draft cover letter", "Not built yet"))
843 .button(Button::form(
844 "Record submission",
845 Form::new("record-submission")
846 .arg("market", "clarkesworld")
847 .field(Field::number("draft", "Draft sent").default(3))
848 .field(Field::date("sent", "Date sent").default("2026-09-21")),
849 )),
850 ),
851 )
852 .section(
853 DetailSection::side("Shut for now")
854 .collapsed_on_phone()
855 .note("Nothing on the list is taking submissions."),
856 );
857
858 assert_eq!(
859 serde_json::to_value(&detail).unwrap(),
860 json!({
861 "title": "Three Minutes to Midnight",
862 "statuses": [{ "word": "Submitted", "tone": "warning" }],
863 "subtitle": "4,200 words, science fiction, v3",
864 "back": { "view": "stories" },
865 "sections": [
866 { "heading": "Send next", "column": "main", "numbered": true,
867 "rows": [
868 { "title": "Clarkesworld",
869 "link": "https://example.invalid/guidelines",
870 "facts": ["$0.12 a word", "rank 1"],
871 "notes": ["Reads one story at a time"],
872 "buttons": [
873 { "label": "Guidelines", "type": "link",
874 "url": "https://example.invalid/guidelines" },
875 { "label": "Draft cover letter", "type": "disabled",
876 "reason": "Not built yet" },
877 { "label": "Record submission", "type": "form",
878 "action": "record-submission",
879 "args": { "market": "clarkesworld" },
880 "fields": [
881 { "key": "draft", "label": "Draft sent",
882 "type": "number", "default": "3" },
883 { "key": "sent", "label": "Date sent",
884 "type": "date", "default": "2026-09-21" }] }] }] },
885 { "heading": "Shut for now", "column": "side",
886 "collapsed_on_phone": true,
887 "note": "Nothing on the list is taking submissions.",
888 "rows": [] }
889 ]
890 })
891 );
892 }
893
894 #[test]
895 fn a_detail_page_names_the_actions_its_buttons_offer() {
896 let detail = Detail::new("Three Minutes to Midnight")
897 .section(
898 DetailSection::main("Out now").row(DetailRow::new("Clarkesworld").button(
899 Button::form("Record the answer", Form::new("record-answer")),
900 )),
901 )
902 .section(
903 DetailSection::side("Send next")
904 .row(
905 DetailRow::new("Asimov's")
906 .button(Button::link("Guidelines", "https://example.invalid"))
907 .button(Button::disabled("Draft cover letter", "Not built yet"))
908 .button(Button::form(
909 "Record submission",
910 Form::new("record-submission").arg("market", "asimovs"),
911 )),
912 )
913 .row(DetailRow::new("Nothing to do here")),
914 );
915
916 let offered: Vec<(&str, Vec<(&str, &str)>)> = detail
919 .actions()
920 .map(|(action, args)| {
921 (
922 action,
923 args.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(),
924 )
925 })
926 .collect();
927 assert_eq!(
928 offered,
929 vec![
930 ("record-answer", vec![]),
931 ("record-submission", vec![("market", "asimovs")]),
932 ]
933 );
934 }
935
936 #[test]
937 fn a_field_serializes_to_the_documented_shape() {
938 assert_eq!(
939 serde_json::to_value(Field::text("borrower", "Borrower")).unwrap(),
940 json!({ "key": "borrower", "label": "Borrower", "type": "text" })
941 );
942 assert_eq!(
943 serde_json::to_value(Field::number("days", "Days").default(21)).unwrap(),
944 json!({ "key": "days", "label": "Days", "type": "number", "default": "21" })
945 );
946 assert_eq!(
947 serde_json::to_value(
948 Field::one_of(
949 "result",
950 "What came back",
951 [SelectOption::new("Rejected"), SelectOption::new("Accepted")]
952 )
953 .default("Rejected")
954 )
955 .unwrap(),
956 json!({ "key": "result", "label": "What came back", "type": "one-of",
957 "options": [{ "value": "Rejected" }, { "value": "Accepted" }],
958 "default": "Rejected" })
959 );
960 }
961
962 #[test]
963 fn a_form_carries_the_arguments_of_the_row_it_was_built_for() {
964 let form = Form::new("lend")
965 .arg("title", "Nine Doors")
966 .arg("copies", 2);
967 assert_eq!(
968 serde_json::to_value(&form).unwrap(),
969 json!({ "action": "lend",
970 "args": { "copies": "2", "title": "Nine Doors" },
971 "fields": [] })
972 );
973 }
974}