Skip to main content

doing_plugins/
html.rs

1use std::sync::LazyLock;
2
3use doing_config::Config;
4use doing_taskpaper::Entry;
5use doing_template::renderer::RenderOptions;
6use regex::Regex;
7
8use crate::{ExportPlugin, ExportPluginSettings, helpers};
9
10static TAG_HIGHLIGHT_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(@[^\s(]+(?:\([^)]*\))?)").unwrap());
11
12pub const DEFAULT_CSS: &str = r#"body {
13  background: #fff;
14  color: #333;
15  font-family: Helvetica,arial,freesans,clean,sans-serif;
16  font-size: 21px;
17  line-height: 1.5;
18  text-align: justify;
19}
20
21@media only screen and (max-width: 900px) {
22  body {
23    font-size: calc(12px + 1vw);
24  }
25
26  .date,
27  .note {
28    font-size: calc(8px + 1vw)!important;
29  }
30}
31
32h1 {
33  margin-bottom: 1em;
34  margin-left: .1em;
35  position: relative;
36  text-align: left;
37}
38
39ul {
40  list-style-position: outside;
41  position: relative;
42  text-align: left;
43  padding-left: 0;
44}
45
46article > ul > li {
47  display: grid;
48  grid-template-columns: 14ch auto;
49  line-height: 1.2;
50  list-style-type: none;
51  padding-left: 10px;
52  position: relative;
53  word-break: break-word;
54  transition: background .2s ease-in-out;
55}
56
57article > ul > li:hover {
58  background: rgba(150,150,150,.05);
59}
60
61.date {
62  color: #7d9ca2;
63  font-size: 17px;
64  padding: 3px 1ch 0 0;
65  text-align: right;
66  white-space: nowrap;
67  transition: color .2s ease-in-out;
68}
69
70.entry {
71  border-left: solid 1px #ccc;
72  line-height: 1.2;
73  padding: 2px 10px 2px 3ch;
74  text-indent: -2ch;
75}
76
77.tag {
78  color: #999;
79  transition: color 1s ease-in;
80}
81
82.note {
83  color: #aaa;
84  display: block;
85  font-size: 17px;
86  line-height: 1.1;
87  padding: 1em 0 0 2ch;
88  position: relative;
89  transition: color .2s ease-in-out;
90}
91
92li:hover .note {
93  color: #777;
94}
95
96li:hover .tag {
97  color: rgb(182, 120, 125);
98}
99
100li:hover .date {
101  color: rgb(100, 169, 165);
102}
103
104.note li {
105  margin-bottom: .5em;
106  list-style: none;
107  position: relative;
108}
109
110.note li:before {
111  color: #ddd;
112  content: '\25BA';
113  font-size: 12px;
114  font-weight: 300;
115  left: -3ch;
116  position: absolute;
117  top: .25em;
118}
119
120.time {
121  background: #f9fced;
122  border-bottom: dashed 1px #ccc;
123  color: #729953;
124  font-size: 15px;
125  margin-right: 4px;
126  padding: 0 5px;
127  position: relative;
128  text-align: right;
129}
130
131.section {
132  border-left: solid 1px rgb(182, 120, 125);
133  border-radius: 25px;
134  border-right: solid 1px rgb(182, 120, 125);
135  color: rgb(182, 120, 125);
136  font-size: .8em;
137  line-height: 1 !important;
138  padding: 0 4px;
139  transition: background .4s ease-in, color .4s ease-in;
140}
141
142li:hover .section {
143  color: #fff;
144  background: rgb(182, 120, 125);
145}
146
147a:link {
148  background-color: rgba(203, 255, 251, .15);
149  color: #64a9a5;
150  text-decoration: none;
151}"#;
152
153/// Export plugin that renders entries as a self-contained HTML page with inline CSS.
154///
155/// Entries are grouped by section. Tags, intervals, and notes are rendered with
156/// appropriate styling. The CSS can be customized via the `export_templates.css`
157/// config key.
158pub struct HtmlExport;
159
160impl ExportPlugin for HtmlExport {
161  fn name(&self) -> &str {
162    "html"
163  }
164
165  fn render(&self, entries: &[Entry], options: &RenderOptions, config: &Config) -> String {
166    let sections = helpers::group_by_section(entries);
167    let style = DEFAULT_CSS;
168    let mut items_html = String::new();
169    for (section, items) in &sections {
170      for entry in items {
171        let title_with_tags = escape_html(&entry.full_title());
172        let title_styled = TAG_HIGHLIGHT_RE
173          .replace_all(&title_with_tags, r#"<span class="tag">$1</span>"#)
174          .into_owned();
175
176        let date = entry.date().format(&options.date_format).to_string();
177
178        let time_html = helpers::format_interval(entry, config)
179          .map(|t| format!(r#"<span class="time">{}</span>"#, escape_html(&t)))
180          .unwrap_or_default();
181
182        let note_html = helpers::note_to_html_list(entry, "note", escape_html);
183
184        items_html.push_str(&format!(
185          concat!(
186            "<li>",
187            r#"<span class="date">{date}</span>"#,
188            r#"<div class="entry">{title} <span class="section">{section}</span>"#,
189            "{time}{note}",
190            "</div>",
191            "</li>\n",
192          ),
193          date = escape_html(&date),
194          title = title_styled,
195          section = escape_html(section),
196          time = time_html,
197          note = note_html,
198        ));
199      }
200    }
201
202    format!(
203      concat!(
204        "<!DOCTYPE html>\n",
205        "<html>\n",
206        "<head>\n",
207        r#"<meta charset="utf-8">"#,
208        "\n",
209        "<title>what are you doing?</title>\n",
210        "<style>{style}</style>\n",
211        "</head>\n",
212        "<body>\n",
213        "<header><h1>what are you doing?</h1></header>\n",
214        "<article>\n",
215        "<ul>\n",
216        "{items}",
217        "</ul>\n",
218        "</article>\n",
219        "</body>\n",
220        "</html>\n",
221      ),
222      style = style,
223      items = items_html,
224    )
225  }
226
227  fn settings(&self) -> ExportPluginSettings {
228    ExportPluginSettings {
229      trigger: "html?|web(?:page)?".into(),
230    }
231  }
232}
233
234/// Escape special HTML characters.
235pub fn escape_html(s: &str) -> String {
236  s.replace('&', "&amp;")
237    .replace('<', "&lt;")
238    .replace('>', "&gt;")
239    .replace('"', "&quot;")
240}
241
242#[cfg(test)]
243mod test {
244  use chrono::{Local, TimeZone};
245  use doing_taskpaper::{Note, Tag, Tags};
246
247  use super::*;
248
249  fn sample_date(hour: u32, minute: u32) -> chrono::DateTime<Local> {
250    Local.with_ymd_and_hms(2024, 3, 17, hour, minute, 0).unwrap()
251  }
252
253  fn sample_options() -> RenderOptions {
254    RenderOptions {
255      date_format: "%Y-%m-%d %H:%M".into(),
256      include_notes: true,
257      template: String::new(),
258      wrap_width: 0,
259    }
260  }
261
262  mod escape_html {
263    use pretty_assertions::assert_eq;
264
265    use super::super::escape_html;
266
267    #[test]
268    fn it_escapes_ampersands() {
269      assert_eq!(escape_html("A & B"), "A &amp; B");
270    }
271
272    #[test]
273    fn it_escapes_angle_brackets() {
274      assert_eq!(escape_html("<div>"), "&lt;div&gt;");
275    }
276
277    #[test]
278    fn it_escapes_quotes() {
279      assert_eq!(escape_html(r#"say "hi""#), "say &quot;hi&quot;");
280    }
281
282    #[test]
283    fn it_returns_plain_text_unchanged() {
284      assert_eq!(escape_html("hello world"), "hello world");
285    }
286  }
287
288  mod group_by_section {
289    use pretty_assertions::assert_eq;
290
291    use super::*;
292
293    #[test]
294    fn it_groups_entries_by_section() {
295      let entries = vec![
296        Entry::new(
297          sample_date(14, 0),
298          "A",
299          Tags::new(),
300          Note::new(),
301          "Currently",
302          None::<String>,
303        ),
304        Entry::new(
305          sample_date(15, 0),
306          "B",
307          Tags::new(),
308          Note::new(),
309          "Archive",
310          None::<String>,
311        ),
312        Entry::new(
313          sample_date(16, 0),
314          "C",
315          Tags::new(),
316          Note::new(),
317          "Currently",
318          None::<String>,
319        ),
320      ];
321
322      let groups = crate::helpers::group_by_section(&entries);
323
324      assert_eq!(groups.len(), 2);
325      assert_eq!(groups[0].0, "Currently");
326      assert_eq!(groups[0].1.len(), 2);
327      assert_eq!(groups[1].0, "Archive");
328      assert_eq!(groups[1].1.len(), 1);
329    }
330
331    #[test]
332    fn it_preserves_first_seen_order() {
333      let entries = vec![
334        Entry::new(
335          sample_date(14, 0),
336          "A",
337          Tags::new(),
338          Note::new(),
339          "Archive",
340          None::<String>,
341        ),
342        Entry::new(
343          sample_date(15, 0),
344          "B",
345          Tags::new(),
346          Note::new(),
347          "Currently",
348          None::<String>,
349        ),
350      ];
351
352      let groups = crate::helpers::group_by_section(&entries);
353
354      assert_eq!(groups[0].0, "Archive");
355      assert_eq!(groups[1].0, "Currently");
356    }
357  }
358
359  mod html_export_name {
360    use pretty_assertions::assert_eq;
361
362    use super::*;
363
364    #[test]
365    fn it_returns_html() {
366      assert_eq!(HtmlExport.name(), "html");
367    }
368  }
369
370  mod html_export_render {
371    use super::*;
372
373    #[test]
374    fn it_renders_empty_entries() {
375      let config = Config::default();
376      let options = sample_options();
377
378      let output = HtmlExport.render(&[], &options, &config);
379
380      assert!(output.contains("<!DOCTYPE html>"));
381      assert!(output.contains("<ul>\n</ul>"));
382    }
383
384    #[test]
385    fn it_renders_entry_with_tags() {
386      let config = Config::default();
387      let options = sample_options();
388      let entry = Entry::new(
389        sample_date(14, 30),
390        "Working on project",
391        Tags::from_iter(vec![Tag::new("coding", None::<String>)]),
392        Note::new(),
393        "Currently",
394        None::<String>,
395      );
396
397      let output = HtmlExport.render(&[entry], &options, &config);
398
399      assert!(output.contains("Working on project"));
400      assert!(output.contains(r#"<span class="tag">@coding</span>"#));
401      assert!(output.contains(r#"<span class="section">Currently</span>"#));
402    }
403
404    #[test]
405    fn it_wraps_done_tag_with_date_in_single_span() {
406      let config = Config::default();
407      let options = sample_options();
408      let entry = Entry::new(
409        sample_date(14, 30),
410        "Finished task",
411        Tags::from_iter(vec![Tag::new("done", Some("2024-03-17 15:00"))]),
412        Note::new(),
413        "Currently",
414        None::<String>,
415      );
416
417      let output = HtmlExport.render(&[entry], &options, &config);
418
419      assert!(
420        output.contains(r#"<span class="tag">@done(2024-03-17 15:00)</span>"#),
421        "done tag with date should be wrapped in a single span, got: {}",
422        output
423      );
424    }
425
426    #[test]
427    fn it_renders_entry_with_note() {
428      let config = Config::default();
429      let options = sample_options();
430      let entry = Entry::new(
431        sample_date(14, 30),
432        "Task",
433        Tags::new(),
434        Note::from_str("Note line 1\nNote line 2"),
435        "Currently",
436        None::<String>,
437      );
438
439      let output = HtmlExport.render(&[entry], &options, &config);
440
441      assert!(output.contains(r#"<ul class="note">"#));
442      assert!(output.contains("<li>Note line 1</li>"));
443      assert!(output.contains("<li>Note line 2</li>"));
444    }
445
446    #[test]
447    fn it_renders_entry_with_interval() {
448      let config = Config::default();
449      let options = sample_options();
450      let entry = Entry::new(
451        sample_date(14, 30),
452        "Working on project",
453        Tags::from_iter(vec![
454          Tag::new("coding", None::<String>),
455          Tag::new("done", Some("2024-03-17 15:00")),
456        ]),
457        Note::new(),
458        "Currently",
459        None::<String>,
460      );
461
462      let output = HtmlExport.render(&[entry], &options, &config);
463
464      assert!(output.contains(r#"<span class="time">"#));
465      assert!(output.contains("00:30:00"));
466    }
467
468    #[test]
469    fn it_includes_inline_css() {
470      let config = Config::default();
471      let options = sample_options();
472
473      let output = HtmlExport.render(&[], &options, &config);
474
475      assert!(output.contains("<style>"));
476      assert!(output.contains("font-family"));
477    }
478
479    #[test]
480    fn it_escapes_html_in_titles() {
481      let config = Config::default();
482      let options = sample_options();
483      let entry = Entry::new(
484        sample_date(14, 30),
485        "Fix <script> & bugs",
486        Tags::new(),
487        Note::new(),
488        "Currently",
489        None::<String>,
490      );
491
492      let output = HtmlExport.render(&[entry], &options, &config);
493
494      assert!(output.contains("Fix &lt;script&gt; &amp; bugs"));
495      assert!(!output.contains("<script>"));
496    }
497
498    #[test]
499    fn it_renders_multiple_sections() {
500      let config = Config::default();
501      let options = sample_options();
502      let entries = vec![
503        Entry::new(
504          sample_date(14, 0),
505          "A",
506          Tags::new(),
507          Note::new(),
508          "Currently",
509          None::<String>,
510        ),
511        Entry::new(
512          sample_date(15, 0),
513          "B",
514          Tags::new(),
515          Note::new(),
516          "Archive",
517          None::<String>,
518        ),
519      ];
520
521      let output = HtmlExport.render(&entries, &options, &config);
522
523      assert!(output.contains(r#"<span class="section">Currently</span>"#));
524      assert!(output.contains(r#"<span class="section">Archive</span>"#));
525    }
526  }
527
528  mod html_export_settings {
529    use pretty_assertions::assert_eq;
530
531    use super::*;
532
533    #[test]
534    fn it_returns_html_trigger() {
535      let settings = HtmlExport.settings();
536
537      assert_eq!(settings.trigger, "html?|web(?:page)?");
538    }
539  }
540}