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