// ── CalDAV Sync ── import { $, esc, api, getCsrf } from './util.js'; import { state } from './state.js'; const CAL_ICONS = [ { id: 'calendar-dots', label: 'calendar' }, { id: 'briefcase', label: 'work' }, { id: 'house', label: 'home' }, { id: 'heart', label: 'personal' }, { id: 'cake', label: 'birthday' }, { id: 'graduation-cap', label: 'education' }, { id: 'airplane', label: 'travel' }, { id: 'basketball', label: 'sport' }, { id: 'first-aid', label: 'health' }, { id: 'star', label: 'important' }, { id: 'users', label: 'team' }, { id: 'phone', label: 'calls' }, ]; const CAL_COLORS = ['#38bdf8', '#8b5cf6', '#fb7185', '#fbbf24', '#4ade80', '#fb923c', '#22d3ee', '#f472b6', '#a3e635', '#94a3b8']; let calendarConfigs = {}; // slug → { color, icon } const CAL_CONFIG_KEY = 'hyprcrd-cal-configs'; const settingsOverlay = $('settings-overlay'); const urlInput = $('caldav-url'); const userInput = $('caldav-user'); const passInput = $('caldav-pass'); const testStatus = $('caldav-test-status'); const syncStatus = $('caldav-sync-status'); const logEl = $('caldav-log'); export function openCaldavSettings() { // Open settings overlay on sync tab settingsOverlay?.classList.add('open'); settingsOverlay?.querySelectorAll('.settings-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === 'caldav')); settingsOverlay?.querySelectorAll('[id^="settings-panel-"]').forEach(p => p.classList.toggle('hidden', p.id !== 'settings-panel-caldav')); loadSettings(); } export function closeCaldavSettings() { settingsOverlay?.classList.remove('open'); } function loadCalConfigs() { try { calendarConfigs = JSON.parse(localStorage.getItem(CAL_CONFIG_KEY) || '{}'); } catch { calendarConfigs = {}; } } function saveCalConfigs() { localStorage.setItem(CAL_CONFIG_KEY, JSON.stringify(calendarConfigs)); } export function getCalConfig(slug) { loadCalConfigs(); return calendarConfigs[slug] || null; } export function getAllCalConfigs() { loadCalConfigs(); return calendarConfigs; } function renderCalList(calendars) { const container = $('caldav-cal-list'); const wrap = $('caldav-calendars'); if (!container || !wrap || !calendars?.length) return; loadCalConfigs(); wrap.classList.remove('hidden'); container.innerHTML = calendars.map(cal => { const slug = cal.name.toLowerCase().replace(/[^a-z0-9_-]/g, ''); const cfg = calendarConfigs[slug] || {}; const activeColor = cfg.color || '#94a3b8'; const activeIcon = cfg.icon || 'calendar-dots'; return `
${esc(cal.name)}
${CAL_COLORS.map(c => ``).join('')}
${CAL_ICONS.slice(0, 6).map(ic => ``).join('')}
`; }).join(''); container.querySelectorAll('.cal-cfg-color').forEach(btn => { btn.addEventListener('click', () => { const slug = btn.closest('[data-cal-slug]').dataset.calSlug; if (!calendarConfigs[slug]) calendarConfigs[slug] = {}; calendarConfigs[slug].color = btn.dataset.color; saveCalConfigs(); renderCalList(calendars); }); }); container.querySelectorAll('.cal-cfg-icon').forEach(btn => { btn.addEventListener('click', () => { const slug = btn.closest('[data-cal-slug]').dataset.calSlug; if (!calendarConfigs[slug]) calendarConfigs[slug] = {}; calendarConfigs[slug].icon = btn.dataset.icon; saveCalConfigs(); renderCalList(calendars); }); }); } export async function loadSettings() { const s = await api('api/caldav/settings'); if (!s) return; if (urlInput) urlInput.value = s.url || ''; if (userInput) userInput.value = s.username || ''; if (passInput) passInput.value = ''; if (passInput && s.has_password) passInput.placeholder = '(saved)'; if (syncStatus) syncStatus.textContent = s.last_sync ? `last sync: ${s.last_sync}` : ''; // Load calendar list if configured if (s.url && s.has_password) { const test = await fetch('api/caldav/test', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() }, body: '{}', }).then(r => r.json()).catch(() => null); if (test?.calendars) renderCalList(test.calendars); } } function setCaldavTestStatus(text, kind = 'info') { if (!testStatus) return; if (!text) { testStatus.classList.add('hidden'); testStatus.textContent = ''; testStatus.style.borderColor = ''; testStatus.style.color = ''; return; } testStatus.classList.remove('hidden'); testStatus.textContent = text; if (kind === 'error') { testStatus.style.borderColor = 'var(--t-danger)'; testStatus.style.color = 'var(--t-danger)'; } else if (kind === 'success') { testStatus.style.borderColor = 'var(--t-accent)'; testStatus.style.color = 'var(--t-accent)'; } else { testStatus.style.borderColor = 'var(--t-border)'; testStatus.style.color = 'var(--t-muted)'; } } $('caldav-test')?.addEventListener('click', async () => { setCaldavTestStatus('testing...'); const body = { url: urlInput.value, username: userInput.value }; if (passInput.value) body.password = passInput.value; const res = await fetch('api/caldav/test', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() }, body: JSON.stringify(body), }).then(r => r.json()).catch(() => ({ ok: false, error: 'network error' })); if (res.ok) { const names = (res.calendars || []).map(c => c.name).join(', '); setCaldavTestStatus(`connected (${names || 'no calendars'})`, 'success'); if (res.calendars) renderCalList(res.calendars); } else { setCaldavTestStatus(res.error || 'failed', 'error'); } showConnectionLog(res.log); }); $('caldav-save')?.addEventListener('click', async () => { const body = { url: urlInput.value, username: userInput.value, enabled: true }; if (passInput.value) body.password = passInput.value; await api('api/caldav/settings', { method: 'POST', body }); testStatus.textContent = 'saved'; testStatus.style.color = 'var(--t-accent)'; }); $('caldav-sync-now')?.addEventListener('click', () => triggerSync()); export async function triggerSync() { const btn = $('cal-sync'); if (btn) btn.style.opacity = '0.5'; const headerIcon = $('header-sync-icon'); if (headerIcon) headerIcon.classList.add('card-spinner'); if (syncStatus) { syncStatus.textContent = 'syncing...'; syncStatus.style.color = 'var(--t-dim)'; } const res = await fetch('api/caldav/sync', { method: 'POST', headers: { 'X-CSRF-Token': getCsrf() }, }).then(r => r.json()).catch(() => ({ ok: false, error: 'network error' })); if (btn) btn.style.opacity = ''; // Also poll relay // (header spinner stays on until the relay poll completes too — see end) const relayRes = await fetch('api/relay/poll', { method: 'POST', headers: { 'X-CSRF-Token': getCsrf() }, }).then(r => r.ok ? r.json() : null).catch(() => null); drainSyncEvents(relayRes); if (res.ok) { const s = res.stats || {}; const rp = relayRes?.pulled || 0; const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 }; const allLog = res.log || []; const hasErrors = allLog.some(l => l.status === 'error'); const msg = `pushed:${total.pushed} pulled:${total.pulled} del:${total.deleted}${rp ? ' relay:' + rp : ''}`; if (syncStatus) { syncStatus.textContent = msg; syncStatus.style.color = hasErrors ? 'var(--t-danger)' : 'var(--t-accent)'; } await window._reloadAll?.(); showLog(allLog); showSyncNotification(total); } else { if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; } } if (headerIcon) headerIcon.classList.remove('card-spinner'); } function showLog(log) { if (!logEl || !log?.length) return; logEl.classList.remove('hidden'); logEl.innerHTML = log.map(l => `
${l.dir} ${l.action} ${l.status}${l.detail ? ' — ' + l.detail : ''}
` ).join(''); } function showConnectionLog(lines) { if (!logEl || !lines?.length) return; logEl.classList.remove('hidden'); logEl.innerHTML = lines.map(l => `
${l.replace(/` ).join(''); } // ── Relay settings ── function setRelayStatus(text, kind = 'info') { const el = $('relay-status'); if (!el) return; if (!text) { el.classList.add('hidden'); el.textContent = ''; el.style.borderColor = ''; el.style.color = ''; return; } el.classList.remove('hidden'); el.textContent = text; if (kind === 'error') { el.style.borderColor = 'var(--t-danger)'; el.style.color = 'var(--t-danger)'; } else if (kind === 'success') { el.style.borderColor = 'var(--t-accent)'; el.style.color = 'var(--t-accent)'; } else { el.style.borderColor = 'var(--t-border)'; el.style.color = 'var(--t-muted)'; } } $('relay-save')?.addEventListener('click', async () => { const body = { url: $('relay-url')?.value || '', username: $('relay-username')?.value || '', key: $('relay-key')?.value || '', }; setRelayStatus('saving...'); await api('api/relay/settings', { method: 'POST', body }); setRelayStatus('saved', 'success'); setTimeout(() => setRelayStatus(''), 1500); }); $('relay-test')?.addEventListener('click', async () => { setRelayStatus('testing...'); const res = await fetch('api/relay/poll', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() }, body: JSON.stringify({ strict: true }), }).then(r => r.json()).catch(() => null); if (res?.ok) { setRelayStatus(`ok — ${res.pulled} pulled`, 'success'); if (res.pulled) await window._reloadAll?.(); drainSyncEvents(res); } else { const detail = res?.sent_username ? ` (sent username: ${res.sent_username})` : ''; setRelayStatus((res?.error || 'failed') + detail, 'error'); } }); export async function loadRelaySettings() { const s = await api('api/relay/settings'); if (!s) return; const urlInput = $('relay-url'); const userInput = $('relay-username'); const keyInput = $('relay-key'); if (urlInput) urlInput.value = s.url || ''; if (userInput) userInput.value = s.username || ''; if (keyInput) { keyInput.value = s.key || ''; keyInput.placeholder = 'shared secret'; } } $('cal-caldav-settings')?.addEventListener('click', openCaldavSettings); $('cal-sync')?.addEventListener('click', () => triggerSync()); $('header-sync')?.addEventListener('click', () => triggerSync()); // Auto-sync throttle let lastAutoSync = 0; export function autoSync() { if (window._testRunning) return; const now = Date.now(); if (now - lastAutoSync < 60000) return; lastAutoSync = now; triggerSync(); } // Background sync every 2 minutes function showSyncNotification(stats) { const s = stats || {}; const parts = []; if (s.pulled) parts.push(`${s.pulled} received`); if (s.pushed) parts.push(`${s.pushed} sent`); if (s.deleted) parts.push(`${s.deleted} deleted`); if (!parts.length) return; const note = document.createElement('div'); note.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);max-width:300px;'; note.innerHTML = `
CalDAV: ${parts.join(', ')}
`; document.body.appendChild(note); setTimeout(() => { note.style.transition = 'opacity 0.3s'; note.style.opacity = '0'; setTimeout(() => note.remove(), 350); }, 4000); } // One toast per incoming sync event: "alice synced: Card Title". // Stacks downward so a burst of multiple doesn't overlay each other. let syncEventToastOffset = 0; function showSyncEventToast(ev) { const body = String(ev?.body || '').trim(); if (!body) return; const bottom = 110 + syncEventToastOffset * 48; syncEventToastOffset++; const note = document.createElement('div'); note.style.cssText = `position:fixed;bottom:${bottom}px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent2);border-radius:4px;padding:9px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:320px;`; note.innerHTML = `
${body.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
`; document.body.appendChild(note); setTimeout(() => { note.style.transition = 'opacity 0.3s'; note.style.opacity = '0'; setTimeout(() => { note.remove(); syncEventToastOffset = Math.max(0, syncEventToastOffset - 1); }, 350); }, 5000); } function drainSyncEvents(relayRes) { const events = Array.isArray(relayRes?.sync_events) ? relayRes.sync_events : []; for (const ev of events) showSyncEventToast(ev); } async function backgroundSync() { if (state.bgSyncRunning) return; state.bgSyncRunning = true; try { const headers = { 'X-CSRF-Token': getCsrf() }; const [res, relayRes] = await Promise.all([ fetch('api/caldav/sync', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null), fetch('api/relay/poll', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null), ]); const s = res?.stats || {}; const rp = relayRes?.pulled || 0; const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 }; if (total.pulled > 0 || total.pushed > 0 || total.deleted > 0) { await window._reloadAll?.(); showSyncNotification(total); } drainSyncEvents(relayRes); } catch {} finally { state.bgSyncRunning = false; } } // Start background sync after app is ready let bgSyncInterval = null; export function startBackgroundSync() { if (bgSyncInterval) return; backgroundSync(); bgSyncInterval = setInterval(backgroundSync, 45000); // every 45 seconds } export function stopBackgroundSync() { if (bgSyncInterval) { clearInterval(bgSyncInterval); bgSyncInterval = null; } }