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>
This commit is contained in:
1 parent
b7f450e41f
commit
1d6499e5dd
11 files changed
+400
-335
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
define('APP_NAME', 'hyprcrd');
|
||||
define('APP_VERSION', '1.9.0');
|
||||
define('APP_VERSION', '1.9.2');
|
||||
|
||||
// Database path — writable directory, outside webroot
|
||||
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hyprcrd",
|
||||
"version": "1.9.0",
|
||||
"version": "1.9.2",
|
||||
"description": "hyprcrd — self-hosted card manager",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
/*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */
|
||||
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"JetBrains Mono", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-green-400:oklch(79.2% .209 151.711);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-bold:700;--tracking-wider:.05em;--leading-snug:1.375;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-lg:.5rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-pixel:"Silkscreen", cursive;--color-t-bg:var(--t-bg);--color-t-surface:var(--t-surface);--color-t-raised:var(--t-raised);--color-t-border:var(--t-border);--color-t-dim:var(--t-dim);--color-t-muted:var(--t-muted);--color-t-text:var(--t-text);--color-t-bright:var(--t-bright);--color-t-accent:var(--t-accent);--color-t-accent2:var(--t-accent2);--color-t-danger:var(--t-danger)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-widLine truncated
|
||||
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"JetBrains Mono", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-green-400:oklch(79.2% .209 151.711);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-bold:700;--tracking-wider:.05em;--leading-snug:1.375;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-lg:.5rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-pixel:"Silkscreen", cursive;--color-t-bg:var(--t-bg);--color-t-surface:var(--t-surface);--color-t-raised:var(--t-raised);--color-t-border:var(--t-border);--color-t-dim:var(--t-dim);--color-t-muted:var(--t-muted);--color-t-text:var(--t-text);--color-t-bright:var(--t-bright);--color-t-accent:var(--t-accent);--color-t-accent2:var(--t-accent2);--color-t-danger:var(--t-danger)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-widLine truncated
|
||||
+3
-3
@@ -1834,9 +1834,9 @@ if (str_starts_with($route, '/api/')) {
|
||||
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-timer text-xs"></i> sync frequency</div>
|
||||
<p class="text-[10px] text-t-dim leading-relaxed mb-2">How often the background sync polls the relay + CalDAV for new messages, collab updates and sync events. Lower = snappier, higher = less network traffic.</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-xs text-t-muted flex-1" for="setting-sync-interval">Poll every</label>
|
||||
<input id="setting-sync-interval" type="number" min="5" max="3600" step="1" class="bg-t-bg border border-t-border text-t-bright text-xs font-mono px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none" style="width:72px;text-align:right;">
|
||||
<span class="text-xs text-t-dim">sec</span>
|
||||
<label class="text-xs text-t-muted" for="setting-sync-interval">Poll every</label>
|
||||
<input id="setting-sync-interval" type="range" min="0" max="14" step="1" class="design-slider">
|
||||
<span id="setting-sync-interval-val" class="text-[11px] text-t-muted" style="min-width:52px;text-align:right;flex-shrink:0;">15s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -18,7 +18,8 @@ import { registerShortcut, guards, renderShortcutHelp } from './modules/shortcut
|
||||
import { openGallery, closeGallery } from './modules/gallery.js';
|
||||
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
|
||||
import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.js';
|
||||
import { startBackgroundSync, triggerSync, loadSettings as loadCaldavSettings, loadRelaySettings } from './modules/caldav.js';
|
||||
import { loadSettings as loadCaldavSettings } from './modules/caldav.js';
|
||||
import { startBackgroundSync, triggerSync, loadRelaySettings } from './modules/sync.js';
|
||||
import { openPluginSettings } from './modules/plugin-settings.js';
|
||||
|
||||
// ── Global API (for inline onclick handlers and cross-module calls) ──
|
||||
|
||||
+8
-294
@@ -1,7 +1,13 @@
|
||||
// ── CalDAV Sync ──
|
||||
// ── CalDAV Settings + Calendar Config ──
|
||||
//
|
||||
// Genuinely CalDAV-scoped concerns only:
|
||||
// * Settings panel (connection URL / user / password, Test + Save)
|
||||
// * Per-calendar color/icon configuration (persisted in localStorage)
|
||||
//
|
||||
// Sync orchestration (triggerSync, background poll, autosync toggle,
|
||||
// sync-interval, relay settings, sync.tick hook) lives in sync.js.
|
||||
|
||||
import { $, esc, api, getCsrf } from './util.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
const CAL_ICONS = [
|
||||
{ id: 'calendar-dots', label: 'calendar' },
|
||||
@@ -119,7 +125,6 @@ export async function loadSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function setCaldavTestStatus(text, kind = 'info') {
|
||||
if (!testStatus) return;
|
||||
if (!text) {
|
||||
@@ -170,54 +175,6 @@ $('caldav-save')?.addEventListener('click', async () => {
|
||||
testStatus.style.color = 'var(--t-accent)';
|
||||
});
|
||||
|
||||
$('caldav-sync-now')?.addEventListener('click', () => triggerSync());
|
||||
|
||||
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
|
||||
// (header spinner stays on until the relay poll completes too — see end)
|
||||
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);
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -226,247 +183,4 @@ function showConnectionLog(lines) {
|
||||
).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 = `<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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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);
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -3,7 +3,8 @@
|
||||
import { $, esc } from './util.js';
|
||||
import { state } from './state.js';
|
||||
import { stripMd, getTodos } from './markdown.js';
|
||||
import { autoSync, getCalConfig, getAllCalConfigs } from './caldav.js';
|
||||
import { autoSync } from './sync.js';
|
||||
import { getCalConfig, getAllCalConfigs } from './caldav.js';
|
||||
import { renderCardHtml } from './board.js';
|
||||
|
||||
const overlay = $('calendar');
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
// ── 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -410,6 +410,12 @@ function init() {
|
||||
restartPoll();
|
||||
}
|
||||
});
|
||||
|
||||
// Also refresh on the global sync heartbeat. Our own timer keeps
|
||||
// the faster flyout-open cadence (UX-specific); tick guarantees
|
||||
// that the header Sync button refreshes chat too, even when the
|
||||
// flyout is closed and our own timer sits at 20s.
|
||||
hyprcrd.hooks?.on?.('sync.tick', () => { pollOnce().catch(() => {}); });
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
// rows in the standard bucket bar. The plugin just owns the sync + the
|
||||
// visual unread-state decoration:
|
||||
//
|
||||
// - Polls /api/collab-buckets/poll every POLL_INTERVAL_MS. The backend
|
||||
// mirrors each remote bucket into the main `buckets` table so the core
|
||||
// bucket bar renders it automatically.
|
||||
// - Polls /api/collab-buckets/poll on every `sync.tick` (shared timer
|
||||
// in sync.js). The backend mirrors each remote bucket into the main
|
||||
// `buckets` table so the core bucket bar renders it automatically.
|
||||
// - Exposes `window._collabBucketsCreateDialog` — an async function that
|
||||
// the core + Bucket button calls so we can pop a richer "new bucket"
|
||||
// modal with an optional "collaborative" checkbox + member picker.
|
||||
@@ -26,14 +26,9 @@ const $ = (id) => document.getElementById(id);
|
||||
let collabByUid = new Map();
|
||||
// card_id (number) -> true for cards with foreign edits we haven't opened yet
|
||||
let unreadCards = new Set();
|
||||
let pollHandle = null;
|
||||
// The core CalDAV/relay sync cadence (Settings → Relay → Sync frequency,
|
||||
// default 15s) also drives the collab poll. Users can dial up or down
|
||||
// without touching code. Defaults + clamp mirror the core helper.
|
||||
function getPollIntervalMs() {
|
||||
const v = parseInt(localStorage.getItem('hyprcrd-sync-interval-sec'));
|
||||
return (Number.isFinite(v) && v >= 5 ? v : 15) * 1000;
|
||||
}
|
||||
// Collab card sync is driven by the shared `sync.tick` hook from
|
||||
// sync.js — one timer fans out to every plugin poller, the central
|
||||
// timer honors the Autosync pause for us.
|
||||
|
||||
// Phase 4: edit locks (now exclusive — server-enforced)
|
||||
// card_uid -> { bucket_uid, holder_username, expires_at }
|
||||
@@ -171,24 +166,11 @@ function showQueuedToast(message) {
|
||||
setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 6000);
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
pollHandle = setInterval(pollOnce, getPollIntervalMs());
|
||||
}
|
||||
function stopPolling() {
|
||||
if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
|
||||
}
|
||||
|
||||
// Restart when the user changes the sync interval in settings.
|
||||
hyprcrd.hooks?.on?.('sync.interval.changed', () => {
|
||||
if (pollHandle) startPolling();
|
||||
});
|
||||
|
||||
// Pause / resume alongside the header autosync toggle.
|
||||
hyprcrd.hooks?.on?.('autosync.changed', (on) => {
|
||||
if (on) startPolling();
|
||||
else stopPolling();
|
||||
});
|
||||
// Subscribe to the global sync heartbeat. The central timer in sync.js
|
||||
// honors the Autosync pause + the configurable interval for us, and
|
||||
// the header Sync button emits the same tick so manual sync pushes
|
||||
// collab cards too.
|
||||
hyprcrd.hooks?.on?.('sync.tick', () => { pollOnce().catch(() => {}); });
|
||||
|
||||
// ── Bucket bar decoration ─────────────────────────────────────────
|
||||
|
||||
@@ -992,9 +974,8 @@ async function init() {
|
||||
decorateBucketBar();
|
||||
decorateBoardLocks();
|
||||
decorateBoardUnread();
|
||||
// Respect the header autosync toggle — if the user has paused it,
|
||||
// skip the recurring poll entirely. Hook above restarts when toggled.
|
||||
if (localStorage.getItem('hyprcrd-autosync-enabled') !== '0') startPolling();
|
||||
// No local timer: subsequent polls come from the global `sync.tick`
|
||||
// hook (central timer in sync.js honors Autosync pause + interval).
|
||||
}
|
||||
|
||||
// Defer init until the CSRF token is live — `checkAuth()` sets it async on
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// Backup is created before and auto-restored after every run.
|
||||
|
||||
import { getCsrf } from '../../js/modules/util.js';
|
||||
import { stopBackgroundSync, startBackgroundSync } from '../../js/modules/caldav.js';
|
||||
import { stopBackgroundSync, startBackgroundSync } from '../../js/modules/sync.js';
|
||||
|
||||
// Suites live on window so two runner.js instances (one cached and loaded
|
||||
// by test files via bare imports, one fresh and loaded by loader.js with
|
||||
|
||||
Reference in new issue
Block a user