diff --git a/assets/page/anchors.js b/assets/page/anchors.js
index 43709da..9cd7721 100644
--- a/assets/page/anchors.js
+++ b/assets/page/anchors.js
@@ -2,113 +2,16 @@
var content = document.querySelector('.content');
if (!content) return;
- var allHeadings = content.querySelectorAll('h1, h2, h3, h4, h5, h6');
- if (!allHeadings.length) return;
+ var headings = content.querySelectorAll('h2, h3, h4');
+ if (!headings.length) return;
- 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 = '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) {
+ headings.forEach(function (h) {
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);
+ 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);
});
}());
diff --git a/assets/page/main.html b/assets/page/main.html
index a93fc3e..3890b30 100644
--- a/assets/page/main.html
+++ b/assets/page/main.html
@@ -57,7 +57,6 @@
{{if not .IsRoot}}
{{end}}
-
{{if not .IsRoot}}
{{end}}
diff --git a/assets/page/tasks.js b/assets/page/tasks.js
index 5fca60f..180ce36 100644
--- a/assets/page/tasks.js
+++ b/assets/page/tasks.js
@@ -21,25 +21,4 @@
});
});
});
-
- 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/style.css b/assets/style.css
index 9402728..3976f44 100644
--- a/assets/style.css
+++ b/assets/style.css
@@ -267,17 +267,13 @@ main > h2 {
text-decoration: line-through;
}
-.dropdown.heading-anchor { margin-right: 0.4em; font-weight: normal; }
-.heading-anchor .dropdown-toggle {
- background: none;
- border: none;
- padding: 0;
- cursor: pointer;
+a.heading-anchor {
color: var(--text-muted);
- font: inherit;
+ margin-right: 0.4em;
font-weight: normal;
+ text-decoration: none;
}
-.heading-anchor .dropdown-toggle:hover { color: var(--primary-hover); }
+a.heading-anchor:hover { color: var(--primary-hover); }
/* === Data tables ===
Shared style for the file listing, search-suggestion dropdown, and
diff --git a/main.go b/main.go
index b1ef564..3c196b5 100644
--- a/main.go
+++ b/main.go
@@ -371,14 +371,6 @@ 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 query.Has("settings") {
h.handleSettings(w, r, urlPath, fsPath)
return
diff --git a/tasks.go b/tasks.go
index e11bcb0..3cf5e15 100644
--- a/tasks.go
+++ b/tasks.go
@@ -7,7 +7,6 @@ import (
"path/filepath"
"regexp"
"strconv"
- "strings"
)
// taskCheckboxRe matches the tag goldmark's GFM extension emits for a
@@ -111,201 +110,3 @@ 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)
-}