mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-15 12:58:04 +00:00
Fix calendar recurrence controls
This commit is contained in:
@@ -1670,6 +1670,7 @@ class CalendarEvent(TimestampMixin, Base):
|
|||||||
# `Z`-suffix on serialization so the frontend interprets correctly.
|
# `Z`-suffix on serialization so the frontend interprets correctly.
|
||||||
is_utc = Column(Boolean, default=False, nullable=False)
|
is_utc = Column(Boolean, default=False, nullable=False)
|
||||||
rrule = Column(String, default="")
|
rrule = Column(String, default="")
|
||||||
|
recurrence_exdates = Column(Text, default="") # JSON list of skipped occurrence starts
|
||||||
color = Column(String, nullable=True) # per-event color override
|
color = Column(String, nullable=True) # per-event color override
|
||||||
status = Column(String, default="confirmed") # confirmed, cancelled
|
status = Column(String, default="confirmed") # confirmed, cancelled
|
||||||
importance = Column(String, default="normal") # low | normal | high | critical
|
importance = Column(String, default="normal") # low | normal | high | critical
|
||||||
@@ -1833,6 +1834,7 @@ def init_db():
|
|||||||
_migrate_add_calendar_origin()
|
_migrate_add_calendar_origin()
|
||||||
_migrate_add_calendar_account_id()
|
_migrate_add_calendar_account_id()
|
||||||
_migrate_add_caldav_sync_columns()
|
_migrate_add_caldav_sync_columns()
|
||||||
|
_migrate_add_calendar_recurrence_exdates()
|
||||||
_migrate_chat_messages_fts()
|
_migrate_chat_messages_fts()
|
||||||
_migrate_encrypt_email_passwords()
|
_migrate_encrypt_email_passwords()
|
||||||
_migrate_encrypt_signatures()
|
_migrate_encrypt_signatures()
|
||||||
@@ -2184,6 +2186,28 @@ def _migrate_add_calendar_metadata():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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():
|
def get_db():
|
||||||
"""
|
"""
|
||||||
Dependency to get a database session.
|
Dependency to get a database session.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Calendar routes — local SQLite-backed calendar CRUD."""
|
"""Calendar routes — local SQLite-backed calendar CRUD."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
@@ -509,6 +510,7 @@ def _event_to_dict(ev: CalendarEvent) -> dict:
|
|||||||
"description": ev.description or "",
|
"description": ev.description or "",
|
||||||
"location": ev.location or "",
|
"location": ev.location or "",
|
||||||
"rrule": ev.rrule or "",
|
"rrule": ev.rrule or "",
|
||||||
|
"recurrence_exdates": _recurrence_exdates(ev),
|
||||||
"calendar": ev.calendar.name if ev.calendar else "",
|
"calendar": ev.calendar.name if ev.calendar else "",
|
||||||
"calendar_href": ev.calendar_id,
|
"calendar_href": ev.calendar_id,
|
||||||
"color": ev.color or (ev.calendar.color if ev.calendar else ""),
|
"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
|
_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(
|
def _expand_rrule(
|
||||||
ev: CalendarEvent, start: datetime, end: datetime
|
ev: CalendarEvent, start: datetime, end: datetime
|
||||||
) -> List[dict]:
|
) -> List[dict]:
|
||||||
@@ -586,6 +610,7 @@ def _expand_rrule(
|
|||||||
results = []
|
results = []
|
||||||
truncated = False
|
truncated = False
|
||||||
base = _event_to_dict(ev)
|
base = _event_to_dict(ev)
|
||||||
|
exdates = set(_recurrence_exdates(ev))
|
||||||
|
|
||||||
for occ_start in rule.xafter(expand_start, inc=True):
|
for occ_start in rule.xafter(expand_start, inc=True):
|
||||||
if occ_start >= end:
|
if occ_start >= end:
|
||||||
@@ -606,8 +631,13 @@ def _expand_rrule(
|
|||||||
# Build the compound uid: {base_uid}::{date} or ::{datetime}
|
# Build the compound uid: {base_uid}::{date} or ::{datetime}
|
||||||
if ev.all_day:
|
if ev.all_day:
|
||||||
occ_uid = f"{ev.uid}::{occ_start.strftime('%Y-%m-%d')}"
|
occ_uid = f"{ev.uid}::{occ_start.strftime('%Y-%m-%d')}"
|
||||||
|
exdate_key = occ_start.strftime("%Y-%m-%d")
|
||||||
else:
|
else:
|
||||||
occ_uid = f"{ev.uid}::{occ_start.strftime('%Y-%m-%dT%H:%M')}"
|
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 = dict(base)
|
||||||
d["uid"] = occ_uid
|
d["uid"] = occ_uid
|
||||||
@@ -1118,7 +1148,7 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@router.delete("/events/{uid}")
|
@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)
|
owner = _require_user(request)
|
||||||
try:
|
try:
|
||||||
base_uid = _resolve_base_uid(uid)
|
base_uid = _resolve_base_uid(uid)
|
||||||
@@ -1127,7 +1157,22 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ev = _get_or_404_event(db, base_uid, owner)
|
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"
|
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:
|
if is_caldav:
|
||||||
_record_caldav_delete_tombstone(db, ev, owner)
|
_record_caldav_delete_tombstone(db, ev, owner)
|
||||||
db.delete(ev)
|
db.delete(ev)
|
||||||
|
|||||||
+2
-1
@@ -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`. \
|
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 `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 `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"). \
|
`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`). \
|
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. \
|
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.""",
|
`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.",
|
"create_session": "- ```create_session``` — Create a new chat. Line 1 = chat name, line 2 = model name. Use for background/parallel work.",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ Design notes:
|
|||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
@@ -494,6 +495,7 @@ def _event_payload(ev) -> dict:
|
|||||||
"all_day": ev.all_day,
|
"all_day": ev.all_day,
|
||||||
"is_utc": ev.is_utc,
|
"is_utc": ev.is_utc,
|
||||||
"rrule": ev.rrule or "",
|
"rrule": ev.rrule or "",
|
||||||
|
"recurrence_exdates": json.loads(ev.recurrence_exdates or "[]") if getattr(ev, "recurrence_exdates", "") else [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -33,7 +33,8 @@ def build_event_ical(ev: dict) -> str:
|
|||||||
"""Serialize a local event dict to a VCALENDAR/VEVENT iCalendar string.
|
"""Serialize a local event dict to a VCALENDAR/VEVENT iCalendar string.
|
||||||
|
|
||||||
``ev`` keys: uid, summary, description, location, dtstart (datetime),
|
``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.
|
Mirrors how the pull path interprets is_utc/all_day so a round-trip is stable.
|
||||||
"""
|
"""
|
||||||
from icalendar import Calendar, Event as iEvent
|
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"]))
|
ve.add("rrule", vRecur.from_ical(ev["rrule"]))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("CalDAV write-back: skipping unparseable rrule %r", ev.get("rrule"))
|
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)
|
cal.add_component(ve)
|
||||||
return cal.to_ical().decode("utf-8")
|
return cal.to_ical().decode("utf-8")
|
||||||
|
|||||||
@@ -1760,6 +1760,17 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
return value
|
return value
|
||||||
return None
|
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,
|
def _create_calendar_reminder(summary: str, location: str, dtstart: datetime,
|
||||||
all_day: bool, minutes_before: int,
|
all_day: bool, minutes_before: int,
|
||||||
is_utc: bool = False) -> tuple[Optional[str], Optional[str]]:
|
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,
|
"calendar_href": ev.calendar_id,
|
||||||
"event_type": ev.event_type or "",
|
"event_type": ev.event_type or "",
|
||||||
"importance": ev.importance or "normal",
|
"importance": ev.importance or "normal",
|
||||||
|
"rrule": ev.rrule or "",
|
||||||
})
|
})
|
||||||
if not events:
|
if not events:
|
||||||
response_text = f"No events between {start_dt.date().isoformat()} and {end_dt.date().isoformat()}."
|
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']}"
|
line += f" #{ev['event_type']}"
|
||||||
if ev.get("importance") and ev["importance"] != "normal":
|
if ev.get("importance") and ev["importance"] != "normal":
|
||||||
line += f" !{ev['importance']}"
|
line += f" !{ev['importance']}"
|
||||||
|
if ev.get("rrule"):
|
||||||
|
line += f" repeats({ev['rrule']})"
|
||||||
if ev.get("location"):
|
if ev.get("location"):
|
||||||
line += f" @ {ev['location']}"
|
line += f" @ {ev['location']}"
|
||||||
if ev.get("calendar"):
|
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 "",
|
location=args.get("location", "") or "",
|
||||||
dtstart=dtstart, dtend=dtend, all_day=all_day,
|
dtstart=dtstart, dtend=dtend, all_day=all_day,
|
||||||
is_utc=dtstart_is_utc and not 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,
|
event_type=event_type,
|
||||||
importance=importance,
|
importance=importance,
|
||||||
caldav_sync_pending="create" if cal.source == "caldav" else None,
|
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
|
ev.event_type = _tag or None
|
||||||
if args.get("importance") is not None:
|
if args.get("importance") is not None:
|
||||||
ev.importance = args["importance"]
|
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"
|
is_caldav = ev.calendar and ev.calendar.source == "caldav"
|
||||||
if is_caldav:
|
if is_caldav:
|
||||||
ev.caldav_sync_pending = "update"
|
ev.caldav_sync_pending = "update"
|
||||||
|
|||||||
+1
-1
@@ -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.",
|
"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 <person>' / 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_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 <person>' / 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_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.",
|
"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 <repo> --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.",
|
"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 <repo> --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'.",
|
"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'.",
|
||||||
|
|||||||
+2
-2
@@ -538,7 +538,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "manage_calendar",
|
"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": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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."},
|
"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')"},
|
"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'."},
|
"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"]
|
"required": ["action"]
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-21
@@ -300,7 +300,7 @@ async function _updateEvent(uid, data) {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _deleteEvent(uid) {
|
async function _deleteEvent(uid, { scope = 'series' } = {}) {
|
||||||
// Multiple "sibling" UIDs may need to vanish optimistically:
|
// Multiple "sibling" UIDs may need to vanish optimistically:
|
||||||
// 1. The exact uid the user clicked.
|
// 1. The exact uid the user clicked.
|
||||||
// 2. If the user clicked a RECURRING occurrence (uid contains "::"),
|
// 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.
|
// other days kept rendering until the next full refresh.
|
||||||
// 3. If the user clicked the master, strip every "master::*"
|
// 3. If the user clicked the master, strip every "master::*"
|
||||||
// expansion (same prefix scan).
|
// expansion (same prefix scan).
|
||||||
|
const deleteOccurrenceOnly = scope === 'occurrence' && uid.includes('::');
|
||||||
const masterUid = uid.includes('::') ? uid.split('::')[0] : uid;
|
const masterUid = uid.includes('::') ? uid.split('::')[0] : uid;
|
||||||
const backups = {};
|
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)) {
|
for (const k of Object.keys(_allEvents)) {
|
||||||
if (_matches(k)) {
|
if (_matches(k)) {
|
||||||
@@ -327,7 +330,8 @@ async function _deleteEvent(uid) {
|
|||||||
if (_open) _render();
|
if (_open) _render();
|
||||||
_updateBadge && _updateBadge();
|
_updateBadge && _updateBadge();
|
||||||
const isRecurring = uid.includes('::');
|
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',
|
method: 'DELETE', credentials: 'same-origin',
|
||||||
}).then(r => {
|
}).then(r => {
|
||||||
// 404 = the event was already deleted by another session/device. That's
|
// 404 = the event was already deleted by another session/device. That's
|
||||||
@@ -429,14 +433,77 @@ function _todayCount() {
|
|||||||
}).length;
|
}).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) {
|
function _wireQuickDelete(body) {
|
||||||
body.querySelectorAll('.cal-event-more').forEach(btn => {
|
body.querySelectorAll('.cal-event-more').forEach(btn => {
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const uid = btn.dataset.uid;
|
const uid = btn.dataset.uid;
|
||||||
if (!uid) return;
|
if (!uid) return;
|
||||||
const ev = _allEvents[uid];
|
const ev = _findEventByUid(uid);
|
||||||
if (!ev) return;
|
if (!ev) return;
|
||||||
_showEventMoreMenu(ev, btn);
|
_showEventMoreMenu(ev, btn);
|
||||||
});
|
});
|
||||||
@@ -489,10 +556,7 @@ function _showEventMoreMenu(ev, anchor) {
|
|||||||
|
|
||||||
dropdown.appendChild(_item(_trashIcon, 'Delete', async () => {
|
dropdown.appendChild(_item(_trashIcon, 'Delete', async () => {
|
||||||
closeMenu();
|
closeMenu();
|
||||||
const name = ev.summary ? `"${ev.summary}"` : 'this event';
|
await _confirmAndDeleteEvent(ev);
|
||||||
const ok = await uiModule.styledConfirm(`Delete ${name}?`, { confirmText: 'Delete', danger: true });
|
|
||||||
if (!ok) return;
|
|
||||||
try { await _deleteEvent(ev.uid); setTimeout(() => _render(), 100); } catch (_) {}
|
|
||||||
}, true));
|
}, true));
|
||||||
|
|
||||||
document.body.appendChild(dropdown);
|
document.body.appendChild(dropdown);
|
||||||
@@ -1813,7 +1877,7 @@ function _dayDetailHTML(dateStr) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
if (_searchQuery) {
|
if (_searchQuery) {
|
||||||
const q = _searchQuery.toLowerCase();
|
const q = _searchQuery.toLowerCase();
|
||||||
const results = _events
|
const results = Object.values(_allEvents || {})
|
||||||
.filter(_eventVisible)
|
.filter(_eventVisible)
|
||||||
.filter(e =>
|
.filter(e =>
|
||||||
(e.summary || '').toLowerCase().includes(q) ||
|
(e.summary || '').toLowerCase().includes(q) ||
|
||||||
@@ -3115,7 +3179,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
|||||||
all_day: isAD,
|
all_day: isAD,
|
||||||
description: document.getElementById('cal-f-desc').value,
|
description: document.getElementById('cal-f-desc').value,
|
||||||
location: document.getElementById('cal-f-loc').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 || ''),
|
calendar_href: document.getElementById('cal-f-cal')?.value || (_calendars[0]?.href || ''),
|
||||||
color: colorVal || undefined,
|
color: colorVal || undefined,
|
||||||
};
|
};
|
||||||
@@ -3141,11 +3205,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
|||||||
} catch (e) { uiModule.showToast('Failed to save'); }
|
} catch (e) { uiModule.showToast('Failed to save'); }
|
||||||
});
|
});
|
||||||
document.getElementById('cal-f-del')?.addEventListener('click', async () => {
|
document.getElementById('cal-f-del')?.addEventListener('click', async () => {
|
||||||
const name = existing && existing.summary ? `"${existing.summary}"` : 'this event';
|
await _confirmAndDeleteEvent(existing);
|
||||||
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'); }
|
|
||||||
});
|
});
|
||||||
// ── Bespoke-form behavior ──────────────────────────────────────────
|
// ── Bespoke-form behavior ──────────────────────────────────────────
|
||||||
const formEl = body.querySelector('.cal-form');
|
const formEl = body.querySelector('.cal-form');
|
||||||
@@ -3454,8 +3514,18 @@ function openCalendar() {
|
|||||||
// Layer Esc: close the topmost calendar surface first, only fall through
|
// Layer Esc: close the topmost calendar surface first, only fall through
|
||||||
// to closing the whole calendar when nothing else is on top.
|
// to closing the whole calendar when nothing else is on top.
|
||||||
const settings = document.getElementById('cal-settings-panel');
|
const settings = document.getElementById('cal-settings-panel');
|
||||||
if (settings) { settings.remove(); return; }
|
if (settings) {
|
||||||
if (document.querySelector('.cal-form')) { _render(); return; }
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
settings.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (document.querySelector('.cal-form')) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
_render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
closeCalendar();
|
closeCalendar();
|
||||||
}
|
}
|
||||||
else if (e.key === 'ArrowLeft') document.getElementById('cal-prev')?.click();
|
else if (e.key === 'ArrowLeft') document.getElementById('cal-prev')?.click();
|
||||||
@@ -3484,14 +3554,25 @@ async function openCalendarTo(target) {
|
|||||||
if (!target) return;
|
if (!target) return;
|
||||||
try {
|
try {
|
||||||
await _fetchCalendars();
|
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.
|
// If target looks like an ISO date (YYYY-MM-DD...), go straight there.
|
||||||
let dt = null;
|
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) {
|
if (isoMatch) {
|
||||||
dt = new Date(target);
|
dt = new Date(targetStr);
|
||||||
} else {
|
} else {
|
||||||
// Treat as an event uid — find it among loaded events.
|
// 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 && ev.dtstart) dt = new Date(ev.dtstart);
|
||||||
if (ev) _highlightEventUid = ev.uid;
|
if (ev) _highlightEventUid = ev.uid;
|
||||||
}
|
}
|
||||||
|
|||||||
+81
-4
@@ -16,6 +16,8 @@ let sessions = [];
|
|||||||
let currentSessionId = null;
|
let currentSessionId = null;
|
||||||
let _sessionNavToken = 0;
|
let _sessionNavToken = 0;
|
||||||
let _skipAutoSelect = false;
|
let _skipAutoSelect = false;
|
||||||
|
const HISTORY_DISPLAY_CHAR_LIMIT = 160000;
|
||||||
|
const HISTORY_DISPLAY_TAIL_CHARS = 20000;
|
||||||
|
|
||||||
const SIDEBAR_MAX_VISIBLE = 10;
|
const SIDEBAR_MAX_VISIBLE = 10;
|
||||||
const FOLDER_MAX_VISIBLE = 5;
|
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 _isMac = /Mac|iPhone|iPad/.test(navigator.platform);
|
||||||
const _mod = _isMac ? '⌘' : 'Ctrl';
|
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() {
|
function _getIncognitoIds() {
|
||||||
try { return JSON.parse(sessionStorage.getItem(_INCOGNITO_SESSIONS_KEY) || '[]'); } catch { return []; }
|
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.)
|
// place, producing a ReferenceError every selectSession.)
|
||||||
const isOC = meta && (meta.is_openclaw || id === 'openclaw');
|
const isOC = meta && (meta.is_openclaw || id === 'openclaw');
|
||||||
let msgHistory = [], modelName = null;
|
let msgHistory = [], modelName = null;
|
||||||
|
let paintedLoading = false;
|
||||||
if (!isOC) {
|
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 res = await fetch(`${API_BASE}/api/history/${id}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (navToken !== _sessionNavToken || currentSessionId !== id) return;
|
if (navToken !== _sessionNavToken || currentSessionId !== id) return;
|
||||||
@@ -1623,8 +1691,17 @@ export async function selectSession(id, { keepSidebar = false } = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fade out old content, swap, fade in
|
if (paintedLoading && chatHistory) {
|
||||||
if (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.transition = 'opacity 0.12s ease-out';
|
||||||
chatHistory.style.opacity = '0';
|
chatHistory.style.opacity = '0';
|
||||||
await new Promise(r => setTimeout(r, 120));
|
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;
|
const meta = msg.metadata ? { ...msg.metadata, _fromHistory: true } : null;
|
||||||
let displayContent;
|
let displayContent;
|
||||||
if (typeof msg.content === 'string') {
|
if (typeof msg.content === 'string') {
|
||||||
displayContent = msg.content;
|
displayContent = _displayHistoryContent(msg.content);
|
||||||
} else if (Array.isArray(msg.content)) {
|
} else if (Array.isArray(msg.content)) {
|
||||||
// Multimodal (image/audio attachments): extract text parts, skip binary
|
// 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 {
|
} else {
|
||||||
displayContent = '';
|
displayContent = '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ def _make_event(**overrides):
|
|||||||
"all_day": False,
|
"all_day": False,
|
||||||
"is_utc": False,
|
"is_utc": False,
|
||||||
"rrule": "",
|
"rrule": "",
|
||||||
|
"recurrence_exdates": "",
|
||||||
"calendar": _MOCK_CAL.name,
|
"calendar": _MOCK_CAL.name,
|
||||||
"calendar_id": "cal-001",
|
"calendar_id": "cal-001",
|
||||||
"color": None,
|
"color": None,
|
||||||
@@ -84,6 +85,23 @@ def test_expand_non_recurring_returns_single():
|
|||||||
assert r["is_recurrence"] is False
|
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():
|
def test_expand_yearly_old_dtstart_later_year_single_occurrence():
|
||||||
"""Create an old DTSTART + FREQ=YEARLY, query a later year, verify
|
"""Create an old DTSTART + FREQ=YEARLY, query a later year, verify
|
||||||
exactly one occurrence is returned.
|
exactly one occurrence is returned.
|
||||||
|
|||||||
@@ -76,3 +76,84 @@ async def test_create_event_without_rrule_is_single():
|
|||||||
assert ev is not None and (ev.rrule or "") == ""
|
assert ev is not None and (ev.rrule or "") == ""
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user