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 doing_taskpaper::{Note, Tag, Tags};
247
248  use super::*;
249  use crate::test_helpers::{sample_date, sample_options};
250
251  mod escape_html {
252    use pretty_assertions::assert_eq;
253
254    use super::super::escape_html;
255
256    #[test]
257    fn it_escapes_ampersands() {
258      assert_eq!(escape_html("A & B"), "A &amp; B");
259    }
260
261    #[test]
262    fn it_escapes_angle_brackets() {
263      assert_eq!(escape_html("<div>"), "&lt;div&gt;");
264    }
265
266    #[test]
267    fn it_escapes_quotes() {
268      assert_eq!(escape_html(r#"say "hi""#), "say &quot;hi&quot;");
269    }
270
271    #[test]
272    fn it_returns_plain_text_unchanged() {
273      assert_eq!(escape_html("hello world"), "hello world");
274    }
275  }
276
277  mod html_export_name {
278    use pretty_assertions::assert_eq;
279
280    use super::*;
281
282    #[test]
283    fn it_returns_html() {
284      assert_eq!(HtmlExport.name(), "html");
285    }
286  }
287
288  mod html_export_render {
289    use super::*;
290
291    #[test]
292    fn it_renders_empty_entries() {
293      let config = Config::default();
294      let options = sample_options();
295
296      let output = HtmlExport.render(&[], &options, &config);
297
298      assert!(output.contains("<!DOCTYPE html>"));
299      assert!(output.contains("<ul>\n</ul>"));
300    }
301
302    #[test]
303    fn it_renders_entry_with_tags() {
304      let config = Config::default();
305      let options = sample_options();
306      let entry = Entry::new(
307        sample_date(17, 14, 30),
308        "Working on project",
309        Tags::from_iter(vec![Tag::new("coding", None::<String>)]),
310        Note::new(),
311        "Currently",
312        None::<String>,
313      );
314
315      let output = HtmlExport.render(&[entry], &options, &config);
316
317      assert!(output.contains("Working on project"));
318      assert!(output.contains(r#"<span class="tag">@coding</span>"#));
319      assert!(output.contains(r#"<span class="section">Currently</span>"#));
320    }
321
322    #[test]
323    fn it_wraps_done_tag_with_date_in_single_span() {
324      let config = Config::default();
325      let options = sample_options();
326      let entry = Entry::new(
327        sample_date(17, 14, 30),
328        "Finished task",
329        Tags::from_iter(vec![Tag::new("done", Some("2024-03-17 15:00"))]),
330        Note::new(),
331        "Currently",
332        None::<String>,
333      );
334
335      let output = HtmlExport.render(&[entry], &options, &config);
336
337      assert!(
338        output.contains(r#"<span class="tag">@done(2024-03-17 15:00)</span>"#),
339        "done tag with date should be wrapped in a single span, got: {}",
340        output
341      );
342    }
343
344    #[test]
345    fn it_renders_entry_with_note() {
346      let config = Config::default();
347      let options = sample_options();
348      let entry = Entry::new(
349        sample_date(17, 14, 30),
350        "Task",
351        Tags::new(),
352        Note::from_text("Note line 1\nNote line 2"),
353        "Currently",
354        None::<String>,
355      );
356
357      let output = HtmlExport.render(&[entry], &options, &config);
358
359      assert!(output.contains(r#"<ul class="note">"#));
360      assert!(output.contains("<li>Note line 1</li>"));
361      assert!(output.contains("<li>Note line 2</li>"));
362    }
363
364    #[test]
365    fn it_renders_entry_with_interval() {
366      let config = Config::default();
367      let options = sample_options();
368      let entry = Entry::new(
369        sample_date(17, 14, 30),
370        "Working on project",
371        Tags::from_iter(vec![
372          Tag::new("coding", None::<String>),
373          Tag::new("done", Some("2024-03-17 15:00")),
374        ]),
375        Note::new(),
376        "Currently",
377        None::<String>,
378      );
379
380      let output = HtmlExport.render(&[entry], &options, &config);
381
382      assert!(output.contains(r#"<span class="time">"#));
383      assert!(output.contains("00:30:00"));
384    }
385
386    #[test]
387    fn it_includes_inline_css() {
388      let config = Config::default();
389      let options = sample_options();
390
391      let output = HtmlExport.render(&[], &options, &config);
392
393      assert!(output.contains("<style>"));
394      assert!(output.contains("font-family"));
395    }
396
397    #[test]
398    fn it_escapes_html_in_titles() {
399      let config = Config::default();
400      let options = sample_options();
401      let entry = Entry::new(
402        sample_date(17, 14, 30),
403        "Fix <script> & bugs",
404        Tags::new(),
405        Note::new(),
406        "Currently",
407        None::<String>,
408      );
409
410      let output = HtmlExport.render(&[entry], &options, &config);
411
412      assert!(output.contains("Fix &lt;script&gt; &amp; bugs"));
413      assert!(!output.contains("<script>"));
414    }
415
416    #[test]
417    fn it_renders_multiple_sections() {
418      let config = Config::default();
419      let options = sample_options();
420      let entries = vec![
421        Entry::new(
422          sample_date(17, 14, 0),
423          "A",
424          Tags::new(),
425          Note::new(),
426          "Currently",
427          None::<String>,
428        ),
429        Entry::new(
430          sample_date(17, 15, 0),
431          "B",
432          Tags::new(),
433          Note::new(),
434          "Archive",
435          None::<String>,
436        ),
437      ];
438
439      let output = HtmlExport.render(&entries, &options, &config);
440
441      assert!(output.contains(r#"<span class="section">Currently</span>"#));
442      assert!(output.contains(r#"<span class="section">Archive</span>"#));
443    }
444  }
445
446  mod html_export_settings {
447    use pretty_assertions::assert_eq;
448
449    use super::*;
450
451    #[test]
452    fn it_returns_html_trigger() {
453      let settings = HtmlExport.settings();
454
455      assert_eq!(settings.trigger, "html?|web(?:page)?");
456    }
457  }
458}