Fix calendar recurrence controls

This commit is contained in:
pewdiepie-archdaemon
2026-06-24 11:11:07 +00:00
parent dd055ee6e3
commit 87e46e576a
12 changed files with 389 additions and 32 deletions
+102 -21
View File
@@ -300,7 +300,7 @@ async function _updateEvent(uid, data) {
return { ok: true };
}
async function _deleteEvent(uid) {
async function _deleteEvent(uid, { scope = 'series' } = {}) {
// Multiple "sibling" UIDs may need to vanish optimistically:
// 1. The exact uid the user clicked.
// 2. If the user clicked a RECURRING occurrence (uid contains "::"),
@@ -311,9 +311,12 @@ async function _deleteEvent(uid) {
// other days kept rendering until the next full refresh.
// 3. If the user clicked the master, strip every "master::*"
// expansion (same prefix scan).
const deleteOccurrenceOnly = scope === 'occurrence' && uid.includes('::');
const masterUid = uid.includes('::') ? uid.split('::')[0] : uid;
const backups = {};
const _matches = (k) => k === uid || k === masterUid || k.startsWith(masterUid + '::');
const _matches = deleteOccurrenceOnly
? (k) => k === uid
: (k) => k === uid || k === masterUid || k.startsWith(masterUid + '::');
for (const k of Object.keys(_allEvents)) {
if (_matches(k)) {
@@ -327,7 +330,8 @@ async function _deleteEvent(uid) {
if (_open) _render();
_updateBadge && _updateBadge();
const isRecurring = uid.includes('::');
fetch(`${API_BASE}/api/calendar/events/${encodeURIComponent(uid)}`, {
const scopeParam = deleteOccurrenceOnly ? '?scope=occurrence' : '';
fetch(`${API_BASE}/api/calendar/events/${encodeURIComponent(uid)}${scopeParam}`, {
method: 'DELETE', credentials: 'same-origin',
}).then(r => {
// 404 = the event was already deleted by another session/device. That's
@@ -429,14 +433,77 @@ function _todayCount() {
}).length;
}
// Per-event ⋮ menu: Remind me / Delete
function _findEventByUid(uid) {
return _allEvents[uid] || _events.find(e => e && e.uid === uid) || null;
}
function _isRecurringEvent(ev) {
return !!(ev && (ev.is_recurrence || ev.uid?.includes('::') || ev.rrule));
}
function _chooseRecurringDeleteScope(ev) {
return new Promise(resolve => {
const name = ev?.summary ? `"${ev.summary}"` : 'this event';
const overlay = document.createElement('div');
overlay.className = 'modal';
overlay.style.display = '';
overlay.innerHTML = `
<div class="modal-content styled-confirm-box" role="dialog" aria-modal="true" aria-labelledby="cal-delete-choice-title">
<div class="modal-header"><h4 id="cal-delete-choice-title">Delete recurring event</h4></div>
<div class="modal-body"><p>Delete ${_e(name)}?</p></div>
<div class="modal-footer" style="gap:8px;flex-wrap:wrap;">
<button class="confirm-btn confirm-btn-secondary" data-choice="cancel">Cancel</button>
<button class="confirm-btn confirm-btn-danger" data-choice="occurrence">This event only</button>
<button class="confirm-btn confirm-btn-danger" data-choice="series">All recurring events</button>
</div>
</div>`;
document.body.appendChild(overlay);
const close = (value) => {
document.removeEventListener('keydown', onKey);
overlay.remove();
resolve(value);
};
const onKey = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
close(null);
}
};
overlay.addEventListener('click', (e) => {
if (e.target === overlay) return close(null);
const btn = e.target.closest('[data-choice]');
if (!btn) return;
const choice = btn.dataset.choice;
close(choice === 'cancel' ? null : choice);
});
document.addEventListener('keydown', onKey);
overlay.querySelector('[data-choice="occurrence"]')?.focus();
});
}
async function _confirmAndDeleteEvent(ev) {
if (!ev) return;
const name = ev.summary ? `"${ev.summary}"` : 'this event';
let scope = 'series';
if (_isRecurringEvent(ev)) {
scope = await _chooseRecurringDeleteScope(ev);
if (!scope) return;
} else {
const ok = await uiModule.styledConfirm(`Delete ${name}?`, { confirmText: 'Delete', danger: true });
if (!ok) return;
}
try { await _deleteEvent(ev.uid, { scope }); setTimeout(() => _render(), 100); }
catch (_) { uiModule.showToast('Failed to delete'); }
}
// Per-event ⋮ menu: Edit / Delete
function _wireQuickDelete(body) {
body.querySelectorAll('.cal-event-more').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const uid = btn.dataset.uid;
if (!uid) return;
const ev = _allEvents[uid];
const ev = _findEventByUid(uid);
if (!ev) return;
_showEventMoreMenu(ev, btn);
});
@@ -489,10 +556,7 @@ function _showEventMoreMenu(ev, anchor) {
dropdown.appendChild(_item(_trashIcon, 'Delete', async () => {
closeMenu();
const name = ev.summary ? `"${ev.summary}"` : 'this event';
const ok = await uiModule.styledConfirm(`Delete ${name}?`, { confirmText: 'Delete', danger: true });
if (!ok) return;
try { await _deleteEvent(ev.uid); setTimeout(() => _render(), 100); } catch (_) {}
await _confirmAndDeleteEvent(ev);
}, true));
document.body.appendChild(dropdown);
@@ -1813,7 +1877,7 @@ function _dayDetailHTML(dateStr) {
</div>`;
if (_searchQuery) {
const q = _searchQuery.toLowerCase();
const results = _events
const results = Object.values(_allEvents || {})
.filter(_eventVisible)
.filter(e =>
(e.summary || '').toLowerCase().includes(q) ||
@@ -3115,7 +3179,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
all_day: isAD,
description: document.getElementById('cal-f-desc').value,
location: document.getElementById('cal-f-loc').value,
rrule: document.getElementById('cal-f-rrule').value || undefined,
rrule: document.getElementById('cal-f-rrule').value || '',
calendar_href: document.getElementById('cal-f-cal')?.value || (_calendars[0]?.href || ''),
color: colorVal || undefined,
};
@@ -3141,11 +3205,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
} catch (e) { uiModule.showToast('Failed to save'); }
});
document.getElementById('cal-f-del')?.addEventListener('click', async () => {
const name = existing && existing.summary ? `"${existing.summary}"` : 'this event';
const ok = await uiModule.styledConfirm(`Delete ${name}?`, { confirmText: 'Delete', danger: true });
if (!ok) return;
try { await _deleteEvent(existing.uid); _render(); }
catch (e) { uiModule.showToast('Failed to delete'); }
await _confirmAndDeleteEvent(existing);
});
// ── Bespoke-form behavior ──────────────────────────────────────────
const formEl = body.querySelector('.cal-form');
@@ -3454,8 +3514,18 @@ function openCalendar() {
// Layer Esc: close the topmost calendar surface first, only fall through
// to closing the whole calendar when nothing else is on top.
const settings = document.getElementById('cal-settings-panel');
if (settings) { settings.remove(); return; }
if (document.querySelector('.cal-form')) { _render(); return; }
if (settings) {
e.preventDefault();
e.stopPropagation();
settings.remove();
return;
}
if (document.querySelector('.cal-form')) {
e.preventDefault();
e.stopPropagation();
_render();
return;
}
closeCalendar();
}
else if (e.key === 'ArrowLeft') document.getElementById('cal-prev')?.click();
@@ -3484,14 +3554,25 @@ async function openCalendarTo(target) {
if (!target) return;
try {
await _fetchCalendars();
const targetStr = String(target || '').trim();
if (targetStr.startsWith('search:')) {
_searchQuery = targetStr.slice('search:'.length).trim();
const now = new Date();
await _fetchEvents(`${now.getFullYear()}-01-01`, `${now.getFullYear() + 2}-01-01`);
_currentDate = now;
_selectedDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
_view = 'month';
_render();
return;
}
// If target looks like an ISO date (YYYY-MM-DD...), go straight there.
let dt = null;
const isoMatch = /^\d{4}-\d{2}-\d{2}/.test(String(target));
const isoMatch = /^\d{4}-\d{2}-\d{2}/.test(targetStr);
if (isoMatch) {
dt = new Date(target);
dt = new Date(targetStr);
} else {
// Treat as an event uid — find it among loaded events.
const ev = (_events || []).find(e => e.uid === target || (e.uid || '').startsWith(target));
const ev = Object.values(_allEvents || {}).find(e => e.uid === targetStr || (e.uid || '').startsWith(targetStr));
if (ev && ev.dtstart) dt = new Date(ev.dtstart);
if (ev) _highlightEventUid = ev.uid;
}
+81 -4
View File
@@ -16,6 +16,8 @@ let sessions = [];
let currentSessionId = null;
let _sessionNavToken = 0;
let _skipAutoSelect = false;
const HISTORY_DISPLAY_CHAR_LIMIT = 160000;
const HISTORY_DISPLAY_TAIL_CHARS = 20000;
const SIDEBAR_MAX_VISIBLE = 10;
const FOLDER_MAX_VISIBLE = 5;
@@ -27,6 +29,65 @@ const _INCOGNITO_SESSIONS_KEY = 'ody-incognito-sessions'; // sessionStorage key
const _isMac = /Mac|iPhone|iPad/.test(navigator.platform);
const _mod = _isMac ? '⌘' : 'Ctrl';
function _paintSessionLoading(chatHistory, label = 'Loading chat') {
if (!chatHistory) return;
chatHistory.style.transition = '';
chatHistory.style.opacity = '1';
chatHistory.classList.add('no-animate');
chatHistory.innerHTML = '';
const wrap = document.createElement('div');
wrap.className = 'session-loading-state';
wrap.setAttribute('role', 'status');
wrap.setAttribute('aria-live', 'polite');
wrap.style.cssText = [
'min-height:100%',
'display:flex',
'align-items:center',
'justify-content:center',
'gap:10px',
'color:var(--muted, var(--fg))',
'opacity:0.72',
'font-size:12px'
].join(';');
const spinner = spinnerModule.createWhirlpool(18);
const text = document.createElement('span');
text.className = 'session-loading-state-label';
text.textContent = label;
wrap.appendChild(spinner.element);
wrap.appendChild(text);
chatHistory.appendChild(wrap);
}
function _updateSessionLoading(chatHistory, label) {
const el = chatHistory?.querySelector('.session-loading-state-label');
if (el) el.textContent = label;
}
function _nextPaint() {
return new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}
function _displayHistoryContent(content) {
const text = String(content || '');
if (text.length <= HISTORY_DISPLAY_CHAR_LIMIT) return text;
const head = text.slice(0, HISTORY_DISPLAY_CHAR_LIMIT - HISTORY_DISPLAY_TAIL_CHARS);
const tail = text.slice(-HISTORY_DISPLAY_TAIL_CHARS);
const omitted = text.length - head.length - tail.length;
return [
`> Large message display clipped (${omitted.toLocaleString()} characters omitted). Full content remains stored in chat history/export.`,
'',
head,
'',
'```text',
`[... ${omitted.toLocaleString()} characters omitted from on-screen history render ...]`,
'```',
'',
tail,
].join('\n');
}
function _getIncognitoIds() {
try { return JSON.parse(sessionStorage.getItem(_INCOGNITO_SESSIONS_KEY) || '[]'); } catch { return []; }
}
@@ -1589,7 +1650,14 @@ export async function selectSession(id, { keepSidebar = false } = {}) {
// place, producing a ReferenceError every selectSession.)
const isOC = meta && (meta.is_openclaw || id === 'openclaw');
let msgHistory = [], modelName = null;
let paintedLoading = false;
if (!isOC) {
if (chatHistory && prevSessionId !== id) {
_paintSessionLoading(chatHistory, 'Loading chat');
paintedLoading = true;
await _nextPaint();
if (navToken !== _sessionNavToken || currentSessionId !== id) return;
}
const res = await fetch(`${API_BASE}/api/history/${id}`);
const data = await res.json();
if (navToken !== _sessionNavToken || currentSessionId !== id) return;
@@ -1623,8 +1691,17 @@ export async function selectSession(id, { keepSidebar = false } = {}) {
return;
}
// Fade out old content, swap, fade in
if (chatHistory) {
if (paintedLoading && chatHistory) {
_updateSessionLoading(chatHistory, msgHistory.length ? 'Rendering chat' : 'Opening chat');
await _nextPaint();
if (navToken !== _sessionNavToken || currentSessionId !== id) return;
chatHistory.innerHTML = '';
}
// Fade out old content, swap, fade in. When we already painted a loading
// state, keep it visible until render starts instead of fading to a blank
// pane during slow history fetches.
if (chatHistory && !paintedLoading) {
chatHistory.style.transition = 'opacity 0.12s ease-out';
chatHistory.style.opacity = '0';
await new Promise(r => setTimeout(r, 120));
@@ -1647,10 +1724,10 @@ export async function selectSession(id, { keepSidebar = false } = {}) {
const meta = msg.metadata ? { ...msg.metadata, _fromHistory: true } : null;
let displayContent;
if (typeof msg.content === 'string') {
displayContent = msg.content;
displayContent = _displayHistoryContent(msg.content);
} else if (Array.isArray(msg.content)) {
// Multimodal (image/audio attachments): extract text parts, skip binary
displayContent = msg.content.filter(p => p.type === 'text').map(p => p.text).join('\n').trim();
displayContent = _displayHistoryContent(msg.content.filter(p => p.type === 'text').map(p => p.text).join('\n').trim());
} else {
displayContent = '';
}