[pve-devel] [PATCH v3 proxmox 13/66] notify: add template rendering

Lukas Wagner l.wagner at proxmox.com
Mon Jul 17 16:59:58 CEST 2023


This commit adds template rendering to the `proxmox-notify` crate, based
on the `handlebars` crate.

Title and body of a notification are rendered using any `properties`
passed along with the notification. There are also a few helpers,
allowing to render tables from `serde_json::Value`.

'Value' renderers. These can also be used in table cells using the
'renderer' property in a table schema:
  - {{human-bytes val}}
    Render bytes with human-readable units (base 2)
  - {{duration val}}
    Render a duration (based on seconds)
  - {{timestamp val}}
    Render a unix-epoch (based on seconds)

There are also a few 'block-level' helpers.
  - {{table val}}
    Render a table from given val (containing a schema for the columns,
    as well as the table data)
  - {{object val}}
    Render a value as a pretty-printed json
  - {{heading_1 val}}
    Render a top-level heading
  - {{heading_2 val}}
    Render a not-so-top-level heading
  - {{verbatim val}} or {{/verbatim}}<content>{{#verbatim}}
    Do not reflow text. NOP for plain text, but for HTML output the text
    will be contained in a <pre> with a regular font.
  - {{verbatim-monospaced val}} or
      {{/verbatim-monospaced}}<content>{{#verbatim-monospaced}}
    Do not reflow text. NOP for plain text, but for HTML output the text
    will be contained in a <pre> with a monospaced font.

Signed-off-by: Lukas Wagner <l.wagner at proxmox.com>
---
 Cargo.toml                               |   1 +
 proxmox-notify/Cargo.toml                |   6 +-
 proxmox-notify/src/endpoints/gotify.rs   |  40 ++-
 proxmox-notify/src/endpoints/sendmail.rs |  26 +-
 proxmox-notify/src/lib.rs                |   6 +-
 proxmox-notify/src/renderer/html.rs      | 100 +++++++
 proxmox-notify/src/renderer/mod.rs       | 366 +++++++++++++++++++++++
 proxmox-notify/src/renderer/plaintext.rs | 141 +++++++++
 proxmox-notify/src/renderer/table.rs     |  24 ++
 9 files changed, 684 insertions(+), 26 deletions(-)
 create mode 100644 proxmox-notify/src/renderer/html.rs
 create mode 100644 proxmox-notify/src/renderer/mod.rs
 create mode 100644 proxmox-notify/src/renderer/plaintext.rs
 create mode 100644 proxmox-notify/src/renderer/table.rs

diff --git a/Cargo.toml b/Cargo.toml
index ef8a050a..c30131fe 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -88,6 +88,7 @@ proxmox-api-macro = { version = "1.0.4", path = "proxmox-api-macro" }
 proxmox-async = { version = "0.4.1", path = "proxmox-async" }
 proxmox-compression = { version = "0.2.0", path = "proxmox-compression" }
 proxmox-http = { version = "0.9.0", path = "proxmox-http" }
+proxmox-human-byte = { version = "0.1.0", path = "proxmox-human-byte" }
 proxmox-io = { version = "1.0.0", path = "proxmox-io" }
 proxmox-lang = { version = "1.1", path = "proxmox-lang" }
 proxmox-rest-server = { version = "0.4.0", path = "proxmox-rest-server" }
diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml
index 738674ae..a635798b 100644
--- a/proxmox-notify/Cargo.toml
+++ b/proxmox-notify/Cargo.toml
@@ -8,19 +8,21 @@ repository.workspace = true
 exclude.workspace = true
 
 [dependencies]
-handlebars = { workspace = true, optional = true }
+handlebars = { workspace = true }
 lazy_static.workspace = true
 log.workspace = true
 openssl.workspace = true
 proxmox-http = { workspace = true, features = ["client-sync"], optional = true }
+proxmox-human-byte.workspace = true
 proxmox-schema = { workspace = true, features = ["api-macro"]}
 proxmox-section-config = { workspace = true }
 proxmox-sys = { workspace = true, optional = true }
+proxmox-time.workspace = true
 regex.workspace = true
 serde = { workspace = true, features = ["derive"]}
 serde_json.workspace = true
 
 [features]
 default = ["sendmail", "gotify"]
-sendmail = ["dep:handlebars", "dep:proxmox-sys"]
+sendmail = ["dep:proxmox-sys"]
 gotify = ["dep:proxmox-http"]
diff --git a/proxmox-notify/src/endpoints/gotify.rs b/proxmox-notify/src/endpoints/gotify.rs
index 6c42100e..b278b90d 100644
--- a/proxmox-notify/src/endpoints/gotify.rs
+++ b/proxmox-notify/src/endpoints/gotify.rs
@@ -1,21 +1,16 @@
 use std::collections::HashMap;
 
+use crate::renderer::TemplateRenderer;
 use crate::schema::{COMMENT_SCHEMA, ENTITY_NAME_SCHEMA};
-use crate::{Endpoint, Error, Notification, Severity};
+use crate::{renderer, Endpoint, Error, Notification, Severity};
 
 use serde::{Deserialize, Serialize};
+use serde_json::json;
 
 use proxmox_http::client::sync::Client;
 use proxmox_http::{HttpClient, HttpOptions};
 use proxmox_schema::{api, Updater};
 
-#[derive(Serialize)]
-struct GotifyMessageBody<'a> {
-    title: &'a str,
-    message: &'a str,
-    priority: u32,
-}
-
 fn severity_to_priority(level: Severity) -> u32 {
     match level {
         Severity::Info => 1,
@@ -93,11 +88,30 @@ impl Endpoint for GotifyEndpoint {
 
         let uri = format!("{}/message", self.config.server);
 
-        let body = GotifyMessageBody {
-            title: &notification.title,
-            message: &notification.body,
-            priority: severity_to_priority(notification.severity),
-        };
+        let properties = notification.properties.as_ref();
+
+        let title = renderer::render_template(
+            TemplateRenderer::Plaintext,
+            &notification.title,
+            properties,
+        )?;
+        let message =
+            renderer::render_template(TemplateRenderer::Plaintext, &notification.body, properties)?;
+
+        // We don't have a TemplateRenderer::Markdown yet, so simply put everything
+        // in code tags. Otherwise tables etc. are not formatted properly
+        let message = format!("```\n{message}\n```");
+
+        let body = json!({
+            "title": &title,
+            "message": &message,
+            "priority": severity_to_priority(notification.severity),
+            "extras": {
+                "client::display": {
+                    "contentType": "text/markdown"
+                }
+            }
+        });
 
         let body = serde_json::to_vec(&body)
             .map_err(|err| Error::NotifyFailed(self.name().to_string(), err.into()))?;
diff --git a/proxmox-notify/src/endpoints/sendmail.rs b/proxmox-notify/src/endpoints/sendmail.rs
index fcac6248..9d06e7c4 100644
--- a/proxmox-notify/src/endpoints/sendmail.rs
+++ b/proxmox-notify/src/endpoints/sendmail.rs
@@ -1,5 +1,6 @@
+use crate::renderer::TemplateRenderer;
 use crate::schema::{COMMENT_SCHEMA, EMAIL_SCHEMA, ENTITY_NAME_SCHEMA};
-use crate::{Endpoint, Error, Notification};
+use crate::{renderer, Endpoint, Error, Notification};
 
 use proxmox_schema::{api, Updater};
 use serde::{Deserialize, Serialize};
@@ -68,12 +69,17 @@ impl Endpoint for SendmailEndpoint {
     fn send(&self, notification: &Notification) -> Result<(), Error> {
         let recipients: Vec<&str> = self.config.mailto.iter().map(String::as_str).collect();
 
-        // Note: OX has serious problems displaying text mails,
-        // so we include html as well
-        let html = format!(
-            "<html><body><pre>\n{}\n<pre>",
-            handlebars::html_escape(&notification.body)
-        );
+        let properties = notification.properties.as_ref();
+
+        let subject = renderer::render_template(
+            TemplateRenderer::Plaintext,
+            &notification.title,
+            properties,
+        )?;
+        let html_part =
+            renderer::render_template(TemplateRenderer::Html, &notification.body, properties)?;
+        let text_part =
+            renderer::render_template(TemplateRenderer::Plaintext, &notification.body, properties)?;
 
         // proxmox_sys::email::sendmail will set the author to
         // "Proxmox Backup Server" if it is not set.
@@ -81,9 +87,9 @@ impl Endpoint for SendmailEndpoint {
 
         proxmox_sys::email::sendmail(
             &recipients,
-            &notification.title,
-            Some(&notification.body),
-            Some(&html),
+            &subject,
+            Some(&text_part),
+            Some(&html_part),
             self.config.from_address.as_deref(),
             author,
         )
diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs
index 5d408c85..548cc56f 100644
--- a/proxmox-notify/src/lib.rs
+++ b/proxmox-notify/src/lib.rs
@@ -14,8 +14,9 @@ use std::error::Error as StdError;
 pub mod api;
 mod config;
 pub mod endpoints;
-mod filter;
+pub mod filter;
 pub mod group;
+pub mod renderer;
 pub mod schema;
 
 #[derive(Debug)]
@@ -26,6 +27,7 @@ pub enum Error {
     TargetDoesNotExist(String),
     TargetTestFailed(Vec<Box<dyn StdError + Send + Sync + 'static>>),
     FilterFailed(String),
+    RenderError(Box<dyn StdError + Send + Sync + 'static>),
 }
 
 impl Display for Error {
@@ -53,6 +55,7 @@ impl Display for Error {
             Error::FilterFailed(message) => {
                 write!(f, "could not apply filter: {message}")
             }
+            Error::RenderError(err) => write!(f, "could not render notification template: {err}"),
         }
     }
 }
@@ -66,6 +69,7 @@ impl StdError for Error {
             Error::TargetDoesNotExist(_) => None,
             Error::TargetTestFailed(errs) => Some(&*errs[0]),
             Error::FilterFailed(_) => None,
+            Error::RenderError(err) => Some(&**err),
         }
     }
 }
diff --git a/proxmox-notify/src/renderer/html.rs b/proxmox-notify/src/renderer/html.rs
new file mode 100644
index 00000000..7a41e873
--- /dev/null
+++ b/proxmox-notify/src/renderer/html.rs
@@ -0,0 +1,100 @@
+use crate::define_helper_with_prefix_and_postfix;
+use crate::renderer::BlockRenderFunctions;
+use handlebars::{
+    Context, Handlebars, Helper, HelperResult, Output, RenderContext,
+    RenderError as HandlebarsRenderError,
+};
+use serde_json::Value;
+
+use super::{table::Table, value_to_string};
+
+fn render_html_table(
+    h: &Helper,
+    _: &Handlebars,
+    _: &Context,
+    _: &mut RenderContext,
+    out: &mut dyn Output,
+) -> HelperResult {
+    let param = h
+        .param(0)
+        .ok_or_else(|| HandlebarsRenderError::new("parameter not found"))?;
+
+    let value = param.value();
+
+    let table: Table = serde_json::from_value(value.clone())?;
+
+    out.write("<table style=\"border: 1px solid\";border-style=\"collapse\">\n")?;
+
+    // Write header
+    out.write("  <tr>\n")?;
+    for column in &table.schema.columns {
+        out.write("    <th style=\"border: 1px solid\">")?;
+        out.write(&handlebars::html_escape(&column.label))?;
+        out.write("</th>\n")?;
+    }
+    out.write("  </tr>\n")?;
+
+    // Write individual rows
+    for row in &table.data {
+        out.write("  <tr>\n")?;
+
+        for column in &table.schema.columns {
+            let entry = row.get(&column.id).unwrap_or(&Value::Null);
+
+            let text = if let Some(renderer) = &column.renderer {
+                renderer.render(entry)?
+            } else {
+                value_to_string(entry)
+            };
+
+            out.write("    <td style=\"border: 1px solid\">")?;
+            out.write(&handlebars::html_escape(&text))?;
+            out.write("</td>\n")?;
+        }
+        out.write("  </tr>\n")?;
+    }
+
+    out.write("</table>\n")?;
+
+    Ok(())
+}
+
+fn render_object(
+    h: &Helper,
+    _: &Handlebars,
+    _: &Context,
+    _: &mut RenderContext,
+    out: &mut dyn Output,
+) -> HelperResult {
+    let param = h
+        .param(0)
+        .ok_or_else(|| HandlebarsRenderError::new("parameter not found"))?;
+
+    let value = param.value();
+
+    out.write("\n<pre>")?;
+    out.write(&serde_json::to_string_pretty(&value)?)?;
+    out.write("\n</pre>\n")?;
+
+    Ok(())
+}
+
+define_helper_with_prefix_and_postfix!(verbatim_monospaced, "<pre>", "</pre>");
+define_helper_with_prefix_and_postfix!(heading_1, "<h1 style=\"font-size: 1.2em\">", "</h1>");
+define_helper_with_prefix_and_postfix!(heading_2, "<h2 style=\"font-size: 1em\">", "</h2>");
+define_helper_with_prefix_and_postfix!(
+    verbatim,
+    "<pre style=\"font-family: sans-serif\">",
+    "</pre>"
+);
+
+pub(super) fn block_render_functions() -> BlockRenderFunctions {
+    BlockRenderFunctions {
+        table: Box::new(render_html_table),
+        verbatim_monospaced: Box::new(verbatim_monospaced),
+        object: Box::new(render_object),
+        heading_1: Box::new(heading_1),
+        heading_2: Box::new(heading_2),
+        verbatim: Box::new(verbatim),
+    }
+}
diff --git a/proxmox-notify/src/renderer/mod.rs b/proxmox-notify/src/renderer/mod.rs
new file mode 100644
index 00000000..2cf64a9f
--- /dev/null
+++ b/proxmox-notify/src/renderer/mod.rs
@@ -0,0 +1,366 @@
+//! Module for rendering notification templates.
+
+use handlebars::{
+    Context, Handlebars, Helper, HelperDef, HelperResult, Output, RenderContext,
+    RenderError as HandlebarsRenderError,
+};
+use std::time::Duration;
+
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use crate::Error;
+use proxmox_human_byte::HumanByte;
+use proxmox_time::TimeSpan;
+
+mod html;
+mod plaintext;
+mod table;
+
+/// Convert a serde_json::Value to a String.
+///
+/// The main difference between this and simply calling Value::to_string is that
+/// this will print strings without double quotes
+fn value_to_string(value: &Value) -> String {
+    match value {
+        Value::String(s) => s.clone(),
+        v => v.to_string(),
+    }
+}
+
+/// Render a serde_json::Value as a byte size with proper units (IEC, base 2)
+///
+/// Will return `None` if `val` does not contain a number.
+fn value_to_byte_size(val: &Value) -> Option<String> {
+    let size = val.as_f64()?;
+    Some(format!("{}", HumanByte::new_binary(size)))
+}
+
+/// Render a serde_json::Value as a duration.
+/// The value is expected to contain the duration in seconds.
+///
+/// Will return `None` if `val` does not contain a number.
+fn value_to_duration(val: &Value) -> Option<String> {
+    let duration = val.as_u64()?;
+    let time_span = TimeSpan::from(Duration::from_secs(duration));
+
+    Some(format!("{time_span}"))
+}
+
+/// Render as serde_json::Value as a timestamp.
+/// The value is expected to contain the timestamp as a unix epoch.
+///
+/// Will return `None` if `val` does not contain a number.
+fn value_to_timestamp(val: &Value) -> Option<String> {
+    let timestamp = val.as_i64()?;
+    proxmox_time::strftime_local("%F %H:%M:%S", timestamp).ok()
+}
+
+/// Available render functions for `serde_json::Values``
+///
+/// May be used as a handlebars helper, e.g.
+/// ```text
+/// {{human-bytes 1024}}
+/// ```
+///
+/// Value renderer can also be used for rendering values in table columns:
+/// ```text
+/// let properties = json!({
+///     "table": {
+///         "schema": {
+///             "columns": [
+///                 {
+///                     "label": "Size",
+///                     "id": "size",
+///                     "renderer": "human-bytes"
+///                 }
+///             ],
+///         },
+///         "data" : [
+///             {
+///                 "size": 1024 * 1024,
+///             },
+///         ]
+///     }
+/// });
+/// ```
+///
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum ValueRenderFunction {
+    HumanBytes,
+    Duration,
+    Timestamp,
+}
+
+impl ValueRenderFunction {
+    fn render(&self, value: &Value) -> Result<String, HandlebarsRenderError> {
+        match self {
+            ValueRenderFunction::HumanBytes => value_to_byte_size(value),
+            ValueRenderFunction::Duration => value_to_duration(value),
+            ValueRenderFunction::Timestamp => value_to_timestamp(value),
+        }
+        .ok_or_else(|| {
+            HandlebarsRenderError::new(format!(
+                "could not render value {value} with renderer {self:?}"
+            ))
+        })
+    }
+
+    fn register_helpers(handlebars: &mut Handlebars) {
+        ValueRenderFunction::HumanBytes.register_handlebars_helper(handlebars);
+        ValueRenderFunction::Duration.register_handlebars_helper(handlebars);
+        ValueRenderFunction::Timestamp.register_handlebars_helper(handlebars);
+    }
+
+    fn register_handlebars_helper(&'static self, handlebars: &mut Handlebars) {
+        // Use serde to get own kebab-case representation that is later used
+        // to register the helper, e.g. HumanBytes -> human-bytes
+        let tag = serde_json::to_string(self)
+            .expect("serde failed to serialize ValueRenderFunction enum");
+
+        // But as it's a string value, the generated string is quoted,
+        // so remove leading/trailing double quotes
+        let tag = tag
+            .strip_prefix('\"')
+            .and_then(|t| t.strip_suffix('\"'))
+            .expect("serde serialized string representation was not contained in double quotes");
+
+        handlebars.register_helper(
+            tag,
+            Box::new(
+                |h: &Helper,
+                 _r: &Handlebars,
+                 _: &Context,
+                 _rc: &mut RenderContext,
+                 out: &mut dyn Output|
+                 -> HelperResult {
+                    let param = h
+                        .param(0)
+                        .ok_or(HandlebarsRenderError::new("parameter not found"))?;
+
+                    let value = param.value();
+                    out.write(&self.render(value)?)?;
+
+                    Ok(())
+                },
+            ),
+        );
+    }
+}
+
+/// Available renderers for notification templates.
+#[derive(Copy, Clone)]
+pub enum TemplateRenderer {
+    /// Render to HTML code
+    Html,
+    /// Render to plain text
+    Plaintext,
+}
+
+impl TemplateRenderer {
+    fn prefix(&self) -> &str {
+        match self {
+            TemplateRenderer::Html => "<html>\n<body>\n",
+            TemplateRenderer::Plaintext => "",
+        }
+    }
+
+    fn postfix(&self) -> &str {
+        match self {
+            TemplateRenderer::Html => "\n</body>\n</html>",
+            TemplateRenderer::Plaintext => "",
+        }
+    }
+
+    fn block_render_fns(&self) -> BlockRenderFunctions {
+        match self {
+            TemplateRenderer::Html => html::block_render_functions(),
+            TemplateRenderer::Plaintext => plaintext::block_render_functions(),
+        }
+    }
+
+    fn escape_fn(&self) -> fn(&str) -> String {
+        match self {
+            TemplateRenderer::Html => handlebars::html_escape,
+            TemplateRenderer::Plaintext => handlebars::no_escape,
+        }
+    }
+}
+
+type HelperFn = dyn HelperDef + Send + Sync;
+
+struct BlockRenderFunctions {
+    table: Box<HelperFn>,
+    verbatim_monospaced: Box<HelperFn>,
+    object: Box<HelperFn>,
+    heading_1: Box<HelperFn>,
+    heading_2: Box<HelperFn>,
+    verbatim: Box<HelperFn>,
+}
+
+impl BlockRenderFunctions {
+    fn register_helpers(self, handlebars: &mut Handlebars) {
+        handlebars.register_helper("table", self.table);
+        handlebars.register_helper("verbatim", self.verbatim);
+        handlebars.register_helper("verbatim-monospaced", self.verbatim_monospaced);
+        handlebars.register_helper("object", self.object);
+        handlebars.register_helper("heading-1", self.heading_1);
+        handlebars.register_helper("heading-2", self.heading_2);
+    }
+}
+
+fn render_template_impl(
+    template: &str,
+    properties: Option<&Value>,
+    renderer: TemplateRenderer,
+) -> Result<String, Error> {
+    let properties = properties.unwrap_or(&Value::Null);
+
+    let mut handlebars = Handlebars::new();
+    handlebars.register_escape_fn(renderer.escape_fn());
+
+    let block_render_fns = renderer.block_render_fns();
+    block_render_fns.register_helpers(&mut handlebars);
+
+    ValueRenderFunction::register_helpers(&mut handlebars);
+
+    let rendered_template = handlebars
+        .render_template(template, properties)
+        .map_err(|err| Error::RenderError(err.into()))?;
+
+    Ok(rendered_template)
+}
+
+/// Render a template string.
+///
+/// The output format can be chosen via the `renderer` parameter (see [TemplateRenderer]
+/// for available options).
+pub fn render_template(
+    renderer: TemplateRenderer,
+    template: &str,
+    properties: Option<&Value>,
+) -> Result<String, Error> {
+    let mut rendered_template = String::from(renderer.prefix());
+
+    rendered_template.push_str(&render_template_impl(template, properties, renderer)?);
+    rendered_template.push_str(renderer.postfix());
+
+    Ok(rendered_template)
+}
+
+#[macro_export]
+macro_rules! define_helper_with_prefix_and_postfix {
+    ($name:ident, $pre:expr, $post:expr) => {
+        fn $name<'reg, 'rc>(
+            h: &Helper<'reg, 'rc>,
+            handlebars: &'reg Handlebars,
+            context: &'rc Context,
+            render_context: &mut RenderContext<'reg, 'rc>,
+            out: &mut dyn Output,
+        ) -> HelperResult {
+            use handlebars::Renderable;
+
+            let block_text = h.template();
+            let param = h.param(0);
+
+            out.write($pre)?;
+            match (param, block_text) {
+                (None, Some(block_text)) => {
+                    block_text.render(handlebars, context, render_context, out)
+                }
+                (Some(param), None) => {
+                    let value = param.value();
+                    let text = value.as_str().ok_or_else(|| {
+                        HandlebarsRenderError::new(format!("value {value} is not a string"))
+                    })?;
+
+                    out.write(text)?;
+                    Ok(())
+                }
+                (Some(_), Some(_)) => Err(HandlebarsRenderError::new(
+                    "Cannot use parameter and template at the same time",
+                )),
+                (None, None) => Err(HandlebarsRenderError::new(
+                    "Neither parameter nor template was provided",
+                )),
+            }?;
+            out.write($post)?;
+            Ok(())
+        }
+    };
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use serde_json::json;
+
+    #[test]
+    fn test_render_template() -> Result<(), Error> {
+        let properties = json!({
+            "dur": 12345,
+            "size": 1024 * 15,
+
+            "table": {
+                "schema": {
+                    "columns": [
+                        {
+                            "id": "col1",
+                            "label": "Column 1"
+                        },
+                        {
+                            "id": "col2",
+                            "label": "Column 2"
+                        }
+                    ]
+                },
+                "data": [
+                    {
+                        "col1": "val1",
+                        "col2": "val2"
+                    },
+                    {
+                        "col1": "val3",
+                        "col2": "val4"
+                    },
+                ]
+            }
+
+        });
+
+        let template = r#"
+{{heading-1 "Hello World"}}
+
+{{heading-2 "Hello World"}}
+
+{{human-bytes size}}
+{{duration dur}}
+
+{{table table}}"#;
+
+        let expected_plaintext = r#"
+Hello World
+===========
+
+Hello World
+-----------
+
+15 KiB
+3h 25min 45s
+
+Column 1    Column 2    
+val1        val2        
+val3        val4        
+"#;
+
+        let rendered_plaintext =
+            render_template(TemplateRenderer::Plaintext, template, Some(&properties))?;
+
+        // Let's not bother about testing the HTML output, too fragile.
+
+        assert_eq!(rendered_plaintext, expected_plaintext);
+
+        Ok(())
+    }
+}
diff --git a/proxmox-notify/src/renderer/plaintext.rs b/proxmox-notify/src/renderer/plaintext.rs
new file mode 100644
index 00000000..58c51599
--- /dev/null
+++ b/proxmox-notify/src/renderer/plaintext.rs
@@ -0,0 +1,141 @@
+use crate::define_helper_with_prefix_and_postfix;
+use crate::renderer::BlockRenderFunctions;
+use handlebars::{
+    Context, Handlebars, Helper, HelperResult, Output, RenderContext,
+    RenderError as HandlebarsRenderError,
+};
+use serde_json::Value;
+use std::collections::HashMap;
+
+use super::{table::Table, value_to_string};
+
+fn optimal_column_widths(table: &Table) -> HashMap<&str, usize> {
+    let mut widths = HashMap::new();
+
+    for column in &table.schema.columns {
+        let mut min_width = column.label.len();
+
+        for row in &table.data {
+            let entry = row.get(&column.id).unwrap_or(&Value::Null);
+
+            let text = if let Some(renderer) = &column.renderer {
+                renderer.render(entry).unwrap_or_default()
+            } else {
+                value_to_string(entry)
+            };
+
+            min_width = std::cmp::max(text.len(), min_width);
+        }
+
+        widths.insert(column.label.as_str(), min_width + 4);
+    }
+
+    widths
+}
+
+fn render_plaintext_table(
+    h: &Helper,
+    _: &Handlebars,
+    _: &Context,
+    _: &mut RenderContext,
+    out: &mut dyn Output,
+) -> HelperResult {
+    let param = h
+        .param(0)
+        .ok_or_else(|| HandlebarsRenderError::new("parameter not found"))?;
+    let value = param.value();
+    let table: Table = serde_json::from_value(value.clone())?;
+    let widths = optimal_column_widths(&table);
+
+    // Write header
+    for column in &table.schema.columns {
+        let width = widths.get(column.label.as_str()).unwrap_or(&0);
+        out.write(&format!("{label:width$}", label = column.label))?;
+    }
+
+    out.write("\n")?;
+
+    // Write individual rows
+    for row in &table.data {
+        for column in &table.schema.columns {
+            let entry = row.get(&column.id).unwrap_or(&Value::Null);
+            let width = widths.get(column.label.as_str()).unwrap_or(&0);
+
+            let text = if let Some(renderer) = &column.renderer {
+                renderer.render(entry)?
+            } else {
+                value_to_string(entry)
+            };
+
+            out.write(&format!("{text:width$}",))?;
+        }
+        out.write("\n")?;
+    }
+
+    Ok(())
+}
+
+macro_rules! define_underlining_heading_fn {
+    ($name:ident, $underline:expr) => {
+        fn $name<'reg, 'rc>(
+            h: &Helper<'reg, 'rc>,
+            _handlebars: &'reg Handlebars,
+            _context: &'rc Context,
+            _render_context: &mut RenderContext<'reg, 'rc>,
+            out: &mut dyn Output,
+        ) -> HelperResult {
+            let param = h
+                .param(0)
+                .ok_or_else(|| HandlebarsRenderError::new("No parameter provided"))?;
+
+            let value = param.value();
+            let text = value.as_str().ok_or_else(|| {
+                HandlebarsRenderError::new(format!("value {value} is not a string"))
+            })?;
+
+            out.write(text)?;
+            out.write("\n")?;
+
+            for _ in 0..text.len() {
+                out.write($underline)?;
+            }
+            Ok(())
+        }
+    };
+}
+
+define_helper_with_prefix_and_postfix!(verbatim_monospaced, "", "");
+define_underlining_heading_fn!(heading_1, "=");
+define_underlining_heading_fn!(heading_2, "-");
+define_helper_with_prefix_and_postfix!(verbatim, "", "");
+
+fn render_object(
+    h: &Helper,
+    _: &Handlebars,
+    _: &Context,
+    _: &mut RenderContext,
+    out: &mut dyn Output,
+) -> HelperResult {
+    let param = h
+        .param(0)
+        .ok_or_else(|| HandlebarsRenderError::new("parameter not found"))?;
+
+    let value = param.value();
+
+    out.write("\n")?;
+    out.write(&serde_json::to_string_pretty(&value)?)?;
+    out.write("\n")?;
+
+    Ok(())
+}
+
+pub(super) fn block_render_functions() -> BlockRenderFunctions {
+    BlockRenderFunctions {
+        table: Box::new(render_plaintext_table),
+        verbatim_monospaced: Box::new(verbatim_monospaced),
+        verbatim: Box::new(verbatim),
+        object: Box::new(render_object),
+        heading_1: Box::new(heading_1),
+        heading_2: Box::new(heading_2),
+    }
+}
diff --git a/proxmox-notify/src/renderer/table.rs b/proxmox-notify/src/renderer/table.rs
new file mode 100644
index 00000000..74f68482
--- /dev/null
+++ b/proxmox-notify/src/renderer/table.rs
@@ -0,0 +1,24 @@
+use std::collections::HashMap;
+
+use serde::Deserialize;
+use serde_json::Value;
+
+use super::ValueRenderFunction;
+
+#[derive(Debug, Deserialize)]
+pub struct ColumnSchema {
+    pub label: String,
+    pub id: String,
+    pub renderer: Option<ValueRenderFunction>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct TableSchema {
+    pub columns: Vec<ColumnSchema>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct Table {
+    pub schema: TableSchema,
+    pub data: Vec<HashMap<String, Value>>,
+}
-- 
2.39.2






More information about the pve-devel mailing list