forked from proxmox-mirrors/proxmox
notify: switch to file-based templating system
Instead of passing the template strings for subject and body when constructing a notification, we pass only the name of a template. When rendering the template, the name of the template is used to find corresponding template files. For PVE, they are located at /usr/share/proxmox-ve/templates/default. The `default` part is the 'template namespace', which is a preparation for user-customizable and/or translatable notifications. Previously, the same template string was used to render HTML and plaintext notifications. This was achieved by providing some template helpers that 'abstract away' HTML/plaintext formatting. However, in hindsight this turned out to be pretty finicky. Since the current changes lay the foundations for user-customizable notification templates, I ripped these abstractions out. Now there are simply two templates, one for plaintext, one for HTML. Signed-off-by: Lukas Wagner <l.wagner@proxmox.com> Tested-by: Folke Gleumes <f.gleumes@proxmox.com> Reviewed-by: Fiona Ebner <f.ebner@proxmox.com>
This commit is contained in:
parent
42fb9ed26b
commit
1516cc26d2
@ -1,63 +0,0 @@
|
|||||||
use proxmox_notify::renderer::{render_template, TemplateRenderer};
|
|
||||||
use proxmox_notify::Error;
|
|
||||||
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
const TEMPLATE: &str = r#"
|
|
||||||
{{ heading-1 "Backup Report"}}
|
|
||||||
A backup job on host {{host}} was run.
|
|
||||||
|
|
||||||
{{ heading-2 "Guests"}}
|
|
||||||
{{ table table }}
|
|
||||||
The total size of all backups is {{human-bytes total-size}}.
|
|
||||||
|
|
||||||
The backup job took {{duration total-time}}.
|
|
||||||
|
|
||||||
{{ heading-2 "Logs"}}
|
|
||||||
{{ verbatim-monospaced logs}}
|
|
||||||
|
|
||||||
{{ heading-2 "Objects"}}
|
|
||||||
{{ object table }}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
fn main() -> Result<(), Error> {
|
|
||||||
let properties = json!({
|
|
||||||
"host": "pali",
|
|
||||||
"logs": "100: starting backup\n100: backup failed",
|
|
||||||
"total-size": 1024 * 1024 + 2048 * 1024,
|
|
||||||
"total-time": 100,
|
|
||||||
"table": {
|
|
||||||
"schema": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"label": "VMID",
|
|
||||||
"id": "vmid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Size",
|
|
||||||
"id": "size",
|
|
||||||
"renderer": "human-bytes"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"data" : [
|
|
||||||
{
|
|
||||||
"vmid": 1001,
|
|
||||||
"size": "1048576"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vmid": 1002,
|
|
||||||
"size": 2048 * 1024,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let output = render_template(TemplateRenderer::Html, TEMPLATE, &properties)?;
|
|
||||||
println!("{output}");
|
|
||||||
|
|
||||||
let output = render_template(TemplateRenderer::Plaintext, TEMPLATE, &properties)?;
|
|
||||||
println!("{output}");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
@ -1,6 +1,8 @@
|
|||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::Error;
|
||||||
|
|
||||||
#[cfg(any(feature = "pve-context", feature = "pbs-context"))]
|
#[cfg(any(feature = "pve-context", feature = "pbs-context"))]
|
||||||
pub mod common;
|
pub mod common;
|
||||||
#[cfg(feature = "pbs-context")]
|
#[cfg(feature = "pbs-context")]
|
||||||
@ -20,8 +22,14 @@ pub trait Context: Send + Sync + Debug {
|
|||||||
fn default_sendmail_from(&self) -> String;
|
fn default_sendmail_from(&self) -> String;
|
||||||
/// Proxy configuration for the current node
|
/// Proxy configuration for the current node
|
||||||
fn http_proxy_config(&self) -> Option<String>;
|
fn http_proxy_config(&self) -> Option<String>;
|
||||||
// Return default config for built-in targets/matchers.
|
/// Return default config for built-in targets/matchers.
|
||||||
fn default_config(&self) -> &'static str;
|
fn default_config(&self) -> &'static str;
|
||||||
|
/// Lookup a template in a certain (optional) namespace
|
||||||
|
fn lookup_template(
|
||||||
|
&self,
|
||||||
|
filename: &str,
|
||||||
|
namespace: Option<&str>,
|
||||||
|
) -> Result<Option<String>, Error>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
use proxmox_schema::{ObjectSchema, Schema, StringSchema};
|
use proxmox_schema::{ObjectSchema, Schema, StringSchema};
|
||||||
use proxmox_section_config::{SectionConfig, SectionConfigPlugin};
|
use proxmox_section_config::{SectionConfig, SectionConfigPlugin};
|
||||||
|
|
||||||
use crate::context::{common, Context};
|
use crate::context::{common, Context};
|
||||||
|
use crate::Error;
|
||||||
|
|
||||||
const PBS_USER_CFG_FILENAME: &str = "/etc/proxmox-backup/user.cfg";
|
const PBS_USER_CFG_FILENAME: &str = "/etc/proxmox-backup/user.cfg";
|
||||||
const PBS_NODE_CFG_FILENAME: &str = "/etc/proxmox-backup/node.cfg";
|
const PBS_NODE_CFG_FILENAME: &str = "/etc/proxmox-backup/node.cfg";
|
||||||
@ -98,6 +100,20 @@ impl Context for PBSContext {
|
|||||||
fn default_config(&self) -> &'static str {
|
fn default_config(&self) -> &'static str {
|
||||||
return DEFAULT_CONFIG;
|
return DEFAULT_CONFIG;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn lookup_template(
|
||||||
|
&self,
|
||||||
|
filename: &str,
|
||||||
|
namespace: Option<&str>,
|
||||||
|
) -> Result<Option<String>, Error> {
|
||||||
|
let path = Path::new("/usr/share/proxmox-backup/templates")
|
||||||
|
.join(namespace.unwrap_or("default"))
|
||||||
|
.join(filename);
|
||||||
|
|
||||||
|
let template_string = proxmox_sys::fs::file_read_optional_string(path)
|
||||||
|
.map_err(|err| Error::Generic(format!("could not load template: {err}")))?;
|
||||||
|
Ok(template_string)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
use crate::context::{common, Context};
|
use crate::context::{common, Context};
|
||||||
|
use crate::Error;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
fn lookup_mail_address(content: &str, user: &str) -> Option<String> {
|
fn lookup_mail_address(content: &str, user: &str) -> Option<String> {
|
||||||
common::normalize_for_return(content.lines().find_map(|line| {
|
common::normalize_for_return(content.lines().find_map(|line| {
|
||||||
@ -51,6 +53,19 @@ impl Context for PVEContext {
|
|||||||
fn default_config(&self) -> &'static str {
|
fn default_config(&self) -> &'static str {
|
||||||
return DEFAULT_CONFIG;
|
return DEFAULT_CONFIG;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn lookup_template(
|
||||||
|
&self,
|
||||||
|
filename: &str,
|
||||||
|
namespace: Option<&str>,
|
||||||
|
) -> Result<Option<String>, Error> {
|
||||||
|
let path = Path::new("/usr/share/pve-manager/templates")
|
||||||
|
.join(namespace.unwrap_or("default"))
|
||||||
|
.join(filename);
|
||||||
|
let template_string = proxmox_sys::fs::file_read_optional_string(path)
|
||||||
|
.map_err(|err| Error::Generic(format!("could not load template: {err}")))?;
|
||||||
|
Ok(template_string)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub static PVE_CONTEXT: PVEContext = PVEContext;
|
pub static PVE_CONTEXT: PVEContext = PVEContext;
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
use crate::context::Context;
|
use crate::context::Context;
|
||||||
|
use crate::Error;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct TestContext;
|
pub struct TestContext;
|
||||||
@ -23,4 +24,12 @@ impl Context for TestContext {
|
|||||||
fn default_config(&self) -> &'static str {
|
fn default_config(&self) -> &'static str {
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn lookup_template(
|
||||||
|
&self,
|
||||||
|
_filename: &str,
|
||||||
|
_namespace: Option<&str>,
|
||||||
|
) -> Result<Option<String>, Error> {
|
||||||
|
Ok(Some(String::new()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ use proxmox_schema::api_types::COMMENT_SCHEMA;
|
|||||||
use proxmox_schema::{api, Updater};
|
use proxmox_schema::{api, Updater};
|
||||||
|
|
||||||
use crate::context::context;
|
use crate::context::context;
|
||||||
use crate::renderer::TemplateRenderer;
|
use crate::renderer::TemplateType;
|
||||||
use crate::schema::ENTITY_NAME_SCHEMA;
|
use crate::schema::ENTITY_NAME_SCHEMA;
|
||||||
use crate::{renderer, Content, Endpoint, Error, Notification, Origin, Severity};
|
use crate::{renderer, Content, Endpoint, Error, Notification, Origin, Severity};
|
||||||
|
|
||||||
@ -92,14 +92,13 @@ impl Endpoint for GotifyEndpoint {
|
|||||||
fn send(&self, notification: &Notification) -> Result<(), Error> {
|
fn send(&self, notification: &Notification) -> Result<(), Error> {
|
||||||
let (title, message) = match ¬ification.content {
|
let (title, message) = match ¬ification.content {
|
||||||
Content::Template {
|
Content::Template {
|
||||||
title_template,
|
template_name,
|
||||||
body_template,
|
|
||||||
data,
|
data,
|
||||||
} => {
|
} => {
|
||||||
let rendered_title =
|
let rendered_title =
|
||||||
renderer::render_template(TemplateRenderer::Plaintext, title_template, data)?;
|
renderer::render_template(TemplateType::Subject, template_name, data)?;
|
||||||
let rendered_message =
|
let rendered_message =
|
||||||
renderer::render_template(TemplateRenderer::Plaintext, body_template, data)?;
|
renderer::render_template(TemplateType::PlaintextBody, template_name, data)?;
|
||||||
|
|
||||||
(rendered_title, rendered_message)
|
(rendered_title, rendered_message)
|
||||||
}
|
}
|
||||||
|
@ -3,9 +3,9 @@ use serde::{Deserialize, Serialize};
|
|||||||
use proxmox_schema::api_types::COMMENT_SCHEMA;
|
use proxmox_schema::api_types::COMMENT_SCHEMA;
|
||||||
use proxmox_schema::{api, Updater};
|
use proxmox_schema::{api, Updater};
|
||||||
|
|
||||||
use crate::context::context;
|
use crate::context;
|
||||||
use crate::endpoints::common::mail;
|
use crate::endpoints::common::mail;
|
||||||
use crate::renderer::TemplateRenderer;
|
use crate::renderer::TemplateType;
|
||||||
use crate::schema::{EMAIL_SCHEMA, ENTITY_NAME_SCHEMA, USER_SCHEMA};
|
use crate::schema::{EMAIL_SCHEMA, ENTITY_NAME_SCHEMA, USER_SCHEMA};
|
||||||
use crate::{renderer, Content, Endpoint, Error, Notification, Origin};
|
use crate::{renderer, Content, Endpoint, Error, Notification, Origin};
|
||||||
|
|
||||||
@ -103,16 +103,15 @@ impl Endpoint for SendmailEndpoint {
|
|||||||
|
|
||||||
match ¬ification.content {
|
match ¬ification.content {
|
||||||
Content::Template {
|
Content::Template {
|
||||||
title_template,
|
template_name,
|
||||||
body_template,
|
|
||||||
data,
|
data,
|
||||||
} => {
|
} => {
|
||||||
let subject =
|
let subject =
|
||||||
renderer::render_template(TemplateRenderer::Plaintext, title_template, data)?;
|
renderer::render_template(TemplateType::Subject, template_name, data)?;
|
||||||
let html_part =
|
let html_part =
|
||||||
renderer::render_template(TemplateRenderer::Html, body_template, data)?;
|
renderer::render_template(TemplateType::HtmlBody, template_name, data)?;
|
||||||
let text_part =
|
let text_part =
|
||||||
renderer::render_template(TemplateRenderer::Plaintext, body_template, data)?;
|
renderer::render_template(TemplateType::PlaintextBody, template_name, data)?;
|
||||||
|
|
||||||
let author = self
|
let author = self
|
||||||
.config
|
.config
|
||||||
|
@ -11,7 +11,7 @@ use proxmox_schema::{api, Updater};
|
|||||||
|
|
||||||
use crate::context::context;
|
use crate::context::context;
|
||||||
use crate::endpoints::common::mail;
|
use crate::endpoints::common::mail;
|
||||||
use crate::renderer::TemplateRenderer;
|
use crate::renderer::TemplateType;
|
||||||
use crate::schema::{EMAIL_SCHEMA, ENTITY_NAME_SCHEMA, USER_SCHEMA};
|
use crate::schema::{EMAIL_SCHEMA, ENTITY_NAME_SCHEMA, USER_SCHEMA};
|
||||||
use crate::{renderer, Content, Endpoint, Error, Notification, Origin};
|
use crate::{renderer, Content, Endpoint, Error, Notification, Origin};
|
||||||
|
|
||||||
@ -202,16 +202,15 @@ impl Endpoint for SmtpEndpoint {
|
|||||||
|
|
||||||
let mut email = match ¬ification.content {
|
let mut email = match ¬ification.content {
|
||||||
Content::Template {
|
Content::Template {
|
||||||
title_template,
|
template_name,
|
||||||
body_template,
|
|
||||||
data,
|
data,
|
||||||
} => {
|
} => {
|
||||||
let subject =
|
let subject =
|
||||||
renderer::render_template(TemplateRenderer::Plaintext, title_template, data)?;
|
renderer::render_template(TemplateType::Subject, template_name, data)?;
|
||||||
let html_part =
|
let html_part =
|
||||||
renderer::render_template(TemplateRenderer::Html, body_template, data)?;
|
renderer::render_template(TemplateType::HtmlBody, template_name, data)?;
|
||||||
let text_part =
|
let text_part =
|
||||||
renderer::render_template(TemplateRenderer::Plaintext, body_template, data)?;
|
renderer::render_template(TemplateType::PlaintextBody, template_name, data)?;
|
||||||
|
|
||||||
email_builder = email_builder.subject(subject);
|
email_builder = email_builder.subject(subject);
|
||||||
|
|
||||||
|
@ -162,10 +162,8 @@ pub trait Endpoint {
|
|||||||
pub enum Content {
|
pub enum Content {
|
||||||
/// Title and body will be rendered as a template
|
/// Title and body will be rendered as a template
|
||||||
Template {
|
Template {
|
||||||
/// Template for the notification title.
|
/// Name of the used template
|
||||||
title_template: String,
|
template_name: String,
|
||||||
/// Template for the notification body.
|
|
||||||
body_template: String,
|
|
||||||
/// Data that can be used for template rendering.
|
/// Data that can be used for template rendering.
|
||||||
data: Value,
|
data: Value,
|
||||||
},
|
},
|
||||||
@ -203,10 +201,9 @@ pub struct Notification {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Notification {
|
impl Notification {
|
||||||
pub fn new_templated<S: AsRef<str>>(
|
pub fn from_template<S: AsRef<str>>(
|
||||||
severity: Severity,
|
severity: Severity,
|
||||||
title: S,
|
template_name: S,
|
||||||
body: S,
|
|
||||||
template_data: Value,
|
template_data: Value,
|
||||||
fields: HashMap<String, String>,
|
fields: HashMap<String, String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
@ -217,8 +214,7 @@ impl Notification {
|
|||||||
timestamp: proxmox_time::epoch_i64(),
|
timestamp: proxmox_time::epoch_i64(),
|
||||||
},
|
},
|
||||||
content: Content::Template {
|
content: Content::Template {
|
||||||
title_template: title.as_ref().to_string(),
|
template_name: template_name.as_ref().to_string(),
|
||||||
body_template: body.as_ref().to_string(),
|
|
||||||
data: template_data,
|
data: template_data,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -549,8 +545,7 @@ impl Bus {
|
|||||||
timestamp: proxmox_time::epoch_i64(),
|
timestamp: proxmox_time::epoch_i64(),
|
||||||
},
|
},
|
||||||
content: Content::Template {
|
content: Content::Template {
|
||||||
title_template: "Test notification".into(),
|
template_name: "test".to_string(),
|
||||||
body_template: "This is a test of the notification target '{{ target }}'".into(),
|
|
||||||
data: json!({ "target": target }),
|
data: json!({ "target": target }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -623,10 +618,9 @@ mod tests {
|
|||||||
bus.add_matcher(matcher);
|
bus.add_matcher(matcher);
|
||||||
|
|
||||||
// Send directly to endpoint
|
// Send directly to endpoint
|
||||||
bus.send(&Notification::new_templated(
|
bus.send(&Notification::from_template(
|
||||||
Severity::Info,
|
Severity::Info,
|
||||||
"Title",
|
"test",
|
||||||
"Body",
|
|
||||||
Default::default(),
|
Default::default(),
|
||||||
Default::default(),
|
Default::default(),
|
||||||
));
|
));
|
||||||
@ -661,10 +655,9 @@ mod tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let send_with_severity = |severity| {
|
let send_with_severity = |severity| {
|
||||||
let notification = Notification::new_templated(
|
let notification = Notification::from_template(
|
||||||
severity,
|
severity,
|
||||||
"Title",
|
"test",
|
||||||
"Body",
|
|
||||||
Default::default(),
|
Default::default(),
|
||||||
Default::default(),
|
Default::default(),
|
||||||
);
|
);
|
||||||
|
@ -456,7 +456,7 @@ mod tests {
|
|||||||
fields.insert("foo".into(), "bar".into());
|
fields.insert("foo".into(), "bar".into());
|
||||||
|
|
||||||
let notification =
|
let notification =
|
||||||
Notification::new_templated(Severity::Notice, "test", "test", Value::Null, fields);
|
Notification::from_template(Severity::Notice, "test", Value::Null, fields);
|
||||||
|
|
||||||
let matcher: FieldMatcher = "exact:foo=bar".parse().unwrap();
|
let matcher: FieldMatcher = "exact:foo=bar".parse().unwrap();
|
||||||
assert!(matcher.matches(¬ification).unwrap());
|
assert!(matcher.matches(¬ification).unwrap());
|
||||||
@ -474,14 +474,14 @@ mod tests {
|
|||||||
fields.insert("foo".into(), "test".into());
|
fields.insert("foo".into(), "test".into());
|
||||||
|
|
||||||
let notification =
|
let notification =
|
||||||
Notification::new_templated(Severity::Notice, "test", "test", Value::Null, fields);
|
Notification::from_template(Severity::Notice, "test", Value::Null, fields);
|
||||||
assert!(matcher.matches(¬ification).unwrap());
|
assert!(matcher.matches(¬ification).unwrap());
|
||||||
|
|
||||||
let mut fields = HashMap::new();
|
let mut fields = HashMap::new();
|
||||||
fields.insert("foo".into(), "notthere".into());
|
fields.insert("foo".into(), "notthere".into());
|
||||||
|
|
||||||
let notification =
|
let notification =
|
||||||
Notification::new_templated(Severity::Notice, "test", "test", Value::Null, fields);
|
Notification::from_template(Severity::Notice, "test", Value::Null, fields);
|
||||||
assert!(!matcher.matches(¬ification).unwrap());
|
assert!(!matcher.matches(¬ification).unwrap());
|
||||||
|
|
||||||
assert!("regex:'3=b.*".parse::<FieldMatcher>().is_err());
|
assert!("regex:'3=b.*".parse::<FieldMatcher>().is_err());
|
||||||
@ -489,13 +489,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn test_severities() {
|
fn test_severities() {
|
||||||
let notification = Notification::new_templated(
|
let notification =
|
||||||
Severity::Notice,
|
Notification::from_template(Severity::Notice, "test", Value::Null, Default::default());
|
||||||
"test",
|
|
||||||
"test",
|
|
||||||
Value::Null,
|
|
||||||
Default::default(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let matcher: SeverityMatcher = "info,notice,warning,error".parse().unwrap();
|
let matcher: SeverityMatcher = "info,notice,warning,error".parse().unwrap();
|
||||||
assert!(matcher.matches(¬ification).unwrap());
|
assert!(matcher.matches(¬ification).unwrap());
|
||||||
@ -503,13 +498,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_empty_matcher_matches_always() {
|
fn test_empty_matcher_matches_always() {
|
||||||
let notification = Notification::new_templated(
|
let notification =
|
||||||
Severity::Notice,
|
Notification::from_template(Severity::Notice, "test", Value::Null, Default::default());
|
||||||
"test",
|
|
||||||
"test",
|
|
||||||
Value::Null,
|
|
||||||
Default::default(),
|
|
||||||
);
|
|
||||||
|
|
||||||
for mode in [MatchModeOperator::All, MatchModeOperator::Any] {
|
for mode in [MatchModeOperator::All, MatchModeOperator::Any] {
|
||||||
let config = MatcherConfig {
|
let config = MatcherConfig {
|
||||||
|
@ -5,7 +5,6 @@ use handlebars::{
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use super::{table::Table, value_to_string};
|
use super::{table::Table, value_to_string};
|
||||||
use crate::define_helper_with_prefix_and_postfix;
|
|
||||||
use crate::renderer::BlockRenderFunctions;
|
use crate::renderer::BlockRenderFunctions;
|
||||||
|
|
||||||
fn render_html_table(
|
fn render_html_table(
|
||||||
@ -79,22 +78,9 @@ fn render_object(
|
|||||||
Ok(())
|
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 {
|
pub(super) fn block_render_functions() -> BlockRenderFunctions {
|
||||||
BlockRenderFunctions {
|
BlockRenderFunctions {
|
||||||
table: Box::new(render_html_table),
|
table: Box::new(render_html_table),
|
||||||
verbatim_monospaced: Box::new(verbatim_monospaced),
|
|
||||||
object: Box::new(render_object),
|
object: Box::new(render_object),
|
||||||
heading_1: Box::new(heading_1),
|
|
||||||
heading_2: Box::new(heading_2),
|
|
||||||
verbatim: Box::new(verbatim),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,7 @@ use serde_json::Value;
|
|||||||
use proxmox_human_byte::HumanByte;
|
use proxmox_human_byte::HumanByte;
|
||||||
use proxmox_time::TimeSpan;
|
use proxmox_time::TimeSpan;
|
||||||
|
|
||||||
use crate::Error;
|
use crate::{context, Error};
|
||||||
|
|
||||||
mod html;
|
mod html;
|
||||||
mod plaintext;
|
mod plaintext;
|
||||||
@ -165,41 +165,47 @@ impl ValueRenderFunction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Available renderers for notification templates.
|
/// Available template types
|
||||||
#[derive(Copy, Clone)]
|
#[derive(Copy, Clone)]
|
||||||
pub enum TemplateRenderer {
|
pub enum TemplateType {
|
||||||
/// Render to HTML code
|
/// HTML body template
|
||||||
Html,
|
HtmlBody,
|
||||||
/// Render to plain text
|
/// Plaintext body template
|
||||||
Plaintext,
|
PlaintextBody,
|
||||||
|
/// Plaintext body template
|
||||||
|
Subject,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TemplateRenderer {
|
impl TemplateType {
|
||||||
fn prefix(&self) -> &str {
|
fn file_suffix(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
TemplateRenderer::Html => "<html>\n<body>\n",
|
TemplateType::HtmlBody => "body.html.hbs",
|
||||||
TemplateRenderer::Plaintext => "",
|
TemplateType::PlaintextBody => "body.txt.hbs",
|
||||||
|
TemplateType::Subject => "subject.txt.hbs",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn postfix(&self) -> &str {
|
fn postprocess(&self, mut rendered: String) -> String {
|
||||||
match self {
|
if let Self::Subject = self {
|
||||||
TemplateRenderer::Html => "\n</body>\n</html>",
|
rendered = rendered.replace('\n', " ");
|
||||||
TemplateRenderer::Plaintext => "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rendered
|
||||||
}
|
}
|
||||||
|
|
||||||
fn block_render_fns(&self) -> BlockRenderFunctions {
|
fn block_render_fns(&self) -> BlockRenderFunctions {
|
||||||
match self {
|
match self {
|
||||||
TemplateRenderer::Html => html::block_render_functions(),
|
TemplateType::HtmlBody => html::block_render_functions(),
|
||||||
TemplateRenderer::Plaintext => plaintext::block_render_functions(),
|
TemplateType::Subject => plaintext::block_render_functions(),
|
||||||
|
TemplateType::PlaintextBody => plaintext::block_render_functions(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn escape_fn(&self) -> fn(&str) -> String {
|
fn escape_fn(&self) -> fn(&str) -> String {
|
||||||
match self {
|
match self {
|
||||||
TemplateRenderer::Html => handlebars::html_escape,
|
TemplateType::PlaintextBody => handlebars::no_escape,
|
||||||
TemplateRenderer::Plaintext => handlebars::no_escape,
|
TemplateType::Subject => handlebars::no_escape,
|
||||||
|
TemplateType::HtmlBody => handlebars::html_escape,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -208,28 +214,20 @@ type HelperFn = dyn HelperDef + Send + Sync;
|
|||||||
|
|
||||||
struct BlockRenderFunctions {
|
struct BlockRenderFunctions {
|
||||||
table: Box<HelperFn>,
|
table: Box<HelperFn>,
|
||||||
verbatim_monospaced: Box<HelperFn>,
|
|
||||||
object: Box<HelperFn>,
|
object: Box<HelperFn>,
|
||||||
heading_1: Box<HelperFn>,
|
|
||||||
heading_2: Box<HelperFn>,
|
|
||||||
verbatim: Box<HelperFn>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BlockRenderFunctions {
|
impl BlockRenderFunctions {
|
||||||
fn register_helpers(self, handlebars: &mut Handlebars) {
|
fn register_helpers(self, handlebars: &mut Handlebars) {
|
||||||
handlebars.register_helper("table", self.table);
|
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("object", self.object);
|
||||||
handlebars.register_helper("heading-1", self.heading_1);
|
|
||||||
handlebars.register_helper("heading-2", self.heading_2);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_template_impl(
|
fn render_template_impl(
|
||||||
template: &str,
|
template: &str,
|
||||||
data: &Value,
|
data: &Value,
|
||||||
renderer: TemplateRenderer,
|
renderer: TemplateType,
|
||||||
) -> Result<String, Error> {
|
) -> Result<String, Error> {
|
||||||
let mut handlebars = Handlebars::new();
|
let mut handlebars = Handlebars::new();
|
||||||
handlebars.register_escape_fn(renderer.escape_fn());
|
handlebars.register_escape_fn(renderer.escape_fn());
|
||||||
@ -248,61 +246,45 @@ fn render_template_impl(
|
|||||||
|
|
||||||
/// Render a template string.
|
/// Render a template string.
|
||||||
///
|
///
|
||||||
/// The output format can be chosen via the `renderer` parameter (see [TemplateRenderer]
|
/// The output format can be chosen via the `renderer` parameter (see [TemplateType]
|
||||||
/// for available options).
|
/// for available options).
|
||||||
pub fn render_template(
|
pub fn render_template(
|
||||||
renderer: TemplateRenderer,
|
mut ty: TemplateType,
|
||||||
template: &str,
|
template: &str,
|
||||||
data: &Value,
|
data: &Value,
|
||||||
) -> Result<String, Error> {
|
) -> Result<String, Error> {
|
||||||
let mut rendered_template = String::from(renderer.prefix());
|
let filename = format!("{template}-{suffix}", suffix = ty.file_suffix());
|
||||||
|
|
||||||
rendered_template.push_str(&render_template_impl(template, data, renderer)?);
|
let template_string = context::context().lookup_template(&filename, None)?;
|
||||||
rendered_template.push_str(renderer.postfix());
|
|
||||||
|
|
||||||
Ok(rendered_template)
|
let (template_string, fallback) = match (template_string, ty) {
|
||||||
}
|
(None, TemplateType::HtmlBody) => {
|
||||||
|
ty = TemplateType::PlaintextBody;
|
||||||
#[macro_export]
|
let plaintext_filename = format!("{template}-{suffix}", suffix = ty.file_suffix());
|
||||||
macro_rules! define_helper_with_prefix_and_postfix {
|
log::info!("html template '{filename}' not found, falling back to plain text template '{plaintext_filename}'");
|
||||||
($name:ident, $pre:expr, $post:expr) => {
|
(
|
||||||
fn $name<'reg, 'rc>(
|
context::context().lookup_template(&plaintext_filename, None)?,
|
||||||
h: &Helper<'reg, 'rc>,
|
true,
|
||||||
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(())
|
|
||||||
}
|
}
|
||||||
|
(template_string, _) => (template_string, false),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let template_string = template_string.ok_or(Error::Generic(format!(
|
||||||
|
"could not load template '{template}'"
|
||||||
|
)))?;
|
||||||
|
|
||||||
|
let mut rendered = render_template_impl(&template_string, data, ty)?;
|
||||||
|
rendered = ty.postprocess(rendered);
|
||||||
|
|
||||||
|
if fallback {
|
||||||
|
rendered = format!(
|
||||||
|
"<html><body><pre>{}</pre></body></html>",
|
||||||
|
handlebars::html_escape(&rendered)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(rendered)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@ -310,73 +292,6 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_render_template() -> Result<(), Error> {
|
|
||||||
let data = 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, &data)?;
|
|
||||||
|
|
||||||
// Let's not bother about testing the HTML output, too fragile.
|
|
||||||
|
|
||||||
assert_eq!(rendered_plaintext, expected_plaintext);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_helpers() {
|
fn test_helpers() {
|
||||||
assert_eq!(value_to_byte_size(&json!(1024)), Some("1 KiB".to_string()));
|
assert_eq!(value_to_byte_size(&json!(1024)), Some("1 KiB".to_string()));
|
||||||
|
@ -7,7 +7,6 @@ use handlebars::{
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use super::{table::Table, value_to_string};
|
use super::{table::Table, value_to_string};
|
||||||
use crate::define_helper_with_prefix_and_postfix;
|
|
||||||
use crate::renderer::BlockRenderFunctions;
|
use crate::renderer::BlockRenderFunctions;
|
||||||
|
|
||||||
fn optimal_column_widths(table: &Table) -> HashMap<&str, usize> {
|
fn optimal_column_widths(table: &Table) -> HashMap<&str, usize> {
|
||||||
@ -76,40 +75,6 @@ fn render_plaintext_table(
|
|||||||
Ok(())
|
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(
|
fn render_object(
|
||||||
h: &Helper,
|
h: &Helper,
|
||||||
_: &Handlebars,
|
_: &Handlebars,
|
||||||
@ -133,10 +98,6 @@ fn render_object(
|
|||||||
pub(super) fn block_render_functions() -> BlockRenderFunctions {
|
pub(super) fn block_render_functions() -> BlockRenderFunctions {
|
||||||
BlockRenderFunctions {
|
BlockRenderFunctions {
|
||||||
table: Box::new(render_plaintext_table),
|
table: Box::new(render_plaintext_table),
|
||||||
verbatim_monospaced: Box::new(verbatim_monospaced),
|
|
||||||
verbatim: Box::new(verbatim),
|
|
||||||
object: Box::new(render_object),
|
object: Box::new(render_object),
|
||||||
heading_1: Box::new(heading_1),
|
|
||||||
heading_2: Box::new(heading_2),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user