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