From 87e46e576a5ebe8f7ce20c1acbb09ed7961c28d4 Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Wed, 24 Jun 2026 11:11:07 +0000 Subject: [PATCH] Fix calendar recurrence controls --- core/database.py | 24 ++++++ routes/calendar_routes.py | 47 +++++++++++- src/agent_loop.py | 3 +- src/caldav_sync.py | 2 + src/caldav_writeback.py | 12 ++- src/tool_implementations.py | 20 ++++- src/tool_index.py | 2 +- src/tool_schemas.py | 4 +- static/js/calendar.js | 123 +++++++++++++++++++++++++----- static/js/sessions.js | 85 ++++++++++++++++++++- tests/test_calendar_recurrence.py | 18 +++++ tests/test_calendar_rrule.py | 81 ++++++++++++++++++++ 12 files changed, 389 insertions(+), 32 deletions(-) diff --git a/core/database.py b/core/database.py index 0f1089b39..1616a5587 100644 --- a/core/database.py +++ b/core/database.py @@ -1670,6 +1670,7 @@ class CalendarEvent(TimestampMixin, Base): # `Z`-suffix on serialization so the frontend interprets correctly. is_utc = Column(Boolean, default=False, nullable=False) rrule = Column(String, default="") + recurrence_exdates = Column(Text, default="") # JSON list of skipped occurrence starts color = Column(String, nullable=True) # per-event color override status = Column(String, default="confirmed") # confirmed, cancelled importance = Column(String, default="normal") # low | normal | high | critical @@ -1833,6 +1834,7 @@ def init_db(): _migrate_add_calendar_origin() _migrate_add_calendar_account_id() _migrate_add_caldav_sync_columns() + _migrate_add_calendar_recurrence_exdates() _migrate_chat_messages_fts() _migrate_encrypt_email_passwords() _migrate_encrypt_signatures() @@ -2184,6 +2186,28 @@ def _migrate_add_calendar_metadata(): except Exception: pass + +def _migrate_add_calendar_recurrence_exdates(): + """Add skipped recurrence occurrences for deleting one instance of a series.""" + import sqlite3 + db_path = DATABASE_URL.replace("sqlite:///", "") + if not os.path.exists(db_path): + return + conn = None + try: + conn = sqlite3.connect(db_path) + columns = [row[1] for row in conn.execute("PRAGMA table_info(calendar_events)").fetchall()] + if columns and "recurrence_exdates" not in columns: + conn.execute("ALTER TABLE calendar_events ADD COLUMN recurrence_exdates TEXT DEFAULT ''") + conn.commit() + except Exception as e: + logging.getLogger(__name__).warning(f"calendar_events recurrence_exdates migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass + def get_db(): """ Dependency to get a database session. diff --git a/routes/calendar_routes.py b/routes/calendar_routes.py index 87397e6fc..31ef76215 100644 --- a/routes/calendar_routes.py +++ b/routes/calendar_routes.py @@ -1,6 +1,7 @@ """Calendar routes — local SQLite-backed calendar CRUD.""" import logging +import json import re import uuid from datetime import datetime, date, timedelta @@ -509,6 +510,7 @@ def _event_to_dict(ev: CalendarEvent) -> dict: "description": ev.description or "", "location": ev.location or "", "rrule": ev.rrule or "", + "recurrence_exdates": _recurrence_exdates(ev), "calendar": ev.calendar.name if ev.calendar else "", "calendar_href": ev.calendar_id, "color": ev.color or (ev.calendar.color if ev.calendar else ""), @@ -522,6 +524,28 @@ def _event_to_dict(ev: CalendarEvent) -> dict: _RRULE_EXPANSION_LIMIT = 1000 +def _recurrence_exdates(ev: CalendarEvent) -> list[str]: + raw = getattr(ev, "recurrence_exdates", "") or "" + if not raw: + return [] + try: + values = json.loads(raw) + except Exception: + return [] + if not isinstance(values, list): + return [] + return [str(v) for v in values if isinstance(v, str) and v.strip()] + + +def _occurrence_exdate_key(uid: str, ev: CalendarEvent) -> str: + if "::" not in uid: + return "" + suffix = uid.split("::", 1)[1] + if ev.all_day: + return suffix[:10] + return suffix[:16] + + def _expand_rrule( ev: CalendarEvent, start: datetime, end: datetime ) -> List[dict]: @@ -586,6 +610,7 @@ def _expand_rrule( results = [] truncated = False base = _event_to_dict(ev) + exdates = set(_recurrence_exdates(ev)) for occ_start in rule.xafter(expand_start, inc=True): if occ_start >= end: @@ -606,8 +631,13 @@ def _expand_rrule( # Build the compound uid: {base_uid}::{date} or ::{datetime} if ev.all_day: occ_uid = f"{ev.uid}::{occ_start.strftime('%Y-%m-%d')}" + exdate_key = occ_start.strftime("%Y-%m-%d") else: occ_uid = f"{ev.uid}::{occ_start.strftime('%Y-%m-%dT%H:%M')}" + exdate_key = occ_start.strftime("%Y-%m-%dT%H:%M") + + if exdate_key in exdates: + continue d = dict(base) d["uid"] = occ_uid @@ -1118,7 +1148,7 @@ def setup_calendar_routes() -> APIRouter: db.close() @router.delete("/events/{uid}") - async def delete_event(request: Request, uid: str): + async def delete_event(request: Request, uid: str, scope: str = "series"): owner = _require_user(request) try: base_uid = _resolve_base_uid(uid) @@ -1127,7 +1157,22 @@ def setup_calendar_routes() -> APIRouter: db = SessionLocal() try: ev = _get_or_404_event(db, base_uid, owner) + is_occurrence_delete = scope in {"occurrence", "instance"} and "::" in uid and bool(ev.rrule) is_caldav = ev.calendar and ev.calendar.source == "caldav" + if is_occurrence_delete: + key = _occurrence_exdate_key(uid, ev) + if not key: + raise HTTPException(400, "Invalid recurring occurrence uid") + exdates = _recurrence_exdates(ev) + if key not in exdates: + exdates.append(key) + ev.recurrence_exdates = json.dumps(sorted(exdates)) + if is_caldav: + ev.caldav_sync_pending = "update" + db.commit() + if is_caldav: + await _push_caldav_event_after_commit(owner, base_uid, "update") + return {"ok": True, "scope": "occurrence", "exdate": key} if is_caldav: _record_caldav_delete_tombstone(db, ev, owner) db.delete(ev) diff --git a/src/agent_loop.py b/src/agent_loop.py index c90168324..c0b7a38bc 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -466,9 +466,10 @@ Bulk delete/archive/mark emails. Use this for "delete all those" after listing e Calendar event management (CalDAV). Actions: `list_events`, `create_event`, `update_event`, `delete_event`, `list_calendars`. \ For `list_events`: {start?, end?, calendar?}; prefer `start`/`end` for the range, though start_date/end_date and from/to aliases are accepted. \ For `create_event`: {summary, dtstart, dtend?, duration?, calendar?, location?, description?, reminder_minutes?, rrule?}. \ +For `update_event`: {uid, summary?, dtstart?, dtend?, all_day?, location?, description?, event_type?, importance?, rrule?}. Pass `rrule: ""` to remove recurrence and make a repeating event a single event. \ `dtstart` accepts natural language ("tomorrow at 1pm", "in 2 hours", "next monday 9am") or ISO ("2026-05-12T13:00:00"). \ If `dtend` omitted, defaults to dtstart+1h (or +1d when `all_day: true`). \ -For a RECURRING event pass `rrule` as an iCalendar RRULE string, e.g. `"FREQ=WEEKLY;BYDAY=MO"` (every Monday), `"FREQ=DAILY;COUNT=10"`, or `"FREQ=MONTHLY;BYMONTHDAY=1"` — create ONE event with the rrule, do not loop creating many events. \ +For a RECURRING event pass `rrule` as an iCalendar RRULE string, e.g. `"FREQ=WEEKLY;BYDAY=MO"` (every Monday), `"FREQ=DAILY;COUNT=10"`, or `"FREQ=MONTHLY;BYMONTHDAY=1"` — create ONE event with the rrule, do not loop creating many events. Do not pass `rrule` for "next Wednesday only", "just this once", or any single occurrence. \ If the user asks for a reminder/alarm before the event, pass `reminder_minutes` as an integer; do not write reminder text into the event description and do NOT also call `manage_notes` for the same reminder because calendar reminders are routed through Notes automatically. \ `calendar` accepts a name ("Main") or short-id prefix.""", "create_session": "- ```create_session``` — Create a new chat. Line 1 = chat name, line 2 = model name. Use for background/parallel work.", diff --git a/src/caldav_sync.py b/src/caldav_sync.py index 4cf3c1e5a..fef180e81 100644 --- a/src/caldav_sync.py +++ b/src/caldav_sync.py @@ -25,6 +25,7 @@ Design notes: import asyncio import hashlib import ipaddress +import json import logging import os import socket @@ -494,6 +495,7 @@ def _event_payload(ev) -> dict: "all_day": ev.all_day, "is_utc": ev.is_utc, "rrule": ev.rrule or "", + "recurrence_exdates": json.loads(ev.recurrence_exdates or "[]") if getattr(ev, "recurrence_exdates", "") else [], } diff --git a/src/caldav_writeback.py b/src/caldav_writeback.py index ffb0021e3..b1cf288b1 100644 --- a/src/caldav_writeback.py +++ b/src/caldav_writeback.py @@ -33,7 +33,8 @@ def build_event_ical(ev: dict) -> str: """Serialize a local event dict to a VCALENDAR/VEVENT iCalendar string. ``ev`` keys: uid, summary, description, location, dtstart (datetime), - dtend (datetime), all_day (bool), is_utc (bool), rrule (str). + dtend (datetime), all_day (bool), is_utc (bool), rrule (str), + recurrence_exdates (list[str]). Mirrors how the pull path interprets is_utc/all_day so a round-trip is stable. """ from icalendar import Calendar, Event as iEvent @@ -70,6 +71,15 @@ def build_event_ical(ev: dict) -> str: ve.add("rrule", vRecur.from_ical(ev["rrule"])) except Exception: logger.debug("CalDAV write-back: skipping unparseable rrule %r", ev.get("rrule")) + for exdate in ev.get("recurrence_exdates") or []: + try: + if ev.get("all_day"): + ve.add("exdate", datetime.strptime(exdate[:10], "%Y-%m-%d").date()) + else: + dt = datetime.strptime(exdate[:16], "%Y-%m-%dT%H:%M") + ve.add("exdate", dt.replace(tzinfo=timezone.utc) if ev.get("is_utc") else dt) + except Exception: + logger.debug("CalDAV write-back: skipping unparseable exdate %r", exdate) cal.add_component(ve) return cal.to_ical().decode("utf-8") diff --git a/src/tool_implementations.py b/src/tool_implementations.py index 3ce3ee613..5c24f25ed 100644 --- a/src/tool_implementations.py +++ b/src/tool_implementations.py @@ -1760,6 +1760,17 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict: return value return None + def _normalized_rrule(raw: Any) -> str: + """Normalize model-friendly recurrence clearing aliases to no RRULE.""" + if raw is None: + return "" + if raw is False: + return "" + text = str(raw).strip() + if text.lower() in {"", "none", "no", "off", "false", "single", "once", "one_off", "one-off"}: + return "" + return text + def _create_calendar_reminder(summary: str, location: str, dtstart: datetime, all_day: bool, minutes_before: int, is_utc: bool = False) -> tuple[Optional[str], Optional[str]]: @@ -1864,6 +1875,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict: "calendar_href": ev.calendar_id, "event_type": ev.event_type or "", "importance": ev.importance or "normal", + "rrule": ev.rrule or "", }) if not events: response_text = f"No events between {start_dt.date().isoformat()} and {end_dt.date().isoformat()}." @@ -1878,6 +1890,8 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict: line += f" #{ev['event_type']}" if ev.get("importance") and ev["importance"] != "normal": line += f" !{ev['importance']}" + if ev.get("rrule"): + line += f" repeats({ev['rrule']})" if ev.get("location"): line += f" @ {ev['location']}" if ev.get("calendar"): @@ -2013,7 +2027,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict: location=args.get("location", "") or "", dtstart=dtstart, dtend=dtend, all_day=all_day, is_utc=dtstart_is_utc and not all_day, - rrule=args.get("rrule", "") or "", + rrule=_normalized_rrule(args.get("rrule")), event_type=event_type, importance=importance, caldav_sync_pending="create" if cal.source == "caldav" else None, @@ -2090,6 +2104,10 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict: ev.event_type = _tag or None if args.get("importance") is not None: ev.importance = args["importance"] + if any(name in args for name in ("rrule", "recurrence", "repeat")): + ev.rrule = _normalized_rrule( + args.get("rrule", args.get("recurrence", args.get("repeat"))) + ) is_caldav = ev.calendar and ev.calendar.source == "caldav" if is_caldav: ev.caldav_sync_pending = "update" diff --git a/src/tool_index.py b/src/tool_index.py index 64640bcef..65332adb0 100644 --- a/src/tool_index.py +++ b/src/tool_index.py @@ -118,7 +118,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = { "resolve_contact": "Look up a contact's email address by name. Searches CardDAV address book and sent email history. Use when the user says 'message [name]', 'email [name]', or 'send to [name]' without an email address.", "manage_contact": "Save / update / delete / list address-book contacts (CardDAV). Use for info about ANOTHER person — name, email, phone, postal address. Args: action=list|add|update|delete, name, email, phones, address, uid (from list). For 'save this for ' / address pastes / phone numbers next to a name, this is the right tool — NOT manage_memory. Do NOT use for facts about the USER ('my name is X'); those are manage_memory.", "manage_notes": "Create and manage notes and checklists (Google Keep-style). ALWAYS use this for note/todo/checklist/reminder creation — NEVER hit /api/notes via app_api. Accepts natural-language `due_date` like 'tomorrow at 9am' or '11pm today' (parsed in the USER'S timezone). The due_date IS the reminder — it fires a notification at that time, so do NOT also create a calendar event for the same reminder. Set colors, labels, pin, archive. Do NOT use manage_memory for note content.", - "manage_calendar": "Calendar event management: list, create, update, delete. Each event can carry a tag/category (event_type — work/personal/health/travel/meal/social/admin/other) and importance (low/normal/high/critical). Resolve today/tomorrow using the Current date and time context, then use ISO datetimes in the user's local wall time; supports all-day events. For event reminders/alarms, pass reminder_minutes; this creates the Notes reminder, so do not also call manage_notes for the same reminder.", + "manage_calendar": "Calendar event management: list, create, update, delete. Each event can carry a tag/category (event_type — work/personal/health/travel/meal/social/admin/other) and importance (low/normal/high/critical). Resolve today/tomorrow using the Current date and time context, then use ISO datetimes in the user's local wall time; supports all-day events. Use rrule only for explicit recurrence; for update_event pass rrule='' to remove repeats. For event reminders/alarms, pass reminder_minutes; this creates the Notes reminder, so do not also call manage_notes for the same reminder.", "download_model": "Download a HuggingFace model to a local or remote server. Specify repo_id (e.g. 'Qwen/Qwen3-8B'), optional server host, and optional include filter for specific files.", "serve_model": "Start serving a model with vLLM, SGLang, llama.cpp, Ollama, or Diffusers. cmd MUST start with the binary directly — e.g. `vllm serve /mnt/HADES/models/Qwen3.5-397B-A17B-AWQ --port 8003 --tensor-parallel-size 8 …`. NEVER prefix with `cd …`, `source …`, or chain with `&&`/`||` — those get rejected by the validator. The venv activation (env_prefix) and CUDA env are added automatically from the target host's saved settings. For image/inpainting/diffusion use python3 scripts/diffusion_server.py --model --port 8100. After launch, call list_served_models for readiness/errors and retry suggestions. If serve_model fails with 'Invalid characters in cmd', simplify to the bare binary + args.", "list_served_models": "List currently running model servers in the Cookbook — shows status (loading, ready, idle, error), model name, port, throughput, and serve failure diagnosis/retry suggestions. Use when the user asks 'what's running', 'show my cookbook', 'which models are up', 'what's serving'.", diff --git a/src/tool_schemas.py b/src/tool_schemas.py index 1d64b5db6..36321f3d9 100644 --- a/src/tool_schemas.py +++ b/src/tool_schemas.py @@ -538,7 +538,7 @@ FUNCTION_TOOL_SCHEMAS = [ "type": "function", "function": { "name": "manage_calendar", - "description": "Manage calendar events: list events in a date range, create, update, delete. Each event can carry a tag/category (event_type) and importance level. Resolve relative dates like today/tomorrow against the 'Current date and time' system context, then pass ISO 8601 datetimes in the user's local wall time; for all-day events set all_day=true and pass YYYY-MM-DD. For event reminders/alarms, pass reminder_minutes; the tool creates the Odysseus note reminder, so do not also call manage_notes for the same reminder.", + "description": "Manage calendar events: list events in a date range, create, update, delete. Each event can carry a tag/category (event_type) and importance level. Resolve relative dates like today/tomorrow against the 'Current date and time' system context, then pass ISO 8601 datetimes in the user's local wall time; for all-day events set all_day=true and pass YYYY-MM-DD. For event reminders/alarms, pass reminder_minutes; the tool creates the Odysseus note reminder, so do not also call manage_notes for the same reminder. Do not set rrule for single-occurrence requests such as 'next Wednesday only'; use rrule only when the user explicitly wants recurrence.", "parameters": { "type": "object", "properties": { @@ -559,7 +559,7 @@ FUNCTION_TOOL_SCHEMAS = [ "event_type": {"type": "string", "description": "Tag / category for the event. Common values: work, personal, health, travel, meal, social, admin, other. Aliases accepted: tag, category, type."}, "importance": {"type": "string", "enum": ["low", "normal", "high", "critical"], "description": "Priority level (defaults to 'normal')"}, "reminder_minutes": {"type": "integer", "description": "For create_event: create an Odysseus reminder this many minutes before the event, e.g. 5 for 'reminder 5 min before'."}, - "rrule": {"type": "string", "description": "Recurrence rule in iCalendar RRULE format, e.g. 'FREQ=WEEKLY;BYDAY=MO' for weekly on Monday. Use with create_event or update_event."} + "rrule": {"type": "string", "description": "Recurrence rule in iCalendar RRULE format, e.g. 'FREQ=WEEKLY;BYDAY=MO' for weekly on Monday. Use with create_event or update_event. For update_event, pass an explicit empty string to remove recurrence and make the event single-occurrence."} }, "required": ["action"] } diff --git a/static/js/calendar.js b/static/js/calendar.js index 2b14d024a..2dba69b65 100644 --- a/static/js/calendar.js +++ b/static/js/calendar.js @@ -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 = ` + `; + 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) { `; 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; } diff --git a/static/js/sessions.js b/static/js/sessions.js index a337be105..ea94aec13 100644 --- a/static/js/sessions.js +++ b/static/js/sessions.js @@ -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 = ''; } diff --git a/tests/test_calendar_recurrence.py b/tests/test_calendar_recurrence.py index bc78127ed..bc1aad190 100644 --- a/tests/test_calendar_recurrence.py +++ b/tests/test_calendar_recurrence.py @@ -57,6 +57,7 @@ def _make_event(**overrides): "all_day": False, "is_utc": False, "rrule": "", + "recurrence_exdates": "", "calendar": _MOCK_CAL.name, "calendar_id": "cal-001", "color": None, @@ -84,6 +85,23 @@ def test_expand_non_recurring_returns_single(): assert r["is_recurrence"] is False +def test_expand_rrule_skips_deleted_occurrence_exdate(): + cal = _import_calendar_helpers() + ev = _make_event( + dtstart=datetime(2026, 7, 1, 14, 0), + dtend=datetime(2026, 7, 1, 15, 0), + rrule="FREQ=WEEKLY;BYDAY=WE", + recurrence_exdates='["2026-07-08T14:00"]', + ) + + results = cal._expand_rrule(ev, datetime(2026, 7, 1), datetime(2026, 7, 22)) + uids = [r["uid"] for r in results] + + assert "evt-test-001::2026-07-01T14:00" in uids + assert "evt-test-001::2026-07-08T14:00" not in uids + assert "evt-test-001::2026-07-15T14:00" in uids + + def test_expand_yearly_old_dtstart_later_year_single_occurrence(): """Create an old DTSTART + FREQ=YEARLY, query a later year, verify exactly one occurrence is returned. diff --git a/tests/test_calendar_rrule.py b/tests/test_calendar_rrule.py index 6a14010dc..2bca87593 100644 --- a/tests/test_calendar_rrule.py +++ b/tests/test_calendar_rrule.py @@ -76,3 +76,84 @@ async def test_create_event_without_rrule_is_single(): assert ev is not None and (ev.rrule or "") == "" finally: db.close() + + +async def test_update_event_can_clear_rrule(): + from src.tool_implementations import do_manage_calendar + + owner = "tester-" + uuid.uuid4().hex[:6] + created = await do_manage_calendar(json.dumps({ + "action": "create_event", + "summary": "Repeating standup", + "dtstart": "2026-07-01T14:00:00Z", + "rrule": "FREQ=WEEKLY;BYDAY=WE", + }), owner=owner) + assert created.get("exit_code", 0) == 0, created + + updated = await do_manage_calendar(json.dumps({ + "action": "update_event", + "uid": created["uid"], + "rrule": "", + }), owner=owner) + assert updated.get("exit_code", 0) == 0, updated + + db = _TS() + try: + ev = db.query(CalendarEvent).filter(CalendarEvent.uid == created["uid"]).first() + assert ev is not None + assert (ev.rrule or "") == "" + finally: + db.close() + + +async def test_update_event_can_clear_rrule_with_repeat_none_alias(): + from src.tool_implementations import do_manage_calendar + + owner = "tester-" + uuid.uuid4().hex[:6] + created = await do_manage_calendar(json.dumps({ + "action": "create_event", + "summary": "Repeating review", + "dtstart": "2026-07-01T15:00:00Z", + "rrule": "FREQ=WEEKLY;BYDAY=WE", + }), owner=owner) + assert created.get("exit_code", 0) == 0, created + + updated = await do_manage_calendar(json.dumps({ + "action": "update_event", + "uid": created["uid"], + "repeat": "none", + }), owner=owner) + assert updated.get("exit_code", 0) == 0, updated + + db = _TS() + try: + ev = db.query(CalendarEvent).filter(CalendarEvent.uid == created["uid"]).first() + assert ev is not None + assert (ev.rrule or "") == "" + finally: + db.close() + + +async def test_list_events_exposes_rrule_for_repeating_events(): + from src.tool_implementations import do_manage_calendar + + owner = "tester-" + uuid.uuid4().hex[:6] + rrule = "FREQ=WEEKLY;BYDAY=WE" + created = await do_manage_calendar(json.dumps({ + "action": "create_event", + "summary": "Weekly sync", + "dtstart": "2026-07-01T14:00:00Z", + "rrule": rrule, + }), owner=owner) + assert created.get("exit_code", 0) == 0, created + + listed = await do_manage_calendar(json.dumps({ + "action": "list_events", + "start": "2026-07-01T00:00:00Z", + "end": "2026-07-02T00:00:00Z", + }), owner=owner) + assert listed.get("exit_code", 0) == 0, listed + matches = [ev for ev in listed["events"] if ev["uid"] == created["uid"]] + assert matches + assert matches[0]["rrule"] == rrule + assert f"repeats({rrule})" in listed["response"]