// ── 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 <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' });