]>
Commit | Line | Data |
---|---|---|
48657113 LW |
1 | //! Module for rendering notification templates. |
2 | ||
7cb339df WB |
3 | use std::time::Duration; |
4 | ||
48657113 LW |
5 | use handlebars::{ |
6 | Context, Handlebars, Helper, HelperDef, HelperResult, Output, RenderContext, | |
7 | RenderError as HandlebarsRenderError, | |
8 | }; | |
48657113 LW |
9 | use serde::{Deserialize, Serialize}; |
10 | use serde_json::Value; | |
11 | ||
48657113 LW |
12 | use proxmox_human_byte::HumanByte; |
13 | use proxmox_time::TimeSpan; | |
14 | ||
1516cc26 | 15 | use crate::{context, Error}; |
7cb339df | 16 | |
48657113 LW |
17 | mod html; |
18 | mod plaintext; | |
19 | mod table; | |
20 | ||
21 | /// Convert a serde_json::Value to a String. | |
22 | /// | |
23 | /// The main difference between this and simply calling Value::to_string is that | |
24 | /// this will print strings without double quotes | |
25 | fn value_to_string(value: &Value) -> String { | |
26 | match value { | |
27 | Value::String(s) => s.clone(), | |
28 | v => v.to_string(), | |
29 | } | |
30 | } | |
31 | ||
d49fc1aa LW |
32 | /// Render a `serde_json::Value` as a byte size with proper units (IEC, base 2). |
33 | /// Accepts `serde_json::Value::{Number,String}`. | |
48657113 | 34 | /// |
d49fc1aa | 35 | /// Will return `None` if `val` does not contain a number/parseable string. |
48657113 | 36 | fn value_to_byte_size(val: &Value) -> Option<String> { |
d49fc1aa LW |
37 | let size = match val { |
38 | Value::Number(n) => n.as_f64(), | |
39 | Value::String(s) => s.parse().ok(), | |
40 | _ => None, | |
41 | }?; | |
42 | ||
48657113 LW |
43 | Some(format!("{}", HumanByte::new_binary(size))) |
44 | } | |
45 | ||
46 | /// Render a serde_json::Value as a duration. | |
47 | /// The value is expected to contain the duration in seconds. | |
d49fc1aa | 48 | /// Accepts `serde_json::Value::{Number,String}`. |
48657113 | 49 | /// |
d49fc1aa | 50 | /// Will return `None` if `val` does not contain a number/parseable string. |
48657113 | 51 | fn value_to_duration(val: &Value) -> Option<String> { |
d49fc1aa LW |
52 | let duration = match val { |
53 | Value::Number(n) => n.as_u64(), | |
54 | Value::String(s) => s.parse().ok(), | |
55 | _ => None, | |
56 | }?; | |
48657113 LW |
57 | let time_span = TimeSpan::from(Duration::from_secs(duration)); |
58 | ||
59 | Some(format!("{time_span}")) | |
60 | } | |
61 | ||
62 | /// Render as serde_json::Value as a timestamp. | |
63 | /// The value is expected to contain the timestamp as a unix epoch. | |
d49fc1aa | 64 | /// Accepts `serde_json::Value::{Number,String}`. |
48657113 | 65 | /// |
d49fc1aa | 66 | /// Will return `None` if `val` does not contain a number/parseable string. |
48657113 | 67 | fn value_to_timestamp(val: &Value) -> Option<String> { |
d49fc1aa LW |
68 | let timestamp = match val { |
69 | Value::Number(n) => n.as_i64(), | |
70 | Value::String(s) => s.parse().ok(), | |
71 | _ => None, | |
72 | }?; | |
48657113 LW |
73 | proxmox_time::strftime_local("%F %H:%M:%S", timestamp).ok() |
74 | } | |
75 | ||
c028a32c LW |
76 | fn handlebars_relative_percentage_helper( |
77 | h: &Helper, | |
78 | _: &Handlebars, | |
79 | _: &Context, | |
80 | _rc: &mut RenderContext, | |
81 | out: &mut dyn Output, | |
82 | ) -> HelperResult { | |
83 | let param0 = h | |
84 | .param(0) | |
85 | .and_then(|v| v.value().as_f64()) | |
86 | .ok_or_else(|| HandlebarsRenderError::new("relative-percentage: param0 not found"))?; | |
87 | let param1 = h | |
88 | .param(1) | |
89 | .and_then(|v| v.value().as_f64()) | |
90 | .ok_or_else(|| HandlebarsRenderError::new("relative-percentage: param1 not found"))?; | |
91 | ||
92 | if param1 == 0.0 { | |
93 | out.write("-")?; | |
94 | } else { | |
95 | out.write(&format!("{:.2}%", (param0 * 100.0) / param1))?; | |
96 | } | |
97 | Ok(()) | |
98 | } | |
99 | ||
48657113 LW |
100 | /// Available render functions for `serde_json::Values`` |
101 | /// | |
102 | /// May be used as a handlebars helper, e.g. | |
103 | /// ```text | |
104 | /// {{human-bytes 1024}} | |
105 | /// ``` | |
106 | /// | |
107 | /// Value renderer can also be used for rendering values in table columns: | |
108 | /// ```text | |
109 | /// let properties = json!({ | |
110 | /// "table": { | |
111 | /// "schema": { | |
112 | /// "columns": [ | |
113 | /// { | |
114 | /// "label": "Size", | |
115 | /// "id": "size", | |
116 | /// "renderer": "human-bytes" | |
117 | /// } | |
118 | /// ], | |
119 | /// }, | |
120 | /// "data" : [ | |
121 | /// { | |
122 | /// "size": 1024 * 1024, | |
123 | /// }, | |
124 | /// ] | |
125 | /// } | |
126 | /// }); | |
127 | /// ``` | |
128 | /// | |
129 | #[derive(Debug, Deserialize, Serialize)] | |
130 | #[serde(rename_all = "kebab-case")] | |
131 | pub enum ValueRenderFunction { | |
132 | HumanBytes, | |
133 | Duration, | |
134 | Timestamp, | |
135 | } | |
136 | ||
137 | impl ValueRenderFunction { | |
d49fc1aa | 138 | fn render(&self, value: &Value) -> String { |
48657113 LW |
139 | match self { |
140 | ValueRenderFunction::HumanBytes => value_to_byte_size(value), | |
141 | ValueRenderFunction::Duration => value_to_duration(value), | |
142 | ValueRenderFunction::Timestamp => value_to_timestamp(value), | |
143 | } | |
d49fc1aa LW |
144 | .unwrap_or_else(|| { |
145 | log::error!("could not render value {value} with renderer {self:?}"); | |
146 | String::from("ERROR") | |
48657113 LW |
147 | }) |
148 | } | |
149 | ||
150 | fn register_helpers(handlebars: &mut Handlebars) { | |
151 | ValueRenderFunction::HumanBytes.register_handlebars_helper(handlebars); | |
152 | ValueRenderFunction::Duration.register_handlebars_helper(handlebars); | |
153 | ValueRenderFunction::Timestamp.register_handlebars_helper(handlebars); | |
154 | } | |
155 | ||
156 | fn register_handlebars_helper(&'static self, handlebars: &mut Handlebars) { | |
157 | // Use serde to get own kebab-case representation that is later used | |
158 | // to register the helper, e.g. HumanBytes -> human-bytes | |
159 | let tag = serde_json::to_string(self) | |
160 | .expect("serde failed to serialize ValueRenderFunction enum"); | |
161 | ||
162 | // But as it's a string value, the generated string is quoted, | |
163 | // so remove leading/trailing double quotes | |
164 | let tag = tag | |
165 | .strip_prefix('\"') | |
166 | .and_then(|t| t.strip_suffix('\"')) | |
167 | .expect("serde serialized string representation was not contained in double quotes"); | |
168 | ||
169 | handlebars.register_helper( | |
170 | tag, | |
171 | Box::new( | |
172 | |h: &Helper, | |
173 | _r: &Handlebars, | |
174 | _: &Context, | |
175 | _rc: &mut RenderContext, | |
176 | out: &mut dyn Output| | |
177 | -> HelperResult { | |
178 | let param = h | |
179 | .param(0) | |
180 | .ok_or(HandlebarsRenderError::new("parameter not found"))?; | |
181 | ||
182 | let value = param.value(); | |
d49fc1aa | 183 | out.write(&self.render(value))?; |
48657113 LW |
184 | |
185 | Ok(()) | |
186 | }, | |
187 | ), | |
188 | ); | |
189 | } | |
190 | } | |
191 | ||
1516cc26 | 192 | /// Available template types |
48657113 | 193 | #[derive(Copy, Clone)] |
1516cc26 LW |
194 | pub enum TemplateType { |
195 | /// HTML body template | |
196 | HtmlBody, | |
197 | /// Plaintext body template | |
198 | PlaintextBody, | |
199 | /// Plaintext body template | |
200 | Subject, | |
48657113 LW |
201 | } |
202 | ||
1516cc26 LW |
203 | impl TemplateType { |
204 | fn file_suffix(&self) -> &'static str { | |
48657113 | 205 | match self { |
1516cc26 LW |
206 | TemplateType::HtmlBody => "body.html.hbs", |
207 | TemplateType::PlaintextBody => "body.txt.hbs", | |
208 | TemplateType::Subject => "subject.txt.hbs", | |
48657113 LW |
209 | } |
210 | } | |
211 | ||
1516cc26 LW |
212 | fn postprocess(&self, mut rendered: String) -> String { |
213 | if let Self::Subject = self { | |
214 | rendered = rendered.replace('\n', " "); | |
48657113 | 215 | } |
1516cc26 LW |
216 | |
217 | rendered | |
48657113 LW |
218 | } |
219 | ||
220 | fn block_render_fns(&self) -> BlockRenderFunctions { | |
221 | match self { | |
1516cc26 LW |
222 | TemplateType::HtmlBody => html::block_render_functions(), |
223 | TemplateType::Subject => plaintext::block_render_functions(), | |
224 | TemplateType::PlaintextBody => plaintext::block_render_functions(), | |
48657113 LW |
225 | } |
226 | } | |
227 | ||
228 | fn escape_fn(&self) -> fn(&str) -> String { | |
229 | match self { | |
1516cc26 LW |
230 | TemplateType::PlaintextBody => handlebars::no_escape, |
231 | TemplateType::Subject => handlebars::no_escape, | |
232 | TemplateType::HtmlBody => handlebars::html_escape, | |
48657113 LW |
233 | } |
234 | } | |
235 | } | |
236 | ||
237 | type HelperFn = dyn HelperDef + Send + Sync; | |
238 | ||
239 | struct BlockRenderFunctions { | |
240 | table: Box<HelperFn>, | |
48657113 | 241 | object: Box<HelperFn>, |
48657113 LW |
242 | } |
243 | ||
244 | impl BlockRenderFunctions { | |
245 | fn register_helpers(self, handlebars: &mut Handlebars) { | |
246 | handlebars.register_helper("table", self.table); | |
48657113 | 247 | handlebars.register_helper("object", self.object); |
48657113 LW |
248 | } |
249 | } | |
250 | ||
251 | fn render_template_impl( | |
252 | template: &str, | |
df4858e9 | 253 | data: &Value, |
1516cc26 | 254 | renderer: TemplateType, |
48657113 | 255 | ) -> Result<String, Error> { |
48657113 LW |
256 | let mut handlebars = Handlebars::new(); |
257 | handlebars.register_escape_fn(renderer.escape_fn()); | |
258 | ||
259 | let block_render_fns = renderer.block_render_fns(); | |
260 | block_render_fns.register_helpers(&mut handlebars); | |
261 | ||
262 | ValueRenderFunction::register_helpers(&mut handlebars); | |
263 | ||
c028a32c LW |
264 | handlebars.register_helper( |
265 | "relative-percentage", | |
266 | Box::new(handlebars_relative_percentage_helper), | |
267 | ); | |
268 | ||
48657113 | 269 | let rendered_template = handlebars |
df4858e9 | 270 | .render_template(template, data) |
48657113 LW |
271 | .map_err(|err| Error::RenderError(err.into()))?; |
272 | ||
273 | Ok(rendered_template) | |
274 | } | |
275 | ||
276 | /// Render a template string. | |
277 | /// | |
1516cc26 | 278 | /// The output format can be chosen via the `renderer` parameter (see [TemplateType] |
48657113 LW |
279 | /// for available options). |
280 | pub fn render_template( | |
1516cc26 | 281 | mut ty: TemplateType, |
48657113 | 282 | template: &str, |
df4858e9 | 283 | data: &Value, |
48657113 | 284 | ) -> Result<String, Error> { |
1516cc26 LW |
285 | let filename = format!("{template}-{suffix}", suffix = ty.file_suffix()); |
286 | ||
287 | let template_string = context::context().lookup_template(&filename, None)?; | |
288 | ||
289 | let (template_string, fallback) = match (template_string, ty) { | |
290 | (None, TemplateType::HtmlBody) => { | |
291 | ty = TemplateType::PlaintextBody; | |
292 | let plaintext_filename = format!("{template}-{suffix}", suffix = ty.file_suffix()); | |
293 | log::info!("html template '{filename}' not found, falling back to plain text template '{plaintext_filename}'"); | |
294 | ( | |
295 | context::context().lookup_template(&plaintext_filename, None)?, | |
296 | true, | |
297 | ) | |
298 | } | |
299 | (template_string, _) => (template_string, false), | |
300 | }; | |
48657113 | 301 | |
1516cc26 LW |
302 | let template_string = template_string.ok_or(Error::Generic(format!( |
303 | "could not load template '{template}'" | |
304 | )))?; | |
48657113 | 305 | |
1516cc26 LW |
306 | let mut rendered = render_template_impl(&template_string, data, ty)?; |
307 | rendered = ty.postprocess(rendered); | |
48657113 | 308 | |
1516cc26 LW |
309 | if fallback { |
310 | rendered = format!( | |
311 | "<html><body><pre>{}</pre></body></html>", | |
312 | handlebars::html_escape(&rendered) | |
313 | ); | |
314 | } | |
48657113 | 315 | |
1516cc26 | 316 | Ok(rendered) |
48657113 LW |
317 | } |
318 | ||
319 | #[cfg(test)] | |
320 | mod tests { | |
321 | use super::*; | |
322 | use serde_json::json; | |
323 | ||
d49fc1aa LW |
324 | #[test] |
325 | fn test_helpers() { | |
326 | assert_eq!(value_to_byte_size(&json!(1024)), Some("1 KiB".to_string())); | |
327 | assert_eq!( | |
328 | value_to_byte_size(&json!("1024")), | |
329 | Some("1 KiB".to_string()) | |
330 | ); | |
331 | ||
332 | assert_eq!(value_to_duration(&json!(60)), Some("1min ".to_string())); | |
333 | assert_eq!(value_to_duration(&json!("60")), Some("1min ".to_string())); | |
334 | ||
335 | // The rendered value is in localtime, so we only check if the result is `Some`... | |
336 | // ... otherwise the test will break in another timezone :S | |
337 | assert!(value_to_timestamp(&json!(60)).is_some()); | |
338 | assert!(value_to_timestamp(&json!("60")).is_some()); | |
339 | } | |
48657113 | 340 | } |