Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfd5420981 | ||
|
|
fa3a6c2501 | ||
|
|
1a889ea435 | ||
|
|
1d6499e5dd |
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '1.9.0');
|
define('APP_VERSION', '1.9.2');
|
||||||
|
|
||||||
// Database path — writable directory, outside webroot
|
// Database path — writable directory, outside webroot
|
||||||
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hyprcrd",
|
"name": "hyprcrd",
|
||||||
"version": "1.9.0",
|
"version": "1.9.2",
|
||||||
"description": "hyprcrd — self-hosted card manager",
|
"description": "hyprcrd — self-hosted card manager",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ body.anim-slow .card-spinner {
|
|||||||
.design-row { display: flex; align-items: center; gap: 10px; padding: 2px 0; }
|
.design-row { display: flex; align-items: center; gap: 10px; padding: 2px 0; }
|
||||||
.design-row > label { flex: 0 0 88px; font-size: 11px; color: var(--t-muted); }
|
.design-row > label { flex: 0 0 88px; font-size: 11px; color: var(--t-muted); }
|
||||||
.design-row > select { width: 110px; }
|
.design-row > select { width: 110px; }
|
||||||
.design-row > .design-val { font-family: var(--t-font-pixel); font-size: 9px; color: var(--t-dim); width: 36px; text-align: right; flex-shrink: 0; }
|
.design-row > .design-val { font-size: 11px; color: var(--t-muted); width: 44px; text-align: right; flex-shrink: 0; }
|
||||||
.design-section-label { font-family: var(--t-font-pixel); font-size: 9px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--t-dim); margin: 12px 0 4px; }
|
.design-section-label { font-family: var(--t-font-pixel); font-size: 9px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--t-dim); margin: 12px 0 4px; }
|
||||||
.design-section-label:first-child { margin-top: 0; }
|
.design-section-label:first-child { margin-top: 0; }
|
||||||
#design-advanced > summary { list-style: none; }
|
#design-advanced > summary { list-style: none; }
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
/*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */
|
/*! 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>
|
<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>
|
<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">
|
<div class="flex items-center gap-3">
|
||||||
<label class="text-xs text-t-muted flex-1" for="setting-sync-interval">Poll every</label>
|
<label class="text-xs text-t-muted" 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;">
|
<input id="setting-sync-interval" type="range" min="0" max="14" step="1" class="design-slider">
|
||||||
<span class="text-xs text-t-dim">sec</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-1
@@ -18,7 +18,8 @@ import { registerShortcut, guards, renderShortcutHelp } from './modules/shortcut
|
|||||||
import { openGallery, closeGallery } from './modules/gallery.js';
|
import { openGallery, closeGallery } from './modules/gallery.js';
|
||||||
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
|
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
|
||||||
import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.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';
|
import { openPluginSettings } from './modules/plugin-settings.js';
|
||||||
|
|
||||||
// ── Global API (for inline onclick handlers and cross-module calls) ──
|
// ── 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 { $, esc, api, getCsrf } from './util.js';
|
||||||
import { state } from './state.js';
|
|
||||||
|
|
||||||
const CAL_ICONS = [
|
const CAL_ICONS = [
|
||||||
{ id: 'calendar-dots', label: 'calendar' },
|
{ id: 'calendar-dots', label: 'calendar' },
|
||||||
@@ -119,7 +125,6 @@ export async function loadSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function setCaldavTestStatus(text, kind = 'info') {
|
function setCaldavTestStatus(text, kind = 'info') {
|
||||||
if (!testStatus) return;
|
if (!testStatus) return;
|
||||||
if (!text) {
|
if (!text) {
|
||||||
@@ -170,54 +175,6 @@ $('caldav-save')?.addEventListener('click', async () => {
|
|||||||
testStatus.style.color = 'var(--t-accent)';
|
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) {
|
function showConnectionLog(lines) {
|
||||||
if (!logEl || !lines?.length) return;
|
if (!logEl || !lines?.length) return;
|
||||||
logEl.classList.remove('hidden');
|
logEl.classList.remove('hidden');
|
||||||
@@ -226,247 +183,4 @@ function showConnectionLog(lines) {
|
|||||||
).join('');
|
).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-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 { $, esc } from './util.js';
|
||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { stripMd, getTodos } from './markdown.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';
|
import { renderCardHtml } from './board.js';
|
||||||
|
|
||||||
const overlay = $('calendar');
|
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();
|
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') {
|
if (document.readyState === 'loading') {
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
// rows in the standard bucket bar. The plugin just owns the sync + the
|
// rows in the standard bucket bar. The plugin just owns the sync + the
|
||||||
// visual unread-state decoration:
|
// visual unread-state decoration:
|
||||||
//
|
//
|
||||||
// - Polls /api/collab-buckets/poll every POLL_INTERVAL_MS. The backend
|
// - Polls /api/collab-buckets/poll on every `sync.tick` (shared timer
|
||||||
// mirrors each remote bucket into the main `buckets` table so the core
|
// in sync.js). The backend mirrors each remote bucket into the main
|
||||||
// bucket bar renders it automatically.
|
// `buckets` table so the core bucket bar renders it automatically.
|
||||||
// - Exposes `window._collabBucketsCreateDialog` — an async function that
|
// - Exposes `window._collabBucketsCreateDialog` — an async function that
|
||||||
// the core + Bucket button calls so we can pop a richer "new bucket"
|
// the core + Bucket button calls so we can pop a richer "new bucket"
|
||||||
// modal with an optional "collaborative" checkbox + member picker.
|
// modal with an optional "collaborative" checkbox + member picker.
|
||||||
@@ -26,14 +26,9 @@ const $ = (id) => document.getElementById(id);
|
|||||||
let collabByUid = new Map();
|
let collabByUid = new Map();
|
||||||
// card_id (number) -> true for cards with foreign edits we haven't opened yet
|
// card_id (number) -> true for cards with foreign edits we haven't opened yet
|
||||||
let unreadCards = new Set();
|
let unreadCards = new Set();
|
||||||
let pollHandle = null;
|
// Collab card sync is driven by the shared `sync.tick` hook from
|
||||||
// The core CalDAV/relay sync cadence (Settings → Relay → Sync frequency,
|
// sync.js — one timer fans out to every plugin poller, the central
|
||||||
// default 15s) also drives the collab poll. Users can dial up or down
|
// timer honors the Autosync pause for us.
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 4: edit locks (now exclusive — server-enforced)
|
// Phase 4: edit locks (now exclusive — server-enforced)
|
||||||
// card_uid -> { bucket_uid, holder_username, expires_at }
|
// 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);
|
setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 6000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startPolling() {
|
// Subscribe to the global sync heartbeat. The central timer in sync.js
|
||||||
stopPolling();
|
// honors the Autosync pause + the configurable interval for us, and
|
||||||
pollHandle = setInterval(pollOnce, getPollIntervalMs());
|
// the header Sync button emits the same tick so manual sync pushes
|
||||||
}
|
// collab cards too.
|
||||||
function stopPolling() {
|
hyprcrd.hooks?.on?.('sync.tick', () => { pollOnce().catch(() => {}); });
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Bucket bar decoration ─────────────────────────────────────────
|
// ── Bucket bar decoration ─────────────────────────────────────────
|
||||||
|
|
||||||
@@ -992,9 +974,8 @@ async function init() {
|
|||||||
decorateBucketBar();
|
decorateBucketBar();
|
||||||
decorateBoardLocks();
|
decorateBoardLocks();
|
||||||
decorateBoardUnread();
|
decorateBoardUnread();
|
||||||
// Respect the header autosync toggle — if the user has paused it,
|
// No local timer: subsequent polls come from the global `sync.tick`
|
||||||
// skip the recurring poll entirely. Hook above restarts when toggled.
|
// hook (central timer in sync.js honors Autosync pause + interval).
|
||||||
if (localStorage.getItem('hyprcrd-autosync-enabled') !== '0') startPolling();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defer init until the CSRF token is live — `checkAuth()` sets it async on
|
// 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.
|
// Backup is created before and auto-restored after every run.
|
||||||
|
|
||||||
import { getCsrf } from '../../js/modules/util.js';
|
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
|
// 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
|
// by test files via bare imports, one fresh and loaded by loader.js with
|
||||||
@@ -170,6 +170,71 @@ export function csrf() {
|
|||||||
return getCsrf();
|
return getCsrf();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snapshot the view-affecting bits of window.state so we can force a
|
||||||
|
// neutral baseline (normal board, no filter, manual sort, no hides)
|
||||||
|
// for the test run and restore the user's view afterwards. Tests were
|
||||||
|
// flaky when the runner fired while the board was in super-view: DOM
|
||||||
|
// selectors like `.card[data-id]` don't match `.superview-item`, so
|
||||||
|
// multi-select and due-date tests read wrong initial values.
|
||||||
|
export function snapshotViewState() {
|
||||||
|
const s = window.state;
|
||||||
|
if (!s) return null;
|
||||||
|
return {
|
||||||
|
superView: s.superView,
|
||||||
|
activeBucket: s.activeBucket,
|
||||||
|
activeTags: [...(s.activeTags || [])],
|
||||||
|
activeGroup: s.activeGroup,
|
||||||
|
sortMode: s.sortMode,
|
||||||
|
hideTermine: s.hideTermine,
|
||||||
|
hideDone: s.hideDone,
|
||||||
|
hideTagged: s.hideTagged,
|
||||||
|
boardSplitOpen: s.boardSplitOpen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetViewState() {
|
||||||
|
const s = window.state;
|
||||||
|
if (!s) return;
|
||||||
|
s.superView = false;
|
||||||
|
s.activeBucket = 'all';
|
||||||
|
s.activeTags = [];
|
||||||
|
s.activeGroup = null;
|
||||||
|
s.sortMode = 'manual';
|
||||||
|
s.hideTermine = false;
|
||||||
|
s.hideDone = false;
|
||||||
|
s.hideTagged = false;
|
||||||
|
s.boardSplitOpen = false;
|
||||||
|
s.selectedIds?.clear?.();
|
||||||
|
// Sync super-view toggle visuals with the reset state so tests that
|
||||||
|
// inspect the button see the correct pose.
|
||||||
|
const btn = document.getElementById('superview-toggle');
|
||||||
|
const icon = btn?.querySelector('i');
|
||||||
|
if (icon) icon.className = 'ph ph-list text-sm';
|
||||||
|
btn?.classList.remove('active');
|
||||||
|
window._renderBoard?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreViewState(snap) {
|
||||||
|
const s = window.state;
|
||||||
|
if (!s || !snap) return;
|
||||||
|
Object.assign(s, {
|
||||||
|
superView: snap.superView,
|
||||||
|
activeBucket: snap.activeBucket,
|
||||||
|
activeTags: snap.activeTags,
|
||||||
|
activeGroup: snap.activeGroup,
|
||||||
|
sortMode: snap.sortMode,
|
||||||
|
hideTermine: snap.hideTermine,
|
||||||
|
hideDone: snap.hideDone,
|
||||||
|
hideTagged: snap.hideTagged,
|
||||||
|
boardSplitOpen: snap.boardSplitOpen,
|
||||||
|
});
|
||||||
|
const btn = document.getElementById('superview-toggle');
|
||||||
|
const icon = btn?.querySelector('i');
|
||||||
|
if (icon) icon.className = `ph ${snap.superView ? 'ph-squares-four' : 'ph-list'} text-sm`;
|
||||||
|
btn?.classList.toggle('active', !!snap.superView);
|
||||||
|
window._renderBoard?.();
|
||||||
|
}
|
||||||
|
|
||||||
export async function closeAll() {
|
export async function closeAll() {
|
||||||
document.getElementById('hterm')?.classList.add('hidden');
|
document.getElementById('hterm')?.classList.add('hidden');
|
||||||
for (const id of ['settings-overlay', 'help', 'shortcuts', 'search', 'calendar', 'gallery', 'detail', 'modal', 'tasks', 'dialog']) {
|
for (const id of ['settings-overlay', 'help', 'shortcuts', 'search', 'calendar', 'gallery', 'detail', 'modal', 'tasks', 'dialog']) {
|
||||||
@@ -452,6 +517,15 @@ async function run() {
|
|||||||
stopBackgroundSync();
|
stopBackgroundSync();
|
||||||
window._testRunning = true;
|
window._testRunning = true;
|
||||||
|
|
||||||
|
// 2b. Snapshot + reset view state. Tests assume a normal board with
|
||||||
|
// no filters / super-view off / manual sort — if the user left the
|
||||||
|
// app in super-view or with a bucket filter active, assertions on
|
||||||
|
// .card[data-id], card count, etc. read wrong initial values.
|
||||||
|
const viewSnap = snapshotViewState();
|
||||||
|
await closeAll();
|
||||||
|
resetViewState();
|
||||||
|
await sleep(50);
|
||||||
|
|
||||||
// 3. Hide panel so tests run against visible UI
|
// 3. Hide panel so tests run against visible UI
|
||||||
panel.style.display = 'none';
|
panel.style.display = 'none';
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
@@ -476,6 +550,11 @@ async function run() {
|
|||||||
await window._reloadAll?.();
|
await window._reloadAll?.();
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
// Re-reset the view in case a failing test left it in super-view,
|
||||||
|
// a bucket filter, or a sorted mode — otherwise the retry runs
|
||||||
|
// against the same wrong baseline that flaked it the first time.
|
||||||
|
resetViewState();
|
||||||
|
await sleep(50);
|
||||||
const retryResults = await runAll(failedKeys);
|
const retryResults = await runAll(failedKeys);
|
||||||
const retryMap = new Map();
|
const retryMap = new Map();
|
||||||
for (const sr of retryResults) {
|
for (const sr of retryResults) {
|
||||||
@@ -508,6 +587,11 @@ async function run() {
|
|||||||
await sleep(200);
|
await sleep(200);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
// 6b. Put the view back the way the user left it (super-view, sort,
|
||||||
|
// filters, …) so launching the test runner doesn't silently wipe
|
||||||
|
// their working context.
|
||||||
|
restoreViewState(viewSnap);
|
||||||
|
|
||||||
// 7. Resume background sync (delay to avoid immediate re-sync creating duplicates)
|
// 7. Resume background sync (delay to avoid immediate re-sync creating duplicates)
|
||||||
window._testRunning = false;
|
window._testRunning = false;
|
||||||
setTimeout(() => startBackgroundSync(), 60000);
|
setTimeout(() => startBackgroundSync(), 60000);
|
||||||
|
|||||||
@@ -105,6 +105,47 @@ if ($route === '/api/collab-cards' && $method === 'POST') {
|
|||||||
|
|
||||||
// ── GET: pull deltas since a given timestamp ─────────────────────
|
// ── GET: pull deltas since a given timestamp ─────────────────────
|
||||||
|
|
||||||
|
// ── Session-auth summary for the relay UI ─────────────────────
|
||||||
|
// Lets the relay's web UI surface collab bucket activity to the
|
||||||
|
// logged-in tenant without exposing the per-client encryption dance:
|
||||||
|
// decrypts payloads with the inter-tenant key (we're authorizing via
|
||||||
|
// the PHP session, so there's no per-request client secret). Returns
|
||||||
|
// just title + small body preview + author + bucket so the UI can list
|
||||||
|
// recent activity like an inbox.
|
||||||
|
if ($route === '/api/collab-inbox' && $method === 'GET' && $sessionTenant) {
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT c.bucket_uid, c.card_uid, c.author_username, c.payload, c.updated_at, c.deleted,
|
||||||
|
b.name AS bucket_name, b.color AS bucket_color
|
||||||
|
FROM collab_cards c
|
||||||
|
INNER JOIN collab_bucket_members m ON m.bucket_uid = c.bucket_uid
|
||||||
|
LEFT JOIN collab_buckets b ON b.uid = c.bucket_uid
|
||||||
|
WHERE m.tenant_id = :tid AND c.deleted = 0
|
||||||
|
ORDER BY c.updated_at DESC
|
||||||
|
LIMIT 200
|
||||||
|
');
|
||||||
|
$stmt->execute(['tid' => $sessionTenant['id']]);
|
||||||
|
$out = [];
|
||||||
|
foreach ($stmt->fetchAll() as $row) {
|
||||||
|
$plain = clientDecrypt($row['payload'] ?? '', $interKey);
|
||||||
|
$fields = $plain ? (json_decode($plain, true) ?: []) : [];
|
||||||
|
$body = (string)($fields['body'] ?? '');
|
||||||
|
$out[] = [
|
||||||
|
'bucket_uid' => $row['bucket_uid'],
|
||||||
|
'bucket_name' => $row['bucket_name'],
|
||||||
|
'bucket_color' => $row['bucket_color'] ?? '#38bdf8',
|
||||||
|
'card_uid' => $row['card_uid'],
|
||||||
|
'author_username' => $row['author_username'],
|
||||||
|
'title' => (string)($fields['title'] ?? ''),
|
||||||
|
'preview' => mb_substr(trim(strip_tags($body)), 0, 140),
|
||||||
|
'updated_at' => $row['updated_at'],
|
||||||
|
'mine' => $row['author_username'] === $sessionTenant['username'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
jsonResponse(['ok' => true, 'items' => $out]);
|
||||||
|
}
|
||||||
|
|
||||||
if ($route === '/api/collab-cards' && $method === 'GET') {
|
if ($route === '/api/collab-cards' && $method === 'GET') {
|
||||||
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
if (isRateLimited($db, 'collab-cards-get', 120)) jsonResponse(['error' => 'rate limited'], 429);
|
if (isRateLimited($db, 'collab-cards-get', 120)) jsonResponse(['error' => 'rate limited'], 429);
|
||||||
|
|||||||
+42
-1
@@ -93,6 +93,7 @@ function showView(name) {
|
|||||||
loadTenants().then(() => populateRecipientPicker());
|
loadTenants().then(() => populateRecipientPicker());
|
||||||
loadInbox();
|
loadInbox();
|
||||||
loadOutbox();
|
loadOutbox();
|
||||||
|
loadCollabInbox();
|
||||||
startInboxPoll();
|
startInboxPoll();
|
||||||
$('compose-body')?.focus();
|
$('compose-body')?.focus();
|
||||||
} else {
|
} else {
|
||||||
@@ -126,7 +127,11 @@ function startInboxPoll() {
|
|||||||
stopInboxPoll();
|
stopInboxPoll();
|
||||||
inboxPollHandle = setInterval(() => {
|
inboxPollHandle = setInterval(() => {
|
||||||
if (inboxBusy) return;
|
if (inboxBusy) return;
|
||||||
if (!$('view-compose')?.classList.contains('hidden')) { loadInbox({ quiet: true }); loadOutbox({ quiet: true }); }
|
if (!$('view-compose')?.classList.contains('hidden')) {
|
||||||
|
loadInbox({ quiet: true });
|
||||||
|
loadOutbox({ quiet: true });
|
||||||
|
loadCollabInbox({ quiet: true });
|
||||||
|
}
|
||||||
}, 15000);
|
}, 15000);
|
||||||
}
|
}
|
||||||
function stopInboxPoll() {
|
function stopInboxPoll() {
|
||||||
@@ -594,6 +599,42 @@ async function loadOutbox({ quiet = false } = {}) {
|
|||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shared buckets / collab inbox ──
|
||||||
|
let lastCollabJson = '';
|
||||||
|
async function loadCollabInbox({ quiet = false } = {}) {
|
||||||
|
const list = $('shared-list');
|
||||||
|
const countEl = $('shared-count');
|
||||||
|
if (!list) return;
|
||||||
|
const res = await api('api/collab-inbox');
|
||||||
|
const items = res?.items || [];
|
||||||
|
const json = JSON.stringify(items.map(c => c.card_uid + ':' + c.updated_at));
|
||||||
|
if (quiet && json === lastCollabJson) return;
|
||||||
|
lastCollabJson = json;
|
||||||
|
if (countEl) countEl.textContent = items.length;
|
||||||
|
if (!items.length) {
|
||||||
|
list.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="ph ph-users-three icon"></i>
|
||||||
|
<div class="label">no shared bucket activity</div>
|
||||||
|
<div class="hint">Cards from collaborative buckets that you're a member of will appear here.</div>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = items.map(c => `
|
||||||
|
<div class="inbox-item">
|
||||||
|
<div class="content">
|
||||||
|
<div class="from">
|
||||||
|
<span class="shared-bucket-chip" style="border-left:3px solid ${escapeHtml(c.bucket_color || '#38bdf8')};padding-left:6px;">${escapeHtml(c.bucket_name || c.bucket_uid)}</span>
|
||||||
|
<span style="color:var(--dim);margin-left:6px;">${c.mine ? 'you' : escapeHtml(c.author_username || '—')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="title">${c.title ? escapeHtml(c.title) : '<span style="color:var(--dim);font-style:italic;font-weight:normal;">(untitled)</span>'}</div>
|
||||||
|
${c.preview ? `<div style="color:var(--dim);font-size:11px;margin-top:2px;">${escapeHtml(c.preview)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="meta">${escapeHtml(formatRelTime(c.updated_at))}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteMessage(id, btn, source) {
|
async function deleteMessage(id, btn, source) {
|
||||||
inboxBusy = true;
|
inboxBusy = true;
|
||||||
// Inline confirmation: replace the trash button with yes/no.
|
// Inline confirmation: replace the trash button with yes/no.
|
||||||
|
|||||||
+16
-10
@@ -105,16 +105,22 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
</div>
|
</div>
|
||||||
<div id="compose-status" class="compose-status-bar"></div>
|
<div id="compose-status" class="compose-status-bar"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="inbox-section" class="section">
|
<div id="compose-sidebar" class="compose-sidebar">
|
||||||
<h2><i class="ph ph-tray"></i> your inbox <span class="count" id="inbox-count">0</span>
|
<div id="inbox-section" class="section">
|
||||||
<span style="flex:1"></span>
|
<h2><i class="ph ph-tray"></i> your inbox <span class="count" id="inbox-count">0</span>
|
||||||
<button id="inbox-delete-all" class="btn btn-danger hidden" style="padding:3px 10px;font-size:9px;" onclick="deleteAllMessages()"><i class="ph ph-trash"></i> delete all</button>
|
<span style="flex:1"></span>
|
||||||
</h2>
|
<button id="inbox-delete-all" class="btn btn-danger hidden" style="padding:3px 10px;font-size:9px;" onclick="deleteAllMessages()"><i class="ph ph-trash"></i> delete all</button>
|
||||||
<div id="inbox-list"></div>
|
</h2>
|
||||||
</div>
|
<div id="inbox-list"></div>
|
||||||
<div id="outbox-section" class="section">
|
</div>
|
||||||
<h2><i class="ph ph-paper-plane-tilt"></i> sent <span class="count" id="outbox-count">0</span></h2>
|
<div id="shared-section" class="section">
|
||||||
<div id="outbox-list"></div>
|
<h2><i class="ph ph-users-three"></i> shared buckets <span class="count" id="shared-count">0</span></h2>
|
||||||
|
<div id="shared-list"></div>
|
||||||
|
</div>
|
||||||
|
<div id="outbox-section" class="section">
|
||||||
|
<h2><i class="ph ph-paper-plane-tilt"></i> sent <span class="count" id="outbox-count">0</span></h2>
|
||||||
|
<div id="outbox-list"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -687,13 +687,19 @@ header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; }
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compose view: 2-column grid */
|
/* Compose view: 2 columns. Left is the compose editor. Right is a
|
||||||
|
sidebar with inbox → shared → sent stacked vertically. */
|
||||||
#view-compose:not(.hidden) {
|
#view-compose:not(.hidden) {
|
||||||
display: grid !important;
|
display: grid !important;
|
||||||
grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr);
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
#compose-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Cards: depth via shadow + larger padding */
|
/* Cards: depth via shadow + larger padding */
|
||||||
.compose-card,
|
.compose-card,
|
||||||
|
|||||||
Reference in new issue
Block a user