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