Files
hyprcrd/public/js/modules/sync.js
T
thandClaude Opus 4.6 1d6499e5dd extract sync orchestration into sync.js, unify on sync.tick heartbeat
caldav.js was carrying the core sync timer, autosync toggle, relay
settings and every poller hook alongside its actual CalDAV/calendar
config concerns. Split out:

- New sync.js owns the single background timer, header Sync button,
  autosync pause toggle, configurable interval, and emits `sync.tick`
  so plugins can fan out without their own setIntervals.
- collab-buckets + chat now subscribe to sync.tick instead of running
  their own pollers, so the manual Sync button refreshes them too and
  the Autosync pause actually pauses everything.
- Replace the number input for sync interval with a stepped slider
  (5s..1h snap points) and a live label, bumped to 1.9.2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:02:44 +02:00

363 lines
16 KiB
JavaScript

// ── 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 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');
}
// 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 =>
`<div class="${l.status === 'error' ? 'text-t-danger' : 'text-t-dim'}">${l.dir} ${l.action} ${l.status}${l.detail ? ' — ' + l.detail : ''}</div>`
).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 ──────────────────────────────────────────
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);
}
// 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 = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent2);display:flex;align-items:center;gap:6px;"><i class="ph ph-check-circle"></i>${body.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}</div>`;
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) {
keyInput.value = s.key || '';
keyInput.placeholder = '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);
};
}
}