Remove TODO.txt integration

This commit is contained in:
2026-07-23 17:38:13 +02:00
parent ddfd29ea0e
commit 49c6240416
10 changed files with 31 additions and 613 deletions
-12
View File
@@ -18,8 +18,6 @@ Minimal self-hosted personal wiki. Folders are pages.
- **Quick-add bookmarklet** save the current browser tab to a predetermined wiki page (e.g. `/Topics/Bookmarks/`) with one click. See the [Quick-Add Bookmarklet](#quick-add-bookmarklet) section. - **Quick-add bookmarklet** save the current browser tab to a predetermined wiki page (e.g. `/Topics/Bookmarks/`) with one click. See the [Quick-Add Bookmarklet](#quick-add-bookmarklet) section.
- **Todo list** a `todo.txt` in the wiki root appears as an always-editable, syntax-highlighted widget above the folder tree. See the [Todo List](#todo-list) section.
## Build ## Build
```bash ```bash
@@ -108,16 +106,6 @@ They resolve against the year page rather than separate per-day URLs:
Legacy `YYYY/MM/` and `YYYY/MM/DD/` URLs (no longer the canonical form) redirect to the matching anchor on the year page. Legacy `YYYY/MM/` and `YYYY/MM/DD/` URLs (no longer the canonical form) redirect to the matching anchor on the year page.
## Todo List
A `todo.txt` in the **wiki root** renders as an editable, syntax-highlighted
widget above the folder tree, in [todo.txt](https://github.com/todotxt/todo.txt)
format (`x ` done, `(A)` priority, `+project` / `@context`, dates). No file, no
widget — create it out-of-band to opt in. Edits autosave (no save button), and
open tabs on other devices auto-refresh when the file changes. A toolbar acts on
the current line: `[done]` toggles the `x <date> ` prefix, `[pri]` cycles
priority, `[date]` inserts today, `[del]` deletes the line.
## Quick-Add Bookmarklet ## Quick-Add Bookmarklet
Replace `wiki.host` with your wiki host and `/Topics/Bookmarks/` with the destination page (one bookmarklet per target): Replace `wiki.host` with your wiki host and `/Topics/Bookmarks/` with the destination page (one bookmarklet per target):
File diff suppressed because one or more lines are too long
+2 -4
View File
@@ -15,11 +15,10 @@
<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> <script src="/_/tree-sidebar.js" defer></script>{{end}}
<script src="/_/todo-rail.js" defer></script>{{end}}
{{block "headScripts" .}}{{end}} {{block "headScripts" .}}{{end}}
</head> </head>
<body data-editor-version="{{editorBundleVersion}}"> <body>
<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>
@@ -33,7 +32,6 @@
</header> </header>
<div class="shell"> <div class="shell">
{{if not .EditMode}}<aside class="tree-sidebar col"> {{if not .EditMode}}<aside class="tree-sidebar col">
<div class="todo-rail" data-todo-rail hidden></div>
<div class="tree-scroll"></div> <div class="tree-scroll"></div>
</aside>{{end}} </aside>{{end}}
<div class="center"> <div class="center">
+1 -1
View File
@@ -100,7 +100,7 @@
} }
// Fires after the node is back in its original spot, so the callback // 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). // sees the final DOM.
var cb = onCloseCb; var cb = onCloseCb;
onCloseCb = null; onCloseCb = null;
if (cb) cb(); if (cb) cb();
+5 -71
View File
@@ -466,9 +466,6 @@ 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; }
@@ -677,11 +674,6 @@ 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; }
@@ -733,9 +725,9 @@ 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 flex column in the app-shell (composes with .col) Desktop: a full-height flex column in the app-shell (composes with .col)
holding the todo widget pinned on top and the tree's own scroll region holding the tree's own scroll region. Mobile: not laid out inline — its
below. Mobile: not laid out inline — its content is surfaced through the content is surfaced through the Overlay via the stacked FABs (see
Overlay via the stacked FABs (see responsive). */ responsive). */
/* No right padding on the aside: the scrolling children below reach the /* No right padding on the aside: the scrolling children below reach the
border-right so their scrollbars sit flush against the divider line (they border-right so their scrollbars sit flush against the divider line (they
pad their own content off the scrollbar instead). */ pad their own content off the scrollbar instead). */
@@ -746,9 +738,8 @@ aside.sidebar:empty { display: none; }
padding: var(--space-4) 0 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 /* The tree's scroll region: the tree overflows here. Also the node the tree FAB
keeps its own internal scroll. Also the node the tree FAB moves into the moves into the Overlay, so font sizing lives here rather than on the aside. */
Overlay, so font sizing lives here rather than on the aside. */
.tree-scroll { .tree-scroll {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
@@ -760,63 +751,6 @@ aside.sidebar:empty { display: none; }
user-select: none; user-select: 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);
}
/* Compact action toolbar in the widget header. Buttons are the app's standard
bracketed .btn/.btn-tool; the row just holds them together and lets them wrap
in the narrow rail rather than overflow. */
.todo-toolbar { flex-wrap: wrap; }
/* Roomier tap targets when the widget is surfaced full-screen on mobile. */
.overlay-body .todo-toolbar .btn {
font-size: var(--font-sm);
padding: var(--space-1) 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); }
/* Hanging indent per task. Each todo (one logical line) starts flush-left and
its wrapped continuation lines are indented, so a new task is distinguishable
from a wrapped line even when it has no leading priority/date/x marker. The
indent is character-relative (ch) because it aligns to the monospace text,
not to the layout's spacing scale. */
.todo-rail .cm-line {
padding-left: 2ch;
text-indent: -2ch;
}
.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; }
.movie-info::after { content: ""; display: block; clear: both; } .movie-info::after { content: ""; display: block; clear: both; }
-350
View File
@@ -1,350 +0,0 @@
// 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. While the tab is
// visible the widget polls /_todo (conditional GET, ETag) and reloads when
// another device changed the file — but only while the local buffer is clean,
// so an active typist is never interrupted (last-write-wins on a real clash).
// 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 POLL_MS = 15000; // external-change check cadence (visible tab only)
var view = null; // CM EditorView once mounted
var fallback = null; // <textarea> fallback if the bundle fails to load
var readView = null; // placeholder shown until the editor mounts
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
var knownETag = null; // ETag of the content currently in the buffer
var pendingAction = null; // toolbar action clicked before the editor mounted
fetch(TODO_URL, { credentials: 'same-origin', cache: 'no-store' }).then(function (r) {
if (r.status === 404) return null; // no todo.txt — no widget
if (!r.ok) throw new Error('HTTP ' + r.status);
knownETag = r.headers.get('ETag');
return r.text();
}).then(function (text) {
if (typeof text === 'string') { init(text); startPolling(); }
}).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';
// Toolbar replaces the old static "todo.txt" label. Each button acts on
// the current line of the editor via runAction; the labels render
// bracketed ([done] [pri] …) to match the app's button aesthetic.
var toolbar = document.createElement('div');
toolbar.className = 'row gap-1 todo-toolbar';
toolbar.appendChild(toolButton('pri', 'Cycle priority (A → B → C → D → none)', actions.priority));
toolbar.appendChild(toolButton('sort', 'Sort by priority, then context, then text', actions.sort));
toolbar.appendChild(toolButton('del', 'Delete this line', actions.del, 'danger'));
marker = document.createElement('span');
marker.className = 'todo-unsaved';
marker.textContent = 'unsaved';
marker.hidden = true;
head.appendChild(toolbar);
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 placeholder (click or keyboard tab both
// focus it — tabIndex 0) before the idle upgrade fires, mount the editor
// now and let it take focus so they can type without waiting.
readView.addEventListener('focus', 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();
// A toolbar button clicked during the mount runs now that the view exists.
if (pendingAction) { var p = pendingAction; pendingAction = null; p(view); }
}
function mountFallback() {
pendingAction = null; // the plain textarea can't run CM-based actions
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();
}
// --- Toolbar ---------------------------------------------------------
function mainLine(v) { return v.state.doc.lineAt(v.state.selection.main.head); }
// Each action operates on the current line of the CM view. Kept deliberately
// simple (string edits at the line start / cursor) — no task parsing, per
// the todo.txt widget's highlighting-only remit.
var actions = {
// Cycle the leading priority: none → (A) → (B) → (C) → (D) → none.
priority: function (v) {
var line = mainLine(v);
var m = /^\(([A-Z])\) /.exec(line.text);
var next = !m ? '(A) ' : m[1] === 'A' ? '(B) ' : m[1] === 'B' ? '(C) '
: m[1] === 'C' ? '(D) ' : '';
v.dispatch({ changes: { from: line.from, to: line.from + (m ? m[0].length : 0), insert: next } });
},
// Sort the whole list. A plain case-insensitive line sort yields the
// desired priority → context → text order for free: "(A) " sorts ahead
// of everything (the "(" leads), so prioritised lines rise to the top in
// A/B/C/D order; the "@context" written right after the priority breaks
// ties, and the remaining text breaks those. Completed "x …" lines fall
// to the bottom (x sorts late). Blank lines are dropped so they don't
// float to the top.
sort: function (v) {
var doc = v.state.doc.toString();
var trailingNL = /\n$/.test(doc);
var lines = doc.split('\n').filter(function (l) { return l.trim() !== ''; });
lines.sort(function (a, b) {
var la = a.toLowerCase(), lb = b.toLowerCase();
if (la !== lb) return la < lb ? -1 : 1;
return a < b ? -1 : a > b ? 1 : 0;
});
var out = lines.join('\n') + (trailingNL ? '\n' : '');
if (out === doc) return;
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: out } });
},
del: function (v) { CM.deleteLine(v); },
};
function runAction(fn) {
if (view) { fn(view); view.focus(); return; }
if (fallback) return; // unstyled fallback has no CM view to act on
// Editor still mounting — remember the action and mount it now, focused.
pendingAction = fn;
wantFocus = true;
upgrade();
}
function toolButton(label, title, fn, extraClass) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'btn btn-tool' + (extraClass ? ' ' + extraClass : '');
b.textContent = label;
b.title = title;
// Keep the editor focused (and the mobile keyboard up) when tapping the
// toolbar, mirroring the page editor — preventDefault blocks the focus
// shift on mousedown; the click still fires.
b.addEventListener('mousedown', function (e) { e.preventDefault(); });
b.addEventListener('click', function () { runAction(fn); });
return b;
}
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;
// Adopt the server's new ETag so our own write polls back as 304
// instead of reloading itself as an "external" change.
knownETag = r.headers.get('ETag') || knownETag;
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);
});
}
// Replace the buffer with content another device wrote. Only ever called
// when the local buffer is clean (see poll), so nothing unsaved is lost.
function applyExternal(text, etag) {
knownETag = etag;
lastSaved = text;
if (view) {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } });
} else if (fallback) {
fallback.value = text;
} else if (readView) {
readView.textContent = text; // editor not mounted yet — reseed the placeholder
}
marker.hidden = true;
}
// Conditional GET: 304 means unchanged (cheap), 200 means another device
// wrote the file. Skip while hidden, while our own save is in flight, or
// while the buffer is dirty — a real concurrent clash resolves last-write-
// wins when this device saves, and the other device polls the result.
function poll() {
if (document.visibilityState !== 'visible') return;
if (saving || saveTimer) return;
if (currentText() !== lastSaved) return;
fetch(TODO_URL, {
credentials: 'same-origin',
cache: 'no-store',
headers: knownETag ? { 'If-None-Match': knownETag } : {},
}).then(function (r) {
if (r.status === 304 || !r.ok) return; // unchanged, or a transient error/404
var etag = r.headers.get('ETag');
return r.text().then(function (text) {
// Re-check: the user may have started typing during the fetch.
if (!saving && !saveTimer && currentText() === lastSaved) {
applyExternal(text, etag);
}
});
}).catch(function () { /* offline blip — retry next cycle */ });
}
function startPolling() {
setInterval(poll, POLL_MS);
// Coming back to the tab/window is the moment a device switch matters
// most, so refresh immediately instead of waiting for the next tick.
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') poll();
});
window.addEventListener('focus', poll);
}
// 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);
}
})();
+5 -7
View File
@@ -7,8 +7,7 @@
// omits the container). // omits the container).
(function () { (function () {
// The aside is shared with the todo widget (todo-rail.js); this script owns // This script owns the .tree-scroll child of the aside, not the aside itself.
// only the .tree-scroll child so neither feature clobbers the other.
var container = document.querySelector('aside.tree-sidebar .tree-scroll'); var container = document.querySelector('aside.tree-sidebar .tree-scroll');
if (!container) return; if (!container) return;
@@ -190,11 +189,10 @@
} }
// 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 tree's scroll container (not the whole aside — the todo widget // opens the tree's scroll container in the full-viewport Overlay
// has its own FAB/overlay) in the full-viewport Overlay (overlay.js). The // (overlay.js). The container always exists when not editing, so — per the
// container always exists when not editing, so — per the layout spec — // layout spec — the FAB always renders; the overlay auto-closes when a
// the FAB always renders; the overlay auto-closes when a folder/file link // 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() {
var fab = document.createElement('button'); var fab = document.createElement('button');
fab.type = 'button'; fab.type = 'button';
+1 -61
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, StreamLanguage } from "@codemirror/language"; import { syntaxHighlighting, HighlightStyle, indentOnInput } from "@codemirror/language";
import { import {
autocompletion, autocompletion,
closeBrackets, closeBrackets,
@@ -77,63 +77,6 @@ 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,
@@ -161,7 +104,4 @@ window.CM = {
startCompletion, startCompletion,
theme, theme,
highlightStyle, highlightStyle,
todoLanguage,
todoHighlightStyle,
todoTheme,
}; };
+2 -4
View File
@@ -36,9 +36,8 @@ 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 // tmplFuncs is shared by every layout-based template: the edit template appends
// bundle version as a data attribute on <body> so global JS (todo-rail.js) can // editorBundleVersion to its CodeMirror <script> src to cache-bust the bundle.
// cache-bust its lazy bundle load the same way the edit template does.
var tmplFuncs = template.FuncMap{ var tmplFuncs = template.FuncMap{
"editorBundleVersion": func() string { return editorBundleVersion }, "editorBundleVersion": func() string { return editorBundleVersion },
"fileIcon": fileIcon, "fileIcon": fileIcon,
@@ -125,7 +124,6 @@ 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)
-86
View File
@@ -1,86 +0,0 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"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.
//
// Both GET and POST carry an ETag (a content hash) so the widget can poll for
// external edits: it revalidates GET with If-None-Match and reloads only when
// another device actually changed the bytes. The POST response echoes the new
// ETag so a device's own save comes back 304 rather than reloading itself.
func (h *handler) todoPath() string {
return filepath.Join(h.root, "todo.txt")
}
// todoETag is a strong validator derived from the file content — not mtime, so
// a save of identical bytes (or a touch) does not look like a change.
func todoETag(data []byte) string {
sum := sha256.Sum256(data)
return `"` + hex.EncodeToString(sum[:])[:16] + `"`
}
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 and polls for external edits; a
// heuristically cached copy would show stale todos. Revalidation is driven
// by the ETag (If-None-Match) rather than the browser's own HTTP cache.
etag := todoETag(data)
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "no-store")
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
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
}
// Echo the new ETag so the saving client can adopt it and skip reloading
// its own write on the next poll.
w.Header().Set("ETag", todoETag([]byte(content)))
w.WriteHeader(http.StatusNoContent)
}