]> git.proxmox.com Git - proxmox.git/blame - proxmox-notify/src/renderer/mod.rs
notify: renderer: add relative-percentage helper from PBS
[proxmox.git] / proxmox-notify / src / renderer / mod.rs
CommitLineData
48657113
LW
1//! Module for rendering notification templates.
2
7cb339df
WB
3use std::time::Duration;
4
48657113
LW
5use handlebars::{
6 Context, Handlebars, Helper, HelperDef, HelperResult, Output, RenderContext,
7 RenderError as HandlebarsRenderError,
8};
48657113
LW
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
48657113
LW
12use proxmox_human_byte::HumanByte;
13use proxmox_time::TimeSpan;
14
1516cc26 15use crate::{context, Error};
7cb339df 16
48657113
LW
17mod html;
18mod plaintext;
19mod 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
25fn 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 36fn 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 51fn 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 67fn 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
76fn 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")]
131pub enum ValueRenderFunction {
132 HumanBytes,
133 Duration,
134 Timestamp,
135}
136
137impl 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
194pub 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
203impl 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
237type HelperFn = dyn HelperDef + Send + Sync;
238
239struct BlockRenderFunctions {
240 table: Box<HelperFn>,
48657113 241 object: Box<HelperFn>,
48657113
LW
242}
243
244impl 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
251fn 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).
280pub 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)]
320mod 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}