From bf16f2ec3c490313a0b3a98441d585ad8b0df060 Mon Sep 17 00:00:00 2001 From: luxick Date: Sat, 23 May 2026 08:44:19 +0200 Subject: [PATCH] improve task list handling --- CLAUDE.md | 3 + assets/page/actions.js | 48 +++++++--- assets/page/anchors.js | 119 +++++++++++++++++++++--- assets/page/main.html | 3 + assets/page/tasks.js | 21 +++++ assets/page/toc.js | 2 +- assets/style.css | 14 ++- main.go | 8 ++ tasks.go | 199 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 392 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ba3e31c..7b533b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,9 @@ Prefer separate, human-readable `.html` files over inlined HTML strings in Go. E - Editor toolbar buttons use `data-action` + `data-key`; adding `data-key` auto-registers the shortcut - Prefer generic, descriptive CSS classes (`btn`, `btn-small`, `muted`, `danger`) over element-specific names (`save-button`, `cancel-button`, `form-name-input`). Use a modifier + base class pattern (`btn btn-small`) rather than one-off classes that duplicate shared styles. - Where possible, re-use existing CSS classes +- For mutating modals (anything that POSTs and then navigates), call `closeModal()` and then `postReplace(action, body, target)` from `page/actions.js`. Do NOT use `
.submit()`. Two reasons: + 1. The modal must be removed from the DOM before navigation, or the browser's bfcache snapshots it open and back-nav restores the modal. + 2. `postReplace` uses `window.location.replace` so the action + result occupy a single history entry. A naive POST → 303 → GET creates two entries, and back-nav lands on a stale pre-mutation snapshot of the same page. ## Development Priorities diff --git a/assets/page/actions.js b/assets/page/actions.js index 2a933f8..b7fc9a4 100644 --- a/assets/page/actions.js +++ b/assets/page/actions.js @@ -3,6 +3,36 @@ function encodePickedPath(p) { return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/'); } +// postReplace POSTs to action with the optional form body, then loads target +// into the current history entry — so the action and its result occupy one +// entry instead of two, and back-navigation skips past the stale pre-mutation +// snapshot in bfcache. body may be null for empty POSTs. +// +// We can't just call window.location.replace(target): when target differs from +// the current URL only by fragment, the browser updates the URL bar without +// re-fetching, so a server-side mutation wouldn't be reflected. Instead, +// rewrite the current entry's URL via history.replaceState, then reload — the +// reload always re-fetches and preserves the (new) URL including its fragment. +function postReplace(action, body, target) { + var init = { method: 'POST', redirect: 'manual' }; + if (body) { + init.headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; + init.body = body; + } + fetch(action, init).then(function (res) { + if (res.type === 'opaqueredirect' || res.ok) { + window.history.replaceState(null, '', target); + window.location.reload(); + return; + } + return res.text().then(function (msg) { + alert(msg || ('Request failed (' + res.status + ')')); + }); + }).catch(function () { + alert('Network error'); + }); +} + function promptPageName(title, initial, confirmLabel, onName) { var input = document.createElement('input'); input.type = 'text'; @@ -89,11 +119,9 @@ function movePage() { var action = window.location.pathname + '?move=' + encodeURIComponent(dest); if (linksCheckbox.checked) action += '&links=1'; - var form = document.createElement('form'); - form.method = 'POST'; - form.action = action; - document.body.appendChild(form); - form.submit(); + var target = encodePickedPath(dest) + '/'; + closeModal(); + postReplace(action, null, target); } } }); @@ -112,11 +140,11 @@ function deletePage() { danger: true, enterConfirms: false, onConfirm: function () { - var form = document.createElement('form'); - form.method = 'POST'; - form.action = window.location.pathname + '?delete=1'; - document.body.appendChild(form); - form.submit(); + var p = window.location.pathname.replace(/\/+$/, ''); + var idx = p.lastIndexOf('/'); + var parent = idx > 0 ? p.substring(0, idx + 1) : '/'; + closeModal(); + postReplace(window.location.pathname + '?delete=1', null, parent); } }, cancel: { autofocus: true }, diff --git a/assets/page/anchors.js b/assets/page/anchors.js index 24cf4c1..0dbaa3f 100644 --- a/assets/page/anchors.js +++ b/assets/page/anchors.js @@ -1,17 +1,114 @@ (function () { - var content = document.querySelector("main"); + var content = document.querySelector('.content'); if (!content) return; - var headings = content.querySelectorAll("h2, h3, h4"); - if (!headings) return + var allHeadings = content.querySelectorAll('h1, h2, h3, h4, h5, h6'); + if (!allHeadings.length) return; - headings.forEach(function (h) { - if (!h.id) return; - var a = document.createElement('a'); - a.href = '#' + h.id; - a.className = 'heading-anchor'; - a.setAttribute('aria-label', 'Link to this section'); - a.textContent = '#'; - h.insertBefore(a, h.firstChild); + function copyAnchor(id, item, label, menu) { + var url = window.location.origin + window.location.pathname + '#' + id; + function flash() { + item.textContent = 'Copied!'; + setTimeout(function () { + item.textContent = label; + menu.classList.remove('is-open'); + }, 1200); + } + function fallback() { + var ta = document.createElement('textarea'); + ta.value = url; + ta.setAttribute('readonly', ''); + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + var ok = false; + try { ok = document.execCommand('copy'); } catch (e) { ok = false; } + document.body.removeChild(ta); + if (ok) flash(); + else openModal({ + title: 'Copy anchor link', + body: 'Could not copy automatically. URL:\n' + url, + confirm: { label: 'OK', onConfirm: function () { closeModal(); } } + }); + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(url).then(flash, fallback); + } else { + fallback(); + } + } + + function addTask(sectionIndex, headingId) { + var input = document.createElement('input'); + input.type = 'text'; + input.className = 'modal-input'; + input.placeholder = 'Task description'; + var ctrl = openModal({ + title: 'Add task', + body: input, + confirm: { + label: 'ADD', + initiallyDisabled: true, + onConfirm: function () { + var text = input.value.trim(); + if (!text) return; + var action = window.location.pathname + '?addtask=' + sectionIndex; + var target = window.location.pathname + '#' + headingId; + closeModal(); + postReplace(action, 'text=' + encodeURIComponent(text), target); + } + } + }); + input.addEventListener('input', function () { + ctrl.setConfirmDisabled(input.value.trim() === ''); + }); + } + + allHeadings.forEach(function (h, i) { + if (!h.id) return; + var tag = h.tagName.toLowerCase(); + if (tag !== 'h2' && tag !== 'h3' && tag !== 'h4') return; + + var sectionIndex = i + 1; + + var wrap = document.createElement('span'); + wrap.className = 'dropdown heading-anchor'; + + var trigger = document.createElement('button'); + trigger.type = 'button'; + trigger.className = 'dropdown-toggle'; + trigger.setAttribute('aria-haspopup', 'menu'); + trigger.setAttribute('aria-label', 'Section actions'); + trigger.textContent = '#'; + + var menu = document.createElement('div'); + menu.className = 'dropdown-menu'; + + var copyLabel = 'Copy anchor link'; + var copyBtn = document.createElement('button'); + copyBtn.type = 'button'; + copyBtn.className = 'btn btn-tool btn-block'; + copyBtn.dataset.action = 'copy-anchor'; + copyBtn.textContent = copyLabel; + copyBtn.addEventListener('click', function (e) { + e.stopPropagation(); + copyAnchor(h.id, copyBtn, copyLabel, menu); + }); + + var addBtn = document.createElement('button'); + addBtn.type = 'button'; + addBtn.className = 'btn btn-tool btn-block'; + addBtn.dataset.action = 'add-task'; + addBtn.textContent = 'Add task'; + addBtn.addEventListener('click', function () { addTask(sectionIndex, h.id); }); + + menu.appendChild(copyBtn); + menu.appendChild(addBtn); + wrap.appendChild(trigger); + wrap.appendChild(menu); + h.insertBefore(wrap, h.firstChild); + + wireDropdown(trigger); }); }()); diff --git a/assets/page/main.html b/assets/page/main.html index bc4328f..d9b8fa6 100644 --- a/assets/page/main.html +++ b/assets/page/main.html @@ -42,6 +42,9 @@ {{if not .IsRoot}} + {{end}} + + {{if not .IsRoot}} {{end}} {{end}}{{if .SidebarWidget}}{{.SidebarWidget}}{{end}}{{end}} diff --git a/assets/page/tasks.js b/assets/page/tasks.js index 180ce36..5fca60f 100644 --- a/assets/page/tasks.js +++ b/assets/page/tasks.js @@ -21,4 +21,25 @@ }); }); }); + + var hasChecked = !!document.querySelector('input.task-checkbox:checked'); + if (hasChecked) { + var btn = document.querySelector('[data-action="clean-tasks"]'); + if (btn) btn.hidden = false; + } })(); + +function cleanUpTasks() { + openModal({ + title: 'Clean up tasks', + body: 'Remove all completed tasks from this page?', + confirm: { + label: 'CLEAN UP', + danger: true, + onConfirm: function () { + closeModal(); + postReplace(window.location.pathname + '?cleantasks=1', null, window.location.pathname); + } + } + }); +} diff --git a/assets/page/toc.js b/assets/page/toc.js index 0f7d0c7..27eead5 100644 --- a/assets/page/toc.js +++ b/assets/page/toc.js @@ -21,7 +21,7 @@ document.addEventListener("DOMContentLoaded", function () { var a = document.createElement("a"); a.href = "#" + h.id; var clone = h.cloneNode(true); - clone.querySelectorAll(".btn, .muted, .heading-anchor").forEach(function (el) { el.remove(); }); + clone.querySelectorAll(".btn, .muted, .heading-anchor, .dropdown").forEach(function (el) { el.remove(); }); a.textContent = clone.textContent.trim(); li.appendChild(a); list.appendChild(li); diff --git a/assets/style.css b/assets/style.css index 58d7a88..bc4c87e 100644 --- a/assets/style.css +++ b/assets/style.css @@ -273,12 +273,20 @@ main { max-width: 100%; } -a.heading-anchor { - color: var(--text-muted); +.dropdown.heading-anchor { margin-right: 0.4em; font-weight: normal; } -a.heading-anchor:hover { +.heading-anchor .dropdown-toggle { + background: none; + border: none; + padding: 0; + cursor: pointer; + color: var(--text-muted); + font: inherit; + font-weight: normal; +} +.heading-anchor .dropdown-toggle:hover { color: var(--primary-hover); } diff --git a/main.go b/main.go index e8ddaee..d738020 100644 --- a/main.go +++ b/main.go @@ -333,6 +333,14 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs h.handleAppend(w, r, urlPath, fsPath) return } + if query.Has("cleantasks") { + h.handleCleanTasks(w, r, urlPath, fsPath) + return + } + if query.Has("addtask") { + h.handleAddTask(w, r, urlPath, fsPath) + return + } if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) diff --git a/tasks.go b/tasks.go index 3cf5e15..e11bcb0 100644 --- a/tasks.go +++ b/tasks.go @@ -7,6 +7,7 @@ import ( "path/filepath" "regexp" "strconv" + "strings" ) // taskCheckboxRe matches the tag goldmark's GFM extension emits for a @@ -110,3 +111,201 @@ func flipTaskLine(raw []byte, n int, checked bool) ([]byte, bool) { lines[target] = taskLineRe.ReplaceAll(lines[target], replacement) return bytes.Join(lines, []byte("\n")), true } + +// handleCleanTasks rewrites index.md with every completed task line — and its +// continuation lines — removed. Triggered by POST /{path}?cleantasks=1. +func (h *handler) handleCleanTasks(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) { + 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 := stripCompletedTasks(raw) + if !bytes.Equal(updated, raw) { + if err := writeFileAtomic(indexPath, updated, 0644); err != nil { + http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError) + return + } + } + http.Redirect(w, r, urlPath, http.StatusSeeOther) +} + +// handleAddTask appends a single `- [ ] text` task to the last task list in +// the selected section, or creates a new list at the end of the section if +// none exists. Triggered by POST /{path}?addtask= with form +// field `text`. +func (h *handler) handleAddTask(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) { + sectionIndex, err := strconv.Atoi(r.URL.Query().Get("addtask")) + if err != nil || sectionIndex < 1 { + http.Error(w, "bad section index", http.StatusBadRequest) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + text := r.FormValue("text") + if strings.ContainsAny(text, "\r\n") { + http.Error(w, "text must be single-line", http.StatusBadRequest) + return + } + text = strings.TrimSpace(text) + if text == "" { + http.Error(w, "empty text", http.StatusBadRequest) + return + } + + indexPath := filepath.Join(fsPath, "index.md") + raw, err := os.ReadFile(indexPath) + if err != nil { + http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError) + return + } + sections := splitSections(raw) + if sectionIndex >= len(sections) { + http.Error(w, "section out of range", http.StatusBadRequest) + return + } + + sections[sectionIndex] = appendToLastTaskList(sections[sectionIndex], text) + updated := joinSections(sections) + if err := writeFileAtomic(indexPath, updated, 0644); err != nil { + http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError) + return + } + + target := urlPath + ids := headingIDs(updated) + if sectionIndex-1 < len(ids) { + target = urlPath + "#" + ids[sectionIndex-1] + } + http.Redirect(w, r, target, http.StatusSeeOther) +} + +// splitLines returns raw split on '\n', dropping the trailing empty element +// produced when raw ends in '\n', and reports whether that newline was there. +// reassemble undoes the split with the matching trailing-newline state. +func splitLines(raw []byte) (lines [][]byte, trailingNewline bool) { + trailingNewline = len(raw) > 0 && raw[len(raw)-1] == '\n' + lines = bytes.Split(raw, []byte("\n")) + if trailingNewline && len(lines) > 0 && len(lines[len(lines)-1]) == 0 { + lines = lines[:len(lines)-1] + } + return lines, trailingNewline +} + +func reassemble(lines [][]byte, trailingNewline bool) []byte { + out := bytes.Join(lines, []byte("\n")) + if trailingNewline && len(out) > 0 { + out = append(out, '\n') + } + return out +} + +// isFence reports whether line opens or closes a fenced code block. +func isFence(line []byte) bool { + t := bytes.TrimLeft(line, " \t") + return bytes.HasPrefix(t, []byte("```")) || bytes.HasPrefix(t, []byte("~~~")) +} + +// indentWidth counts leading-whitespace columns, tabs and spaces equally. +// Adequate for the user's own wiki text, where mixed tab/space indents are rare. +func indentWidth(line []byte) int { + n := 0 + for n < len(line) && (line[n] == ' ' || line[n] == '\t') { + n++ + } + return n +} + +// stripCompletedTasks removes every `[x]`/`[X]` task line and its continuation +// lines (blank, or indented strictly more than the bullet) from raw. Lines +// inside fenced code blocks are ignored, matching flipTaskLine's contract. +func stripCompletedTasks(raw []byte) []byte { + lines, trailing := splitLines(raw) + out := make([][]byte, 0, len(lines)) + inFence := false + for i := 0; i < len(lines); i++ { + line := lines[i] + if isFence(line) { + inFence = !inFence + out = append(out, line) + continue + } + if !inFence { + if m := taskLineRe.FindSubmatch(line); m != nil && (m[2][0] == 'x' || m[2][0] == 'X') { + bulletIndent := indentWidth(line) + j := i + 1 + for j < len(lines) { + next := lines[j] + if len(bytes.TrimSpace(next)) > 0 && indentWidth(next) <= bulletIndent { + break + } + j++ + } + i = j - 1 + continue + } + } + out = append(out, line) + } + return reassemble(out, trailing) +} + +// appendToLastTaskList inserts `- [ ] text` after the last task list item in +// sectionBytes. If no task list exists in the section, it appends a new list +// at the end, separated by a blank line. Bullet character and indent are +// inherited from the existing last item when present. +func appendToLastTaskList(sectionBytes []byte, text string) []byte { + lines, trailing := splitLines(sectionBytes) + + // Forward scan: track fence state and remember the last non-fenced task line. + lastTask, lastPrefix, lastIndent := -1, "", 0 + inFence := false + for i, line := range lines { + if isFence(line) { + inFence = !inFence + continue + } + if inFence { + continue + } + if m := taskLineRe.FindSubmatch(line); m != nil { + lastTask = i + lastPrefix = string(m[1]) + lastIndent = indentWidth(line) + } + } + + if lastTask >= 0 { + // Walk forward over continuation lines (blank or more-indented), then + // back over trailing blanks so the new task slots in after the last + // real content line of the item. + end := lastTask + 1 + for end < len(lines) { + next := lines[end] + if len(bytes.TrimSpace(next)) > 0 && indentWidth(next) <= lastIndent { + break + } + end++ + } + for end > lastTask+1 && len(bytes.TrimSpace(lines[end-1])) == 0 { + end-- + } + newLine := []byte(lastPrefix + "[ ] " + text) + out := append(append(append([][]byte{}, lines[:end]...), newLine), lines[end:]...) + return reassemble(out, trailing) + } + + // No task list — append one at section end, blank-line-separated from any + // preceding content. Trim trailing blanks first to control spacing exactly. + for len(lines) > 0 && len(bytes.TrimSpace(lines[len(lines)-1])) == 0 { + lines = lines[:len(lines)-1] + } + if len(lines) > 0 { + lines = append(lines, nil) + } + lines = append(lines, []byte("- [ ] "+text)) + return reassemble(lines, trailing) +}