From 66b4374b483af02c325e98dfa0c91d049b3945c0 Mon Sep 17 00:00:00 2001 From: luxick Date: Mon, 4 May 2026 11:37:38 +0200 Subject: [PATCH] Allow checking off tasks from rendered pages --- assets/page.html | 1 + assets/style.css | 6 +++ assets/tasks.js | 24 ++++++++++ main.go | 4 ++ render.go | 2 +- tasks.go | 112 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 assets/tasks.js create mode 100644 tasks.go diff --git a/assets/page.html b/assets/page.html index e628f45..0af04b4 100644 --- a/assets/page.html +++ b/assets/page.html @@ -11,6 +11,7 @@ + {{end}} {{if .Content}} diff --git a/assets/style.css b/assets/style.css index 1b324a7..1a4c66a 100644 --- a/assets/style.css +++ b/assets/style.css @@ -439,6 +439,12 @@ textarea { font-size: 0.85rem; } +/* === Task lists === */ +.content li:has(> input.task-checkbox:checked) { + color: var(--text-muted); + text-decoration: line-through; +} + /* === Photo grid === */ .photo-grid { display: grid; diff --git a/assets/tasks.js b/assets/tasks.js new file mode 100644 index 0000000..180ce36 --- /dev/null +++ b/assets/tasks.js @@ -0,0 +1,24 @@ +(function () { + document.querySelectorAll('input.task-checkbox[data-task-index]').forEach(function (cb) { + cb.addEventListener('change', function () { + var idx = cb.dataset.taskIndex; + var checked = cb.checked; + cb.disabled = true; + fetch(window.location.pathname + '?toggle=' + idx, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'checked=' + checked + }).then(function (res) { + if (!res.ok) { + cb.checked = !checked; + alert('Failed to save task state (' + res.status + ')'); + } + }).catch(function () { + cb.checked = !checked; + alert('Failed to save task state'); + }).finally(function () { + cb.disabled = false; + }); + }); + }); +})(); diff --git a/main.go b/main.go index 25ba057..a21b528 100644 --- a/main.go +++ b/main.go @@ -249,6 +249,10 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs h.handleMove(w, r, urlPath, fsPath, query.Get("move")) return } + if query.Has("toggle") { + h.handleToggle(w, r, fsPath) + return + } if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) diff --git a/render.go b/render.go index fc4aa53..eba39ed 100644 --- a/render.go +++ b/render.go @@ -70,7 +70,7 @@ func renderMarkdown(raw []byte) template.HTML { if err := md.Convert(raw, &buf); err != nil { return "" } - return template.HTML(buf.String()) + return template.HTML(rewriteTaskCheckboxes(buf.Bytes())) } // extractFirstHeading returns the text of the first ATX heading in raw markdown, diff --git a/tasks.go b/tasks.go new file mode 100644 index 0000000..3cf5e15 --- /dev/null +++ b/tasks.go @@ -0,0 +1,112 @@ +package main + +import ( + "bytes" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" +) + +// taskCheckboxRe matches the tag goldmark's GFM extension emits for a +// task list checkbox. Used to enumerate and rewrite checkboxes in rendered HTML. +var taskCheckboxRe = regexp.MustCompile(``) + +// taskLineRe matches a markdown task list line: leading whitespace, a bullet, +// then a `[ ]` / `[x]` / `[X]` checkbox marker. +var taskLineRe = regexp.MustCompile(`^(\s*[-*+]\s+)\[([ xX])\]`) + +// rewriteTaskCheckboxes enables and indexes the task checkboxes in rendered +// HTML so JS can wire them up. Each checkbox gains a data-task-index matching +// its position among task list items in source order; the disabled attribute +// is removed so the user can toggle them. +func rewriteTaskCheckboxes(in []byte) []byte { + idx := 0 + return taskCheckboxRe.ReplaceAllFunc(in, func(match []byte) []byte { + checked := bytes.Contains(match, []byte("checked")) + var out bytes.Buffer + out.WriteString(`') + idx++ + return out.Bytes() + }) +} + +// handleToggle flips the Nth task list checkbox in index.md based on the +// `toggle` query param and `checked` form value. Indices match the order in +// which goldmark emits checkboxes, which is source order excluding fenced +// code blocks. +func (h *handler) handleToggle(w http.ResponseWriter, r *http.Request, fsPath string) { + n, err := strconv.Atoi(r.URL.Query().Get("toggle")) + if err != nil || n < 0 { + http.Error(w, "bad toggle index", http.StatusBadRequest) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + checked := r.FormValue("checked") == "true" + + indexPath := filepath.Join(fsPath, "index.md") + raw, err := os.ReadFile(indexPath) + if err != nil { + http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError) + return + } + + updated, ok := flipTaskLine(raw, n, checked) + if !ok { + http.Error(w, "task not found", http.StatusNotFound) + return + } + + if err := writeFileAtomic(indexPath, updated, 0644); err != nil { + http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// flipTaskLine returns raw with the Nth task list bullet's `[ ]`/`[x]` marker +// set according to checked. Lines inside fenced code blocks are skipped so +// they do not consume an index. Returns ok=false when there is no Nth task. +func flipTaskLine(raw []byte, n int, checked bool) ([]byte, bool) { + lines := bytes.Split(raw, []byte("\n")) + inFence := false + count := 0 + target := -1 + for i, line := range lines { + trimmed := bytes.TrimLeft(line, " \t") + if bytes.HasPrefix(trimmed, []byte("```")) || bytes.HasPrefix(trimmed, []byte("~~~")) { + inFence = !inFence + continue + } + if inFence { + continue + } + if !taskLineRe.Match(line) { + continue + } + if count == n { + target = i + break + } + count++ + } + if target == -1 { + return nil, false + } + replacement := []byte("${1}[ ]") + if checked { + replacement = []byte("${1}[x]") + } + lines[target] = taskLineRe.ReplaceAll(lines[target], replacement) + return bytes.Join(lines, []byte("\n")), true +}