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 <card>` | delete a card | +| `tag <card> <tag>` | append tag to card body | +| `untag <card> <tag>` | remove tag from card body | +| `color <card> <hex>` | change card color | +| `move <card> <bucket>` | move card to another bucket | +| `search <query>` | open search overlay with query | +| `find <text>` | list matching cards inline | +| `calendar` | open calendar | +| `assets` | open assets gallery | +| `export` | download export ZIP | +| `sort <mode>` | 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 @@ +<div id="hterm" class="hterm hidden"> + <div id="hterm-output"></div> + <div class="hterm-input-row"> + <span class="hterm-prompt">></span> + <input type="text" id="hterm-input" autocomplete="off" spellcheck="false" placeholder="type a command..."> + </div> +</div> 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 <bucket> — switch to bucket', (args) => { + const name = args.join(' '); + if (!name) { print('usage: go <bucket name>', '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 <card> — open card preview', async (args) => { + const q = args.join(' '); + if (!q) { print('usage: open <card title>', '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 <card> — open card in editor', async (args) => { + const q = args.join(' '); + if (!q) { print('usage: edit <card title>', '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 <title> — create card in active bucket', async (args) => { + const title = args.join(' '); + if (!title) { print('usage: new <title>', '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 <card> — delete a card', async (args) => { + const q = args.join(' '); + if (!q) { print('usage: delete <card title>', '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 <card> <tag> — add tag to card', async (args) => { + if (args.length < 2) { print('usage: tag <card title> <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 <card> <tag> — remove tag from card', async (args) => { + if (args.length < 2) { print('usage: untag <card title> <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 || '').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 <card> <hex> — change card color', async (args) => { + if (args.length < 2) { print('usage: color <card title> <#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 <card> <bucket> — move card to bucket', async (args) => { + if (args.length < 2) { print('usage: move <card title> <bucket name>', '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 <query> — 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 <mode> — manual/newest/oldest/alpha/tag', (args) => { + const mode = args[0]; + if (!mode) { print('usage: sort <manual|newest|oldest|alpha|tag>', '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 <card> — show edit history', async (args) => { + const q = args.join(' '); + if (!q) { print('usage: history <card title>', '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 <text> — list matching cards', (args) => { + const q = args.join(' ').toLowerCase(); + if (!q) { print('usage: find <text>', '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)'); } }