Skip to main content

max / goingson

32.4 KB · 812 lines History Blame Raw
1 //! Event tools: the read surface (`list_events`, `get_event`) and the write
2 //! surface (`create_event`, `bulk_import_events`, `update_event`, `delete_event`).
3 //!
4 //! # Series and instances
5 //!
6 //! A recurring event is stored once, as a series carrying a rule. The instances
7 //! it implies are not rows; `expand_recurrence_in_tz` materializes them on
8 //! demand for a window, with synthetic ids that live only in that reply.
9 //!
10 //! `list_events` therefore has two modes, and `expand` picks between them:
11 //!
12 //! - **off (default)**: one row per stored series. "Weekly standup" is one row
13 //! with `recurrence.display` reading "Every week on Mon". This is the mode for
14 //! entering and auditing data, where 52 identical rows are noise and the thing
15 //! you actually want to know is whether the series already exists.
16 //! - **on**: the calendar laid out, every occurrence in the window as its own
17 //! row marked `is_recurring_instance`.
18 //!
19 //! Series-by-default is the important half. A session filling in a calendar
20 //! needs to see what rules exist, not their consequences.
21 //!
22 //! # Why the window is not just a SQL range
23 //!
24 //! `EventRepository::list_between` matches stored rows overlapping the window,
25 //! which is the wrong answer for a series: a standup that started two years ago
26 //! and still recurs every Monday has no row inside next month, so a plain range
27 //! query reports it absent. Both modes therefore union the range query with
28 //! `list_recurring`, keeping the series whose rule actually reaches the window.
29 //! Without that, a session would ask "do I already have a standup?", be told no,
30 //! and enter a second one.
31
32 use std::collections::HashSet;
33 use std::sync::Arc;
34
35 use async_trait::async_trait;
36 use chrono::{DateTime, Duration, Utc};
37 use goingson_core::repository::EventRepository;
38 use goingson_core::tz::system_tz;
39 use goingson_core::{
40 Event, EventId, NewEvent, ProjectId, TzKind, UpdateEvent, Validate, expand_recurrence_in_tz,
41 snap_all_day_span,
42 };
43 use kberg::{Error, Result, Tool, ToolCallResult, ToolKind};
44 use serde_json::{Value, json};
45
46 use super::contact::{ContactCache, contact_arg, contact_fields};
47 use super::{project_id_arg, project_id_field, recurrence_field};
48 use crate::caps;
49 use crate::context::Ctx;
50 use crate::convert::{
51 MAX_LIMIT, TZ_KINDS, event_row, event_summary_row, parse_block_type, parse_enum,
52 parse_event_id, parse_instant, parse_limit, parse_offset, parse_project_id, parse_recurrence,
53 parse_reminders, req_str,
54 };
55
56 /// Default `external_source` a bulk import stamps on the rows it creates, so a
57 /// re-run can find its own work. Callers doing more than one kind of import can
58 /// pass their own.
59 const DEFAULT_IMPORT_SOURCE: &str = "go-mcp";
60
61 /// Window an unparameterized `list_events` covers, forward from now. Long enough
62 /// to answer "what does my next month look like", short enough that expanding a
63 /// daily series over it stays well inside the 500-occurrence expansion cap.
64 const DEFAULT_WINDOW_DAYS: i64 = 30;
65
66 fn fail(tool: &str, e: impl std::fmt::Display) -> Error {
67 Error::ToolFailed {
68 tool: tool.to_string(),
69 message: e.to_string(),
70 }
71 }
72
73 /// The "no such event" error, shared by every tool that takes an id.
74 ///
75 /// It names the synthetic-id case: an id lifted from an expanded `list_events`
76 /// row is the single most likely reason a lookup misses, and "not found" alone
77 /// would send a caller hunting for a row that was never supposed to exist.
78 fn not_found(tool: &str, id: EventId) -> Error {
79 Error::ToolFailed {
80 tool: tool.to_string(),
81 message: format!(
82 "no event with id {id} (a synthetic recurrence-instance id will not resolve; \
83 use the instance's `recurrence_parent_id`)"
84 ),
85 }
86 }
87
88 pub struct ListEvents(pub Arc<Ctx>);
89
90 #[async_trait]
91 impl Tool for ListEvents {
92 fn name(&self) -> &'static str {
93 "list_events"
94 }
95 fn description(&self) -> &'static str {
96 "List calendar events in a time window. `from`/`to` are RFC 3339 timestamps or bare YYYY-MM-DD dates (local midnight); they default to now and 30 days out. By default each recurring event appears once, as the stored series, with its rule under `recurrence`. Pass `expand: true` to get every occurrence in the window instead, each marked `is_recurring_instance` with a synthetic id that only `list_events` understands, never `get_event` or the write tools. Optional filters: `project_id`, `project` (name, a convenience for reading only), `recurring_only`. Paged: `limit` (default 50, max 200) and `offset`. Long descriptions are clipped and the row marked `truncated`; use `get_event` for the full text."
97 }
98 fn kind(&self) -> ToolKind {
99 ToolKind::Read
100 }
101 fn small_model_safe(&self) -> bool {
102 true
103 }
104 fn input_schema(&self) -> Value {
105 json!({
106 "type": "object",
107 "properties": {
108 "from": { "type": "string", "description": "RFC 3339 timestamp or YYYY-MM-DD (local midnight). Defaults to now." },
109 "to": { "type": "string", "description": "RFC 3339 timestamp or YYYY-MM-DD (local midnight). Defaults to 30 days after `from`." },
110 "project_id": { "type": "string", "description": "Project id (UUID) from list_projects." },
111 "project": { "type": "string", "description": "Project name. Reads only, and a rename moves it; prefer project_id." },
112 "recurring_only": { "type": "boolean" },
113 "expand": { "type": "boolean", "description": "Materialize recurring occurrences in the window instead of listing series." },
114 "limit": { "type": "integer", "minimum": 1, "maximum": MAX_LIMIT },
115 "offset": { "type": "integer", "minimum": 0 }
116 }
117 })
118 }
119 async fn call(&self, args: Value) -> Result<ToolCallResult> {
120 let limit = parse_limit(self.name(), args.get("limit"))?;
121 let offset = parse_offset(self.name(), args.get("offset"))?;
122 let expand = args
123 .get("expand")
124 .and_then(Value::as_bool)
125 .unwrap_or_default();
126 let recurring_only = args
127 .get("recurring_only")
128 .and_then(Value::as_bool)
129 .unwrap_or_default();
130
131 let tz = system_tz();
132 let from = parse_instant(self.name(), "from", args.get("from"))?.unwrap_or_else(Utc::now);
133 let to = parse_instant(self.name(), "to", args.get("to"))?
134 .unwrap_or_else(|| from + Duration::days(DEFAULT_WINDOW_DAYS));
135 if to < from {
136 return Err(Error::InvalidArgs {
137 tool: self.name().to_string(),
138 message: format!(
139 "`to` ({}) is before `from` ({})",
140 to.to_rfc3339(),
141 from.to_rfc3339()
142 ),
143 });
144 }
145
146 let repo = self.0.events();
147 let mut events = repo
148 .list_between(self.0.user_id, from, to)
149 .map_err(|e| fail(self.name(), e))?;
150
151 // Series whose stored row already landed in the range query. Pushing one
152 // again below would double it in the reply.
153 let in_range: HashSet<EventId> = events.iter().map(|e| e.id).collect();
154
155 let recurring = repo
156 .list_recurring(self.0.user_id)
157 .map_err(|e| fail(self.name(), e))?;
158
159 for series in recurring {
160 // The series' own zone, not the reader's: a Local event expands the
161 // same way whoever is looking, and only a Relative one follows `tz`.
162 let instances = expand_recurrence_in_tz(&series, from, to, series.tz_for(tz));
163 let parent_in_window = parent_overlaps(&series, from, to);
164
165 if expand {
166 events.extend(instances);
167 if parent_in_window && !in_range.contains(&series.id) {
168 events.push(series);
169 }
170 } else if (parent_in_window || !instances.is_empty()) && !in_range.contains(&series.id)
171 {
172 // The series reaches the window even though its own row sits
173 // outside it. This is the case a plain range query loses.
174 events.push(series);
175 }
176 }
177
178 events.sort_by_key(|e| e.start_time);
179
180 let project = args.get("project").and_then(Value::as_str);
181 let project_id = match args.get("project_id").and_then(Value::as_str) {
182 Some(raw) if !raw.trim().is_empty() => Some(parse_project_id(self.name(), raw)?),
183 _ => None,
184 };
185 let matched: Vec<&Event> = events
186 .iter()
187 .filter(|e| project_id.is_none_or(|want| e.project_id == Some(want)))
188 .filter(|e| project.is_none_or(|p| e.project_name.as_deref() == Some(p)))
189 .filter(|e| !recurring_only || e.has_recurrence())
190 .collect();
191
192 let total = matched.len();
193 let rows: Vec<Value> = matched
194 .into_iter()
195 .skip(offset)
196 .take(limit)
197 .map(|e| event_summary_row(e, tz))
198 .collect();
199
200 let mut reply = json!({
201 "count": rows.len(),
202 "total": total,
203 "offset": offset,
204 "from": from.to_rfc3339(),
205 "to": to.to_rfc3339(),
206 "expanded": expand,
207 "events": rows,
208 });
209 // Only present when a page remains, so its absence is the stop condition.
210 let next = offset.saturating_add(rows_len(&reply));
211 if next < total {
212 reply["next_offset"] = json!(next);
213 }
214
215 Ok(ToolCallResult::text(serde_json::to_string(&reply).unwrap()))
216 }
217 }
218
219 /// Row count of a built reply, for the paging cursor.
220 fn rows_len(reply: &Value) -> usize {
221 reply["count"].as_u64().unwrap_or_default() as usize
222 }
223
224 /// Whether a series' own stored occurrence overlaps the window. An event with no
225 /// end time is treated as an hour long, matching the expansion logic's default.
226 fn parent_overlaps(e: &Event, from: chrono::DateTime<Utc>, to: chrono::DateTime<Utc>) -> bool {
227 let effective_end = e.end_time.unwrap_or(e.start_time + Duration::hours(1));
228 effective_end >= from && e.start_time <= to
229 }
230
231 pub struct GetEvent(pub Arc<Ctx>);
232
233 #[async_trait]
234 impl Tool for GetEvent {
235 fn name(&self) -> &'static str {
236 "get_event"
237 }
238 fn description(&self) -> &'static str {
239 "Fetch one event by id, with its full description and recurrence rule. Takes a stored event id: the synthetic ids on `list_events` expanded instances are not rows and will not resolve, use their `recurrence_parent_id` instead."
240 }
241 fn kind(&self) -> ToolKind {
242 ToolKind::Read
243 }
244 fn small_model_safe(&self) -> bool {
245 true
246 }
247 fn input_schema(&self) -> Value {
248 json!({
249 "type": "object",
250 "properties": { "id": { "type": "string" } },
251 "required": ["id"]
252 })
253 }
254 async fn call(&self, args: Value) -> Result<ToolCallResult> {
255 let id = parse_event_id(self.name(), req_str(self.name(), &args, "id")?)?;
256 let event = self
257 .0
258 .events()
259 .get_by_id(id, self.0.user_id)
260 .map_err(|e| fail(self.name(), e))?
261 .ok_or_else(|| not_found(self.name(), id))?;
262
263 let row = event_row(&event, system_tz());
264 Ok(ToolCallResult::text(serde_json::to_string(&row).unwrap()))
265 }
266 }
267
268 // writes
269
270 /// Resolve the start/end pair a write tool was given.
271 ///
272 /// All-day canonicalization runs here, through the same `snap_all_day_span` the
273 /// desktop command layer uses, so an event authored over MCP has the shape
274 /// (`local midnight`, whole days) that `Event::is_all_day_in` detects. Authoring
275 /// it any other way would produce a row the app renders as a timed event.
276 fn resolve_span(tool: &str, args: &Value) -> Result<(DateTime<Utc>, Option<DateTime<Utc>>)> {
277 let start =
278 parse_instant(tool, "start", args.get("start"))?.ok_or_else(|| Error::InvalidArgs {
279 tool: tool.to_string(),
280 message: "missing `start` (RFC 3339 timestamp or YYYY-MM-DD)".to_string(),
281 })?;
282 let end = parse_instant(tool, "end", args.get("end"))?;
283 let all_day = args
284 .get("all_day")
285 .and_then(Value::as_bool)
286 .unwrap_or_default();
287 finish_span(tool, start, end, all_day)
288 }
289
290 /// Shared tail of [`resolve_span`] and [`overlay_span`]: snap when all-day,
291 /// otherwise hold the caller to `end > start`.
292 fn finish_span(
293 tool: &str,
294 start: DateTime<Utc>,
295 end: Option<DateTime<Utc>>,
296 all_day: bool,
297 ) -> Result<(DateTime<Utc>, Option<DateTime<Utc>>)> {
298 if all_day {
299 let (start, end) = snap_all_day_span(start, end, &system_tz());
300 return Ok((start, Some(end)));
301 }
302 if let Some(end) = end
303 && end <= start
304 {
305 return Err(Error::InvalidArgs {
306 tool: tool.to_string(),
307 message: format!(
308 "`end` ({}) must be after `start` ({})",
309 end.to_rfc3339(),
310 start.to_rfc3339()
311 ),
312 });
313 }
314 Ok((start, end))
315 }
316
317 /// Read `tz_kind` (and the `timezone` it may require) off one wire object.
318 ///
319 /// A `local` event without a zone name is refused rather than quietly demoted:
320 /// the whole point of the kind is that the zone is recorded, and falling back to
321 /// the writer's zone would bake the headless peer's environment into the row.
322 fn parse_tz_kind(tool: &str, item: &Value) -> Result<(TzKind, Option<String>)> {
323 let kind = match item.get("tz_kind").and_then(Value::as_str) {
324 None => TzKind::Absolute,
325 Some(raw) => parse_enum(tool, "tz_kind", raw, TZ_KINDS)?,
326 };
327 let timezone = item
328 .get("timezone")
329 .and_then(Value::as_str)
330 .map(str::trim)
331 .filter(|s| !s.is_empty());
332
333 if kind == TzKind::Local {
334 let name = timezone.ok_or_else(|| Error::InvalidArgs {
335 tool: tool.to_string(),
336 message: "`tz_kind: local` requires an IANA `timezone` (e.g. America/Denver)".into(),
337 })?;
338 // Reject an unknown name here rather than letting `event_tz` silently
339 // fall back to the reader's zone every time the row is read.
340 name.parse::<chrono_tz::Tz>()
341 .map_err(|_| Error::InvalidArgs {
342 tool: tool.to_string(),
343 message: format!("`{name}` is not a known IANA timezone"),
344 })?;
345 return Ok((kind, Some(name.to_string())));
346 }
347 Ok((kind, None))
348 }
349
350 /// Build a validated [`NewEvent`] from one wire object, shared by `create_event`
351 /// and each item of `bulk_import_events`.
352 ///
353 /// `linked_task_id` is always `None`: a time-block's task link is set by the
354 /// app when it blocks time, never by a caller filling in a calendar.
355 async fn new_event_from(
356 ctx: &Ctx,
357 tool: &str,
358 item: &Value,
359 seen_projects: &mut HashSet<ProjectId>,
360 contacts: &mut ContactCache,
361 ) -> Result<NewEvent> {
362 let title = req_str(tool, item, "title")?.to_string();
363 let (start_time, end_time) = resolve_span(tool, item)?;
364 let (recurrence, recurrence_rule) = parse_recurrence(tool, item.get("recurrence"))?;
365 let block_type = parse_block_type(tool, item.get("block_type"))?;
366 let (tz_kind, timezone) = parse_tz_kind(tool, item)?;
367
368 let project_id = project_id_arg(ctx, tool, item, seen_projects).await?;
369 let contact_id = contact_arg(ctx, tool, item, contacts).await?;
370
371 let mut event = NewEvent {
372 user_id: Some(ctx.user_id),
373 project_id,
374 contact_id,
375 title,
376 description: item
377 .get("description")
378 .and_then(Value::as_str)
379 .unwrap_or_default()
380 .to_string(),
381 start_time,
382 end_time,
383 location: item
384 .get("location")
385 .and_then(Value::as_str)
386 .filter(|l| !l.trim().is_empty())
387 .map(str::to_string),
388 linked_task_id: None,
389 recurrence,
390 recurrence_rule,
391 block_type,
392 reminder_offsets_seconds: parse_reminders(item.get("reminders")),
393 tz_kind,
394 timezone,
395 // Derived from the resolved instants below rather than taken from the
396 // wire: a session sends one time, not a UTC/civil pair it has to keep
397 // consistent itself.
398 start_local: None,
399 end_local: None,
400 };
401 if tz_kind.is_civil() {
402 let tz = goingson_core::tz::event_tz(tz_kind, event.timezone.as_deref(), system_tz());
403 event.start_local = Some(start_time.with_timezone(&tz).naive_local());
404 event.end_local = end_time.map(|e| e.with_timezone(&tz).naive_local());
405 }
406
407 // The same validation the desktop command layer runs. go-mcp is a peer
408 // writer, not a back door: it must not be able to store a row the app
409 // itself would have refused.
410 event.validate().map_err(|e| Error::InvalidArgs {
411 tool: tool.to_string(),
412 message: e.to_string(),
413 })?;
414 Ok(event)
415 }
416
417 /// The JSON schema fragment shared by `create_event` and each import item.
418 fn event_fields() -> Value {
419 let mut fields = json!({
420 "title": { "type": "string" },
421 "start": { "type": "string", "description": "RFC 3339 timestamp or YYYY-MM-DD (local midnight)." },
422 "end": { "type": "string" },
423 "all_day": { "type": "boolean", "description": "Snap the span to whole local days." },
424 "description": { "type": "string" },
425 "project_id": project_id_field(),
426 "location": { "type": "string" },
427 "recurrence": recurrence_field(),
428 "tz_kind": {
429 "type": "string",
430 "description": "relative (a wall clock that follows the user, e.g. a daily routine) | local (a wall clock anchored to `timezone`, correct across that zone's DST) | absolute (a fixed instant). Defaults to absolute."
431 },
432 "timezone": { "type": "string", "description": "IANA zone name, required when tz_kind is `local` (e.g. America/Denver)." },
433 "block_type": { "type": "string", "description": "free_time | personal | vacation | focus" },
434 "reminders": {
435 "type": "array",
436 "items": { "type": "integer" },
437 "description": "Seconds before start to fire a reminder, e.g. [0, 900]. Max 8."
438 }
439 });
440 // Merged rather than inlined so the contact wording lives once, next to the
441 // resolution it describes.
442 for (key, value) in contact_fields().as_object().expect("object literal") {
443 fields[key] = value.clone();
444 }
445 fields
446 }
447
448 pub struct CreateEvent(pub Arc<Ctx>);
449
450 #[async_trait]
451 impl Tool for CreateEvent {
452 fn name(&self) -> &'static str {
453 "create_event"
454 }
455 fn description(&self) -> &'static str {
456 "Create one calendar event. `title` and `start` are required; `start`/`end` take RFC 3339 or YYYY-MM-DD (local midnight). Pass `all_day: true` to snap the span to whole local days. `project_id` (from list_projects) files it under a project; omit it for an unfiled event. `contact_id` (from list_contacts) or `contact` (a name) attaches it to a person; an ambiguous name is refused rather than guessed, and no contact is created. `recurrence` is a word (None|Daily|Weekly|Monthly) or a rich rule object; a recurring event is stored once as a series, not as one row per occurrence. Use `bulk_import_events` for more than a few."
457 }
458 fn kind(&self) -> ToolKind {
459 ToolKind::Write(caps::event_create())
460 }
461 fn input_schema(&self) -> Value {
462 json!({
463 "type": "object",
464 "properties": event_fields(),
465 "required": ["title", "start"]
466 })
467 }
468 async fn call(&self, args: Value) -> Result<ToolCallResult> {
469 let mut seen = HashSet::new();
470 let mut contacts = ContactCache::default();
471 let event = new_event_from(&self.0, self.name(), &args, &mut seen, &mut contacts).await?;
472 let created = self
473 .0
474 .events()
475 .create(self.0.user_id, event)
476 .map_err(|e| fail(self.name(), e))?;
477
478 Ok(ToolCallResult::text(
479 serde_json::to_string(&json!({ "id": created.id.to_string() })).unwrap(),
480 ))
481 }
482 }
483
484 pub struct BulkImportEvents(pub Arc<Ctx>);
485
486 #[async_trait]
487 impl Tool for BulkImportEvents {
488 fn name(&self) -> &'static str {
489 "bulk_import_events"
490 }
491 fn description(&self) -> &'static str {
492 "Create many calendar events in one call. Each item takes the same fields as `create_event`, plus `source_ref`, a stable provenance key that defaults to the title. An item whose `(source, source_ref)` pair already exists is skipped, not rewritten, so re-running an import is safe and never clobbers an edit made in the app since; change one with `update_event`. `source` names the import (default `go-mcp`). Returns created/skipped counts and the new event ids."
493 }
494 fn kind(&self) -> ToolKind {
495 ToolKind::Write(caps::event_bulk_import())
496 }
497 fn input_schema(&self) -> Value {
498 let mut item_fields = event_fields();
499 item_fields["source_ref"] = json!({
500 "type": "string",
501 "description": "Stable idempotency key for this event. Defaults to the title."
502 });
503 json!({
504 "type": "object",
505 "properties": {
506 "events": {
507 "type": "array",
508 "items": {
509 "type": "object",
510 "properties": item_fields,
511 "required": ["title", "start"]
512 }
513 },
514 "source": { "type": "string", "description": "Names the import; default `go-mcp`." }
515 },
516 "required": ["events"]
517 })
518 }
519 async fn call(&self, args: Value) -> Result<ToolCallResult> {
520 let items = args
521 .get("events")
522 .and_then(Value::as_array)
523 .ok_or_else(|| Error::InvalidArgs {
524 tool: self.name().to_string(),
525 message: "missing array field `events`".into(),
526 })?;
527 let source = args
528 .get("source")
529 .and_then(Value::as_str)
530 .map(str::trim)
531 .filter(|s| !s.is_empty())
532 .unwrap_or(DEFAULT_IMPORT_SOURCE);
533
534 let repo = self.0.events();
535 let mut seen_projects: HashSet<ProjectId> = HashSet::new();
536 let mut contacts = ContactCache::default();
537 let mut seen: HashSet<String> = HashSet::new();
538 let mut created_ids = Vec::new();
539 let mut skipped = 0usize;
540
541 for (idx, item) in items.iter().enumerate() {
542 // Resolved before the event is built, so a duplicate costs nothing
543 // and cannot create a project as a side effect of being skipped.
544 let source_ref = item
545 .get("source_ref")
546 .or_else(|| item.get("title"))
547 .and_then(Value::as_str)
548 .map(str::trim)
549 .filter(|s| !s.is_empty())
550 .map(str::to_string);
551
552 if let Some(key) = &source_ref {
553 if !seen.insert(key.clone()) {
554 skipped += 1;
555 continue;
556 }
557 let existing = repo
558 .find_by_external_id(source, key, self.0.user_id)
559 .map_err(|e| fail(self.name(), e))?;
560 if existing.is_some() {
561 skipped += 1;
562 continue;
563 }
564 }
565
566 let event = match new_event_from(
567 &self.0,
568 self.name(),
569 item,
570 &mut seen_projects,
571 &mut contacts,
572 )
573 .await
574 {
575 Ok(event) => event,
576 // Report the offending item by index; a 200-item payload with
577 // one bad date is otherwise a guessing game.
578 Err(Error::InvalidArgs { message, .. }) => {
579 return Err(Error::InvalidArgs {
580 tool: self.name().to_string(),
581 message: format!("events[{idx}]: {message}"),
582 });
583 }
584 Err(other) => return Err(other),
585 };
586
587 let created = repo
588 .create(self.0.user_id, event)
589 .map_err(|e| fail(self.name(), e))?;
590
591 if let Some(key) = &source_ref {
592 repo.set_external_ref(created.id, self.0.user_id, source, key)
593 .map_err(|e| fail(self.name(), e))?;
594 }
595 created_ids.push(created.id.to_string());
596 }
597
598 Ok(ToolCallResult::text(
599 serde_json::to_string(&json!({
600 "created": created_ids.len(),
601 "skipped": skipped,
602 "source": source,
603 "event_ids": created_ids,
604 }))
605 .unwrap(),
606 ))
607 }
608 }
609
610 /// Build a full [`UpdateEvent`] mirroring an event's current state, so a caller
611 /// can overlay just the fields it wants to change.
612 fn update_from_event(e: &Event) -> UpdateEvent {
613 UpdateEvent {
614 project_id: e.project_id,
615 contact_id: e.contact_id,
616 title: e.title.clone(),
617 description: e.description.clone(),
618 start_time: e.start_time,
619 end_time: e.end_time,
620 location: e.location.clone(),
621 linked_task_id: e.linked_task_id,
622 recurrence: e.recurrence.clone(),
623 recurrence_rule: e.recurrence_rule.clone(),
624 block_type: e.block_type.clone(),
625 reminder_offsets_seconds: e.reminder_offsets_seconds.clone(),
626 tz_kind: e.tz_kind,
627 timezone: e.timezone.clone(),
628 start_local: e.start_local,
629 end_local: e.end_local,
630 }
631 }
632
633 /// Resolve the span of an update, merging what the caller passed over what the
634 /// event already has.
635 ///
636 /// A `start` with no `end` is a *move*, so the end shifts with it and the event
637 /// keeps its duration. Carrying the stored end over unchanged would invert the
638 /// span the moment a caller moved an event past its own end, which the desktop
639 /// form never does (it always posts both) but a field-at-a-time overlay will.
640 ///
641 /// `all_day` defaults to whether the event currently *is* all-day, so moving one
642 /// to another date keeps it all-day rather than silently collapsing it to a
643 /// midnight-to-midnight timed event. `snap_all_day_span` is idempotent, so
644 /// re-snapping an already-snapped span does not grow it by a day.
645 fn overlay_span(
646 tool: &str,
647 args: &Value,
648 current: &Event,
649 ) -> Result<(DateTime<Utc>, Option<DateTime<Utc>>)> {
650 let start = parse_instant(tool, "start", args.get("start"))?.unwrap_or(current.start_time);
651 let end = match args.get("end") {
652 // Present but empty or null clears the end time; absent moves it.
653 Some(value) => parse_instant(tool, "end", Some(value))?,
654 None => current
655 .end_time
656 .map(|end| end + (start - current.start_time)),
657 };
658 let all_day = args
659 .get("all_day")
660 .and_then(Value::as_bool)
661 .unwrap_or_else(|| current.is_all_day_in(&current.tz_for(system_tz())));
662 finish_span(tool, start, end, all_day)
663 }
664
665 pub struct UpdateEventTool(pub Arc<Ctx>);
666
667 #[async_trait]
668 impl Tool for UpdateEventTool {
669 fn name(&self) -> &'static str {
670 "update_event"
671 }
672 fn description(&self) -> &'static str {
673 "Update fields of an existing event. Only the fields you pass change; the rest keep their current values. Accepts the same fields as `create_event`; passing `project_id` or `contact_id` empty detaches the project or the person. Passing `recurrence` rewrites the rule for the whole series, not one occurrence: an expanded instance is not a row and cannot be edited on its own. Events synced from an external calendar are read-only and are refused."
674 }
675 fn kind(&self) -> ToolKind {
676 ToolKind::Write(caps::event_update())
677 }
678 fn input_schema(&self) -> Value {
679 let mut fields = event_fields();
680 fields["id"] = json!({ "type": "string" });
681 json!({
682 "type": "object",
683 "properties": fields,
684 "required": ["id"]
685 })
686 }
687 async fn call(&self, args: Value) -> Result<ToolCallResult> {
688 let id = parse_event_id(self.name(), req_str(self.name(), &args, "id")?)?;
689 let repo = self.0.events();
690 let current = repo
691 .get_by_id(id, self.0.user_id)
692 .map_err(|e| fail(self.name(), e))?
693 .ok_or_else(|| not_found(self.name(), id))?;
694
695 if current.is_read_only {
696 return Err(Error::ToolFailed {
697 tool: self.name().to_string(),
698 message: format!(
699 "event {id} is read-only (synced from `{}`); edit it in the source calendar",
700 current
701 .external_source
702 .as_deref()
703 .unwrap_or("an external calendar")
704 ),
705 });
706 }
707
708 let mut patch = update_from_event(&current);
709
710 if let Some(title) = args.get("title").and_then(Value::as_str) {
711 patch.title = title.to_string();
712 }
713 if let Some(description) = args.get("description").and_then(Value::as_str) {
714 patch.description = description.to_string();
715 }
716 if args.get("location").is_some() {
717 patch.location = args
718 .get("location")
719 .and_then(Value::as_str)
720 .filter(|l| !l.trim().is_empty())
721 .map(str::to_string);
722 }
723 if args.get("recurrence").is_some() {
724 let (recurrence, rule) = parse_recurrence(self.name(), args.get("recurrence"))?;
725 patch.recurrence = recurrence;
726 patch.recurrence_rule = rule;
727 }
728 if args.get("block_type").is_some() {
729 patch.block_type = parse_block_type(self.name(), args.get("block_type"))?;
730 }
731 if args.get("reminders").is_some() {
732 patch.reminder_offsets_seconds = parse_reminders(args.get("reminders"));
733 }
734 // Present-but-empty (or null) clears the project, matching update_task.
735 if args.get("project_id").is_some() {
736 let mut seen = HashSet::new();
737 patch.project_id = project_id_arg(&self.0, self.name(), &args, &mut seen).await?;
738 }
739 // Same rule for the person: present-but-empty detaches. Either field
740 // being present is the signal, since `contact` is the other way to say
741 // who this is.
742 if args.get("contact_id").is_some() || args.get("contact").is_some() {
743 let mut contacts = ContactCache::default();
744 patch.contact_id = contact_arg(&self.0, self.name(), &args, &mut contacts).await?;
745 }
746
747 let (start_time, end_time) = overlay_span(self.name(), &args, &current)?;
748 patch.start_time = start_time;
749 patch.end_time = end_time;
750
751 patch.validate().map_err(|e| Error::InvalidArgs {
752 tool: self.name().to_string(),
753 message: e.to_string(),
754 })?;
755
756 let updated = repo
757 .update(id, self.0.user_id, patch)
758 .map_err(|e| fail(self.name(), e))?
759 .ok_or_else(|| not_found(self.name(), id))?;
760
761 Ok(ToolCallResult::text(
762 serde_json::to_string(&event_row(&updated, system_tz())).unwrap(),
763 ))
764 }
765 }
766
767 pub struct DeleteEvent(pub Arc<Ctx>);
768
769 #[async_trait]
770 impl Tool for DeleteEvent {
771 fn name(&self) -> &'static str {
772 "delete_event"
773 }
774 fn description(&self) -> &'static str {
775 "Delete one event by id. Deleting a recurring series deletes every occurrence it implies, since the occurrences are not rows. There is no undo and no archive; prefer `update_event` unless the event should not exist at all."
776 }
777 fn kind(&self) -> ToolKind {
778 ToolKind::Write(caps::event_delete())
779 }
780 fn input_schema(&self) -> Value {
781 json!({
782 "type": "object",
783 "properties": { "id": { "type": "string" } },
784 "required": ["id"]
785 })
786 }
787 async fn call(&self, args: Value) -> Result<ToolCallResult> {
788 let id = parse_event_id(self.name(), req_str(self.name(), &args, "id")?)?;
789 let repo = self.0.events();
790
791 // Read first, so the reply can name what went and a synthetic id is
792 // refused with the same message `get_event` gives.
793 let current = repo
794 .get_by_id(id, self.0.user_id)
795 .map_err(|e| fail(self.name(), e))?
796 .ok_or_else(|| not_found(self.name(), id))?;
797
798 let deleted = repo
799 .delete(id, self.0.user_id)
800 .map_err(|e| fail(self.name(), e))?;
801
802 Ok(ToolCallResult::text(
803 serde_json::to_string(&json!({
804 "deleted": deleted,
805 "title": current.title,
806 "was_recurring": current.has_recurrence(),
807 }))
808 .unwrap(),
809 ))
810 }
811 }
812