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, Plugin, PluginSettings, 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 render(&self, entries: &[Entry], options: &RenderOptions, config: &Config) -> String {
162    let sections = helpers::group_by_section(entries);
163    let style = DEFAULT_CSS;
164    let mut items_html = String::new();
165    for (section, items) in &sections {
166      for entry in items {
167        let title_with_tags = escape_html(&entry.full_title());
168        let title_styled = TAG_HIGHLIGHT_RE
169          .replace_all(&title_with_tags, r#"<span class="tag">$1</span>"#)
170          .into_owned();
171
172        let date = entry.date().format(&options.date_format).to_string();
173
174        let time_html = helpers::format_interval(entry, config)
175          .map(|t| format!(r#"<span class="time">{}</span>"#, escape_html(&t)))
176          .unwrap_or_default();
177
178        let note_html = helpers::note_to_html_list(entry, "note", escape_html);
179
180        items_html.push_str(&format!(
181          concat!(
182            "<li>",
183            r#"<span class="date">{date}</span>"#,
184            r#"<div class="entry">{title} <span class="section">{section}</span>"#,
185            "{time}{note}",
186            "</div>",
187            "</li>\n",
188          ),
189          date = escape_html(&date),
190          title = title_styled,
191          section = escape_html(section),
192          time = time_html,
193          note = note_html,
194        ));
195      }
196    }
197
198    format!(
199      concat!(
200        "<!DOCTYPE html>\n",
201        "<html>\n",
202        "<head>\n",
203        r#"<meta charset="utf-8">"#,
204        "\n",
205        "<title>what are you doing?</title>\n",
206        "<style>{style}</style>\n",
207        "</head>\n",
208        "<body>\n",
209        "<header><h1>what are you doing?</h1></header>\n",
210        "<article>\n",
211        "<ul>\n",
212        "{items}",
213        "</ul>\n",
214        "</article>\n",
215        "</body>\n",
216        "</html>\n",
217      ),
218      style = style,
219      items = items_html,
220    )
221  }
222}
223
224impl Plugin for HtmlExport {
225  fn name(&self) -> &str {
226    "html"
227  }
228
229  fn settings(&self) -> PluginSettings {
230    PluginSettings {
231      trigger: "html?|web(?:page)?".into(),
232    }
233  }
234}
235
236/// Escape special HTML characters.
237pub fn escape_html(s: &str) -> String {
238  s.replace('&', "&amp;")
239    .replace('<', "&lt;")
240    .replace('>', "&gt;")
241    .replace('"', "&quot;")
242}
243
244#[cfg(test)]
245mod test {
246  use chrono::{Local, TimeZone};
247  use doing_taskpaper::{Note, Tag, Tags};
248
249  use super::*;
250
251  fn sample_date(hour: u32, minute: u32) -> chrono::DateTime<Local> {
252    Local.with_ymd_and_hms(2024, 3, 17, hour, minute, 0).unwrap()
253  }
254
255  fn sample_options() -> RenderOptions {
256    RenderOptions {
257      date_format: "%Y-%m-%d %H:%M".into(),
258      include_notes: true,
259      template: String::new(),
260      wrap_width: 0,
261    }
262  }
263
264  mod escape_html {
265    use pretty_assertions::assert_eq;
266
267    use super::super::escape_html;
268
269    #[test]
270    fn it_escapes_ampersands() {
271      assert_eq!(escape_html("A & B"), "A &amp; B");
272    }
273
274    #[test]
275    fn it_escapes_angle_brackets() {
276      assert_eq!(escape_html("<div>"), "&lt;div&gt;");
277    }
278
279    #[test]
280    fn it_escapes_quotes() {
281      assert_eq!(escape_html(r#"say "hi""#), "say &quot;hi&quot;");
282    }
283
284    #[test]
285    fn it_returns_plain_text_unchanged() {
286      assert_eq!(escape_html("hello world"), "hello world");
287    }
288  }
289
290  mod group_by_section {
291    use pretty_assertions::assert_eq;
292
293    use super::*;
294
295    #[test]
296    fn it_groups_entries_by_section() {
297      let entries = vec![
298        Entry::new(
299          sample_date(14, 0),
300          "A",
301          Tags::new(),
302          Note::new(),
303          "Currently",
304          None::<String>,
305        ),
306        Entry::new(
307          sample_date(15, 0),
308          "B",
309          Tags::new(),
310          Note::new(),
311          "Archive",
312          None::<String>,
313        ),
314        Entry::new(
315          sample_date(16, 0),
316          "C",
317          Tags::new(),
318          Note::new(),
319          "Currently",
320          None::<String>,
321        ),
322      ];
323
324      let groups = crate::helpers::group_by_section(&entries);
325
326      assert_eq!(groups.len(), 2);
327      assert_eq!(groups[0].0, "Currently");
328      assert_eq!(groups[0].1.len(), 2);
329      assert_eq!(groups[1].0, "Archive");
330      assert_eq!(groups[1].1.len(), 1);
331    }
332
333    #[test]
334    fn it_preserves_first_seen_order() {
335      let entries = vec![
336        Entry::new(
337          sample_date(14, 0),
338          "A",
339          Tags::new(),
340          Note::new(),
341          "Archive",
342          None::<String>,
343        ),
344        Entry::new(
345          sample_date(15, 0),
346          "B",
347          Tags::new(),
348          Note::new(),
349          "Currently",
350          None::<String>,
351        ),
352      ];
353
354      let groups = crate::helpers::group_by_section(&entries);
355
356      assert_eq!(groups[0].0, "Archive");
357      assert_eq!(groups[1].0, "Currently");
358    }
359  }
360
361  mod html_export_name {
362    use pretty_assertions::assert_eq;
363
364    use super::*;
365
366    #[test]
367    fn it_returns_html() {
368      assert_eq!(HtmlExport.name(), "html");
369    }
370  }
371
372  mod html_export_render {
373    use super::*;
374
375    #[test]
376    fn it_renders_empty_entries() {
377      let config = Config::default();
378      let options = sample_options();
379
380      let output = HtmlExport.render(&[], &options, &config);
381
382      assert!(output.contains("<!DOCTYPE html>"));
383      assert!(output.contains("<ul>\n</ul>"));
384    }
385
386    #[test]
387    fn it_renders_entry_with_tags() {
388      let config = Config::default();
389      let options = sample_options();
390      let entry = Entry::new(
391        sample_date(14, 30),
392        "Working on project",
393        Tags::from_iter(vec![Tag::new("coding", None::<String>)]),
394        Note::new(),
395        "Currently",
396        None::<String>,
397      );
398
399      let output = HtmlExport.render(&[entry], &options, &config);
400
401      assert!(output.contains("Working on project"));
402      assert!(output.contains(r#"<span class="tag">@coding</span>"#));
403      assert!(output.contains(r#"<span class="section">Currently</span>"#));
404    }
405
406    #[test]
407    fn it_wraps_done_tag_with_date_in_single_span() {
408      let config = Config::default();
409      let options = sample_options();
410      let entry = Entry::new(
411        sample_date(14, 30),
412        "Finished task",
413        Tags::from_iter(vec![Tag::new("done", Some("2024-03-17 15:00"))]),
414        Note::new(),
415        "Currently",
416        None::<String>,
417      );
418
419      let output = HtmlExport.render(&[entry], &options, &config);
420
421      assert!(
422        output.contains(r#"<span class="tag">@done(2024-03-17 15:00)</span>"#),
423        "done tag with date should be wrapped in a single span, got: {}",
424        output
425      );
426    }
427
428    #[test]
429    fn it_renders_entry_with_note() {
430      let config = Config::default();
431      let options = sample_options();
432      let entry = Entry::new(
433        sample_date(14, 30),
434        "Task",
435        Tags::new(),
436        Note::from_text("Note line 1\nNote line 2"),
437        "Currently",
438        None::<String>,
439      );
440
441      let output = HtmlExport.render(&[entry], &options, &config);
442
443      assert!(output.contains(r#"<ul class="note">"#));
444      assert!(output.contains("<li>Note line 1</li>"));
445      assert!(output.contains("<li>Note line 2</li>"));
446    }
447
448    #[test]
449    fn it_renders_entry_with_interval() {
450      let config = Config::default();
451      let options = sample_options();
452      let entry = Entry::new(
453        sample_date(14, 30),
454        "Working on project",
455        Tags::from_iter(vec![
456          Tag::new("coding", None::<String>),
457          Tag::new("done", Some("2024-03-17 15:00")),
458        ]),
459        Note::new(),
460        "Currently",
461        None::<String>,
462      );
463
464      let output = HtmlExport.render(&[entry], &options, &config);
465
466      assert!(output.contains(r#"<span class="time">"#));
467      assert!(output.contains("00:30:00"));
468    }
469
470    #[test]
471    fn it_includes_inline_css() {
472      let config = Config::default();
473      let options = sample_options();
474
475      let output = HtmlExport.render(&[], &options, &config);
476
477      assert!(output.contains("<style>"));
478      assert!(output.contains("font-family"));
479    }
480
481    #[test]
482    fn it_escapes_html_in_titles() {
483      let config = Config::default();
484      let options = sample_options();
485      let entry = Entry::new(
486        sample_date(14, 30),
487        "Fix <script> & bugs",
488        Tags::new(),
489        Note::new(),
490        "Currently",
491        None::<String>,
492      );
493
494      let output = HtmlExport.render(&[entry], &options, &config);
495
496      assert!(output.contains("Fix &lt;script&gt; &amp; bugs"));
497      assert!(!output.contains("<script>"));
498    }
499
500    #[test]
501    fn it_renders_multiple_sections() {
502      let config = Config::default();
503      let options = sample_options();
504      let entries = vec![
505        Entry::new(
506          sample_date(14, 0),
507          "A",
508          Tags::new(),
509          Note::new(),
510          "Currently",
511          None::<String>,
512        ),
513        Entry::new(
514          sample_date(15, 0),
515          "B",
516          Tags::new(),
517          Note::new(),
518          "Archive",
519          None::<String>,
520        ),
521      ];
522
523      let output = HtmlExport.render(&entries, &options, &config);
524
525      assert!(output.contains(r#"<span class="section">Currently</span>"#));
526      assert!(output.contains(r#"<span class="section">Archive</span>"#));
527    }
528  }
529
530  mod html_export_settings {
531    use pretty_assertions::assert_eq;
532
533    use super::*;
534
535    #[test]
536    fn it_returns_html_trigger() {
537      let settings = HtmlExport.settings();
538
539      assert_eq!(settings.trigger, "html?|web(?:page)?");
540    }
541  }
542}