improve task list handling
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// taskCheckboxRe matches the <input> 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=<section-index> 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user