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 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 & B");
259 }
260
261 #[test]
262 fn it_escapes_angle_brackets() {
263 assert_eq!(escape_html("<div>"), "<div>");
264 }
265
266 #[test]
267 fn it_escapes_quotes() {
268 assert_eq!(escape_html(r#"say "hi""#), "say "hi"");
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 <script> & 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}