todo.txt integration

This commit is contained in:
2026-07-03 11:08:27 +02:00
parent 8c9b448bdc
commit 5e72b073b8
9 changed files with 458 additions and 36 deletions
File diff suppressed because one or more lines are too long
+7 -3
View File
@@ -14,10 +14,11 @@
<script src="/_/tree-picker.js"></script> <script src="/_/tree-picker.js"></script>
<script src="/_/companion.js" defer></script> <script src="/_/companion.js" defer></script>
{{if not .EditMode}}<script src="/_/overlay.js" defer></script> {{if not .EditMode}}<script src="/_/overlay.js" defer></script>
<script src="/_/tree-sidebar.js" defer></script>{{end}} <script src="/_/tree-sidebar.js" defer></script>
<script src="/_/todo-rail.js" defer></script>{{end}}
{{block "headScripts" .}}{{end}} {{block "headScripts" .}}{{end}}
</head> </head>
<body> <body data-editor-version="{{editorBundleVersion}}">
<header> <header>
<nav class="breadcrumb row"> <nav class="breadcrumb row">
<a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a> <a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a>
@@ -30,7 +31,10 @@
<div class="header-actions row">{{block "headerActions" .}}{{end}}</div> <div class="header-actions row">{{block "headerActions" .}}{{end}}</div>
</header> </header>
<div class="shell"> <div class="shell">
{{if not .EditMode}}<aside class="tree-sidebar"></aside>{{end}} {{if not .EditMode}}<aside class="tree-sidebar col">
<div class="todo-rail" data-todo-rail hidden></div>
<div class="tree-scroll"></div>
</aside>{{end}}
<div class="center"> <div class="center">
<main> <main>
{{block "content" .}}{{end}} {{block "content" .}}{{end}}
+8
View File
@@ -16,6 +16,7 @@
var placeholder = null; // marks where to restore it var placeholder = null; // marks where to restore it
var prevFocus = null; var prevFocus = null;
var onKeydown = null; var onKeydown = null;
var onCloseCb = null; // opts.onClose of the currently hosted node
function build() { function build() {
overlay = document.createElement('div'); overlay = document.createElement('div');
@@ -56,6 +57,7 @@
opts = opts || {}; opts = opts || {};
prevFocus = document.activeElement; prevFocus = document.activeElement;
onCloseCb = typeof opts.onClose === 'function' ? opts.onClose : null;
hosted = node; hosted = node;
placeholder = document.createComment('overlay-slot'); placeholder = document.createComment('overlay-slot');
node.parentNode.insertBefore(placeholder, node); node.parentNode.insertBefore(placeholder, node);
@@ -96,6 +98,12 @@
if (toRestore && toRestore.focus) { if (toRestore && toRestore.focus) {
try { toRestore.focus(); } catch (e) {} try { toRestore.focus(); } catch (e) {}
} }
// Fires after the node is back in its original spot, so the callback
// sees the final DOM (e.g. the todo rail flushing an autosave).
var cb = onCloseCb;
onCloseCb = null;
if (cb) cb();
} }
// Belt-and-suspenders bfcache guard: force the overlay closed (restoring // Belt-and-suspenders bfcache guard: force the overlay closed (restoring
+68 -9
View File
@@ -99,8 +99,8 @@ hr { border: none; border-top: var(--border-dashed); margin: var(--space-4) 0; }
The shell is a full-height flex row below the header: left rail | center | The shell is a full-height flex row below the header: left rail | center |
right rail. Each column owns its own vertical scroll (overflow-y:auto + right rail. Each column owns its own vertical scroll (overflow-y:auto +
min-height:0), so nothing scrolls the page as a whole. Edge-to-edge: no min-height:0), so nothing scrolls the page as a whole. Edge-to-edge: no
centered max-width container. Either rail collapses out of the flex flow when centered max-width container. The right rail collapses out of the flex flow
empty (:empty { display:none }), letting the center reclaim the width. */ when empty (:empty { display:none }), letting the center reclaim the width. */
.shell { .shell {
display: flex; display: flex;
min-height: 0; min-height: 0;
@@ -465,6 +465,9 @@ body.edit-mode footer { max-width: none; }
.fab { position: fixed; bottom: var(--space-4); right: var(--space-4); z-index: 50; } .fab { position: fixed; bottom: var(--space-4); right: var(--space-4); z-index: 50; }
button.fab { display: none; } button.fab { display: none; }
.fab-rail { bottom: calc(var(--space-4) + 3rem + var(--space-2)); } .fab-rail { bottom: calc(var(--space-4) + 3rem + var(--space-2)); }
/* Todo FAB: third slot, above the tree and right-rail FABs. Only created when
todo.txt exists (todo-rail.js). */
.fab-todo { bottom: calc(var(--space-4) + 2 * (3rem + var(--space-2))); }
/* === Companion status === */ /* === Companion status === */
.companion-status { margin-left: auto; } .companion-status { margin-left: auto; }
@@ -664,7 +667,7 @@ aside.sidebar:empty { display: none; }
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
.overlay-body .tree-sidebar, .overlay-body .tree-scroll,
.overlay-body .sidebar { .overlay-body .sidebar {
display: block; display: block;
width: auto; width: auto;
@@ -673,6 +676,11 @@ aside.sidebar:empty { display: none; }
padding: 0; padding: 0;
overflow: visible; overflow: visible;
} }
/* The todo widget fills the overlay: drop the rail divider and lift the
internal height cap so the whole file is editable on mobile. */
.overlay-body .todo-rail { border-bottom: none; padding-bottom: 0; }
.overlay-body .todo-rail .todo-rail-view,
.overlay-body .todo-rail .cm-editor { max-height: none; }
/* Right rail keeps its column spacing (TOC above widgets) inside the overlay; /* Right rail keeps its column spacing (TOC above widgets) inside the overlay;
the base .sidebar already sets flex-direction + gap. */ the base .sidebar already sets flex-direction + gap. */
.overlay-body .sidebar { display: flex; } .overlay-body .sidebar { display: flex; }
@@ -723,21 +731,72 @@ aside.sidebar:empty { display: none; }
/* === Tree sidebar (persistent left navigation rail) === /* === Tree sidebar (persistent left navigation rail) ===
Reuses the .tree-row / .tree-children / .tree-name / .tree-chevron modules. Reuses the .tree-row / .tree-children / .tree-name / .tree-chevron modules.
Desktop: a full-height column in the app-shell that scrolls its own Desktop: a full-height flex column in the app-shell (composes with .col)
overflow. Mobile: not laid out inline — its content is surfaced through the holding the todo widget pinned on top and the tree's own scroll region
Overlay via .fab-tree (see responsive). */ below. Mobile: not laid out inline — its content is surfaced through the
Overlay via the stacked FABs (see responsive). */
/* No right padding on the aside: the scrolling children below reach the
border-right so their scrollbars sit flush against the divider line (they
pad their own content off the scrollbar instead). */
.tree-sidebar { .tree-sidebar {
width: var(--tree-width); width: var(--tree-width);
flex-shrink: 0; flex-shrink: 0;
overflow-y: auto; min-height: 0;
padding: var(--space-4) var(--space-2) var(--space-4) var(--space-4); padding: var(--space-4) 0 var(--space-4) var(--space-4);
border-right: var(--border-dashed); border-right: var(--border-dashed);
}
/* The tree's scroll region: only the tree overflows, the todo widget above
keeps its own internal scroll. Also the node the tree FAB moves into the
Overlay, so font sizing lives here rather than on the aside. */
.tree-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding-right: var(--space-2);
font-size: var(--font-sm); font-size: var(--font-sm);
/* Rows are click targets (navigate / toggle), not prose — suppress the /* Rows are click targets (navigate / toggle), not prose — suppress the
text-selection highlight that double/drag clicks would otherwise leave. */ text-selection highlight that double/drag clicks would otherwise leave. */
user-select: none; user-select: none;
} }
aside.tree-sidebar:empty { display: none; }
/* === Todo rail (root todo.txt widget) ===
Pinned above the folder tree (todo-rail.js). Hidden via the [hidden]
attribute until GET /_todo confirms the file exists. A long file scrolls
internally rather than pushing the tree off screen; the height cap is
lifted inside the Overlay. */
.todo-rail {
flex-shrink: 0;
padding-bottom: var(--space-3);
border-bottom: var(--border-dashed);
}
.todo-rail-head {
font-size: var(--font-xs);
margin-bottom: var(--space-2);
padding-right: var(--space-2);
}
.todo-unsaved { color: var(--primary-hover); font-size: var(--font-xs); }
/* The scrolling children run edge-to-edge (the aside has no right padding) so
their scrollbars are flush with the divider; padding-right keeps their text
clear of the scrollbar. */
.todo-rail-view {
font-family: "Iosevka Slab", monospace;
font-size: var(--font-xs);
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
max-height: 40vh;
overflow-y: auto;
padding-right: var(--space-2);
cursor: text;
}
.todo-rail .cm-editor { max-height: 40vh; }
.todo-rail .cm-content { padding-right: var(--space-2); }
.todo-rail .todo-fallback {
width: 100%;
min-height: 10rem;
font-family: "Iosevka Slab", monospace;
font-size: var(--font-xs);
}
/* === Movie info box === */ /* === Movie info box === */
.movie-info { margin: var(--space-3) 0; } .movie-info { margin: var(--space-3) 0; }
+218
View File
@@ -0,0 +1,218 @@
// Root todo.txt rail widget. Surfaces wiki-root/todo.txt at the top of the
// left navigation rail (above the folder tree) and keeps it editable in place:
// a plain read view paints first (fast, no dependency), then a minimal
// CodeMirror instance (todo.txt highlighting from the bundle) always mounts —
// lazily, off the critical path, so the bundle fetch never delays first paint.
// Autosave is debounced + saves on blur via POST /_todo. If the file is absent
// (GET /_todo → 404) the widget renders nothing and no FAB is created; the
// file is only ever created out-of-band. Mobile: a dedicated todo FAB opens
// the widget in the full-viewport Overlay (overlay.js), saving on dismiss.
(function () {
var container = document.querySelector('[data-todo-rail]');
if (!container) return;
var TODO_URL = '/_todo';
var DEBOUNCE_MS = 800;
var RETRY_MS = 5000;
var view = null; // CM EditorView once mounted
var fallback = null; // <textarea> fallback if the bundle fails to load
var readView = null; // pre-focus read view
var bodyEl = null; // hosts readView, then the editor
var marker = null; // "unsaved" indicator (shown on save failure)
var lastSaved = ''; // last text confirmed written to disk
var saveTimer = null;
var retryTimer = null;
var saving = false;
var loadingBundle = false;
var wantFocus = false; // user reached for the editor before it mounted
fetch(TODO_URL, { credentials: 'same-origin' }).then(function (r) {
if (r.status === 404) return null; // no todo.txt — no widget
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.text();
}).then(function (text) {
if (typeof text === 'string') init(text);
}).catch(function () { /* fetch failed — leave the widget hidden */ });
function init(text) {
lastSaved = text;
var head = document.createElement('div');
head.className = 'row space-between todo-rail-head';
var label = document.createElement('span');
label.className = 'muted';
label.textContent = 'todo.txt';
marker = document.createElement('span');
marker.className = 'todo-unsaved';
marker.textContent = 'unsaved';
marker.hidden = true;
head.appendChild(label);
head.appendChild(marker);
bodyEl = document.createElement('div');
readView = document.createElement('div');
readView.className = 'todo-rail-view';
readView.tabIndex = 0;
readView.textContent = text;
// If the user reaches for the read view before the idle upgrade fires,
// remember it so the mounting editor takes focus.
readView.addEventListener('focus', focusUpgrade);
readView.addEventListener('click', focusUpgrade);
bodyEl.appendChild(readView);
container.appendChild(head);
container.appendChild(bodyEl);
container.hidden = false;
setupFab();
// Always upgrade to the live editor, but only once the browser is idle
// so the CM bundle fetch never competes with first paint. The read view
// is the placeholder shown until then.
scheduleUpgrade();
}
function focusUpgrade() {
wantFocus = true;
upgrade();
}
function scheduleUpgrade() {
if (window.requestIdleCallback) {
requestIdleCallback(function () { upgrade(); }, { timeout: 2000 });
} else {
setTimeout(upgrade, 200);
}
}
// Inject the vendored CM bundle (cache-busted with the same content hash the
// edit template uses, exposed on <body>), then mount the editor. If the
// bundle cannot load (offline/VPN blip), fall back to a plain textarea so
// editing and saving still work unstyled.
function upgrade() {
if (view || fallback || loadingBundle) return;
if (window.CM) { mountEditor(); return; }
loadingBundle = true;
var v = document.body.getAttribute('data-editor-version') || '';
var s = document.createElement('script');
s.src = '/_/editor/vendor/codemirror.bundle.js' + (v ? '?v=' + v : '');
s.onload = function () { loadingBundle = false; mountEditor(); };
s.onerror = function () { loadingBundle = false; mountFallback(); };
document.head.appendChild(s);
}
function mountEditor() {
var state = CM.EditorState.create({
doc: readView.textContent,
extensions: [
CM.history(),
CM.drawSelection(),
CM.EditorView.lineWrapping,
CM.todoLanguage,
CM.syntaxHighlighting(CM.todoHighlightStyle),
CM.todoTheme,
CM.keymap.of([].concat(CM.defaultKeymap, CM.historyKeymap)),
CM.EditorView.updateListener.of(function (u) {
if (u.docChanged) scheduleSave();
}),
CM.EditorView.domEventHandlers({ blur: function () { saveNow(); } }),
],
});
bodyEl.textContent = '';
view = new CM.EditorView({ state: state, parent: bodyEl });
// Only steal focus if the user actually reached for the editor — the
// passive idle mount must not grab focus or scroll the page.
if (wantFocus) view.focus();
}
function mountFallback() {
fallback = document.createElement('textarea');
fallback.className = 'input todo-fallback';
fallback.value = readView.textContent;
fallback.addEventListener('input', scheduleSave);
fallback.addEventListener('blur', saveNow);
bodyEl.textContent = '';
bodyEl.appendChild(fallback);
if (wantFocus) fallback.focus();
}
function currentText() {
if (view) return view.state.doc.toString();
if (fallback) return fallback.value;
return readView.textContent;
}
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(save, DEBOUNCE_MS);
}
function saveNow() {
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
save();
}
// Last write wins, no conflict detection (accepted). On failure: show the
// "unsaved" marker, keep the buffer untouched, retry silently.
function save() {
saveTimer = null;
var text = currentText();
if (text === lastSaved) return;
if (saving) { scheduleSave(); return; } // serialize; re-check afterwards
saving = true;
fetch(TODO_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'content=' + encodeURIComponent(text),
}).then(function (r) {
saving = false;
if (!r.ok) throw new Error('HTTP ' + r.status);
lastSaved = text;
marker.hidden = true;
if (currentText() !== text) scheduleSave(); // typed while saving
}).catch(function () {
saving = false;
marker.hidden = false;
if (retryTimer) clearTimeout(retryTimer);
retryTimer = setTimeout(save, RETRY_MS);
});
}
// Closing the tab mid-debounce would drop the last jot; sendBeacon
// survives page teardown where fetch does not.
window.addEventListener('pagehide', function () {
var text = currentText();
if (text === lastSaved || !navigator.sendBeacon) return;
var body = new Blob(['content=' + encodeURIComponent(text)],
{ type: 'application/x-www-form-urlencoded' });
navigator.sendBeacon(TODO_URL, body);
});
// Mobile: dedicated todo FAB (above the tree FAB in the stacked group,
// created only when todo.txt exists) opens the widget in the full-viewport
// Overlay; dismissing the overlay flushes any pending save.
function setupFab() {
var fab = document.createElement('button');
fab.type = 'button';
fab.className = 'btn btn-fab fab fab-todo';
fab.title = 'Todo';
fab.setAttribute('aria-label', 'Todo');
fab.innerHTML = '<svg viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter"><path d="M2 2h12v12H2z"/><path d="M5 8.5l2 2L11 5.5"/></svg>';
fab.addEventListener('click', function () {
if (typeof openOverlay !== 'function') return;
openOverlay(container, { onClose: saveNow });
wantFocus = true;
if (view) {
// The editor may have mounted while the rail was display:none
// (measured at zero size); re-measure now that it is visible.
view.requestMeasure();
view.focus();
} else {
upgrade(); // idle mount hasn't run yet — force it, focused
}
});
document.body.appendChild(fab);
}
})();
+6 -3
View File
@@ -7,7 +7,9 @@
// omits the container). // omits the container).
(function () { (function () {
var container = document.querySelector('aside.tree-sidebar'); // The aside is shared with the todo widget (todo-rail.js); this script owns
// only the .tree-scroll child so neither feature clobbers the other.
var container = document.querySelector('aside.tree-sidebar .tree-scroll');
if (!container) return; if (!container) return;
function joinPath(parent, name) { function joinPath(parent, name) {
@@ -188,8 +190,9 @@
} }
// Mobile: the tree FAB sits at the bottom of the stacked FAB group and // Mobile: the tree FAB sits at the bottom of the stacked FAB group and
// opens the rail's content in the full-viewport Overlay (overlay.js). The // opens the tree's scroll container (not the whole aside — the todo widget
// rail container always exists when not editing, so — per the layout spec — // has its own FAB/overlay) in the full-viewport Overlay (overlay.js). The
// container always exists when not editing, so — per the layout spec —
// the FAB always renders; the overlay auto-closes when a folder/file link // the FAB always renders; the overlay auto-closes when a folder/file link
// inside it navigates. Hidden on desktop by .fab CSS. // inside it navigates. Hidden on desktop by .fab CSS.
function setupFab() { function setupFab() {
+61 -1
View File
@@ -8,7 +8,7 @@ import { EditorState, EditorSelection, Compartment, Prec } from "@codemirror/sta
import { EditorView, keymap, drawSelection } from "@codemirror/view"; import { EditorView, keymap, drawSelection } from "@codemirror/view";
import { history, historyKeymap, defaultKeymap, indentWithTab, undo, redo, deleteLine } from "@codemirror/commands"; import { history, historyKeymap, defaultKeymap, indentWithTab, undo, redo, deleteLine } from "@codemirror/commands";
import { markdown, markdownLanguage, markdownKeymap } from "@codemirror/lang-markdown"; import { markdown, markdownLanguage, markdownKeymap } from "@codemirror/lang-markdown";
import { syntaxHighlighting, HighlightStyle, indentOnInput } from "@codemirror/language"; import { syntaxHighlighting, HighlightStyle, indentOnInput, StreamLanguage } from "@codemirror/language";
import { import {
autocompletion, autocompletion,
closeBrackets, closeBrackets,
@@ -77,6 +77,63 @@ const highlightStyle = HighlightStyle.define([
{ tag: [tags.processingInstruction, tags.meta], color: "var(--text-muted)" }, { tag: [tags.processingInstruction, tags.meta], color: "var(--text-muted)" },
]); ]);
// todo.txt language for the left-rail todo widget (assets/todo-rail.js).
// Line-oriented: `x ` done-prefix strikes the whole line; `(A)` priority,
// `+project`, `@context` and ISO dates get their own colors. No parsing into
// task objects — highlighting only.
const todoLanguage = StreamLanguage.define({
token(stream) {
if (stream.sol() && stream.match(/^x\s.*/)) return "todoDone";
if (stream.sol() && stream.match(/^\([A-Z]\)(?=\s|$)/)) return "todoPriority";
// +project / @context / dates only count at word starts (start of line
// or after whitespace), per the todo.txt format.
const atWordStart = stream.start === 0 || /\s/.test(stream.string.charAt(stream.start - 1));
if (atWordStart) {
if (stream.match(/^\d{4}-\d{2}-\d{2}(?=\s|$)/)) return "todoDate";
if (stream.match(/^\+\S+/)) return "todoProject";
if (stream.match(/^@\S+/)) return "todoContext";
}
// Consume whitespace OR one plain word — never both, or the word after
// a space would be swallowed before it can be matched above.
if (!stream.eatSpace()) {
stream.next();
stream.eatWhile(/\S/);
}
return null;
},
tokenTable: {
todoDone: tags.strikethrough,
todoPriority: tags.keyword,
todoProject: tags.typeName,
todoContext: tags.atom,
todoDate: tags.meta,
},
});
const todoHighlightStyle = HighlightStyle.define([
{ tag: tags.strikethrough, textDecoration: "line-through", color: "var(--text-muted)" },
{ tag: tags.keyword, color: "var(--secondary)", fontWeight: "bold" },
{ tag: tags.typeName, color: "var(--link)" },
{ tag: tags.atom, color: "var(--primary-hover)" },
{ tag: tags.meta, color: "var(--text-muted)" },
]);
// Minimal chrome for the rail editor — the full editor `theme` above forces a
// 60vh min-height and page-editor padding that don't fit a sidebar widget.
// Sizing/height caps live in style.css under .todo-rail.
const todoTheme = EditorView.theme(
{
"&": { backgroundColor: "transparent", color: "var(--text)", fontSize: "var(--font-xs)" },
"&.cm-focused": { outline: "none" },
".cm-scroller": { fontFamily: '"Iosevka Slab", monospace', lineHeight: "1.5" },
".cm-content": { caretColor: "var(--text)" },
".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--text)" },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
{ backgroundColor: "var(--bg-panel-hover)" },
},
{ dark: true }
);
window.CM = { window.CM = {
EditorState, EditorState,
EditorSelection, EditorSelection,
@@ -104,4 +161,7 @@ window.CM = {
startCompletion, startCompletion,
theme, theme,
highlightStyle, highlightStyle,
todoLanguage,
todoHighlightStyle,
todoTheme,
}; };
+11 -5
View File
@@ -36,12 +36,17 @@ func hashAsset(name string) string {
return hex.EncodeToString(sum[:])[:12] return hex.EncodeToString(sum[:])[:12]
} }
// tmplFuncs is shared by every layout-based template: layout.html renders the
// bundle version as a data attribute on <body> so global JS (todo-rail.js) can
// cache-bust its lazy bundle load the same way the edit template does.
var tmplFuncs = template.FuncMap{
"editorBundleVersion": func() string { return editorBundleVersion },
}
var ( var (
pageTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/page/main.html")) pageTmpl = template.Must(template.New("page").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/page/main.html"))
editTmpl = template.Must(template.New("edit").Funcs(template.FuncMap{ editTmpl = template.Must(template.New("edit").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
"editorBundleVersion": func() string { return editorBundleVersion }, searchTmpl = template.Must(template.New("search").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
}).ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
searchTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
) )
// specialPage is the result returned by a pageTypeHandler. // specialPage is the result returned by a pageTypeHandler.
@@ -119,6 +124,7 @@ func main() {
http.HandleFunc("/_logout", h.handleLogout) http.HandleFunc("/_logout", h.handleLogout)
http.HandleFunc("/_reindex", h.handleReindex) http.HandleFunc("/_reindex", h.handleReindex)
http.HandleFunc("/_search", h.handleSearchSuggest) http.HandleFunc("/_search", h.handleSearchSuggest)
http.HandleFunc("/_todo", h.handleTodo)
http.HandleFunc("/quickadd", h.handleQuickAdd) http.HandleFunc("/quickadd", h.handleQuickAdd)
http.Handle("/", h) http.Handle("/", h)
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"net/http"
"os"
"path/filepath"
)
// Root todo.txt rail widget (assets/todo-rail.js). /_todo reads and writes the
// single wiki-root todo.txt — the path is fixed, so there is no traversal
// surface. GET returns the raw text (404 when the file is absent, which the
// widget treats as "render nothing"); POST replaces the whole file atomically.
// Unlike the index.md save path, empty content does NOT delete the file: the
// widget has no in-UI way to recreate it, so an empty save keeps an empty file.
func (h *handler) todoPath() string {
return filepath.Join(h.root, "todo.txt")
}
func (h *handler) handleTodo(w http.ResponseWriter, r *http.Request) {
if !h.checkAuth(w, r) {
return
}
switch r.Method {
case http.MethodGet:
h.handleTodoGet(w, r)
case http.MethodPost:
h.handleTodoPost(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *handler) handleTodoGet(w http.ResponseWriter, r *http.Request) {
data, err := os.ReadFile(h.todoPath())
if err != nil {
if os.IsNotExist(err) {
http.NotFound(w, r)
return
}
http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
return
}
// The rail re-fetches on every page load; a heuristically cached copy
// would show stale todos after an edit.
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write(data)
}
func (h *handler) handleTodoPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
content := r.FormValue("content")
if err := writeFileAtomic(h.todoPath(), []byte(content), 0644); err != nil {
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}