Skip to main content

hammer/
action.rs

1//! The `action` export: an item on the menus of the screens in the manifest entry's `places`. Hammer
2//! asks for any `[[actions.field]]`s first, then calls it, and every call returns a [Reply] as JSON.
3//! A reply with a [Page] shows it in the action's dialog; pressing one of the page's buttons calls the
4//! action again with the button's id, the page's values as [Request::input], and the page's `state`.
5//! A reply with `errors` keeps the user on the page they were on, one with `open` ends the action and
6//! goes to an item, and one with none of these ends the action. A `message` is shown briefly either way.
7//!
8//! ```ignore
9//! let page = Page {
10//!     title: Some("Who?".into()),
11//!     fields: vec![Field::text("name", "Name").required()],
12//!     buttons: vec![Button::new("back", "Back").skip_checks(), Button::new("next", "Next").primary()],
13//!     ..Page::default()
14//! }
15//! .with_state(&Step { step: 2 })?;
16//! Reply::page(page).to_json()
17//! ```
18
19use serde::de::DeserializeOwned;
20use serde::{Deserialize, Serialize};
21use serde_json::{Map, Value};
22use std::collections::BTreeMap;
23
24#[derive(Deserialize)]
25pub struct Request {
26    /// Which of the manifest's `[[actions]]` to run.
27    pub action: String,
28    pub project: String,
29    /// The plugin's declared settings, every key present.
30    #[serde(default)]
31    pub settings: Map<String, Value>,
32    /// A value for each field of the page the user was on; on the first call, the action's
33    /// `[[actions.field]]`s.
34    #[serde(default)]
35    pub input: Map<String, Value>,
36    #[serde(default)]
37    pub context: Context,
38    /// The id of the button pressed; none on the first call, which opens the action or runs the
39    /// manifest's fields.
40    #[serde(default)]
41    pub button: Option<String>,
42    /// Reserved for fields that call the action when they change; always none.
43    #[serde(default)]
44    pub changed: Option<String>,
45    /// The last page's `state`, as it was written; none on the first call.
46    #[serde(default)]
47    pub state: Option<Value>,
48    /// Set on the first call after Hammer restored a run the user had left.
49    #[serde(default)]
50    pub resumed: bool,
51    /// The UI's BCP 47 tag, for replying in the user's language; not the project's language.
52    #[serde(default)]
53    pub locale: Option<String>,
54}
55
56impl Request {
57    /// The input read into a `T`, with a field per field of the page.
58    pub fn input<T: DeserializeOwned>(&self) -> crate::Result<T> {
59        Ok(serde_json::from_value(Value::Object(self.input.clone()))?)
60    }
61
62    /// The state read into a `T`: none when there is none, or it is not a `T`.
63    pub fn state<T: DeserializeOwned>(&self) -> Option<T> {
64        self.state.as_ref().and_then(|state| T::deserialize(state).ok())
65    }
66}
67
68/// The screen an action was run from, and the id of the item it shows there (none on Project Home).
69#[derive(Deserialize)]
70pub struct Context {
71    /// `project`, `scene`, `note`, `entry`, or `event`.
72    pub place: String,
73    #[serde(default)]
74    pub id: Option<i64>,
75}
76
77impl Default for Context {
78    fn default() -> Self {
79        Context { place: "project".into(), id: None }
80    }
81}
82
83/// What an `action` export returns, as [Reply::to_json]. Every part is optional; the default ends the
84/// action without a word.
85#[derive(Serialize, Default)]
86pub struct Reply {
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub page: Option<Page>,
89    /// Shown briefly, whatever else the reply does.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub message: Option<String>,
92    /// Messages by field key, shown under each field, keeping the user on the page they were on with
93    /// what they entered; a key of `""`, or one that is no field's, is shown for the whole page.
94    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
95    pub errors: BTreeMap<String, String>,
96    /// An item to go to, ending the action.
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub open: Option<Open>,
99}
100
101impl Reply {
102    /// Ends the action, showing `message` briefly.
103    pub fn message(message: impl Into<String>) -> Reply {
104        Reply { message: Some(message.into()), ..Reply::default() }
105    }
106
107    /// Shows `markdown` alone, a report the user can copy or save as a note.
108    pub fn document(markdown: impl Into<String>) -> Reply {
109        Reply::page(Page { markdown: Some(markdown.into()), ..Page::default() })
110    }
111
112    pub fn page(page: Page) -> Reply {
113        Reply { page: Some(page), ..Reply::default() }
114    }
115
116    /// Keeps the user on the page, showing `message` under the field `key`, or for the whole page when
117    /// `key` is `""`.
118    pub fn error(key: impl Into<String>, message: impl Into<String>) -> Reply {
119        Reply { errors: BTreeMap::from([(key.into(), message.into())]), ..Reply::default() }
120    }
121
122    /// Ends the action and goes to an item.
123    pub fn open(open: Open) -> Reply {
124        Reply { open: Some(open), ..Reply::default() }
125    }
126
127    /// What the `action` export returns for this reply.
128    pub fn to_json(&self) -> crate::Result<String> {
129        Ok(serde_json::to_string(self)?)
130    }
131}
132
133/// What the action's dialog shows: a title, markdown, the fields in order, and buttons.
134#[derive(Serialize, Default)]
135pub struct Page {
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub title: Option<String>,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub markdown: Option<String>,
140    #[serde(skip_serializing_if = "Vec::is_empty")]
141    pub fields: Vec<Field>,
142    #[serde(skip_serializing_if = "Vec::is_empty")]
143    pub buttons: Vec<Button>,
144    /// Handed back untouched as the next call's [Request::state]. Hammer never reads it, and fails a
145    /// call whose state is over 1 MiB as JSON.
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub state: Option<Value>,
148}
149
150impl Page {
151    /// The page with `state` as its state, for [Request::state] to read back.
152    pub fn with_state(mut self, state: &impl Serialize) -> crate::Result<Page> {
153        self.state = Some(serde_json::to_value(state)?);
154        Ok(self)
155    }
156}
157
158/// A field of a page. Its value comes back in the next call's input under its key: a string for
159/// `text` and `choice`, a bool, an integer, for `scenes` a list of scene ids (one id or null when
160/// [Field::single]), and for `entity` an [EntityRef] or null (a list of them when [Field::multiple]).
161/// Hammer checks `required`, an `int`'s bounds, a `choice`'s options, and that an `entity`'s picks
162/// exist and match its filters before calling, unless the button pressed skips them.
163#[derive(Serialize, Clone)]
164pub struct Field {
165    pub key: String,
166    #[serde(rename = "type")]
167    pub kind: FieldKind,
168    pub label: String,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub hint: Option<String>,
171    /// For `text`, not blank; for `scenes`, not empty; for `entity`, a pick.
172    #[serde(skip_serializing_if = "std::ops::Not::not")]
173    pub required: bool,
174    /// The initial value; none for the kind's own: false, `min` or 0, the first option, "", or none.
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub default: Option<Value>,
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub min: Option<i64>,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub max: Option<i64>,
181    #[serde(skip_serializing_if = "Vec::is_empty")]
182    pub options: Vec<Choice>,
183    #[serde(skip_serializing_if = "TextFormat::is_plain")]
184    pub format: TextFormat,
185    #[serde(skip_serializing_if = "std::ops::Not::not")]
186    pub multiline: bool,
187    /// An `entity`'s kinds of item: `scene`, `note`, `entry`, or `event`; all four when empty.
188    #[serde(skip_serializing_if = "Vec::is_empty")]
189    pub kinds: Vec<String>,
190    /// An `entity`'s types of entry: `person`, `place`, `thing`, `event`, or `idea`; any when empty.
191    #[serde(skip_serializing_if = "Vec::is_empty")]
192    pub entry_types: Vec<String>,
193    /// An `entity`'s tags: an item needs one of them; any item when empty.
194    #[serde(skip_serializing_if = "Vec::is_empty")]
195    pub tags: Vec<String>,
196    /// Whether the value is a list; none for the kind's own, a list for `scenes` and one item for
197    /// `entity`.
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub multiple: Option<bool>,
200}
201
202#[derive(Serialize, Clone, Copy, PartialEq, Eq, Debug)]
203#[serde(rename_all = "lowercase")]
204pub enum FieldKind {
205    Text,
206    Bool,
207    Int,
208    Choice,
209    Scenes,
210    Entity,
211}
212
213/// What edits a `text` field: an ordinary text field, or the scene editor's, whose value is markdown,
214/// with its format bar or the extended one (highlight, task lists, tables).
215#[derive(Serialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
216#[serde(rename_all = "kebab-case")]
217pub enum TextFormat {
218    #[default]
219    Plain,
220    Rich,
221    RichExtended,
222}
223
224impl TextFormat {
225    fn is_plain(&self) -> bool {
226        *self == TextFormat::Plain
227    }
228}
229
230/// One of a `choice` field's options: `value` is what comes back in the input, `label` what is shown.
231#[derive(Serialize, Clone)]
232pub struct Choice {
233    pub value: String,
234    pub label: String,
235}
236
237impl Choice {
238    pub fn new(value: impl Into<String>, label: impl Into<String>) -> Choice {
239        Choice { value: value.into(), label: label.into() }
240    }
241}
242
243impl Field {
244    fn new(key: impl Into<String>, kind: FieldKind, label: impl Into<String>) -> Field {
245        Field {
246            key: key.into(),
247            kind,
248            label: label.into(),
249            hint: None,
250            required: false,
251            default: None,
252            min: None,
253            max: None,
254            options: Vec::new(),
255            format: TextFormat::Plain,
256            multiline: false,
257            kinds: Vec::new(),
258            entry_types: Vec::new(),
259            tags: Vec::new(),
260            multiple: None,
261        }
262    }
263
264    /// A text field, plain unless given a [Field::format].
265    pub fn text(key: impl Into<String>, label: impl Into<String>) -> Field {
266        Field::new(key, FieldKind::Text, label)
267    }
268
269    pub fn bool(key: impl Into<String>, label: impl Into<String>) -> Field {
270        Field::new(key, FieldKind::Bool, label)
271    }
272
273    pub fn int(key: impl Into<String>, label: impl Into<String>, min: Option<i64>, max: Option<i64>) -> Field {
274        Field { min, max, ..Field::new(key, FieldKind::Int, label) }
275    }
276
277    /// A choice of `options`, at least one.
278    pub fn choice(key: impl Into<String>, label: impl Into<String>, options: Vec<Choice>) -> Field {
279        Field { options, ..Field::new(key, FieldKind::Choice, label) }
280    }
281
282    /// Scenes picked from the project's tree: a list, or one with [Field::single].
283    pub fn scenes(key: impl Into<String>, label: impl Into<String>) -> Field {
284        Field::new(key, FieldKind::Scenes, label)
285    }
286
287    /// An item of the project picked by name: one [EntityRef] or null, or a list with
288    /// [Field::multiple]. [Field::kinds], [Field::entry_types], and [Field::tags] narrow what the
289    /// picker offers. Hammer shows a default that is gone or does not match as empty.
290    pub fn entity(key: impl Into<String>, label: impl Into<String>) -> Field {
291        Field::new(key, FieldKind::Entity, label)
292    }
293
294    pub fn hint(mut self, hint: impl Into<String>) -> Field {
295        self.hint = Some(hint.into());
296        self
297    }
298
299    pub fn required(mut self) -> Field {
300        self.required = true;
301        self
302    }
303
304    /// The initial value, such as `"elvish"`, `8`, `true`, `vec![3, 4]`, or an [EntityRef] (a `Vec` of
305    /// them for an entity with [Field::multiple]).
306    pub fn default(mut self, value: impl Into<Value>) -> Field {
307        self.default = Some(value.into());
308        self
309    }
310
311    pub fn format(mut self, format: TextFormat) -> Field {
312        self.format = format;
313        self
314    }
315
316    /// A plain text field of several lines.
317    pub fn multiline(mut self) -> Field {
318        self.multiline = true;
319        self
320    }
321
322    /// Scenes as one scene, or null, instead of a list.
323    pub fn single(mut self) -> Field {
324        self.multiple = (self.kind != FieldKind::Entity).then_some(false);
325        self
326    }
327
328    /// An entity as a list of items instead of one.
329    pub fn multiple(mut self) -> Field {
330        self.multiple = (self.kind != FieldKind::Scenes).then_some(true);
331        self
332    }
333
334    /// The kinds of item an entity picks: `scene`, `note`, `entry`, or `event`.
335    pub fn kinds(mut self, kinds: &[&str]) -> Field {
336        self.kinds = kinds.iter().map(|it| it.to_string()).collect();
337        self
338    }
339
340    /// The types of entry an entity picks, with `entry` among its kinds: `person`, `place`, `thing`,
341    /// `event`, or `idea`.
342    pub fn entry_types(mut self, types: &[&str]) -> Field {
343        self.entry_types = types.iter().map(|it| it.to_string()).collect();
344        self
345    }
346
347    /// Tags an entity's item needs one of.
348    pub fn tags(mut self, tags: &[&str]) -> Field {
349        self.tags = tags.iter().map(|it| it.to_string()).collect();
350        self
351    }
352}
353
354/// An item of the project, as an `entity` field's value: `kind` is `scene`, `note`, `entry`, or
355/// `event`, and `id` the item's. Read one field as `Option<EntityRef>` and one with
356/// [Field::multiple] as `Vec<EntityRef>`; `Open::new(&it.kind, Some(it.id))` goes to it.
357#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
358pub struct EntityRef {
359    pub kind: String,
360    pub id: i64,
361}
362
363impl EntityRef {
364    pub fn new(kind: impl Into<String>, id: i64) -> EntityRef {
365        EntityRef { kind: kind.into(), id }
366    }
367}
368
369impl From<EntityRef> for Value {
370    fn from(entity: EntityRef) -> Value {
371        serde_json::json!({"kind": entity.kind, "id": entity.id})
372    }
373}
374
375/// A button of a page. Pressing it calls the action again with its id as [Request::button]. It sits in
376/// the dialog's footer, or with `inline` under the markdown, for acting on something in it.
377#[derive(Serialize, Clone)]
378pub struct Button {
379    pub id: String,
380    pub label: String,
381    #[serde(skip_serializing_if = "std::ops::Not::not")]
382    pub primary: bool,
383    #[serde(skip_serializing_if = "std::ops::Not::not")]
384    pub inline: bool,
385    /// Whether Hammer checks the page's fields before calling.
386    #[serde(skip_serializing_if = "is_true")]
387    pub validate: bool,
388}
389
390impl Button {
391    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Button {
392        Button { id: id.into(), label: label.into(), primary: false, inline: false, validate: true }
393    }
394
395    pub fn primary(mut self) -> Button {
396        self.primary = true;
397        self
398    }
399
400    pub fn inline(mut self) -> Button {
401        self.inline = true;
402        self
403    }
404
405    /// Calls the action without Hammer's checks of the fields, as Back or Cancel should.
406    pub fn skip_checks(mut self) -> Button {
407        self.validate = false;
408        self
409    }
410}
411
412/// An item for Hammer to go to: `place` is `project`, `scene`, `note`, `entry`, or `event`, and `id`
413/// the item's (none for `project`). An id that does not exist fails.
414#[derive(Serialize, Clone)]
415pub struct Open {
416    pub place: String,
417    #[serde(skip_serializing_if = "Option::is_none")]
418    pub id: Option<i64>,
419}
420
421impl Open {
422    pub fn new(place: impl Into<String>, id: Option<i64>) -> Open {
423        Open { place: place.into(), id }
424    }
425}
426
427fn is_true(value: &bool) -> bool {
428    *value
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use serde_json::json;
435
436    #[test]
437    fn replies_leave_out_what_is_unset() {
438        assert_eq!(Reply::default().to_json().unwrap(), "{}");
439        assert_eq!(Reply::message("Hi").to_json().unwrap(), r#"{"message":"Hi"}"#);
440        assert_eq!(Reply::document("# Hi").to_json().unwrap(), r##"{"page":{"markdown":"# Hi"}}"##);
441        assert_eq!(Reply::error("", "No").to_json().unwrap(), r#"{"errors":{"":"No"}}"#);
442        let open = Reply { message: Some("Added".into()), ..Reply::open(Open::new("entry", Some(12))) };
443        assert_eq!(open.to_json().unwrap(), r#"{"message":"Added","open":{"place":"entry","id":12}}"#);
444    }
445
446    #[test]
447    fn a_page_has_its_fields_buttons_and_state() {
448        #[derive(Serialize)]
449        struct Step {
450            step: u32,
451        }
452        let page = Page {
453            title: Some("Who?".into()),
454            fields: vec![
455                Field::text("name", "Name").required(),
456                Field::text("want", "Wants").format(TextFormat::RichExtended).hint("A line"),
457                Field::text("notes", "Notes").multiline(),
458                Field::choice("set", "Set", vec![Choice::new("quick", "Quick")]).default("quick"),
459                Field::int("count", "Count", Some(1), None).default(8),
460                Field::bool("all", "All"),
461                Field::scenes("scene", "Scene").single(),
462            ],
463            buttons: vec![Button::new("back", "Back").skip_checks(), Button::new("next", "Next").primary().inline()],
464            ..Page::default()
465        }
466        .with_state(&Step { step: 2 })
467        .unwrap();
468        assert_eq!(
469            serde_json::to_value(Reply::page(page)).unwrap(),
470            json!({"page": {
471                "title": "Who?",
472                "fields": [
473                    {"key": "name", "type": "text", "label": "Name", "required": true},
474                    {"key": "want", "type": "text", "label": "Wants", "hint": "A line", "format": "rich-extended"},
475                    {"key": "notes", "type": "text", "label": "Notes", "multiline": true},
476                    {"key": "set", "type": "choice", "label": "Set", "default": "quick",
477                     "options": [{"value": "quick", "label": "Quick"}]},
478                    {"key": "count", "type": "int", "label": "Count", "default": 8, "min": 1},
479                    {"key": "all", "type": "bool", "label": "All"},
480                    {"key": "scene", "type": "scenes", "label": "Scene", "multiple": false},
481                ],
482                "buttons": [
483                    {"id": "back", "label": "Back", "validate": false},
484                    {"id": "next", "label": "Next", "primary": true, "inline": true},
485                ],
486                "state": {"step": 2},
487            }})
488        );
489    }
490
491    #[test]
492    fn a_request_carries_state() {
493        #[derive(Deserialize)]
494        struct Step {
495            step: u32,
496        }
497        let request: Request = serde_json::from_value(json!({
498            "action": "wave", "project": "Storm", "settings": {}, "locale": null,
499            "context": {"place": "project", "id": null}, "input": {}, "button": "next",
500            "changed": null, "state": {"step": 2}, "resumed": true,
501        }))
502        .unwrap();
503        assert_eq!(request.state::<Step>().map(|it| it.step), Some(2));
504        assert!(request.state::<String>().is_none());
505        assert!(request.resumed);
506        let first: Request = serde_json::from_value(json!({"action": "wave", "project": "Storm", "state": null})).unwrap();
507        assert!(first.state.is_none() && !first.resumed && first.button.is_none());
508    }
509
510    #[test]
511    fn an_entity_is_one_item_unless_multiple() {
512        let mara = EntityRef::new("entry", 12);
513        let fields = vec![
514            Field::entity("who", "Who"),
515            Field::entity("a", "First")
516                .kinds(&["entry"])
517                .entry_types(&["person"])
518                .tags(&["pov"])
519                .required()
520                .default(mara.clone()),
521            Field::entity("cast", "Cast").multiple().default(vec![mara.clone(), EntityRef::new("scene", 3)]),
522            Field::entity("one", "One").multiple().single(),
523            Field::scenes("scenes", "Scenes").single().multiple(),
524        ];
525        assert_eq!(
526            serde_json::to_value(&fields).unwrap(),
527            json!([
528                {"key": "who", "type": "entity", "label": "Who"},
529                {"key": "a", "type": "entity", "label": "First", "required": true,
530                 "default": {"kind": "entry", "id": 12},
531                 "kinds": ["entry"], "entry_types": ["person"], "tags": ["pov"]},
532                {"key": "cast", "type": "entity", "label": "Cast",
533                 "default": [{"kind": "entry", "id": 12}, {"kind": "scene", "id": 3}], "multiple": true},
534                {"key": "one", "type": "entity", "label": "One"},
535                {"key": "scenes", "type": "scenes", "label": "Scenes"},
536            ])
537        );
538    }
539
540    #[test]
541    fn entity_input_reads_as_entity_refs() {
542        #[derive(Deserialize)]
543        struct Picks {
544            who: Option<EntityRef>,
545            #[serde(default)]
546            cast: Vec<EntityRef>,
547        }
548        let request = |input: Value| -> Request {
549            serde_json::from_value(json!({"action": "meet", "project": "Storm", "input": input})).unwrap()
550        };
551        let picked: Picks = request(json!({"who": {"kind": "entry", "id": 12}, "cast": [{"kind": "event", "id": 4}]}))
552            .input()
553            .unwrap();
554        assert_eq!(picked.who, Some(EntityRef::new("entry", 12)));
555        assert_eq!(picked.cast, vec![EntityRef::new("event", 4)]);
556        let none: Picks = request(json!({"who": null, "cast": []})).input().unwrap();
557        assert!(none.who.is_none() && none.cast.is_empty());
558    }
559}