anvilsign in

collin/anvil

main / crates / anvil-web / src / todomd.rs
1//! todo-md parsing and kanban rendering.
2//!
3//! Implements the todo-md spec (the `todo-md` repo). A `TODO.md` is split into
4//! sections by the shallowest heading level used (the *column level*):
5//!
6//! - **Sections** — headings at the column level (e.g. every `#`). They become
7//! kanban columns. A heading containing `[x]` marks a done section; every
8//! task in it counts as done.
9//! - **Tasks** come in two forms inside a section:
10//! - **checkbox tasks** — `- [ ]` / `- [x]` list items; lines indented under
11//! them are their details (opaque markdown).
12//! - **tickets** — a heading nested *deeper* than the column level (e.g. a
13//! `## Implement Navbar` under `# Done`). The heading is the ticket title
14//! and everything beneath it, until the next heading, is its detail body.
15//! A ticket is done if its section is done or its own heading carries
16//! `[x]`.
17//! - Prose-only sections render as ordinary markdown below the board.
18//!
19//! This module is the first "custom renderer" for a well-known filename; the
20//! filename → renderer match in `ui::blob` is the registry that routes here.
21
22use maud::{
23 Markup,
24 html,
25};
26
27use crate::ui::{
28 render_markdown,
29 render_markdown_inline,
30};
31
32pub struct Section {
33 /// Heading level (1–6); 0 for the implicit preamble section.
34 pub level: u8,
35 /// Heading text with any `[x]` / `✓` done-marker stripped.
36 pub title: String,
37 /// Done section: tasks in it count as done regardless of their checkbox.
38 pub done: bool,
39 pub tasks: Vec<Task>,
40 /// Non-task content of the section, verbatim (used for the notes area).
41 pub prose: String,
42}
43
44pub struct Task {
45 pub done: bool,
46 pub title: String,
47 /// Raw markdown block: a checkbox task's indented lines (dedented one
48 /// level) or a ticket's body beneath its heading.
49 pub details: String,
50 /// True when the task came from a nested heading (a ticket) rather than a
51 /// `- [ ]` checkbox line.
52 pub ticket: bool,
53}
54
55/// Where subsequent non-structural lines accumulate.
56#[derive(Clone, Copy, Eq, PartialEq)]
57enum Mode {
58 /// Section prose (no open task).
59 Prose,
60 /// Indented detail lines of a checkbox task (dedented one level).
61 TaskDetail,
62 /// Body of a ticket — runs until the next heading; verbatim.
63 TicketBody,
64}
65
66/// Whether a path names a `TODO.md` (any directory, any case).
67pub fn is_todo_md(path: &str) -> bool {
68 std::path::Path::new(path)
69 .file_name()
70 .is_some_and(|n| n.eq_ignore_ascii_case("TODO.md"))
71}
72
73/// The shallowest heading level in the document (the column level), or 0 if
74/// there are no headings. Fenced regions are skipped.
75fn column_level(text: &str) -> u8 {
76 let mut in_fence = false;
77 let mut min = 0u8;
78 for line in text.lines() {
79 if line.trim_start().starts_with("```") {
80 in_fence = !in_fence;
81 continue;
82 }
83 if in_fence {
84 continue;
85 }
86 if let Some((level, _)) = heading(line)
87 && (min == 0 || level < min)
88 {
89 min = level;
90 }
91 }
92 min
93}
94
95/// Parse a todo-md document into sections. The preamble (content before the
96/// first heading) becomes a level-0 section with an empty title.
97pub fn parse(text: &str) -> Vec<Section> {
98 let col = column_level(text);
99 let mut sections = vec![Section {
100 level: 0,
101 title: String::new(),
102 done: false,
103 tasks: Vec::new(),
104 prose: String::new(),
105 }];
106 let mut in_fence = false;
107 let mut mode = Mode::Prose;
108
109 for line in text.lines() {
110 let fence_toggle = line.trim_start().starts_with("```");
111 let structural = !in_fence && !fence_toggle;
112 if fence_toggle {
113 in_fence = !in_fence;
114 }
115
116 if structural {
117 if let Some((level, rest)) = heading(line) {
118 let cur = sections.last_mut().expect("never empty");
119 if col != 0 && level > col {
120 // A heading deeper than the column level is a ticket in
121 // the current section.
122 cur.tasks.push(Task {
123 done: cur.done || marked_done(rest),
124 title: strip_marker(rest),
125 details: String::new(),
126 ticket: true,
127 });
128 mode = Mode::TicketBody;
129 } else {
130 sections.push(Section {
131 level,
132 title: strip_marker(rest),
133 done: marked_done(rest),
134 tasks: Vec::new(),
135 prose: String::new(),
136 });
137 mode = Mode::Prose;
138 }
139 continue;
140 }
141
142 // Inside a ticket body only a heading (handled above) ends it;
143 // everything else — prose, checkbox lines, blanks — is body.
144 if mode != Mode::TicketBody {
145 if let Some((done, title)) = task_line(line) {
146 sections.last_mut().expect("never empty").tasks.push(Task {
147 done,
148 title: title.to_string(),
149 details: String::new(),
150 ticket: false,
151 });
152 mode = Mode::TaskDetail;
153 continue;
154 }
155 // A non-indented, non-blank line ends a checkbox task's
156 // details and reverts to section prose.
157 if mode == Mode::TaskDetail && !line.is_empty() && !line.starts_with(" ") {
158 mode = Mode::Prose;
159 }
160 }
161 }
162
163 append_line(sections.last_mut().expect("never empty"), mode, line);
164 }
165
166 for s in &mut sections {
167 if s.done {
168 for t in &mut s.tasks {
169 t.done = true;
170 }
171 }
172 for t in &mut s.tasks {
173 // Drop trailing blank lines swallowed while the task was open.
174 while t.details.ends_with('\n') {
175 t.details.pop();
176 }
177 }
178 }
179 sections
180}
181
182fn append_line(section: &mut Section, mode: Mode, line: &str) {
183 match mode {
184 Mode::Prose => {
185 section.prose.push_str(line);
186 section.prose.push('\n');
187 }
188 Mode::TaskDetail => {
189 let t = section.tasks.last_mut().expect("open task exists");
190 // Dedent one level (2–4 spaces) so details render as their own
191 // markdown rather than a code block.
192 let spaces = line.len() - line.trim_start_matches(' ').len();
193 t.details.push_str(&line[spaces.min(4).min(line.len())..]);
194 t.details.push('\n');
195 }
196 Mode::TicketBody => {
197 let t = section.tasks.last_mut().expect("open ticket exists");
198 t.details.push_str(line);
199 t.details.push('\n');
200 }
201 }
202}
203
204fn heading(line: &str) -> Option<(u8, &str)> {
205 let hashes = line.bytes().take_while(|&b| b == b'#').count();
206 if (1..=6).contains(&hashes) && line[hashes..].starts_with(' ') {
207 Some((hashes as u8, line[hashes..].trim()))
208 } else {
209 None
210 }
211}
212
213/// Whether a heading's text carries a done marker (`[x]`, ASCII; `✓` accepted
214/// for todomd compatibility).
215fn marked_done(heading_rest: &str) -> bool {
216 heading_rest.contains("[x]") || heading_rest.contains("[X]") || heading_rest.contains('✓')
217}
218
219fn strip_marker(heading_rest: &str) -> String {
220 heading_rest
221 .replace("[x]", "")
222 .replace("[X]", "")
223 .replace('✓', "")
224 .trim()
225 .to_string()
226}
227
228fn task_line(line: &str) -> Option<(bool, &str)> {
229 let open = line.strip_prefix("- [ ] ");
230 let done = line.strip_prefix("- [x] ").or(line.strip_prefix("- [X] "));
231 match (open, done) {
232 (Some(rest), _) => Some((false, rest.trim())),
233 (_, Some(rest)) => Some((true, rest.trim())),
234 _ => None,
235 }
236}
237
238/// The **add** operation: append a ticket — a nested heading (`## <title>`,
239/// one level below the column headings) followed by an optional `body` (the
240/// ticket's description) — to the end of the section named `section`, touching
241/// no other byte of the document (the todo-md round-trip rule). A ticket is the
242/// richer card style: it inherits done-ness from its column, so it carries no
243/// checkbox. `body` should already be LF-normalized; its surrounding blank
244/// lines are trimmed. Returns `None` when the title is blank, the document has
245/// no column headings to nest under, or no such section exists.
246pub fn add_task(text: &str, section: &str, title: &str, body: &str) -> Option<String> {
247 let title = title.split_whitespace().collect::<Vec<_>>().join(" ");
248 if title.is_empty() {
249 return None;
250 }
251
252 // `split('\n')` (not `lines()`) so reconstruction is byte-exact.
253 let lines: Vec<&str> = text.split('\n').collect();
254 let col = column_level(text);
255 // A ticket nests one level below the columns; with no headings at all
256 // there's no column to nest it under.
257 if col == 0 {
258 return None;
259 }
260
261 // The target span: [start, end) of the section's body lines.
262 let mut start = None;
263 let mut end = lines.len();
264 let mut in_fence = false;
265 for (i, raw) in lines.iter().enumerate() {
266 let line = raw.trim_end_matches('\r');
267 if line.trim_start().starts_with("```") {
268 in_fence = !in_fence;
269 continue;
270 }
271 if in_fence {
272 continue;
273 }
274 if let Some((level, rest)) = heading(line)
275 && level == col
276 {
277 match start {
278 None if strip_marker(rest) == section => start = Some(i + 1),
279 Some(_) => {
280 end = i;
281 break;
282 }
283 None => {}
284 }
285 }
286 }
287 let start = start?;
288
289 // The ticket block: a blank line (separating it from prior content or the
290 // section heading), the heading, then — if there's a description — a blank
291 // line and the body.
292 let heading = format!("{} {title}", "#".repeat(col as usize + 1));
293 let mut block = vec![String::new(), heading];
294 let body = body.trim();
295 if !body.is_empty() {
296 block.push(String::new());
297 block.extend(body.split('\n').map(str::to_string));
298 }
299
300 // Insert after the section's last non-blank line, or right after the
301 // heading for an empty section.
302 let pos = match (start..end).rev().find(|&i| !lines[i].trim().is_empty()) {
303 Some(i) => i + 1,
304 None => start,
305 };
306 let mut out: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
307 out.splice(pos..pos, block);
308 Some(out.join("\n"))
309}
310
311/// The section names a task can be added to: the column-level headings, in
312/// document order, stripped of any done marker. These are exactly the names
313/// [`add_task`] accepts. Empty when the document has no headings.
314pub fn task_sections(text: &str) -> Vec<String> {
315 let col = column_level(text);
316 if col == 0 {
317 return Vec::new();
318 }
319 let mut in_fence = false;
320 let mut out = Vec::new();
321 for line in text.lines() {
322 if line.trim_start().starts_with("```") {
323 in_fence = !in_fence;
324 continue;
325 }
326 if in_fence {
327 continue;
328 }
329 if let Some((level, rest)) = heading(line)
330 && level == col
331 {
332 out.push(strip_marker(rest));
333 }
334 }
335 out
336}
337
338/// Where a ticket body ends: the next heading (any level) or `end`. Fenced
339/// regions are skipped so a `#` inside a code block doesn't end it.
340fn ticket_body_end(lines: &[&str], end: usize, from: usize) -> usize {
341 let mut j = from;
342 let mut f = false;
343 while j < end {
344 let l = lines[j].trim_end_matches('\r');
345 if l.trim_start().starts_with("```") {
346 f = !f;
347 } else if !f && heading(l).is_some() {
348 break;
349 }
350 j += 1;
351 }
352 j
353}
354
355/// Body line span `[start, end)` of the column-level section named `section`
356/// (the lines after its heading, up to the next column heading or EOF).
357fn find_section_body(lines: &[&str], col: u8, section: &str) -> Option<(usize, usize)> {
358 let mut start = None;
359 let mut end = lines.len();
360 let mut in_fence = false;
361 for (i, raw) in lines.iter().enumerate() {
362 let line = raw.trim_end_matches('\r');
363 if line.trim_start().starts_with("```") {
364 in_fence = !in_fence;
365 continue;
366 }
367 if in_fence {
368 continue;
369 }
370 if let Some((level, rest)) = heading(line)
371 && level == col
372 {
373 match start {
374 None if strip_marker(rest) == section => start = Some(i + 1),
375 Some(_) => {
376 end = i;
377 break;
378 }
379 None => {}
380 }
381 }
382 }
383 start.map(|s| (s, end))
384}
385
386/// The line span `[from, to)` of the task titled `title` within a section body
387/// `[start, end)` — a ticket (heading + body) or a checkbox line (+ indented
388/// details). Trailing blank lines are excluded so the span is the task's own
389/// content. `None` if not found.
390fn find_task_block(
391 lines: &[&str],
392 col: u8,
393 start: usize,
394 end: usize,
395 title: &str,
396) -> Option<(usize, usize)> {
397 let mut from = None;
398 let mut to = end;
399 let mut i = start;
400 let mut fence = false;
401 while i < end {
402 let line = lines[i].trim_end_matches('\r');
403 if line.trim_start().starts_with("```") {
404 fence = !fence;
405 i += 1;
406 continue;
407 }
408 if !fence {
409 if let Some((level, rest)) = heading(line) {
410 if level > col {
411 let body_end = ticket_body_end(lines, end, i + 1);
412 if strip_marker(rest) == title {
413 from = Some(i);
414 to = body_end;
415 break;
416 }
417 i = body_end;
418 continue;
419 }
420 } else if let Some((_, t)) = task_line(line) {
421 let mut j = i + 1;
422 while j < end && lines[j].starts_with(" ") {
423 j += 1;
424 }
425 if t == title {
426 from = Some(i);
427 to = j;
428 break;
429 }
430 i = j;
431 continue;
432 }
433 }
434 i += 1;
435 }
436 let from = from?;
437 while to > from && lines[to - 1].trim().is_empty() {
438 to -= 1;
439 }
440 Some((from, to))
441}
442
443/// The start line of the `n`-th (0-based) task within `[start, end)`, or `None`
444/// if there are fewer than `n + 1` tasks (caller appends at the section end).
445fn nth_task_start(lines: &[&str], col: u8, start: usize, end: usize, n: usize) -> Option<usize> {
446 let mut count = 0;
447 let mut i = start;
448 let mut fence = false;
449 while i < end {
450 let line = lines[i].trim_end_matches('\r');
451 if line.trim_start().starts_with("```") {
452 fence = !fence;
453 i += 1;
454 continue;
455 }
456 if !fence {
457 if let Some((level, _)) = heading(line) {
458 if level > col {
459 if count == n {
460 return Some(i);
461 }
462 count += 1;
463 i = ticket_body_end(lines, end, i + 1);
464 continue;
465 }
466 } else if task_line(line).is_some() {
467 if count == n {
468 return Some(i);
469 }
470 count += 1;
471 let mut j = i + 1;
472 while j < end && lines[j].starts_with(" ") {
473 j += 1;
474 }
475 i = j;
476 continue;
477 }
478 }
479 i += 1;
480 }
481 None
482}
483
484/// The inverse of [`add_task`]: delete the task titled `title` from the section
485/// named `section`, touching no other byte (round-trip rule). Handles both a
486/// ticket (a nested `## title` heading and its body, up to the next heading)
487/// and a `- [ ]` checkbox task (its line plus indented detail lines). Returns
488/// `None` if the section or task isn't found. Removes its own trailing blank
489/// lines and collapses the blank seam left behind so spacing stays tidy.
490pub fn remove_task(text: &str, section: &str, title: &str) -> Option<String> {
491 let lines: Vec<&str> = text.split('\n').collect();
492 let col = column_level(text);
493 if col == 0 {
494 return None;
495 }
496 let (start, end) = find_section_body(&lines, col, section)?;
497 let (from, to) = find_task_block(&lines, col, start, end, title)?;
498
499 let mut out: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
500 out.drain(from..to);
501 if from > 0
502 && from < out.len()
503 && out[from - 1].trim().is_empty()
504 && out[from].trim().is_empty()
505 {
506 out.remove(from);
507 }
508 Some(out.join("\n"))
509}
510
511/// Move the task titled `title` from `from_section` to position `to_index`
512/// (0-based, among that section's tasks) of `to_section` — the same section to
513/// reorder, or a different one to move between columns. The task's own block is
514/// preserved verbatim; only its placement (and the surrounding blank-line
515/// spacing) changes. `None` if the task or either section isn't found.
516pub fn move_task(
517 text: &str,
518 title: &str,
519 from_section: &str,
520 to_section: &str,
521 to_index: usize,
522) -> Option<String> {
523 let lines: Vec<&str> = text.split('\n').collect();
524 let col = column_level(text);
525 if col == 0 {
526 return None;
527 }
528
529 // Cut the task's block out of its source section.
530 let (fs, fe) = find_section_body(&lines, col, from_section)?;
531 let (from, to) = find_task_block(&lines, col, fs, fe, title)?;
532 let block: Vec<String> = lines[from..to].iter().map(|l| l.to_string()).collect();
533
534 let mut out: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
535 out.drain(from..to);
536 if from > 0
537 && from < out.len()
538 && out[from - 1].trim().is_empty()
539 && out[from].trim().is_empty()
540 {
541 out.remove(from);
542 }
543
544 // Re-locate the target section in the post-removal document and splice the
545 // block back in. Tickets (a `## heading` block) are blank-line separated;
546 // checkbox tasks are a tight list — so only pad tickets, or a move would
547 // keep injecting stray blank lines into a checkbox column.
548 let is_ticket = block
549 .first()
550 .is_some_and(|l| heading(l.trim_end_matches('\r')).is_some());
551 let refs: Vec<&str> = out.iter().map(|s| s.as_str()).collect();
552 let (ts, te) = find_section_body(&refs, col, to_section)?;
553 let mut ins: Vec<String> = Vec::new();
554 let at = match nth_task_start(&refs, col, ts, te, to_index) {
555 // Before the to_index-th task.
556 Some(at) => {
557 if is_ticket {
558 if at > ts && !refs[at - 1].trim().is_empty() {
559 ins.push(String::new()); // ensure a blank before the heading
560 }
561 ins.extend(block);
562 ins.push(String::new()); // and a blank before the following task
563 } else {
564 ins.extend(block); // tight: slot the list item straight in
565 }
566 at
567 }
568 // Past the last task: append after the section's last non-blank line.
569 None => {
570 let mut at = te;
571 while at > ts && refs[at - 1].trim().is_empty() {
572 at -= 1;
573 }
574 if is_ticket {
575 ins.push(String::new());
576 }
577 ins.extend(block);
578 at
579 }
580 };
581 out.splice(at..at, ins);
582 Some(out.join("\n"))
583}
584
585/// What the board needs to render per-task delete controls: the repo
586/// coordinates, the branch tip (compare-and-swap guard), and a CSRF token.
587/// `None` renders a read-only board.
588pub struct BoardActions<'a> {
589 pub owner: &'a str,
590 pub repo: &'a str,
591 pub rev: &'a str,
592 pub path: &'a str,
593 pub tip: &'a str,
594 pub csrf: &'a str,
595}
596
597/// The repo-home teaser: where the board's head link and its "+N more"
598/// overflow point, and the file name to show.
599pub struct Preview<'a> {
600 /// The `TODO.md` blob view — the full board.
601 pub href: &'a str,
602 /// File name as written in the tree, e.g. `TODO.md`.
603 pub name: &'a str,
604}
605
606/// Tasks a column shows before the rest is deferred to the file view. Capping
607/// by task rather than by height means the teaser always ends on a whole task.
608const PREVIEW_TASKS: usize = 5;
609
610/// Render a todo-md document as a kanban board (columns = task-bearing
611/// sections) with prose sections as a notes area below. `None` if the file
612/// contains no tasks at all — callers fall back to plain markdown.
613pub fn render_board(text: &str, actions: Option<&BoardActions>) -> Option<Markup> {
614 board(text, actions, None)
615}
616
617/// The same board as a repo-home teaser: columns capped, notes left to the
618/// file view, and never editable (the blob view owns the actions).
619pub fn render_board_preview(text: &str, preview: &Preview) -> Option<Markup> {
620 board(text, None, Some(preview))
621}
622
623/// A column's tally: open tasks, or the total for a section marked done.
624fn count_label(s: &Section) -> String {
625 if s.done {
626 format!("{} done", s.tasks.len())
627 } else {
628 format!("{} open", s.tasks.iter().filter(|t| !t.done).count())
629 }
630}
631
632fn board(text: &str, actions: Option<&BoardActions>, preview: Option<&Preview>) -> Option<Markup> {
633 let sections = parse(text);
634 if sections.iter().all(|s| s.tasks.is_empty()) {
635 return None;
636 }
637 let cols: Vec<&Section> = sections.iter().filter(|s| !s.tasks.is_empty()).collect();
638
639 // Prose-only sections (and stray preamble/column prose) become one
640 // markdown notes blob, headings preserved. The teaser skips it: the file
641 // view it links to is one click away and carries the whole document.
642 let mut notes = String::new();
643 for s in sections.iter().filter(|_| preview.is_none()) {
644 let prose_empty = s.prose.trim().is_empty();
645 if s.tasks.is_empty() && s.level > 0 && prose_empty {
646 continue; // empty section: nothing to show either way
647 }
648 if s.tasks.is_empty() {
649 if s.level > 0 {
650 notes.push_str(&format!("{} {}\n", "#".repeat(s.level as usize), s.title));
651 }
652 if !prose_empty {
653 notes.push_str(&s.prose);
654 notes.push('\n');
655 }
656 }
657 }
658
659 // A one-column teaser would stack two labels for the same thing — the file
660 // name over the column heading ("TODO.md" / "TODO"). Fold the count into
661 // the head link instead and drop the heading.
662 let lone = preview.is_some() && cols.len() == 1;
663 let cap = preview.map_or(usize::MAX, |_| PREVIEW_TASKS);
664
665 let move_url = actions.map(|a| {
666 format!(
667 "/{}/{}/move-task/{}/{}",
668 a.owner,
669 a.repo,
670 crate::ui::enc_ref(a.rev),
671 a.path
672 )
673 });
674 Some(html! {
675 @if let Some(p) = preview {
676 p.todo-board-head {
677 a href=(p.href) { (p.name) }
678 @if lone { span.count { (count_label(cols[0])) } }
679 }
680 }
681 div.kanban data-move-url=[move_url.as_deref()]
682 data-tip=[actions.map(|a| a.tip)] data-csrf=[actions.map(|a| a.csrf)] {
683 @for s in &cols {
684 div.col data-section=[actions.map(|_| s.title.as_str())] {
685 @if !lone {
686 h3 {
687 @if s.title.is_empty() { "Tasks" } @else { (s.title) }
688 span.count { (count_label(s)) }
689 }
690 }
691 ol.tasks {
692 @for t in s.tasks.iter().take(cap) {
693 li.task.done[t.done]
694 data-title=[actions.map(|_| t.title.as_str())] {
695 // The bead reads the task's state and, on an
696 // editable board, is also what you grab to
697 // reorder it — one mark on the rail per task.
698 span.task-bead title=[actions.map(|_| "Drag to reorder")]
699 aria-hidden="true" {}
700 @if t.details.trim().is_empty() {
701 div.task-title { (render_markdown_inline(&t.title)) }
702 } @else {
703 details {
704 summary.task-title { (render_markdown_inline(&t.title)) }
705 div.task-body { (render_markdown(&t.details)) }
706 }
707 }
708 @if let Some(a) = actions {
709 form.task-del method="post"
710 action=(format!("/{}/{}/delete-task/{}/{}", a.owner, a.repo, crate::ui::enc_ref(a.rev), a.path)) {
711 (crate::ui::csrf_input(a.csrf))
712 input type="hidden" name="expected_tip" value=(a.tip);
713 input type="hidden" name="section" value=(s.title);
714 input type="hidden" name="title" value=(t.title);
715 button.task-del-btn type="submit" title="Delete task" aria-label="Delete task" { "×" }
716 }
717 }
718 }
719 }
720 @if let Some(p) = preview {
721 @let hidden = s.tasks.len().saturating_sub(cap);
722 @if hidden > 0 {
723 li.task-more {
724 a href=(p.href) { "+" (hidden) " more" }
725 }
726 }
727 }
728 }
729 }
730 }
731 }
732 @if !notes.trim().is_empty() {
733 details.todo-notes {
734 summary { "notes" }
735 div.md-body { (render_markdown(&notes)) }
736 }
737 }
738 @if actions.is_some() { script { (maud::PreEscaped(BOARD_DND_JS)) } }
739 })
740}
741
742/// Drag-and-drop reordering for the board, via a per-card grip handle and
743/// Pointer Events — so it works with mouse, touch, and pen alike (plain HTML5
744/// drag-and-drop never fires on touchscreens). Grab a card's grip, drag within
745/// or across columns; the card reorders live, and on release the new
746/// (section, index) is POSTed to the move endpoint and the page reloads.
747/// Reads the endpoint/tip/csrf from the `.kanban` element's data-attrs.
748const BOARD_DND_JS: &str = r#"
749(function(){
750 var board = document.querySelector('.kanban[data-move-url]');
751 if (!board) return;
752 var url = board.dataset.moveUrl, tip = board.dataset.tip, csrf = board.dataset.csrf;
753 var card = null, fromSection = null, grip = null, pid = null, moved = false;
754
755 // The column whose box contains (x, y) — by geometry, so it works for both
756 // the horizontal desktop layout and the stacked mobile layout, and doesn't
757 // depend on elementFromPoint (which the dragged card can shadow).
758 function colAt(x, y){
759 var cols = board.querySelectorAll('.col');
760 for (var i = 0; i < cols.length; i++){
761 var b = cols[i].getBoundingClientRect();
762 if (x >= b.left && x <= b.right && y >= b.top && y <= b.bottom) return cols[i];
763 }
764 return null;
765 }
766 function afterCard(list, y){
767 var cards = [].slice.call(list.querySelectorAll('.task:not(.dragging)'));
768 var best = null, bestOffset = -Infinity;
769 cards.forEach(function(c){
770 var box = c.getBoundingClientRect();
771 var offset = y - box.top - box.height / 2;
772 if (offset < 0 && offset > bestOffset) { bestOffset = offset; best = c; }
773 });
774 return best;
775 }
776 function onMove(e){
777 if (!card || e.pointerId !== pid) return;
778 e.preventDefault(); // stop the page scrolling mid-drag
779 moved = true;
780 var col = colAt(e.clientX, e.clientY) || card.closest('.col');
781 var list = col.querySelector('.tasks'); // tasks live on the column's rail
782 var after = afterCard(list, e.clientY);
783 if (after == null) list.appendChild(card);
784 else list.insertBefore(card, after);
785 }
786 function onUp(e){
787 if (!card || (e && e.pointerId !== pid)) return;
788 document.removeEventListener('pointermove', onMove);
789 document.removeEventListener('pointerup', onUp);
790 document.removeEventListener('pointercancel', onUp);
791 var c = card;
792 card = null;
793 c.classList.remove('dragging');
794 try { grip.releasePointerCapture(pid); } catch (_) {}
795 if (!moved) return; // a tap or cancelled gesture — nothing to commit
796 var col = c.closest('.col');
797 var cards = [].slice.call(col.querySelectorAll('.task'));
798 var body = new URLSearchParams();
799 body.set('csrf', csrf);
800 body.set('expected_tip', tip);
801 body.set('from_section', fromSection);
802 body.set('to_section', col.dataset.section);
803 body.set('title', c.dataset.title);
804 body.set('to_index', String(cards.indexOf(c)));
805 fetch(url, {
806 method: 'POST',
807 headers: {'X-CSRF-Token': csrf, 'Content-Type': 'application/x-www-form-urlencoded'},
808 body: body.toString()
809 }).then(function(r){
810 if (!r.ok) throw new Error('move failed (' + r.status + ')');
811 location.reload();
812 }).catch(function(err){ console.error(err); location.reload(); });
813 }
814 board.addEventListener('pointerdown', function(e){
815 var g = e.target.closest('.task-bead');
816 if (!g) return;
817 var c = g.closest('.task');
818 if (!c) return;
819 e.preventDefault();
820 card = c; grip = g; pid = e.pointerId; moved = false;
821 fromSection = c.closest('.col').dataset.section;
822 c.classList.add('dragging');
823 // Capture is best-effort (it can throw on some browsers); the move/up
824 // listeners live on `document` so the drag works whether or not it sticks.
825 try { g.setPointerCapture(e.pointerId); } catch (_) {}
826 document.addEventListener('pointermove', onMove, { passive: false });
827 document.addEventListener('pointerup', onUp);
828 document.addEventListener('pointercancel', onUp);
829 });
830})();
831"#;
832
833#[cfg(test)]
834mod tests {
835 use super::*;
836
837 const DOC: &str = "\
838intro prose
839
840# Now
841
842- [ ] first task
843 - a detail line
844 - another `detail`
845- [x] finished task
846
847# Done [x]
848
849- [ ] moved here, checkbox stale
850
851## Implement Navbar
852
853Sticky top bar with the repo switcher.
854
855- shipped behind a flag
856- needs a follow-up for mobile
857
858# Notes
859
860just prose, no tasks
861
862```
863# not a heading
864- [ ] not a task
865```
866";
867
868 #[test]
869 fn parses_sections_tasks_details() {
870 let s = parse(DOC);
871 assert_eq!(s.len(), 4); // preamble + 3 column headings (## is a ticket)
872 assert_eq!(s[0].level, 0);
873 assert_eq!(s[0].prose.trim(), "intro prose");
874
875 assert_eq!(s[1].title, "Now");
876 assert!(!s[1].done);
877 assert_eq!(s[1].tasks.len(), 2);
878 assert_eq!(s[1].tasks[0].title, "first task");
879 assert!(!s[1].tasks[0].done);
880 assert!(!s[1].tasks[0].ticket);
881 assert_eq!(s[1].tasks[0].details, "- a detail line\n- another `detail`");
882 assert!(s[1].tasks[1].done);
883
884 // Done section: heading marker wins over the task's own checkbox.
885 assert_eq!(s[2].title, "Done");
886 assert!(s[2].done);
887 assert_eq!(s[2].tasks.len(), 2); // checkbox task + the ticket
888 assert!(s[2].tasks[0].done);
889 assert!(!s[2].tasks[0].ticket);
890 }
891
892 #[test]
893 fn nested_heading_is_a_ticket() {
894 let s = parse(DOC);
895 let ticket = &s[2].tasks[1];
896 assert!(ticket.ticket);
897 assert_eq!(ticket.title, "Implement Navbar");
898 // Done inherited from the `# Done [x]` section.
899 assert!(ticket.done);
900 assert!(ticket.details.contains("Sticky top bar"));
901 assert!(ticket.details.contains("- needs a follow-up for mobile"));
902 // The body must not leak into a sibling section.
903 assert!(!ticket.details.contains("just prose"));
904 }
905
906 #[test]
907 fn ticket_own_done_marker() {
908 let s = parse("# Backlog\n\n## Fix login [x]\n\nbody\n\n## Add search\n\nbody\n");
909 assert!(!s[1].done); // section is open
910 assert_eq!(s[1].tasks.len(), 2);
911 assert!(s[1].tasks[0].done); // ticket marked done on its own heading
912 assert_eq!(s[1].tasks[0].title, "Fix login");
913 assert!(!s[1].tasks[1].done);
914 }
915
916 #[test]
917 fn todo_md_by_filename() {
918 assert!(is_todo_md("TODO.md"));
919 assert!(is_todo_md("docs/todo.MD"));
920 assert!(!is_todo_md("TODO.txt"));
921 assert!(!is_todo_md("NOT-TODO.md"));
922 }
923
924 #[test]
925 fn board_renders_columns_tickets_and_falls_back() {
926 let board = render_board(DOC, None).expect("has tasks").into_string();
927 assert!(board.contains("kanban"));
928 assert!(board.contains("Now"));
929 assert!(board.contains("first task"));
930 // Tickets render as ordinary tasks — no checkbox input, no special
931 // class. (The DOC text mentions "checkbox", so check elements.)
932 assert!(board.contains("Implement Navbar"));
933 assert!(!board.contains("<input"), "no checkbox clutter: {board}");
934 assert!(!board.contains("ticket\""), "no ticket task class: {board}");
935 assert!(board.contains("just prose"), "notes area kept: {board}");
936 assert!(render_board("# readme\n\nonly prose\n", None).is_none());
937 }
938
939 #[test]
940 fn preview_caps_columns_and_links_the_rest() {
941 let mut doc = String::from("# Now\n\n");
942 for i in 1..=8 {
943 doc.push_str(&format!("- [ ] task {i}\n"));
944 }
945 doc.push_str("\n# Notes\n\nsome prose\n");
946 let p = Preview {
947 href: "/o/r/blob/master/TODO.md",
948 name: "TODO.md",
949 };
950 let out = render_board_preview(&doc, &p)
951 .expect("has tasks")
952 .into_string();
953 assert!(out.contains("task 5"), "shows the first five: {out}");
954 assert!(!out.contains("task 6"), "and no more: {out}");
955 assert!(out.contains("+3 more"), "defers the rest: {out}");
956 assert!(out.contains(p.href), "and links the file: {out}");
957 // One column: its heading would only repeat the file name, so the
958 // tally rides on the head link instead.
959 assert!(!out.contains("<h3"), "no lone column heading: {out}");
960 assert!(out.contains("8 open"), "tally on the head: {out}");
961 assert!(!out.contains("some prose"), "notes stay in the file: {out}");
962 }
963
964 #[test]
965 fn preview_keeps_headings_when_there_are_several_columns() {
966 let p = Preview {
967 href: "/o/r/blob/master/TODO.md",
968 name: "TODO.md",
969 };
970 let out = render_board_preview(DOC, &p)
971 .expect("has tasks")
972 .into_string();
973 assert!(out.contains("<h3"), "columns keep their headings: {out}");
974 assert!(
975 !out.contains("more</a>"),
976 "short columns need no overflow: {out}"
977 );
978 }
979
980 #[test]
981 fn numbered_ticket_title_stays_a_title() {
982 let out = render_board("# Now\n\n## 1. Undo across a hand boundary\n\nbody\n", None)
983 .expect("has tasks")
984 .into_string();
985 assert!(
986 out.contains(r#"<summary class="task-title">1. Undo across a hand boundary</summary>"#),
987 "title is inline, not a one-item list: {out}"
988 );
989 // The only list on the board is the column's own task rail.
990 assert_eq!(out.matches("<ol").count(), 1, "{out}");
991 }
992
993 #[test]
994 fn add_task_appends_ticket_within_section_byte_exactly() {
995 let out = add_task(DOC, "Now", "new ticket", "").unwrap();
996 // Lands as a nested heading after the section's last non-blank line,
997 // padded by a blank line, before the next column heading.
998 assert!(out.contains("- [x] finished task\n\n## new ticket\n\n# Done"));
999 // Round-trip rule: removing the inserted ticket restores the original.
1000 assert_eq!(out.replacen("## new ticket\n\n", "", 1), DOC);
1001 }
1002
1003 #[test]
1004 fn add_task_with_body_writes_heading_then_description() {
1005 let out = add_task("# Now\n# Done\n", "Now", "Title", "Line one\nLine two").unwrap();
1006 assert_eq!(out, "# Now\n\n## Title\n\nLine one\nLine two\n# Done\n");
1007 // The body becomes the ticket's parsed details.
1008 let now = parse(&out).into_iter().find(|s| s.title == "Now").unwrap();
1009 let ticket = now.tasks.iter().find(|t| t.title == "Title").unwrap();
1010 assert!(ticket.ticket);
1011 assert_eq!(ticket.details.trim(), "Line one\nLine two");
1012 }
1013
1014 #[test]
1015 fn add_task_ticket_level_tracks_the_column_level() {
1016 // Columns at `##` ⇒ tickets nest at `###`.
1017 let doc = "## Backlog\n\n### Existing\n\nbody\n";
1018 let out = add_task(doc, "Backlog", "New one", "").unwrap();
1019 assert!(out.contains("body\n\n### New one"));
1020 }
1021
1022 #[test]
1023 fn add_task_to_done_section_inherits_done_no_checkbox() {
1024 let out = add_task(DOC, "Done", "tidy up", "").unwrap();
1025 assert!(out.contains("## tidy up"));
1026 assert!(!out.contains("- [ ] tidy up") && !out.contains("- [x] tidy up"));
1027 // Parses as a ticket under the done column, so it reads as done.
1028 let done = parse(&out).into_iter().find(|s| s.title == "Done").unwrap();
1029 assert!(
1030 done.tasks
1031 .iter()
1032 .any(|t| t.ticket && t.title == "tidy up" && t.done)
1033 );
1034 }
1035
1036 #[test]
1037 fn add_task_into_empty_section_inserts_blank_then_ticket() {
1038 let doc = "# Now\n# Done\n";
1039 assert_eq!(
1040 add_task(doc, "Now", "first", "").unwrap(),
1041 "# Now\n\n## first\n# Done\n"
1042 );
1043 }
1044
1045 #[test]
1046 fn add_task_rejects_missing_section_blank_title_and_headingless() {
1047 assert!(add_task(DOC, "Nonexistent", "x", "").is_none());
1048 assert!(add_task(DOC, "Now", " ", "body still rejected").is_none());
1049 assert!(add_task("no headings here\n", "Whatever", "x", "").is_none());
1050 }
1051
1052 #[test]
1053 fn added_ticket_renders_as_a_board_card() {
1054 let out = add_task(DOC, "Now", "Wire uploads", "").unwrap();
1055 let board = render_board(&out, None).expect("has tasks").into_string();
1056 assert!(board.contains("Wire uploads"));
1057 }
1058
1059 #[test]
1060 fn task_sections_lists_column_headings_stripped() {
1061 assert_eq!(task_sections(DOC), ["Now", "Done", "Notes"]);
1062 assert!(task_sections("# readme\n\nonly prose\n") == ["readme"]);
1063 assert!(task_sections("no headings at all\n").is_empty());
1064 }
1065
1066 #[test]
1067 fn add_then_remove_task_round_trips() {
1068 // Removing a just-added ticket restores the document byte-for-byte.
1069 let added = add_task(DOC, "Now", "throwaway", "with a body line").unwrap();
1070 assert_eq!(
1071 remove_task(&added, "Now", "throwaway").as_deref(),
1072 Some(DOC)
1073 );
1074 }
1075
1076 #[test]
1077 fn remove_task_deletes_ticket_block() {
1078 let doc = "# Now\n\n## first\n\nbody one\n\n## second\n\nbody two\n";
1079 assert_eq!(
1080 remove_task(doc, "Now", "first").unwrap(),
1081 "# Now\n\n## second\n\nbody two\n"
1082 );
1083 // Removing the last ticket leaves the section heading intact.
1084 assert_eq!(
1085 remove_task(doc, "Now", "second").unwrap(),
1086 "# Now\n\n## first\n\nbody one\n"
1087 );
1088 }
1089
1090 #[test]
1091 fn remove_task_deletes_checkbox_with_its_details() {
1092 let doc = "# Now\n\n- [ ] a\n detail of a\n- [x] b\n\n# Done\n";
1093 assert_eq!(
1094 remove_task(doc, "Now", "a").unwrap(),
1095 "# Now\n\n- [x] b\n\n# Done\n"
1096 );
1097 }
1098
1099 #[test]
1100 fn remove_task_rejects_missing_section_or_task() {
1101 assert!(remove_task(DOC, "Nope", "first task").is_none());
1102 assert!(remove_task(DOC, "Now", "not a real task").is_none());
1103 }
1104
1105 /// Ticket titles of a section, in order — order is what reordering changes.
1106 fn order(text: &str, section: &str) -> Vec<String> {
1107 parse(text)
1108 .into_iter()
1109 .find(|s| s.title == section)
1110 .map(|s| s.tasks.into_iter().map(|t| t.title).collect())
1111 .unwrap_or_default()
1112 }
1113
1114 #[test]
1115 fn move_task_reorders_within_a_section() {
1116 let doc = "# Now\n\n## a\n\n## b\n\n## c\n";
1117 assert_eq!(
1118 order(&move_task(doc, "c", "Now", "Now", 0).unwrap(), "Now"),
1119 ["c", "a", "b"]
1120 );
1121 // to_index past the end appends.
1122 assert_eq!(
1123 order(&move_task(doc, "a", "Now", "Now", 9).unwrap(), "Now"),
1124 ["b", "c", "a"]
1125 );
1126 }
1127
1128 #[test]
1129 fn move_task_across_sections_preserves_body() {
1130 let doc = "# Now\n\n## a\n\nbody of a\n\n# Done\n\n## b\n";
1131 let out = move_task(doc, "a", "Now", "Done", 1).unwrap();
1132 assert_eq!(order(&out, "Now"), Vec::<String>::new());
1133 assert_eq!(order(&out, "Done"), ["b", "a"]);
1134 assert!(
1135 out.contains("## a\n\nbody of a"),
1136 "body travels with the ticket"
1137 );
1138 }
1139
1140 #[test]
1141 fn move_task_rejects_unknown_task_or_section() {
1142 let doc = "# Now\n\n## a\n\n# Done\n";
1143 assert!(move_task(doc, "ghost", "Now", "Done", 0).is_none());
1144 assert!(move_task(doc, "a", "Now", "Nope", 0).is_none());
1145 }
1146
1147 #[test]
1148 fn move_checkbox_task_keeps_the_list_tight() {
1149 // A checkbox column is a tight list — moving must not inject blanks.
1150 let doc = "# Backlog\n\n- [ ] a\n- [ ] b\n- [ ] c\n";
1151 assert_eq!(
1152 move_task(doc, "c", "Backlog", "Backlog", 0).unwrap(),
1153 "# Backlog\n\n- [ ] c\n- [ ] a\n- [ ] b\n"
1154 );
1155 }
1156
1157 #[test]
1158 fn move_task_to_same_spot_is_a_noop() {
1159 // Dropping a card back where it was must not change a byte (no stray
1160 // newlines) — for both checkbox lists and ticket columns.
1161 let checks = "# Backlog\n\n- [ ] a\n- [ ] b\n- [ ] c\n";
1162 assert_eq!(
1163 move_task(checks, "b", "Backlog", "Backlog", 1).as_deref(),
1164 Some(checks)
1165 );
1166 let tickets = "# Now\n\n## a\n\n## b\n\n## c\n";
1167 assert_eq!(
1168 move_task(tickets, "b", "Now", "Now", 1).as_deref(),
1169 Some(tickets)
1170 );
1171 }
1172}