anvilsign in

collin/anvil

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