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
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="/_/companion.js" defer></script>
{{if not .EditMode}}<script src="/_/overlay.js" defer></script>
<script src="/_/tree-sidebar.js" defer></script>
<script src="/_/todo-rail.js" defer></script>{{end}}
<script src="/_/tree-sidebar.js" defer></script>{{end}}
{{block "headScripts" .}}{{end}}
</head>
<body data-editor-version="{{editorBundleVersion}}">
<body>
<header>
<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>
@@ -33,7 +32,6 @@
</header>
<div class="shell">
{{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">
+1 -1
View File
@@ -100,7 +100,7 @@
}
// 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;
onCloseCb = null;
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; }
button.fab { display: none; }
.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 { margin-left: auto; }
@@ -677,11 +674,6 @@ aside.sidebar:empty { display: none; }
padding: 0;
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;
the base .sidebar already sets flex-direction + gap. */
.overlay-body .sidebar { display: flex; }
@@ -733,9 +725,9 @@ aside.sidebar:empty { display: none; }
/* === Tree sidebar (persistent left navigation rail) ===
Reuses the .tree-row / .tree-children / .tree-name / .tree-chevron modules.
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
below. Mobile: not laid out inline — its content is surfaced through the
Overlay via the stacked FABs (see responsive). */
holding the tree's own scroll region. 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). */
@@ -746,9 +738,8 @@ aside.sidebar:empty { display: none; }
padding: var(--space-4) 0 var(--space-4) var(--space-4);
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. */
/* The tree's scroll region: the tree overflows here. 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;
@@ -760,63 +751,6 @@ aside.sidebar:empty { display: 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 { margin: var(--space-3) 0; }
.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).
(function () {
// 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.
// This script owns the .tree-scroll child of the aside, not the aside itself.
var container = document.querySelector('aside.tree-sidebar .tree-scroll');
if (!container) return;
@@ -190,11 +189,10 @@
}
// 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
// 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
// inside it navigates. Hidden on desktop by .fab CSS.
// opens the tree's scroll container 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 inside it navigates. Hidden on desktop by .fab CSS.
function setupFab() {
var fab = document.createElement('button');
fab.type = 'button';