package main import ( "bytes" "net/http" "os" "path/filepath" "regexp" "strconv" "strings" ) // 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 } // 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) }