// ── 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 `
${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';
}
// Sync-interval number input (shared setting, persisted in localStorage).
// `change` commits on blur or Enter; `input` would save on every keystroke
// and restart the timer on each digit typed — noisy and pointless.
const intervalInp = $('setting-sync-interval');
if (intervalInp) {
intervalInp.value = String(getSyncIntervalSec());
intervalInp.onchange = () => {
setSyncIntervalSec(intervalInp.value);
// Reflect the clamped value back so users see when we refused a 2
// or 5000.
intervalInp.value = String(getSyncIntervalSec());
};
}
}
$('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) {
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);
}
async function backgroundSync() {
if (state.bgSyncRunning) return;
state.bgSyncRunning = true;
pulseAutosyncDot();
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;
}
}
// ── Configurable sync interval ────────────────────────────────
// One setting drives both the CalDAV/relay background sync and the
// collab-buckets plugin poll. Stored in localStorage as seconds.
// Changed via the Relay settings panel → emits `sync.interval.changed`
// so every poller can restart with the new cadence without reload.
const SYNC_INTERVAL_KEY = 'hyprcrd-sync-interval-sec';
const SYNC_INTERVAL_DEFAULT = 15; // snappy default — tune up on slow machines or paid relays
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 ────────────────────────────────────
// User can pause background polling from the header (play/pause icon
// next to the Sync button). Setting persists in localStorage.
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();
// Broadcast so collab-buckets (and any future pollers) pause alongside.
window.hyprcrd?.hooks?.emit?.('autosync.changed', on);
}
// Start background sync after app is ready
let bgSyncInterval = null;
export function startBackgroundSync() {
if (bgSyncInterval) return;
if (!isAutoSyncEnabled()) { updateAutosyncButton(); return; }
backgroundSync();
bgSyncInterval = setInterval(backgroundSync, getSyncIntervalSec() * 1000);
updateAutosyncButton();
}
export function stopBackgroundSync() {
if (bgSyncInterval) { clearInterval(bgSyncInterval); bgSyncInterval = null; }
updateAutosyncButton();
}
// Restart the background timer when the user changes the interval.
window.hyprcrd?.hooks?.on?.('sync.interval.changed', () => {
if (bgSyncInterval) {
clearInterval(bgSyncInterval);
bgSyncInterval = setInterval(backgroundSync, getSyncIntervalSec() * 1000);
}
});
// ── Header autosync button ────────────────────────────────────
// Accent dot when on, grey when off; a single burst-pulse fires at the
// start of every actual sync so the user sees the poll happen.
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; retriggers cleanly even if called mid-animation by
// ripping the class and forcing a reflow before re-adding.
export function pulseAutosyncDot() {
const dot = $('header-autosync-dot');
if (!dot) return;
dot.classList.remove('pulsing');
void dot.offsetWidth;
dot.classList.add('pulsing');
}
// Also accept pulses from other pollers (collab-buckets, etc.) via hooks.
window.hyprcrd?.hooks?.on?.('sync.pulse', pulseAutosyncDot);
$('header-autosync')?.addEventListener('click', () => setAutoSyncEnabled(!isAutoSyncEnabled()));
// Also refresh tooltip when interval changes
window.hyprcrd?.hooks?.on?.('sync.interval.changed', updateAutosyncButton);
updateAutosyncButton();