From a042abbf5943f9d5ec2c57cd708375f0f9b86e6a Mon Sep 17 00:00:00 2001 From: simonsan <14062932+simonsan@users.noreply.github.com> Date: Sun, 24 Mar 2024 14:29:28 +0100 Subject: [PATCH] feat(template): implement html and markdown templating for reflections (#105) * feat(template): implement html templating for reflections Signed-off-by: simonsan <14062932+simonsan@users.noreply.github.com> * use tera one_off for rendering template from user Signed-off-by: simonsan <14062932+simonsan@users.noreply.github.com> * convert ReflectionSummary to PaceReflection to wrap tera::Context Signed-off-by: simonsan <14062932+simonsan@users.noreply.github.com> * feat(time): make duration human readable for templating Signed-off-by: simonsan <14062932+simonsan@users.noreply.github.com> * more templating Signed-off-by: simonsan <14062932+simonsan@users.noreply.github.com> * feat(templating): add first templates for html and markdown Signed-off-by: simonsan <14062932+simonsan@users.noreply.github.com> * fix config Signed-off-by: simonsan <14062932+simonsan@users.noreply.github.com> * fix config Signed-off-by: simonsan <14062932+simonsan@users.noreply.github.com> * replace lazy_static with once_cell Signed-off-by: simonsan <14062932+simonsan@users.noreply.github.com> --------- Signed-off-by: simonsan <14062932+simonsan@users.noreply.github.com> --- .dprint.json | 3 +- Cargo.lock | 170 ++++++++++++++++++ Cargo.toml | 14 +- config/pace.toml | 4 +- crates/core/Cargo.toml | 2 + crates/core/src/commands/reflect.rs | 65 +++++-- crates/core/src/domain/reflection.rs | 8 +- crates/core/src/error.rs | 48 +++-- crates/core/src/lib.rs | 1 + crates/core/src/template.rs | 93 ++++++++++ crates/time/src/duration.rs | 7 +- templates/README.md | 22 +++ ...pace_report.html => pace_report_json.html} | 0 templates/reflections/basic.html | 54 ++++++ templates/reflections/basic.md | 21 +++ tests/fixtures/configs/pace.toml | 4 +- 16 files changed, 470 insertions(+), 46 deletions(-) create mode 100644 crates/core/src/template.rs create mode 100644 templates/README.md rename templates/{pace_report.html => pace_report_json.html} (100%) create mode 100644 templates/reflections/basic.html create mode 100644 templates/reflections/basic.md diff --git a/.dprint.json b/.dprint.json index 838aac2c..44c9a7b6 100644 --- a/.dprint.json +++ b/.dprint.json @@ -20,7 +20,8 @@ ], "excludes": [ "target/**/*", - "CHANGELOG.md" + "CHANGELOG.md", + "templates/reflections/**.md" ], "plugins": [ "https://plugins.dprint.dev/markdown-0.16.4.wasm", diff --git a/Cargo.lock b/Cargo.lock index bef86063..03bf8665 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -442,6 +442,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "deunicode" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6e854126756c496b8c81dec88f9a706b15b875c5849d4097a3854476b9fdf94" + [[package]] name = "dialoguer" version = "0.11.0" @@ -780,6 +786,30 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "globset" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1" +dependencies = [ + "aho-corasick", + "bstr 1.9.1", + "log", + "regex-automata 0.4.6", + "regex-syntax 0.8.2", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + [[package]] name = "hashbrown" version = "0.14.3" @@ -814,6 +844,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + [[package]] name = "humantime" version = "2.1.0" @@ -849,6 +888,22 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "ignore" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata 0.4.6", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indenter" version = "0.3.3" @@ -954,6 +1009,12 @@ version = "0.2.153" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +[[package]] +name = "libm" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" + [[package]] name = "libredox" version = "0.0.1" @@ -1243,6 +1304,7 @@ dependencies = [ "libsqlite3-sys", "merge", "miette", + "once_cell", "open", "pace_testing", "pace_time", @@ -1257,6 +1319,7 @@ dependencies = [ "strum", "strum_macros", "tabled", + "tera", "thiserror", "toml 0.8.12", "tracing", @@ -1352,6 +1415,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + [[package]] name = "pest" version = "2.7.8" @@ -1736,6 +1805,15 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1874,6 +1952,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "slug" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bd94acec9c8da640005f8e135a39fc0372e74535e6b368b7a04b875f784c8c4" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + [[package]] name = "smallvec" version = "1.13.2" @@ -2005,6 +2093,28 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "tera" +version = "1.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "970dff17c11e884a4a09bc76e3a17ef71e01bb13447a11e85226e254fe6d10b8" +dependencies = [ + "chrono", + "chrono-tz", + "globwalk", + "humansize", + "lazy_static", + "percent-encoding", + "pest", + "pest_derive", + "rand", + "regex", + "serde", + "serde_json", + "slug", + "unic-segment", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -2275,6 +2385,56 @@ dependencies = [ "web-time", ] +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-segment" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4ed5d26be57f84f176157270c112ef57b86debac9cd21daaabbe56db0f88f23" +dependencies = [ + "unic-ucd-segment", +] + +[[package]] +name = "unic-ucd-segment" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2079c122a62205b421f499da10f3ee0f7697f012f55b675e002483c73ea34700" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + [[package]] name = "unicode-ident" version = "1.0.12" @@ -2347,6 +2507,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index 7d440c5b..987fba8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ itertools = "0.12.1" libsqlite3-sys = "0.27" merge = "0.1.0" miette = "7.2.0" -once_cell = "1.19" +once_cell = "1.19.0" open = "5.1.2" pace_cli = { path = "crates/cli", version = "0" } pace_core = { path = "crates/core", version = "0" } @@ -59,6 +59,7 @@ strum = "0.26.2" strum_macros = "0.26.2" tabled = "0.15.0" tempfile = "3.10.1" +tera = "1.19.1" thiserror = "1.0.58" toml = "0.8.12" tracing = "0.1.40" @@ -94,6 +95,7 @@ license = false eula = false [dependencies] +abscissa_core = { workspace = true } chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["env", "wrap_help", "derive"] } clap_complete = { workspace = true } @@ -107,17 +109,16 @@ pace_core = { workspace = true, features = ["cli"] } pace_time = { workspace = true, features = ["cli"] } serde = { workspace = true } serde_derive = { workspace = true } - -# Better error messages for Serde -# serde_path_to_error = "0.1.15" - -abscissa_core = { workspace = true } thiserror = { workspace = true } toml = { workspace = true, features = ["preserve_order"] } + # optional: use `gimli` to capture backtraces # see https://github.com/rust-lang/backtrace-rs/issues/189 # features = ["gimli-backtrace"] +# Better error messages for Serde +# serde_path_to_error = "0.1.15" + [dev-dependencies] abscissa_core = { workspace = true, features = ["testing"] } assert_cmd = { workspace = true } @@ -161,6 +162,7 @@ install-updater = true include = [ "./config/", "./docs/", + "./templates/", ] # see: https://nnethercote.github.io/perf-book/build-configuration.html diff --git a/config/pace.toml b/config/pace.toml index 12c82456..7e5cb80f 100644 --- a/config/pace.toml +++ b/config/pace.toml @@ -13,8 +13,8 @@ default-priority = "medium" default-timezone = "UTC" [reflections] -# Format of the reflections generated by the pace: "pdf", "html", "markdown", etc. -format = "html" +# Format of the reflections generated by the pace: "console", "template", "json", or "csv" etc. +format = "console" # Directory where the reflections will be stored directory = "/path/to/your/reflections/" diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index b7cdc93f..bb08b6b0 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -40,6 +40,7 @@ itertools = { workspace = true } libsqlite3-sys = { workspace = true, features = ["bundled"], optional = true } merge = { workspace = true } miette = { workspace = true, features = ["fancy"] } +once_cell = { workspace = true } open = { workspace = true } pace_time = { workspace = true } parking_lot = { workspace = true, features = ["deadlock_detection"] } @@ -50,6 +51,7 @@ serde_json = { workspace = true } strum = { workspace = true, features = ["derive"] } strum_macros = { workspace = true } tabled = { workspace = true } +tera = { workspace = true } thiserror = { workspace = true } toml = { workspace = true, features = ["indexmap", "preserve_order"] } tracing = { workspace = true } diff --git a/crates/core/src/commands/reflect.rs b/crates/core/src/commands/reflect.rs index 55e35f7c..f3fda45d 100644 --- a/crates/core/src/commands/reflect.rs +++ b/crates/core/src/commands/reflect.rs @@ -14,9 +14,10 @@ use typed_builder::TypedBuilder; use crate::{ config::PaceConfig, domain::{activity::ActivityKind, filter::FilterOptions, reflection::ReflectionsFormatKind}, - error::{PaceResult, UserMessage}, + error::{PaceResult, TemplatingErrorKind, UserMessage}, service::{activity_store::ActivityStore, activity_tracker::ActivityTracker}, storage::get_storage_from_config, + template::{PaceReflectionTemplate, TEMPLATES}, }; /// `reflect` subcommand options @@ -47,13 +48,21 @@ pub struct ReflectCommandOptions { )] case_sensitive: bool, - /// Specify output format (e.g., text, markdown, pdf) + /// Specify output format for the reflection #[cfg_attr( feature = "clap", - clap(short, long, value_name = "Output Format", visible_alias = "format") + clap(short, long, value_name = "Output Format", visible_alias = "format",) )] output_format: Option, + /// Use this template for rendering the reflection + // TODO: Make it dependent on the `output_format` argument + #[cfg_attr( + feature = "clap", + clap(short, long, value_name = "Template File", visible_alias = "tpl") + )] + template_file: Option, + /// Export the reflections to a specified file #[cfg_attr( feature = "clap", @@ -119,6 +128,8 @@ impl ReflectCommandOptions { export_file, time_flags, date_flags, + template_file, + output_format, // time_zone, // time_zone_offset, .. // TODO: ignore the rest of the fields for now, @@ -138,7 +149,7 @@ impl ReflectCommandOptions { debug!("Displaying reflection for time frame: {}", time_frame); - let Some(reflections) = + let Some(reflection) = activity_tracker.generate_reflection(FilterOptions::from(self), time_frame)? else { return Ok(UserMessage::new( @@ -146,12 +157,12 @@ impl ReflectCommandOptions { )); }; - match self.output_format() { + match output_format { Some(ReflectionsFormatKind::Console) | None => { - return Ok(UserMessage::new(reflections.to_string())); + return Ok(UserMessage::new(reflection.to_string())); } Some(ReflectionsFormatKind::Json) => { - let json = serde_json::to_string_pretty(&reflections)?; + let json = serde_json::to_string_pretty(&reflection)?; debug!("Reflection: {}", json); @@ -168,14 +179,40 @@ impl ReflectCommandOptions { return Ok(UserMessage::new(json)); } - Some(ReflectionsFormatKind::Html) => unimplemented!("HTML format not yet supported"), - Some(ReflectionsFormatKind::Csv) => unimplemented!("CSV format not yet supported"), - Some(ReflectionsFormatKind::Markdown) => { - unimplemented!("Markdown format not yet supported") - } - Some(ReflectionsFormatKind::PlainText) => { - unimplemented!("Plain text format not yet supported") + Some(ReflectionsFormatKind::Template) => { + let context = PaceReflectionTemplate::from(reflection).into_context(); + + let templated = if template_file.is_none() { + TEMPLATES + .render("base.html", &context) + .map_err(TemplatingErrorKind::RenderingToTemplateFailed)? + } else { + let Some(user_tpl) = template_file.as_ref() else { + return Err(TemplatingErrorKind::TemplateFileNotSpecified.into()); + }; + + let user_tpl = std::fs::read_to_string(user_tpl) + .map_err(TemplatingErrorKind::FailedToReadTemplateFile)?; + + tera::Tera::one_off(&user_tpl, &context, true) + .map_err(TemplatingErrorKind::RenderingToTemplateFailed)? + }; + + debug!("Reflection: {}", templated); + + // write to file if export file is specified + if let Some(export_file) = export_file { + std::fs::write(export_file, templated)?; + + return Ok(UserMessage::new(format!( + "Reflection generated: {}", + export_file.display() + ))); + } + + return Ok(UserMessage::new(templated)); } + Some(ReflectionsFormatKind::Csv) => unimplemented!("CSV format not yet supported"), } } } diff --git a/crates/core/src/domain/reflection.rs b/crates/core/src/domain/reflection.rs index cee5695c..75886b62 100644 --- a/crates/core/src/domain/reflection.rs +++ b/crates/core/src/domain/reflection.rs @@ -23,15 +23,9 @@ use crate::domain::activity::{ActivityGroup, ActivityItem, ActivityKind}; pub enum ReflectionsFormatKind { #[default] Console, + Template, Json, - Html, Csv, - #[cfg_attr(feature = "clap", clap(alias("md")))] - #[serde(rename = "md")] - Markdown, - #[cfg_attr(feature = "clap", clap(alias("txt")))] - #[serde(rename = "txt")] - PlainText, } /// Represents a category for summarizing activities. diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index 7953e979..9582667d 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -3,7 +3,7 @@ use displaydoc::Display; use miette::Diagnostic; use pace_time::error::PaceTimeErrorKind; -use std::{error::Error, path::PathBuf}; +use std::{error::Error, io, path::PathBuf}; use thiserror::Error; use crate::domain::activity::{Activity, ActivityGuid}; @@ -162,6 +162,10 @@ pub enum PaceErrorKind { /// There is no path available to store the activity log NoPathAvailable, + + /// {0} + #[error(transparent)] + Template(#[from] TemplatingErrorKind), } /// [`ActivityLogErrorKind`] describes the errors that can happen while dealing with the activity log. @@ -192,16 +196,16 @@ pub enum ActivityLogErrorKind { /// Cache not available CacheNotAvailable, - /// `Activity` with id '{0}' not found + /// `Activity` with id {0} not found ActivityNotFound(ActivityGuid), - /// `Activity` with id '{0}' can't be removed from the activity log + /// `Activity` with id {0} can't be removed from the activity log ActivityCantBeRemoved(usize), /// This activity has no id ActivityIdNotSet, - /// `Activity` with id '{0}' already in use, can't create a new activity with the same id + /// `Activity` with id {0} already in use, can't create a new activity with the same id ActivityIdAlreadyInUse(ActivityGuid), /// `Activity` in the `ActivityLog` has a different id than the one provided: {0} != {1} @@ -213,28 +217,28 @@ pub enum ActivityLogErrorKind { /// There have been some activities that have not been ended ActivityNotEnded, - /// No active activity found with id '{0}' + /// No active activity found with id {0} NoActiveActivityFound(ActivityGuid), - /// `Activity` with id '{0}' already ended + /// `Activity` with id {0} already ended ActivityAlreadyEnded(ActivityGuid), - /// Activity with id '{0}' already has been archived + /// Activity with id {0} already has been archived ActivityAlreadyArchived(ActivityGuid), - /// Active activity with id '{0}' found, although we wanted a held activity + /// Active activity with id {0} found, although we wanted a held activity ActiveActivityFound(ActivityGuid), - /// Activity with id '{0}' is not held, but we wanted to resume it + /// Activity with id {0} is not held, but we wanted to resume it NoHeldActivityFound(ActivityGuid), - /// No activity kind options found for activity with id '{0}' + /// No activity kind options found for activity with id {0} ActivityKindOptionsNotFound(ActivityGuid), - /// `ParentId` not set for activity with id '{0}' + /// `ParentId` not set for activity with id {0} ParentIdNotSet(ActivityGuid), - /// Category not set for activity with id '{0}' + /// Category not set for activity with id {0} CategoryNotSet(ActivityGuid), /// No active activity to adjust @@ -247,7 +251,24 @@ pub enum ActivityLogErrorKind { NoEndOptionsFound, } -/// [`PaceTimeErrorKind`] describes the errors that can happen while dealing with time. +/// [`TemplatingErrorKind`] describes the errors that can happen while dealing with templating. +#[non_exhaustive] +#[derive(Error, Debug, Display)] +pub enum TemplatingErrorKind { + /// Failed to generate context from serializable struct: {0} + FailedToGenerateContextFromSerialize(tera::Error), + + /// Failed to render template: {0} + RenderingToTemplateFailed(tera::Error), + + /// Failed to read template file: {0} + FailedToReadTemplateFile(io::Error), + + /// Template file not specified + TemplateFileNotSpecified, +} + +/// [`ActivityStoreErrorKind`] describes the errors that can happen while dealing with time. #[non_exhaustive] #[derive(Error, Debug, Display)] pub enum ActivityStoreErrorKind { @@ -295,6 +316,7 @@ impl PaceErrorMarker for chrono::OutOfRangeError {} impl PaceErrorMarker for ActivityLogErrorKind {} impl PaceErrorMarker for PaceTimeErrorKind {} impl PaceErrorMarker for ActivityStoreErrorKind {} +impl PaceErrorMarker for TemplatingErrorKind {} impl From for PaceError where diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index de069a7d..bdb6787e 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -6,6 +6,7 @@ pub(crate) mod domain; pub(crate) mod error; pub(crate) mod service; pub(crate) mod storage; +pub(crate) mod template; pub(crate) mod util; // Constants diff --git a/crates/core/src/template.rs b/crates/core/src/template.rs new file mode 100644 index 00000000..e8193fba --- /dev/null +++ b/crates/core/src/template.rs @@ -0,0 +1,93 @@ +use std::collections::HashMap; + +use once_cell::sync::Lazy; +use pace_time::duration::PaceDuration; +use tera::{from_value, to_value, Context, Error, Tera, Value}; + +use crate::domain::reflection::{ReflectionSummary, SummaryActivityGroup}; + +pub static TEMPLATES: Lazy = Lazy::new(|| { + let mut tera = match Tera::new("templates/reflections/**") { + Ok(t) => t, + Err(e) => { + println!("Parsing error(s): {e}"); + ::std::process::exit(1); + } + }; + tera.autoescape_on(vec![".html", ".sql"]); + tera.register_filter("human_duration", human_duration); + tera +}); + +/// Returns the human duration of the argument. +pub fn human_duration(value: &Value, _: &HashMap) -> Result { + let Ok(duration) = from_value::(value.clone()) else { + return Err(Error::msg(format!( + "Function `human-duration` received an invalid argument: `{value:?}`" + ))); + }; + + to_value(duration.human_readable()).map_err(Error::json) +} + +#[derive(Debug)] +pub struct PaceReflectionTemplate { + context: Context, +} + +impl PaceReflectionTemplate { + pub fn into_context(self) -> Context { + self.context + } +} + +impl From for PaceReflectionTemplate { + fn from(value: ReflectionSummary) -> Self { + let mut context = Context::new(); + context.insert("time_range_start", &value.time_range().start()); + context.insert("time_range_end", &value.time_range().end()); + + context.insert( + "total_time_spent", + &value.total_time_spent().human_readable(), + ); + context.insert( + "total_break_duration", + &value.total_break_duration().human_readable(), + ); + + // key must be a string, because of the way tera works with nested objects + // we need to convert the key to a string + + // merge key tuples into a single string + let summary_groups_by_category = value + .summary_groups_by_category() + .iter() + .map(|((category, subcategory), summary_group)| { + let key = format!("{category}::{subcategory}"); + (key, summary_group) + }) + .collect::>(); + + context.insert("summary_groups_by_category", &summary_groups_by_category); + + Self { context } + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn test_template_filter_human_duration_passes() -> Result<(), Error> { + let value = 31_651_469; + + let print_duration = human_duration(&to_value(value)?, &HashMap::default())?; + + assert_eq!(print_duration, to_value("1year 1day 2h 4m 29s")?); + + Ok(()) + } +} diff --git a/crates/time/src/duration.rs b/crates/time/src/duration.rs index ba5e4c58..e05d3c38 100644 --- a/crates/time/src/duration.rs +++ b/crates/time/src/duration.rs @@ -83,7 +83,7 @@ pub struct PaceDuration(u64); impl Display for PaceDuration { fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result { - write!(f, "{}", format_duration(Duration::from_secs(self.0))) + write!(f, "{}", self.human_readable()) } } @@ -94,6 +94,11 @@ impl PaceDuration { self.0 } + #[must_use] + pub fn human_readable(&self) -> String { + format_duration(Duration::from_secs(self.0)).to_string() + } + #[must_use] pub const fn new(duration: u64) -> Self { Self(duration) diff --git a/templates/README.md b/templates/README.md new file mode 100644 index 00000000..48a1a5ae --- /dev/null +++ b/templates/README.md @@ -0,0 +1,22 @@ +# Templating + +These are the templates for the project. They use the Tera templating engine. If +you want to learn more about Tera, you can check out the +[Tera documentation](https://keats.github.io/tera/docs/#introduction). + +## Template files + +You can find the following templates in this directory: + +- `reflections/basic.html`: A basic template for the reflections. +- `reflections/basic.md`: A basic template for the reflections in markdown. +- `reflections/pace_report_json.html`: A template for using the exported JSON + data. + +## Generating the reflections + +To generate the reflections, you can call the following command: + +```console +pace reflection -o template -t templates/reflections/basic.md -e test.md today +``` diff --git a/templates/pace_report.html b/templates/pace_report_json.html similarity index 100% rename from templates/pace_report.html rename to templates/pace_report_json.html diff --git a/templates/reflections/basic.html b/templates/reflections/basic.html new file mode 100644 index 00000000..de12bdf9 --- /dev/null +++ b/templates/reflections/basic.html @@ -0,0 +1,54 @@ + + + + + + + Activity Report + + + + + +

Activity Report

+ +

Time Range

+

Start: {{ time_range_start }}

+

End: {{ time_range_end }}

+ +

Total Time Spent

+

{{ total_time_spent }}

+ +

Total Break Duration

+

{{ total_break_duration }}

+ +

Summary Groups by Category

+ + + + + + + + + {{ summary_groups_by_category }} +
CategoryTotal DurationDescriptionAdjusted Duration
+ + + \ No newline at end of file diff --git a/templates/reflections/basic.md b/templates/reflections/basic.md new file mode 100644 index 00000000..18d53b14 --- /dev/null +++ b/templates/reflections/basic.md @@ -0,0 +1,21 @@ +# Activity Reflection + +## Time Range + +Start: **{{ time_range_start }}** + +End: **{{ time_range_end }}** + +## Overview + +Total Time Spent: **{{ total_time_spent }}** + +Total Break Time: **{{ total_break_duration }}** + +## Summary Groups By Category + +| Category | Description | Duration | Break Duration (Count) | +|----------|-------------|----------|------------------------| +{% for category, summary_group in summary_groups_by_category -%} {%- for description, activity_group in summary_group.activity_groups_by_description -%} +| {{ category }} | {{ description }} | {{ summary_group.total_duration }} | {{ summary_group.total_break_duration }} ({{ summary_group.total_break_count }}) | +{% endfor %}{% endfor %} diff --git a/tests/fixtures/configs/pace.toml b/tests/fixtures/configs/pace.toml index 12c82456..7e5cb80f 100644 --- a/tests/fixtures/configs/pace.toml +++ b/tests/fixtures/configs/pace.toml @@ -13,8 +13,8 @@ default-priority = "medium" default-timezone = "UTC" [reflections] -# Format of the reflections generated by the pace: "pdf", "html", "markdown", etc. -format = "html" +# Format of the reflections generated by the pace: "console", "template", "json", or "csv" etc. +format = "console" # Directory where the reflections will be stored directory = "/path/to/your/reflections/"