// ── CalDAV Sync ── import { $, esc, api, getCsrf } from './util.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 === 'sync')); settingsOverlay?.querySelectorAll('[id^="settings-panel-"]').forEach(p => p.classList.toggle('hidden', p.id !== 'settings-panel-sync')); 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); } } $('caldav-test')?.addEventListener('click', async () => { testStatus.textContent = 'testing...'; testStatus.style.color = 'var(--t-dim)'; 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(', '); testStatus.textContent = `connected (${names || 'no calendars'})`; testStatus.style.color = 'var(--t-accent)'; if (res.calendars) renderCalList(res.calendars); } else { testStatus.textContent = res.error || 'failed'; testStatus.style.color = 'var(--t-danger)'; } 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()); $('notes-reset')?.addEventListener('click', async () => { if (syncStatus) { syncStatus.textContent = 'resetting notes...'; syncStatus.style.color = 'var(--t-dim)'; } const res = await fetch('api/notes/reset', { method: 'POST', headers: { 'X-CSRF-Token': getCsrf() }, }).then(r => r.json()).catch(() => null); if (res?.ok) { const s = res.stats || {}; if (syncStatus) { syncStatus.textContent = `reset: ${res.reset || 0} cleared, ${s.pulled || 0} re-pulled`; syncStatus.style.color = 'var(--t-accent)'; } await window._reloadAll?.(); } else { if (syncStatus) { syncStatus.textContent = res?.error || 'reset failed'; syncStatus.style.color = 'var(--t-danger)'; } } }); export async function triggerSync() { const btn = $('cal-sync'); if (btn) btn.style.opacity = '0.5'; 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 sync WebDAV Notes const notesRes = await fetch('api/notes/sync', { method: 'POST', headers: { 'X-CSRF-Token': getCsrf() }, }).then(r => r.json()).catch(() => null); if (res.ok) { const s = res.stats || {}; const ns = notesRes?.stats || {}; const total = { pushed: (s.pushed || 0) + (ns.pushed || 0), pulled: (s.pulled || 0) + (ns.pulled || 0), deleted: (s.deleted || 0) + (ns.deleted || 0) }; const allLog = [...(res.log || []), ...(notesRes?.log || [])]; const hasErrors = allLog.some(l => l.status === 'error'); const msg = `pushed:${total.pushed} pulled:${total.pulled} del:${total.deleted}${ns.pulled ? ' notes:' + ns.pulled : ''}`; 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)'; } } } 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(''); } $('notes-rekey')?.addEventListener('click', async () => { const { showDialog } = await import('./dialog.js'); const oldPass = await showDialog({ title: 're-key notes', input: 'password', placeholder: 'old password', okLabel: 're-key' }); if (!oldPass) return; if (syncStatus) { syncStatus.textContent = 're-keying notes...'; syncStatus.style.color = 'var(--t-dim)'; } const res = await fetch('api/notes/rekey', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() }, body: JSON.stringify({ old_password: oldPass }), }).then(r => r.json()).catch(() => null); if (res?.ok) { if (syncStatus) { syncStatus.textContent = `re-keyed ${res.rekeyed} notes — syncing...`; syncStatus.style.color = 'var(--t-accent)'; } // Re-pull with correct key const pullRes = await fetch('api/notes/reset', { method: 'POST', headers: { 'X-CSRF-Token': getCsrf() } }).then(r => r.json()).catch(() => null); await window._reloadAll?.(); if (syncStatus) syncStatus.textContent = `re-keyed ${res.rekeyed} notes, ${pullRes?.stats?.pulled || 0} re-pulled`; } else { if (syncStatus) { syncStatus.textContent = res?.error || 're-key failed'; syncStatus.style.color = 'var(--t-danger)'; } } }); $('cal-caldav-settings')?.addEventListener('click', openCaldavSettings); $('cal-sync')?.addEventListener('click', () => triggerSync()); // Auto-sync throttle let lastAutoSync = 0; export function autoSync() { 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); } let bgSyncRunning = false; async function backgroundSync() { if (bgSyncRunning) return; bgSyncRunning = true; try { const headers = { 'X-CSRF-Token': getCsrf() }; const [res, notesRes] = await Promise.all([ fetch('api/caldav/sync', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null), fetch('api/notes/sync', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null), ]); const s = res?.stats || {}; const ns = notesRes?.stats || {}; const total = { pushed: (s.pushed || 0) + (ns.pushed || 0), pulled: (s.pulled || 0) + (ns.pulled || 0), deleted: (s.deleted || 0) + (ns.deleted || 0) }; if (total.pulled > 0 || total.pushed > 0 || total.deleted > 0) { await window._reloadAll?.(); showSyncNotification(total); } } catch {} finally { 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 }