Skip to main content

sofie_engine/
client_view.rs

1//! What a client area's screen is given beside its data, answered for one request: the gates
2//! its template asks, the messages it shows in the request's language, the language's formats,
3//! its forms as the input types describe them, and the enums it shows.
4
5use std::collections::{BTreeMap, BTreeSet};
6use std::sync::{Arc, Mutex};
7
8use serde_json::{Map, Value, json};
9use sofie_core::unpoisoned;
10use sofie_core::value::serde::ser::{Serialize, SerializeMap, Serializer};
11
12use crate::catalog::{FormPart, constant_label};
13use crate::language::{Language, Texts};
14use crate::message::Recorded;
15use crate::syntax::{Expression, Op};
16use crate::walk::{Visit, walk};
17
18const ENGINE_KEYS: [&str; 5] = [
19    "table.empty",
20    "table.filter",
21    "table.search",
22    "table.previous",
23    "table.next",
24];
25
26/// What a client area's screen is given beside its data, for no request.
27#[must_use]
28pub fn plain_client_view(template: &crate::Template) -> Value {
29    client_parts(template).view(Language::plain()).to_value()
30}
31
32pub(crate) fn client_parts(template: &crate::Template) -> &ClientParts {
33    template
34        .precomputed
35        .client_parts
36        .get_or_init(|| ClientParts::collect(&template.ops))
37}
38
39/// What a template's view is made of, collected once so a request only fills in its language
40/// and viewer.
41#[derive(Debug)]
42pub(crate) struct ClientParts {
43    #[cfg(feature = "gates")]
44    gates: Vec<crate::rendering::Gate>,
45    keys: BTreeSet<String>,
46    counted: BTreeSet<String>,
47    /// Each `t(…)` key's text compiled into the screen, written where the language has none.
48    recorded: BTreeMap<String, Recorded>,
49    forms: Vec<ClientForm>,
50    enums: BTreeSet<String>,
51    /// Where a table's rows lie in the data, which may hold a listing's refused filters.
52    #[cfg(all(feature = "listing", any(feature = "gates", feature = "i18n")))]
53    tables: Vec<Vec<String>>,
54    /// One per language asked of the newest translations, so at most as many as the app
55    /// declares.
56    in_languages: Mutex<Vec<(Texts, LanguageView)>>,
57}
58
59type LanguageView = Arc<Map<String, Value>>;
60
61#[derive(Debug)]
62struct ClientForm {
63    key: String,
64    multipart: bool,
65    parts: Vec<ClientFormPart>,
66}
67
68#[derive(Debug)]
69enum ClientFormPart {
70    Field {
71        description: Value,
72        label: Expression,
73    },
74    Submit,
75}
76
77impl ClientParts {
78    pub(crate) fn collect(ops: &[Op]) -> Self {
79        let mut found = Found::default();
80        walk(ops, &mut found);
81        Self {
82            #[cfg(feature = "gates")]
83            gates: found.gates.into_iter().cloned().collect(),
84            keys: owned(found.keys),
85            counted: owned(found.counted),
86            recorded: found.recorded,
87            forms: described_forms(found.forms),
88            enums: owned(found.enums),
89            #[cfg(all(feature = "listing", any(feature = "gates", feature = "i18n")))]
90            tables: found.tables,
91            in_languages: Mutex::default(),
92        }
93    }
94
95    pub(crate) fn view(&self, language: Language<'_>) -> ClientView {
96        ClientView {
97            in_language: self.in_language(language),
98            #[cfg(feature = "gates")]
99            gates: self.gates(language),
100        }
101    }
102
103    fn in_language(&self, language: Language<'_>) -> LanguageView {
104        let texts = language.texts();
105        let mut in_languages = unpoisoned::lock(&self.in_languages);
106        if let Some((_, view)) = in_languages.iter().find(|(cached, _)| *cached == texts) {
107            return Arc::clone(view);
108        }
109        let mut view = Map::new();
110        view.insert("messages".to_owned(), self.messages(language));
111        view.insert("conventions".to_owned(), conventions(language));
112        view.insert("forms".to_owned(), self.forms(language));
113        view.insert("enums".to_owned(), self.enums(language));
114        let view = Arc::new(view);
115        in_languages.retain(|(cached, _)| cached.is_of_same_translations(texts));
116        in_languages.push((texts, Arc::clone(&view)));
117        view
118    }
119}
120
121/// Serialized from the language's cached view, which is shared rather than copied per request.
122#[derive(Debug)]
123pub struct ClientView {
124    in_language: LanguageView,
125    #[cfg(feature = "gates")]
126    gates: Value,
127}
128
129impl ClientView {
130    #[must_use]
131    pub fn to_value(&self) -> Value {
132        let view = Map::clone(&self.in_language);
133        #[cfg(feature = "gates")]
134        let view = {
135            let mut view = view;
136            view.insert("gates".to_owned(), self.gates.clone());
137            view
138        };
139        Value::Object(view)
140    }
141}
142
143impl Serialize for ClientView {
144    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
145        let mut map = serializer.serialize_map(None)?;
146        for (name, value) in self.in_language.iter() {
147            map.serialize_entry(name, value)?;
148        }
149        #[cfg(feature = "gates")]
150        map.serialize_entry("gates", &self.gates)?;
151        map.end()
152    }
153}
154
155fn owned(names: BTreeSet<&str>) -> BTreeSet<String> {
156    names.into_iter().map(str::to_owned).collect()
157}
158
159/// Forms in an `{#if}`'s branches may share an id, so each is looked up by its id and how many
160/// before it in the template share that id; the client compiler numbers them alike.
161#[derive(Default)]
162pub(crate) struct FormKeys(BTreeMap<String, usize>);
163
164impl FormKeys {
165    pub(crate) fn next(&mut self, id: &str) -> String {
166        let seen = self.0.entry(id.to_owned()).or_default();
167        *seen += 1;
168        if *seen == 1 {
169            return id.to_owned();
170        }
171        format!("{id}#{seen}")
172    }
173}
174
175fn described_forms(forms: Vec<&crate::catalog::Form>) -> Vec<ClientForm> {
176    let mut keys = FormKeys::default();
177    forms
178        .into_iter()
179        .map(|form| ClientForm::of(form, keys.next(&form.id)))
180        .collect()
181}
182
183impl ClientForm {
184    fn of(form: &crate::catalog::Form, key: String) -> Self {
185        let parts = form
186            .parts
187            .iter()
188            .map(|part| match part {
189                FormPart::Field(field) => ClientFormPart::Field {
190                    description: field.client_description(),
191                    label: field.label.clone(),
192                },
193                FormPart::Submit(_) => ClientFormPart::Submit,
194            })
195            .collect();
196        let multipart = form
197            .parts
198            .iter()
199            .any(|part| matches!(part, FormPart::Field(field) if field.is_file()));
200        Self {
201            key,
202            multipart,
203            parts,
204        }
205    }
206
207    fn view(&self, language: Language<'_>) -> Value {
208        let mut submits = 0;
209        let parts: Vec<Value> = self
210            .parts
211            .iter()
212            .map(|part| match part {
213                ClientFormPart::Field { description, label } => {
214                    let mut field = description.clone();
215                    field["label"] = json!(constant_label(label, language));
216                    json!({ "field": field })
217                }
218                ClientFormPart::Submit => {
219                    submits += 1;
220                    json!({ "submit": submits - 1 })
221                }
222            })
223            .collect();
224        json!({ "multipart": self.multipart, "parts": parts })
225    }
226}
227
228#[derive(Default)]
229struct Found<'t> {
230    #[cfg(feature = "gates")]
231    gates: Vec<&'t crate::rendering::Gate>,
232    keys: BTreeSet<&'t str>,
233    counted: BTreeSet<&'t str>,
234    recorded: BTreeMap<String, Recorded>,
235    forms: Vec<&'t crate::catalog::Form>,
236    enums: BTreeSet<&'t str>,
237    #[cfg(all(feature = "listing", any(feature = "gates", feature = "i18n")))]
238    tables: Vec<Vec<String>>,
239}
240
241impl<'t> Visit<'t> for Found<'t> {
242    /// A table inside a loop reads its rows from an item, which is not followed.
243    #[cfg(all(feature = "listing", any(feature = "gates", feature = "i18n")))]
244    fn columns(&mut self, rows: &'t Expression, _columns: &'t [String]) {
245        if let Expression::Path(path) = rows
246            && path.root == crate::syntax::Root::Data
247        {
248            self.tables.push(path.fields.clone());
249        }
250    }
251
252    fn form(&mut self, form: &'t crate::catalog::Form) {
253        self.forms.push(form);
254    }
255
256    fn enumeration(&mut self, name: &'t str) {
257        self.enums.insert(name);
258    }
259
260    fn expression(&mut self, expression: &'t Expression) {
261        match expression {
262            Expression::Interpolated(parts) => {
263                for part in parts {
264                    self.expression(part);
265                }
266            }
267            Expression::Call(call) => self.call(call),
268            #[cfg(feature = "gates")]
269            Expression::Gate(gate) => self.gates.push(gate),
270            Expression::Path(_)
271            | Expression::Text(_)
272            | Expression::Integer(_)
273            | Expression::Authored(_) => {}
274        }
275    }
276}
277
278#[cfg(feature = "i18n")]
279impl<'t> Found<'t> {
280    fn call(&mut self, call: &'t crate::translations::Call) {
281        use crate::translations::Call;
282        match call {
283            Call::Translate {
284                key,
285                arguments,
286                recorded,
287                ..
288            } => self.translation(key, arguments, recorded.as_ref()),
289            Call::Format { value, .. } => self.expression(value),
290            Call::Label { key, .. } => {
291                self.keys.insert(key);
292            }
293        }
294    }
295}
296
297#[cfg(not(feature = "i18n"))]
298impl<'t> Found<'t> {
299    fn call(&mut self, call: &'t crate::language::Call) {
300        use crate::language::Call;
301        match call {
302            Call::Translate {
303                key,
304                arguments,
305                recorded,
306                ..
307            } => self.translation(key, arguments, recorded.as_ref()),
308            Call::Date { value, .. } => self.expression(value),
309        }
310    }
311}
312
313impl<'t> Found<'t> {
314    fn translation(
315        &mut self,
316        key: &'t str,
317        arguments: &'t [crate::language::Argument],
318        recorded: Option<&Recorded>,
319    ) {
320        self.keys.insert(key);
321        if arguments.iter().any(|argument| argument.name == "count") {
322            self.counted.insert(key);
323        }
324        if let Some(recorded) = recorded {
325            self.recorded.insert(key.to_owned(), recorded.clone());
326        }
327        for argument in arguments {
328            self.expression(&argument.value);
329        }
330    }
331}
332
333impl ClientParts {
334    #[cfg(feature = "gates")]
335    fn gates(&self, language: Language<'_>) -> Value {
336        let mut gates = json!({ "permission": {}, "flag": {}, "entitlement": {} });
337        for gate in &self.gates {
338            gates[gate.asks.name()][gate.name.as_str()] = Value::Bool(gate.holds(language.gates()));
339        }
340        gates
341    }
342
343    /// Only what the language says otherwise than the template's own fallback.
344    #[cfg(feature = "i18n")]
345    fn messages(&self, language: Language<'_>) -> Value {
346        let plain = self
347            .keys
348            .iter()
349            .map(String::as_str)
350            .chain(ENGINE_KEYS)
351            .map(str::to_owned);
352        let others = self.counted.iter().map(|key| format!("{key}.other"));
353        let ones = self.counted.iter().filter_map(|key| {
354            let one = format!("{key}.one");
355            Some((language.own_message(&one)?, one))
356        });
357        let mut messages: Map<String, Value> = plain
358            .chain(others)
359            .filter_map(|key| Some((language.message(&key)?, key)))
360            .chain(ones)
361            .map(|(message, key)| (key, Value::String(message.written())))
362            .collect();
363        for (key, recorded) in &self.recorded {
364            if !messages.contains_key(key) && !messages.contains_key(&format!("{key}.other")) {
365                self.write_recorded(&mut messages, key, recorded);
366            }
367        }
368        Value::Object(messages)
369    }
370
371    #[cfg(not(feature = "i18n"))]
372    fn messages(&self, _language: Language<'_>) -> Value {
373        let _ = (&self.keys, ENGINE_KEYS);
374        let mut messages = Map::new();
375        for (key, recorded) in &self.recorded {
376            self.write_recorded(&mut messages, key, recorded);
377        }
378        Value::Object(messages)
379    }
380
381    /// A listing's refused filters are sent with their keys; each message is written in the
382    /// viewer's language as the HTML table writes it.
383    #[cfg(all(feature = "listing", any(feature = "gates", feature = "i18n")))]
384    pub(crate) fn translate_listing_errors(&self, data: &mut Value, language: Language<'_>) {
385        for path in &self.tables {
386            let errors = path
387                .iter()
388                .try_fold(&mut *data, |value, field| value.get_mut(field.as_str()))
389                .and_then(|listed| listed.get_mut(sofie_core::listing::ERRORS))
390                .and_then(Value::as_object_mut);
391            for refusal in errors.into_iter().flat_map(|errors| errors.values_mut()) {
392                translate_refusal(refusal, language);
393            }
394        }
395    }
396
397    /// Under the keys the client looks a call up by, as the language's own texts are.
398    fn write_recorded(&self, messages: &mut Map<String, Value>, key: &str, recorded: &Recorded) {
399        if !self.counted.contains(key) {
400            messages.insert(key.to_owned(), Value::String(recorded.other.written()));
401            return;
402        }
403        let other = Value::String(recorded.other.written());
404        messages.insert(format!("{key}.other"), other);
405        if let Some(one) = &recorded.one {
406            messages.insert(format!("{key}.one"), Value::String(one.written()));
407        }
408    }
409
410    fn forms(&self, language: Language<'_>) -> Value {
411        let forms: Map<String, Value> = self
412            .forms
413            .iter()
414            .map(|form| (form.key.clone(), form.view(language)))
415            .collect();
416        Value::Object(forms)
417    }
418
419    #[cfg(feature = "i18n")]
420    fn enums(&self, language: Language<'_>) -> Value {
421        let enums: Map<String, Value> = self
422            .enums
423            .iter()
424            .map(|name| {
425                let variants: Vec<Value> = language
426                    .enumeration(name)
427                    .unwrap_or_default()
428                    .iter()
429                    .map(|variant| {
430                        json!({
431                            "name": variant.name,
432                            "label": language.text(&variant.key, &variant.humanized),
433                            "class": variant.tone.class(),
434                        })
435                    })
436                    .collect();
437                (name.clone(), Value::Array(variants))
438            })
439            .collect();
440        Value::Object(enums)
441    }
442
443    #[cfg(not(feature = "i18n"))]
444    fn enums(&self, _language: Language<'_>) -> Value {
445        let _ = &self.enums;
446        Value::Object(Map::new())
447    }
448}
449
450#[cfg(all(feature = "listing", any(feature = "gates", feature = "i18n")))]
451fn translate_refusal(refusal: &mut Value, language: Language<'_>) {
452    use sofie_core::listing::{REFUSAL_ARGUMENTS, REFUSAL_KEY, REFUSAL_MESSAGE};
453    let Some(record) = refusal.as_object_mut() else {
454        return;
455    };
456    let translated = {
457        let text = |name: &str| record.get(name).and_then(Value::as_str);
458        let (Some(key), Some(message)) = (text(REFUSAL_KEY), text(REFUSAL_MESSAGE)) else {
459            return;
460        };
461        let arguments: Vec<(&str, String)> = record
462            .get(REFUSAL_ARGUMENTS)
463            .and_then(Value::as_object)
464            .into_iter()
465            .flatten()
466            .filter_map(|(name, value)| Some((name.as_str(), value.as_str()?.to_owned())))
467            .collect();
468        language.field_error(key, &arguments, message)
469    };
470    record.insert(REFUSAL_MESSAGE.to_owned(), Value::String(translated));
471}
472
473#[cfg(feature = "i18n")]
474fn conventions(language: Language<'_>) -> Value {
475    let mut described = language.conventions().describe();
476    described["plural"] = Value::from(language.plural_rule().name());
477    described["zone"] = Value::from(sofie_core::DisplayZone::current().name());
478    described
479}
480
481/// One language without translations writes amounts as `1234.50 EUR` and days as ISO dates;
482/// instants are shown in the display zone either way.
483#[cfg(not(feature = "i18n"))]
484fn conventions(_language: Language<'_>) -> Value {
485    json!({
486        "decimal": ".",
487        "group": null,
488        "money": "code-after",
489        "days": "iso",
490        "zone": sofie_core::DisplayZone::current().name(),
491    })
492}
493
494#[cfg(test)]
495mod tests {
496    use serde_json::{Value, json};
497    use sofie_values::{Control, FormField, Input};
498
499    use crate::Template;
500
501    #[test]
502    fn a_view_serializes_as_its_value_does() {
503        let template = Template::parse(
504            "screen",
505            "{t(\"cart.items\", count = data.n)}<Form id=\"f\" post=\"/f\">\
506             <Field name=\"note\" /></Form>",
507        )
508        .unwrap();
509        let view = super::client_parts(&template).view(crate::language::Language::plain());
510
511        assert_eq!(serde_json::to_value(&view).unwrap(), view.to_value());
512    }
513
514    #[test]
515    fn forms_sharing_an_id_in_two_branches_are_described_each_on_its_own() {
516        let template = Template::parse(
517            "screen",
518            "{#if data.a}<Form id=\"f\" post=\"/f\"><Field name=\"a\" /><Submit label=\"Go\" />\
519             </Form>{:else}<Form id=\"f\" post=\"/f\"><Field name=\"b\" /><Field name=\"a\" />\
520             <Submit label=\"Go\" /><Submit label=\"Later\" /></Form>{/if}",
521        )
522        .unwrap();
523
524        let view = super::plain_client_view(&template);
525        let shapes = |key: &str| -> Vec<Value> {
526            view["forms"][key]["parts"]
527                .as_array()
528                .unwrap()
529                .iter()
530                .map(|part| part["field"]["name"].clone())
531                .collect()
532        };
533
534        assert_eq!(shapes("f"), [json!("a"), Value::Null]);
535        assert_eq!(
536            shapes("f#2"),
537            [json!("b"), json!("a"), Value::Null, Value::Null]
538        );
539    }
540
541    #[test]
542    fn a_view_names_the_forms_and_enums_a_screen_shows() {
543        let template = Template::parse(
544            "screen",
545            "<Form id=\"f\" post=\"/f\"><Field name=\"note\" required /><Submit label=\"Go\" />\
546             </Form>",
547        )
548        .unwrap();
549
550        let view = super::plain_client_view(&template);
551
552        assert_eq!(
553            view["forms"]["f"],
554            json!({
555                "multipart": false,
556                "parts": [
557                    {
558                        "field": {
559                            "name": "note",
560                            "label": "Note",
561                            "control": "input",
562                            "attributes": [["type", "text"]],
563                            "options": [],
564                            "parts": [],
565                            "required": true,
566                            "constraints": [],
567                        },
568                    },
569                    { "submit": 0 },
570                ],
571            })
572        );
573        assert_eq!(view["conventions"]["decimal"], ".");
574        assert_eq!(view["conventions"]["zone"], "UTC");
575    }
576
577    #[test]
578    fn a_view_follows_a_form_described_after_it_was_first_asked() {
579        const PASSWORD: &[FormField] = &[FormField {
580            name: "password",
581            input: Input::new(Control::Password),
582            required: true,
583            constraints: &[],
584        }];
585        let mut template = Template::parse(
586            "screen",
587            "<Form id=\"f\" post=\"/f\"><Field name=\"password\" /></Form>",
588        )
589        .unwrap();
590        let field = |template: &Template| {
591            super::plain_client_view(template)["forms"]["f"]["parts"][0]["field"].clone()
592        };
593        assert_eq!(field(&template)["control"], "input");
594
595        template.describe_form("f", PASSWORD).unwrap();
596
597        assert_eq!(field(&template)["control"], "password");
598        assert_eq!(field(&template)["label"], "Password");
599    }
600}