// ── Sync Orchestration ───────────────────────────────────────────── // // Owns the single source of truth for "time to sync". Every scheduled // poller — core CalDAV + relay mail pull, the collab-buckets card sync, // the chat plugin — hangs off the same interval via the `sync.tick` hook // instead of running its own setInterval. That way: // // - The header Sync button triggers ALL syncs (emits `sync.tick`). // - The Autosync toggle pauses ALL syncs (stops the single timer). // - The configurable interval changes ALL cadences at once. // // This module also owns the autosync button UI, the sync-event toast // feed, and the relay settings panel (which hosts the interval input). // // Hooks emitted: // sync.tick — fire all pollers now // sync.pulse — pulse the header dot (subset of tick) // sync.interval.changed — restart timers with new cadence // autosync.changed — user paused/resumed scheduled polling // // CalDAV-specific UI (calendar config rendering, CalDAV settings) lives // in caldav.js. This file has no CalDAV icons/colors concerns. import { $, api, getCsrf } from './util.js'; import { state } from './state.js'; const syncStatus = $('caldav-sync-status'); // ── Manual "Sync now" trigger ─────────────────────────────────── // Fired by the header Sync button, the CalDAV panel "sync now" button, // and the `autoSync()` throttled helper. Always runs regardless of the // autosync-paused setting — the user pressed a button, we honor that. // Fans out to every plugin poller via `sync.tick`. export async function triggerSync() { pulseAutosyncDot(); 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 for mail-style messages. Header spinner stays on // until this completes too. 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); // Fan out to plugin pollers (collab-buckets, chat, …). They run // concurrently with us — no await needed here. window.hyprcrd?.hooks?.emit?.('sync.tick'); if (res.ok) { const s = res.stats || {}; const rp = relayRes?.pulled || 0; const caldavStats = { pushed: s.pushed || 0, pulled: s.pulled || 0, deleted: s.deleted || 0 }; const relayStats = { pulled: rp }; const allLog = res.log || []; const hasErrors = allLog.some(l => l.status === 'error'); const msg = `pushed:${caldavStats.pushed} pulled:${caldavStats.pulled + rp} del:${caldavStats.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('CalDAV', 'ph-cloud-arrow-down', caldavStats); showSyncNotification('Relay', 'ph-broadcast', relayStats); } else { if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; } } if (headerIcon) headerIcon.classList.remove('card-spinner'); } // CalDAV log display inside the Settings → CalDAV panel. Lives here // (not in caldav.js) because triggerSync writes to it. function showLog(log) { const logEl = $('caldav-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(''); } // ── Auto-sync throttle ────────────────────────────────────────── // Used by the CalDAV module when the user saves a card with #termin or // #geburtstag — we want to fire a sync immediately but not storm the // server if they paste a big batch. 60s cooldown between auto-fires. let lastAutoSync = 0; export function autoSync() { if (window._testRunning) return; const now = Date.now(); if (now - lastAutoSync < 60000) return; lastAutoSync = now; triggerSync(); } // ── Sync notifications ────────────────────────────────────────── /** Per-source sync toast. Caller picks the label ("CalDAV" vs "Relay") * and icon so the user knows which pipeline just finished, instead of * the old always-CalDAV banner that lumped relay traffic in silently. */ let syncNoteOffset = 0; function showSyncNotification(label, icon, 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 bottom = 60 + syncNoteOffset * 48; syncNoteOffset++; 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-accent);border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;`; note.innerHTML = `
${label}: ${parts.join(', ')}
`; document.body.appendChild(note); setTimeout(() => { note.style.transition = 'opacity 0.3s'; note.style.opacity = '0'; setTimeout(() => { note.remove(); syncNoteOffset = Math.max(0, syncNoteOffset - 1); }, 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) { if (relayRes?.sync_debug) { console.debug('[sync-events]', relayRes.sync_debug); } const events = Array.isArray(relayRes?.sync_events) ? relayRes.sync_events : []; for (const ev of events) showSyncEventToast(ev); } // ── Scheduled background pull ─────────────────────────────────── // Internal helper — the CalDAV + relay mail side of a tick. Plugin // pollers run concurrently via the `sync.tick` hook. async function backgroundPull() { 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; } } // Central tick: the one and only scheduled sync heartbeat. Fires the // CalDAV+relay pull and emits `sync.tick` for every other poller. function tick() { pulseAutosyncDot(); backgroundPull(); window.hyprcrd?.hooks?.emit?.('sync.tick'); } // ── Configurable sync interval ──────────────────────────────── // One setting drives every scheduled poller. Changed via the Relay // settings panel; emits `sync.interval.changed` so the central timer // restarts on the new cadence without a page reload. const SYNC_INTERVAL_KEY = 'hyprcrd-sync-interval-sec'; const SYNC_INTERVAL_DEFAULT = 15; const SYNC_INTERVAL_MIN = 5; const SYNC_INTERVAL_MAX = 3600; export function getSyncIntervalSec() { const v = parseInt(localStorage.getItem(SYNC_INTERVAL_KEY)); return Number.isFinite(v) && v >= SYNC_INTERVAL_MIN ? v : SYNC_INTERVAL_DEFAULT; } export function setSyncIntervalSec(sec) { const n = parseInt(sec); const clamped = Math.max(SYNC_INTERVAL_MIN, Math.min(SYNC_INTERVAL_MAX, Number.isFinite(n) ? n : SYNC_INTERVAL_DEFAULT)); localStorage.setItem(SYNC_INTERVAL_KEY, String(clamped)); window.hyprcrd?.hooks?.emit?.('sync.interval.changed', clamped); } // ── Autosync on/off toggle ──────────────────────────────────── const AUTOSYNC_KEY = 'hyprcrd-autosync-enabled'; export function isAutoSyncEnabled() { return localStorage.getItem(AUTOSYNC_KEY) !== '0'; } export function setAutoSyncEnabled(on) { localStorage.setItem(AUTOSYNC_KEY, on ? '1' : '0'); if (on) startBackgroundSync(); else stopBackgroundSync(); updateAutosyncButton(); window.hyprcrd?.hooks?.emit?.('autosync.changed', on); } // ── Central tick timer ──────────────────────────────────────── let tickInterval = null; export function startBackgroundSync() { if (tickInterval) return; if (!isAutoSyncEnabled()) { updateAutosyncButton(); return; } tick(); tickInterval = setInterval(tick, getSyncIntervalSec() * 1000); updateAutosyncButton(); } export function stopBackgroundSync() { if (tickInterval) { clearInterval(tickInterval); tickInterval = null; } updateAutosyncButton(); } // Restart the central timer when the user changes the interval. window.hyprcrd?.hooks?.on?.('sync.interval.changed', () => { if (tickInterval) { clearInterval(tickInterval); tickInterval = setInterval(tick, getSyncIntervalSec() * 1000); } }); // ── Header autosync button ──────────────────────────────────── function updateAutosyncButton() { const btn = $('header-autosync'); if (!btn) return; const on = isAutoSyncEnabled(); btn.dataset.autosync = on ? 'on' : 'off'; btn.setAttribute('data-tip', on ? `auto-sync: on (every ${getSyncIntervalSec()}s) — click to pause` : 'auto-sync: paused — click to resume'); } // Pulse the dot once. Rip and re-add the class after a reflow so // re-triggering mid-animation restarts cleanly. export function pulseAutosyncDot() { const dot = $('header-autosync-dot'); if (!dot) return; dot.classList.remove('pulsing'); void dot.offsetWidth; dot.classList.add('pulsing'); } window.hyprcrd?.hooks?.on?.('sync.pulse', pulseAutosyncDot); $('header-autosync')?.addEventListener('click', () => setAutoSyncEnabled(!isAutoSyncEnabled())); window.hyprcrd?.hooks?.on?.('sync.interval.changed', updateAutosyncButton); updateAutosyncButton(); // ── Sync-adjacent button wiring ─────────────────────────────── $('cal-sync')?.addEventListener('click', () => triggerSync()); $('header-sync')?.addEventListener('click', () => triggerSync()); $('caldav-sync-now')?.addEventListener('click', () => triggerSync()); // ── Relay settings panel ────────────────────────────────────── // The relay panel lives in Settings → Relay. It holds the URL / // username / shared-key inputs plus the shared sync-interval number // input that drives every scheduled poller. 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) { // The server no longer echoes the stored secret. Leave the field // blank; placeholder tells the user whether one is set. An empty // save is treated as "field untouched" server-side. keyInput.value = ''; keyInput.placeholder = s.has_key ? '•••••••• (saved — type to change)' : 'shared secret'; } // Sync-interval slider lives in the Relay panel but drives every // poller. Snap points span 5s..1h; live label updates on drag, // persisted value commits on release. const intervalInp = $('setting-sync-interval'); const intervalVal = $('setting-sync-interval-val'); if (intervalInp) { const STEPS = [5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 300, 600, 900, 1800, 3600]; const fmt = (s) => { if (s < 60) return `${s}s`; if (s === 3600) return '1h'; const m = Math.floor(s / 60), r = s % 60; return r ? `${m}m ${r}s` : `${m}m`; }; const idxFor = (sec) => { let best = 0, bestDiff = Infinity; for (let i = 0; i < STEPS.length; i++) { const d = Math.abs(STEPS[i] - sec); if (d < bestDiff) { bestDiff = d; best = i; } } return best; }; intervalInp.max = String(STEPS.length - 1); const sync = (idx) => { const sec = STEPS[idx]; if (intervalVal) intervalVal.textContent = fmt(sec); return sec; }; intervalInp.value = String(idxFor(getSyncIntervalSec())); sync(parseInt(intervalInp.value)); intervalInp.oninput = () => sync(parseInt(intervalInp.value)); intervalInp.onchange = () => { const sec = sync(parseInt(intervalInp.value)); setSyncIntervalSec(sec); }; } }