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
153pub 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 §ions {
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
236pub fn escape_html(s: &str) -> String {
238 s.replace('&', "&")
239 .replace('<', "<")
240 .replace('>', ">")
241 .replace('"', """)
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 & B");
272 }
273
274 #[test]
275 fn it_escapes_angle_brackets() {
276 assert_eq!(escape_html("<div>"), "<div>");
277 }
278
279 #[test]
280 fn it_escapes_quotes() {
281 assert_eq!(escape_html(r#"say "hi""#), "say "hi"");
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 <script> & 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}