From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id 73EFAEF17 for ; Thu, 20 Jul 2023 16:33:34 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 012B914D22 for ; Thu, 20 Jul 2023 16:33:22 +0200 (CEST) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [94.136.29.106]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS for ; Thu, 20 Jul 2023 16:33:16 +0200 (CEST) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 6D4C141ECB for ; Thu, 20 Jul 2023 16:33:15 +0200 (CEST) From: Lukas Wagner To: pve-devel@lists.proxmox.com Date: Thu, 20 Jul 2023 16:31:42 +0200 Message-Id: <20230720143236.652292-16-l.wagner@proxmox.com> X-Mailer: git-send-email 2.39.2 In-Reply-To: <20230720143236.652292-1-l.wagner@proxmox.com> References: <20230720143236.652292-1-l.wagner@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.074 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record T_SCC_BODY_TEXT_LINE -0.01 - Subject: [pve-devel] [PATCH v4 proxmox 15/69] notify: add template rendering X-BeenThere: pve-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox VE development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Thu, 20 Jul 2023 14:33:34 -0000 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}}{{#verbatim}} Do not reflow text. NOP for plain text, but for HTML output the text will be contained in a
 with a regular font.
  - {{verbatim-monospaced val}} or
      {{/verbatim-monospaced}}{{#verbatim-monospaced}}
    Do not reflow text. NOP for plain text, but for HTML output the text
    will be contained in a 
 with a monospaced font.

Signed-off-by: Lukas Wagner 
---
 Cargo.toml                               |   1 +
 proxmox-notify/Cargo.toml                |   6 +-
 proxmox-notify/src/endpoints/gotify.rs   |  40 ++-
 proxmox-notify/src/endpoints/sendmail.rs |  28 +-
 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, 685 insertions(+), 27 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 b54f8adc..6bf4d076 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", "api-types"]}
 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 349eba4c..15fb82cf 100644
--- a/proxmox-notify/src/endpoints/gotify.rs
+++ b/proxmox-notify/src/endpoints/gotify.rs
@@ -1,22 +1,17 @@
 use std::collections::HashMap;
 
+use crate::renderer::TemplateRenderer;
 use crate::schema::ENTITY_NAME_SCHEMA;
-use crate::{Endpoint, Error, Notification, Severity};
+use crate::{renderer, Endpoint, Error, Notification, Severity};
 
 use proxmox_schema::api_types::COMMENT_SCHEMA;
 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,
@@ -94,11 +89,30 @@ impl Endpoint for GotifyEndpoint {
 
         let uri = format!("{}/message", self.config.server);
 
-        let body = GotifyMessageBody {
-            title: ¬ification.title,
-            message: ¬ification.body,
-            priority: severity_to_priority(notification.severity),
-        };
+        let properties = notification.properties.as_ref();
+
+        let title = renderer::render_template(
+            TemplateRenderer::Plaintext,
+            ¬ification.title,
+            properties,
+        )?;
+        let message =
+            renderer::render_template(TemplateRenderer::Plaintext, ¬ification.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 d89ee979..abc262b2 100644
--- a/proxmox-notify/src/endpoints/sendmail.rs
+++ b/proxmox-notify/src/endpoints/sendmail.rs
@@ -1,5 +1,6 @@
-use crate::schema::{EMAIL_SCHEMA, ENTITY_NAME_SCHEMA, USER_SCHEMA};
-use crate::{Endpoint, Error, Notification};
+use crate::renderer::TemplateRenderer;
+use crate::schema::{EMAIL_SCHEMA, ENTITY_NAME_SCHEMA};
+use crate::{renderer, Endpoint, Error, Notification};
 
 use proxmox_schema::api_types::COMMENT_SCHEMA;
 use proxmox_schema::{api, Updater};
@@ -69,12 +70,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!(
-            "
\n{}\n
",
-            handlebars::html_escape(¬ification.body)
-        );
+        let properties = notification.properties.as_ref();
+
+        let subject = renderer::render_template(
+            TemplateRenderer::Plaintext,
+            ¬ification.title,
+            properties,
+        )?;
+        let html_part =
+            renderer::render_template(TemplateRenderer::Html, ¬ification.body, properties)?;
+        let text_part =
+            renderer::render_template(TemplateRenderer::Plaintext, ¬ification.body, properties)?;
 
         // proxmox_sys::email::sendmail will set the author to
         // "Proxmox Backup Server" if it is not set.
@@ -82,9 +88,9 @@ impl Endpoint for SendmailEndpoint {
 
         proxmox_sys::email::sendmail(
             &recipients,
-            ¬ification.title,
-            Some(¬ification.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 f90dc0d9..e254604b 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>),
     FilterFailed(String),
+    RenderError(Box),
 }
 
 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("\n")?;
+
+    // Write header
+    out.write("  \n")?;
+    for column in &table.schema.columns {
+        out.write("    \n")?;
+    }
+    out.write("  \n")?;
+
+    // Write individual rows
+    for row in &table.data {
+        out.write("  \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("    \n")?;
+        }
+        out.write("  \n")?;
+    }
+
+    out.write("
")?; + out.write(&handlebars::html_escape(&column.label))?; + out.write("
")?; + out.write(&handlebars::html_escape(&text))?; + out.write("
\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
")?;
+    out.write(&serde_json::to_string_pretty(&value)?)?;
+    out.write("\n
\n")?; + + Ok(()) +} + +define_helper_with_prefix_and_postfix!(verbatim_monospaced, "
", "
"); +define_helper_with_prefix_and_postfix!(heading_1, "

", "

"); +define_helper_with_prefix_and_postfix!(heading_2, "

", "

"); +define_helper_with_prefix_and_postfix!( + verbatim, + "
",
+    "
" +); + +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 { + 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 { + 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 { + 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 { + 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 => "\n\n", + TemplateRenderer::Plaintext => "", + } + } + + fn postfix(&self) -> &str { + match self { + TemplateRenderer::Html => "\n\n", + 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, + verbatim_monospaced: Box, + object: Box, + heading_1: Box, + heading_2: Box, + verbatim: Box, +} + +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 { + 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 { + 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, +} + +#[derive(Debug, Deserialize)] +pub struct TableSchema { + pub columns: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct Table { + pub schema: TableSchema, + pub data: Vec>, +} -- 2.39.2