improve task list handling
This commit is contained in:
@@ -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
|
- 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.
|
- 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
|
- 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 `<form>.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
|
## Development Priorities
|
||||||
|
|
||||||
|
|||||||
+38
-10
@@ -3,6 +3,36 @@ function encodePickedPath(p) {
|
|||||||
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
|
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) {
|
function promptPageName(title, initial, confirmLabel, onName) {
|
||||||
var input = document.createElement('input');
|
var input = document.createElement('input');
|
||||||
input.type = 'text';
|
input.type = 'text';
|
||||||
@@ -89,11 +119,9 @@ function movePage() {
|
|||||||
var action = window.location.pathname + '?move=' +
|
var action = window.location.pathname + '?move=' +
|
||||||
encodeURIComponent(dest);
|
encodeURIComponent(dest);
|
||||||
if (linksCheckbox.checked) action += '&links=1';
|
if (linksCheckbox.checked) action += '&links=1';
|
||||||
var form = document.createElement('form');
|
var target = encodePickedPath(dest) + '/';
|
||||||
form.method = 'POST';
|
closeModal();
|
||||||
form.action = action;
|
postReplace(action, null, target);
|
||||||
document.body.appendChild(form);
|
|
||||||
form.submit();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -112,11 +140,11 @@ function deletePage() {
|
|||||||
danger: true,
|
danger: true,
|
||||||
enterConfirms: false,
|
enterConfirms: false,
|
||||||
onConfirm: function () {
|
onConfirm: function () {
|
||||||
var form = document.createElement('form');
|
var p = window.location.pathname.replace(/\/+$/, '');
|
||||||
form.method = 'POST';
|
var idx = p.lastIndexOf('/');
|
||||||
form.action = window.location.pathname + '?delete=1';
|
var parent = idx > 0 ? p.substring(0, idx + 1) : '/';
|
||||||
document.body.appendChild(form);
|
closeModal();
|
||||||
form.submit();
|
postReplace(window.location.pathname + '?delete=1', null, parent);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
cancel: { autofocus: true },
|
cancel: { autofocus: true },
|
||||||
|
|||||||
+108
-11
@@ -1,17 +1,114 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var content = document.querySelector("main");
|
var content = document.querySelector('.content');
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
|
|
||||||
var headings = content.querySelectorAll("h2, h3, h4");
|
var allHeadings = content.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||||
if (!headings) return
|
if (!allHeadings.length) return;
|
||||||
|
|
||||||
headings.forEach(function (h) {
|
function copyAnchor(id, item, label, menu) {
|
||||||
if (!h.id) return;
|
var url = window.location.origin + window.location.pathname + '#' + id;
|
||||||
var a = document.createElement('a');
|
function flash() {
|
||||||
a.href = '#' + h.id;
|
item.textContent = 'Copied!';
|
||||||
a.className = 'heading-anchor';
|
setTimeout(function () {
|
||||||
a.setAttribute('aria-label', 'Link to this section');
|
item.textContent = label;
|
||||||
a.textContent = '#';
|
menu.classList.remove('is-open');
|
||||||
h.insertBefore(a, h.firstChild);
|
}, 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);
|
||||||
});
|
});
|
||||||
}());
|
}());
|
||||||
|
|||||||
@@ -42,6 +42,9 @@
|
|||||||
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager">REVEAL ON CLIENT</button>
|
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager">REVEAL ON CLIENT</button>
|
||||||
{{if not .IsRoot}}
|
{{if not .IsRoot}}
|
||||||
<button class="btn btn-block" onclick="movePage()" title="Move page (M)">MOVE PAGE</button>
|
<button class="btn btn-block" onclick="movePage()" title="Move page (M)">MOVE PAGE</button>
|
||||||
|
{{end}}
|
||||||
|
<button class="btn btn-block" data-action="clean-tasks" onclick="cleanUpTasks()" title="Clean up finished tasks" hidden>CLEAN UP TASKS</button>
|
||||||
|
{{if not .IsRoot}}
|
||||||
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page">DELETE PAGE</button>
|
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page">DELETE PAGE</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
</nav>{{end}}{{if .SidebarWidget}}{{.SidebarWidget}}{{end}}{{end}}
|
</nav>{{end}}{{if .SidebarWidget}}{{.SidebarWidget}}{{end}}{{end}}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
var a = document.createElement("a");
|
var a = document.createElement("a");
|
||||||
a.href = "#" + h.id;
|
a.href = "#" + h.id;
|
||||||
var clone = h.cloneNode(true);
|
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();
|
a.textContent = clone.textContent.trim();
|
||||||
li.appendChild(a);
|
li.appendChild(a);
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
|
|||||||
+11
-3
@@ -273,12 +273,20 @@ main {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.heading-anchor {
|
.dropdown.heading-anchor {
|
||||||
color: var(--text-muted);
|
|
||||||
margin-right: 0.4em;
|
margin-right: 0.4em;
|
||||||
font-weight: normal;
|
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);
|
color: var(--primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -333,6 +333,14 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
h.handleAppend(w, r, urlPath, fsPath)
|
h.handleAppend(w, r, urlPath, fsPath)
|
||||||
return
|
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 {
|
if err := r.ParseForm(); err != nil {
|
||||||
http.Error(w, "bad request", http.StatusBadRequest)
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// taskCheckboxRe matches the <input> tag goldmark's GFM extension emits for a
|
// 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)
|
lines[target] = taskLineRe.ReplaceAll(lines[target], replacement)
|
||||||
return bytes.Join(lines, []byte("\n")), true
|
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