/* ══════════════════════════════════════ hyprcrd — app.js (orchestrator) ══════════════════════════════════════ */ import { api, setCsrf, getCsrf } from './modules/util.js'; import { state, getGroupCards } from './modules/state.js'; import { showDialog, isDialogOpen } from './modules/dialog.js'; import './modules/theme.js'; import { loadBuckets, renderTagBar } from './modules/buckets.js'; import { renderBoard, updateSortUI, getSelectedIds, clearSelection } from './modules/board.js'; import { openDetail, closeDetail, showDetailCard, ungroupCard } from './modules/detail.js'; import { openModal, closeModal, saveCard, saveOnly } from './modules/editor.js'; import { openSearch, closeSearch } from './modules/search.js'; import { showContextMenu } from './modules/context-menu.js'; import { undo, canUndo } from './modules/undo.js'; import { openGallery, closeGallery } from './modules/gallery.js'; import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js'; import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.js'; import { startBackgroundSync, triggerSync, loadSettings as loadCaldavSettings, loadRelaySettings } from './modules/caldav.js'; import { openPluginSettings } from './modules/plugin-settings.js'; // ── Global API (for inline onclick handlers and cross-module calls) ── window._openDetail = openDetail; window._openModal = openModal; window._openGallery = openGallery; window._closeSearch = closeSearch; window._closeTasks = closeTasks; window._renderBoard = renderBoard; window._openCalendar = openCalendar; window._triggerSync = triggerSync; window._openPluginSettings = openPluginSettings; window._loadCaldavSettings = loadCaldavSettings; window._loadRelaySettings = loadRelaySettings; window._renderTagBar = renderTagBar; window._showContextMenu = showContextMenu; window._ungroupCard = ungroupCard; window._getSelectedIds = getSelectedIds; window._clearSelection = clearSelection; window._refreshTasks = refreshTasksView; window._reloadAll = async () => { await loadBuckets(); state.cards = await api('api/cards') || []; renderBoard(); refreshCalendar(); }; window._extractToCard = async (text) => { if (!text?.trim()) return; const lines = text.trim().split('\n'); const title = lines[0].replace(/^#+\s*/, '').replace(/^\*+/, '').replace(/^-\s*/, '').trim() || 'Extracted'; const body = text.trim(); // Inherit context from the source card (viewing or editing) const source = state.viewingCard || (state.editingId ? state.cards.find(c => c.id == state.editingId) : null); const data = { title, body, color: source?.color || '#38bdf8', position: -1 }; if (source?.bucket_id) data.bucket_id = source.bucket_id; if (source?.parent_id) data.parent_id = source.parent_id; else if (source && state.cards.some(c => c.parent_id == source.id)) data.parent_id = source.id; await api('api/cards', { method: 'POST', body: data }); await window._reloadAll(); }; // ── Data loading ── const CARDS_CACHE_KEY = 'hyprcrd-cards-cache'; function getCachedCards() { try { return JSON.parse(localStorage.getItem(CARDS_CACHE_KEY) || '[]'); } catch { return []; } } function setCachedCards(cards) { try { const slim = cards.map(c => ({ id: c.id, title: c.title, color: c.color, tags: c.tags, bucket_id: c.bucket_id, bucket_name: c.bucket_name, parent_id: c.parent_id, position: c.position, created_at: c.created_at, updated_at: c.updated_at, due_date: c.due_date, caldav_uid: c.caldav_uid, caldav_rrule: c.caldav_rrule })); localStorage.setItem(CARDS_CACHE_KEY, JSON.stringify(slim)); } catch {} } async function loadCards() { // Phase 0: Show cached cards instantly (from previous session) const cached = getCachedCards(); if (cached.length) { state.cards = cached.map(c => ({ ...c, body: null })); updateSortUI(); renderBoard(); } // Phase 1: Load metadata → update board with fresh titles/tags/dates const meta = await api('api/cards/meta'); if (!meta) return; state.cards = meta.map(c => ({ ...c, body: null })); setCachedCards(meta); updateSortUI(); renderBoard(); // Phase 2: Load full cards in batches → replace spinners with content const batchSize = 25; for (let i = 0; i < meta.length; i += batchSize) { const batch = meta.slice(i, i + batchSize); const ids = batch.map(c => c.id).join(','); const full = await api('api/cards?ids=' + ids); if (!full) continue; for (const card of full) { const idx = state.cards.findIndex(c => c.id === card.id); if (idx >= 0) state.cards[idx] = card; } renderBoard(); } } // ── Help ── const helpOverlay = document.getElementById('help'); function openHelp() { helpOverlay?.classList.add('open'); } function closeHelp() { helpOverlay?.classList.remove('open'); } document.getElementById('help-btn')?.addEventListener('click', openHelp); document.getElementById('help-close')?.addEventListener('click', closeHelp); helpOverlay?.addEventListener('click', e => { if (e.target === helpOverlay) closeHelp(); }); document.addEventListener('keydown', e => { if (e.key === 'F1') { e.preventDefault(); helpOverlay?.classList.contains('open') ? closeHelp() : openHelp(); } }); // ── Export / Import ── document.getElementById('export-btn')?.addEventListener('click', () => { window.location.href = 'api/export'; }); document.getElementById('export-md-btn')?.addEventListener('click', () => { window.location.href = 'api/export-md'; }); const importFile = document.getElementById('import-file'); document.getElementById('import-btn')?.addEventListener('click', () => importFile?.click()); importFile?.addEventListener('change', async e => { const file = e.target.files?.[0]; if (!file) return; // Check if it's a selective export (merge) or ask for full replace const form = new FormData(); form.append('file', file); const mode = await showDialog({ title: 'import_data', message: 'How do you want to import?', okLabel: 'Merge (add to existing)', cancelLabel: 'Replace (wipe all)' }); // mode: true = merge, null = replace if (mode === null) { const sure = await showDialog({ title: 'replace_all', message: 'All existing data will be replaced!', danger: true, okLabel: 'Replace' }); if (!sure) { e.target.value = ''; return; } } try { const url = mode ? 'api/import?merge=1' : 'api/import'; const res = await fetch(url, { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() } }); const data = JSON.parse(await res.text()); if (data.error) throw new Error(data.error); await loadBuckets(); state.cards = await api('api/cards'); renderBoard(); } catch {} e.target.value = ''; }); // ── Auth ── document.getElementById('logout-btn')?.addEventListener('click', async () => { const ok = await showDialog({ title: 'logout', message: 'Log out?', okLabel: 'Logout', danger: true }); if (!ok) return; await fetch('api/auth/logout', { method: 'POST' }); location.reload(); }); async function checkAuth() { const res = await fetch('api/auth/status'); const data = await res.json(); if (data.csrf) { setCsrf(data.csrf); window.hyprcrd?.util?.setCsrf?.(data.csrf); } if (data.setup) { let pw = null; while (!pw || pw.length < 4) { pw = await showDialog({ title: 'setup', message: 'Welcome to hyprcrd. Set a password:', input: 'password', placeholder: '> password (min. 4 chars)...', okLabel: 'Set', cancelLabel: '' }); if (pw && pw.length < 4) pw = null; } const r = await fetch('api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) }); const d = await r.json(); if (d.error) { location.reload(); return; } if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); } } else if (!data.logged_in) { let ok = false; while (!ok) { const pw = await showDialog({ title: 'login', message: '', input: 'password', placeholder: '> password...', okLabel: 'Login', cancelLabel: '' }); if (!pw) continue; const r = await fetch('api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) }); const d = await r.json(); if (d.ok) { ok = true; if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); } } else { /* failed login: caller will retry */ } } } loadBuckets(); loadCards(); startBackgroundSync(); } // ── Global keyboard ── document.addEventListener('keydown', e => { const modal = document.getElementById('modal'); const detail = document.getElementById('detail'); const search = document.getElementById('search'); const tasks = document.getElementById('tasks'); if (isDialogOpen()) return; if (e.key === 'k' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); search.classList.contains('open') ? closeSearch() : openSearch(); return; } if (e.key === 'n' && (e.ctrlKey || e.metaKey) && !e.shiftKey) { e.preventDefault(); const qc = document.getElementById('quick-capture'); const qci = document.getElementById('quick-capture-input'); if (qc.classList.contains('hidden')) { qc.classList.remove('hidden'); qci.focus(); } else { qc.classList.add('hidden'); qci.value = ''; } return; } if (e.key === 'Escape') { const qc = document.getElementById('quick-capture'); if (!qc.classList.contains('hidden')) { qc.classList.add('hidden'); document.getElementById('quick-capture-input').value = ''; return; } if (detail.classList.contains('open')) { // If the user chose "exit zen first", ESC leaves zen mode before closing the card. const escAction = localStorage.getItem('hyprcrd-esc-action') || 'close'; if (escAction === 'exitZen' && detail.classList.contains('zen')) { document.getElementById('detail-zen')?.click(); return; } closeDetail(); } else if (modal.classList.contains('open')) closeModal(); else if (search.classList.contains('open')) closeSearch(); else if (tasks?.classList.contains('open')) closeTasks(); else if (document.getElementById('gallery')?.classList.contains('open')) closeGallery(); return; } if (e.key === 's' && (e.ctrlKey || e.metaKey) && modal.classList.contains('open')) { e.preventDefault(); e.shiftKey ? saveCard() : saveOnly(); return; } if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !modal.classList.contains('open') && canUndo()) { e.preventDefault(); undo(); return; } if (e.key === '/' && ![modal, detail, search, tasks].some(el => el?.classList.contains('open')) && document.activeElement === document.body) { e.preventDefault(); openSearch(); } // Arrow keys navigate group in detail view if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && detail.classList.contains('open') && state.viewingCard) { const gc = getGroupCards(state.viewingCard); if (!gc || gc.length < 2) return; const idx = gc.findIndex(c => c.id == state.viewingCard.id); if (idx === -1) return; e.preventDefault(); showDetailCard((e.key === 'ArrowRight' ? gc[(idx + 1) % gc.length] : gc[(idx - 1 + gc.length) % gc.length]).id); } }); // ── Quick Capture ── document.getElementById('quick-capture-input')?.addEventListener('keydown', async e => { if (e.key === 'Escape') { e.stopPropagation(); document.getElementById('quick-capture').classList.add('hidden'); e.target.value = ''; return; } if (e.key !== 'Enter') return; const raw = e.target.value.trim(); if (!raw) return; e.preventDefault(); // Split on first "|" — title before, body after. // Also accept newlines as a fallback (first line = title, rest = body). let cardTitle, body; const pipeIdx = raw.indexOf('|'); if (pipeIdx >= 0) { cardTitle = raw.slice(0, pipeIdx).trim(); body = raw.slice(pipeIdx + 1).trim(); } else { const lines = raw.split(/\n/); cardTitle = lines[0].trim(); body = lines.slice(1).join('\n').trim(); } if (!cardTitle) return; if (!body.includes('#inbox')) body = (body ? body + '\n\n' : '') + '#inbox'; await api('api/cards', { method: 'POST', body: { title: cardTitle, body, color: '#38bdf8', position: -1 } }); e.target.value = ''; document.getElementById('quick-capture').classList.add('hidden'); await window._reloadAll(); // Toast const n = document.createElement('div'); n.style.cssText = 'position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent);border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);'; n.innerHTML = `
captured: ${cardTitle.substring(0, 40)}
`; document.body.appendChild(n); setTimeout(() => { n.style.transition = 'opacity .3s'; n.style.opacity = '0'; setTimeout(() => n.remove(), 350); }, 2500); }); // ── Boot ── checkAuth(); // ── Test runner: now provided by the "tests" plugin (see public/plugins/tests/) ──