Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
377 lines
13 KiB
JavaScript
377 lines
13 KiB
JavaScript
// ── 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('tim', 'open time calculator', () => {
|
|
if (window._timToggle) { window._timToggle(); print('tim opened', 'ok'); }
|
|
else print('tim plugin not loaded', 'err');
|
|
});
|
|
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' });
|