Files
hyprcrd/public/js/modules/caldav.js
T
2026-04-09 19:14:52 +02:00

291 lines
12 KiB
JavaScript

// ── 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 === '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 `<div class="flex items-center gap-2 py-1" data-cal-slug="${slug}">
<span style="width:10px;height:10px;border-radius:2px;background:${activeColor};flex-shrink:0;"></span>
<span class="font-pixel text-[10px] text-t-bright flex-1">${esc(cal.name)}</span>
<div class="flex gap-1">
${CAL_COLORS.map(c => `<button class="cal-cfg-color" data-color="${c}" style="width:14px;height:14px;border-radius:2px;background:${c};border:2px solid ${c === activeColor ? 'var(--t-bright)' : 'transparent'};cursor:pointer;"></button>`).join('')}
</div>
<div class="flex gap-1 ml-1">
${CAL_ICONS.slice(0, 6).map(ic => `<button class="cal-cfg-icon" data-icon="${ic.id}" title="${ic.label}" style="font-size:12px;cursor:pointer;padding:1px;border-radius:2px;${ic.id === activeIcon ? 'color:var(--t-accent);' : 'color:var(--t-dim);'}"><i class="ph ph-${ic.id}"></i></button>`).join('')}
</div>
</div>`;
}).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());
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 poll relay
const relayRes = await fetch('api/relay/poll', {
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
}).then(r => r.ok ? r.json() : null).catch(() => null);
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)'; }
}
}
function showLog(log) {
if (!logEl || !log?.length) return;
logEl.classList.remove('hidden');
logEl.innerHTML = log.map(l =>
`<div class="${l.status === 'error' ? 'text-t-danger' : 'text-t-dim'}">${l.dir} ${l.action} ${l.status}${l.detail ? ' — ' + l.detail : ''}</div>`
).join('');
}
function showConnectionLog(lines) {
if (!logEl || !lines?.length) return;
logEl.classList.remove('hidden');
logEl.innerHTML = lines.map(l =>
`<div class="${l.startsWith('ERROR') || l.startsWith('EXCEPTION') ? 'text-t-danger' : l.startsWith('OK') ? 'text-t-accent' : 'text-t-dim'}">${l.replace(/</g, '&lt;')}</div>`
).join('');
}
// ── Relay settings ──
$('relay-save')?.addEventListener('click', async () => {
const relayStatus = $('relay-status');
const body = { url: $('relay-url')?.value || '' };
const key = $('relay-key')?.value;
if (key) body.key = key;
await api('api/relay/settings', { method: 'POST', body });
if (relayStatus) { relayStatus.textContent = 'saved'; relayStatus.style.color = 'var(--t-accent)'; }
});
$('relay-test')?.addEventListener('click', async () => {
const relayStatus = $('relay-status');
if (relayStatus) { relayStatus.textContent = 'testing...'; relayStatus.style.color = 'var(--t-dim)'; }
const res = await fetch('api/relay/poll', { method: 'POST', headers: { 'X-CSRF-Token': getCsrf() } }).then(r => r.json()).catch(() => null);
if (res?.ok) {
if (relayStatus) { relayStatus.textContent = `ok — ${res.pulled} pulled`; relayStatus.style.color = 'var(--t-accent)'; }
if (res.pulled) await window._reloadAll?.();
} else {
if (relayStatus) { relayStatus.textContent = res?.error || 'failed'; relayStatus.style.color = 'var(--t-danger)'; }
}
});
export async function loadRelaySettings() {
const s = await api('api/relay/settings');
if (!s) return;
const urlInput = $('relay-url');
const keyInput = $('relay-key');
if (urlInput) urlInput.value = s.url || '';
if (keyInput) keyInput.placeholder = s.has_key ? '(saved)' : 'shared secret';
}
$('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 = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent);display:flex;align-items:center;gap:6px;"><i class="ph ph-cloud-arrow-down"></i>CalDAV: ${parts.join(', ')}</div>`;
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, 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);
}
} 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
}