From 2169c1de1b8ba37cb44005771536801d8ea9b3a1 Mon Sep 17 00:00:00 2001
From: th
Date: Mon, 13 Apr 2026 14:10:43 +0200
Subject: [PATCH] terminal plugin, card edit history, bump script
Terminal: in-app command line with 22 commands (Shift+T toggle).
Navigation, CRUD, search, view toggles, tab completion, history.
Card history: card_history table tracks field changes on every PATCH.
GET /api/cards/{id}/history endpoint. Terminal `history` command.
bump.sh: standalone version bump for config.php, electron/package.json,
website footer. dist.sh no longer bumps.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
config.php | 2 +-
public/index.php | 22 ++
public/plugins/terminal/README.md | 48 ++++
public/plugins/terminal/plugin.json | 10 +
public/plugins/terminal/terminal.css | 39 +++
public/plugins/terminal/terminal.html | 7 +
public/plugins/terminal/terminal.js | 372 ++++++++++++++++++++++++++
public/plugins/tests/runner.js | 1 +
public/plugins/tests/terminal.test.js | 192 +++++++++++++
public/plugins/tests/tests.js | 1 +
src/Database.php | 14 +
11 files changed, 707 insertions(+), 1 deletion(-)
create mode 100644 public/plugins/terminal/README.md
create mode 100644 public/plugins/terminal/plugin.json
create mode 100644 public/plugins/terminal/terminal.css
create mode 100644 public/plugins/terminal/terminal.html
create mode 100644 public/plugins/terminal/terminal.js
create mode 100644 public/plugins/tests/terminal.test.js
diff --git a/config.php b/config.php
index bf6e394..39a3dcf 100644
--- a/config.php
+++ b/config.php
@@ -1,7 +1,7 @@
true]); exit;
}
+ if (preg_match('#^/api/cards/(\d+)/history$#', $route, $m) && $method === 'GET') {
+ $stmt = $db->prepare('SELECT field, old_value, new_value, changed_at FROM card_history WHERE card_id = :id ORDER BY changed_at DESC LIMIT 100');
+ $stmt->execute(['id' => $m[1]]);
+ echo json_encode($stmt->fetchAll()); exit;
+ }
+
if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'PATCH') {
$input = json_decode(file_get_contents('php://input'), true);
+ // Snapshot changed fields before update
+ $tracked = ['title', 'body', 'color', 'bucket_id', 'due_date'];
+ $changedTracked = array_intersect($tracked, array_keys($input));
+ if ($changedTracked) {
+ $old = $db->prepare('SELECT * FROM cards WHERE id = :id'); $old->execute(['id' => $m[1]]); $old = $old->fetch();
+ if ($old) {
+ $histStmt = $db->prepare('INSERT INTO card_history (card_id, field, old_value, new_value) VALUES (:cid, :f, :o, :n)');
+ foreach ($changedTracked as $f) {
+ $oldVal = $old[$f] ?? null; $newVal = $input[$f] ?? null;
+ if ($f === 'body') { $oldVal = mb_substr($oldVal ?? '', 0, 500); $newVal = mb_substr($newVal ?? '', 0, 500); }
+ if ((string)$oldVal !== (string)$newVal) {
+ $histStmt->execute(['cid' => $m[1], 'f' => $f, 'o' => $oldVal, 'n' => $newVal]);
+ }
+ }
+ }
+ }
$fields = []; $params = ['id' => $m[1]];
foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id', 'due_date'] as $f) {
if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $input[$f]; }
diff --git a/public/plugins/terminal/README.md b/public/plugins/terminal/README.md
new file mode 100644
index 0000000..f546a80
--- /dev/null
+++ b/public/plugins/terminal/README.md
@@ -0,0 +1,48 @@
+# terminal plugin
+
+In-app command line for controlling hyprcrd with text commands.
+
+## Toggle
+
+**Shift+T** opens/closes the terminal. **ESC** closes it from inside.
+
+## Commands
+
+| Command | Description |
+|---------|-------------|
+| `help` | list all commands |
+| `clear` | clear terminal output |
+| `go ` | switch bucket (name or: all, current, favorites, journal, unsorted) |
+| `open ` | open card preview (fuzzy title match) |
+| `edit ` | open card in editor |
+| `new ` | create card in active bucket |
+| `delete ` | delete a card |
+| `tag ` | append tag to card body |
+| `untag ` | remove tag from card body |
+| `color ` | change card color |
+| `move ` | move card to another bucket |
+| `search ` | open search overlay with query |
+| `find ` | list matching cards inline |
+| `calendar` | open calendar |
+| `assets` | open assets gallery |
+| `export` | download export ZIP |
+| `sort ` | change sort (manual/newest/oldest/alpha/tag) |
+| `split` | toggle split preview |
+| `zen` | toggle zen mode |
+| `count` | show card and bucket counts |
+| `tags` | list all tags |
+| `buckets` | list all buckets |
+| `version` | show app version |
+
+## Features
+
+- **Tab completion** on command names
+- **History** with arrow up/down
+- **Fuzzy matching** on card and bucket names
+- Colored output: accent for success, red for errors, grey for info
+- Slides up from the bottom, max 45% viewport height
+- Scrollable output buffer
+
+## Disabling
+
+Add `"terminal"` to the `disabled` array in `data/plugins.json`.
diff --git a/public/plugins/terminal/plugin.json b/public/plugins/terminal/plugin.json
new file mode 100644
index 0000000..8b2a957
--- /dev/null
+++ b/public/plugins/terminal/plugin.json
@@ -0,0 +1,10 @@
+{
+ "name": "terminal",
+ "version": "1.0.0",
+ "description": "In-app terminal for controlling hyprcrd with text commands. Toggle with Shift+T.",
+ "frontend": {
+ "js": ["terminal.js"],
+ "css": ["terminal.css"],
+ "html": ["terminal.html"]
+ }
+}
diff --git a/public/plugins/terminal/terminal.css b/public/plugins/terminal/terminal.css
new file mode 100644
index 0000000..bd34d6b
--- /dev/null
+++ b/public/plugins/terminal/terminal.css
@@ -0,0 +1,39 @@
+.hterm {
+ position: fixed; bottom: 0; left: 0; right: 0; z-index: 500;
+ background: var(--t-bg); border-top: 1px solid var(--t-border);
+ max-height: 45vh; display: flex; flex-direction: column;
+ box-shadow: 0 -8px 32px rgba(0,0,0,0.4);
+ transform: translateY(0);
+ transition: transform 0.2s ease, opacity 0.2s ease;
+}
+.hterm.hidden { transform: translateY(100%); opacity: 0; pointer-events: none; }
+
+#hterm-output {
+ flex: 1; overflow-y: auto; padding: 12px 16px;
+ font-family: var(--t-font-content, 'JetBrains Mono', monospace);
+ font-size: 12px; line-height: 1.7;
+ scrollbar-width: thin;
+}
+.hterm-line { color: var(--t-text); white-space: pre-wrap; word-break: break-word; }
+.hterm-line.cmd { color: var(--t-bright); }
+.hterm-line.cmd::before { content: '> '; color: var(--t-accent); }
+.hterm-line.ok { color: var(--t-accent); }
+.hterm-line.err { color: var(--t-danger); }
+.hterm-line.dim { color: var(--t-dim); }
+.hterm-line.heading { color: var(--t-accent); font-family: var(--t-font-pixel, 'Silkscreen', cursive); font-size: 10px; margin-top: 8px; }
+
+.hterm-input-row {
+ display: flex; align-items: center; gap: 8px;
+ padding: 8px 16px; border-top: 1px solid var(--t-border);
+ background: var(--t-surface);
+}
+.hterm-prompt {
+ color: var(--t-accent); font-family: var(--t-font-pixel, 'Silkscreen', cursive);
+ font-size: 11px; flex-shrink: 0;
+}
+#hterm-input {
+ flex: 1; background: transparent; border: none; color: var(--t-bright);
+ font-family: var(--t-font-content, 'JetBrains Mono', monospace);
+ font-size: 12px; outline: none;
+}
+#hterm-input::placeholder { color: var(--t-dim); }
diff --git a/public/plugins/terminal/terminal.html b/public/plugins/terminal/terminal.html
new file mode 100644
index 0000000..389a6bd
--- /dev/null
+++ b/public/plugins/terminal/terminal.html
@@ -0,0 +1,7 @@
+
diff --git a/public/plugins/terminal/terminal.js b/public/plugins/terminal/terminal.js
new file mode 100644
index 0000000..1b181bf
--- /dev/null
+++ b/public/plugins/terminal/terminal.js
@@ -0,0 +1,372 @@
+// ── Terminal plugin — in-app command line ──
+
+const h = window.hyprcrd;
+const $ = id => document.getElementById(id);
+
+const panel = $('hterm');
+const output = $('hterm-output');
+const input = $('hterm-input');
+
+let visible = false;
+let history = [];
+let historyIdx = -1;
+
+function toggle() {
+ visible = !visible;
+ panel.classList.toggle('hidden', !visible);
+ if (visible) { input.focus(); scrollBottom(); }
+ else { document.activeElement?.blur(); }
+}
+
+function scrollBottom() {
+ output.scrollTop = output.scrollHeight;
+}
+
+function print(text, cls = '') {
+ const line = document.createElement('div');
+ line.className = 'hterm-line' + (cls ? ' ' + cls : '');
+ line.textContent = text;
+ output.appendChild(line);
+ scrollBottom();
+}
+
+function printHtml(html, cls = '') {
+ const line = document.createElement('div');
+ line.className = 'hterm-line' + (cls ? ' ' + cls : '');
+ line.innerHTML = html;
+ output.appendChild(line);
+ scrollBottom();
+}
+
+// ── API helpers ──
+
+async function api(path, opts = {}) {
+ return h?.api?.(path, opts) || fetch(path, {
+ headers: { 'Content-Type': 'application/json' },
+ ...opts,
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
+ }).then(r => r.json());
+}
+
+function fuzzy(needle, haystack) {
+ const n = needle.toLowerCase();
+ return haystack.filter(s => s.toLowerCase().includes(n));
+}
+
+function findCard(query) {
+ const q = query.toLowerCase();
+ return window.state?.cards?.find(c => c.title.toLowerCase() === q)
+ || window.state?.cards?.find(c => c.title.toLowerCase().includes(q));
+}
+
+function findBucket(query) {
+ const q = query.toLowerCase();
+ return window.state?.buckets?.find(b => b.name.toLowerCase() === q)
+ || window.state?.buckets?.find(b => b.name.toLowerCase().includes(q));
+}
+
+// ── Commands ──
+
+const COMMANDS = {};
+
+function register(name, desc, fn) {
+ COMMANDS[name] = { desc, fn };
+}
+
+register('help', 'show available commands', () => {
+ print('COMMANDS', 'heading');
+ const maxLen = Math.max(...Object.keys(COMMANDS).map(k => k.length));
+ for (const [name, cmd] of Object.entries(COMMANDS)) {
+ print(` ${name.padEnd(maxLen + 2)} ${cmd.desc}`, 'dim');
+ }
+});
+
+register('clear', 'clear terminal output', () => {
+ output.innerHTML = '';
+});
+
+register('go', 'go — switch to bucket', (args) => {
+ const name = args.join(' ');
+ if (!name) { print('usage: go ', 'err'); return; }
+ const specials = { all: 'all', current: 'current', favorites: 'favorites', journal: 'journal', unsorted: 'none' };
+ if (specials[name.toLowerCase()]) {
+ window.state.activeBucket = specials[name.toLowerCase()];
+ window._renderBoard?.();
+ print(`switched to ${name}`, 'ok');
+ return;
+ }
+ const b = findBucket(name);
+ if (!b) { print(`bucket not found: ${name}`, 'err'); return; }
+ window.state.activeBucket = b.id;
+ window._renderBoard?.();
+ print(`switched to ${b.name}`, 'ok');
+});
+
+register('open', 'open — open card preview', async (args) => {
+ const q = args.join(' ');
+ if (!q) { print('usage: open ', 'err'); return; }
+ const c = findCard(q);
+ if (!c) { print(`card not found: ${q}`, 'err'); return; }
+ window._openDetail?.(c.id);
+ print(`opened: ${c.title}`, 'ok');
+});
+
+register('edit', 'edit — open card in editor', async (args) => {
+ const q = args.join(' ');
+ if (!q) { print('usage: edit ', 'err'); return; }
+ const c = findCard(q);
+ if (!c) { print(`card not found: ${q}`, 'err'); return; }
+ window._openModal?.(c);
+ print(`editing: ${c.title}`, 'ok');
+});
+
+register('new', 'new — create card in active bucket', async (args) => {
+ const title = args.join(' ');
+ if (!title) { print('usage: new ', 'err'); return; }
+ const bucketId = window.state?.activeBucket;
+ const numBucket = Number.isFinite(Number(bucketId)) ? Number(bucketId) : null;
+ const res = await api('api/cards', { method: 'POST', body: { title, body: '', color: '#38bdf8', bucket_id: numBucket } });
+ if (res?.id) {
+ print(`created: ${title} (id: ${res.id})`, 'ok');
+ await window._reloadAll?.();
+ } else {
+ print(res?.error || 'failed', 'err');
+ }
+});
+
+register('delete', 'delete — delete a card', async (args) => {
+ const q = args.join(' ');
+ if (!q) { print('usage: delete ', 'err'); return; }
+ const c = findCard(q);
+ if (!c) { print(`card not found: ${q}`, 'err'); return; }
+ await api(`api/cards/${c.id}`, { method: 'DELETE' });
+ print(`deleted: ${c.title}`, 'ok');
+ await window._reloadAll?.();
+});
+
+register('tag', 'tag — add tag to card', async (args) => {
+ if (args.length < 2) { print('usage: tag ', 'err'); return; }
+ const tag = args.pop();
+ const q = args.join(' ');
+ const c = findCard(q);
+ if (!c) { print(`card not found: ${q}`, 'err'); return; }
+ const body = (c.body || '') + `\n#${tag}`;
+ await api(`api/cards/${c.id}`, { method: 'PATCH', body: { body } });
+ print(`tagged ${c.title} with #${tag}`, 'ok');
+ await window._reloadAll?.();
+});
+
+register('untag', 'untag — remove tag from card', async (args) => {
+ if (args.length < 2) { print('usage: untag ', 'err'); return; }
+ const tag = args.pop();
+ const q = args.join(' ');
+ const c = findCard(q);
+ if (!c) { print(`card not found: ${q}`, 'err'); return; }
+ const body = (c.body || '').replace(new RegExp(`\\s*#${tag}\\b`, 'g'), '');
+ await api(`api/cards/${c.id}`, { method: 'PATCH', body: { body } });
+ print(`removed #${tag} from ${c.title}`, 'ok');
+ await window._reloadAll?.();
+});
+
+register('color', 'color — change card color', async (args) => {
+ if (args.length < 2) { print('usage: color <#hex>', 'err'); return; }
+ const hex = args.pop();
+ const q = args.join(' ');
+ const c = findCard(q);
+ if (!c) { print(`card not found: ${q}`, 'err'); return; }
+ await api(`api/cards/${c.id}`, { method: 'PATCH', body: { color: hex } });
+ print(`${c.title} → ${hex}`, 'ok');
+ await window._reloadAll?.();
+});
+
+register('move', 'move — move card to bucket', async (args) => {
+ if (args.length < 2) { print('usage: move ', 'err'); return; }
+ const bucketName = args.pop();
+ const q = args.join(' ');
+ const c = findCard(q);
+ if (!c) { print(`card not found: ${q}`, 'err'); return; }
+ const b = findBucket(bucketName);
+ if (!b) { print(`bucket not found: ${bucketName}`, 'err'); return; }
+ await api(`api/cards/${c.id}`, { method: 'PATCH', body: { bucket_id: b.id } });
+ print(`moved ${c.title} → ${b.name}`, 'ok');
+ await window._reloadAll?.();
+});
+
+register('search', 'search — open search with query', (args) => {
+ const q = args.join(' ');
+ window._closeSearch?.();
+ const searchInput = document.getElementById('search-input');
+ const overlay = document.getElementById('search');
+ if (overlay) overlay.classList.add('open');
+ if (searchInput) { searchInput.value = q; searchInput.dispatchEvent(new Event('input')); searchInput.focus(); }
+ print(`searching: ${q}`, 'ok');
+});
+
+register('calendar', 'open calendar', () => { window._openCalendar?.(); print('calendar opened', 'ok'); });
+register('assets', 'open assets gallery', () => { window._openGallery?.(); print('assets opened', 'ok'); });
+
+register('export', 'download export ZIP', () => {
+ document.getElementById('export-btn')?.click();
+ print('export started', 'ok');
+});
+
+register('sort', 'sort — manual/newest/oldest/alpha/tag', (args) => {
+ const mode = args[0];
+ if (!mode) { print('usage: sort ', 'err'); return; }
+ const sortBtn = document.getElementById('sort-toggle');
+ if (sortBtn) {
+ const label = document.getElementById('sort-label');
+ if (label) label.textContent = mode;
+ window.state.sortMode = mode;
+ window._renderBoard?.();
+ print(`sort: ${mode}`, 'ok');
+ }
+});
+
+register('split', 'toggle split preview', () => {
+ document.getElementById('board-split-toggle')?.click();
+ print('split toggled', 'ok');
+});
+
+register('zen', 'toggle zen mode on current detail', () => {
+ document.getElementById('detail-zen')?.click();
+ print('zen toggled', 'ok');
+});
+
+register('count', 'show card and bucket counts', () => {
+ const cards = window.state?.cards?.length || 0;
+ const buckets = window.state?.buckets?.length || 0;
+ print(`${cards} cards · ${buckets} buckets`, 'ok');
+});
+
+register('tags', 'list all tags', () => {
+ const tagSet = new Set();
+ for (const c of window.state?.cards || []) {
+ for (const t of (c.tags || '').split(',').filter(Boolean)) tagSet.add(t);
+ }
+ if (!tagSet.size) { print('no tags', 'dim'); return; }
+ const sorted = [...tagSet].sort();
+ print(`${sorted.length} tags:`, 'heading');
+ print(sorted.map(t => '#' + t).join(' '));
+});
+
+register('buckets', 'list all buckets', () => {
+ const bs = window.state?.buckets || [];
+ if (!bs.length) { print('no buckets', 'dim'); return; }
+ print(`${bs.length} buckets:`, 'heading');
+ for (const b of bs) print(` ${b.name} (${b.id})${b.parent_id ? ` → child of ${b.parent_id}` : ''}`);
+});
+
+register('history', 'history — show edit history', async (args) => {
+ const q = args.join(' ');
+ if (!q) { print('usage: history ', 'err'); return; }
+ const c = findCard(q);
+ if (!c) { print(`card not found: ${q}`, 'err'); return; }
+ let entries;
+ try {
+ const res = await fetch(`api/cards/${c.id}/history`);
+ entries = await res.json();
+ } catch (e) { print('failed to fetch history', 'err'); return; }
+ if (!entries || !entries.length) { print(`${c.title}: no edit history`, 'dim'); return; }
+ print(`${c.title} — ${entries.length} changes:`, 'heading');
+ for (const e of entries) {
+ const old = (e.old_value || '(empty)').replace(/\n/g, '↵').substring(0, 60);
+ const nw = (e.new_value || '(empty)').replace(/\n/g, '↵').substring(0, 60);
+ const time = e.changed_at?.substring(0, 16) || '?';
+ print(` ${time} ${e.field}: ${old} → ${nw}`, 'dim');
+ }
+});
+
+register('find', 'find — list matching cards', (args) => {
+ const q = args.join(' ').toLowerCase();
+ if (!q) { print('usage: find ', 'err'); return; }
+ const hits = (window.state?.cards || []).filter(c =>
+ c.title.toLowerCase().includes(q) || (c.body || '').toLowerCase().includes(q) || (c.tags || '').toLowerCase().includes(q)
+ );
+ if (!hits.length) { print('no matches', 'dim'); return; }
+ print(`${hits.length} matches:`, 'heading');
+ for (const c of hits.slice(0, 20)) {
+ print(` ${c.title}${c.tags ? ' ' + (c.tags || '').split(',').map(t => '#' + t).join(' ') : ''}`, 'dim');
+ }
+ if (hits.length > 20) print(` ... and ${hits.length - 20} more`, 'dim');
+});
+
+register('version', 'show app version', () => {
+ const v = document.querySelector('[data-app-version]')?.textContent || '?';
+ print(v, 'ok');
+});
+
+// ── Tab completion ──
+
+function tabComplete() {
+ const val = input.value;
+ const parts = val.split(/\s+/);
+ if (parts.length <= 1) {
+ const matches = fuzzy(parts[0] || '', Object.keys(COMMANDS));
+ if (matches.length === 1) { input.value = matches[0] + ' '; }
+ else if (matches.length > 1) { print(matches.join(' '), 'dim'); }
+ }
+}
+
+// ── Execute ──
+
+async function exec(line) {
+ const trimmed = line.trim();
+ if (!trimmed) return;
+
+ history.push(trimmed);
+ historyIdx = history.length;
+ print(trimmed, 'cmd');
+
+ const parts = trimmed.split(/\s+/);
+ const cmd = parts.shift().toLowerCase();
+ const command = COMMANDS[cmd];
+
+ if (!command) {
+ print(`unknown command: ${cmd}. Type "help" for a list.`, 'err');
+ return;
+ }
+ try {
+ await command.fn(parts);
+ } catch (e) {
+ print(`error: ${e.message}`, 'err');
+ }
+}
+
+// ── Event handlers ──
+
+input?.addEventListener('keydown', e => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ const val = input.value;
+ input.value = '';
+ exec(val);
+ } else if (e.key === 'Tab') {
+ e.preventDefault();
+ tabComplete();
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ if (historyIdx > 0) { historyIdx--; input.value = history[historyIdx]; }
+ } else if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ if (historyIdx < history.length - 1) { historyIdx++; input.value = history[historyIdx]; }
+ else { historyIdx = history.length; input.value = ''; }
+ } else if (e.key === 'Escape') {
+ e.preventDefault(); e.stopPropagation();
+ toggle();
+ }
+});
+
+// Shift+T toggle (capture, before shortcuts)
+window.addEventListener('keydown', e => {
+ if (e.key === 'T' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
+ const tag = document.activeElement?.tagName;
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || document.activeElement?.isContentEditable) return;
+ e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
+ toggle();
+ }
+}, { capture: true });
+
+print('hyprcrd terminal — type "help" for commands, Shift+T to toggle', 'dim');
+
+h?.registerPlugin({ name: 'terminal', version: '1.0.0' });
diff --git a/public/plugins/tests/runner.js b/public/plugins/tests/runner.js
index 0108ec0..3dadef9 100644
--- a/public/plugins/tests/runner.js
+++ b/public/plugins/tests/runner.js
@@ -171,6 +171,7 @@ export function csrf() {
}
export async function closeAll() {
+ document.getElementById('hterm')?.classList.add('hidden');
for (const id of ['settings-overlay', 'help', 'shortcuts', 'search', 'calendar', 'gallery', 'detail', 'modal', 'tasks', 'dialog']) {
const el = document.getElementById(id);
if (el?.classList.contains('open')) el.classList.remove('open');
diff --git a/public/plugins/tests/terminal.test.js b/public/plugins/tests/terminal.test.js
new file mode 100644
index 0000000..420a8a0
--- /dev/null
+++ b/public/plugins/tests/terminal.test.js
@@ -0,0 +1,192 @@
+import { describe, it, afterEach, expect, $, click, pressKey, sleep, closeAll, csrf } from './runner.js?v=4';
+import { state } from '../../js/modules/state.js';
+
+function termToggle() {
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'T', shiftKey: true, bubbles: true }));
+}
+
+function termExec(cmd) {
+ const input = document.getElementById('hterm-input');
+ if (!input) return;
+ input.value = cmd;
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+}
+
+function termOutput() {
+ return document.getElementById('hterm-output')?.textContent || '';
+}
+
+async function makeCard(title, body = '') {
+ return fetch('api/cards', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
+ body: JSON.stringify({ title, body, color: '#38bdf8' }),
+ }).then(r => r.json());
+}
+
+describe('Terminal: Toggle', () => {
+ afterEach(async () => {
+ document.getElementById('hterm')?.classList.add('hidden');
+ await closeAll();
+ });
+
+ it('panel exists but is hidden', () => {
+ const panel = document.getElementById('hterm');
+ expect(panel).toExist();
+ expect(panel.classList.contains('hidden')).toBe(true);
+ });
+
+ it('Shift+T opens the terminal', async () => {
+ termToggle();
+ await sleep(50);
+ expect(document.getElementById('hterm').classList.contains('hidden')).toBe(false);
+ });
+
+ it('Shift+T again closes it', async () => {
+ termToggle();
+ await sleep(50);
+ termToggle();
+ await sleep(50);
+ expect(document.getElementById('hterm').classList.contains('hidden')).toBe(true);
+ });
+
+ it('ESC closes the terminal', async () => {
+ termToggle();
+ await sleep(50);
+ const input = document.getElementById('hterm-input');
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ await sleep(50);
+ expect(document.getElementById('hterm').classList.contains('hidden')).toBe(true);
+ });
+});
+
+describe('Terminal: Commands', () => {
+ afterEach(async () => {
+ document.getElementById('hterm')?.classList.add('hidden');
+ document.getElementById('hterm-output').innerHTML = '';
+ await closeAll();
+ });
+
+ it('help lists commands', async () => {
+ termToggle();
+ await sleep(50);
+ termExec('help');
+ await sleep(50);
+ const out = termOutput();
+ expect(out).toContain('help');
+ expect(out).toContain('new');
+ expect(out).toContain('search');
+ });
+
+ it('count shows numbers', async () => {
+ termToggle();
+ await sleep(50);
+ termExec('count');
+ await sleep(50);
+ expect(termOutput()).toContain('cards');
+ expect(termOutput()).toContain('buckets');
+ });
+
+ it('version shows version string', async () => {
+ termToggle();
+ await sleep(50);
+ termExec('version');
+ await sleep(50);
+ expect(termOutput()).toContain('v');
+ });
+
+ it('unknown command shows error', async () => {
+ termToggle();
+ await sleep(50);
+ termExec('xyznonexistent');
+ await sleep(50);
+ expect(termOutput()).toContain('unknown command');
+ });
+
+ it('tags lists existing tags', async () => {
+ await makeCard('__term_tag_test__', '#alpha #beta');
+ await window._reloadAll?.();
+ await sleep(200);
+
+ termToggle();
+ await sleep(50);
+ termExec('tags');
+ await sleep(50);
+ expect(termOutput()).toContain('#alpha');
+ expect(termOutput()).toContain('#beta');
+ });
+
+ it('find matches cards by title', async () => {
+ await makeCard('__term_find_me__', 'findable body');
+ await window._reloadAll?.();
+ await sleep(200);
+
+ termToggle();
+ await sleep(50);
+ termExec('find __term_find_me__');
+ await sleep(50);
+ expect(termOutput()).toContain('__term_find_me__');
+ });
+
+ it('go switches to bucket', async () => {
+ termToggle();
+ await sleep(50);
+ termExec('go all');
+ await sleep(100);
+ expect(String(state.activeBucket)).toBe('all');
+ });
+
+ it('new creates a card', async () => {
+ termToggle();
+ await sleep(50);
+ termExec('new __term_new_card__');
+ await sleep(400);
+ const out = termOutput();
+ expect(out).toContain('created');
+ const cards = await fetch('api/cards').then(r => r.json());
+ expect(cards.some(c => c.title === '__term_new_card__')).toBe(true);
+ });
+
+ it('clear empties output', async () => {
+ termToggle();
+ await sleep(50);
+ termExec('help');
+ await sleep(50);
+ expect(termOutput().length).toBeGreaterThan(10);
+ termExec('clear');
+ await sleep(50);
+ expect(document.getElementById('hterm-output').children.length).toBe(0);
+ });
+});
+
+describe('Terminal: Tab Completion', () => {
+ afterEach(async () => {
+ document.getElementById('hterm')?.classList.add('hidden');
+ document.getElementById('hterm-output').innerHTML = '';
+ await closeAll();
+ });
+
+ it('Tab completes unique command', async () => {
+ termToggle();
+ await sleep(50);
+ const input = document.getElementById('hterm-input');
+ input.value = 'vers';
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
+ await sleep(50);
+ expect(input.value).toBe('version ');
+ });
+
+ it('Tab shows multiple matches', async () => {
+ termToggle();
+ await sleep(50);
+ const input = document.getElementById('hterm-input');
+ input.value = 'c';
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
+ await sleep(50);
+ const out = termOutput();
+ expect(out).toContain('clear');
+ expect(out).toContain('count');
+ expect(out).toContain('calendar');
+ expect(out).toContain('color');
+ });
+});
diff --git a/public/plugins/tests/tests.js b/public/plugins/tests/tests.js
index a5aca57..9a945a6 100644
--- a/public/plugins/tests/tests.js
+++ b/public/plugins/tests/tests.js
@@ -51,6 +51,7 @@ const FILES = [
'./notifications.test.js',
'./quick-capture.test.js',
'./asset-picker.test.js',
+ './terminal.test.js',
'./theme.test.js',
];
diff --git a/src/Database.php b/src/Database.php
index 1988828..2b08764 100644
--- a/src/Database.php
+++ b/src/Database.php
@@ -151,5 +151,19 @@ class Database
self::$pdo->exec('ALTER TABLE buckets ADD COLUMN parent_id INTEGER DEFAULT NULL REFERENCES buckets(id)');
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_buckets_parent_id ON buckets(parent_id)');
}
+
+ // Card edit history
+ self::$pdo->exec('
+ CREATE TABLE IF NOT EXISTS card_history (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ card_id INTEGER NOT NULL,
+ field TEXT NOT NULL,
+ old_value TEXT,
+ new_value TEXT,
+ changed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
+ )
+ ');
+ self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_card_history_card ON card_history(card_id, changed_at DESC)');
}
}
|